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