Files
alknet/docs/architecture/crates/core/config.md
glm-5.2 f224ea998c docs(arch): ADR-030..033 — repo/adapter pattern, PeerEntry, CredentialStore, forwarded-for
Land the storage and auth strategy research (findings.md) as four
accepted ADRs and amend the core and call specs to match:

- ADR-030: PeerEntry and Identity.id decoupling. Replaces
  authorized_fingerprints with peers: Vec<PeerEntry>; Identity.id becomes
  the stable peer_id, decoupled from the rotating fingerprint. Supersedes
  ADR-029 Assumption 1's UUID source (one-way door preserved, source
  changes). Resolves OQ-33 and the storage-boundary half of OQ-34. Records
  the API-key asymmetry as deliberate (OQ-35).

- ADR-031: CredentialStore repo trait + InMemoryCredentialStore default
  adapter in core. Second repo trait alongside IdentityProvider. Vault
  encrypts; the store persists the EncryptedData blob; assembly layer
  loads into Capabilities. EncryptedData core mirror includes salt for
  wire-format compat.

- ADR-032: Forwarded-for identity. forwarded_for field on call.requested
  and OperationContext — metadata only, never read by AccessControl::check
  (enforced structurally via the check signature). The from_call handler
  populates it. Wire-format one-way door, folded into the ADR-029
  migration window.

- ADR-033: Storage boundary and repo/adapter pattern. Core defines repo
  traits + in-memory defaults; persistence adapters are separate crates;
  assembly layer wires. Resolves OQ-34. Concrete adapter shapes deferred
  for exploration (OQ-36).

Amends auth.md, config.md, operation-registry.md, client-and-adapters.md,
open-questions.md, README.md, crates/core/README.md. Marks ADR-029
Accepted (Assumption 1 carries the ADR-030 superseded note). Marks the
research findings doc reviewed.
2026-06-27 12:12:25 +00:00

14 KiB

status, last_updated
status last_updated
draft 2026-06-27

Configuration

StaticConfig, DynamicConfig, ArcSwap, and ConfigReloadHandle.

StaticConfig

Immutable configuration resolved at startup. Cannot be changed without restarting the endpoint.

pub struct StaticConfig {
    /// Bind address for the quinn endpoint (e.g., "0.0.0.0:4433").
    /// None if the quinn endpoint is not configured (iroh-only node).
    pub listen_addr: Option<SocketAddr>,

    /// TLS identity mode for the quinn endpoint.
    /// Required if listen_addr is Some.
    pub tls_identity: Option<TlsIdentity>,

    /// iroh relay URL (e.g., "https://relay.iroh.network/").
    /// None if the iroh endpoint is not configured.
    pub iroh_relay: Option<RelayUrl>,

    /// Drain timeout for graceful shutdown (default: 2 seconds).
    pub drain_timeout: Duration,
}

/// TLS identity configuration for the quinn endpoint.
pub enum TlsIdentity {
    /// X.509 certificate for domain-facing identity.
    /// Required for browser/WebTransport clients.
    X509 {
        cert: PathBuf,
        key: PathBuf,
    },

    /// RFC 7250 raw Ed25519 public key.
    /// No domain, no CA, no cert renewal. Key = identity.
    /// Same model as iroh's NodeId, but for direct QUIC connections.
    /// Uses `Ed25519SecretKey` (alknet-core-owned wrapper over
    /// `ed25519_dalek::SigningKey`) — not coupled to the `iroh` feature.
    /// Available in quinn-only builds. See ADR-027.
    RawKey(Ed25519SecretKey),

    /// Self-signed X.509 cert for development.
    /// Generated on startup, not validated by external clients.
    SelfSigned,

    /// ACME auto-provisioning via Let's Encrypt (rustls-acme).
    /// Produces X.509 certs at runtime; handles TLS-ALPN-01 challenges
    /// and automatic renewal. Feature-gated behind `acme`. See ADR-027.
    Acme {
        domains: Vec<String>,
        cache_dir: PathBuf,
        directory: AcmeDirectory,  // Production, Staging, Custom(url)
        contact: Vec<String>,      // e.g. ["mailto:admin@example.com"]
    },
}

