refactor: overhaul daemon server, client, and handlers
This commit is contained in:
+72
-52
@@ -5,14 +5,19 @@ Communicates with vacuum-walld over a Unix socket using requests-unixsocket.
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
import requests_unixsocket
|
import requests_unixsocket
|
||||||
|
|
||||||
|
from daemon.iface import PathLike
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_param_re = re.compile(r"<(\w+)>")
|
||||||
|
|
||||||
|
|
||||||
class NotFound(Exception):
|
class NotFound(Exception):
|
||||||
"""Raised when the daemon returns HTTP 404."""
|
"""Raised when the daemon returns HTTP 404."""
|
||||||
@@ -68,9 +73,41 @@ def set_socket_path(path: str) -> None:
|
|||||||
_DEFAULT_SOCKET = path
|
_DEFAULT_SOCKET = path
|
||||||
|
|
||||||
|
|
||||||
|
def _format_path(path: str, params: dict[str, Any] | None) -> str:
|
||||||
|
"""Replace ``<param>`` path segments with URL-encoded values from *params*.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: URL path that may contain ``<key>`` placeholders.
|
||||||
|
params: Dict of parameter values to substitute.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path with all ``<key>`` segments replaced by their URL-encoded
|
||||||
|
values. Unmatched placeholders are left unchanged.
|
||||||
|
"""
|
||||||
|
if params is None:
|
||||||
|
return path
|
||||||
|
|
||||||
|
def _replace(m: re.Match[str]) -> str:
|
||||||
|
key = m.group(1)
|
||||||
|
if key in params:
|
||||||
|
return urllib.parse.quote(str(params[key]), safe="")
|
||||||
|
return m.group(0)
|
||||||
|
|
||||||
|
return _param_re.sub(_replace, path)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_path(method_or_ep: PathLike, path: str | None = None) -> tuple[str, str]:
|
||||||
|
"""Resolve method/path from an Endpoint tuple or two separate arguments."""
|
||||||
|
if isinstance(method_or_ep, tuple):
|
||||||
|
return (method_or_ep[0], method_or_ep[1])
|
||||||
|
if path is None:
|
||||||
|
raise ValueError("path is required when method is a string")
|
||||||
|
return (method_or_ep, path)
|
||||||
|
|
||||||
|
|
||||||
def request(
|
def request(
|
||||||
method: str,
|
method: PathLike,
|
||||||
path: str,
|
path: str | None = None,
|
||||||
json_body: dict[str, Any] | None = None,
|
json_body: dict[str, Any] | None = None,
|
||||||
query_params: dict[str, Any] | None = None,
|
query_params: dict[str, Any] | None = None,
|
||||||
socket_path: str | None = None,
|
socket_path: str | None = None,
|
||||||
@@ -78,27 +115,40 @@ def request(
|
|||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Make a request to the daemon and return the parsed response body.
|
"""Make a request to the daemon and return the parsed response body.
|
||||||
|
|
||||||
|
*method* can be an :class:`Endpoint` tuple from :mod:`daemon.iface`,
|
||||||
|
in which case *path* should be omitted.
|
||||||
|
|
||||||
For GET requests, query_params are sent as URL query parameters instead
|
For GET requests, query_params are sent as URL query parameters instead
|
||||||
of a JSON body. For other methods, json_body is sent as JSON.
|
of a JSON body. For other methods, json_body is sent as JSON.
|
||||||
|
|
||||||
Raises RuntimeError on non-2xx responses or connection errors.
|
Raises RuntimeError on non-2xx responses or connection errors.
|
||||||
Raises NotFound on HTTP 404. Raises BadRequest on HTTP 400.
|
Raises NotFound on HTTP 404. Raises BadRequest on HTTP 400.
|
||||||
"""
|
"""
|
||||||
|
resolved_method, resolved_path = _resolve_path(method, path)
|
||||||
|
|
||||||
|
# Substitute <param> segments from body/query params so the daemon
|
||||||
|
# receives a concrete path instead of a template.
|
||||||
|
# Merge body and query params for <param> substitution. query_params
|
||||||
|
# takes precedence on key conflicts, so callers should avoid passing
|
||||||
|
# the same key in both dicts.
|
||||||
|
combined = {**(json_body or {}), **(query_params or {})}
|
||||||
|
formatted_path = _format_path(resolved_path, combined)
|
||||||
|
|
||||||
sp = socket_path or _get_socket_path()
|
sp = socket_path or _get_socket_path()
|
||||||
url = f"http+unix://{urllib.parse.quote(sp, safe='')}{path}"
|
url = f"http+unix://{urllib.parse.quote(sp, safe='')}{formatted_path}"
|
||||||
sess = requests_unixsocket.Session()
|
sess = requests_unixsocket.Session()
|
||||||
try:
|
try:
|
||||||
kwargs: dict[str, Any] = {
|
kwargs: dict[str, Any] = {
|
||||||
"timeout": timeout,
|
"timeout": timeout,
|
||||||
}
|
}
|
||||||
if method == "GET":
|
if resolved_method == "GET":
|
||||||
if query_params:
|
if query_params:
|
||||||
kwargs["params"] = query_params
|
kwargs["params"] = query_params
|
||||||
else:
|
else:
|
||||||
if json_body is not None:
|
if json_body is not None:
|
||||||
kwargs["json"] = json_body
|
kwargs["json"] = json_body
|
||||||
resp = sess.request(
|
resp = sess.request(
|
||||||
method,
|
resolved_method,
|
||||||
url,
|
url,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
)
|
||||||
@@ -112,7 +162,9 @@ def request(
|
|||||||
except requests.ConnectionError as exc:
|
except requests.ConnectionError as exc:
|
||||||
raise RuntimeError(f"Cannot connect to daemon at {sp}: {exc}") from exc
|
raise RuntimeError(f"Cannot connect to daemon at {sp}: {exc}") from exc
|
||||||
except requests.Timeout as exc:
|
except requests.Timeout as exc:
|
||||||
raise RuntimeError(f"Daemon request timed out: {method} {path}") from exc
|
raise RuntimeError(
|
||||||
|
f"Daemon request timed out: {resolved_method} {resolved_path}"
|
||||||
|
) from exc
|
||||||
except requests.HTTPError as exc:
|
except requests.HTTPError as exc:
|
||||||
try:
|
try:
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
@@ -129,72 +181,40 @@ def request(
|
|||||||
return data.get("data")
|
return data.get("data")
|
||||||
|
|
||||||
|
|
||||||
def get(path: str, params: dict[str, Any] | None = None, **kwargs: Any) -> Any:
|
def get(path: PathLike, params: dict[str, Any] | None = None, **kwargs: Any) -> Any:
|
||||||
"""Send a GET request to the daemon.
|
"""Send a GET request to the daemon.
|
||||||
|
|
||||||
Query parameters are passed as URL params rather than a JSON body.
|
*path* can be a :class:`Endpoint` tuple from :mod:`daemon.iface`
|
||||||
Additional keyword arguments are forwarded to request().
|
(e.g., ``GET_FIREWALL_ZONES``), or a plain string path.
|
||||||
|
|
||||||
Args:
|
|
||||||
path: URL path to request on the daemon.
|
|
||||||
params: Optional query parameters to append to the URL.
|
|
||||||
**kwargs: Extra arguments forwarded to request().
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The parsed JSON response data from the daemon.
|
|
||||||
"""
|
"""
|
||||||
return request("GET", path, query_params=params, **kwargs)
|
return request(path, query_params=params, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
def post(path: str, body: dict[str, Any] | None = None, **kwargs: Any) -> Any:
|
def post(path: PathLike, body: dict[str, Any] | None = None, **kwargs: Any) -> Any:
|
||||||
"""Send a POST request to the daemon.
|
"""Send a POST request to the daemon.
|
||||||
|
|
||||||
The body is transmitted as a JSON payload. Extra keyword arguments
|
The body is transmitted as a JSON payload. Extra keyword arguments
|
||||||
are forwarded to request().
|
are forwarded to request(). *path* can be an :class:`Endpoint` tuple.
|
||||||
|
|
||||||
Args:
|
|
||||||
path: URL path to request on the daemon.
|
|
||||||
body: Optional JSON-serializable payload.
|
|
||||||
**kwargs: Extra arguments forwarded to request().
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The parsed JSON response data from the daemon.
|
|
||||||
"""
|
"""
|
||||||
return request("POST", path, json_body=body, **kwargs)
|
return request(path, json_body=body, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
def patch(path: str, body: dict[str, Any] | None = None, **kwargs: Any) -> Any:
|
def patch(path: PathLike, body: dict[str, Any] | None = None, **kwargs: Any) -> Any:
|
||||||
"""Send a PATCH request to the daemon.
|
"""Send a PATCH request to the daemon.
|
||||||
|
|
||||||
The body is transmitted as a JSON payload. Extra keyword arguments
|
The body is transmitted as a JSON payload. Extra keyword arguments
|
||||||
are forwarded to request().
|
are forwarded to request(). *path* can be an :class:`Endpoint` tuple.
|
||||||
|
|
||||||
Args:
|
|
||||||
path: URL path to request on the daemon.
|
|
||||||
body: Optional JSON-serializable payload.
|
|
||||||
**kwargs: Extra arguments forwarded to request().
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The parsed JSON response data from the daemon.
|
|
||||||
"""
|
"""
|
||||||
return request("PATCH", path, json_body=body, **kwargs)
|
return request(path, json_body=body, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
def delete(path: str, body: dict[str, Any] | None = None, **kwargs: Any) -> Any:
|
def delete(path: PathLike, body: dict[str, Any] | None = None, **kwargs: Any) -> Any:
|
||||||
"""Send a DELETE request to the daemon.
|
"""Send a DELETE request to the daemon.
|
||||||
|
|
||||||
The body is transmitted as a JSON payload. Extra keyword arguments
|
The body is transmitted as a JSON payload. Extra keyword arguments
|
||||||
are forwarded to request().
|
are forwarded to request(). *path* can be an :class:`Endpoint` tuple.
|
||||||
|
|
||||||
Args:
|
|
||||||
path: URL path to request on the daemon.
|
|
||||||
body: Optional JSON-serializable payload.
|
|
||||||
**kwargs: Extra arguments forwarded to request().
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The parsed JSON response data from the daemon.
|
|
||||||
"""
|
"""
|
||||||
return request("DELETE", path, json_body=body, **kwargs)
|
return request(path, json_body=body, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
def batch(ops: list[dict[str, Any]], **kwargs: Any) -> dict[str, Any]:
|
def batch(ops: list[dict[str, Any]], **kwargs: Any) -> dict[str, Any]:
|
||||||
@@ -203,4 +223,4 @@ def batch(ops: list[dict[str, Any]], **kwargs: Any) -> dict[str, Any]:
|
|||||||
Each op is a dict with 'id', 'method', 'path', and optionally 'body'.
|
Each op is a dict with 'id', 'method', 'path', and optionally 'body'.
|
||||||
Returns a dict mapping each id to its result.
|
Returns a dict mapping each id to its result.
|
||||||
"""
|
"""
|
||||||
return post("/batch", {"ops": ops}, **kwargs)
|
return request("POST", "/batch", json_body={"ops": ops}, **kwargs)
|
||||||
|
|||||||
+89
-42
@@ -12,7 +12,21 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import uuid4
|
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 daemon.server import NotFoundError, refresh_state, registry
|
||||||
|
from lib.state import _run_acme
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -99,38 +113,6 @@ class IssueRequest:
|
|||||||
# Internal helpers
|
# 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:
|
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.state import _find_acme
|
||||||
@@ -326,14 +308,14 @@ def _validate(domain: str) -> dict[str, Any]:
|
|||||||
# Routes — status reads from state, mutations call refresh_state
|
# 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]:
|
def list_certs(_request: Any, _body: Any) -> list[dict]:
|
||||||
"""GET /acme/list — return managed certificates."""
|
"""GET /acme/list — return managed certificates."""
|
||||||
ac = _get_acme_state()
|
ac = _get_acme_state()
|
||||||
return ac.get("certs", [])
|
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:
|
def get_cert_info(_request: Any, body: dict[str, Any] | None) -> dict:
|
||||||
"""GET /acme/info — return details for a single domain certificate.
|
"""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}")
|
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]:
|
def validate_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
"""POST /acme/validate — run pre-flight checks for a domain.
|
"""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)
|
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]:
|
async def issue_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
"""POST /acme/issue — create a new certificate issuance request.
|
"""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}
|
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]:
|
def get_issuance_status(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
"""GET /acme/issue/status — poll status of an issuance request.
|
"""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)
|
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]:
|
def renew_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
"""POST /acme/renew — renew a certificate for the given domain.
|
"""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()}
|
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]:
|
def remove_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
"""DELETE /acme/remove — remove a certificate from ACME management.
|
"""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}
|
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]:
|
def set_email(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
"""POST /acme/email — set the ACME contact email via account registration.
|
"""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}
|
return {"email": email}
|
||||||
|
|
||||||
|
|
||||||
@registry.register("GET", "/acme/email")
|
@registry.register(GET_ACME_EMAIL)
|
||||||
def get_email(_request: Any, _body: Any) -> dict[str, Any]:
|
def get_email(_request: Any, _body: Any) -> dict[str, Any]:
|
||||||
"""GET /acme/email — return the currently configured ACME contact email."""
|
"""GET /acme/email — return the currently configured ACME contact email."""
|
||||||
ac = _get_acme_state()
|
ac = _get_acme_state()
|
||||||
@@ -582,7 +564,7 @@ def get_email(_request: Any, _body: Any) -> dict[str, Any]:
|
|||||||
return {"email": email}
|
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]:
|
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.
|
"""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",
|
"ca": f"{acme_home}/ca.cer",
|
||||||
"fullchain": f"{acme_home}/fullchain.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 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 daemon.server import NotFoundError, refresh_state, registry
|
||||||
from lib.common import deep_merge, ensure_dirs, load_json, run, run_proc, save_json
|
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 = [
|
interfaces = [
|
||||||
r["interface"] for r in dhcp_cfg.get("ranges", []) if "interface" in r
|
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")
|
tmpl = ENV.get_template("dnsmasq.conf")
|
||||||
return tmpl.render(
|
return tmpl.render(
|
||||||
timestamp=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
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,
|
dhcp=dhcp_cfg,
|
||||||
dns=dns_cfg,
|
dns=dns_cfg,
|
||||||
fragments_dir=str(FRAGMENTS_DIR) if FRAGMENTS_DIR.exists() else None,
|
fragments_dir=str(FRAGMENTS_DIR) if FRAGMENTS_DIR.exists() else None,
|
||||||
@@ -86,7 +118,7 @@ def _get_dnsmasq_state() -> dict[str, Any]:
|
|||||||
# Routes
|
# Routes
|
||||||
|
|
||||||
|
|
||||||
@registry.register("GET", "/dnsmasq/config")
|
@registry.register(GET_DNSMASQ_CONFIG)
|
||||||
def get_config(_request: Any, _body: Any) -> dict[str, Any]:
|
def get_config(_request: Any, _body: Any) -> dict[str, Any]:
|
||||||
"""\
|
"""\
|
||||||
Endpoint: GET /dnsmasq/config
|
Endpoint: GET /dnsmasq/config
|
||||||
@@ -99,7 +131,7 @@ def get_config(_request: Any, _body: Any) -> dict[str, Any]:
|
|||||||
return _get_config()
|
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]:
|
def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
"""\
|
"""\
|
||||||
Endpoint: POST /dnsmasq/config
|
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}
|
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]:
|
def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
"""\
|
"""\
|
||||||
Endpoint: PATCH /dnsmasq/config
|
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}
|
return {"config_saved": True}
|
||||||
|
|
||||||
|
|
||||||
@registry.register("POST", "/dnsmasq/apply")
|
@registry.register(POST_DNSMASQ_APPLY)
|
||||||
def apply_config(_request: Any, _body: Any) -> dict[str, Any]:
|
def apply_config(_request: Any, _body: Any) -> dict[str, Any]:
|
||||||
"""\
|
"""\
|
||||||
Endpoint: POST /dnsmasq/apply
|
Endpoint: POST /dnsmasq/apply
|
||||||
@@ -152,7 +184,7 @@ def apply_config(_request: Any, _body: Any) -> dict[str, Any]:
|
|||||||
return {"applied": True}
|
return {"applied": True}
|
||||||
|
|
||||||
|
|
||||||
@registry.register("GET", "/dnsmasq/status")
|
@registry.register(GET_DNSMASQ_STATUS)
|
||||||
def get_status(_request: Any, _body: Any) -> dict[str, Any]:
|
def get_status(_request: Any, _body: Any) -> dict[str, Any]:
|
||||||
"""\
|
"""\
|
||||||
Endpoint: GET /dnsmasq/status
|
Endpoint: GET /dnsmasq/status
|
||||||
@@ -165,7 +197,7 @@ def get_status(_request: Any, _body: Any) -> dict[str, Any]:
|
|||||||
return {}
|
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]:
|
def set_dhcp_range(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
"""\
|
"""\
|
||||||
Endpoint: POST /dnsmasq/ranges/add
|
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}
|
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]:
|
def remove_dhcp_range(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
"""\
|
"""\
|
||||||
Endpoint: DELETE /dnsmasq/ranges/remove
|
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}
|
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]]:
|
def get_leases(_request: Any, _body: Any) -> list[dict[str, Any]]:
|
||||||
"""\
|
"""\
|
||||||
Endpoint: GET /dnsmasq/leases
|
Endpoint: GET /dnsmasq/leases
|
||||||
@@ -262,7 +294,7 @@ def get_leases(_request: Any, _body: Any) -> list[dict[str, Any]]:
|
|||||||
return []
|
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]:
|
def add_static_lease(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
"""\
|
"""\
|
||||||
Endpoint: POST /dnsmasq/static-lease/add
|
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}
|
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]:
|
def remove_static_lease(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
"""\
|
"""\
|
||||||
Endpoint: DELETE /dnsmasq/static-lease/remove
|
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}
|
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]:
|
def add_dns_record(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
"""\
|
"""\
|
||||||
Endpoint: POST /dnsmasq/dns-record/add
|
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}
|
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]:
|
def remove_dns_record(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
"""\
|
"""\
|
||||||
Endpoint: DELETE /dnsmasq/dns-record/remove
|
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}
|
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]:
|
def set_upstreams(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
"""\
|
"""\
|
||||||
Endpoint: POST /dnsmasq/upstreams
|
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"]}
|
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]:
|
def set_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
"""\
|
"""\
|
||||||
Endpoint: POST /dnsmasq/domain
|
Endpoint: POST /dnsmasq/domain
|
||||||
|
|||||||
+44
-21
@@ -10,6 +10,29 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import uuid4
|
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 daemon.server import NotFoundError, refresh_state, registry
|
||||||
from lib.common import load_json, run, save_json
|
from lib.common import load_json, run, save_json
|
||||||
from lib.firewall import (
|
from lib.firewall import (
|
||||||
@@ -269,14 +292,14 @@ def _get_fw_state() -> dict[str, Any]:
|
|||||||
return fw
|
return fw
|
||||||
|
|
||||||
|
|
||||||
@registry.register("GET", "/firewall/interfaces")
|
@registry.register(GET_FIREWALL_INTERFACES)
|
||||||
def get_interfaces(_request: Any, _body: Any) -> list[dict[str, Any]]:
|
def get_interfaces(_request: Any, _body: Any) -> list[dict[str, Any]]:
|
||||||
"""GET /firewall/interfaces — return active interfaces from state."""
|
"""GET /firewall/interfaces — return active interfaces from state."""
|
||||||
fw = _get_fw_state()
|
fw = _get_fw_state()
|
||||||
return fw.get("interfaces", [])
|
return fw.get("interfaces", [])
|
||||||
|
|
||||||
|
|
||||||
@registry.register("GET", "/firewall/zones")
|
@registry.register(GET_FIREWALL_ZONES)
|
||||||
def get_zones(_request: Any, _body: Any) -> dict[str, Any]:
|
def get_zones(_request: Any, _body: Any) -> dict[str, Any]:
|
||||||
fw = _get_fw_state()
|
fw = _get_fw_state()
|
||||||
active = fw.get("active_zones", {})
|
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())}
|
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]:
|
def get_zone_info(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
if not body or "zone" not in body:
|
if not body or "zone" not in body:
|
||||||
raise ValueError("'zone' is required")
|
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]
|
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]]:
|
def get_all_zones_info(_request: Any, _body: Any) -> list[dict[str, Any]]:
|
||||||
fw = _get_fw_state()
|
fw = _get_fw_state()
|
||||||
active = fw.get("active_zones", {})
|
active = fw.get("active_zones", {})
|
||||||
@@ -308,18 +331,18 @@ def get_all_zones_info(_request: Any, _body: Any) -> list[dict[str, Any]]:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@registry.register("GET", "/firewall/services")
|
@registry.register(GET_FIREWALL_SERVICES)
|
||||||
def get_services(_request: Any, _body: Any) -> list[str]:
|
def get_services(_request: Any, _body: Any) -> list[str]:
|
||||||
fw = _get_fw_state()
|
fw = _get_fw_state()
|
||||||
return fw.get("available_services", [])
|
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]:
|
def get_config(_request: Any, _body: Any) -> dict[str, Any]:
|
||||||
return _get_config()
|
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]:
|
def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
if not body or "zones" not in body:
|
if not body or "zones" not in body:
|
||||||
raise ValueError("'zones' key is required")
|
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}
|
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]:
|
def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
if not body:
|
if not body:
|
||||||
raise ValueError("Request body must be a JSON object")
|
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}
|
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]:
|
def config_pending_handler(_request: Any, _body: Any) -> dict[str, Any]:
|
||||||
fw = _get_fw_state()
|
fw = _get_fw_state()
|
||||||
return fw.get("pending", {})
|
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]:
|
def config_apply(_request: Any, _body: Any) -> dict[str, Any]:
|
||||||
result = _config_apply()
|
result = _config_apply()
|
||||||
logger.info("Firewall config applied: %s", result.get("applied_zones", []))
|
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
|
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]:
|
def create_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
if not body:
|
if not body:
|
||||||
raise ValueError("Request body required")
|
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}
|
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]:
|
def delete_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
if not body or "zone" not in body:
|
if not body or "zone" not in body:
|
||||||
raise ValueError("'zone' is required")
|
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}
|
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]:
|
def set_zone_interfaces(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
if not body:
|
if not body:
|
||||||
raise ValueError("Request body required")
|
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}
|
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]:
|
def set_zone_services(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
if not body:
|
if not body:
|
||||||
raise ValueError("Request body required")
|
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}
|
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]:
|
def add_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
if not body:
|
if not body:
|
||||||
raise ValueError("Request body required")
|
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}
|
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]:
|
def remove_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
if not body:
|
if not body:
|
||||||
raise ValueError("Request body required")
|
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}
|
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]]:
|
def list_rich_rules(_request: Any, body: dict[str, Any] | None) -> list[dict[str, Any]]:
|
||||||
if not body or "zone" not in body:
|
if not body or "zone" not in body:
|
||||||
raise ValueError("'zone' is required")
|
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
|
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]:
|
def set_masquerade(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
if not body:
|
if not body:
|
||||||
raise ValueError("Request body required")
|
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)}
|
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]:
|
def add_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
if not body:
|
if not body:
|
||||||
raise ValueError("Request body required")
|
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}
|
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]:
|
def remove_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
if not body:
|
if not body:
|
||||||
raise ValueError("Request body required")
|
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}
|
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]:
|
def get_state(_request: Any, _body: Any) -> dict[str, Any]:
|
||||||
fw = _get_state()
|
fw = _get_state()
|
||||||
if fw is None:
|
if fw is None:
|
||||||
|
|||||||
+16
-9
@@ -6,7 +6,14 @@ Reads system logs and journal entries.
|
|||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
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
|
from lib.common import run_proc
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -36,9 +43,9 @@ def _tail_file(path: str, n: int = _MAX_LINES, sudo: bool = False) -> str:
|
|||||||
lines = f.readlines()
|
lines = f.readlines()
|
||||||
return "".join(lines[-n:])
|
return "".join(lines[-n:])
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
return "(log file not found)\n"
|
raise NotFoundError("log file not found") from None
|
||||||
except PermissionError:
|
except PermissionError:
|
||||||
return "(permission denied)\n"
|
raise RuntimeError("permission denied") from None
|
||||||
|
|
||||||
|
|
||||||
def _sudo_journalctl(unit: str, n: int = _MAX_LINES) -> str:
|
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()
|
output = result.stdout.strip()
|
||||||
return output if output else f"(no journal entries for {unit})\n"
|
return output if output else f"(no journal entries for {unit})\n"
|
||||||
except Exception as exc:
|
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:
|
def journal(_request, _body) -> str:
|
||||||
"""GET /logs/journal — return vacuum-wall daemon journal entries."""
|
"""GET /logs/journal — return vacuum-wall daemon journal entries."""
|
||||||
return _sudo_journalctl("vacuum-wall")
|
return _sudo_journalctl("vacuum-wall")
|
||||||
|
|
||||||
|
|
||||||
@registry.register("GET", "/logs/nginx/access")
|
@registry.register(GET_LOGS_NGINX_ACCESS)
|
||||||
def nginx_access(_request, _body) -> str:
|
def nginx_access(_request, _body) -> str:
|
||||||
"""GET /logs/nginx/access — return recent nginx access log lines."""
|
"""GET /logs/nginx/access — return recent nginx access log lines."""
|
||||||
return _tail_file("/var/log/nginx/access.log", sudo=True)
|
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:
|
def nginx_error(_request, _body) -> str:
|
||||||
"""GET /logs/nginx/error — return recent nginx error log lines."""
|
"""GET /logs/nginx/error — return recent nginx error log lines."""
|
||||||
return _tail_file("/var/log/nginx/error.log", sudo=True)
|
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:
|
def dnsmasq_log(_request, _body) -> str:
|
||||||
"""GET /logs/dnsmasq — return recent dnsmasq journal entries."""
|
"""GET /logs/dnsmasq — return recent dnsmasq journal entries."""
|
||||||
return _sudo_journalctl("dnsmasq")
|
return _sudo_journalctl("dnsmasq")
|
||||||
|
|
||||||
|
|
||||||
@registry.register("GET", "/logs/app")
|
@registry.register(GET_LOGS_APP)
|
||||||
def app_log(_request, _body) -> str:
|
def app_log(_request, _body) -> str:
|
||||||
"""GET /logs/app — return recent application log lines."""
|
"""GET /logs/app — return recent application log lines."""
|
||||||
return _tail_file(str(_APP_LOG_FILE))
|
return _tail_file(str(_APP_LOG_FILE))
|
||||||
|
|||||||
+115
-19
@@ -6,13 +6,25 @@ via config/network/config.json and generated .network files.
|
|||||||
|
|
||||||
import contextlib
|
import contextlib
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
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 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.dnsmasq import set_upstreams
|
||||||
from lib.network import (
|
from lib.network import (
|
||||||
|
KNOWN_INTERFACE_KEYS,
|
||||||
collect_upstream_dns,
|
collect_upstream_dns,
|
||||||
generate_network_files,
|
generate_network_files,
|
||||||
get_config,
|
get_config,
|
||||||
@@ -31,15 +43,48 @@ DATA_DIR = PROJECT_DIR / "data" / "networkd"
|
|||||||
|
|
||||||
|
|
||||||
def _copy_and_reload(iface_name: str) -> None:
|
def _copy_and_reload(iface_name: str) -> None:
|
||||||
"""Copy generated 50-<name>.network file to /etc/systemd/network/ and reload."""
|
"""Copy generated 99-<name>.network file to /etc/systemd/network/ and reload."""
|
||||||
src = DATA_DIR / f"50-{iface_name}.network"
|
validate_interface_name(iface_name)
|
||||||
|
src = DATA_DIR / f"99-{iface_name}.network"
|
||||||
dst_dir = Path("/etc/systemd/network")
|
dst_dir = Path("/etc/systemd/network")
|
||||||
run(["mkdir", "-p", str(dst_dir)], sudo=True)
|
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)
|
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)
|
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:
|
def _full_reload() -> None:
|
||||||
"""Reload networkd for all interfaces."""
|
"""Reload networkd for all interfaces."""
|
||||||
run(["networkctl", "reload"], sudo=True)
|
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]:
|
def get_interfaces(_request: Any, _body: Any) -> dict[str, Any]:
|
||||||
"""GET /network/interfaces — return all interface config + runtime state."""
|
"""GET /network/interfaces — return all interface config + runtime state."""
|
||||||
cfg = get_config()
|
cfg = get_config()
|
||||||
@@ -68,15 +113,17 @@ def get_interfaces(_request: Any, _body: Any) -> dict[str, Any]:
|
|||||||
"runtime": runtime.get(name, {}),
|
"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]:
|
def get_interface(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
"""GET /network/interfaces/<name> — return config for one interface."""
|
"""GET /network/interfaces/<name> — return config for one interface."""
|
||||||
if not body or "name" not in body:
|
if not body or "name" not in body:
|
||||||
raise ValueError("Interface name is required")
|
raise ValueError("Interface name is required")
|
||||||
name = body["name"]
|
name = validate_interface_name(body["name"])
|
||||||
cfg = get_config()
|
cfg = get_config()
|
||||||
ifaces = cfg.get("interfaces", {})
|
ifaces = cfg.get("interfaces", {})
|
||||||
if name not in ifaces:
|
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]:
|
def save_interface(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
"""POST /network/interfaces/<name> — save config, render, apply."""
|
"""POST /network/interfaces/<name> — save config, render, apply."""
|
||||||
if not body:
|
if not body:
|
||||||
raise ValueError("Request body required")
|
raise ValueError("Request body required")
|
||||||
name = body.get("name", "").strip()
|
name = validate_interface_name(body.get("name", ""))
|
||||||
if not name:
|
|
||||||
raise ValueError("'name' is required")
|
|
||||||
|
|
||||||
iface_cfg = {k: v for k, v in body.items() if k not in ("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):
|
with contextlib.suppress(Exception):
|
||||||
raw = run(["networkctl", "status", "--all"], sudo=True)
|
raw = run(["networkctl", "status", "--all"], sudo=True)
|
||||||
runtime = parse_networkctl_status(raw)
|
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)
|
content = render_network_file(name, iface_cfg)
|
||||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
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
|
# Deploy to system. In containerized environments this may fail
|
||||||
# (e.g. read-only /run/sudo timestamps) — don't let that block the save.
|
# (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}
|
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]:
|
def reload_interface(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
"""POST /network/interfaces/<name>/reload — reload networkd for interface."""
|
"""POST /network/interfaces/<name>/reload — reload networkd for interface."""
|
||||||
if not body or "name" not in body:
|
if not body or "name" not in body:
|
||||||
raise ValueError("'name' is required in request body")
|
raise ValueError("'name' is required in request body")
|
||||||
name = body["name"]
|
name = validate_interface_name(body["name"])
|
||||||
|
|
||||||
with contextlib.suppress(Exception):
|
with contextlib.suppress(Exception):
|
||||||
run(["networkctl", "reconfigure", name], sudo=True)
|
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}
|
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]:
|
def apply_all(_request: Any, _body: Any) -> dict[str, Any]:
|
||||||
"""POST /network/apply — apply ALL interfaces (full sync)."""
|
"""POST /network/apply — apply ALL interfaces (full sync)."""
|
||||||
cfg = get_config()
|
cfg = get_config()
|
||||||
@@ -164,14 +218,21 @@ def apply_all(_request: Any, _body: Any) -> dict[str, Any]:
|
|||||||
generated = result.get("generated", [])
|
generated = result.get("generated", [])
|
||||||
cleaned = result.get("cleaned", [])
|
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}
|
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")
|
sys_dir = Path("/etc/systemd/network")
|
||||||
if sys_dir.exists():
|
if sys_dir.exists():
|
||||||
for f in sys_dir.iterdir():
|
for f in sys_dir.iterdir():
|
||||||
if f.name.endswith(".network") and f.name not in expected_names:
|
if f.name.endswith(".network") and f.name not in expected_names:
|
||||||
|
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):
|
with contextlib.suppress(Exception):
|
||||||
run(["rm", str(f)], sudo=True)
|
run(["rm", str(f)], sudo=True)
|
||||||
|
cleaned.append(f)
|
||||||
|
|
||||||
for f in generated:
|
for f in generated:
|
||||||
dst = sys_dir / f.name
|
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]:
|
def get_infer_dhcp_ranges(_request: Any, _body: Any) -> dict[str, Any]:
|
||||||
"""GET /network/infer-dhcp-ranges — suggest DHCP ranges from static IPs."""
|
"""GET /network/infer-dhcp-ranges — suggest DHCP ranges from static IPs."""
|
||||||
cfg = get_config()
|
cfg = get_config()
|
||||||
@@ -209,9 +270,44 @@ def get_infer_dhcp_ranges(_request: Any, _body: Any) -> dict[str, Any]:
|
|||||||
return {"ranges": ranges}
|
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]:
|
def get_infer_zones(_request: Any, _body: Any) -> dict[str, Any]:
|
||||||
"""GET /network/infer-zones — suggest firewalld zones from interface config."""
|
"""GET /network/infer-zones — suggest firewalld zones from interface config."""
|
||||||
cfg = get_config()
|
cfg = get_config()
|
||||||
zones = infer_zones(cfg)
|
zones = infer_zones(cfg)
|
||||||
return {"zones": zones}
|
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 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 daemon.server import NotFoundError, refresh_state, registry
|
||||||
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
|
||||||
|
|
||||||
@@ -125,7 +139,7 @@ def _write_include_file() -> None:
|
|||||||
"""Write the system include file that references all per-site configs."""
|
"""Write the system include file that references all per-site configs."""
|
||||||
tmpl = ENV.get_template("nginx/include.conf")
|
tmpl = ENV.get_template("nginx/include.conf")
|
||||||
content = tmpl.render(sites_glob=str(SITES_DIR / "*.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:
|
with open(tmp, "w") as f:
|
||||||
f.write(content)
|
f.write(content)
|
||||||
os.chmod(tmp, 0o644)
|
os.chmod(tmp, 0o644)
|
||||||
@@ -143,7 +157,7 @@ def _write_ssl_snippet() -> None:
|
|||||||
ssl_cfg.setdefault("ciphers", DEFAULT_SSL["ciphers"])
|
ssl_cfg.setdefault("ciphers", DEFAULT_SSL["ciphers"])
|
||||||
tmpl = ENV.get_template("nginx/ssl_snippet.conf")
|
tmpl = ENV.get_template("nginx/ssl_snippet.conf")
|
||||||
content = tmpl.render(ssl=ssl_cfg)
|
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:
|
with open(tmp, "w") as f:
|
||||||
f.write(content)
|
f.write(content)
|
||||||
os.chmod(tmp, 0o644)
|
os.chmod(tmp, 0o644)
|
||||||
@@ -225,6 +239,20 @@ def _write_all_sites() -> None:
|
|||||||
os.replace(tmp, site)
|
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:
|
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.
|
||||||
|
|
||||||
@@ -233,10 +261,7 @@ def _write_htpasswd(user: str, password: str) -> None:
|
|||||||
password: Plain-text password to hash.
|
password: Plain-text password to hash.
|
||||||
"""
|
"""
|
||||||
ensure_dirs(DATA_DIR)
|
ensure_dirs(DATA_DIR)
|
||||||
import crypt
|
hashed = _hash_password(password)
|
||||||
|
|
||||||
salt = os.urandom(16).hex()[:16]
|
|
||||||
hashed = crypt.crypt(password, f"$5${salt}")
|
|
||||||
existing: dict[str, str] = {}
|
existing: dict[str, str] = {}
|
||||||
if HTPASSWD_FILE.exists():
|
if HTPASSWD_FILE.exists():
|
||||||
with open(HTPASSWD_FILE) as f:
|
with open(HTPASSWD_FILE) as f:
|
||||||
@@ -268,7 +293,7 @@ def _get_nginx_state() -> dict[str, Any]:
|
|||||||
# Routes
|
# Routes
|
||||||
|
|
||||||
|
|
||||||
@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.
|
||||||
|
|
||||||
@@ -281,7 +306,7 @@ def get_config(_request: Any, _body: Any) -> dict[str, Any]:
|
|||||||
return _get_config()
|
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]:
|
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.
|
||||||
|
|
||||||
@@ -295,7 +320,7 @@ def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str,
|
|||||||
return {"config_saved": True}
|
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]:
|
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.
|
||||||
|
|
||||||
@@ -313,7 +338,7 @@ def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
|||||||
return {"config_saved": True}
|
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]]:
|
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.
|
||||||
|
|
||||||
@@ -326,7 +351,7 @@ def get_domains(_request: Any, _body: Any) -> list[dict[str, Any]]:
|
|||||||
return []
|
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]:
|
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.
|
||||||
|
|
||||||
@@ -369,7 +394,7 @@ def add_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
|||||||
return {"domain": domain}
|
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]:
|
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.
|
||||||
|
|
||||||
@@ -394,7 +419,7 @@ def remove_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
|||||||
return {"domain": domain}
|
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]:
|
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.
|
||||||
|
|
||||||
@@ -422,7 +447,7 @@ def update_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
|||||||
return {"domain": domain}
|
return {"domain": domain}
|
||||||
|
|
||||||
|
|
||||||
@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.
|
||||||
|
|
||||||
@@ -440,7 +465,7 @@ def apply(_request: Any, _body: Any) -> dict[str, Any]:
|
|||||||
return {"applied": True}
|
return {"applied": True}
|
||||||
|
|
||||||
|
|
||||||
@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.
|
||||||
|
|
||||||
@@ -451,7 +476,7 @@ def test(_request: Any, _body: Any) -> dict[str, Any]:
|
|||||||
return {"valid": valid, "output": output}
|
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]:
|
def ssl_apply(_request: Any, _body: Any) -> dict[str, Any]:
|
||||||
"""POST /nginx/ssl-apply — re-render and install only the SSL snippet."""
|
"""POST /nginx/ssl-apply — re-render and install only the SSL snippet."""
|
||||||
_write_ssl_snippet()
|
_write_ssl_snippet()
|
||||||
@@ -459,7 +484,7 @@ def ssl_apply(_request: Any, _body: Any) -> dict[str, Any]:
|
|||||||
return {"applied": True}
|
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]:
|
def set_management_proxy(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
"""POST /nginx/management — configure the management UI reverse proxy.
|
"""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}
|
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)."""
|
||||||
_reload_nginx()
|
_reload_nginx()
|
||||||
|
|||||||
@@ -9,6 +9,20 @@ from typing import Any
|
|||||||
|
|
||||||
from jinja2 import Environment, FileSystemLoader
|
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 daemon.server import NotFoundError, refresh_state, registry
|
||||||
from lib.common import deep_merge, load_json, run, run_proc, save_json
|
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
|
# Routes
|
||||||
|
|
||||||
|
|
||||||
@registry.register("GET", "/wireguard/config")
|
@registry.register(GET_WIREGUARD_CONFIG)
|
||||||
def get_config(_request: Any, _body: Any) -> dict[str, Any]:
|
def get_config(_request: Any, _body: Any) -> dict[str, Any]:
|
||||||
"""GET /wireguard/config — return WireGuard config with private key stripped."""
|
"""GET /wireguard/config — return WireGuard config with private key stripped."""
|
||||||
wg = _get_wg_state()
|
wg = _get_wg_state()
|
||||||
@@ -94,7 +108,7 @@ def get_config(_request: Any, _body: Any) -> dict[str, Any]:
|
|||||||
return safe
|
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]:
|
def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
"""POST /wireguard/config — replace config, preserving existing private key.
|
"""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}
|
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]:
|
def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
"""PATCH /wireguard/config — deep-merge patch into existing config.
|
"""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}
|
return {"config_saved": True}
|
||||||
|
|
||||||
|
|
||||||
@registry.register("POST", "/wireguard/apply")
|
@registry.register(POST_WIREGUARD_APPLY)
|
||||||
def apply(_request: Any, _body: Any) -> dict[str, Any]:
|
def apply(_request: Any, _body: Any) -> dict[str, Any]:
|
||||||
"""POST /wireguard/apply — render config, write to disk, bring up tunnel via sudo."""
|
"""POST /wireguard/apply — render config, write to disk, bring up tunnel via sudo."""
|
||||||
cfg = _get_config()
|
cfg = _get_config()
|
||||||
@@ -157,7 +171,7 @@ def apply(_request: Any, _body: Any) -> dict[str, Any]:
|
|||||||
return {"applied": True}
|
return {"applied": True}
|
||||||
|
|
||||||
|
|
||||||
@registry.register("POST", "/wireguard/down")
|
@registry.register(POST_WIREGUARD_DOWN)
|
||||||
def down(_request: Any, _body: Any) -> dict[str, Any]:
|
def down(_request: Any, _body: Any) -> dict[str, Any]:
|
||||||
"""POST /wireguard/down — bring down the WireGuard tunnel via sudo."""
|
"""POST /wireguard/down — bring down the WireGuard tunnel via sudo."""
|
||||||
cfg = _get_config()
|
cfg = _get_config()
|
||||||
@@ -168,7 +182,7 @@ def down(_request: Any, _body: Any) -> dict[str, Any]:
|
|||||||
return {"down": True}
|
return {"down": True}
|
||||||
|
|
||||||
|
|
||||||
@registry.register("GET", "/wireguard/status")
|
@registry.register(GET_WIREGUARD_STATUS)
|
||||||
def status(_request: Any, _body: Any) -> dict[str, Any]:
|
def status(_request: Any, _body: Any) -> dict[str, Any]:
|
||||||
"""GET /wireguard/status — return current WireGuard status from cache."""
|
"""GET /wireguard/status — return current WireGuard status from cache."""
|
||||||
wg = _get_wg_state()
|
wg = _get_wg_state()
|
||||||
@@ -177,7 +191,7 @@ def status(_request: Any, _body: Any) -> dict[str, Any]:
|
|||||||
return {"up": False, "interface": {}, "peers": []}
|
return {"up": False, "interface": {}, "peers": []}
|
||||||
|
|
||||||
|
|
||||||
@registry.register("POST", "/wireguard/initialize")
|
@registry.register(POST_WIREGUARD_INITIALIZE)
|
||||||
def initialize(_request: Any, _body: Any) -> dict[str, Any]:
|
def initialize(_request: Any, _body: Any) -> dict[str, Any]:
|
||||||
"""POST /wireguard/initialize — generate keypair and store in config (idempotent)."""
|
"""POST /wireguard/initialize — generate keypair and store in config (idempotent)."""
|
||||||
cfg = _get_config()
|
cfg = _get_config()
|
||||||
@@ -198,7 +212,7 @@ def initialize(_request: Any, _body: Any) -> dict[str, Any]:
|
|||||||
return {"initialized": True, "config": safe}
|
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]:
|
def add_peer(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
"""POST /wireguard/peers/add — add new peer or update existing one.
|
"""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
|
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]:
|
def remove_peer(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
"""DELETE /wireguard/peers/remove — remove a peer by name.
|
"""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}
|
return {"name": name}
|
||||||
|
|
||||||
|
|
||||||
@registry.register("GET", "/wireguard/peers")
|
@registry.register(GET_WIREGUARD_PEERS)
|
||||||
def list_peers(_request: Any, _body: Any) -> list[dict[str, Any]]:
|
def list_peers(_request: Any, _body: Any) -> list[dict[str, Any]]:
|
||||||
"""GET /wireguard/peers — return configured peers with private keys stripped."""
|
"""GET /wireguard/peers — return configured peers with private keys stripped."""
|
||||||
wg = _get_wg_state()
|
wg = _get_wg_state()
|
||||||
@@ -282,7 +296,7 @@ def list_peers(_request: Any, _body: Any) -> list[dict[str, Any]]:
|
|||||||
return result
|
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]]:
|
def get_peer_status(_request: Any, _body: Any) -> list[dict[str, Any]]:
|
||||||
"""GET /wireguard/peer-status — return runtime peer status from cache."""
|
"""GET /wireguard/peer-status — return runtime peer status from cache."""
|
||||||
wg = _get_wg_state()
|
wg = _get_wg_state()
|
||||||
@@ -291,7 +305,7 @@ def get_peer_status(_request: Any, _body: Any) -> list[dict[str, Any]]:
|
|||||||
return []
|
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]:
|
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.
|
"""POST /wireguard/generate-client — render client-side WireGuard config for a peer.
|
||||||
|
|
||||||
|
|||||||
+153
@@ -0,0 +1,153 @@
|
|||||||
|
"""Shared walld interface definitions.
|
||||||
|
|
||||||
|
This module is the **single source of truth** for all daemon API endpoints.
|
||||||
|
Every endpoint is a frozen tuple of (method, path). Both the server's
|
||||||
|
registry.register() and the client's request/get/post/patch/delete() accept
|
||||||
|
an Endpoint in addition to a plain string path, so renaming an endpoint here
|
||||||
|
automatically updates both sides.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
|
||||||
|
# Server (daemon/handlers/)
|
||||||
|
from daemon.iface import GET_FIREWALL_ZONES
|
||||||
|
|
||||||
|
@registry.register(GET_FIREWALL_ZONES)
|
||||||
|
def get_zones(_request, _body):
|
||||||
|
...
|
||||||
|
|
||||||
|
# Client (webui/api/)
|
||||||
|
from daemon.iface import GET_FIREWALL_ZONES
|
||||||
|
from daemon.client import get
|
||||||
|
|
||||||
|
data = get(GET_FIREWALL_ZONES)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
Endpoint = tuple[str, str]
|
||||||
|
PathLike = str | Endpoint
|
||||||
|
|
||||||
|
|
||||||
|
def _ep(method: str, path: str) -> Endpoint:
|
||||||
|
return (method, path)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Nginx / Proxy ----
|
||||||
|
GET_NGINX_CONFIG: Endpoint = _ep("GET", "/nginx/config")
|
||||||
|
POST_NGINX_CONFIG: Endpoint = _ep("POST", "/nginx/config")
|
||||||
|
PATCH_NGINX_CONFIG: Endpoint = _ep("PATCH", "/nginx/config")
|
||||||
|
GET_NGINX_DOMAINS: Endpoint = _ep("GET", "/nginx/domains")
|
||||||
|
POST_NGINX_DOMAINS_ADD: Endpoint = _ep("POST", "/nginx/domains/add")
|
||||||
|
DELETE_NGINX_DOMAINS_REMOVE: Endpoint = _ep("DELETE", "/nginx/domains/remove")
|
||||||
|
POST_NGINX_DOMAINS_UPDATE: Endpoint = _ep("POST", "/nginx/domains/update")
|
||||||
|
POST_NGINX_APPLY: Endpoint = _ep("POST", "/nginx/apply")
|
||||||
|
POST_NGINX_TEST: Endpoint = _ep("POST", "/nginx/test")
|
||||||
|
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")
|
||||||
|
|
||||||
|
# ---- Firewall ----
|
||||||
|
GET_FIREWALL_INTERFACES: Endpoint = _ep("GET", "/firewall/interfaces")
|
||||||
|
GET_FIREWALL_ZONES: Endpoint = _ep("GET", "/firewall/zones")
|
||||||
|
GET_FIREWALL_ZONES_INFO: Endpoint = _ep("GET", "/firewall/zones/info")
|
||||||
|
GET_FIREWALL_ZONES_ALL: Endpoint = _ep("GET", "/firewall/zones/all")
|
||||||
|
GET_FIREWALL_SERVICES: Endpoint = _ep("GET", "/firewall/services")
|
||||||
|
GET_FIREWALL_CONFIG: Endpoint = _ep("GET", "/firewall/config")
|
||||||
|
POST_FIREWALL_CONFIG: Endpoint = _ep("POST", "/firewall/config")
|
||||||
|
PATCH_FIREWALL_CONFIG: Endpoint = _ep("PATCH", "/firewall/config")
|
||||||
|
GET_FIREWALL_CONFIG_PENDING: Endpoint = _ep("GET", "/firewall/config/pending")
|
||||||
|
POST_FIREWALL_CONFIG_APPLY: Endpoint = _ep("POST", "/firewall/config/apply")
|
||||||
|
POST_FIREWALL_ZONES_CREATE: Endpoint = _ep("POST", "/firewall/zones/create")
|
||||||
|
DELETE_FIREWALL_ZONES_DELETE: Endpoint = _ep("DELETE", "/firewall/zones/delete")
|
||||||
|
POST_FIREWALL_ZONES_INTERFACES: Endpoint = _ep("POST", "/firewall/zones/interfaces")
|
||||||
|
POST_FIREWALL_ZONES_SERVICES: Endpoint = _ep("POST", "/firewall/zones/services")
|
||||||
|
POST_FIREWALL_RICH_RULES_ADD: Endpoint = _ep("POST", "/firewall/rich-rules/add")
|
||||||
|
DELETE_FIREWALL_RICH_RULES_REMOVE: Endpoint = _ep(
|
||||||
|
"DELETE", "/firewall/rich-rules/remove"
|
||||||
|
)
|
||||||
|
GET_FIREWALL_RICH_RULES: Endpoint = _ep("GET", "/firewall/rich-rules")
|
||||||
|
POST_FIREWALL_MASQUERADE: Endpoint = _ep("POST", "/firewall/masquerade")
|
||||||
|
POST_FIREWALL_FORWARD_PORT_ADD: Endpoint = _ep("POST", "/firewall/forward-port/add")
|
||||||
|
DELETE_FIREWALL_FORWARD_PORT_REMOVE: Endpoint = _ep(
|
||||||
|
"DELETE", "/firewall/forward-port/remove"
|
||||||
|
)
|
||||||
|
GET_FIREWALL_STATE: Endpoint = _ep("GET", "/firewall/state")
|
||||||
|
|
||||||
|
# ---- WireGuard ----
|
||||||
|
GET_WIREGUARD_CONFIG: Endpoint = _ep("GET", "/wireguard/config")
|
||||||
|
POST_WIREGUARD_CONFIG: Endpoint = _ep("POST", "/wireguard/config")
|
||||||
|
PATCH_WIREGUARD_CONFIG: Endpoint = _ep("PATCH", "/wireguard/config")
|
||||||
|
POST_WIREGUARD_APPLY: Endpoint = _ep("POST", "/wireguard/apply")
|
||||||
|
POST_WIREGUARD_DOWN: Endpoint = _ep("POST", "/wireguard/down")
|
||||||
|
GET_WIREGUARD_STATUS: Endpoint = _ep("GET", "/wireguard/status")
|
||||||
|
POST_WIREGUARD_INITIALIZE: Endpoint = _ep("POST", "/wireguard/initialize")
|
||||||
|
POST_WIREGUARD_PEERS_ADD: Endpoint = _ep("POST", "/wireguard/peers/add")
|
||||||
|
DELETE_WIREGUARD_PEERS_REMOVE: Endpoint = _ep("DELETE", "/wireguard/peers/remove")
|
||||||
|
GET_WIREGUARD_PEERS: Endpoint = _ep("GET", "/wireguard/peers")
|
||||||
|
GET_WIREGUARD_PEER_STATUS: Endpoint = _ep("GET", "/wireguard/peer-status")
|
||||||
|
POST_WIREGUARD_GENERATE_CLIENT: Endpoint = _ep("POST", "/wireguard/generate-client")
|
||||||
|
|
||||||
|
# ---- ACME / Certs ----
|
||||||
|
GET_ACME_LIST: Endpoint = _ep("GET", "/acme/list")
|
||||||
|
GET_ACME_INFO: Endpoint = _ep("GET", "/acme/info")
|
||||||
|
POST_ACME_VALIDATE: Endpoint = _ep("POST", "/acme/validate")
|
||||||
|
POST_ACME_ISSUE: Endpoint = _ep("POST", "/acme/issue")
|
||||||
|
GET_ACME_ISSUE_STATUS: Endpoint = _ep("GET", "/acme/issue/status")
|
||||||
|
POST_ACME_RENEW: Endpoint = _ep("POST", "/acme/renew")
|
||||||
|
DELETE_ACME_REMOVE: Endpoint = _ep("DELETE", "/acme/remove")
|
||||||
|
POST_ACME_EMAIL: Endpoint = _ep("POST", "/acme/email")
|
||||||
|
GET_ACME_EMAIL: Endpoint = _ep("GET", "/acme/email")
|
||||||
|
GET_ACME_PATHS: Endpoint = _ep("GET", "/acme/paths")
|
||||||
|
POST_ACME_SELF_SIGNED: Endpoint = _ep("POST", "/acme/self-signed")
|
||||||
|
|
||||||
|
# ---- Dnsmasq / DHCP ----
|
||||||
|
GET_DNSMASQ_CONFIG: Endpoint = _ep("GET", "/dnsmasq/config")
|
||||||
|
POST_DNSMASQ_CONFIG: Endpoint = _ep("POST", "/dnsmasq/config")
|
||||||
|
PATCH_DNSMASQ_CONFIG: Endpoint = _ep("PATCH", "/dnsmasq/config")
|
||||||
|
POST_DNSMASQ_APPLY: Endpoint = _ep("POST", "/dnsmasq/apply")
|
||||||
|
GET_DNSMASQ_STATUS: Endpoint = _ep("GET", "/dnsmasq/status")
|
||||||
|
POST_DNSMASQ_RANGES_ADD: Endpoint = _ep("POST", "/dnsmasq/ranges/add")
|
||||||
|
DELETE_DNSMASQ_RANGES_REMOVE: Endpoint = _ep("DELETE", "/dnsmasq/ranges/remove")
|
||||||
|
GET_DNSMASQ_LEASES: Endpoint = _ep("GET", "/dnsmasq/leases")
|
||||||
|
POST_DNSMASQ_STATIC_LEASE_ADD: Endpoint = _ep("POST", "/dnsmasq/static-lease/add")
|
||||||
|
DELETE_DNSMASQ_STATIC_LEASE_REMOVE: Endpoint = _ep(
|
||||||
|
"DELETE", "/dnsmasq/static-lease/remove"
|
||||||
|
)
|
||||||
|
POST_DNSMASQ_DNS_RECORD_ADD: Endpoint = _ep("POST", "/dnsmasq/dns-record/add")
|
||||||
|
DELETE_DNSMASQ_DNS_RECORD_REMOVE: Endpoint = _ep("DELETE", "/dnsmasq/dns-record/remove")
|
||||||
|
POST_DNSMASQ_UPSTREAMS: Endpoint = _ep("POST", "/dnsmasq/upstreams")
|
||||||
|
POST_DNSMASQ_DOMAIN: Endpoint = _ep("POST", "/dnsmasq/domain")
|
||||||
|
|
||||||
|
# ---- Network ----
|
||||||
|
GET_NETWORK_INTERFACES: Endpoint = _ep("GET", "/network/interfaces")
|
||||||
|
GET_NETWORK_INTERFACE_NAME: Endpoint = _ep("GET", "/network/interfaces/<name>")
|
||||||
|
POST_NETWORK_INTERFACE_NAME: Endpoint = _ep("POST", "/network/interfaces/<name>")
|
||||||
|
POST_NETWORK_INTERFACE_RELOAD: Endpoint = _ep(
|
||||||
|
"POST", "/network/interfaces/<name>/reload"
|
||||||
|
)
|
||||||
|
POST_NETWORK_APPLY: Endpoint = _ep("POST", "/network/apply")
|
||||||
|
GET_NETWORK_INFER_DHCP_RANGES: Endpoint = _ep("GET", "/network/infer-dhcp-ranges")
|
||||||
|
GET_NETWORK_INFER_ZONES: Endpoint = _ep("GET", "/network/infer-zones")
|
||||||
|
POST_NETWORK_SYSCTL_SET: Endpoint = _ep("POST", "/network/sysctl/set")
|
||||||
|
|
||||||
|
# ---- Logs ----
|
||||||
|
GET_LOGS_JOURNAL: Endpoint = _ep("GET", "/logs/journal")
|
||||||
|
GET_LOGS_NGINX_ACCESS: Endpoint = _ep("GET", "/logs/nginx/access")
|
||||||
|
GET_LOGS_NGINX_ERROR: Endpoint = _ep("GET", "/logs/nginx/error")
|
||||||
|
GET_LOGS_DNSMASQ: Endpoint = _ep("GET", "/logs/dnsmasq")
|
||||||
|
GET_LOGS_APP: Endpoint = _ep("GET", "/logs/app")
|
||||||
|
|
||||||
|
# ---- Server infra (not going through client) ----
|
||||||
|
GET_HEALTH: Endpoint = _ep("GET", "/health")
|
||||||
|
GET_STATUS_ALL: Endpoint = _ep("GET", "/status/all")
|
||||||
|
POST_STATUS_REFRESH: Endpoint = _ep("POST", "/status/refresh")
|
||||||
|
GET_WS: Endpoint = _ep("GET", "/ws")
|
||||||
|
POST_BATCH: Endpoint = _ep("POST", "/batch")
|
||||||
|
|
||||||
|
# Collect all endpoint module-level constants for __all__ verification
|
||||||
|
_all_endpoints = [
|
||||||
|
name
|
||||||
|
for name, val in globals().items()
|
||||||
|
if isinstance(val, tuple) and len(val) == 2 and all(isinstance(x, str) for x in val)
|
||||||
|
]
|
||||||
|
__all__ = ["Endpoint", "PathLike", *sorted(_all_endpoints)]
|
||||||
+76
-5
@@ -15,12 +15,14 @@ from typing import Any
|
|||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
||||||
|
from daemon.iface import PathLike
|
||||||
from lib.state import state as state_store
|
from lib.state import state as state_store
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
PROJECT_DIR = Path(__file__).resolve().parent.parent
|
PROJECT_DIR = Path(__file__).resolve().parent.parent
|
||||||
SOCKET_PATH = PROJECT_DIR / "data" / "daemon.sock"
|
SOCKET_PATH = PROJECT_DIR / "data" / "daemon.sock"
|
||||||
|
_WS_PORT = int(os.environ.get("VACUUM_WALLD_WS_PORT", "9091"))
|
||||||
|
|
||||||
|
|
||||||
class Handler:
|
class Handler:
|
||||||
@@ -53,20 +55,28 @@ class Registry:
|
|||||||
"""Initialize an empty route registry."""
|
"""Initialize an empty route registry."""
|
||||||
self._routes: dict[tuple[str, str], Callable] = {}
|
self._routes: dict[tuple[str, str], Callable] = {}
|
||||||
|
|
||||||
def register(self, method: str, path: str):
|
def register(self, method: PathLike, path: str | None = None):
|
||||||
"""Decorator that registers a handler for the given method and path.
|
"""Decorator that registers a handler for the given method and path.
|
||||||
|
|
||||||
|
Accepts either two separate arguments (``method``, ``path``) or a
|
||||||
|
single :class:`daemon.iface.Endpoint` tuple.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
method: HTTP method (e.g. "GET", "POST").
|
method: HTTP method string, or an :class:`Endpoint` tuple.
|
||||||
path: URL path to register the handler under.
|
path: URL path (omit when passing an :class:`Endpoint`).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Decorator function wrapping the handler.
|
Decorator function wrapping the handler.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
if isinstance(method, tuple):
|
||||||
|
ep_method, ep_path = method
|
||||||
|
path = ep_path
|
||||||
|
method = ep_method
|
||||||
|
|
||||||
def decorator(fn: Callable) -> Callable:
|
def decorator(fn: Callable) -> Callable:
|
||||||
self._routes[(method.upper(), path)] = fn
|
self._routes[(method.upper(), path)] = fn # type: ignore[arg-type]
|
||||||
fn._handler = Handler(method, path) # type: ignore[attr-defined]
|
fn._handler = Handler(method, path) # type: ignore[attr-defined,reportArgumentType]
|
||||||
return fn
|
return fn
|
||||||
|
|
||||||
return decorator
|
return decorator
|
||||||
@@ -111,6 +121,17 @@ def refresh_state(subsystems: list[str] | None = None) -> None:
|
|||||||
subsystems: List of subsystem names to refresh. If None, all subsystems are refreshed.
|
subsystems: List of subsystem names to refresh. If None, all subsystems are refreshed.
|
||||||
"""
|
"""
|
||||||
state_store.populate(subsystems)
|
state_store.populate(subsystems)
|
||||||
|
targets = subsystems or state_store.SUBSYSTEMS
|
||||||
|
for name in targets:
|
||||||
|
state_store.bump(name)
|
||||||
|
try:
|
||||||
|
asyncio.get_running_loop()
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
task = asyncio.create_task(broadcast_versions())
|
||||||
|
task.add_done_callback(_ws_tasks.discard)
|
||||||
|
_ws_tasks.add(task)
|
||||||
|
|
||||||
|
|
||||||
class NotFoundError(Exception):
|
class NotFoundError(Exception):
|
||||||
@@ -295,10 +316,57 @@ def create_app() -> web.Application:
|
|||||||
app.router.add_route("GET", "/status/all", get_status_all)
|
app.router.add_route("GET", "/status/all", get_status_all)
|
||||||
app.router.add_route("POST", "/status/refresh", refresh_status)
|
app.router.add_route("POST", "/status/refresh", refresh_status)
|
||||||
app.router.add_route("POST", "/batch", _handle_batch)
|
app.router.add_route("POST", "/batch", _handle_batch)
|
||||||
|
app.router.add_route("GET", "/ws", _handle_ws)
|
||||||
app.router.add_route("*", "/{tail:.*}", _catch_all)
|
app.router.add_route("*", "/{tail:.*}", _catch_all)
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
# WebSocket subscribers
|
||||||
|
_ws_subscribers: set[web.WebSocketResponse] = set()
|
||||||
|
_ws_tasks: set[asyncio.Task[None]] = set()
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_ws(request: web.Request) -> web.Response:
|
||||||
|
"""WebSocket endpoint for real-time state change notifications.
|
||||||
|
|
||||||
|
On connect: sends current versions. On state change: broadcasts
|
||||||
|
updated subsystem versions. Clients disconnect to unsubscribe.
|
||||||
|
"""
|
||||||
|
ws = web.WebSocketResponse()
|
||||||
|
await ws.prepare(request)
|
||||||
|
_ws_subscribers.add(ws)
|
||||||
|
|
||||||
|
await ws.send_json({"type": "init", "versions": state_store.get_versions()})
|
||||||
|
|
||||||
|
try:
|
||||||
|
async for msg in ws:
|
||||||
|
if msg.type == web.WSMsgType.ERROR:
|
||||||
|
break
|
||||||
|
if msg.type == web.WSMsgType.CLOSE:
|
||||||
|
break
|
||||||
|
finally:
|
||||||
|
_ws_subscribers.discard(ws)
|
||||||
|
|
||||||
|
return ws
|
||||||
|
|
||||||
|
|
||||||
|
async def broadcast_versions() -> None:
|
||||||
|
"""Broadcast updated subsystem versions to all WebSocket clients."""
|
||||||
|
updated = state_store.get_updated_versions()
|
||||||
|
if not updated or not _ws_subscribers:
|
||||||
|
return
|
||||||
|
data = json.dumps({"type": "versions", "updated": updated})
|
||||||
|
dead: set[web.WebSocketResponse] = set()
|
||||||
|
for ws in _ws_subscribers:
|
||||||
|
try:
|
||||||
|
await ws.send_str(data)
|
||||||
|
except Exception:
|
||||||
|
dead.add(ws)
|
||||||
|
_ws_subscribers.difference_update(dead)
|
||||||
|
if dead:
|
||||||
|
logger.warning("Removed %d dead WS subscribers", len(dead))
|
||||||
|
|
||||||
|
|
||||||
async def _health(_request: web.Request) -> web.Response:
|
async def _health(_request: web.Request) -> web.Response:
|
||||||
"""Return the health check response.
|
"""Return the health check response.
|
||||||
|
|
||||||
@@ -399,6 +467,8 @@ def main() -> None:
|
|||||||
loop.run_until_complete(runner.setup())
|
loop.run_until_complete(runner.setup())
|
||||||
site = web.UnixSite(runner, socket_path)
|
site = web.UnixSite(runner, socket_path)
|
||||||
loop.run_until_complete(site.start())
|
loop.run_until_complete(site.start())
|
||||||
|
tcp_site = web.TCPSite(runner, "127.0.0.1", _WS_PORT)
|
||||||
|
loop.run_until_complete(tcp_site.start())
|
||||||
|
|
||||||
os.chmod(socket_path, 0o660)
|
os.chmod(socket_path, 0o660)
|
||||||
|
|
||||||
@@ -406,6 +476,7 @@ def main() -> None:
|
|||||||
logger.info("Populating system state...")
|
logger.info("Populating system state...")
|
||||||
state_store.populate()
|
state_store.populate()
|
||||||
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)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
loop.run_forever()
|
loop.run_forever()
|
||||||
|
|||||||
Reference in New Issue
Block a user