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();