Why TlsIdentity instead of tls_cert/tls_key options

TLS identity in alknet has two distinct use cases, not one. The original tls_cert: Option<PathBuf> / tls_key: Option<PathBuf> assumed X.509 was the only TLS identity model. RFC 7250 raw public keys (used by iroh, supported by rustls) provide a fundamentally different mode: Ed25519 key as identity, no X.509, no CA, no domain. This is the default for most alknet nodes — it works natively with SSH auth and git. X.509 certs are for domain-hosted services and browser/WebTransport clients, which don't support RFC 7250.

The TlsIdentity enum captures all four modes. See OQ-12 for the use-case rationale and ADR-027 for the ACME + RawKey decoupling design.

Ed25519SecretKey

A thin alknet-core-owned wrapper over ed25519_dalek::SigningKey. Not feature-gated — available in all builds. Used by TlsIdentity::RawKey for RFC 7250 raw public key TLS identity. When the iroh transport is configured, build_iroh_endpoint converts to iroh::SecretKey::from_bytes (see ADR-027, Decision 4).

AcmeDirectory

pub enum AcmeDirectory {
    Production,   // Let's Encrypt production
    Staging,      // Let's Encrypt staging
    Custom(String), // custom ACME directory URL
}

Construction examples (updated)

// P2P / key-based identity (default for most nodes) — no iroh dep needed
let p2p_config = StaticConfig {
    listen_addr: Some("0.0.0.0:4433".parse()?),
    tls_identity: Some(TlsIdentity::RawKey(Ed25519SecretKey::generate())),
    iroh_relay: None,
    drain_timeout: Duration::from_secs(2),
};

// Domain-hosted service with ACME auto-provisioning
let acme_config = StaticConfig {
    listen_addr: Some("0.0.0.0:443".parse()?),
    tls_identity: Some(TlsIdentity::Acme {
        domains: vec!["relay.alk.dev".to_string()],
        cache_dir: "/var/lib/alknet/acme".into(),
        directory: AcmeDirectory::Production,
        contact: vec!["mailto:admin@alk.dev".to_string()],
    }),
    iroh_relay: None,
    drain_timeout: Duration::from_secs(2),
};

Key differences from reference implementation

The reference StaticConfig (in alknet-main/crates/alknet-core/src/config/static_config.rs) is SSH-centric: it holds host_key, host_key_algorithm, proxy_config, stealth, transport_mode, and listeners. The new model removes all of these:

  • No host_key/host_key_algorithm: SSH host keys are managed by the SSH handler, not by core config. The endpoint uses TLS certs, not SSH host keys.
  • No proxy_config: Outbound proxy is an SSH-specific concern (SOCKS5/HTTP CONNECT forwarding). Not in core config.
  • No stealth: ALPN eliminates the need for stealth/byte-peeking. See ADR-001.
  • No transport_mode/listeners: The old ServeTransportMode and ListenerConfig enum are replaced by listen_addr (quinn) and iroh_relay (iroh). Both are optional — a node can use either or both. See ADR-010.

Construction

StaticConfig is constructed by the CLI binary from CLI arguments or a config file. The exact shape of StartupOptions (or whatever the CLI uses) is a CLI concern, not a core concern. alknet-core provides StaticConfig as a data structure; the CLI is responsible for populating it.

// The CLI binary constructs StaticConfig from its own options/config.
// StartupOptions is NOT a core type — it belongs to the alknet CLI binary.
// alknet-core receives a fully populated StaticConfig.

// P2P / key-based identity (default for most nodes)
let p2p_config = StaticConfig {
    listen_addr: Some("0.0.0.0:4433".parse()?),
    tls_identity: Some(TlsIdentity::RawKey(Ed25519SecretKey::generate())),
    iroh_relay: None,
    drain_timeout: Duration::from_secs(2),
};

