Compare commits
4
Commits
5de0501cbc
...
e1146ed4fe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1146ed4fe | ||
|
|
9ffdbfa259 | ||
|
|
6f9e800779 | ||
|
|
05f44b9928 |
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, config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -17,6 +17,21 @@ def configure_server(db: Session = Depends(get_db)):
|
||||
# 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}")
|
||||
ok, msg = process.publish_crl()
|
||||
if not ok:
|
||||
if config.get_system_settings(db).crl_verify:
|
||||
raise HTTPException(status_code=500, detail=f"Configuration installed, but CRL publishing failed: {msg}")
|
||||
logger.warning(f"[SERVER] CRL not published (crl_verify is off): {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):
|
||||
# For local dev or non-root host, use staging
|
||||
@@ -29,5 +44,9 @@ 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:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
+146
-3
@@ -1,4 +1,7 @@
|
||||
from pydantic import BaseModel, Field
|
||||
import ipaddress
|
||||
import re
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
from services import validation as v
|
||||
from typing import List, Optional, Literal
|
||||
from datetime import datetime
|
||||
|
||||
@@ -21,7 +24,54 @@ class PKISettingBase(BaseModel):
|
||||
easyrsa_batch: bool = True
|
||||
|
||||
class PKISettingUpdate(PKISettingBase):
|
||||
pass
|
||||
@field_validator("fqdn_ca", "fqdn_server")
|
||||
@classmethod
|
||||
def _check_fqdn(cls, val):
|
||||
if not re.match(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$", val) or ".." in val:
|
||||
raise ValueError("Invalid name (letters, digits, . _ -; max 64)")
|
||||
return val
|
||||
|
||||
@field_validator("easyrsa_dn")
|
||||
@classmethod
|
||||
def _check_dn(cls, val):
|
||||
if val not in ("cn_only", "org"):
|
||||
raise ValueError("easyrsa_dn must be 'cn_only' or 'org'")
|
||||
return val
|
||||
|
||||
@field_validator("easyrsa_req_country")
|
||||
@classmethod
|
||||
def _check_country(cls, val):
|
||||
if not re.match(r"^[A-Z]{2}$", val):
|
||||
raise ValueError("Country must be a 2-letter uppercase code")
|
||||
return val
|
||||
|
||||
@field_validator("easyrsa_req_province", "easyrsa_req_city", "easyrsa_req_org", "easyrsa_req_ou")
|
||||
@classmethod
|
||||
def _check_dn_text(cls, val):
|
||||
if not re.match(r"^[A-Za-z0-9 .,_-]{0,64}$", val):
|
||||
raise ValueError("Only letters, digits, space and . , _ - are allowed (max 64)")
|
||||
return val
|
||||
|
||||
@field_validator("easyrsa_req_email")
|
||||
@classmethod
|
||||
def _check_email(cls, val):
|
||||
if not re.match(r"^[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9.-]{1,190}$", val):
|
||||
raise ValueError("Invalid email")
|
||||
return val
|
||||
|
||||
@field_validator("easyrsa_key_size")
|
||||
@classmethod
|
||||
def _check_key_size(cls, val):
|
||||
if val not in (2048, 3072, 4096):
|
||||
raise ValueError("Key size must be 2048, 3072 or 4096")
|
||||
return val
|
||||
|
||||
@field_validator("easyrsa_ca_expire", "easyrsa_cert_expire", "easyrsa_cert_renew", "easyrsa_crl_days")
|
||||
@classmethod
|
||||
def _check_days(cls, val):
|
||||
if not 1 <= val <= 36500:
|
||||
raise ValueError("Days must be between 1 and 36500")
|
||||
return val
|
||||
|
||||
class PKISetting(PKISettingBase):
|
||||
id: int
|
||||
@@ -53,7 +103,100 @@ class SystemSettingsBase(BaseModel):
|
||||
mssfix: Optional[int] = None
|
||||
|
||||
class SystemSettingsUpdate(SystemSettingsBase):
|
||||
pass
|
||||
@field_validator("port", "management_port")
|
||||
@classmethod
|
||||
def _check_port(cls, val):
|
||||
if not 1 <= val <= 65535:
|
||||
raise ValueError("Port must be between 1 and 65535")
|
||||
return val
|
||||
|
||||
@field_validator("vpn_network")
|
||||
@classmethod
|
||||
def _check_network(cls, val):
|
||||
try:
|
||||
ipaddress.IPv4Address(val)
|
||||
except ValueError:
|
||||
raise ValueError("Invalid IPv4 network address")
|
||||
return val
|
||||
|
||||
@field_validator("vpn_netmask")
|
||||
@classmethod
|
||||
def _check_netmask(cls, val):
|
||||
if not v.valid_netmask(val):
|
||||
raise ValueError("Invalid netmask")
|
||||
return val
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_subnet(self):
|
||||
try:
|
||||
net = ipaddress.IPv4Network(f"{self.vpn_network}/{self.vpn_netmask}", strict=True)
|
||||
except ValueError:
|
||||
raise ValueError("vpn_network is not a valid network address for vpn_netmask")
|
||||
if not 8 <= net.prefixlen <= 30:
|
||||
raise ValueError("VPN subnet prefix must be between /8 and /30")
|
||||
return self
|
||||
|
||||
@field_validator("split_routes")
|
||||
@classmethod
|
||||
def _check_routes(cls, val):
|
||||
if len(val) > 256:
|
||||
raise ValueError("Too many routes (max 256)")
|
||||
for r in val:
|
||||
if not v.valid_route(r):
|
||||
raise ValueError(f"Invalid route: {r[:40]!r} (use a.b.c.d/nn or 'a.b.c.d mask')")
|
||||
return val
|
||||
|
||||
@field_validator("dns_servers")
|
||||
@classmethod
|
||||
def _check_dns(cls, val):
|
||||
if len(val) > 8:
|
||||
raise ValueError("Too many DNS servers (max 8)")
|
||||
for d in val:
|
||||
try:
|
||||
ipaddress.ip_address(d)
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid DNS server address: {d[:40]!r}")
|
||||
return val
|
||||
|
||||
@field_validator("connect_script", "disconnect_script")
|
||||
@classmethod
|
||||
def _check_script_path(cls, val):
|
||||
if val and not v.SCRIPT_RE.match(val):
|
||||
raise ValueError("Script path must be " + v.SCRIPTS_DIR + "/<name> (letters, digits, . _ -)")
|
||||
return val
|
||||
|
||||
@field_validator("management_interface_address")
|
||||
@classmethod
|
||||
def _check_mgmt_addr(cls, val):
|
||||
try:
|
||||
if not ipaddress.ip_address(val).is_loopback:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
raise ValueError("Management interface must listen on a loopback address")
|
||||
return val
|
||||
|
||||
@field_validator("public_ip")
|
||||
@classmethod
|
||||
def _check_public_ip(cls, val):
|
||||
if val in (None, ""):
|
||||
return val
|
||||
if not v.valid_host(val):
|
||||
raise ValueError("public_ip must be an IP address or a hostname")
|
||||
return val
|
||||
|
||||
@field_validator("tun_mtu")
|
||||
@classmethod
|
||||
def _check_mtu(cls, val):
|
||||
if val is not None and not 576 <= val <= 9000:
|
||||
raise ValueError("tun_mtu must be between 576 and 9000")
|
||||
return val
|
||||
|
||||
@field_validator("mssfix")
|
||||
@classmethod
|
||||
def _check_mss(cls, val):
|
||||
if val is not None and not 536 <= val <= 1500:
|
||||
raise ValueError("mssfix must be between 536 and 1500")
|
||||
return val
|
||||
|
||||
class SystemSettings(SystemSettingsBase):
|
||||
id: int
|
||||
|
||||
@@ -4,6 +4,7 @@ from jinja2 import Environment, FileSystemLoader
|
||||
from sqlalchemy.orm import Session
|
||||
from .config import get_system_settings, get_pki_settings
|
||||
from .pki import PKI_DIR
|
||||
from . import validation
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -24,9 +25,13 @@ def generate_server_config(db: Session, output_path: str = "server.conf"):
|
||||
file_dh_path = os.path.join(PKI_DIR, "dh.pem")
|
||||
file_ta_path = os.path.join(PKI_DIR, "ta.key")
|
||||
file_crl_path = os.path.join(PKI_DIR, "crl.pem")
|
||||
from .process import is_container
|
||||
if os.geteuid() != 0 and not is_container():
|
||||
# unprivileged API: OpenVPN (nobody) cannot enter the 0700 pki dir, use the copy published by the helper
|
||||
file_crl_path = "/etc/openvpn/crl.pem"
|
||||
|
||||
# Render template
|
||||
config_content = template.render(
|
||||
ctx = dict(
|
||||
protocol=settings.protocol,
|
||||
port=settings.port,
|
||||
ca_path=file_ca_path,
|
||||
@@ -53,6 +58,12 @@ def generate_server_config(db: Session, output_path: str = "server.conf"):
|
||||
tun_mtu=settings.tun_mtu,
|
||||
mssfix=settings.mssfix
|
||||
)
|
||||
for _name, _val in ctx.items():
|
||||
validation.assert_safe_scalar(_name, _val)
|
||||
if settings.user_defined_cdscripts:
|
||||
validation.check_script(settings.connect_script)
|
||||
validation.check_script(settings.disconnect_script)
|
||||
config_content = template.render(**ctx)
|
||||
|
||||
# Write to file
|
||||
with open(output_path, "w") as f:
|
||||
@@ -98,6 +109,8 @@ def generate_client_config(db: Session, username: str, output_path: str):
|
||||
|
||||
template = env.get_template("client.ovpn.j2")
|
||||
|
||||
validation.assert_safe_scalar("remote_ip", remote_ip)
|
||||
validation.assert_safe_scalar("protocol", settings.protocol)
|
||||
config_content = template.render(
|
||||
protocol=settings.protocol,
|
||||
remote_ip=remote_ip,
|
||||
|
||||
@@ -146,6 +146,7 @@ def init_pki(db: Session):
|
||||
|
||||
# Gen CRL
|
||||
_run_easyrsa(["gen-crl"], env)
|
||||
_publish_crl()
|
||||
|
||||
return "PKI Initialized"
|
||||
|
||||
@@ -183,9 +184,20 @@ def build_client(username: str, db: Session):
|
||||
_run_easyrsa(["build-client-full", username, "nopass"], env)
|
||||
return True
|
||||
|
||||
def _publish_crl():
|
||||
"""Make the fresh CRL readable by OpenVPN when the API runs unprivileged (see ovpmon-helper)."""
|
||||
from .process import publish_crl
|
||||
ok, msg = publish_crl()
|
||||
if not ok:
|
||||
logger.error(f"CRL was generated but could not be published: {msg}")
|
||||
return ok
|
||||
|
||||
|
||||
def revoke_client(username: str, db: Session):
|
||||
validate_username(username)
|
||||
env = _get_easyrsa_env(db)
|
||||
_run_easyrsa(["revoke", username], env)
|
||||
_run_easyrsa(["gen-crl"], env)
|
||||
if not _publish_crl():
|
||||
raise RuntimeError("Certificate revoked, but the CRL could not be published to OpenVPN")
|
||||
return True
|
||||
@@ -20,6 +20,47 @@ 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 publish_crl():
|
||||
"""Publish pki/crl.pem to a root-owned location readable by the OpenVPN user (nobody).
|
||||
No-op when running as root/in a container (OpenVPN reads the PKI directly). Returns (ok, message)."""
|
||||
if is_container() or os.geteuid() == 0:
|
||||
return True, "not required"
|
||||
rc, data, raw = _run_helper(["publish-crl"])
|
||||
if rc == 0 and data and data.get("status") == "ok":
|
||||
return True, "CRL published"
|
||||
msg = (data or {}).get("error") or raw or "helper failed"
|
||||
logger.error(f"[PROCESS] publish-crl failed: {msg}")
|
||||
return False, msg
|
||||
|
||||
|
||||
def control_service(action: str):
|
||||
"""
|
||||
Action: start, stop, restart
|
||||
@@ -94,6 +135,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}")
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Input validation helpers for settings that end up in generated OpenVPN configs."""
|
||||
import ipaddress
|
||||
import os
|
||||
import re
|
||||
|
||||
SCRIPTS_DIR = "/etc/openvpn/scripts"
|
||||
SCRIPT_RE = re.compile(r"^/etc/openvpn/scripts/[A-Za-z0-9_.-]{1,64}$")
|
||||
HOSTNAME_RE = re.compile(
|
||||
r"^(?=.{1,253}$)([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)*[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?$"
|
||||
)
|
||||
FORBIDDEN_CHARS = set('\n\r"\\\x00')
|
||||
|
||||
|
||||
def assert_safe_scalar(name: str, value) -> None:
|
||||
"""Reject values that could break out of a config line (newlines, quotes, backslashes, NUL)."""
|
||||
if isinstance(value, (list, tuple)):
|
||||
for item in value:
|
||||
assert_safe_scalar(name, item)
|
||||
return
|
||||
if isinstance(value, str) and (FORBIDDEN_CHARS & set(value) or any(ord(c) < 32 for c in value)):
|
||||
raise ValueError(f"Unsafe characters in '{name}'")
|
||||
|
||||
|
||||
def valid_netmask(mask: str) -> bool:
|
||||
try:
|
||||
ipaddress.IPv4Network(f"0.0.0.0/{mask}")
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def valid_route(route: str) -> bool:
|
||||
"""'a.b.c.d/nn' or 'a.b.c.d m.m.m.m'."""
|
||||
parts = route.split()
|
||||
try:
|
||||
if len(parts) == 1:
|
||||
ipaddress.IPv4Network(parts[0], strict=False)
|
||||
return True
|
||||
if len(parts) == 2:
|
||||
ipaddress.IPv4Address(parts[0])
|
||||
return valid_netmask(parts[1])
|
||||
except ValueError:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def valid_host(value: str) -> bool:
|
||||
try:
|
||||
ipaddress.ip_address(value)
|
||||
return True
|
||||
except ValueError:
|
||||
return bool(HOSTNAME_RE.match(value))
|
||||
|
||||
|
||||
def check_script(path: str) -> None:
|
||||
"""A connect/disconnect script must be a root-owned, non-writable file inside SCRIPTS_DIR."""
|
||||
if not path:
|
||||
return
|
||||
if not SCRIPT_RE.match(path):
|
||||
raise ValueError(f"Script path must match {SCRIPTS_DIR}/<name>")
|
||||
real = os.path.realpath(path)
|
||||
if os.path.dirname(real) != SCRIPTS_DIR:
|
||||
raise ValueError("Script must reside directly in " + SCRIPTS_DIR)
|
||||
if not os.path.isfile(real):
|
||||
raise ValueError("Script file does not exist")
|
||||
st = os.stat(real)
|
||||
if st.st_uid != 0 or st.st_mode & 0o022:
|
||||
raise ValueError("Script must be owned by root and not writable by group/others")
|
||||
dst = os.stat(SCRIPTS_DIR)
|
||||
if dst.st_uid != 0 or dst.st_mode & 0o022:
|
||||
raise ValueError(SCRIPTS_DIR + " must be owned by root and not writable by group/others")
|
||||
@@ -0,0 +1,77 @@
|
||||
# Privilege separation: API services no longer run as root (2026-09-30)
|
||||
|
||||
Problem: `ovpmon-api`, `ovpmon-gatherer` and `ovpmon-profiler` ran as root. Any bug in an API (or a stolen admin token combined with an input-validation gap) meant root on the host.
|
||||
|
||||
## Design
|
||||
|
||||
```
|
||||
ovpmon-api / gatherer / profiler (user ovpmon, no login shell)
|
||||
|
|
||||
| doas -n /usr/local/sbin/ovpmon-helper <fixed args> (only 6 exact commands allowed)
|
||||
v
|
||||
ovpmon-helper (root) -> install-config : validates the staged server.conf against an allowlist,
|
||||
installs /etc/openvpn/server.conf atomically
|
||||
-> publish-crl : copies pki/crl.pem to /etc/openvpn/crl.pem (root:root 644)
|
||||
-> service start|stop|restart|status : rc-service openvpn
|
||||
```
|
||||
|
||||
| Item | Detail |
|
||||
|---|---|
|
||||
| Service user | `ovpmon` (system user, nologin), owns `/var/lib/ovpmon` (DBs, `staging/`), `/var/log/ovpmon`, `APP_PROFILER/{easy-rsa,client-config,profiler.log}`, runtime logs and `__pycache__`. Code and virtualenvs stay root-owned (read-only for the service) |
|
||||
| OpenRC | `command_user="ovpmon:ovpmon"` in the three `ovpmon-*` init scripts; `/etc/ovpmon/env` stays `root:root 600` (read by the init script before the privilege drop) |
|
||||
| doas | `/etc/doas.d/ovpmon.conf`: `permit nopass ovpmon as root cmd /usr/local/sbin/ovpmon-helper args <install-config \| service start\|stop\|restart\|status>`; nothing else is permitted |
|
||||
| Helper | `/usr/local/sbin/ovpmon-helper` (root, 755). Source: `DOCS/General/privilege-separation/ovpmon-helper` |
|
||||
| Profiler code | `services/process.py`: when not root, calls the helper (`_run_helper`, `install_config`); `routers/server.py`: renders to `/var/lib/ovpmon/staging/server.conf`, then asks the helper to install it (rejection → HTTP 400). Container and root paths are unchanged |
|
||||
| Status log | the helper sets `root:ovpmon 640` on `openvpn-status.log` before every start/restart so the gatherer can read it |
|
||||
|
||||
### What the helper allows in `server.conf`
|
||||
Only the directives the template produces, each with checked arguments: `dev tun`, `proto`, `port`, `ca/cert/key/dh/tls-auth/crl-verify` (files must resolve inside the PKI directory), `tun-mtu`, `mssfix`, `topology subnet`, `server`, fixed `ifconfig-pool-persist`, `log`, `log-append`, `status`, `verb`, `push` (only `redirect-gateway def1 bypass-dhcp`, `route <net>`, `dhcp-option DNS <ip>`), `user nobody`, `group nogroup`, ciphers/auth/keepalive, `client-to-client`, `duplicate-cn`, `persist-*`, `script-security 2`, `client-connect/disconnect` (script must be root-owned, not group/other-writable, directly in `/etc/openvpn/scripts/`), `management` (loopback only). Everything else (`up`, `down`, `plugin`, `route-up`, `tls-verify`, `setenv`, `config`, ...) is rejected. `user nobody`, `group nogroup`, `server`, `ca`, `cert`, `key` are mandatory. The file is read once (no TOCTOU between check and install), must be an `ovpmon`-owned regular file (no symlinks), ASCII only, at most 64 KiB.
|
||||
|
||||
## Rollout (what was done)
|
||||
1. Backup: `/root/backup-p2-*.tar` (`/etc/openvpn`, `/var/lib/ovpmon`, `easy-rsa`, `client-config`, init scripts, doas config, changed code) and `/root/app-bak/p2/`.
|
||||
2. Create the user/group, install the helper and the doas rules; test the helper as `ovpmon` before touching services.
|
||||
3. Patch `process.py` / `server.py` (root code path unchanged, so nothing changed while services still ran as root).
|
||||
4. `chown` runtime data, add `command_user`, restart the gatherer, then the API, then the profiler, checking each.
|
||||
|
||||
## Results
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| Processes | gunicorn, uvicorn and the gatherer run as `ovpmon`; only the `supervise-daemon` supervisors are root |
|
||||
| Helper: current live config | accepted, live `server.conf` byte-identical |
|
||||
| Helper: 17 injected directives (`up`, `plugin`, `script-security 3`, `client-connect /tmp/x`, `route-up`, `tls-verify`, `setenv`, `config`, `ca /etc/shadow`, `management 0.0.0.0`, `log /etc/passwd`, `user root`, `status /etc/cron.d/x`, `push "setenv-safe"`, `dev tap`, multi-argument `push`) | all rejected, live config unchanged |
|
||||
| Helper: missing `user nobody`, cert outside PKI, CR injection, symlinked staged file | rejected |
|
||||
| doas: arbitrary command, helper with other args | denied |
|
||||
| Service user cannot | read `/etc/shadow`, `/root`, `/etc/hysteria/*.yaml`, `/etc/ovpmon/env`; write `/etc/openvpn`, `/etc/init.d`, `authorized_keys`, application code; run `iptables` |
|
||||
| API: monitoring, config, process stats, `server/configure` via helper (config identical) | 200 |
|
||||
| API: create profile (easyrsa), download `.ovpn`, revoke | 200 |
|
||||
| API: OpenVPN restart through the helper | 200; OpenVPN running, `tun0` up, status log readable by the gatherer, egress rules (`ip rule 102`, MASQUERADE to `hytun`) intact, no gatherer errors |
|
||||
|
||||
## Limitations / notes
|
||||
- The supervisors stay root by design (they only respawn the service user's process).
|
||||
- Helper checks resolve PKI paths at install time; a service-user-owned PKI directory could later swap a file for a symlink before OpenVPN (root) starts. Impact is limited to OpenVPN failing to parse or reading a key/cert-shaped file; keep the PKI directory owned by `ovpmon` only.
|
||||
- CRL checking (`crl_verify`) works with the unprivileged API, see the section below.
|
||||
- systemd deployments: same idea with `User=ovpmon`, a polkit/sudoers rule for the helper's fixed commands, and the same helper (replace `rc-service` with `systemctl`).
|
||||
- Rollback: restore `/etc/init.d/ovpmon-*` from `/root/app-bak/p2/`, `chown -R root:root` the data directories, restart the services (the helper and doas rules can stay).
|
||||
|
||||
## CRL publishing (`crl_verify`)
|
||||
|
||||
OpenVPN drops to `nobody` after start and re-reads the CRL on each new connection. `easy-rsa` creates `pki/` as `0700` owned by the service user, so `nobody` could not read `pki/crl.pem` and every client would be refused once `crl_verify` was enabled.
|
||||
|
||||
| Item | Detail |
|
||||
|---|---|
|
||||
| Published copy | `/etc/openvpn/crl.pem`, `root:root 644`, written atomically by `ovpmon-helper publish-crl` |
|
||||
| Helper checks | source must resolve inside the PKI directory, regular file (no symlink) owned by the service user, at most 1 MiB, PEM CRL markers, and `openssl crl` must parse it |
|
||||
| When it runs | after `gen-crl` in *Initialize PKI* and in *revoke* (`services/pki.py`, if publishing fails the revoke call reports an error instead of silently leaving the old CRL), on *server/configure* (an error only when `crl_verify` is on) and on every helper `service start\|restart` |
|
||||
| Config | with an unprivileged API the generator renders `crl-verify /etc/openvpn/crl.pem`; as root/in a container it still uses `pki/crl.pem`. The helper allowlist accepts only the published path for `crl-verify` |
|
||||
| doas | one more exact rule: `args publish-crl` |
|
||||
|
||||
Results (throw-away second OpenVPN instance on another port/subnet running as `nobody` with the same PKI and `crl-verify /etc/openvpn/crl.pem`; production OpenVPN untouched):
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| `publish-crl` as `ovpmon` | ok; copy is `root:root 644`, readable by `nobody`; `pki/crl.pem` still unreadable by `nobody` |
|
||||
| Garbage file, fake PEM markers, symlinked source | rejected |
|
||||
| `crl_verify=true` via API + `server/configure` | 200; config contains `crl-verify /etc/openvpn/crl.pem`, accepted by the helper; setting restored afterwards, live `server.conf` byte-identical to the original |
|
||||
| Valid client with active CRL check | connects (`Initialization Sequence Completed`) |
|
||||
| Revoke through the API, then reconnect | server sends `certificate revoked`, client is refused; no CRL read errors |
|
||||
@@ -0,0 +1,46 @@
|
||||
# Server settings validation (2026-09-30)
|
||||
|
||||
Problem: values of the server/PKI settings (`PUT /profiles-api/config/server|pki`) were free strings that were rendered into `server.conf` (written by a root process) and passed to `easyrsa`. A user with a valid token could inject extra OpenVPN directives (for example `up`/`plugin`) or shell-relevant DN characters, which is a path to code execution as root.
|
||||
|
||||
## Defence in three layers
|
||||
|
||||
| Layer | Where | What it does |
|
||||
|---|---|---|
|
||||
| A. Schema | `APP_PROFILER/schemas.py` (`SystemSettingsUpdate`, `PKISettingUpdate`) | Rejects invalid values with 422. Applies to updates only, so already stored values never break `GET /config` |
|
||||
| B. Scripts | `services/validation.py: check_script`, used in `services/generator.py` | `connect_script`/`disconnect_script` must be a file directly inside `/etc/openvpn/scripts/`, owned by root, not group/other-writable, in a root-owned non-writable directory (symlinks out of the directory are rejected) |
|
||||
| C. Renderer | `services/generator.py` | Before writing `server.conf` / client `.ovpn`, every value is checked for newline, CR, NUL, other control characters, `"` and `\`; on violation nothing is written and the API returns 400 |
|
||||
|
||||
## Rules (layer A)
|
||||
|
||||
| Field | Rule |
|
||||
|---|---|
|
||||
| `port`, `management_port` | 1-65535 |
|
||||
| `vpn_network` + `vpn_netmask` | valid IPv4 network address for a contiguous mask, prefix /8-/30 |
|
||||
| `split_routes[]` | `a.b.c.d/nn` or `a.b.c.d mask`, at most 256 |
|
||||
| `dns_servers[]` | IPv4/IPv6 addresses, at most 8 |
|
||||
| `public_ip` | IP address or hostname |
|
||||
| `management_interface_address` | loopback only |
|
||||
| `tun_mtu`, `mssfix` | 576-9000, 536-1500 |
|
||||
| `connect_script`, `disconnect_script` | empty or `/etc/openvpn/scripts/<letters, digits, . _ ->` |
|
||||
| PKI `fqdn_ca`, `fqdn_server` | `^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$`, no `..` |
|
||||
| PKI `easyrsa_dn` | `cn_only` or `org` |
|
||||
| PKI country / province, city, org, ou / email | `^[A-Z]{2}$` / `^[A-Za-z0-9 .,_-]{0,64}$` / simple email pattern |
|
||||
| PKI `key_size`, days | 2048/3072/4096; 1-36500 |
|
||||
|
||||
## Results
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| Round trip of current server and PKI settings via `PUT` | 200 |
|
||||
| 17 malicious/invalid values (newline in DNS or routes, quote, `../` and `/tmp` scripts, port 0/70000, bad mask, host bits in network, non-loopback management, `a b;c` host, MTU 100, `/` in organisation, lowercase country, `../` in FQDN, key size 512) | all 422 with a clear message |
|
||||
| 4 valid changes (DNS incl. IPv6, routes in both notations, hostname, allowed script path) | 200 |
|
||||
| Script checks: root-owned 755 file / group-writable / symlink out of the directory / missing / outside the directory / traversal / empty | accepted / rejected / rejected / rejected / rejected / rejected / accepted |
|
||||
| Layer C with unsafe values injected past the schema (newline in DNS, quote in route, newline in script, script outside the directory, newline in management address) | all blocked, nothing written |
|
||||
| Regression: settings render to `server.conf` | identical to the live config |
|
||||
|
||||
Settings were restored after the tests and left unchanged.
|
||||
|
||||
## Notes
|
||||
|
||||
- The scripts directory `/etc/openvpn/scripts/` does not exist by default; create it as `root:root 755` and put root-owned `755` scripts there before enabling `user_defined_cdscripts`.
|
||||
- Next step (planned): run the API as an unprivileged user with a root helper that re-validates the config before installing it. See the project plan.
|
||||
@@ -44,6 +44,10 @@ OpenRC (Alpine): `supervisor=supervise-daemon`, `respawn_delay=3`, source `/etc/
|
||||
|
||||
The Profiler restarts OpenVPN through `rc-service openvpn` (Alpine) or `systemctl openvpn`; on Alpine link the config: `ln -s server.conf /etc/openvpn/openvpn.conf` and enable the `openvpn` service.
|
||||
|
||||
## 4a. Run as an unprivileged user (recommended)
|
||||
|
||||
Create the `ovpmon` user, give it the data directories, add `command_user="ovpmon:ovpmon"` to the init scripts (or `User=ovpmon` in systemd units), install the root helper and the doas rules. The API then renders `server.conf` to `/var/lib/ovpmon/staging/`; the helper validates and installs it, publishes the CRL for OpenVPN and controls the `openvpn` service. Full procedure, helper source and results: [Privilege separation](../Changes/2026-09-30_Privilege_Separation.md); files in [`privilege-separation/`](privilege-separation/).
|
||||
|
||||
## 5. UI and Nginx (HTTPS on 8088)
|
||||
|
||||
```bash
|
||||
|
||||
@@ -13,6 +13,8 @@ Welcome to the documentation for the OpenVPN Monitor suite.
|
||||
## 🛠 Changes and results
|
||||
- [Security hardening (2026-09-30)](../Changes/2026-09-30_Security_Hardening.md)
|
||||
- [Admin username change (2026-09-30)](../Changes/2026-09-30_Admin_Username_Change.md)
|
||||
- [Settings validation (2026-09-30)](../Changes/2026-09-30_Settings_Validation.md)
|
||||
- [Privilege separation (2026-09-30)](../Changes/2026-09-30_Privilege_Separation.md)
|
||||
- [Egress via Hysteria2 (2026-09-30)](../Changes/2026-09-30_Egress_via_Hysteria2.md)
|
||||
|
||||
## 🔍 Core Monitoring (`APP_CORE`)
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
permit nopass ovpmon as root cmd /usr/local/sbin/ovpmon-helper args install-config
|
||||
permit nopass ovpmon as root cmd /usr/local/sbin/ovpmon-helper args publish-crl
|
||||
permit nopass ovpmon as root cmd /usr/local/sbin/ovpmon-helper args service start
|
||||
permit nopass ovpmon as root cmd /usr/local/sbin/ovpmon-helper args service stop
|
||||
permit nopass ovpmon as root cmd /usr/local/sbin/ovpmon-helper args service restart
|
||||
permit nopass ovpmon as root cmd /usr/local/sbin/ovpmon-helper args service status
|
||||
+262
@@ -0,0 +1,262 @@
|
||||
#!/usr/bin/python3
|
||||
"""ovpmon-helper: the only root-side entry point for the unprivileged ovpmon services.
|
||||
|
||||
Usage (via doas): ovpmon-helper install-config
|
||||
ovpmon-helper service start|stop|restart|status
|
||||
install-config reads the staged OpenVPN server config, validates it against a strict
|
||||
allowlist of directives and installs it atomically to /etc/openvpn/server.conf.
|
||||
"""
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import pwd
|
||||
import re
|
||||
import shlex
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
STAGED = "/var/lib/ovpmon/staging/server.conf"
|
||||
TARGET = "/etc/openvpn/server.conf"
|
||||
PKI_DIR = "/opt/OpenVPN-Monitoring-Simple/APP_PROFILER/easy-rsa/pki"
|
||||
SCRIPTS_DIR = "/etc/openvpn/scripts"
|
||||
STATUS_LOG = "/var/log/openvpn/openvpn-status.log"
|
||||
CRL_SRC = PKI_DIR + "/crl.pem"
|
||||
CRL_PUBLISHED = "/etc/openvpn/crl.pem"
|
||||
CRL_MAX = 1024 * 1024
|
||||
SERVICE_USER = "ovpmon"
|
||||
MAX_SIZE = 64 * 1024
|
||||
CIPHERS_RE = re.compile(r"^[A-Za-z0-9:_-]{1,200}$")
|
||||
os.environ["PATH"] = "/usr/sbin:/usr/bin:/sbin:/bin"
|
||||
|
||||
|
||||
class Reject(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def under(path, base):
|
||||
real = os.path.realpath(path)
|
||||
return real == base or real.startswith(base.rstrip("/") + "/")
|
||||
|
||||
|
||||
def need(cond, msg):
|
||||
if not cond:
|
||||
raise Reject(msg)
|
||||
|
||||
|
||||
def is_int(x, lo, hi):
|
||||
return re.fullmatch(r"\d{1,6}", x) is not None and lo <= int(x) <= hi
|
||||
|
||||
|
||||
def valid_route(r):
|
||||
parts = r.split()
|
||||
try:
|
||||
if len(parts) == 1:
|
||||
ipaddress.IPv4Network(parts[0], strict=False)
|
||||
elif len(parts) == 2:
|
||||
ipaddress.IPv4Address(parts[0])
|
||||
ipaddress.IPv4Network("0.0.0.0/" + parts[1])
|
||||
else:
|
||||
return False
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def check_script(path):
|
||||
need(re.fullmatch(r"/etc/openvpn/scripts/[A-Za-z0-9_.-]{1,64}", path), "script path not allowed")
|
||||
real = os.path.realpath(path)
|
||||
need(os.path.dirname(real) == SCRIPTS_DIR and os.path.isfile(real), "script must be a file in " + SCRIPTS_DIR)
|
||||
st, dst = os.stat(real), os.stat(SCRIPTS_DIR)
|
||||
need(st.st_uid == 0 and not st.st_mode & 0o022, "script must be root-owned and not group/other-writable")
|
||||
need(dst.st_uid == 0 and not dst.st_mode & 0o022, SCRIPTS_DIR + " must be root-owned and not writable")
|
||||
|
||||
|
||||
def check_line(tokens):
|
||||
d, a = tokens[0], tokens[1:]
|
||||
if d == "dev":
|
||||
need(a == ["tun"], "dev must be tun")
|
||||
elif d == "proto":
|
||||
need(len(a) == 1 and a[0] in ("udp", "tcp", "udp4", "tcp4", "udp6", "tcp6"), "bad proto")
|
||||
elif d in ("tls-server", "client-to-client", "duplicate-cn", "persist-key", "persist-tun"):
|
||||
need(not a, d + " takes no arguments")
|
||||
elif d == "explicit-exit-notify":
|
||||
need(len(a) == 1 and is_int(a[0], 1, 10), "bad explicit-exit-notify")
|
||||
elif d in ("port", "management-port"):
|
||||
need(len(a) == 1 and is_int(a[0], 1, 65535), "bad port")
|
||||
elif d in ("ca", "cert", "key", "dh"):
|
||||
need(len(a) == 1 and under(a[0], PKI_DIR), d + " must be a file inside the PKI directory")
|
||||
elif d == "crl-verify":
|
||||
need(a == [CRL_PUBLISHED], "crl-verify must be " + CRL_PUBLISHED)
|
||||
elif d == "tls-auth":
|
||||
need(len(a) == 2 and under(a[0], PKI_DIR) and a[1] in ("0", "1"), "bad tls-auth")
|
||||
elif d == "tun-mtu":
|
||||
need(len(a) == 1 and is_int(a[0], 576, 9000), "bad tun-mtu")
|
||||
elif d == "mssfix":
|
||||
need(len(a) == 1 and is_int(a[0], 536, 1500), "bad mssfix")
|
||||
elif d == "topology":
|
||||
need(a == ["subnet"], "topology must be subnet")
|
||||
elif d == "server":
|
||||
need(len(a) == 2, "bad server")
|
||||
net = ipaddress.IPv4Network(f"{a[0]}/{a[1]}", strict=True)
|
||||
need(8 <= net.prefixlen <= 30, "bad server prefix")
|
||||
elif d == "ifconfig-pool-persist":
|
||||
need(a == ["/etc/openvpn/ipp.txt"], "ifconfig-pool-persist path not allowed")
|
||||
elif d in ("log", "log-append"):
|
||||
need(a == ["/var/log/openvpn/openvpn.log"], d + " path not allowed")
|
||||
elif d == "verb":
|
||||
need(len(a) == 1 and is_int(a[0], 0, 9), "bad verb")
|
||||
elif d == "status":
|
||||
need(len(a) == 2 and a[0] == STATUS_LOG and is_int(a[1], 1, 3600), "bad status")
|
||||
elif d == "status-version":
|
||||
need(a in (["1"], ["2"], ["3"]), "bad status-version")
|
||||
elif d == "push":
|
||||
need(len(a) == 1, "push takes one quoted argument")
|
||||
p = a[0]
|
||||
if p == "redirect-gateway def1 bypass-dhcp":
|
||||
return
|
||||
m = re.fullmatch(r"route (.+)", p)
|
||||
if m:
|
||||
need(valid_route(m.group(1)), "bad pushed route")
|
||||
return
|
||||
m = re.fullmatch(r"dhcp-option DNS (\S+)", p)
|
||||
need(m is not None, "pushed option not allowed")
|
||||
ipaddress.ip_address(m.group(1))
|
||||
elif d == "user":
|
||||
need(a == ["nobody"], "user must be nobody")
|
||||
elif d == "group":
|
||||
need(a == ["nogroup"], "group must be nogroup")
|
||||
elif d in ("data-ciphers", "data-ciphers-fallback"):
|
||||
need(len(a) == 1 and CIPHERS_RE.match(a[0]), "bad cipher list")
|
||||
elif d == "auth":
|
||||
need(len(a) == 1 and a[0] in ("SHA256", "SHA384", "SHA512"), "bad auth")
|
||||
elif d == "keepalive":
|
||||
need(len(a) == 2 and is_int(a[0], 1, 3600) and is_int(a[1], 1, 7200), "bad keepalive")
|
||||
elif d == "script-security":
|
||||
need(a == ["2"], "script-security must be 2")
|
||||
elif d in ("client-connect", "client-disconnect"):
|
||||
need(len(a) == 1, d + " takes one argument")
|
||||
check_script(a[0])
|
||||
elif d == "management":
|
||||
need(len(a) == 2 and ipaddress.ip_address(a[0]).is_loopback and is_int(a[1], 1, 65535), "management must be loopback")
|
||||
else:
|
||||
raise Reject("directive not allowed: " + d)
|
||||
|
||||
|
||||
def validate(text):
|
||||
seen = set()
|
||||
for n, raw in enumerate(text.splitlines(), 1):
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#") or line.startswith(";"):
|
||||
continue
|
||||
need(all(32 <= ord(c) < 127 for c in line), f"line {n}: non-printable or non-ASCII character")
|
||||
try:
|
||||
tokens = shlex.split(line, comments=False)
|
||||
except ValueError as e:
|
||||
raise Reject(f"line {n}: {e}")
|
||||
try:
|
||||
check_line(tokens)
|
||||
except Reject as e:
|
||||
raise Reject(f"line {n}: {e}")
|
||||
except ValueError as e:
|
||||
raise Reject(f"line {n}: invalid value ({e})")
|
||||
seen.add(tokens[0])
|
||||
for req in ("user", "group", "server", "ca", "cert", "key"):
|
||||
need(req in seen, f"required directive missing: {req}")
|
||||
|
||||
|
||||
def install_config():
|
||||
uid = pwd.getpwnam(SERVICE_USER).pw_uid
|
||||
fd = os.open(STAGED, os.O_RDONLY | os.O_NOFOLLOW)
|
||||
try:
|
||||
st = os.fstat(fd)
|
||||
need(st.st_uid == uid and stat.S_ISREG(st.st_mode), "staged config must be a regular file owned by " + SERVICE_USER)
|
||||
need(st.st_size <= MAX_SIZE, "staged config too large")
|
||||
data = os.read(fd, MAX_SIZE + 1)
|
||||
finally:
|
||||
os.close(fd)
|
||||
text = data.decode("ascii") # one read: validate exactly what gets installed
|
||||
validate(text)
|
||||
tmp = TARGET + ".tmp"
|
||||
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW, 0o644)
|
||||
with os.fdopen(fd, "w") as f:
|
||||
f.write(text)
|
||||
os.chmod(tmp, 0o644)
|
||||
os.replace(tmp, TARGET)
|
||||
if not os.path.lexists("/etc/openvpn/openvpn.conf"):
|
||||
os.symlink("server.conf", "/etc/openvpn/openvpn.conf")
|
||||
|
||||
|
||||
def prepare_status_log():
|
||||
"""Let the unprivileged monitoring gatherer read the status log."""
|
||||
import grp
|
||||
gid = grp.getgrnam(SERVICE_USER).gr_gid
|
||||
if not os.path.exists(STATUS_LOG):
|
||||
open(STATUS_LOG, "a").close()
|
||||
os.chown(STATUS_LOG, 0, gid)
|
||||
os.chmod(STATUS_LOG, 0o640)
|
||||
|
||||
|
||||
def publish_crl():
|
||||
"""Copy the CRL generated by easy-rsa to a root-owned, world-readable path.
|
||||
OpenVPN reads the CRL as the unprivileged user 'nobody', who cannot enter the 0700 pki/ directory."""
|
||||
uid = pwd.getpwnam(SERVICE_USER).pw_uid
|
||||
need(under(CRL_SRC, PKI_DIR), "CRL source outside PKI directory")
|
||||
fd = os.open(CRL_SRC, os.O_RDONLY | os.O_NOFOLLOW)
|
||||
try:
|
||||
st = os.fstat(fd)
|
||||
need(stat.S_ISREG(st.st_mode) and st.st_uid in (0, uid), "CRL must be a regular file owned by " + SERVICE_USER)
|
||||
need(0 < st.st_size <= CRL_MAX, "CRL size out of range")
|
||||
data = os.read(fd, CRL_MAX + 1)
|
||||
finally:
|
||||
os.close(fd)
|
||||
need(data.startswith(b"-----BEGIN X509 CRL-----") and data.rstrip().endswith(b"-----END X509 CRL-----"), "not a PEM CRL")
|
||||
r = subprocess.run(["/usr/bin/openssl", "crl", "-noout", "-inform", "PEM"], input=data, capture_output=True, timeout=20)
|
||||
need(r.returncode == 0, "openssl rejects the CRL")
|
||||
tmp = CRL_PUBLISHED + ".tmp"
|
||||
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW, 0o644)
|
||||
with os.fdopen(fd, "wb") as f:
|
||||
f.write(data)
|
||||
os.chmod(tmp, 0o644)
|
||||
os.replace(tmp, CRL_PUBLISHED)
|
||||
|
||||
|
||||
def service(action):
|
||||
need(action in ("start", "stop", "restart", "status"), "invalid action")
|
||||
if action in ("start", "restart"):
|
||||
need(os.path.isfile(TARGET), "server.conf is not installed")
|
||||
prepare_status_log()
|
||||
if os.path.isfile(CRL_SRC):
|
||||
try:
|
||||
publish_crl()
|
||||
except Exception:
|
||||
pass # a broken CRL must not stop the VPN; crl-verify will then keep the previously published file
|
||||
r = subprocess.run(["/sbin/rc-service", "openvpn", action], capture_output=True, text=True, timeout=60)
|
||||
return r.returncode, (r.stdout + r.stderr).strip()[-500:]
|
||||
|
||||
|
||||
def main(argv):
|
||||
try:
|
||||
if argv == ["install-config"]:
|
||||
install_config()
|
||||
print(json.dumps({"status": "ok"}))
|
||||
return 0
|
||||
if argv == ["publish-crl"]:
|
||||
publish_crl()
|
||||
print(json.dumps({"status": "ok"}))
|
||||
return 0
|
||||
if len(argv) == 2 and argv[0] == "service":
|
||||
rc, out = service(argv[1])
|
||||
print(json.dumps({"status": "ok" if rc == 0 else "error", "output": out}))
|
||||
return 0 if rc == 0 else 2
|
||||
raise Reject("usage: install-config | publish-crl | service start|stop|restart|status")
|
||||
except Reject as e:
|
||||
print(json.dumps({"status": "rejected", "error": str(e)}))
|
||||
return 3
|
||||
except Exception as e: # never leak a traceback with paths to the caller
|
||||
print(json.dumps({"status": "error", "error": type(e).__name__ + ": " + str(e)[:200]}))
|
||||
return 4
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
@@ -22,6 +22,14 @@ After the first start: sign in, open **PKI Configuration** → **Initialize PKI*
|
||||
|
||||
No default user is created. Seed the initial admin with `OVPMON_INITIAL_ADMIN_USER` / `OVPMON_INITIAL_ADMIN_PASSWORD` on first start (empty `users` table only), then remove them. Change the username and password and enable 2FA in **Account**.
|
||||
|
||||
## Security defaults
|
||||
|
||||
- No built-in account; the username can be changed in **Account** (API `POST /api/auth/change-username`).
|
||||
- All API routes require a JWT; 2FA-pending tokens are accepted only by `/api/auth/verify-2fa`.
|
||||
- Server/PKI settings are validated before they reach `server.conf` (ports, networks, routes, DNS, host names, script paths, DN fields); scripts run only from `/etc/openvpn/scripts/`.
|
||||
- Native deployments: APIs run as user `ovpmon`; a root helper installs the validated `server.conf`, publishes the CRL (`crl_verify`) and controls `openvpn` through `doas` (fixed commands).
|
||||
- CORS is same-origin only unless `OVPMON_CORS_ORIGINS` is set; TLS on the panel port; brute-force limits on login (Nginx + app, fail2ban jail in the deployment guide).
|
||||
|
||||
## Configuration
|
||||
|
||||
`config.ini` per component; overridden by `OVPMON_{SECTION}_{KEY}` environment variables.
|
||||
@@ -40,7 +48,7 @@ No default user is created. Seed the initial admin with `OVPMON_INITIAL_ADMIN_US
|
||||
|
||||
- Index: [DOCS/General/Index.md](DOCS/General/Index.md)
|
||||
- Deployment: [Docker](DOCS/General/Deployment_Docker.md) · [System services](DOCS/General/Deployment_Native.md) · [Nginx](DOCS/General/Nginx_Configuration.md) · [Service management](DOCS/General/Service_Management.md)
|
||||
- Security model: [Security Architecture](DOCS/General/Security_Architecture.md)
|
||||
- Security model: [Security Architecture](DOCS/General/Security_Architecture.md) · root helper and doas rules: [`DOCS/General/privilege-separation/`](DOCS/General/privilege-separation/)
|
||||
- APIs: [Monitoring](DOCS/Core_Monitoring/API_Reference.md) · [Profiler](DOCS/Profiler_Management/API_Reference.md)
|
||||
|
||||
## Changes and results
|
||||
@@ -49,9 +57,11 @@ No default user is created. Seed the initial admin with `OVPMON_INITIAL_ADMIN_US
|
||||
|---|---|---|
|
||||
| 2026-09-30 | Security hardening: path traversal, 2FA token bypass, CORS, log leak, HTTPS, SSH, fail2ban | [Security hardening](DOCS/Changes/2026-09-30_Security_Hardening.md) |
|
||||
| 2026-09-30 | Admin username change (API + UI), no built-in default admin | [Admin username change](DOCS/Changes/2026-09-30_Admin_Username_Change.md) |
|
||||
| 2026-09-30 | Validation of server/PKI settings (config injection into the root-written OpenVPN config) | [Settings validation](DOCS/Changes/2026-09-30_Settings_Validation.md) |
|
||||
| 2026-09-30 | API services run as an unprivileged user; root helper validates and installs the OpenVPN config, publishes the CRL | [Privilege separation](DOCS/Changes/2026-09-30_Privilege_Separation.md) (includes CRL publishing for `crl_verify`) |
|
||||
| 2026-09-30 | Route OpenVPN clients through a Hysteria2 tunnel to an exit node | [Egress via Hysteria2](DOCS/Changes/2026-09-30_Egress_via_Hysteria2.md) |
|
||||
|
||||
## Notes
|
||||
|
||||
- `ovpmon-api` and `ovpmon-profiler` currently run as root (they manage OpenVPN and PKI).
|
||||
- Native deployments run the APIs as user `ovpmon`; OpenVPN config install and service control go through a root helper (`doas`, fixed commands): see [Privilege separation](DOCS/Changes/2026-09-30_Privilege_Separation.md).
|
||||
- Keep `easy-rsa/`, `client-config/`, databases and `*.env` out of git: they contain private keys and secrets.
|
||||
Reference in new issue
Block a user