refactor: overhaul daemon server, client, and handlers
This commit is contained in:
+89
-42
@@ -12,7 +12,21 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from daemon.iface import (
|
||||
DELETE_ACME_REMOVE,
|
||||
GET_ACME_EMAIL,
|
||||
GET_ACME_INFO,
|
||||
GET_ACME_ISSUE_STATUS,
|
||||
GET_ACME_LIST,
|
||||
GET_ACME_PATHS,
|
||||
POST_ACME_EMAIL,
|
||||
POST_ACME_ISSUE,
|
||||
POST_ACME_RENEW,
|
||||
POST_ACME_SELF_SIGNED,
|
||||
POST_ACME_VALIDATE,
|
||||
)
|
||||
from daemon.server import NotFoundError, refresh_state, registry
|
||||
from lib.state import _run_acme
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -99,38 +113,6 @@ class IssueRequest:
|
||||
# Internal helpers
|
||||
|
||||
|
||||
def _run_acme(args: list[str]) -> str:
|
||||
"""Execute an acme.sh command and return combined output.
|
||||
|
||||
Returns:
|
||||
Standard output (plus stderr).
|
||||
|
||||
Raises:
|
||||
RuntimeError: On timeout or non-zero exit.
|
||||
"""
|
||||
from lib.state import _find_acme
|
||||
|
||||
acme_bin = _find_acme()
|
||||
acme_home_env = os.environ.get("ACME_HOME", str(_ACME_HOME))
|
||||
cmd = [acme_bin, "--home", acme_home_env, "--config-home", acme_home_env, *args]
|
||||
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 _find_acme_bin() -> str:
|
||||
"""Return the path to the acme.sh binary."""
|
||||
from lib.state import _find_acme
|
||||
@@ -326,14 +308,14 @@ def _validate(domain: str) -> dict[str, Any]:
|
||||
# Routes — status reads from state, mutations call refresh_state
|
||||
|
||||
|
||||
@registry.register("GET", "/acme/list")
|
||||
@registry.register(GET_ACME_LIST)
|
||||
def list_certs(_request: Any, _body: Any) -> list[dict]:
|
||||
"""GET /acme/list — return managed certificates."""
|
||||
ac = _get_acme_state()
|
||||
return ac.get("certs", [])
|
||||
|
||||
|
||||
@registry.register("GET", "/acme/info")
|
||||
@registry.register(GET_ACME_INFO)
|
||||
def get_cert_info(_request: Any, body: dict[str, Any] | None) -> dict:
|
||||
"""GET /acme/info — return details for a single domain certificate.
|
||||
|
||||
@@ -351,7 +333,7 @@ def get_cert_info(_request: Any, body: dict[str, Any] | None) -> dict:
|
||||
raise NotFoundError(f"No certificate found for domain: {domain}")
|
||||
|
||||
|
||||
@registry.register("POST", "/acme/validate")
|
||||
@registry.register(POST_ACME_VALIDATE)
|
||||
def validate_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""POST /acme/validate — run pre-flight checks for a domain.
|
||||
|
||||
@@ -366,7 +348,7 @@ def validate_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
return _validate(domain)
|
||||
|
||||
|
||||
@registry.register("POST", "/acme/issue")
|
||||
@registry.register(POST_ACME_ISSUE)
|
||||
async def issue_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""POST /acme/issue — create a new certificate issuance request.
|
||||
|
||||
@@ -429,7 +411,7 @@ async def issue_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, An
|
||||
return {"request_id": request_id, "domain": domain}
|
||||
|
||||
|
||||
@registry.register("GET", "/acme/issue/status")
|
||||
@registry.register(GET_ACME_ISSUE_STATUS)
|
||||
def get_issuance_status(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""GET /acme/issue/status — poll status of an issuance request.
|
||||
|
||||
@@ -498,7 +480,7 @@ async def _run_issue(req: IssueRequest) -> None:
|
||||
logger.error("Cert issuance for %s failed: %s", req.domain, exc)
|
||||
|
||||
|
||||
@registry.register("POST", "/acme/renew")
|
||||
@registry.register(POST_ACME_RENEW)
|
||||
def renew_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""POST /acme/renew — renew a certificate for the given domain.
|
||||
|
||||
@@ -524,7 +506,7 @@ def renew_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
return {"domain": domain, "output": output.strip()}
|
||||
|
||||
|
||||
@registry.register("DELETE", "/acme/remove")
|
||||
@registry.register(DELETE_ACME_REMOVE)
|
||||
def remove_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""DELETE /acme/remove — remove a certificate from ACME management.
|
||||
|
||||
@@ -542,7 +524,7 @@ def remove_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
return {"domain": domain}
|
||||
|
||||
|
||||
@registry.register("POST", "/acme/email")
|
||||
@registry.register(POST_ACME_EMAIL)
|
||||
def set_email(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""POST /acme/email — set the ACME contact email via account registration.
|
||||
|
||||
@@ -570,7 +552,7 @@ def set_email(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
return {"email": email}
|
||||
|
||||
|
||||
@registry.register("GET", "/acme/email")
|
||||
@registry.register(GET_ACME_EMAIL)
|
||||
def get_email(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
"""GET /acme/email — return the currently configured ACME contact email."""
|
||||
ac = _get_acme_state()
|
||||
@@ -582,7 +564,7 @@ def get_email(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
return {"email": email}
|
||||
|
||||
|
||||
@registry.register("GET", "/acme/paths")
|
||||
@registry.register(GET_ACME_PATHS)
|
||||
def get_cert_paths(_request: Any, body: dict[str, Any] | None) -> dict[str, str]:
|
||||
"""GET /acme/paths — return filesystem paths for a domain's certificate files.
|
||||
|
||||
@@ -600,3 +582,68 @@ def get_cert_paths(_request: Any, body: dict[str, Any] | None) -> dict[str, str]
|
||||
"ca": f"{acme_home}/ca.cer",
|
||||
"fullchain": f"{acme_home}/fullchain.cer",
|
||||
}
|
||||
|
||||
|
||||
@registry.register(POST_ACME_SELF_SIGNED)
|
||||
def generate_self_signed(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""POST /acme/self-signed — generate a self-signed certificate for a domain.
|
||||
|
||||
Idempotent: skips generation if cert and key already exist.
|
||||
|
||||
Raises:
|
||||
ValueError: When domain is missing.
|
||||
"""
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
domain = body.get("domain", "").strip()
|
||||
if not domain:
|
||||
raise ValueError("'domain' is required")
|
||||
days = body.get("days", 365)
|
||||
|
||||
cert_dir = _ACME_HOME / domain
|
||||
cert_dir.mkdir(parents=True, exist_ok=True)
|
||||
cert_file = cert_dir / "fullchain.cer"
|
||||
key_file = cert_dir / f"{domain}.key"
|
||||
|
||||
if cert_file.is_file() and key_file.is_file():
|
||||
logger.info("Self-signed cert for %s already exists, skipping", domain)
|
||||
return {
|
||||
"domain": domain,
|
||||
"cert": str(cert_file),
|
||||
"key": str(key_file),
|
||||
"generated": False,
|
||||
}
|
||||
|
||||
subprocess.run(
|
||||
[
|
||||
"openssl",
|
||||
"req",
|
||||
"-x509",
|
||||
"-newkey",
|
||||
"rsa:2048",
|
||||
"-keyout",
|
||||
str(key_file),
|
||||
"-out",
|
||||
str(cert_file),
|
||||
"-days",
|
||||
str(days),
|
||||
"-nodes",
|
||||
"-subj",
|
||||
f"/CN={domain}",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
cert_file.chmod(0o644)
|
||||
key_file.chmod(0o600)
|
||||
|
||||
logger.info("Self-signed cert for %s generated (%d days)", domain, days)
|
||||
return {
|
||||
"domain": domain,
|
||||
"cert": str(cert_file),
|
||||
"key": str(key_file),
|
||||
"generated": True,
|
||||
}
|
||||
|
||||
+47
-15
@@ -8,6 +8,22 @@ from typing import Any
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from daemon.iface import (
|
||||
DELETE_DNSMASQ_DNS_RECORD_REMOVE,
|
||||
DELETE_DNSMASQ_RANGES_REMOVE,
|
||||
DELETE_DNSMASQ_STATIC_LEASE_REMOVE,
|
||||
GET_DNSMASQ_CONFIG,
|
||||
GET_DNSMASQ_LEASES,
|
||||
GET_DNSMASQ_STATUS,
|
||||
PATCH_DNSMASQ_CONFIG,
|
||||
POST_DNSMASQ_APPLY,
|
||||
POST_DNSMASQ_CONFIG,
|
||||
POST_DNSMASQ_DNS_RECORD_ADD,
|
||||
POST_DNSMASQ_DOMAIN,
|
||||
POST_DNSMASQ_RANGES_ADD,
|
||||
POST_DNSMASQ_STATIC_LEASE_ADD,
|
||||
POST_DNSMASQ_UPSTREAMS,
|
||||
)
|
||||
from daemon.server import NotFoundError, refresh_state, registry
|
||||
from lib.common import deep_merge, ensure_dirs, load_json, run, run_proc, save_json
|
||||
|
||||
@@ -64,10 +80,26 @@ def _generate_conf(cfg: dict[str, Any]) -> str:
|
||||
interfaces = [
|
||||
r["interface"] for r in dhcp_cfg.get("ranges", []) if "interface" in r
|
||||
]
|
||||
|
||||
# Fallback: use network-managed interface addresses for listen-address
|
||||
listen_addresses = []
|
||||
try:
|
||||
from lib.network import get_config as _get_net_config
|
||||
|
||||
net_cfg = _get_net_config()
|
||||
for _iface, info in net_cfg.get("interfaces", {}).items():
|
||||
for addr_str in info.get("addresses", []):
|
||||
if "/" in addr_str:
|
||||
addr_str = addr_str.split("/")[0]
|
||||
listen_addresses.append(addr_str)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
tmpl = ENV.get_template("dnsmasq.conf")
|
||||
return tmpl.render(
|
||||
timestamp=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
interfaces=interfaces,
|
||||
interfaces=interfaces or None,
|
||||
listen_addresses=listen_addresses if listen_addresses else None,
|
||||
dhcp=dhcp_cfg,
|
||||
dns=dns_cfg,
|
||||
fragments_dir=str(FRAGMENTS_DIR) if FRAGMENTS_DIR.exists() else None,
|
||||
@@ -86,7 +118,7 @@ def _get_dnsmasq_state() -> dict[str, Any]:
|
||||
# Routes
|
||||
|
||||
|
||||
@registry.register("GET", "/dnsmasq/config")
|
||||
@registry.register(GET_DNSMASQ_CONFIG)
|
||||
def get_config(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
"""\
|
||||
Endpoint: GET /dnsmasq/config
|
||||
@@ -99,7 +131,7 @@ def get_config(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
return _get_config()
|
||||
|
||||
|
||||
@registry.register("POST", "/dnsmasq/config")
|
||||
@registry.register(POST_DNSMASQ_CONFIG)
|
||||
def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""\
|
||||
Endpoint: POST /dnsmasq/config
|
||||
@@ -113,7 +145,7 @@ def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str,
|
||||
return {"config_saved": True}
|
||||
|
||||
|
||||
@registry.register("PATCH", "/dnsmasq/config")
|
||||
@registry.register(PATCH_DNSMASQ_CONFIG)
|
||||
def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""\
|
||||
Endpoint: PATCH /dnsmasq/config
|
||||
@@ -129,7 +161,7 @@ def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
return {"config_saved": True}
|
||||
|
||||
|
||||
@registry.register("POST", "/dnsmasq/apply")
|
||||
@registry.register(POST_DNSMASQ_APPLY)
|
||||
def apply_config(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
"""\
|
||||
Endpoint: POST /dnsmasq/apply
|
||||
@@ -152,7 +184,7 @@ def apply_config(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
return {"applied": True}
|
||||
|
||||
|
||||
@registry.register("GET", "/dnsmasq/status")
|
||||
@registry.register(GET_DNSMASQ_STATUS)
|
||||
def get_status(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
"""\
|
||||
Endpoint: GET /dnsmasq/status
|
||||
@@ -165,7 +197,7 @@ def get_status(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
return {}
|
||||
|
||||
|
||||
@registry.register("POST", "/dnsmasq/ranges/add")
|
||||
@registry.register(POST_DNSMASQ_RANGES_ADD)
|
||||
def set_dhcp_range(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""\
|
||||
Endpoint: POST /dnsmasq/ranges/add
|
||||
@@ -214,7 +246,7 @@ def set_dhcp_range(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]
|
||||
return {"interface": iface, "start": start, "end": end}
|
||||
|
||||
|
||||
@registry.register("DELETE", "/dnsmasq/ranges/remove")
|
||||
@registry.register(DELETE_DNSMASQ_RANGES_REMOVE)
|
||||
def remove_dhcp_range(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""\
|
||||
Endpoint: DELETE /dnsmasq/ranges/remove
|
||||
@@ -249,7 +281,7 @@ def remove_dhcp_range(_request: Any, body: dict[str, Any] | None) -> dict[str, A
|
||||
return {"interface": iface, "start": start, "end": end}
|
||||
|
||||
|
||||
@registry.register("GET", "/dnsmasq/leases")
|
||||
@registry.register(GET_DNSMASQ_LEASES)
|
||||
def get_leases(_request: Any, _body: Any) -> list[dict[str, Any]]:
|
||||
"""\
|
||||
Endpoint: GET /dnsmasq/leases
|
||||
@@ -262,7 +294,7 @@ def get_leases(_request: Any, _body: Any) -> list[dict[str, Any]]:
|
||||
return []
|
||||
|
||||
|
||||
@registry.register("POST", "/dnsmasq/static-lease/add")
|
||||
@registry.register(POST_DNSMASQ_STATIC_LEASE_ADD)
|
||||
def add_static_lease(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""\
|
||||
Endpoint: POST /dnsmasq/static-lease/add
|
||||
@@ -295,7 +327,7 @@ def add_static_lease(_request: Any, body: dict[str, Any] | None) -> dict[str, An
|
||||
return {"mac": mac, "ip": ip, "hostname": hostname}
|
||||
|
||||
|
||||
@registry.register("DELETE", "/dnsmasq/static-lease/remove")
|
||||
@registry.register(DELETE_DNSMASQ_STATIC_LEASE_REMOVE)
|
||||
def remove_static_lease(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""\
|
||||
Endpoint: DELETE /dnsmasq/static-lease/remove
|
||||
@@ -320,7 +352,7 @@ def remove_static_lease(_request: Any, body: dict[str, Any] | None) -> dict[str,
|
||||
return {"mac": mac}
|
||||
|
||||
|
||||
@registry.register("POST", "/dnsmasq/dns-record/add")
|
||||
@registry.register(POST_DNSMASQ_DNS_RECORD_ADD)
|
||||
def add_dns_record(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""\
|
||||
Endpoint: POST /dnsmasq/dns-record/add
|
||||
@@ -353,7 +385,7 @@ def add_dns_record(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]
|
||||
return {"name": name, "address": address, "hostname": hostname}
|
||||
|
||||
|
||||
@registry.register("DELETE", "/dnsmasq/dns-record/remove")
|
||||
@registry.register(DELETE_DNSMASQ_DNS_RECORD_REMOVE)
|
||||
def remove_dns_record(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""\
|
||||
Endpoint: DELETE /dnsmasq/dns-record/remove
|
||||
@@ -376,7 +408,7 @@ def remove_dns_record(_request: Any, body: dict[str, Any] | None) -> dict[str, A
|
||||
return {"name": name}
|
||||
|
||||
|
||||
@registry.register("POST", "/dnsmasq/upstreams")
|
||||
@registry.register(POST_DNSMASQ_UPSTREAMS)
|
||||
def set_upstreams(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""\
|
||||
Endpoint: POST /dnsmasq/upstreams
|
||||
@@ -392,7 +424,7 @@ def set_upstreams(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
return {"upstreams": cfg["dns"]["upstreams"]}
|
||||
|
||||
|
||||
@registry.register("POST", "/dnsmasq/domain")
|
||||
@registry.register(POST_DNSMASQ_DOMAIN)
|
||||
def set_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""\
|
||||
Endpoint: POST /dnsmasq/domain
|
||||
|
||||
+44
-21
@@ -10,6 +10,29 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from daemon.iface import (
|
||||
DELETE_FIREWALL_FORWARD_PORT_REMOVE,
|
||||
DELETE_FIREWALL_RICH_RULES_REMOVE,
|
||||
DELETE_FIREWALL_ZONES_DELETE,
|
||||
GET_FIREWALL_CONFIG,
|
||||
GET_FIREWALL_CONFIG_PENDING,
|
||||
GET_FIREWALL_INTERFACES,
|
||||
GET_FIREWALL_RICH_RULES,
|
||||
GET_FIREWALL_SERVICES,
|
||||
GET_FIREWALL_STATE,
|
||||
GET_FIREWALL_ZONES,
|
||||
GET_FIREWALL_ZONES_ALL,
|
||||
GET_FIREWALL_ZONES_INFO,
|
||||
PATCH_FIREWALL_CONFIG,
|
||||
POST_FIREWALL_CONFIG,
|
||||
POST_FIREWALL_CONFIG_APPLY,
|
||||
POST_FIREWALL_FORWARD_PORT_ADD,
|
||||
POST_FIREWALL_MASQUERADE,
|
||||
POST_FIREWALL_RICH_RULES_ADD,
|
||||
POST_FIREWALL_ZONES_CREATE,
|
||||
POST_FIREWALL_ZONES_INTERFACES,
|
||||
POST_FIREWALL_ZONES_SERVICES,
|
||||
)
|
||||
from daemon.server import NotFoundError, refresh_state, registry
|
||||
from lib.common import load_json, run, save_json
|
||||
from lib.firewall import (
|
||||
@@ -269,14 +292,14 @@ def _get_fw_state() -> dict[str, Any]:
|
||||
return fw
|
||||
|
||||
|
||||
@registry.register("GET", "/firewall/interfaces")
|
||||
@registry.register(GET_FIREWALL_INTERFACES)
|
||||
def get_interfaces(_request: Any, _body: Any) -> list[dict[str, Any]]:
|
||||
"""GET /firewall/interfaces — return active interfaces from state."""
|
||||
fw = _get_fw_state()
|
||||
return fw.get("interfaces", [])
|
||||
|
||||
|
||||
@registry.register("GET", "/firewall/zones")
|
||||
@registry.register(GET_FIREWALL_ZONES)
|
||||
def get_zones(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
fw = _get_fw_state()
|
||||
active = fw.get("active_zones", {})
|
||||
@@ -284,7 +307,7 @@ def get_zones(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
return {"active": active, "available": list(zones.keys())}
|
||||
|
||||
|
||||
@registry.register("GET", "/firewall/zones/info")
|
||||
@registry.register(GET_FIREWALL_ZONES_INFO)
|
||||
def get_zone_info(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body or "zone" not in body:
|
||||
raise ValueError("'zone' is required")
|
||||
@@ -296,7 +319,7 @@ def get_zone_info(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
return zones[zone]
|
||||
|
||||
|
||||
@registry.register("GET", "/firewall/zones/all")
|
||||
@registry.register(GET_FIREWALL_ZONES_ALL)
|
||||
def get_all_zones_info(_request: Any, _body: Any) -> list[dict[str, Any]]:
|
||||
fw = _get_fw_state()
|
||||
active = fw.get("active_zones", {})
|
||||
@@ -308,18 +331,18 @@ def get_all_zones_info(_request: Any, _body: Any) -> list[dict[str, Any]]:
|
||||
return result
|
||||
|
||||
|
||||
@registry.register("GET", "/firewall/services")
|
||||
@registry.register(GET_FIREWALL_SERVICES)
|
||||
def get_services(_request: Any, _body: Any) -> list[str]:
|
||||
fw = _get_fw_state()
|
||||
return fw.get("available_services", [])
|
||||
|
||||
|
||||
@registry.register("GET", "/firewall/config")
|
||||
@registry.register(GET_FIREWALL_CONFIG)
|
||||
def get_config(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
return _get_config()
|
||||
|
||||
|
||||
@registry.register("POST", "/firewall/config")
|
||||
@registry.register(POST_FIREWALL_CONFIG)
|
||||
def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body or "zones" not in body:
|
||||
raise ValueError("'zones' key is required")
|
||||
@@ -331,7 +354,7 @@ def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str,
|
||||
return {"config_saved": True}
|
||||
|
||||
|
||||
@registry.register("PATCH", "/firewall/config")
|
||||
@registry.register(PATCH_FIREWALL_CONFIG)
|
||||
def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body must be a JSON object")
|
||||
@@ -345,13 +368,13 @@ def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
return {"config_saved": True}
|
||||
|
||||
|
||||
@registry.register("GET", "/firewall/config/pending")
|
||||
@registry.register(GET_FIREWALL_CONFIG_PENDING)
|
||||
def config_pending_handler(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
fw = _get_fw_state()
|
||||
return fw.get("pending", {})
|
||||
|
||||
|
||||
@registry.register("POST", "/firewall/config/apply")
|
||||
@registry.register(POST_FIREWALL_CONFIG_APPLY)
|
||||
def config_apply(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
result = _config_apply()
|
||||
logger.info("Firewall config applied: %s", result.get("applied_zones", []))
|
||||
@@ -359,7 +382,7 @@ def config_apply(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
return result
|
||||
|
||||
|
||||
@registry.register("POST", "/firewall/zones/create")
|
||||
@registry.register(POST_FIREWALL_ZONES_CREATE)
|
||||
def create_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
@@ -385,7 +408,7 @@ def create_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
return {"zone": zone_name}
|
||||
|
||||
|
||||
@registry.register("DELETE", "/firewall/zones/delete")
|
||||
@registry.register(DELETE_FIREWALL_ZONES_DELETE)
|
||||
def delete_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body or "zone" not in body:
|
||||
raise ValueError("'zone' is required")
|
||||
@@ -400,7 +423,7 @@ def delete_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
return {"zone": zone}
|
||||
|
||||
|
||||
@registry.register("POST", "/firewall/zones/interfaces")
|
||||
@registry.register(POST_FIREWALL_ZONES_INTERFACES)
|
||||
def set_zone_interfaces(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
@@ -470,7 +493,7 @@ def set_zone_interfaces(_request: Any, body: dict[str, Any] | None) -> dict[str,
|
||||
return {"zone": zone, "interfaces": interfaces}
|
||||
|
||||
|
||||
@registry.register("POST", "/firewall/zones/services")
|
||||
@registry.register(POST_FIREWALL_ZONES_SERVICES)
|
||||
def set_zone_services(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
@@ -509,7 +532,7 @@ def set_zone_services(_request: Any, body: dict[str, Any] | None) -> dict[str, A
|
||||
return {"zone": zone, "services": services}
|
||||
|
||||
|
||||
@registry.register("POST", "/firewall/rich-rules/add")
|
||||
@registry.register(POST_FIREWALL_RICH_RULES_ADD)
|
||||
def add_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
@@ -540,7 +563,7 @@ def add_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
return {"zone": zone, "id": rule_id, "rule": rule}
|
||||
|
||||
|
||||
@registry.register("DELETE", "/firewall/rich-rules/remove")
|
||||
@registry.register(DELETE_FIREWALL_RICH_RULES_REMOVE)
|
||||
def remove_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
@@ -578,7 +601,7 @@ def remove_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, An
|
||||
return {"zone": zone, "id": rule_id}
|
||||
|
||||
|
||||
@registry.register("GET", "/firewall/rich-rules")
|
||||
@registry.register(GET_FIREWALL_RICH_RULES)
|
||||
def list_rich_rules(_request: Any, body: dict[str, Any] | None) -> list[dict[str, Any]]:
|
||||
if not body or "zone" not in body:
|
||||
raise ValueError("'zone' is required")
|
||||
@@ -598,7 +621,7 @@ def list_rich_rules(_request: Any, body: dict[str, Any] | None) -> list[dict[str
|
||||
return result
|
||||
|
||||
|
||||
@registry.register("POST", "/firewall/masquerade")
|
||||
@registry.register(POST_FIREWALL_MASQUERADE)
|
||||
def set_masquerade(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
@@ -613,7 +636,7 @@ def set_masquerade(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]
|
||||
return {"zone": zone, "masquerade": bool(enable)}
|
||||
|
||||
|
||||
@registry.register("POST", "/firewall/forward-port/add")
|
||||
@registry.register(POST_FIREWALL_FORWARD_PORT_ADD)
|
||||
def add_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
@@ -656,7 +679,7 @@ def add_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str, An
|
||||
return {"zone": zone, "id": fp_id, "port": int(port), "proto": proto}
|
||||
|
||||
|
||||
@registry.register("DELETE", "/firewall/forward-port/remove")
|
||||
@registry.register(DELETE_FIREWALL_FORWARD_PORT_REMOVE)
|
||||
def remove_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
@@ -703,7 +726,7 @@ def remove_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str,
|
||||
return {"zone": zone, "port": int(port), "proto": proto}
|
||||
|
||||
|
||||
@registry.register("GET", "/firewall/state")
|
||||
@registry.register(GET_FIREWALL_STATE)
|
||||
def get_state(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
fw = _get_state()
|
||||
if fw is None:
|
||||
|
||||
+16
-9
@@ -6,7 +6,14 @@ Reads system logs and journal entries.
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from daemon.server import registry
|
||||
from daemon.iface import (
|
||||
GET_LOGS_APP,
|
||||
GET_LOGS_DNSMASQ,
|
||||
GET_LOGS_JOURNAL,
|
||||
GET_LOGS_NGINX_ACCESS,
|
||||
GET_LOGS_NGINX_ERROR,
|
||||
)
|
||||
from daemon.server import NotFoundError, registry
|
||||
from lib.common import run_proc
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -36,9 +43,9 @@ def _tail_file(path: str, n: int = _MAX_LINES, sudo: bool = False) -> str:
|
||||
lines = f.readlines()
|
||||
return "".join(lines[-n:])
|
||||
except FileNotFoundError:
|
||||
return "(log file not found)\n"
|
||||
raise NotFoundError("log file not found") from None
|
||||
except PermissionError:
|
||||
return "(permission denied)\n"
|
||||
raise RuntimeError("permission denied") from None
|
||||
|
||||
|
||||
def _sudo_journalctl(unit: str, n: int = _MAX_LINES) -> str:
|
||||
@@ -61,34 +68,34 @@ def _sudo_journalctl(unit: str, n: int = _MAX_LINES) -> str:
|
||||
output = result.stdout.strip()
|
||||
return output if output else f"(no journal entries for {unit})\n"
|
||||
except Exception as exc:
|
||||
return f"(error reading journal: {exc})\n"
|
||||
raise RuntimeError(f"error reading journal: {exc}") from exc
|
||||
|
||||
|
||||
@registry.register("GET", "/logs/journal")
|
||||
@registry.register(GET_LOGS_JOURNAL)
|
||||
def journal(_request, _body) -> str:
|
||||
"""GET /logs/journal — return vacuum-wall daemon journal entries."""
|
||||
return _sudo_journalctl("vacuum-wall")
|
||||
|
||||
|
||||
@registry.register("GET", "/logs/nginx/access")
|
||||
@registry.register(GET_LOGS_NGINX_ACCESS)
|
||||
def nginx_access(_request, _body) -> str:
|
||||
"""GET /logs/nginx/access — return recent nginx access log lines."""
|
||||
return _tail_file("/var/log/nginx/access.log", sudo=True)
|
||||
|
||||
|
||||
@registry.register("GET", "/logs/nginx/error")
|
||||
@registry.register(GET_LOGS_NGINX_ERROR)
|
||||
def nginx_error(_request, _body) -> str:
|
||||
"""GET /logs/nginx/error — return recent nginx error log lines."""
|
||||
return _tail_file("/var/log/nginx/error.log", sudo=True)
|
||||
|
||||
|
||||
@registry.register("GET", "/logs/dnsmasq")
|
||||
@registry.register(GET_LOGS_DNSMASQ)
|
||||
def dnsmasq_log(_request, _body) -> str:
|
||||
"""GET /logs/dnsmasq — return recent dnsmasq journal entries."""
|
||||
return _sudo_journalctl("dnsmasq")
|
||||
|
||||
|
||||
@registry.register("GET", "/logs/app")
|
||||
@registry.register(GET_LOGS_APP)
|
||||
def app_log(_request, _body) -> str:
|
||||
"""GET /logs/app — return recent application log lines."""
|
||||
return _tail_file(str(_APP_LOG_FILE))
|
||||
|
||||
+117
-21
@@ -6,13 +6,25 @@ via config/network/config.json and generated .network files.
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from daemon.iface import (
|
||||
GET_NETWORK_INFER_DHCP_RANGES,
|
||||
GET_NETWORK_INFER_ZONES,
|
||||
GET_NETWORK_INTERFACE_NAME,
|
||||
GET_NETWORK_INTERFACES,
|
||||
POST_NETWORK_APPLY,
|
||||
POST_NETWORK_INTERFACE_NAME,
|
||||
POST_NETWORK_INTERFACE_RELOAD,
|
||||
POST_NETWORK_SYSCTL_SET,
|
||||
)
|
||||
from daemon.server import NotFoundError, registry
|
||||
from lib.common import run
|
||||
from lib.common import run, validate_interface_name
|
||||
from lib.dnsmasq import set_upstreams
|
||||
from lib.network import (
|
||||
KNOWN_INTERFACE_KEYS,
|
||||
collect_upstream_dns,
|
||||
generate_network_files,
|
||||
get_config,
|
||||
@@ -31,15 +43,48 @@ DATA_DIR = PROJECT_DIR / "data" / "networkd"
|
||||
|
||||
|
||||
def _copy_and_reload(iface_name: str) -> None:
|
||||
"""Copy generated 50-<name>.network file to /etc/systemd/network/ and reload."""
|
||||
src = DATA_DIR / f"50-{iface_name}.network"
|
||||
"""Copy generated 99-<name>.network file to /etc/systemd/network/ and reload."""
|
||||
validate_interface_name(iface_name)
|
||||
src = DATA_DIR / f"99-{iface_name}.network"
|
||||
dst_dir = Path("/etc/systemd/network")
|
||||
run(["mkdir", "-p", str(dst_dir)], sudo=True)
|
||||
dst = dst_dir / f"50-{iface_name}.network"
|
||||
dst = dst_dir / f"99-{iface_name}.network"
|
||||
run(["cp", str(src), str(dst)], sudo=True)
|
||||
|
||||
# Remove lower-priority .network files that match this interface
|
||||
# (they would override our config due to higher systemd priority)
|
||||
if dst_dir.exists():
|
||||
for f in dst_dir.iterdir():
|
||||
if (
|
||||
f.name.endswith(".network")
|
||||
and f.name != dst.name
|
||||
and _matches_interface(f.name, iface_name)
|
||||
):
|
||||
with contextlib.suppress(Exception):
|
||||
run(["rm", str(f)], sudo=True)
|
||||
logger.info("Removed conflicting file: %s", f.name)
|
||||
|
||||
run(["networkctl", "reload"], sudo=True)
|
||||
run(["networkctl", "reconfigure", iface_name], sudo=True)
|
||||
|
||||
|
||||
def _matches_interface(filename: str, iface_name: str) -> bool:
|
||||
"""Check if a .network filename would match the given interface."""
|
||||
base = filename.replace(".network", "")
|
||||
# Strip numeric priority prefix (e.g. "50-eth1" → "eth1")
|
||||
if "-" in base and base.split("-", 1)[0].isdigit():
|
||||
base = base.split("-", 1)[1]
|
||||
return base == iface_name
|
||||
|
||||
|
||||
def _extract_iface_from_filename(filename: str) -> str | None:
|
||||
"""Extract interface name from a .network filename (e.g. '50-eth1.network' → 'eth1')."""
|
||||
base = filename.replace(".network", "")
|
||||
if "-" in base and base.split("-", 1)[0].isdigit():
|
||||
return base.split("-", 1)[1]
|
||||
return base if base else None
|
||||
|
||||
|
||||
def _full_reload() -> None:
|
||||
"""Reload networkd for all interfaces."""
|
||||
run(["networkctl", "reload"], sudo=True)
|
||||
@@ -50,7 +95,7 @@ def _full_reload() -> None:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@registry.register("GET", "/network/interfaces")
|
||||
@registry.register(GET_NETWORK_INTERFACES)
|
||||
def get_interfaces(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
"""GET /network/interfaces — return all interface config + runtime state."""
|
||||
cfg = get_config()
|
||||
@@ -68,15 +113,17 @@ def get_interfaces(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
"runtime": runtime.get(name, {}),
|
||||
}
|
||||
|
||||
return {"interfaces": merged, "timestamp": ""}
|
||||
from lib.state import _now_iso
|
||||
|
||||
return {"interfaces": merged, "timestamp": _now_iso()}
|
||||
|
||||
|
||||
@registry.register("GET", "/network/interfaces/<name>")
|
||||
@registry.register(GET_NETWORK_INTERFACE_NAME)
|
||||
def get_interface(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""GET /network/interfaces/<name> — return config for one interface."""
|
||||
if not body or "name" not in body:
|
||||
raise ValueError("Interface name is required")
|
||||
name = body["name"]
|
||||
name = validate_interface_name(body["name"])
|
||||
cfg = get_config()
|
||||
ifaces = cfg.get("interfaces", {})
|
||||
if name not in ifaces:
|
||||
@@ -94,17 +141,24 @@ def get_interface(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
@registry.register("POST", "/network/interfaces/<name>")
|
||||
@registry.register(POST_NETWORK_INTERFACE_NAME)
|
||||
def save_interface(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""POST /network/interfaces/<name> — save config, render, apply."""
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
name = body.get("name", "").strip()
|
||||
if not name:
|
||||
raise ValueError("'name' is required")
|
||||
name = validate_interface_name(body.get("name", ""))
|
||||
|
||||
iface_cfg = {k: v for k, v in body.items() if k not in ("name",)}
|
||||
|
||||
unknown = set(iface_cfg.keys()) - KNOWN_INTERFACE_KEYS
|
||||
if unknown:
|
||||
logger.warning(
|
||||
"Interface '%s': unexpected config keys %s — these will be "
|
||||
"saved but not rendered to .network files",
|
||||
name,
|
||||
sorted(unknown),
|
||||
)
|
||||
|
||||
with contextlib.suppress(Exception):
|
||||
raw = run(["networkctl", "status", "--all"], sudo=True)
|
||||
runtime = parse_networkctl_status(raw)
|
||||
@@ -122,7 +176,7 @@ def save_interface(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]
|
||||
|
||||
content = render_network_file(name, iface_cfg)
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
(DATA_DIR / f"50-{name}.network").write_text(content)
|
||||
(DATA_DIR / f"99-{name}.network").write_text(content)
|
||||
|
||||
# Deploy to system. In containerized environments this may fail
|
||||
# (e.g. read-only /run/sudo timestamps) — don't let that block the save.
|
||||
@@ -142,12 +196,12 @@ def save_interface(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]
|
||||
return {"name": name, "applied": deployed}
|
||||
|
||||
|
||||
@registry.register("POST", "/network/interfaces/<name>/reload")
|
||||
@registry.register(POST_NETWORK_INTERFACE_RELOAD)
|
||||
def reload_interface(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""POST /network/interfaces/<name>/reload — reload networkd for interface."""
|
||||
if not body or "name" not in body:
|
||||
raise ValueError("'name' is required in request body")
|
||||
name = body["name"]
|
||||
name = validate_interface_name(body["name"])
|
||||
|
||||
with contextlib.suppress(Exception):
|
||||
run(["networkctl", "reconfigure", name], sudo=True)
|
||||
@@ -156,7 +210,7 @@ def reload_interface(_request: Any, body: dict[str, Any] | None) -> dict[str, An
|
||||
return {"name": name, "reloaded": True}
|
||||
|
||||
|
||||
@registry.register("POST", "/network/apply")
|
||||
@registry.register(POST_NETWORK_APPLY)
|
||||
def apply_all(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
"""POST /network/apply — apply ALL interfaces (full sync)."""
|
||||
cfg = get_config()
|
||||
@@ -164,14 +218,21 @@ def apply_all(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
generated = result.get("generated", [])
|
||||
cleaned = result.get("cleaned", [])
|
||||
|
||||
# Remove stale files from system dir that aren't in config
|
||||
# Remove stale/conflicting files from system dir
|
||||
expected_names = {f.name for f in generated}
|
||||
managed_ifaces = {
|
||||
f.name.replace("99-", "").replace(".network", "") for f in generated
|
||||
}
|
||||
sys_dir = Path("/etc/systemd/network")
|
||||
if sys_dir.exists():
|
||||
for f in sys_dir.iterdir():
|
||||
if f.name.endswith(".network") and f.name not in expected_names:
|
||||
with contextlib.suppress(Exception):
|
||||
run(["rm", str(f)], sudo=True)
|
||||
iface_from_file = _extract_iface_from_filename(f.name)
|
||||
if iface_from_file and iface_from_file in managed_ifaces:
|
||||
# Remove conflicting external configs for managed interfaces
|
||||
with contextlib.suppress(Exception):
|
||||
run(["rm", str(f)], sudo=True)
|
||||
cleaned.append(f)
|
||||
|
||||
for f in generated:
|
||||
dst = sys_dir / f.name
|
||||
@@ -201,7 +262,7 @@ def apply_all(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
@registry.register("GET", "/network/infer-dhcp-ranges")
|
||||
@registry.register(GET_NETWORK_INFER_DHCP_RANGES)
|
||||
def get_infer_dhcp_ranges(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
"""GET /network/infer-dhcp-ranges — suggest DHCP ranges from static IPs."""
|
||||
cfg = get_config()
|
||||
@@ -209,9 +270,44 @@ def get_infer_dhcp_ranges(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
return {"ranges": ranges}
|
||||
|
||||
|
||||
@registry.register("GET", "/network/infer-zones")
|
||||
@registry.register(GET_NETWORK_INFER_ZONES)
|
||||
def get_infer_zones(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
"""GET /network/infer-zones — suggest firewalld zones from interface config."""
|
||||
cfg = get_config()
|
||||
zones = infer_zones(cfg)
|
||||
return {"zones": zones}
|
||||
|
||||
|
||||
@registry.register(POST_NETWORK_SYSCTL_SET)
|
||||
def set_sysctl(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""POST /sysctl/set — set a sysctl kernel parameter value.
|
||||
|
||||
Writes the value via `sysctl -w`, then verifies by reading it back.
|
||||
|
||||
Raises:
|
||||
ValueError: When name or value is missing.
|
||||
"""
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
name = body.get("name", "").strip()
|
||||
if not name:
|
||||
raise ValueError("'name' is required")
|
||||
if not re.match(r"^[a-zA-Z0-9_]+(\.[a-zA-Z0-9_]+)*$", name):
|
||||
raise ValueError("'name' is not a valid sysctl key")
|
||||
value = str(body.get("value", "")).strip()
|
||||
if not value:
|
||||
raise ValueError("'value' is required")
|
||||
|
||||
run(["sysctl", "-w", f"{name}={value}"], sudo=True)
|
||||
|
||||
# Verify by reading back via /proc/sys (no sudo needed for reads, avoid
|
||||
# triggering sudoers for read-only sysctl which is not whitelisted)
|
||||
proc_path = Path(f"/proc/sys/{name.replace('.', '/')}")
|
||||
read_value = proc_path.read_text().strip()
|
||||
if read_value != value:
|
||||
raise RuntimeError(
|
||||
f"sysctl verify failed: set {name}={value} but read back {read_value}"
|
||||
)
|
||||
|
||||
logger.info("sysctl %s set to %s", name, value)
|
||||
return {"name": name, "value": value}
|
||||
|
||||
+43
-18
@@ -8,6 +8,20 @@ from typing import Any
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from daemon.iface import (
|
||||
DELETE_NGINX_DOMAINS_REMOVE,
|
||||
GET_NGINX_CONFIG,
|
||||
GET_NGINX_DOMAINS,
|
||||
PATCH_NGINX_CONFIG,
|
||||
POST_NGINX_APPLY,
|
||||
POST_NGINX_CONFIG,
|
||||
POST_NGINX_DOMAINS_ADD,
|
||||
POST_NGINX_DOMAINS_UPDATE,
|
||||
POST_NGINX_MANAGEMENT,
|
||||
POST_NGINX_RELOAD,
|
||||
POST_NGINX_SSL_APPLY,
|
||||
POST_NGINX_TEST,
|
||||
)
|
||||
from daemon.server import NotFoundError, refresh_state, registry
|
||||
from lib.common import ensure_dirs, load_json, run, run_proc, save_json
|
||||
|
||||
@@ -125,7 +139,7 @@ def _write_include_file() -> None:
|
||||
"""Write the system include file that references all per-site configs."""
|
||||
tmpl = ENV.get_template("nginx/include.conf")
|
||||
content = tmpl.render(sites_glob=str(SITES_DIR / "*.conf"))
|
||||
tmp = INCLUDE_FILE.with_suffix(".tmp")
|
||||
tmp = Path("/tmp") / "vacuum-wall-include.tmp"
|
||||
with open(tmp, "w") as f:
|
||||
f.write(content)
|
||||
os.chmod(tmp, 0o644)
|
||||
@@ -143,7 +157,7 @@ def _write_ssl_snippet() -> None:
|
||||
ssl_cfg.setdefault("ciphers", DEFAULT_SSL["ciphers"])
|
||||
tmpl = ENV.get_template("nginx/ssl_snippet.conf")
|
||||
content = tmpl.render(ssl=ssl_cfg)
|
||||
tmp = SSL_SNIPPET.with_suffix(".tmp")
|
||||
tmp = Path("/tmp") / "vacuum-wall-ssl-snippet.tmp"
|
||||
with open(tmp, "w") as f:
|
||||
f.write(content)
|
||||
os.chmod(tmp, 0o644)
|
||||
@@ -225,6 +239,20 @@ def _write_all_sites() -> None:
|
||||
os.replace(tmp, site)
|
||||
|
||||
|
||||
def _hash_password(password: str) -> str:
|
||||
"""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
|
||||
|
||||
return sha256_crypt.hash(password)
|
||||
|
||||
|
||||
def _write_htpasswd(user: str, password: str) -> None:
|
||||
"""Add or update a user entry in the .htpasswd file using SHA-256 hashing.
|
||||
|
||||
@@ -233,10 +261,7 @@ def _write_htpasswd(user: str, password: str) -> None:
|
||||
password: Plain-text password to hash.
|
||||
"""
|
||||
ensure_dirs(DATA_DIR)
|
||||
import crypt
|
||||
|
||||
salt = os.urandom(16).hex()[:16]
|
||||
hashed = crypt.crypt(password, f"$5${salt}")
|
||||
hashed = _hash_password(password)
|
||||
existing: dict[str, str] = {}
|
||||
if HTPASSWD_FILE.exists():
|
||||
with open(HTPASSWD_FILE) as f:
|
||||
@@ -268,7 +293,7 @@ def _get_nginx_state() -> dict[str, Any]:
|
||||
# Routes
|
||||
|
||||
|
||||
@registry.register("GET", "/nginx/config")
|
||||
@registry.register(GET_NGINX_CONFIG)
|
||||
def get_config(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
"""GET /nginx/config — return current nginx config.
|
||||
|
||||
@@ -281,7 +306,7 @@ def get_config(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
return _get_config()
|
||||
|
||||
|
||||
@registry.register("POST", "/nginx/config")
|
||||
@registry.register(POST_NGINX_CONFIG)
|
||||
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.
|
||||
|
||||
@@ -295,7 +320,7 @@ def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str,
|
||||
return {"config_saved": True}
|
||||
|
||||
|
||||
@registry.register("PATCH", "/nginx/config")
|
||||
@registry.register(PATCH_NGINX_CONFIG)
|
||||
def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""PATCH /nginx/config — deep-merge partial updates into current config.
|
||||
|
||||
@@ -313,7 +338,7 @@ def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
return {"config_saved": True}
|
||||
|
||||
|
||||
@registry.register("GET", "/nginx/domains")
|
||||
@registry.register(GET_NGINX_DOMAINS)
|
||||
def get_domains(_request: Any, _body: Any) -> list[dict[str, Any]]:
|
||||
"""GET /nginx/domains — return the list of configured proxy domains.
|
||||
|
||||
@@ -326,7 +351,7 @@ def get_domains(_request: Any, _body: Any) -> list[dict[str, Any]]:
|
||||
return []
|
||||
|
||||
|
||||
@registry.register("POST", "/nginx/domains/add")
|
||||
@registry.register(POST_NGINX_DOMAINS_ADD)
|
||||
def add_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""POST /nginx/domains/add — add a new reverse-proxy domain entry.
|
||||
|
||||
@@ -369,7 +394,7 @@ def add_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
return {"domain": domain}
|
||||
|
||||
|
||||
@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]:
|
||||
"""DELETE /nginx/domains/remove — remove a domain from the proxy config.
|
||||
|
||||
@@ -394,7 +419,7 @@ def remove_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
return {"domain": domain}
|
||||
|
||||
|
||||
@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]:
|
||||
"""POST /nginx/domains/update — patch fields of an existing domain entry.
|
||||
|
||||
@@ -422,7 +447,7 @@ def update_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
return {"domain": domain}
|
||||
|
||||
|
||||
@registry.register("POST", "/nginx/apply")
|
||||
@registry.register(POST_NGINX_APPLY)
|
||||
def apply(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
"""POST /nginx/apply — render all configs, test, and reload nginx.
|
||||
|
||||
@@ -440,7 +465,7 @@ def apply(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
return {"applied": True}
|
||||
|
||||
|
||||
@registry.register("POST", "/nginx/test")
|
||||
@registry.register(POST_NGINX_TEST)
|
||||
def test(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
"""POST /nginx/test — dry-run validate the live nginx config without applying.
|
||||
|
||||
@@ -451,7 +476,7 @@ def test(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
return {"valid": valid, "output": output}
|
||||
|
||||
|
||||
@registry.register("POST", "/nginx/ssl-apply")
|
||||
@registry.register(POST_NGINX_SSL_APPLY)
|
||||
def ssl_apply(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
"""POST /nginx/ssl-apply — re-render and install only the SSL snippet."""
|
||||
_write_ssl_snippet()
|
||||
@@ -459,7 +484,7 @@ def ssl_apply(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
return {"applied": True}
|
||||
|
||||
|
||||
@registry.register("POST", "/nginx/management")
|
||||
@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.
|
||||
|
||||
@@ -490,7 +515,7 @@ def set_management_proxy(_request: Any, body: dict[str, Any] | None) -> dict[str
|
||||
return {"domain": domain}
|
||||
|
||||
|
||||
@registry.register("POST", "/nginx/reload")
|
||||
@registry.register(POST_NGINX_RELOAD)
|
||||
def reload_nginx(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
"""POST /nginx/reload — trigger an nginx reload (SIGHUP)."""
|
||||
_reload_nginx()
|
||||
|
||||
@@ -9,6 +9,20 @@ from typing import Any
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from daemon.iface import (
|
||||
DELETE_WIREGUARD_PEERS_REMOVE,
|
||||
GET_WIREGUARD_CONFIG,
|
||||
GET_WIREGUARD_PEER_STATUS,
|
||||
GET_WIREGUARD_PEERS,
|
||||
GET_WIREGUARD_STATUS,
|
||||
PATCH_WIREGUARD_CONFIG,
|
||||
POST_WIREGUARD_APPLY,
|
||||
POST_WIREGUARD_CONFIG,
|
||||
POST_WIREGUARD_DOWN,
|
||||
POST_WIREGUARD_GENERATE_CLIENT,
|
||||
POST_WIREGUARD_INITIALIZE,
|
||||
POST_WIREGUARD_PEERS_ADD,
|
||||
)
|
||||
from daemon.server import NotFoundError, refresh_state, registry
|
||||
from lib.common import deep_merge, load_json, run, run_proc, save_json
|
||||
|
||||
@@ -80,7 +94,7 @@ def _get_wg_state() -> dict[str, Any]:
|
||||
# Routes
|
||||
|
||||
|
||||
@registry.register("GET", "/wireguard/config")
|
||||
@registry.register(GET_WIREGUARD_CONFIG)
|
||||
def get_config(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
"""GET /wireguard/config — return WireGuard config with private key stripped."""
|
||||
wg = _get_wg_state()
|
||||
@@ -94,7 +108,7 @@ def get_config(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
return safe
|
||||
|
||||
|
||||
@registry.register("POST", "/wireguard/config")
|
||||
@registry.register(POST_WIREGUARD_CONFIG)
|
||||
def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""POST /wireguard/config — replace config, preserving existing private key.
|
||||
|
||||
@@ -116,7 +130,7 @@ def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str,
|
||||
return {"config_saved": True}
|
||||
|
||||
|
||||
@registry.register("PATCH", "/wireguard/config")
|
||||
@registry.register(PATCH_WIREGUARD_CONFIG)
|
||||
def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""PATCH /wireguard/config — deep-merge patch into existing config.
|
||||
|
||||
@@ -136,7 +150,7 @@ def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
return {"config_saved": True}
|
||||
|
||||
|
||||
@registry.register("POST", "/wireguard/apply")
|
||||
@registry.register(POST_WIREGUARD_APPLY)
|
||||
def apply(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
"""POST /wireguard/apply — render config, write to disk, bring up tunnel via sudo."""
|
||||
cfg = _get_config()
|
||||
@@ -157,7 +171,7 @@ def apply(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
return {"applied": True}
|
||||
|
||||
|
||||
@registry.register("POST", "/wireguard/down")
|
||||
@registry.register(POST_WIREGUARD_DOWN)
|
||||
def down(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
"""POST /wireguard/down — bring down the WireGuard tunnel via sudo."""
|
||||
cfg = _get_config()
|
||||
@@ -168,7 +182,7 @@ def down(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
return {"down": True}
|
||||
|
||||
|
||||
@registry.register("GET", "/wireguard/status")
|
||||
@registry.register(GET_WIREGUARD_STATUS)
|
||||
def status(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
"""GET /wireguard/status — return current WireGuard status from cache."""
|
||||
wg = _get_wg_state()
|
||||
@@ -177,7 +191,7 @@ def status(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
return {"up": False, "interface": {}, "peers": []}
|
||||
|
||||
|
||||
@registry.register("POST", "/wireguard/initialize")
|
||||
@registry.register(POST_WIREGUARD_INITIALIZE)
|
||||
def initialize(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
"""POST /wireguard/initialize — generate keypair and store in config (idempotent)."""
|
||||
cfg = _get_config()
|
||||
@@ -198,7 +212,7 @@ def initialize(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
return {"initialized": True, "config": safe}
|
||||
|
||||
|
||||
@registry.register("POST", "/wireguard/peers/add")
|
||||
@registry.register(POST_WIREGUARD_PEERS_ADD)
|
||||
def add_peer(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""POST /wireguard/peers/add — add new peer or update existing one.
|
||||
|
||||
@@ -242,7 +256,7 @@ def add_peer(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
return peer_out
|
||||
|
||||
|
||||
@registry.register("DELETE", "/wireguard/peers/remove")
|
||||
@registry.register(DELETE_WIREGUARD_PEERS_REMOVE)
|
||||
def remove_peer(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""DELETE /wireguard/peers/remove — remove a peer by name.
|
||||
|
||||
@@ -266,7 +280,7 @@ def remove_peer(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
return {"name": name}
|
||||
|
||||
|
||||
@registry.register("GET", "/wireguard/peers")
|
||||
@registry.register(GET_WIREGUARD_PEERS)
|
||||
def list_peers(_request: Any, _body: Any) -> list[dict[str, Any]]:
|
||||
"""GET /wireguard/peers — return configured peers with private keys stripped."""
|
||||
wg = _get_wg_state()
|
||||
@@ -282,7 +296,7 @@ def list_peers(_request: Any, _body: Any) -> list[dict[str, Any]]:
|
||||
return result
|
||||
|
||||
|
||||
@registry.register("GET", "/wireguard/peer-status")
|
||||
@registry.register(GET_WIREGUARD_PEER_STATUS)
|
||||
def get_peer_status(_request: Any, _body: Any) -> list[dict[str, Any]]:
|
||||
"""GET /wireguard/peer-status — return runtime peer status from cache."""
|
||||
wg = _get_wg_state()
|
||||
@@ -291,7 +305,7 @@ def get_peer_status(_request: Any, _body: Any) -> list[dict[str, Any]]:
|
||||
return []
|
||||
|
||||
|
||||
@registry.register("POST", "/wireguard/generate-client")
|
||||
@registry.register(POST_WIREGUARD_GENERATE_CLIENT)
|
||||
def generate_client_conf(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""POST /wireguard/generate-client — render client-side WireGuard config for a peer.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user