- Add definitions.md: normative terminology disambiguation (Interface, Service, Transport, Token, Identity, Domain, Scope, CredentialProvider, etc.) - Add credentials.md: CredentialProvider trait and CredentialSet enum for outbound auth, mirroring IdentityProvider pattern for inbound auth - Rewrite interface.md: StreamInterface/MessageInterface split (ADR-035), InterfaceRequest/InterfaceResponse, HttpInterface/DnsInterface stubs, ListenerConfig with Stream/Http/Dns variants, credential presentation table - Update auth.md: API keys in DynamicConfig (ADR-037), credential presentation per (Transport, Interface) pair, ApiKeyEntry struct in AuthPolicy - Update configuration.md: API keys, ListenerConfig with Http/Dns variants, expanded TOML config examples - Update call-protocol.md: resolve OQ-IF-01 (InterfaceEvent carries EventEnvelope + Identity), add MessageInterface awareness to protocol adapter layer - Update overview.md: three-layer model now includes StreamInterface/ MessageInterface, CredentialProvider/CredentialSet exports, definitions.md reference, ADRs 035-037 - Update open-questions.md: resolve OQ-IF-01, OQ-IF-02, add OQ-P2-01 through OQ-P2-04, add OQ-CP-01 through OQ-CP-04, add OQ-DEF-01, OQ-DEF-03, OQ-DEF-08 - Update README.md: add definitions.md, credentials.md, ADRs 035-037, phase2 research docs, current state description Key architectural decisions: - ADR-035: StreamInterface/MessageInterface split (two Layer 2 traits) - ADR-036: CredentialProvider as core type (outbound auth, alknet_core::credentials) - ADR-037: API keys as DynamicConfig auth (hash-verified bearer tokens)
9.2 KiB
status, last_updated
| status | last_updated |
|---|---|
| draft | 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):
- 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 |
References
- research/configuration.md — Full analysis and proposed solution
- identity.md — IdentityProvider trait, DynamicConfig.auth
- ADR-013 — Rate limiting parameters