Wingsmith
Configuration

Plane Configuration

Plane environment variables: database, auth, hub, reactor, metrics, setup chart.

The plane is configured entirely through environment variables typed by app/config/settings.py. Each field reads HANGAR_<NAME> and nothing else; a bare DATABASE_URL or APP_ENV is ignored.

Both processes (API and hub) read the same environment and the same database. The reactor runs under its own process.

Environment and mode

VariableDefaultDescription
HANGAR_APP_ENVproductionlocal is opt-in; compose sets it
HANGAR_APP_DEBUGfalseDebug mode
HANGAR_APP_LOG_LEVELINFOLog level
HANGAR_APP_LOG_FORMATconsole (json in production)Log format

APP_ENV defaults to production, so a plane started with no environment fails closed. Under APP_ENV=local, which has to be asked for:

  • ADMIN_AUTH defaults to disabled: a request with no credential resolves to the admin principal
  • The API binds 127.0.0.1
  • The process logs a warning at startup

Outside local, the plane refuses to boot with ADMIN_AUTH=disabled, without SESSION_SIGNING_SECRET, or without ADMIN_PASSWORD_HASH.

Database

VariableDefaultDescription
HANGAR_DATABASE_URLpostgresql+psycopg://hangar:hangar@localhost:5442/hangarDatabase connection string
HANGAR_DB_ECHOfalseEcho SQL statements
HANGAR_DB_POOL_SIZE10Connection pool size
HANGAR_DB_POOL_MAX_OVERFLOW10Max overflow connections
HANGAR_DB_POOL_PRE_PINGtrueTest connections before using

Requires Postgres 15 or later.

API process

VariableDefaultDescription
HANGAR_API_HOSTunset (127.0.0.1 in local, 0.0.0.0 otherwise)API bind address
HANGAR_API_PORT8000API port

The API process serves its own Prometheus metrics at GET /metrics, unauthenticated.

Hub process

VariableDefaultDescription
HANGAR_HUB_HOST0.0.0.0Hub bind address
HANGAR_HUB_PORT8001Hub port
HANGAR_HUB_PATH/v1/hub/syncWebSocket path
HANGAR_HUB_INSTANCE_IDunsetNames a pod in the claim table; falls back to $HOSTNAME, then a random id
HANGAR_HUB_SESSION_TTL_SECONDS600Session lifetime without an attached connection
HANGAR_HUB_REAPER_INTERVAL_SECONDS60How often to reap abandoned sessions
HANGAR_HUB_OWNERSHIP_CHECK_SECONDS10How often a session checks its own claim row

Hub scaling

The hub scales by shedding session state instead of sharing it. SessionState (cursors and replay buffer) stays in one pod’s memory, never in Postgres. Reconnecting to the pod you left resumes exactly; reconnecting to a different one costs a full_resync.

What is shared in Postgres is one fact: exactly one hub session may be the writer for a given cluster_id at a time. hello claims that cluster with one atomic INSERT ... ON CONFLICT DO UPDATE. The session it displaces discovers the loss by polling its own claim row on HUB_OWNERSHIP_CHECK_SECONDS and closes the connection the moment the row names someone else.

A session whose cluster never reconnects is reaped after HUB_SESSION_TTL_SECONDS of no attached connection, so an abandoned replay buffer does not sit in memory for the life of the process.

Reactor process

VariableDefaultDescription
HANGAR_REACTOR_ENABLEDtrueRun the reactor
HANGAR_REACTOR_INTERVAL_SECONDS60Reactor loop interval
HANGAR_REACTOR_LEASE_SECONDS180Reactor lease duration
HANGAR_REACTOR_EVENT_BATCH500Event batch size
HANGAR_REACTOR_NOTIFY_TIMEOUT5Notification timeout

The reactor derives alerts on its own loop under a lease. It is a separate process from the API and hub.

Authentication

VariableDefaultDescription
HANGAR_ADMIN_AUTHunsetdisabled when APP_ENV=local; always enabled otherwise
HANGAR_ADMIN_PASSWORD_HASHunsetRequired outside local
HANGAR_SESSION_SIGNING_SECRETunsetRequired outside local; random per-process key in local
HANGAR_SESSION_TTL_SECONDS900Access token lifetime
HANGAR_REFRESH_TTL_SECONDS604800Absolute session lifetime
HANGAR_TRUSTED_PROXIESunsetProxies whose X-Forwarded-For is believed when charging failed sign-ins
HANGAR_ALLOWED_ORIGINShttp://localhost:5273,http://127.0.0.1:5273CORS allowed origins

Wingsmith is single-operator software: there is one admin identity, and every person signed in as admin can do everything. There are no per-user accounts, roles, or scopes.

