329 lines
9.7 KiB
Markdown
329 lines
9.7 KiB
Markdown
---
|
|
status: draft
|
|
last_updated: 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):
|
|
|
|
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<DynamicConfig>`. 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<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.
|
|
|
|
```rust
|
|
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](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<ForwardingRule>,
|
|
}
|
|
```
|
|
|
|
### 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 |
|
|
|
|
## Phase 2 Implementation Notes
|
|
|
|
- `DynamicConfig.auth` now includes `api_keys: Vec<ApiKeyEntry>` (ADR-037)
|
|
- `DynamicConfig.credentials: HashMap<String, CredentialSet>` added for static outbound credentials (ADR-036)
|
|
- `ListenerConfig` restructured from flat struct to enum: `Stream { transport, interface }`, `Http { config: HttpListenerConfig }`, `Dns { config: DnsListenerConfig }` (ADR-035)
|
|
- `HttpListenerConfig` and `DnsListenerConfig` builder-pattern structs added
|
|
- `ListenerConfig::validate()` now validates all three variants
|
|
|
|
## 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 |