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:
@@ -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>``.
|
||||
"""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user