diff --git a/easytier-gui/src-tauri/src/lib.rs b/easytier-gui/src-tauri/src/lib.rs index d4dd6a4d..73a74c04 100644 --- a/easytier-gui/src-tauri/src/lib.rs +++ b/easytier-gui/src-tauri/src/lib.rs @@ -15,7 +15,9 @@ use easytier::rpc_service::remote_client::{ use easytier::web_client::{self, WebClient}; use easytier::{ common::{ - config::{ConfigLoader, FileLoggerConfig, LoggingConfigBuilder, TomlConfigLoader}, + config::{ + ConfigLoader, ConfigSource, FileLoggerConfig, LoggingConfigBuilder, TomlConfigLoader, + }, log, }, instance_manager::NetworkInstanceManager, @@ -118,7 +120,7 @@ async fn run_network_instance( let client_manager = get_client_manager!()?; let toml_config = cfg.gen_config().map_err(|e| e.to_string())?; client_manager - .pre_run_network_instance_hook(&app, &toml_config) + .pre_run_network_instance_hook(&app, &toml_config, manager::PersistedConfigSource::User) .await?; client_manager .handle_run_network_instance(app.clone(), cfg, save) @@ -207,13 +209,17 @@ async fn update_network_config_state( .map_err(|e: uuid::Error| e.to_string())?; let client_manager = get_client_manager!()?; if !disabled { - let cfg = client_manager - .handle_get_network_config(app.clone(), instance_id) + let (cfg, source) = client_manager + .handle_get_network_config_with_source(app.clone(), instance_id) .await .map_err(|e| e.to_string())?; let toml_config = cfg.gen_config().map_err(|e| e.to_string())?; client_manager - .pre_run_network_instance_hook(&app, &toml_config) + .pre_run_network_instance_hook( + &app, + &toml_config, + manager::PersistedConfigSource::from_runtime_source(source), + ) .await?; } client_manager @@ -272,7 +278,7 @@ async fn get_config(app: AppHandle, instance_id: String) -> Result, + configs: Vec, enabled_networks: Vec, ) -> Result<(), String> { get_client_manager!()? @@ -612,7 +618,11 @@ mod manager { ) -> Result<(), String> { let client_manager = get_client_manager!()?; client_manager - .pre_run_network_instance_hook(&self.app, cfg) + .pre_run_network_instance_hook( + &self.app, + cfg, + PersistedConfigSource::from_runtime_source(cfg.get_network_config_source()), + ) .await } @@ -631,14 +641,87 @@ mod manager { } } + #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] + #[serde(rename_all = "snake_case")] + #[derive(Default)] + pub(super) enum PersistedConfigSource { + User, + Webhook, + #[serde(other)] + #[default] + Legacy, + } + + impl PersistedConfigSource { + pub(super) fn from_runtime_source(source: ConfigSource) -> Self { + match source { + ConfigSource::User => Self::User, + ConfigSource::Webhook => Self::Webhook, + } + } + + fn merge_persisted(self, incoming: Self) -> Self { + match (self, incoming) { + // Older runtimes report missing source as `user`. Keep the stronger persisted + // ownership until webhook sync or an explicit user save repairs it. + (Self::Webhook, Self::User) | (Self::Legacy, Self::User) => self, + (_, next) => next, + } + } + + fn to_runtime_source(self) -> ConfigSource { + match self { + Self::User | Self::Legacy => ConfigSource::User, + Self::Webhook => ConfigSource::Webhook, + } + } + + #[cfg(any(test, target_os = "android"))] + fn is_webhook_like(self) -> bool { + matches!(self, Self::Webhook) + } + } + #[derive(Clone)] - pub(super) struct GUIConfig(String, pub(crate) NetworkConfig); + pub(super) struct GUIConfig { + inst_id: String, + pub(crate) config: NetworkConfig, + source: PersistedConfigSource, + } + + #[derive(Clone, serde::Serialize, serde::Deserialize)] + pub(super) struct StoredGuiConfig { + config: NetworkConfig, + #[serde(default)] + source: PersistedConfigSource, + } + + impl GUIConfig { + fn new(inst_id: String, config: NetworkConfig, source: PersistedConfigSource) -> Self { + Self { + inst_id, + config, + source, + } + } + + fn into_stored(self) -> StoredGuiConfig { + StoredGuiConfig { + config: self.config, + source: self.source, + } + } + } + impl PersistentConfig for GUIConfig { fn get_network_inst_id(&self) -> &str { - &self.0 + &self.inst_id } fn get_network_config(&self) -> Result { - Ok(self.1.clone()) + Ok(self.config.clone()) + } + fn get_network_config_source(&self) -> ConfigSource { + self.source.to_runtime_source() } } @@ -655,13 +738,12 @@ mod manager { } fn save_configs(&self, app: &AppHandle) -> anyhow::Result<()> { - let configs: Result, _> = self + let configs = self .network_configs .iter() - .map(|entry| serde_json::to_string(&entry.value().1)) - .collect(); - let payload = format!("[{}]", configs?.join(",")); - app.emit_str("save_configs", payload)?; + .map(|entry| entry.value().clone().into_stored()) + .collect::>(); + app.emit("save_configs", configs)?; Ok(()) } @@ -680,8 +762,14 @@ mod manager { app: &AppHandle, inst_id: Uuid, cfg: NetworkConfig, + source: PersistedConfigSource, ) -> anyhow::Result<()> { - let config = GUIConfig(inst_id.to_string(), cfg); + let source = self + .network_configs + .get(&inst_id) + .map(|existing| existing.source.merge_persisted(source)) + .unwrap_or(source); + let config = GUIConfig::new(inst_id.to_string(), cfg, source); self.network_configs.insert(inst_id, config); self.save_configs(app) } @@ -693,8 +781,14 @@ mod manager { app: AppHandle, network_inst_id: Uuid, network_config: NetworkConfig, + source: ConfigSource, ) -> Result<(), anyhow::Error> { - self.save_config(&app, network_inst_id, network_config)?; + self.save_config( + &app, + network_inst_id, + network_config, + PersistedConfigSource::from_runtime_source(source), + )?; self.enabled_networks.insert(network_inst_id); self.save_enabled_networks(&app)?; Ok(()) @@ -811,17 +905,36 @@ mod manager { .network_configs .iter() .filter(|v| self.storage.enabled_networks.contains(v.key())) - .filter(|v| !v.1.no_tun()) - .filter_map(|c| c.1.instance_id().parse::().ok()) + .filter(|v| !v.config.no_tun()) + .filter_map(|c| c.config.instance_id().parse::().ok()) + } + + #[cfg(target_os = "android")] + pub fn get_enabled_instances_with_webhook_like_tun_ids( + &self, + ) -> impl Iterator + '_ { + self.storage + .network_configs + .iter() + .filter(|v| self.storage.enabled_networks.contains(v.key())) + .filter(|v| !v.config.no_tun()) + .filter(|v| v.source.is_webhook_like()) + .filter_map(|c| c.config.instance_id().parse::().ok()) } #[cfg(target_os = "android")] pub(super) async fn disable_instances_with_tun( &self, app: &AppHandle, + webhook_only: bool, ) -> Result<(), easytier::rpc_service::remote_client::RemoteClientError> { - let inst_ids: Vec = self.get_enabled_instances_with_tun_ids().collect(); + let inst_ids: Vec = if webhook_only { + self.get_enabled_instances_with_webhook_like_tun_ids() + .collect() + } else { + self.get_enabled_instances_with_tun_ids().collect() + }; for inst_id in inst_ids { self.handle_update_network_state(app.clone(), inst_id, true) .await?; @@ -842,6 +955,7 @@ mod manager { &self, app: &AppHandle, cfg: &easytier::common::config::TomlConfigLoader, + source: PersistedConfigSource, ) -> Result<(), String> { let instance_id = cfg.get_id(); app.emit("pre_run_network_instance", instance_id.to_string()) @@ -849,9 +963,24 @@ mod manager { #[cfg(target_os = "android")] if !cfg.get_flags().no_tun { - self.disable_instances_with_tun(app) - .await - .map_err(|e| e.to_string())?; + match source { + PersistedConfigSource::User | PersistedConfigSource::Legacy => { + self.disable_instances_with_tun(app, false) + .await + .map_err(|e| e.to_string())?; + } + PersistedConfigSource::Webhook => { + self.disable_instances_with_tun(app, true) + .await + .map_err(|e| e.to_string())?; + if self.get_enabled_instances_with_tun_ids().next().is_some() { + return Err( + "Android only supports one active TUN network; user-managed VPN remains active" + .to_string(), + ); + } + } + } } self.storage @@ -859,6 +988,7 @@ mod manager { app, instance_id, NetworkConfig::new_from_config(cfg).map_err(|e| e.to_string())?, + source, ) .map_err(|e| e.to_string())?; @@ -962,15 +1092,15 @@ mod manager { pub(super) async fn load_configs( &self, app: AppHandle, - configs: Vec, + configs: Vec, enabled_networks: Vec, ) -> anyhow::Result<()> { self.storage.network_configs.clear(); - for cfg in configs { - let instance_id = cfg.instance_id(); + for stored in configs { + let instance_id = stored.config.instance_id(); self.storage.network_configs.insert( instance_id.parse()?, - GUIConfig(instance_id.to_string(), cfg), + GUIConfig::new(instance_id.to_string(), stored.config, stored.source), ); } @@ -986,12 +1116,12 @@ mod manager { .storage .network_configs .get(&uuid) - .map(|i| i.value().1.clone()); - let Some(config) = config else { + .map(|i| (i.value().config.clone(), i.value().source)); + let Some((config, source)) = config else { continue; }; let toml_config = config.gen_config()?; - self.pre_run_network_instance_hook(&app, &toml_config) + self.pre_run_network_instance_hook(&app, &toml_config, source) .await .map_err(|e| anyhow::anyhow!(e))?; client @@ -1001,6 +1131,7 @@ mod manager { inst_id: None, config: Some(config), overwrite: false, + source: source.to_runtime_source().to_rpc(), }, ) .await?; @@ -1032,6 +1163,44 @@ mod manager { &self.storage } } + + #[cfg(test)] + mod tests { + use super::{PersistedConfigSource, StoredGuiConfig}; + use easytier::proto::api::manage::NetworkConfig; + + #[test] + fn stored_gui_config_defaults_missing_source_to_legacy() { + let stored: StoredGuiConfig = serde_json::from_value(serde_json::json!({ + "config": NetworkConfig::default(), + })) + .unwrap(); + assert_eq!(stored.source, PersistedConfigSource::Legacy); + } + + #[test] + fn persisted_source_merge_keeps_legacy_and_webhook_over_ambiguous_user() { + assert_eq!( + PersistedConfigSource::Legacy.merge_persisted(PersistedConfigSource::User), + PersistedConfigSource::Legacy + ); + assert_eq!( + PersistedConfigSource::Webhook.merge_persisted(PersistedConfigSource::User), + PersistedConfigSource::Webhook + ); + assert_eq!( + PersistedConfigSource::Legacy.merge_persisted(PersistedConfigSource::Webhook), + PersistedConfigSource::Webhook + ); + } + + #[test] + fn only_webhook_configs_are_webhook_like() { + assert!(!PersistedConfigSource::Legacy.is_webhook_like()); + assert!(!PersistedConfigSource::User.is_webhook_like()); + assert!(PersistedConfigSource::Webhook.is_webhook_like()); + } + } } #[cfg(not(target_os = "android"))] diff --git a/easytier-gui/src/composables/backend.ts b/easytier-gui/src/composables/backend.ts index 31024b9c..9b9a2f62 100644 --- a/easytier-gui/src/composables/backend.ts +++ b/easytier-gui/src/composables/backend.ts @@ -6,6 +6,7 @@ import { GetNetworkMetasResponse } from 'node_modules/easytier-frontend-lib/dist type NetworkConfig = NetworkTypes.NetworkConfig type ValidateConfigResponse = Api.ValidateConfigResponse type ListNetworkInstanceIdResponse = Api.ListNetworkInstanceIdResponse +type ConfigSource = 'user' | 'webhook' | 'legacy' interface ServiceOptions { config_dir: string rpc_portal: string @@ -16,6 +17,39 @@ interface ServiceOptions { export type ServiceStatus = "Running" | "Stopped" | "NotInstalled" +interface StoredGuiConfig { + config: NetworkConfig + source: ConfigSource +} + +function parseStoredConfigs(raw: string | null): StoredGuiConfig[] { + const parsed: unknown = JSON.parse(raw || '[]') + if (!Array.isArray(parsed)) { + return [] + } + + return parsed.flatMap((entry): StoredGuiConfig[] => { + if (entry && typeof entry === 'object' && 'config' in entry) { + const { config, source } = entry as { + config?: NetworkConfig + source?: ConfigSource + } + if (!config) { + return [] + } + return [{ + config: NetworkTypes.normalizeNetworkConfig(config), + source: source === 'user' || source === 'webhook' ? source : 'legacy', + }] + } + + return [{ + config: NetworkTypes.normalizeNetworkConfig(entry as NetworkConfig), + source: 'legacy', + }] + }) +} + export async function parseNetworkConfig(cfg: NetworkConfig) { return invoke('parse_network_config', { cfg: NetworkTypes.toBackendNetworkConfig(cfg) }) } @@ -71,9 +105,12 @@ export async function getConfig(instanceId: string) { } export async function sendConfigs(enabledNetworks: string[]) { - const networkList: NetworkConfig[] = JSON.parse(localStorage.getItem('networkList') || '[]'); + const networkList = parseStoredConfigs(localStorage.getItem('networkList')) return await invoke('load_configs', { - configs: networkList.map((config) => NetworkTypes.toBackendNetworkConfig(NetworkTypes.normalizeNetworkConfig(config))), + configs: networkList.map(({ config, source }) => ({ + config: NetworkTypes.toBackendNetworkConfig(config), + source, + })), enabledNetworks }) } diff --git a/easytier-gui/src/composables/event.ts b/easytier-gui/src/composables/event.ts index 6f24ec36..588be86e 100644 --- a/easytier-gui/src/composables/event.ts +++ b/easytier-gui/src/composables/event.ts @@ -3,6 +3,11 @@ import { type } from "@tauri-apps/plugin-os"; import { NetworkTypes } from "easytier-frontend-lib" import { Utils } from "easytier-frontend-lib"; +interface StoredGuiConfig { + config: NetworkTypes.NetworkConfig + source?: 'user' | 'webhook' | 'legacy' +} + const EVENTS = Object.freeze({ SAVE_CONFIGS: 'save_configs', PRE_RUN_NETWORK_INSTANCE: 'pre_run_network_instance', @@ -13,9 +18,15 @@ const EVENTS = Object.freeze({ EVENT_LAGGED: 'event_lagged', }); -function onSaveConfigs(event: Event) { +function onSaveConfigs(event: Event) { console.log(`Received event '${EVENTS.SAVE_CONFIGS}': ${event.payload}`); - localStorage.setItem('networkList', JSON.stringify(event.payload.map((config) => NetworkTypes.normalizeNetworkConfig(config)))); + localStorage.setItem( + 'networkList', + JSON.stringify(event.payload.map(({ config, source }) => ({ + config: NetworkTypes.normalizeNetworkConfig(config), + source: source ?? 'legacy', + }))), + ); } function normalizeInstanceIdPayload(payload: unknown): string { diff --git a/easytier-web/src/client_manager/session.rs b/easytier-web/src/client_manager/session.rs index 3867ca4d..99b1daea 100644 --- a/easytier-web/src/client_manager/session.rs +++ b/easytier-web/src/client_manager/session.rs @@ -7,17 +7,17 @@ use std::{ use anyhow::Context; use easytier::{ - common::scoped_task::ScopedTask, + common::{config::ConfigSource, scoped_task::ScopedTask}, proto::{ api::manage::{ - NetworkConfig, RunNetworkInstanceRequest, WebClientService, - WebClientServiceClientFactory, + ConfigSource as RpcConfigSource, NetworkConfig, NetworkMeta, RunNetworkInstanceRequest, + WebClientService, WebClientServiceClientFactory, }, rpc_impl::bidirect::BidirectRpcManager, rpc_types::{self, controller::BaseController}, web::{HeartbeatRequest, HeartbeatResponse, WebServerService, WebServerServiceServer}, }, - rpc_service::remote_client::{ListNetworkProps, Storage as _}, + rpc_service::remote_client::{ListNetworkProps, PersistentConfig as _, Storage as _}, tunnel::Tunnel, }; use tokio::sync::{RwLock, broadcast}; @@ -26,6 +26,50 @@ use super::storage::{Storage, StorageToken, WeakRefStorage}; use crate::FeatureFlags; use crate::webhook::SharedWebhookConfig; +const LEGACY_NETWORK_CONFIG_SOURCE: &str = "legacy"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PersistedConfigSource { + User, + Webhook, + Legacy, +} + +impl PersistedConfigSource { + fn from_db(source: &str) -> Self { + match source { + "webhook" => Self::Webhook, + "user" => Self::User, + LEGACY_NETWORK_CONFIG_SOURCE => Self::Legacy, + _ => Self::User, + } + } + + fn should_update_from_runtime(self, runtime_source: ConfigSource) -> bool { + match (self, runtime_source) { + // Older clients report missing source as `user`, which is not authoritative enough + // to downgrade an existing webhook-owned or legacy row. + (Self::Webhook | Self::Legacy, ConfigSource::User) => false, + _ => self.as_runtime_source() != runtime_source, + } + } + + fn as_runtime_source(self) -> ConfigSource { + match self { + Self::User | Self::Legacy => ConfigSource::User, + Self::Webhook => ConfigSource::Webhook, + } + } + + fn auto_run_rpc_source(self) -> Option { + match self { + Self::User => Some(RpcConfigSource::User), + Self::Webhook => Some(RpcConfigSource::Webhook), + Self::Legacy => None, + } + } +} + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct Location { pub country: String, @@ -148,7 +192,7 @@ impl SessionRpcService { Ok(serde_json::from_value::(network_config)?) } - async fn reconcile_managed_network_configs( + async fn reconcile_webhook_source_configs( storage: &Storage, user_id: i32, machine_id: uuid::Uuid, @@ -159,9 +203,19 @@ impl SessionRpcService { .list_network_configs((user_id, machine_id), ListNetworkProps::All) .await .map_err(|e| anyhow::anyhow!("failed to list existing network configs: {:?}", e))?; - let existing_ids = existing_configs + let existing_sources = existing_configs .iter() - .filter_map(|cfg| uuid::Uuid::parse_str(&cfg.network_instance_id).ok()) + .filter_map(|cfg| { + uuid::Uuid::parse_str(&cfg.network_instance_id) + .ok() + .map(|inst_id| (inst_id, PersistedConfigSource::from_db(&cfg.source))) + }) + .collect::>(); + let existing_webhook_ids = existing_sources + .iter() + .filter_map(|(inst_id, source)| { + (*source == PersistedConfigSource::Webhook).then_some(*inst_id) + }) .collect::>(); let mut desired_ids = HashSet::with_capacity(desired_configs.len()); @@ -169,10 +223,30 @@ impl SessionRpcService { for desired in desired_configs { let inst_id = uuid::Uuid::parse_str(&desired.instance_id).with_context(|| { format!( - "invalid desired managed instance id: {}", + "invalid desired webhook config instance id: {}", desired.instance_id ) })?; + match existing_sources.get(&inst_id) { + Some(PersistedConfigSource::User) => { + tracing::warn!( + ?user_id, + ?machine_id, + instance_id = %inst_id, + "skip webhook config because a user-owned config already exists" + ); + continue; + } + Some(PersistedConfigSource::Legacy) => { + tracing::info!( + ?user_id, + ?machine_id, + instance_id = %inst_id, + "adopt legacy config as webhook-owned during reconciliation" + ); + } + _ => {} + } let config = Self::normalize_network_config(desired.network_config, inst_id)?; desired_ids.insert(inst_id); normalized.insert(inst_id, config); @@ -181,18 +255,23 @@ impl SessionRpcService { for (inst_id, config) in normalized { storage .db() - .insert_or_update_user_network_config((user_id, machine_id), inst_id, config) + .insert_or_update_user_network_config( + (user_id, machine_id), + inst_id, + config, + ConfigSource::Webhook, + ) .await .map_err(|e| { anyhow::anyhow!( - "failed to persist managed network config {}: {:?}", + "failed to persist webhook network config {}: {:?}", inst_id, e ) })?; } - let stale_ids = existing_ids + let stale_ids = existing_webhook_ids .difference(&desired_ids) .copied() .collect::>(); @@ -225,7 +304,7 @@ impl SessionRpcService { let ( user_id, - webhook_managed_network_configs, + webhook_source_configs, webhook_config_revision, webhook_validated, binding_version, @@ -306,11 +385,11 @@ impl SessionRpcService { if webhook_validated && data.applied_config_revision.as_deref() != Some(webhook_config_revision.as_str()) { - Self::reconcile_managed_network_configs( + Self::reconcile_webhook_source_configs( &storage, user_id, machine_id, - webhook_managed_network_configs, + webhook_source_configs, ) .await .map_err(rpc_types::error::Error::from)?; @@ -448,13 +527,133 @@ impl Session { ); } + fn collect_webhook_source_instance_ids( + metas: Vec, + ) -> HashSet { + metas + .into_iter() + .filter_map(|meta| { + (RpcConfigSource::try_from(meta.source).ok() == Some(RpcConfigSource::Webhook)) + .then(|| { + meta.inst_id + .map(|inst_id| Into::::into(inst_id).to_string()) + }) + .flatten() + }) + .collect() + } + + async fn sync_running_config_sources( + db: &crate::db::Db, + user_id: i32, + machine_id: uuid::Uuid, + local_configs: &[crate::db::entity::user_running_network_configs::Model], + metas: &[NetworkMeta], + ) -> anyhow::Result<()> { + let local_configs_by_id = local_configs + .iter() + .map(|cfg| (cfg.network_instance_id.clone(), cfg)) + .collect::>(); + + for meta in metas { + let Some(inst_id) = meta.inst_id.as_ref().map(|inst_id| { + let inst_id: uuid::Uuid = (*inst_id).into(); + inst_id + }) else { + continue; + }; + let inst_id_str = inst_id.to_string(); + let Some(local_cfg) = local_configs_by_id.get(&inst_id_str) else { + continue; + }; + + let Some(running_source) = ConfigSource::from_rpc(meta.source) else { + continue; + }; + let local_source = PersistedConfigSource::from_db(&local_cfg.source); + if !local_source.should_update_from_runtime(running_source) { + continue; + } + + db.insert_or_update_user_network_config( + (user_id, machine_id), + inst_id, + local_cfg.get_network_config().map_err(|e| { + anyhow::anyhow!("failed to decode local network config {}: {:?}", inst_id, e) + })?, + running_source, + ) + .await + .map_err(|e| { + anyhow::anyhow!( + "failed to sync running network config source {}: {:?}", + inst_id, + e + ) + })?; + } + + Ok(()) + } + + async fn repair_legacy_running_config_sources( + db: &crate::db::Db, + user_id: i32, + machine_id: uuid::Uuid, + local_configs: &[crate::db::entity::user_running_network_configs::Model], + ) -> anyhow::Result { + let legacy_configs = local_configs + .iter() + .filter(|cfg| { + PersistedConfigSource::from_db(&cfg.source) == PersistedConfigSource::Legacy + }) + .collect::>(); + + if legacy_configs.is_empty() { + return Ok(false); + } + + for local_cfg in legacy_configs { + let inst_id = + uuid::Uuid::parse_str(&local_cfg.network_instance_id).with_context(|| { + format!( + "failed to parse legacy network config instance id {}", + local_cfg.network_instance_id + ) + })?; + + db.insert_or_update_user_network_config( + (user_id, machine_id), + inst_id, + local_cfg.get_network_config().map_err(|e| { + anyhow::anyhow!( + "failed to decode legacy network config {}: {:?}", + inst_id, + e + ) + })?, + ConfigSource::User, + ) + .await + .map_err(|e| { + anyhow::anyhow!( + "failed to repair legacy network config source {}: {:?}", + inst_id, + e + ) + })?; + } + + Ok(true) + } + async fn run_network_on_start( mut heartbeat_waiter: broadcast::Receiver, storage: WeakRefStorage, rpc_client: SessionRpcClient, ) { - let mut cleaned_web_managed_instances = false; - let mut last_desired_inst_ids: Option> = None; + let mut cleaned_webhook_source_instances = false; + let mut last_desired_webhook_inst_ids: Option> = None; loop { heartbeat_waiter = heartbeat_waiter.resubscribe(); let req = heartbeat_waiter.recv().await; @@ -510,37 +709,160 @@ impl Session { } }; + let mut local_configs = local_configs; + let running_metas = if req.support_config_source { + let ret = if running_inst_ids.is_empty() { + Ok(Vec::new()) + } else { + rpc_client + .list_network_instance_meta( + BaseController::default(), + easytier::proto::api::manage::ListNetworkInstanceMetaRequest { + inst_ids: running_inst_ids + .iter() + .filter_map(|inst_id| uuid::Uuid::parse_str(inst_id).ok()) + .map(Into::into) + .collect(), + }, + ) + .await + .map(|resp| resp.metas) + }; + + match ret { + Ok(metas) => { + if let Err(e) = Self::sync_running_config_sources( + &storage.db, + user_id, + machine_id.into(), + &local_configs, + &metas, + ) + .await + { + tracing::warn!( + ?user_id, + ?machine_id, + %e, + "Failed to sync running network config sources" + ); + } else if !metas.is_empty() { + local_configs = match storage + .db + .list_network_configs( + (user_id, machine_id.into()), + ListNetworkProps::EnabledOnly, + ) + .await + { + Ok(configs) => configs, + Err(e) => { + tracing::error!( + "Failed to reload network configs after source sync, error: {:?}", + e + ); + return; + } + }; + } + Some(metas) + } + Err(e) => { + tracing::warn!( + ?user_id, + %e, + "Failed to list running network instance metadata" + ); + None + } + } + } else { + None + }; + + match Self::repair_legacy_running_config_sources( + &storage.db, + user_id, + machine_id.into(), + &local_configs, + ) + .await + { + Ok(true) => { + local_configs = match storage + .db + .list_network_configs( + (user_id, machine_id.into()), + ListNetworkProps::EnabledOnly, + ) + .await + { + Ok(configs) => configs, + Err(e) => { + tracing::error!( + "Failed to reload network configs after legacy source repair, error: {:?}", + e + ); + return; + } + }; + } + Ok(false) => {} + Err(e) => { + tracing::warn!( + ?user_id, + ?machine_id, + %e, + "Failed to repair legacy running network config sources" + ); + } + } + let mut has_failed = false; - let should_be_alive_inst_ids = local_configs + let should_be_alive_webhook_inst_ids = local_configs .iter() + .filter(|cfg| cfg.get_runtime_network_config_source() == ConfigSource::Webhook) .map(|cfg| cfg.network_instance_id.clone()) .collect::>(); - let desired_changed = last_desired_inst_ids + let desired_changed = last_desired_webhook_inst_ids .as_ref() - .is_none_or(|last| last != &should_be_alive_inst_ids); + .is_none_or(|last| last != &should_be_alive_webhook_inst_ids); - if !cleaned_web_managed_instances || desired_changed { - let all_local_configs = match storage + if !cleaned_webhook_source_instances || desired_changed { + let db_webhook_inst_ids = match storage .db .list_network_configs((user_id, machine_id.into()), ListNetworkProps::All) .await { - Ok(configs) => configs, + Ok(configs) => configs + .iter() + .filter(|cfg| { + cfg.get_runtime_network_config_source() == ConfigSource::Webhook + }) + .map(|cfg| cfg.network_instance_id.clone()) + .collect::>(), Err(e) => { tracing::error!("Failed to list all network configs, error: {:?}", e); return; } }; - let all_inst_ids = all_local_configs - .iter() - .map(|cfg| cfg.network_instance_id.clone()) + let running_webhook_inst_ids = if let Some(metas) = running_metas.as_ref() { + Self::collect_webhook_source_instance_ids(metas.clone()) + } else { + running_inst_ids + .intersection(&db_webhook_inst_ids) + .cloned() + .collect() + }; + + let should_delete_inst_ids = running_webhook_inst_ids + .difference(&should_be_alive_webhook_inst_ids) + .cloned() .collect::>(); - let should_delete_ids = running_inst_ids + let should_delete_ids = should_delete_inst_ids .iter() - .chain(all_inst_ids.iter()) - .filter(|inst_id| !should_be_alive_inst_ids.contains(*inst_id)) .filter_map(|inst_id| uuid::Uuid::parse_str(inst_id).ok()) .map(Into::into) .collect::>(); @@ -556,7 +878,7 @@ impl Session { .await; tracing::info!( ?user_id, - "Clean non-web-managed network instances on start: {:?}, user_token: {:?}", + "Clean stale webhook-source network instances on start: {:?}, user_token: {:?}", ret, req.user_token ); @@ -564,8 +886,8 @@ impl Session { } if !has_failed { - cleaned_web_managed_instances = true; - last_desired_inst_ids = Some(should_be_alive_inst_ids.clone()); + cleaned_webhook_source_instances = true; + last_desired_webhook_inst_ids = Some(should_be_alive_webhook_inst_ids.clone()); } } @@ -573,6 +895,16 @@ impl Session { if running_inst_ids.contains(&c.network_instance_id) { continue; } + let Some(source) = PersistedConfigSource::from_db(&c.source).auto_run_rpc_source() + else { + tracing::warn!( + ?user_id, + ?machine_id, + instance_id = %c.network_instance_id, + "skip auto-run for legacy config until source ownership is repaired" + ); + continue; + }; let ret = rpc_client .run_network_instance( BaseController::default(), @@ -582,6 +914,7 @@ impl Session { serde_json::from_str::(&c.network_config).unwrap(), ), overwrite: false, + source: source as i32, }, ) .await; @@ -596,7 +929,7 @@ impl Session { } if !has_failed { - last_desired_inst_ids = Some(should_be_alive_inst_ids); + last_desired_webhook_inst_ids = Some(should_be_alive_webhook_inst_ids); } } } @@ -634,13 +967,17 @@ impl Session { #[cfg(test)] mod tests { - use easytier::rpc_service::remote_client::{ListNetworkProps, Storage as _}; + use easytier::{ + common::config::ConfigSource, + rpc_service::remote_client::{ListNetworkProps, PersistentConfig as _, Storage as _}, + }; + use sea_orm::{ActiveModelTrait, Set}; use serde_json::json; use super::{super::storage::Storage, *}; #[tokio::test] - async fn reconcile_managed_network_configs_upserts_and_deletes_exact_set() { + async fn reconcile_webhook_source_configs_upserts_and_deletes_exact_set() { let storage = Storage::new(crate::db::Db::memory_db().await); let user_id = storage .db() @@ -662,6 +999,7 @@ mod tests { network_name: Some("old-name".to_string()), ..Default::default() }, + ConfigSource::Webhook, ) .await .unwrap(); @@ -674,11 +1012,12 @@ mod tests { network_name: Some("stale".to_string()), ..Default::default() }, + ConfigSource::Webhook, ) .await .unwrap(); - SessionRpcService::reconcile_managed_network_configs( + SessionRpcService::reconcile_webhook_source_configs( &storage, user_id, machine_id, @@ -729,5 +1068,353 @@ mod tests { updated_keep_config.network_name.as_deref(), Some("updated-name") ); + assert_eq!( + updated_keep.get_network_config_source(), + ConfigSource::Webhook + ); + } + + #[tokio::test] + async fn reconcile_webhook_source_configs_keep_user_owned_configs() { + let storage = Storage::new(crate::db::Db::memory_db().await); + let user_id = storage + .db() + .auto_create_user("webhook-user-keep-user") + .await + .unwrap() + .id; + let machine_id = uuid::Uuid::new_v4(); + let user_owned_id = uuid::Uuid::new_v4(); + let webhook_owned_id = uuid::Uuid::new_v4(); + + storage + .db() + .insert_or_update_user_network_config( + (user_id, machine_id), + user_owned_id, + NetworkConfig { + network_name: Some("user-owned".to_string()), + ..Default::default() + }, + ConfigSource::User, + ) + .await + .unwrap(); + storage + .db() + .insert_or_update_user_network_config( + (user_id, machine_id), + webhook_owned_id, + NetworkConfig { + network_name: Some("webhook-owned".to_string()), + ..Default::default() + }, + ConfigSource::Webhook, + ) + .await + .unwrap(); + + SessionRpcService::reconcile_webhook_source_configs( + &storage, + user_id, + machine_id, + vec![crate::webhook::ManagedNetworkConfig { + instance_id: user_owned_id.to_string(), + network_config: json!({ + "instance_id": user_owned_id.to_string(), + "network_name": "webhook-tries-to-take-over" + }), + }], + ) + .await + .unwrap(); + + let user_owned = storage + .db() + .get_network_config((user_id, machine_id), &user_owned_id.to_string()) + .await + .unwrap() + .unwrap(); + assert_eq!(user_owned.get_network_config_source(), ConfigSource::User); + let user_owned_cfg: NetworkConfig = + serde_json::from_str(&user_owned.network_config).unwrap(); + assert_eq!(user_owned_cfg.network_name.as_deref(), Some("user-owned")); + + let webhook_owned = storage + .db() + .get_network_config((user_id, machine_id), &webhook_owned_id.to_string()) + .await + .unwrap(); + assert!(webhook_owned.is_none()); + } + + #[tokio::test] + async fn reconcile_webhook_source_configs_adopts_legacy_rows_for_webhook() { + let storage = Storage::new(crate::db::Db::memory_db().await); + let user_id = storage + .db() + .auto_create_user("webhook-user-legacy") + .await + .unwrap() + .id; + let machine_id = uuid::Uuid::new_v4(); + let legacy_match_id = uuid::Uuid::new_v4(); + let legacy_user_id = uuid::Uuid::new_v4(); + + crate::db::entity::user_running_network_configs::ActiveModel { + user_id: Set(user_id), + device_id: Set(machine_id.to_string()), + network_instance_id: Set(legacy_match_id.to_string()), + network_config: Set(serde_json::to_string(&NetworkConfig { + network_name: Some("legacy-webhook".to_string()), + ..Default::default() + }) + .unwrap()), + source: Set(LEGACY_NETWORK_CONFIG_SOURCE.to_string()), + disabled: Set(false), + create_time: Set(sqlx::types::chrono::Local::now().fixed_offset()), + update_time: Set(sqlx::types::chrono::Local::now().fixed_offset()), + ..Default::default() + } + .insert(storage.db().orm_db()) + .await + .unwrap(); + + crate::db::entity::user_running_network_configs::ActiveModel { + user_id: Set(user_id), + device_id: Set(machine_id.to_string()), + network_instance_id: Set(legacy_user_id.to_string()), + network_config: Set(serde_json::to_string(&NetworkConfig { + network_name: Some("legacy-user".to_string()), + ..Default::default() + }) + .unwrap()), + source: Set(LEGACY_NETWORK_CONFIG_SOURCE.to_string()), + disabled: Set(false), + create_time: Set(sqlx::types::chrono::Local::now().fixed_offset()), + update_time: Set(sqlx::types::chrono::Local::now().fixed_offset()), + ..Default::default() + } + .insert(storage.db().orm_db()) + .await + .unwrap(); + + SessionRpcService::reconcile_webhook_source_configs( + &storage, + user_id, + machine_id, + vec![crate::webhook::ManagedNetworkConfig { + instance_id: legacy_match_id.to_string(), + network_config: json!({ + "instance_id": legacy_match_id.to_string(), + "network_name": "managed-by-webhook" + }), + }], + ) + .await + .unwrap(); + + let adopted = storage + .db() + .get_network_config((user_id, machine_id), &legacy_match_id.to_string()) + .await + .unwrap() + .unwrap(); + assert_eq!(adopted.source, ConfigSource::Webhook.as_str()); + let adopted_cfg: NetworkConfig = serde_json::from_str(&adopted.network_config).unwrap(); + assert_eq!( + adopted_cfg.network_name.as_deref(), + Some("managed-by-webhook") + ); + + let untouched_legacy = storage + .db() + .get_network_config((user_id, machine_id), &legacy_user_id.to_string()) + .await + .unwrap() + .unwrap(); + assert_eq!(untouched_legacy.source, LEGACY_NETWORK_CONFIG_SOURCE); + } + + #[tokio::test] + async fn sync_running_config_sources_updates_enabled_config_source_from_runtime() { + let storage = Storage::new(crate::db::Db::memory_db().await); + let user_id = storage + .db() + .auto_create_user("webhook-user-sync-source") + .await + .unwrap() + .id; + let machine_id = uuid::Uuid::new_v4(); + let inst_id = uuid::Uuid::new_v4(); + + storage + .db() + .insert_or_update_user_network_config( + (user_id, machine_id), + inst_id, + NetworkConfig { + network_name: Some("webhook-owned".to_string()), + ..Default::default() + }, + ConfigSource::Webhook, + ) + .await + .unwrap(); + + let local_configs = storage + .db() + .list_network_configs((user_id, machine_id), ListNetworkProps::EnabledOnly) + .await + .unwrap(); + Session::sync_running_config_sources( + storage.db(), + user_id, + machine_id, + &local_configs, + &[easytier::proto::api::manage::NetworkMeta { + inst_id: Some(inst_id.into()), + source: RpcConfigSource::User as i32, + ..Default::default() + }], + ) + .await + .unwrap(); + + let updated = storage + .db() + .get_network_config((user_id, machine_id), &inst_id.to_string()) + .await + .unwrap() + .unwrap(); + assert_eq!(updated.get_network_config_source(), ConfigSource::Webhook); + } + + #[tokio::test] + async fn sync_running_config_sources_keeps_legacy_rows_when_runtime_source_is_user() { + let storage = Storage::new(crate::db::Db::memory_db().await); + let user_id = storage + .db() + .auto_create_user("webhook-user-sync-legacy") + .await + .unwrap() + .id; + let machine_id = uuid::Uuid::new_v4(); + let inst_id = uuid::Uuid::new_v4(); + + crate::db::entity::user_running_network_configs::ActiveModel { + user_id: Set(user_id), + device_id: Set(machine_id.to_string()), + network_instance_id: Set(inst_id.to_string()), + network_config: Set(serde_json::to_string(&NetworkConfig { + network_name: Some("legacy".to_string()), + ..Default::default() + }) + .unwrap()), + source: Set(LEGACY_NETWORK_CONFIG_SOURCE.to_string()), + disabled: Set(false), + create_time: Set(sqlx::types::chrono::Local::now().fixed_offset()), + update_time: Set(sqlx::types::chrono::Local::now().fixed_offset()), + ..Default::default() + } + .insert(storage.db().orm_db()) + .await + .unwrap(); + + let local_configs = storage + .db() + .list_network_configs((user_id, machine_id), ListNetworkProps::EnabledOnly) + .await + .unwrap(); + Session::sync_running_config_sources( + storage.db(), + user_id, + machine_id, + &local_configs, + &[easytier::proto::api::manage::NetworkMeta { + inst_id: Some(inst_id.into()), + source: RpcConfigSource::User as i32, + ..Default::default() + }], + ) + .await + .unwrap(); + + let updated = storage + .db() + .get_network_config((user_id, machine_id), &inst_id.to_string()) + .await + .unwrap() + .unwrap(); + assert_eq!(updated.source, LEGACY_NETWORK_CONFIG_SOURCE); + } + + #[tokio::test] + async fn repair_legacy_running_config_sources_promotes_remaining_legacy_rows_to_user() { + let storage = Storage::new(crate::db::Db::memory_db().await); + let user_id = storage + .db() + .auto_create_user("webhook-user-repair-legacy") + .await + .unwrap() + .id; + let machine_id = uuid::Uuid::new_v4(); + let inst_id = uuid::Uuid::new_v4(); + + crate::db::entity::user_running_network_configs::ActiveModel { + user_id: Set(user_id), + device_id: Set(machine_id.to_string()), + network_instance_id: Set(inst_id.to_string()), + network_config: Set(serde_json::to_string(&NetworkConfig { + network_name: Some("legacy".to_string()), + ..Default::default() + }) + .unwrap()), + source: Set(LEGACY_NETWORK_CONFIG_SOURCE.to_string()), + disabled: Set(false), + create_time: Set(sqlx::types::chrono::Local::now().fixed_offset()), + update_time: Set(sqlx::types::chrono::Local::now().fixed_offset()), + ..Default::default() + } + .insert(storage.db().orm_db()) + .await + .unwrap(); + + let local_configs = storage + .db() + .list_network_configs((user_id, machine_id), ListNetworkProps::EnabledOnly) + .await + .unwrap(); + assert!( + Session::repair_legacy_running_config_sources( + storage.db(), + user_id, + machine_id, + &local_configs, + ) + .await + .unwrap() + ); + + let updated = storage + .db() + .get_network_config((user_id, machine_id), &inst_id.to_string()) + .await + .unwrap() + .unwrap(); + assert_eq!(updated.source, ConfigSource::User.as_str()); + } + + #[test] + fn legacy_configs_are_not_auto_run_until_repaired() { + assert_eq!(PersistedConfigSource::Legacy.auto_run_rpc_source(), None); + assert_eq!( + PersistedConfigSource::Webhook.auto_run_rpc_source(), + Some(RpcConfigSource::Webhook) + ); + assert_eq!( + PersistedConfigSource::User.auto_run_rpc_source(), + Some(RpcConfigSource::User) + ); } } diff --git a/easytier-web/src/db/entity/user_running_network_configs.rs b/easytier-web/src/db/entity/user_running_network_configs.rs index 0525ba36..88a974a7 100644 --- a/easytier-web/src/db/entity/user_running_network_configs.rs +++ b/easytier-web/src/db/entity/user_running_network_configs.rs @@ -1,6 +1,9 @@ //! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.0 -use easytier::{launcher::NetworkConfig, rpc_service::remote_client::PersistentConfig}; +use easytier::{ + common::config::ConfigSource, launcher::NetworkConfig, + rpc_service::remote_client::PersistentConfig, +}; use sea_orm::entity::prelude::*; use serde::{Deserialize, Serialize}; @@ -12,10 +15,12 @@ pub struct Model { pub user_id: i32, #[sea_orm(column_type = "Text")] pub device_id: String, - #[sea_orm(column_type = "Text", unique)] + #[sea_orm(column_type = "Text")] pub network_instance_id: String, #[sea_orm(column_type = "Text")] pub network_config: String, + #[sea_orm(column_type = "Text")] + pub source: String, pub disabled: bool, pub create_time: DateTimeWithTimeZone, pub update_time: DateTimeWithTimeZone, @@ -48,4 +53,7 @@ impl PersistentConfig for Model { fn get_network_config(&self) -> Result { serde_json::from_str(&self.network_config).map_err(|e| DbErr::Json(e.to_string())) } + fn get_network_config_source(&self) -> ConfigSource { + self.source.parse().unwrap_or(ConfigSource::User) + } } diff --git a/easytier-web/src/db/mod.rs b/easytier-web/src/db/mod.rs index 8627b505..66b0e567 100644 --- a/easytier-web/src/db/mod.rs +++ b/easytier-web/src/db/mod.rs @@ -3,6 +3,7 @@ pub mod entity; use easytier::{ + common::config::ConfigSource, launcher::NetworkConfig, rpc_service::remote_client::{ListNetworkProps, Storage}, }; @@ -149,6 +150,7 @@ impl Storage<(UserIdInDb, Uuid), user_running_network_configs::Model, DbErr> for (user_id, device_id): (UserIdInDb, Uuid), network_inst_id: Uuid, network_config: NetworkConfig, + source: ConfigSource, ) -> Result<(), DbErr> { let txn = self.orm_db().begin().await?; @@ -161,6 +163,7 @@ impl Storage<(UserIdInDb, Uuid), user_running_network_configs::Model, DbErr> for ]) .update_columns([ urnc::Column::NetworkConfig, + urnc::Column::Source, urnc::Column::Disabled, urnc::Column::UpdateTime, ]) @@ -172,6 +175,7 @@ impl Storage<(UserIdInDb, Uuid), user_running_network_configs::Model, DbErr> for network_config: sea_orm::Set( serde_json::to_string(&network_config).map_err(|e| DbErr::Json(e.to_string()))?, ), + source: sea_orm::Set(source.as_str().to_string()), disabled: sea_orm::Set(false), create_time: sea_orm::Set(chrono::Local::now().fixed_offset()), update_time: sea_orm::Set(chrono::Local::now().fixed_offset()), @@ -277,8 +281,12 @@ impl Storage<(UserIdInDb, Uuid), user_running_network_configs::Model, DbErr> for #[cfg(test)] mod tests { - use easytier::{proto::api::manage::NetworkConfig, rpc_service::remote_client::Storage}; - use sea_orm::{ColumnTrait, EntityTrait, QueryFilter as _}; + use easytier::{ + common::config::ConfigSource, + proto::api::manage::NetworkConfig, + rpc_service::remote_client::{PersistentConfig, Storage}, + }; + use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter as _, Set}; use crate::db::{Db, ListNetworkProps, entity::user_running_network_configs}; @@ -294,9 +302,14 @@ mod tests { let inst_id = uuid::Uuid::new_v4(); let device_id = uuid::Uuid::new_v4(); - db.insert_or_update_user_network_config((user_id, device_id), inst_id, network_config) - .await - .unwrap(); + db.insert_or_update_user_network_config( + (user_id, device_id), + inst_id, + network_config, + ConfigSource::User, + ) + .await + .unwrap(); let result = user_running_network_configs::Entity::find() .filter(user_running_network_configs::Column::UserId.eq(user_id)) @@ -306,6 +319,7 @@ mod tests { .unwrap(); println!("{:?}", result); assert_eq!(result.network_config, network_config_json); + assert_eq!(result.get_network_config_source(), ConfigSource::User); // overwrite the config let network_config = NetworkConfig { @@ -313,9 +327,14 @@ mod tests { ..Default::default() }; let network_config_json = serde_json::to_string(&network_config).unwrap(); - db.insert_or_update_user_network_config((user_id, device_id), inst_id, network_config) - .await - .unwrap(); + db.insert_or_update_user_network_config( + (user_id, device_id), + inst_id, + network_config, + ConfigSource::Webhook, + ) + .await + .unwrap(); let result2 = user_running_network_configs::Entity::find() .filter(user_running_network_configs::Column::UserId.eq(user_id)) @@ -325,6 +344,11 @@ mod tests { .unwrap(); println!("device: {}, {:?}", device_id, result2); assert_eq!(result2.network_config, network_config_json); + assert_eq!(result2.get_network_config_source(), ConfigSource::Webhook); + assert_eq!( + result2.get_runtime_network_config_source(), + ConfigSource::Webhook + ); assert_eq!(result.create_time, result2.create_time); assert_ne!(result.update_time, result2.update_time); @@ -348,6 +372,45 @@ mod tests { assert!(result3.is_none()); } + #[tokio::test] + async fn test_legacy_network_config_defaults_to_user_runtime_source() { + let db = Db::memory_db().await; + let user_id = 1; + let inst_id = uuid::Uuid::new_v4(); + let device_id = uuid::Uuid::new_v4(); + + user_running_network_configs::ActiveModel { + user_id: Set(user_id), + device_id: Set(device_id.to_string()), + network_instance_id: Set(inst_id.to_string()), + network_config: Set(serde_json::to_string(&NetworkConfig { + network_name: Some("legacy".to_string()), + ..Default::default() + }) + .unwrap()), + source: Set("legacy".to_string()), + disabled: Set(false), + create_time: Set(sqlx::types::chrono::Local::now().fixed_offset()), + update_time: Set(sqlx::types::chrono::Local::now().fixed_offset()), + ..Default::default() + } + .insert(db.orm_db()) + .await + .unwrap(); + + let result = user_running_network_configs::Entity::find() + .filter(user_running_network_configs::Column::UserId.eq(user_id)) + .one(db.orm_db()) + .await + .unwrap() + .unwrap(); + assert_eq!(result.get_network_config_source(), ConfigSource::User); + assert_eq!( + result.get_runtime_network_config_source(), + ConfigSource::User + ); + } + #[tokio::test] async fn test_user_network_config_same_instance_id_is_scoped_by_device() { let db = Db::memory_db().await; @@ -363,6 +426,7 @@ mod tests { network_name: Some("cfg-1".to_string()), ..Default::default() }, + ConfigSource::User, ) .await .unwrap(); @@ -373,6 +437,7 @@ mod tests { network_name: Some("cfg-2".to_string()), ..Default::default() }, + ConfigSource::User, ) .await .unwrap(); diff --git a/easytier-web/src/migrator/m20260421_000003_add_network_config_source.rs b/easytier-web/src/migrator/m20260421_000003_add_network_config_source.rs new file mode 100644 index 00000000..e2ee1719 --- /dev/null +++ b/easytier-web/src/migrator/m20260421_000003_add_network_config_source.rs @@ -0,0 +1,125 @@ +use sea_orm_migration::prelude::*; + +pub struct Migration; + +impl MigrationName for Migration { + fn name(&self) -> &str { + "m20260421_000003_add_network_config_source" + } +} + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let db = manager.get_connection(); + + db.execute_unprepared( + r#" + CREATE TABLE user_running_network_configs_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + user_id INTEGER NOT NULL, + device_id TEXT NOT NULL, + network_instance_id TEXT NOT NULL, + network_config TEXT NOT NULL, + source TEXT NOT NULL DEFAULT 'user', + disabled BOOLEAN NOT NULL DEFAULT FALSE, + create_time TEXT NOT NULL, + update_time TEXT NOT NULL, + CONSTRAINT fk_user_running_network_configs_user_id_to_users_id + FOREIGN KEY (user_id) REFERENCES users(id) + ON DELETE CASCADE + ON UPDATE CASCADE + ); + + INSERT INTO user_running_network_configs_new ( + id, + user_id, + device_id, + network_instance_id, + network_config, + source, + disabled, + create_time, + update_time + ) + SELECT + id, + user_id, + device_id, + network_instance_id, + network_config, + 'legacy', + disabled, + create_time, + update_time + FROM user_running_network_configs; + + DROP TABLE user_running_network_configs; + ALTER TABLE user_running_network_configs_new RENAME TO user_running_network_configs; + + CREATE INDEX idx_user_running_network_configs_user_id + ON user_running_network_configs(user_id); + CREATE UNIQUE INDEX idx_user_running_network_configs_scope_inst + ON user_running_network_configs(user_id, device_id, network_instance_id); + "#, + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let db = manager.get_connection(); + + db.execute_unprepared( + r#" + CREATE TABLE user_running_network_configs_old ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + user_id INTEGER NOT NULL, + device_id TEXT NOT NULL, + network_instance_id TEXT NOT NULL, + network_config TEXT NOT NULL, + disabled BOOLEAN NOT NULL DEFAULT FALSE, + create_time TEXT NOT NULL, + update_time TEXT NOT NULL, + CONSTRAINT fk_user_running_network_configs_user_id_to_users_id + FOREIGN KEY (user_id) REFERENCES users(id) + ON DELETE CASCADE + ON UPDATE CASCADE + ); + + INSERT INTO user_running_network_configs_old ( + id, + user_id, + device_id, + network_instance_id, + network_config, + disabled, + create_time, + update_time + ) + SELECT + id, + user_id, + device_id, + network_instance_id, + network_config, + disabled, + create_time, + update_time + FROM user_running_network_configs; + + DROP TABLE user_running_network_configs; + ALTER TABLE user_running_network_configs_old RENAME TO user_running_network_configs; + + CREATE INDEX idx_user_running_network_configs_user_id + ON user_running_network_configs(user_id); + CREATE UNIQUE INDEX idx_user_running_network_configs_scope_inst + ON user_running_network_configs(user_id, device_id, network_instance_id); + "#, + ) + .await?; + + Ok(()) + } +} diff --git a/easytier-web/src/migrator/mod.rs b/easytier-web/src/migrator/mod.rs index e5490267..03be5eea 100644 --- a/easytier-web/src/migrator/mod.rs +++ b/easytier-web/src/migrator/mod.rs @@ -2,6 +2,7 @@ use sea_orm_migration::prelude::*; mod m20241029_000001_init; mod m20260403_000002_scope_network_config_unique; +mod m20260421_000003_add_network_config_source; pub struct Migrator; @@ -11,6 +12,7 @@ impl MigratorTrait for Migrator { vec![ Box::new(m20241029_000001_init::Migration), Box::new(m20260403_000002_scope_network_config_unique::Migration), + Box::new(m20260421_000003_add_network_config_source::Migration), ] } } diff --git a/easytier/src/common/config.rs b/easytier/src/common/config.rs index 3969dd0a..be959587 100644 --- a/easytier/src/common/config.rs +++ b/easytier/src/common/config.rs @@ -18,6 +18,7 @@ use crate::{ instance::dns_server::DEFAULT_ET_DNS_ZONE, proto::{ acl::Acl, + api::manage::ConfigSource as RpcConfigSource, common::{CompressionAlgoPb, PortForwardConfigPb, SecureModeConfig, SocketType}, }, tunnel::generate_digest_from_str, @@ -206,6 +207,11 @@ pub trait ConfigLoader: Send + Sync { } fn set_credential_file(&self, _path: Option) {} + fn get_network_config_source(&self) -> ConfigSource { + ConfigSource::User + } + fn set_network_config_source(&self, _source: Option) {} + fn dump(&self) -> String; } @@ -225,6 +231,55 @@ pub struct NetworkIdentity { pub network_secret_digest: Option, } +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum ConfigSource { + #[default] + User, + Webhook, +} + +impl ConfigSource { + pub fn as_str(self) -> &'static str { + match self { + Self::User => "user", + Self::Webhook => "webhook", + } + } + + pub fn from_rpc(source: i32) -> Option { + match RpcConfigSource::try_from(source).ok() { + Some(RpcConfigSource::Webhook) => Some(Self::Webhook), + Some(RpcConfigSource::User) => Some(Self::User), + _ => None, + } + } + + pub fn to_rpc(self) -> i32 { + match self { + Self::User => RpcConfigSource::User as i32, + Self::Webhook => RpcConfigSource::Webhook as i32, + } + } +} + +impl std::str::FromStr for ConfigSource { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "user" => Ok(Self::User), + "webhook" => Ok(Self::Webhook), + other => Err(format!("unknown network config source: {other}")), + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +struct ConfigSourceConfig { + source: ConfigSource, +} + #[derive(Eq, PartialEq, Hash)] struct NetworkIdentityWithOnlyDigest { network_name: String, @@ -466,6 +521,7 @@ struct Config { stun_servers_v6: Option>, credential_file: Option, + source: Option, } #[derive(Debug, Clone)] @@ -480,10 +536,21 @@ impl Default for TomlConfigLoader { } impl TomlConfigLoader { + fn normalize_config_source(config: &mut Config) { + if matches!( + config.source.as_ref().map(|source| source.source), + Some(ConfigSource::User) + ) { + config.source = None; + } + } + pub fn new_from_str(config_str: &str) -> Result { let mut config = toml::de::from_str::(config_str) .with_context(|| format!("failed to parse config file: {}", config_str))?; + Self::normalize_config_source(&mut config); + config.flags_struct = Some(Self::gen_flags(config.flags.clone().unwrap_or_default())); let config = TomlConfigLoader { @@ -867,6 +934,23 @@ impl ConfigLoader for TomlConfigLoader { self.config.lock().unwrap().credential_file = path; } + fn get_network_config_source(&self) -> ConfigSource { + self.config + .lock() + .unwrap() + .source + .as_ref() + .map(|source| source.source) + .unwrap_or(ConfigSource::User) + } + + fn set_network_config_source(&self, source: Option) { + self.config.lock().unwrap().source = source.and_then(|source| match source { + ConfigSource::User => None, + other => Some(ConfigSourceConfig { source: other }), + }); + } + fn dump(&self) -> String { let default_flags_json = serde_json::to_string(&gen_default_flags()).unwrap(); let default_flags_hashmap = @@ -888,6 +972,7 @@ impl ConfigLoader for TomlConfigLoader { } let mut config = self.config.lock().unwrap().clone(); + Self::normalize_config_source(&mut config); config.flags = Some(flag_map); if config.stun_servers == Some(StunInfoCollector::get_default_servers()) { config.stun_servers = None; @@ -1126,6 +1211,46 @@ stun_servers = [ assert_eq!(stun_servers[2], "txt:stun.easytier.cn"); } + #[test] + fn test_network_config_source_toml_roundtrip() { + let config = TomlConfigLoader::default(); + assert_eq!(config.get_network_config_source(), ConfigSource::User); + + config.set_network_config_source(Some(ConfigSource::Webhook)); + let dumped = config.dump(); + + assert!(dumped.contains("[source]")); + assert!(dumped.contains("source = \"webhook\"")); + + let loaded = TomlConfigLoader::new_from_str(&dumped).unwrap(); + assert_eq!(loaded.get_network_config_source(), ConfigSource::Webhook); + } + + #[test] + fn test_network_config_source_user_is_implicit() { + let config = TomlConfigLoader::default(); + config.set_network_config_source(Some(ConfigSource::User)); + let dumped = config.dump(); + + assert!(!dumped.contains("[source]")); + + let loaded = TomlConfigLoader::new_from_str(&dumped).unwrap(); + assert_eq!(loaded.get_network_config_source(), ConfigSource::User); + + let explicit_user = TomlConfigLoader::new_from_str( + r#" +[source] +source = "user" +"#, + ) + .unwrap(); + assert_eq!( + explicit_user.get_network_config_source(), + ConfigSource::User + ); + assert!(!explicit_user.dump().contains("[source]")); + } + #[tokio::test] async fn full_example_test() { let config_str = r#" diff --git a/easytier/src/core.rs b/easytier/src/core.rs index 4f89a843..96e3df75 100644 --- a/easytier/src/core.rs +++ b/easytier/src/core.rs @@ -813,7 +813,7 @@ impl NetworkOptions { fn can_merge( &self, cfg: &TomlConfigLoader, - source: ConfigSource, + source: ConfigFileSource, explicit_config_file_count: usize, config_dir_file_count: usize, ) -> bool { @@ -821,7 +821,7 @@ impl NetworkOptions { return false; } - if source == ConfigSource::CliConfigFile + if source == ConfigFileSource::CliConfigFile && explicit_config_file_count == 1 && config_dir_file_count == 0 { @@ -832,7 +832,7 @@ impl NetworkOptions { return false; }; - if source == ConfigSource::ConfigDir { + if source == ConfigFileSource::ConfigDir { return cfg.get_network_identity().network_name == *network_name; } @@ -1161,7 +1161,7 @@ impl NetworkOptions { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ConfigSource { +enum ConfigFileSource { CliConfigFile, ConfigDir, } @@ -1353,7 +1353,7 @@ async fn run_main(cli: Cli) -> anyhow::Result<()> { let mut config_files = if let Some(v) = cli.config_file { v.iter() .cloned() - .map(|path| (path, ConfigSource::CliConfigFile)) + .map(|path| (path, ConfigFileSource::CliConfigFile)) .collect() } else { vec![] @@ -1376,7 +1376,7 @@ async fn run_main(cli: Cli) -> anyhow::Result<()> { continue; } config_dir_file_count += 1; - config_files.push((path, ConfigSource::ConfigDir)); + config_files.push((path, ConfigFileSource::ConfigDir)); } } let config_file_count = config_files.len(); diff --git a/easytier/src/instance_manager.rs b/easytier/src/instance_manager.rs index 460c3772..26b257e3 100644 --- a/easytier/src/instance_manager.rs +++ b/easytier/src/instance_manager.rs @@ -4,7 +4,7 @@ use std::{collections::BTreeMap, path::PathBuf, sync::Arc}; use crate::{ common::{ - config::{ConfigFileControl, ConfigLoader, TomlConfigLoader}, + config::{ConfigFileControl, ConfigLoader, ConfigSource, TomlConfigLoader}, global_ctx::{EventBusSubscriber, GlobalCtxEvent}, log, scoped_task::ScopedTask, @@ -217,6 +217,15 @@ impl NetworkInstanceManager { .map(|instance| instance.value().get_config_file_control().clone()) } + pub fn get_instance_network_config_source( + &self, + instance_id: &uuid::Uuid, + ) -> Option { + self.instance_map + .get(instance_id) + .map(|instance| instance.value().get_network_config_source()) + } + pub fn get_instance_service( &self, instance_id: &uuid::Uuid, diff --git a/easytier/src/launcher.rs b/easytier/src/launcher.rs index 0d64bb84..6fbc67c6 100644 --- a/easytier/src/launcher.rs +++ b/easytier/src/launcher.rs @@ -1,4 +1,6 @@ -use crate::common::config::{ConfigFileControl, PortForwardConfig, process_secure_mode_cfg}; +use crate::common::config::{ + ConfigFileControl, ConfigSource, PortForwardConfig, process_secure_mode_cfg, +}; use crate::proto::api::{self, manage}; use crate::proto::rpc_types::controller::BaseController; use crate::rpc_service::InstanceRpcService; @@ -434,6 +436,10 @@ impl NetworkInstance { &self.config_file_control } + pub fn get_network_config_source(&self) -> ConfigSource { + self.config.get_network_config_source() + } + pub fn get_latest_error_msg(&self) -> Option { if let Some(launcher) = self.launcher.as_ref() { launcher.error_msg.read().unwrap().clone() diff --git a/easytier/src/peers/foreign_network_manager.rs b/easytier/src/peers/foreign_network_manager.rs index c9693e16..1fb6c22c 100644 --- a/easytier/src/peers/foreign_network_manager.rs +++ b/easytier/src/peers/foreign_network_manager.rs @@ -1575,6 +1575,41 @@ pub mod tests { ); } + #[tokio::test] + async fn secure_center_can_serve_legacy_and_secure_foreign_networks() { + let pm_center = create_mock_peer_manager_with_mock_stun(NatType::Unknown).await; + set_secure_mode_cfg(&pm_center.get_global_ctx(), true); + + let legacy_a = create_mock_peer_manager_for_foreign_network("legacy-net").await; + let legacy_b = create_mock_peer_manager_for_foreign_network("legacy-net").await; + connect_peer_manager(legacy_a.clone(), pm_center.clone()).await; + connect_peer_manager(legacy_b.clone(), pm_center.clone()).await; + wait_route_appear(legacy_a.clone(), legacy_b.clone()) + .await + .unwrap(); + + let secure_a = create_mock_peer_manager_for_secure_foreign_network("secure-net").await; + let secure_b = create_mock_peer_manager_for_secure_foreign_network("secure-net").await; + connect_peer_manager(secure_a.clone(), pm_center.clone()).await; + connect_peer_manager(secure_b.clone(), pm_center.clone()).await; + wait_route_appear(secure_a.clone(), secure_b.clone()) + .await + .unwrap(); + + assert_eq!(2, legacy_a.list_routes().await.len()); + assert_eq!(2, legacy_b.list_routes().await.len()); + assert_eq!(2, secure_a.list_routes().await.len()); + assert_eq!(2, secure_b.list_routes().await.len()); + + let rpc_resp = pm_center + .get_foreign_network_manager() + .list_foreign_networks() + .await; + assert_eq!(2, rpc_resp.foreign_networks.len()); + assert_eq!(2, rpc_resp.foreign_networks["legacy-net"].peers.len()); + assert_eq!(2, rpc_resp.foreign_networks["secure-net"].peers.len()); + } + #[tokio::test] async fn credential_pubkey_trust_requires_ospf_credential_source() { let global_ctx = get_mock_global_ctx_with_network(Some(NetworkIdentity::new( diff --git a/easytier/src/peers/peer_manager.rs b/easytier/src/peers/peer_manager.rs index c2dcbc0f..3fa73f59 100644 --- a/easytier/src/peers/peer_manager.rs +++ b/easytier/src/peers/peer_manager.rs @@ -536,6 +536,21 @@ impl PeerManager { async fn add_new_peer_conn(&self, peer_conn: PeerConn) -> Result<(), Error> { let my_identity = self.global_ctx.get_network_identity(); let peer_identity = peer_conn.get_network_identity(); + let conn_info = peer_conn.get_conn_info(); + let local_secure_mode = self + .global_ctx + .config + .get_secure_mode() + .as_ref() + .map(|cfg| cfg.enabled) + .unwrap_or(false); + let peer_secure_mode = !conn_info.noise_remote_static_pubkey.is_empty(); + + if local_secure_mode != peer_secure_mode { + return Err(Error::SecretKeyError( + "same-network peers must use the same secure mode".to_string(), + )); + } // For credential nodes, network_secret_digest is either None or all-zeros // (all-zeros when received over the wire via handshake). @@ -2717,7 +2732,7 @@ mod tests { } #[tokio::test] - async fn peer_manager_safe_server_accept_legacy_client() { + async fn peer_manager_same_network_secure_mode_mismatch_rejected() { let peer_mgr_client = create_mock_peer_manager_with_mock_stun(NatType::Unknown).await; let peer_mgr_server = create_mock_peer_manager_with_mock_stun(NatType::Unknown).await; @@ -2737,64 +2752,65 @@ mod tests { peer_mgr_client.add_client_tunnel(c_ring, false), peer_mgr_server.add_tunnel_as_server(s_ring, true) ); - let (server_id, _) = c_ret.unwrap(); - s_ret.unwrap(); + let _ = c_ret; + assert!( + s_ret.is_err(), + "same-network peer with mismatched secure mode should be rejected" + ); - wait_for_condition( - || { - let peer_mgr_client = peer_mgr_client.clone(); - async move { - if !peer_mgr_client - .get_peer_map() - .list_peers_with_conn() - .await - .contains(&server_id) - { - return false; - } - let Some(conns) = peer_mgr_client - .get_peer_map() - .list_peer_conns(server_id) - .await - else { - return false; - }; - conns.iter().any(|c| { - c.noise_local_static_pubkey.is_empty() - && c.noise_remote_static_pubkey.is_empty() - && c.secure_auth_level == SecureAuthLevel::None as i32 - }) - } - }, - Duration::from_secs(10), - ) - .await; - - let client_id = peer_mgr_client.my_peer_id(); wait_for_condition( || { let peer_mgr_server = peer_mgr_server.clone(); async move { - if !peer_mgr_server + peer_mgr_server .get_peer_map() .list_peers_with_conn() .await - .contains(&client_id) - { - return false; - } - let Some(conns) = peer_mgr_server + .is_empty() + } + }, + Duration::from_secs(5), + ) + .await; + } + + #[tokio::test] + async fn credential_node_rejects_legacy_client() { + let peer_mgr_client = create_mock_peer_manager_with_mock_stun(NatType::Unknown).await; + let peer_mgr_server = create_mock_peer_manager_with_mock_stun(NatType::Unknown).await; + + peer_mgr_client + .get_global_ctx() + .config + .set_network_identity(NetworkIdentity::new("net1".to_string(), "sec1".to_string())); + peer_mgr_server + .get_global_ctx() + .config + .set_network_identity(NetworkIdentity::new_credential("net1".to_string())); + + set_secure_mode_cfg(&peer_mgr_server.get_global_ctx(), true); + + let (c_ring, s_ring) = create_ring_tunnel_pair(); + let (c_ret, s_ret) = tokio::join!( + peer_mgr_client.add_client_tunnel(c_ring, false), + peer_mgr_server.add_tunnel_as_server(s_ring, true) + ); + + let _ = c_ret; + assert!( + s_ret.is_err(), + "credential server should reject legacy client" + ); + + wait_for_condition( + || { + let peer_mgr_server = peer_mgr_server.clone(); + async move { + peer_mgr_server .get_peer_map() - .list_peer_conns(client_id) + .list_peers_with_conn() .await - else { - return false; - }; - conns.iter().any(|c| { - c.noise_local_static_pubkey.is_empty() - && c.noise_remote_static_pubkey.is_empty() - && c.secure_auth_level == SecureAuthLevel::None as i32 - }) + .is_empty() } }, Duration::from_secs(5), diff --git a/easytier/src/proto/api_manage.proto b/easytier/src/proto/api_manage.proto index ab8a648c..1b5d9882 100644 --- a/easytier/src/proto/api_manage.proto +++ b/easytier/src/proto/api_manage.proto @@ -13,6 +13,12 @@ enum NetworkingMethod { Standalone = 2; } +enum ConfigSource { + ConfigSourceUnspecified = 0; + ConfigSourceUser = 1; + ConfigSourceWebhook = 2; +} + message NetworkConfig { optional string instance_id = 1; @@ -132,6 +138,7 @@ message NetworkMeta { string network_name = 2; uint32 config_permission = 3; string instance_name = 4; + ConfigSource source = 5; } message ValidateConfigRequest { NetworkConfig config = 1; } @@ -142,6 +149,7 @@ message RunNetworkInstanceRequest { common.UUID inst_id = 1; NetworkConfig config = 2; bool overwrite = 3; + ConfigSource source = 4; } message RunNetworkInstanceResponse { common.UUID inst_id = 1; } @@ -168,7 +176,10 @@ message DeleteNetworkInstanceResponse { message GetNetworkInstanceConfigRequest { common.UUID inst_id = 1; } -message GetNetworkInstanceConfigResponse { NetworkConfig config = 1; } +message GetNetworkInstanceConfigResponse { + NetworkConfig config = 1; + ConfigSource source = 2; +} message ListNetworkInstanceMetaRequest { repeated common.UUID inst_ids = 1; } diff --git a/easytier/src/proto/web.proto b/easytier/src/proto/web.proto index c28595e9..4468e10f 100644 --- a/easytier/src/proto/web.proto +++ b/easytier/src/proto/web.proto @@ -21,6 +21,7 @@ message HeartbeatRequest { repeated common.UUID running_network_instances = 7; DeviceOsInfo device_os = 8; + bool support_config_source = 9; } message HeartbeatResponse {} diff --git a/easytier/src/rpc_service/instance_manage.rs b/easytier/src/rpc_service/instance_manage.rs index 9e69b63a..af134cc9 100644 --- a/easytier/src/rpc_service/instance_manage.rs +++ b/easytier/src/rpc_service/instance_manage.rs @@ -1,10 +1,23 @@ use std::{collections::HashSet, sync::Arc}; use crate::{ - common::config::{ConfigFileControl, ConfigFilePermission, ConfigLoader}, + common::config::{ConfigFileControl, ConfigFilePermission, ConfigLoader, ConfigSource}, instance_manager::NetworkInstanceManager, proto::{ - api::{config::GetConfigRequest, manage::*}, + api::{ + config::GetConfigRequest, + manage::{ + CollectNetworkInfoRequest, CollectNetworkInfoResponse, + DeleteNetworkInstanceRequest, DeleteNetworkInstanceResponse, + GetNetworkInstanceConfigRequest, GetNetworkInstanceConfigResponse, + ListNetworkInstanceMetaRequest, ListNetworkInstanceMetaResponse, + ListNetworkInstanceRequest, ListNetworkInstanceResponse, + NetworkInstanceRunningInfoMap, NetworkMeta, RetainNetworkInstanceRequest, + RetainNetworkInstanceResponse, RunNetworkInstanceRequest, + RunNetworkInstanceResponse, ValidateConfigRequest, ValidateConfigResponse, + WebClientService, + }, + }, rpc_types::{self, controller::BaseController}, }, web_client::WebClientHooks, @@ -44,53 +57,64 @@ impl WebClientService for InstanceManageRpcService { return Err(anyhow::anyhow!("config is required").into()); } let cfg = req.config.unwrap().gen_config()?; - let id = cfg.get_id(); + let mut effective_id = cfg.get_id(); if let Some(inst_id) = req.inst_id { - cfg.set_id(inst_id.into()); + effective_id = inst_id.into(); + cfg.set_id(effective_id); } + let requested_source = ConfigSource::from_rpc(req.source); let resp = RunNetworkInstanceResponse { - inst_id: Some(id.into()), + inst_id: Some(effective_id.into()), }; - let mut control = if let Some(control) = self.manager.get_instance_config_control(&id) { - let error_msg = self - .manager - .get_network_info(&id) - .await - .and_then(|i| i.error_msg) - .unwrap_or_default(); + let mut control = + if let Some(control) = self.manager.get_instance_config_control(&effective_id) { + let existing_source = self + .manager + .get_instance_network_config_source(&effective_id); + let error_msg = self + .manager + .get_network_info(&effective_id) + .await + .and_then(|i| i.error_msg) + .unwrap_or_default(); - if !req.overwrite && error_msg.is_empty() { - return Ok(resp); - } - if control.is_read_only() { - return Err( - anyhow::anyhow!("instance {} is read-only, cannot be overwritten", id).into(), - ); - } - - if let Some(path) = control.path.as_ref() { - let real_control = ConfigFileControl::from_path(path.clone()).await; - if real_control.is_read_only() { + if !req.overwrite && error_msg.is_empty() { + return Ok(resp); + } + if control.is_read_only() { return Err(anyhow::anyhow!( - "config file {} is read-only, cannot be overwritten", - path.display() + "instance {} is read-only, cannot be overwritten", + effective_id ) .into()); } - } - self.manager.delete_network_instance(vec![id])?; + if let Some(path) = control.path.as_ref() { + let real_control = ConfigFileControl::from_path(path.clone()).await; + if real_control.is_read_only() { + return Err(anyhow::anyhow!( + "config file {} is read-only, cannot be overwritten", + path.display() + ) + .into()); + } + } - control.clone() - } else if let Some(config_dir) = self.manager.get_config_dir() { - ConfigFileControl::new( - Some(config_dir.join(format!("{}.toml", id))), - ConfigFilePermission::default(), - ) - } else { - ConfigFileControl::new(None, ConfigFilePermission::default()) - }; + self.manager.delete_network_instance(vec![effective_id])?; + + cfg.set_network_config_source(requested_source.or(existing_source)); + control.clone() + } else if let Some(config_dir) = self.manager.get_config_dir() { + cfg.set_network_config_source(requested_source); + ConfigFileControl::new( + Some(config_dir.join(format!("{}.toml", effective_id))), + ConfigFilePermission::default(), + ) + } else { + cfg.set_network_config_source(requested_source); + ConfigFileControl::new(None, ConfigFilePermission::default()) + }; if !control.is_read_only() && let Some(config_file) = control.path.as_ref() @@ -109,9 +133,9 @@ impl WebClientService for InstanceManageRpcService { } self.manager.run_network_instance(cfg, true, control)?; - println!("instance {} started", id); + println!("instance {} started", effective_id); - if let Err(e) = self.hooks.post_run_network_instance(&id).await { + if let Err(e) = self.hooks.post_run_network_instance(&effective_id).await { tracing::warn!("post-run hook failed: {}", e); } @@ -261,7 +285,14 @@ impl WebClientService for InstanceManageRpcService { .get_config(BaseController::default(), GetConfigRequest::default()) .await? .config; - Ok(GetNetworkInstanceConfigResponse { config }) + Ok(GetNetworkInstanceConfigResponse { + config, + source: self + .manager + .get_instance_network_config_source(&inst_id) + .unwrap_or(ConfigSource::User) + .to_rpc(), + }) } async fn list_network_instance_meta( @@ -286,6 +317,11 @@ impl WebClientService for InstanceManageRpcService { network_name, config_permission: control.permission.into(), instance_name, + source: self + .manager + .get_instance_network_config_source(&inst_id) + .unwrap_or(ConfigSource::User) + .to_rpc(), }; metas.push(meta); } diff --git a/easytier/src/rpc_service/remote_client.rs b/easytier/src/rpc_service/remote_client.rs index b088dde8..5cabe5f3 100644 --- a/easytier/src/rpc_service/remote_client.rs +++ b/easytier/src/rpc_service/remote_client.rs @@ -1,7 +1,18 @@ use async_trait::async_trait; use uuid::Uuid; -use crate::proto::{api::manage::*, rpc_types::controller::BaseController}; +use crate::{ + common::config::ConfigSource, + proto::{ + api::manage::{ + CollectNetworkInfoRequest, CollectNetworkInfoResponse, DeleteNetworkInstanceRequest, + GetNetworkInstanceConfigRequest, ListNetworkInstanceMetaRequest, + ListNetworkInstanceRequest, NetworkConfig, NetworkMeta, RunNetworkInstanceRequest, + ValidateConfigRequest, ValidateConfigResponse, WebClientService, + }, + rpc_types::controller::BaseController, + }, +}; #[async_trait] pub trait RemoteClientManager @@ -52,6 +63,7 @@ where inst_id: None, config: Some(config.clone()), overwrite: true, + source: ConfigSource::User.to_rpc(), }, ) .await?; @@ -62,6 +74,7 @@ where identify, resp.inst_id.unwrap_or_default().into(), config, + ConfigSource::User, ) .await .map_err(RemoteClientError::PersistentError)?; @@ -162,13 +175,18 @@ where .get_rpc_client(identify.clone()) .ok_or(RemoteClientError::ClientNotFound)?; - let cfg = self - .handle_get_network_config(identify.clone(), inst_id) + let (cfg, source) = self + .handle_get_network_config_with_source(identify.clone(), inst_id) .await?; if disabled { self.get_storage() - .insert_or_update_user_network_config(identify.clone(), inst_id, cfg.clone()) + .insert_or_update_user_network_config( + identify.clone(), + inst_id, + cfg.clone(), + source, + ) .await .map_err(RemoteClientError::PersistentError)?; @@ -188,6 +206,7 @@ where inst_id: Some(inst_id.into()), config: Some(cfg), overwrite: true, + source: source.to_rpc(), }, ) .await?; @@ -230,8 +249,8 @@ where if metas.contains_key(&instance_id) { continue; } - let config = self - .handle_get_network_config(identify.clone(), instance_id) + let (config, source) = self + .handle_get_network_config_with_source(identify.clone(), instance_id) .await?; let network_name = config.network_name.unwrap_or_default(); metas.insert( @@ -241,6 +260,7 @@ where network_name: network_name.clone(), config_permission: 0, instance_name: network_name, + source: source.to_rpc(), }, ); } @@ -255,7 +275,12 @@ where config: NetworkConfig, ) -> Result<(), RemoteClientError> { self.get_storage() - .insert_or_update_user_network_config(identify.clone(), inst_id, config) + .insert_or_update_user_network_config( + identify.clone(), + inst_id, + config, + ConfigSource::User, + ) .await .map_err(RemoteClientError::PersistentError)?; self.get_storage() @@ -270,6 +295,16 @@ where identify: T, inst_id: uuid::Uuid, ) -> Result> { + self.handle_get_network_config_with_source(identify, inst_id) + .await + .map(|(config, _)| config) + } + + async fn handle_get_network_config_with_source( + &self, + identify: T, + inst_id: uuid::Uuid, + ) -> Result<(NetworkConfig, ConfigSource), RemoteClientError> { if let Some(client) = self.get_rpc_client(identify.clone()) && let Ok(resp) = client .get_network_instance_config( @@ -281,7 +316,17 @@ where .await && let Some(config) = resp.config { - return Ok(config); + let source = if let Some(source) = ConfigSource::from_rpc(resp.source) { + source + } else { + self.get_storage() + .get_network_config(identify.clone(), &inst_id.to_string()) + .await + .map_err(RemoteClientError::PersistentError)? + .map(|cfg| cfg.get_runtime_network_config_source()) + .unwrap_or(ConfigSource::User) + }; + return Ok((config, source)); } let inst_id = inst_id.to_string(); @@ -296,9 +341,12 @@ where inst_id )))?; - Ok(db_row - .get_network_config() - .map_err(RemoteClientError::PersistentError)?) + Ok(( + db_row + .get_network_config() + .map_err(RemoteClientError::PersistentError)?, + db_row.get_runtime_network_config_source(), + )) } } @@ -336,6 +384,10 @@ pub struct GetNetworkMetasResponse { pub trait PersistentConfig { fn get_network_inst_id(&self) -> &str; fn get_network_config(&self) -> Result; + fn get_network_config_source(&self) -> ConfigSource; + fn get_runtime_network_config_source(&self) -> ConfigSource { + self.get_network_config_source() + } } #[async_trait] @@ -348,6 +400,7 @@ where identify: T, network_inst_id: Uuid, network_config: NetworkConfig, + source: ConfigSource, ) -> Result<(), E>; async fn delete_network_configs(&self, identify: T, network_inst_ids: &[Uuid]) diff --git a/easytier/src/web_client/session.rs b/easytier/src/web_client/session.rs index fd28961b..a81b3f80 100644 --- a/easytier/src/web_client/session.rs +++ b/easytier/src/web_client/session.rs @@ -93,6 +93,7 @@ impl Session { hostname: hostname.clone(), report_time: chrono::Local::now().to_rfc3339(), device_os: Some(device_os.clone()), + support_config_source: true, running_network_instances: controller .list_network_instance_ids()