Four principal kinds

KindCredentialReaches
adminSession from POST /v1/auth/login with admin passwordEvery route except the gateway’s
tenantSession from POST /v1/auth/login with tenant’s console token (hgrt1.)GET /v1/serving/... only
gatewayGATEWAY_SERVICE_TOKEN, static bearerGET /v1/gateway/tenants, GET /v1/endpoints/resolved, POST /v1/clusters/tunnel-auth
operatorCluster’s own enrolment token (hgro1.), minted at registrationHub only, bound to that one cluster; never the HTTP API

There is no shared operator token. Each cluster’s enrolment token is stored as a hash and resolves to an operator principal bound to that cluster.

Sessions

Login returns a short-lived access token (hgrs1., SESSION_TTL_SECONDS) and a refresh token (hgrr1.). Refreshing rotates both atomically. A session lasts at most REFRESH_TTL_SECONDS from sign-in, however often it is refreshed.

The guard checks the session on every request, so POST /v1/auth/logout, a revoked tenant token, or a disabled tenant ends access on the next request. Changing ADMIN_PASSWORD_HASH ends every admin session.

Failed sign-ins

Failed sign-ins are budgeted per caller address and account. Behind a proxy, set TRUSTED_PROXIES or every caller shares the proxy’s budget.

Cross-site writes

A POST, PUT, PATCH, or DELETE whose Origin is neither the plane’s own nor in ALLOWED_ORIGINS is 403, and a request body that is not JSON is 415. While admin auth is off, any request whose Host is not loopback, PUBLIC_HOST, or a host in ALLOWED_ORIGINS is 403 too.

Every response carries frame-ancestors 'none' and X-Frame-Options: DENY, so no other site can frame the console.

Generate password hash

uv run python -m app.auth hash          # prompts twice
uv run python -m app.auth hash --stdin  # reads one line from a pipe

Gateway integration

VariableDefaultDescription
HANGAR_GATEWAY_SERVICE_TOKENunsetFalls back to hangar-local-gateway-token only when APP_ENV=local

The gateway’s service credential for the plane, accepted on GET /v1/endpoints/resolved, GET /v1/gateway/tenants, and POST /v1/clusters/tunnel-auth only.

Metrics and observability

VariableDefaultDescription
HANGAR_METRICS_SOURCESunsetTakes default=<url>|token=...; per-cluster label is Cluster.metrics_label
HANGAR_METRICS_TIMEOUT5Metrics query timeout
HANGAR_METRICS_WINDOW5mMetrics query window
HANGAR_METRICS_STEP60sMetrics query step
HANGAR_METRICS_REMOTE_WRITE_URLunsetWhere the fleet pushes; rendered into setup values. Empty renders monitoring.enabled: false

Remote write authentication

VariableDefaultDescription
HANGAR_REMOTE_WRITE_AUTH_ENABLEDfalseKeep an htpasswd Secret for a basic-auth write receiver
HANGAR_REMOTE_WRITE_AUTH_NAMESPACEunsetNamespace for the auth Secret
HANGAR_REMOTE_WRITE_AUTH_SECRETprom-remote-write-authSecret name
HANGAR_REMOTE_WRITE_AUTH_KEYusersSecret key

Setup chart and images

VariableDefaultDescription
HANGAR_SETUP_CHART_REFoci://ghcr.io/hangar-dev/charts/cluster-setupChart reference
HANGAR_SETUP_CHART_VERSIONunsetChart version
HANGAR_SETUP_CHART_PRIVATEfalseChart requires authentication
HANGAR_OPERATOR_IMAGEunsetOperator image (overrides chart default)
HANGAR_AGENT_IMAGEunsetAgent image (overrides chart default)

The plane renders setup values for each cluster on the register screen. These variables control the chart reference and image overrides.

Benchmarks

VariableDefaultDescription
HANGAR_BENCHMARK_CONFORMANCE_IMAGEghcr.io/hangar-dev/conformance:0.1.0Conformance harness image
HANGAR_BENCHMARK_EVALSCOPE_IMAGEghcr.io/hangar-dev/evalscope:1.11.1Evalscope harness image

These images are passed to the operator for benchmark runs.

Integrations

VariableDefaultDescription
HANGAR_HF_TOKENunsetHugging Face token for artifact resolution

Running the plane

Both processes read the same environment:

uv sync
export HANGAR_APP_ENV=local
uv run python -m app.api            # HTTP API on :8000
uv run python -m app.api --reload   # with autoreload
uv run python -m app.hub            # operator hub on :8001
uv run python -m app.reactor        # reactor

--check on any process builds it, runs its lifecycle, and exits, which proves wiring without binding a long-lived listener.