Compare commits
5
Commits
5de0501cbc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3836049230 | ||
|
|
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__)
|
||||
|
||||
@@ -16,6 +16,21 @@ 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}")
|
||||
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):
|
||||
@@ -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,7 +58,13 @@ 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:
|
||||
f.write(config_content)
|
||||
@@ -97,7 +108,9 @@ def generate_client_config(db: Session, username: str, output_path: str):
|
||||
remote_ip = get_public_ip()
|
||||
|
||||
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,7 +146,8 @@ def init_pki(db: Session):
|
||||
|
||||
# Gen CRL
|
||||
_run_easyrsa(["gen-crl"], env)
|
||||
|
||||
_publish_crl()
|
||||
|
||||
return "PKI Initialized"
|
||||
|
||||
def clear_pki(db: Session):
|
||||
@@ -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
|
||||
|
||||
@@ -10,9 +10,14 @@ Welcome to the documentation for the OpenVPN Monitor suite.
|
||||
- [Nginx Configuration](Nginx_Configuration.md)
|
||||
- [Security Architecture](Security_Architecture.md): Details on Authentication, 2FA, and Security features.
|
||||
|
||||
## 🗂 Operations
|
||||
- [Deployment records](../Operations/README.md): state summary, reboot test, plans and rollout records.
|
||||
|
||||
## 🛠 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:]))
|
||||
@@ -0,0 +1,168 @@
|
||||
# Hysteria 2 entry→exit chain (manifest)
|
||||
|
||||
Client ─hy2/UDP443→ **ENTRY** (Alpine, OpenRC) ─hy2/UDP443→ **EXIT** (Debian, systemd) → Internet (IPv4)
|
||||
|
||||
Secrets live only in: `client-uris.txt`, `singbox-*.json`, `/etc/hysteria/*` on hosts (chmod 600). Never commit/print them.
|
||||
|
||||
## Inventory (this deployment)
|
||||
| Role | Host | OS | Access |
|
||||
|---|---|---|---|
|
||||
| ENTRY (Timeweb) | 213.226.125.13 | Alpine 3.23 | `ssh -i private.key root@` (key must be chmod 600) |
|
||||
| EXIT (Gcore Hel) | 85.234.86.140 | Debian 13 | `ssh -i hpriv.key debian@` (sudo NOPASSWD) |
|
||||
|
||||
## Prerequisites (verify first, they were the real blockers)
|
||||
1. **UDP 443 open both ways** (Security Group on EXIT; ENTRY provider). Test: `tcpdump -ni <if> udp port 443` on the receiver while `echo x | nc -u -w1 <ip> 443` from the sender. ICMP/TCP passing proves nothing about UDP.
|
||||
2. Use **official** binary `apernet/hysteria` (HyNetworks/hysteria is a fork). Verify `sha256sum` against `hashes.txt` of the release:
|
||||
`https://github.com/apernet/hysteria/releases/latest/download/{hysteria-linux-amd64,hashes.txt}`
|
||||
3. Hysteria server `socks5`/`http` outbounds are **TCP-only** → for UDP use TUN + policy routing (below), not a SOCKS5 chain.
|
||||
|
||||
## EXIT (Debian) — server
|
||||
- `/usr/local/bin/hysteria`, `/etc/hysteria/{server.crt,server.key(600),config.yaml}`
|
||||
- Self-signed cert: `openssl req -x509 -nodes -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -days 3650 -subj /CN=<name> -keyout server.key -out server.crt`
|
||||
- `config.yaml`: `listen: :443`, `tls{cert,key}`, `auth{type: password, password: <rand>}`, `masquerade{type: proxy, proxy{url: https://news.ycombinator.com/, rewriteHost: true}}`
|
||||
- systemd unit `hysteria-server` (`ExecStart=hysteria server -c ...`, `Restart=always`, `AmbientCapabilities=CAP_NET_BIND_SERVICE`)
|
||||
- `/etc/sysctl.d/99-quic.conf`: `net.core.rmem_max=7500000`, `net.core.wmem_max=7500000`
|
||||
- No IPv6 uplink here → IPv4 only.
|
||||
|
||||
## ENTRY (Alpine) — tunnel client + client-facing server
|
||||
Packages: `iproute2 libcap-utils openssl`; user `hyedge` (`adduser -S -D -H -s /sbin/nologin`); `/dev/net/tun` must exist and module `tun` must be in `/etc/modules` (loaded module, not builtin; without it the tunnel does not come up after reboot).
|
||||
|
||||
1. **`/etc/hysteria/client.yaml`** (600): `server: <EXIT>:443`, `auth: <pw>`, `tls{sni, insecure: true, pinSHA256: <EXIT cert fp>}`, `lazy: true`, `bandwidth{up: 180 mbps, down: 180 mbps}` (Brutal CC; measured path ≈191-195 Mbps via `hysteria speedtest`, single-flow without it was ~44 Mbps), `socks5{127.0.0.1:1080}`, `http{127.0.0.1:8080}`, `tun{name: hytun, mtu: 1400, timeout: 5m, address{ipv4: 100.100.100.101/30}}` (no `route:` → no auto-routes).
|
||||
2. **`/etc/hysteria/edge.yaml`** (600, owner hyedge): `listen: :443`, own self-signed `edge.crt/edge.key`, `auth{type: userpass, userpass{<dev>: <pw>}}`, `resolver{type: udp, udp{addr: 1.1.1.1:53}}`, `outbounds[direct{mode: "4"}]`, `trafficStats{listen: 127.0.0.1:9999, secret}`, `acl.inline`: `reject` 10/8,172.16/12,192.168/16,127/8,169.254/16,100.64/10 then `direct(all)`.
|
||||
- Do **not** add `reject(::/0)` — it matches any domain having AAAA and rejects dual-stack sites.
|
||||
- DoH resolver timed out via tun; UDP resolver works.
|
||||
3. Binary copy for low port: `cp hysteria hysteria-edge; setcap cap_net_bind_service=+ep /usr/local/bin/hysteria-edge`.
|
||||
4. **OpenRC** (`supervisor=supervise-daemon`, `respawn_delay=3`, `respawn_max=0`):
|
||||
- `hysteria` (client, root) ← `need net`
|
||||
- `hysteria-route` ← `need hysteria`; waits for `hytun`, then:
|
||||
```
|
||||
ip route replace blackhole default metric 1000 table 100 # anti-leak fallback
|
||||
ip route replace default dev hytun metric 10 table 100
|
||||
ip rule add priority 100 ipproto udp sport 443 lookup main # edge replies bypass tun
|
||||
ip rule add priority 101 uidrange <uid hyedge> lookup 100
|
||||
```
|
||||
- `hysteria-edge` (`command_user=hyedge`) ← `need net hysteria-route`
|
||||
- `rc-update add hysteria/hysteria-route/hysteria-edge default`
|
||||
5. **sysctl (critical):** `net.ipv4.conf.all.rp_filter=2` and `default=2` (`/etc/sysctl.d/99-hytun.conf`). With strict `1` the TUN SYN-ACKs are dropped → TCP via tun silently hangs.
|
||||
6. Only uid `hyedge` traffic and, since 2026-09-30, the OpenVPN client subnet (`172.20.1.0/24`, rule 102, see below) enter the tunnel; SSH/root traffic untouched.
|
||||
|
||||
## OpenVPN Monitor & Profiler on ENTRY (added 2026-09-30)
|
||||
ENTRY also runs the web suite for OpenVPN (native OpenRC services, no containers). OpenVPN clients exit through the same `hytun` tunnel to EXIT.
|
||||
|
||||
| Item | Value |
|
||||
|---|---|
|
||||
| Panel | `https://213.226.125.13:8088/` (self-signed TLS, login `tstark`, 2FA; password is set by the owner, not stored here) |
|
||||
| OpenVPN | `udp/1194`, clients `172.20.1.0/24`, full tunnel |
|
||||
| Services | `ovpmon-api`, `ovpmon-gatherer`, `ovpmon-profiler` (user `ovpmon`), `nginx`, `openvpn`, `ovpn-hytun`, `fail2ban` |
|
||||
| Egress | `ip rule 102 from 172.20.1.0/24 → table 100` (hytun, blackhole fallback) + MASQUERADE on `hytun` + FORWARD DROP to any other interface (no leak) |
|
||||
| Privileges | APIs run unprivileged; a root helper (`ovpmon-helper` via `doas`, 6 fixed commands) validates/installs `server.conf`, publishes the CRL and controls `openvpn` |
|
||||
| Hardening | SSH key-only (`00-hardening.conf`), fail2ban (`sshd`, `sshd-ddos`, `ovpmon-login`), input validation, CORS restricted |
|
||||
| Docs | [`SUMMARY-ovpn-monitor.md`](SUMMARY-ovpn-monitor.md) (state, results, security assessment, rollback), `IMPLEMENTATION-01/02-*.md`, `PLAN-validation-and-privilege-drop.md`; application repo `DOCS/` |
|
||||
|
||||
Check: `rc-status | grep -E "ovpmon|openvpn|ovpn-hytun|nginx|fail2ban"`, `ip rule | grep 102`, a VPN client on `https://ifconfig.me` shows the EXIT IP (85.234.86.140).
|
||||
|
||||
## Clients
|
||||
- URI: `hy2://<user>:<pw>@<ENTRY>:443/?sni=<edge sni>&insecure=1&pinSHA256=<hex, no colons>#name` (strip `#name` when scripting pin extraction).
|
||||
- Hiddify/NekoBox/Streisand/Shadowrocket: paste URI. sing-box (SFA/SFI, ≥1.12): JSON profile with `hysteria2` outbound, `password: "<user>:<pw>"`, embedded `tls.certificate` PEM (edge.crt), TUN inbound, DoH via outbound, `hijack-dns`, private + ENTRY IP → direct.
|
||||
|
||||
### Recommended client apps
|
||||
Protocol: Hysteria 2 (QUIC/UDP 443). Server cert is self-signed, so the app must support `pinSHA256`/custom certificate — all apps below do via the URI/QR or sing-box JSON.
|
||||
|
||||
| Platform | App | Import | Notes |
|
||||
|---|---|---|---|
|
||||
| Android | **Hiddify** (primary) | hy2:// URI or QR | sing-box core, pin support, simplest |
|
||||
| Android | NekoBox for Android | hy2:// URI or QR | alternative, more knobs |
|
||||
| Android | SFA (sing-box) | JSON profile only | official sing-box client; use `profiles/singbox/*.json` |
|
||||
| iOS | **Streisand** (primary) | hy2:// URI or QR | free |
|
||||
| iOS | Shadowrocket | hy2:// URI or QR | paid, stable |
|
||||
| iOS | SFI (sing-box) / Hiddify | JSON profile / URI | sing-box >= 1.12 required for the JSON |
|
||||
| Desktop | Hiddify, sing-box CLI, `hysteria client` | URI / JSON / YAML | sing-box needs root/admin for TUN |
|
||||
|
||||
Required client settings (otherwise many sites fail or hang):
|
||||
- **IPv6 off** (Hiddify: IPv6 mode = Disable / IPv4 only). EXIT has no IPv6; apps sending IPv6 literals get `no IPv4 address available`.
|
||||
- Remote DNS via the tunnel (DoH `https://1.1.1.1/dns-query`); do not leak DNS outside.
|
||||
- QUIC blocking is **not** needed (UDP is carried end to end).
|
||||
- Verify: `ifconfig.me` shows the EXIT IP (85.234.86.140); `test-ipv6.com` shows no IPv6.
|
||||
|
||||
App availability/names vary by store and region and change over time; this list comes from the project's client testing (Hiddify confirmed working on a phone) and general knowledge, not a current store check. One profile per person.
|
||||
|
||||
## Profiles (20 client users)
|
||||
`profiles/` (dir 700, files 600; contains secrets): `hy2/clientNN.txt` (hy2:// URI), `singbox/clientNN.json` (sing-box >=1.12), `qr/<user>.png` (QR of the hy2:// URI, 22 files incl. laptop/mobile; made with `qrencode -t PNG -s 8 -m 2 -l M`; decoding verified), `ALL-hy2.txt`, `INDEX.md` (no passwords). Mobile apps: scan QR -> imports hy2:// profile. Users `client01..client20` live in `edge.yaml` `auth.userpass` (backup `edge.yaml.bak4`; `laptop`, `mobile` kept). Wrong password -> client error `authentication error, HTTP status code: 404`.
|
||||
Add/revoke a user: edit `auth.userpass` on ENTRY, `rc-service hysteria-edge restart` (all sessions reconnect within seconds), then regenerate/delete the profile files. Verified login for client01/10/20 via temp client (egress = EXIT IP).
|
||||
|
||||
## Verification checklist
|
||||
```
|
||||
su -s /bin/sh hyedge -c 'curl -4 -s https://ifconfig.me' # = EXIT ip (TCP via tun)
|
||||
su -s /bin/sh hyedge -c 'nslookup example.com 1.1.1.1' # UDP via tun
|
||||
ip route del default dev hytun table 100; <same curl must FAIL>; ip route replace default dev hytun metric 10 table 100
|
||||
hysteria client (temp, socks 127.0.0.1:1081) -> curl -x socks5h://... https://ifconfig.me # = EXIT ip; http://10.0.0.1 rejected
|
||||
```
|
||||
Logs: ENTRY `/var/log/hysteria.log`, `/var/log/hysteria-edge.log`; EXIT `journalctl -u hysteria-server`.
|
||||
|
||||
## Tuning / diagnostics learned
|
||||
- Path MTU ENTRY↔EXIT = 1500 (DF ping + tracepath); hytun mtu 1400 is safe; MTU is **not** the cause of site failures here.
|
||||
- Capacity test: on EXIT temporarily add `speedTest: true` to config.yaml, restart, run `hysteria speedtest -c /etc/hysteria/client.yaml` on ENTRY, then remove it.
|
||||
- `su hyedge -c curl <domain>` is ~4s slower (system resolver = provider DNS, unreachable via EXIT). Not a client issue: hysteria-edge uses its own `resolver` (1.1.1.1). Use `--resolve` or `-x socks5h` when benchmarking.
|
||||
- Clients that connect to **IPv6 literals** get `no IPv4 address available` (EXIT has no IPv6). Fix on the client: IPv6 off / IPv4-only DNS strategy (Hiddify: IPv6 mode = disable). Server cannot fix it without EXIT IPv6.
|
||||
- `UdpRcvbufErrors` rises at ~190 Mbps (quic-go requests 7 MB socket buffers; rmem_max already 7.5 MB) — cosmetic.
|
||||
|
||||
## Hysteria diagnostics (manual)
|
||||
Version: v2.12.3. Replace IPs/paths if the layout differs. On ENTRY run as root.
|
||||
|
||||
### Service state
|
||||
```
|
||||
# ENTRY (OpenRC)
|
||||
for s in hysteria hysteria-route hysteria-edge; do rc-service $s status; done
|
||||
ss -lunp | grep -E ':443\b' # edge listener (hysteria-edge)
|
||||
ss -unp | grep hysteria # client UDP socket to EXIT
|
||||
tail -f /var/log/hysteria.log /var/log/hysteria-edge.log
|
||||
# EXIT (systemd)
|
||||
systemctl status hysteria-server --no-pager; ss -lunp | grep :443
|
||||
journalctl -u hysteria-server -f # "client connected" / "TCP error"
|
||||
```
|
||||
|
||||
### Tunnel (ENTRY -> EXIT)
|
||||
```
|
||||
hysteria ping -c /etc/hysteria/client.yaml 1.1.1.1:443 # RTT of a connect via the tunnel
|
||||
hysteria speedtest -c /etc/hysteria/client.yaml # needs `speedTest: true` on EXIT (remove afterwards)
|
||||
ip -br a show hytun; ip rule | grep -E '^10[01]'; ip route show table 100
|
||||
su -s /bin/sh hyedge -c 'curl -4 -s https://ifconfig.me' # must print the EXIT IP
|
||||
tcpdump -ni hytun -c 20 # traffic entering the tun
|
||||
tcpdump -ni eth0 'udp port 443 and host 85.234.86.140' # QUIC to EXIT (on EXIT: enp3s0, host 213.226.125.13)
|
||||
```
|
||||
- Client log `connected to server ... count: N` – N grows on each reconnect. `TUN UDP error ... EOF` right after an EXIT restart is normal.
|
||||
- `rc-service hysteria status` = `crashed` and `connect error: timeout` -> UDP 443 path/Security Group problem (see prerequisites).
|
||||
|
||||
### Clients (Traffic Stats API on edge, enabled, 127.0.0.1 only)
|
||||
Config block in `edge.yaml`: `trafficStats: {listen: 127.0.0.1:9999, secret: <in /etc/hysteria/.stats-secret, 600>}`.
|
||||
```
|
||||
S=$(cat /etc/hysteria/.stats-secret); A="Authorization: $S"; U=http://127.0.0.1:9999
|
||||
curl -s -H "$A" $U/online # {"mobile":1} devices per user
|
||||
curl -s -H "$A" $U/traffic # {"mobile":{"tx":..,"rx":..}} add ?clear=1 to reset counters
|
||||
curl -s -H "$A" -H 'Accept: text/plain' $U/dump/streams # live streams: user, bytes, lifetime, req-addr
|
||||
curl -s -H "$A" -X POST -d '["mobile"]' $U/kick # drop a user's sessions (they reconnect if creds still valid)
|
||||
```
|
||||
Without the header the API returns 401. To block a user permanently remove it from `auth.userpass` and restart `hysteria-edge`.
|
||||
|
||||
### Useful subcommands
|
||||
```
|
||||
hysteria share -c /etc/hysteria/client.yaml # hy2:// URI for a client config (prints the password!)
|
||||
hysteria check-update # compare with the latest release
|
||||
hysteria version
|
||||
```
|
||||
|
||||
### Error -> meaning
|
||||
| Log text | Meaning |
|
||||
|---|---|
|
||||
| `no IPv4 address available` (edge) | client asked for an IPv6 literal; EXIT has no IPv6 -> disable IPv6 on the client |
|
||||
| `rejected` (edge) | ACL hit (private ranges) |
|
||||
| `resolve error ... deadline exceeded` (edge) | edge `resolver` unreachable through the tunnel |
|
||||
| `no certificate matches the pinned hash` | wrong `pinSHA256` (strip `#name` from the URI fragment) |
|
||||
| `authentication failed` / 404 on auth | wrong `user:password` |
|
||||
| curl via tun hangs, `tcpdump -i hytun` shows SYN-ACK from dst on hytun | strict `rp_filter` -> set `=2` |
|
||||
|
||||
## Known issues / notes
|
||||
- Backups on ENTRY: `/etc/hysteria/*.bak*`. Remote shell: zsh does not word-split variables → use functions for ssh wrappers.
|
||||
- ENTRY reboot tested (2026-09-30): all three services, hytun, rules, sysctl and tunnel came up by themselves; mobile client reconnected. EXIT reboot tested too: hysteria-server and sysctl came up, ENTRY client reconnected automatically (count 2).
|
||||
- strongSwan/IKE to Gcore (62.112.222.168) is a separate, unresolved task (needs UDP 500/4500 reachability); `charon` started manually, not enabled at boot.
|
||||
- SSH on ENTRY is key-only since 2026-09-30 (a cloud-init drop-in used to re-enable passwords; hardening lives in `/etc/ssh/sshd_config.d/00-hardening.conf`). A full ENTRY reboot test after the OpenVPN Monitor changes passed (2026-09-30, see `REBOOT-TEST.md`).
|
||||
@@ -0,0 +1,50 @@
|
||||
# Артефакт внедрения: п.1 — валидация настроек Profiler
|
||||
|
||||
Дата: 2026-09-30. Узел: 213.226.125.13 (Alpine, OpenRC). Приложение: `/opt/OpenVPN-Monitoring-Simple`. План: `PLAN-validation-and-privilege-drop.md`.
|
||||
Коммит в репозитории приложения на узле: `05f44b9` («Profiler: validate server/PKI settings before they reach OpenVPN config»), пуш не выполнялся.
|
||||
|
||||
## 1. Что внедрено
|
||||
|
||||
| Слой | Файл | Суть |
|
||||
|---|---|---|
|
||||
| A | `APP_PROFILER/schemas.py` | валидаторы `SystemSettingsUpdate`, `PKISettingUpdate` (422) |
|
||||
| B | `APP_PROFILER/services/validation.py` (новый), `services/generator.py` | `check_script`: скрипт только из `/etc/openvpn/scripts/`, root-owned, без записи group/other |
|
||||
| C | `APP_PROFILER/services/generator.py`, `routers/server.py` | отказ при `\n`, `\r`, NUL, управляющих символах, `"`, `\`; `ValueError` → HTTP 400 |
|
||||
|
||||
## 2. Порядок внедрения
|
||||
|
||||
1. Бэкап изменяемых файлов: `/root/app-bak/p1/` (`schemas.py`, `generator.py`, `server.py`).
|
||||
2. Добавлен `services/validation.py`; правки `schemas.py`, `generator.py`, `routers/server.py` (Python-патч с проверкой якорей `assert`).
|
||||
3. Проверка импорта: `venv/bin/python -c "import schemas"`.
|
||||
4. `rc-service ovpmon-profiler restart`.
|
||||
5. Тесты (скрипт запускался на узле с локально выпущенным токеном, т.к. на учётке включена 2FA).
|
||||
6. Документация: `DOCS/Changes/2026-09-30_Settings_Validation.md`, ссылки в `README.md`, `DOCS/General/Index.md`; коммит.
|
||||
|
||||
## 3. Доказательства (результаты тестов)
|
||||
|
||||
| Проверка | Результат |
|
||||
|---|---|
|
||||
| Круговой `PUT` текущих настроек server / pki | 200 / 200 |
|
||||
| 17 вредоносных или невалидных значений | все 422 |
|
||||
| 4 валидных изменения | все 200 |
|
||||
| Скрипты: root 755 / group-writable / симлинк / нет файла / вне каталога / traversal / пусто | принят / отклонён / отклонён / отклонён / отклонён / отклонён / принят |
|
||||
| Слой C при обходе схемы (5 инъекций) | все заблокированы, ничего не записано |
|
||||
| Рендер текущих настроек против рабочего `server.conf` | идентичен |
|
||||
| После тестов | настройки восстановлены и совпадают с исходными; `rc-status`: все сервисы `started` |
|
||||
|
||||
## 4. Откат
|
||||
|
||||
```sh
|
||||
cd /opt/OpenVPN-Monitoring-Simple/APP_PROFILER
|
||||
cp /root/app-bak/p1/schemas.py schemas.py
|
||||
cp /root/app-bak/p1/generator.py services/generator.py
|
||||
cp /root/app-bak/p1/server.py routers/server.py
|
||||
rm services/validation.py
|
||||
rc-service ovpmon-profiler restart
|
||||
```
|
||||
Либо `git revert 05f44b9` в репозитории.
|
||||
|
||||
## 5. Оговорки
|
||||
- Каталога `/etc/openvpn/scripts/` по умолчанию нет (для теста создавался временно и удалён): перед включением скриптов создать `root:root 755`, файлы `755` от root.
|
||||
- Валидаторы действуют на обновления; ранее сохранённые значения проверяются при рендере (слой C).
|
||||
- Слой B/C защищает конфиг, но сервис по-прежнему работает от root — это закрывает пункт 2 (см. `IMPLEMENTATION-02-…` после выполнения).
|
||||
@@ -0,0 +1,68 @@
|
||||
# Артефакт внедрения: п.2 — API без root + root-помощник
|
||||
|
||||
Дата: 2026-09-30. Узел: 213.226.125.13 (Alpine, OpenRC). Приложение: `/opt/OpenVPN-Monitoring-Simple`. План: `PLAN-validation-and-privilege-drop.md`.
|
||||
Коммит в репозитории приложения на узле: `6f9e800` («Run API services unprivileged; add root helper…»; после `--amend`, ранее упоминался как `10020ca`), дополнение по CRL — `9ffdbfa`, пуш не выполнялся. Полное описание: `DOCS/Changes/2026-09-30_Privilege_Separation.md`.
|
||||
|
||||
## 1. Что внедрено
|
||||
|
||||
| Компонент | Расположение | Суть |
|
||||
|---|---|---|
|
||||
| Пользователь | `ovpmon` (uid 109, nologin) | владелец данных: `/var/lib/ovpmon`, `/var/log/ovpmon`, `easy-rsa`, `client-config`, логи, `__pycache__` |
|
||||
| OpenRC | `/etc/init.d/ovpmon-{api,gatherer,profiler}` | `command_user="ovpmon:ovpmon"`; `/etc/ovpmon/env` остаётся root 600 |
|
||||
| Помощник | `/usr/local/sbin/ovpmon-helper` (root, 755) | `install-config` (allowlist директив, атомарная установка) и `service start\|stop\|restart\|status` |
|
||||
| doas | `/etc/doas.d/ovpmon.conf` | ровно 5 команд помощника, без других аргументов |
|
||||
| Код Profiler | `services/process.py`, `routers/server.py` | без root: staging → помощник; ветки root/контейнера не менялись |
|
||||
|
||||
## 2. Порядок внедрения
|
||||
|
||||
1. Бэкап: `/root/backup-p2-2026-09-30-1229.tar`, `/root/app-bak/p2/` (init-скрипты, `process.py`, `server.py`).
|
||||
2. Создание `ovpmon`, установка помощника и doas-правил; проверка помощника от имени `ovpmon` (сервисы ещё root).
|
||||
3. Патч `process.py` / `server.py` (+ `except HTTPException: raise`, чтобы отказ 400 не превращался в 500).
|
||||
4. `chown` данных, `command_user` в init-скриптах; перезапуск gatherer → api → profiler с проверкой каждого.
|
||||
5. Функциональные и негативные тесты; рестарт OpenVPN через API.
|
||||
6. Документация и коммит (исправлен эпизод с порчей `Deployment_Native.md`: восстановлен из предыдущего коммита и переделан, коммит переписан `--amend`, пуша не было).
|
||||
|
||||
## 3. Доказательства
|
||||
|
||||
| Проверка | Результат |
|
||||
|---|---|
|
||||
| Процессы | gunicorn, uvicorn, gatherer — `ovpmon`; root остались только supervise-daemon |
|
||||
| Помощник принимает рабочий `server.conf` | да, файл в `/etc/openvpn` не изменился (sha256 совпал) |
|
||||
| 17 инъекций директив, отсутствие `user nobody`, cert вне PKI, CR, симлинк staged-файла | все отклонены, боевой конфиг не тронут |
|
||||
| doas: произвольная команда, другие аргументы помощника | `Operation not permitted` |
|
||||
| `ovpmon` не может: читать `/etc/shadow`, `/root`, `/etc/hysteria/*.yaml`, `/etc/ovpmon/env`; писать в `/etc/openvpn`, `/etc/init.d`, `authorized_keys`, код приложения; запускать `iptables` | все отказы |
|
||||
| API: мониторинг, config, process stats, `server/configure` (конфиг идентичен), создание/скачивание/отзыв профиля | всё 200 |
|
||||
| Рестарт OpenVPN через API (helper) | 200; OpenVPN и `tun0` подняты, статус-лог читается gatherer, `ip rule 102` и MASQUERADE на месте, ошибок в логе gatherer нет |
|
||||
|
||||
Побочный эффект тестов: один рестарт OpenVPN (~секунды разрыва VPN-сессий; клиент переподключается сам).
|
||||
|
||||
## 4. Откат
|
||||
|
||||
```sh
|
||||
cp /root/app-bak/p2/ovpmon-{api,gatherer,profiler} /etc/init.d/
|
||||
chown -R root:root /var/lib/ovpmon /var/log/ovpmon /opt/OpenVPN-Monitoring-Simple/APP_PROFILER/{easy-rsa,client-config}
|
||||
for s in ovpmon-gatherer ovpmon-api ovpmon-profiler; do rc-service $s restart; done
|
||||
```
|
||||
(помощник и doas-правила можно оставить; код Profiler при root работает по прежней ветке).
|
||||
|
||||
## 5. Оговорки
|
||||
- `crl_verify`: ограничение снято (2026-09-30, коммит `9ffdbfa`) — см. раздел 6 ниже.
|
||||
- Проверка путей PKI в помощнике выполняется на момент установки; каталог PKI принадлежит `ovpmon` — держать его только за этим пользователем.
|
||||
- Скрипты подключения по-прежнему требуют `/etc/openvpn/scripts/` (root, 755) — каталога нет по умолчанию.
|
||||
|
||||
## 6. Дополнение: публикация CRL для `crl_verify` (2026-09-30)
|
||||
|
||||
Проблема: OpenVPN после старта работает от `nobody`, а `pki/` создаётся с правами 700 (владелец `ovpmon`), поэтому при включённом `crl_verify` все клиенты получили бы отказ.
|
||||
Решение: новая команда помощника `publish-crl` (6-е правило doas) копирует `pki/crl.pem` в `/etc/openvpn/crl.pem` (`root:root 644`) с проверками (файл внутри PKI, не симлинк, владелец `ovpmon`, ≤ 1 МиБ, PEM-маркеры и разбор `openssl crl`). Вызывается после `gen-crl` (init/revoke), при `server/configure` и при каждом `service start|restart`; ошибка публикации при отзыве возвращается как ошибка API. Генератор без root пишет `crl-verify /etc/openvpn/crl.pem`, помощник разрешает для `crl-verify` только этот путь.
|
||||
|
||||
Проверка (отдельный временный экземпляр OpenVPN на другом порту и подсети, от `nobody`, тот же PKI и CRL; боевой OpenVPN не перезапускался):
|
||||
|
||||
| Проверка | Результат |
|
||||
|---|---|
|
||||
| `publish-crl` от `ovpmon`; права копии; чтение `nobody` | ok; `root:root 644`; читается; исходный `pki/crl.pem` для `nobody` по-прежнему недоступен |
|
||||
| мусорный файл / поддельные PEM-маркеры / симлинк вместо CRL | отклонены |
|
||||
| `crl_verify=true` через API + `server/configure` | 200, в конфиге `crl-verify /etc/openvpn/crl.pem`, помощник принял; настройка возвращена, боевой `server.conf` идентичен исходному |
|
||||
| валидный клиент при активной проверке CRL | подключается |
|
||||
| отзыв через API и повторное подключение | `certificate revoked`, клиент отклонён, ошибок чтения CRL нет |
|
||||
|
||||
Побочное: в списке профилей остались записи со статусом revoked от тестов (`ok-user1`, `p2test-*`, `crl-e2e-*`). Тестовый экземпляр и файлы удалены, боевой OpenVPN (pid 8884) и правила выхода через hel не затронуты. Значение `crl_verify` в настройках оставлено выключенным, как было.
|
||||
@@ -0,0 +1,82 @@
|
||||
# План: валидация настроек (п.1) и запуск API не от root (п.2)
|
||||
|
||||
Дата: 2026-09-30. Узел: 213.226.125.13 (Alpine, OpenRC). Приложение: `/opt/OpenVPN-Monitoring-Simple`.
|
||||
Основание: SUMMARY-ovpn-monitor.md, раздел 6.5 — администратор панели фактически равен root, т.к. Profiler работает от root, а настройки без проверки попадают в `server.conf`.
|
||||
|
||||
## Исходные факты
|
||||
- `APP_CORE` (API, gatherer) root не нужен: читает `openvpn-status.log`, пишет свою SQLite-БД и лог.
|
||||
- Profiler требует root только для: записи `/etc/openvpn/server.conf`, `rc-service openvpn`, чтения PKI-файлов самим OpenVPN.
|
||||
- В коде уже есть fallback `staging/server.conf`.
|
||||
- В шаблоне `script-security 2` стоит внутри `{% if user_defined_cdscripts %}`.
|
||||
- `doas` установлен; конфигурация только `permit nopass :wheel`.
|
||||
|
||||
## Пункт 1. Валидация настроек (3 слоя)
|
||||
|
||||
**Слой A. Схемы (`APP_PROFILER/schemas.py`)** — валидаторы только на `*Update`-схемах (старые значения из БД не ломают `GET`).
|
||||
|
||||
| Поле | Правило |
|
||||
|---|---|
|
||||
| `port`, `management_port` | 1–65535 |
|
||||
| `vpn_network`, `vpn_netmask` | `ipaddress`: сеть и корректная маска |
|
||||
| `split_routes[]` | CIDR или «сеть маска», нормализация через `ipaddress` |
|
||||
| `dns_servers[]` | IPv4/IPv6 через `ipaddress` |
|
||||
| `public_ip` | IP или hostname по маске, без пробелов и кавычек |
|
||||
| `management_interface_address` | только loopback |
|
||||
| `tun_mtu` / `mssfix` | 576–9000 / 536–1500 |
|
||||
| `connect_script`, `disconnect_script` | пусто или `^/etc/openvpn/scripts/[A-Za-z0-9_.-]{1,64}$` |
|
||||
| PKI `fqdn_ca`, `fqdn_server` | маска как у `username` |
|
||||
| PKI `easyrsa_dn` | `cn_only` или `org` |
|
||||
| PKI `req_country` | `^[A-Z]{2}$` |
|
||||
| PKI `req_province/city/org/ou` | `^[A-Za-z0-9 .,_-]{0,64}$` |
|
||||
| PKI `req_email` | простая маска email |
|
||||
| PKI `key_size` | 2048 / 3072 / 4096 |
|
||||
| PKI сроки | целые в разумных пределах |
|
||||
|
||||
**Слой B. Скрипты (`routers/server.py`, до генерации):** `realpath` внутри `/etc/openvpn/scripts/`, файл существует, владелец root, нет прав записи для group/other.
|
||||
|
||||
**Слой C. Генератор (`services/generator.py`):** перед записью проверить, что ни одно значение не содержит `\n`, `\r`, `"`, `\`; иначе ошибка и файл не пишется.
|
||||
|
||||
Тесты: невалидные значения (перевод строки в DNS, `../` в скрипте, `/` в организации, порт 0) → 422; валидный набор → 200 и корректный `server.conf`.
|
||||
|
||||
## Пункт 2. Profiler и API не от root
|
||||
|
||||
Схема: пользователь `ovpmon` + маленький root-помощник, сам валидирующий конфиг (защита в глубину: инъекция в конфиг не даёт root даже при обходе слоёв A–C).
|
||||
|
||||
1. **Пользователь и права.** Системный `ovpmon`. Владелец: `/var/lib/ovpmon`, `/var/log/ovpmon`, `APP_PROFILER/{easy-rsa,client-config,staging,*.db,profiler.log}`, `APP_CORE/{*.db,*.log}`. `pki/`: каталоги 755, `crl.pem` 644, приватные ключи 600. Код и `venv` остаются root-owned. `/etc/ovpmon/env` остаётся root 600.
|
||||
2. **OpenRC:** `command_user="ovpmon:ovpmon"` в `ovpmon-*`.
|
||||
3. **Помощник `/usr/local/sbin/ovpmon-helper`** (root, 755, Python). Подкоманды без пользовательских аргументов:
|
||||
- `install-config`: читает только `/var/lib/ovpmon/staging/server.conf` (один раз в память, защита от TOCTOU), проверяет allowlist директив (запрещены `up`, `down`, `plugin`, `route-up`, `tls-verify`, `auth-user-pass-verify`, `learn-address`, неизвестные; `client-connect/disconnect` и `script-security` — только со скриптом из `/etc/openvpn/scripts/` с проверкой владельца и прав), пишет `/etc/openvpn/server.conf` атомарно (root:root 644).
|
||||
- `service start|stop|restart|status` только для `openvpn`.
|
||||
4. **`doas`:** `/etc/doas.d/ovpmon.conf`, точные `args` для каждой подкоманды.
|
||||
5. **Код Profiler:** на хосте `services/process.py` и `routers/server.py` вызывают `doas ovpmon-helper …`; ветка контейнера и `staging` не меняются.
|
||||
6. **`APP_CORE`:** только смена владельца и `command_user`.
|
||||
7. **Каталог скриптов:** `/etc/openvpn/scripts/` root:root 755, по умолчанию пустой.
|
||||
|
||||
### Порядок
|
||||
1. Бэкап `/etc/openvpn`, БД, `/etc/init.d/ovpmon-*`, код, `easy-rsa/`.
|
||||
2. Слои A–C + тесты, перезапуск Profiler.
|
||||
3. Пользователь, `chown`, помощник, `doas`; ручная проверка помощника.
|
||||
4. Переключить `process.py` и `server.py` на помощник.
|
||||
5. Переключить init-скрипты на `command_user`, перезапускать по одному.
|
||||
6. Обновить документацию и SUMMARY, отдельные коммиты.
|
||||
|
||||
### Проверка (п.2)
|
||||
- `ps`: Profiler и API под `ovpmon`.
|
||||
- От `ovpmon`: нет доступа к `/etc/shadow`, `/root`, `/etc/hysteria/*.yaml`, запись в `/etc/openvpn` запрещена.
|
||||
- UI: Initialize PKI, генерация и запись конфига, Start/Stop/Restart OpenVPN, создание/скачивание/отзыв профиля, мониторинг.
|
||||
- Инъекция директивы в `staging/server.conf` отвергается помощником, `/etc/openvpn/server.conf` не меняется.
|
||||
- Прямые вызовы `doas` с другими аргументами отклоняются.
|
||||
- OpenVPN-клиент подключается и выходит через hel.
|
||||
- Не сломаны fail2ban, HTTPS 8088, `ip rule 102`.
|
||||
|
||||
## Риски
|
||||
| Риск | Смягчение |
|
||||
|---|---|
|
||||
| Сервис не стартует из-за прав | бэкап, `chown` до переключения, откат правкой init-скриптов |
|
||||
| Allowlist блокирует легальный конфиг | первый прогон в режиме проверки без записи; список директив из `server.conf.j2` |
|
||||
| Ошибка в `doas` `args` | негативные тесты от `ovpmon`, запасная root-SSH-сессия |
|
||||
| Потеря ключей PKI | копия `easy-rsa/` перед `chown` |
|
||||
|
||||
## Статус
|
||||
- Пункт 1 — реализован (2026-09-30), см. SUMMARY раздел 6.6 и DOCS/Changes/2026-09-30_Settings_Validation.md.
|
||||
- Пункт 2 — реализован (2026-09-30), см. IMPLEMENTATION-02-privilege-separation.md и DOCS/Changes/2026-09-30_Privilege_Separation.md.
|
||||
@@ -0,0 +1,12 @@
|
||||
# Operations records (deployment on ENTRY, Timeweb)
|
||||
|
||||
Records of one concrete deployment (host addresses, results, rollback). No passwords or keys are stored here.
|
||||
|
||||
| Document | Content |
|
||||
|---|---|
|
||||
| [SUMMARY-ovpn-monitor.md](SUMMARY-ovpn-monitor.md) | Current state, access, install, egress via Hysteria2, security findings/fixes, risk assessment, commits, backups, open items |
|
||||
| [Hysteria_Chain_Manifest.md](Hysteria_Chain_Manifest.md) | Hysteria 2 entry-exit chain manifest, diagnostics, plus the OpenVPN Monitor section |
|
||||
| [REBOOT-TEST.md](REBOOT-TEST.md) | Reboot tests (Hysteria chain; ENTRY after the OpenVPN Monitor deployment) |
|
||||
| [PLAN-validation-and-privilege-drop.md](PLAN-validation-and-privilege-drop.md) | Plan: settings validation and running the API unprivileged |
|
||||
| [IMPLEMENTATION-01-settings-validation.md](IMPLEMENTATION-01-settings-validation.md) | Rollout record: settings validation |
|
||||
| [IMPLEMENTATION-02-privilege-separation.md](IMPLEMENTATION-02-privilege-separation.md) | Rollout record: privilege separation and CRL publishing |
|
||||
@@ -0,0 +1,106 @@
|
||||
# Reboot test: ENTRY (Timeweb) and EXIT (Hel)
|
||||
|
||||
Date: 2026-09-30. Goal: confirm the Hysteria 2 chain recovers by itself after a reboot of each node, with no manual steps.
|
||||
|
||||
Nodes: ENTRY 213.226.125.13 (Alpine, OpenRC), EXIT 85.234.86.140 (Debian 13, systemd).
|
||||
|
||||
## Methodology
|
||||
|
||||
Order: ENTRY first, then EXIT. Each node is rebooted separately, so the other side is always up and its reconnect behavior is observable.
|
||||
|
||||
### 1. Pre-flight (before each reboot)
|
||||
| Check | ENTRY | EXIT |
|
||||
|---|---|---|
|
||||
| Services enabled | `rc-update show default` (hysteria, hysteria-route, hysteria-edge) | `systemctl is-enabled hysteria-server` |
|
||||
| Kernel module for TUN persists | `lsmod \| grep tun`, `grep -x tun /etc/modules` | not needed |
|
||||
| sysctl persisted | `ls /etc/sysctl.d/` (99-hytun.conf, 99-quic.conf) | `ls /etc/sysctl.d/99-quic.conf` |
|
||||
| Temporary test settings removed | n/a | `grep -c speedTest /etc/hysteria/config.yaml` = 0 |
|
||||
|
||||
Any gap found is fixed before rebooting.
|
||||
|
||||
### 2. Reboot
|
||||
`sync`, then a delayed `reboot` / `systemctl reboot` (detached, so the SSH command returns). Wait 45 s, then poll SSH every 6 s (up to 20 tries) until `uptime` answers.
|
||||
|
||||
### 3. Post-boot verification
|
||||
ENTRY:
|
||||
1. `rc-service hysteria|hysteria-route|hysteria-edge status` → `started`.
|
||||
2. `ls -l /dev/net/tun`, `ip -br a show hytun`.
|
||||
3. `ip rule | grep -E '^10[01]'` and `ip route show table 100` → both rules, default via hytun, blackhole fallback.
|
||||
4. `sysctl net.ipv4.conf.all.rp_filter net.core.rmem_max` → 2 and 7500000.
|
||||
5. `ss -lunp | grep :443` → `hysteria-edge` listening.
|
||||
6. Tunnel TCP: `su -s /bin/sh hyedge -c 'curl -4 -s https://ifconfig.me'` → must equal the EXIT IP.
|
||||
7. Tunnel UDP: `su -s /bin/sh hyedge -c 'nslookup example.com 1.1.1.1'` → address returned.
|
||||
8. Logs: `/var/log/hysteria.log`, `/var/log/hysteria-edge.log`; a real client (mobile) reconnecting is a bonus signal.
|
||||
|
||||
EXIT:
|
||||
1. `systemctl is-active hysteria-server`, `ss -lunp | grep :443`.
|
||||
2. `sudo /usr/sbin/sysctl net.core.rmem_max net.core.wmem_max` (`sysctl` is not in a non-root user's PATH).
|
||||
3. `journalctl -u hysteria-server -b` → "server up and running" and "client connected" from ENTRY.
|
||||
4. From ENTRY: client log shows a new `connected to server` (count incremented) and the `hyedge` curl test (step 6 above) returns the EXIT IP.
|
||||
|
||||
## Results
|
||||
|
||||
### ENTRY reboot (Timeweb)
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| Pre-flight | **Gap found:** module `tun` loaded by hand but absent from `/etc/modules` → after reboot `/dev/net/tun` could be missing and the tunnel would not start. Fixed (`echo tun >> /etc/modules`) before rebooting. |
|
||||
| SSH back | Yes, about 45-60 s after the reboot command |
|
||||
| Services | hysteria, hysteria-route, hysteria-edge all `started` |
|
||||
| TUN / routing | `/dev/net/tun` present, `hytun` up (100.100.100.101/30), rules 100 and 101 present, table 100: default dev hytun + blackhole |
|
||||
| sysctl | `rp_filter=2`, `rmem_max=7500000` applied |
|
||||
| Listener | `hysteria-edge` on UDP 443 |
|
||||
| Tunnel TCP | `85.234.86.140` (EXIT IP) |
|
||||
| Tunnel UDP | DNS answer received |
|
||||
| Client | Hiddify (`mobile`) reconnected on its own right after boot |
|
||||
| Noise in logs | A few dial timeouts to individual remote hosts (unreachable destinations, not a tunnel fault) |
|
||||
|
||||
Verdict: **PASS** (after the `/etc/modules` fix).
|
||||
|
||||
### EXIT reboot (Hel)
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| Pre-flight | `hysteria-server` enabled, `99-quic.conf` present, `speedTest` absent from config (0) |
|
||||
| SSH back | Yes, about 45-50 s after the reboot command |
|
||||
| Service | `hysteria-server` `active`, listening on UDP 443 |
|
||||
| sysctl | `rmem_max` and `wmem_max` = 7500000 |
|
||||
| Journal | "server up and running" at boot, then "client connected" from 213.226.125.13 about 30 s later |
|
||||
| ENTRY side | Client reconnected automatically (`connected to server`, `count: 2`); brief `TUN UDP error ... EOF` while EXIT was down; `hyedge` curl returned `85.234.86.140` |
|
||||
|
||||
Verdict: **PASS**.
|
||||
|
||||
## Conclusions
|
||||
- Both nodes recover unattended; the ENTRY hysteria client (supervise-daemon, `lazy: true`) reconnects without restart when EXIT comes back.
|
||||
- During an EXIT outage the anti-leak blackhole route in table 100 keeps `hyedge` traffic from leaving via ENTRY's own uplink (leak test done separately: removing the tun default route makes the test curl fail instead of exposing the ENTRY IP).
|
||||
- Not covered: simultaneous reboot of both nodes, power-loss/unclean shutdown, and IKE/strongSwan (separate task, `charon` is not enabled at boot).
|
||||
|
||||
## ENTRY reboot after the OpenVPN Monitor deployment (2026-09-30)
|
||||
|
||||
Goal: confirm the whole ENTRY stack (Hysteria chain + OpenVPN Monitor, OpenVPN, egress rules, hardening) recovers by itself. Only ENTRY was rebooted; EXIT stayed up.
|
||||
|
||||
### Pre-flight
|
||||
- `rc-update show default`: `hysteria`, `hysteria-route`, `hysteria-edge`, `ovpn-hytun`, `openvpn`, `ovpmon-api`, `ovpmon-gatherer`, `ovpmon-profiler`, `nginx`, `fail2ban`, `sshd`, `chronyd` enabled.
|
||||
- `tun` in `/etc/modules`; sysctl files present (`ip_forward=1`, `rp_filter=2`); `sshd -t` and `doas -C /etc/doas.d/ovpmon.conf` valid.
|
||||
- Baseline snapshot `/root/pre-reboot-snapshot.txt`: service states, `ip rule 100-102`, table 100, nat/FORWARD rules, listeners, process owners.
|
||||
|
||||
### Reboot and result
|
||||
`sync`, detached `reboot`; SSH answered again after ~45 s. Post-boot snapshot `/root/post-reboot-snapshot.txt` is **identical** to the baseline.
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| All services in `default` | started by themselves |
|
||||
| API processes | gunicorn/uvicorn/gatherer run as `ovpmon` |
|
||||
| `hytun`, `tun0`, `rp_filter=2`, `ip_forward=1` | present |
|
||||
| `ip rule` 100/101/102, table 100 (hytun + blackhole), MASQUERADE to `hytun`, FORWARD DROP guard | restored by `hysteria-route` / `ovpn-hytun` |
|
||||
| Tunnel TCP as `hyedge` | `85.234.86.140` (EXIT) |
|
||||
| Hysteria client | reconnected to EXIT |
|
||||
| Panel | `https://…:8088/` 200, profiler without token 401, HTTP→HTTPS 301, login `tstark` works |
|
||||
| Helper/doas | `service status` and `process stats` OK; gatherer reads `openvpn-status.log` (`root:ovpmon 640` persisted) |
|
||||
| `/etc/openvpn/crl.pem`, `/var/lib/ovpmon` ownership | intact |
|
||||
| fail2ban | 3 jails active |
|
||||
| SSH | `passwordauthentication no`, `permitrootlogin prohibit-password` |
|
||||
| Errors in `ovpmon-*` logs since boot | none (older entries predate the boot) |
|
||||
| Real VPN client | reconnected by itself ~3.5 min after boot (UDP client waits for its `keepalive 10 120` timeout); ~5 MB downloaded through the tunnel, MASQUERADE counters rose, leak guard `FORWARD DROP` counter stayed 0 |
|
||||
|
||||
### Notes
|
||||
- Expected client gap after a server reboot is up to ~2-4 minutes for UDP OpenVPN clients (ping-restart), not a fault of the server.
|
||||
- `openvpn-status.log` keeps `root:ovpmon 640` because the file persists on disk; if it is ever deleted, OpenVPN recreates it as `600` and the gatherer cannot read it until the next `ovpmon-helper service start|restart` (which fixes the mode). Not triggered by a reboot.
|
||||
@@ -0,0 +1,169 @@
|
||||
# Summary: OpenVPN Monitor & Profiler на ENTRY (Timeweb) + выход через hysteria2 → hel
|
||||
|
||||
Актуализировано: 2026-09-30 (по всем доработкам). Узел: 213.226.125.13 (Alpine 3.23.6, OpenRC), доступ `ssh -i private.key root@213.226.125.13` (только по ключу).
|
||||
Пароль администратора панели в документе **не хранится** (после развёртывания заменён владельцем; сгенерированный начальный пароль больше не действует). Документ не содержит секретов, но остаётся внутренним (`chmod 600`).
|
||||
|
||||
Связанные документы:
|
||||
- Рядом в `DOCS/Operations/`: `Hysteria_Chain_Manifest.md` (манифест цепочки Hysteria + раздел про OpenVPN Monitor), `PLAN-validation-and-privilege-drop.md`, `IMPLEMENTATION-01-settings-validation.md`, `IMPLEMENTATION-02-privilege-separation.md`.
|
||||
- В репозитории приложения на узле (`/opt/OpenVPN-Monitoring-Simple/DOCS/`): `General/Deployment_Docker.md`, `General/Deployment_Native.md`, `Changes/2026-09-30_*.md` (Security_Hardening, Admin_Username_Change, Settings_Validation, Privilege_Separation, Egress_via_Hysteria2), `General/privilege-separation/` (помощник и правила doas).
|
||||
|
||||
## 1. Текущее состояние
|
||||
|
||||
| Компонент | Состояние |
|
||||
|---|---|
|
||||
| Панель | `https://213.226.125.13:8088/` — nginx, TLS 1.2/1.3, самоподписанный сертификат |
|
||||
| Учётная запись | логин `tstark` (не `admin`), 2FA включена владельцем, встроенной `admin/password` нет |
|
||||
| Сервисы OpenRC (автозагрузка) | `ovpmon-api` (gunicorn, 127.0.0.1:5001), `ovpmon-gatherer`, `ovpmon-profiler` (uvicorn, 127.0.0.1:8000) — **от пользователя `ovpmon`**; `nginx`; `openvpn`; `ovpn-hytun`; `fail2ban` |
|
||||
| OpenVPN | udp/1194, сеть клиентов 172.20.1.0/24, полный туннель, клиентский трафик уходит через hysteria2 на hel; сквозной тест реальным клиентом пройден |
|
||||
| Root-помощник | `/usr/local/sbin/ovpmon-helper` + 6 правил `doas` (`/etc/doas.d/ovpmon.conf`) |
|
||||
| SSH | только ключи (`PasswordAuthentication no`, `PermitRootLogin prohibit-password`, `MaxAuthTries 3`) |
|
||||
| `crl_verify` | поддерживается (CRL публикуется помощником), в настройках сейчас **выключен** |
|
||||
| Hysteria (entry/client, UDP 443, 1080, 8080, 9999) | не затрагивалась доработками |
|
||||
|
||||
## 2. Доступ к админке
|
||||
|
||||
| Параметр | Значение |
|
||||
|---|---|
|
||||
| URL | `https://213.226.125.13:8088/` (HTTP на этом порту → 301 на HTTPS) |
|
||||
| Логин | `tstark` |
|
||||
| Пароль / 2FA | задаются и меняются владельцем в UI: **Account** (Change Password, Change Username, Two-Factor Authentication) |
|
||||
| Сертификат | самоподписанный EC P-256, SAN `IP:213.226.125.13, DNS:ovpmon`, до 2029-01-02, файлы `/etc/ovpmon/tls/ovpmon.{crt,key}` |
|
||||
| SHA-256 отпечаток | `E1:7F:A1:C1:F9:E8:22:C0:32:59:82:EF:D2:D7:FC:75:F6:3D:7E:8E:48:FA:55:88:9F:69:4D:15:3E:2B:7B:58` |
|
||||
|
||||
- Проверка клиентом: `curl --cacert ovpmon.crt https://213.226.125.13:8088/` (скопировать `ovpmon.crt` с узла).
|
||||
- Первая фаза уже выполнена (PKI инициализирован); далее в UI: генерация server.conf, запуск OpenVPN, профили клиентов.
|
||||
- JWT-секрет: `/etc/ovpmon/env` (`root:root 600`).
|
||||
- Аварийное восстановление (если таблица `users` пуста): временно задать `OVPMON_INITIAL_ADMIN_USER` / `OVPMON_INITIAL_ADMIN_PASSWORD` в `/etc/ovpmon/env`, `rc-service ovpmon-api restart`, затем удалить переменные и перезапустить сервис. Без переменных пользователь не создаётся (в логе ERROR).
|
||||
|
||||
## 3. Установка окружения (без контейнеров)
|
||||
|
||||
Приложение: `/opt/OpenVPN-Monitoring-Simple` (не `/opt/ovpn-bash-profiler` — там только bash-профайлер без веб-части). Docker-compose репозитория не используется; сервисы вынесены в OpenRC.
|
||||
|
||||
1. Пакеты: `apk add python3 py3-pip nginx nodejs npm openvpn easy-rsa iptables bash` (+ `fail2ban`, `doas` уже был).
|
||||
2. Backend: venv в `APP_CORE` (+ `gunicorn`) и `APP_PROFILER`, зависимости из `requirements.txt`.
|
||||
3. UI: `npm install && npm run build` в `APP_UI`, `dist/` → `/var/www/ovpmon` (предыдущая сборка: `/var/www/ovpmon.bak`).
|
||||
4. Конфиг: `/etc/ovpmon/env` (600): общий JWT-секрет, пути БД `/var/lib/ovpmon/*.db`, путь `openvpn-status.log`, `OVPMON_CORS_ORIGINS=https://213.226.125.13:8088`. Каталоги: `/var/lib/ovpmon` (+ `staging/`), `/var/log/ovpmon`, `/var/log/openvpn`.
|
||||
5. Сервисы (`supervisor=supervise-daemon`, `respawn_delay=3`, `command_user="ovpmon:ovpmon"`, окружение подгружается в `start_pre` из `/etc/ovpmon/env`).
|
||||
6. nginx: `/etc/nginx/http.d/ovpmon.conf` — 8088 TLS; `/` статика UI, `/api/` → :5001, `/profiles-api/` → :8000/api/.
|
||||
7. Для Alpine: каталог `APP_PROFILER/easy-rsa` копируется из `/usr/share/easy-rsa` вручную (в Docker это делает entrypoint); симлинк `/etc/openvpn/openvpn.conf → server.conf`, чтобы кнопка Start в UI (`rc-service openvpn`) работала.
|
||||
8. Параметры OpenVPN (`/etc/openvpn/server.conf`, генерируется приложением): udp/1194, `server 172.20.1.0/24`, `tun-mtu 1400`, `mssfix 1360`, `redirect-gateway def1`, DNS 1.1.1.1 и 8.8.8.8, `user nobody`, `group nogroup`.
|
||||
|
||||
## 4. Трафик клиентов OpenVPN → hysteria2 → hel
|
||||
|
||||
```
|
||||
OpenVPN client ─udp/1194→ tun0 (172.20.1.1/24)
|
||||
→ ip rule 102: from 172.20.1.0/24 → table 100
|
||||
→ table 100: default dev hytun (metric 10); blackhole default (metric 1000)
|
||||
→ iptables nat POSTROUTING -s 172.20.1.0/24 -o hytun -j MASQUERADE
|
||||
→ hytun (TUN клиента hysteria, 100.100.100.101/30, mtu 1400)
|
||||
→ hysteria client ─hy2/QUIC UDP 443→ EXIT 85.234.86.140 → Интернет (IPv4)
|
||||
```
|
||||
|
||||
OpenRC-сервис `ovpn-hytun` (`need hysteria-route`, `before openvpn`) ставит `ip rule 102`, MASQUERADE на `hytun`, TCP MSS clamp и анти-утечку `FORWARD -s 172.20.1.0/24 ! -o hytun -j DROP`, а также `net.ipv4.ip_forward=1` (`/etc/sysctl.d/98-forward.conf`). Правила идемпотентны, `stop` их снимает.
|
||||
|
||||
- Приоритеты: 100 — ответы `sport 443` в main, 101 — `uid hyedge`, 102 — подсеть OpenVPN; нужен `rp_filter=2`.
|
||||
- Служебный трафик (SSH, порт 1194 OpenVPN) идёт мимо туннеля. При падении hysteria таблица 100 уходит в blackhole: клиенты теряют интернет, утечки нет. На EXIT изменений не потребовалось.
|
||||
- Проверка: `ip rule | grep 102`, `iptables -t nat -S POSTROUTING`; клиент на `https://ifconfig.me` видит `85.234.86.140`; эталон на узле — `su -s /bin/sh hyedge -c "curl -4 https://ifconfig.me"`.
|
||||
- Статус: TCP/UDP через `hytun` подтверждены на узле, сквозной тест с реальным VPN-клиентом пройден (1194/udp достижим снаружи).
|
||||
|
||||
## 5. HTTPS
|
||||
|
||||
nginx на 8088: TLS 1.2/1.3 (TLS 1.1 отклоняется), HSTS, `X-Content-Type-Options`, `X-Frame-Options: DENY`, `Referrer-Policy`, `Cache-Control: no-store`, `server_tokens off`, редирект HTTP→HTTPS (`error_page 497`), `limit_req` 5/мин на `POST /api/auth/login`. Бэкенды слушают только 127.0.0.1. Прежний HTTP-конфиг: `/root/ovpmon.conf.bak`. Не объявлять второй `ssl_session_cache shared:SSL` другого размера — конфликт с `nginx.conf`. Порт управления OpenVPN (management) не включён; при включении — только loopback.
|
||||
|
||||
## 6. Безопасность: находки и их устранение
|
||||
|
||||
| # | Критичность | Находка | Исправление | Проверка |
|
||||
|---|---|---|---|---|
|
||||
| 1 | Критично | SSH: пароль root по сети (drop-in `50-cloud-init.conf` возвращал `PasswordAuthentication yes`; уже шёл перебор — 113 неудачных попыток) | `/etc/ssh/sshd_config.d/00-hardening.conf`: пароли и kbd-interactive выключены, `PermitRootLogin prohibit-password`, `MaxAuthTries 3`, `LoginGraceTime 30`; бэкап `/root/sshd_config.bak` | вход по ключу ок, по паролю `Permission denied (publickey)` |
|
||||
| 2 | Высоко | Path traversal в Profiler (`username` без валидации, сервис от root) | маска `^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$` в схеме, `validate_username()` в `pki.py`, проверки `realpath` в роутере и генераторе | `../../tmp/pwn`, `a/b`, `..`, `-x` → 422, файл не создан |
|
||||
| 3 | Высоко | Обход 2FA: временный токен принимался всеми ручками | `token_required` (Flask) и `verify_token` (Profiler) отклоняют `is_2fa_pending`; принимает его только `verify-2fa` | temp-токен → 401 везде, полный токен → 200 |
|
||||
| 4 | Средне | `enable_2fa` писал OTP и TOTP-секрет в лог | убрано из сообщения | в логе секретов нет |
|
||||
| 5 | Средне | CORS `*` + credentials | origin из `OVPMON_CORS_ORIGINS` (по умолчанию только same-origin), для Profiler сужены методы/заголовки | чужой `Origin` не получает `Access-Control-Allow-Origin` |
|
||||
| 6 | Средне | Нет защиты от перебора | fail2ban: jails `sshd`, `sshd-ddos`, `ovpmon-login` (401/429/503 на логине), IP администратора в `ignoreip` | jails активны, баны SSH-сканеров зафиксированы |
|
||||
| 7 | Средне | Панель по HTTP | TLS в nginx (раздел 5) | HTTPS 200, TLS 1.1 отклонён, rate-limit 503 |
|
||||
| 8 | Высоко | Инъекция в `server.conf` через настройки (админ-токен → root) | валидация настроек в 3 слоя (раздел 7) | 17 вредных значений → 422, обход схемы блокируется рендером |
|
||||
| 9 | Высоко | Сервисы API работали от root | пользователь `ovpmon` + root-помощник (раздел 8) | процессы под `ovpmon`, инъекции директив отклоняются |
|
||||
| 10 | Низко | Стандартное имя `admin`, встроенный `admin/password` | смена имени в приложении, дефолтная учётка удалена (раздел 9) | логин `tstark` 200, `admin` 401 |
|
||||
| 11 | Функц. | `crl_verify` не работал бы без root (`nobody` не читает `pki/`) | публикация CRL в `/etc/openvpn/crl.pem` (раздел 8) | клиент проходит, отозванный отклоняется |
|
||||
|
||||
Проверено без замечаний: алгоритм JWT зафиксирован HS256, секрет — 32 случайных байта; SQL f-строки используют только внутренние имена таблиц/колонок; nginx работает под непривилегированным пользователем; порты 1194/udp и 443/udp открыты по назначению со своей аутентификацией.
|
||||
|
||||
## 7. Валидация настроек Profiler (п.1 плана)
|
||||
|
||||
Слои: **A** — схемы `SystemSettingsUpdate` / `PKISettingUpdate` (порты, подсеть+маска, маршруты, DNS, `public_ip`, management только loopback, MTU/MSS, пути скриптов, поля DN и FQDN для PKI, размер ключа, сроки); **B** — скрипты подключения только из `/etc/openvpn/scripts/` (root-владелец, без записи group/other, без симлинков наружу); **C** — генератор отказывается писать `server.conf`/`.ovpn`, если значение содержит `\n`, `\r`, NUL, управляющие символы, `"` или `\`.
|
||||
Результат: 17 вредоносных значений → 422; 4 валидных изменения → 200; инъекции мимо схемы блокируются; рендер текущих настроек идентичен рабочему конфигу. Каталога `/etc/openvpn/scripts/` по умолчанию нет — создать `root:root 755`, файлы `755`, перед включением скриптов. Артефакт: `IMPLEMENTATION-01-settings-validation.md`.
|
||||
|
||||
## 8. API не от root: пользователь `ovpmon`, root-помощник, CRL (п.2 плана)
|
||||
|
||||
```
|
||||
ovpmon-api / gatherer / profiler (user ovpmon)
|
||||
└─ doas -n /usr/local/sbin/ovpmon-helper <фиксированные аргументы> (6 точных правил)
|
||||
├─ install-config — проверка staged server.conf по allowlist директив, атомарная установка в /etc/openvpn
|
||||
├─ publish-crl — копия pki/crl.pem → /etc/openvpn/crl.pem (root:root 644)
|
||||
└─ service start|stop|restart|status — rc-service openvpn
|
||||
```
|
||||
|
||||
- `ovpmon` владеет `/var/lib/ovpmon`, `/var/log/ovpmon`, `APP_PROFILER/{easy-rsa,client-config,profiler.log}`, логами и `__pycache__`; код и venv — root, только чтение. От root остались лишь supervise-daemon.
|
||||
- Allowlist `install-config`: только директивы шаблона с проверенными аргументами (`dev tun`, `proto`, `port`, пути PKI, `server`, `log`, `status`, `push` трёх видов, `user nobody`, `group nogroup`, шифры, `client-connect/disconnect` только из `/etc/openvpn/scripts/` и т.д.); `up`, `down`, `plugin`, `route-up`, `tls-verify`, `setenv`, `config` и всё неизвестное отклоняются; файл читается один раз, только обычный файл `ovpmon`, ASCII, ≤ 64 КиБ.
|
||||
- CRL: помощник копирует CRL после `gen-crl` (init/revoke), при `server/configure` и при каждом `start|restart`; для `crl-verify` разрешён только путь `/etc/openvpn/crl.pem`; ошибка публикации при отзыве возвращается ошибкой API.
|
||||
- Проверено: 17 инъекций директив, отсутствие `user nobody`, cert вне PKI, CR, симлинк — всё отклонено, боевой конфиг не менялся; `doas` отказывает на любую иную команду; `ovpmon` не читает `/etc/shadow`, `/root`, `/etc/hysteria/*.yaml`, `/etc/ovpmon/env`, не пишет в `/etc/openvpn`, `/etc/init.d`, `authorized_keys`, код, не запускает `iptables`; API (мониторинг, конфиг, `server/configure`, создание/скачивание/отзыв профиля, рестарт OpenVPN) работает; CRL проверен на отдельном экземпляре OpenVPN от `nobody` (валидный клиент подключается, отозванный получает `certificate revoked`).
|
||||
- Артефакт и откат: `IMPLEMENTATION-02-privilege-separation.md`.
|
||||
|
||||
## 9. Смена имени администратора
|
||||
|
||||
`POST /api/auth/change-username` (JWT + текущий пароль + OTP при 2FA; маска `^[A-Za-z][A-Za-z0-9_.-]{2,31}$`, запрещены `admin, administrator, root, user, test, guest`, уникальность без учёта регистра → 409, неверный пароль/OTP учитываются rate-limit); кнопка «Change Username» в Account; в 2FA-URI подставляется реальное имя; `ensure_default_admin` больше не создаёт `admin/password` (только из `OVPMON_INITIAL_ADMIN_*`); баг UI «в шапке всегда Admin» исправлен (синхронизация с `/user/me`, событие `ovpmon-user-changed`). JWT хранит `user_id`, поэтому смена имени сессии не рвёт. Бэкап БД: `/root/openvpn_monitor.db.bak-2026-09-30`.
|
||||
Ручной тест владельца в UI (смена пароля, включение 2FA) пройден.
|
||||
|
||||
## 10. Оценка вероятности получения root (актуальная)
|
||||
|
||||
**Без учётных данных, извне — низкая.** SSH только по ключам + fail2ban; панель за TLS, rate-limit и fail2ban; все API за JWT; OpenVPN — mTLS + `tls-auth`; Hysteria — пароль на пользователя; служебные порты на loopback. Остаточный риск: неизвестные уязвимости OpenVPN/nginx/sshd/hysteria/ядра и отставание пакетов (актуальность индекса `apk` и ядра не проверялась).
|
||||
|
||||
**С учётными данными панели (пароль + 2FA / токен).** Раньше это давало root (через инъекцию в `server.conf` от root-процесса). Теперь: валидация настроек (слои A–C), API работает от `ovpmon`, а помощник не пропустит опасные директивы. Остаточные пути:
|
||||
1. уязвимость в `ovpmon-helper`, `doas` или ядре;
|
||||
2. включение скриптов подключения (исполняются от `nobody`, берутся только из root-каталога `/etc/openvpn/scripts/`);
|
||||
3. `ovpmon` владеет PKI, включая ключ CA: компрометация API позволяет выпускать клиентские сертификаты (доступ к VPN), но не даёт root. Проверки путей PKI в помощнике выполняются на момент установки (теоретическое окно TOCTOU с подменой файла на симлинк до старта OpenVPN — влияет на работоспособность, не на выполнение кода).
|
||||
|
||||
Итог: цепочка «интернет → root» сводится к неизвестной уязвимости (0-day) в привилегированных компонентах; компрометация админа панели больше не эквивалентна root.
|
||||
|
||||
## 11. Репозиторий, резервные копии, откат
|
||||
|
||||
Репозиторий приложения на узле (`/opt/OpenVPN-Monitoring-Simple`, ветка `main`), коммиты **не запушены** (автор `iclaoudezin`, пуш за пользователем):
|
||||
|
||||
| Коммит | Содержание |
|
||||
|---|---|
|
||||
| `11c1b63` | смена имени, 2FA-исправления, валидация `username`, CORS, UI |
|
||||
| `9b2882d` | README и документация (Docker/native, Changes) |
|
||||
| `5de0501` | docker-compose: обязательный `JWT_SECRET`, seed админа, порты |
|
||||
| `05f44b9` | валидация настроек Profiler |
|
||||
| `6f9e800` | API не от root + root-помощник |
|
||||
| `9ffdbfa` | публикация CRL для `crl_verify` |
|
||||
| `e1146ed` | README приложения: раздел Security defaults, ссылки на `privilege-separation/` |
|
||||
|
||||
Не вошли в коммиты: `APP_CORE/config.ini` и `APP_UI/package-lock.json` (побочные правки приложения/npm), `venv/`, `easy-rsa/` и `client-config/` (приватные ключи — в `.gitignore` не добавлены, добавить отдельным коммитом).
|
||||
|
||||
Бэкапы на узле: `/root/backup-p2-2026-09-30-1229.tar` (`/etc/openvpn`, `/var/lib/ovpmon`, easy-rsa, init-скрипты, doas, код), `/root/openvpn_monitor.db.bak-2026-09-30`, `/root/ovpmon.conf.bak`, `/root/sshd_config.bak`, `/root/app-bak/` (файлы приложения по этапам, `README.md.orig`, `p1/ p2/ p3/`), `/var/www/ovpmon.bak`. Откат — в `IMPLEMENTATION-02-privilege-separation.md` и `DOCS/Changes/*`.
|
||||
|
||||
## 12. Открытые вопросы и рекомендации
|
||||
|
||||
- Пуш коммитов (учётные данные внешнего репозитория у пользователя).
|
||||
- Тест перезагрузки ENTRY после доработок **пройден** (2026-09-30): снимок состояния до/после идентичен, все сервисы, правила выхода, hardening и панель поднялись сами; VPN-клиент переподключился сам через ~3,5 мин (ping-restart UDP-клиента), трафик идёт через `hytun`, утечек нет. Подробности — `REBOOT-TEST.md`, раздел «ENTRY reboot after the OpenVPN Monitor deployment». Нюанс: права `root:ovpmon 640` на `openvpn-status.log` сохраняются, пока файл на диске; при его удалении OpenVPN создаст файл с 600 до ближайшего `start|restart` через помощник.
|
||||
- Самоподписанный сертификат: сверять отпечаток; при появлении домена — Let's Encrypt.
|
||||
- Ограничить панель по IP или пускать через VPN; сократить срок жизни JWT (сейчас 8 ч) и отзывать токены при смене пароля/имени.
|
||||
- Включить `crl_verify` в настройках, если нужна проверка отзыва (публикация CRL уже работает).
|
||||
- Создать `/etc/openvpn/scripts/` (`root:root 755`) перед использованием скриптов подключения.
|
||||
- В шаблоне клиентского `.ovpn` есть Windows-опция `windows-driver` — не подходит Linux-клиентам; при необходимости вынести в условие (шаблон не менялся).
|
||||
- Регулярно `apk upgrade`, следить за advisory OpenVPN/nginx/OpenSSH/hysteria.
|
||||
- В списке профилей остались записи `revoked` от тестов (`ok-user1`, `p2test-*`, `crl-e2e-*`).
|
||||
|
||||
## Журнал изменений (2026-09-30)
|
||||
|
||||
1. Развёртывание приложения без контейнеров (OpenRC, nginx :8088), инициализация PKI (фикс каталога `easy-rsa`).
|
||||
2. Выход клиентов OpenVPN через `hytun` → hel (`ovpn-hytun`), сквозной тест пройден.
|
||||
3. HTTPS на 8088, rate-limit, заголовки.
|
||||
4. Аудит безопасности; SSH key-only, fail2ban, валидация `username`, CORS.
|
||||
5. Смена имени администратора (данные + API + UI + без дефолтной учётки); исправление обхода 2FA и лога секретов; баг имени в шапке.
|
||||
6. Документация и коммиты (README, Docker/native, Changes), актуализация `docker-compose.yml`.
|
||||
7. Валидация настроек Profiler (п.1).
|
||||
8. API не от root, root-помощник (п.2), публикация CRL.
|
||||
9. Актуализация SUMMARY и README.
|
||||
10. Тест перезагрузки ENTRY: пройден (`REBOOT-TEST.md`).
|
||||
@@ -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,8 @@ 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)
|
||||
- Deployment records (state, results, reboot test, plans): [DOCS/Operations](DOCS/Operations/README.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 +58,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