// Domain-hosted service (relays, public services, browsers) — manual certs
let domain_config = StaticConfig {
    listen_addr: Some("0.0.0.0:4433".parse()?),
    tls_identity: Some(TlsIdentity::X509 {
        cert: "/path/to/cert.pem".into(),
        key: "/path/to/key.pem".into(),
    }),
    iroh_relay: None,
    drain_timeout: Duration::from_secs(2),
};

DynamicConfig

Runtime-reloadable configuration. Hot-reloaded via ArcSwap without restarting the endpoint.

#[derive(Debug, Clone)]
pub struct DynamicConfig {
    pub auth: AuthPolicy,
    pub rate_limits: RateLimitConfig,
}

AuthPolicy

Authorization policy derived from peer entries and API keys.

pub struct AuthPolicy {
    /// Peer entries: each maps a stable logical peer_id to its current
    /// fingerprint, scopes, resources, and enabled state. Replaces the
    /// pre-ADR-030 `authorized_fingerprints: HashSet<String>`. The list
    /// is keyed by `peer_id`; resolution looks up by `fingerprint`.
    /// See ADR-030.
    pub peers: Vec<PeerEntry>,

    /// API keys for token-based auth. Unchanged by ADR-030 — API keys
    /// don't get the PeerEntry treatment (rotation = new identity is the
    /// correct semantics for bearer tokens). See ADR-030 §"API keys".
    pub api_keys: Vec<ApiKeyEntry>,
}

PeerEntry

A peer entry maps a stable logical peer identity to its current cryptographic material and authorization scopes. The peer_id is stable across key rotation; the fingerprint changes when the node rotates its TLS key. ConfigIdentityProvider::resolve_from_fingerprint resolves fingerprint → PeerEntryIdentity { id: peer_id, ... }, so Identity.id is the stable peer_id, not the rotating fingerprint.

pub struct PeerEntry {
    /// Stable logical peer id ("worker-a", "alice"). Does NOT change on
    /// key rotation. This becomes Identity.id on resolution.
    pub peer_id: String,

    /// Current cryptographic material — the fingerprint the endpoint
    /// extracts from the TLS handshake (SHA256:... for X.509, ed25519:...
    /// for RFC 7250 raw keys). Changes on key rotation.
    pub fingerprint: String,

    /// Authorization scopes granted to this peer. Resolved into
    /// Identity.scopes.
    pub scopes: Vec<String>,

    /// Named resource lists granted to this peer. Resolved into
    /// Identity.resources. Populated from config (ADR-030 lifts the
    /// pre-ADR-030 limitation that fingerprint-resolved identities had
    /// empty resources).
    pub resources: HashMap<String, Vec<String>>,

    /// Human-readable display name for logs / UIs. Optional.
    pub display_name: Option<String>,

    /// Whether this peer is authorized at all. false = the fingerprint
    /// is recognized but the peer is disabled (token-revoked-equivalent
    /// for fingerprints). Resolution returns None.
    pub enabled: bool,
}

See ADR-030 for the PeerEntry model, the id-fingerprint decoupling rationale, and the key-rotation story (vault rotates locally; the remote side updates the PeerEntry.fingerprint field; the peer_id and all ACL / routing references stay stable).

