use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; /// Client runtime configuration. /// Constructed by the main binary from the unified `config.json`, /// then passed into `runner::run_client`. All I/O happens in the /// binary layer — this crate only owns the plain data structures. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ClientConfig { pub mode: String, #[serde(default)] pub debug: bool, pub ostp: OstpConfig, pub local_proxy: LocalProxyConfig, #[serde(default)] pub transport: TransportConfig, #[serde(default)] pub exclusions: ExclusionConfig, #[serde(default)] pub multiplex: MultiplexConfig, pub dns_server: Option, #[serde(default = "default_tun_stack")] pub tun_stack: String, #[serde(default)] pub kill_switch: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub gui: Option, } fn default_tun_stack() -> String { "system".to_string() } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct ExclusionConfig { #[serde(default)] pub domains: Vec, #[serde(default)] pub ips: Vec, #[serde(default)] pub processes: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MultiplexConfig { pub enabled: bool, pub sessions: usize, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OstpConfig { pub server_addr: String, pub local_bind_addr: String, #[serde(alias = "auth_token")] pub access_key: String, pub handshake_timeout_ms: u64, pub io_timeout_ms: u64, #[serde(default = "default_mtu")] pub mtu: usize, #[serde(default = "default_keepalive")] pub keepalive_interval_sec: u64, } fn default_keepalive() -> u64 { 5 } fn default_mtu() -> usize { 1140 } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LocalProxyConfig { pub bind_addr: String, pub connect_timeout_ms: u64, } /// Transport layer configuration. /// `mode` = "udp" (default) or "uot" (UDP over TCP, no protocol mimicry — /// zapret-like: no recognizable header at all, not a fake TLS/HTTP shell). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TransportConfig { /// "udp" or "uot" #[serde(default = "default_transport_mode")] pub mode: String, /// Split the first UoT/TCP packet (handshake) into tiny TCP segments to /// break DPI that inspects the first packet. UoT/TCP only; ignored for UDP. pub tcp_fragmentation: bool, /// TCP chunk size (bytes) #[serde(default = "default_frag_chunk")] pub frag_chunk: usize, /// TCP sleep duration between chunks (ms) #[serde(default = "default_frag_sleep")] pub frag_sleep: u64, /// [min, max] junk packet count #[serde(default = "default_junk_count")] pub junk_pc: [usize; 2], /// [min, max] junk packet size in bytes #[serde(default = "default_junk_size")] pub junk_ps: [usize; 2], } fn default_transport_mode() -> String { "udp".to_string() } fn default_frag_chunk() -> usize { 2 } fn default_frag_sleep() -> u64 { 2 } fn default_junk_count() -> [usize; 2] { [2, 5] } fn default_junk_size() -> [usize; 2] { [100, 1000] } impl Default for TransportConfig { fn default() -> Self { Self { mode: default_transport_mode(), tcp_fragmentation: false, frag_chunk: default_frag_chunk(), frag_sleep: default_frag_sleep(), junk_pc: default_junk_count(), junk_ps: default_junk_size(), } } } impl Default for OstpConfig { fn default() -> Self { Self { server_addr: "127.0.0.1:50000".to_string(), local_bind_addr: "0.0.0.0:0".to_string(), access_key: String::new(), handshake_timeout_ms: 5000, io_timeout_ms: 2500, mtu: default_mtu(), keepalive_interval_sec: default_keepalive(), } } } impl Default for LocalProxyConfig { fn default() -> Self { Self { bind_addr: "127.0.0.1:1088".to_string(), connect_timeout_ms: 15000, } } } impl Default for ClientConfig { fn default() -> Self { Self { mode: "proxy".to_string(), debug: false, ostp: OstpConfig::default(), local_proxy: LocalProxyConfig::default(), transport: TransportConfig::default(), exclusions: ExclusionConfig::default(), multiplex: MultiplexConfig::default(), dns_server: None, tun_stack: "system".to_string(), kill_switch: false, gui: None, } } } impl Default for MultiplexConfig { fn default() -> Self { Self { enabled: false, sessions: 1, } } } /// Unified shape of `config.json` as seen by the client. /// Used only for hot-reloading (`BridgeCommand::ReloadConfig`). #[derive(Debug, Deserialize)] struct RawUnifiedConfig { #[allow(dead_code)] mode: String, debug: Option, server: Option, access_key: Option, mtu: Option, socks5_bind: Option, tun: Option, exclude: Option, mux: Option, transport: Option, gui: Option, } #[derive(Debug, Deserialize)] struct RawTransportSection { mode: Option, tcp_fragmentation: Option, frag_chunk: Option, frag_sleep: Option, junk_pc: Option<[usize; 2]>, junk_ps: Option<[usize; 2]>, } #[derive(Debug, Deserialize)] struct RawTunSection { enable: Option, dns: Option, stack: Option, kill_switch: Option, } #[derive(Debug, Deserialize)] struct RawExcludeSection { domains: Option>, ips: Option>, processes: Option>, } #[derive(Debug, Deserialize)] struct RawMuxSection { enabled: Option, sessions: Option, } impl ClientConfig { /// Hot-reload from `config.json` placed next to the running binary. /// Returns a new `ClientConfig` built from the unified JSON format. pub fn reload_from_json_near_binary() -> Result { let exe = std::env::current_exe().context("cannot resolve binary path")?; let dir = exe.parent().context("cannot resolve binary directory")?; let path = dir.join("config.json"); let raw = std::fs::read_to_string(&path) .with_context(|| format!("failed to read {}", path.display()))?; let mut stripped = json_comments::StripComments::new(raw.as_bytes()); let raw: RawUnifiedConfig = serde_json::from_reader(&mut stripped) .with_context(|| format!("failed to parse {}", path.display()))?; let is_tun = raw.tun.as_ref().and_then(|t| t.enable).unwrap_or(false); let server = raw.server.unwrap_or_else(|| "127.0.0.1:50000".to_string()); let key = raw.access_key.unwrap_or_default(); let mtu = raw.mtu.unwrap_or(default_mtu()); let socks5 = raw.socks5_bind.unwrap_or_else(|| "127.0.0.1:1088".to_string()); let exclusions = raw.exclude.unwrap_or(RawExcludeSection { domains: None, ips: None, processes: None, }); let mux = raw.mux.unwrap_or(RawMuxSection { enabled: None, sessions: None, }); Ok(ClientConfig { mode: if is_tun { "tun".to_string() } else { "proxy".to_string() }, debug: raw.debug.unwrap_or(false), ostp: OstpConfig { server_addr: server, local_bind_addr: "0.0.0.0:0".to_string(), access_key: key, handshake_timeout_ms: 5000, io_timeout_ms: 2500, mtu, keepalive_interval_sec: default_keepalive(), }, local_proxy: LocalProxyConfig { bind_addr: socks5, connect_timeout_ms: 15000, }, transport: TransportConfig { mode: raw.transport.as_ref().and_then(|t| t.mode.clone()).unwrap_or_else(default_transport_mode), tcp_fragmentation: raw.transport.as_ref().and_then(|t| t.tcp_fragmentation).unwrap_or(false), frag_chunk: raw.transport.as_ref().and_then(|t| t.frag_chunk).unwrap_or_else(default_frag_chunk), frag_sleep: raw.transport.as_ref().and_then(|t| t.frag_sleep).unwrap_or_else(default_frag_sleep), junk_pc: raw.transport.as_ref().and_then(|t| t.junk_pc).unwrap_or_else(default_junk_count), junk_ps: raw.transport.as_ref().and_then(|t| t.junk_ps).unwrap_or_else(default_junk_size), }, exclusions: ExclusionConfig { domains: exclusions.domains.unwrap_or_default(), ips: exclusions.ips.unwrap_or_default(), processes: exclusions.processes.unwrap_or_default(), }, multiplex: MultiplexConfig { enabled: mux.enabled.unwrap_or(false), sessions: mux.sessions.unwrap_or(1), }, dns_server: raw.tun.as_ref().and_then(|t| t.dns.clone()), tun_stack: raw.tun.as_ref().and_then(|t| t.stack.clone()).unwrap_or_else(|| "system".to_string()), kill_switch: raw.tun.as_ref().and_then(|t| t.kill_switch).unwrap_or(false), gui: raw.gui, }) } } // ═══════════════════════════════════════════════════════════════════════ // On-disk config.json shapes — client, server, and relay. // // This is the ONE place these are defined. They used to be declared locally // inside ostp/src/main.rs (the CLI binary) with no other consumer able to // see them, which is exactly how ostp-client::migrate ended up working // against loosely-typed serde_json::Value instead of a real schema, and how // the CLI, the migrator, and this crate's own hot-reload path could each // silently drift out of sync with what a config.json actually looks like. // main.rs now imports these instead of re-declaring them (see the `use // ostp_client::config::{...}` at its top). // // These are DELIBERATELY separate from ClientConfig/OstpConfig/etc. above: // this section is the friendly, minimal shape a user actually edits by // hand; the types above are what the running engine needs internally // (handshake/io timeouts, resolved addresses, ...) and are built FROM one // of these via the mapping in ostp/src/main.rs::run_client_directly. Only // `ClientConfig` collides by name with the runtime type above, so the // on-disk one is `ClientFileConfig` — everything else keeps its natural name. // ═══════════════════════════════════════════════════════════════════════ #[derive(Debug, Deserialize, Serialize)] #[serde(tag = "mode", rename_all = "lowercase")] pub enum AppMode { Server(ServerConfig), Client(ClientFileConfig), Relay(RelayServerConfig), } #[derive(Debug, Deserialize, Serialize)] pub struct UnifiedConfig { #[serde(flatten)] pub mode: AppMode, pub log_level: Option, } impl UnifiedConfig { pub fn validate(&self) -> Result<()> { match &self.mode { AppMode::Server(cfg) => { if cfg.access_keys.is_empty() { anyhow::bail!("Server configuration must contain at least one access_key."); } if let Some(outbound) = &cfg.outbound { if outbound.enabled { let action = outbound.default_action.as_deref().unwrap_or("direct"); if action == "direct" && outbound.rules.is_empty() { println!("\n[WARNING] Server outbound proxy is ENABLED, but default_action is 'direct' and there are no rules!"); println!(" This means ALL traffic will bypass the proxy and go out directly from the server IP."); println!(" If you want all traffic to be proxied, change 'default_action' to 'proxy'.\n"); } } } } AppMode::Client(cfg) => { if cfg.access_key.is_empty() { anyhow::bail!("Client configuration must contain an access_key."); } } AppMode::Relay(cfg) => { if cfg.upstream_tcp.is_empty() { anyhow::bail!("Relay configuration must specify upstream_tcp address."); } if cfg.upstream_api_url.is_empty() { anyhow::bail!("Relay configuration must specify upstream_api_url."); } } } Ok(()) } } #[derive(Debug, Deserialize, Serialize, Clone)] #[serde(untagged)] pub enum UserConfig { Detailed { access_key: String, name: Option, limit_bytes: Option, }, KeyOnly(String), } impl UserConfig { pub fn key(&self) -> String { match self { UserConfig::KeyOnly(k) => k.clone(), UserConfig::Detailed { access_key, .. } => access_key.clone(), } } pub fn name(&self) -> Option { match self { UserConfig::KeyOnly(_) => None, UserConfig::Detailed { name, .. } => name.clone(), } } pub fn limit(&self) -> Option { match self { UserConfig::KeyOnly(_) => None, UserConfig::Detailed { limit_bytes, .. } => *limit_bytes, } } } #[derive(Debug, Deserialize, Serialize)] pub struct ServerConfig { pub listen: ListenConfig, pub access_keys: Vec, pub debug: Option, pub outbound: Option, pub api: Option, pub fallback: Option, pub transport: Option, // Left untyped: ostp-client does not (and should not) depend on // ostp-server just to name its DnsConfig type. The CLI binary — which // already depends on both crates — deserializes this into // ostp_server::dns::DnsConfig right before handing it to run_server(). pub dns: Option, } /// Relay-node config.json shape. #[derive(Debug, Deserialize, Serialize)] pub struct RelayServerConfig { /// Listen address(es) (UDP + TCP UoT) pub listen: ListenConfig, /// Upstream address for TCP (UoT) traffic pub upstream_tcp: String, /// Upstream address for UDP traffic pub upstream_udp: String, /// Target server's API URL, for key sync pub upstream_api_url: String, /// Bearer token for the target server's API #[serde(default)] pub upstream_api_token: String, /// Key sync interval in seconds (default 30) #[serde(default = "default_sync_interval")] pub sync_interval_secs: u64, pub debug: Option, } fn default_sync_interval() -> u64 { 30 } /// Supports both a single string "0.0.0.0:50000" and an array /// ["0.0.0.0:50000", "[::]:50000"]. #[derive(Debug, Deserialize, Serialize, Clone)] #[serde(untagged)] pub enum ListenConfig { Single(String), Multiple(Vec), } impl ListenConfig { pub fn addresses(&self) -> Vec { match self { ListenConfig::Single(s) => vec![s.clone()], ListenConfig::Multiple(v) => v.clone(), } } pub fn primary(&self) -> String { match self { ListenConfig::Single(s) => s.clone(), ListenConfig::Multiple(v) => v.first().cloned().unwrap_or_default(), } } } #[derive(Debug, Deserialize, Serialize)] pub struct ApiConfig { pub enabled: Option, pub bind: Option, pub token: Option, pub webpath: Option, pub username: Option, pub password_hash: Option, } #[derive(Debug, Deserialize, Serialize)] pub struct FallbackCfg { pub enabled: Option, pub listen: Option, pub target: Option, } #[derive(Debug, Deserialize, Serialize)] pub struct ClientFileConfig { pub server: String, pub access_key: String, pub mtu: Option, pub socks5_bind: Option, pub tun: Option, pub debug: Option, pub exclude: Option, pub mux: Option, pub transport: Option, pub gui: Option, } #[derive(Debug, Deserialize, Serialize, Clone)] pub struct TransportConfigRaw { pub mode: Option, pub tcp_fragmentation: Option, } #[derive(Debug, Deserialize, Serialize, Clone)] pub struct TunConfig { pub enable: bool, pub wintun_path: Option, pub ipv4_address: Option, pub dns: Option, pub kill_switch: Option, } #[derive(Debug, Deserialize, Serialize)] pub struct OutboundConfig { pub enabled: bool, pub protocol: String, pub address: String, pub port: u16, #[serde(default)] pub rules: Vec, pub default_action: Option, } #[derive(Debug, Deserialize, Serialize)] pub struct OutboundRule { pub domain_suffix: Option>, pub ip_cidr: Option>, pub protocol: Option, pub action: Option, } #[derive(Debug, Deserialize, Serialize)] pub struct ExcludeConfig { pub domains: Option>, pub ips: Option>, pub processes: Option>, } #[derive(Debug, Deserialize, Serialize)] pub struct MuxConfig { pub enabled: Option, pub sessions: Option, }