TLS and Networking

What you'll accomplish: Understand the per-surface [surfaces.<name>] networking config in hoziron-server — TLS termination, CORS, request limits, IP allowlisting, Unix sockets — and how each surface is either a main route that owns a listener or mounted on another surface's listener.

Auth is mandatory on any non-loopback bind. auth.mode defaults to disabled, and the daemon refuses to boot a surface with auth disabled while that surface's effective listen address (its own if it's a main route, or its mount target's if it's mounted) is bound to anything other than loopback (127.0.0.1, ::1) or a unix:// socket. This gate runs for the API, MCP, and Dashboard surfaces individually against each one's real resolved bind address. See Authentication setup.

Surfaces: main routes vs. mounted

Every surface — api, registry, mcp, dashboard — is configured under [surfaces.<name>] and is either:

  • A main route: sets listen = "host:port" or listen = "unix:///path", and owns a listener. It may optionally configure tls, allowed_ips, and limits — these apply to the whole merged router on that listener, including anything mounted on it.
  • Mounted: sets mount = "<other-surface-name>" instead of listen, and shares that surface's listener (and its hardening) as a sub-path. A mounted surface must not set its own tls, allowed_ips, or limits — that's a hard config-validation error at boot, since those settings terminate/filter before any per-surface routing and can't meaningfully differ between two routers sharing one socket.

Defaults:

  • api — main route on 127.0.0.1:4200.
  • registry — disabled by default; when enabled, main route on 127.0.0.1:4210.
  • mcp, dashboard — disabled by default; when enabled, default to mount = "api" rather than declaring their own independent address.
[surfaces.api]
enabled = true
listen = "0.0.0.0:4200"
allowed_ips = ["10.0.0.0/8"]

[surfaces.api.tls]
enabled = true
cert_path = "/data/certs/cert.pem"
key_path = "/data/certs/key.pem"

[surfaces.api.limits]
max_request_body_bytes = 10485760
request_timeout_secs = 600

[surfaces.mcp]
enabled = true
mount = "api"   # shares the API listener's address, TLS, allowlist, and limits
base_path = "/mcp"

Validation, checked once at boot across the active surface set (validate_surface_topology):

  • Exactly one of listen/mount per active surface.
  • A mount target must name a known, enabled, main-route surface — no mount chains (mounting onto a surface that is itself mounted is rejected).
  • No two main routes may declare the same listen address; surfaces that want to share an address use mount instead.
  • A mounted surface can't also set tls/allowed_ips/limits.
  • allowed_ips cannot be combined with a unix:// listen address on a main route — the IP-allowlist middleware relies on a TCP peer address (ConnectInfo<SocketAddr>), which Unix-socket connections never carry, so it fails open (allows everything) in that case. Setting both used to silently no-op; it's now a hard config error at boot. Remove allowed_ips or use a TCP listen address instead.

CLI flags (--api-listen, --registry-listen, --mcp-listen, --dashboard-listen) override a surface's listen address; setting one promotes that surface to a main route and clears any configured mount.

TLS termination

TLS is genuinely terminated in-process via axum-server + rustls, once per merged listener (not per surface). Set it on a main route:

[surfaces.api]
listen = "0.0.0.0:4200"

[surfaces.api.tls]
enabled = true
cert_path = "/data/certs/cert.pem"
key_path = "/data/certs/key.pem"

cert_path/key_path are required (and validated at boot) when tls.enabled = true. A background task polls the cert/key files every 60 seconds and hot-reloads them on change, so certbot/ACME renewal doesn't require a restart. A surface mounted on a TLS-enabled main route (e.g. mcp mounted on api) is served over that same TLS termination — it has no independent TLS state to configure.

