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:
iclaoudezinandClaude Sonnet 5.5 committed 2026-09-30 12:33:15 +00:00
1 parent 05f44b9928
commit 6f9e800779
8 files changed
+341 -2

No files matched your search

+13 -1
View File
@@ -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:
+36
View File
@@ -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}")