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.modedefaults todisabled, 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 aunix://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"orlisten = "unix:///path", and owns a listener. It may optionally configuretls,allowed_ips, andlimits— these apply to the whole merged router on that listener, including anything mounted on it. - Mounted: sets
mount = "<other-surface-name>"instead oflisten, and shares that surface's listener (and its hardening) as a sub-path. A mounted surface must not set its owntls,allowed_ips, orlimits— 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 on127.0.0.1:4200.registry— disabled by default; when enabled, main route on127.0.0.1:4210.mcp,dashboard— disabled by default; when enabled, default tomount = "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/mountper active surface. - A
mounttarget 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
listenaddress; surfaces that want to share an address usemountinstead. - A mounted surface can't also set
tls/allowed_ips/limits. allowed_ipscannot be combined with aunix://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. Removeallowed_ipsor 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
Caddyfileand thecaddyservice behind theproxyprofile indocker-compose.yml) - Docker Compose: the
proxyprofile'scaddyservice — 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
*withallow_credentials = true - Each origin must be a well-formed URL
- An empty
allowed_originslist 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'sContent-Lengthheader unconditionally — notaxum::extract::DefaultBodyLimit, which only fires if a handler actually extracts the body. An oversized request is rejected with413 Payload Too Largebefore it reaches a handler. - Request timeout:
tower_http::timeout::TimeoutLayer. A request that runs pastrequest_timeout_secsis cut off with408 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:
- 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 anymount. - Config file:
[surfaces.<name>].listen(main route) or[surfaces.<name>].mount(shares another surface's listener). - Default:
127.0.0.1:4200for API (main route);mount = "api"for MCP/Dashboard when enabled;127.0.0.1:4210for 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: