Skip to content

Networking

Clauster decides whether a given bind is allowed by asking one question: will authentication actually gate requests? This is enforced at config-load time by the _loopback_or_authed model validator in config.py, backed by the shared _missing_enforced_auth helper.

Loopback vs non-loopback

The loopback hosts are 127.0.0.1, ::1, and localhost. Binding to any of these never requires auth — the dashboard is only reachable from the machine itself.

Binding to anything else (a LAN IP, 0.0.0.0, a public address) is a non-loopback bind and is refused unless authentication is actually enforced. "Enforced" means:

auth.enabled == true  AND  (auth.password_required  OR  auth.reverse_proxy.enabled  OR  auth.api_token_hash)

Any one of the three methods satisfies the requirement. password_required gates the browser login, reverse_proxy.enabled trusts a proxy-terminated identity, and api_token_hash (Bearer-token auth for headless/API clients, minted with clauster hash-token) lets a caller present Authorization: Bearer <token> instead of a browser session.

The explicit auth.allow_unauthenticated_network opt-out lets you bypass that requirement on a trusted LAN — the config validator permits it, and clauster doctor downgrades it to a warning rather than an error.

The auth / networking matrix

host auth.enabled method (password_required, reverse_proxy.enabled, or api_token_hash) allow_unauthenticated_network Result
loopback any any any ✅ Starts (loopback never needs auth)
non-loopback false false Refused — silent open door
non-loopback true none of the three set false Refused — switch on but nothing enforces it
non-loopback false/true password set but enabled: false false Refused — password without the master switch is a no-op
non-loopback true password_required (+ hash) any ✅ Starts (password login)
non-loopback true reverse_proxy.enabled any ✅ Starts (proxy trust)
non-loopback true api_token_hash set any ✅ Starts (Bearer-token auth)
non-loopback any any true ⚠️ Starts (explicit unauthenticated opt-out; doctor warns)

A second validator independently refuses to start when password_required is set but password_hash is empty — regardless of host — because that would lock everyone out or be silently skipped.

Every non-loopback bind also needs auth.allowed_origins. A loopback bind auto-trusts 127.0.0.1/localhost/[::1] at its own port; a non-loopback bind auto-trusts nothing. The strict Origin allowlist gates every unsafe request and every WebSocket handshake — and, since 1.0, it does so whether or not auth.enabled is set, because it is a cross-site defence rather than an authentication method. So list the browser-facing origin, or the dashboard's own actions and live views are refused with origin check failed. This bites even when you browse the service at http://localhost:<port> (a published Docker port, for instance) — the bind host is what decides, not the address you type.

Password auth on a non-loopback bind

projects_root: ~/code
host: 0.0.0.0
auth:
  enabled: true
  password_required: true
  password_hash: "$argon2id$v=19$..."   # clauster hash-password
  cookie_secure: always                 # if there is no TLS-terminating proxy

Plain-http cookie warning

With password auth on and no TLS proxy, the session cookie may ship without the Secure flag and be sniffable on the wire. Clauster warns about this at startup. Put it behind https / a TLS proxy, or set cookie_secure: always.

Native HTTPS (built-in TLS)

Instead of an external TLS proxy, Clauster can terminate HTTPS itself via uvicorn. This is the simplest way to get a secure context (required for browser features like Web Notifications on a LAN IP) when you don't want a reverse proxy or tailscale serve.

Two provisioning modes are available via tls.provision:

Bring your own cert (provision = off, default)

Point Clauster at an existing certificate + key:

projects_root: ~/code
host: 0.0.0.0
auth:
  enabled: true
  password_required: true
  password_hash: "$argon2id$v=19$..."   # clauster hash-password
tls:
  cert_file: /etc/clauster/tls/fullchain.pem   # PEM cert (chain)
  key_file: /etc/clauster/tls/privkey.pem      # PEM private key

Both paths are validated fail-closed, twice — at config-load and again at server start — for existence, readability, and that they resolve to an absolute file (any .. is collapsed). If TLS is configured but a file is missing or unreadable, Clauster aborts startup with a clear error rather than silently falling back to plain HTTP. The key material is never logged.

Self-signed cert generation (provision = self-signed)

Clauster can generate and manage a self-signed RSA-2048 cert+key pair for you, no external tools required. This requires the cryptography package (already a core dependency since 0.13):

projects_root: ~/code
host: 0.0.0.0
auth:
  enabled: true
  password_required: true
  password_hash: "$argon2id$v=19$..."   # clauster hash-password
tls:
  provision: self-signed
  hostnames:
    - 192.168.1.10     # LAN IP (or your NAS hostname)
    - clauster.local   # optional additional name

At startup, Clauster writes state_dir/tls/self-signed.crt and state_dir/tls/self-signed.key (private key: 0600) and serves from them. The cert is regenerated automatically when it is within 30 days of expiry (825-day lifetime) or the SAN set changes. Do not set cert_file / key_file in this mode — they are managed by Clauster.

Browser trust

A self-signed cert is not trusted by browsers by default. You can add a security exception in the browser, or install the cert into your OS / browser trust store once. This is intentional: the cert is only for securing the wire on your LAN — it does not provide public-CA-level identity assurance.

ACME / Let's Encrypt

Automated certificate renewal via ACME is deferred to a follow-up issue (issue 774).

With native TLS (either mode) the connection is https, so auth.cookie_secure: auto already marks the session cookie Secure — no cookie_secure: always needed, and the plain-http cookie warning is suppressed. The bind/auth rules are unchanged: HTTPS does not relax the non-loopback "enforced auth" requirement.

tls is file/CLI-managed only

tls fields are structural settings, so — like the bind host and secret hashes — they are not editable from the in-app config editor. Set them in clauster.yml (or via env vars: CLAUSTER_TLS_CERT_FILE / CLAUSTER_TLS_KEY_FILE / CLAUSTER_TLS_PROVISION).

Behind a reverse proxy

Set root_path if Clauster is served under a sub-path, and use auth.reverse_proxy for proxy-terminated auth:

projects_root: ~/code
host: 0.0.0.0
root_path: /clauster            # if mounted under a sub-path
auth:
  enabled: true
  allowed_origins:
    - https://clauster.example.com
  reverse_proxy:
    enabled: true
    user_header: Remote-User
    shared_secret_header: X-Proxy-Auth
    shared_secret: "<hmac-key-the-proxy-signs-with>"
    trusted_ips:
      - 10.0.0.2                # the proxy's peer IP
    hmac_window_seconds: 60     # clock-skew / replay window

The proxy authenticates the user, sets user_header, and signs shared_secret_header with the shared HMAC key. Clauster verifies the request came from a trusted_ips peer and that the HMAC is valid within hmac_window_seconds. With reverse_proxy.enabled + auth.enabled, no password hash is required.

When a trusted proxy terminates TLS and reports X-Forwarded-Proto=https, cookie_secure: auto correctly marks the session cookie Secure.

Trusted-header (forward-auth) mode

The HMAC recipe above is the higher-assurance default. Many SSO proxies, though — Authelia, authentik, Caddy forward_auth, Traefik, oauth2-proxy — authenticate the user and forward a Remote-User header but do not sign a per-request HMAC. For those, set reverse_proxy.require_hmac: false: a request from a trusted_ips peer carrying user_header then authenticates on the header alone — no HMAC needed.

host: 0.0.0.0
auth:
  enabled: true
  allowed_origins:
    - https://clauster.example.com
  reverse_proxy:
    enabled: true
    require_hmac: false         # forward-auth: trust user_header from a trusted peer
    user_header: Remote-User
    trusted_ips:
      - 10.0.0.2                # the proxy's peer IP — REQUIRED in this mode

The header is only as trustworthy as the proxy

With require_hmac: false the user_header is unsigned and forgeable by anyone who can reach a trusted_ips peer. Keep require_hmac: true (the default) whenever your proxy can sign an HMAC; only drop to header-only mode under the two conditions below.

Use header-only mode only when all three hold:

  • the proxy is the sole network route to clauster (clauster is not reachable directly), and
  • the proxy strips user_header from every inbound client request before re-adding its own authenticated value (otherwise a client can forge the user), and
  • trusted_ips lists only the proxy's own peer IP — never a host an attacker can reach or originate traffic from. Each entry is matched against the socket peer as an IP or CIDR, so an over-broad range (e.g. a whole subnet, or 0.0.0.0/0) admits any peer inside it to authenticate as any user on the unsigned user_header. Pin the single proxy IP (or the tightest CIDR that covers only it); a trusted_ips entry that includes attacker-reachable hosts is a full auth bypass, not a hardening knob.

trusted_ips is mandatory whenever reverse_proxy.enabled (both the HMAC and the header-only forward_auth modes) — clauster refuses to start without it. The login rate-limiter never keys on the bare header: a forged-username flood from a trusted IP collapses to the shared-IP global backoff, so it can't mint a fresh per-user login budget.

Proxy identity is admission, not access control

Clauster reads user_header only to authenticate the request. The identity does not scope anything: every admitted request acts with the full single-operator capability, and the config-write audit log records the constant admin actor rather than the header value — actions by two different IdP users are indistinguishable there. Admitting a second person to this proxy grants them your shell; treat the IdP group as an on/off switch for the whole host, not as per-user access control.

Recipe — Caddy forward_auth + Authelia

Caddy delegates each request to Authelia, then forwards Authelia's Remote-User to clauster. Clauster trusts it because Caddy is the only trusted_ips peer.

clauster.example.com {
    # Defence-in-depth: strip any client-supplied identity headers before
    # forward_auth runs, so only Authelia's values reach clauster.
    request_header -Remote-User
    request_header -Remote-Groups
    request_header -Remote-Email
    forward_auth authelia:9091 {
        uri /api/verify?rd=https://auth.example.com
        # Authelia returns the authenticated user in Remote-User; copy_headers
        # writes it onto the upstream request, so clauster sees Authelia's value.
        copy_headers Remote-User Remote-Groups Remote-Email
    }
    reverse_proxy clauster:7621
}
# clauster.yml
auth:
  enabled: true
  allowed_origins: ["https://clauster.example.com"]
  reverse_proxy:
    enabled: true
    require_hmac: false
    user_header: Remote-User
    trusted_ips: ["10.0.0.2"]   # Caddy's peer IP as seen by clauster

Recipe — oauth2-proxy (header injection)

oauth2-proxy authenticates against your OIDC/OAuth provider and injects the user into a configurable header. Point clauster's user_header at it.

# oauth2-proxy.cfg
upstreams = ["http://clauster:7621/"]
set_xauthrequest = true                 # emit X-Auth-Request-User upstream
pass_user_headers = true
# oauth2-proxy strips inbound X-Auth-Request-* from the client by default.
# clauster.yml
auth:
  enabled: true
  allowed_origins: ["https://clauster.example.com"]
  reverse_proxy:
    enabled: true
    require_hmac: false
    user_header: X-Auth-Request-User
    trusted_ips: ["10.0.0.3"]   # oauth2-proxy's peer IP

Traefik (forwardAuth middleware) and authentik (outpost) follow the same shape: authenticate at the proxy, forward an authenticated user header, list the proxy's peer IP in trusted_ips, and ensure the proxy strips the client-supplied header.

Scraping /metrics from behind the auth gate

The optional Prometheus /metrics endpoint (observability.prometheus_enabled) sits behind the same auth guard as the rest of the dashboard, so on a non-loopback, authenticated bind a scraper can't reach it without credentials. Two ways through:

  • Scrape over loopback — Prometheus on the same host scrapes http://127.0.0.1:7621/metrics, where no auth is enforced.
  • Set a scrape tokenobservability.metrics_token_hash (mint with clauster hash-metrics-token) lets a scraper present Authorization: Bearer <token> to reach /metrics (only that route) without a browser session. This is the path for an off-host Prometheus.

The token journey, the full metric list, and a prometheus.yml snippet are in Operations → Metrics.

Docker

The container binds 0.0.0.0 internally, so it requires enforced auth to start — the bundled compose.yaml sets CLAUSTER_AUTH_ENABLED=true and CLAUSTER_AUTH_PASSWORD_REQUIRED=true and expects a CLAUSTER_AUTH_PASSWORD_HASH. See Installation.

Because that bind is non-loopback, it also needs auth.allowed_origins (see the callout above) — otherwise the login POST is refused with 403 origin check failed. Completing the first-run setup wizard records the origin you reached it at automatically; a Compose deployment configured purely by environment sets CLAUSTER_AUTH_ALLOWED_ORIGINS instead (comma-separated for more than one).