diff --git a/Cargo.lock b/Cargo.lock index f17937e0..3c6dfbb7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2942,6 +2942,7 @@ dependencies = [ "thunk-rs", "tokio", "tokio-util", + "toml 1.1.6+spec-1.1.0", "tower", "tower-http 0.7.1", "tower-sessions", diff --git a/docs/core-architecture.md b/docs/core-architecture.md index d32ee1a6..0b0abf17 100644 --- a/docs/core-architecture.md +++ b/docs/core-architecture.md @@ -502,6 +502,28 @@ behavior: - gateway and connectivity settings; - runtime configuration patches. +Manual routing retains three distinct states: absent routes select automatic +routing, a present empty list installs no remote subnet routes, and a present +nonempty list selects explicit routes. `NetworkConfig.enable_manual_routes` +preserves this distinction on readback. `InstanceConfigPatch` applies its +optional `enable_manual_routes` together with route edits in one route +sub-patch: true retains an empty manual list and false restores automatic +routing. Omitting the mode preserves released RoutePatch behavior, including +returning to automatic routing when the edited list becomes empty. This does +not change the ordered partial-commit contract of a multi-field patch. + +`HeartbeatRequest.support_manual_routes_patch` advertises this complete route +contract, including typed empty-manual readback, on the WebClient Session. +The Web controller uses the current authenticated heartbeat to choose route +patches; observed instance configuration caches contain no capability state. +Released clients omit the flag and still receive +full configuration replacements for route changes. For their ambiguous empty +readback, Web reads the existing `GetConfig.toml_config`, or, when absent, the +instance-scoped `ShowNodeInfo` TOML used by 2.6.4. It reads only the instance ID +and route field, requires a matching instance, and confirms the actual mode +before recording application. Supporting clients use typed readback directly; +compact clients with raw GetConfig need no Peer management RPC. + Host persistence is an effect following a successful core transaction. A Host Adapter must not call back into an instance to obtain a hidden configuration snapshot while core is applying an operation. diff --git a/easytier-core/src/config/api.rs b/easytier-core/src/config/api.rs index 1f38eed9..3566cfbc 100644 --- a/easytier-core/src/config/api.rs +++ b/easytier-core/src/config/api.rs @@ -107,9 +107,7 @@ pub fn network_config_from_toml(config: &TomlConfig) -> NetworkConfig { }); } - if let Some(routes) = config.get_routes() - && !routes.is_empty() - { + if let Some(routes) = config.get_routes() { result.enable_manual_routes = Some(true); result.routes = routes.iter().map(ToString::to_string).collect(); } diff --git a/easytier-core/src/config/api_input.rs b/easytier-core/src/config/api_input.rs index 5f4fbffa..61d27e63 100644 --- a/easytier-core/src/config/api_input.rs +++ b/easytier-core/src/config/api_input.rs @@ -653,9 +653,7 @@ impl NetworkConfigExt for NetworkConfig { }); } - if let Some(routes) = config.get_routes() - && !routes.is_empty() - { + if let Some(routes) = config.get_routes() { result.enable_manual_routes = Some(true); result.routes = routes.iter().map(|r| r.to_string()).collect(); } @@ -718,6 +716,45 @@ mod tests { } } + #[test] + fn manual_routes_preserve_empty_and_automatic_modes() { + for routes in [Vec::new(), vec!["10.20.0.0/24".to_owned()]] { + for enabled in [None, Some(false), Some(true)] { + let mut input = standalone_config(); + input.enable_manual_routes = enabled; + input.routes = routes.clone(); + let config = input.gen_config().unwrap(); + let expected = if enabled == Some(true) { + Some(routes.iter().map(|route| route.parse().unwrap()).collect()) + } else { + None + }; + assert_eq!(config.get_routes(), expected); + let readback = NetworkConfig::new_from_config(config.clone()).unwrap(); + assert_eq!( + readback.enable_manual_routes, + enabled.filter(|value| *value) + ); + assert_eq!(readback.gen_config().unwrap().get_routes(), expected); + assert_eq!( + TomlConfigLoader::new_from_str(&config.dump()) + .unwrap() + .get_routes(), + expected + ); + #[cfg(feature = "web-client")] + { + let projected = super::super::api::network_config_from_toml(&config); + assert_eq!( + projected.enable_manual_routes, + readback.enable_manual_routes + ); + assert_eq!(projected.routes, readback.routes); + } + } + } + } + #[test] fn dhcp_cidr_survives_api_and_toml_round_trips() { let mut input = standalone_config(); diff --git a/easytier-core/src/instance/tests.rs b/easytier-core/src/instance/tests.rs index 202f5285..b5cf282c 100644 --- a/easytier-core/src/instance/tests.rs +++ b/easytier-core/src/instance/tests.rs @@ -884,6 +884,88 @@ network_secret = "network-secret" assert!(error.to_string().contains("instance is not ready")); } + #[cfg(feature = "management")] + #[tokio::test] + async fn manual_route_patch_preserves_empty_mode_without_restart() { + use easytier_proto::api::config::{ConfigPatchAction, InstanceConfigPatch, RoutePatch}; + + let (packet_sink, _packet_receiver) = tokio::sync::mpsc::channel(16); + let config = TomlConfig::new_from_str( + "[network_identity]\nnetwork_name = 'manual-routes'\nnetwork_secret = 'secret'", + ) + .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 persistence = RecordingConfigPatchPersistence { + writes: std::sync::Mutex::new(Vec::new()), + fail: AtomicBool::new(true), + }; + let route: cidr::Ipv4Cidr = "10.20.0.0/24".parse().unwrap(); + let add = RoutePatch { + action: ConfigPatchAction::Add as i32, + cidr: Some("10.20.0.0/24".parse::().unwrap().into()), + }; + let clear = RoutePatch { + action: ConfigPatchAction::Clear as i32, + ..Default::default() + }; + let failed = crate::management::apply_config_patch( + &instance, + InstanceConfigPatch { + enable_manual_routes: Some(true), + routes: vec![add], + ..Default::default() + }, + Some(&persistence), + ) + .await; + assert!(failed.is_err()); + assert_eq!(instance.toml_config().unwrap().get_routes(), None); + assert_eq!( + instance.runtime_config.snapshot().services.manual_routes, + None + ); + assert!(persistence.writes.lock().unwrap().is_empty()); + persistence.fail.store(false, Ordering::Relaxed); + + for (mode, patches, expected) in [ + (Some(true), Vec::new(), Some(Vec::new())), + (Some(true), vec![add], Some(vec![route])), + (Some(true), vec![clear], Some(Vec::new())), + (Some(false), Vec::new(), None), + // Released callers that omit the new mode keep their old behavior. + (None, vec![add], Some(vec![route])), + (None, vec![clear], None), + ] { + crate::management::apply_config_patch( + &instance, + InstanceConfigPatch { + enable_manual_routes: mode, + routes: patches, + ..Default::default() + }, + Some(&persistence), + ) + .await + .unwrap(); + assert_eq!(instance.toml_config().unwrap().get_routes(), expected); + assert_eq!( + instance.runtime_config.snapshot().services.manual_routes, + expected + .as_ref() + .map(|routes| routes.iter().copied().collect()) + ); + let writes = persistence.writes.lock().unwrap(); + let persisted = TomlConfig::new_from_str(writes.last().unwrap()).unwrap(); + assert_eq!(persisted.get_routes(), expected); + assert_eq!(instance.peer_id(), peer_id); + assert_eq!(instance.state(), CoreInstanceState::Running); + } + instance.stop().await; + } + #[cfg(feature = "management")] #[tokio::test] async fn managed_credential_patch_is_durable_atomic_and_does_not_restart() { diff --git a/easytier-core/src/management/full/config_patch.rs b/easytier-core/src/management/full/config_patch.rs index f5de2d50..d4714d97 100644 --- a/easytier-core/src/management/full/config_patch.rs +++ b/easytier-core/src/management/full/config_patch.rs @@ -63,7 +63,7 @@ where validate_persist_and_commit_candidate(instance, &config, &candidate, persistence).await?; result?; - let result = patch_routes(&candidate, patch.routes); + let result = patch_routes(&candidate, patch.routes, patch.enable_manual_routes); validate_persist_and_commit_candidate(instance, &config, &candidate, persistence).await?; result?; @@ -455,15 +455,25 @@ fn patch_proxy_networks( Ok(()) } -fn patch_routes(config: &TomlConfig, patches: Vec) -> anyhow::Result<()> { - if patches.is_empty() { +fn patch_routes( + config: &TomlConfig, + patches: Vec, + enable_manual_routes: Option, +) -> anyhow::Result<()> { + if patches.is_empty() && enable_manual_routes.is_none() { return Ok(()); } let mut current = config.get_routes().unwrap_or_default(); let patches = patches.into_iter().map(Into::into).collect::>(); trace_patchables(&patches); config::patch_vec(&mut current, patches); - config.set_routes((!current.is_empty()).then_some(current)); + // Mode and list form one configuration update. An explicit mode supports + // empty manual routes; callers omitting it retain the released semantics. + config.set_routes( + enable_manual_routes + .unwrap_or(!current.is_empty()) + .then_some(current), + ); Ok(()) } diff --git a/easytier-core/src/management/full/web_client.rs b/easytier-core/src/management/full/web_client.rs index d5a57cee..26cf8567 100644 --- a/easytier-core/src/management/full/web_client.rs +++ b/easytier-core/src/management/full/web_client.rs @@ -435,6 +435,7 @@ fn build_heartbeat_request( .map(Into::into) .collect(), support_heartbeat_policy: true, + support_manual_routes_patch: true, } } @@ -748,6 +749,8 @@ mod tests { vec![failed] ); assert!(request.support_heartbeat_policy); + assert!(request.support_manual_routes_patch); + assert!(!HeartbeatRequest::default().support_manual_routes_patch); } #[test] diff --git a/easytier-proto/proto/api_config.proto b/easytier-proto/proto/api_config.proto index ae299b7b..af48eeca 100644 --- a/easytier-proto/proto/api_config.proto +++ b/easytier-proto/proto/api_config.proto @@ -31,6 +31,9 @@ message InstanceConfigPatch { repeated VpnPortalClientPatch vpn_portal_clients = 15; api.manage.ManagedCredentialSet managed_credentials = 16; optional bool prefer_peer_relay = 17; + // Applies with routes: true preserves an empty manual list; false restores + // automatic routes. Omission preserves legacy RoutePatch behavior. + optional bool enable_manual_routes = 18; } message VpnPortalClientPatch { diff --git a/easytier-proto/proto/web.proto b/easytier-proto/proto/web.proto index 1ce60766..bed4198b 100644 --- a/easytier-proto/proto/web.proto +++ b/easytier-proto/proto/web.proto @@ -24,6 +24,9 @@ message HeartbeatRequest { bool support_config_source = 9; repeated common.UUID failed_network_instances = 10; bool support_heartbeat_policy = 11; + // Supports explicit manual mode patches and typed empty-manual readback. + // Older peers silently ignore unknown patch fields; missing means false. + bool support_manual_routes_patch = 12; } message HeartbeatResponse { diff --git a/easytier-web/Cargo.toml b/easytier-web/Cargo.toml index 5c6d97f5..0ddd279b 100644 --- a/easytier-web/Cargo.toml +++ b/easytier-web/Cargo.toml @@ -67,6 +67,7 @@ clap = { workspace = true, features = [ ] } serde = { workspace = true, features = ["derive"] } serde_json.workspace = true +toml.workspace = true uuid = { workspace = true, features = [ "v4", "fast-rng", diff --git a/easytier-web/src/client_manager/runtime_reconcile.rs b/easytier-web/src/client_manager/runtime_reconcile.rs index 1989db63..0873050f 100644 --- a/easytier-web/src/client_manager/runtime_reconcile.rs +++ b/easytier-web/src/client_manager/runtime_reconcile.rs @@ -10,14 +10,14 @@ use easytier::{ acl::Acl, api::{ config::{ - AclPatch, ConfigPatchAction, InstanceConfigPatch, PatchConfigRequest, - PortForwardPatch, ProxyNetworkPatch, VpnPortalClientPatch, + AclPatch, ConfigPatchAction, GetConfigRequest, InstanceConfigPatch, + PatchConfigRequest, PortForwardPatch, ProxyNetworkPatch, RoutePatch, + VpnPortalClientPatch, }, - instance::{InstanceIdentifier, instance_identifier}, + instance::{InstanceIdentifier, ShowNodeInfoRequest, instance_identifier}, manage::{ - ConfigSource as RpcConfigSource, GetNetworkInstanceConfigRequest, - ManagedCredentialConfig, ManagedCredentialSet, NetworkConfig, - RunNetworkInstanceRequest, + ConfigSource as RpcConfigSource, ManagedCredentialConfig, ManagedCredentialSet, + NetworkConfig, RunNetworkInstanceRequest, }, }, common::{CompressionAlgoPb, FlagsPatch, Ipv4Inet as RpcIpv4Inet}, @@ -26,7 +26,7 @@ use easytier::{ }; use optionize::Retain; -use super::session::{SessionConfigClient, SessionRpcClient}; +use super::session::{SessionConfigClient, SessionPeerClient, SessionRpcClient}; pub(super) enum RuntimeReconcileAction { Unchanged(Box), @@ -284,6 +284,7 @@ fn is_automatic_windows_dev_name(dev_name: &str) -> bool { fn web_source_runtime_patch( current: &NetworkConfig, desired: &NetworkConfig, + support_manual_routes_patch: bool, ) -> anyhow::Result> { let mut current_base = hot_patch_base(current)?; let mut desired_base = hot_patch_base(desired)?; @@ -299,11 +300,30 @@ fn web_source_runtime_patch( } let current_hostname = current_base.hostname.take().unwrap_or_default(); let desired_hostname = desired_base.hostname.take().unwrap_or_default(); + let mut patch = InstanceConfigPatch::default(); + if support_manual_routes_patch { + let current_mode = current_base.enable_manual_routes.take().unwrap_or_default(); + let desired_mode = desired_base.enable_manual_routes.take().unwrap_or_default(); + let current_routes = std::mem::take(&mut current_base.routes); + let desired_routes = std::mem::take(&mut desired_base.routes); + if current_mode != desired_mode || current_routes != desired_routes { + patch.enable_manual_routes = Some(desired_mode); + patch.routes.push(RoutePatch { + action: ConfigPatchAction::Clear as i32, + ..Default::default() + }); + for route in desired_routes { + patch.routes.push(RoutePatch { + action: ConfigPatchAction::Add as i32, + cidr: Some(parse_rpc_ipv4_inet(&route)?), + }); + } + } + } if current_base != desired_base { return Ok(None); } - let mut patch = InstanceConfigPatch::default(); if desired.hostname.is_some() && current_hostname != desired_hostname { patch.hostname = Some(desired_hostname); } @@ -397,7 +417,9 @@ pub(super) fn ensure_runtime_config_converged( desired: &NetworkConfig, hostname_applied: bool, ) -> anyhow::Result<()> { - let patch = web_source_runtime_patch(current, desired)?; + // Capability changes how a difference is applied, never whether the + // observed configuration matches the desired configuration. + let patch = web_source_runtime_patch(current, desired, true)?; match patch { Some(mut patch) => { // Release 2.6.4 omits a configured hostname when it equals @@ -438,26 +460,83 @@ async fn run_web_source_instance( } pub(super) async fn get_runtime_config( - rpc_client: &mut SessionRpcClient, + config_client: &mut SessionConfigClient, + peer_client: &mut SessionPeerClient, inst_id: &str, + support_manual_routes_patch: bool, ) -> anyhow::Result { - rpc_client - .get_network_instance_config( + let response = config_client + .get_config( BaseController::default(), - GetNetworkInstanceConfigRequest { - inst_id: Some(inst_id.to_string().into()), + GetConfigRequest { + instance: Some(instance_identifier(inst_id)?), }, ) - .await? + .await?; + let mut config = response .config - .ok_or_else(|| anyhow::anyhow!("runtime returned empty config for {inst_id}")) + .ok_or_else(|| anyhow::anyhow!("runtime returned empty config for {inst_id}"))?; + if !support_manual_routes_patch && config.routes.is_empty() { + // Released 2.6.4 omits the manual-mode bit for routes=[]. Its + // instance-scoped ShowNodeInfo retains the original TOML. Clients + // with GetConfig.toml_config need no Peer RPC; supporting clients + // above already preserve the mode in the typed configuration. + let raw = if response.toml_config.is_empty() { + peer_client + .show_node_info( + BaseController::default(), + ShowNodeInfoRequest { + instance: Some(instance_identifier(inst_id)?), + }, + ) + .await? + .node_info + .ok_or_else(|| anyhow::anyhow!("runtime returned no node info for {inst_id}"))? + .config + } else { + response.toml_config + }; + restore_manual_routes_from_toml(&mut config, inst_id, &raw)?; + } + Ok(config) +} + +fn restore_manual_routes_from_toml( + config: &mut NetworkConfig, + inst_id: &str, + raw: &str, +) -> anyhow::Result<()> { + // Read only this compatibility field. Parsing a released client's entire + // TOML with the current schema would reject unrelated historical fields. + #[derive(serde::Deserialize)] + struct Routes { + instance_id: uuid::Uuid, + routes: Option>, + } + // Parser diagnostics can include credential-bearing TOML source lines. + let observed: Routes = toml::from_str(raw) + .map_err(|_| anyhow::anyhow!("runtime returned invalid route TOML for {inst_id}"))?; + anyhow::ensure!( + observed.instance_id == inst_id.parse::()?, + "runtime returned route TOML for a different instance" + ); + config.enable_manual_routes = observed.routes.as_ref().map(|_| true); + config.routes = observed + .routes + .unwrap_or_default() + .into_iter() + .map(|route| route.to_string()) + .collect(); + Ok(()) } pub(super) async fn prepare_web_source_runtime_reconcile( - rpc_client: &mut SessionRpcClient, + config_client: &mut SessionConfigClient, + peer_client: &mut SessionPeerClient, inst_id: &str, desired_config: NetworkConfig, is_running: bool, + support_manual_routes_patch: bool, ) -> anyhow::Result { if !is_running { return Ok(RuntimeReconcileAction::Run { @@ -466,16 +545,29 @@ pub(super) async fn prepare_web_source_runtime_reconcile( }); } - let current_config = get_runtime_config(rpc_client, inst_id).await?; + let current_config = get_runtime_config( + config_client, + peer_client, + inst_id, + support_manual_routes_patch, + ) + .await?; - prepare_web_source_runtime_reconcile_from_current(¤t_config, desired_config) + prepare_web_source_runtime_reconcile_from_current( + ¤t_config, + desired_config, + support_manual_routes_patch, + ) } pub(super) fn prepare_web_source_runtime_reconcile_from_current( current_config: &NetworkConfig, desired_config: NetworkConfig, + support_manual_routes_patch: bool, ) -> anyhow::Result { - let Some(patch) = web_source_runtime_patch(current_config, &desired_config)? else { + let Some(patch) = + web_source_runtime_patch(current_config, &desired_config, support_manual_routes_patch)? + else { let mut run_config = desired_config; if run_config.hostname.is_none() { run_config.hostname = current_config.hostname.clone(); @@ -497,16 +589,24 @@ pub(super) fn prepare_web_source_runtime_reconcile_from_current( pub(super) async fn apply_web_source_runtime_reconcile( rpc_client: &mut SessionRpcClient, config_client: &mut SessionConfigClient, + peer_client: &mut SessionPeerClient, inst_id: &str, desired_config: NetworkConfig, action: RuntimeReconcileAction, + support_manual_routes_patch: bool, ) -> anyhow::Result { match action { RuntimeReconcileAction::Unchanged(current_config) => Ok(*current_config), RuntimeReconcileAction::Run { config, overwrite } => { let hostname_applied = config.hostname.is_some(); run_web_source_instance(rpc_client, inst_id, *config, overwrite).await?; - let mut current_config = get_runtime_config(rpc_client, inst_id).await?; + let mut current_config = get_runtime_config( + config_client, + peer_client, + inst_id, + support_manual_routes_patch, + ) + .await?; ensure_runtime_config_converged(¤t_config, &desired_config, hostname_applied)?; restore_omitted_hostname(&mut current_config, &desired_config, hostname_applied); Ok(current_config) @@ -522,7 +622,13 @@ pub(super) async fn apply_web_source_runtime_reconcile( }, ) .await?; - let mut current_config = get_runtime_config(rpc_client, inst_id).await?; + let mut current_config = get_runtime_config( + config_client, + peer_client, + inst_id, + support_manual_routes_patch, + ) + .await?; ensure_runtime_config_converged(¤t_config, &desired_config, hostname_applied)?; restore_omitted_hostname(&mut current_config, &desired_config, hostname_applied); Ok(current_config) @@ -641,7 +747,7 @@ mod tests { "0.0.0.0:22121", ); - let patch = web_source_runtime_patch(¤t, &desired) + let patch = web_source_runtime_patch(¤t, &desired, false) .unwrap() .expect("client-only changes must be hot-patchable"); @@ -676,7 +782,7 @@ mod tests { "0.0.0.0:22121", ); - let patch = web_source_runtime_patch(¤t, &desired) + let patch = web_source_runtime_patch(¤t, &desired, false) .unwrap() .unwrap(); assert!(patch.vpn_portal_clients.is_empty()); @@ -693,7 +799,7 @@ mod tests { "0.0.0.0:22122", ); assert!( - web_source_runtime_patch(¤t, &desired) + web_source_runtime_patch(¤t, &desired, false) .unwrap() .is_none() ); @@ -710,7 +816,7 @@ mod tests { .unwrap() .wireguard_private_key = Some("bm90LXRoZS1zYW1lLWtleQ==".to_owned()); assert!( - web_source_runtime_patch(¤t, &different_key) + web_source_runtime_patch(¤t, &different_key, false) .unwrap() .is_none() ); @@ -725,12 +831,12 @@ mod tests { ); assert!( - web_source_runtime_patch(&without_portal, &with_portal) + web_source_runtime_patch(&without_portal, &with_portal, false) .unwrap() .is_none() ); assert!( - web_source_runtime_patch(&with_portal, &without_portal) + web_source_runtime_patch(&with_portal, &without_portal, false) .unwrap() .is_none() ); @@ -738,23 +844,23 @@ mod tests { let mut disabled = with_portal.clone(); disabled.vpn_portal_config.as_mut().unwrap().enabled = Some(false); assert!( - web_source_runtime_patch(&with_portal, &disabled) + web_source_runtime_patch(&with_portal, &disabled, false) .unwrap() .is_none() ); assert!( - web_source_runtime_patch(&disabled, &with_portal) + web_source_runtime_patch(&disabled, &with_portal, false) .unwrap() .is_none() ); assert_eq!( - web_source_runtime_patch(&disabled, &disabled).unwrap(), + web_source_runtime_patch(&disabled, &disabled, false).unwrap(), Some(InstanceConfigPatch::default()) ); let mut edited = disabled.clone(); edited.vpn_portal_config.as_mut().unwrap().clients.clear(); assert!( - web_source_runtime_patch(&disabled, &edited) + web_source_runtime_patch(&disabled, &edited, false) .unwrap() .is_none() ); @@ -774,7 +880,7 @@ mod tests { let desired = config_with_port_forwards(vec![port_forward(23000, 5174), port_forward(23007, 3389)]); - let patch = web_source_runtime_patch(¤t, &desired) + let patch = web_source_runtime_patch(¤t, &desired, false) .expect("build patch") .expect("hot patch"); @@ -796,7 +902,7 @@ mod tests { config_with_port_forwards(vec![port_forward(23000, 5174), port_forward(23007, 3389)]); let desired = config_with_port_forwards(vec![port_forward(23000, 5174)]); - let patch = web_source_runtime_patch(¤t, &desired) + let patch = web_source_runtime_patch(¤t, &desired, false) .expect("build patch") .expect("hot patch"); @@ -818,7 +924,7 @@ mod tests { config_with_port_forwards(vec![port_forward(23000, 5174), port_forward(23000, 5174)]); let desired = config_with_port_forwards(vec![port_forward(23000, 5174)]); - let patch = web_source_runtime_patch(¤t, &desired) + let patch = web_source_runtime_patch(¤t, &desired, false) .expect("build patch") .expect("hot patch"); @@ -849,7 +955,7 @@ mod tests { current.dev_name = Some("et_3_abcd".to_string()); let desired = config_with_port_forwards(Vec::new()); - let action = prepare_web_source_runtime_reconcile_from_current(¤t, desired) + let action = prepare_web_source_runtime_reconcile_from_current(¤t, desired, false) .expect("prepare reconcile"); assert!(matches!(action, RuntimeReconcileAction::Unchanged(_))); @@ -862,7 +968,7 @@ mod tests { let mut desired = config_with_port_forwards(Vec::new()); desired.dev_name = Some(String::new()); - let action = prepare_web_source_runtime_reconcile_from_current(¤t, desired) + let action = prepare_web_source_runtime_reconcile_from_current(¤t, desired, false) .expect("prepare reconcile"); assert!(matches!(action, RuntimeReconcileAction::Unchanged(_))); @@ -875,7 +981,7 @@ mod tests { let mut desired = config_with_port_forwards(Vec::new()); desired.dev_name = Some(String::new()); - let action = prepare_web_source_runtime_reconcile_from_current(¤t, desired) + let action = prepare_web_source_runtime_reconcile_from_current(¤t, desired, false) .expect("prepare reconcile"); let RuntimeReconcileAction::Run { overwrite, .. } = action else { panic!("clearing an explicit device name should require a full overwrite"); @@ -891,7 +997,7 @@ mod tests { let mut desired = config_with_port_forwards(Vec::new()); desired.dev_name = Some("managed-device".to_string()); - let action = prepare_web_source_runtime_reconcile_from_current(¤t, desired) + let action = prepare_web_source_runtime_reconcile_from_current(¤t, desired, false) .expect("prepare reconcile"); let RuntimeReconcileAction::Run { overwrite, .. } = action else { panic!("explicit device name should require a full overwrite"); @@ -927,7 +1033,7 @@ mod tests { desired_port_forward.proto = "TCP".to_string(); let desired = config_with_port_forwards(vec![desired_port_forward]); - let patch = web_source_runtime_patch(¤t, &desired) + let patch = web_source_runtime_patch(¤t, &desired, false) .expect("build patch") .expect("hot patch"); @@ -942,7 +1048,7 @@ mod tests { desired.network_secret = Some("new-secret".to_string()); - let patch = web_source_runtime_patch(¤t, &desired).expect("build patch"); + let patch = web_source_runtime_patch(¤t, &desired, false).expect("build patch"); assert!(patch.is_none()); } @@ -955,9 +1061,12 @@ mod tests { unmanaged_desired.hostname = None; unmanaged_desired.network_secret = Some("new-secret".to_string()); - let action = - prepare_web_source_runtime_reconcile_from_current(¤t, unmanaged_desired.clone()) - .expect("prepare full overwrite"); + let action = prepare_web_source_runtime_reconcile_from_current( + ¤t, + unmanaged_desired.clone(), + false, + ) + .expect("prepare full overwrite"); let RuntimeReconcileAction::Run { config, overwrite } = action else { panic!("non-hot change should require a full overwrite"); }; @@ -967,9 +1076,12 @@ mod tests { let observed_after_run = *config; let mut explicit_clear = unmanaged_desired; explicit_clear.hostname = Some(String::new()); - let action = - prepare_web_source_runtime_reconcile_from_current(&observed_after_run, explicit_clear) - .expect("prepare explicit clear"); + let action = prepare_web_source_runtime_reconcile_from_current( + &observed_after_run, + explicit_clear, + false, + ) + .expect("prepare explicit clear"); let RuntimeReconcileAction::Patch(patch) = action else { panic!("explicit clear should patch the preserved runtime hostname"); }; @@ -988,7 +1100,7 @@ mod tests { ..Default::default() }]; - let action = prepare_web_source_runtime_reconcile_from_current(¤t, desired) + let action = prepare_web_source_runtime_reconcile_from_current(¤t, desired, false) .expect("prepare reconcile"); let RuntimeReconcileAction::Patch(patch) = action else { panic!("managed credential change must use a hot patch"); @@ -998,6 +1110,248 @@ mod tests { assert_eq!(managed.entries[0].credential_id, "managed"); } + #[test] + fn empty_manual_routes_require_overwrite_when_switching_modes() { + let automatic = config_with_port_forwards(Vec::new()); + let mut empty_manual = automatic.clone(); + empty_manual.enable_manual_routes = Some(true); + assert!(empty_manual.routes.is_empty()); + + for (current, desired) in [(&automatic, &empty_manual), (&empty_manual, &automatic)] { + let action = + prepare_web_source_runtime_reconcile_from_current(current, desired.clone(), false) + .expect("prepare mode change"); + assert!(matches!( + action, + RuntimeReconcileAction::Run { + overwrite: true, + .. + } + )); + } + let action = prepare_web_source_runtime_reconcile_from_current( + &empty_manual, + empty_manual.clone(), + false, + ) + .expect("prepare unchanged empty manual routes"); + assert!(matches!(action, RuntimeReconcileAction::Unchanged(_))); + } + + #[test] + fn manual_route_modes_and_lists_patch_when_supported() { + let automatic = config_with_port_forwards(Vec::new()); + let mut empty = automatic.clone(); + empty.enable_manual_routes = Some(true); + let mut nonempty = empty.clone(); + nonempty.routes = vec!["10.20.0.0/24".to_owned()]; + for current in [&automatic, &empty, &nonempty] { + for desired in [&automatic, &empty, &nonempty] { + let action = prepare_web_source_runtime_reconcile_from_current( + current, + desired.clone(), + true, + ) + .unwrap(); + if current == desired { + assert!(matches!(action, RuntimeReconcileAction::Unchanged(_))); + continue; + } + let RuntimeReconcileAction::Patch(patch) = action else { + panic!("route-only changes must not restart a supporting Core"); + }; + assert_eq!( + patch.enable_manual_routes, + Some(desired.enable_manual_routes.unwrap_or_default()) + ); + assert_eq!(patch.routes[0].action, ConfigPatchAction::Clear as i32); + assert_eq!(patch.routes.len(), desired.routes.len() + 1); + for (entry, cidr) in patch.routes.iter().skip(1).zip(&desired.routes) { + assert_eq!(entry.action, ConfigPatchAction::Add as i32); + assert_eq!( + entry.cidr.as_ref(), + Some(&parse_rpc_ipv4_inet(cidr).unwrap()) + ); + } + assert!(ensure_runtime_config_converged(current, desired, false).is_err()); + } + } + } + + #[test] + fn raw_route_readback_distinguishes_empty_and_automatic_without_guessing() { + let id = "11111111-1111-1111-1111-111111111111"; + let mut config = config_with_port_forwards(Vec::new()); + for (routes, expected) in [("routes=[]\n", Some(true)), ("", None)] { + let raw = format!("instance_id='{id}'\n{routes}[historical_field]\nunknown=true"); + restore_manual_routes_from_toml(&mut config, id, &raw).unwrap(); + assert_eq!(config.enable_manual_routes, expected); + assert!(config.routes.is_empty()); + } + for raw in [ + String::new(), + format!("instance_id='{id}'\nroutes='credential-secret-do-not-log'"), + "instance_id='22222222-2222-2222-2222-222222222222'\nroutes=[]".to_owned(), + ] { + let error = restore_manual_routes_from_toml(&mut config, id, &raw).unwrap_err(); + assert!(!error.to_string().contains("credential-secret")); + } + } + + #[tokio::test] + async fn runtime_route_readback_uses_current_rpc_or_released_node_info() { + use easytier::proto::api::{ + config::{ConfigRpc, GetConfigResponse, PatchConfigResponse}, + instance::*, + }; + use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }; + + struct ConfigReader(GetConfigResponse); + #[async_trait::async_trait] + impl ConfigRpc for ConfigReader { + type Controller = BaseController; + async fn get_config( + &self, + _: BaseController, + _: GetConfigRequest, + ) -> easytier::proto::rpc_types::error::Result { + Ok(self.0.clone()) + } + async fn patch_config( + &self, + _: BaseController, + _: PatchConfigRequest, + ) -> easytier::proto::rpc_types::error::Result { + unreachable!("readback must not patch configuration") + } + } + struct PeerReader { + raw: Option, + calls: Arc, + } + #[async_trait::async_trait] + impl PeerManageRpc for PeerReader { + type Controller = BaseController; + async fn list_peer( + &self, + _: BaseController, + _: ListPeerRequest, + ) -> easytier::proto::rpc_types::error::Result { + unreachable!("route configuration readback only uses ShowNodeInfo") + } + async fn list_public_ipv6_info( + &self, + _: BaseController, + _: ListPublicIpv6InfoRequest, + ) -> easytier::proto::rpc_types::error::Result { + unreachable!("route configuration readback only uses ShowNodeInfo") + } + async fn list_route( + &self, + _: BaseController, + _: ListRouteRequest, + ) -> easytier::proto::rpc_types::error::Result { + unreachable!("route configuration readback only uses ShowNodeInfo") + } + async fn dump_route( + &self, + _: BaseController, + _: DumpRouteRequest, + ) -> easytier::proto::rpc_types::error::Result { + unreachable!("route configuration readback only uses ShowNodeInfo") + } + async fn list_foreign_network( + &self, + _: BaseController, + _: ListForeignNetworkRequest, + ) -> easytier::proto::rpc_types::error::Result { + unreachable!("route configuration readback only uses ShowNodeInfo") + } + async fn list_global_foreign_network( + &self, + _: BaseController, + _: ListGlobalForeignNetworkRequest, + ) -> easytier::proto::rpc_types::error::Result + { + unreachable!("route configuration readback only uses ShowNodeInfo") + } + async fn get_foreign_network_summary( + &self, + _: BaseController, + _: GetForeignNetworkSummaryRequest, + ) -> easytier::proto::rpc_types::error::Result + { + unreachable!("route configuration readback only uses ShowNodeInfo") + } + async fn show_node_info( + &self, + _: BaseController, + _: ShowNodeInfoRequest, + ) -> easytier::proto::rpc_types::error::Result { + self.calls.fetch_add(1, Ordering::Relaxed); + let raw = self + .raw + .clone() + .ok_or_else(|| anyhow::anyhow!("Peer RPC unavailable"))?; + Ok(ShowNodeInfoResponse { + node_info: Some(NodeInfo { + config: raw, + ..Default::default() + }), + }) + } + } + let id = "11111111-1111-1111-1111-111111111111"; + for (has_raw_config, supports_patch) in [(false, false), (true, false), (false, true)] { + for manual in [false, true] { + let raw = format!( + "instance_id='{id}'\n{}", + if manual { "routes=[]" } else { "" } + ); + let mut config: SessionConfigClient = Box::new(ConfigReader(GetConfigResponse { + config: Some(NetworkConfig { + enable_manual_routes: (supports_patch && manual).then_some(true), + ..config_with_port_forwards(Vec::new()) + }), + toml_config: if has_raw_config { + raw.clone() + } else { + String::new() + }, + })); + let calls = Arc::new(AtomicUsize::new(0)); + let mut peer: SessionPeerClient = Box::new(PeerReader { + raw: (!has_raw_config && !supports_patch).then_some(raw), + calls: calls.clone(), + }); + let observed = get_runtime_config(&mut config, &mut peer, id, supports_patch) + .await + .unwrap(); + assert_eq!(observed.enable_manual_routes, manual.then_some(true)); + assert_eq!( + calls.load(Ordering::Relaxed), + usize::from(!has_raw_config && !supports_patch) + ); + } + } + let mut config: SessionConfigClient = Box::new(ConfigReader(GetConfigResponse { + config: Some(config_with_port_forwards(Vec::new())), + ..Default::default() + })); + let mut peer: SessionPeerClient = Box::new(PeerReader { + raw: None, + calls: Arc::new(AtomicUsize::new(0)), + }); + assert!( + get_runtime_config(&mut config, &mut peer, id, false) + .await + .is_err() + ); + } + #[test] fn runtime_patch_rejects_routes_change() { let mut current = config_with_port_forwards(Vec::new()); @@ -1007,7 +1361,7 @@ mod tests { desired.enable_manual_routes = Some(true); desired.routes = vec!["10.2.0.0/16".to_string(), "10.3.0.0/16".to_string()]; - let patch = web_source_runtime_patch(¤t, &desired).expect("build patch"); + let patch = web_source_runtime_patch(¤t, &desired, false).expect("build patch"); assert!(patch.is_none()); } @@ -1025,7 +1379,7 @@ mod tests { "10.3.0.0/16".to_string(), ]; - let patch = web_source_runtime_patch(¤t, &desired) + let patch = web_source_runtime_patch(¤t, &desired, false) .expect("build patch") .expect("hot patch"); @@ -1066,7 +1420,7 @@ mod tests { let mut desired = config_with_port_forwards(Vec::new()); desired.proxy_cidrs = vec!["10.1.2.0/24->10.1.3.0/24".to_string()]; - let patch = web_source_runtime_patch(¤t, &desired) + let patch = web_source_runtime_patch(¤t, &desired, false) .expect("build patch") .expect("hot patch"); @@ -1095,7 +1449,7 @@ mod tests { let mut desired = config_with_port_forwards(Vec::new()); desired.proxy_cidrs = vec!["10.1.2.0/24".to_string()]; - let patch = web_source_runtime_patch(¤t, &desired).expect("build patch"); + let patch = web_source_runtime_patch(¤t, &desired, false).expect("build patch"); assert!(patch.is_none()); } @@ -1106,7 +1460,7 @@ mod tests { current.proxy_cidrs = vec!["10.1.2.0/24".to_string()]; let desired = config_with_port_forwards(Vec::new()); - let patch = web_source_runtime_patch(¤t, &desired) + let patch = web_source_runtime_patch(¤t, &desired, false) .expect("build patch") .expect("hot patch"); @@ -1127,7 +1481,7 @@ mod tests { let mut desired = current.clone(); desired.disable_relay_data = Some(true); - let patch = web_source_runtime_patch(¤t, &desired) + let patch = web_source_runtime_patch(¤t, &desired, false) .expect("build patch") .expect("hot patch"); @@ -1140,7 +1494,7 @@ mod tests { let mut desired = current.clone(); desired.prefer_peer_relay = Some(true); - let patch = web_source_runtime_patch(¤t, &desired) + let patch = web_source_runtime_patch(¤t, &desired, false) .expect("build patch") .expect("hot patch"); @@ -1154,7 +1508,7 @@ mod tests { let mut desired = current.clone(); desired.no_tun = Some(true); - let patch = web_source_runtime_patch(¤t, &desired).expect("build patch"); + let patch = web_source_runtime_patch(¤t, &desired, false).expect("build patch"); assert!(patch.is_none()); } @@ -1165,7 +1519,7 @@ mod tests { let mut desired = current.clone(); desired.encryption_algorithm = Some("managed-test-algo".to_string()); - let patch = web_source_runtime_patch(¤t, &desired).expect("build patch"); + let patch = web_source_runtime_patch(¤t, &desired, false).expect("build patch"); assert!(patch.is_none()); } @@ -1176,7 +1530,7 @@ mod tests { let mut desired = current.clone(); desired.data_compress_algo = Some(CompressionAlgoPb::Zstd as i32); - let patch = web_source_runtime_patch(¤t, &desired).expect("build patch"); + let patch = web_source_runtime_patch(¤t, &desired, false).expect("build patch"); assert!(patch.is_none()); } @@ -1197,7 +1551,7 @@ mod tests { local_public_key: None, }); - let patch = web_source_runtime_patch(¤t, &desired).expect("build patch"); + let patch = web_source_runtime_patch(¤t, &desired, false).expect("build patch"); assert!(patch.is_none()); } @@ -1218,7 +1572,7 @@ mod tests { local_public_key: None, }); - let patch = web_source_runtime_patch(¤t, &desired) + let patch = web_source_runtime_patch(¤t, &desired, false) .expect("build patch") .expect("hot patch"); @@ -1241,7 +1595,7 @@ mod tests { let desired = config_with_port_forwards(vec![port_forward(23000, 5174), port_forward(23007, 3389)]); - let patch = web_source_runtime_patch(¤t, &desired) + let patch = web_source_runtime_patch(¤t, &desired, false) .expect("build patch") .expect("hot patch"); @@ -1263,7 +1617,7 @@ mod tests { let mut desired = config_with_port_forwards(Vec::new()); desired.hostname = Some("desired-host".to_string()); - let action = prepare_web_source_runtime_reconcile_from_current(¤t, desired) + let action = prepare_web_source_runtime_reconcile_from_current(¤t, desired, false) .expect("prepare reconcile"); let RuntimeReconcileAction::Patch(patch) = action else { panic!("hostname-only change should use a hot patch"); @@ -1320,7 +1674,7 @@ mod tests { let mut desired = config_with_port_forwards(Vec::new()); desired.hostname = Some(String::new()); - let patch = web_source_runtime_patch(¤t, &desired) + let patch = web_source_runtime_patch(¤t, &desired, false) .expect("build patch") .expect("hot patch"); @@ -1333,7 +1687,7 @@ mod tests { let mut desired = config_with_port_forwards(Vec::new()); desired.hostname = Some(String::new()); - let action = prepare_web_source_runtime_reconcile_from_current(¤t, desired) + let action = prepare_web_source_runtime_reconcile_from_current(¤t, desired, false) .expect("prepare reconcile"); assert!(matches!(action, RuntimeReconcileAction::Unchanged(_))); @@ -1345,7 +1699,7 @@ mod tests { current.hostname = Some("desired-host".to_string()); let desired = current.clone(); - let action = prepare_web_source_runtime_reconcile_from_current(¤t, desired) + let action = prepare_web_source_runtime_reconcile_from_current(¤t, desired, false) .expect("prepare reconcile"); assert!(matches!(action, RuntimeReconcileAction::Unchanged(_))); @@ -1357,7 +1711,7 @@ mod tests { let mut desired = config_with_port_forwards(Vec::new()); desired.hostname = Some("a".repeat(33)); - let patch = web_source_runtime_patch(¤t, &desired) + let patch = web_source_runtime_patch(¤t, &desired, false) .expect("build patch") .expect("hot patch"); @@ -1370,7 +1724,7 @@ mod tests { let mut desired = config_with_port_forwards(Vec::new()); desired.hostname = Some("node\u{7}-name".to_string()); - let patch = web_source_runtime_patch(¤t, &desired) + let patch = web_source_runtime_patch(¤t, &desired, false) .expect("build patch") .expect("hot patch"); @@ -1384,7 +1738,7 @@ mod tests { let mut desired = config_with_port_forwards(Vec::new()); desired.hostname = Some("\u{7}\n".to_string()); - let patch = web_source_runtime_patch(¤t, &desired) + let patch = web_source_runtime_patch(¤t, &desired, false) .expect("build patch") .expect("hot patch"); diff --git a/easytier-web/src/client_manager/session.rs b/easytier-web/src/client_manager/session.rs index ecc64ccd..0dabffbe 100644 --- a/easytier-web/src/client_manager/session.rs +++ b/easytier-web/src/client_manager/session.rs @@ -10,6 +10,7 @@ use anyhow::Context; use easytier::proto::{ api::{ config::{ConfigRpc, ConfigRpcClientFactory}, + instance::{PeerManageRpc, PeerManageRpcClientFactory}, manage::{WebClientService, WebClientServiceClientFactory}, }, rpc::bidirect::BidirectRpcManager, @@ -926,6 +927,7 @@ impl Debug for Session { pub(super) type SessionRpcClient = Box + Send>; pub(super) type SessionConfigClient = Box + Send>; +pub(super) type SessionPeerClient = Box + Send>; impl Session { pub fn new( @@ -983,6 +985,7 @@ impl Session { data.storage.clone(), self.scoped_rpc_client(), self.scoped_config_client(), + self.scoped_client::>(), ), ))); } diff --git a/easytier-web/src/client_manager/session/runtime_revision.rs b/easytier-web/src/client_manager/session/runtime_revision.rs index 25dededa..889808fd 100644 --- a/easytier-web/src/client_manager/session/runtime_revision.rs +++ b/easytier-web/src/client_manager/session/runtime_revision.rs @@ -1,5 +1,6 @@ use std::collections::{HashMap, HashSet}; +use anyhow::Context as _; use easytier::proto::{ api::manage::{ DeleteNetworkInstanceRequest, DeleteNetworkInstanceResponse, @@ -13,8 +14,8 @@ use easytier_core::management::remote_client::{ListNetworkProps, Storage as _}; use tokio::sync::{RwLock, broadcast}; use super::{ - ManagedConfigReconcileHint, SessionConfigClient, SessionData, SessionRpcClient, - SessionRpcService, + ManagedConfigReconcileHint, SessionConfigClient, SessionData, SessionPeerClient, + SessionRpcClient, SessionRpcService, }; use crate::client_manager::{ managed_config::{self, PersistedConfigSource}, @@ -56,13 +57,25 @@ async fn recv_latest_heartbeat( Some(req) } +struct SessionClients { + rpc: SessionRpcClient, + config: SessionConfigClient, + peer: SessionPeerClient, +} + pub(super) async fn reconcile_network_configs_on_heartbeat( session_data: std::sync::Weak>, mut heartbeat_waiter: broadcast::Receiver, storage: WeakRefStorage, - mut rpc_client: SessionRpcClient, - mut config_client: SessionConfigClient, + rpc_client: SessionRpcClient, + config_client: SessionConfigClient, + peer_client: SessionPeerClient, ) { + let mut clients = SessionClients { + rpc: rpc_client, + config: config_client, + peer: peer_client, + }; let mut cache = ReconcileCache::default(); loop { let Some(req) = recv_latest_heartbeat(&mut heartbeat_waiter).await else { @@ -74,14 +87,14 @@ pub(super) async fn reconcile_network_configs_on_heartbeat( }; let mut round = - match prepare_reconcile_round(&session_data, &storage, &mut rpc_client, req).await { + match prepare_reconcile_round(&session_data, &storage, &mut clients.rpc, req).await { RoundStatus::Ready(round) => round, RoundStatus::Skip => continue, RoundStatus::Stop => return, }; cache.reset_if_runtime_config_cache_epoch_changed(round.runtime_config_cache_epoch); let running_metas = - match sync_running_sources_for_round(&mut rpc_client, &storage, &mut round).await { + match sync_running_sources_for_round(&mut clients.rpc, &storage, &mut round).await { RoundStatus::Ready(running_metas) => running_metas, RoundStatus::Skip => continue, RoundStatus::Stop => return, @@ -105,7 +118,7 @@ pub(super) async fn reconcile_network_configs_on_heartbeat( match cleanup_stale_web_source_instances( &context, &storage, - &mut rpc_client, + &mut clients.rpc, running_metas.as_deref(), &desired_web_inst_ids, &mut cache, @@ -121,7 +134,7 @@ pub(super) async fn reconcile_network_configs_on_heartbeat( ReconcileScope::Patch { .. } => { match cleanup_patch_deleted_instances( &session_data, - &mut rpc_client, + &mut clients.rpc, &round, running_metas.as_deref(), &round.delete_instance_ids, @@ -140,8 +153,7 @@ pub(super) async fn reconcile_network_configs_on_heartbeat( outcome.merge( reconcile_desired_runtime_configs( &context, - &mut rpc_client, - &mut config_client, + &mut clients, &mut cache, &mut mutation_fence, ) @@ -227,6 +239,7 @@ impl SessionRuntimeConfigCache { &self, inst_id: &str, desired_config: NetworkConfig, + support_manual_routes_patch: bool, ) -> anyhow::Result> { let Some(observed_config) = self.entries.get(inst_id) else { return Ok(None); @@ -235,6 +248,7 @@ impl SessionRuntimeConfigCache { runtime_reconcile::prepare_web_source_runtime_reconcile_from_current( observed_config, desired_config, + support_manual_routes_patch, ) .map(Some) } @@ -920,8 +934,7 @@ fn should_reconcile_running_web_config( async fn reconcile_desired_runtime_configs( context: &ReconcileRoundContext<'_>, - rpc_client: &mut SessionRpcClient, - config_client: &mut SessionConfigClient, + clients: &mut SessionClients, cache: &mut ReconcileCache, mutation_fence: &mut RuntimeMutationFence, ) -> ReconcileOutcome { @@ -963,8 +976,7 @@ async fn reconcile_desired_runtime_configs( let action_result = if should_reconcile_running_web_config { reconcile_running_web_config( context, - rpc_client, - config_client, + clients, config, desired_config, &mut cache.runtime_configs, @@ -977,7 +989,7 @@ async fn reconcile_desired_runtime_configs( } let action_result = run_missing_network_config( session_data, - rpc_client, + &mut clients.rpc, round, config, desired_config.clone(), @@ -988,10 +1000,11 @@ async fn reconcile_desired_runtime_configs( && source == PersistedConfigSource::Web { if let Err(e) = remember_web_runtime_config_after_run( - rpc_client, + clients, &config.network_instance_id, &desired_config, &mut cache.runtime_configs, + round.req.support_manual_routes_patch, ) .await { @@ -1034,8 +1047,7 @@ async fn reconcile_desired_runtime_configs( async fn reconcile_running_web_config( context: &ReconcileRoundContext<'_>, - rpc_client: &mut SessionRpcClient, - config_client: &mut SessionConfigClient, + clients: &mut SessionClients, config: &crate::db::entity::user_running_network_configs::Model, desired_config: NetworkConfig, runtime_config_cache: &mut SessionRuntimeConfigCache, @@ -1054,19 +1066,24 @@ async fn reconcile_running_web_config( let operation_started_at = std::time::Instant::now(); let ret = async { - let action = - match runtime_config_cache.plan(&config.network_instance_id, desired_config.clone())? { - Some(action) => action, - None => { - runtime_reconcile::prepare_web_source_runtime_reconcile( - &mut *rpc_client, - &config.network_instance_id, - desired_config.clone(), - true, - ) - .await? - } - }; + let action = match runtime_config_cache.plan( + &config.network_instance_id, + desired_config.clone(), + round.req.support_manual_routes_patch, + )? { + Some(action) => action, + None => { + runtime_reconcile::prepare_web_source_runtime_reconcile( + &mut clients.config, + &mut clients.peer, + &config.network_instance_id, + desired_config.clone(), + true, + round.req.support_manual_routes_patch, + ) + .await? + } + }; if !SessionRpcService::runtime_heartbeat_is_current(session_data, &round.req).await { anyhow::bail!("webhook session is no longer current before runtime reconcile apply"); } @@ -1078,11 +1095,13 @@ async fn reconcile_running_web_config( anyhow::bail!("managed runtime mutation fence is no longer current"); } let observed_config = runtime_reconcile::apply_web_source_runtime_reconcile( - &mut *rpc_client, - &mut *config_client, + &mut clients.rpc, + &mut clients.config, + &mut clients.peer, &config.network_instance_id, desired_config.clone(), action, + round.req.support_manual_routes_patch, ) .await?; runtime_config_cache.remember(&config.network_instance_id, observed_config); @@ -1186,12 +1205,19 @@ async fn record_direct_run_result( } async fn remember_web_runtime_config_after_run( - rpc_client: &mut SessionRpcClient, + clients: &mut SessionClients, inst_id: &str, desired_config: &NetworkConfig, runtime_config_cache: &mut SessionRuntimeConfigCache, + support_manual_routes_patch: bool, ) -> anyhow::Result<()> { - let observed_config = runtime_reconcile::get_runtime_config(rpc_client, inst_id).await?; + let observed_config = runtime_reconcile::get_runtime_config( + &mut clients.config, + &mut clients.peer, + inst_id, + support_manual_routes_patch, + ) + .await?; remember_if_runtime_matches_desired( inst_id, desired_config, @@ -1206,16 +1232,8 @@ fn remember_if_runtime_matches_desired( observed_config: NetworkConfig, runtime_config_cache: &mut SessionRuntimeConfigCache, ) -> anyhow::Result<()> { - let action = runtime_reconcile::prepare_web_source_runtime_reconcile_from_current( - &observed_config, - desired_config.clone(), - )?; - if !matches!( - action, - runtime_reconcile::RuntimeReconcileAction::Unchanged(_) - ) { - anyhow::bail!("runtime config still differs after managed run"); - } + runtime_reconcile::ensure_runtime_config_converged(&observed_config, desired_config, false) + .context("runtime config still differs after managed run")?; runtime_config_cache.remember(inst_id, observed_config); Ok(()) } @@ -1839,12 +1857,46 @@ mod tests { fn session_runtime_config_cache_misses_unknown_instance() { let cache = SessionRuntimeConfigCache::default(); let action = cache - .plan("missing", config_with_port_forwards(Vec::new())) + .plan("missing", config_with_port_forwards(Vec::new()), false) .expect("prepare action"); assert!(action.is_none()); } + #[test] + fn session_runtime_config_cache_uses_current_heartbeat_route_support() { + let automatic = config_with_port_forwards(Vec::new()); + let mut manual = automatic.clone(); + manual.enable_manual_routes = Some(true); + let mut cache = SessionRuntimeConfigCache::default(); + cache.remember("managed", automatic); + for supported in [false, true, false] { + let heartbeat = HeartbeatRequest { + support_manual_routes_patch: supported, + ..Default::default() + }; + let action = cache + .plan( + "managed", + manual.clone(), + heartbeat.support_manual_routes_patch, + ) + .unwrap() + .unwrap(); + assert_eq!( + matches!(action, runtime_reconcile::RuntimeReconcileAction::Patch(_)), + supported + ); + assert_eq!( + matches!( + action, + runtime_reconcile::RuntimeReconcileAction::Run { .. } + ), + !supported + ); + } + } + #[test] fn session_runtime_config_cache_skips_matching_observed_config() { let mut cache = SessionRuntimeConfigCache::default(); @@ -1852,7 +1904,7 @@ mod tests { cache.remember("managed", config.clone()); let action = cache - .plan("managed", config) + .plan("managed", config, false) .expect("prepare action") .expect("cached action"); @@ -1871,7 +1923,7 @@ mod tests { let unmanaged_desired = config_with_port_forwards(Vec::new()); let action = cache - .plan("managed", unmanaged_desired) + .plan("managed", unmanaged_desired, false) .expect("prepare unmanaged hostname action") .expect("cached action"); let runtime_reconcile::RuntimeReconcileAction::Unchanged(observed) = action else { @@ -1883,7 +1935,7 @@ mod tests { let mut explicit_clear = config_with_port_forwards(Vec::new()); explicit_clear.hostname = Some(String::new()); let action = cache - .plan("managed", explicit_clear) + .plan("managed", explicit_clear, false) .expect("prepare explicit clear action") .expect("cached action"); let runtime_reconcile::RuntimeReconcileAction::Patch(patch) = action else { @@ -1902,7 +1954,7 @@ mod tests { cache.remember("managed", current); let action = cache - .plan("managed", desired) + .plan("managed", desired, false) .expect("prepare action") .expect("cached action"); @@ -1934,7 +1986,7 @@ mod tests { cache.forget("managed"); let action = cache - .plan("managed", config) + .plan("managed", config, false) .expect("prepare action after remove"); assert!(action.is_none()); } @@ -1947,7 +1999,7 @@ mod tests { remember_if_runtime_matches_desired("managed", &config, config.clone(), &mut cache) .expect("remember observed config after run"); let action = cache - .plan("managed", config) + .plan("managed", config, false) .expect("prepare action after run") .expect("cached action"); @@ -1972,7 +2024,7 @@ mod tests { .contains("runtime config still differs after managed run") ); let action = cache - .plan("managed", desired) + .plan("managed", desired, false) .expect("prepare action after stale run result"); assert!(action.is_none()); } @@ -2007,7 +2059,7 @@ mod tests { let mut cache = SessionRuntimeConfigCache::default(); cache.remember("managed", observed); let action = cache - .plan("managed", desired) + .plan("managed", desired, false) .expect("prepare action after restore") .expect("cached action"); diff --git a/easytier-web/src/restful/network.rs b/easytier-web/src/restful/network.rs index a42c1904..202539cd 100644 --- a/easytier-web/src/restful/network.rs +++ b/easytier-web/src/restful/network.rs @@ -531,6 +531,7 @@ impl NetworkApi { support_config_source: false, failed_network_instances: Vec::new(), support_heartbeat_policy: false, + support_manual_routes_patch: false, }), }; machines.push(ListMachineItem {