diff --git a/Cargo.lock b/Cargo.lock index a998e576b..095518242 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1432,12 +1432,15 @@ version = "0.10.0" dependencies = [ "anyhow", "aws-lc-rs", + "base64 0.22.1", "clap", "clash-lib", "dhat", "human-panic", "sentry", + "tempfile", "tikv-jemallocator", + "time", ] [[package]] diff --git a/clash-bin/Cargo.toml b/clash-bin/Cargo.toml index 7dc162670..2042c8bbd 100644 --- a/clash-bin/Cargo.toml +++ b/clash-bin/Cargo.toml @@ -38,6 +38,8 @@ ring = ["clash-lib/ring"] [dependencies] clap = { version = "4", features = ["derive"] } anyhow = "1" +base64 = "0.22" +time = { version = "0.3", features = ["formatting", "local-offset", "macros"] } clash-lib = { path = "../clash-lib", default-features = false } @@ -49,3 +51,6 @@ human-panic = "2.0" aws-lc-rs = { version = "1.16", optional = true, default-features = false } + +[dev-dependencies] +tempfile = "3" diff --git a/clash-bin/src/main.rs b/clash-bin/src/main.rs index 866a97e63..80a76e2ca 100644 --- a/clash-bin/src/main.rs +++ b/clash-bin/src/main.rs @@ -15,13 +15,19 @@ static GLOBAL: Jemalloc = Jemalloc; extern crate clash_lib as clash; +use anyhow::{Context, anyhow}; +use base64::{Engine, engine::general_purpose::STANDARD}; use clap::Parser; use clash::TokioRuntime; use std::{ - io::Write, + io::{Read, Write}, path::{Path, PathBuf}, process::exit, }; +use time::{OffsetDateTime, macros::format_description}; + +const DEFAULT_CONFIG_FILE: &str = "config.yaml"; +const DEFAULT_CONFIG_CONTENT: &str = "mixed-port: 7890"; #[derive(Parser)] #[clap(author, about, long_about = None)] @@ -35,10 +41,11 @@ struct Cli { visible_short_aliases = ['f'], // -f is used by clash, it is a compatibility option value_parser, value_name = "FILE", - default_value = "config.yaml", help = "Specify configuration file" )] - config: PathBuf, + config: Option, + #[clap(long = "config-string", hide = true, value_name = "BASE64")] + config_string: Option, #[clap( short = 't', long, @@ -93,13 +100,7 @@ fn main() -> anyhow::Result<()> { // Those arguments are for compatibility with `mihomo` // Technically, I do not think `mihomo` is a modern/standard POSIX Cli program - let args: Vec = std::env::args() - .map(|arg| match arg.as_str() { - "-ext-ctl-unix" => "--ext-ctl-unix".to_string(), - "-ext-ctl-pipe" => "--ext-ctl-pipe".to_string(), - _ => arg, - }) - .collect(); + let args = preprocess_args(std::env::args()); let cli = Cli::parse_from(args); if cli.version { @@ -111,43 +112,28 @@ fn main() -> anyhow::Result<()> { exit(0) } - let file = cli - .directory - .as_ref() - .unwrap_or(&std::env::current_dir().unwrap()) - .join(cli.config) - .to_string_lossy() - .to_string(); - - if !Path::new(&file).exists() { - let default_config = "port: 7890"; - let mut config_file = match std::fs::File::create(&file) { - Ok(config_file) => config_file, - _ => { - eprintln!("default profile cannot be created: {file}"); - exit(1); - } - }; - - if config_file.write_all(default_config.as_bytes()).is_err() { - eprintln!("default profile cannot be written: {file}"); + let config_input = match ConfigInput::resolve(&cli) { + Ok(config_input) => config_input, + Err(err) => { + print_cli_log("fatal", &err.to_string()); exit(1); - }; + } + }; - println!( - "the configuration file cannot be found, the template has been created \ - and used: {file}" - ); + if let Err(err) = config_input.ensure_file() { + print_cli_log("fatal", &err.to_string()); + exit(1); } if cli.test_config { - match clash::Config::File(file.clone()).try_parse() { + match config_input.try_parse() { Ok(_) => { - println!("configuration file {file} test is successful"); + print_test_success(&config_input); exit(0); } Err(e) => { - eprintln!("configuration file {file} test failed: {e}"); + print_cli_log("error", &e.to_string()); + print_test_failure(&config_input); exit(1); } } @@ -174,7 +160,7 @@ fn main() -> anyhow::Result<()> { ))); } - let mut config = clash::Config::File(file).try_parse()?; + let mut config = config_input.try_parse()?; config.general.controller.external_controller_ipc = cli.controller_ipc; @@ -221,3 +207,214 @@ fn main() -> anyhow::Result<()> { .inspect_err(|err| eprintln!("Failed to start clash: {err}"))?; Ok(()) } + +enum ConfigInput { + File { path: PathBuf, display_path: String }, + Bytes { content: String }, +} + +impl ConfigInput { + fn resolve(cli: &Cli) -> anyhow::Result { + if let Some(config) = cli.config_string.as_deref() { + return decode_config(config); + } + if let Ok(config) = std::env::var("CLASH_CONFIG_STRING") { + return decode_config(&config); + } + + let current_dir = + std::env::current_dir().context("get current directory")?; + let home_dir = cli + .directory + .clone() + .or_else(|| std::env::var_os("CLASH_HOME_DIR").map(PathBuf::from)); + let directory = match home_dir { + Some(directory) if directory.is_absolute() => directory, + Some(directory) => current_dir.join(directory), + None => current_dir, + }; + + if let Some(config) = cli.config.as_ref() { + return resolve_config_path(config, &directory); + } + + if let Some(file) = std::env::var_os("CLASH_CONFIG_FILE") { + return resolve_config_file(&PathBuf::from(file)); + } + + Ok(Self::from_file_path(directory.join(DEFAULT_CONFIG_FILE))) + } + + fn from_file_path(path: PathBuf) -> Self { + let path = + absolutize(path).unwrap_or_else(|_| PathBuf::from(DEFAULT_CONFIG_FILE)); + let display_path = path.to_string_lossy().to_string(); + Self::File { path, display_path } + } + + fn ensure_file(&self) -> anyhow::Result<()> { + let Self::File { path, .. } = self else { + return Ok(()); + }; + if path.exists() { + return Ok(()); + } + if let Some(parent) = path.parent() + && !parent.as_os_str().is_empty() + { + std::fs::create_dir_all(parent).with_context(|| { + format!( + "default profile directory cannot be created: {}", + parent.display() + ) + })?; + } + let mut config_file = std::fs::File::create(path).with_context(|| { + format!("default profile cannot be created: {}", path.display()) + })?; + config_file + .write_all(DEFAULT_CONFIG_CONTENT.as_bytes()) + .with_context(|| { + format!("default profile cannot be written: {}", path.display()) + })?; + Ok(()) + } + + fn try_parse(&self) -> clash::Result { + match self { + Self::File { path, display_path } => { + if std::fs::metadata(path).map(|metadata| metadata.len() == 0)? { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("configuration file {display_path} is empty"), + ) + .into()); + } + clash::Config::File(path.to_string_lossy().to_string()).try_parse() + } + Self::Bytes { content } => { + clash::Config::Str(content.clone()).try_parse() + } + } + } +} + +fn preprocess_args(args: impl IntoIterator) -> Vec { + args.into_iter() + .map(|arg| match arg.as_str() { + "-ext-ctl-unix" => "--ext-ctl-unix".to_string(), + "-ext-ctl-pipe" => "--ext-ctl-pipe".to_string(), + "-config" => "--config-string".to_string(), + "-f" => "--config".to_string(), + _ if arg.starts_with("-config=") => { + arg.replacen("-config=", "--config-string=", 1) + } + _ if arg.starts_with("-f=") => arg.replacen("-f=", "--config=", 1), + _ if arg.starts_with("-f") && arg.len() > 2 => { + format!("--config={}", &arg[2..]) + } + _ => arg, + }) + .collect() +} + +fn decode_config(config: &str) -> anyhow::Result { + let content = STANDARD + .decode(config) + .map_err(|err| anyhow!("decode config: {err}"))?; + let content = String::from_utf8_lossy(&content).into_owned(); + Ok(ConfigInput::Bytes { content }) +} + +fn resolve_config_path( + config: &Path, + directory: &Path, +) -> anyhow::Result { + if config == Path::new("-") { + return resolve_config_file(config); + } + if config.is_absolute() { + Ok(ConfigInput::from_file_path(config.to_path_buf())) + } else { + Ok(ConfigInput::from_file_path(directory.join(config))) + } +} + +fn resolve_config_file(file: &Path) -> anyhow::Result { + if file == Path::new("-") { + let mut content = String::new(); + std::io::stdin() + .read_to_string(&mut content) + .context("read configuration from stdin")?; + if !content.is_empty() { + return Ok(ConfigInput::Bytes { content }); + } + return Ok(ConfigInput::from_file_path(PathBuf::from( + DEFAULT_CONFIG_FILE, + ))); + } + Ok(ConfigInput::from_file_path(file.to_path_buf())) +} + +fn absolutize(path: PathBuf) -> anyhow::Result { + if path.is_absolute() { + Ok(path) + } else { + Ok(std::env::current_dir() + .context("get current directory")? + .join(path)) + } +} + +fn print_test_success(input: &ConfigInput) { + match input { + ConfigInput::File { display_path, .. } => { + println!("configuration file {display_path} test is successful"); + } + ConfigInput::Bytes { .. } => { + println!("configuration file {DEFAULT_CONFIG_FILE} test is successful"); + } + } +} + +fn print_test_failure(input: &ConfigInput) { + match input { + ConfigInput::File { display_path, .. } => { + println!("configuration file {display_path} test failed"); + } + ConfigInput::Bytes { .. } => { + println!("configuration test failed"); + } + } +} + +fn print_cli_log(level: &str, msg: &str) { + let format = format_description!( + "[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond \ + digits:9][offset_hour sign:mandatory]:[offset_minute]" + ); + let timestamp = OffsetDateTime::now_local() + .unwrap_or_else(|_| OffsetDateTime::now_utc()) + .format(format) + .unwrap_or_else(|_| "1970-01-01T00:00:00.000000000+00:00".to_string()); + println!( + "time=\"{}\" level={} msg=\"{}\"", + timestamp, + level, + escape_logrus_text(msg) + ); +} + +fn escape_logrus_text(msg: &str) -> String { + msg.chars().fold(String::new(), |mut escaped, ch| { + match ch { + '\\' => escaped.push_str("\\\\"), + '"' => escaped.push_str("\\\""), + '\n' => escaped.push_str("\\n"), + '\r' => escaped.push_str("\\r"), + '\t' => escaped.push_str("\\t"), + _ => escaped.push(ch), + } + escaped + }) +} diff --git a/clash-bin/tests/config_input_test.rs b/clash-bin/tests/config_input_test.rs new file mode 100644 index 000000000..60e4bf0a8 --- /dev/null +++ b/clash-bin/tests/config_input_test.rs @@ -0,0 +1,345 @@ +use base64::{Engine, engine::general_purpose::STANDARD}; +use std::{ + fs, + io::Write, + path::Path, + process::{Command, Output, Stdio}, + sync::OnceLock, +}; +use tempfile::tempdir; + +const VALID_CONFIG: &str = r#"mixed-port: 8899 +external-controller: 127.0.0.1:9090 +mode: global +bind-address: "0.0.0.0" +"#; + +/// Returns `true` when the `clash-rs` binary can actually be executed as a +/// subprocess in the current environment. +/// +/// In cross-compilation test runs (e.g. `cross` + QEMU), the test binary +/// itself runs inside an emulator, but `execve` of a child binary for a +/// foreign architecture is not intercepted by the emulator. glibc falls back +/// to running the binary through `/bin/sh`, which exits with code 127 +/// ("cannot execute binary file"). Any test that spawns the binary must skip +/// itself in such environments. +fn binary_can_be_spawned() -> bool { + static RESULT: OnceLock = OnceLock::new(); + *RESULT.get_or_init(|| { + std::process::Command::new(env!("CARGO_BIN_EXE_clash-rs")) + .arg("-v") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.code() != Some(127)) + .unwrap_or(false) + }) +} + +/// Skip the calling test when running inside a cross-compilation environment +/// where the binary cannot be spawned as a subprocess. +macro_rules! skip_on_cross { + () => { + if !binary_can_be_spawned() { + eprintln!( + "SKIP: clash-rs binary cannot be executed as a subprocess \ + (cross-compilation / QEMU environment)" + ); + return; + } + }; +} + +fn clash_cmd() -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_clash-rs")); + command + .env_remove("CLASH_CONFIG_FILE") + .env_remove("CLASH_CONFIG_STRING") + .env_remove("CLASH_HOME_DIR"); + command +} + +fn run_with_stdin(mut command: Command, stdin: &str) -> Output { + let mut child = command + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn clash-rs"); + child + .stdin + .as_mut() + .expect("open stdin") + .write_all(stdin.as_bytes()) + .expect("write stdin"); + child.wait_with_output().expect("wait for clash-rs") +} + +fn stdout(output: &Output) -> String { + String::from_utf8_lossy(&output.stdout).into_owned() +} + +fn stderr(output: &Output) -> String { + String::from_utf8_lossy(&output.stderr).into_owned() +} + +fn path_display(path: &Path) -> String { + path.to_string_lossy().into_owned() +} + +#[test] +fn file_success_outputs_summary_on_stdout() { + skip_on_cross!(); + let temp = tempdir().expect("create temp dir"); + let config = temp.path().join("ok.yaml"); + fs::write(&config, VALID_CONFIG).expect("write config"); + + let output = clash_cmd() + .args(["-t", "-f"]) + .arg(&config) + .output() + .expect("run clash-rs"); + + assert!( + output.status.success(), + "stdout:\n{}\nstderr:\n{}", + stdout(&output), + stderr(&output) + ); + assert_eq!(stderr(&output), ""); + assert!(stdout(&output).ends_with(&format!( + "configuration file {} test is successful\n", + path_display(&config) + ))); +} + +#[test] +fn file_success_accepts_go_style_equals_flag() { + skip_on_cross!(); + let temp = tempdir().expect("create temp dir"); + let config = temp.path().join("ok.yaml"); + fs::write(&config, VALID_CONFIG).expect("write config"); + + let output = clash_cmd() + .arg("-t") + .arg(format!("-f={}", path_display(&config))) + .output() + .expect("run clash-rs"); + + assert!( + output.status.success(), + "stdout:\n{}\nstderr:\n{}", + stdout(&output), + stderr(&output) + ); + assert_eq!(stderr(&output), ""); + assert_eq!( + stdout(&output), + format!( + "configuration file {} test is successful\n", + path_display(&config) + ) + ); +} + +#[test] +fn file_failure_logs_error_then_summary_on_stdout() { + skip_on_cross!(); + let temp = tempdir().expect("create temp dir"); + let config = temp.path().join("invalid.yaml"); + fs::write(&config, "log-level: definitely-not-a-level\n").expect("write config"); + + let output = clash_cmd() + .args(["-t", "-f"]) + .arg(&config) + .output() + .expect("run clash-rs"); + + assert!(!output.status.success()); + assert_eq!(output.status.code(), Some(1)); + assert_eq!(stderr(&output), ""); + + let stdout = stdout(&output); + let lines = stdout.lines().collect::>(); + assert!( + lines.first().is_some_and(|line| line.starts_with("time=\"") + && line.contains(" level=error msg=\"")), + "stdout:\n{stdout}" + ); + let summary = + format!("configuration file {} test failed", path_display(&config)); + assert_eq!(lines.last().copied(), Some(summary.as_str())); +} + +#[test] +fn proxy_provider_without_health_check_is_accepted() { + skip_on_cross!(); + let temp = tempdir().expect("create temp dir"); + let config = temp.path().join("provider.yaml"); + fs::write( + &config, + r#"mixed-port: 8899 +proxy-providers: + 0.LocalProxyNode: + type: file + path: ./providers.yaml +"#, + ) + .expect("write config"); + + let output = clash_cmd() + .args(["-t", "-f"]) + .arg(&config) + .output() + .expect("run clash-rs"); + + assert!( + output.status.success(), + "stdout:\n{}\nstderr:\n{}", + stdout(&output), + stderr(&output) + ); + assert_eq!(stderr(&output), ""); + assert_eq!( + stdout(&output), + format!( + "configuration file {} test is successful\n", + path_display(&config) + ) + ); +} + +#[test] +fn stdin_success_uses_bytes_summary_and_does_not_create_default_config() { + skip_on_cross!(); + let temp = tempdir().expect("create temp dir"); + let mut command = clash_cmd(); + command.current_dir(temp.path()).args(["-t", "-f", "-"]); + + let output = run_with_stdin(command, VALID_CONFIG); + + assert!( + output.status.success(), + "stdout:\n{}\nstderr:\n{}", + stdout(&output), + stderr(&output) + ); + assert_eq!(stderr(&output), ""); + assert!( + stdout(&output) + .ends_with("configuration file config.yaml test is successful\n") + ); + assert!(!temp.path().join("config.yaml").exists()); +} + +#[test] +fn base64_config_success_uses_bytes_summary() { + skip_on_cross!(); + let temp = tempdir().expect("create temp dir"); + let encoded = STANDARD.encode(VALID_CONFIG); + + let output = clash_cmd() + .current_dir(temp.path()) + .args(["-t", "-config", &encoded]) + .output() + .expect("run clash-rs"); + + assert!( + output.status.success(), + "stdout:\n{}\nstderr:\n{}", + stdout(&output), + stderr(&output) + ); + assert_eq!(stderr(&output), ""); + assert_eq!( + stdout(&output), + "configuration file config.yaml test is successful\n" + ); + assert!(!temp.path().join("config.yaml").exists()); +} + +#[test] +fn base64_decode_failure_is_stdout_fatal() { + skip_on_cross!(); + let output = clash_cmd() + .args(["-t", "-config", "not@base64"]) + .output() + .expect("run clash-rs"); + + assert!(!output.status.success()); + assert_eq!(output.status.code(), Some(1)); + assert_eq!(stderr(&output), ""); + let stdout = stdout(&output); + assert!(stdout.starts_with("time=\""), "stdout:\n{stdout}"); + assert!( + stdout.contains(" level=fatal msg=\"decode config:"), + "stdout:\n{stdout}" + ); +} + +#[test] +fn missing_file_mode_creates_default_config() { + skip_on_cross!(); + let temp = tempdir().expect("create temp dir"); + let config = temp.path().join("home").join("config.yaml"); + + let output = clash_cmd() + .args(["-t", "-f"]) + .arg(&config) + .output() + .expect("run clash-rs"); + + assert!( + output.status.success(), + "stdout:\n{}\nstderr:\n{}", + stdout(&output), + stderr(&output) + ); + assert_eq!(stderr(&output), ""); + assert_eq!( + fs::read_to_string(&config).expect("read default config"), + "mixed-port: 7890" + ); + assert_eq!( + stdout(&output), + format!( + "configuration file {} test is successful\n", + path_display(&config) + ) + ); +} + +#[test] +fn empty_file_reports_empty_file_error() { + skip_on_cross!(); + let temp = tempdir().expect("create temp dir"); + let config = temp.path().join("empty.yaml"); + fs::write(&config, "").expect("write empty config"); + + let output = clash_cmd() + .args(["-t", "-f"]) + .arg(&config) + .output() + .expect("run clash-rs"); + + assert!(!output.status.success()); + assert_eq!(output.status.code(), Some(1)); + assert_eq!(stderr(&output), ""); + + let stdout = stdout(&output); + let lines = stdout.lines().collect::>(); + // The first line is a log entry; `escape_logrus_text` escapes path + // separators on Windows, so we only check for the "is empty" substring + // rather than the full path to stay cross-platform. + assert!( + lines.first().is_some_and( + |line| line.starts_with("time=\"") && line.contains("is empty") + ), + "stdout:\n{stdout}" + ); + // The summary line is printed via `println!` without any escaping, so + // comparing the full path here is safe on all platforms. + let summary = + format!("configuration file {} test failed", path_display(&config)); + assert_eq!(lines.last().copied(), Some(summary.as_str())); +} diff --git a/clash-lib/src/app/outbound/manager.rs b/clash-lib/src/app/outbound/manager.rs index dfe3aab2e..6fc51f084 100644 --- a/clash-lib/src/app/outbound/manager.rs +++ b/clash-lib/src/app/outbound/manager.rs @@ -909,6 +909,9 @@ impl OutboundManager { for (name, provider) in proxy_providers.into_iter() { match provider { OutboundProxyProviderDef::Http(http) => { + let health_check_interval = + http.health_check.effective_interval(); + let health_check_lazy = http.health_check.effective_lazy(); let vehicle = http_vehicle::Vehicle::new( http.url.parse::().unwrap_or_else(|_| { print_and_exit!("invalid provider url: {}", http.url); @@ -920,8 +923,8 @@ impl OutboundManager { let hc = HealthCheck::new( vec![], http.health_check.url, - http.health_check.interval, - http.health_check.lazy.unwrap_or_default(), + health_check_interval, + health_check_lazy, proxy_manager.clone(), ); @@ -938,6 +941,9 @@ impl OutboundManager { provider_registry.insert(name, Arc::new(RwLock::new(provider))); } OutboundProxyProviderDef::File(file) => { + let health_check_interval = + file.health_check.effective_interval(); + let health_check_lazy = file.health_check.effective_lazy(); let vehicle = file_vehicle::Vehicle::new( PathBuf::from(cwd.clone()) .join(&file.path) @@ -947,8 +953,8 @@ impl OutboundManager { let hc = HealthCheck::new( vec![], file.health_check.url, - file.health_check.interval, - file.health_check.lazy.unwrap_or_default(), + health_check_interval, + health_check_lazy, proxy_manager.clone(), ); diff --git a/clash-lib/src/config/internal/convert/mod.rs b/clash-lib/src/config/internal/convert/mod.rs index fcb44d2d1..712023852 100644 --- a/clash-lib/src/config/internal/convert/mod.rs +++ b/clash-lib/src/config/internal/convert/mod.rs @@ -74,7 +74,7 @@ pub(super) fn convert(mut c: def::Config) -> Result, _>>()?, - rule_providers: rule_provider::convert(c.rule_provider.take()), + rule_providers: rule_provider::convert(c.rule_provider.take())?, users: c .authentication .clone() @@ -143,8 +143,8 @@ pub(super) fn convert(mut c: def::Config) -> Result(rv) }) - .expect("proxy provider parse error") }) + .transpose()? .unwrap_or_default(), listeners: listener::convert(c.listeners.take(), &c)?, inbound_providers: c diff --git a/clash-lib/src/config/internal/convert/rule_provider.rs b/clash-lib/src/config/internal/convert/rule_provider.rs index 1e1f8f94b..b377df9c3 100644 --- a/clash-lib/src/config/internal/convert/rule_provider.rs +++ b/clash-lib/src/config/internal/convert/rule_provider.rs @@ -10,8 +10,8 @@ use crate::{ pub(super) fn convert( before: Option>>, -) -> HashMap { - before +) -> Result, Error> { + Ok(before .map(|m| { m.into_iter() .try_fold(HashMap::new(), |mut rv, (name, mut body)| { @@ -49,9 +49,9 @@ pub(super) fn convert( rv.insert(name, provider); Ok::, Error>(rv) }) - .expect("proxy provider parse error") }) - .unwrap_or_default() + .transpose()? + .unwrap_or_default()) } impl TryFrom> for RuleProviderDef { diff --git a/clash-lib/src/config/internal/proxy.rs b/clash-lib/src/config/internal/proxy.rs index 06206ad54..61cf041e1 100644 --- a/clash-lib/src/config/internal/proxy.rs +++ b/clash-lib/src/config/internal/proxy.rs @@ -687,6 +687,7 @@ pub struct OutboundHttpProvider { pub url: String, pub interval: u64, pub path: String, + #[serde(default)] pub health_check: HealthCheck, } @@ -697,17 +698,50 @@ pub struct OutboundFileProvider { pub name: String, pub path: String, pub interval: Option, + #[serde(default)] pub health_check: HealthCheck, } -#[derive(serde::Serialize, serde::Deserialize, Debug)] +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)] pub struct HealthCheck { + #[serde(default)] pub enable: bool, + #[serde(default)] pub url: String, + #[serde(default)] pub interval: u64, + #[serde(default)] pub lazy: Option, } +impl Default for HealthCheck { + fn default() -> Self { + HealthCheck { + enable: false, + url: String::new(), + interval: 0, + lazy: Some(true), + } + } +} + +impl HealthCheck { + pub fn effective_interval(&self) -> u64 { + if !self.enable { + return 0; + } + if self.interval == 0 { + 300 + } else { + self.interval + } + } + + pub fn effective_lazy(&self) -> bool { + self.lazy.unwrap_or(true) + } +} + impl TryFrom> for OutboundProxyProviderDef { type Error = crate::Error; @@ -726,6 +760,56 @@ impl TryFrom> for OutboundProxyProviderDef { } } +#[cfg(test)] +mod proxy_provider_tests { + use super::OutboundProxyProviderDef; + use serde_yaml::Value; + use std::collections::HashMap; + + fn value(input: &str) -> Value { + Value::String(input.to_owned()) + } + + #[test] + fn missing_health_check_uses_default_values() { + let provider = OutboundProxyProviderDef::try_from(HashMap::from([ + ("name".to_owned(), value("provider")), + ("type".to_owned(), value("file")), + ("path".to_owned(), value("./provider.yaml")), + ])) + .expect("parse provider"); + + let OutboundProxyProviderDef::File(provider) = provider else { + panic!("expected file provider"); + }; + assert!(!provider.health_check.enable); + assert_eq!(provider.health_check.url, ""); + assert_eq!(provider.health_check.effective_interval(), 0); + assert!(provider.health_check.effective_lazy()); + } + + #[test] + fn enabled_health_check_defaults_interval_to_300() { + let provider = OutboundProxyProviderDef::try_from(HashMap::from([ + ("name".to_owned(), value("provider")), + ("type".to_owned(), value("file")), + ("path".to_owned(), value("./provider.yaml")), + ( + "health-check".to_owned(), + serde_yaml::from_str("enable: true").expect("parse health check"), + ), + ])) + .expect("parse provider"); + + let OutboundProxyProviderDef::File(provider) = provider else { + panic!("expected file provider"); + }; + assert!(provider.health_check.enable); + assert_eq!(provider.health_check.effective_interval(), 300); + assert!(provider.health_check.effective_lazy()); + } +} + #[cfg(all(test, feature = "tailscale"))] mod tailscale_tests { use super::{OutboundProxyProtocol, OutboundTailscale};