feat(core): implement StaticConfig/DynamicConfig split with ArcSwap hot-reload

Split alknet-core configuration into StaticConfig (immutable after startup)
and DynamicConfig (hot-reloadable at runtime via ArcSwap).

- Add StaticConfig struct in config/static_config.rs with all fields per ADR-030
- Add DynamicConfig struct with AuthPolicy, ForwardingPolicy, RateLimitConfig
- Add ForwardingPolicy with allow_all()/deny_all() defaults (ADR-031)
- Add ConfigReloadHandle with reload() method for runtime config updates
- Replace Arc<ServerAuthConfig> with Arc<ArcSwap<DynamicConfig>> in ServerHandler
- Add config_reload_handle() to Server for obtaining reload handles
- Add AuthPolicy with authenticate_publickey/authenticate_certificate methods
- All existing tests pass with the new config structure
- Default DynamicConfig produces identical behavior to current code
This commit is contained in:
2026-06-07 14:03:46 +00:00
parent a7f0dcdeb9
commit ee1b3f3819
36 changed files with 964 additions and 393 deletions

View File

@@ -7,9 +7,9 @@ use rustls::crypto::aws_lc_rs::default_provider;
use rustls::ServerConfig;
use rustls_acme::caches::DirCache;
use rustls_acme::{AcmeConfig, AcmeState, ResolvesServerCertAcme};
use tracing::{error, info};
use tokio::net::TcpListener;
use tokio_rustls::TlsAcceptor as TokioTlsAcceptor;
use tracing::{error, info};
use super::{TransportAcceptor, TransportInfo, TransportKind};
@@ -94,14 +94,10 @@ impl AcmeCertProvider {
.contact(self.contact.clone());
let state = match &self.cache_dir {
Some(cache_dir) => {
base_config.cache(DirCache::new(cache_dir.clone())).state()
}
None => {
base_config
.cache(rustls_acme::caches::NoCache::default())
.state()
}
Some(cache_dir) => base_config.cache(DirCache::new(cache_dir.clone())).state(),
None => base_config
.cache(rustls_acme::caches::NoCache::default())
.state(),
};
let resolver = state.resolver();
@@ -132,10 +128,7 @@ pub struct AcmeTlsAcceptor {
}
impl AcmeTlsAcceptor {
pub async fn bind_acme(
addr: SocketAddr,
provider: Arc<AcmeCertProvider>,
) -> Result<Self> {
pub async fn bind_acme(addr: SocketAddr, provider: Arc<AcmeCertProvider>) -> Result<Self> {
let (state, resolver) = provider.build_acme_state();
let server_config = provider.build_server_config_with_resolver(resolver.clone())?;
@@ -193,11 +186,7 @@ impl TransportAcceptor for AcmeTlsAcceptor {
let (tcp_stream, remote_addr) = self.listener.accept().await?;
let tls_stream = self.tokio_acceptor.accept(tcp_stream).await?;
let server_name = tls_stream
.get_ref()
.1
.server_name()
.map(|s| s.to_string());
let server_name = tls_stream.get_ref().1.server_name().map(|s| s.to_string());
let info = TransportInfo {
remote_addr: Some(remote_addr),
@@ -277,8 +266,7 @@ mod tests {
#[test]
fn acme_cert_provider_build_state_with_cache() {
let provider =
AcmeCertProvider::domain("example.com").with_cache_dir("/tmp/test_cache");
let provider = AcmeCertProvider::domain("example.com").with_cache_dir("/tmp/test_cache");
let (_state, resolver) = provider.build_acme_state();
assert!(Arc::strong_count(&resolver) >= 2);
}
@@ -288,7 +276,9 @@ mod tests {
let _ = default_provider().install_default();
let provider = AcmeCertProvider::domain("example.com");
let (_, resolver) = provider.build_acme_state();
let config = provider.build_server_config_with_resolver(resolver).unwrap();
let config = provider
.build_server_config_with_resolver(resolver)
.unwrap();
assert!(!config.alpn_protocols.is_empty());
assert!(config
.alpn_protocols
@@ -359,4 +349,4 @@ mod tests {
let acceptor = result.unwrap();
assert_eq!(acceptor.listen_addr().port(), 443);
}
}
}

View File

@@ -1,9 +1,7 @@
use anyhow::{anyhow, Result};
use async_trait::async_trait;
use iroh::{
endpoint::RecvStream,
node_info::NodeIdExt,
Endpoint, NodeId, RelayMap, RelayMode, RelayUrl,
endpoint::RecvStream, node_info::NodeIdExt, Endpoint, NodeId, RelayMap, RelayMode, RelayUrl,
};
use tokio::io;
@@ -39,7 +37,9 @@ impl IrohTransport {
proxy_url: Option<url::Url>,
) -> Result<Self> {
let relay_url = relay_url.unwrap_or_else(|| {
DEFAULT_RELAY_URL.parse().expect("default relay URL is valid")
DEFAULT_RELAY_URL
.parse()
.expect("default relay URL is valid")
});
let relay_map = RelayMap::from_url(relay_url);
let mut builder = Endpoint::builder()
@@ -49,7 +49,11 @@ impl IrohTransport {
builder = builder.proxy_url(proxy.clone());
}
let endpoint = builder.bind().await?;
Ok(Self { node_id, endpoint, owned: true })
Ok(Self {
node_id,
endpoint,
owned: true,
})
}
/// Create an iroh transport using an existing shared endpoint.
@@ -60,7 +64,11 @@ impl IrohTransport {
/// other protocol handlers on the same QUIC endpoint — one connection
/// per peer, multiplexed by ALPN.
pub fn from_endpoint(node_id: NodeId, endpoint: Endpoint) -> Self {
Self { node_id, endpoint, owned: false }
Self {
node_id,
endpoint,
owned: false,
}
}
pub fn endpoint_id(&self) -> String {
@@ -115,12 +123,11 @@ impl IrohAcceptor {
/// Bind a new iroh endpoint with a dedicated `alknet-ssh` ALPN.
///
/// Use this when alknet is the only iroh service on this node.
pub async fn bind(
relay_url: Option<RelayUrl>,
proxy_url: Option<url::Url>,
) -> Result<Self> {
pub async fn bind(relay_url: Option<RelayUrl>, proxy_url: Option<url::Url>) -> Result<Self> {
let relay_url = relay_url.unwrap_or_else(|| {
DEFAULT_RELAY_URL.parse().expect("default relay URL is valid")
DEFAULT_RELAY_URL
.parse()
.expect("default relay URL is valid")
});
let relay_map = RelayMap::from_url(relay_url);
let mut builder = Endpoint::builder()
@@ -130,7 +137,10 @@ impl IrohAcceptor {
builder = builder.proxy_url(proxy.clone());
}
let endpoint = builder.bind().await?;
Ok(Self { endpoint, owned: true })
Ok(Self {
endpoint,
owned: true,
})
}
/// Create an iroh acceptor using an existing shared endpoint.
@@ -146,7 +156,10 @@ impl IrohAcceptor {
/// [`IrohAcceptor::bind`] instead, which handles the accept loop
/// internally.
pub fn from_endpoint(endpoint: Endpoint) -> Self {
Self { endpoint, owned: false }
Self {
endpoint,
owned: false,
}
}
pub fn endpoint_id(&self) -> String {
@@ -219,18 +232,14 @@ mod tests {
#[test]
fn iroh_transport_describe_format() {
let node_id: NodeId = iroh::SecretKey::generate(rand_core::OsRng)
.public()
.into();
let node_id: NodeId = iroh::SecretKey::generate(rand_core::OsRng).public().into();
let desc = format!("iroh://{}", node_id.to_z32());
assert!(desc.starts_with("iroh://"));
}
#[tokio::test]
async fn iroh_transport_connect_builds_endpoint() {
let node_id: NodeId = iroh::SecretKey::generate(rand_core::OsRng)
.public()
.into();
let node_id: NodeId = iroh::SecretKey::generate(rand_core::OsRng).public().into();
let transport = IrohTransport::new(node_id, None, None).await.unwrap();
assert!(transport.describe().starts_with("iroh://"));
assert!(!transport.endpoint_id().is_empty());
@@ -239,9 +248,7 @@ mod tests {
#[tokio::test]
async fn iroh_transport_from_endpoint() {
let node_id: NodeId = iroh::SecretKey::generate(rand_core::OsRng)
.public()
.into();
let node_id: NodeId = iroh::SecretKey::generate(rand_core::OsRng).public().into();
let acceptor = IrohAcceptor::bind(None, None).await.unwrap();
let endpoint = acceptor.endpoint.clone();
let transport = IrohTransport::from_endpoint(node_id, endpoint);
@@ -318,4 +325,4 @@ mod tests {
transport.connect().await.unwrap();
let _server_stream = accept_handle.await.unwrap();
}
}
}

View File

@@ -13,13 +13,13 @@
//! See [ADR-001](docs/architecture/decisions/001-pluggable-transport.md) and
//! [ADR-004](docs/architecture/decisions/004-ssh-over-transport.md) for design rationale.
mod tcp;
#[cfg(feature = "iroh")]
mod iroh_transport;
mod tcp;
pub use tcp::{TcpAcceptor, TcpTransport};
#[cfg(feature = "iroh")]
pub use iroh_transport::{IrohAcceptor, IrohTransport, ALPN as IROH_ALPN};
pub use tcp::{TcpAcceptor, TcpTransport};
#[cfg(feature = "tls")]
mod tls;
@@ -89,12 +89,8 @@ pub struct TransportInfo {
#[derive(Debug, Clone)]
pub enum TransportKind {
Tcp,
Tls {
server_name: Option<String>,
},
Iroh {
endpoint_id: String,
},
Tls { server_name: Option<String> },
Iroh { endpoint_id: String },
}
#[cfg(test)]
@@ -185,4 +181,4 @@ mod tests {
assert_eq!(endpoint_id, "abc123");
}
}
}
}

View File

@@ -159,4 +159,4 @@ mod tests {
.unwrap();
assert_ne!(acceptor.listen_addr().port(), 0);
}
}
}

View File

@@ -7,7 +7,9 @@ use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, Server
use rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName};
use rustls::{ClientConfig, DigitallySignedStruct, RootCertStore, ServerConfig};
use tokio::net::{TcpListener, TcpStream};
use tokio_rustls::{client::TlsStream as ClientTlsStream, TlsAcceptor as TokioTlsAcceptor, TlsConnector};
use tokio_rustls::{
client::TlsStream as ClientTlsStream, TlsAcceptor as TokioTlsAcceptor, TlsConnector,
};
#[cfg(feature = "acme")]
use rustls::crypto::aws_lc_rs::default_provider;
@@ -169,7 +171,9 @@ impl TlsAcceptor {
.map_err(|e| anyhow!("failed to set protocol versions: {}", e))?
.with_no_client_auth()
.with_cert_resolver(acme_resolver);
server_config.alpn_protocols.push(ACME_TLS_ALPN_NAME.to_vec());
server_config
.alpn_protocols
.push(ACME_TLS_ALPN_NAME.to_vec());
let server_config = Arc::new(server_config);
let tokio_acceptor = TokioTlsAcceptor::from(server_config.clone());
@@ -195,11 +199,7 @@ impl TransportAcceptor for TlsAcceptor {
let (tcp_stream, remote_addr) = self.listener.accept().await?;
let tls_stream = self.tokio_acceptor.accept(tcp_stream).await?;
let server_name = tls_stream
.get_ref()
.1
.server_name()
.map(|s| s.to_string());
let server_name = tls_stream.get_ref().1.server_name().map(|s| s.to_string());
let info = TransportInfo {
remote_addr: Some(remote_addr),
@@ -324,10 +324,7 @@ mod tests {
let (mut server, info) = accept_handle.await.unwrap();
assert!(info.remote_addr.is_some());
assert!(matches!(
info.transport_kind,
TransportKind::Tls { .. }
));
assert!(matches!(info.transport_kind, TransportKind::Tls { .. }));
client.write_all(b"hello tls").await.unwrap();
let mut buf = [0u8; 9];
@@ -429,4 +426,4 @@ mod tests {
let verifier = NoVerifier;
assert!(verifier.supported_verify_schemes().len() > 0);
}
}
}