--- status: draft last_updated: 2026-06-07 --- # Configuration ## What Alknet's configuration is split into `StaticConfig` (immutable after startup) and `DynamicConfig` (hot-reloadable at runtime), with `ArcSwap` providing lock-free reads on the hot path. `ConfigService` wraps reloads behind an irpc protocol for production deployments. ## Why Three specific failures motivated the split (ADR-030): 1. No hot reload of authentication credentials — adding a key requires a restart. 2. No port forwarding access control — any authenticated client has unrestricted access (ADR-031). 3. No structured configuration beyond CLI flags — operators need config files and the NAPI layer needs programmatic reload. The split is clean: anything that affects SSH handshake or socket binding is static; anything checked per-connection or per-channel is dynamic. ## Architecture ### StaticConfig Immutable after startup. Constructed from `ServeOptions` (the builder pattern is preserved per ADR-011). Contains: - Transport mode, listen address - TLS config (cert, key) - iroh config (relay URL) - Stealth mode flag - Host key, host key algorithm - Max auth attempts, max connections per IP - Proxy config Changing any of these requires a restart. ### DynamicConfig Hot-reloadable at runtime via `ArcSwap`. Contains: - `AuthPolicy` — authorized keys, certificate authorities, token config - `ForwardingPolicy` — allow/deny rules for channel targets (ADR-031) - `RateLimitConfig` — rate limiting parameters `ArcSwap` provides lock-free reads. Every `auth_publickey()` and `channel_open_direct_tcpip()` call does a single `Arc` dereference — zero cost compared to the current approach. Writes are atomic: `store()` swaps the pointer. ### API Keys `DynamicConfig.auth` also includes API keys for service accounts and HTTP interface auth (ADR-037): ```toml [[auth.api_keys]] prefix = "alk_" hash = "sha256:abc..." scopes = ["relay:connect"] description = "dashboard service account" ttl = "30d" # optional ``` API keys are verified by `ConfigIdentityProvider::resolve_from_token()` — if the token starts with the configured prefix, it's treated as an API key and verified by SHA-256 hash lookup. Otherwise, it's treated as an Ed25519 AuthToken. Both paths produce the same `Identity` result. ### ConfigReloadHandle ```rust pub struct ConfigReloadHandle { dynamic: Arc>, } impl ConfigReloadHandle { pub fn reload(&self, new_config: DynamicConfig) { ... } } ``` Obtained from `Server::run()`. Passed to NAPI or CLI for explicit reload. ### ConfigServiceImpl The Phase 1 implementation of config service logic, backed by `ArcSwap`. Where `ConfigIdentityProvider` wraps the auth section of `DynamicConfig`, `ConfigServiceImpl` wraps the forwarding and rate-limit sections. Both are ArcSwap-backed and share the same `DynamicConfig` instance. ```rust pub struct ConfigServiceImpl { dynamic: Arc>, } impl ConfigServiceImpl { pub fn forwarding_policy(&self) -> Arc { self.dynamic.load().forwarding.clone() } pub fn rate_limits(&self) -> Arc { self.dynamic.load().rate_limits.clone() } pub fn reload(&self, new_config: DynamicConfig) { self.dynamic.store(Arc::new(new_config)); } } ``` Phase 1 deploys `ConfigServiceImpl` directly — no irpc service boundary. The `ConfigProtocol` irpc service (behind feature flag) wraps `ConfigServiceImpl` for production deployments that use the service layer. This mirrors the `ConfigIdentityProvider` / `AuthProtocol` pattern from [identity.md](identity.md) and ADR-028. ### ConfigService irpc Service ```rust enum ConfigProtocol { GetForwardingPolicy, GetRateLimits, ReloadForwarding { policy: ForwardingPolicy }, ReloadRateLimits { limits: RateLimitConfig }, } ``` Behind the `irpc` feature flag. For production deployments that use the service layer. For minimal deployments, direct `ConfigReloadHandle::reload()` is sufficient. ### ForwardingPolicy Part of DynamicConfig (ADR-031). Evaluated per-channel-open, matched against the authenticated `Identity`. Rules are evaluated in order; first match wins. Default determines fallback. ```rust pub struct ForwardingPolicy { pub default: ForwardingAction, pub rules: Vec, } ``` ### TOML Config File Optional convenience input format (amends ADR-011, does not replace programmatic API). Covers static config plus initial auth/forwarding paths. ```toml [server] # Stream-based listener: TLS + SSH on port 443 [[listeners]] type = "stream" transport = "tls" interface = "ssh" listen = "0.0.0.0:443" [server.tls] cert = "/etc/alknet/tls/cert.pem" key = "/etc/alknet/tls/key.pem" # Stream-based listener: TCP + SSH on port 22 [[listeners]] type = "stream" transport = "tcp" interface = "ssh" listen = "0.0.0.0:22" # Stream-based listener: iroh P2P [[listeners]] type = "stream" transport = "iroh" iroh_relay = "https://relay.alk.dev" # Message-based listener: HTTP on port 443 (with stealth) [[listeners]] type = "http" listen = "0.0.0.0:443" tls = true stealth = true # Message-based listener: HTTP on port 8080 (separate, no stealth) # [[listeners]] # type = "http" # listen = "0.0.0.0:8080" # tls = false # stealth = false # Message-based listener: DNS on port 53 # [[listeners]] # type = "dns" # listen = "0.0.0.0:53" # tls = false [auth] host_key = "/etc/alknet/ssh/host_key" [auth.ssh] authorized_keys = [...] [auth.token] enabled = true max_token_age = "5m" [[auth.api_keys]] prefix = "alk_" hash = "sha256:abc..." scopes = ["relay:connect"] description = "dashboard service account" ttl = "30d" [forwarding] default = "deny" [[forwarding.rules]] target = "localhost:*" action = "allow" ``` ### NAPI Reload API ```typescript interface AlknetServer { reloadAuth(auth: { authorizedKeys?: Buffer, certAuthority?: Buffer }): void; reloadForwarding(policy: ForwardingPolicyConfig): void; reloadAll(config: DynamicConfig): void; } ``` ### Multi-Transport Listeners A head node may accept connections on multiple transports and interfaces simultaneously. Listeners come in two categories: stream-based (Transport + StreamInterface pairs) and message-based (self-contained HTTP or DNS servers). ```rust pub enum ListenerConfig { Stream { transport: TransportKind, interface: StreamInterfaceKind, }, Http { bind_addr: SocketAddr, tls: bool, stealth: bool, // byte-peek protocol detection on shared port }, Dns { bind_addr: SocketAddr, tls: bool, }, } ``` For stream-based listeners, `Server::run()` spawns one accept loop per listener. For HTTP listeners, it spawns an axum server. For DNS listeners, it spawns a DNS server. All share `DynamicConfig`, `ConnectionRateLimiter`, sessions, and shutdown signal. ```toml [[listeners]] transport = "tls" listen = "0.0.0.0:443" stealth = true [[listeners]] transport = "tcp" listen = "0.0.0.0:22" [[listeners]] transport = "iroh" iroh_relay = "https://relay.alk.dev" ``` ### CLI vs Programmatic Behavior | Interface | Static config | Dynamic config | Reload mechanism | |-----------|--------------|----------------|------------------| | CLI | Flags + optional `--config` file | Loaded at startup from `--authorized-keys` | None (restart to change) | | Core Rust | `StaticConfig` struct | `AuthProtocol` (irpc) or `ConfigIdentityProvider` (ArcSwap) | `ConfigProtocol::ReloadDynamicConfig` or `ConfigReloadHandle::reload()` | | NAPI | `serve()` options | Same | `server.reloadAuth()`, `server.reloadForwarding()` | ## Constraints - `StaticConfig` cannot be changed after startup. Changing transport mode, listen address, TLS config, or host key requires a restart. - `DynamicConfig` is reloaded atomically via `ArcSwap`. Existing connections continue with their current config; new connections get the new config. - Config file is optional. `ServeOptions` builder pattern remains the primary API (amends ADR-011, does not supersede it). - No file watching (OQ-13 resolved: potential attack vector, unnecessary complexity). - Client configuration stays as `ConnectOptions` — no `ArcSwap` needed. ## Open Questions - None. All configuration-related questions are resolved per ADR-030, ADR-031, and the resolved OQs in [open-questions.md](open-questions.md). ## Design Decisions | ADR | Decision | Summary | |-----|----------|---------| | [030](decisions/030-static-dynamic-config-split.md) | Static/dynamic config split | Immutable transport vs. reloadable auth/forwarding | | [011](decisions/011-no-ssh-config-programmatic-api.md) | Programmatic-first API | Amended, not superseded — TOML is convenience layer | | [031](decisions/031-forwarding-policy.md) | Forwarding policy | Rule-based allow/deny, TransportKind-aware | | [029](decisions/029-identity-core-type.md) | Identity as core type | DynamicConfig.auth consumed by IdentityProvider | | [028](decisions/028-auth-irpc-service.md) | Auth as irpc service | ConfigService wraps DynamicConfig reloads | ## References - [research/configuration.md](../research/configuration.md) — Full analysis and proposed solution - [identity.md](identity.md) — IdentityProvider trait, DynamicConfig.auth - [ADR-013](decisions/013-fail2ban-friendly-logging.md) — Rate limiting parameters