feat(credentials): manage declarative credentials through TOML (#2515)

* feat(credentials): manage declarative credentials through TOML

Make managed credentials part of the canonical TOML configuration and
load them before peers can authenticate.

Reuse ConfigRpc hot patches to durably replace the configured credential
set without restarting the instance. Serialize credential mutations so
base, managed, and ephemeral keys cannot race into conflicts.

Remove the managed overlay file format, digest protocol, capability
negotiation, force reconciliation, and database CAS machinery. Redact
credential secrets from debug output and management events. Write
credential-bearing files atomically with private permissions.

* fix(core): release JoinSet reapers with their owners

Pass weak task-set references into background reapers so they cannot
retain the JoinSet they are meant to collect. This lets stale smoltcp
bridge tasks terminate when an IPv4 generation is replaced.

Add ownership and TCP generation-replacement regressions covering the
production port-forward failure.
This commit is contained in:
KKRainbow
2026-08-22 16:30:51 +08:00
committed by GitHub
parent 8794e12a26
commit 3fe427bc99
44 changed files with 1600 additions and 259 deletions
+48
View File
@@ -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),
}]
);
}
}
+51 -2
View File
@@ -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 {
+85
View File
@@ -270,6 +270,11 @@ pub trait ConfigLoader: Send + Sync {
}
fn set_credential_file(&self, _path: Option<std::path::PathBuf>) {}
fn get_managed_credentials(&self) -> Vec<ManagedCredentialConfig> {
Vec::new()
}
fn set_managed_credentials(&self, _credentials: Vec<ManagedCredentialConfig>) {}
fn get_network_config_source(&self) -> ConfigSource {
ConfigSource::User
}
@@ -471,6 +476,41 @@ pub struct VpnPortalClientConfig {
pub groups: Vec<String>,
}
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<String>,
#[serde(default)]
pub allow_relay: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub allowed_proxy_cidrs: Vec<String>,
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", &"<redacted>")
.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<Vec<String>>,
credential_file: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
managed_credentials: Vec<ManagedCredentialConfig>,
source: Option<ConfigSourceConfig>,
}
@@ -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<Self, anyhow::Error> {
@@ -1088,6 +1135,14 @@ impl ConfigLoader for TomlConfig {
self.config.lock().unwrap().credential_file = path;
}
fn get_managed_credentials(&self) -> Vec<ManagedCredentialConfig> {
self.config.lock().unwrap().managed_credentials.clone()
}
fn set_managed_credentials(&self, credentials: Vec<ManagedCredentialConfig>) {
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("<redacted>").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("<redacted>"));
assert!(!TomlConfig::default().dump().contains("managed_credentials"));
}
#[test]
fn hostname_normalization_is_portable_and_has_no_host_fallback() {
let absent = TomlConfig::default();
@@ -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"),
)));
}
@@ -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()),
+16 -3
View File
@@ -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<T>(tasks: Arc<Mutex<JoinSet<T>>>, origin: &'static str)
pub(crate) async fn reap_joinset_background<T>(tasks: Weak<Mutex<JoinSet<T>>>, 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));
+5 -1
View File
@@ -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![];
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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",
));
@@ -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());
+7 -3
View File
@@ -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(()));
+1 -1
View File
@@ -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();
@@ -1144,6 +1144,7 @@ mod tests {
let peer = Arc::new(
PeerManagerCore::new(
portable,
Vec::new(),
store.clone(),
Arc::new(()),
packet_sender,
+29
View File
@@ -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() {
+16 -2
View File
@@ -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<crate::peers::credential_manager::CredentialManager> {
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<MetricSnapshot> {
self.peer_manager.stats_manager().get_all_metrics()
}
+6 -6
View File
@@ -291,6 +291,12 @@ impl<F: InstanceFactory> InstanceManager<F> {
.remove(&instance_id)
}
pub fn config_control(&self, instance_id: Uuid) -> Option<ConfigFileControl> {
self.config_controls
.get(&instance_id)
.map(|control| control.clone())
}
pub fn mutation_lock(&self) -> Arc<tokio::sync::Mutex<()>> {
self.mutation_lock.clone()
}
@@ -405,12 +411,6 @@ where
self.list()
}
pub fn config_control(&self, instance_id: Uuid) -> Option<ConfigFileControl> {
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"))?
+3
View File
@@ -184,6 +184,8 @@ pub struct CoreInstanceConfig {
pub connectivity: CoreConnectivityConfig,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vpn_portal: Option<PortalRuntimeConfig>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub managed_credentials: Vec<crate::config::toml::ManagedCredentialConfig>,
}
#[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,
+169
View File
@@ -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<Arc<CoreInstance<TestHost>>> {
build_with_engines(config, WrappedTransportEngines::default())
}
#[cfg(feature = "management")]
struct RecordingConfigPatchPersistence {
writes: std::sync::Mutex<Vec<String>>,
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::<cidr::Ipv4Inet>().unwrap().into()),
..Default::default()
},
None,
)
.await
.unwrap_err();
@@ -909,6 +1077,7 @@ virtual_ip = "10.82.0.2"
}],
..Default::default()
},
None,
)
.await
.unwrap();
@@ -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<F, H>(
manager: Arc<InstanceManager<F>>,
registry: &ServiceRegistry,
storage: Arc<dyn ConfigFileStorage>,
) where
F: InstanceFactory<Instance = CoreInstance<H>>,
H: CoreInstanceHost,
{
let rpc = InstanceManagementRpc::<F>::new(manager.clone());
let rpc = InstanceManagementRpc::<F>::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()), "");
+113 -12
View File
@@ -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<H>(
instance: &Arc<CoreInstance<H>>,
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<bool> = 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::<Vec<_>>();
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<H>(
instance: &CoreInstance<H>,
candidate: &TomlConfig,
@@ -227,6 +307,27 @@ fn trace_patchables<T: Debug>(patches: &[Patchable<T>]) {
}
}
#[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<PortForwardPatch>) -> anyhow::Result<()> {
if patches.is_empty() {
return Ok(());
+6 -3
View File
@@ -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, H>(
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::<F>::new(instances, hooks, storage)),
@@ -102,7 +102,10 @@ pub(crate) fn register_web_client_rpc<F, H>(
F::Error: std::fmt::Debug + std::fmt::Display + Send + Sync + 'static,
H: CoreInstanceHost,
{
let config_rpc = super::instance_rpc::InstanceManagementRpc::<F>::new(instances.clone());
let config_rpc = super::instance_rpc::InstanceManagementRpc::<F>::new_with_config_storage(
instances.clone(),
storage.clone(),
);
registry.register(ConfigRpcServer::new(config_rpc), "");
registry.register(
WebClientServiceServer::new(ProcessManagementRpc::<F>::new(instances, hooks, storage)),
@@ -63,6 +63,8 @@ pub trait ConfigFileStorage: Send + Sync + 'static {
async fn read(&self, path: &Path) -> anyhow::Result<Option<Vec<u8>>>;
/// 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<()>;
@@ -26,7 +26,7 @@ where
) -> rpc_types::error::Result<PatchConfigResponse> {
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())
}
@@ -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<R> {
resolver: R,
#[cfg(feature = "web-client")]
config_patch_persistence: Option<Arc<dyn ConfigPatchPersistence>>,
}
impl<R> Clone for ResolvedInstanceManagementRpc<R>
@@ -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<InstanceManager<F>>) -> 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<InstanceManager<F>>,
storage: Arc<dyn ConfigFileStorage>,
) -> 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<F, H>
where
F: InstanceFactory,
H: CoreInstanceHost,
{
manager: Arc<InstanceManager<F>>,
_host: std::marker::PhantomData<fn() -> H>,
}
#[async_trait::async_trait]
#[cfg(feature = "web-client")]
impl<F, H> ConfigPatchPersistence for ManagerPathlessConfigPatchPersistence<F, H>
where
F: InstanceFactory<Instance = CoreInstance<H>>,
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<F, H>
where
F: InstanceFactory,
H: CoreInstanceHost,
{
manager: Arc<InstanceManager<F>>,
storage: Arc<dyn ConfigFileStorage>,
_host: std::marker::PhantomData<fn() -> 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<F, H> ConfigPatchPersistence for ManagerConfigPatchPersistence<F, H>
where
F: InstanceFactory<Instance = CoreInstance<H>>,
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<Option<Vec<u8>>> {
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);
}
}
+3 -3
View File
@@ -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")]
+11 -10
View File
@@ -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,
@@ -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();
+510 -179
View File
@@ -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<Self, String> {
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::<cidr::IpCidr>()
.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<String, CredentialEntry>,
managed: HashMap<String, CredentialEntry>,
pending_managed: Option<HashMap<String, CredentialEntry>>,
ephemeral: HashMap<uuid::Uuid, CredentialEntry>,
}
pub(crate) struct CredentialManager {
credentials: Mutex<HashMap<String, CredentialEntry>>,
ephemeral_credentials: Mutex<HashMap<uuid::Uuid, CredentialEntry>>,
state: Mutex<CredentialState>,
storage: Option<Arc<dyn CredentialStorage>>,
storage_write: Mutex<()>,
storage_load_error: Option<String>,
}
/// 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<dyn CredentialStorage>) -> 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<R>(&self, f: impl FnOnce(&HashMap<String, CredentialEntry>) -> R) -> R {
let credentials = self.credentials.lock().unwrap();
f(&credentials)
}
pub fn generate_credential_with_options(
&self,
groups: Vec<String>,
@@ -170,61 +221,68 @@ impl CredentialManager {
ttl: Duration,
credential_id: Option<String>,
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<String>,
allow_relay: bool,
allowed_proxy_cidrs: Vec<String>,
ttl: Duration,
credential_id: Option<String>,
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<GeneratedCredential, String> {
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<bool, String> {
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<String>,
) -> Option<bool> {
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<TrustedCredentialPubkeyProof> {
@@ -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::<Vec<_>>();
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<CredentialInfo> {
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::<Vec<_>>();
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<ManagedCredentialReplacement<'_>, 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<Item = (&String, &CredentialEntry)> {
state.managed.iter().chain(
state
.pending_managed
.iter()
.flat_map(|pending| pending.iter()),
)
}
fn managed_values(state: &CredentialState) -> impl Iterator<Item = &CredentialEntry> {
Self::managed_entries(state).map(|(_, entry)| entry)
}
fn build_managed_entries(
credentials: &[ManagedCredentialConfig],
) -> Result<HashMap<String, CredentialEntry>, 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<String, CredentialEntry>,
) -> 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<Vec<u8>> {
@@ -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<String, CredentialEntry>) -> 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")
);
}
}
@@ -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",
));
+9
View File
@@ -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<ManagedCredentialConfig>,
runtime_config: CoreRuntimeConfigStore,
stun_info_source: Arc<dyn PeerStunInfoSource>,
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
+1 -1
View File
@@ -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",
));
+8 -1
View File
@@ -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);