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

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