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:
152
core/tests/test_gateway_auth_and_bindings.py
Normal file
152
core/tests/test_gateway_auth_and_bindings.py
Normal 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})
|
||||
Reference in New Issue
Block a user