Run API services unprivileged; add root helper for OpenVPN config and service control
- Profiler: when not root, render server.conf to the staging dir and let the root helper validate (directive allowlist) and install it; control the openvpn service through the helper (doas, fixed commands). - Add ovpmon-helper and doas rules under DOCS/General/privilege-separation. - Document the design, rollout, results and limitations. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
05f44b9928
commit
6f9e800779
8 files changed
+341
-2
No files matched your search
@@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from database import get_db
|
||||
from utils.auth import verify_token
|
||||
from services import generator
|
||||
from services import generator, process
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -16,6 +16,16 @@ def configure_server(db: Session = Depends(get_db)):
|
||||
# Generate to a temporary location or standard location
|
||||
# As per plan, we behave like srvconf
|
||||
output_path = "/etc/openvpn/server.conf"
|
||||
|
||||
# Unprivileged service: render to the staging dir, the root helper validates and installs it
|
||||
if not process.is_container() and os.geteuid() != 0:
|
||||
staged = os.path.join(os.getenv("OVPMON_STAGING_DIR", "/var/lib/ovpmon/staging"), "server.conf")
|
||||
os.makedirs(os.path.dirname(staged), exist_ok=True)
|
||||
generator.generate_server_config(db, output_path=staged)
|
||||
ok, msg = process.install_config()
|
||||
if not ok:
|
||||
raise HTTPException(status_code=400, detail=f"Configuration rejected: {msg}")
|
||||
return {"message": "Server configuration generated", "path": output_path}
|
||||
|
||||
# Ensure we can write to /etc/openvpn
|
||||
if not os.path.exists(os.path.dirname(output_path)) or not os.access(os.path.dirname(output_path), os.W_OK):
|
||||
@@ -29,6 +39,8 @@ def configure_server(db: Session = Depends(get_db)):
|
||||
|
||||
content = generator.generate_server_config(db, output_path=output_path)
|
||||
return {"message": "Server configuration generated", "path": output_path}
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
|
||||
@@ -20,6 +20,34 @@ def is_container():
|
||||
pass
|
||||
return False
|
||||
|
||||
HELPER_PATH = "/usr/local/sbin/ovpmon-helper"
|
||||
|
||||
|
||||
def _run_helper(args):
|
||||
"""Call the root-side helper through doas (used when this process is unprivileged).
|
||||
Returns (returncode, parsed_json_or_None, raw_output)."""
|
||||
import json
|
||||
try:
|
||||
r = subprocess.run(["doas", "-n", HELPER_PATH] + args, capture_output=True, text=True, timeout=90)
|
||||
except (OSError, subprocess.TimeoutExpired) as e:
|
||||
return 1, None, str(e)
|
||||
out = (r.stdout or "").strip()
|
||||
try:
|
||||
return r.returncode, json.loads(out.splitlines()[-1]), out
|
||||
except Exception:
|
||||
return r.returncode, None, (out + " " + (r.stderr or "")).strip()
|
||||
|
||||
|
||||
def install_config():
|
||||
"""Ask the helper to validate and install the staged server.conf. Returns (ok, message)."""
|
||||
rc, data, raw = _run_helper(["install-config"])
|
||||
if rc == 0 and data and data.get("status") == "ok":
|
||||
return True, "Configuration installed"
|
||||
msg = (data or {}).get("error") or raw or "helper failed"
|
||||
logger.error(f"[PROCESS] install-config failed: {msg}")
|
||||
return False, msg
|
||||
|
||||
|
||||
def control_service(action: str):
|
||||
"""
|
||||
Action: start, stop, restart
|
||||
@@ -94,6 +122,14 @@ def control_service(action: str):
|
||||
stop_vpn_direct()
|
||||
return start_vpn_direct()
|
||||
|
||||
# Unprivileged service: delegate to the root helper (validated, fixed set of actions)
|
||||
if os.geteuid() != 0:
|
||||
rc, data, raw = _run_helper(["service", action])
|
||||
if rc == 0 and data and data.get("status") == "ok":
|
||||
return {"status": "success", "message": f"Service {action} executed successfully via helper", "stdout": data.get("output", "")}
|
||||
logger.error(f"[PROCESS] helper service {action} failed: {raw}")
|
||||
return {"status": "error", "message": f"Failed to {action} service via helper", "stderr": (data or {}).get("error") or (data or {}).get("output") or raw}
|
||||
|
||||
# On Host OS: Use system service manager
|
||||
os_type = get_os_type()
|
||||
logger.info(f"[PROCESS] Host OS detected ({os_type}), using service manager for {action}")
|
||||
|
||||
Reference in new issue
Block a user