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

@@ -0,0 +1,395 @@
use std::sync::Arc;
use arc_swap::ArcSwap;
use crate::auth::ServerAuthConfig;
pub struct AuthPolicy {
pub authorized_keys: std::collections::HashSet<russh::keys::PublicKey>,
pub cert_authorities: Vec<crate::auth::keys::CertAuthorityEntry>,
encoded_keys: std::collections::HashSet<Vec<u8>>,
}
fn encode_key_data(key: &russh::keys::PublicKey) -> Vec<u8> {
use russh::keys::helpers::EncodedExt;
key.key_data().encoded().unwrap_or_default()
}
impl AuthPolicy {
pub fn new(
authorized_keys: std::collections::HashSet<russh::keys::PublicKey>,
cert_authorities: Vec<crate::auth::keys::CertAuthorityEntry>,
) -> Self {
let encoded_keys = authorized_keys.iter().map(encode_key_data).collect();
Self {
authorized_keys,
cert_authorities,
encoded_keys,
}
}
pub fn from_server_auth_config(config: ServerAuthConfig) -> Self {
Self::new(config.authorized_keys, config.cert_authorities)
}
pub fn empty() -> Self {
Self::new(std::collections::HashSet::new(), Vec::new())
}
pub fn authenticate_publickey(
&self,
key: &russh::keys::PublicKey,
) -> Result<(), crate::error::AuthError> {
let encoded = encode_key_data(key);
if self.encoded_keys.contains(&encoded) {
return Ok(());
}
Err(crate::error::AuthError::KeyRejected)
}
pub fn authenticate_certificate(
&self,
cert: &russh::keys::Certificate,
user: &str,
client_ip: Option<std::net::IpAddr>,
) -> Result<(), crate::error::AuthError> {
use std::time::SystemTime;
let matching_ca = self
.cert_authorities
.iter()
.find(|ca| cert.signature_key() == ca.public_key.key_data());
let ca_entry = match matching_ca {
Some(entry) => entry,
None => return Err(crate::error::AuthError::CertInvalid),
};
if cert.verify_signature().is_err() {
return Err(crate::error::AuthError::CertInvalid);
}
let now = SystemTime::now();
let now_secs = now
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
if now_secs < cert.valid_after() || now_secs >= cert.valid_before() {
return Err(crate::error::AuthError::CertExpired);
}
let principals = cert.valid_principals();
if !principals.is_empty() && !principals.iter().any(|p| p == user) {
return Err(crate::error::AuthError::CertPrincipalMismatch);
}
check_critical_options(cert, ca_entry, client_ip)?;
check_extensions(cert, ca_entry)?;
Ok(())
}
}
fn check_critical_options(
cert: &russh::keys::Certificate,
ca_entry: &crate::auth::keys::CertAuthorityEntry,
client_ip: Option<std::net::IpAddr>,
) -> Result<(), crate::error::AuthError> {
let ca_has_no_pty = ca_entry.options.iter().any(|o| o == "no-pty");
for (name, data) in cert.critical_options().iter() {
match name.as_str() {
"source-address" => {
if !check_source_address(data, client_ip) {
return Err(crate::error::AuthError::CertInvalid);
}
}
"force-command" => {}
"no-pty" => {}
_ => {
let _ = ca_has_no_pty;
return Err(crate::error::AuthError::CertInvalid);
}
}
}
Ok(())
}
fn check_extensions(
cert: &russh::keys::Certificate,
ca_entry: &crate::auth::keys::CertAuthorityEntry,
) -> Result<(), crate::error::AuthError> {
let ca_permit_port_forwarding = ca_entry
.options
.iter()
.any(|o| o == "permit-port-forwarding");
if ca_permit_port_forwarding {
let cert_allows = cert
.extensions()
.iter()
.any(|(n, _)| n == "permit-port-forwarding");
if !cert_allows {
return Err(crate::error::AuthError::CertInvalid);
}
}
Ok(())
}
fn check_source_address(allowed: &str, client_ip: Option<std::net::IpAddr>) -> bool {
use ipnetwork::IpNetwork;
use std::net::IpAddr;
use std::str::FromStr;
let Some(ip) = client_ip else {
return false;
};
for pattern in allowed.split(',') {
let pattern = pattern.trim();
if pattern.is_empty() {
continue;
}
if let Ok(cidr) = IpNetwork::from_str(pattern) {
if cidr.contains(ip) {
return true;
}
}
if let Ok(net_ip) = IpAddr::from_str(pattern) {
if net_ip == ip {
return true;
}
}
}
false
}
impl std::fmt::Debug for AuthPolicy {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AuthPolicy")
.field("authorized_keys_count", &self.authorized_keys.len())
.field("cert_authorities_count", &self.cert_authorities.len())
.finish()
}
}
impl Clone for AuthPolicy {
fn clone(&self) -> Self {
Self {
authorized_keys: self.authorized_keys.clone(),
cert_authorities: self.cert_authorities.clone(),
encoded_keys: self.encoded_keys.clone(),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum ForwardingAction {
Allow,
Deny,
}
#[derive(Debug, Clone)]
pub struct ForwardingRule {
pub action: ForwardingAction,
pub principals: Vec<String>,
pub transports: Vec<crate::server::handler::TransportKind>,
}
#[derive(Debug, Clone)]
pub struct ForwardingPolicy {
pub default: ForwardingAction,
pub rules: Vec<ForwardingRule>,
}
impl ForwardingPolicy {
pub fn allow_all() -> Self {
Self {
default: ForwardingAction::Allow,
rules: Vec::new(),
}
}
pub fn deny_all() -> Self {
Self {
default: ForwardingAction::Deny,
rules: Vec::new(),
}
}
}
#[derive(Debug, Clone)]
pub struct RateLimitConfig {
pub max_connections_per_ip: usize,
pub max_auth_attempts: usize,
}
impl Default for RateLimitConfig {
fn default() -> Self {
Self {
max_connections_per_ip: 0,
max_auth_attempts: 10,
}
}
}
#[derive(Debug, Clone)]
pub struct DynamicConfig {
pub auth: AuthPolicy,
pub forwarding: ForwardingPolicy,
pub rate_limits: RateLimitConfig,
}
impl DynamicConfig {
pub fn new(auth: AuthPolicy) -> Self {
Self {
auth,
forwarding: ForwardingPolicy::allow_all(),
rate_limits: RateLimitConfig::default(),
}
}
pub fn with_forwarding_policy(mut self, policy: ForwardingPolicy) -> Self {
self.forwarding = policy;
self
}
pub fn with_rate_limits(mut self, limits: RateLimitConfig) -> Self {
self.rate_limits = limits;
self
}
}
impl Default for DynamicConfig {
fn default() -> Self {
Self {
auth: AuthPolicy::empty(),
forwarding: ForwardingPolicy::allow_all(),
rate_limits: RateLimitConfig::default(),
}
}
}
pub struct ConfigReloadHandle {
pub(crate) dynamic: Arc<ArcSwap<DynamicConfig>>,
}
impl ConfigReloadHandle {
pub fn reload(&self, new_config: DynamicConfig) {
self.dynamic.store(Arc::new(new_config));
}
pub fn dynamic(&self) -> Arc<DynamicConfig> {
self.dynamic.load_full()
}
}
impl std::fmt::Debug for ConfigReloadHandle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ConfigReloadHandle").finish()
}
}
pub fn new_dynamic_config() -> (Arc<ArcSwap<DynamicConfig>>, ConfigReloadHandle) {
let inner = Arc::new(ArcSwap::new(Arc::new(DynamicConfig::default())));
let handle = ConfigReloadHandle {
dynamic: Arc::clone(&inner),
};
(inner, handle)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn forwarding_policy_allow_all_default() {
let policy = ForwardingPolicy::allow_all();
assert_eq!(policy.default, ForwardingAction::Allow);
assert!(policy.rules.is_empty());
}
#[test]
fn forwarding_policy_deny_all() {
let policy = ForwardingPolicy::deny_all();
assert_eq!(policy.default, ForwardingAction::Deny);
assert!(policy.rules.is_empty());
}
#[test]
fn dynamic_config_default() {
let config = DynamicConfig::default();
assert_eq!(config.forwarding.default, ForwardingAction::Allow);
assert_eq!(config.rate_limits.max_connections_per_ip, 0);
assert_eq!(config.rate_limits.max_auth_attempts, 10);
}
#[test]
fn config_reload_handle_updates_dynamic() {
let (arc_swap, handle) = new_dynamic_config();
let initial = arc_swap.load();
assert_eq!(initial.forwarding.default, ForwardingAction::Allow);
let new_config = DynamicConfig {
auth: AuthPolicy::empty(),
forwarding: ForwardingPolicy::deny_all(),
rate_limits: RateLimitConfig::default(),
};
handle.reload(new_config);
let updated = arc_swap.load();
assert_eq!(updated.forwarding.default, ForwardingAction::Deny);
}
#[test]
fn dynamic_config_with_forwarding_policy_builder() {
let config = DynamicConfig::new(AuthPolicy::empty())
.with_forwarding_policy(ForwardingPolicy::deny_all());
assert_eq!(config.forwarding.default, ForwardingAction::Deny);
}
#[test]
fn rate_limit_config_custom() {
let limits = RateLimitConfig {
max_connections_per_ip: 5,
max_auth_attempts: 3,
};
assert_eq!(limits.max_connections_per_ip, 5);
assert_eq!(limits.max_auth_attempts, 3);
}
#[test]
fn forwarding_action_equality() {
assert_eq!(ForwardingAction::Allow, ForwardingAction::Allow);
assert_eq!(ForwardingAction::Deny, ForwardingAction::Deny);
assert_ne!(ForwardingAction::Allow, ForwardingAction::Deny);
}
#[test]
fn auth_policy_empty_rejects_all() {
let policy = AuthPolicy::empty();
let key_text = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHeLC1lWiCYrXsf/85O/pkbUFZ6OGIt49PX3nw8iRoXE other@host";
let other_ssh_key =
russh::keys::parse_public_key_base64(key_text.split_whitespace().nth(1).unwrap())
.unwrap();
assert_eq!(
policy.authenticate_publickey(&other_ssh_key),
Err(crate::error::AuthError::KeyRejected)
);
}
#[test]
fn auth_policy_debug_redacts_keys() {
let policy = AuthPolicy::empty();
let debug_str = format!("{:?}", policy);
assert!(debug_str.contains("authorized_keys_count"));
assert!(debug_str.contains("cert_authorities_count"));
}
}