mirror of https://github.com/ospab/ostp.git
536 lines
18 KiB
Rust
536 lines
18 KiB
Rust
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<String>,
|
|
#[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<serde_json::Value>,
|
|
}
|
|
|
|
fn default_tun_stack() -> String { "system".to_string() }
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
pub struct ExclusionConfig {
|
|
#[serde(default)]
|
|
pub domains: Vec<String>,
|
|
#[serde(default)]
|
|
pub ips: Vec<String>,
|
|
#[serde(default)]
|
|
pub processes: Vec<String>,
|
|
}
|
|
|
|
#[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<bool>,
|
|
server: Option<String>,
|
|
access_key: Option<String>,
|
|
mtu: Option<usize>,
|
|
socks5_bind: Option<String>,
|
|
tun: Option<RawTunSection>,
|
|
exclude: Option<RawExcludeSection>,
|
|
mux: Option<RawMuxSection>,
|
|
transport: Option<RawTransportSection>,
|
|
gui: Option<serde_json::Value>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct RawTransportSection {
|
|
mode: Option<String>,
|
|
tcp_fragmentation: Option<bool>,
|
|
frag_chunk: Option<usize>,
|
|
frag_sleep: Option<u64>,
|
|
junk_pc: Option<[usize; 2]>,
|
|
junk_ps: Option<[usize; 2]>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct RawTunSection {
|
|
enable: Option<bool>,
|
|
dns: Option<String>,
|
|
stack: Option<String>,
|
|
kill_switch: Option<bool>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct RawExcludeSection {
|
|
domains: Option<Vec<String>>,
|
|
ips: Option<Vec<String>>,
|
|
processes: Option<Vec<String>>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct RawMuxSection {
|
|
enabled: Option<bool>,
|
|
sessions: Option<usize>,
|
|
}
|
|
|
|
|
|
|
|
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<Self> {
|
|
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<String>,
|
|
}
|
|
|
|
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<String>,
|
|
limit_bytes: Option<u64>,
|
|
},
|
|
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<String> {
|
|
match self {
|
|
UserConfig::KeyOnly(_) => None,
|
|
UserConfig::Detailed { name, .. } => name.clone(),
|
|
}
|
|
}
|
|
pub fn limit(&self) -> Option<u64> {
|
|
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<UserConfig>,
|
|
pub debug: Option<bool>,
|
|
pub outbound: Option<OutboundConfig>,
|
|
pub api: Option<ApiConfig>,
|
|
pub fallback: Option<FallbackCfg>,
|
|
pub transport: Option<TransportConfigRaw>,
|
|
// 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<serde_json::Value>,
|
|
}
|
|
|
|
/// 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<bool>,
|
|
}
|
|
|
|
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<String>),
|
|
}
|
|
|
|
impl ListenConfig {
|
|
pub fn addresses(&self) -> Vec<String> {
|
|
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<bool>,
|
|
pub bind: Option<String>,
|
|
pub token: Option<String>,
|
|
pub webpath: Option<String>,
|
|
pub username: Option<String>,
|
|
pub password_hash: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Serialize)]
|
|
pub struct FallbackCfg {
|
|
pub enabled: Option<bool>,
|
|
pub listen: Option<String>,
|
|
pub target: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Serialize)]
|
|
pub struct ClientFileConfig {
|
|
pub server: String,
|
|
pub access_key: String,
|
|
pub mtu: Option<usize>,
|
|
pub socks5_bind: Option<String>,
|
|
pub tun: Option<TunConfig>,
|
|
pub debug: Option<bool>,
|
|
pub exclude: Option<ExcludeConfig>,
|
|
pub mux: Option<MuxConfig>,
|
|
pub transport: Option<TransportConfigRaw>,
|
|
pub gui: Option<serde_json::Value>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Serialize, Clone)]
|
|
pub struct TransportConfigRaw {
|
|
pub mode: Option<String>,
|
|
pub tcp_fragmentation: Option<bool>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Serialize, Clone)]
|
|
pub struct TunConfig {
|
|
pub enable: bool,
|
|
pub wintun_path: Option<String>,
|
|
pub ipv4_address: Option<String>,
|
|
pub dns: Option<String>,
|
|
pub kill_switch: Option<bool>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Serialize)]
|
|
pub struct OutboundConfig {
|
|
pub enabled: bool,
|
|
pub protocol: String,
|
|
pub address: String,
|
|
pub port: u16,
|
|
#[serde(default)]
|
|
pub rules: Vec<OutboundRule>,
|
|
pub default_action: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Serialize)]
|
|
pub struct OutboundRule {
|
|
pub domain_suffix: Option<Vec<String>>,
|
|
pub ip_cidr: Option<Vec<String>>,
|
|
pub protocol: Option<String>,
|
|
pub action: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Serialize)]
|
|
pub struct ExcludeConfig {
|
|
pub domains: Option<Vec<String>>,
|
|
pub ips: Option<Vec<String>>,
|
|
pub processes: Option<Vec<String>>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Serialize)]
|
|
pub struct MuxConfig {
|
|
pub enabled: Option<bool>,
|
|
pub sessions: Option<usize>,
|
|
}
|