Refactor nginx to path-based domain model with config migration
Replace the legacy top-level management key with a unified paths-based model. Each domain now contains a paths map where each entry defines its own backend, auth, headers, and flags (is_management, is_websocket). - Add _migrate_config() to auto-migrate legacy formats on first load - Remove set_management_proxy() and POST_NGINX_MANAGEMENT endpoint - Update server_block.conf template to iterate paths with per-location auth - Update daemon handler, API blueprint, state collector, and install script - Add server config generation tests for paths, WebSocket, auth inheritance - Update frontend proxy page to display per-path rows with flags
This commit is contained in:
+163
-119
@@ -49,7 +49,6 @@ DEFAULT_SSL: dict[str, Any] = {
|
||||
|
||||
DEFAULT_CONFIG: dict[str, Any] = {
|
||||
"domains": {},
|
||||
"management": None,
|
||||
"ssl": {**DEFAULT_SSL},
|
||||
}
|
||||
|
||||
@@ -59,14 +58,71 @@ DEFAULT_CONFIG: dict[str, Any] = {
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def _migrate_config(raw: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Migrate legacy config formats to the new paths-based model.
|
||||
|
||||
Handles two migrations:
|
||||
1. Legacy ``management`` top-level key -> path entry under its domain.
|
||||
2. Domain entries without ``paths`` -> wrap ``backend`` inside ``paths["/"]``.
|
||||
|
||||
Args:
|
||||
raw: Config dict as loaded from disk.
|
||||
|
||||
Returns:
|
||||
The migrated config dict.
|
||||
"""
|
||||
# Migrate management key
|
||||
if "management" in raw and raw["management"] is not None:
|
||||
mgmt = raw["management"]
|
||||
mgmt_domain = mgmt.get("domain", "")
|
||||
if mgmt_domain:
|
||||
domains = raw.setdefault("domains", {})
|
||||
if mgmt_domain not in domains:
|
||||
domains[mgmt_domain] = {
|
||||
"force_ssl": True,
|
||||
"paths": {},
|
||||
}
|
||||
dom = domains[mgmt_domain]
|
||||
paths = dom.setdefault("paths", {})
|
||||
if "/" not in paths:
|
||||
paths["/"] = {
|
||||
"backend": {
|
||||
"host": mgmt.get("backend", {}).get("host", "127.0.0.1"),
|
||||
"port": mgmt.get("backend", {}).get("port", 9090),
|
||||
"proto": "http",
|
||||
},
|
||||
"is_management": True,
|
||||
}
|
||||
if mgmt.get("auth"):
|
||||
paths["/"]["auth"] = mgmt["auth"]
|
||||
# Add WebSocket path if not present
|
||||
if "/ws" not in paths:
|
||||
paths["/ws"] = {
|
||||
"backend": {"host": "127.0.0.1", "port": 9091, "proto": "http"},
|
||||
"is_websocket": True,
|
||||
}
|
||||
del raw["management"]
|
||||
|
||||
# Migrate domain entries without paths
|
||||
for dom in raw.get("domains", {}).values():
|
||||
if "paths" not in dom and "backend" in dom:
|
||||
dom["paths"] = {
|
||||
"/": {
|
||||
"backend": dom.pop("backend"),
|
||||
"headers": dom.pop("headers", {}),
|
||||
}
|
||||
}
|
||||
return raw
|
||||
|
||||
|
||||
def get_config() -> dict[str, Any]:
|
||||
"""Load the current nginx config, initializing with defaults if needed.
|
||||
|
||||
Ensure config and sites directories exist, then return a copy of the
|
||||
JSON file. On missing file or missing keys, populate from defaults.
|
||||
Ensures config and sites directories exist, applies migrations for
|
||||
legacy formats, then returns the config dict.
|
||||
|
||||
Returns:
|
||||
The complete config dict with ``domains``, ``ssl``, and ``management`` keys.
|
||||
The complete config dict with ``domains`` and ``ssl`` keys.
|
||||
"""
|
||||
ensure_dirs(CONFIG_DIR, SITES_DIR)
|
||||
raw = load_json(CONFIG_FILE)
|
||||
@@ -74,6 +130,8 @@ def get_config() -> dict[str, Any]:
|
||||
raw = deepcopy(DEFAULT_CONFIG)
|
||||
if "ssl" not in raw:
|
||||
raw["ssl"] = deepcopy(DEFAULT_SSL)
|
||||
raw = _migrate_config(raw)
|
||||
save_config(raw)
|
||||
return raw
|
||||
|
||||
|
||||
@@ -83,26 +141,35 @@ def save_config(cfg: dict[str, Any]) -> None:
|
||||
|
||||
|
||||
def get_domains() -> list[dict[str, Any]]:
|
||||
"""Return a list of all configured proxy domains with status.
|
||||
"""Return a list of all configured proxy domains flattened by path.
|
||||
|
||||
Each entry includes the domain name, backend info, SSL flag, and
|
||||
whether a site config file currently exists on disk.
|
||||
Each path within a domain becomes a separate entry with domain-level
|
||||
settings repeated.
|
||||
|
||||
Returns:
|
||||
List of dicts with ``domain``, ``backend``, ``online``, and ``force_ssl``.
|
||||
List of dicts with ``domain``, ``path``, ``backend``, ``online``,
|
||||
``force_ssl``, and path-level flags.
|
||||
"""
|
||||
cfg = get_config()
|
||||
result: list[dict[str, Any]] = []
|
||||
for name, dom in cfg.get("domains", {}).items():
|
||||
site = SITES_DIR / f"{name}.conf"
|
||||
result.append(
|
||||
{
|
||||
paths = dom.get("paths", {})
|
||||
if not paths:
|
||||
continue
|
||||
for ppath, pcfg in paths.items():
|
||||
entry: dict[str, Any] = {
|
||||
"domain": name,
|
||||
"backend": dom.get("backend", {}),
|
||||
"path": ppath,
|
||||
"backend": pcfg.get("backend", {}),
|
||||
"online": site.exists(),
|
||||
"force_ssl": dom.get("force_ssl", True),
|
||||
}
|
||||
)
|
||||
if pcfg.get("is_management"):
|
||||
entry["is_management"] = True
|
||||
if pcfg.get("is_websocket"):
|
||||
entry["is_websocket"] = True
|
||||
result.append(entry)
|
||||
return result
|
||||
|
||||
|
||||
@@ -113,21 +180,24 @@ def get_domains() -> list[dict[str, Any]]:
|
||||
|
||||
def add_domain(
|
||||
domain: str,
|
||||
backend_host: str,
|
||||
backend_port: int,
|
||||
backend_host: str | None = None,
|
||||
backend_port: int | None = None,
|
||||
backend_proto: str = "http",
|
||||
cert: str | None = None,
|
||||
extra_headers: dict[str, str] | None = None,
|
||||
paths: dict[str, dict[str, Any]] | None = None,
|
||||
) -> None:
|
||||
"""Add a new proxy domain with the given backend and optional settings.
|
||||
|
||||
Args:
|
||||
domain: Domain name to add.
|
||||
backend_host: Upstream host to proxy to.
|
||||
backend_port: Upstream port.
|
||||
backend_proto: Protocol (``http`` or ``https``).
|
||||
backend_host: Upstream host to proxy to (legacy mode).
|
||||
backend_port: Upstream port (legacy mode).
|
||||
backend_proto: Protocol (``http`` or ``https``; legacy mode).
|
||||
cert: Optional certificate type identifier.
|
||||
extra_headers: Optional dict of extra headers to forward.
|
||||
extra_headers: Optional dict of extra headers to forward (legacy mode).
|
||||
paths: Optional path-to-config map (new mode). Each path entry must
|
||||
have a ``backend`` key with ``host``, ``port``, and ``proto``.
|
||||
|
||||
Raises:
|
||||
ValueError: If the domain is already configured.
|
||||
@@ -135,27 +205,36 @@ def add_domain(
|
||||
cfg = get_config()
|
||||
if domain in cfg["domains"]:
|
||||
raise ValueError(f"Domain {domain!r} already configured")
|
||||
entry: dict[str, Any] = {
|
||||
"backend": {
|
||||
"host": backend_host,
|
||||
"port": int(backend_port),
|
||||
"proto": backend_proto,
|
||||
},
|
||||
"force_ssl": True,
|
||||
}
|
||||
if cert is not None:
|
||||
entry["cert"] = cert
|
||||
if extra_headers is not None:
|
||||
entry["headers"] = extra_headers
|
||||
|
||||
if paths is not None:
|
||||
entry: dict[str, Any] = {
|
||||
"paths": paths,
|
||||
"force_ssl": True,
|
||||
}
|
||||
if cert is not None:
|
||||
entry["cert"] = cert
|
||||
else:
|
||||
if not backend_host or backend_port is None:
|
||||
raise ValueError("'backend_host' and 'backend_port' are required")
|
||||
entry = {
|
||||
"paths": {
|
||||
"/": {
|
||||
"backend": {
|
||||
"host": backend_host,
|
||||
"port": int(backend_port),
|
||||
"proto": backend_proto,
|
||||
},
|
||||
"headers": extra_headers or {},
|
||||
}
|
||||
},
|
||||
"force_ssl": True,
|
||||
}
|
||||
if cert is not None:
|
||||
entry["cert"] = cert
|
||||
|
||||
cfg["domains"][domain] = entry
|
||||
save_config(cfg)
|
||||
logger.info(
|
||||
"Proxy domain '%s' added -> %s:%d (%s)",
|
||||
domain,
|
||||
backend_host,
|
||||
backend_port,
|
||||
backend_proto,
|
||||
)
|
||||
logger.info("Proxy domain '%s' added", domain)
|
||||
|
||||
|
||||
def remove_domain(domain: str) -> None:
|
||||
@@ -172,6 +251,11 @@ def remove_domain(domain: str) -> None:
|
||||
def update_domain(domain: str, **kwargs: Any) -> None:
|
||||
"""Update fields of an existing domain entry in-place.
|
||||
|
||||
Supports both domain-level keys (``force_ssl``, ``cert``, ``auth``,
|
||||
``paths``) and paths-level shorthand (``backend``, ``headers`` for
|
||||
the root path). When ``paths`` is provided, it is fully replaced.
|
||||
When ``backend`` is provided, it updates ``paths["/"]["backend"]``.
|
||||
|
||||
Args:
|
||||
domain: Domain name to update.
|
||||
**kwargs: Key-value pairs to merge into the domain config.
|
||||
@@ -183,7 +267,27 @@ def update_domain(domain: str, **kwargs: Any) -> None:
|
||||
if domain not in cfg["domains"]:
|
||||
raise KeyError(f"Domain {domain!r} not configured")
|
||||
entry = cfg["domains"][domain]
|
||||
|
||||
# If paths is given, replace entirely
|
||||
if "paths" in kwargs:
|
||||
entry["paths"] = kwargs["paths"]
|
||||
else:
|
||||
# Legacy: top-level backend/headers -> paths["/"]
|
||||
paths = entry.setdefault("paths", {})
|
||||
if "backend" in kwargs:
|
||||
root = paths.setdefault("/", {"backend": {}, "headers": {}})
|
||||
root["backend"] = kwargs["backend"]
|
||||
if "headers" in kwargs:
|
||||
root = paths.setdefault("/", {"backend": {}, "headers": {}})
|
||||
root["headers"] = kwargs["headers"]
|
||||
|
||||
# Remove legacy top-level keys from domain entry
|
||||
entry.pop("backend", None)
|
||||
entry.pop("headers", None)
|
||||
|
||||
for key, val in kwargs.items():
|
||||
if key in ("backend", "headers", "paths"):
|
||||
continue
|
||||
if isinstance(val, dict) and key in entry:
|
||||
entry[key].update(val)
|
||||
else:
|
||||
@@ -198,7 +302,7 @@ def update_domain(domain: str, **kwargs: Any) -> None:
|
||||
|
||||
|
||||
def generate_server_conf(domain_cfg: dict[str, Any]) -> str:
|
||||
"""Render the Jinja template for a standard domain server block.
|
||||
"""Render the Jinja template for a domain server block.
|
||||
|
||||
Args:
|
||||
domain_cfg: Domain entry dict including the ``domain`` key.
|
||||
@@ -209,42 +313,25 @@ def generate_server_conf(domain_cfg: dict[str, Any]) -> str:
|
||||
tmpl = ENV.get_template("nginx/server_block.conf")
|
||||
acme_home_path = PROJECT_DIR / "data" / "acme"
|
||||
acme_cert_dir = str(find_cert_dir(domain_cfg["domain"], acme_home_path))
|
||||
paths = domain_cfg.get("paths", {})
|
||||
has_management = any(p.get("is_management") for p in paths.values())
|
||||
# Resolve custom cert paths for cert=="file"
|
||||
cert_cfg = domain_cfg.get("cert")
|
||||
if isinstance(cert_cfg, dict):
|
||||
cert_path = cert_cfg.get("cert_path", "")
|
||||
cert_key_path = cert_cfg.get("cert_key_path", "")
|
||||
else:
|
||||
cert_path = domain_cfg.get("cert_path", "")
|
||||
cert_key_path = domain_cfg.get("cert_key_path", "")
|
||||
return tmpl.render(
|
||||
domain=domain_cfg["domain"],
|
||||
backend=domain_cfg.get("backend", {}),
|
||||
headers=domain_cfg.get("headers", {}),
|
||||
paths=paths,
|
||||
force_ssl=domain_cfg.get("force_ssl", True),
|
||||
cert=domain_cfg.get("cert"),
|
||||
auth=domain_cfg.get("auth"),
|
||||
is_management=False,
|
||||
acme_cert_dir=acme_cert_dir,
|
||||
certs_dir=str(PROJECT_DIR / "data" / "certs"),
|
||||
acme_webroot=str(PROJECT_DIR / "data" / "acme" / "www"),
|
||||
)
|
||||
|
||||
|
||||
def _generate_management_conf(management: dict[str, Any]) -> str:
|
||||
"""Render the Jinja template for the management WebUI server block.
|
||||
|
||||
Args:
|
||||
management: Management proxy config dict containing ``domain`` and optional ``auth``.
|
||||
|
||||
Returns:
|
||||
The complete nginx server-block configuration for the management UI.
|
||||
"""
|
||||
tmpl = ENV.get_template("nginx/server_block.conf")
|
||||
acme_home_path = PROJECT_DIR / "data" / "acme"
|
||||
acme_cert_dir = str(find_cert_dir(management.get("domain", ""), acme_home_path))
|
||||
return tmpl.render(
|
||||
domain=management.get("domain"),
|
||||
backend=dict(
|
||||
management.get("backend", {}), host="127.0.0.1", port=9090, proto="http"
|
||||
),
|
||||
headers={},
|
||||
force_ssl=True,
|
||||
cert=None,
|
||||
auth=management.get("auth"),
|
||||
is_management=True,
|
||||
cert_path=cert_path,
|
||||
cert_key_path=cert_key_path,
|
||||
domain_auth=domain_cfg.get("auth"),
|
||||
has_management=has_management,
|
||||
acme_cert_dir=acme_cert_dir,
|
||||
certs_dir=str(PROJECT_DIR / "data" / "certs"),
|
||||
acme_webroot=str(PROJECT_DIR / "data" / "acme" / "www"),
|
||||
@@ -295,8 +382,8 @@ def write_acme_challenge() -> None:
|
||||
def write_all_sites() -> None:
|
||||
"""Regenerate all site configs from the current config state.
|
||||
|
||||
Writes server blocks for every configured domain and the management
|
||||
proxy (if any), removes orphaned site files, and ensures the ACME
|
||||
Writes server blocks for every configured domain (now unified, including
|
||||
any management paths), removes orphaned site files, and ensures the ACME
|
||||
challenge config is present.
|
||||
"""
|
||||
ensure_dirs(SITES_DIR)
|
||||
@@ -311,10 +398,10 @@ def write_all_sites() -> None:
|
||||
write_site(name, conf)
|
||||
written.add(f"{name}.conf")
|
||||
|
||||
if cfg.get("management"):
|
||||
mgmt_conf = _generate_management_conf(cfg["management"])
|
||||
write_site("management", mgmt_conf)
|
||||
written.add("management.conf")
|
||||
# Remove old management.conf if it exists
|
||||
old_mgmt = SITES_DIR / "management.conf"
|
||||
if old_mgmt.exists() and old_mgmt.name not in written:
|
||||
old_mgmt.unlink()
|
||||
|
||||
for old in existing:
|
||||
if old.suffix == ".conf" and old.name not in written:
|
||||
@@ -411,48 +498,6 @@ def apply() -> None:
|
||||
logger.info("nginx configuration applied and reloaded")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Management WebUI
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def set_management_proxy(
|
||||
domain: str,
|
||||
flask_host: str = "127.0.0.1",
|
||||
flask_port: int = 9090,
|
||||
auth_user: str | None = None,
|
||||
auth_pass: str | None = None,
|
||||
) -> None:
|
||||
"""Configure the management reverse proxy for the WebUI.
|
||||
|
||||
Args:
|
||||
domain: Management domain name.
|
||||
flask_host: Upstream Flask host (default ``127.0.0.1``).
|
||||
flask_port: Upstream Flask port (default ``9090``).
|
||||
auth_user: Optional basic-auth username.
|
||||
auth_pass: Optional basic-auth password; writes htpasswd when provided with ``auth_user``.
|
||||
"""
|
||||
cfg = get_config()
|
||||
entry: dict[str, Any] = {
|
||||
"domain": domain,
|
||||
"backend": {
|
||||
"host": flask_host,
|
||||
"port": int(flask_port),
|
||||
"proto": "http",
|
||||
},
|
||||
}
|
||||
if auth_user:
|
||||
entry["auth"] = {
|
||||
"user": auth_user,
|
||||
"htpasswd": str(HTPASSWD_FILE),
|
||||
}
|
||||
cfg["management"] = entry
|
||||
save_config(cfg)
|
||||
if auth_user and auth_pass:
|
||||
write_htpasswd(auth_user, auth_pass)
|
||||
logger.info("Management proxy set to '%s'", domain)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# htpasswd
|
||||
# ------------------------------------------------------------------
|
||||
@@ -510,7 +555,6 @@ __all__ = [
|
||||
"get_domains",
|
||||
"remove_domain",
|
||||
"save_config",
|
||||
"set_management_proxy",
|
||||
"test_config",
|
||||
"update_domain",
|
||||
"write_acme_challenge",
|
||||
|
||||
+16
-7
@@ -636,7 +636,6 @@ def _collect_nginx() -> dict[str, Any]:
|
||||
|
||||
default_cfg: dict[str, Any] = {
|
||||
"domains": {},
|
||||
"management": None,
|
||||
"ssl": deepcopy(DEFAULT_SSL),
|
||||
}
|
||||
cfg = deepcopy(default_cfg)
|
||||
@@ -652,18 +651,27 @@ def _collect_nginx() -> dict[str, Any]:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Build domains list with site existence
|
||||
# Build flattened domains list (one entry per path)
|
||||
domains: list[dict[str, Any]] = []
|
||||
for name, dom in cfg.get("domains", {}).items():
|
||||
site = SITES_DIR / f"{name}.conf"
|
||||
domains.append(
|
||||
{
|
||||
paths = dom.get("paths", {})
|
||||
if not paths:
|
||||
continue
|
||||
for ppath, pcfg in paths.items():
|
||||
entry: dict[str, Any] = {
|
||||
"domain": name,
|
||||
"backend": dom.get("backend", {}),
|
||||
"path": ppath,
|
||||
"backend": pcfg.get("backend", {}),
|
||||
"online": site.exists() if SITES_DIR.exists() else False,
|
||||
"force_ssl": dom.get("force_ssl", True),
|
||||
"cert": dom.get("cert"),
|
||||
}
|
||||
)
|
||||
if pcfg.get("is_management"):
|
||||
entry["is_management"] = True
|
||||
if pcfg.get("is_websocket"):
|
||||
entry["is_websocket"] = True
|
||||
domains.append(entry)
|
||||
|
||||
return {
|
||||
"config": cfg,
|
||||
@@ -819,7 +827,8 @@ def _collect_acme() -> dict[str, Any]:
|
||||
if not main:
|
||||
continue
|
||||
san_domains = [
|
||||
d.strip() for d in entry.get("san_domains", "").split(",")
|
||||
d.strip()
|
||||
for d in entry.get("san_domains", "").split(",")
|
||||
if d.strip() and d.strip().lower() != "no"
|
||||
]
|
||||
cert_dir = acme_home / main
|
||||
|
||||
Reference in New Issue
Block a user