Certificate authority entries for cert-based auth are omitted from AuthPolicy until alknet-ssh is implemented, to avoid referencing an undefined type. Adding the cert_authorities field is additive (a new field on AuthPolicy is non-breaking for existing config files that don't use it). alknet-ssh will define CertAuthorityEntry with the necessary fields (public key, principals, options).

This replaces the reference implementation's AuthPolicy which depended on russh::keys::PublicKey. The new version stores fingerprints as strings (in PeerEntry.fingerprint), not russh types. This removes the russh dependency from alknet-core.

ApiKeyEntry

pub struct ApiKeyEntry {
    /// Key prefix (first 8 chars of the key). Used for O(1) lookup.
    pub prefix: String,

    /// SHA-256 hash of the full key. Used for verification.
    pub hash: String,

    /// Authorization scopes granted by this key.
    pub scopes: Vec<String>,

    /// Human-readable description.
    pub description: String,

    /// Unix timestamp when the key expires. None = never expires.
    pub expires_at: Option<u64>,
}

Carries forward from the reference implementation with no changes.

RateLimitConfig

pub struct RateLimitConfig {
    pub max_connections_per_ip: usize,
    pub max_auth_attempts: usize,
}

Carries forward from the reference implementation. Rate limits are entirely dynamic — StaticConfig does not contain rate limit fields. The CLI binary sets initial RateLimitConfig values when constructing the initial DynamicConfig. Hot-reloading via ConfigReloadHandle replaces rate limits immediately without restart.

ArcSwap Pattern

DynamicConfig is wrapped in Arc<ArcSwap<DynamicConfig>> for lock-free reads and atomic swaps.

let dynamic = Arc::new(ArcSwap::new(Arc::new(DynamicConfig::default())));
  • Reads: dynamic.load() returns Arc<DynamicConfig>. Multiple readers can hold references simultaneously without blocking.
  • Writes: dynamic.store(Arc::new(new_config)) atomically replaces the config. All subsequent reads see the new config.
  • No locks: ArcSwap uses atomic operations. No reader is ever blocked by a writer.

This pattern carries forward directly from the reference implementation (alknet-main/crates/alknet-core/src/config/dynamic_config.rs).

ConfigReloadHandle

pub struct ConfigReloadHandle {
    dynamic: Arc<ArcSwap<DynamicConfig>>,
}

impl ConfigReloadHandle {
    pub fn reload(&self, new_config: DynamicConfig);
    pub fn dynamic(&self) -> Arc<DynamicConfig>;
}
  • reload(): Atomically replaces the dynamic config. All subsequent reads (including in-flight IdentityProvider calls) see the new config.
  • dynamic(): Returns the current config as Arc<DynamicConfig>.

The CLI binary creates a ConfigReloadHandle and passes it to a config watcher (file watcher, SIGHUP handler, or call protocol operation) that calls reload() when config changes are detected.

Config reload is a privilege-escalation path. ConfigIdentityProvider reads from ArcSwap<DynamicConfig>, so a reload that adds an authorized fingerprint or API key grants access immediately. A malicious reload is equivalent to root-level privilege grant. The reload trigger must be authenticated/local-only: SIGHUP (local signal), local file watch, or an admin call protocol operation with the same auth treatment as any other mutation (requires admin scope, ADR-015). The implementation must not ship a reload endpoint with no auth "for convenience."

ConfigError

pub enum ConfigError {
    InvalidFlag { name: String },
    KeyFileNotFound { path: String },
    BindFailed(io::Error),
    TlsConfig(io::Error),
    IncompatibleOptions,
}

Simplified from the reference implementation. Removes proxy-specific errors (now an SSH concern) and listener validation errors (no more ListenerConfig enum).

Key Differences from Reference Implementation

Aspect Reference New Model
StaticConfig fields SSH host key, stealth, transport_mode, listeners, proxy listen_addr, TLS cert/key, drain_timeout
DynamicConfig.auth HashSet<PublicKey> (russh types) HashSet<String> (fingerprint strings)
ListenerConfig Enum with Stream/Http/Dns variants Eliminated — single endpoint, ALPN dispatch
TransportMode Tcp/Tls/Iroh Eliminated — always QUIC+TLS
Stealth mode Byte-peeking HTTP/SSH detection Eliminated — ALPN handles protocol detection
ForwardingPolicy In DynamicConfig Moved to handler-specific config (SSH)

Design Decisions

Decision ADR Summary
No russh dependency in core ADR-003 Core is ALPN-agnostic; russh is an alknet-ssh dependency
ArcSwap for dynamic config Carry-forward from reference Lock-free reads, atomic swaps
No ListenerConfig ADR-001 Single endpoint, ALPN replaces multiple listener types
PeerEntry and Identity.id decoupling ADR-030 authorized_fingerprints: HashSet<String>peers: Vec<PeerEntry>; Identity.id = peer_id (stable), not fingerprint
Storage boundary and repo/adapter pattern ADR-033 Core defines repo traits + in-memory defaults; AuthPolicy.peers is the config model for the in-memory ConfigIdentityProvider adapter; persistence adapters are separate crates