Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <root>`, 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
Expand Down
62 changes: 55 additions & 7 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool> {
let raw_mihomo_yaml = fs::read_to_string(path)?;
pub fn render_mihomo_overlay(path: &Path, override_config: &MihomoConfig) -> Result<bool> {
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);
}
}
Comment on lines +318 to +324

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<bool> {
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
Expand All @@ -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(&current_output)?;
let serialized_value: serde_yaml::Value = serde_yaml::from_str(&serialized_mihomo_yaml)?;
if raw_value == serialized_value {
return Ok(false);
}
}
Comment on lines +359 to 365

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<bool> {
let path = Path::new(path);
render_mihomo_override(path, path, override_config)
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
289 changes: 289 additions & 0 deletions src/config_store.rs
Original file line number Diff line number Diff line change
@@ -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<Path>) -> 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<Path>) -> Self {
Self {
paths: ConfigGenerationPaths::for_user_root(root),
}
}

pub fn seed_source_from_legacy_config(&self) -> Result<bool> {
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<bool> {
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<bool> {
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<PathBuf> {
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(())
}
}
1 change: 1 addition & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
mod cmd;
mod config;
mod config_store;
mod cron;
mod init;
mod mihoro;
Expand Down
Loading