core: a login at the gateway, and container ports bound to one address

For services with no authentication of their own -- a trace viewer, an admin
UI, an OTLP receiver -- which must not be open to the LAN just because they run
here.

- `expose.http.basic_auth: {user, password_hash}` has the gateway ask for HTTP
  Basic auth (Caddy's `basic_auth`, bcrypt from `caddy hash-password`) before
  proxying, on the internal name and any public one. It is recorded in the
  registry too, so a Caddyfile generated from the registry alone still asks
  rather than serving the route open. Checked against caddy 2.11: 401 without
  credentials, through with them.
- A container's `ports` may give the host side an address, `4318:
  127.0.0.1:4318` -> `-p 127.0.0.1:4318:4318`. A bare port still publishes on
  every interface, as before, and the docs now say that this means the LAN.
This commit is contained in:
2026-10-22 10:13:44 -07:00
parent 517471605a
commit 01819b2984
6 changed files with 280 additions and 20 deletions

View File

@@ -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,

View File

@@ -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 <host> / handle @host_X { reverse_proxy <target> }` block.
Shared by the off-mode `:<port>` 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 ``*.<public_domain>`` 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} {{",

View File

@@ -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 <ipv4>:<port> 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 ``<name>.<domain>:<port>`` 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 ``<name>.<domain>``.
"""

View File

@@ -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 <subdomain>.<public_domain>.
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
# <name>.<gateway.domain>:<tcp_port> 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:

View File

@@ -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})

View File

@@ -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