From 3fe427bc9966504354e65194c0dac5774ee36557 Mon Sep 17 00:00:00 2001 From: KKRainbow <443152178@qq.com> Date: Sat, 22 Aug 2026 16:30:51 +0800 Subject: [PATCH] feat(credentials): manage declarative credentials through TOML (#2515) * feat(credentials): manage declarative credentials through TOML Make managed credentials part of the canonical TOML configuration and load them before peers can authenticate. Reuse ConfigRpc hot patches to durably replace the configured credential set without restarting the instance. Serialize credential mutations so base, managed, and ephemeral keys cannot race into conflicts. Remove the managed overlay file format, digest protocol, capability negotiation, force reconciliation, and database CAS machinery. Redact credential secrets from debug output and management events. Write credential-bearing files atomically with private permissions. * fix(core): release JoinSet reapers with their owners Pass weak task-set references into background reapers so they cannot retain the JoinSet they are meant to collect. This lets stale smoltcp bridge tasks terminate when an IPv4 generation is replaced. Add ownership and TCP generation-replacement regressions covering the production port-forward failure. --- .github/workflows/test.yml | 7 + easytier-core/src/config/api.rs | 48 ++ easytier-core/src/config/api_input.rs | 53 +- easytier-core/src/config/toml.rs | 85 +++ .../src/connectivity/hole_punch/tcp.rs | 2 +- .../hole_punch/udp/socket_array.rs | 5 +- easytier-core/src/foundation/task.rs | 19 +- easytier-core/src/foundation/token_bucket.rs | 6 +- easytier-core/src/gateway/dataplane/mod.rs | 2 +- easytier-core/src/gateway/dataplane/stack.rs | 2 +- easytier-core/src/gateway/dataplane/tests.rs | 47 ++ easytier-core/src/gateway/port_forward.rs | 10 +- easytier-core/src/gateway/socks5/adapter.rs | 2 +- .../src/gateway/vpn_portal/runtime.rs | 1 + easytier-core/src/instance/config.rs | 29 + easytier-core/src/instance/management.rs | 18 +- easytier-core/src/instance/manager.rs | 12 +- easytier-core/src/instance/mod.rs | 3 + easytier-core/src/instance/tests.rs | 169 +++++ easytier-core/src/management/full/compiled.rs | 4 +- .../src/management/full/config_patch.rs | 125 +++- easytier-core/src/management/full/mod.rs | 9 +- .../src/management/full/process_rpc.rs | 2 + .../src/management/instance_rpc/config.rs | 2 +- .../src/management/instance_rpc/mod.rs | 206 +++++- easytier-core/src/management/mod.rs | 6 +- easytier-core/src/peers/attached.rs | 21 +- .../src/peers/conn/peer_conn_ping.rs | 6 +- easytier-core/src/peers/credential_manager.rs | 689 +++++++++++++----- .../src/peers/foreign_network/mod.rs | 2 +- easytier-core/src/peers/peer_manager.rs | 9 + easytier-core/src/rpc/server.rs | 2 +- easytier-core/src/wasi/web_client.rs | 9 +- easytier-proto/build/main.rs | 1 + easytier-proto/proto/api_config.proto | 1 + easytier-proto/proto/api_manage.proto | 15 + easytier-proto/src/api.rs | 28 + .../src/client_manager/runtime_reconcile.rs | 40 +- easytier-web/src/db/mod.rs | 32 + easytier/src/common/credential_manager.rs | 43 +- easytier/src/instance/config_storage.rs | 44 +- easytier/src/instance/test_instance.rs | 2 +- easytier/src/tests/credential_tests.rs | 38 +- easytier/src/tests/three_node.rs | 3 + 44 files changed, 1600 insertions(+), 259 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8c8417a0..a6713565 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -54,6 +54,7 @@ jobs: - uses: actions-rust-lang/setup-rust-toolchain@v1 with: components: rustfmt,clippy + target: wasm32-wasip1 rustflags: '' - uses: taiki-e/install-action@cargo-hack @@ -70,6 +71,12 @@ jobs: if: ${{ !cancelled() }} run: cargo hack check --package easytier --each-feature --exclude-features macos-ne --verbose + - name: Check WASI + if: ${{ !cancelled() }} + run: >- + cargo check --package easytier-core --lib --target wasm32-wasip1 + --features management-rpc,proxy-smoltcp-stack,ring-crypto,wasi-crypto-offload + - name: Check Cargo.lock is up to date if: ${{ !cancelled() }} run: | diff --git a/easytier-core/src/config/api.rs b/easytier-core/src/config/api.rs index c7411db0..aed8330b 100644 --- a/easytier-core/src/config/api.rs +++ b/easytier-core/src/config/api.rs @@ -118,6 +118,19 @@ pub fn network_config_from_toml(config: &TomlConfig) -> NetworkConfig { result.credential_file = config .get_credential_file() .map(|path| path.to_string_lossy().into_owned()); + result.managed_credentials = config + .get_managed_credentials() + .into_iter() + .map(|credential| manage::ManagedCredentialConfig { + credential_id: credential.credential_id, + credential_secret: credential.credential_secret, + groups: credential.groups, + allow_relay: credential.allow_relay, + allowed_proxy_cidrs: credential.allowed_proxy_cidrs, + expiry_unix: credential.expiry_unix, + reusable: Some(credential.reusable), + }) + .collect(); let flags = config.get_flags(); let default_flags = default_config.get_flags(); @@ -172,3 +185,38 @@ pub fn network_config_from_toml(config: &TomlConfig) -> NetworkConfig { result } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::toml::ManagedCredentialConfig; + + #[test] + fn includes_managed_credentials() { + let config = TomlConfig::default(); + config.set_managed_credentials(vec![ManagedCredentialConfig { + credential_id: "managed-a".to_owned(), + credential_secret: "credential-secret".to_owned(), + groups: vec!["ops".to_owned()], + allow_relay: true, + allowed_proxy_cidrs: vec!["10.0.0.0/24".to_owned()], + expiry_unix: 2_000_000_000, + reusable: false, + }]); + + let projected = network_config_from_toml(&config); + + assert_eq!( + projected.managed_credentials, + vec![manage::ManagedCredentialConfig { + credential_id: "managed-a".to_owned(), + credential_secret: "credential-secret".to_owned(), + groups: vec!["ops".to_owned()], + allow_relay: true, + allowed_proxy_cidrs: vec!["10.0.0.0/24".to_owned()], + expiry_unix: 2_000_000_000, + reusable: Some(false), + }] + ); + } +} diff --git a/easytier-core/src/config/api_input.rs b/easytier-core/src/config/api_input.rs index 489fce07..15863520 100644 --- a/easytier-core/src/config/api_input.rs +++ b/easytier-core/src/config/api_input.rs @@ -8,8 +8,8 @@ use easytier_proto::api::manage; use crate::config::{ MappedListenerPolicy, normalize_secure_mode_config, toml::{ - ConfigLoader, NetworkIdentity, PeerConfig, PortForwardConfig, TomlConfigLoader, - VpnPortalClientConfig, VpnPortalConfig, gen_default_flags, + ConfigLoader, ManagedCredentialConfig, NetworkIdentity, PeerConfig, PortForwardConfig, + TomlConfigLoader, VpnPortalClientConfig, VpnPortalConfig, gen_default_flags, }, }; @@ -298,6 +298,21 @@ impl NetworkConfigExt for NetworkConfig { cfg.set_credential_file(Some(credential_file.into())); } + cfg.set_managed_credentials( + self.managed_credentials + .iter() + .map(|credential| ManagedCredentialConfig { + credential_id: credential.credential_id.clone(), + credential_secret: credential.credential_secret.clone(), + groups: credential.groups.clone(), + allow_relay: credential.allow_relay, + allowed_proxy_cidrs: credential.allowed_proxy_cidrs.clone(), + expiry_unix: credential.expiry_unix, + reusable: credential.reusable.unwrap_or(true), + }) + .collect(), + ); + if let Some(credential_secret) = credential_secret { cfg.set_secure_mode(Some(normalize_secure_mode_config( easytier_proto::common::SecureModeConfig { @@ -606,6 +621,19 @@ impl NetworkConfigExt for NetworkConfig { result.credential_file = config .get_credential_file() .map(|path| path.to_string_lossy().into_owned()); + result.managed_credentials = config + .get_managed_credentials() + .into_iter() + .map(|credential| manage::ManagedCredentialConfig { + credential_id: credential.credential_id, + credential_secret: credential.credential_secret, + groups: credential.groups, + allow_relay: credential.allow_relay, + allowed_proxy_cidrs: credential.allowed_proxy_cidrs, + expiry_unix: credential.expiry_unix, + reusable: Some(credential.reusable), + }) + .collect(); let flags = config.get_flags(); let default_flags = default_config.get_flags(); result.latency_first = Some(flags.latency_first); @@ -714,6 +742,27 @@ mod tests { assert_eq!(output.enable_vpn_portal, None); } + #[test] + fn managed_credentials_round_trip_through_toml_model() { + let input = NetworkConfig { + managed_credentials: vec![manage::ManagedCredentialConfig { + credential_id: "managed-a".to_owned(), + credential_secret: "secret".to_owned(), + groups: vec!["ops".to_owned()], + allow_relay: true, + allowed_proxy_cidrs: vec!["10.0.0.0/24".to_owned()], + expiry_unix: 2_000_000_000, + reusable: None, + }], + ..standalone_config() + }; + + let config = input.gen_config().unwrap(); + let output = NetworkConfig::new_from_config(&config).unwrap(); + assert_eq!(output.managed_credentials[0].credential_id, "managed-a"); + assert_eq!(output.managed_credentials[0].reusable, Some(true)); + } + #[test] fn legacy_enabled_vpn_portal_config_reports_migration_error() { let error = NetworkConfig { diff --git a/easytier-core/src/config/toml.rs b/easytier-core/src/config/toml.rs index ef8ca432..c56ed849 100644 --- a/easytier-core/src/config/toml.rs +++ b/easytier-core/src/config/toml.rs @@ -270,6 +270,11 @@ pub trait ConfigLoader: Send + Sync { } fn set_credential_file(&self, _path: Option) {} + fn get_managed_credentials(&self) -> Vec { + Vec::new() + } + fn set_managed_credentials(&self, _credentials: Vec) {} + fn get_network_config_source(&self) -> ConfigSource { ConfigSource::User } @@ -471,6 +476,41 @@ pub struct VpnPortalClientConfig { pub groups: Vec, } +fn default_true() -> bool { + true +} + +#[derive(Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct ManagedCredentialConfig { + pub credential_id: String, + pub credential_secret: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub groups: Vec, + #[serde(default)] + pub allow_relay: bool, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub allowed_proxy_cidrs: Vec, + pub expiry_unix: i64, + #[serde(default = "default_true")] + pub reusable: bool, +} + +impl std::fmt::Debug for ManagedCredentialConfig { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ManagedCredentialConfig") + .field("credential_id", &self.credential_id) + .field("credential_secret", &"") + .field("groups", &self.groups) + .field("allow_relay", &self.allow_relay) + .field("allowed_proxy_cidrs", &self.allowed_proxy_cidrs) + .field("expiry_unix", &self.expiry_unix) + .field("reusable", &self.reusable) + .finish() + } +} + #[derive(Debug, Clone, PartialEq, Deserialize)] #[cfg_attr(feature = "config-write", derive(Serialize))] struct Config { @@ -516,6 +556,8 @@ struct Config { stun_servers_v6: Option>, credential_file: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + managed_credentials: Vec, source: Option, } @@ -628,6 +670,11 @@ impl TomlConfig { } } } + for credential in &mut config.managed_credentials { + if !credential.credential_secret.is_empty() { + credential.credential_secret = REDACTED.to_owned(); + } + } } pub fn new_from_str(config_str: &str) -> Result { @@ -1088,6 +1135,14 @@ impl ConfigLoader for TomlConfig { self.config.lock().unwrap().credential_file = path; } + fn get_managed_credentials(&self) -> Vec { + self.config.lock().unwrap().managed_credentials.clone() + } + + fn set_managed_credentials(&self, credentials: Vec) { + self.config.lock().unwrap().managed_credentials = credentials; + } + fn get_network_config_source(&self) -> ConfigSource { self.config .lock() @@ -1254,6 +1309,36 @@ group_secret = "group-secret" assert_eq!(redacted.matches("").count(), 4); } + #[cfg(feature = "config-write")] + #[test] + fn managed_credentials_round_trip_and_redact_secret() { + let config = TomlConfig::new_from_str( + r#" +[[managed_credentials]] +credential_id = "managed-a" +credential_secret = "private-key-material" +groups = ["ops"] +allow_relay = true +allowed_proxy_cidrs = ["10.0.0.0/24"] +expiry_unix = 2000000000 +"#, + ) + .unwrap(); + + let dumped = config.dump(); + let restored = TomlConfig::new_from_str(&dumped).unwrap(); + assert_eq!( + restored.get_managed_credentials(), + config.get_managed_credentials() + ); + assert!(dumped.contains("private-key-material")); + + let redacted = config.dump_redacted(); + assert!(!redacted.contains("private-key-material")); + assert!(redacted.contains("")); + assert!(!TomlConfig::default().dump().contains("managed_credentials")); + } + #[test] fn hostname_normalization_is_portable_and_has_no_host_fallback() { let absent = TomlConfig::default(); diff --git a/easytier-core/src/connectivity/hole_punch/tcp.rs b/easytier-core/src/connectivity/hole_punch/tcp.rs index dd3c6d5b..2aee0718 100644 --- a/easytier-core/src/connectivity/hole_punch/tcp.rs +++ b/easytier-core/src/connectivity/hole_punch/tcp.rs @@ -474,7 +474,7 @@ where self.stopping.store(false, Ordering::Release); } reaper.replace(AbortOnDropHandle::new(tokio::spawn( - reap_joinset_background(self.tasks.clone(), "tcp hole punch"), + reap_joinset_background(Arc::downgrade(&self.tasks), "tcp hole punch"), ))); } diff --git a/easytier-core/src/connectivity/hole_punch/udp/socket_array.rs b/easytier-core/src/connectivity/hole_punch/udp/socket_array.rs index 00a5ef5f..7d07c8c2 100644 --- a/easytier-core/src/connectivity/hole_punch/udp/socket_array.rs +++ b/easytier-core/src/connectivity/hole_punch/udp/socket_array.rs @@ -56,7 +56,10 @@ where socket_context: SocketContext, ) -> Self { let tasks = Arc::new(Mutex::new(JoinSet::new())); - tokio::spawn(reap_joinset_background(tasks.clone(), "UdpSocketArray")); + tokio::spawn(reap_joinset_background( + Arc::downgrade(&tasks), + "UdpSocketArray", + )); Self { sockets: Arc::new(DashMap::new()), diff --git a/easytier-core/src/foundation/task.rs b/easytier-core/src/foundation/task.rs index eb950e73..22672b23 100644 --- a/easytier-core/src/foundation/task.rs +++ b/easytier-core/src/foundation/task.rs @@ -1,6 +1,6 @@ use std::{ result::Result, - sync::{Arc, Mutex, atomic::Ordering}, + sync::{Arc, Mutex, Weak, atomic::Ordering}, time::Duration, }; @@ -15,11 +15,10 @@ use tokio::{ }; use tokio_util::task::AbortOnDropHandle; -pub(crate) async fn reap_joinset_background(tasks: Arc>>, origin: &'static str) +pub(crate) async fn reap_joinset_background(tasks: Weak>>, origin: &'static str) where T: Send + 'static, { - let tasks = Arc::downgrade(&tasks); loop { crate::foundation::time::sleep(Duration::from_secs(1)).await; let Some(tasks) = tasks.upgrade() else { @@ -282,6 +281,20 @@ mod tests { } } + #[tokio::test] + async fn joinset_reaper_does_not_keep_task_set_alive() { + let tasks = Arc::new(Mutex::new(JoinSet::new())); + let weak_tasks = Arc::downgrade(&tasks); + tasks + .lock() + .unwrap() + .spawn(reap_joinset_background(weak_tasks.clone(), "test")); + + drop(tasks); + + assert!(weak_tasks.upgrade().is_none()); + } + #[tokio::test] async fn peer_task_manager_is_cold_and_joins_children_on_stop() { let active_tasks = Arc::new(AtomicUsize::new(0)); diff --git a/easytier-core/src/foundation/token_bucket.rs b/easytier-core/src/foundation/token_bucket.rs index e8bffae2..583aa566 100644 --- a/easytier-core/src/foundation/token_bucket.rs +++ b/easytier-core/src/foundation/token_bucket.rs @@ -433,7 +433,11 @@ mod tests { } /// Test high load with concurrent access - #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + #[cfg_attr( + not(target_os = "wasi"), + tokio::test(flavor = "multi_thread", worker_threads = 4) + )] + #[cfg_attr(target_os = "wasi", tokio::test)] async fn test_concurrent_access() { let bucket = TokenBucket::new(10_000, 1); let mut handles = vec![]; diff --git a/easytier-core/src/gateway/dataplane/mod.rs b/easytier-core/src/gateway/dataplane/mod.rs index 91709b7a..81597af1 100644 --- a/easytier-core/src/gateway/dataplane/mod.rs +++ b/easytier-core/src/gateway/dataplane/mod.rs @@ -506,7 +506,7 @@ where .lock() .unwrap() .spawn(reap_joinset_background( - self.runtime_tasks.clone(), + Arc::downgrade(&self.runtime_tasks), "data plane runtime", )); self.run_net_update_task().await; diff --git a/easytier-core/src/gateway/dataplane/stack.rs b/easytier-core/src/gateway/dataplane/stack.rs index 5f3aea6a..19b664cb 100644 --- a/easytier-core/src/gateway/dataplane/stack.rs +++ b/easytier-core/src/gateway/dataplane/stack.rs @@ -105,7 +105,7 @@ impl SmoltcpPlane { let forward_tasks = Arc::new(std::sync::Mutex::new(forward_tasks)); forward_tasks.lock().unwrap().spawn(reap_joinset_background( - forward_tasks.clone(), + Arc::downgrade(&forward_tasks), "SmoltcpPlane", )); diff --git a/easytier-core/src/gateway/dataplane/tests.rs b/easytier-core/src/gateway/dataplane/tests.rs index fabea048..83b2c39f 100644 --- a/easytier-core/src/gateway/dataplane/tests.rs +++ b/easytier-core/src/gateway/dataplane/tests.rs @@ -653,6 +653,53 @@ async fn immediate_consumer_reacquire_never_leases_closing_generation() { endpoint.peer_manager.clear_resources().await; } +#[tokio::test] +async fn tcp_connect_survives_ipv4_generation_replacement() { + let (a, b) = setup_data_plane_pair().await; + let _consumer = b.gateway.acquire_consumer_lease().unwrap(); + + for ip in ["10.126.127.2", "10.126.126.2"] { + let ip: IpAddr = ip.parse().unwrap(); + b.gateway.runtime_config.update_peer_with(|peer| { + peer.runtime.core.routes.ipv4 = Some(IpPrefix::new(ip, 24).unwrap()); + }); + tokio::time::timeout(Duration::from_secs(1), async { + loop { + if b.gateway + .net + .lock() + .await + .as_ref() + .is_some_and(|plane| IpAddr::V4(plane.ipv4_addr.address()) == ip) + { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("data-plane IPv4 generation did not update"); + } + + let timeout = Duration::from_secs(10); + let mut listener = b.gateway.data_plane_tcp_bind(0, timeout).await.unwrap(); + let listen_addr = SocketAddr::new(b.ip.address().into(), listener.local_addr().port()); + let (accepted, client) = tokio::join!( + listener.accept(), + a.gateway.data_plane_tcp_connect(listen_addr, timeout), + ); + let (mut server, _) = accepted.unwrap(); + let mut client = client.unwrap(); + + client.write_all(b"ping").await.unwrap(); + client.flush().await.unwrap(); + let mut buf = [0u8; 4]; + server.read_exact(&mut buf).await.unwrap(); + assert_eq!(&buf, b"ping"); + + stop_data_plane_pair(&a, &b).await; +} + #[tokio::test] async fn ipv4_change_closes_existing_generation_with_typed_error() { let host = Arc::new(TestHost::default()); diff --git a/easytier-core/src/gateway/port_forward.rs b/easytier-core/src/gateway/port_forward.rs index 84bb9840..c373b36f 100644 --- a/easytier-core/src/gateway/port_forward.rs +++ b/easytier-core/src/gateway/port_forward.rs @@ -148,7 +148,7 @@ where return Ok(()); } self.tasks.lock().unwrap().spawn(reap_joinset_background( - self.tasks.clone(), + Arc::downgrade(&self.tasks), "port-forward adapter", )); self.start_udp_reaper(); @@ -246,7 +246,7 @@ where let data_plane = self.data_plane.clone(); let connections = Arc::new(std::sync::Mutex::new(JoinSet::new())); connections.lock().unwrap().spawn(reap_joinset_background( - connections.clone(), + Arc::downgrade(&connections), "TCP port-forward connections", )); self.tasks.lock().unwrap().spawn(async move { @@ -624,7 +624,11 @@ mod tests { assert_eq!(slots.available_permits(), 2); } - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + #[cfg_attr( + not(target_os = "wasi"), + tokio::test(flavor = "multi_thread", worker_threads = 2) + )] + #[cfg_attr(target_os = "wasi", tokio::test)] async fn udp_client_admission_covers_client_and_response_task_publication() { let slots = Arc::new(Semaphore::new(1)); let admission = Arc::new(Mutex::new(())); diff --git a/easytier-core/src/gateway/socks5/adapter.rs b/easytier-core/src/gateway/socks5/adapter.rs index 9b578a0b..1868f387 100644 --- a/easytier-core/src/gateway/socks5/adapter.rs +++ b/easytier-core/src/gateway/socks5/adapter.rs @@ -159,7 +159,7 @@ where let consumer_lease = self.data_plane.acquire_consumer_lease()?; self.tasks.lock().unwrap().spawn(reap_joinset_background( - self.tasks.clone(), + Arc::downgrade(&self.tasks), "SOCKS5 gateway adapter", )); let data_plane = self.data_plane.clone(); diff --git a/easytier-core/src/gateway/vpn_portal/runtime.rs b/easytier-core/src/gateway/vpn_portal/runtime.rs index 083c9ace..ac3ec58d 100644 --- a/easytier-core/src/gateway/vpn_portal/runtime.rs +++ b/easytier-core/src/gateway/vpn_portal/runtime.rs @@ -1144,6 +1144,7 @@ mod tests { let peer = Arc::new( PeerManagerCore::new( portable, + Vec::new(), store.clone(), Arc::new(()), packet_sender, diff --git a/easytier-core/src/instance/config.rs b/easytier-core/src/instance/config.rs index 4d762198..3e5fa9fe 100644 --- a/easytier-core/src/instance/config.rs +++ b/easytier-core/src/instance/config.rs @@ -173,6 +173,12 @@ impl CoreInstanceConfig { let flags = host.runtime_flags(config.get_flags()); let instance_id = config.get_id(); let identity: crate::config::NetworkIdentity = config.get_network_identity().into(); + let managed_credentials = config.get_managed_credentials(); + if !managed_credentials.is_empty() && identity.network_secret.is_none() { + anyhow::bail!( + "only admin nodes with a network_secret can configure managed credentials" + ); + } let network_name = identity.network_name.clone(); let socket_context = SocketContext::default() .with_socket_mark(flags.socket_mark) @@ -325,6 +331,7 @@ impl CoreInstanceConfig { Ok(Self { instance_name: config.get_inst_name(), peer, + managed_credentials, vpn_portal: (!host.ignore_unsupported_config || host.vpn_portal_enabled) .then(|| config.get_vpn_portal_config()) .flatten() @@ -391,6 +398,8 @@ impl CoreInstanceConfig { #[cfg(test)] mod tests { + use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; + use super::*; #[test] @@ -456,6 +465,26 @@ stun_servers_v6 = ["custom-v6.example.com:3478"] ); } + #[test] + fn credential_nodes_cannot_declare_managed_credentials() { + let config = TomlConfig::default(); + config.set_network_identity(crate::config::toml::NetworkIdentity::new_credential( + "credential-network".to_owned(), + )); + config.set_managed_credentials(vec![crate::config::toml::ManagedCredentialConfig { + credential_id: "managed".to_owned(), + credential_secret: BASE64_STANDARD.encode([1u8; 32]), + groups: Vec::new(), + allow_relay: false, + allowed_proxy_cidrs: Vec::new(), + expiry_unix: 2_000_000_000, + reusable: true, + }]); + + let error = CoreInstanceConfig::from_toml(&config).unwrap_err(); + assert!(error.to_string().contains("only admin nodes")); + } + #[cfg(feature = "config-write")] #[test] fn explicit_stun_servers_survive_dump_reload() { diff --git a/easytier-core/src/instance/management.rs b/easytier-core/src/instance/management.rs index 168fb7f8..ec26474d 100644 --- a/easytier-core/src/instance/management.rs +++ b/easytier-core/src/instance/management.rs @@ -164,7 +164,8 @@ where options.ttl, options.credential_id, options.reusable, - ); + ) + .map_err(anyhow::Error::msg)?; self.peer_manager.notify_credential_changed(); Ok(generated) } @@ -176,7 +177,8 @@ where let revoked = self .peer_manager .credential_manager() - .revoke_credential(credential_id); + .revoke_credential(credential_id) + .map_err(anyhow::Error::msg)?; if revoked { self.peer_manager.notify_credential_changed(); } @@ -202,6 +204,18 @@ where self.peer_manager.credential_manager().list_credentials() } + #[cfg(feature = "web-client")] + pub(crate) fn credential_manager( + &self, + ) -> Arc { + self.peer_manager.credential_manager() + } + + #[cfg(feature = "web-client")] + pub(crate) fn notify_credential_changed(&self) { + self.peer_manager.notify_credential_changed(); + } + pub fn metric_snapshots(&self) -> Vec { self.peer_manager.stats_manager().get_all_metrics() } diff --git a/easytier-core/src/instance/manager.rs b/easytier-core/src/instance/manager.rs index 7e7935e8..fa4b6ab9 100644 --- a/easytier-core/src/instance/manager.rs +++ b/easytier-core/src/instance/manager.rs @@ -291,6 +291,12 @@ impl InstanceManager { .remove(&instance_id) } + pub fn config_control(&self, instance_id: Uuid) -> Option { + self.config_controls + .get(&instance_id) + .map(|control| control.clone()) + } + pub fn mutation_lock(&self) -> Arc> { self.mutation_lock.clone() } @@ -405,12 +411,6 @@ where self.list() } - pub fn config_control(&self, instance_id: Uuid) -> Option { - self.config_controls - .get(&instance_id) - .map(|control| control.clone()) - } - pub fn attach_tun_fd(&self, instance_id: Uuid, fd: i32) -> anyhow::Result<()> { self.get(instance_id) .ok_or_else(|| anyhow::anyhow!("instance {instance_id} not found"))? diff --git a/easytier-core/src/instance/mod.rs b/easytier-core/src/instance/mod.rs index b7b6b60c..c208446f 100644 --- a/easytier-core/src/instance/mod.rs +++ b/easytier-core/src/instance/mod.rs @@ -184,6 +184,8 @@ pub struct CoreInstanceConfig { pub connectivity: CoreConnectivityConfig, #[serde(default, skip_serializing_if = "Option::is_none")] pub vpn_portal: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub managed_credentials: Vec, } #[cfg(any(test, feature = "test-utils"))] @@ -503,6 +505,7 @@ where )); let peer_manager = Arc::new(PeerManagerCore::new( config.peer, + config.managed_credentials, runtime_config.clone(), Arc::new(CoreStunPeerInfoSource(peer_stun)), packet_tx, diff --git a/easytier-core/src/instance/tests.rs b/easytier-core/src/instance/tests.rs index 881a71c4..4fee8754 100644 --- a/easytier-core/src/instance/tests.rs +++ b/easytier-core/src/instance/tests.rs @@ -174,6 +174,7 @@ fn core_instance_config_round_trips_as_normalized_json() { peer, connectivity: CoreConnectivityConfig::default(), vpn_portal: None, + managed_credentials: Vec::new(), }; let mut config = config; @@ -328,6 +329,7 @@ mod portable_runtime { peer, connectivity, vpn_portal: None, + managed_credentials: Vec::new(), } } #[cfg(feature = "vpn-portal")] @@ -452,6 +454,28 @@ mod portable_runtime { fn build_instance(config: CoreInstanceConfig) -> anyhow::Result>> { build_with_engines(config, WrappedTransportEngines::default()) } + + #[cfg(feature = "management")] + struct RecordingConfigPatchPersistence { + writes: std::sync::Mutex>, + fail: AtomicBool, + } + + #[cfg(feature = "management")] + #[async_trait] + impl crate::management::ConfigPatchPersistence for RecordingConfigPatchPersistence { + async fn persist( + &self, + _instance_id: uuid::Uuid, + config: &TomlConfig, + ) -> anyhow::Result<()> { + if self.fail.load(Ordering::Relaxed) { + anyhow::bail!("injected config persistence failure"); + } + self.writes.lock().unwrap().push(config.dump()); + Ok(()) + } + } #[cfg(feature = "vpn-portal")] #[tokio::test] async fn runtime_update_rejects_portal_client_address_conflict() { @@ -613,12 +637,14 @@ mod portable_runtime { instance::manager::{InstanceFactory, InstanceManager}, management::InstanceManagementRpc, }; + use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; use easytier_proto::{ api::config::{ConfigRpc, GetConfigRequest, InstanceConfigPatch, PatchConfigRequest}, api::instance::{ PeerManageRpc, ShowNodeInfoRequest, instance_identifier::{InstanceSelector, Selector}, }, + api::manage::{ManagedCredentialConfig, ManagedCredentialSet}, rpc_types::controller::BaseController, }; @@ -648,6 +674,10 @@ mod portable_runtime { r#" instance_name = "managed-by-name" hostname = "core-owned-config" + +[network_identity] +network_name = "managed-network" +network_secret = "network-secret" "#, ) .unwrap(); @@ -703,6 +733,46 @@ hostname = "core-owned-config" response.config.unwrap().hostname.as_deref(), Some("patched-in-core") ); + + let secret = BASE64_STANDARD.encode([9u8; 32]); + rpc.patch_config( + BaseController::default(), + PatchConfigRequest { + patch: Some(InstanceConfigPatch { + managed_credentials: Some(ManagedCredentialSet { + entries: vec![ManagedCredentialConfig { + credential_id: "pathless".to_owned(), + credential_secret: secret.clone(), + expiry_unix: i64::MAX, + ..Default::default() + }], + }), + ..Default::default() + }), + instance: Some(selector()), + }, + ) + .await + .unwrap(); + let response = rpc + .get_config( + BaseController::default(), + GetConfigRequest { + instance: Some(selector()), + }, + ) + .await + .unwrap(); + assert_eq!( + response.config.unwrap().managed_credentials, + vec![ManagedCredentialConfig { + credential_id: "pathless".to_owned(), + credential_secret: secret, + expiry_unix: i64::MAX, + reusable: Some(true), + ..Default::default() + }] + ); let runtime = instance.runtime_config.snapshot(); assert!(runtime.services.proxy.enable_exit_node); assert!(runtime.services.public_ipv6_provider.provider_supported); @@ -720,6 +790,7 @@ hostname = "core-owned-config" hostname: Some("too-early".to_owned()), ..Default::default() }, + None, ) .await .unwrap_err(); @@ -727,6 +798,101 @@ hostname = "core-owned-config" assert!(error.to_string().contains("instance is not ready")); } + #[cfg(feature = "management")] + #[tokio::test] + async fn managed_credential_patch_is_durable_atomic_and_does_not_restart() { + use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; + use easytier_proto::api::{ + config::InstanceConfigPatch, + manage::{ManagedCredentialConfig, ManagedCredentialSet}, + }; + + let (packet_sink, _packet_receiver) = tokio::sync::mpsc::channel(16); + let config = TomlConfig::new_from_str( + r#" +[network_identity] +network_name = "managed-network" +network_secret = "network-secret" + +[source] +source = "web" +"#, + ) + .unwrap(); + let instance = + CoreInstance::from_toml(config, adapters(None, Arc::new(packet_sink))).unwrap(); + instance.start().await.unwrap(); + let peer_id = instance.peer_id(); + let secret = BASE64_STANDARD.encode([7u8; 32]); + let patch = InstanceConfigPatch { + managed_credentials: Some(ManagedCredentialSet { + entries: vec![ManagedCredentialConfig { + credential_id: "managed".to_owned(), + credential_secret: secret.clone(), + groups: vec!["ops".to_owned()], + allow_relay: false, + allowed_proxy_cidrs: Vec::new(), + expiry_unix: 2_000_000_000, + reusable: Some(true), + }], + }), + ..Default::default() + }; + let persistence = RecordingConfigPatchPersistence { + writes: std::sync::Mutex::new(Vec::new()), + fail: AtomicBool::new(true), + }; + + let error = + crate::management::apply_config_patch(&instance, patch.clone(), Some(&persistence)) + .await + .unwrap_err(); + assert!( + error + .to_string() + .contains("injected config persistence failure") + ); + assert!( + instance + .toml_config() + .unwrap() + .get_managed_credentials() + .is_empty() + ); + let private_bytes: [u8; 32] = BASE64_STANDARD.decode(&secret).unwrap().try_into().unwrap(); + let public_key = + x25519_dalek::PublicKey::from(&x25519_dalek::StaticSecret::from(private_bytes)); + assert!( + !instance + .credential_manager() + .is_pubkey_trusted(public_key.as_bytes()) + ); + + persistence.fail.store(false, Ordering::Relaxed); + crate::management::apply_config_patch(&instance, patch, Some(&persistence)) + .await + .unwrap(); + + assert_eq!(instance.peer_id(), peer_id); + assert_eq!(instance.state(), CoreInstanceState::Running); + assert!( + instance + .credential_manager() + .is_pubkey_trusted(public_key.as_bytes()) + ); + assert_eq!( + instance + .toml_config() + .unwrap() + .get_managed_credentials() + .len(), + 1 + ); + let persisted = persistence.writes.lock().unwrap(); + assert_eq!(persisted.len(), 1); + assert!(persisted[0].contains(&secret)); + } + #[cfg(all(feature = "management", not(feature = "proxy-smoltcp-stack")))] #[tokio::test] async fn unavailable_gateway_patch_does_not_commit_shared_toml() { @@ -769,6 +935,7 @@ hostname = "core-owned-config" }], ..Default::default() }, + None, ) .await .unwrap_err(); @@ -833,6 +1000,7 @@ virtual_ip = "10.82.0.2" ipv4: Some("10.82.0.2/24".parse::().unwrap().into()), ..Default::default() }, + None, ) .await .unwrap_err(); @@ -909,6 +1077,7 @@ virtual_ip = "10.82.0.2" }], ..Default::default() }, + None, ) .await .unwrap(); diff --git a/easytier-core/src/management/full/compiled.rs b/easytier-core/src/management/full/compiled.rs index 6e131c43..0852fb85 100644 --- a/easytier-core/src/management/full/compiled.rs +++ b/easytier-core/src/management/full/compiled.rs @@ -13,6 +13,7 @@ use easytier_proto::{ }; use super::super::instance_rpc::InstanceManagementRpc; +use super::ConfigFileStorage; use crate::{ instance::{ CoreInstance, CoreInstanceHost, @@ -26,11 +27,12 @@ use crate::{ pub fn register_instance_management_rpc( manager: Arc>, registry: &ServiceRegistry, + storage: Arc, ) where F: InstanceFactory>, H: CoreInstanceHost, { - let rpc = InstanceManagementRpc::::new(manager.clone()); + let rpc = InstanceManagementRpc::::new_with_config_storage(manager.clone(), storage); registry.register(PeerManageRpcServer::new(rpc.clone()), ""); registry.register(ConnectorManageRpcServer::new(rpc.clone()), ""); registry.register(MappedListenerManageRpcServer::new(rpc.clone()), ""); diff --git a/easytier-core/src/management/full/config_patch.rs b/easytier-core/src/management/full/config_patch.rs index 37b15075..e1b59c36 100644 --- a/easytier-core/src/management/full/config_patch.rs +++ b/easytier-core/src/management/full/config_patch.rs @@ -10,14 +10,21 @@ use crate::{ config::{ peers::AclRuleConfig, runtime::CoreInstanceRuntimeConfig, - toml::{ConfigLoader as _, TomlConfig}, + toml::{ConfigLoader as _, ManagedCredentialConfig, TomlConfig}, }, instance::{CoreInstance, CoreInstanceConfig, CoreInstanceHost, CoreInstanceState}, + peers::credential_manager::CredentialManager, }; +#[async_trait::async_trait] +pub trait ConfigPatchPersistence: Send + Sync { + async fn persist(&self, instance_id: uuid::Uuid, config: &TomlConfig) -> anyhow::Result<()>; +} + pub async fn apply_config_patch( instance: &Arc>, patch: InstanceConfigPatch, + persistence: Option<&dyn ConfigPatchPersistence>, ) -> anyhow::Result<()> where H: CoreInstanceHost, @@ -33,11 +40,15 @@ where let candidate = config.detached_snapshot(); let parsed_prefix = parse_ipv6_public_addr_prefix_patch(patch.ipv6_public_addr_prefix.as_deref())?; - let patch_for_host = patch.clone(); + // Take the credential set out first so the host-facing copy below never + // clones secret material. + let mut patch = patch; + let managed_credentials = patch.managed_credentials.take(); + let patch_for_host = patch_without_managed_credentials(&patch); // Preserve the existing ordered partial-commit contract: earlier valid // sub-patches remain applied if a later sub-patch fails. - let patch_result: anyhow::Result = async { + let patch_result: anyhow::Result<(bool, bool)> = async { let result = patch_port_forwards(&candidate, patch.port_forwards); validate_and_commit_candidate(instance, &config, &candidate)?; result?; @@ -95,6 +106,7 @@ where candidate.set_ipv6_public_addr_prefix(prefix); provider_config_changed = true; } + let mut managed_credentials_changed = false; // Runs last so client validation sees the fully patched candidate, // including routes and the node IPv4 set earlier in this request. @@ -124,22 +136,84 @@ where validate_and_commit_candidate(instance, &config, &candidate)?; } - let normalized = validate_and_commit_candidate(instance, &config, &candidate)?; + if let Some(managed) = &managed_credentials { + // Managed credential patch transaction: validate and reserve → + // persist → install. The reservation prevents base or ephemeral + // credential mutations from invalidating the replacement while + // the durable write is in flight, without holding a synchronous + // lock across the await. Dropping the replacement before install + // releases the reservation. + // + // Accepted consistency limits: + // + // 1. A persistence implementation may finish its write after this + // RPC future is cancelled. The reservation is then released and + // the running instance keeps its previous credentials even if + // the durable file contains the replacement. A retry, controller + // reconcile, or restart is required to converge; until then a + // removed credential may remain trusted by the running instance. + // + // 2. This instance operation is not serialized with a process-level + // instance overwrite. The built-in web reconciler serializes its + // own actions, but independently concurrent admin RPCs are + // last-writer-wins and may leave the running instance and durable + // file on different config generations. A restart aligns runtime + // with the file; controller reconcile is required to restore its + // desired generation. + let credential_manager = instance.credential_manager(); + let entries = managed + .entries + .iter() + .map(|credential| ManagedCredentialConfig { + credential_id: credential.credential_id.clone(), + credential_secret: credential.credential_secret.clone(), + groups: credential.groups.clone(), + allow_relay: credential.allow_relay, + allowed_proxy_cidrs: credential.allowed_proxy_cidrs.clone(), + expiry_unix: credential.expiry_unix, + reusable: credential.reusable.unwrap_or(true), + }) + .collect::>(); + let replacement = credential_manager + .validate_managed_credentials(&entries) + .map_err(anyhow::Error::msg)?; + candidate.set_managed_credentials(entries); + validate_candidate(instance, &candidate)?; + // File-backed configs persist every successful patch, so the + // durable file and the shared TOML model can never diverge. + persistence + .ok_or_else(|| anyhow::anyhow!("durable config patching is unavailable"))? + .persist(instance.instance_id(), &candidate) + .await?; + config.replace_from_snapshot(&candidate); + managed_credentials_changed = + CredentialManager::install_managed_credentials(replacement); + } else { + validate_and_commit_candidate(instance, &config, &candidate)?; + } + let normalized = validate_candidate(instance, &candidate)?; let runtime = runtime_config_from_normalized(&normalized); - instance - .instance_runtime - .synchronize_config(&patch_for_host, &runtime); - Ok(provider_config_changed) + if patch_for_host != InstanceConfigPatch::default() { + instance + .instance_runtime + .synchronize_config(&patch_for_host, &runtime); + } + Ok((provider_config_changed, managed_credentials_changed)) } .await; instance .update_runtime_config_under_operation(runtime_config_from_toml(instance, &config)?) .await?; - let provider_config_changed = patch_result?; - instance - .instance_runtime - .publish_config_patch(patch_for_host); + let (provider_config_changed, managed_credentials_changed) = patch_result?; + if patch_for_host != InstanceConfigPatch::default() { + instance + .instance_runtime + .publish_config_patch(patch_for_host); + } + if managed_credentials_changed { + instance.notify_credential_changed(); + } #[cfg(feature = "public-ipv6-provider")] if provider_config_changed && instance.state() == CoreInstanceState::Running { instance.reconcile_public_ipv6_provider().await; @@ -149,6 +223,12 @@ where Ok(()) } +fn patch_without_managed_credentials(patch: &InstanceConfigPatch) -> InstanceConfigPatch { + let mut patch = patch.clone(); + patch.managed_credentials = None; + patch +} + fn validate_candidate( instance: &CoreInstance, candidate: &TomlConfig, @@ -227,6 +307,27 @@ fn trace_patchables(patches: &[Patchable]) { } } +#[cfg(test)] +mod managed_credential_tests { + use easytier_proto::api::manage::ManagedCredentialSet; + + use super::*; + + #[test] + fn event_patch_drops_managed_credential_secrets() { + let patch = InstanceConfigPatch { + managed_credentials: Some(ManagedCredentialSet::default()), + ..Default::default() + }; + + assert!( + patch_without_managed_credentials(&patch) + .managed_credentials + .is_none() + ); + } +} + fn patch_port_forwards(config: &TomlConfig, patches: Vec) -> anyhow::Result<()> { if patches.is_empty() { return Ok(()); diff --git a/easytier-core/src/management/full/mod.rs b/easytier-core/src/management/full/mod.rs index 66f122ea..2a29358e 100644 --- a/easytier-core/src/management/full/mod.rs +++ b/easytier-core/src/management/full/mod.rs @@ -37,7 +37,7 @@ use super::{ #[cfg(feature = "management")] pub use compiled::register_instance_management_rpc; -pub use config_patch::apply_config_patch; +pub use config_patch::{ConfigPatchPersistence, apply_config_patch}; pub use instance_info::network_instance_running_info; #[cfg(feature = "management")] pub use logger_rpc::{ @@ -82,7 +82,7 @@ pub fn register_management_rpc( F::Error: std::fmt::Debug + std::fmt::Display + Send + Sync + 'static, H: CoreInstanceHost, { - register_instance_management_rpc(instances.clone(), registry); + register_instance_management_rpc(instances.clone(), registry, storage.clone()); registry.register(LoggerRpcServer::new(LoggerManagementRpc::new(logger)), ""); registry.register( WebClientServiceServer::new(ProcessManagementRpc::::new(instances, hooks, storage)), @@ -102,7 +102,10 @@ pub(crate) fn register_web_client_rpc( F::Error: std::fmt::Debug + std::fmt::Display + Send + Sync + 'static, H: CoreInstanceHost, { - let config_rpc = super::instance_rpc::InstanceManagementRpc::::new(instances.clone()); + let config_rpc = super::instance_rpc::InstanceManagementRpc::::new_with_config_storage( + instances.clone(), + storage.clone(), + ); registry.register(ConfigRpcServer::new(config_rpc), ""); registry.register( WebClientServiceServer::new(ProcessManagementRpc::::new(instances, hooks, storage)), diff --git a/easytier-core/src/management/full/process_rpc.rs b/easytier-core/src/management/full/process_rpc.rs index 1632d9ff..035ebd5f 100644 --- a/easytier-core/src/management/full/process_rpc.rs +++ b/easytier-core/src/management/full/process_rpc.rs @@ -63,6 +63,8 @@ pub trait ConfigFileStorage: Send + Sync + 'static { async fn read(&self, path: &Path) -> anyhow::Result>>; + /// Atomically replaces the file and restricts newly created files to the + /// current user when the Host supports file permissions. async fn write(&self, path: &Path, contents: &[u8]) -> anyhow::Result<()>; async fn remove(&self, path: &Path) -> anyhow::Result<()>; diff --git a/easytier-core/src/management/instance_rpc/config.rs b/easytier-core/src/management/instance_rpc/config.rs index 769c2c9d..6cc6057d 100644 --- a/easytier-core/src/management/instance_rpc/config.rs +++ b/easytier-core/src/management/instance_rpc/config.rs @@ -26,7 +26,7 @@ where ) -> rpc_types::error::Result { let instance = self.instance(request.instance.as_ref())?; if let Some(patch) = request.patch { - apply_config_patch(&instance, patch).await?; + apply_config_patch(&instance, patch, self.config_patch_persistence.as_deref()).await?; } Ok(PatchConfigResponse::default()) } diff --git a/easytier-core/src/management/instance_rpc/mod.rs b/easytier-core/src/management/instance_rpc/mod.rs index 9c3feb38..ff60a42b 100644 --- a/easytier-core/src/management/instance_rpc/mod.rs +++ b/easytier-core/src/management/instance_rpc/mod.rs @@ -16,7 +16,10 @@ use easytier_proto::{ }; use crate::{ - config::{IpPrefix, ProxyNetworkConfig}, + config::{ + IpPrefix, ProxyNetworkConfig, + toml::{ConfigLoader as _, TomlConfig}, + }, connectivity::manual::{ManualConnectorSnapshot, ManualConnectorStatus}, instance::{ CoreInstance, CoreInstanceHost, @@ -26,6 +29,8 @@ use crate::{ }; use super::resolve_instance; +#[cfg(feature = "web-client")] +use super::{ConfigFileStorage, ConfigPatchPersistence}; #[cfg(feature = "web-client")] mod config; @@ -124,6 +129,8 @@ where #[doc(hidden)] pub struct ResolvedInstanceManagementRpc { resolver: R, + #[cfg(feature = "web-client")] + config_patch_persistence: Option>, } impl Clone for ResolvedInstanceManagementRpc @@ -133,6 +140,8 @@ where fn clone(&self) -> Self { Self { resolver: self.resolver.clone(), + #[cfg(feature = "web-client")] + config_patch_persistence: self.config_patch_persistence.clone(), } } } @@ -158,8 +167,30 @@ where H: CoreInstanceHost, { pub fn new(manager: Arc>) -> Self { + #[cfg(feature = "web-client")] + let persistence = Arc::new(ManagerPathlessConfigPatchPersistence { + manager: manager.clone(), + _host: std::marker::PhantomData, + }); Self { resolver: ManagerInstanceResolver { manager }, + #[cfg(feature = "web-client")] + config_patch_persistence: Some(persistence), + } + } + #[cfg(feature = "web-client")] + pub fn new_with_config_storage( + manager: Arc>, + storage: Arc, + ) -> Self { + let persistence = Arc::new(ManagerConfigPatchPersistence { + manager: manager.clone(), + storage, + _host: std::marker::PhantomData, + }); + Self { + resolver: ManagerInstanceResolver { manager }, + config_patch_persistence: Some(persistence), } } @@ -178,6 +209,179 @@ where { ResolvedInstanceManagementRpc { resolver: BoundInstanceResolver { instance }, + #[cfg(feature = "web-client")] + config_patch_persistence: Some(Arc::new(InMemoryConfigPatchPersistence)), + } +} + +#[cfg(all(feature = "web-client", target_os = "wasi"))] +struct InMemoryConfigPatchPersistence; + +#[async_trait::async_trait] +#[cfg(all(feature = "web-client", target_os = "wasi"))] +impl ConfigPatchPersistence for InMemoryConfigPatchPersistence { + async fn persist(&self, _instance_id: uuid::Uuid, _config: &TomlConfig) -> anyhow::Result<()> { + Ok(()) + } +} + +#[cfg(feature = "web-client")] +struct ManagerPathlessConfigPatchPersistence +where + F: InstanceFactory, + H: CoreInstanceHost, +{ + manager: Arc>, + _host: std::marker::PhantomData H>, +} + +#[async_trait::async_trait] +#[cfg(feature = "web-client")] +impl ConfigPatchPersistence for ManagerPathlessConfigPatchPersistence +where + F: InstanceFactory>, + H: CoreInstanceHost, +{ + async fn persist(&self, instance_id: uuid::Uuid, _config: &TomlConfig) -> anyhow::Result<()> { + let Some(control) = self.manager.config_control(instance_id) else { + return Ok(()); + }; + if control.is_read_only() { + anyhow::bail!("configuration file is read-only"); + } + if let Some(path) = control.path { + anyhow::bail!( + "config file {} requires a durable config storage backend", + path.display() + ); + } + Ok(()) + } +} + +#[cfg(feature = "web-client")] +struct ManagerConfigPatchPersistence +where + F: InstanceFactory, + H: CoreInstanceHost, +{ + manager: Arc>, + storage: Arc, + _host: std::marker::PhantomData H>, +} + +#[cfg(feature = "web-client")] +async fn persist_config_patch( + storage: &dyn ConfigFileStorage, + control: &crate::instance::manager::ConfigFileControl, + config: &TomlConfig, +) -> anyhow::Result<()> { + if control.is_read_only() { + anyhow::bail!("configuration file is read-only"); + } + let Some(path) = control.path.as_deref() else { + return Ok(()); + }; + if storage.inspect(path).await.is_read_only() { + anyhow::bail!( + "config file {} is read-only, cannot be overwritten", + path.display() + ); + } + storage.write(path, config.dump().as_bytes()).await +} + +#[async_trait::async_trait] +#[cfg(feature = "web-client")] +impl ConfigPatchPersistence for ManagerConfigPatchPersistence +where + F: InstanceFactory>, + H: CoreInstanceHost, +{ + async fn persist(&self, instance_id: uuid::Uuid, config: &TomlConfig) -> anyhow::Result<()> { + let control = self + .manager + .config_control(instance_id) + .ok_or_else(|| anyhow::anyhow!("configuration file control is unavailable"))?; + persist_config_patch(self.storage.as_ref(), &control, config).await + } +} + +#[cfg(all(test, feature = "web-client"))] +mod config_patch_persistence_tests { + use std::{ + path::{Path, PathBuf}, + sync::atomic::{AtomicBool, AtomicUsize, Ordering}, + }; + + use super::*; + use crate::{ + instance::manager::{ConfigFileControl, ConfigFilePermission}, + management::ConfigFileStorage, + }; + + #[derive(Default)] + struct RecordingStorage { + read_only: AtomicBool, + inspections: AtomicUsize, + writes: AtomicUsize, + } + + #[async_trait::async_trait] + impl ConfigFileStorage for RecordingStorage { + async fn inspect(&self, path: &Path) -> ConfigFileControl { + self.inspections.fetch_add(1, Ordering::Relaxed); + let permission = if self.read_only.load(Ordering::Relaxed) { + ConfigFilePermission::from(ConfigFilePermission::READ_ONLY) + } else { + ConfigFilePermission::default() + }; + ConfigFileControl::new(Some(path.to_owned()), permission) + } + + async fn read(&self, _path: &Path) -> anyhow::Result>> { + unreachable!("config patch persistence does not read files") + } + + async fn write(&self, _path: &Path, _contents: &[u8]) -> anyhow::Result<()> { + self.writes.fetch_add(1, Ordering::Relaxed); + Ok(()) + } + + async fn remove(&self, _path: &Path) -> anyhow::Result<()> { + unreachable!("config patch persistence does not remove files") + } + } + + #[tokio::test] + async fn pathless_config_patch_skips_persistence() { + let storage = RecordingStorage::default(); + let control = ConfigFileControl::new(None, ConfigFilePermission::default()); + + persist_config_patch(&storage, &control, &TomlConfig::default()) + .await + .unwrap(); + + assert_eq!(storage.inspections.load(Ordering::Relaxed), 0); + assert_eq!(storage.writes.load(Ordering::Relaxed), 0); + } + + #[tokio::test] + async fn config_patch_rechecks_file_permission_before_write() { + let storage = RecordingStorage::default(); + storage.read_only.store(true, Ordering::Relaxed); + let control = ConfigFileControl::new( + Some(PathBuf::from("managed.toml")), + ConfigFilePermission::default(), + ); + + let error = persist_config_patch(&storage, &control, &TomlConfig::default()) + .await + .unwrap_err(); + + assert!(error.to_string().contains("managed.toml is read-only")); + assert_eq!(storage.inspections.load(Ordering::Relaxed), 1); + assert_eq!(storage.writes.load(Ordering::Relaxed), 0); } } diff --git a/easytier-core/src/management/mod.rs b/easytier-core/src/management/mod.rs index 76bd3381..9f948d40 100644 --- a/easytier-core/src/management/mod.rs +++ b/easytier-core/src/management/mod.rs @@ -34,9 +34,9 @@ pub(crate) use full::register_web_client_rpc; pub use full::remote_client; #[cfg(feature = "web-client")] pub use full::{ - ConfigFileStorage, ConfigServerEndpoint, InstanceMutationHooks, InstanceMutationResult, - ProcessManagement, ProcessManagementRpc, UnsupportedConfigFileStorage, WebClient, - WebClientConfig, apply_config_patch, config_source_from_rpc, config_source_to_rpc, + ConfigFileStorage, ConfigPatchPersistence, ConfigServerEndpoint, InstanceMutationHooks, + InstanceMutationResult, ProcessManagement, ProcessManagementRpc, UnsupportedConfigFileStorage, + WebClient, WebClientConfig, apply_config_patch, config_source_from_rpc, config_source_to_rpc, network_instance_running_info, }; #[cfg(feature = "management")] diff --git a/easytier-core/src/peers/attached.rs b/easytier-core/src/peers/attached.rs index fea269d1..9ae26aa8 100644 --- a/easytier-core/src/peers/attached.rs +++ b/easytier-core/src/peers/attached.rs @@ -189,16 +189,15 @@ impl AttachedPeerRuntime { let runtime_handle = Handle::current(); let network = network_runtime_config.snapshot(); let (peer_snapshot, credential_public_key) = build_peer_snapshot(&network, &config)?; - let credential_registration = credential_public_key - .map(|public_key| { - AttachedCredentialRegistration::register( - network_peer_manager.clone(), - network_runtime_config.clone(), - public_key, - config.groups.clone(), - ) - }) - .transpose()?; + let credential_registration = match credential_public_key { + Some(public_key) => Some(AttachedCredentialRegistration::register( + network_peer_manager.clone(), + network_runtime_config.clone(), + public_key, + config.groups.clone(), + )?), + None => None, + }; let services = build_attached_services(&network.services, credential_public_key.is_some()); let runtime_config = CoreRuntimeConfigStore::new(services, Arc::new(peer_snapshot.clone())); let (packet_sender, packet_receiver) = host_packet_channel(); @@ -212,6 +211,7 @@ impl AttachedPeerRuntime { exit_nodes: Vec::new(), foreign_context_default_flags: flags, }, + Vec::new(), runtime_config, Arc::new(()), packet_sender, @@ -623,6 +623,7 @@ mod tests { let peer_manager = Arc::new( PeerManagerCore::new( portable, + Vec::new(), store.clone(), Arc::new(()), packet_sender, diff --git a/easytier-core/src/peers/conn/peer_conn_ping.rs b/easytier-core/src/peers/conn/peer_conn_ping.rs index 77ddecc4..88f9daf4 100644 --- a/easytier-core/src/peers/conn/peer_conn_ping.rs +++ b/easytier-core/src/peers/conn/peer_conn_ping.rs @@ -372,7 +372,11 @@ mod tests { ); } - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + #[cfg_attr( + not(target_os = "wasi"), + tokio::test(flavor = "multi_thread", worker_threads = 2) + )] + #[cfg_attr(target_os = "wasi", tokio::test)] async fn echoed_business_traffic_keeps_connection_alive_when_pongs_are_lost() { let local_liveness = PeerConnLiveness::new(); let remote_liveness = PeerConnLiveness::new(); diff --git a/easytier-core/src/peers/credential_manager.rs b/easytier-core/src/peers/credential_manager.rs index 62d87abb..a1acf1b5 100644 --- a/easytier-core/src/peers/credential_manager.rs +++ b/easytier-core/src/peers/credential_manager.rs @@ -1,5 +1,5 @@ use std::{ - collections::HashMap, + collections::{HashMap, HashSet}, sync::{Arc, Mutex}, time::{Duration, SystemTime, UNIX_EPOCH}, }; @@ -9,7 +9,10 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use x25519_dalek::{PublicKey, StaticSecret}; -use crate::proto::peer_rpc::{TrustedCredentialPubkey, TrustedCredentialPubkeyProof}; +use crate::{ + config::toml::ManagedCredentialConfig, + proto::peer_rpc::{TrustedCredentialPubkey, TrustedCredentialPubkeyProof}, +}; fn default_true() -> bool { true @@ -43,7 +46,7 @@ pub struct CredentialUpsertOptions { pub reusable: bool, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub(crate) struct CredentialEntry { pubkey: String, #[serde(default)] @@ -85,6 +88,33 @@ impl CredentialEntry { .unwrap_or_default(), } } + + fn from_managed(entry: &ManagedCredentialConfig) -> Result { + let credential_id = entry.credential_id.trim(); + let private_bytes: [u8; 32] = BASE64_STANDARD + .decode(entry.credential_secret.trim()) + .map_err(|_| format!("credential_secret for {credential_id} must be base64"))? + .try_into() + .map_err(|_| format!("credential_secret for {credential_id} must contain 32 bytes"))?; + let private = StaticSecret::from(private_bytes); + let mut allowed_proxy_cidrs = Vec::with_capacity(entry.allowed_proxy_cidrs.len()); + for cidr in &entry.allowed_proxy_cidrs { + let cidr = cidr.trim(); + cidr.parse::() + .map_err(|_| format!("invalid allowed_proxy_cidr for {credential_id}: {cidr}"))?; + allowed_proxy_cidrs.push(cidr.to_owned()); + } + Ok(Self { + pubkey: BASE64_STANDARD.encode(PublicKey::from(&private).as_bytes()), + secret: BASE64_STANDARD.encode(private.as_bytes()), + groups: entry.groups.clone(), + allow_relay: entry.allow_relay, + allowed_proxy_cidrs, + reusable: entry.reusable, + expiry_unix: entry.expiry_unix, + created_at_unix: 0, + }) + } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -114,11 +144,35 @@ pub trait CredentialStorage: Send + Sync + 'static { fn store(&self, serialized_credentials: &str) -> anyhow::Result<()>; } +#[derive(Default)] +struct CredentialState { + base: HashMap, + managed: HashMap, + pending_managed: Option>, + ephemeral: HashMap, +} + pub(crate) struct CredentialManager { - credentials: Mutex>, - ephemeral_credentials: Mutex>, + state: Mutex, storage: Option>, - storage_write: Mutex<()>, + storage_load_error: Option, +} + +/// A validated managed credential replacement awaiting installation. +#[cfg(feature = "web-client")] +pub(crate) struct ManagedCredentialReplacement<'a> { + manager: &'a CredentialManager, + changed: bool, + installed: bool, +} + +#[cfg(feature = "web-client")] +impl Drop for ManagedCredentialReplacement<'_> { + fn drop(&mut self) { + if self.changed && !self.installed { + self.manager.state.lock().unwrap().pending_managed = None; + } + } } impl Default for CredentialManager { @@ -130,38 +184,35 @@ impl Default for CredentialManager { impl CredentialManager { pub fn new() -> Self { Self { - credentials: Mutex::new(HashMap::new()), - ephemeral_credentials: Mutex::new(HashMap::new()), + state: Mutex::new(CredentialState::default()), storage: None, - storage_write: Mutex::new(()), + storage_load_error: None, } } pub fn from_storage(storage: Arc) -> Self { - let credentials = match storage.load() { - Ok(Some(serialized)) => serde_json::from_str(&serialized).unwrap_or_else(|error| { - tracing::warn!(?error, "failed to parse stored credentials"); - HashMap::new() - }), - Ok(None) => HashMap::new(), + let loaded = match storage.load() { + Ok(Some(serialized)) => serde_json::from_str(&serialized).map_err(anyhow::Error::from), + Ok(None) => Ok(HashMap::new()), + Err(error) => Err(error), + }; + let (base, storage_load_error) = match loaded { + Ok(base) => (base, None), Err(error) => { - tracing::warn!(?error, "failed to load stored credentials"); - HashMap::new() + tracing::error!(?error, "credential storage is unavailable"); + (HashMap::new(), Some(error.to_string())) } }; Self { - credentials: Mutex::new(credentials), - ephemeral_credentials: Mutex::new(HashMap::new()), + state: Mutex::new(CredentialState { + base, + ..Default::default() + }), storage: Some(storage), - storage_write: Mutex::new(()), + storage_load_error, } } - pub fn with_entries(&self, f: impl FnOnce(&HashMap) -> R) -> R { - let credentials = self.credentials.lock().unwrap(); - f(&credentials) - } - pub fn generate_credential_with_options( &self, groups: Vec, @@ -170,61 +221,68 @@ impl CredentialManager { ttl: Duration, credential_id: Option, reusable: bool, - ) -> GeneratedCredential { - self.remove_expired_credentials(); - self.generate_credential_with_options_after_cleanup( - groups, - allow_relay, - allowed_proxy_cidrs, - ttl, - credential_id, - reusable, - ) - } - - pub fn generate_credential_with_options_after_cleanup( - &self, - groups: Vec, - allow_relay: bool, - allowed_proxy_cidrs: Vec, - ttl: Duration, - credential_id: Option, - reusable: bool, - ) -> GeneratedCredential { - let generated = { - let mut credentials = self.credentials.lock().unwrap(); - let id = if let Some(id) = credential_id - .map(|x| x.trim().to_string()) - .filter(|x| !x.is_empty()) + ) -> Result { + self.ensure_storage_available() + .map_err(|error| error.to_string())?; + let mut state = self.state.lock().unwrap(); + let now = current_unix_timestamp(); + let mut updated = state.base.clone(); + updated.retain(|_, entry| entry.is_active_at(now)); + let id = if let Some(id) = credential_id + .map(|x| x.trim().to_string()) + .filter(|x| !x.is_empty()) + { + if Self::managed_contains_id(&state, &id) { + return Err(format!("credential_id {id} is managed by configuration")); + } + if let Some(existing) = updated.get(&id) + && !existing.secret.is_empty() { - if let Some(existing) = credentials.get(&id) - && !existing.secret.is_empty() - { - return GeneratedCredential { - credential_id: id, - secret: existing.secret.clone(), - expiry_unix: existing.expiry_unix, - changed: false, - }; + return Ok(GeneratedCredential { + credential_id: id, + secret: existing.secret.clone(), + expiry_unix: existing.expiry_unix, + changed: false, + }); + } + id + } else { + loop { + let id = uuid::Uuid::new_v4().to_string(); + if !updated.contains_key(&id) && !Self::managed_contains_id(&state, &id) { + break id; } - id - } else { - uuid::Uuid::new_v4().to_string() - }; - - let (entry, secret) = - Self::build_entry(groups, allow_relay, allowed_proxy_cidrs, reusable, ttl); - let expiry_unix = entry.expiry_unix; - credentials.insert(id.clone(), entry); - GeneratedCredential { - credential_id: id, - secret, - expiry_unix, - changed: true, } }; - self.persist(); - generated + + let (entry, secret) = loop { + let generated = Self::build_entry( + groups.clone(), + allow_relay, + allowed_proxy_cidrs.clone(), + reusable, + ttl, + ); + let public_key_in_use = updated + .values() + .chain(Self::managed_values(&state)) + .chain(state.ephemeral.values()) + .any(|existing| existing.pubkey == generated.0.pubkey); + if !public_key_in_use { + break generated; + } + }; + let expiry_unix = entry.expiry_unix; + updated.insert(id.clone(), entry); + self.store_base(&updated) + .map_err(|error| format!("failed to store credentials: {error}"))?; + state.base = updated; + Ok(GeneratedCredential { + credential_id: id, + secret, + expiry_unix, + changed: true, + }) } fn build_entry( @@ -258,17 +316,19 @@ impl CredentialManager { (entry, secret) } - pub fn revoke_credential(&self, credential_id: &str) -> bool { - let removed = self - .credentials - .lock() - .unwrap() - .remove(credential_id) - .is_some(); - if removed { - self.persist(); + pub fn revoke_credential(&self, credential_id: &str) -> Result { + self.ensure_storage_available() + .map_err(|error| error.to_string())?; + let mut state = self.state.lock().unwrap(); + if !state.base.contains_key(credential_id) { + return Ok(false); } - removed + let mut updated = state.base.clone(); + updated.remove(credential_id); + self.store_base(&updated) + .map_err(|error| format!("failed to store credentials: {error}"))?; + state.base = updated; + Ok(true) } pub fn register_ephemeral_credential( @@ -290,17 +350,14 @@ impl CredentialManager { created_at_unix: current_unix_timestamp(), }; - let _storage_write = self.storage_write.lock().unwrap(); - if self - .credentials - .lock() - .unwrap() + let mut state = self.state.lock().unwrap(); + if state + .base .values() + .chain(Self::managed_values(&state)) .any(|existing| existing.pubkey == entry.pubkey) - || self - .ephemeral_credentials - .lock() - .unwrap() + || state + .ephemeral .values() .any(|existing| existing.pubkey == entry.pubkey) { @@ -308,10 +365,7 @@ impl CredentialManager { } let credential_id = uuid::Uuid::new_v4(); - self.ephemeral_credentials - .lock() - .unwrap() - .insert(credential_id, entry); + state.ephemeral.insert(credential_id, entry); Ok(credential_id) } @@ -320,8 +374,8 @@ impl CredentialManager { credential_id: uuid::Uuid, groups: Vec, ) -> Option { - let mut credentials = self.ephemeral_credentials.lock().unwrap(); - let credential = credentials.get_mut(&credential_id)?; + let mut state = self.state.lock().unwrap(); + let credential = state.ephemeral.get_mut(&credential_id)?; if credential.groups == groups { return Some(false); } @@ -330,9 +384,10 @@ impl CredentialManager { } pub fn revoke_ephemeral_credential(&self, credential_id: uuid::Uuid) -> bool { - self.ephemeral_credentials + self.state .lock() .unwrap() + .ephemeral .remove(&credential_id) .is_some() } @@ -372,23 +427,32 @@ impl CredentialManager { created_at_unix: current_unix_timestamp(), }; - let _storage_write = self.storage_write.lock().unwrap(); - let mut credentials = self.credentials.lock().unwrap(); - if credentials.iter().any(|(existing_id, existing)| { - existing_id != &credential_id && existing.pubkey == entry.pubkey - }) { + self.ensure_storage_available() + .map_err(|error| error.to_string())?; + let mut state = self.state.lock().unwrap(); + if Self::managed_contains_id(&state, &credential_id) { + return Err(format!( + "credential_id {credential_id} is managed by configuration" + )); + } + if state + .base + .iter() + .chain(Self::managed_entries(&state)) + .any(|(existing_id, existing)| { + existing_id != &credential_id && existing.pubkey == entry.pubkey + }) + { return Err("credential_secret is already used by another credential_id".to_string()); } - if self - .ephemeral_credentials - .lock() - .unwrap() + if state + .ephemeral .values() .any(|existing| existing.pubkey == entry.pubkey) { return Err("credential public key is already registered".to_owned()); } - let changed = credentials.get(&credential_id).is_none_or(|existing| { + let changed = state.base.get(&credential_id).is_none_or(|existing| { existing.secret != entry.secret || existing.pubkey != entry.pubkey || existing.groups != entry.groups @@ -401,18 +465,11 @@ impl CredentialManager { return Ok(false); } - if let Some(storage) = &self.storage { - let mut updated = credentials.clone(); - updated.insert(credential_id, entry); - let serialized = serde_json::to_string_pretty(&updated) - .map_err(|error| format!("failed to serialize credentials: {error}"))?; - storage - .store(&serialized) - .map_err(|error| format!("failed to store credentials: {error}"))?; - *credentials = updated; - } else { - credentials.insert(credential_id, entry); - } + let mut updated = state.base.clone(); + updated.insert(credential_id, entry); + self.store_base(&updated) + .map_err(|error| format!("failed to store credentials: {error}"))?; + state.base = updated; Ok(true) } @@ -421,15 +478,18 @@ impl CredentialManager { } fn remove_expired_credentials_at(&self, now: i64) -> bool { - let mut credentials = self.credentials.lock().unwrap(); - let before = credentials.len(); - credentials.retain(|_, entry| entry.is_active_at(now)); - let changed = before != credentials.len(); - drop(credentials); - if changed { - self.persist(); + let mut state = self.state.lock().unwrap(); + let mut updated = state.base.clone(); + updated.retain(|_, entry| entry.is_active_at(now)); + if updated == state.base { + return false; } - changed + if let Err(error) = self.store_base(&updated) { + tracing::warn!(?error, "failed to remove expired credentials"); + return false; + } + state.base = updated; + true } pub fn get_trusted_pubkeys(&self, network_secret: &str) -> Vec { @@ -439,36 +499,29 @@ impl CredentialManager { TrustedCredentialPubkeyProof::new_signed(credential, network_secret) }) }; - let mut trusted = self - .credentials - .lock() - .unwrap() + let state = self.state.lock().unwrap(); + let mut trusted = state + .base .values() + .chain(state.managed.values()) .filter(|entry| entry.is_active_at(now)) .filter_map(to_proof) .collect::>(); - trusted.extend( - self.ephemeral_credentials - .lock() - .unwrap() - .values() - .filter_map(to_proof), - ); + trusted.extend(state.ephemeral.values().filter_map(to_proof)); trusted } pub fn is_pubkey_trusted(&self, pubkey: &[u8]) -> bool { let now = current_unix_timestamp(); let encoded = BASE64_STANDARD.encode(pubkey); - self.credentials - .lock() - .unwrap() + let state = self.state.lock().unwrap(); + state + .base .values() + .chain(state.managed.values()) .any(|entry| entry.pubkey == encoded && entry.is_active_at(now)) - || self - .ephemeral_credentials - .lock() - .unwrap() + || state + .ephemeral .values() .any(|entry| entry.pubkey == encoded) } @@ -476,13 +529,136 @@ impl CredentialManager { pub fn list_credentials(&self) -> Vec { let now = current_unix_timestamp(); - self.credentials - .lock() - .unwrap() + let state = self.state.lock().unwrap(); + let mut credentials = state + .base .iter() + .chain(state.managed.iter()) .filter(|(_, entry)| entry.is_active_at(now)) .map(|(id, entry)| entry.to_credential_info(id)) - .collect() + .collect::>(); + credentials.sort_unstable_by(|left, right| left.credential_id.cmp(&right.credential_id)); + credentials + } + + pub fn install_initial_managed_credentials( + &self, + credentials: &[ManagedCredentialConfig], + ) -> Result<(), String> { + self.ensure_storage_available() + .map_err(|error| error.to_string())?; + let replacement = Self::build_managed_entries(credentials)?; + let mut state = self.state.lock().unwrap(); + Self::validate_managed_conflicts(&state, &replacement)?; + state.managed = replacement; + Ok(()) + } + + /// Fallible checks for a managed credential replacement (secret parsing, + /// duplicate IDs/keys, conflicts with base/ephemeral credentials). Must + /// run before the candidate is persisted so a rejected patch never + /// reaches disk. + #[cfg(feature = "web-client")] + pub fn validate_managed_credentials( + &self, + credentials: &[ManagedCredentialConfig], + ) -> Result, String> { + let replacement = Self::build_managed_entries(credentials)?; + let mut state = self.state.lock().unwrap(); + if state.pending_managed.is_some() { + return Err("managed credential replacement is already pending".to_owned()); + } + Self::validate_managed_conflicts(&state, &replacement)?; + let changed = state.managed != replacement; + if changed { + state.pending_managed = Some(replacement); + } + Ok(ManagedCredentialReplacement { + manager: self, + changed, + installed: false, + }) + } + + /// Installs a replacement whose IDs and public keys were reserved by + /// [`Self::validate_managed_credentials`]. + #[cfg(feature = "web-client")] + pub fn install_managed_credentials(mut replacement: ManagedCredentialReplacement<'_>) -> bool { + if !replacement.changed { + return false; + } + let mut state = replacement.manager.state.lock().unwrap(); + state.managed = state + .pending_managed + .take() + .expect("validated managed credential replacement must remain reserved"); + replacement.installed = true; + true + } + + fn managed_contains_id(state: &CredentialState, credential_id: &str) -> bool { + state.managed.contains_key(credential_id) + || state + .pending_managed + .as_ref() + .is_some_and(|pending| pending.contains_key(credential_id)) + } + + fn managed_entries( + state: &CredentialState, + ) -> impl Iterator { + state.managed.iter().chain( + state + .pending_managed + .iter() + .flat_map(|pending| pending.iter()), + ) + } + + fn managed_values(state: &CredentialState) -> impl Iterator { + Self::managed_entries(state).map(|(_, entry)| entry) + } + + fn build_managed_entries( + credentials: &[ManagedCredentialConfig], + ) -> Result, String> { + let mut entries = HashMap::with_capacity(credentials.len()); + let mut public_keys = HashSet::with_capacity(credentials.len()); + for credential in credentials { + let credential_id = credential.credential_id.trim().to_owned(); + if credential_id.is_empty() { + return Err("credential_id must not be empty".to_owned()); + } + let entry = CredentialEntry::from_managed(credential)?; + if !public_keys.insert(entry.pubkey.clone()) { + return Err("credential_secret is assigned to multiple credential IDs".to_owned()); + } + if entries.insert(credential_id.clone(), entry).is_some() { + return Err(format!("duplicate managed credential_id: {credential_id}")); + } + } + Ok(entries) + } + + fn validate_managed_conflicts( + state: &CredentialState, + replacement: &HashMap, + ) -> Result<(), String> { + if let Some(credential_id) = replacement.keys().find(|id| state.base.contains_key(*id)) { + return Err(format!( + "credential_id {credential_id} is already owned by the credential file" + )); + } + if replacement.values().any(|entry| { + state + .base + .values() + .chain(state.ephemeral.values()) + .any(|existing| existing.pubkey == entry.pubkey) + }) { + return Err("credential public key is already registered".to_owned()); + } + Ok(()) } fn decode_pubkey_b64(s: &str) -> Option> { @@ -503,21 +679,19 @@ impl CredentialManager { ) } - fn persist(&self) { - let Some(storage) = &self.storage else { - return; - }; - let _storage_write = self.storage_write.lock().unwrap(); - let serialized = match self.with_entries(serde_json::to_string_pretty) { - Ok(serialized) => serialized, - Err(error) => { - tracing::warn!(?error, "failed to serialize credentials"); - return; - } - }; - if let Err(error) = storage.store(&serialized) { - tracing::warn!(?error, "failed to store credentials"); + fn ensure_storage_available(&self) -> anyhow::Result<()> { + if let Some(error) = &self.storage_load_error { + anyhow::bail!("credential storage is unavailable: {error}"); } + Ok(()) + } + + fn store_base(&self, base: &HashMap) -> anyhow::Result<()> { + self.ensure_storage_available()?; + let Some(storage) = &self.storage else { + return Ok(()); + }; + storage.store(&serde_json::to_string_pretty(base)?) } } @@ -525,6 +699,32 @@ impl CredentialManager { mod tests { use super::*; + fn managed_credential( + credential_id: &str, + secret_byte: u8, + expiry_unix: i64, + ) -> ManagedCredentialConfig { + ManagedCredentialConfig { + credential_id: credential_id.to_owned(), + credential_secret: BASE64_STANDARD.encode([secret_byte; 32]), + groups: vec!["ops".to_owned()], + allow_relay: false, + allowed_proxy_cidrs: vec!["10.0.0.0/24".to_owned()], + expiry_unix, + reusable: true, + } + } + + #[test] + fn managed_credential_trims_allowed_proxy_cidrs() { + let mut credential = managed_credential("managed", 1, i64::MAX); + credential.allowed_proxy_cidrs = vec![" 10.0.0.0/24 ".to_owned()]; + + let entry = CredentialEntry::from_managed(&credential).unwrap(); + + assert_eq!(entry.allowed_proxy_cidrs, ["10.0.0.0/24"]); + } + impl CredentialManager { pub(crate) fn generate_credential( &self, @@ -541,6 +741,7 @@ mod tests { None, true, ) + .unwrap() } fn generate_credential_with_id( @@ -559,6 +760,7 @@ mod tests { credential_id, true, ) + .unwrap() } } @@ -641,7 +843,7 @@ mod tests { ); assert_eq!(trusted[0].credential.as_ref().unwrap().reusable, Some(true)); - assert!(mgr.revoke_credential(&generated.credential_id)); + assert!(mgr.revoke_credential(&generated.credential_id).unwrap()); assert!(!mgr.is_pubkey_trusted(&pubkey_bytes)); assert!(mgr.get_trusted_pubkeys("sec").is_empty()); } @@ -684,14 +886,16 @@ mod tests { #[test] fn upsert_credential_preserves_key_attributes_and_storage() { let source = CredentialManager::new(); - let generated = source.generate_credential_with_options( - vec!["users".to_string()], - false, - vec!["10.0.0.0/8".to_string()], - Duration::from_secs(3600), - Some("shared-id".to_string()), - false, - ); + let generated = source + .generate_credential_with_options( + vec!["users".to_string()], + false, + vec!["10.0.0.0/8".to_string()], + Duration::from_secs(3600), + Some("shared-id".to_string()), + false, + ) + .unwrap(); let source_info = source.list_credentials().remove(0); let options = CredentialUpsertOptions { credential_id: generated.credential_id, @@ -802,13 +1006,13 @@ mod tests { generated.credential_id ); - assert!(manager.revoke_credential(&generated.credential_id)); + assert!(manager.revoke_credential(&generated.credential_id).unwrap()); let reloaded = CredentialManager::from_storage(storage); assert!(reloaded.list_credentials().is_empty()); } #[test] - fn malformed_storage_starts_with_empty_credentials() { + fn malformed_storage_is_fail_closed() { let storage = Arc::new(MemoryCredentialStorage { serialized: Mutex::new(Some("not json".to_owned())), }); @@ -816,6 +1020,7 @@ mod tests { let manager = CredentialManager::from_storage(storage); assert!(manager.list_credentials().is_empty()); + assert!(manager.install_initial_managed_credentials(&[]).is_err()); } #[test] @@ -846,4 +1051,130 @@ mod tests { assert!(manager.get_trusted_pubkeys("network-secret").is_empty()); assert!(storage.serialized.lock().unwrap().is_none()); } + + #[cfg(feature = "web-client")] + #[test] + fn managed_credentials_work_without_base_storage_and_expire_in_place() { + let manager = CredentialManager::new(); + let active = managed_credential("active", 1, current_unix_timestamp() + 60); + let expired = managed_credential("expired", 2, current_unix_timestamp() - 1); + + manager + .install_initial_managed_credentials(&[active.clone(), expired]) + .unwrap(); + + assert_eq!(manager.list_credentials().len(), 1); + assert_eq!(manager.list_credentials()[0].credential_id, "active"); + let private_bytes: [u8; 32] = BASE64_STANDARD + .decode(active.credential_secret) + .unwrap() + .try_into() + .unwrap(); + let public = PublicKey::from(&StaticSecret::from(private_bytes)); + assert!(manager.is_pubkey_trusted(public.as_bytes())); + + let replacement = manager.validate_managed_credentials(&[]).unwrap(); + assert!(replacement.changed); + assert!(CredentialManager::install_managed_credentials(replacement)); + assert!(manager.list_credentials().is_empty()); + } + + #[cfg(feature = "web-client")] + #[test] + fn pending_managed_replacement_reserves_ids_and_public_keys() { + let manager = CredentialManager::new(); + let pending = managed_credential("pending", 5, current_unix_timestamp() + 60); + let private_bytes: [u8; 32] = BASE64_STANDARD + .decode(&pending.credential_secret) + .unwrap() + .try_into() + .unwrap(); + let public = PublicKey::from(&StaticSecret::from(private_bytes)); + + let replacement = manager + .validate_managed_credentials(std::slice::from_ref(&pending)) + .unwrap(); + + let error = manager + .generate_credential_with_options( + Vec::new(), + false, + Vec::new(), + Duration::from_secs(60), + Some("pending".to_owned()), + true, + ) + .unwrap_err(); + assert!(error.contains("managed by configuration")); + assert!( + manager + .register_ephemeral_credential( + *public.as_bytes(), + Vec::new(), + false, + Vec::new(), + false, + ) + .is_err() + ); + assert!(!manager.is_pubkey_trusted(public.as_bytes())); + + drop(replacement); + assert!( + manager + .register_ephemeral_credential( + *public.as_bytes(), + Vec::new(), + false, + Vec::new(), + false, + ) + .is_ok() + ); + } + + #[cfg(feature = "web-client")] + #[test] + fn managed_and_base_credentials_must_be_disjoint() { + let manager = CredentialManager::new(); + manager + .install_initial_managed_credentials(&[managed_credential( + "managed", + 3, + current_unix_timestamp() + 60, + )]) + .unwrap(); + + let error = manager + .generate_credential_with_options( + Vec::new(), + false, + Vec::new(), + Duration::from_secs(60), + Some("managed".to_owned()), + true, + ) + .unwrap_err(); + assert!(error.contains("managed by configuration")); + + let generated = + manager.generate_credential(Vec::new(), false, Vec::new(), Duration::from_secs(60)); + let conflicting = ManagedCredentialConfig { + credential_id: "other".to_owned(), + credential_secret: generated.secret, + ..managed_credential("other", 4, current_unix_timestamp() + 60) + }; + let error = manager + .validate_managed_credentials(&[conflicting]) + .err() + .unwrap(); + assert_eq!(error, "credential public key is already registered"); + // The rejected replacement must not have touched existing state. + assert!( + manager + .list_credentials() + .iter() + .any(|info| info.credential_id == "managed") + ); + } } diff --git a/easytier-core/src/peers/foreign_network/mod.rs b/easytier-core/src/peers/foreign_network/mod.rs index 46c440c4..8924de59 100644 --- a/easytier-core/src/peers/foreign_network/mod.rs +++ b/easytier-core/src/peers/foreign_network/mod.rs @@ -811,7 +811,7 @@ impl ForeignNetworkManager { let tasks = Arc::new(std::sync::Mutex::new(JoinSet::new())); let task_reaper = tokio::spawn(reap_joinset_background( - tasks.clone(), + Arc::downgrade(&tasks), "ForeignNetworkManager", )); diff --git a/easytier-core/src/peers/peer_manager.rs b/easytier-core/src/peers/peer_manager.rs index 8006bcdf..95d3f088 100644 --- a/easytier-core/src/peers/peer_manager.rs +++ b/easytier-core/src/peers/peer_manager.rs @@ -28,6 +28,7 @@ use crate::{ PeerRuntimeSnapshot, }, runtime::{CoreInstanceRuntimeConfig, CoreRuntimeConfigStore}, + toml::ManagedCredentialConfig, }, events::CoreEventSink, foundation::task::ExternalTaskSignal, @@ -811,6 +812,7 @@ impl PeerManagerCore { #[allow(clippy::too_many_arguments)] pub(crate) fn new( mut config: PortablePeerManagerConfig, + managed_credentials: Vec, runtime_config: CoreRuntimeConfigStore, stun_info_source: Arc, nic_channel: HostPacketSender, @@ -922,6 +924,10 @@ impl PeerManagerCore { credential_storage, }, )); + context + .credential_manager() + .install_initial_managed_credentials(&managed_credentials) + .map_err(anyhow::Error::msg)?; let peer_manager = Self::assemble( config.route_algo, my_peer_id, @@ -3563,6 +3569,7 @@ mod tests { let stun_info_source = Arc::new(RuntimeConfigStunInfoSource(runtime_config.clone())); Self::new( config, + Vec::new(), runtime_config, stun_info_source, nic_channel, @@ -3879,6 +3886,7 @@ mod tests { let core = PeerManagerCore::new( config, + Vec::new(), runtime_config, Arc::new(()), packet_tx, @@ -4099,6 +4107,7 @@ mod tests { admin_a .credential_manager() .revoke_credential(&generated.credential_id) + .unwrap() ); admin_b .context diff --git a/easytier-core/src/rpc/server.rs b/easytier-core/src/rpc/server.rs index c93f7827..796cd397 100644 --- a/easytier-core/src/rpc/server.rs +++ b/easytier-core/src/rpc/server.rs @@ -123,7 +123,7 @@ impl Server { self.stopped.store(false, Ordering::Relaxed); let handler_tasks = self.handler_tasks.clone(); self.tasks.lock().unwrap().spawn(reap_joinset_background( - handler_tasks.clone(), + Arc::downgrade(&handler_tasks), "rpc server handlers", )); diff --git a/easytier-core/src/wasi/web_client.rs b/easytier-core/src/wasi/web_client.rs index e061073c..e422e288 100644 --- a/easytier-core/src/wasi/web_client.rs +++ b/easytier-core/src/wasi/web_client.rs @@ -127,6 +127,7 @@ fn hosted_network_config(config: &NetworkConfig) -> NetworkConfig { disable_upnp: config.disable_upnp, disable_relay_data: config.disable_relay_data, enable_udp_broadcast_relay: config.enable_udp_broadcast_relay, + managed_credentials: config.managed_credentials.clone(), peers, ..Default::default() } @@ -388,7 +389,7 @@ impl WasiWebClientRuntime { mod tests { use super::*; use crate::proto::{ - api::manage::NetworkPeerConfig, + api::manage::{ManagedCredentialConfig, NetworkPeerConfig}, common::{CompressionAlgoPb, SecureModeConfig}, }; @@ -425,6 +426,11 @@ mod tests { enable_private_mode: Some(true), disable_relay_data: Some(true), proxy_cidrs: vec!["10.88.0.0/24".to_owned()], + managed_credentials: vec![ManagedCredentialConfig { + credential_id: "managed".to_owned(), + credential_secret: "secret".to_owned(), + ..Default::default() + }], port_forwards: vec![crate::proto::api::manage::PortForwardConfig { proto: "tcp".to_owned(), bind_ip: "127.0.0.1".to_owned(), @@ -450,6 +456,7 @@ mod tests { assert_eq!(hosted.enable_private_mode, Some(true)); assert_eq!(hosted.disable_relay_data, Some(true)); assert_eq!(hosted.proxy_cidrs, original.proxy_cidrs); + assert_eq!(hosted.managed_credentials, original.managed_credentials); assert_eq!(hosted.port_forwards, original.port_forwards); assert_eq!(hosted.enable_vpn_portal, None); assert_eq!(hosted.data_compress_algo, None); diff --git a/easytier-proto/build/main.rs b/easytier-proto/build/main.rs index 8c9752c4..2dcd1ec0 100644 --- a/easytier-proto/build/main.rs +++ b/easytier-proto/build/main.rs @@ -128,6 +128,7 @@ fn main() -> Result<(), Box> { ".common.Ipv4Addr", ".common.Ipv6Addr", ".common.UUID", + ".api.manage.ManagedCredentialConfig", ".api.manage.VpnPortalConfig", ]); diff --git a/easytier-proto/proto/api_config.proto b/easytier-proto/proto/api_config.proto index 3ea8183f..8493db28 100644 --- a/easytier-proto/proto/api_config.proto +++ b/easytier-proto/proto/api_config.proto @@ -29,6 +29,7 @@ message InstanceConfigPatch { optional string ipv6_public_addr_prefix = 13; optional bool disable_relay_data = 14; repeated VpnPortalClientPatch vpn_portal_clients = 15; + api.manage.ManagedCredentialSet managed_credentials = 16; } message VpnPortalClientPatch { diff --git a/easytier-proto/proto/api_manage.proto b/easytier-proto/proto/api_manage.proto index 6f67a901..398dc3e6 100644 --- a/easytier-proto/proto/api_manage.proto +++ b/easytier-proto/proto/api_manage.proto @@ -104,6 +104,21 @@ message NetworkConfig { optional uint32 socket_mark = 67; repeated NetworkPeerConfig peers = 68; optional VpnPortalConfig vpn_portal_config = 69; + repeated ManagedCredentialConfig managed_credentials = 71; +} + +message ManagedCredentialConfig { + string credential_id = 1; + string credential_secret = 2; + repeated string groups = 3; + bool allow_relay = 4; + repeated string allowed_proxy_cidrs = 5; + int64 expiry_unix = 6; + optional bool reusable = 7; +} + +message ManagedCredentialSet { + repeated ManagedCredentialConfig entries = 1; } message VpnPortalClientConfig { diff --git a/easytier-proto/src/api.rs b/easytier-proto/src/api.rs index efc552c7..9105b4bc 100644 --- a/easytier-proto/src/api.rs +++ b/easytier-proto/src/api.rs @@ -335,6 +335,21 @@ pub mod manage { #[cfg(feature = "json-rpc")] include!(concat!(env!("OUT_DIR"), "/api.manage.serde.rs")); + impl std::fmt::Debug for ManagedCredentialConfig { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ManagedCredentialConfig") + .field("credential_id", &self.credential_id) + .field("credential_secret", &"") + .field("groups", &self.groups) + .field("allow_relay", &self.allow_relay) + .field("allowed_proxy_cidrs", &self.allowed_proxy_cidrs) + .field("expiry_unix", &self.expiry_unix) + .field("reusable", &self.reusable) + .finish() + } + } + impl std::fmt::Debug for VpnPortalConfig { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { formatter @@ -379,6 +394,19 @@ mod tests { assert!(!debug.contains("private-key-material")); } + #[test] + fn managed_credential_debug_redacts_secret() { + let credential = super::manage::ManagedCredentialConfig { + credential_id: "managed".to_owned(), + credential_secret: "private-key-material".to_owned(), + ..Default::default() + }; + + let debug = format!("{credential:?}"); + assert!(debug.contains("")); + assert!(!debug.contains("private-key-material")); + } + #[derive(Clone, Default)] struct WebClientServiceJsonCallHandler; diff --git a/easytier-web/src/client_manager/runtime_reconcile.rs b/easytier-web/src/client_manager/runtime_reconcile.rs index 8c10d136..603b78ff 100644 --- a/easytier-web/src/client_manager/runtime_reconcile.rs +++ b/easytier-web/src/client_manager/runtime_reconcile.rs @@ -15,7 +15,8 @@ use easytier::{ }, instance::{InstanceIdentifier, instance_identifier}, manage::{ - ConfigSource as RpcConfigSource, GetNetworkInstanceConfigRequest, NetworkConfig, + ConfigSource as RpcConfigSource, GetNetworkInstanceConfigRequest, + ManagedCredentialConfig, ManagedCredentialSet, NetworkConfig, RunNetworkInstanceRequest, }, }, @@ -66,6 +67,7 @@ fn hot_patch_base(config: &NetworkConfig) -> anyhow::Result { // VPN portal clients are diffed separately; the listener identity // (address and private key) decides between patch and recreate. config.vpn_portal_config = None; + config.managed_credentials.clear(); if config.dhcp.unwrap_or_default() { config.virtual_ipv4 = None; config.network_length = None; @@ -256,6 +258,12 @@ fn client_name_only(name: &str) -> easytier::proto::api::manage::VpnPortalClient } } +fn normalized_managed_credentials( + config: &NetworkConfig, +) -> anyhow::Result> { + Ok(NetworkConfig::new_from_config(config.gen_config()?)?.managed_credentials) +} + fn web_source_runtime_patch( current: &NetworkConfig, desired: &NetworkConfig, @@ -328,6 +336,13 @@ fn web_source_runtime_patch( (Some(_), None) | (None, Some(_)) => return Ok(None), (None, None) => {} } + let current_managed_credentials = normalized_managed_credentials(current)?; + let desired_managed_credentials = normalized_managed_credentials(desired)?; + if current_managed_credentials != desired_managed_credentials { + patch.managed_credentials = Some(ManagedCredentialSet { + entries: desired_managed_credentials, + }); + } Ok(Some(patch)) } @@ -339,7 +354,7 @@ fn ensure_runtime_config_converged( let patch = web_source_runtime_patch(current, desired)?; match patch { Some(patch) if patch == InstanceConfigPatch::default() => Ok(()), - Some(patch) => anyhow::bail!("runtime config still needs patch after reconcile: {patch:?}"), + Some(_) => anyhow::bail!("runtime config still needs patch after reconcile"), None => anyhow::bail!("runtime config still needs full overwrite after reconcile"), } } @@ -771,6 +786,27 @@ mod tests { assert!(patch.is_none()); } + #[test] + fn runtime_patch_replaces_managed_credentials_without_full_run() { + let current = config_with_port_forwards(Vec::new()); + let mut desired = current.clone(); + desired.managed_credentials = vec![ManagedCredentialConfig { + credential_id: "managed".to_owned(), + credential_secret: "credential-secret".to_owned(), + expiry_unix: 2_000_000_000, + ..Default::default() + }]; + + let action = prepare_web_source_runtime_reconcile_from_current(¤t, desired) + .expect("prepare reconcile"); + let RuntimeReconcileAction::Patch(patch) = action else { + panic!("managed credential change must use a hot patch"); + }; + let managed = patch.managed_credentials.expect("managed credential patch"); + assert_eq!(managed.entries.len(), 1); + assert_eq!(managed.entries[0].credential_id, "managed"); + } + #[test] fn runtime_patch_rejects_routes_change() { let mut current = config_with_port_forwards(Vec::new()); diff --git a/easytier-web/src/db/mod.rs b/easytier-web/src/db/mod.rs index 01fe054c..1b2d2c94 100644 --- a/easytier-web/src/db/mod.rs +++ b/easytier-web/src/db/mod.rs @@ -11,6 +11,8 @@ use sea_orm::{ }; use sea_orm_migration::MigratorTrait as _; use sqlx::{Sqlite, SqlitePool, migrate::MigrateDatabase as _, types::chrono}; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt as _; use uuid::Uuid; use crate::migrator; @@ -18,6 +20,35 @@ use async_trait::async_trait; pub type UserIdInDb = i32; +#[cfg(unix)] +fn restrict_database_file_permissions(db_path: &str) -> anyhow::Result<()> { + if db_path.ends_with(":memory:") || db_path.contains("mode=memory") { + return Ok(()); + } + let path = db_path + .strip_prefix("sqlite://") + .or_else(|| db_path.strip_prefix("sqlite:")) + .unwrap_or(db_path); + let path = path + .strip_prefix("file:") + .unwrap_or(path) + .split('?') + .next() + .filter(|path| !path.is_empty()); + let Some(path) = path else { + return Ok(()); + }; + let mut permissions = std::fs::metadata(path)?.permissions(); + permissions.set_mode(0o600); + std::fs::set_permissions(path, permissions)?; + Ok(()) +} + +#[cfg(not(unix))] +fn restrict_database_file_permissions(_db_path: &str) -> anyhow::Result<()> { + Ok(()) +} + #[derive(Debug, Clone)] pub struct Db { db_path: String, @@ -48,6 +79,7 @@ impl Db { tracing::info!("Database not found, creating a new one"); Sqlite::create_database(db_path).await?; } + restrict_database_file_permissions(db_path)?; let db = sqlx::pool::PoolOptions::new() .max_lifetime(None) diff --git a/easytier/src/common/credential_manager.rs b/easytier/src/common/credential_manager.rs index 16b87972..7bd270f0 100644 --- a/easytier/src/common/credential_manager.rs +++ b/easytier/src/common/credential_manager.rs @@ -1,6 +1,6 @@ use std::{io::Write, path::PathBuf, sync::Arc}; -use atomic_write_file::AtomicWriteFile; +use atomic_write_file::{AtomicWriteFile, OpenOptions}; use easytier_core::peers::credential_manager::CredentialStorage; struct FileCredentialStorage { @@ -9,21 +9,33 @@ struct FileCredentialStorage { impl CredentialStorage for FileCredentialStorage { fn load(&self) -> anyhow::Result> { - let Ok(serialized) = std::fs::read_to_string(&self.path) else { - return Ok(None); + let serialized = match std::fs::read_to_string(&self.path) { + Ok(serialized) => serialized, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error.into()), }; tracing::info!(path = %self.path.display(), "loaded credentials"); Ok(Some(serialized)) } fn store(&self, serialized_credentials: &str) -> anyhow::Result<()> { - let mut file = AtomicWriteFile::open(&self.path)?; + let mut file = restricted_atomic_file(&self.path)?; file.write_all(serialized_credentials.as_bytes())?; file.commit()?; Ok(()) } } +fn restricted_atomic_file(path: &std::path::Path) -> std::io::Result { + let mut options = OpenOptions::new(); + #[cfg(unix)] + { + atomic_write_file::unix::OpenOptionsExt::preserve_mode(&mut options, false); + std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600); + } + options.open(path) +} + pub(crate) fn runtime_credential_storage( path: Option, ) -> Option> { @@ -48,5 +60,28 @@ mod tests { storage.load().unwrap().as_deref(), Some("{\"credential\":false}") ); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + + assert_eq!( + std::fs::metadata(&storage.path) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600 + ); + } + } + + #[test] + fn file_storage_reports_read_errors() { + let directory = tempfile::tempdir().unwrap(); + let storage = FileCredentialStorage { + path: directory.path().to_path_buf(), + }; + + assert!(storage.load().is_err()); } } diff --git a/easytier/src/instance/config_storage.rs b/easytier/src/instance/config_storage.rs index d27e4034..c76a1d86 100644 --- a/easytier/src/instance/config_storage.rs +++ b/easytier/src/instance/config_storage.rs @@ -1,4 +1,6 @@ -use std::path::Path; +use std::{io::Write as _, path::Path}; + +use atomic_write_file::{AtomicWriteFile, OpenOptions}; use easytier_core::management::{ConfigFileControl, ConfigFilePermission, ConfigFileStorage}; @@ -31,7 +33,20 @@ impl ConfigFileStorage for NativeConfigFileStorage { } async fn write(&self, path: &Path, contents: &[u8]) -> anyhow::Result<()> { - tokio::fs::write(path, contents).await?; + let path = path.to_owned(); + let contents = contents.to_owned(); + tokio::task::spawn_blocking(move || { + let mut options = OpenOptions::new(); + #[cfg(unix)] + { + atomic_write_file::unix::OpenOptionsExt::preserve_mode(&mut options, false); + std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600); + } + let mut file: AtomicWriteFile = options.open(path)?; + file.write_all(&contents)?; + file.commit() + }) + .await??; Ok(()) } @@ -40,3 +55,28 @@ impl ConfigFileStorage for NativeConfigFileStorage { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn config_write_is_atomic_and_private() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("instance.toml"); + let storage = NativeConfigFileStorage; + + storage.write(&path, b"first").await.unwrap(); + storage.write(&path, b"second").await.unwrap(); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "second"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + + assert_eq!( + std::fs::metadata(path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + } +} diff --git a/easytier/src/instance/test_instance.rs b/easytier/src/instance/test_instance.rs index 0f03af81..3d815950 100644 --- a/easytier/src/instance/test_instance.rs +++ b/easytier/src/instance/test_instance.rs @@ -95,7 +95,7 @@ impl TestConfigPatcher { &self, patch: crate::proto::api::config::InstanceConfigPatch, ) -> anyhow::Result<()> { - easytier_core::management::apply_config_patch(&self.core, patch).await + easytier_core::management::apply_config_patch(&self.core, patch, None).await } } diff --git a/easytier/src/tests/credential_tests.rs b/easytier/src/tests/credential_tests.rs index f373e216..91780929 100644 --- a/easytier/src/tests/credential_tests.rs +++ b/easytier/src/tests/credential_tests.rs @@ -30,7 +30,7 @@ const PUBLIC_SERVER_NETWORK_NAME: &str = "__public_server__"; const PUBLIC_SERVER_SHARED_SECRET: &str = "public-server-shared-secret"; const NEED_P2P_ADMIN_NETWORK_NAME: &str = "need_p2p_credential_test_network"; -fn generate_credential( +async fn generate_credential( admin: &Instance, groups: Vec, allow_relay: bool, @@ -46,9 +46,10 @@ fn generate_credential( None, true, ) + .await } -fn generate_credential_with_options( +async fn generate_credential_with_options( admin: &Instance, groups: Vec, allow_relay: bool, @@ -184,7 +185,7 @@ async fn create_credential_config( ipv6: &str, ) -> TomlConfigLoader { let (_cred_id, cred_secret) = - generate_credential(admin_inst, vec![], false, vec![], Duration::from_secs(3600)); + generate_credential(admin_inst, vec![], false, vec![], Duration::from_secs(3600)).await; build_credential_config( admin_inst @@ -481,7 +482,8 @@ async fn credential_peers_p2p_to_need_p2p_admin_through_public_server( Duration::from_secs(3600), Some("credential-peer-a".to_string()), false, - ); + ) + .await; let (_credential_b_id, credential_b_secret) = generate_credential_with_options( &admin_inst, vec![], @@ -490,7 +492,8 @@ async fn credential_peers_p2p_to_need_p2p_admin_through_public_server( Duration::from_secs(3600), Some("credential-peer-b".to_string()), false, - ); + ) + .await; admin_inst .get_global_ctx() .issue_event(GlobalCtxEvent::CredentialChanged); @@ -593,7 +596,7 @@ async fn credential_peers_p2p_to_need_p2p_admin_through_public_server( .await; } -fn create_generated_credential_config( +async fn create_generated_credential_config( admin_inst: &Instance, inst_name: &str, ns: Option<&str>, @@ -601,7 +604,7 @@ fn create_generated_credential_config( ipv6: &str, ) -> (TomlConfigLoader, String) { let (cred_id, cred_secret) = - generate_credential(admin_inst, vec![], false, vec![], Duration::from_secs(3600)); + generate_credential(admin_inst, vec![], false, vec![], Duration::from_secs(3600)).await; let config = build_credential_config( admin_inst .get_global_ctx() @@ -881,7 +884,8 @@ async fn credential_relay_capability(#[case] allow_relay: bool) { false, vec![], Duration::from_secs(3600), - ); + ) + .await; let (_cred_b_id, cred_b_secret) = generate_credential( &admin_inst, @@ -889,7 +893,8 @@ async fn credential_relay_capability(#[case] allow_relay: bool) { false, vec![], Duration::from_secs(3600), - ); + ) + .await; let (_cred_c_id, cred_c_secret) = generate_credential( &admin_inst, @@ -897,7 +902,8 @@ async fn credential_relay_capability(#[case] allow_relay: bool) { allow_relay, vec![], Duration::from_secs(3600), - ); + ) + .await; // Create credential A on ns_c1 let cred_a_config = { @@ -1215,7 +1221,8 @@ async fn credential_revocation_propagates() { false, vec![], Duration::from_secs(3600), - ); + ) + .await; // Create credential node let cred_config = { @@ -1335,7 +1342,8 @@ async fn credential_non_reusable_allows_only_one_peer() { Duration::from_secs(3600), None, false, - ); + ) + .await; let network_name = admin_inst .get_global_ctx() @@ -1582,7 +1590,8 @@ async fn credential_unknown_via_shared_rejected(#[values(true, false)] test_revo Some("ns_c2"), "10.144.144.5", "fd00::5/64", - ); + ) + .await; (config, Some(cred_id)) } else { ( @@ -1841,7 +1850,8 @@ async fn credential_non_reusable_across_two_admins_allows_only_one_peer() { Duration::from_secs(3600), None, false, - ); + ) + .await; admin_a_inst .get_global_ctx() .issue_event(GlobalCtxEvent::CredentialChanged); diff --git a/easytier/src/tests/three_node.rs b/easytier/src/tests/three_node.rs index 8ab5da90..252eb16f 100644 --- a/easytier/src/tests/three_node.rs +++ b/easytier/src/tests/three_node.rs @@ -2210,6 +2210,7 @@ pub async fn wireguard_vpn_portal_dynamic_clients() { }], ..Default::default() }, + None, ) .await .unwrap(); @@ -2239,6 +2240,7 @@ pub async fn wireguard_vpn_portal_dynamic_clients() { }], ..Default::default() }, + None, ) .await .unwrap_err(); @@ -2301,6 +2303,7 @@ pub async fn wireguard_vpn_portal_dynamic_clients() { }], ..Default::default() }, + None, ) .await .unwrap();