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:
@@ -957,6 +957,11 @@ def _build_deployed(
|
|||||||
subdomain=(name if expose else None),
|
subdomain=(name if expose else None),
|
||||||
public=bool(dep.public and expose),
|
public=bool(dep.public and expose),
|
||||||
public_host=(dep.public_host if (dep.public and expose) else None),
|
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,
|
tcp_port=tcp_port,
|
||||||
schedule=getattr(dep, "schedule", None),
|
schedule=getattr(dep, "schedule", None),
|
||||||
managed=managed,
|
managed=managed,
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ class GatewayRoute:
|
|||||||
public_host: str | None = None
|
public_host: str | None = None
|
||||||
# static routes only: SPA fallback (True) vs content-site serving (False).
|
# static routes only: SPA fallback (True) vs content-site serving (False).
|
||||||
spa: bool = True
|
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
|
@property
|
||||||
def is_host(self) -> bool:
|
def is_host(self) -> bool:
|
||||||
@@ -72,15 +75,16 @@ def service_proxy_targets(name: str, dep: SystemdDeployment) -> ProxyTargets:
|
|||||||
|
|
||||||
def _local_routes(
|
def _local_routes(
|
||||||
config: WildpcConfig | None, registry: NodeRegistry
|
config: WildpcConfig | None, registry: NodeRegistry
|
||||||
) -> list[tuple[str, str, str, bool, str | None, bool]]:
|
) -> 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)``.
|
"""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
|
``kind`` is ``static`` (a caddy deployment — file-serve a built dir) or
|
||||||
``proxy`` (a proxied systemd process). Prefers ``wildpc.yaml``
|
``proxy`` (a proxied systemd process). Prefers ``wildpc.yaml``
|
||||||
(``config.deployments``) so a regenerated Caddyfile reflects the current spec;
|
(``config.deployments``) so a regenerated Caddyfile reflects the current spec;
|
||||||
falls back to the deployed registry snapshot when config isn't available.
|
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:
|
if config is not None:
|
||||||
# Only HTTP-exposed kinds route: static (file-serve) and service (proxy).
|
# Only HTTP-exposed kinds route: static (file-serve) and service (proxy).
|
||||||
# jobs/tools/references never do.
|
# jobs/tools/references never do.
|
||||||
@@ -93,21 +97,30 @@ def _local_routes(
|
|||||||
src = _program_source(config, dep.program)
|
src = _program_source(config, dep.program)
|
||||||
if src is not None:
|
if src is not None:
|
||||||
pub_host = dep.public_host if dep.public else 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):
|
elif kind == "service" and isinstance(dep, SystemdDeployment):
|
||||||
expose, port, base_url = service_proxy_targets(name, dep)
|
expose, port, base_url = service_proxy_targets(name, dep)
|
||||||
if expose and (port or base_url):
|
if expose and (port or base_url):
|
||||||
pub_host = dep.public_host if dep.public else None
|
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
|
return out
|
||||||
# No config → route from the deployed registry snapshot.
|
# No config → route from the deployed registry snapshot.
|
||||||
for _kind, name, d in registry.all():
|
for _kind, name, d in registry.all():
|
||||||
if not d.enabled:
|
if not d.enabled:
|
||||||
continue
|
continue
|
||||||
if d.static_root:
|
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):
|
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
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -148,8 +161,10 @@ def compute_routes(
|
|||||||
# built dir; everything else that's exposed reverse-proxies its port/base_url.
|
# built dir; everything else that's exposed reverse-proxies its port/base_url.
|
||||||
# (Static frontends are `runner: static` services now — no separate program
|
# (Static frontends are `runner: static` services now — no separate program
|
||||||
# branch, so routing derives from one place.)
|
# branch, so routing derives from one place.)
|
||||||
for name, kind, target, is_public, pub_host, spa in _local_routes(config, registry):
|
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))
|
routes.append(
|
||||||
|
GatewayRoute(name, kind, target, name, node, is_public, pub_host, spa, basic_auth=auth)
|
||||||
|
)
|
||||||
|
|
||||||
if remote_registries:
|
if remote_registries:
|
||||||
routes.extend(_remote_routes(config, registry, remote_registries))
|
routes.extend(_remote_routes(config, registry, remote_registries))
|
||||||
@@ -195,7 +210,20 @@ def _remote_routes(
|
|||||||
return out
|
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.
|
"""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
|
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 [
|
return [
|
||||||
f" {matcher} host {host}",
|
f" {matcher} host {host}",
|
||||||
f" handle {matcher} {{",
|
f" handle {matcher} {{",
|
||||||
|
*_basic_auth_lines(basic_auth, " "),
|
||||||
f" reverse_proxy {target}",
|
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).
|
"""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
|
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:
|
else:
|
||||||
body = [f" reverse_proxy {target}"]
|
body = [*_basic_auth_lines(basic_auth, " "), f" reverse_proxy {target}"]
|
||||||
return [f"{host} {{", *body, "}", ""]
|
return [f"{host} {{", *body, "}", ""]
|
||||||
|
|
||||||
|
|
||||||
@@ -362,7 +394,7 @@ def generate_caddyfile_from_registry(
|
|||||||
elif r.kind == "remote":
|
elif r.kind == "remote":
|
||||||
lines += _host_remote_block(r.name or r.address, host, r.target)
|
lines += _host_remote_block(r.name or r.address, host, r.target)
|
||||||
else:
|
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 += _no_route_fallback()
|
||||||
lines.append("}")
|
lines.append("}")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
@@ -388,13 +420,15 @@ def generate_caddyfile_from_registry(
|
|||||||
elif r.kind == "remote":
|
elif r.kind == "remote":
|
||||||
lines += _host_remote_block(label, host, r.target)
|
lines += _host_remote_block(label, host, r.target)
|
||||||
else:
|
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 += _no_route_fallback()
|
||||||
lines.append("}")
|
lines.append("}")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
for r in custom_pub:
|
for r in custom_pub:
|
||||||
if r.public_host: # always true (custom_pub filter); narrows the type
|
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.
|
# Redirect the bare gateway port to the dashboard subdomain.
|
||||||
lines += [
|
lines += [
|
||||||
f":{gw_port} {{",
|
f":{gw_port} {{",
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Annotated, Literal, Union
|
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]
|
EnvMap = dict[str, str]
|
||||||
|
|
||||||
@@ -86,7 +87,10 @@ class RunContainer(LaunchBase):
|
|||||||
image: str
|
image: str
|
||||||
command: list[str] | None = None
|
command: list[str] | None = None
|
||||||
args: list[str] = Field(default_factory=list)
|
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)
|
volumes: list[str] = Field(default_factory=list)
|
||||||
env: EnvMap = Field(default_factory=dict)
|
env: EnvMap = Field(default_factory=dict)
|
||||||
workdir: str | None = None
|
workdir: str | None = None
|
||||||
@@ -102,6 +106,18 @@ class RunContainer(LaunchBase):
|
|||||||
gpus: str | None = None
|
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):
|
class RunNode(LaunchBase):
|
||||||
launcher: Literal["node"]
|
launcher: Literal["node"]
|
||||||
script: str
|
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):
|
class HttpInternal(BaseModel):
|
||||||
host: str = "127.0.0.1"
|
host: str = "127.0.0.1"
|
||||||
port: int = Field(ge=1, le=65535)
|
port: int = Field(ge=1, le=65535)
|
||||||
@@ -193,6 +224,9 @@ class HttpInternal(BaseModel):
|
|||||||
class HttpExposeSpec(BaseModel):
|
class HttpExposeSpec(BaseModel):
|
||||||
internal: HttpInternal
|
internal: HttpInternal
|
||||||
health_path: str | None = None
|
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):
|
class TlsMaterial(str, Enum):
|
||||||
@@ -225,9 +259,10 @@ class TcpExposeSpec(BaseModel):
|
|||||||
"""A raw-TCP service (postgres, redis, …). It doesn't ride the HTTP gateway:
|
"""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
|
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
|
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
|
the LAN is the deployment's own job (a container's ``run.ports`` — whose host
|
||||||
service binding ``0.0.0.0``); wildpc doesn't rebind it, so there's no bind-host
|
side may name an address, ``127.0.0.1:4000`` — or a native service binding
|
||||||
field here to imply otherwise. ``tls`` (optional) has wildpc drop the wildcard
|
``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>``.
|
cert onto the service so it presents a trusted cert for ``<name>.<domain>``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|||||||
@@ -86,6 +86,10 @@ class Deployment:
|
|||||||
# Optional public hostname override (exact FQDN, apex allowed). When set it is
|
# Optional public hostname override (exact FQDN, apex allowed). When set it is
|
||||||
# the public-facing name instead of the derived <subdomain>.<public_domain>.
|
# the public-facing name instead of the derived <subdomain>.<public_domain>.
|
||||||
public_host: str | None = None
|
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
|
# Raw-TCP exposure port (postgres, redis, …). Set → reachable at
|
||||||
# <name>.<gateway.domain>:<tcp_port> via bind + wildcard DNS (no Caddy route).
|
# <name>.<gateway.domain>:<tcp_port> via bind + wildcard DNS (no Caddy route).
|
||||||
tcp_port: int | None = None
|
tcp_port: int | None = None
|
||||||
@@ -193,6 +197,7 @@ def load_registry(path: Path | None = None) -> NodeRegistry:
|
|||||||
subdomain=comp_data.get("subdomain"),
|
subdomain=comp_data.get("subdomain"),
|
||||||
public=comp_data.get("public", False),
|
public=comp_data.get("public", False),
|
||||||
public_host=comp_data.get("public_host"),
|
public_host=comp_data.get("public_host"),
|
||||||
|
basic_auth=comp_data.get("basic_auth"),
|
||||||
tcp_port=comp_data.get("tcp_port"),
|
tcp_port=comp_data.get("tcp_port"),
|
||||||
static_root=comp_data.get("static_root"),
|
static_root=comp_data.get("static_root"),
|
||||||
spa=comp_data.get("spa", True),
|
spa=comp_data.get("spa", True),
|
||||||
@@ -273,6 +278,8 @@ def save_registry(registry: NodeRegistry, path: Path | None = None) -> None:
|
|||||||
entry["public"] = comp.public
|
entry["public"] = comp.public
|
||||||
if comp.public_host:
|
if comp.public_host:
|
||||||
entry["public_host"] = 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:
|
if comp.tcp_port is not None:
|
||||||
entry["tcp_port"] = comp.tcp_port
|
entry["tcp_port"] = comp.tcp_port
|
||||||
if comp.static_root:
|
if comp.static_root:
|
||||||
|
|||||||
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})
|
||||||
@@ -294,6 +294,19 @@ run:
|
|||||||
program: my-service # name in [project.scripts]
|
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
|
A `compose` launcher supervises a **whole multi-container stack as one systemd
|
||||||
unit** — `ExecStart` runs `docker compose … up` attached (`Type=simple`) and a
|
unit** — `ExecStart` runs `docker compose … up` attached (`Type=simple`) and a
|
||||||
generated `ExecStop` runs `… down` so networks/anonymous volumes are reclaimed on
|
generated `ExecStop` runs `… down` so networks/anonymous volumes are reclaimed on
|
||||||
@@ -339,6 +352,20 @@ expose:
|
|||||||
health_path: /health # Used by health polling
|
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` — Expose the service at a subdomain
|
||||||
|
|
||||||
`proxy` is a **checkbox** (a bool): `true` means the gateway routes
|
`proxy` is a **checkbox** (a bool): `true` means the gateway routes
|
||||||
|
|||||||
Reference in New Issue
Block a user