diff --git a/core/src/wildpc_core/deploy.py b/core/src/wildpc_core/deploy.py index c847404..436619d 100644 --- a/core/src/wildpc_core/deploy.py +++ b/core/src/wildpc_core/deploy.py @@ -957,6 +957,11 @@ def _build_deployed( subdomain=(name if expose else None), public=bool(dep.public and expose), public_host=(dep.public_host if (dep.public and expose) else None), + basic_auth=( + dep.expose.http.basic_auth.model_dump() + if expose and dep.expose and dep.expose.http and dep.expose.http.basic_auth + else None + ), tcp_port=tcp_port, schedule=getattr(dep, "schedule", None), managed=managed, diff --git a/core/src/wildpc_core/generators/caddyfile.py b/core/src/wildpc_core/generators/caddyfile.py index 74f1c37..bf29420 100644 --- a/core/src/wildpc_core/generators/caddyfile.py +++ b/core/src/wildpc_core/generators/caddyfile.py @@ -46,6 +46,9 @@ class GatewayRoute: public_host: str | None = None # static routes only: SPA fallback (True) vs content-site serving (False). spa: bool = True + # proxy routes only: HTTP Basic auth the gateway demands first, + # {"user", "password_hash"} — see manifest.BasicAuthSpec. + basic_auth: dict[str, str] | None = None @property def is_host(self) -> bool: @@ -72,15 +75,16 @@ def service_proxy_targets(name: str, dep: SystemdDeployment) -> ProxyTargets: def _local_routes( config: WildpcConfig | None, registry: NodeRegistry -) -> list[tuple[str, str, str, bool, str | None, bool]]: - """Each local deployment's route as ``(name, kind, target, public, public_host, spa)``. +) -> list[tuple[str, str, str, bool, str | None, bool, dict[str, str] | None]]: + """Each local deployment's route as + ``(name, kind, target, public, public_host, spa, basic_auth)``. ``kind`` is ``static`` (a caddy deployment — file-serve a built dir) or ``proxy`` (a proxied systemd process). Prefers ``wildpc.yaml`` (``config.deployments``) so a regenerated Caddyfile reflects the current spec; falls back to the deployed registry snapshot when config isn't available. """ - out: list[tuple[str, str, str, bool, str | None, bool]] = [] + out: list[tuple[str, str, str, bool, str | None, bool, dict[str, str] | None]] = [] if config is not None: # Only HTTP-exposed kinds route: static (file-serve) and service (proxy). # jobs/tools/references never do. @@ -93,21 +97,30 @@ def _local_routes( src = _program_source(config, dep.program) if src is not None: pub_host = dep.public_host if dep.public else None - out.append((name, "static", str(src / dep.root), bool(dep.public), pub_host, dep.spa)) + out.append( + (name, "static", str(src / dep.root), bool(dep.public), pub_host, dep.spa, None) + ) elif kind == "service" and isinstance(dep, SystemdDeployment): expose, port, base_url = service_proxy_targets(name, dep) if expose and (port or base_url): pub_host = dep.public_host if dep.public else None - out.append((name, "proxy", base_url or f"localhost:{port}", bool(dep.public), pub_host, True)) + auth = dep.expose.http.basic_auth if dep.expose and dep.expose.http else None + out.append(( + name, "proxy", base_url or f"localhost:{port}", bool(dep.public), pub_host, + True, auth.model_dump() if auth else None, + )) return out # No config → route from the deployed registry snapshot. for _kind, name, d in registry.all(): if not d.enabled: continue if d.static_root: - out.append((name, "static", d.static_root, d.public, d.public_host, d.spa)) + out.append((name, "static", d.static_root, d.public, d.public_host, d.spa, None)) elif d.subdomain and (d.port or d.base_url): - out.append((name, "proxy", d.base_url or f"localhost:{d.port}", d.public, d.public_host, True)) + out.append(( + name, "proxy", d.base_url or f"localhost:{d.port}", d.public, d.public_host, + True, d.basic_auth, + )) return out @@ -148,8 +161,10 @@ def compute_routes( # built dir; everything else that's exposed reverse-proxies its port/base_url. # (Static frontends are `runner: static` services now — no separate program # branch, so routing derives from one place.) - for name, kind, target, is_public, pub_host, spa in _local_routes(config, registry): - routes.append(GatewayRoute(name, kind, target, name, node, is_public, pub_host, spa)) + for name, kind, target, is_public, pub_host, spa, auth in _local_routes(config, registry): + routes.append( + GatewayRoute(name, kind, target, name, node, is_public, pub_host, spa, basic_auth=auth) + ) if remote_registries: routes.extend(_remote_routes(config, registry, remote_registries)) @@ -195,7 +210,20 @@ def _remote_routes( return out -def _host_matcher_block(label: str, host: str, target: str) -> list[str]: +def _basic_auth_lines(auth: dict[str, str] | None, indent: str) -> list[str]: + """Caddy's `basic_auth`, ahead of the proxy, when the route asks for a login.""" + if not auth: + return [] + return [ + f"{indent}basic_auth {{", + f"{indent} {auth['user']} {auth['password_hash']}", + f"{indent}}}", + ] + + +def _host_matcher_block( + label: str, host: str, target: str, basic_auth: dict[str, str] | None = None +) -> list[str]: """A `@host_X host / handle @host_X { reverse_proxy }` block. Shared by the off-mode `:` site and the acme wildcard site so the two @@ -204,6 +232,7 @@ def _host_matcher_block(label: str, host: str, target: str) -> list[str]: return [ f" {matcher} host {host}", f" handle {matcher} {{", + *_basic_auth_lines(basic_auth, " "), f" reverse_proxy {target}", " }", "", @@ -271,7 +300,10 @@ def _no_route_fallback() -> list[str]: ] -def _public_site_block(host: str, kind: str, target: str, spa: bool = True) -> list[str]: +def _public_site_block( + host: str, kind: str, target: str, spa: bool = True, + basic_auth: dict[str, str] | None = None, +) -> list[str]: """A standalone Caddy site for a custom ``public_host`` (apex or another zone). Unlike the ``*.`` wildcard site, an apex/foreign host isn't @@ -291,7 +323,7 @@ def _public_site_block(host: str, kind: str, target: str, spa: bool = True) -> l " }", ] else: - body = [f" reverse_proxy {target}"] + body = [*_basic_auth_lines(basic_auth, " "), f" reverse_proxy {target}"] return [f"{host} {{", *body, "}", ""] @@ -362,7 +394,7 @@ def generate_caddyfile_from_registry( elif r.kind == "remote": lines += _host_remote_block(r.name or r.address, host, r.target) else: - lines += _host_matcher_block(r.name or r.address, host, r.target) + lines += _host_matcher_block(r.name or r.address, host, r.target, r.basic_auth) lines += _no_route_fallback() lines.append("}") lines.append("") @@ -388,13 +420,15 @@ def generate_caddyfile_from_registry( elif r.kind == "remote": lines += _host_remote_block(label, host, r.target) else: - lines += _host_matcher_block(label, host, r.target) + lines += _host_matcher_block(label, host, r.target, r.basic_auth) lines += _no_route_fallback() lines.append("}") lines.append("") for r in custom_pub: if r.public_host: # always true (custom_pub filter); narrows the type - lines += _public_site_block(r.public_host, r.kind, r.target, r.spa) + lines += _public_site_block( + r.public_host, r.kind, r.target, r.spa, basic_auth=r.basic_auth + ) # Redirect the bare gateway port to the dashboard subdomain. lines += [ f":{gw_port} {{", diff --git a/core/src/wildpc_core/manifest.py b/core/src/wildpc_core/manifest.py index cbe2254..37b6213 100644 --- a/core/src/wildpc_core/manifest.py +++ b/core/src/wildpc_core/manifest.py @@ -2,10 +2,11 @@ from __future__ import annotations +import re from enum import Enum from typing import Annotated, Literal, Union -from pydantic import BaseModel, ConfigDict, Field, model_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator EnvMap = dict[str, str] @@ -86,7 +87,10 @@ class RunContainer(LaunchBase): image: str command: list[str] | None = None args: list[str] = Field(default_factory=list) - ports: dict[int, int] = Field(default_factory=dict) + # container port → host port. The host side may name the address to publish + # on, "127.0.0.1:4318": a bare port publishes on every interface (docker's + # default), which puts it on the LAN whatever `reach:` says. + ports: dict[int, int | str] = Field(default_factory=dict) volumes: list[str] = Field(default_factory=list) env: EnvMap = Field(default_factory=dict) workdir: str | None = None @@ -102,6 +106,18 @@ class RunContainer(LaunchBase): gpus: str | None = None + @field_validator("ports") + @classmethod + def _host_binding(cls, ports: dict[int, int | str]) -> dict[int, int | str]: + for container_port, host in ports.items(): + if isinstance(host, str) and not _HOST_BINDING.match(host): + raise ValueError( + f"ports[{container_port}]: {host!r} is not a host port or " + "an : binding (e.g. 127.0.0.1:4318)" + ) + return ports + + class RunNode(LaunchBase): launcher: Literal["node"] script: str @@ -184,6 +200,21 @@ class ManageSpec(BaseModel): # --------------------- +#: "127.0.0.1:4318" — an IPv4 address and a port, as docker's `-p` takes them. +_HOST_BINDING = re.compile(r"^(\d{1,3}\.){3}\d{1,3}:\d{1,5}$") + + +class BasicAuthSpec(BaseModel): + """HTTP Basic auth at the gateway, for a service with no login of its own + (a trace viewer, an admin UI). Caddy's ``basic_auth``: one user, and a bcrypt + hash of the password — ``caddy hash-password`` makes one. The hash is not the + password, which is why it may live in the manifest; the gateway is HTTPS, so + the credential never crosses the LAN in the clear.""" + + user: str = Field(min_length=1, pattern=r"^[^\s:]+$") + password_hash: str = Field(pattern=r"^\$2[aby]\$\d\d\$[./A-Za-z0-9]{53}$") + + class HttpInternal(BaseModel): host: str = "127.0.0.1" port: int = Field(ge=1, le=65535) @@ -193,6 +224,9 @@ class HttpInternal(BaseModel): class HttpExposeSpec(BaseModel): internal: HttpInternal health_path: str | None = None + # Require a login at the gateway before proxying. Applies wherever the + # gateway routes this service (internal and public names alike). + basic_auth: BasicAuthSpec | None = None class TlsMaterial(str, Enum): @@ -225,9 +259,10 @@ class TcpExposeSpec(BaseModel): """A raw-TCP service (postgres, redis, …). It doesn't ride the HTTP gateway: with ``reach: internal`` it's reachable at ``.:`` via the wildcard DNS record + the bound port (no Caddy route). Publishing the port on - the LAN is the deployment's own job (a container's ``run.ports``, or a native - service binding ``0.0.0.0``); wildpc doesn't rebind it, so there's no bind-host - field here to imply otherwise. ``tls`` (optional) has wildpc drop the wildcard + the LAN is the deployment's own job (a container's ``run.ports`` — whose host + side may name an address, ``127.0.0.1:4000`` — or a native service binding + ``0.0.0.0``); wildpc doesn't rebind it, so there's no bind-host field here to + imply otherwise. ``tls`` (optional) has wildpc drop the wildcard cert onto the service so it presents a trusted cert for ``.``. """ diff --git a/core/src/wildpc_core/registry.py b/core/src/wildpc_core/registry.py index 6b5c6a8..4399f7e 100644 --- a/core/src/wildpc_core/registry.py +++ b/core/src/wildpc_core/registry.py @@ -86,6 +86,10 @@ class Deployment: # Optional public hostname override (exact FQDN, apex allowed). When set it is # the public-facing name instead of the derived .. public_host: str | None = None + # HTTP Basic auth the gateway demands before proxying: {"user", "password_hash"}. + # Recorded here too, so a Caddyfile generated from the registry alone (no + # config to hand) still asks for it rather than serving the route open. + basic_auth: dict[str, str] | None = None # Raw-TCP exposure port (postgres, redis, …). Set → reachable at # .: via bind + wildcard DNS (no Caddy route). tcp_port: int | None = None @@ -193,6 +197,7 @@ def load_registry(path: Path | None = None) -> NodeRegistry: subdomain=comp_data.get("subdomain"), public=comp_data.get("public", False), public_host=comp_data.get("public_host"), + basic_auth=comp_data.get("basic_auth"), tcp_port=comp_data.get("tcp_port"), static_root=comp_data.get("static_root"), spa=comp_data.get("spa", True), @@ -273,6 +278,8 @@ def save_registry(registry: NodeRegistry, path: Path | None = None) -> None: entry["public"] = comp.public if comp.public_host: entry["public_host"] = comp.public_host + if comp.basic_auth: + entry["basic_auth"] = dict(comp.basic_auth) if comp.tcp_port is not None: entry["tcp_port"] = comp.tcp_port if comp.static_root: diff --git a/core/tests/test_gateway_auth_and_bindings.py b/core/tests/test_gateway_auth_and_bindings.py new file mode 100644 index 0000000..c36cb93 --- /dev/null +++ b/core/tests/test_gateway_auth_and_bindings.py @@ -0,0 +1,152 @@ +"""A login at the gateway, and container ports that stay off the LAN. + +Both exist for the same kind of service: one with no authentication of its own +(a trace viewer, an admin UI, an OTLP receiver) that must not be reachable by +anything on the network just because it runs in a container. +""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + +import pytest +from pydantic import ValidationError + +from wildpc_core.config import GatewayConfig, WildpcConfig +from wildpc_core.deploy import _build_run_cmd +from wildpc_core.generators.caddyfile import compute_routes, generate_caddyfile_from_registry +from wildpc_core.manifest import ( + BasicAuthSpec, + ExposeSpec, + HttpExposeSpec, + HttpInternal, + Reach, + RunContainer, + RunPython, + SystemdDeployment, +) +from wildpc_core.registry import ( + Deployment, + NodeConfig, + NodeRegistry, + load_registry, + save_registry, +) + +#: Caddy's documented example: `caddy hash-password` output for "hiccup". +HASH = "$2a$14$Zkx19XLiW6VYouLHR5NmfOFU0z2GTNmpkT/5qqR7hx4IjWJPDhjvG" +AUTH = {"user": "paul", "password_hash": HASH} + + +@pytest.fixture(autouse=True) +def _isolate_config(monkeypatch: pytest.MonkeyPatch) -> None: + import wildpc_core.config as config_mod + + def _no_config(*args: object, **kwargs: object) -> object: + raise FileNotFoundError("isolated in tests") + + monkeypatch.setattr(config_mod, "load_config", _no_config) + + +def _registry(deployed: dict[str, Deployment] | None = None, **node: object) -> NodeRegistry: + reg = NodeRegistry(node=NodeConfig( + hostname="test", gateway_port=9000, gateway_tls="acme", + gateway_domain="example.com", acme_email="p@e.com", **node, # type: ignore[arg-type] + )) + for name, d in (deployed or {}).items(): + d.name = name + reg.put(d) + return reg + + +def _service(port: int, auth: dict[str, str] | None = None) -> SystemdDeployment: + return SystemdDeployment( + manager="systemd", + run=RunPython(launcher="python", program="svc"), + expose=ExposeSpec(http=HttpExposeSpec( + internal=HttpInternal(port=port), + basic_auth=BasicAuthSpec(**auth) if auth else None, + )), + reach=Reach.INTERNAL, + ) + + +def _config(**deployments: SystemdDeployment) -> WildpcConfig: + return WildpcConfig( + root=None, # type: ignore[arg-type] + gateway=GatewayConfig(port=9000), repo=None, programs={}, + deployments=dict(deployments), # type: ignore[arg-type] + ) + + +def _block(caddyfile: str, matcher: str) -> str: + """The handle block for one host matcher.""" + start = caddyfile.index(f"handle {matcher} {{") + return caddyfile[start:caddyfile.index("\n }", start)] + + +class TestBasicAuth: + def test_a_login_is_asked_for_before_proxying(self, monkeypatch: pytest.MonkeyPatch) -> None: + import wildpc_core.config as config_mod + + config = _config(jaeger=_service(16686, AUTH), open=_service(9001)) + assert [r.basic_auth for r in compute_routes(_registry(), config) if r.name == "jaeger"] == [AUTH] + monkeypatch.setattr(config_mod, "load_config", lambda *a, **k: config) + cf = generate_caddyfile_from_registry(_registry()) + guarded = _block(cf, "@host_jaeger") + assert f"basic_auth {{\n paul {HASH}\n }}" in guarded + assert guarded.index("basic_auth") < guarded.index("reverse_proxy localhost:16686") + assert "basic_auth" not in _block(cf, "@host_open") + + def test_the_registry_alone_still_asks(self) -> None: + """No config to hand → routes come from the deployed registry. A route + that needs a login must not be served open because of where it was read.""" + dep = Deployment(manager="systemd", launcher="python", run_cmd=["x"], port=16686, + subdomain="jaeger", basic_auth=AUTH) + cf = generate_caddyfile_from_registry(_registry({"jaeger": dep})) + assert "basic_auth" in _block(cf, "@host_jaeger") + + def test_a_public_host_asks_too(self) -> None: + dep = Deployment(manager="systemd", launcher="python", run_cmd=["x"], port=16686, + subdomain="jaeger", public=True, public_host="traces.example.org", + basic_auth=AUTH) + cf = generate_caddyfile_from_registry(_registry({"jaeger": dep})) + site = cf[cf.index("traces.example.org {"):] + assert site.index("basic_auth") < site.index("reverse_proxy") + + def test_it_survives_the_registry_round_trip(self, tmp_path: Path) -> None: + dep = Deployment(manager="systemd", launcher="python", run_cmd=["x"], port=1, + subdomain="jaeger", basic_auth=AUTH) + save_registry(_registry({"jaeger": dep}), tmp_path / "registry.yaml") + (again,) = load_registry(tmp_path / "registry.yaml").named("jaeger") + assert again.basic_auth == AUTH + + @pytest.mark.parametrize("bad", [ + {"user": "paul", "password_hash": "hiccup"}, # a password, not a hash + {"user": "pa:ul", "password_hash": HASH}, # Basic auth splits on ':' + {"user": "", "password_hash": HASH}, + ]) + def test_only_a_bcrypt_hash_and_a_plain_user(self, bad: dict[str, str]) -> None: + with pytest.raises(ValidationError): + BasicAuthSpec(**bad) + + +class TestHostBindings: + def _cmd(self, ports: dict[int, int | str]) -> list[str]: + run = RunContainer(launcher="container", image="img:latest", ports=ports) + with patch("wildpc_core.deploy.shutil.which", return_value="/usr/bin/docker"): + return _build_run_cmd("svc", run, {}, [], secret_env_file=None) + + def test_a_bare_port_publishes_as_before(self) -> None: + cmd = self._cmd({4000: 4000}) + assert cmd[cmd.index("-p") + 1] == "4000:4000" + + def test_a_binding_keeps_it_on_one_address(self) -> None: + cmd = self._cmd({4318: "127.0.0.1:4318"}) + assert cmd[cmd.index("-p") + 1] == "127.0.0.1:4318:4318" + + @pytest.mark.parametrize("bad", ["localhost:4318", "4318", "127.0.0.1", "::1:4318"]) + def test_a_binding_is_an_ipv4_and_a_port(self, bad: str) -> None: + with pytest.raises(ValidationError): + RunContainer(launcher="container", image="img", ports={4318: bad}) diff --git a/docs/registry.md b/docs/registry.md index b68895e..0629aef 100644 --- a/docs/registry.md +++ b/docs/registry.md @@ -294,6 +294,19 @@ run: program: my-service # name in [project.scripts] ``` +A `container`'s `ports` map container port → host port, published with docker's +`-p`. A bare host port publishes on **every** interface — so it is on the LAN, +whatever `reach:` says. To keep one to this machine, give the host side an +address: + +```yaml +run: + launcher: container + image: litellm:main + ports: + 4000: 127.0.0.1:4000 # loopback only; `4000: 4000` would be LAN-wide +``` + A `compose` launcher supervises a **whole multi-container stack as one systemd unit** — `ExecStart` runs `docker compose … up` attached (`Type=simple`) and a generated `ExecStop` runs `… down` so networks/anonymous volumes are reclaimed on @@ -339,6 +352,20 @@ expose: health_path: /health # Used by health polling ``` +A service with no login of its own can have the gateway ask for one. HTTP Basic +auth, applied wherever the gateway routes it (internal and public names); the +hash is bcrypt, from `caddy hash-password`, so the manifest never holds the +password itself: + +```yaml +expose: + http: + internal: { port: 16686 } + basic_auth: + user: paul + password_hash: $2a$14$... # caddy hash-password +``` + ### `proxy` — Expose the service at a subdomain `proxy` is a **checkbox** (a bool): `true` means the gateway routes