9.7 KiB
status, last_updated
| status | last_updated |
|---|---|
| draft | 2026-06-09 |
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):
- No hot reload of authentication credentials — adding a key requires a restart.
- No port forwarding access control — any authenticated client has unrestricted access (ADR-031).
- 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<DynamicConfig>. Contains:
AuthPolicy— authorized keys, certificate authorities, token configForwardingPolicy— 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):
[[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
pub struct ConfigReloadHandle {
dynamic: Arc<ArcSwap<DynamicConfig>>,
}
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<DynamicConfig>. 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.
pub struct ConfigServiceImpl {
dynamic: Arc<ArcSwap<DynamicConfig>>,
}
impl ConfigServiceImpl {
pub fn forwarding_policy(&self) -> Arc<ForwardingPolicy> {
self.dynamic.load().forwarding.clone()
}
pub fn rate_limits(&self) -> Arc<RateLimitConfig> {
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
and ADR-028.
ConfigService irpc Service
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.
pub struct ForwardingPolicy {
pub default: ForwardingAction,
pub rules: Vec<ForwardingRule>,
}
TOML Config File
Optional convenience input format (amends ADR-011, does not replace programmatic API). Covers static config plus initial auth/forwarding paths.
[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
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).
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.
[[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
StaticConfigcannot be changed after startup. Changing transport mode, listen address, TLS config, or host key requires a restart.DynamicConfigis reloaded atomically viaArcSwap. Existing connections continue with their current config; new connections get the new config.- Config file is optional.
ServeOptionsbuilder 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— noArcSwapneeded.
Open Questions
- None. All configuration-related questions are resolved per ADR-030, ADR-031, and the resolved OQs in open-questions.md.
Design Decisions
| ADR | Decision | Summary |
|---|---|---|
| 030 | Static/dynamic config split | Immutable transport vs. reloadable auth/forwarding |
| 011 | Programmatic-first API | Amended, not superseded — TOML is convenience layer |
| 031 | Forwarding policy | Rule-based allow/deny, TransportKind-aware |
| 029 | Identity as core type | DynamicConfig.auth consumed by IdentityProvider |
| 028 | Auth as irpc service | ConfigService wraps DynamicConfig reloads |
Phase 2 Implementation Notes
DynamicConfig.authnow includesapi_keys: Vec<ApiKeyEntry>(ADR-037)DynamicConfig.credentials: HashMap<String, CredentialSet>added for static outbound credentials (ADR-036)ListenerConfigrestructured from flat struct to enum:Stream { transport, interface },Http { config: HttpListenerConfig },Dns { config: DnsListenerConfig }(ADR-035)HttpListenerConfigandDnsListenerConfigbuilder-pattern structs addedListenerConfig::validate()now validates all three variants
References
- research/configuration.md — Full analysis and proposed solution
- identity.md — IdentityProvider trait, DynamicConfig.auth
- ADR-013 — Rate limiting parameters