diff --git a/README.md b/README.md index f6bb765..75687d6 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,8 @@ mmdb = "https://testingcf.jsdelivr.net/gh/MetaCubeX/meta-rules-dat@release/count By default, `ui = "metacubexd"` enables dashboard management, so `mihoro init` also downloads the web UI assets and serves them from the configured `external_controller`. When the controller binds all interfaces, `mihoro init` prints localhost plus detected non-loopback machine IPs such as LAN or Tailscale/ZeroTier addresses. +Mihoro keeps generated Mihomo config state under `mihomo_config_root`. The runtime-compatible `config.yaml` remains the file consumed by `mihomo -d `, while `source.yaml`, `overlay.yaml`, `candidate.yaml`, `active.yaml`, and `last-good.yaml` preserve the raw subscription config, local override projection, render candidate, current active config, and previous active config for safer future activation flows. + `init` is idempotent — re-running it skips any artifacts that are already in place. Use `--force` to re-download everything: ```bash diff --git a/src/config.rs b/src/config.rs index 3bfea76..015ff72 100644 --- a/src/config.rs +++ b/src/config.rs @@ -293,8 +293,47 @@ pub struct MihomoYamlConfig { /// * Fields not supported by `mihoro` will be kept as is. /// /// Returns `true` when the file contents had to change. -pub fn apply_mihomo_override(path: &str, override_config: &MihomoConfig) -> Result { - let raw_mihomo_yaml = fs::read_to_string(path)?; +pub fn render_mihomo_overlay(path: &Path, override_config: &MihomoConfig) -> Result { + let overlay = MihomoYamlConfig { + port: Some(override_config.port), + socks_port: Some(override_config.socks_port), + mixed_port: override_config.mixed_port, + redir_port: override_config.redir_port, + allow_lan: override_config.allow_lan, + bind_address: override_config.bind_address.clone(), + mode: Some(override_config.mode.clone()), + log_level: Some(override_config.log_level.clone()), + ipv6: override_config.ipv6, + external_controller: override_config.external_controller.clone(), + external_ui: override_config.external_ui.clone(), + secret: override_config.secret.clone(), + geodata_mode: override_config.geodata_mode, + geo_auto_update: override_config.geo_auto_update, + geo_update_interval: override_config.geo_update_interval, + geox_url: override_config.geox_url.clone(), + extra: HashMap::new(), + }; + + let serialized_overlay = serde_yaml::to_string(&overlay)?; + if let Ok(existing) = fs::read_to_string(path) { + let existing_value: serde_yaml::Value = serde_yaml::from_str(&existing)?; + let overlay_value: serde_yaml::Value = serde_yaml::from_str(&serialized_overlay)?; + if existing_value == overlay_value { + return Ok(false); + } + } + + create_parent_dir(path)?; + fs::write(path, serialized_overlay)?; + Ok(true) +} + +pub fn render_mihomo_override( + source_path: &Path, + output_path: &Path, + override_config: &MihomoConfig, +) -> Result { + let raw_mihomo_yaml = fs::read_to_string(source_path)?; let mut mihomo_yaml: MihomoYamlConfig = serde_yaml::from_str(&raw_mihomo_yaml)?; // Apply config overrides @@ -317,16 +356,25 @@ pub fn apply_mihomo_override(path: &str, override_config: &MihomoConfig) -> Resu // Avoid rewriting already-current YAML just because formatting or map order changed. let serialized_mihomo_yaml = serde_yaml::to_string(&mihomo_yaml)?; - let raw_value: serde_yaml::Value = serde_yaml::from_str(&raw_mihomo_yaml)?; - let serialized_value: serde_yaml::Value = serde_yaml::from_str(&serialized_mihomo_yaml)?; - if raw_value == serialized_value { - return Ok(false); + if let Ok(current_output) = fs::read_to_string(output_path) { + let raw_value: serde_yaml::Value = serde_yaml::from_str(¤t_output)?; + let serialized_value: serde_yaml::Value = serde_yaml::from_str(&serialized_mihomo_yaml)?; + if raw_value == serialized_value { + return Ok(false); + } } - fs::write(path, serialized_mihomo_yaml)?; + create_parent_dir(output_path)?; + fs::write(output_path, serialized_mihomo_yaml)?; Ok(true) } +#[allow(dead_code)] +pub fn apply_mihomo_override(path: &str, override_config: &MihomoConfig) -> Result { + let path = Path::new(path); + render_mihomo_override(path, path, override_config) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/config_store.rs b/src/config_store.rs new file mode 100644 index 0000000..6a409a6 --- /dev/null +++ b/src/config_store.rs @@ -0,0 +1,289 @@ +use crate::config::{render_mihomo_overlay, render_mihomo_override, MihomoConfig}; +use crate::utils::create_parent_dir; +use anyhow::{Context, Result}; +use std::fs; +use std::path::{Path, PathBuf}; + +#[allow(dead_code)] +pub const DEFAULT_SYSTEM_CONFIG_ROOT: &str = "/etc/mihomo"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConfigGenerationPaths { + pub root: PathBuf, + pub source_yaml: PathBuf, + pub overlay_yaml: PathBuf, + pub candidate_yaml: PathBuf, + pub active_yaml: PathBuf, + pub last_good_yaml: PathBuf, + pub compat_config_yaml: PathBuf, +} + +impl ConfigGenerationPaths { + pub fn for_user_root(root: impl AsRef) -> Self { + Self::from_root(root.as_ref()) + } + + #[allow(dead_code)] + pub fn system_root() -> Self { + Self::from_root(Path::new(DEFAULT_SYSTEM_CONFIG_ROOT)) + } + + fn from_root(root: &Path) -> Self { + Self { + root: root.to_path_buf(), + source_yaml: root.join("source.yaml"), + overlay_yaml: root.join("overlay.yaml"), + candidate_yaml: root.join("candidate.yaml"), + active_yaml: root.join("active.yaml"), + last_good_yaml: root.join("last-good.yaml"), + compat_config_yaml: root.join("config.yaml"), + } + } +} + +#[derive(Debug, Clone)] +pub struct ConfigGenerationStore { + pub paths: ConfigGenerationPaths, +} + +impl ConfigGenerationStore { + pub fn new(root: impl AsRef) -> Self { + Self { + paths: ConfigGenerationPaths::for_user_root(root), + } + } + + pub fn seed_source_from_legacy_config(&self) -> Result { + if !self.paths.compat_config_yaml.exists() { + return Ok(false); + } + + let mut changed = false; + if !self.paths.source_yaml.exists() { + create_parent_dir(&self.paths.source_yaml)?; + fs::copy(&self.paths.compat_config_yaml, &self.paths.source_yaml).with_context( + || { + format!( + "failed to seed `{}` from `{}`", + self.paths.source_yaml.display(), + self.paths.compat_config_yaml.display() + ) + }, + )?; + changed = true; + } + if !self.paths.active_yaml.exists() { + create_parent_dir(&self.paths.active_yaml)?; + fs::copy(&self.paths.compat_config_yaml, &self.paths.active_yaml).with_context( + || { + format!( + "failed to seed `{}` from `{}`", + self.paths.active_yaml.display(), + self.paths.compat_config_yaml.display() + ) + }, + )?; + changed = true; + } + + Ok(changed) + } + + pub fn render_candidate(&self, override_config: &MihomoConfig) -> Result { + let source_path = self.source_path_for_render()?; + let overlay_changed = render_mihomo_overlay(&self.paths.overlay_yaml, override_config)?; + let candidate_changed = + render_mihomo_override(&source_path, &self.paths.candidate_yaml, override_config)?; + Ok(overlay_changed || candidate_changed) + } + + pub fn activate_candidate(&self) -> Result { + let candidate = fs::read(&self.paths.candidate_yaml).with_context(|| { + format!( + "failed to read candidate config `{}`", + self.paths.candidate_yaml.display() + ) + })?; + let active = fs::read(&self.paths.active_yaml).ok(); + let compat = fs::read(&self.paths.compat_config_yaml).ok(); + + if active.as_deref() == Some(candidate.as_slice()) + && compat.as_deref() == Some(candidate.as_slice()) + { + return Ok(false); + } + + if let Some(active) = active { + if active != candidate { + create_parent_dir(&self.paths.last_good_yaml)?; + fs::write(&self.paths.last_good_yaml, active)?; + } + } + + create_parent_dir(&self.paths.active_yaml)?; + fs::write(&self.paths.active_yaml, &candidate)?; + create_parent_dir(&self.paths.compat_config_yaml)?; + fs::write(&self.paths.compat_config_yaml, &candidate)?; + Ok(true) + } + + fn source_path_for_render(&self) -> Result { + if self.paths.source_yaml.exists() { + return Ok(self.paths.source_yaml.clone()); + } + if self.paths.active_yaml.exists() { + return Ok(self.paths.active_yaml.clone()); + } + if self.paths.compat_config_yaml.exists() { + return Ok(self.paths.compat_config_yaml.clone()); + } + anyhow::bail!( + "no source config available under `{}`", + self.paths.root.display() + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::MihomoConfig; + use std::fs; + use std::path::PathBuf; + use tempfile::tempdir; + + fn source_yaml(port: u16) -> String { + format!( + " +port: {port} +socks-port: 8081 +mode: rule +proxies: + - name: test + type: http + server: example.com + port: 443 +" + ) + } + + #[test] + fn paths_are_derived_from_user_config_root() { + let paths = ConfigGenerationPaths::for_user_root("/tmp/mihomo"); + + assert_eq!(paths.root, PathBuf::from("/tmp/mihomo")); + assert_eq!(paths.source_yaml, PathBuf::from("/tmp/mihomo/source.yaml")); + assert_eq!( + paths.overlay_yaml, + PathBuf::from("/tmp/mihomo/overlay.yaml") + ); + assert_eq!( + paths.candidate_yaml, + PathBuf::from("/tmp/mihomo/candidate.yaml") + ); + assert_eq!(paths.active_yaml, PathBuf::from("/tmp/mihomo/active.yaml")); + assert_eq!( + paths.last_good_yaml, + PathBuf::from("/tmp/mihomo/last-good.yaml") + ); + assert_eq!( + paths.compat_config_yaml, + PathBuf::from("/tmp/mihomo/config.yaml") + ); + } + + #[test] + fn system_paths_default_to_etc_mihomo() { + let paths = ConfigGenerationPaths::system_root(); + + assert_eq!(paths.root, PathBuf::from("/etc/mihomo")); + assert_eq!(paths.source_yaml, PathBuf::from("/etc/mihomo/source.yaml")); + assert_eq!( + paths.compat_config_yaml, + PathBuf::from("/etc/mihomo/config.yaml") + ); + } + + #[test] + fn render_candidate_does_not_modify_active_last_good_or_compat() -> anyhow::Result<()> { + let dir = tempdir()?; + let store = ConfigGenerationStore::new(dir.path()); + fs::write(&store.paths.source_yaml, source_yaml(8080))?; + fs::write(&store.paths.active_yaml, "active: old\n")?; + fs::write(&store.paths.last_good_yaml, "last: good\n")?; + fs::write(&store.paths.compat_config_yaml, "compat: current\n")?; + + let mut override_config = MihomoConfig::default(); + override_config.port = 9999; + override_config.socks_port = 9998; + + let changed = store.render_candidate(&override_config)?; + + assert!(changed); + let candidate = fs::read_to_string(&store.paths.candidate_yaml)?; + assert!(candidate.contains("port: 9999")); + assert!(candidate.contains("socks-port: 9998")); + assert!(candidate.contains("proxies:")); + let overlay = fs::read_to_string(&store.paths.overlay_yaml)?; + assert!(overlay.contains("port: 9999")); + assert_eq!( + fs::read_to_string(&store.paths.active_yaml)?, + "active: old\n" + ); + assert_eq!( + fs::read_to_string(&store.paths.last_good_yaml)?, + "last: good\n" + ); + assert_eq!( + fs::read_to_string(&store.paths.compat_config_yaml)?, + "compat: current\n" + ); + Ok(()) + } + + #[test] + fn activate_candidate_backs_up_previous_active_and_updates_compat() -> anyhow::Result<()> { + let dir = tempdir()?; + let store = ConfigGenerationStore::new(dir.path()); + fs::write(&store.paths.active_yaml, "port: 1111\n")?; + fs::write(&store.paths.compat_config_yaml, "port: 1111\n")?; + fs::write(&store.paths.candidate_yaml, "port: 2222\n")?; + + let changed = store.activate_candidate()?; + + assert!(changed); + assert_eq!( + fs::read_to_string(&store.paths.last_good_yaml)?, + "port: 1111\n" + ); + assert_eq!( + fs::read_to_string(&store.paths.active_yaml)?, + "port: 2222\n" + ); + assert_eq!( + fs::read_to_string(&store.paths.compat_config_yaml)?, + "port: 2222\n" + ); + Ok(()) + } + + #[test] + fn legacy_config_yaml_seeds_missing_generation_files() -> anyhow::Result<()> { + let dir = tempdir()?; + let store = ConfigGenerationStore::new(dir.path()); + fs::write(&store.paths.compat_config_yaml, source_yaml(8080))?; + + let seeded = store.seed_source_from_legacy_config()?; + + assert!(seeded); + assert_eq!( + fs::read_to_string(&store.paths.source_yaml)?, + fs::read_to_string(&store.paths.compat_config_yaml)? + ); + assert_eq!( + fs::read_to_string(&store.paths.active_yaml)?, + fs::read_to_string(&store.paths.compat_config_yaml)? + ); + Ok(()) + } +} diff --git a/src/main.rs b/src/main.rs index 8dad63c..bc5b287 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ mod cmd; mod config; +mod config_store; mod cron; mod init; mod mihoro; diff --git a/src/mihoro.rs b/src/mihoro.rs index 3fe4851..b34b155 100644 --- a/src/mihoro.rs +++ b/src/mihoro.rs @@ -1,5 +1,6 @@ use crate::cmd::{CronCommands, ProxyCommands}; -use crate::config::{apply_mihomo_override, parse_config, Config}; +use crate::config::{parse_config, Config}; +use crate::config_store::ConfigGenerationStore; use crate::cron; use crate::proxy::{proxy_export_cmd, proxy_unset_cmd}; use crate::resolve_mihomo_bin; @@ -80,6 +81,10 @@ impl Mihoro { } } + fn config_generation_store(&self) -> ConfigGenerationStore { + ConfigGenerationStore::new(&self.mihomo_target_config_root) + } + /// Stage 1 of the binary install: resolve the URL and download to a temp file. /// /// Skips if the binary exists and `force` is false. The returned [`BinaryPlan`] is @@ -146,12 +151,15 @@ impl Mihoro { /// Download remote config YAML and apply TOML overrides. /// If the config file already exists and `force` is false, only re-applies overrides. pub async fn ensure_remote_config(&self, client: &Client, force: bool) -> Result { - let config_path = Path::new(&self.mihomo_target_config_path); - if !force && config_path.exists() { - // Re-apply TOML overrides onto the cached YAML so user changes take effect. - let changed = - apply_mihomo_override(&self.mihomo_target_config_path, &self.config.mihomo_config)?; - return if changed { + let store = self.config_generation_store(); + let seeded = store.seed_source_from_legacy_config()?; + let has_local_config = store.paths.source_yaml.exists() + || store.paths.active_yaml.exists() + || store.paths.compat_config_yaml.exists(); + if !force && has_local_config { + let changed = store.render_candidate(&self.config.mihomo_config)?; + let activated = store.activate_candidate()?; + return if changed || activated || seeded { Ok(StageStatus::Installed) } else { Ok(StageStatus::Skipped("config already current".to_string())) @@ -161,12 +169,18 @@ impl Mihoro { download_file( client, &self.config.remote_config_url, - config_path, + &store.paths.source_yaml, &self.config.mihoro_user_agent, ) .await?; - try_decode_base64_file_inplace(&self.mihomo_target_config_path)?; - apply_mihomo_override(&self.mihomo_target_config_path, &self.config.mihomo_config)?; + let source_path = store + .paths + .source_yaml + .to_str() + .ok_or_else(|| anyhow!("source config path is not valid UTF-8"))?; + try_decode_base64_file_inplace(source_path)?; + store.render_candidate(&self.config.mihomo_config)?; + store.activate_candidate()?; Ok(StageStatus::Installed) } @@ -387,19 +401,28 @@ impl Mihoro { } pub async fn update_config(&self, client: &Client) -> Result { + let store = self.config_generation_store(); + store.seed_source_from_legacy_config()?; + // Download remote mihomo config and apply override download_file( client, &self.config.remote_config_url, - Path::new(&self.mihomo_target_config_path), + &store.paths.source_yaml, &self.config.mihoro_user_agent, ) .await?; // Try to decode base64 file in place if file is base64 encoding, otherwise do nothing - try_decode_base64_file_inplace(&self.mihomo_target_config_path)?; - - apply_mihomo_override(&self.mihomo_target_config_path, &self.config.mihomo_config)?; + let source_path = store + .paths + .source_yaml + .to_str() + .ok_or_else(|| anyhow!("source config path is not valid UTF-8"))?; + try_decode_base64_file_inplace(source_path)?; + + store.render_candidate(&self.config.mihomo_config)?; + store.activate_candidate()?; println!( "{} Updated and applied config overrides", DETAIL_PREFIX.cyan() @@ -470,15 +493,14 @@ impl Mihoro { } pub async fn apply(&self) -> Result<()> { - // Apply mihomo config override - apply_mihomo_override(&self.mihomo_target_config_path, &self.config.mihomo_config).map( - |_| { - println!( - "{} Applied mihomo config overrides", - self.prefix.green().bold() - ); - }, - )?; + let store = self.config_generation_store(); + store.seed_source_from_legacy_config()?; + store.render_candidate(&self.config.mihomo_config)?; + store.activate_candidate()?; + println!( + "{} Applied mihomo config overrides", + self.prefix.green().bold() + ); // Restart mihomo systemd service Systemctl::new() @@ -659,6 +681,7 @@ WantedBy=default.target", #[cfg(test)] mod tests { use super::*; + use crate::config::apply_mihomo_override; use std::fs; use tempfile::tempdir; @@ -843,7 +866,7 @@ mod tests { } #[tokio::test] - async fn test_ensure_remote_config_skips_when_cached_config_is_current() -> Result<()> { + async fn test_ensure_remote_config_seeds_generations_then_skips_when_current() -> Result<()> { let dir = tempdir()?; let config_path = dir.path().join("test.toml"); let yaml_path = dir.path().join("config.yaml"); @@ -881,12 +904,29 @@ mod tests { let status = mihoro.ensure_remote_config(&Client::new(), false).await?; + match status { + StageStatus::Installed => {} + StageStatus::Skipped(reason) => panic!("expected generation seed, got skip: {reason}"), + StageStatus::Failed(_) => panic!("ensure_remote_config returned a failed status"), + } + assert_eq!(fs::read_to_string(&yaml_path)?, current_content); + assert_eq!( + fs::read_to_string(dir.path().join("source.yaml"))?, + current_content + ); + assert_eq!( + fs::read_to_string(dir.path().join("active.yaml"))?, + current_content + ); + assert!(fs::read_to_string(dir.path().join("candidate.yaml"))?.contains("port: 9999")); + assert!(fs::read_to_string(dir.path().join("overlay.yaml"))?.contains("port: 9999")); + + let status = mihoro.ensure_remote_config(&Client::new(), false).await?; match status { StageStatus::Skipped(reason) => assert_eq!(reason, "config already current"), StageStatus::Installed => panic!("expected remote config to be skipped"), StageStatus::Failed(_) => panic!("ensure_remote_config returned a failed status"), } - assert_eq!(fs::read_to_string(&yaml_path)?, current_content); Ok(()) }