#!/usr/bin/env bash set -euo pipefail SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]:-${0}}")" >/dev/null 2>&1 && pwd || echo ".")" DOWNLOADED_API_SOURCE="" cleanup() { if [[ -n "${DOWNLOADED_API_SOURCE}" && -f "${DOWNLOADED_API_SOURCE}" ]]; then rm -f "${DOWNLOADED_API_SOURCE}" fi } trap cleanup EXIT abort() { echo "Error: $*" >&2 exit 1 } need_root() { if [[ "${EUID}" -ne 0 ]]; then abort "Please run this installer as root." fi } need_ubuntu_2204() { if [[ ! -f /etc/os-release ]]; then abort "/etc/os-release is missing; cannot verify the operating system." fi # shellcheck disable=SC1091 source /etc/os-release if [[ "${ID:-}" != "ubuntu" || "${VERSION_ID:-}" != "22.04" ]]; then abort "This installer is written for Ubuntu 22.04 LTS." fi } need_fresh_install_target() { if [[ -e /etc/openvpn/server/server.conf || -d /etc/paymenter-openvpn-manager || -d /etc/openvpn/easy-rsa/pki || -d /opt/paymenter-openvpn-manager || -d /var/lib/paymenter-openvpn-manager ]]; then abort "Existing OpenVPN manager files were detected. Use a fresh host or clean the previous installation first." fi } need_local_assets() { DOWNLOADED_API_SOURCE="$(mktemp /tmp/paymenter-openvpn-manager-api.XXXXXX.py)" cat <<'EOF_PYTHON_API' > "${DOWNLOADED_API_SOURCE}" #!/usr/bin/env python3 import ipaddress import json import os import re import secrets import socket import sqlite3 import subprocess import sys import uuid from datetime import datetime, timezone from functools import wraps from pathlib import Path from flask import Flask, Response, jsonify, request APP_VERSION = "0.1.0" CONFIG_PATH = Path(os.environ.get("PAYMENTER_OPENVPN_CONFIG", "/etc/paymenter-openvpn-manager/config.json")) app = Flask(__name__) def ensure_permissions(path: Path, mode: int, directory: bool = False) -> None: if not path.exists(): return try: os.chmod(path, mode) except PermissionError: pass if directory: try: current_mode = path.stat().st_mode & 0o7777 except PermissionError: return if current_mode != mode: try: os.chmod(path, mode) except PermissionError: pass def utcnow_iso() -> str: return datetime.now(timezone.utc).replace(microsecond=0).isoformat() def resolve_path(value: str | None, *, base: Path | None = None) -> str | None: text = str(value or "").strip() if not text: return None candidate = Path(text) if candidate.is_absolute(): return str(candidate) if base is not None: return str((base / candidate).resolve()) return str(candidate.resolve()) def first_existing_path(candidates: list[str]) -> str | None: for candidate in candidates: if candidate and Path(candidate).exists(): return candidate return None def parse_server_config(path: Path) -> dict[str, list[list[str]]]: directives: dict[str, list[list[str]]] = {} if not path.exists(): return directives for raw_line in path.read_text(encoding="utf-8", errors="ignore").splitlines(): line = raw_line.strip() if not line or line.startswith("#") or line.startswith(";"): continue if "#" in line: line = line.split("#", 1)[0].strip() if ";" in line and not line.startswith('"'): line = line.split(";", 1)[0].strip() if not line: continue parts = line.split() if not parts: continue directives.setdefault(parts[0], []).append(parts[1:]) return directives def load_config() -> dict: if not CONFIG_PATH.exists(): raise RuntimeError(f"Missing configuration file: {CONFIG_PATH}") data = json.loads(CONFIG_PATH.read_text(encoding="utf-8")) if not isinstance(data, dict): raise RuntimeError("Configuration file must contain a JSON object") server_config_path = resolve_path( data.get("server_config_path") or first_existing_path( [ "/etc/openvpn/server/server.conf", "/etc/openvpn/server.conf", ] ) ) data["server_config_path"] = server_config_path server_config = parse_server_config(Path(server_config_path)) if server_config_path else {} server_dir = Path(server_config_path).parent if server_config_path else None management = server_config.get("management", []) if management: mgmt_parts = management[0] if len(mgmt_parts) >= 2 and mgmt_parts[1].lower() == "unix": data.setdefault("management_socket_path", resolve_path(mgmt_parts[0], base=server_dir)) elif len(mgmt_parts) >= 2: data.setdefault("management_host", mgmt_parts[0]) try: data.setdefault("management_port", int(mgmt_parts[1])) except ValueError: pass if "status_file" not in data: status = server_config.get("status", []) status_path = resolve_path(status[0][0], base=server_dir) if status and status[0] else None data["status_file"] = status_path or first_existing_path( [ "/var/log/openvpn/status.log", "/var/lib/paymenter-openvpn-manager/openvpn-status.log", ] ) or "/var/lib/paymenter-openvpn-manager/openvpn-status.log" if "ccd_dir" not in data: ccd = server_config.get("client-config-dir", []) ccd_path = resolve_path(ccd[0][0], base=server_dir) if ccd and ccd[0] else None data["ccd_dir"] = ccd_path or "/etc/openvpn/server/ccd" if "ca_path" not in data: ca = server_config.get("ca", []) data["ca_path"] = resolve_path(ca[0][0], base=server_dir) if ca and ca[0] else "/etc/openvpn/server/ca.crt" if "crl_path" not in data: crl = server_config.get("crl-verify", []) data["crl_path"] = resolve_path(crl[0][0], base=server_dir) if crl and crl[0] else "/etc/openvpn/server/crl.pem" tls_crypt_v2 = server_config.get("tls-crypt-v2", []) tls_crypt = server_config.get("tls-crypt", []) if "tls_crypt_mode" not in data: if tls_crypt_v2: data["tls_crypt_mode"] = "tls-crypt-v2" else: data["tls_crypt_mode"] = "tls-crypt" mode = str(data.get("tls_crypt_mode", "tls-crypt")).strip().lower() if mode == "tls-crypt-v2": if "tls_crypt_v2_server_key_path" not in data: data["tls_crypt_v2_server_key_path"] = ( resolve_path(tls_crypt_v2[0][0], base=server_dir) if tls_crypt_v2 and tls_crypt_v2[0] else first_existing_path(["/etc/openvpn/server/tls-crypt-v2.key"]) ) else: if "tls_crypt_path" not in data: data["tls_crypt_path"] = ( resolve_path(tls_crypt[0][0], base=server_dir) if tls_crypt and tls_crypt[0] else first_existing_path(["/etc/openvpn/server/tls-crypt.key"]) ) if "easyrsa_dir" not in data: data["easyrsa_dir"] = first_existing_path( [ "/etc/openvpn/server/easy-rsa", "/etc/openvpn/easy-rsa", ] ) or "/etc/openvpn/server/easy-rsa" easyrsa_dir = str(data["easyrsa_dir"]) data.setdefault("database_path", "/var/lib/paymenter-openvpn-manager/manager.db") data.setdefault("cert_path_template", str(Path(easyrsa_dir) / "pki/issued/{common_name}.crt")) data.setdefault("key_path_template", str(Path(easyrsa_dir) / "pki/private/{common_name}.key")) data.setdefault("tls_crypt_v2_client_key_dir", "/var/lib/paymenter-openvpn-manager/tls-crypt-v2") return data def db_path() -> Path: cfg = load_config() return Path(cfg.get("database_path", "/var/lib/paymenter-openvpn-manager/manager.db")) def connect_db() -> sqlite3.Connection: path = db_path() path.parent.mkdir(parents=True, exist_ok=True) ensure_permissions(path.parent, 0o2775, directory=True) conn = sqlite3.connect(path) conn.row_factory = sqlite3.Row ensure_permissions(path, 0o664) return conn def init_db() -> None: with connect_db() as conn: conn.executescript( """ CREATE TABLE IF NOT EXISTS clients ( id TEXT PRIMARY KEY, service_ref TEXT NOT NULL, profile_slug TEXT NOT NULL, common_name TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL, download_name TEXT NOT NULL, dns_servers_json TEXT NOT NULL DEFAULT '[]', redirect_gateway INTEGER NOT NULL DEFAULT 1, route_networks_json TEXT NOT NULL DEFAULT '[]', extra_pushes_json TEXT NOT NULL DEFAULT '[]', disabled INTEGER NOT NULL DEFAULT 0, disabled_reason TEXT NOT NULL DEFAULT '', revoked INTEGER NOT NULL DEFAULT 0, total_bytes_received INTEGER NOT NULL DEFAULT 0, total_bytes_sent INTEGER NOT NULL DEFAULT 0, last_connected_at TEXT, last_disconnected_at TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE UNIQUE INDEX IF NOT EXISTS idx_clients_service_slug ON clients(service_ref, profile_slug); """ ) def json_list(value) -> list[str]: if isinstance(value, list): result = [] for item in value: text = str(item).strip() if text: result.append(text) return result return [] def bool_from_value(value, default=False) -> bool: if isinstance(value, bool): return value if isinstance(value, (int, float)): return int(value) != 0 if isinstance(value, str): normalized = value.strip().lower() if normalized in {"1", "true", "yes", "on"}: return True if normalized in {"0", "false", "no", "off"}: return False return default def slugify(value: str, fallback: str = "profile") -> str: text = re.sub(r"[^a-zA-Z0-9._-]+", "-", value.strip().lower()) text = text.strip("-_.") return text or fallback def cert_path(common_name: str) -> Path: cfg = load_config() template = str(cfg["cert_path_template"]) return Path(template.format(common_name=common_name)) def key_path(common_name: str) -> Path: cfg = load_config() template = str(cfg["key_path_template"]) return Path(template.format(common_name=common_name)) def tls_crypt_mode(cfg: dict | None = None) -> str: config = cfg or load_config() return str(config.get("tls_crypt_mode", "tls-crypt")).strip().lower() def tls_crypt_v2_client_key_path(common_name: str) -> Path: cfg = load_config() base_dir = Path(str(cfg.get("tls_crypt_v2_client_key_dir", "/var/lib/paymenter-openvpn-manager/tls-crypt-v2"))) return base_dir / f"{common_name}.key" def ccd_path(common_name: str) -> Path: cfg = load_config() return Path(cfg["ccd_dir"]) / common_name def load_client_by_id(client_id: str): with connect_db() as conn: return conn.execute("SELECT * FROM clients WHERE id = ?", (client_id,)).fetchone() def load_client_by_service_slug(service_ref: str, profile_slug: str): with connect_db() as conn: return conn.execute( "SELECT * FROM clients WHERE service_ref = ? AND profile_slug = ?", (service_ref, profile_slug), ).fetchone() def save_client(row: dict) -> None: now = utcnow_iso() row["updated_at"] = now if "created_at" not in row: row["created_at"] = now with connect_db() as conn: conn.execute( """ INSERT INTO clients ( id, service_ref, profile_slug, common_name, display_name, download_name, dns_servers_json, redirect_gateway, route_networks_json, extra_pushes_json, disabled, disabled_reason, revoked, total_bytes_received, total_bytes_sent, last_connected_at, last_disconnected_at, created_at, updated_at ) VALUES ( :id, :service_ref, :profile_slug, :common_name, :display_name, :download_name, :dns_servers_json, :redirect_gateway, :route_networks_json, :extra_pushes_json, :disabled, :disabled_reason, :revoked, :total_bytes_received, :total_bytes_sent, :last_connected_at, :last_disconnected_at, :created_at, :updated_at ) ON CONFLICT(id) DO UPDATE SET service_ref = excluded.service_ref, profile_slug = excluded.profile_slug, common_name = excluded.common_name, display_name = excluded.display_name, download_name = excluded.download_name, dns_servers_json = excluded.dns_servers_json, redirect_gateway = excluded.redirect_gateway, route_networks_json = excluded.route_networks_json, extra_pushes_json = excluded.extra_pushes_json, disabled = excluded.disabled, disabled_reason = excluded.disabled_reason, revoked = excluded.revoked, total_bytes_received = excluded.total_bytes_received, total_bytes_sent = excluded.total_bytes_sent, last_connected_at = excluded.last_connected_at, last_disconnected_at = excluded.last_disconnected_at, updated_at = excluded.updated_at """, row, ) def delete_client_record(client_id: str) -> None: with connect_db() as conn: conn.execute("DELETE FROM clients WHERE id = ?", (client_id,)) def row_to_dict(row: sqlite3.Row) -> dict: data = dict(row) data["dns_servers"] = json_list(json.loads(data.get("dns_servers_json") or "[]")) data["route_networks"] = json_list(json.loads(data.get("route_networks_json") or "[]")) data["extra_pushes"] = json_list(json.loads(data.get("extra_pushes_json") or "[]")) data["redirect_gateway"] = bool(data.get("redirect_gateway")) data["disabled"] = bool(data.get("disabled")) data["revoked"] = bool(data.get("revoked")) data.pop("dns_servers_json", None) data.pop("route_networks_json", None) data.pop("extra_pushes_json", None) return data def ensure_auth(): cfg = load_config() expected = str(cfg.get("api_token", "")).strip() provided = request.headers.get("Authorization", "") token = "" if provided.lower().startswith("bearer "): token = provided[7:].strip() if expected == "" or token != expected: return jsonify({"error": "unauthorized"}), 401 return None def require_auth(fn): @wraps(fn) def wrapper(*args, **kwargs): auth_result = ensure_auth() if auth_result is not None: return auth_result return fn(*args, **kwargs) return wrapper def run_command(args, cwd=None, env=None): completed = subprocess.run( args, cwd=cwd, env=env, text=True, capture_output=True, check=False, ) if completed.returncode != 0: raise RuntimeError((completed.stderr or completed.stdout or "command failed").strip()) return completed def easyrsa_env() -> dict: env = os.environ.copy() env["EASYRSA_BATCH"] = "1" return env def ensure_client_certificate(common_name: str) -> None: cfg = load_config() cert = cert_path(common_name) key = key_path(common_name) mode = tls_crypt_mode(cfg) client_key = tls_crypt_v2_client_key_path(common_name) if mode == "tls-crypt-v2" else None if cert.exists() and key.exists() and (client_key is None or client_key.exists()): return if not cert.exists() or not key.exists(): run_command( ["./easyrsa", "build-client-full", common_name, "nopass"], cwd=cfg["easyrsa_dir"], env=easyrsa_env(), ) if mode == "tls-crypt-v2": server_key_path = str(cfg.get("tls_crypt_v2_server_key_path", "")).strip() if not server_key_path: raise RuntimeError("tls_crypt_v2_server_key_path is required when tls_crypt_mode is tls-crypt-v2") assert client_key is not None client_key.parent.mkdir(parents=True, exist_ok=True) if not client_key.exists(): run_command( [ "openvpn", "--tls-crypt-v2", server_key_path, "--genkey", "tls-crypt-v2-client", str(client_key), ] ) os.chmod(client_key, 0o640) def revoke_client_certificate(common_name: str) -> None: cfg = load_config() easyrsa_dir = cfg["easyrsa_dir"] env = easyrsa_env() try: run_command(["./easyrsa", "revoke", common_name], cwd=easyrsa_dir, env=env) except RuntimeError as exc: message = str(exc).lower() if "already revoked" not in message and "unable to revoke as the input file does not exist" not in message: raise run_command(["./easyrsa", "gen-crl"], cwd=easyrsa_dir, env=env) source_crl = Path(easyrsa_dir) / "pki" / "crl.pem" target_crl = Path(cfg["crl_path"]) target_crl.write_bytes(source_crl.read_bytes()) os.chmod(target_crl, 0o644) client_key = tls_crypt_v2_client_key_path(common_name) if client_key.exists(): client_key.unlink() def normalize_push_directive(raw: str) -> str | None: text = raw.strip() if not text: return None if text.startswith("push "): return text if text.startswith('"') and text.endswith('"'): return f"push {text}" return f'push "{text}"' def write_ccd(common_name: str, dns_servers: list[str], redirect_gateway: bool, route_networks: list[str], extra_pushes: list[str]) -> None: lines = [] if redirect_gateway: lines.append('push "redirect-gateway def1 bypass-dhcp"') for dns in dns_servers: lines.append(f'push "dhcp-option DNS {dns}"') for cidr in route_networks: network = ipaddress.ip_network(cidr, strict=False) lines.append(f'push "route {network.network_address} {network.netmask}"') for push in extra_pushes: normalized = normalize_push_directive(push) if normalized: lines.append(normalized) target = ccd_path(common_name) target.parent.mkdir(parents=True, exist_ok=True) ensure_permissions(target.parent, 0o755, directory=True) target.write_text("\n".join(lines) + "\n", encoding="utf-8") ensure_permissions(target, 0o644) def row_dns_servers(row: dict, cfg: dict) -> list[str]: dns_servers = json_list(row.get("dns_servers")) if dns_servers: return dns_servers return json_list(cfg.get("default_dns_servers")) def row_route_networks(row: dict) -> list[str]: return json_list(row.get("route_networks")) def row_redirect_gateway(row: dict, cfg: dict) -> bool: if "redirect_gateway" in row: return bool_from_value(row.get("redirect_gateway"), True) return bool_from_value(cfg.get("default_redirect_gateway"), True) def build_client_config(common_name: str, row: dict) -> str: cfg = load_config() ca_text = Path(cfg["ca_path"]).read_text(encoding="utf-8").strip() cert_text = cert_path(common_name).read_text(encoding="utf-8").strip() key_text = key_path(common_name).read_text(encoding="utf-8").strip() control_mode = tls_crypt_mode(cfg) tls_crypt_text = "" tls_directive = None tls_inline_open = None tls_inline_close = None if control_mode == "tls-crypt-v2": client_key_path = tls_crypt_v2_client_key_path(common_name) tls_crypt_text = client_key_path.read_text(encoding="utf-8").strip() tls_directive = "tls-crypt-v2 [inline]" tls_inline_open = "" tls_inline_close = "" else: tls_crypt_text = Path(cfg["tls_crypt_path"]).read_text(encoding="utf-8").strip() tls_directive = "tls-crypt [inline]" tls_inline_open = "" tls_inline_close = "" redirect_gateway = row_redirect_gateway(row, cfg) dns_servers = row_dns_servers(row, cfg) route_networks = row_route_networks(row) lines = [ "client", "dev tun", f"proto {cfg['protocol']}", f"remote {cfg['public_host']} {cfg['port']}", "nobind", "persist-key", "persist-tun", "pull", "route-delay 5", "resolv-retry infinite", "remote-cert-tls server", "auth-nocache", "verb 3", f"cipher {cfg['cipher']}", f"data-ciphers {cfg['data_ciphers']}", f"auth {cfg['auth']}", ] if redirect_gateway: lines.append("redirect-gateway def1 bypass-dhcp") for dns in dns_servers: lines.append(f"dhcp-option DNS {dns}") for cidr in route_networks: network = ipaddress.ip_network(cidr, strict=False) lines.append(f"route {network.network_address} {network.netmask}") lines.extend( [ "", "", ca_text, "", "", cert_text, "", "", key_text, "", tls_inline_open, tls_crypt_text, tls_inline_close, "", ] ) return "\n".join(lines) def management_command(command: str) -> str: cfg = load_config() management_socket_path = str(cfg.get("management_socket_path", "")).strip() if management_socket_path: sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) sock.settimeout(5) sock.connect(management_socket_path) else: host = cfg.get("management_host", "127.0.0.1") port = int(cfg.get("management_port", 7505)) sock = socket.create_connection((host, port), timeout=5) with sock: sock.settimeout(5) sock.sendall((command.strip() + "\nquit\n").encode("utf-8")) chunks = [] while True: try: chunk = sock.recv(4096) except TimeoutError: break if not chunk: break chunks.append(chunk) return b"".join(chunks).decode("utf-8", errors="ignore") def disconnect_active_client(common_name: str) -> None: try: management_command(f"kill {common_name}") except OSError: pass def parse_status_text(status_text: str) -> dict[str, dict]: if not status_text.strip(): return {} headers = {} sessions = {} for raw_line in status_text.splitlines(): line = raw_line.strip() if not line: continue parts = line.split("\t") if "\t" in line else line.split(",") if len(parts) < 2: continue if parts[0] == "HEADER": headers[parts[1]] = parts[2:] continue if parts[0] != "CLIENT_LIST": continue columns = headers.get("CLIENT_LIST", []) values = parts[1:] row = {} for idx, column in enumerate(columns): row[column] = values[idx] if idx < len(values) else "" common_name = str(row.get("Common Name", "") or row.get("Common name", "")).strip() if not common_name: continue current = sessions.setdefault( common_name, { "connected": True, "bytes_received": 0, "bytes_sent": 0, "connected_since": None, "remote_address": None, "virtual_address": None, }, ) try: current["bytes_received"] += int(row.get("Bytes Received", "0") or 0) except ValueError: pass try: current["bytes_sent"] += int(row.get("Bytes Sent", "0") or 0) except ValueError: pass connected_since = row.get("Connected Since") if connected_since: current["connected_since"] = connected_since remote_address = row.get("Real Address") or row.get("Real address") if remote_address: current["remote_address"] = remote_address virtual_address = ( row.get("Virtual Address") or row.get("Virtual IPv4 Address") or row.get("Virtual IPv6 Address") or row.get("Virtual address") ) if virtual_address: current["virtual_address"] = virtual_address return sessions def parse_status() -> dict[str, dict]: try: management_status = management_command("status 3") sessions = parse_status_text(management_status) if sessions: return sessions except OSError: pass except Exception: pass cfg = load_config() status_file = Path(cfg.get("status_file", "/var/lib/paymenter-openvpn-manager/openvpn-status.log")) if not status_file.exists(): return {} return parse_status_text(status_file.read_text(encoding="utf-8", errors="ignore")) def usage_payload(row: sqlite3.Row) -> dict: data = row_to_dict(row) live_sessions = parse_status() live = live_sessions.get(data["common_name"], {}) download_bytes = int(data["total_bytes_sent"] or 0) + int(live.get("bytes_sent") or 0) upload_bytes = int(data["total_bytes_received"] or 0) + int(live.get("bytes_received") or 0) last_connected_at = data.get("last_connected_at") last_disconnected_at = data.get("last_disconnected_at") optimistic_connected = False if not live and last_connected_at: try: connected_dt = datetime.fromisoformat(str(last_connected_at).replace("Z", "+00:00")) disconnected_dt = None if last_disconnected_at: disconnected_dt = datetime.fromisoformat(str(last_disconnected_at).replace("Z", "+00:00")) if disconnected_dt is None or connected_dt > disconnected_dt: optimistic_connected = True except ValueError: optimistic_connected = False return { "id": data["id"], "service_ref": data["service_ref"], "profile_slug": data["profile_slug"], "display_name": data["display_name"], "common_name": data["common_name"], "download_name": data["download_name"], "download_bytes": download_bytes, "upload_bytes": upload_bytes, "connected": bool(live) or optimistic_connected, "connected_since": live.get("connected_since") or last_connected_at, "remote_address": live.get("remote_address"), "virtual_address": live.get("virtual_address"), "disabled": data["disabled"], "disabled_reason": data["disabled_reason"] or None, "revoked": data["revoked"], } def validate_client_payload(payload: dict) -> dict: service_ref = str(payload.get("service_ref", "")).strip() display_name = str(payload.get("display_name", "")).strip() profile_slug = slugify(str(payload.get("profile_slug", "")).strip() or display_name or service_ref) dns_servers = json_list(payload.get("dns_servers")) route_networks = json_list(payload.get("route_networks")) extra_pushes = json_list(payload.get("extra_pushes")) redirect_gateway = bool_from_value(payload.get("redirect_gateway"), True) if not service_ref: raise ValueError("service_ref is required") if not display_name: raise ValueError("display_name is required") for cidr in route_networks: ipaddress.ip_network(cidr, strict=False) return { "service_ref": service_ref, "display_name": display_name, "profile_slug": profile_slug, "dns_servers": dns_servers, "route_networks": route_networks, "extra_pushes": extra_pushes, "redirect_gateway": redirect_gateway, } def make_common_name(service_ref: str, profile_slug: str) -> str: base = slugify(service_ref, "service")[:12] slug = slugify(profile_slug, "profile")[:16] suffix = secrets.token_hex(3) common_name = f"pmt-{base}-{slug}-{suffix}" return common_name[:63] def provision_or_update_client(payload: dict) -> dict: validated = validate_client_payload(payload) existing = load_client_by_service_slug(validated["service_ref"], validated["profile_slug"]) if existing: row = row_to_dict(existing) if row["revoked"]: raise RuntimeError("existing profile was revoked and cannot be reused") row["display_name"] = validated["display_name"] row["dns_servers"] = validated["dns_servers"] row["route_networks"] = validated["route_networks"] row["extra_pushes"] = validated["extra_pushes"] row["redirect_gateway"] = validated["redirect_gateway"] row["download_name"] = row["download_name"] or f"{row['profile_slug']}.ovpn" row["dns_servers_json"] = json.dumps(row["dns_servers"]) row["route_networks_json"] = json.dumps(row["route_networks"]) row["extra_pushes_json"] = json.dumps(row["extra_pushes"]) row["redirect_gateway"] = 1 if row["redirect_gateway"] else 0 save_client(row) write_ccd(row["common_name"], validated["dns_servers"], validated["redirect_gateway"], validated["route_networks"], validated["extra_pushes"]) config = build_client_config(row["common_name"], row) return { "id": row["id"], "common_name": row["common_name"], "display_name": row["display_name"], "profile_slug": row["profile_slug"], "download_name": row["download_name"], "config": config, } common_name = make_common_name(validated["service_ref"], validated["profile_slug"]) client_id = str(uuid.uuid4()) ensure_client_certificate(common_name) write_ccd(common_name, validated["dns_servers"], validated["redirect_gateway"], validated["route_networks"], validated["extra_pushes"]) row = { "id": client_id, "service_ref": validated["service_ref"], "profile_slug": validated["profile_slug"], "common_name": common_name, "display_name": validated["display_name"], "download_name": f"{validated['profile_slug']}.ovpn", "dns_servers_json": json.dumps(validated["dns_servers"]), "redirect_gateway": 1 if validated["redirect_gateway"] else 0, "route_networks_json": json.dumps(validated["route_networks"]), "extra_pushes_json": json.dumps(validated["extra_pushes"]), "disabled": 0, "disabled_reason": "", "revoked": 0, "total_bytes_received": 0, "total_bytes_sent": 0, "last_connected_at": None, "last_disconnected_at": None, } save_client(row) config = build_client_config(common_name, row) return { "id": client_id, "common_name": common_name, "display_name": validated["display_name"], "profile_slug": validated["profile_slug"], "download_name": f"{validated['profile_slug']}.ovpn", "config": config, } def mark_connected(common_name: str) -> bool: with connect_db() as conn: row = conn.execute("SELECT * FROM clients WHERE common_name = ?", (common_name,)).fetchone() if not row: return True disabled = bool(row["disabled"]) revoked = bool(row["revoked"]) if disabled or revoked: return False conn.execute( "UPDATE clients SET last_connected_at = ?, updated_at = ? WHERE common_name = ?", (utcnow_iso(), utcnow_iso(), common_name), ) return True def mark_disconnected(common_name: str, bytes_received: int, bytes_sent: int) -> None: with connect_db() as conn: row = conn.execute("SELECT * FROM clients WHERE common_name = ?", (common_name,)).fetchone() if not row: return conn.execute( """ UPDATE clients SET total_bytes_received = total_bytes_received + ?, total_bytes_sent = total_bytes_sent + ?, last_disconnected_at = ?, updated_at = ? WHERE common_name = ? """, ( max(0, int(bytes_received)), max(0, int(bytes_sent)), utcnow_iso(), utcnow_iso(), common_name, ), ) @app.get("/api/v1/health") @require_auth def health(): cfg = load_config() return jsonify( { "ok": True, "version": APP_VERSION, "public_host": cfg.get("public_host"), "protocol": cfg.get("protocol"), "port": cfg.get("port"), } ) @app.get("/api/v1/server") @require_auth def server(): cfg = load_config() endpoint = f"{cfg.get('public_host')}:{cfg.get('port')} / {str(cfg.get('protocol', '')).upper()}" return jsonify( { "public_host": cfg.get("public_host"), "protocol": cfg.get("protocol"), "port": cfg.get("port"), "public_endpoint": endpoint, "default_dns_servers": json_list(cfg.get("default_dns_servers")), "default_redirect_gateway": bool_from_value(cfg.get("default_redirect_gateway"), True), } ) @app.post("/api/v1/clients") @require_auth def create_client(): try: payload = request.get_json(silent=True) or {} result = provision_or_update_client(payload) return jsonify(result) except ValueError as exc: return jsonify({"error": str(exc)}), 422 except Exception as exc: return jsonify({"error": str(exc)}), 500 @app.get("/api/v1/clients/") @require_auth def get_client(client_id: str): row = load_client_by_id(client_id) if not row: return jsonify({"error": "not_found"}), 404 return jsonify(row_to_dict(row)) @app.get("/api/v1/clients//usage") @require_auth def get_client_usage(client_id: str): row = load_client_by_id(client_id) if not row: return jsonify({"error": "not_found"}), 404 return jsonify(usage_payload(row)) @app.get("/api/v1/clients//config") @require_auth def get_client_config(client_id: str): row = load_client_by_id(client_id) if not row: return jsonify({"error": "not_found"}), 404 data = row_to_dict(row) config = build_client_config(data["common_name"], data) return Response(config, mimetype="text/plain") @app.post("/api/v1/clients//disable") @require_auth def disable_client(client_id: str): row = load_client_by_id(client_id) if not row: return jsonify({"error": "not_found"}), 404 reason = str((request.get_json(silent=True) or {}).get("reason", "")).strip() with connect_db() as conn: conn.execute( "UPDATE clients SET disabled = 1, disabled_reason = ?, updated_at = ? WHERE id = ?", (reason, utcnow_iso(), client_id), ) disconnect_active_client(row["common_name"]) updated = load_client_by_id(client_id) return jsonify(usage_payload(updated)) @app.post("/api/v1/clients//enable") @require_auth def enable_client(client_id: str): row = load_client_by_id(client_id) if not row: return jsonify({"error": "not_found"}), 404 with connect_db() as conn: conn.execute( "UPDATE clients SET disabled = 0, disabled_reason = '', updated_at = ? WHERE id = ?", (utcnow_iso(), client_id), ) updated = load_client_by_id(client_id) return jsonify(usage_payload(updated)) @app.delete("/api/v1/clients/") @require_auth def delete_client(client_id: str): row = load_client_by_id(client_id) if not row: return jsonify({"ok": True}) data = row_to_dict(row) disconnect_active_client(data["common_name"]) revoke_client_certificate(data["common_name"]) ccd = ccd_path(data["common_name"]) if ccd.exists(): ccd.unlink() delete_client_record(client_id) return jsonify({"ok": True}) def main() -> int: init_db() if len(sys.argv) >= 2 and sys.argv[1] == "init-db": return 0 if len(sys.argv) >= 3 and sys.argv[1] == "connect-check": common_name = sys.argv[2].strip() return 0 if mark_connected(common_name) else 1 if len(sys.argv) >= 5 and sys.argv[1] == "disconnect": common_name = sys.argv[2].strip() bytes_received = int(float(sys.argv[3] or 0)) bytes_sent = int(float(sys.argv[4] or 0)) mark_disconnected(common_name, bytes_received, bytes_sent) return 0 if len(sys.argv) >= 2 and sys.argv[1] == "serve": cfg = load_config() host = cfg.get("bind_host", "127.0.0.1") port = int(cfg.get("bind_port", 9081)) app.run(host=host, port=port) return 0 print("Usage: openvpn_manager_api.py [init-db|connect-check |disconnect |serve]", file=sys.stderr) return 1 init_db() if __name__ == "__main__": raise SystemExit(main()) EOF_PYTHON_API python3 -m py_compile "${DOWNLOADED_API_SOURCE}" >/dev/null 2>&1 || \ abort "Bundled companion API file is not valid Python." API_SOURCE="${DOWNLOADED_API_SOURCE}" echo "Extracted companion API file." } ask_default() { local prompt="$1" local default_value="$2" local reply read -r -p "${prompt} [${default_value}]: " reply if [[ -z "${reply}" ]]; then printf '%s' "${default_value}" else printf '%s' "${reply}" fi } ask_required() { local prompt="$1" local reply="" while [[ -z "${reply}" ]]; do read -r -p "${prompt}: " reply done printf '%s' "${reply}" } ask_yes_no() { local prompt="$1" local default_value="$2" local reply local normalized_default normalized_default="$(printf '%s' "${default_value}" | tr '[:upper:]' '[:lower:]')" while true; do if [[ "${normalized_default}" == "y" ]]; then read -r -p "${prompt} [Y/n]: " reply reply="${reply:-Y}" else read -r -p "${prompt} [y/N]: " reply reply="${reply:-N}" fi reply="$(printf '%s' "${reply}" | tr '[:upper:]' '[:lower:]')" case "${reply}" in y|yes) printf 'yes'; return 0 ;; n|no) printf 'no'; return 0 ;; esac done } split_csv_to_json() { python3 - "$1" <<'PY' import json import re import sys raw = sys.argv[1] items = [part.strip() for part in re.split(r"[\r\n,]+", raw) if part.strip()] print(json.dumps(items)) PY } validate_cidr() { python3 - "$1" <<'PY' import ipaddress import sys ipaddress.ip_network(sys.argv[1], strict=False) PY } detect_public_nic() { ip route show default 2>/dev/null | awk '/default/ {print $5; exit}' } prepare_packages() { apt-get update DEBIAN_FRONTEND=noninteractive apt-get install -y \ openvpn \ easy-rsa \ nginx \ certbot \ python3-certbot-nginx \ python3-flask \ gunicorn \ python3 \ jq \ curl \ ca-certificates \ openssl \ ufw } write_easyrsa_vars() { cat > /etc/openvpn/easy-rsa/vars </dev/null ./easyrsa init-pki EASYRSA_BATCH=1 ./easyrsa build-ca nopass EASYRSA_BATCH=1 ./easyrsa build-server-full "${SERVER_COMMON_NAME}" nopass EASYRSA_BATCH=1 ./easyrsa gen-crl popd >/dev/null install -d -m 755 /etc/openvpn/server install -m 644 /etc/openvpn/easy-rsa/pki/ca.crt /etc/openvpn/server/ca.crt install -m 644 "/etc/openvpn/easy-rsa/pki/issued/${SERVER_COMMON_NAME}.crt" /etc/openvpn/server/server.crt install -m 600 "/etc/openvpn/easy-rsa/pki/private/${SERVER_COMMON_NAME}.key" /etc/openvpn/server/server.key install -m 644 /etc/openvpn/easy-rsa/pki/crl.pem /etc/openvpn/server/crl.pem openvpn --genkey secret /etc/openvpn/server/tls-crypt.key chmod 600 /etc/openvpn/server/tls-crypt.key } write_openvpn_server_config() { local explicit_exit="" if [[ "${OPENVPN_PROTOCOL}" == "udp" ]]; then explicit_exit="explicit-exit-notify 1" fi cat > /etc/openvpn/server/server.conf < /etc/paymenter-openvpn-manager/config.json < /opt/paymenter-openvpn-manager/client-connect.sh <<'EOF' #!/usr/bin/env bash set -euo pipefail exec /usr/bin/python3 /opt/paymenter-openvpn-manager/openvpn_manager_api.py connect-check "${common_name:-}" EOF chmod 755 /opt/paymenter-openvpn-manager/client-connect.sh cat > /opt/paymenter-openvpn-manager/client-disconnect.sh <<'EOF' #!/usr/bin/env bash set -euo pipefail exec /usr/bin/python3 /opt/paymenter-openvpn-manager/openvpn_manager_api.py disconnect "${common_name:-}" "${bytes_received:-0}" "${bytes_sent:-0}" EOF chmod 755 /opt/paymenter-openvpn-manager/client-disconnect.sh cat > /etc/systemd/system/paymenter-openvpn-api.service <<'EOF' [Unit] Description=Paymenter OpenVPN Manager API After=network.target openvpn-server@server.service Requires=openvpn-server@server.service [Service] Type=simple WorkingDirectory=/opt/paymenter-openvpn-manager ExecStartPre=/usr/bin/python3 /opt/paymenter-openvpn-manager/openvpn_manager_api.py init-db ExecStart=/usr/bin/gunicorn --workers 2 --threads 4 --bind 127.0.0.1:9081 --chdir /opt/paymenter-openvpn-manager openvpn_manager_api:app Restart=always RestartSec=3 [Install] WantedBy=multi-user.target EOF } initialize_manager_state() { /usr/bin/python3 /opt/paymenter-openvpn-manager/openvpn_manager_api.py init-db chmod 755 /etc/openvpn chmod 755 /etc/openvpn/server chown root:root /etc/openvpn/server/ccd chmod 755 /etc/openvpn/server/ccd find /etc/openvpn/server/ccd -maxdepth 1 -type f -exec chown root:root {} \; find /etc/openvpn/server/ccd -maxdepth 1 -type f -exec chmod 644 {} \; chown root:nogroup /var/lib/paymenter-openvpn-manager chmod 2775 /var/lib/paymenter-openvpn-manager chown root:nogroup /var/lib/paymenter-openvpn-manager/manager.db chmod 664 /var/lib/paymenter-openvpn-manager/manager.db chown root:nogroup /var/lib/paymenter-openvpn-manager/ipp.txt chmod 664 /var/lib/paymenter-openvpn-manager/ipp.txt chown root:nogroup /var/lib/paymenter-openvpn-manager/openvpn-status.log chmod 664 /var/lib/paymenter-openvpn-manager/openvpn-status.log if compgen -G "/var/lib/paymenter-openvpn-manager/manager.db*" > /dev/null; then chown root:nogroup /var/lib/paymenter-openvpn-manager/manager.db* chmod 664 /var/lib/paymenter-openvpn-manager/manager.db* fi if [[ -f /etc/openvpn/server/crl.pem ]]; then chown root:root /etc/openvpn/server/crl.pem chmod 644 /etc/openvpn/server/crl.pem fi chown root:root /etc/paymenter-openvpn-manager chmod 755 /etc/paymenter-openvpn-manager chown root:root /etc/paymenter-openvpn-manager/config.json chmod 644 /etc/paymenter-openvpn-manager/config.json } verify_installation_prereqs() { local required_paths=( /etc/paymenter-openvpn-manager/config.json /opt/paymenter-openvpn-manager/openvpn_manager_api.py /opt/paymenter-openvpn-manager/client-connect.sh /opt/paymenter-openvpn-manager/client-disconnect.sh /etc/openvpn/server/server.conf /etc/openvpn/server/ca.crt /etc/openvpn/server/server.crt /etc/openvpn/server/server.key /etc/openvpn/server/crl.pem /etc/openvpn/server/tls-crypt.key /var/lib/paymenter-openvpn-manager/manager.db ) local path="" for path in "${required_paths[@]}"; do [[ -e "${path}" ]] || abort "Required installation artifact is missing: ${path}" done runuser -u nobody -g nogroup -- test -r /etc/paymenter-openvpn-manager/config.json || \ abort "OpenVPN hook user cannot read /etc/paymenter-openvpn-manager/config.json" runuser -u nobody -g nogroup -- test -r /etc/openvpn/server/crl.pem || \ abort "OpenVPN hook user cannot read /etc/openvpn/server/crl.pem" runuser -u nobody -g nogroup -- test -x /etc/openvpn/server/ccd || \ abort "OpenVPN hook user cannot traverse /etc/openvpn/server/ccd" runuser -u nobody -g nogroup -- /usr/bin/python3 /opt/paymenter-openvpn-manager/openvpn_manager_api.py connect-check __installer_probe__ >/dev/null 2>&1 || \ abort "OpenVPN hook preflight failed; verify config and runtime permissions." } configure_sysctl() { cat > /etc/sysctl.d/99-paymenter-openvpn.conf </dev/null } configure_ufw() { local before_rules="/etc/ufw/before.rules" if ! grep -q "paymenter-openvpn-nat" "${before_rules}"; then cp "${before_rules}" "${before_rules}.bak.$(date +%s)" awk -v vpn_subnet="${VPN_SUBNET}" -v public_nic="${PUBLIC_NIC}" ' BEGIN { print "# START paymenter-openvpn-nat" print "*nat" print ":POSTROUTING ACCEPT [0:0]" print "-A POSTROUTING -s " vpn_subnet " -o " public_nic " -j MASQUERADE" print "COMMIT" print "# END paymenter-openvpn-nat" } { print } ' "${before_rules}" > "${before_rules}.new" mv "${before_rules}.new" "${before_rules}" fi sed -i 's/^DEFAULT_FORWARD_POLICY=.*/DEFAULT_FORWARD_POLICY="ACCEPT"/' /etc/default/ufw ufw allow "${SSH_PORT}/tcp" ufw allow 80/tcp ufw allow 443/tcp ufw allow "${OPENVPN_PORT}/${OPENVPN_PROTOCOL}" ufw route allow in on tun0 out on "${PUBLIC_NIC}" ufw route allow in on "${PUBLIC_NIC}" out on tun0 ufw --force enable } configure_iptables_persistent() { DEBIAN_FRONTEND=noninteractive apt-get install -y iptables-persistent netfilter-persistent iptables -t nat -C POSTROUTING -s "${VPN_SUBNET}" -o "${PUBLIC_NIC}" -j MASQUERADE 2>/dev/null || \ iptables -t nat -A POSTROUTING -s "${VPN_SUBNET}" -o "${PUBLIC_NIC}" -j MASQUERADE iptables -C FORWARD -i tun0 -s "${VPN_SUBNET}" -o "${PUBLIC_NIC}" -j ACCEPT 2>/dev/null || \ iptables -A FORWARD -i tun0 -s "${VPN_SUBNET}" -o "${PUBLIC_NIC}" -j ACCEPT iptables -C FORWARD -i "${PUBLIC_NIC}" -d "${VPN_SUBNET}" -o tun0 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT 2>/dev/null || \ iptables -A FORWARD -i "${PUBLIC_NIC}" -d "${VPN_SUBNET}" -o tun0 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT iptables -C INPUT -p tcp --dport "${SSH_PORT}" -j ACCEPT 2>/dev/null || iptables -A INPUT -p tcp --dport "${SSH_PORT}" -j ACCEPT iptables -C INPUT -p tcp --dport 80 -j ACCEPT 2>/dev/null || iptables -A INPUT -p tcp --dport 80 -j ACCEPT iptables -C INPUT -p tcp --dport 443 -j ACCEPT 2>/dev/null || iptables -A INPUT -p tcp --dport 443 -j ACCEPT iptables -C INPUT -p "${OPENVPN_PROTOCOL}" --dport "${OPENVPN_PORT}" -j ACCEPT 2>/dev/null || \ iptables -A INPUT -p "${OPENVPN_PROTOCOL}" --dport "${OPENVPN_PORT}" -j ACCEPT netfilter-persistent save } write_nginx_bootstrap_config() { cat > /etc/nginx/sites-available/paymenter-openvpn-manager < /etc/nginx/sites-available/paymenter-openvpn-manager </dev/null DEFAULT_DNS_SERVERS="$(ask_default 'Default DNS servers pushed to clients (comma separated)' '1.1.1.1, 1.0.0.1')" API_ALLOWLIST="$(ask_default 'Optional API allowlist CIDRs for nginx (comma separated, blank to allow all)' '')" ENABLE_LETSENCRYPT="$(ask_yes_no 'Issue a Let'\''s Encrypt certificate for the nginx API proxy' 'y')" ACME_EMAIL="" if [[ "${ENABLE_LETSENCRYPT}" == "yes" ]]; then ACME_EMAIL="$(ask_required 'Email address for Let'\''s Encrypt notices')" fi ENABLE_UFW="$(ask_yes_no 'Configure and enable UFW firewall rules' 'y')" SSH_PORT="$(ask_default 'SSH port to allow through the firewall' '22')" API_TOKEN="$(ask_default 'API token to use for Paymenter (leave random if unsure)' "$(openssl rand -hex 32)")" EASYRSA_REQ_COUNTRY="$(ask_default 'Easy-RSA country code' 'US')" EASYRSA_REQ_PROVINCE="$(ask_default 'Easy-RSA state or province' 'State')" EASYRSA_REQ_CITY="$(ask_default 'Easy-RSA city' 'City')" EASYRSA_REQ_ORG="$(ask_default 'Easy-RSA organization' 'Paymenter VPN')" EASYRSA_REQ_EMAIL="$(ask_default 'Easy-RSA contact email' 'admin@'"${API_DOMAIN}")" EASYRSA_REQ_OU="$(ask_default 'Easy-RSA organizational unit' 'Infrastructure')" CA_COMMON_NAME="$(ask_default 'Certificate authority common name' 'Paymenter OpenVPN CA')" SERVER_COMMON_NAME="$(ask_default 'OpenVPN server certificate common name' 'server')" VPN_NETWORK_ADDRESS="$(python3 - "${VPN_SUBNET}" <<'PY' import ipaddress import sys network = ipaddress.ip_network(sys.argv[1], strict=False) print(network.network_address) PY )" VPN_NETMASK="$(python3 - "${VPN_SUBNET}" <<'PY' import ipaddress import sys network = ipaddress.ip_network(sys.argv[1], strict=False) print(network.netmask) PY )" echo echo "Preparing packages and filesystem..." prepare_packages install -d -m 755 /var/www/html install -d -m 755 /var/www/html/.well-known install -d -m 755 /var/www/html/.well-known/acme-challenge write_manager_assets write_manager_config configure_sysctl setup_easy_rsa write_openvpn_server_config initialize_manager_state verify_installation_prereqs echo "Configuring services and reverse proxy..." enable_services write_nginx_bootstrap_config issue_certificate if [[ "${ENABLE_UFW}" == "yes" ]]; then configure_ufw else configure_iptables_persistent fi systemctl restart openvpn-server@server.service systemctl restart paymenter-openvpn-api.service systemctl restart nginx print_summary