You can still choose to terminate TLS externally instead (reverse proxy or load balancer in front of a plaintext listener) — nothing requires in-process TLS:

  • Bare metal/VM: Caddy or nginx in front (see the repo's own Caddyfile and the caddy service behind the proxy profile in docker-compose.yml)
  • Docker Compose: the proxy profile's caddy service — see Docker Compose § Fronting with Caddy

Pick one or the other for a given listener — don't terminate TLS twice.

IP allowlist

allowed_ips (a list of IPs/CIDRs, e.g. ["10.0.0.0/8", "192.168.1.1"]) on a main route is enforced by real middleware (hoziron_http::ip_allowlist), applied once per merged listener. It covers every surface mounted on that listener plus the unified /__server/health endpoint sharing the same address — the one exemption the middleware makes by path is a surface's own /health route (e.g. the API surface's /health), which always bypasses the allowlist so a basic liveness probe never gets locked out even on a restrictive allowlist. Validation rejects an empty list and malformed entries.

[surfaces.api]
listen = "0.0.0.0:4200"
allowed_ips = ["10.0.0.0/8"]

Not usable with a unix:// listen address on the same surface — see the validation rule above. If you need to restrict Unix-socket access, use filesystem permissions on the socket path instead (the listener sets socket permissions to 0660 on creation).

CORS

CORS remains under the shared [server.cors] section (not per-surface — it's a policy setting independent of any specific listen address):

[server.cors]
allowed_origins = ["https://dashboard.company.com"]
allow_credentials = true
max_age_secs = 3600

hoziron-server builds a tower-http CorsLayer from [server.cors] and applies it to the API surface's router and to the unified /__server/health router on every listener. It is not automatically applied to the MCP or Dashboard surfaces. The registry surface has its own, separate CORS config at [surfaces.registry.cors] (same shape, configured independently).

Validation rules (checked at config parse time):

  • Cannot use wildcard * with allow_credentials = true
  • Each origin must be a well-formed URL
  • An empty allowed_origins list is rejected when the [server.cors] section is present at all — omit the section entirely to disable CORS, don't set an empty list

Request limits

limits on a main route is enforced once per merged listener:

[surfaces.api.limits]
max_request_body_bytes = 10485760   # 10 MB — default; must be > 0
request_timeout_secs = 600          # default; must be > 0
  • Body size: tower_http::limit::RequestBodyLimitLayer, checked against the request's Content-Length header unconditionally — not axum::extract::DefaultBodyLimit, which only fires if a handler actually extracts the body. An oversized request is rejected with 413 Payload Too Large before it reaches a handler.
  • Request timeout: tower_http::timeout::TimeoutLayer. A request that runs past request_timeout_secs is cut off with 408 Request Timeout.

Unix sockets

listen = "unix:///var/run/hoziron.sock" binds a real tokio::net::UnixListener and serves requests over it. (Previously this was schema-accepted but silently produced no listener at all, with only an easy-to-miss error log — that gap is closed; it's now covered by an integration test that drives a real request over a UnixStream.) The listener removes any stale socket file at the same path before binding and sets permissions to 0660.

Remember the allowed_ips-on-unix:// restriction above — use filesystem permissions for Unix-socket access control instead of an IP allowlist.

Listen address resolution, per surface

For each surface independently (API, Registry, MCP, Dashboard), the daemon resolves its bind address in this order:

  1. CLI flag: hoziron-server --api-listen 0.0.0.0:4200 (and --registry-listen, --mcp-listen, --dashboard-listen) — this also promotes the surface to a main route, clearing any mount.
  2. Config file: [surfaces.<name>].listen (main route) or [surfaces.<name>].mount (shares another surface's listener).
  3. Default: 127.0.0.1:4200 for API (main route); mount = "api" for MCP/Dashboard when enabled; 127.0.0.1:4210 for Registry.

There is no unified --listen flag and no HOZIRON_LISTEN environment variable — those don't exist. Whichever address a surface effectively resolves to (its own if main route, its mount target's if mounted), the daemon evaluates it against the auth posture gate before boot: a loopback or unix:// bind is allowed to run with auth.mode = "disabled"; anything else requires local/oidc auth or an explicit, logged auth.allow_insecure_no_auth = true escape hatch.

Network architecture

Next steps


Related: