Compare commits
5 Commits
5ba0f31767
...
835326311b
| Author | SHA1 | Date | |
|---|---|---|---|
| 835326311b | |||
| 8feb56faf6 | |||
| 398831b6e2 | |||
| feaf253403 | |||
| e74f0a5ffb |
@@ -35,6 +35,12 @@ class BadRequest(Exception):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Conflict(Exception):
|
||||||
|
"""Raised when the daemon returns HTTP 409."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
_DEFAULT_SOCKET = None
|
_DEFAULT_SOCKET = None
|
||||||
|
|
||||||
|
|
||||||
@@ -175,6 +181,8 @@ def request(
|
|||||||
raise NotFound(data.get("error", str(exc))) from exc
|
raise NotFound(data.get("error", str(exc))) from exc
|
||||||
if resp.status_code == 400:
|
if resp.status_code == 400:
|
||||||
raise BadRequest(data.get("error", str(exc))) from exc
|
raise BadRequest(data.get("error", str(exc))) from exc
|
||||||
|
if resp.status_code == 409:
|
||||||
|
raise Conflict(data.get("error", str(exc))) from exc
|
||||||
raise RuntimeError(data.get("error", str(exc))) from exc
|
raise RuntimeError(data.get("error", str(exc))) from exc
|
||||||
if not data.get("ok"):
|
if not data.get("ok"):
|
||||||
raise RuntimeError(data.get("error", "Unknown error"))
|
raise RuntimeError(data.get("error", "Unknown error"))
|
||||||
|
|||||||
+81
-41
@@ -15,6 +15,7 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import lib.acme
|
||||||
import lib.common as lib_common
|
import lib.common as lib_common
|
||||||
from daemon.iface import (
|
from daemon.iface import (
|
||||||
DELETE_ACME_ACCOUNT_DEACTIVATE,
|
DELETE_ACME_ACCOUNT_DEACTIVATE,
|
||||||
@@ -32,14 +33,16 @@ from daemon.iface import (
|
|||||||
POST_ACME_SELF_SIGNED,
|
POST_ACME_SELF_SIGNED,
|
||||||
POST_ACME_VALIDATE,
|
POST_ACME_VALIDATE,
|
||||||
)
|
)
|
||||||
from daemon.server import NotFoundError, refresh_state, registry
|
from daemon.server import ConflictError, NotFoundError, refresh_state, registry
|
||||||
from lib.state import _run_acme
|
from lib.acme import _run_acme
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
PROJECT_DIR = Path(__file__).resolve().parent.parent.parent
|
PROJECT_DIR = Path(__file__).resolve().parent.parent.parent
|
||||||
_ACME_HOME = PROJECT_DIR / "data" / "acme"
|
_ACME_HOME = PROJECT_DIR / "data" / "acme"
|
||||||
_DEPLOY_HOOK = str(PROJECT_DIR / "system" / "acme-deploy.sh")
|
# acme.sh resolves deploy hooks from $ACME_HOME/deploy/ -- _findHook
|
||||||
|
# only searches the deploy subdirectory, never accepts absolute paths.
|
||||||
|
_DEPLOY_HOOK = "acme-deploy.sh"
|
||||||
|
|
||||||
_ACME_ENVIRON = {
|
_ACME_ENVIRON = {
|
||||||
"HOME": str(PROJECT_DIR),
|
"HOME": str(PROJECT_DIR),
|
||||||
@@ -55,6 +58,14 @@ _ISSUANCES: dict[str, "IssueRequest"] = {}
|
|||||||
_ISSUANCE_TTL = 300 # seconds to keep completed requests
|
_ISSUANCE_TTL = 300 # seconds to keep completed requests
|
||||||
|
|
||||||
|
|
||||||
|
def _find_issuance(domain: str) -> "IssueRequest | None":
|
||||||
|
"""Find an active (running) issuance request by domain."""
|
||||||
|
for req in _ISSUANCES.values():
|
||||||
|
if req.domain == domain and req.status == "running":
|
||||||
|
return req
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class IssueStep:
|
class IssueStep:
|
||||||
"""Single step in a certificate issuance workflow.
|
"""Single step in a certificate issuance workflow.
|
||||||
@@ -122,7 +133,7 @@ class IssueRequest:
|
|||||||
|
|
||||||
def _find_acme_bin() -> str:
|
def _find_acme_bin() -> str:
|
||||||
"""Return the path to the acme.sh binary."""
|
"""Return the path to the acme.sh binary."""
|
||||||
from lib.state import _find_acme
|
from lib.acme import _find_acme
|
||||||
|
|
||||||
return _find_acme()
|
return _find_acme()
|
||||||
|
|
||||||
@@ -172,7 +183,7 @@ def _check_domain_format(domain: str) -> tuple[bool, str]:
|
|||||||
pattern = r"^[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])?)*$"
|
pattern = r"^[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])?)*$"
|
||||||
if not _re.match(pattern, domain):
|
if not _re.match(pattern, domain):
|
||||||
return False, "Invalid domain name format"
|
return False, "Invalid domain name format"
|
||||||
return True, ""
|
return True, "Domain format is valid"
|
||||||
|
|
||||||
|
|
||||||
def _get_local_ips() -> set[str]:
|
def _get_local_ips() -> set[str]:
|
||||||
@@ -332,14 +343,14 @@ def _check_challenge_config() -> tuple[bool, str]:
|
|||||||
def _check_existing_cert(domain: str) -> tuple[bool, str]:
|
def _check_existing_cert(domain: str) -> tuple[bool, str]:
|
||||||
"""Warn if a valid cert already exists (not blocking)."""
|
"""Warn if a valid cert already exists (not blocking)."""
|
||||||
try:
|
try:
|
||||||
from lib.acme import days_until_expiry
|
days = lib.acme.days_until_expiry(domain)
|
||||||
|
except (RuntimeError, FileNotFoundError):
|
||||||
days = days_until_expiry(domain)
|
return True, "No existing certificate found"
|
||||||
if days is not None and days > 0:
|
if days is None:
|
||||||
|
return True, "No existing certificate found"
|
||||||
|
if days > 0:
|
||||||
return True, f"Valid certificate exists ({days} days remaining)"
|
return True, f"Valid certificate exists ({days} days remaining)"
|
||||||
except (ValueError, RuntimeError, FileNotFoundError):
|
return True, f"Certificate expired ({abs(days)} days ago)"
|
||||||
pass
|
|
||||||
return True, ""
|
|
||||||
|
|
||||||
|
|
||||||
def _check_nginx_running() -> tuple[bool, str]:
|
def _check_nginx_running() -> tuple[bool, str]:
|
||||||
@@ -490,7 +501,11 @@ def _check_port_80_listening() -> tuple[bool, str]:
|
|||||||
|
|
||||||
|
|
||||||
def _check_acme_account() -> tuple[bool, str]:
|
def _check_acme_account() -> tuple[bool, str]:
|
||||||
"""Non-blocking: check acme.sh account is configured."""
|
"""Non-blocking: check acme.sh account is configured.
|
||||||
|
|
||||||
|
Tries acme.sh --info first, then falls back to parsed account state
|
||||||
|
(handles both legacy .account.conf and modern declarative config).
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
acme_bin = _find_acme_bin()
|
acme_bin = _find_acme_bin()
|
||||||
acme_home_env = os.environ.get("ACME_HOME", str(_ACME_HOME))
|
acme_home_env = os.environ.get("ACME_HOME", str(_ACME_HOME))
|
||||||
@@ -513,14 +528,11 @@ def _check_acme_account() -> tuple[bool, str]:
|
|||||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
try:
|
from lib.state import _parse_account_conf
|
||||||
account_conf = _ACME_HOME / ".account.conf"
|
|
||||||
if account_conf.is_file():
|
info = _parse_account_conf(_ACME_HOME)
|
||||||
text = account_conf.read_text()
|
if info.get("registered"):
|
||||||
if "ACME_LEEMAIL" in text and "ACME_MCA" in text:
|
|
||||||
return True, "ACME account is configured"
|
return True, "ACME account is configured"
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
False,
|
False,
|
||||||
@@ -531,17 +543,14 @@ def _check_acme_account() -> tuple[bool, str]:
|
|||||||
def _check_account_registered() -> tuple[bool, str]:
|
def _check_account_registered() -> tuple[bool, str]:
|
||||||
"""Blocking check: verify an ACME account is registered.
|
"""Blocking check: verify an ACME account is registered.
|
||||||
|
|
||||||
Reads the user-facing .account.conf (with leading dot) which stores
|
Delegates to ``lib.state._parse_account_conf()`` which checks both
|
||||||
the registered account's ACME_LEEMAIL and ACME_MCA keys.
|
the legacy .account.conf and the declarative config/acme/config.json
|
||||||
|
used by modern acme.sh (v3.x).
|
||||||
"""
|
"""
|
||||||
account_conf = _ACME_HOME / ".account.conf"
|
from lib.state import _parse_account_conf
|
||||||
if not account_conf.is_file():
|
|
||||||
return False, "Register an ACME account before issuing certificates"
|
info = _parse_account_conf(_ACME_HOME)
|
||||||
try:
|
if info.get("registered"):
|
||||||
text = account_conf.read_text()
|
|
||||||
except OSError:
|
|
||||||
return False, "Register an ACME account before issuing certificates"
|
|
||||||
if "ACME_LEEMAIL" in text and "ACME_MCA" in text:
|
|
||||||
return True, "ACME account is registered"
|
return True, "ACME account is registered"
|
||||||
return False, "Register an ACME account before issuing certificates"
|
return False, "Register an ACME account before issuing certificates"
|
||||||
|
|
||||||
@@ -564,6 +573,11 @@ def _check_dns_public(domain: str) -> tuple[bool, str]:
|
|||||||
if not local_ips:
|
if not local_ips:
|
||||||
return True, "Public DNS check skipped (no local IPs detected)"
|
return True, "Public DNS check skipped (no local IPs detected)"
|
||||||
|
|
||||||
|
# Behind NAT: public DNS can never match local (private) IPs.
|
||||||
|
# dns_resolves already verified the domain correctly via external IP.
|
||||||
|
if all(_is_private_ip(ip) for ip in local_ips):
|
||||||
|
return True, "Public DNS check skipped (NAT detected — dns_resolves verified)"
|
||||||
|
|
||||||
for dns_server in ("8.8.8.8", "1.1.1.1"):
|
for dns_server in ("8.8.8.8", "1.1.1.1"):
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
@@ -660,9 +674,23 @@ def get_cert_info(_request: Any, body: dict[str, Any] | None) -> dict:
|
|||||||
raise ValueError("'domain' is required")
|
raise ValueError("'domain' is required")
|
||||||
domain = body["domain"]
|
domain = body["domain"]
|
||||||
certs = list_certs(None, None)
|
certs = list_certs(None, None)
|
||||||
|
req = _find_issuance(domain)
|
||||||
|
|
||||||
for c in certs:
|
for c in certs:
|
||||||
if c["domain"] == domain or domain in c.get("san_domains", []):
|
if c["domain"] == domain or domain in c.get("san_domains", []):
|
||||||
return c
|
result = dict(c)
|
||||||
|
if req:
|
||||||
|
result["issuance"] = req.to_dict()
|
||||||
|
return result
|
||||||
|
|
||||||
|
# No cert found — check if there's an in-progress issuance
|
||||||
|
if req:
|
||||||
|
return {
|
||||||
|
"domain": domain,
|
||||||
|
"status": "issuing",
|
||||||
|
"issuance": req.to_dict(),
|
||||||
|
}
|
||||||
|
|
||||||
raise NotFoundError(f"No certificate found for domain: {domain}")
|
raise NotFoundError(f"No certificate found for domain: {domain}")
|
||||||
|
|
||||||
|
|
||||||
@@ -689,7 +717,6 @@ async def issue_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, An
|
|||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: When domain is missing.
|
ValueError: When domain is missing.
|
||||||
RuntimeError: When pre-flight checks fail.
|
|
||||||
"""
|
"""
|
||||||
if not body:
|
if not body:
|
||||||
raise ValueError("Request body required")
|
raise ValueError("Request body required")
|
||||||
@@ -707,16 +734,28 @@ async def issue_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, An
|
|||||||
|
|
||||||
_clean_expired_issuances()
|
_clean_expired_issuances()
|
||||||
|
|
||||||
# Dedup: if domain already has an active request, return existing ID
|
# Dedup: if domain already has an active request, return it
|
||||||
for existing in _ISSUANCES.values():
|
for existing in _ISSUANCES.values():
|
||||||
if existing.domain == domain and existing.status == "running":
|
if existing.domain == domain and existing.status == "running":
|
||||||
return {
|
return {
|
||||||
"request_id": existing.request_id,
|
"request_id": existing.request_id,
|
||||||
"status": "existing",
|
|
||||||
"domain": domain,
|
"domain": domain,
|
||||||
"message": "Issuance already in progress for this domain",
|
"status": "existing",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Check if cert already exists — call acme.sh directly, not via state
|
||||||
|
try:
|
||||||
|
certs = lib.acme.list_certs()
|
||||||
|
except RuntimeError as exc:
|
||||||
|
raise RuntimeError(f"Cannot check existing certificates: {exc}") from exc
|
||||||
|
for c in certs:
|
||||||
|
if c["domain"] == domain or domain in c.get("san_domains", []):
|
||||||
|
days = c.get("days_until_expiry")
|
||||||
|
if days is not None and days >= 0:
|
||||||
|
raise ConflictError(
|
||||||
|
f"Certificate already exists for {domain} ({days} day{'s' if days != 1 else ''} remaining). Renew instead."
|
||||||
|
)
|
||||||
|
|
||||||
# Run pre-flight checks
|
# Run pre-flight checks
|
||||||
_validate_checks = _validate(domain)
|
_validate_checks = _validate(domain)
|
||||||
if not _validate_checks["ready"]:
|
if not _validate_checks["ready"]:
|
||||||
@@ -910,13 +949,14 @@ def get_cert_paths(_request: Any, body: dict[str, Any] | None) -> dict[str, str]
|
|||||||
if not body or "domain" not in body:
|
if not body or "domain" not in body:
|
||||||
raise ValueError("'domain' is required")
|
raise ValueError("'domain' is required")
|
||||||
domain = body["domain"]
|
domain = body["domain"]
|
||||||
acme_home_env = os.environ.get("ACME_HOME", str(_ACME_HOME))
|
from lib.acme import find_cert_dir
|
||||||
acme_home = str(Path(acme_home_env) / domain)
|
|
||||||
|
cert_dir = str(find_cert_dir(domain, _ACME_HOME))
|
||||||
return {
|
return {
|
||||||
"cert": f"{acme_home}/{domain}.cert",
|
"cert": f"{cert_dir}/{domain}.cert",
|
||||||
"key": f"{acme_home}/{domain}.key",
|
"key": f"{cert_dir}/{domain}.key",
|
||||||
"ca": f"{acme_home}/ca.cer",
|
"ca": f"{cert_dir}/ca.cer",
|
||||||
"fullchain": f"{acme_home}/fullchain.cer",
|
"fullchain": f"{cert_dir}/fullchain.cer",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+170
-164
@@ -17,12 +17,12 @@ from daemon.iface import (
|
|||||||
POST_NGINX_CONFIG,
|
POST_NGINX_CONFIG,
|
||||||
POST_NGINX_DOMAINS_ADD,
|
POST_NGINX_DOMAINS_ADD,
|
||||||
POST_NGINX_DOMAINS_UPDATE,
|
POST_NGINX_DOMAINS_UPDATE,
|
||||||
POST_NGINX_MANAGEMENT,
|
|
||||||
POST_NGINX_RELOAD,
|
POST_NGINX_RELOAD,
|
||||||
POST_NGINX_SSL_APPLY,
|
POST_NGINX_SSL_APPLY,
|
||||||
POST_NGINX_TEST,
|
POST_NGINX_TEST,
|
||||||
)
|
)
|
||||||
from daemon.server import NotFoundError, refresh_state, registry
|
from daemon.server import NotFoundError, refresh_state, registry
|
||||||
|
from lib.acme import find_cert_dir
|
||||||
from lib.common import ensure_dirs, load_json, run, run_proc, save_json
|
from lib.common import ensure_dirs, load_json, run, run_proc, save_json
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -58,11 +58,53 @@ DEFAULT_SSL: dict[str, Any] = {
|
|||||||
|
|
||||||
DEFAULT_CONFIG: dict[str, Any] = {
|
DEFAULT_CONFIG: dict[str, Any] = {
|
||||||
"domains": {},
|
"domains": {},
|
||||||
"management": None,
|
|
||||||
"ssl": {**DEFAULT_SSL},
|
"ssl": {**DEFAULT_SSL},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_config(raw: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Migrate legacy config formats to the new paths-based model."""
|
||||||
|
if "management" in raw and raw["management"] is not None:
|
||||||
|
mgmt = raw["management"]
|
||||||
|
mgmt_domain = mgmt.get("domain", "")
|
||||||
|
if mgmt_domain:
|
||||||
|
domains = raw.setdefault("domains", {})
|
||||||
|
if mgmt_domain not in domains:
|
||||||
|
domains[mgmt_domain] = {
|
||||||
|
"force_ssl": True,
|
||||||
|
"paths": {},
|
||||||
|
}
|
||||||
|
dom = domains[mgmt_domain]
|
||||||
|
paths = dom.setdefault("paths", {})
|
||||||
|
if "/" not in paths:
|
||||||
|
paths["/"] = {
|
||||||
|
"backend": {
|
||||||
|
"host": mgmt.get("backend", {}).get("host", "127.0.0.1"),
|
||||||
|
"port": mgmt.get("backend", {}).get("port", 9090),
|
||||||
|
"proto": "http",
|
||||||
|
},
|
||||||
|
"is_management": True,
|
||||||
|
}
|
||||||
|
if mgmt.get("auth"):
|
||||||
|
paths["/"]["auth"] = mgmt["auth"]
|
||||||
|
if "/ws" not in paths:
|
||||||
|
paths["/ws"] = {
|
||||||
|
"backend": {"host": "127.0.0.1", "port": 9091, "proto": "http"},
|
||||||
|
"is_websocket": True,
|
||||||
|
}
|
||||||
|
del raw["management"]
|
||||||
|
|
||||||
|
for dom in raw.get("domains", {}).values():
|
||||||
|
if "paths" not in dom and "backend" in dom:
|
||||||
|
dom["paths"] = {
|
||||||
|
"/": {
|
||||||
|
"backend": dom.pop("backend"),
|
||||||
|
"headers": dom.pop("headers", {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
def _get_state() -> dict[str, Any] | None:
|
def _get_state() -> dict[str, Any] | None:
|
||||||
"""Retrieve cached nginx state from the state store."""
|
"""Retrieve cached nginx state from the state store."""
|
||||||
from lib.state import state as state_store
|
from lib.state import state as state_store
|
||||||
@@ -71,60 +113,55 @@ def _get_state() -> dict[str, Any] | None:
|
|||||||
|
|
||||||
|
|
||||||
def _get_config() -> dict[str, Any]:
|
def _get_config() -> dict[str, Any]:
|
||||||
"""Load the nginx config JSON, applying defaults for missing fields.
|
"""Load the nginx config JSON, applying defaults and migrations."""
|
||||||
|
|
||||||
Returns:
|
|
||||||
The parsed config dict with ssl defaults filled in.
|
|
||||||
"""
|
|
||||||
ensure_dirs(CONFIG_DIR, SITES_DIR)
|
ensure_dirs(CONFIG_DIR, SITES_DIR)
|
||||||
raw = load_json(CONFIG_FILE)
|
raw = load_json(CONFIG_FILE)
|
||||||
if not raw:
|
if not raw:
|
||||||
raw = deepcopy(DEFAULT_CONFIG)
|
raw = deepcopy(DEFAULT_CONFIG)
|
||||||
if "ssl" not in raw:
|
if "ssl" not in raw:
|
||||||
raw["ssl"] = deepcopy(DEFAULT_SSL)
|
raw["ssl"] = deepcopy(DEFAULT_SSL)
|
||||||
|
raw = _migrate_config(raw)
|
||||||
|
_save_config(raw)
|
||||||
return raw
|
return raw
|
||||||
|
|
||||||
|
|
||||||
def _save_config(cfg: dict[str, Any]) -> None:
|
def _save_config(cfg: dict[str, Any]) -> None:
|
||||||
"""Persist the nginx config dict to disk.
|
"""Persist the nginx config dict to disk."""
|
||||||
|
|
||||||
Args:
|
|
||||||
cfg: The config dictionary to save.
|
|
||||||
"""
|
|
||||||
save_json(CONFIG_FILE, cfg)
|
save_json(CONFIG_FILE, cfg)
|
||||||
|
|
||||||
|
|
||||||
def _generate_server_conf(domain_cfg: dict[str, Any]) -> str:
|
def _generate_server_conf(domain_cfg: dict[str, Any]) -> str:
|
||||||
"""Render an nginx server block config from a domain entry via Jinja.
|
"""Render an nginx server block config from a domain entry via Jinja."""
|
||||||
|
|
||||||
Args:
|
|
||||||
domain_cfg: Domain config dict containing domain name, backend, headers, etc.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Rendered server block as a string.
|
|
||||||
"""
|
|
||||||
tmpl = ENV.get_template("nginx/server_block.conf")
|
tmpl = ENV.get_template("nginx/server_block.conf")
|
||||||
|
acme_home_path = PROJECT_DIR / "data" / "acme"
|
||||||
|
acme_cert_dir = str(find_cert_dir(domain_cfg["domain"], acme_home_path))
|
||||||
|
paths = domain_cfg.get("paths", {})
|
||||||
|
has_management = any(p.get("is_management") for p in paths.values())
|
||||||
|
# Resolve custom cert paths for cert=="file"
|
||||||
|
cert_cfg = domain_cfg.get("cert")
|
||||||
|
if isinstance(cert_cfg, dict):
|
||||||
|
cert_path = cert_cfg.get("cert_path", "")
|
||||||
|
cert_key_path = cert_cfg.get("cert_key_path", "")
|
||||||
|
else:
|
||||||
|
cert_path = domain_cfg.get("cert_path", "")
|
||||||
|
cert_key_path = domain_cfg.get("cert_key_path", "")
|
||||||
return tmpl.render(
|
return tmpl.render(
|
||||||
domain=domain_cfg["domain"],
|
domain=domain_cfg["domain"],
|
||||||
backend=domain_cfg.get("backend", {}),
|
paths=paths,
|
||||||
headers=domain_cfg.get("headers", {}),
|
|
||||||
force_ssl=domain_cfg.get("force_ssl", True),
|
force_ssl=domain_cfg.get("force_ssl", True),
|
||||||
cert=domain_cfg.get("cert"),
|
cert=domain_cfg.get("cert"),
|
||||||
auth=domain_cfg.get("auth"),
|
cert_path=cert_path,
|
||||||
is_management=False,
|
cert_key_path=cert_key_path,
|
||||||
acme_home=str(PROJECT_DIR / "data" / "acme"),
|
domain_auth=domain_cfg.get("auth"),
|
||||||
|
has_management=has_management,
|
||||||
|
acme_cert_dir=acme_cert_dir,
|
||||||
certs_dir=str(PROJECT_DIR / "data" / "certs"),
|
certs_dir=str(PROJECT_DIR / "data" / "certs"),
|
||||||
acme_webroot=str(PROJECT_DIR / "data" / "acme" / "www"),
|
acme_webroot=str(PROJECT_DIR / "data" / "acme" / "www"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _write_site(domain: str, conf_text: str) -> None:
|
def _write_site(domain: str, conf_text: str) -> None:
|
||||||
"""Atomically write a single site config file into sites-enabled.
|
"""Atomically write a single site config file into sites-enabled."""
|
||||||
|
|
||||||
Args:
|
|
||||||
domain: Site name used as the filename.
|
|
||||||
conf_text: Rendered nginx server block content.
|
|
||||||
"""
|
|
||||||
ensure_dirs(SITES_DIR)
|
ensure_dirs(SITES_DIR)
|
||||||
path = SITES_DIR / f"{domain}.conf"
|
path = SITES_DIR / f"{domain}.conf"
|
||||||
tmp = path.with_suffix(".tmp")
|
tmp = path.with_suffix(".tmp")
|
||||||
@@ -167,11 +204,7 @@ def _write_ssl_snippet() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _test_config() -> tuple[bool, str]:
|
def _test_config() -> tuple[bool, str]:
|
||||||
"""Run `nginx -t` to validate the current config.
|
"""Run `nginx -t` to validate the current config."""
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (passed, message).
|
|
||||||
"""
|
|
||||||
result = run_proc(["nginx", "-t"], sudo=True, check=False)
|
result = run_proc(["nginx", "-t"], sudo=True, check=False)
|
||||||
ok = result.returncode == 0
|
ok = result.returncode == 0
|
||||||
output = (result.stderr or result.stdout or "").strip()
|
output = (result.stderr or result.stdout or "").strip()
|
||||||
@@ -181,10 +214,7 @@ def _test_config() -> tuple[bool, str]:
|
|||||||
|
|
||||||
|
|
||||||
def _reload_nginx() -> None:
|
def _reload_nginx() -> None:
|
||||||
"""Send SIGHUP to nginx to reload its configuration.
|
"""Send SIGHUP to nginx to reload its configuration."""
|
||||||
|
|
||||||
Logs an error if the reload fails.
|
|
||||||
"""
|
|
||||||
result = run_proc(["nginx", "-s", "reload"], sudo=True, check=False)
|
result = run_proc(["nginx", "-s", "reload"], sudo=True, check=False)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
logger.error("nginx reload failed: %s", result.stderr.strip())
|
logger.error("nginx reload failed: %s", result.stderr.strip())
|
||||||
@@ -193,10 +223,7 @@ def _reload_nginx() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _write_all_sites() -> None:
|
def _write_all_sites() -> None:
|
||||||
"""Regenerate all site configs, management proxy, and ACME challenge site.
|
"""Regenerate all site configs and ACME challenge site."""
|
||||||
|
|
||||||
Removes stale .conf files that are no longer in config.
|
|
||||||
"""
|
|
||||||
ensure_dirs(SITES_DIR)
|
ensure_dirs(SITES_DIR)
|
||||||
cfg = _get_config()
|
cfg = _get_config()
|
||||||
existing = set(SITES_DIR.iterdir()) if SITES_DIR.exists() else set()
|
existing = set(SITES_DIR.iterdir()) if SITES_DIR.exists() else set()
|
||||||
@@ -206,25 +233,11 @@ def _write_all_sites() -> None:
|
|||||||
conf = _generate_server_conf(dom_copy)
|
conf = _generate_server_conf(dom_copy)
|
||||||
_write_site(name, conf)
|
_write_site(name, conf)
|
||||||
written.add(f"{name}.conf")
|
written.add(f"{name}.conf")
|
||||||
if cfg.get("management"):
|
|
||||||
mgmt = cfg["management"]
|
old_mgmt = SITES_DIR / "management.conf"
|
||||||
tmpl = ENV.get_template("nginx/server_block.conf")
|
if old_mgmt.exists() and old_mgmt.name not in written:
|
||||||
mgmt_conf = tmpl.render(
|
old_mgmt.unlink()
|
||||||
domain=mgmt.get("domain"),
|
|
||||||
backend=dict(
|
|
||||||
mgmt.get("backend", {}), host="127.0.0.1", port=9090, proto="http"
|
|
||||||
),
|
|
||||||
headers={},
|
|
||||||
force_ssl=True,
|
|
||||||
cert=None,
|
|
||||||
auth=mgmt.get("auth"),
|
|
||||||
is_management=True,
|
|
||||||
acme_home=str(PROJECT_DIR / "data" / "acme"),
|
|
||||||
certs_dir=str(PROJECT_DIR / "data" / "certs"),
|
|
||||||
acme_webroot=str(PROJECT_DIR / "data" / "acme" / "www"),
|
|
||||||
)
|
|
||||||
_write_site("management", mgmt_conf)
|
|
||||||
written.add("management.conf")
|
|
||||||
for old in existing:
|
for old in existing:
|
||||||
if old.suffix == ".conf" and old.name not in written:
|
if old.suffix == ".conf" and old.name not in written:
|
||||||
old.unlink()
|
old.unlink()
|
||||||
@@ -240,26 +253,14 @@ def _write_all_sites() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _hash_password(password: str) -> str:
|
def _hash_password(password: str) -> str:
|
||||||
"""Hash *password* using SHA-256 crypt via passlib.
|
"""Hash *password* using SHA-256 crypt via passlib."""
|
||||||
|
|
||||||
Args:
|
|
||||||
password: Plain-text password to hash.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The hashed password string suitable for ``.htpasswd``.
|
|
||||||
"""
|
|
||||||
from passlib.hash import sha256_crypt
|
from passlib.hash import sha256_crypt
|
||||||
|
|
||||||
return sha256_crypt.hash(password)
|
return sha256_crypt.hash(password)
|
||||||
|
|
||||||
|
|
||||||
def _write_htpasswd(user: str, password: str) -> None:
|
def _write_htpasswd(user: str, password: str) -> None:
|
||||||
"""Add or update a user entry in the .htpasswd file using SHA-256 hashing.
|
"""Add or update a user entry in the .htpasswd file using SHA-256 hashing."""
|
||||||
|
|
||||||
Args:
|
|
||||||
user: The username to add or update.
|
|
||||||
password: Plain-text password to hash.
|
|
||||||
"""
|
|
||||||
ensure_dirs(DATA_DIR)
|
ensure_dirs(DATA_DIR)
|
||||||
hashed = _hash_password(password)
|
hashed = _hash_password(password)
|
||||||
existing: dict[str, str] = {}
|
existing: dict[str, str] = {}
|
||||||
@@ -295,11 +296,7 @@ def _get_nginx_state() -> dict[str, Any]:
|
|||||||
|
|
||||||
@registry.register(GET_NGINX_CONFIG)
|
@registry.register(GET_NGINX_CONFIG)
|
||||||
def get_config(_request: Any, _body: Any) -> dict[str, Any]:
|
def get_config(_request: Any, _body: Any) -> dict[str, Any]:
|
||||||
"""GET /nginx/config — return current nginx config.
|
"""GET /nginx/config — return current nginx config."""
|
||||||
|
|
||||||
Returns:
|
|
||||||
Full config dict from state cache, or fallback to file.
|
|
||||||
"""
|
|
||||||
ng = _get_nginx_state()
|
ng = _get_nginx_state()
|
||||||
if ng:
|
if ng:
|
||||||
return ng.get("config", {})
|
return ng.get("config", {})
|
||||||
@@ -308,11 +305,7 @@ def get_config(_request: Any, _body: Any) -> dict[str, Any]:
|
|||||||
|
|
||||||
@registry.register(POST_NGINX_CONFIG)
|
@registry.register(POST_NGINX_CONFIG)
|
||||||
def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
"""POST /nginx/config — replace the entire nginx config and refresh state.
|
"""POST /nginx/config — replace the entire nginx config and refresh state."""
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: When request body is missing.
|
|
||||||
"""
|
|
||||||
if not body:
|
if not body:
|
||||||
raise ValueError("Request body required")
|
raise ValueError("Request body required")
|
||||||
_save_config(body)
|
_save_config(body)
|
||||||
@@ -322,11 +315,7 @@ def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str,
|
|||||||
|
|
||||||
@registry.register(PATCH_NGINX_CONFIG)
|
@registry.register(PATCH_NGINX_CONFIG)
|
||||||
def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
"""PATCH /nginx/config — deep-merge partial updates into current config.
|
"""PATCH /nginx/config — deep-merge partial updates into current config."""
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: When request body is missing.
|
|
||||||
"""
|
|
||||||
if not body:
|
if not body:
|
||||||
raise ValueError("Request body required")
|
raise ValueError("Request body required")
|
||||||
from lib.common import deep_merge
|
from lib.common import deep_merge
|
||||||
@@ -340,11 +329,7 @@ def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
|||||||
|
|
||||||
@registry.register(GET_NGINX_DOMAINS)
|
@registry.register(GET_NGINX_DOMAINS)
|
||||||
def get_domains(_request: Any, _body: Any) -> list[dict[str, Any]]:
|
def get_domains(_request: Any, _body: Any) -> list[dict[str, Any]]:
|
||||||
"""GET /nginx/domains — return the list of configured proxy domains.
|
"""GET /nginx/domains — return the list of configured proxy domains."""
|
||||||
|
|
||||||
Returns:
|
|
||||||
Domains list from state cache, or empty list.
|
|
||||||
"""
|
|
||||||
ng = _get_nginx_state()
|
ng = _get_nginx_state()
|
||||||
if ng:
|
if ng:
|
||||||
return ng.get("domains", [])
|
return ng.get("domains", [])
|
||||||
@@ -355,39 +340,82 @@ def get_domains(_request: Any, _body: Any) -> list[dict[str, Any]]:
|
|||||||
def add_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
def add_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
"""POST /nginx/domains/add — add a new reverse-proxy domain entry.
|
"""POST /nginx/domains/add — add a new reverse-proxy domain entry.
|
||||||
|
|
||||||
Raises:
|
Accepts either legacy backend_* fields or a ``paths`` map.
|
||||||
ValueError: When required fields (domain, backend_host, backend_port) are missing.
|
|
||||||
ValueError: When the domain already exists.
|
|
||||||
"""
|
"""
|
||||||
if not body:
|
if not body:
|
||||||
raise ValueError("Request body required")
|
raise ValueError("Request body required")
|
||||||
domain = body.get("domain", "").strip()
|
domain = body.get("domain", "").strip()
|
||||||
|
if not domain:
|
||||||
|
raise ValueError("'domain' is required")
|
||||||
|
cfg = _get_config()
|
||||||
|
if domain in cfg["domains"]:
|
||||||
|
raise ValueError(f"Domain {domain!r} already configured")
|
||||||
|
|
||||||
|
paths = body.get("paths")
|
||||||
|
cert = body.get("cert")
|
||||||
|
force_ssl = body.get("force_ssl", True)
|
||||||
|
|
||||||
|
if paths is not None:
|
||||||
|
entry: dict[str, Any] = {
|
||||||
|
"paths": paths,
|
||||||
|
"force_ssl": force_ssl,
|
||||||
|
}
|
||||||
|
if cert is not None:
|
||||||
|
entry["cert"] = cert
|
||||||
|
else:
|
||||||
backend_host = body.get("backend_host", "").strip()
|
backend_host = body.get("backend_host", "").strip()
|
||||||
backend_port = body.get("backend_port")
|
backend_port = body.get("backend_port")
|
||||||
backend_proto = body.get("backend_proto", "http").strip() or "http"
|
backend_proto = body.get("backend_proto", "http").strip() or "http"
|
||||||
cert = body.get("cert")
|
|
||||||
extra_headers = body.get("extra_headers")
|
extra_headers = body.get("extra_headers")
|
||||||
if not domain:
|
|
||||||
raise ValueError("'domain' is required")
|
|
||||||
if not backend_host:
|
if not backend_host:
|
||||||
raise ValueError("'backend_host' is required")
|
raise ValueError("'backend_host' is required")
|
||||||
if backend_port is None:
|
if backend_port is None:
|
||||||
raise ValueError("'backend_port' is required")
|
raise ValueError("'backend_port' is required")
|
||||||
cfg = _get_config()
|
entry = {
|
||||||
if domain in cfg["domains"]:
|
"paths": {
|
||||||
raise ValueError(f"Domain {domain!r} already configured")
|
"/": {
|
||||||
entry: dict[str, Any] = {
|
|
||||||
"backend": {
|
"backend": {
|
||||||
"host": backend_host,
|
"host": backend_host,
|
||||||
"port": int(backend_port),
|
"port": int(backend_port),
|
||||||
"proto": backend_proto,
|
"proto": backend_proto,
|
||||||
},
|
},
|
||||||
"force_ssl": True,
|
"headers": extra_headers or {},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"force_ssl": force_ssl,
|
||||||
}
|
}
|
||||||
if cert is not None:
|
if cert is not None:
|
||||||
entry["cert"] = cert
|
entry["cert"] = cert
|
||||||
if extra_headers is not None:
|
|
||||||
entry["headers"] = extra_headers
|
# Handle auth credentials for management domain
|
||||||
|
auth_user = body.get("auth_user", "").strip()
|
||||||
|
auth_pass = body.get("auth_pass", "")
|
||||||
|
if auth_user and auth_pass:
|
||||||
|
_write_htpasswd(auth_user, auth_pass)
|
||||||
|
auth_dict = {"user": auth_user, "htpasswd": str(HTPASSWD_FILE)}
|
||||||
|
paths_entry = entry.get("paths", {})
|
||||||
|
for _ppath, pcfg in paths_entry.items():
|
||||||
|
if pcfg.get("is_management"):
|
||||||
|
pcfg["auth"] = auth_dict
|
||||||
|
break
|
||||||
|
entry["auth"] = auth_dict
|
||||||
|
|
||||||
|
# Handle auth credentials for management paths
|
||||||
|
auth_user = body.get("auth_user", "").strip()
|
||||||
|
auth_pass = body.get("auth_pass", "").strip()
|
||||||
|
if auth_user and auth_pass:
|
||||||
|
_write_htpasswd(auth_user, auth_pass)
|
||||||
|
auth_entry = {
|
||||||
|
"user": auth_user,
|
||||||
|
"htpasswd": str(HTPASSWD_FILE),
|
||||||
|
}
|
||||||
|
# Store auth on root path if it exists
|
||||||
|
root_path = entry.get("paths", {}).get("/")
|
||||||
|
if root_path:
|
||||||
|
root_path["auth"] = auth_entry
|
||||||
|
# Also store at domain level for template
|
||||||
|
entry["auth"] = auth_entry
|
||||||
|
|
||||||
cfg["domains"][domain] = entry
|
cfg["domains"][domain] = entry
|
||||||
_save_config(cfg)
|
_save_config(cfg)
|
||||||
refresh_state(["nginx"])
|
refresh_state(["nginx"])
|
||||||
@@ -396,12 +424,7 @@ def add_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
|||||||
|
|
||||||
@registry.register(DELETE_NGINX_DOMAINS_REMOVE)
|
@registry.register(DELETE_NGINX_DOMAINS_REMOVE)
|
||||||
def remove_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
def remove_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
"""DELETE /nginx/domains/remove — remove a domain from the proxy config.
|
"""DELETE /nginx/domains/remove — remove a domain from the proxy config."""
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: When request body or domain field is missing.
|
|
||||||
NotFoundError: When the domain is not configured.
|
|
||||||
"""
|
|
||||||
if not body:
|
if not body:
|
||||||
raise ValueError("Request body required")
|
raise ValueError("Request body required")
|
||||||
domain = body.get("domain", "").strip()
|
domain = body.get("domain", "").strip()
|
||||||
@@ -421,12 +444,7 @@ def remove_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
|||||||
|
|
||||||
@registry.register(POST_NGINX_DOMAINS_UPDATE)
|
@registry.register(POST_NGINX_DOMAINS_UPDATE)
|
||||||
def update_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
def update_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
"""POST /nginx/domains/update — patch fields of an existing domain entry.
|
"""POST /nginx/domains/update — patch fields of an existing domain entry."""
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: When request body or domain field is missing.
|
|
||||||
NotFoundError: When the domain is not configured.
|
|
||||||
"""
|
|
||||||
if not body:
|
if not body:
|
||||||
raise ValueError("Request body required")
|
raise ValueError("Request body required")
|
||||||
domain = body.get("domain", "").strip()
|
domain = body.get("domain", "").strip()
|
||||||
@@ -435,9 +453,36 @@ def update_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
|||||||
cfg = _get_config()
|
cfg = _get_config()
|
||||||
if domain not in cfg["domains"]:
|
if domain not in cfg["domains"]:
|
||||||
raise NotFoundError(f"Domain {domain!r} not configured")
|
raise NotFoundError(f"Domain {domain!r} not configured")
|
||||||
updates = {k: v for k, v in body.items() if k != "domain"}
|
|
||||||
entry = cfg["domains"][domain]
|
entry = cfg["domains"][domain]
|
||||||
|
|
||||||
|
# Path removal: if body has `path` key (string) but no `paths`/`backend`/`headers`
|
||||||
|
path_to_remove = body.get("path")
|
||||||
|
if path_to_remove is not None and "paths" not in body and "backend" not in body and "headers" not in body:
|
||||||
|
paths = entry.get("paths", {})
|
||||||
|
if path_to_remove in paths:
|
||||||
|
del paths[path_to_remove]
|
||||||
|
if not paths:
|
||||||
|
entry.pop("paths", None)
|
||||||
|
_save_config(cfg)
|
||||||
|
refresh_state(["nginx"])
|
||||||
|
return {"domain": domain, "path_removed": path_to_remove}
|
||||||
|
|
||||||
|
updates = {k: v for k, v in body.items() if k not in ("domain", "path")}
|
||||||
|
|
||||||
|
if "paths" in updates:
|
||||||
|
entry["paths"] = updates["paths"]
|
||||||
|
else:
|
||||||
|
paths = entry.setdefault("paths", {})
|
||||||
|
if "backend" in updates:
|
||||||
|
root = paths.setdefault("/", {"backend": {}, "headers": {}})
|
||||||
|
root["backend"] = updates["backend"]
|
||||||
|
if "headers" in updates:
|
||||||
|
root = paths.setdefault("/", {"backend": {}, "headers": {}})
|
||||||
|
root["headers"] = updates["headers"]
|
||||||
|
|
||||||
for key, val in updates.items():
|
for key, val in updates.items():
|
||||||
|
if key in ("backend", "headers", "paths"):
|
||||||
|
continue
|
||||||
if isinstance(val, dict) and key in entry:
|
if isinstance(val, dict) and key in entry:
|
||||||
entry[key].update(val)
|
entry[key].update(val)
|
||||||
else:
|
else:
|
||||||
@@ -449,11 +494,7 @@ def update_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
|||||||
|
|
||||||
@registry.register(POST_NGINX_APPLY)
|
@registry.register(POST_NGINX_APPLY)
|
||||||
def apply(_request: Any, _body: Any) -> dict[str, Any]:
|
def apply(_request: Any, _body: Any) -> dict[str, Any]:
|
||||||
"""POST /nginx/apply — render all configs, test, and reload nginx.
|
"""POST /nginx/apply — render all configs, test, and reload nginx."""
|
||||||
|
|
||||||
Raises:
|
|
||||||
RuntimeError: When the nginx config test fails.
|
|
||||||
"""
|
|
||||||
_write_ssl_snippet()
|
_write_ssl_snippet()
|
||||||
_write_all_sites()
|
_write_all_sites()
|
||||||
_write_include_file()
|
_write_include_file()
|
||||||
@@ -467,11 +508,7 @@ def apply(_request: Any, _body: Any) -> dict[str, Any]:
|
|||||||
|
|
||||||
@registry.register(POST_NGINX_TEST)
|
@registry.register(POST_NGINX_TEST)
|
||||||
def test(_request: Any, _body: Any) -> dict[str, Any]:
|
def test(_request: Any, _body: Any) -> dict[str, Any]:
|
||||||
"""POST /nginx/test — dry-run validate the live nginx config without applying.
|
"""POST /nginx/test — dry-run validate the live nginx config without applying."""
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict with valid (bool) and output (str) from `nginx -t`.
|
|
||||||
"""
|
|
||||||
valid, output = _test_config()
|
valid, output = _test_config()
|
||||||
return {"valid": valid, "output": output}
|
return {"valid": valid, "output": output}
|
||||||
|
|
||||||
@@ -484,37 +521,6 @@ def ssl_apply(_request: Any, _body: Any) -> dict[str, Any]:
|
|||||||
return {"applied": True}
|
return {"applied": True}
|
||||||
|
|
||||||
|
|
||||||
@registry.register(POST_NGINX_MANAGEMENT)
|
|
||||||
def set_management_proxy(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
|
||||||
"""POST /nginx/management — configure the management UI reverse proxy.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: When request body or domain field is missing.
|
|
||||||
"""
|
|
||||||
if not body:
|
|
||||||
raise ValueError("Request body required")
|
|
||||||
domain = body.get("domain", "").strip()
|
|
||||||
if not domain:
|
|
||||||
raise ValueError("'domain' is required")
|
|
||||||
flask_host = body.get("flask_host", "127.0.0.1").strip() or "127.0.0.1"
|
|
||||||
flask_port = body.get("flask_port", 9090)
|
|
||||||
auth_user = body.get("auth_user")
|
|
||||||
auth_pass = body.get("auth_pass")
|
|
||||||
cfg = _get_config()
|
|
||||||
entry: dict[str, Any] = {
|
|
||||||
"domain": domain,
|
|
||||||
"backend": {"host": flask_host, "port": int(flask_port), "proto": "http"},
|
|
||||||
}
|
|
||||||
if auth_user:
|
|
||||||
entry["auth"] = {"user": auth_user, "htpasswd": str(HTPASSWD_FILE)}
|
|
||||||
cfg["management"] = entry
|
|
||||||
_save_config(cfg)
|
|
||||||
if auth_user and auth_pass:
|
|
||||||
_write_htpasswd(auth_user, auth_pass)
|
|
||||||
refresh_state(["nginx"])
|
|
||||||
return {"domain": domain}
|
|
||||||
|
|
||||||
|
|
||||||
@registry.register(POST_NGINX_RELOAD)
|
@registry.register(POST_NGINX_RELOAD)
|
||||||
def reload_nginx(_request: Any, _body: Any) -> dict[str, Any]:
|
def reload_nginx(_request: Any, _body: Any) -> dict[str, Any]:
|
||||||
"""POST /nginx/reload — trigger an nginx reload (SIGHUP)."""
|
"""POST /nginx/reload — trigger an nginx reload (SIGHUP)."""
|
||||||
|
|||||||
+1
-1
@@ -43,7 +43,7 @@ POST_NGINX_DOMAINS_UPDATE: Endpoint = _ep("POST", "/nginx/domains/update")
|
|||||||
POST_NGINX_APPLY: Endpoint = _ep("POST", "/nginx/apply")
|
POST_NGINX_APPLY: Endpoint = _ep("POST", "/nginx/apply")
|
||||||
POST_NGINX_TEST: Endpoint = _ep("POST", "/nginx/test")
|
POST_NGINX_TEST: Endpoint = _ep("POST", "/nginx/test")
|
||||||
POST_NGINX_SSL_APPLY: Endpoint = _ep("POST", "/nginx/ssl-apply")
|
POST_NGINX_SSL_APPLY: Endpoint = _ep("POST", "/nginx/ssl-apply")
|
||||||
POST_NGINX_MANAGEMENT: Endpoint = _ep("POST", "/nginx/management")
|
|
||||||
POST_NGINX_RELOAD: Endpoint = _ep("POST", "/nginx/reload")
|
POST_NGINX_RELOAD: Endpoint = _ep("POST", "/nginx/reload")
|
||||||
|
|
||||||
# ---- Firewall ----
|
# ---- Firewall ----
|
||||||
|
|||||||
+11
-3
@@ -159,6 +159,12 @@ class NotFoundError(Exception):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ConflictError(Exception):
|
||||||
|
"""Raised when a request conflicts with an existing resource."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def ok(data: Any = None) -> web.Response:
|
def ok(data: Any = None) -> web.Response:
|
||||||
"""Create a success JSON response.
|
"""Create a success JSON response.
|
||||||
|
|
||||||
@@ -247,6 +253,8 @@ async def _handle_request(request: web.Request) -> web.Response:
|
|||||||
result = await result
|
result = await result
|
||||||
except NotFoundError as exc:
|
except NotFoundError as exc:
|
||||||
return error(str(exc), 404)
|
return error(str(exc), 404)
|
||||||
|
except ConflictError as exc:
|
||||||
|
return error(str(exc), 409)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
return error(str(exc), 400)
|
return error(str(exc), 400)
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
@@ -420,10 +428,10 @@ async def _poll_loop(subsystem: str, interval: int) -> None:
|
|||||||
await asyncio.sleep(interval)
|
await asyncio.sleep(interval)
|
||||||
|
|
||||||
|
|
||||||
def start_polling() -> None:
|
def start_polling(loop: asyncio.AbstractEventLoop) -> None:
|
||||||
"""Start one poll loop task per subsystem."""
|
"""Start one poll loop task per subsystem."""
|
||||||
for subsystem, interval in _POLL_INTERVALS.items():
|
for subsystem, interval in _POLL_INTERVALS.items():
|
||||||
task = asyncio.create_task(_poll_loop(subsystem, interval))
|
task = loop.create_task(_poll_loop(subsystem, interval))
|
||||||
task.add_done_callback(_poll_tasks.discard)
|
task.add_done_callback(_poll_tasks.discard)
|
||||||
_poll_tasks.add(task)
|
_poll_tasks.add(task)
|
||||||
|
|
||||||
@@ -547,7 +555,7 @@ def main() -> None:
|
|||||||
for subsystem in state_store.SUBSYSTEMS:
|
for subsystem in state_store.SUBSYSTEMS:
|
||||||
if state_store.get(subsystem) is not None:
|
if state_store.get(subsystem) is not None:
|
||||||
state_store.bump(subsystem)
|
state_store.bump(subsystem)
|
||||||
loop.run_until_complete(start_polling())
|
start_polling(loop)
|
||||||
logger.info("vacuum-walld listening on %s", socket_path)
|
logger.info("vacuum-walld listening on %s", socket_path)
|
||||||
logger.info("WebSocket on 127.0.0.1:%d", _WS_PORT)
|
logger.info("WebSocket on 127.0.0.1:%d", _WS_PORT)
|
||||||
|
|
||||||
|
|||||||
+20
-27
@@ -715,13 +715,15 @@ Write the global nginx SSL snippet configuration.
|
|||||||
GET /api/proxy/domains
|
GET /api/proxy/domains
|
||||||
```
|
```
|
||||||
|
|
||||||
Return all configured proxy domains.
|
Return all configured proxy domains. The response is flattened by path — each path within a domain produces a separate entry.
|
||||||
|
|
||||||
**Response:**
|
**Response:**
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
|-------|------|-------------|
|
|-------|------|-------------|
|
||||||
| `data` | `[object, ...]` | Array of domain configuration objects |
|
| `data` | `[object, ...]` | Array of path-level domain configuration objects |
|
||||||
|
|
||||||
|
Each entry contains `domain` (string), `path` (string), `backend` (object with `host`, `port`, `proto`), `online` (boolean), `force_ssl` (boolean), and optionally `is_management` or `is_websocket` flags.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -731,9 +733,18 @@ Return all configured proxy domains.
|
|||||||
POST /api/proxy/domains
|
POST /api/proxy/domains
|
||||||
```
|
```
|
||||||
|
|
||||||
Add a new reverse proxy domain.
|
Add a new reverse proxy domain. Accepts two modes:
|
||||||
|
|
||||||
**Request Body:**
|
**Paths mode (preferred):**
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `domain` | `string` | Yes | Domain name to proxy |
|
||||||
|
| `paths` | `object` | Yes | Path-to-config map. Each path entry must have a `backend` key with `host`, `port`, `proto`. |
|
||||||
|
| `cert` | `string` | No | Certificate type |
|
||||||
|
| `force_ssl` | `boolean` | No | HTTPS redirect flag (default `true`) |
|
||||||
|
|
||||||
|
**Legacy mode (backward compatible):**
|
||||||
|
|
||||||
| Field | Type | Required | Description |
|
| Field | Type | Required | Description |
|
||||||
|-------|------|----------|-------------|
|
|-------|------|----------|-------------|
|
||||||
@@ -741,7 +752,7 @@ Add a new reverse proxy domain.
|
|||||||
| `backend_host` | `string` | Yes | Backend server IP or hostname |
|
| `backend_host` | `string` | Yes | Backend server IP or hostname |
|
||||||
| `backend_port` | `number` | Yes | Backend server port |
|
| `backend_port` | `number` | Yes | Backend server port |
|
||||||
| `backend_proto` | `string` | No | Backend protocol (`"http"` or `"https"`); defaults to `"http"` |
|
| `backend_proto` | `string` | No | Backend protocol (`"http"` or `"https"`); defaults to `"http"` |
|
||||||
| `cert` | `string` | No | Certificate domain |
|
| `cert` | `string` | No | Certificate type |
|
||||||
| `extra_headers` | `object` | No | Extra proxy headers |
|
| `extra_headers` | `object` | No | Extra proxy headers |
|
||||||
|
|
||||||
**Response (`data`):**
|
**Response (`data`):**
|
||||||
@@ -774,9 +785,9 @@ Returns HTTP `404` if the domain is not configured.
|
|||||||
PUT /api/proxy/domains/<domain>
|
PUT /api/proxy/domains/<domain>
|
||||||
```
|
```
|
||||||
|
|
||||||
Update one or more fields of an existing domain entry. Only fields present in the body are modified.
|
Update one or more fields of an existing domain entry. Only fields present in the body are modified. Supports both domain-level keys (`paths`, `force_ssl`, `cert`, `auth`) and path-level shorthand (`backend`, `headers` for the root path).
|
||||||
|
|
||||||
**Request Body:** Any subset of (`backend_host`, `backend_port`, `backend_proto`, `cert`, `extra_headers`).
|
**Request Body:** Any subset of (`paths`, `backend`, `backend_host`, `backend_port`, `backend_proto`, `cert`, `extra_headers`, `force_ssl`, `auth`).
|
||||||
|
|
||||||
**Response (`data`):**
|
**Response (`data`):**
|
||||||
|
|
||||||
@@ -837,27 +848,9 @@ Run `nginx -t` against the generated configuration without reloading.
|
|||||||
|
|
||||||
**Error (invalid):** HTTP `400` with standard `{"ok": false, "error": "<nginx output>"}` response.
|
**Error (invalid):** HTTP `400` with standard `{"ok": false, "error": "<nginx output>"}` response.
|
||||||
|
|
||||||
### Management
|
### Management Proxy
|
||||||
|
|
||||||
#### Configure Management WebUI Proxy
|
>The legacy `POST /api/proxy/management` endpoint has been removed. The management WebUI proxy is now configured as a regular domain entry with `is_management: true` on the root path and `is_websocket: true` on the `/ws` path. Use the standard domain add/update endpoints to configure it.
|
||||||
|
|
||||||
```
|
|
||||||
POST /api/proxy/management
|
|
||||||
```
|
|
||||||
|
|
||||||
Configure the nginx proxy block for the management WebUI itself, including optional HTTP basic authentication.
|
|
||||||
|
|
||||||
**Request Body:**
|
|
||||||
|
|
||||||
| Field | Type | Required | Description |
|
|
||||||
|-------|------|----------|-------------|
|
|
||||||
| `domain` | `string` | Yes | Management domain (e.g., `"myhost.local"`) |
|
|
||||||
| `flask_host` | `string` | No | Flask app bind host; defaults to `"127.0.0.1"` |
|
|
||||||
| `flask_port` | `number` | No | Flask app bind port; defaults to `9090` |
|
|
||||||
| `auth_user` | `string` | No | Username for basic auth |
|
|
||||||
| `auth_pass` | `string` | No | Password for basic auth |
|
|
||||||
|
|
||||||
**Response:** `data` is `null` on success.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+73
-23
@@ -76,37 +76,66 @@ Additional dnsmasq directives can be appended verbatim by placing plain-text fil
|
|||||||
|
|
||||||
**File**: `config/nginx/config.json`
|
**File**: `config/nginx/config.json`
|
||||||
|
|
||||||
This file defines reverse proxy domains, the management interface, and global SSL settings. The application renders it into per-domain server block files in `data/nginx/sites-enabled/` and into the shared SSL snippet at `/etc/nginx/snippets/vacuum-wall-ssl.conf`.
|
This file defines reverse proxy domains with path-based routing, and global SSL settings. The application renders it into per-domain server block files in `data/nginx/sites-enabled/` and into the shared SSL snippet at `/etc/nginx/snippets/vacuum-wall-ssl.conf`.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"domains": {
|
"domains": {
|
||||||
"app.example.com": {
|
"app.example.com": {
|
||||||
|
"force_ssl": true,
|
||||||
|
"cert": "acme",
|
||||||
|
"auth": {
|
||||||
|
"user": "admin",
|
||||||
|
"htpasswd": "/home/wall/vacuum-wall/data/nginx/.htpasswd"
|
||||||
|
},
|
||||||
|
"paths": {
|
||||||
|
"/": {
|
||||||
"backend": {
|
"backend": {
|
||||||
"host": "192.168.2.50",
|
"host": "192.168.2.50",
|
||||||
"port": 8080,
|
"port": 8080,
|
||||||
"proto": "http"
|
"proto": "http"
|
||||||
},
|
},
|
||||||
"force_ssl": true,
|
|
||||||
"cert": "acme",
|
|
||||||
"headers": {
|
"headers": {
|
||||||
"X-Forwarded-Proto": "https",
|
"X-Forwarded-Proto": "https"
|
||||||
"X-Real-IP": "$remote_addr"
|
}
|
||||||
|
},
|
||||||
|
"/api": {
|
||||||
|
"backend": {
|
||||||
|
"host": "192.168.2.51",
|
||||||
|
"port": 3000,
|
||||||
|
"proto": "http"
|
||||||
|
},
|
||||||
|
"auth": null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"management": {
|
"mgmt.example.com": {
|
||||||
"domain": "vacuum-wall.local",
|
"force_ssl": true,
|
||||||
|
"cert": "acme",
|
||||||
|
"paths": {
|
||||||
|
"/": {
|
||||||
"backend": {
|
"backend": {
|
||||||
"host": "127.0.0.1",
|
"host": "127.0.0.1",
|
||||||
"port": 9090,
|
"port": 9090,
|
||||||
"proto": "http"
|
"proto": "http"
|
||||||
},
|
},
|
||||||
|
"is_management": true,
|
||||||
"auth": {
|
"auth": {
|
||||||
"user": "admin",
|
"user": "admin",
|
||||||
"htpasswd": "/home/wall/vacuum-wall/data/nginx/.htpasswd"
|
"htpasswd": "/home/wall/vacuum-wall/data/nginx/.htpasswd"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/ws": {
|
||||||
|
"backend": {
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"port": 9091,
|
||||||
|
"proto": "http"
|
||||||
|
},
|
||||||
|
"is_websocket": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"ssl": {
|
"ssl": {
|
||||||
"protocols": "TLSv1.2 TLSv1.3",
|
"protocols": "TLSv1.2 TLSv1.3",
|
||||||
"ciphers": "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305",
|
"ciphers": "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305",
|
||||||
@@ -117,17 +146,40 @@ This file defines reverse proxy domains, the management interface, and global SS
|
|||||||
|
|
||||||
### Domain Entries
|
### Domain Entries
|
||||||
|
|
||||||
The `domains` object maps domain names (keys) to proxy configurations. Each entry produces a separate nginx `server` block.
|
The `domains` object maps domain names (keys) to proxy configurations. Each entry produces a separate nginx `server` block. All routing is path-based — a domain can proxy multiple paths to different backends.
|
||||||
|
|
||||||
| Field | Type | Required | Description |
|
| Field | Type | Required | Description |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `backend` | object | Yes | The upstream service that receives proxied traffic. |
|
| `paths` | object | Yes | Path-to-config map. Each key is a URL path (e.g., `"/"`, `"/api"`). No catch-all unless `"/"` is explicitly defined. |
|
||||||
|
| `force_ssl` | boolean | No | Enable HTTPS redirect. HTTP requests to this domain receive a 301 redirect to HTTPS. Default: `true`. |
|
||||||
|
| `cert` | string | No | Certificate provisioning method. One of: `"acme"`, `"file"`, or `"selfsigned"`. Omit for domains that don't need a dedicated cert. |
|
||||||
|
| `auth` | object | No | Domain-level HTTP basic auth configuration (`{ user, htppasswd }`). Applies to all paths unless overridden at the path level. |
|
||||||
|
|
||||||
|
### Path Entries
|
||||||
|
|
||||||
|
Each entry under `paths` defines a location block and its proxy backend.
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `backend` | object | Yes | The upstream service for this path. |
|
||||||
| `backend.host` | string | Yes | IP address or hostname of the backend service. |
|
| `backend.host` | string | Yes | IP address or hostname of the backend service. |
|
||||||
| `backend.port` | integer | Yes | Port the backend service is listening on. |
|
| `backend.port` | integer | Yes | Port the backend service is listening on. |
|
||||||
| `backend.proto` | string | No | Protocol for the backend connection: `http` or `https`. Default: `http`. |
|
| `backend.proto` | string | No | Protocol: `http` or `https`. Default: `http`. |
|
||||||
| `force_ssl` | boolean | No | Enable HTTPS redirect. HTTP requests to this domain receive a 301 redirect to HTTPS. Default: `true`. |
|
| `headers` | object | No | Custom proxy headers (key-value pairs). Supports nginx variable interpolation. |
|
||||||
| `headers` | object | No | Custom headers to set on proxied requests. Supports nginx variable interpolation (e.g., `$remote_addr`). |
|
| `auth` | object \| null | No | Path-level auth override. `{ user, htppasswd }` replaces domain-level auth. `null` disables auth for this path. |
|
||||||
| `cert` | string | No | Certificate provisioning method. One of: `"acme"`, `"file"`, or `"selfsigned"`. Omit for domains that don't need a dedicated cert. |
|
| `is_management` | boolean | No | Marks this path as the Vacuum Wall WebUI backend. Suppresses security headers (X-Frame-Options, etc.) so the SPA works correctly. |
|
||||||
|
| `is_websocket` | boolean | No | Marks this path as a WebSocket pass-through. Disables auth, sets Upgrade/Connection headers, uses extended timeouts. |
|
||||||
|
|
||||||
|
### Auth Inheritance Rules
|
||||||
|
|
||||||
|
- Domain-level `auth` applies to all paths unless overridden.
|
||||||
|
- Path-level `auth: null` means "no auth" for that path.
|
||||||
|
- Path-level `auth: { ... }` overrides domain-level for that path.
|
||||||
|
- No other domain-level settings inherit — `headers` is path-only.
|
||||||
|
|
||||||
|
### Path ordering
|
||||||
|
|
||||||
|
Nginx evaluates `location` blocks by specificity: more specific prefixes (e.g., `/api`) always match before `/` by nginx's own priority rules. The order of keys in the `paths` dict does not affect routing behavior.
|
||||||
|
|
||||||
### Certificate Types
|
### Certificate Types
|
||||||
|
|
||||||
@@ -141,22 +193,20 @@ The `cert` field is a string that selects the provisioning method:
|
|||||||
|
|
||||||
### Management Domain
|
### Management Domain
|
||||||
|
|
||||||
The `management` block configures the Vacuum Wall admin interface itself. It follows the same structure as a domain entry but can include an `auth` block for HTTP Basic Authentication.
|
The Vacuum Wall admin interface is configured as a regular domain entry under `domains`, with `is_management: true` on the path pointing to the Flask app. A second path (`/ws`) with `is_websocket: true` provides WebSocket pass-through for real-time state updates. This replaces the legacy `management` top-level key.
|
||||||
|
|
||||||
| Field | Type | Required | Description |
|
The application can create the `.htpasswd` file programmatically via `write_htpasswd()` (using passlib's SHA-256 crypt). Manual creation is also possible:
|
||||||
|---|---|---|---|
|
|
||||||
| `domain` | string | Yes | The hostname used to access the management WebUI (e.g., `<hostname>.local`). |
|
|
||||||
| `backend` | object | Yes | Points to the Flask app at `127.0.0.1:9090`. |
|
|
||||||
| `auth` | object | No | HTTP Basic Authentication configuration. Only created if `auth_user` is provided when setting the management proxy. |
|
|
||||||
| `auth.user` | string | Yes | Username for the `.htpasswd` file. |
|
|
||||||
| `auth.htpasswd` | string | Yes | Full path to the `.htpasswd` file containing the username and hashed password. |
|
|
||||||
|
|
||||||
The application can create the `.htpasswd` file programmatically via `write_htpasswd()` (using passlib's `apache_passwd` with Apache-Round-12, falling back to SHA-256 crypt). Manual creation is also possible:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
htpasswd -bc data/nginx/.htpasswd admin yourpassword
|
htpasswd -bc data/nginx/.htpasswd admin yourpassword
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Backward Compatibility
|
||||||
|
|
||||||
|
Config files using the legacy format are auto-migrated on first load:
|
||||||
|
- Domain entries with a top-level `backend` key are wrapped into `paths["/"]`.
|
||||||
|
- A legacy `management` top-level key is migrated into `domains[management.domain]` with `is_management` on the root path and a `/ws` WebSocket path.
|
||||||
|
|
||||||
### Global SSL Settings
|
### Global SSL Settings
|
||||||
|
|
||||||
The `ssl` block defines TLS parameters applied to all HTTPS server blocks via the shared snippet.
|
The `ssl` block defines TLS parameters applied to all HTTPS server blocks via the shared snippet.
|
||||||
|
|||||||
+19
-7
@@ -218,8 +218,12 @@ else
|
|||||||
log "acme.sh already installed."
|
log "acme.sh already installed."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Ensure the acme deploy hook script has correct permissions
|
# Install the deploy hook into acme.sh's deploy directory
|
||||||
chmod 0755 "${PROJECT_DIR}/system/acme-deploy.sh"
|
# (acme.sh only resolves hooks from $ACME_HOME/deploy/)
|
||||||
|
mkdir -p "$ACME_HOME/deploy"
|
||||||
|
cp "${PROJECT_DIR}/system/acme-deploy.sh" "$ACME_HOME/deploy/acme-deploy.sh"
|
||||||
|
chown "$USER_DAEMON_NAME:$USER_GROUP" "$ACME_HOME/deploy/acme-deploy.sh"
|
||||||
|
chmod 0755 "$ACME_HOME/deploy/acme-deploy.sh"
|
||||||
|
|
||||||
# --- 3. Setup directories ---
|
# --- 3. Setup directories ---
|
||||||
log "Creating config and data directories..."
|
log "Creating config and data directories..."
|
||||||
@@ -361,7 +365,7 @@ else
|
|||||||
"${PROJECT_DIR}/.venv/bin/python3" -c "
|
"${PROJECT_DIR}/.venv/bin/python3" -c "
|
||||||
import daemon.client as c
|
import daemon.client as c
|
||||||
from daemon.iface import (
|
from daemon.iface import (
|
||||||
POST_ACME_SELF_SIGNED, POST_NGINX_MANAGEMENT, POST_NGINX_APPLY,
|
POST_ACME_SELF_SIGNED, POST_NGINX_DOMAINS_ADD, POST_NGINX_APPLY,
|
||||||
POST_FIREWALL_CONFIG, POST_FIREWALL_CONFIG_APPLY, POST_NETWORK_SYSCTL_SET,
|
POST_FIREWALL_CONFIG, POST_FIREWALL_CONFIG_APPLY, POST_NETWORK_SYSCTL_SET,
|
||||||
GET_NETWORK_INFER_DHCP_RANGES,
|
GET_NETWORK_INFER_DHCP_RANGES,
|
||||||
)
|
)
|
||||||
@@ -380,12 +384,20 @@ try:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f' [cert] Warning: {e}', file=sys.stderr)
|
print(f' [cert] Warning: {e}', file=sys.stderr)
|
||||||
|
|
||||||
# Management proxy + htpasswd
|
# Management proxy domain + htpasswd
|
||||||
try:
|
try:
|
||||||
c.post(POST_NGINX_MANAGEMENT, {
|
c.post(POST_NGINX_DOMAINS_ADD, {
|
||||||
'domain': domain,
|
'domain': domain,
|
||||||
'flask_host': '127.0.0.1',
|
'paths': {
|
||||||
'flask_port': 9090,
|
'/': {
|
||||||
|
'backend': {'host': '127.0.0.1', 'port': 9090, 'proto': 'http'},
|
||||||
|
'is_management': True,
|
||||||
|
},
|
||||||
|
'/ws': {
|
||||||
|
'backend': {'host': '127.0.0.1', 'port': 9091, 'proto': 'http'},
|
||||||
|
'is_websocket': True,
|
||||||
|
},
|
||||||
|
},
|
||||||
'auth_user': mgmt_user,
|
'auth_user': mgmt_user,
|
||||||
'auth_pass': mgmt_pass,
|
'auth_pass': mgmt_pass,
|
||||||
})
|
})
|
||||||
|
|||||||
+99
-30
@@ -18,7 +18,9 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
PROJECT_DIR = Path(__file__).resolve().parent.parent
|
PROJECT_DIR = Path(__file__).resolve().parent.parent
|
||||||
_ACME_HOME = PROJECT_DIR / "data" / "acme"
|
_ACME_HOME = PROJECT_DIR / "data" / "acme"
|
||||||
_DEPLOY_HOOK = str(PROJECT_DIR / "system" / "acme-deploy.sh")
|
# acme.sh resolves deploy hooks from $ACME_HOME/deploy/ -- _findHook
|
||||||
|
# only searches the deploy subdirectory, never accepts absolute paths.
|
||||||
|
_DEPLOY_HOOK = "acme-deploy.sh"
|
||||||
|
|
||||||
_ACME_ENVIRON = {
|
_ACME_ENVIRON = {
|
||||||
"HOME": str(PROJECT_DIR),
|
"HOME": str(PROJECT_DIR),
|
||||||
@@ -157,10 +159,12 @@ def _read_acme_email() -> str:
|
|||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
logger.warning("Could not read account config: %s", exc)
|
logger.warning("Could not read account config: %s", exc)
|
||||||
# Fallback: read from declarative ACME config
|
# Fallback: read from declarative ACME config
|
||||||
|
# Derive project root from acme_home (acme_home is at <root>/data/acme).
|
||||||
try:
|
try:
|
||||||
from lib.common import load_json
|
from lib.common import load_json
|
||||||
|
|
||||||
acme_cfg = PROJECT_DIR / "config" / "acme" / "config.json"
|
project_root = acme_home.parent.parent
|
||||||
|
acme_cfg = project_root / "config" / "acme" / "config.json"
|
||||||
conf = load_json(acme_cfg)
|
conf = load_json(acme_cfg)
|
||||||
if conf and "email" in conf:
|
if conf and "email" in conf:
|
||||||
return conf["email"]
|
return conf["email"]
|
||||||
@@ -257,29 +261,31 @@ def list_certs() -> list[dict]:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
san_domains = [
|
san_domains = [
|
||||||
d.strip() for d in entry.get("san_domain", "").split(",") if d.strip()
|
d.strip()
|
||||||
|
for d in entry.get("san_domains", "").split(",")
|
||||||
|
if d.strip() and d.strip().lower() != "no"
|
||||||
]
|
]
|
||||||
|
|
||||||
cert_dir = acme_home / main
|
cert_dir = find_cert_dir(main, acme_home)
|
||||||
cert_path = str(cert_dir / "fullchain.cer")
|
cert_path = str(cert_dir / "fullchain.cer")
|
||||||
key_path = str(cert_dir / f"{main}.key")
|
key_path = str(cert_dir / f"{main}.key")
|
||||||
ca_path = str(cert_dir / "ca.cer")
|
ca_path = str(cert_dir / "ca.cer")
|
||||||
|
|
||||||
days = _days_until(entry.get("certificate_expires", ""))
|
days = _days_until(entry.get("renew", ""))
|
||||||
auto = _has_auto_renew(main)
|
auto = _has_auto_renew(main)
|
||||||
|
|
||||||
certs.append(
|
certs.append(
|
||||||
{
|
{
|
||||||
"domain": main,
|
"domain": main,
|
||||||
"issuer": entry.get("CA", ""),
|
"issuer": entry.get("ca", ""),
|
||||||
"expiry": entry.get("certificate_expires", ""),
|
"expiry": entry.get("renew", ""),
|
||||||
"days_remaining": days,
|
"days_remaining": days,
|
||||||
"expired": days is not None and days <= 0,
|
"expired": days is not None and days <= 0,
|
||||||
"cert_path": cert_path,
|
"cert_path": cert_path,
|
||||||
"key_path": key_path,
|
"key_path": key_path,
|
||||||
"ca_path": ca_path,
|
"ca_path": ca_path,
|
||||||
"issued_at": entry.get("certificate_date", ""),
|
"issued_at": entry.get("created", ""),
|
||||||
"expires_at": entry.get("certificate_expires", ""),
|
"expires_at": entry.get("renew", ""),
|
||||||
"days_until_expiry": days,
|
"days_until_expiry": days,
|
||||||
"auto_renew": auto,
|
"auto_renew": auto,
|
||||||
"san_domains": san_domains,
|
"san_domains": san_domains,
|
||||||
@@ -390,6 +396,34 @@ def copy_cert(domain: str, dest_dir: str) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def find_cert_dir(domain: str, acme_home: Path | None = None) -> Path:
|
||||||
|
"""Find the certificate directory for a domain.
|
||||||
|
|
||||||
|
acme.sh may name the directory {domain}/ (RSA) or {domain}_ecc/ (ECC).
|
||||||
|
Checks both and returns whichever exists. Falls back to {domain}/ if
|
||||||
|
neither exists (preserves original behaviour for forward compatibility).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
domain: The domain name.
|
||||||
|
acme_home: Override ACME home directory. Defaults to _ACME_HOME.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to the directory containing the cert files.
|
||||||
|
"""
|
||||||
|
if acme_home is None:
|
||||||
|
acme_home_env = os.environ.get("ACME_HOME", str(_ACME_HOME))
|
||||||
|
acme_home = Path(acme_home_env)
|
||||||
|
|
||||||
|
ecc_dir = acme_home / f"{domain}_ecc"
|
||||||
|
rsa_dir = acme_home / domain
|
||||||
|
|
||||||
|
if ecc_dir.is_dir():
|
||||||
|
return ecc_dir
|
||||||
|
if rsa_dir.is_dir():
|
||||||
|
return rsa_dir
|
||||||
|
return rsa_dir
|
||||||
|
|
||||||
|
|
||||||
def get_cert_paths(domain: str) -> dict:
|
def get_cert_paths(domain: str) -> dict:
|
||||||
"""Return the file paths for all certificate components.
|
"""Return the file paths for all certificate components.
|
||||||
|
|
||||||
@@ -399,13 +433,12 @@ def get_cert_paths(domain: str) -> dict:
|
|||||||
Returns:
|
Returns:
|
||||||
Dict with keys 'cert', 'key', 'ca', 'fullchain' mapped to paths.
|
Dict with keys 'cert', 'key', 'ca', 'fullchain' mapped to paths.
|
||||||
"""
|
"""
|
||||||
acme_home_env = os.environ.get("ACME_HOME", str(_ACME_HOME))
|
cert_dir = str(find_cert_dir(domain))
|
||||||
acme_home = str(Path(acme_home_env) / domain)
|
|
||||||
return {
|
return {
|
||||||
"cert": f"{acme_home}/{domain}.cert",
|
"cert": f"{cert_dir}/{domain}.cert",
|
||||||
"key": f"{acme_home}/{domain}.key",
|
"key": f"{cert_dir}/{domain}.key",
|
||||||
"ca": f"{acme_home}/ca.cer",
|
"ca": f"{cert_dir}/ca.cer",
|
||||||
"fullchain": f"{acme_home}/fullchain.cer",
|
"fullchain": f"{cert_dir}/fullchain.cer",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -437,28 +470,57 @@ def deploy(domain: str) -> None:
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _split_line(line: str, separator: str | None) -> list[str]:
|
||||||
|
"""Split a line by *separator*, falling back to whitespace for column output."""
|
||||||
|
if separator in line:
|
||||||
|
return line.split(separator)
|
||||||
|
return line.split()
|
||||||
|
|
||||||
|
|
||||||
def _parse_list_output(raw: str) -> list[dict]:
|
def _parse_list_output(raw: str) -> list[dict]:
|
||||||
"""Parse the text output from ``acme.sh --list`` into a list of dicts.
|
"""Parse output from ``acme.sh --list`` into a list of dicts.
|
||||||
|
|
||||||
Each line in the output contains ``Key:Value`` tokens separated by
|
Handles three formats depending on system capabilities:
|
||||||
whitespace, e.g.::
|
- Raw pipe-separated output (``|``)
|
||||||
|
- Tab-separated output (when ``column`` is unavailable)
|
||||||
|
- Column-aligned output (when ``column`` is available)
|
||||||
|
|
||||||
Main_Domain:example.com SAN_Domain:www.example.com CA:Let's
|
All formats share the same header: Main_Domain, KeyLength, SAN_Domains,
|
||||||
Encrypt Certificate_Date:2026-04-01 Certificate_Expired:No
|
Profile, CA, Created, Renew.
|
||||||
|
|
||||||
Keys are converted to lowercase in the returned dicts.
|
|
||||||
"""
|
"""
|
||||||
|
lines = raw.strip().splitlines()
|
||||||
|
|
||||||
|
if len(lines) < 2:
|
||||||
|
return []
|
||||||
|
|
||||||
|
header_line = lines[0]
|
||||||
|
|
||||||
|
# Detect separator from header: pipe, tab, or whitespace
|
||||||
|
if "|" in header_line:
|
||||||
|
headers = _split_line(header_line, "|")
|
||||||
|
separator = "|"
|
||||||
|
elif "\t" in header_line:
|
||||||
|
headers = _split_line(header_line, "\t")
|
||||||
|
separator = "\t"
|
||||||
|
else:
|
||||||
|
headers = _split_line(header_line, None) # whitespace
|
||||||
|
separator = None
|
||||||
|
|
||||||
|
if "Main_Domain" not in headers:
|
||||||
|
raise ValueError(
|
||||||
|
f"acme.sh --list output is not in expected format: {header_line!r}"
|
||||||
|
)
|
||||||
|
|
||||||
entries: list[dict] = []
|
entries: list[dict] = []
|
||||||
for line in raw.strip().splitlines():
|
for line in lines[1:]:
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
if not line:
|
if not line:
|
||||||
continue
|
continue
|
||||||
|
fields = _split_line(line, separator)
|
||||||
entry: dict[str, str] = {}
|
entry: dict[str, str] = {}
|
||||||
for token in line.split():
|
for i, h in enumerate(headers):
|
||||||
if ":" not in token:
|
if i < len(fields):
|
||||||
continue
|
entry[h.lower()] = fields[i].strip().strip('"')
|
||||||
key, _, value = token.partition(":")
|
|
||||||
entry[key.lower()] = value
|
|
||||||
if entry:
|
if entry:
|
||||||
entries.append(entry)
|
entries.append(entry)
|
||||||
return entries
|
return entries
|
||||||
@@ -468,9 +530,15 @@ def _days_until(date_str: str) -> int | None:
|
|||||||
"""Parse an ISO date string and return days until that date from now."""
|
"""Parse an ISO date string and return days until that date from now."""
|
||||||
if not date_str:
|
if not date_str:
|
||||||
return None
|
return None
|
||||||
for fmt in ("%Y-%m-%d", "%Y-%m-%dT%H:%M:%S"):
|
for fmt in (
|
||||||
|
"%Y-%m-%d",
|
||||||
|
"%Y-%m-%dT%H:%M:%SZ",
|
||||||
|
"%Y-%m-%dT%H:%M:%S",
|
||||||
|
"%Y-%m-%dT%H:%M:%S%z",
|
||||||
|
):
|
||||||
try:
|
try:
|
||||||
dt = datetime.strptime(date_str, fmt).replace(tzinfo=UTC)
|
dt = datetime.strptime(date_str, fmt)
|
||||||
|
dt = dt.replace(tzinfo=UTC) if dt.tzinfo is None else dt.astimezone(UTC)
|
||||||
delta = dt - datetime.now(UTC)
|
delta = dt - datetime.now(UTC)
|
||||||
return delta.days
|
return delta.days
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@@ -500,6 +568,7 @@ __all__ = [
|
|||||||
"copy_cert",
|
"copy_cert",
|
||||||
"days_until_expiry",
|
"days_until_expiry",
|
||||||
"deploy",
|
"deploy",
|
||||||
|
"find_cert_dir",
|
||||||
"get_cert_info",
|
"get_cert_info",
|
||||||
"get_cert_paths",
|
"get_cert_paths",
|
||||||
"get_email",
|
"get_email",
|
||||||
|
|||||||
+157
-108
@@ -13,6 +13,7 @@ from typing import Any
|
|||||||
|
|
||||||
from jinja2 import Environment, FileSystemLoader
|
from jinja2 import Environment, FileSystemLoader
|
||||||
|
|
||||||
|
from lib.acme import find_cert_dir
|
||||||
from lib.common import ensure_dirs, load_json, save_json
|
from lib.common import ensure_dirs, load_json, save_json
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -48,7 +49,6 @@ DEFAULT_SSL: dict[str, Any] = {
|
|||||||
|
|
||||||
DEFAULT_CONFIG: dict[str, Any] = {
|
DEFAULT_CONFIG: dict[str, Any] = {
|
||||||
"domains": {},
|
"domains": {},
|
||||||
"management": None,
|
|
||||||
"ssl": {**DEFAULT_SSL},
|
"ssl": {**DEFAULT_SSL},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,14 +58,71 @@ DEFAULT_CONFIG: dict[str, Any] = {
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_config(raw: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Migrate legacy config formats to the new paths-based model.
|
||||||
|
|
||||||
|
Handles two migrations:
|
||||||
|
1. Legacy ``management`` top-level key -> path entry under its domain.
|
||||||
|
2. Domain entries without ``paths`` -> wrap ``backend`` inside ``paths["/"]``.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
raw: Config dict as loaded from disk.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The migrated config dict.
|
||||||
|
"""
|
||||||
|
# Migrate management key
|
||||||
|
if "management" in raw and raw["management"] is not None:
|
||||||
|
mgmt = raw["management"]
|
||||||
|
mgmt_domain = mgmt.get("domain", "")
|
||||||
|
if mgmt_domain:
|
||||||
|
domains = raw.setdefault("domains", {})
|
||||||
|
if mgmt_domain not in domains:
|
||||||
|
domains[mgmt_domain] = {
|
||||||
|
"force_ssl": True,
|
||||||
|
"paths": {},
|
||||||
|
}
|
||||||
|
dom = domains[mgmt_domain]
|
||||||
|
paths = dom.setdefault("paths", {})
|
||||||
|
if "/" not in paths:
|
||||||
|
paths["/"] = {
|
||||||
|
"backend": {
|
||||||
|
"host": mgmt.get("backend", {}).get("host", "127.0.0.1"),
|
||||||
|
"port": mgmt.get("backend", {}).get("port", 9090),
|
||||||
|
"proto": "http",
|
||||||
|
},
|
||||||
|
"is_management": True,
|
||||||
|
}
|
||||||
|
if mgmt.get("auth"):
|
||||||
|
paths["/"]["auth"] = mgmt["auth"]
|
||||||
|
# Add WebSocket path if not present
|
||||||
|
if "/ws" not in paths:
|
||||||
|
paths["/ws"] = {
|
||||||
|
"backend": {"host": "127.0.0.1", "port": 9091, "proto": "http"},
|
||||||
|
"is_websocket": True,
|
||||||
|
}
|
||||||
|
del raw["management"]
|
||||||
|
|
||||||
|
# Migrate domain entries without paths
|
||||||
|
for dom in raw.get("domains", {}).values():
|
||||||
|
if "paths" not in dom and "backend" in dom:
|
||||||
|
dom["paths"] = {
|
||||||
|
"/": {
|
||||||
|
"backend": dom.pop("backend"),
|
||||||
|
"headers": dom.pop("headers", {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
def get_config() -> dict[str, Any]:
|
def get_config() -> dict[str, Any]:
|
||||||
"""Load the current nginx config, initializing with defaults if needed.
|
"""Load the current nginx config, initializing with defaults if needed.
|
||||||
|
|
||||||
Ensure config and sites directories exist, then return a copy of the
|
Ensures config and sites directories exist, applies migrations for
|
||||||
JSON file. On missing file or missing keys, populate from defaults.
|
legacy formats, then returns the config dict.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The complete config dict with ``domains``, ``ssl``, and ``management`` keys.
|
The complete config dict with ``domains`` and ``ssl`` keys.
|
||||||
"""
|
"""
|
||||||
ensure_dirs(CONFIG_DIR, SITES_DIR)
|
ensure_dirs(CONFIG_DIR, SITES_DIR)
|
||||||
raw = load_json(CONFIG_FILE)
|
raw = load_json(CONFIG_FILE)
|
||||||
@@ -73,6 +130,8 @@ def get_config() -> dict[str, Any]:
|
|||||||
raw = deepcopy(DEFAULT_CONFIG)
|
raw = deepcopy(DEFAULT_CONFIG)
|
||||||
if "ssl" not in raw:
|
if "ssl" not in raw:
|
||||||
raw["ssl"] = deepcopy(DEFAULT_SSL)
|
raw["ssl"] = deepcopy(DEFAULT_SSL)
|
||||||
|
raw = _migrate_config(raw)
|
||||||
|
save_config(raw)
|
||||||
return raw
|
return raw
|
||||||
|
|
||||||
|
|
||||||
@@ -82,26 +141,35 @@ def save_config(cfg: dict[str, Any]) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def get_domains() -> list[dict[str, Any]]:
|
def get_domains() -> list[dict[str, Any]]:
|
||||||
"""Return a list of all configured proxy domains with status.
|
"""Return a list of all configured proxy domains flattened by path.
|
||||||
|
|
||||||
Each entry includes the domain name, backend info, SSL flag, and
|
Each path within a domain becomes a separate entry with domain-level
|
||||||
whether a site config file currently exists on disk.
|
settings repeated.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of dicts with ``domain``, ``backend``, ``online``, and ``force_ssl``.
|
List of dicts with ``domain``, ``path``, ``backend``, ``online``,
|
||||||
|
``force_ssl``, and path-level flags.
|
||||||
"""
|
"""
|
||||||
cfg = get_config()
|
cfg = get_config()
|
||||||
result: list[dict[str, Any]] = []
|
result: list[dict[str, Any]] = []
|
||||||
for name, dom in cfg.get("domains", {}).items():
|
for name, dom in cfg.get("domains", {}).items():
|
||||||
site = SITES_DIR / f"{name}.conf"
|
site = SITES_DIR / f"{name}.conf"
|
||||||
result.append(
|
paths = dom.get("paths", {})
|
||||||
{
|
if not paths:
|
||||||
|
continue
|
||||||
|
for ppath, pcfg in paths.items():
|
||||||
|
entry: dict[str, Any] = {
|
||||||
"domain": name,
|
"domain": name,
|
||||||
"backend": dom.get("backend", {}),
|
"path": ppath,
|
||||||
|
"backend": pcfg.get("backend", {}),
|
||||||
"online": site.exists(),
|
"online": site.exists(),
|
||||||
"force_ssl": dom.get("force_ssl", True),
|
"force_ssl": dom.get("force_ssl", True),
|
||||||
}
|
}
|
||||||
)
|
if pcfg.get("is_management"):
|
||||||
|
entry["is_management"] = True
|
||||||
|
if pcfg.get("is_websocket"):
|
||||||
|
entry["is_websocket"] = True
|
||||||
|
result.append(entry)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -112,21 +180,24 @@ def get_domains() -> list[dict[str, Any]]:
|
|||||||
|
|
||||||
def add_domain(
|
def add_domain(
|
||||||
domain: str,
|
domain: str,
|
||||||
backend_host: str,
|
backend_host: str | None = None,
|
||||||
backend_port: int,
|
backend_port: int | None = None,
|
||||||
backend_proto: str = "http",
|
backend_proto: str = "http",
|
||||||
cert: str | None = None,
|
cert: str | None = None,
|
||||||
extra_headers: dict[str, str] | None = None,
|
extra_headers: dict[str, str] | None = None,
|
||||||
|
paths: dict[str, dict[str, Any]] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Add a new proxy domain with the given backend and optional settings.
|
"""Add a new proxy domain with the given backend and optional settings.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
domain: Domain name to add.
|
domain: Domain name to add.
|
||||||
backend_host: Upstream host to proxy to.
|
backend_host: Upstream host to proxy to (legacy mode).
|
||||||
backend_port: Upstream port.
|
backend_port: Upstream port (legacy mode).
|
||||||
backend_proto: Protocol (``http`` or ``https``).
|
backend_proto: Protocol (``http`` or ``https``; legacy mode).
|
||||||
cert: Optional certificate type identifier.
|
cert: Optional certificate type identifier.
|
||||||
extra_headers: Optional dict of extra headers to forward.
|
extra_headers: Optional dict of extra headers to forward (legacy mode).
|
||||||
|
paths: Optional path-to-config map (new mode). Each path entry must
|
||||||
|
have a ``backend`` key with ``host``, ``port``, and ``proto``.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: If the domain is already configured.
|
ValueError: If the domain is already configured.
|
||||||
@@ -134,27 +205,36 @@ def add_domain(
|
|||||||
cfg = get_config()
|
cfg = get_config()
|
||||||
if domain in cfg["domains"]:
|
if domain in cfg["domains"]:
|
||||||
raise ValueError(f"Domain {domain!r} already configured")
|
raise ValueError(f"Domain {domain!r} already configured")
|
||||||
|
|
||||||
|
if paths is not None:
|
||||||
entry: dict[str, Any] = {
|
entry: dict[str, Any] = {
|
||||||
|
"paths": paths,
|
||||||
|
"force_ssl": True,
|
||||||
|
}
|
||||||
|
if cert is not None:
|
||||||
|
entry["cert"] = cert
|
||||||
|
else:
|
||||||
|
if not backend_host or backend_port is None:
|
||||||
|
raise ValueError("'backend_host' and 'backend_port' are required")
|
||||||
|
entry = {
|
||||||
|
"paths": {
|
||||||
|
"/": {
|
||||||
"backend": {
|
"backend": {
|
||||||
"host": backend_host,
|
"host": backend_host,
|
||||||
"port": int(backend_port),
|
"port": int(backend_port),
|
||||||
"proto": backend_proto,
|
"proto": backend_proto,
|
||||||
},
|
},
|
||||||
|
"headers": extra_headers or {},
|
||||||
|
}
|
||||||
|
},
|
||||||
"force_ssl": True,
|
"force_ssl": True,
|
||||||
}
|
}
|
||||||
if cert is not None:
|
if cert is not None:
|
||||||
entry["cert"] = cert
|
entry["cert"] = cert
|
||||||
if extra_headers is not None:
|
|
||||||
entry["headers"] = extra_headers
|
|
||||||
cfg["domains"][domain] = entry
|
cfg["domains"][domain] = entry
|
||||||
save_config(cfg)
|
save_config(cfg)
|
||||||
logger.info(
|
logger.info("Proxy domain '%s' added", domain)
|
||||||
"Proxy domain '%s' added -> %s:%d (%s)",
|
|
||||||
domain,
|
|
||||||
backend_host,
|
|
||||||
backend_port,
|
|
||||||
backend_proto,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def remove_domain(domain: str) -> None:
|
def remove_domain(domain: str) -> None:
|
||||||
@@ -171,6 +251,11 @@ def remove_domain(domain: str) -> None:
|
|||||||
def update_domain(domain: str, **kwargs: Any) -> None:
|
def update_domain(domain: str, **kwargs: Any) -> None:
|
||||||
"""Update fields of an existing domain entry in-place.
|
"""Update fields of an existing domain entry in-place.
|
||||||
|
|
||||||
|
Supports both domain-level keys (``force_ssl``, ``cert``, ``auth``,
|
||||||
|
``paths``) and paths-level shorthand (``backend``, ``headers`` for
|
||||||
|
the root path). When ``paths`` is provided, it is fully replaced.
|
||||||
|
When ``backend`` is provided, it updates ``paths["/"]["backend"]``.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
domain: Domain name to update.
|
domain: Domain name to update.
|
||||||
**kwargs: Key-value pairs to merge into the domain config.
|
**kwargs: Key-value pairs to merge into the domain config.
|
||||||
@@ -182,7 +267,27 @@ def update_domain(domain: str, **kwargs: Any) -> None:
|
|||||||
if domain not in cfg["domains"]:
|
if domain not in cfg["domains"]:
|
||||||
raise KeyError(f"Domain {domain!r} not configured")
|
raise KeyError(f"Domain {domain!r} not configured")
|
||||||
entry = cfg["domains"][domain]
|
entry = cfg["domains"][domain]
|
||||||
|
|
||||||
|
# If paths is given, replace entirely
|
||||||
|
if "paths" in kwargs:
|
||||||
|
entry["paths"] = kwargs["paths"]
|
||||||
|
else:
|
||||||
|
# Legacy: top-level backend/headers -> paths["/"]
|
||||||
|
paths = entry.setdefault("paths", {})
|
||||||
|
if "backend" in kwargs:
|
||||||
|
root = paths.setdefault("/", {"backend": {}, "headers": {}})
|
||||||
|
root["backend"] = kwargs["backend"]
|
||||||
|
if "headers" in kwargs:
|
||||||
|
root = paths.setdefault("/", {"backend": {}, "headers": {}})
|
||||||
|
root["headers"] = kwargs["headers"]
|
||||||
|
|
||||||
|
# Remove legacy top-level keys from domain entry
|
||||||
|
entry.pop("backend", None)
|
||||||
|
entry.pop("headers", None)
|
||||||
|
|
||||||
for key, val in kwargs.items():
|
for key, val in kwargs.items():
|
||||||
|
if key in ("backend", "headers", "paths"):
|
||||||
|
continue
|
||||||
if isinstance(val, dict) and key in entry:
|
if isinstance(val, dict) and key in entry:
|
||||||
entry[key].update(val)
|
entry[key].update(val)
|
||||||
else:
|
else:
|
||||||
@@ -197,7 +302,7 @@ def update_domain(domain: str, **kwargs: Any) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def generate_server_conf(domain_cfg: dict[str, Any]) -> str:
|
def generate_server_conf(domain_cfg: dict[str, Any]) -> str:
|
||||||
"""Render the Jinja template for a standard domain server block.
|
"""Render the Jinja template for a domain server block.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
domain_cfg: Domain entry dict including the ``domain`` key.
|
domain_cfg: Domain entry dict including the ``domain`` key.
|
||||||
@@ -206,41 +311,28 @@ def generate_server_conf(domain_cfg: dict[str, Any]) -> str:
|
|||||||
The complete nginx server-block configuration as a string.
|
The complete nginx server-block configuration as a string.
|
||||||
"""
|
"""
|
||||||
tmpl = ENV.get_template("nginx/server_block.conf")
|
tmpl = ENV.get_template("nginx/server_block.conf")
|
||||||
|
acme_home_path = PROJECT_DIR / "data" / "acme"
|
||||||
|
acme_cert_dir = str(find_cert_dir(domain_cfg["domain"], acme_home_path))
|
||||||
|
paths = domain_cfg.get("paths", {})
|
||||||
|
has_management = any(p.get("is_management") for p in paths.values())
|
||||||
|
# Resolve custom cert paths for cert=="file"
|
||||||
|
cert_cfg = domain_cfg.get("cert")
|
||||||
|
if isinstance(cert_cfg, dict):
|
||||||
|
cert_path = cert_cfg.get("cert_path", "")
|
||||||
|
cert_key_path = cert_cfg.get("cert_key_path", "")
|
||||||
|
else:
|
||||||
|
cert_path = domain_cfg.get("cert_path", "")
|
||||||
|
cert_key_path = domain_cfg.get("cert_key_path", "")
|
||||||
return tmpl.render(
|
return tmpl.render(
|
||||||
domain=domain_cfg["domain"],
|
domain=domain_cfg["domain"],
|
||||||
backend=domain_cfg.get("backend", {}),
|
paths=paths,
|
||||||
headers=domain_cfg.get("headers", {}),
|
|
||||||
force_ssl=domain_cfg.get("force_ssl", True),
|
force_ssl=domain_cfg.get("force_ssl", True),
|
||||||
cert=domain_cfg.get("cert"),
|
cert=domain_cfg.get("cert"),
|
||||||
auth=domain_cfg.get("auth"),
|
cert_path=cert_path,
|
||||||
is_management=False,
|
cert_key_path=cert_key_path,
|
||||||
acme_home=str(PROJECT_DIR / "data" / "acme"),
|
domain_auth=domain_cfg.get("auth"),
|
||||||
certs_dir=str(PROJECT_DIR / "data" / "certs"),
|
has_management=has_management,
|
||||||
acme_webroot=str(PROJECT_DIR / "data" / "acme" / "www"),
|
acme_cert_dir=acme_cert_dir,
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _generate_management_conf(management: dict[str, Any]) -> str:
|
|
||||||
"""Render the Jinja template for the management WebUI server block.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
management: Management proxy config dict containing ``domain`` and optional ``auth``.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The complete nginx server-block configuration for the management UI.
|
|
||||||
"""
|
|
||||||
tmpl = ENV.get_template("nginx/server_block.conf")
|
|
||||||
return tmpl.render(
|
|
||||||
domain=management.get("domain"),
|
|
||||||
backend=dict(
|
|
||||||
management.get("backend", {}), host="127.0.0.1", port=9090, proto="http"
|
|
||||||
),
|
|
||||||
headers={},
|
|
||||||
force_ssl=True,
|
|
||||||
cert=None,
|
|
||||||
auth=management.get("auth"),
|
|
||||||
is_management=True,
|
|
||||||
acme_home=str(PROJECT_DIR / "data" / "acme"),
|
|
||||||
certs_dir=str(PROJECT_DIR / "data" / "certs"),
|
certs_dir=str(PROJECT_DIR / "data" / "certs"),
|
||||||
acme_webroot=str(PROJECT_DIR / "data" / "acme" / "www"),
|
acme_webroot=str(PROJECT_DIR / "data" / "acme" / "www"),
|
||||||
)
|
)
|
||||||
@@ -290,8 +382,8 @@ def write_acme_challenge() -> None:
|
|||||||
def write_all_sites() -> None:
|
def write_all_sites() -> None:
|
||||||
"""Regenerate all site configs from the current config state.
|
"""Regenerate all site configs from the current config state.
|
||||||
|
|
||||||
Writes server blocks for every configured domain and the management
|
Writes server blocks for every configured domain (now unified, including
|
||||||
proxy (if any), removes orphaned site files, and ensures the ACME
|
any management paths), removes orphaned site files, and ensures the ACME
|
||||||
challenge config is present.
|
challenge config is present.
|
||||||
"""
|
"""
|
||||||
ensure_dirs(SITES_DIR)
|
ensure_dirs(SITES_DIR)
|
||||||
@@ -306,10 +398,10 @@ def write_all_sites() -> None:
|
|||||||
write_site(name, conf)
|
write_site(name, conf)
|
||||||
written.add(f"{name}.conf")
|
written.add(f"{name}.conf")
|
||||||
|
|
||||||
if cfg.get("management"):
|
# Remove old management.conf if it exists
|
||||||
mgmt_conf = _generate_management_conf(cfg["management"])
|
old_mgmt = SITES_DIR / "management.conf"
|
||||||
write_site("management", mgmt_conf)
|
if old_mgmt.exists() and old_mgmt.name not in written:
|
||||||
written.add("management.conf")
|
old_mgmt.unlink()
|
||||||
|
|
||||||
for old in existing:
|
for old in existing:
|
||||||
if old.suffix == ".conf" and old.name not in written:
|
if old.suffix == ".conf" and old.name not in written:
|
||||||
@@ -406,48 +498,6 @@ def apply() -> None:
|
|||||||
logger.info("nginx configuration applied and reloaded")
|
logger.info("nginx configuration applied and reloaded")
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Management WebUI
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def set_management_proxy(
|
|
||||||
domain: str,
|
|
||||||
flask_host: str = "127.0.0.1",
|
|
||||||
flask_port: int = 9090,
|
|
||||||
auth_user: str | None = None,
|
|
||||||
auth_pass: str | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Configure the management reverse proxy for the WebUI.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
domain: Management domain name.
|
|
||||||
flask_host: Upstream Flask host (default ``127.0.0.1``).
|
|
||||||
flask_port: Upstream Flask port (default ``9090``).
|
|
||||||
auth_user: Optional basic-auth username.
|
|
||||||
auth_pass: Optional basic-auth password; writes htpasswd when provided with ``auth_user``.
|
|
||||||
"""
|
|
||||||
cfg = get_config()
|
|
||||||
entry: dict[str, Any] = {
|
|
||||||
"domain": domain,
|
|
||||||
"backend": {
|
|
||||||
"host": flask_host,
|
|
||||||
"port": int(flask_port),
|
|
||||||
"proto": "http",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if auth_user:
|
|
||||||
entry["auth"] = {
|
|
||||||
"user": auth_user,
|
|
||||||
"htpasswd": str(HTPASSWD_FILE),
|
|
||||||
}
|
|
||||||
cfg["management"] = entry
|
|
||||||
save_config(cfg)
|
|
||||||
if auth_user and auth_pass:
|
|
||||||
write_htpasswd(auth_user, auth_pass)
|
|
||||||
logger.info("Management proxy set to '%s'", domain)
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# htpasswd
|
# htpasswd
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -505,7 +555,6 @@ __all__ = [
|
|||||||
"get_domains",
|
"get_domains",
|
||||||
"remove_domain",
|
"remove_domain",
|
||||||
"save_config",
|
"save_config",
|
||||||
"set_management_proxy",
|
|
||||||
"test_config",
|
"test_config",
|
||||||
"update_domain",
|
"update_domain",
|
||||||
"write_acme_challenge",
|
"write_acme_challenge",
|
||||||
|
|||||||
+91
-149
@@ -7,8 +7,6 @@ state instead of invoking subprocesses on every request.
|
|||||||
import contextlib
|
import contextlib
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -28,6 +26,11 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
PROJECT_DIR = Path(__file__).resolve().parent.parent
|
PROJECT_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
_CA_NAME_MAP: dict[str, str] = {
|
||||||
|
"letsencrypt": "Let's Encrypt",
|
||||||
|
"zerossl": "ZeroSSL",
|
||||||
|
}
|
||||||
|
|
||||||
_DEFAULT_POLL_INTERVALS: dict[str, int] = {
|
_DEFAULT_POLL_INTERVALS: dict[str, int] = {
|
||||||
"firewall": 30,
|
"firewall": 30,
|
||||||
"wireguard": 10,
|
"wireguard": 10,
|
||||||
@@ -633,7 +636,6 @@ def _collect_nginx() -> dict[str, Any]:
|
|||||||
|
|
||||||
default_cfg: dict[str, Any] = {
|
default_cfg: dict[str, Any] = {
|
||||||
"domains": {},
|
"domains": {},
|
||||||
"management": None,
|
|
||||||
"ssl": deepcopy(DEFAULT_SSL),
|
"ssl": deepcopy(DEFAULT_SSL),
|
||||||
}
|
}
|
||||||
cfg = deepcopy(default_cfg)
|
cfg = deepcopy(default_cfg)
|
||||||
@@ -649,18 +651,27 @@ def _collect_nginx() -> dict[str, Any]:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Build domains list with site existence
|
# Build flattened domains list (one entry per path)
|
||||||
domains: list[dict[str, Any]] = []
|
domains: list[dict[str, Any]] = []
|
||||||
for name, dom in cfg.get("domains", {}).items():
|
for name, dom in cfg.get("domains", {}).items():
|
||||||
site = SITES_DIR / f"{name}.conf"
|
site = SITES_DIR / f"{name}.conf"
|
||||||
domains.append(
|
paths = dom.get("paths", {})
|
||||||
{
|
if not paths:
|
||||||
|
continue
|
||||||
|
for ppath, pcfg in paths.items():
|
||||||
|
entry: dict[str, Any] = {
|
||||||
"domain": name,
|
"domain": name,
|
||||||
"backend": dom.get("backend", {}),
|
"path": ppath,
|
||||||
|
"backend": pcfg.get("backend", {}),
|
||||||
"online": site.exists() if SITES_DIR.exists() else False,
|
"online": site.exists() if SITES_DIR.exists() else False,
|
||||||
"force_ssl": dom.get("force_ssl", True),
|
"force_ssl": dom.get("force_ssl", True),
|
||||||
|
"cert": dom.get("cert"),
|
||||||
}
|
}
|
||||||
)
|
if pcfg.get("is_management"):
|
||||||
|
entry["is_management"] = True
|
||||||
|
if pcfg.get("is_websocket"):
|
||||||
|
entry["is_websocket"] = True
|
||||||
|
domains.append(entry)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"config": cfg,
|
"config": cfg,
|
||||||
@@ -677,117 +688,34 @@ register_collector("nginx", _collect_nginx)
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _find_acme() -> str:
|
def _resolve_ca_name(ca_server: str) -> str:
|
||||||
"""Locate the ``acme.sh`` binary on the filesystem.
|
"""Map a CA server identifier to its human-readable name.
|
||||||
|
|
||||||
Returns:
|
Uses prefix matching sorted by longest prefix first to avoid
|
||||||
Absolute path to the ``acme.sh`` executable.
|
shorter prefixes winning (e.g. "letsencrypt" matching before
|
||||||
|
"letsencrypt.org").
|
||||||
Raises:
|
|
||||||
FileNotFoundError: If acme.sh cannot be found.
|
|
||||||
"""
|
|
||||||
acme_home = PROJECT_DIR / "data" / "acme"
|
|
||||||
candidates = [acme_home / "acme.sh", Path("/usr/local/bin/acme.sh")]
|
|
||||||
for path in candidates:
|
|
||||||
if path.is_file() and os.access(path, os.X_OK):
|
|
||||||
return str(path)
|
|
||||||
acme = shutil.which("acme.sh")
|
|
||||||
if acme:
|
|
||||||
return acme
|
|
||||||
raise FileNotFoundError("acme.sh not found")
|
|
||||||
|
|
||||||
|
|
||||||
def _run_acme(args: list[str]) -> str:
|
|
||||||
"""Run ``acme.sh`` with *args* and return combined output.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
args: Command-line arguments to pass after the home/config flags.
|
ca_server: Raw CA server string from acme.sh config.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Combined stdout/stderr output.
|
Human-readable name, or unchanged string if no match.
|
||||||
|
|
||||||
Raises:
|
|
||||||
RuntimeError: If acme.sh exits non-zero or times out.
|
|
||||||
"""
|
"""
|
||||||
acme_bin = _find_acme()
|
for prefix, name in sorted(
|
||||||
acme_home_env = os.environ.get("ACME_HOME", str(PROJECT_DIR / "data" / "acme"))
|
_CA_NAME_MAP.items(), key=lambda x: len(x[0]), reverse=True
|
||||||
cmd = [acme_bin, "--home", acme_home_env, "--config-home", acme_home_env, *args]
|
):
|
||||||
_ACME_ENVIRON = {
|
if ca_server.startswith(prefix):
|
||||||
"HOME": str(PROJECT_DIR),
|
return name
|
||||||
"PATH": os.environ.get(
|
return ca_server
|
||||||
"PATH", "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
cmd,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=120,
|
|
||||||
env={**os.environ, **_ACME_ENVIRON},
|
|
||||||
)
|
|
||||||
except subprocess.TimeoutExpired as exc:
|
|
||||||
raise RuntimeError(f"acme.sh timed out: {' '.join(cmd)}") from exc
|
|
||||||
output = result.stdout
|
|
||||||
if result.stderr:
|
|
||||||
output = output + result.stderr if output else result.stderr
|
|
||||||
if result.returncode != 0:
|
|
||||||
raise RuntimeError(f"acme.sh failed (rc={result.returncode}): {output.strip()}")
|
|
||||||
return output
|
|
||||||
|
|
||||||
|
|
||||||
def _days_until(date_str: str) -> int | None:
|
|
||||||
"""Parse a date string and return days until *date_str* from now.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
date_str: Date string in common ACME formats.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Number of days remaining, or ``None`` if empty or unparseable.
|
|
||||||
"""
|
|
||||||
if not date_str:
|
|
||||||
return None
|
|
||||||
for fmt in ("%Y-%m-%d", "%Y-%m-%dT%H:%M:%S"):
|
|
||||||
try:
|
|
||||||
dt = datetime.strptime(date_str, fmt).replace(tzinfo=UTC)
|
|
||||||
return (dt - datetime.now(UTC)).days
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
dt = datetime.strptime(date_str, "%Y%m%d%H%M%z").astimezone(UTC)
|
|
||||||
return (dt - datetime.now(UTC)).days
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_acme_list_output(raw: str) -> list[dict]:
|
|
||||||
"""Parse ``acme.sh --list`` output into a list of certificate dicts.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
raw: Raw output string from ``acme.sh --list``.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of dicts with certificate entry fields.
|
|
||||||
"""
|
|
||||||
entries: list[dict] = []
|
|
||||||
for line in raw.strip().splitlines():
|
|
||||||
line = line.strip()
|
|
||||||
if not line:
|
|
||||||
continue
|
|
||||||
entry: dict[str, str] = {}
|
|
||||||
for token in line.split():
|
|
||||||
if ":" not in token:
|
|
||||||
continue
|
|
||||||
key, _, value = token.partition(":")
|
|
||||||
entry[key.lower()] = value
|
|
||||||
if entry:
|
|
||||||
entries.append(entry)
|
|
||||||
return entries
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_account_conf(acme_home: Path | None = None) -> dict[str, Any]:
|
def _parse_account_conf(acme_home: Path | None = None) -> dict[str, Any]:
|
||||||
"""Parse acme.sh .account.conf and return account status dict.
|
"""Parse acme.sh account information and return account status dict.
|
||||||
|
|
||||||
|
Checks three sources in order:
|
||||||
|
1. Legacy ``.account.conf`` file (acme.sh v2.x format)
|
||||||
|
2. Declarative ``config/acme/config.json`` (saved by the registration
|
||||||
|
handler with ``email`` and ``ca`` fields)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
acme_home: Optional override for ACME home directory. Falls back
|
acme_home: Optional override for ACME home directory. Falls back
|
||||||
@@ -795,13 +723,12 @@ def _parse_account_conf(acme_home: Path | None = None) -> dict[str, Any]:
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with ``registered``, ``email``, ``ca``, and
|
Dict with ``registered``, ``email``, ``ca``, and
|
||||||
``key_length`` keys. If the file is missing or keys are absent,
|
``key_length`` keys. If no account is found, ``registered`` is
|
||||||
``registered`` is ``False`` with empty / ``None`` values.
|
``False`` with empty / ``None`` values.
|
||||||
"""
|
"""
|
||||||
if acme_home is None:
|
if acme_home is None:
|
||||||
acme_home_env = os.environ.get("ACME_HOME", str(PROJECT_DIR / "data" / "acme"))
|
acme_home_env = os.environ.get("ACME_HOME", str(PROJECT_DIR / "data" / "acme"))
|
||||||
acme_home = Path(acme_home_env)
|
acme_home = Path(acme_home_env)
|
||||||
account_path = acme_home / ".account.conf"
|
|
||||||
|
|
||||||
default = {
|
default = {
|
||||||
"registered": False,
|
"registered": False,
|
||||||
@@ -810,18 +737,17 @@ def _parse_account_conf(acme_home: Path | None = None) -> dict[str, Any]:
|
|||||||
"key_length": None,
|
"key_length": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
if not account_path.is_file():
|
# 1. Legacy .account.conf (acme.sh v2.x)
|
||||||
return default
|
account_path = acme_home / ".account.conf"
|
||||||
|
if account_path.is_file():
|
||||||
try:
|
try:
|
||||||
text = account_path.read_text()
|
text = account_path.read_text()
|
||||||
except OSError:
|
except OSError:
|
||||||
return default
|
pass
|
||||||
|
else:
|
||||||
email = ""
|
email = ""
|
||||||
ca_raw = ""
|
ca_raw = ""
|
||||||
key_length = None
|
key_length = None
|
||||||
|
|
||||||
for line in text.splitlines():
|
for line in text.splitlines():
|
||||||
if line.startswith("ACME_LEEMAIL="):
|
if line.startswith("ACME_LEEMAIL="):
|
||||||
email = line.split("=", 1)[1].strip().strip("'\"")
|
email = line.split("=", 1)[1].strip().strip("'\"")
|
||||||
@@ -830,35 +756,37 @@ def _parse_account_conf(acme_home: Path | None = None) -> dict[str, Any]:
|
|||||||
elif line.startswith("ACME_CERTKEYSIZE="):
|
elif line.startswith("ACME_CERTKEYSIZE="):
|
||||||
raw_val = line.split("=", 1)[1].strip().strip("'\"")
|
raw_val = line.split("=", 1)[1].strip().strip("'\"")
|
||||||
key_length = int(raw_val) if raw_val.isdigit() else None
|
key_length = int(raw_val) if raw_val.isdigit() else None
|
||||||
|
if email and ca_raw:
|
||||||
if not email or not ca_raw:
|
|
||||||
return default
|
|
||||||
|
|
||||||
ca_map = {
|
|
||||||
"letsencrypt": "Let's Encrypt",
|
|
||||||
"zerossl": "ZeroSSL",
|
|
||||||
}
|
|
||||||
ca = ca_map.get(ca_raw, ca_raw)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"registered": True,
|
"registered": True,
|
||||||
"email": email,
|
"email": email,
|
||||||
"ca": ca,
|
"ca": _resolve_ca_name(ca_raw),
|
||||||
"key_length": key_length,
|
"key_length": key_length,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 2. Declarative config (saved by register_account / set_email handlers)
|
||||||
|
# Modern acme.sh (v3.x) stores account data in per-CA JSON files
|
||||||
|
# (ca/<server>/account.json) — we can't reliably parse those without
|
||||||
|
# walking the directory, so fall back to the declarative config
|
||||||
|
# which the handlers keep in sync.
|
||||||
|
# Derive project root from acme_home (acme_home is at <root>/data/acme).
|
||||||
|
try:
|
||||||
|
project_root = acme_home.parent.parent # data/acme → data → project root
|
||||||
|
acme_cfg = project_root / "config" / "acme" / "config.json"
|
||||||
|
data = load_json(acme_cfg)
|
||||||
|
email = (data.get("email") or "").strip()
|
||||||
|
ca_raw = (data.get("ca") or "").strip()
|
||||||
|
if email and ca_raw:
|
||||||
|
return {
|
||||||
|
"registered": True,
|
||||||
|
"email": email,
|
||||||
|
"ca": _resolve_ca_name(ca_raw),
|
||||||
|
"key_length": None,
|
||||||
|
}
|
||||||
|
except (OSError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
def _has_auto_renew(domain: str) -> bool:
|
return default
|
||||||
"""Check whether *domain* has an auto-renew configuration file.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
domain: Domain name to check.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
``True`` if a corresponding ``acme.sh`` config file exists.
|
|
||||||
"""
|
|
||||||
acme_home_env = os.environ.get("ACME_HOME", str(PROJECT_DIR / "data" / "acme"))
|
|
||||||
return bool(Path(acme_home_env) / f"{domain}.conf")
|
|
||||||
|
|
||||||
|
|
||||||
def _get_acme_email() -> str:
|
def _get_acme_email() -> str:
|
||||||
@@ -882,8 +810,16 @@ def _collect_acme() -> dict[str, Any]:
|
|||||||
|
|
||||||
certs: list[dict[str, Any]] = []
|
certs: list[dict[str, Any]] = []
|
||||||
try:
|
try:
|
||||||
|
from lib.acme import (
|
||||||
|
_days_until,
|
||||||
|
_has_auto_renew,
|
||||||
|
_parse_list_output,
|
||||||
|
_run_acme,
|
||||||
|
)
|
||||||
|
|
||||||
raw = _run_acme(["--list"])
|
raw = _run_acme(["--list"])
|
||||||
entries = _parse_acme_list_output(raw)
|
|
||||||
|
entries = _parse_list_output(raw)
|
||||||
acme_home_env = os.environ.get("ACME_HOME", str(PROJECT_DIR / "data" / "acme"))
|
acme_home_env = os.environ.get("ACME_HOME", str(PROJECT_DIR / "data" / "acme"))
|
||||||
acme_home = Path(acme_home_env)
|
acme_home = Path(acme_home_env)
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
@@ -891,29 +827,35 @@ def _collect_acme() -> dict[str, Any]:
|
|||||||
if not main:
|
if not main:
|
||||||
continue
|
continue
|
||||||
san_domains = [
|
san_domains = [
|
||||||
d.strip() for d in entry.get("san_domain", "").split(",") if d.strip()
|
d.strip()
|
||||||
|
for d in entry.get("san_domains", "").split(",")
|
||||||
|
if d.strip() and d.strip().lower() != "no"
|
||||||
]
|
]
|
||||||
cert_dir = acme_home / main
|
cert_dir = acme_home / main
|
||||||
days = _days_until(entry.get("certificate_expires", ""))
|
days = _days_until(entry.get("renew", ""))
|
||||||
certs.append(
|
certs.append(
|
||||||
{
|
{
|
||||||
"domain": main,
|
"domain": main,
|
||||||
"issuer": entry.get("CA", ""),
|
"issuer": entry.get("ca", ""),
|
||||||
"expiry": entry.get("certificate_expires", ""),
|
"expiry": entry.get("renew", ""),
|
||||||
"days_remaining": days,
|
"days_remaining": days,
|
||||||
"expired": days is not None and days <= 0,
|
"expired": days is not None and days <= 0,
|
||||||
"cert_path": str(cert_dir / "fullchain.cer"),
|
"cert_path": str(cert_dir / "fullchain.cer"),
|
||||||
"key_path": str(cert_dir / f"{main}.key"),
|
"key_path": str(cert_dir / f"{main}.key"),
|
||||||
"ca_path": str(cert_dir / "ca.cer"),
|
"ca_path": str(cert_dir / "ca.cer"),
|
||||||
"issued_at": entry.get("certificate_date", ""),
|
"issued_at": entry.get("created", ""),
|
||||||
"expires_at": entry.get("certificate_expires", ""),
|
"expires_at": entry.get("renew", ""),
|
||||||
"days_until_expiry": days,
|
"days_until_expiry": days,
|
||||||
"auto_renew": _has_auto_renew(main),
|
"auto_renew": _has_auto_renew(main),
|
||||||
"san_domains": san_domains,
|
"san_domains": san_domains,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
logger.warning(
|
||||||
|
"ACME state collection failed, returning empty cert list",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
account = _parse_account_conf()
|
account = _parse_account_conf()
|
||||||
|
|
||||||
|
|||||||
@@ -12,94 +12,97 @@ server {
|
|||||||
root {{ acme_webroot }};
|
root {{ acme_webroot }};
|
||||||
}
|
}
|
||||||
|
|
||||||
# Redirect all HTTP traffic to HTTPS
|
|
||||||
return 301 https://$host$request_uri;
|
return 301 https://$host$request_uri;
|
||||||
}
|
}
|
||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 443 ssl;
|
listen 443 ssl;
|
||||||
listen [::]:443 ssl;
|
listen [::]:443 ssl;
|
||||||
server_name {{ domain }};
|
server_name {{ domain }};
|
||||||
|
|
||||||
{% if cert %}
|
{% if cert %}
|
||||||
{% if cert.type == "acme" %}
|
{% if cert == "acme" %}
|
||||||
# Certificate managed by acme.sh
|
ssl_certificate {{ acme_cert_dir }}/fullchain.cer;
|
||||||
{% if cert.email %} # ACME contact: {{ cert.email }}
|
ssl_certificate_key {{ acme_cert_dir }}/{{ domain }}.key;
|
||||||
{% endif %} ssl_certificate {{ acme_home }}/{{ domain }}/fullchain.cer;
|
{% elif cert == "file" %}
|
||||||
ssl_certificate_key {{ acme_home }}/{{ domain }}/{{ domain }}.key;
|
ssl_certificate {{ cert_path }};
|
||||||
|
ssl_certificate_key {{ cert_key_path }};
|
||||||
{% elif cert.type == "file" %}
|
{% elif cert == "selfsigned" %}
|
||||||
ssl_certificate {{ cert.path }};
|
|
||||||
ssl_certificate_key {{ cert.key_path }};
|
|
||||||
|
|
||||||
{% elif cert.type == "selfsigned" %}
|
|
||||||
ssl_certificate {{ certs_dir }}/{{ domain }}.crt;
|
ssl_certificate {{ certs_dir }}/{{ domain }}.crt;
|
||||||
ssl_certificate_key {{ certs_dir }}/{{ domain }}.key;
|
ssl_certificate_key {{ certs_dir }}/{{ domain }}.key;
|
||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% elif is_management %}
|
{% elif has_management %}
|
||||||
ssl_certificate {{ acme_home }}/{{ domain }}/fullchain.cer;
|
ssl_certificate {{ acme_cert_dir }}/fullchain.cer;
|
||||||
ssl_certificate_key {{ acme_home }}/{{ domain }}/{{ domain }}.key;
|
ssl_certificate_key {{ acme_cert_dir }}/{{ domain }}.key;
|
||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
# Shared SSL settings
|
|
||||||
include snippets/vacuum-wall-ssl.conf;
|
include snippets/vacuum-wall-ssl.conf;
|
||||||
|
|
||||||
{% if auth %}
|
{% if domain_auth %}
|
||||||
# HTTP basic authentication
|
auth_basic "Restricted";
|
||||||
auth_basic "{{ "Vacuum Wall" if is_management else "Restricted" }}";
|
auth_basic_user_file {{ domain_auth.htpasswd }};
|
||||||
auth_basic_user_file {{ auth.htpasswd }};
|
|
||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if not is_management %}
|
|
||||||
# Security hardening headers
|
{% if not has_management %}
|
||||||
add_header X-Content-Type-Options nosniff always;
|
add_header X-Content-Type-Options nosniff always;
|
||||||
add_header X-Frame-Options DENY always;
|
add_header X-Frame-Options DENY always;
|
||||||
add_header X-XSS-Protection "1; mode=block" always;
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
{% endif %}
|
{% endif %}
|
||||||
location / {
|
|
||||||
# Proxy headers
|
{% for ppath, pcfg in paths.items() %}
|
||||||
|
{% if pcfg.is_websocket %}
|
||||||
|
# {{ ppath }} -> {{ pcfg.backend.host }}:{{ pcfg.backend.port }} (WebSocket)
|
||||||
|
location {{ ppath }} {
|
||||||
|
auth_basic off;
|
||||||
|
proxy_pass http://{{ pcfg.backend.host }}:{{ pcfg.backend.port }};
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
{% if not is_management %}
|
proxy_read_timeout 86400s;
|
||||||
{% for hname, hval in headers.items() %}
|
proxy_send_timeout 86400s;
|
||||||
|
}
|
||||||
|
{% else %}
|
||||||
|
# {{ ppath }} -> {{ pcfg.backend.host }}:{{ pcfg.backend.port }}
|
||||||
|
location {{ ppath }} {
|
||||||
|
{% if pcfg.auth is none %}
|
||||||
|
auth_basic off;
|
||||||
|
{% elif pcfg.auth is defined %}
|
||||||
|
auth_basic "Restricted";
|
||||||
|
auth_basic_user_file {{ pcfg.auth.htpasswd }};
|
||||||
|
{% endif %}
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
{% if not pcfg.is_management %}
|
||||||
|
{% for hname, hval in (pcfg.headers or {}).items() %}
|
||||||
proxy_set_header {{ hname }} {{ hval }};
|
proxy_set_header {{ hname }} {{ hval }};
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
# Proxy pass to backend
|
proxy_pass {{ pcfg.backend.proto }}://{{ pcfg.backend.host }}:{{ pcfg.backend.port }};
|
||||||
proxy_pass {{ backend.proto }}://{{ backend.host }}:{{ backend.port }};
|
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|
||||||
# Timeouts
|
|
||||||
proxy_connect_timeout 30s;
|
proxy_connect_timeout 30s;
|
||||||
proxy_send_timeout 60s;
|
proxy_send_timeout 60s;
|
||||||
proxy_read_timeout 60s;
|
proxy_read_timeout 60s;
|
||||||
proxy_buffering off;
|
proxy_buffering off;
|
||||||
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection $connection_upgrade;
|
proxy_set_header Connection $connection_upgrade;
|
||||||
}
|
}
|
||||||
{% if is_management %}
|
|
||||||
location /ws {
|
|
||||||
auth_basic off;
|
|
||||||
proxy_pass http://127.0.0.1:9091;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection "upgrade";
|
|
||||||
}
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
{% if not is_management %}
|
{% if has_management %}
|
||||||
# Access / error logs
|
|
||||||
access_log /var/log/nginx/{{ domain }}_access.log;
|
|
||||||
error_log /var/log/nginx/{{ domain }}_error.log warn;
|
|
||||||
{% else %}
|
|
||||||
access_log /var/log/nginx/wall_mgmt_access.log;
|
access_log /var/log/nginx/wall_mgmt_access.log;
|
||||||
error_log /var/log/nginx/wall_mgmt_error.log warn;
|
error_log /var/log/nginx/wall_mgmt_error.log warn;
|
||||||
|
{% else %}
|
||||||
|
access_log /var/log/nginx/{{ domain }}_access.log;
|
||||||
|
error_log /var/log/nginx/{{ domain }}_error.log warn;
|
||||||
{% endif %}
|
{% endif %}
|
||||||
}
|
}
|
||||||
@@ -14,6 +14,7 @@ Defaults:{{ USER_DAEMON_NAME }} secure_path="/usr/local/sbin:/usr/local/bin:/usr
|
|||||||
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp * /etc/nginx/snippets/*
|
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp * /etc/nginx/snippets/*
|
||||||
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/rm /etc/nginx/conf.d/vacuum-wall.conf
|
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/rm /etc/nginx/conf.d/vacuum-wall.conf
|
||||||
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/rm /etc/nginx/snippets/vacuum-wall-ssl.conf
|
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/rm /etc/nginx/snippets/vacuum-wall-ssl.conf
|
||||||
|
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/chown root\:root /etc/nginx/conf.d/vacuum-wall.conf
|
||||||
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/chown root\:root /etc/nginx/snippets/vacuum-wall-ssl.conf
|
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/chown root\:root /etc/nginx/snippets/vacuum-wall-ssl.conf
|
||||||
|
|
||||||
# Dnsmasq management
|
# Dnsmasq management
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ Environment=HOME={{ PROJECT_DIR }}
|
|||||||
|
|
||||||
# Security hardening
|
# Security hardening
|
||||||
ProtectSystem=strict
|
ProtectSystem=strict
|
||||||
ReadWritePaths={{ PROJECT_DIR }} /tmp /etc/systemd/network /etc/nginx /etc/dnsmasq.d /etc/wireguard /run/sudo /run/firewalld /run/nginx
|
ReadWritePaths={{ PROJECT_DIR }} /tmp /etc/systemd/network /etc/nginx /etc/dnsmasq.d /etc/wireguard /run/sudo /run/firewalld /run/nginx /run/nginx.pid /var/log/nginx
|
||||||
PrivateTmp=yes
|
PrivateTmp=yes
|
||||||
ProtectKernelTunables=yes
|
ProtectKernelTunables=yes
|
||||||
ProtectKernelModules=yes
|
ProtectKernelModules=yes
|
||||||
|
|||||||
+85
-7
@@ -61,17 +61,14 @@ class TestRunAcme:
|
|||||||
|
|
||||||
class TestParseListOutput:
|
class TestParseListOutput:
|
||||||
def test_parses_single_entry(self):
|
def test_parses_single_entry(self):
|
||||||
raw = "Main_Domain:example.com CA:LetsEncrypt Certificate_Date:2026-04-01 Certificate_Expires:2026-07-01 Certificate_Expired:No"
|
raw = "Main_Domain\tCA\nexample.com\tLetsEncrypt\n"
|
||||||
result = acme._parse_list_output(raw)
|
result = acme._parse_list_output(raw)
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
assert result[0]["main_domain"] == "example.com"
|
assert result[0]["main_domain"] == "example.com"
|
||||||
assert result[0]["ca"] == "LetsEncrypt"
|
assert result[0]["ca"] == "LetsEncrypt"
|
||||||
|
|
||||||
def test_parses_multiple_entries(self):
|
def test_parses_multiple_entries(self):
|
||||||
raw = (
|
raw = "Main_Domain\tCA\na.com\tLE\nb.com\tLE\n"
|
||||||
"Main_Domain:a.com CA:LE Certificate_Expires:2026-07-01 Certificate_Expired:No\n"
|
|
||||||
"Main_Domain:b.com CA:LE Certificate_Expires:2026-08-01 Certificate_Expired:No"
|
|
||||||
)
|
|
||||||
result = acme._parse_list_output(raw)
|
result = acme._parse_list_output(raw)
|
||||||
assert len(result) == 2
|
assert len(result) == 2
|
||||||
|
|
||||||
@@ -79,10 +76,46 @@ class TestParseListOutput:
|
|||||||
result = acme._parse_list_output("")
|
result = acme._parse_list_output("")
|
||||||
assert result == []
|
assert result == []
|
||||||
|
|
||||||
def test_skips_lines_without_colons(self):
|
def test_skips_empty_lines(self):
|
||||||
raw = "some random line\nMain_Domain:a.com"
|
raw = "Main_Domain\tCA\nexample.com\tLE\n\n \nother.com\tLE\n"
|
||||||
|
result = acme._parse_list_output(raw)
|
||||||
|
assert len(result) == 2
|
||||||
|
|
||||||
|
def test_header_skipped(self):
|
||||||
|
"""Header row is not included in results."""
|
||||||
|
raw = "Main_Domain\tKeyLength\tCA\nexample.com\tec-256\tZeroSSL.com\n"
|
||||||
result = acme._parse_list_output(raw)
|
result = acme._parse_list_output(raw)
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
|
assert result[0]["main_domain"] == "example.com"
|
||||||
|
|
||||||
|
def test_field_mapping(self):
|
||||||
|
"""Tab columns are mapped to lowercased header names as dict keys."""
|
||||||
|
raw = (
|
||||||
|
"Main_Domain\tKeyLength\tSAN_Domains\tProfile\tCA\tCreated\tRenew\n"
|
||||||
|
'example.com\t"ec-256"\twww.example.com\t\tZeroSSL.com\t2026-01-01\t2026-07-01\n'
|
||||||
|
)
|
||||||
|
result = acme._parse_list_output(raw)
|
||||||
|
assert len(result) == 1
|
||||||
|
entry = result[0]
|
||||||
|
assert entry["main_domain"] == "example.com"
|
||||||
|
assert entry["keylength"] == "ec-256"
|
||||||
|
assert entry["san_domains"] == "www.example.com"
|
||||||
|
assert entry["profile"] == ""
|
||||||
|
assert entry["ca"] == "ZeroSSL.com"
|
||||||
|
assert entry["created"] == "2026-01-01"
|
||||||
|
assert entry["renew"] == "2026-07-01"
|
||||||
|
|
||||||
|
def test_quoted_values_stripped(self):
|
||||||
|
"""Quoted values have quotes removed."""
|
||||||
|
raw = 'Main_Domain\tKeyLength\nexample.com\t"ec-256"\n'
|
||||||
|
result = acme._parse_list_output(raw)
|
||||||
|
assert result[0]["keylength"] == "ec-256"
|
||||||
|
|
||||||
|
def test_header_only(self):
|
||||||
|
"""Header with no data rows returns empty list."""
|
||||||
|
raw = "Main_Domain\tKeyLength\tCA\n"
|
||||||
|
result = acme._parse_list_output(raw)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
|
||||||
class TestDaysUntil:
|
class TestDaysUntil:
|
||||||
@@ -124,6 +157,39 @@ class TestGetEmail:
|
|||||||
assert result == "test@example.com"
|
assert result == "test@example.com"
|
||||||
|
|
||||||
|
|
||||||
|
class TestFindCertDir:
|
||||||
|
def test_rsa_dir(self, tmp_path):
|
||||||
|
rsa_dir = tmp_path / "example.com"
|
||||||
|
rsa_dir.mkdir()
|
||||||
|
result = acme.find_cert_dir("example.com", tmp_path)
|
||||||
|
assert result == rsa_dir
|
||||||
|
|
||||||
|
def test_ecc_dir(self, tmp_path):
|
||||||
|
ecc_dir = tmp_path / "example.com_ecc"
|
||||||
|
ecc_dir.mkdir()
|
||||||
|
result = acme.find_cert_dir("example.com", tmp_path)
|
||||||
|
assert result == ecc_dir
|
||||||
|
|
||||||
|
def test_eccPreferred(self, tmp_path):
|
||||||
|
rsa_dir = tmp_path / "example.com"
|
||||||
|
rsa_dir.mkdir()
|
||||||
|
ecc_dir = tmp_path / "example.com_ecc"
|
||||||
|
ecc_dir.mkdir()
|
||||||
|
result = acme.find_cert_dir("example.com", tmp_path)
|
||||||
|
assert result == ecc_dir
|
||||||
|
|
||||||
|
def test_fallback_when_neither(self, tmp_path):
|
||||||
|
result = acme.find_cert_dir("example.com", tmp_path)
|
||||||
|
assert result == tmp_path / "example.com"
|
||||||
|
|
||||||
|
def test_resolves_ecc_only(self, tmp_path):
|
||||||
|
"""Only _ecc dir exists, no RSA dir — should resolve to _ecc."""
|
||||||
|
ecc_dir = tmp_path / "example.com_ecc"
|
||||||
|
ecc_dir.mkdir()
|
||||||
|
result = acme.find_cert_dir("example.com", tmp_path)
|
||||||
|
assert result == ecc_dir
|
||||||
|
|
||||||
|
|
||||||
class TestGetCertPaths:
|
class TestGetCertPaths:
|
||||||
def test_returns_paths(self, tmp_path):
|
def test_returns_paths(self, tmp_path):
|
||||||
with patch.object(acme, "_ACME_HOME", tmp_path / "data" / "acme"):
|
with patch.object(acme, "_ACME_HOME", tmp_path / "data" / "acme"):
|
||||||
@@ -133,6 +199,18 @@ class TestGetCertPaths:
|
|||||||
assert paths["ca"].endswith("example.com/ca.cer")
|
assert paths["ca"].endswith("example.com/ca.cer")
|
||||||
assert paths["fullchain"].endswith("example.com/fullchain.cer")
|
assert paths["fullchain"].endswith("example.com/fullchain.cer")
|
||||||
|
|
||||||
|
def test_resolves_ecc_dir(self, tmp_path):
|
||||||
|
acme_dir = tmp_path / "data" / "acme"
|
||||||
|
ecc_dir = acme_dir / "example.com_ecc"
|
||||||
|
ecc_dir.mkdir(parents=True)
|
||||||
|
|
||||||
|
with patch.object(acme, "_ACME_HOME", acme_dir):
|
||||||
|
paths = acme.get_cert_paths("example.com")
|
||||||
|
assert paths["cert"].endswith("example.com_ecc/example.com.cert")
|
||||||
|
assert paths["key"].endswith("example.com_ecc/example.com.key")
|
||||||
|
assert paths["ca"].endswith("example.com_ecc/ca.cer")
|
||||||
|
assert paths["fullchain"].endswith("example.com_ecc/fullchain.cer")
|
||||||
|
|
||||||
|
|
||||||
class TestDeployHook:
|
class TestDeployHook:
|
||||||
@patch("lib.acme._run_acme")
|
@patch("lib.acme._run_acme")
|
||||||
|
|||||||
@@ -766,21 +766,6 @@ class TestDhcpConfigCrud:
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
class TestProxyManagement:
|
|
||||||
@_px("post")
|
|
||||||
def test_set_management(self, mock_post, client):
|
|
||||||
mock_post.return_value = {"domain": "vacuum-wall.local"}
|
|
||||||
resp = client.post(
|
|
||||||
"/api/proxy/management",
|
|
||||||
json={
|
|
||||||
"domain": "vacuum-wall.local",
|
|
||||||
"flask_host": "127.0.0.1",
|
|
||||||
"flask_port": 9090,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert resp.status_code == 200
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Proxy domain update
|
# Proxy domain update
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Tests for daemon/handlers/acme.py — handler endpoint logic."""
|
"""Tests for daemon/handlers/acme.py — handler endpoint logic."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import urllib.error
|
import urllib.error
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
@@ -24,8 +25,10 @@ from daemon.handlers.acme import (
|
|||||||
deactivate_account,
|
deactivate_account,
|
||||||
generate_self_signed,
|
generate_self_signed,
|
||||||
get_account,
|
get_account,
|
||||||
|
issue_cert,
|
||||||
register_account,
|
register_account,
|
||||||
)
|
)
|
||||||
|
from daemon.server import ConflictError
|
||||||
|
|
||||||
|
|
||||||
class TestGenerateSelfSigned:
|
class TestGenerateSelfSigned:
|
||||||
@@ -345,10 +348,12 @@ class TestCheckDnsPublic:
|
|||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
mock_result = MagicMock(
|
mock_result = MagicMock(
|
||||||
returncode=0, stdout="example.com has address 192.168.1.1"
|
returncode=0, stdout="example.com has address 52.14.150.110"
|
||||||
)
|
)
|
||||||
with (
|
with (
|
||||||
patch("socket.gethostbyname", return_value="192.168.1.1"),
|
patch(
|
||||||
|
"daemon.handlers.acme._get_local_ips", return_value={"52.14.150.110"}
|
||||||
|
),
|
||||||
patch("subprocess.run", return_value=mock_result),
|
patch("subprocess.run", return_value=mock_result),
|
||||||
):
|
):
|
||||||
passed, _ = _check_dns_public("example.com")
|
passed, _ = _check_dns_public("example.com")
|
||||||
@@ -359,12 +364,23 @@ class TestCheckDnsPublic:
|
|||||||
|
|
||||||
mock_result = MagicMock(returncode=1, stdout="NXDOMAIN")
|
mock_result = MagicMock(returncode=1, stdout="NXDOMAIN")
|
||||||
with (
|
with (
|
||||||
patch("socket.gethostbyname", return_value="192.168.1.1"),
|
patch("daemon.handlers.acme._get_local_ips", return_value={"8.8.8.8"}),
|
||||||
patch("subprocess.run", return_value=mock_result),
|
patch("subprocess.run", return_value=mock_result),
|
||||||
):
|
):
|
||||||
passed, _ = _check_dns_public("example.com")
|
passed, _ = _check_dns_public("example.com")
|
||||||
assert passed is False
|
assert passed is False
|
||||||
|
|
||||||
|
def test_nat_detected_skips_check(self):
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"daemon.handlers.acme._get_local_ips",
|
||||||
|
return_value={"192.168.1.1"},
|
||||||
|
):
|
||||||
|
passed, msg = _check_dns_public("example.com")
|
||||||
|
assert passed is True
|
||||||
|
assert "NAT" in msg
|
||||||
|
|
||||||
|
|
||||||
class TestCheckDomainFormat:
|
class TestCheckDomainFormat:
|
||||||
def test_valid(self):
|
def test_valid(self):
|
||||||
@@ -1088,3 +1104,64 @@ class TestDeactivateAccount:
|
|||||||
|
|
||||||
assert not (acme_dir / ".account.conf").is_file()
|
assert not (acme_dir / ".account.conf").is_file()
|
||||||
assert not (acme_dir / "account.conf").is_file()
|
assert not (acme_dir / "account.conf").is_file()
|
||||||
|
|
||||||
|
|
||||||
|
class TestIssueCertExistingCerts:
|
||||||
|
"""Phase 3: issue_cert blocks when cert expires today (days == 0) or tomorrow (days == 1)."""
|
||||||
|
|
||||||
|
def test_days_zero_blocks(self):
|
||||||
|
"""days_until_expiry returns 0 (expires today) — should block."""
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"daemon.handlers.acme._validate",
|
||||||
|
return_value={"ready": True, "checks": []},
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"lib.acme.list_certs",
|
||||||
|
return_value=[{"domain": "example.com", "days_until_expiry": 0}],
|
||||||
|
),
|
||||||
|
pytest.raises(ConflictError, match="0 days remaining"),
|
||||||
|
):
|
||||||
|
asyncio.run(issue_cert(None, {"domain": "example.com"}))
|
||||||
|
|
||||||
|
def test_days_one_blocks(self):
|
||||||
|
"""days_until_expiry returns 1 (expires tomorrow) — should still block."""
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"daemon.handlers.acme._validate",
|
||||||
|
return_value={"ready": True, "checks": []},
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"lib.acme.list_certs",
|
||||||
|
return_value=[{"domain": "example.com", "days_until_expiry": 1}],
|
||||||
|
),
|
||||||
|
pytest.raises(ConflictError, match="1 day remaining"),
|
||||||
|
):
|
||||||
|
asyncio.run(issue_cert(None, {"domain": "example.com"}))
|
||||||
|
|
||||||
|
def test_days_negative_one_allows(self):
|
||||||
|
"""days_until_expiry returns -1 (already expired) — should not block."""
|
||||||
|
|
||||||
|
async def _fake_run_issue(req):
|
||||||
|
pass
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"daemon.handlers.acme._validate",
|
||||||
|
return_value={"ready": True, "checks": []},
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"lib.acme.list_certs",
|
||||||
|
return_value=[{"domain": "example.com", "days_until_expiry": -1}],
|
||||||
|
),
|
||||||
|
patch.dict("daemon.handlers.acme._ISSUANCES", clear=True),
|
||||||
|
patch(
|
||||||
|
"daemon.handlers.acme._run_issue",
|
||||||
|
new=MagicMock(side_effect=_fake_run_issue),
|
||||||
|
) as mock_run_issue,
|
||||||
|
):
|
||||||
|
result = asyncio.run(issue_cert(None, {"domain": "example.com"}))
|
||||||
|
|
||||||
|
assert result["domain"] == "example.com"
|
||||||
|
assert "request_id" in result
|
||||||
|
mock_run_issue.assert_called_once()
|
||||||
|
|||||||
+139
-6
@@ -63,7 +63,10 @@ class TestSaveConfig:
|
|||||||
}
|
}
|
||||||
nginx.save_config(cfg)
|
nginx.save_config(cfg)
|
||||||
loaded = nginx.get_config()
|
loaded = nginx.get_config()
|
||||||
assert loaded["domains"]["example.com"]["backend"]["host"] == "localhost"
|
assert (
|
||||||
|
loaded["domains"]["example.com"]["paths"]["/"]["backend"]["host"]
|
||||||
|
== "localhost"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestGetDomains:
|
class TestGetDomains:
|
||||||
@@ -97,8 +100,10 @@ class TestAddDomain:
|
|||||||
nginx.add_domain("example.com", "10.0.0.5", 8080)
|
nginx.add_domain("example.com", "10.0.0.5", 8080)
|
||||||
cfg = nginx.get_config()
|
cfg = nginx.get_config()
|
||||||
assert "example.com" in cfg["domains"]
|
assert "example.com" in cfg["domains"]
|
||||||
assert cfg["domains"]["example.com"]["backend"]["host"] == "10.0.0.5"
|
assert (
|
||||||
assert cfg["domains"]["example.com"]["backend"]["port"] == 8080
|
cfg["domains"]["example.com"]["paths"]["/"]["backend"]["host"] == "10.0.0.5"
|
||||||
|
)
|
||||||
|
assert cfg["domains"]["example.com"]["paths"]["/"]["backend"]["port"] == 8080
|
||||||
|
|
||||||
@patch("lib.nginx.get_config")
|
@patch("lib.nginx.get_config")
|
||||||
def test_duplicate_domain_raises(self, mock_get, temp_data_dir):
|
def test_duplicate_domain_raises(self, mock_get, temp_data_dir):
|
||||||
@@ -163,21 +168,149 @@ class TestWriteSite:
|
|||||||
assert "server { listen 443; }" in content
|
assert "server { listen 443; }" in content
|
||||||
|
|
||||||
|
|
||||||
|
class TestGenerateServerConf:
|
||||||
|
def test_simple_root_path(self, temp_data_dir):
|
||||||
|
cfg = {
|
||||||
|
"domain": "example.com",
|
||||||
|
"paths": {
|
||||||
|
"/": {
|
||||||
|
"backend": {"host": "10.0.0.1", "port": 80, "proto": "http"},
|
||||||
|
"headers": {"X-Custom": "value"},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"force_ssl": True,
|
||||||
|
"cert": "acme",
|
||||||
|
}
|
||||||
|
out = nginx.generate_server_conf(cfg)
|
||||||
|
assert "location /" in out
|
||||||
|
assert "proxy_pass http://10.0.0.1:80;" in out
|
||||||
|
assert "proxy_set_header X-Custom value;" in out
|
||||||
|
assert "add_header X-Content-Type-Options" in out
|
||||||
|
|
||||||
|
def test_multiple_paths(self, temp_data_dir):
|
||||||
|
cfg = {
|
||||||
|
"domain": "app.example.com",
|
||||||
|
"paths": {
|
||||||
|
"/": {
|
||||||
|
"backend": {"host": "10.0.0.1", "port": 80, "proto": "http"},
|
||||||
|
"headers": {},
|
||||||
|
},
|
||||||
|
"/api": {
|
||||||
|
"backend": {"host": "10.0.0.2", "port": 8080, "proto": "http"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"force_ssl": True,
|
||||||
|
"cert": "acme",
|
||||||
|
}
|
||||||
|
out = nginx.generate_server_conf(cfg)
|
||||||
|
assert "proxy_pass http://10.0.0.1:80;" in out
|
||||||
|
assert "proxy_pass http://10.0.0.2:8080;" in out
|
||||||
|
assert "location /api" in out
|
||||||
|
|
||||||
|
def test_management_path(self, temp_data_dir):
|
||||||
|
cfg = {
|
||||||
|
"domain": "mgmt.example.com",
|
||||||
|
"paths": {
|
||||||
|
"/": {
|
||||||
|
"backend": {"host": "127.0.0.1", "port": 9090, "proto": "http"},
|
||||||
|
"is_management": True,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"force_ssl": True,
|
||||||
|
"cert": "acme",
|
||||||
|
"auth": {"user": "admin", "htpasswd": "/path/.htpasswd"},
|
||||||
|
}
|
||||||
|
out = nginx.generate_server_conf(cfg)
|
||||||
|
assert "proxy_pass http://127.0.0.1:9090;" in out
|
||||||
|
assert "add_header X-Content-Type-Options" not in out
|
||||||
|
assert "wall_mgmt_access.log" in out
|
||||||
|
|
||||||
|
def test_websocket_path(self, temp_data_dir):
|
||||||
|
cfg = {
|
||||||
|
"domain": "mgmt.example.com",
|
||||||
|
"paths": {
|
||||||
|
"/": {
|
||||||
|
"backend": {"host": "127.0.0.1", "port": 9090, "proto": "http"},
|
||||||
|
},
|
||||||
|
"/ws": {
|
||||||
|
"backend": {"host": "127.0.0.1", "port": 9091, "proto": "http"},
|
||||||
|
"is_websocket": True,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"force_ssl": True,
|
||||||
|
"cert": "acme",
|
||||||
|
}
|
||||||
|
out = nginx.generate_server_conf(cfg)
|
||||||
|
assert "proxy_pass http://127.0.0.1:9091;" in out
|
||||||
|
assert "proxy_set_header Upgrade" in out
|
||||||
|
assert "proxy_read_timeout 86400s;" in out
|
||||||
|
|
||||||
|
def test_auth_inheritance(self, temp_data_dir):
|
||||||
|
cfg = {
|
||||||
|
"domain": "app.example.com",
|
||||||
|
"paths": {
|
||||||
|
"/": {
|
||||||
|
"backend": {"host": "10.0.0.1", "port": 80, "proto": "http"},
|
||||||
|
"headers": {},
|
||||||
|
},
|
||||||
|
"/api": {
|
||||||
|
"backend": {"host": "10.0.0.2", "port": 8080, "proto": "http"},
|
||||||
|
"auth": None,
|
||||||
|
},
|
||||||
|
"/admin": {
|
||||||
|
"backend": {"host": "10.0.0.3", "port": 9000, "proto": "http"},
|
||||||
|
"auth": {"user": "admin", "htpasswd": "/other/.htpasswd"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"force_ssl": True,
|
||||||
|
"cert": "acme",
|
||||||
|
"auth": {"user": "admin", "htpasswd": "/path/.htpasswd"},
|
||||||
|
}
|
||||||
|
out = nginx.generate_server_conf(cfg)
|
||||||
|
assert "auth_basic_user_file /path/.htpasswd;" in out
|
||||||
|
lines = out.split("\n")
|
||||||
|
api_idx = next(i for i, line in enumerate(lines) if "location /api" in line)
|
||||||
|
admin_idx = next(i for i, line in enumerate(lines) if "location /admin" in line)
|
||||||
|
# /api should have auth_basic off
|
||||||
|
assert "auth_basic off;" in "\n".join(lines[api_idx : api_idx + 5])
|
||||||
|
# /admin should have path-level auth override
|
||||||
|
assert "auth_basic_user_file /other/.htpasswd;" in "\n".join(
|
||||||
|
lines[admin_idx : admin_idx + 5]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestWriteAllSites:
|
class TestWriteAllSites:
|
||||||
@patch("lib.nginx.get_config")
|
@patch("lib.nginx.get_config")
|
||||||
def test_writes_all_domains(self, mock_get, temp_data_dir):
|
def test_writes_all_domains(self, mock_get, temp_data_dir):
|
||||||
mock_get.return_value = {
|
mock_get.return_value = {
|
||||||
"domains": {
|
"domains": {
|
||||||
"a.com": {
|
"a.com": {
|
||||||
"backend": {"host": "10.0.0.1", "port": 80, "proto": "http"},
|
"paths": {
|
||||||
|
"/": {
|
||||||
|
"backend": {
|
||||||
|
"host": "10.0.0.1",
|
||||||
|
"port": 80,
|
||||||
|
"proto": "http",
|
||||||
|
},
|
||||||
|
"headers": {},
|
||||||
|
}
|
||||||
|
},
|
||||||
"force_ssl": True,
|
"force_ssl": True,
|
||||||
},
|
},
|
||||||
"b.com": {
|
"b.com": {
|
||||||
"backend": {"host": "10.0.0.2", "port": 80, "proto": "http"},
|
"paths": {
|
||||||
|
"/": {
|
||||||
|
"backend": {
|
||||||
|
"host": "10.0.0.2",
|
||||||
|
"port": 80,
|
||||||
|
"proto": "http",
|
||||||
|
},
|
||||||
|
"headers": {},
|
||||||
|
}
|
||||||
|
},
|
||||||
"force_ssl": True,
|
"force_ssl": True,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"management": None,
|
|
||||||
"ssl": {"protocols": "TLSv1.2 TLSv1.3"},
|
"ssl": {"protocols": "TLSv1.2 TLSv1.3"},
|
||||||
}
|
}
|
||||||
nginx.write_all_sites()
|
nginx.write_all_sites()
|
||||||
|
|||||||
@@ -20,18 +20,17 @@ class TestSPARoutes:
|
|||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert b'id="app"' in resp.data
|
assert b'id="app"' in resp.data
|
||||||
|
|
||||||
def test_spa_catch_all_serves_index(self, client):
|
def test_spa_unknown_path_404(self, client):
|
||||||
resp = client.get("/dashboard")
|
resp = client.get("/dashboard")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 404
|
||||||
assert b"index.html" in resp.data or b'id="app"' in resp.data
|
|
||||||
|
|
||||||
def test_spa_catch_all_other_page(self, client):
|
def test_spa_unknown_path_404_other(self, client):
|
||||||
resp = client.get("/zones")
|
resp = client.get("/zones")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 404
|
||||||
|
|
||||||
def test_api_routes_still_work(self, client):
|
def test_api_routes_still_work(self, client):
|
||||||
resp = client.get("/api/firewall/zones")
|
resp = client.get("/api/firewall/zones")
|
||||||
assert resp.status_code in (200, 502, 503)
|
assert resp.status_code in (200, 500)
|
||||||
|
|
||||||
|
|
||||||
class TestWsUrlGeneration:
|
class TestWsUrlGeneration:
|
||||||
|
|||||||
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
-12
@@ -1,12 +0,0 @@
|
|||||||
htmx.defineExtension('json-enc', {
|
|
||||||
onEvent: function(name, evt) {
|
|
||||||
if (name === 'htmx:configRequest') {
|
|
||||||
evt.detail.headers['Content-Type'] = 'application/json'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
encodeParameters: function(xhr, parameters, elt) {
|
|
||||||
xhr.overrideMimeType('text/json')
|
|
||||||
return (JSON.stringify(parameters))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
+3
-1
@@ -7,7 +7,7 @@ import logging
|
|||||||
|
|
||||||
from flask import Blueprint, request
|
from flask import Blueprint, request
|
||||||
|
|
||||||
from daemon.client import BadRequest, NotFound, delete, get, post
|
from daemon.client import BadRequest, Conflict, NotFound, delete, get, post
|
||||||
from daemon.iface import (
|
from daemon.iface import (
|
||||||
DELETE_ACME_ACCOUNT_DEACTIVATE,
|
DELETE_ACME_ACCOUNT_DEACTIVATE,
|
||||||
DELETE_ACME_REMOVE,
|
DELETE_ACME_REMOVE,
|
||||||
@@ -114,6 +114,8 @@ def issue_start():
|
|||||||
except BadRequest as exc:
|
except BadRequest as exc:
|
||||||
logger.info("Cert issue for '%s' rejected: %s", domain, exc)
|
logger.info("Cert issue for '%s' rejected: %s", domain, exc)
|
||||||
return _error(str(exc), 400)
|
return _error(str(exc), 400)
|
||||||
|
except Conflict as exc:
|
||||||
|
return _error(str(exc), 409)
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
logger.error("Failed to start cert issue for '%s': %s", domain, exc)
|
logger.error("Failed to start cert issue for '%s': %s", domain, exc)
|
||||||
return _error(str(exc), 500)
|
return _error(str(exc), 500)
|
||||||
|
|||||||
+23
-55
@@ -17,7 +17,6 @@ from daemon.iface import (
|
|||||||
POST_NGINX_CONFIG,
|
POST_NGINX_CONFIG,
|
||||||
POST_NGINX_DOMAINS_ADD,
|
POST_NGINX_DOMAINS_ADD,
|
||||||
POST_NGINX_DOMAINS_UPDATE,
|
POST_NGINX_DOMAINS_UPDATE,
|
||||||
POST_NGINX_MANAGEMENT,
|
|
||||||
POST_NGINX_SSL_APPLY,
|
POST_NGINX_SSL_APPLY,
|
||||||
POST_NGINX_TEST,
|
POST_NGINX_TEST,
|
||||||
)
|
)
|
||||||
@@ -140,7 +139,13 @@ def add_domain_bp():
|
|||||||
|
|
||||||
POST /api/proxy/domains
|
POST /api/proxy/domains
|
||||||
|
|
||||||
Body fields:
|
Body fields (paths mode):
|
||||||
|
domain: Domain name.
|
||||||
|
paths: Path-to-config map (e.g. ``{"/": {"backend": {...}}, "/app": {...}}``).
|
||||||
|
cert: Optional certificate type.
|
||||||
|
force_ssl: Optional SSL redirect flag (default ``true``).
|
||||||
|
|
||||||
|
Body fields (legacy mode):
|
||||||
domain: Domain name.
|
domain: Domain name.
|
||||||
backend_host: Upstream host.
|
backend_host: Upstream host.
|
||||||
backend_port: Upstream port.
|
backend_port: Upstream port.
|
||||||
@@ -153,29 +158,37 @@ def add_domain_bp():
|
|||||||
"""
|
"""
|
||||||
body = request.get_json(silent=True) or {}
|
body = request.get_json(silent=True) or {}
|
||||||
domain = body.get("domain", "").strip()
|
domain = body.get("domain", "").strip()
|
||||||
|
if not domain:
|
||||||
|
return _error("'domain' is required", 400)
|
||||||
|
|
||||||
|
paths = body.get("paths")
|
||||||
|
if paths is not None:
|
||||||
|
payload = {
|
||||||
|
"domain": domain,
|
||||||
|
"paths": paths,
|
||||||
|
"cert": body.get("cert"),
|
||||||
|
"force_ssl": body.get("force_ssl", True),
|
||||||
|
}
|
||||||
|
else:
|
||||||
backend_host = body.get("backend_host", "").strip()
|
backend_host = body.get("backend_host", "").strip()
|
||||||
backend_port = body.get("backend_port")
|
backend_port = body.get("backend_port")
|
||||||
backend_proto = body.get("backend_proto", "http").strip() or "http"
|
backend_proto = body.get("backend_proto", "http").strip() or "http"
|
||||||
cert = body.get("cert")
|
cert = body.get("cert")
|
||||||
extra_headers = body.get("extra_headers")
|
extra_headers = body.get("extra_headers")
|
||||||
if not domain:
|
|
||||||
return _error("'domain' is required", 400)
|
|
||||||
if not backend_host:
|
if not backend_host:
|
||||||
return _error("'backend_host' is required", 400)
|
return _error("'backend_host' is required", 400)
|
||||||
if backend_port is None:
|
if backend_port is None:
|
||||||
return _error("'backend_port' is required", 400)
|
return _error("'backend_port' is required", 400)
|
||||||
try:
|
payload = {
|
||||||
post(
|
|
||||||
POST_NGINX_DOMAINS_ADD,
|
|
||||||
{
|
|
||||||
"domain": domain,
|
"domain": domain,
|
||||||
"backend_host": backend_host,
|
"backend_host": backend_host,
|
||||||
"backend_port": int(backend_port),
|
"backend_port": int(backend_port),
|
||||||
"backend_proto": backend_proto,
|
"backend_proto": backend_proto,
|
||||||
"cert": cert,
|
"cert": cert,
|
||||||
"extra_headers": extra_headers,
|
"extra_headers": extra_headers,
|
||||||
},
|
}
|
||||||
)
|
try:
|
||||||
|
post(POST_NGINX_DOMAINS_ADD, payload)
|
||||||
logger.info("Proxy domain added via API: %s", domain)
|
logger.info("Proxy domain added via API: %s", domain)
|
||||||
return _ok({"domain": domain})
|
return _ok({"domain": domain})
|
||||||
except BadRequest as exc:
|
except BadRequest as exc:
|
||||||
@@ -272,48 +285,3 @@ def test_bp():
|
|||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
logger.error("nginx config test failed: %s", exc)
|
logger.error("nginx config test failed: %s", exc)
|
||||||
return _error(str(exc), 500)
|
return _error(str(exc), 500)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/management", methods=["POST"])
|
|
||||||
def management_bp():
|
|
||||||
"""Configure the management reverse proxy for the WebUI.
|
|
||||||
|
|
||||||
POST /api/proxy/management
|
|
||||||
|
|
||||||
Body fields:
|
|
||||||
domain: Management domain name.
|
|
||||||
flask_host: Upstream Flask host (default ``127.0.0.1``).
|
|
||||||
flask_port: Upstream Flask port (default 9090).
|
|
||||||
auth_user: Optional basic-auth username.
|
|
||||||
auth_pass: Optional basic-auth password.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
``{"ok": true}`` on success.
|
|
||||||
"""
|
|
||||||
body = request.get_json(silent=True) or {}
|
|
||||||
domain = body.get("domain", "").strip()
|
|
||||||
if not domain:
|
|
||||||
return _error("'domain' is required", 400)
|
|
||||||
flask_host = body.get("flask_host", "127.0.0.1").strip() or "127.0.0.1"
|
|
||||||
flask_port = body.get("flask_port", 9090)
|
|
||||||
auth_user = body.get("auth_user")
|
|
||||||
auth_pass = body.get("auth_pass")
|
|
||||||
try:
|
|
||||||
post(
|
|
||||||
POST_NGINX_MANAGEMENT,
|
|
||||||
{
|
|
||||||
"domain": domain,
|
|
||||||
"flask_host": flask_host,
|
|
||||||
"flask_port": int(flask_port),
|
|
||||||
"auth_user": auth_user,
|
|
||||||
"auth_pass": auth_pass,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
logger.info("Management proxy configured via API: %s", domain)
|
|
||||||
return _ok(None)
|
|
||||||
except BadRequest as exc:
|
|
||||||
logger.info("Management proxy config rejected: %s", exc)
|
|
||||||
return _error(str(exc), 400)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.error("Failed to set management proxy: %s", exc)
|
|
||||||
return _error(str(exc), 500)
|
|
||||||
|
|||||||
+23
-11
@@ -177,29 +177,41 @@ def api_status_all():
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# SPA catch-all
|
# SPA entry point — serve index.html for /, 404 for everything else
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
SPA_DIR = STATIC_DIR
|
SPA_DIR = STATIC_DIR
|
||||||
|
VENDOR_DIR = PROJECT_DIR / "vendor"
|
||||||
|
|
||||||
|
|
||||||
@app.route("/")
|
@app.route("/")
|
||||||
@app.route("/<path:path>")
|
def spa_root():
|
||||||
def spa_page(path=""):
|
"""Serve the SPA entry point. No catch-all — client handles routing."""
|
||||||
"""Single-page application catch-all.
|
|
||||||
|
|
||||||
Serves ``index.html`` (rendered as a Jinja2 template) for all non-API,
|
|
||||||
non-static paths. The client-side router handles navigation and defaults
|
|
||||||
to ``#dashboard``.
|
|
||||||
"""
|
|
||||||
if path.startswith("api/") or path.startswith("static/"):
|
|
||||||
abort(404)
|
|
||||||
scheme = "wss" if request.is_secure else "ws"
|
scheme = "wss" if request.is_secure else "ws"
|
||||||
ws_url = f"{scheme}://{request.host}/ws"
|
ws_url = f"{scheme}://{request.host}/ws"
|
||||||
html = (SPA_DIR / "index.html").read_text()
|
html = (SPA_DIR / "index.html").read_text()
|
||||||
return html.replace("__WS_URL_PLACEHOLDER__", ws_url)
|
return html.replace("__WS_URL_PLACEHOLDER__", ws_url)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/vendor/<path:filename>")
|
||||||
|
def vendor_files(filename):
|
||||||
|
"""Serve vendored JS libraries (htm.js, etc.)."""
|
||||||
|
from flask import send_file
|
||||||
|
|
||||||
|
target = (VENDOR_DIR / filename).resolve()
|
||||||
|
if not target.is_relative_to(VENDOR_DIR):
|
||||||
|
abort(404)
|
||||||
|
return send_file(target)
|
||||||
|
|
||||||
|
|
||||||
|
@app.errorhandler(404)
|
||||||
|
def not_found(e):
|
||||||
|
"""Return 404 JSON for API clients, 404 HTML for everything else."""
|
||||||
|
if request.path.startswith("/api/"):
|
||||||
|
return {"ok": False, "error": "Not found"}, 404
|
||||||
|
return "", 404
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
logger.info("Starting Flask on 127.0.0.1:9090")
|
logger.info("Starting Flask on 127.0.0.1:9090")
|
||||||
app.run(host="127.0.0.1", port=9090)
|
app.run(host="127.0.0.1", port=9090)
|
||||||
|
|||||||
+16
-12
@@ -1,16 +1,16 @@
|
|||||||
import { h, render, Link, hComp, ToastContainer, connect, apiFetch, modelRegister, modelFetch, reactive } from '/static/hoover/index.js?v=7';
|
import { h, render, Link, hComp, ToastContainer, connect, apiFetch, modelRegister, modelFetch, reactive } from '/static/hoover/index.js?v=8';
|
||||||
|
|
||||||
import DashboardPage from '/static/pages/dashboard.js?v=7';
|
import DashboardPage from '/static/pages/dashboard.js?v=8';
|
||||||
import InterfacesPage from '/static/pages/interfaces.js?v=7';
|
import InterfacesPage from '/static/pages/interfaces.js?v=8';
|
||||||
import ZonesPage from '/static/pages/zones.js?v=7';
|
import ZonesPage from '/static/pages/zones.js?v=8';
|
||||||
import RulesPage from '/static/pages/rules.js?v=7';
|
import RulesPage from '/static/pages/rules.js?v=8';
|
||||||
import NatPage from '/static/pages/nat.js?v=7';
|
import NatPage from '/static/pages/nat.js?v=8';
|
||||||
import DhcpPage from '/static/pages/dhcp.js?v=7';
|
import DhcpPage from '/static/pages/dhcp.js?v=8';
|
||||||
import ProxyPage from '/static/pages/proxy.js?v=7';
|
import ProxyPage from '/static/pages/proxy.js?v=8';
|
||||||
import CertsPage from '/static/pages/certs.js?v=7';
|
import CertsPage from '/static/pages/certs.js?v=8';
|
||||||
import WireguardPage from '/static/pages/wireguard.js?v=7';
|
import WireguardPage from '/static/pages/wireguard.js?v=8';
|
||||||
import LogsPage from '/static/pages/logs.js?v=7';
|
import LogsPage from '/static/pages/logs.js?v=8';
|
||||||
import NotFoundPage from '/static/pages/notfound.js?v=7';
|
import NotFoundPage from '/static/pages/notfound.js?v=8';
|
||||||
|
|
||||||
/* ── Navigation items ──────────────────────────────────────── */
|
/* ── Navigation items ──────────────────────────────────────── */
|
||||||
const Nav = [
|
const Nav = [
|
||||||
@@ -229,4 +229,8 @@ export function initApp() {
|
|||||||
setTimeout(connect, 0);
|
setTimeout(connect, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
document.addEventListener('DOMContentLoaded', initApp);
|
document.addEventListener('DOMContentLoaded', initApp);
|
||||||
|
} else {
|
||||||
|
initApp();
|
||||||
|
}
|
||||||
|
|||||||
Vendored
-1
@@ -1 +0,0 @@
|
|||||||
../../vendor/htmx-2.0.4.min.js
|
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Vacuum Wall</title>
|
<title>Vacuum Wall</title>
|
||||||
<link rel="stylesheet" href="/static/style.css?v=8">
|
<link rel="stylesheet" href="/static/style.css?v=9">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
@@ -15,6 +15,6 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="modal-root"></div>
|
<div id="modal-root"></div>
|
||||||
<script>window.__WS_URL__ = "__WS_URL_PLACEHOLDER__"</script>
|
<script>window.__WS_URL__ = "__WS_URL_PLACEHOLDER__"</script>
|
||||||
<script type="module" src="/static/app.js?v=8"></script>
|
<script type="module" src="/static/app.js?v=10"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
../../vendor/json-enc-2.0.0.js
|
|
||||||
@@ -172,7 +172,7 @@ function _renderIssueContent() {
|
|||||||
<label>Domain</label>
|
<label>Domain</label>
|
||||||
<input id="ic-domain" value=${esc(s.domain)} />
|
<input id="ic-domain" value=${esc(s.domain)} />
|
||||||
</div>
|
</div>
|
||||||
<div id="ic-vresults">${...resultsVNodes}</div>
|
<div id="ic-vresults">${resultsVNodes}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button class="btn btn-outline" data-action="ic-cancel">Cancel</button>
|
<button class="btn btn-outline" data-action="ic-cancel">Cancel</button>
|
||||||
@@ -236,7 +236,12 @@ function _bindIssueButtons(inner, modalIdx) {
|
|||||||
const body = { domain: _currentIssueState.domain };
|
const body = { domain: _currentIssueState.domain };
|
||||||
const issueResp = await apiFetch('/api/certs/issue/start', { method: 'POST', body });
|
const issueResp = await apiFetch('/api/certs/issue/start', { method: 'POST', body });
|
||||||
if (issueResp.ok) {
|
if (issueResp.ok) {
|
||||||
|
const status = issueResp.data?.status;
|
||||||
|
if (status === 'existing') {
|
||||||
|
toast('Issuance already in progress for ' + _currentIssueState.domain, 'warning');
|
||||||
|
} else {
|
||||||
toast('Issuance started for ' + _currentIssueState.domain, 'success');
|
toast('Issuance started for ' + _currentIssueState.domain, 'success');
|
||||||
|
}
|
||||||
closeModal(modalIdx);
|
closeModal(modalIdx);
|
||||||
const rid = issueResp.data?.request_id;
|
const rid = issueResp.data?.request_id;
|
||||||
if (rid) pollCertIssue(rid);
|
if (rid) pollCertIssue(rid);
|
||||||
|
|||||||
+138
-42
@@ -1,9 +1,26 @@
|
|||||||
import { html, PageHeader, Badge, Empty, Table, renderGuardMulti, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionCell, certStatusBadge, ActionGroup, QuickModal } from '/static/hoover/index.js?v=7';
|
import { html, PageHeader, Badge, Empty, Table, renderGuardMulti, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionCell, ConfirmDelete, certStatusBadge, ActionGroup, QuickModal } from '/static/hoover/index.js?v=7';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Cert lookup map from ACME state keyed by domain name
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function certLookup(acmeData) {
|
||||||
|
const m = {};
|
||||||
|
if (acmeData && acmeData.certs) {
|
||||||
|
for (const c of acmeData.certs) {
|
||||||
|
m[c.domain] = c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Add Domain modal — paths-based body
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
const addDomain = QuickModal({
|
const addDomain = QuickModal({
|
||||||
title: 'Add Proxy Domain',
|
title: 'Add Proxy Domain',
|
||||||
fields: [
|
fields: [
|
||||||
{ label: 'Domain', id: 'p-domain', placeholder: 'example.com' },
|
{ label: 'Domain', id: 'p-domain', placeholder: 'example.com' },
|
||||||
|
{ label: 'Path', id: 'p-path', placeholder: '/' },
|
||||||
{ label: 'Backend Host', id: 'p-host', placeholder: '192.168.1.10' },
|
{ label: 'Backend Host', id: 'p-host', placeholder: '192.168.1.10' },
|
||||||
{ label: 'Backend Port', id: 'p-port', type: 'number', placeholder: '8080' },
|
{ label: 'Backend Port', id: 'p-port', type: 'number', placeholder: '8080' },
|
||||||
{ label: 'Protocol', id: 'p-proto', placeholder: 'http or https' },
|
{ label: 'Protocol', id: 'p-proto', placeholder: 'http or https' },
|
||||||
@@ -11,42 +28,127 @@ const addDomain = QuickModal({
|
|||||||
],
|
],
|
||||||
submit: {
|
submit: {
|
||||||
url: '/api/proxy/domains',
|
url: '/api/proxy/domains',
|
||||||
body: () => ({
|
body: () => {
|
||||||
|
const path = ($val('p-path') || '/').trim() || '/';
|
||||||
|
return {
|
||||||
domain: ($val('p-domain') || '').trim(),
|
domain: ($val('p-domain') || '').trim(),
|
||||||
backend_host: ($val('p-host') || '').trim(),
|
paths: {
|
||||||
backend_port: parseInt($val('p-port')),
|
[path]: {
|
||||||
backend_proto: ($val('p-proto') || 'http').trim() || 'http',
|
backend: {
|
||||||
|
host: ($val('p-host') || '').trim(),
|
||||||
|
port: parseInt($val('p-port')),
|
||||||
|
proto: ($val('p-proto') || 'http').trim() || 'http',
|
||||||
|
},
|
||||||
|
headers: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
cert: ($val('p-cert') || '').trim() || undefined,
|
cert: ($val('p-cert') || '').trim() || undefined,
|
||||||
}),
|
};
|
||||||
validate: (b) => !b.domain || !b.backend_host || !b.backend_port ? 'Domain, host, and port are required' : null,
|
},
|
||||||
|
validate: (b) => {
|
||||||
|
if (!b.domain) return 'Domain is required';
|
||||||
|
const p = b.paths ? Object.values(b.paths)[0] : {};
|
||||||
|
const be = p && p.backend;
|
||||||
|
if (!be || !be.host || !be.port) return 'Host and port are required';
|
||||||
|
return null;
|
||||||
|
},
|
||||||
successMsg: 'Domain added',
|
successMsg: 'Domain added',
|
||||||
},
|
},
|
||||||
refresh: ['nginx', 'acme'],
|
refresh: ['nginx', 'acme'],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Edit Domain modal — updates backend for the root path
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
const editDomain = QuickModal({
|
const editDomain = QuickModal({
|
||||||
title: (d) => 'Edit: ' + d.domain,
|
title: (d) => 'Edit: ' + d.domain + ' ' + d.path,
|
||||||
fields: (d) => [
|
fields: (d) => {
|
||||||
{ label: 'Backend Host', id: 'pe-host', value: d.backend_host || '' },
|
const be = d.backend || {};
|
||||||
{ label: 'Backend Port', id: 'pe-port', type: 'number', value: d.backend_port || '' },
|
return [
|
||||||
{ label: 'Protocol', id: 'pe-proto', value: d.backend_proto || d.protocol || 'http' },
|
{ label: 'Backend Host', id: 'pe-host', value: be.host || '' },
|
||||||
{ label: 'Cert (optional)', id: 'pe-cert', value: d.cert || '' },
|
{ label: 'Backend Port', id: 'pe-port', type: 'number', value: be.port || '' },
|
||||||
],
|
{ label: 'Protocol', id: 'pe-proto', value: be.proto || 'http' },
|
||||||
|
{ label: 'Cert (optional)', id: 'pe-cert', value: d._cert || d.cert || '' },
|
||||||
|
];
|
||||||
|
},
|
||||||
submit: {
|
submit: {
|
||||||
url: (d) => '/api/proxy/domains/' + enc(d.domain),
|
url: (d) => '/api/proxy/domains/' + enc(d.domain),
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: () => ({
|
body: (d) => ({
|
||||||
backend_host: ($val('pe-host') || '').trim(),
|
backend: {
|
||||||
backend_port: parseInt($val('pe-port')),
|
host: ($val('pe-host') || '').trim(),
|
||||||
backend_proto: ($val('pe-proto') || 'http').trim(),
|
port: parseInt($val('pe-port')),
|
||||||
|
proto: ($val('pe-proto') || 'http').trim(),
|
||||||
|
},
|
||||||
cert: ($val('pe-cert') || '').trim() || undefined,
|
cert: ($val('pe-cert') || '').trim() || undefined,
|
||||||
}),
|
}),
|
||||||
validate: (b) => !b.backend_host || !b.backend_port ? 'Host and port are required' : null,
|
validate: (b) => !b.backend || !b.backend.host || !b.backend.port ? 'Host and port are required' : null,
|
||||||
successMsg: 'Domain updated',
|
successMsg: 'Domain updated',
|
||||||
},
|
},
|
||||||
refresh: ['nginx', 'acme'],
|
refresh: ['nginx', 'acme'],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Path detail row
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function pathRow(d, certs, domainPaths) {
|
||||||
|
const be = d.backend || {};
|
||||||
|
const cert = certs[d.domain];
|
||||||
|
const certBadge = cert
|
||||||
|
? certStatusBadge({
|
||||||
|
daysRemaining: cert.days_remaining,
|
||||||
|
expired: cert.expired,
|
||||||
|
})
|
||||||
|
: Badge({ text: '—', variant: 'info' });
|
||||||
|
|
||||||
|
const isWs = d.is_websocket;
|
||||||
|
const isMgmt = d.is_management;
|
||||||
|
const multiPath = (domainPaths || []).length > 1;
|
||||||
|
|
||||||
|
let actions;
|
||||||
|
if (isMgmt) {
|
||||||
|
actions = Badge({ text: 'mgmt', variant: 'warning' });
|
||||||
|
} else if (isWs) {
|
||||||
|
actions = ActionButton({
|
||||||
|
url: '/api/proxy/domains/' + enc(d.domain),
|
||||||
|
method: 'PUT',
|
||||||
|
body: () => ({ path: d.path }),
|
||||||
|
label: 'Delete',
|
||||||
|
cls: 'btn btn-sm btn-danger',
|
||||||
|
successMsg: 'Path removed',
|
||||||
|
refresh: ['nginx', 'acme'],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
actions = ActionCell({
|
||||||
|
editLabel: 'Edit',
|
||||||
|
editClick: () => editDomain(d),
|
||||||
|
removeUrl: '/api/proxy/domains/' + enc(d.domain),
|
||||||
|
removeMessage: 'Remove ' + enc(d.domain) + ' ' + enc(d.path) + '?',
|
||||||
|
removeSuccess: 'Removed',
|
||||||
|
removeRefresh: ['nginx', 'acme'],
|
||||||
|
removeLabel: 'Delete',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const flagBadges = [];
|
||||||
|
if (isWs) flagBadges.push(Badge({ text: 'ws', variant: 'secondary' }));
|
||||||
|
if (isMgmt) flagBadges.push(Badge({ text: 'mgmt', variant: 'warning' }));
|
||||||
|
|
||||||
|
return html`<tr key=${d.domain + ':' + d.path} class="path-row">
|
||||||
|
<td>${esc(d.domain)}</td>
|
||||||
|
<td><code>${esc(d.path)}</code></td>
|
||||||
|
<td>${esc(be.host || '-')}</td>
|
||||||
|
<td>${be.port || '-'}</td>
|
||||||
|
<td><${Badge} text=${be.proto || 'http'} variant="info" /></td>
|
||||||
|
<td>${flagBadges}</td>
|
||||||
|
<td>${certBadge}</td>
|
||||||
|
<td>${actions}</td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Page
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
export default definePage({
|
export default definePage({
|
||||||
init() {
|
init() {
|
||||||
return {
|
return {
|
||||||
@@ -59,28 +161,22 @@ export default definePage({
|
|||||||
if (guard) return guard;
|
if (guard) return guard;
|
||||||
|
|
||||||
const domains = state.nginx.data.domains || [];
|
const domains = state.nginx.data.domains || [];
|
||||||
const rows = domains.map(d => {
|
const certs = certLookup(state.acme.data);
|
||||||
const certBadge = certStatusBadge({
|
|
||||||
certStatus: d.cert_status,
|
|
||||||
daysRemaining: d.days_remaining,
|
|
||||||
expired: d.cert_status === 'expired',
|
|
||||||
});
|
|
||||||
|
|
||||||
return html`<tr key=${d.domain}>
|
// Group by domain for multi-path awareness
|
||||||
<td><strong>${esc(d.domain)}</strong></td>
|
const groups = {};
|
||||||
<td>${esc(d.backend_host || '-')}</td>
|
for (const d of domains) {
|
||||||
<td>${d.backend_port || '-'}</td>
|
if (!groups[d.domain]) groups[d.domain] = [];
|
||||||
<td><${Badge} text=${d.backend_proto || d.protocol || 'http'} variant="info" /></td>
|
groups[d.domain].push(d);
|
||||||
<td>${certBadge}</td>
|
}
|
||||||
<${ActionCell}
|
|
||||||
editLabel="Edit" editClick=${() => editDomain(d)}
|
// Attach domain-level cert info to each entry
|
||||||
removeUrl=${'/api/proxy/domains/' + enc(d.domain)}
|
const enriched = domains.map(d => ({
|
||||||
removeMessage=${'Remove proxy for ' + d.domain + '?'}
|
...d,
|
||||||
removeSuccess="Domain removed"
|
_cert: certs[d.domain] || null,
|
||||||
removeRefresh={['nginx', 'acme']}
|
}));
|
||||||
removeLabel="Delete" />
|
|
||||||
</tr>`;
|
const rows = enriched.map(d => pathRow(d, certs, groups[d.domain]));
|
||||||
});
|
|
||||||
|
|
||||||
const actions = ActionGroup(
|
const actions = ActionGroup(
|
||||||
html`<button class="btn btn-primary" onClick=${() => addDomain(state)}>Add Domain</button>`,
|
html`<button class="btn btn-primary" onClick=${() => addDomain(state)}>Add Domain</button>`,
|
||||||
@@ -94,9 +190,9 @@ export default definePage({
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
PageHeader({ title: 'Proxy', subtitle: 'Nginx reverse proxy', actions }),
|
PageHeader({ title: 'Proxy', subtitle: 'Nginx reverse proxy', actions }),
|
||||||
rows.length
|
domains.length
|
||||||
? Table({
|
? Table({
|
||||||
columns: ['Domain', 'Backend Host', 'Port', 'Proto', 'Cert', 'Actions'],
|
columns: ['Domain', 'Path', 'Host', 'Port', 'Proto', 'Flags', 'Cert', 'Actions'],
|
||||||
rows,
|
rows,
|
||||||
})
|
})
|
||||||
: Empty({ text: 'No proxy domains configured. Add a domain to start terminating SSL.' }),
|
: Empty({ text: 'No proxy domains configured. Add a domain to start terminating SSL.' }),
|
||||||
|
|||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
../../../vendor/htm.js
|
||||||
Reference in New Issue
Block a user