From 26665a826f8e1783a0793ba7e9348b911179edc0 Mon Sep 17 00:00:00 2001 From: ospab Date: Wed, 8 Jul 2026 18:45:04 +0300 Subject: [PATCH] feat(client): one authoritative config migrator, manual-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix(cli): stop printing the startup banner ("ostp-cli vX.Y.Z | OS: ...") to stderr on every single command invocation. init_tracing() ran unconditionally before command dispatch, so `ostp -V`, `ostp gk`, etc. all showed it. It's still written to the log file (useful there), just no longer echoed via the stderr tracing layer for one-shot commands. - feat(client): add ostp-client::migrate, the ONE place config migration runs. Previously there were three uncoordinated migration paths: a Python snippet embedded in scripts/install.sh (only touched server api.* fields, ran on every update), the old 0.3.x line's auto-migration on every hot reload (silent besides a log warning), and nothing at all for the current rebuild. Consolidated into one module covering every config shape that's actually existed: - v0.3.1-v0.3.21 modular (inbounds/outbounds/routing) -> current flat schema, including correctly resolving routing.default_outbound through a urltest/selector group to the real server, and reporting (not silently dropping) every additional server a multi-server config had. - pre-0.3.1 flat configs carrying now-dead fields (tun.wintun_path, tun.ipv4_address, transport.wss) -> dropped with an explicit reason, everything else passes through untouched. - server configs -> backfills api.* defaults and drops legacy api.token (ported straight from the install.sh Python, same behavior, correct place). 6 unit tests cover all of the above against realistic fixtures. Wired up as `ostp migrate` (was missing from Commands entirely) — no other code path calls into this module, so a config's shape only ever changes when explicitly asked. - feat(cli): `ostp import ` now asks the same TUN/mux/debug questions `ostp connect ` always did. Previously import just wrote flat defaults to disk with no way to turn any of that on short of hand-editing the resulting config.json afterward. Extracted the shared prompt into prompt_client_options() so both paths stay in sync. - chore(install): remove the embedded Python config-migration snippet from install.sh; schema migration must never happen implicitly during an install/update. Points users at `ostp migrate` instead. --- ostp-client/src/lib.rs | 1 + ostp-client/src/logging.rs | 37 ++- ostp-client/src/migrate.rs | 508 +++++++++++++++++++++++++++++++++++++ ostp/src/main.rs | 176 +++++++++---- scripts/install.sh | 55 +--- 5 files changed, 669 insertions(+), 108 deletions(-) create mode 100644 ostp-client/src/migrate.rs diff --git a/ostp-client/src/lib.rs b/ostp-client/src/lib.rs index 474672a..8f5b7ae 100644 --- a/ostp-client/src/lib.rs +++ b/ostp-client/src/lib.rs @@ -1,6 +1,7 @@ pub mod app; pub mod bridge; pub mod config; +pub mod migrate; pub mod signal; pub mod sysproxy; pub mod transport; diff --git a/ostp-client/src/logging.rs b/ostp-client/src/logging.rs index 661962e..b273e0d 100644 --- a/ostp-client/src/logging.rs +++ b/ostp-client/src/logging.rs @@ -71,9 +71,28 @@ pub fn init_tracing(level: &str, app_name: &str, version: &str) -> Option Option Option, +} + +impl MigrationReport { + fn note(&mut self, msg: impl Into) { + self.changed = true; + self.notes.push(msg.into()); + } +} + +/// Which config this file is (mirrors `AppMode`'s `"mode"` tag). Old configs +/// from before that tag existed are sniffed structurally as a fallback. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConfigKind { + Client, + Server, + Relay, +} + +pub fn detect_kind(json: &Value) -> Option { + match json.get("mode").and_then(|v| v.as_str()) { + Some("client") => return Some(ConfigKind::Client), + Some("server") => return Some(ConfigKind::Server), + Some("relay") => return Some(ConfigKind::Relay), + _ => {} + } + // No (or unrecognized) "mode" tag — this is an older config from before + // it was mandatory. Sniff by the fields that have been present on each + // shape since the earliest surviving config format. + if json.get("upstream_tcp").is_some() || json.get("upstream_api_url").is_some() { + Some(ConfigKind::Relay) + } else if json.get("access_keys").is_some() || json.get("listen").is_some() { + Some(ConfigKind::Server) + } else if json.get("access_key").is_some() || json.get("server").is_some() { + Some(ConfigKind::Client) + } else { + None + } +} + +/// Migrates a client config of any known past shape to the current flat +/// schema. Returns the migrated JSON and a report of every change made. +/// +/// Known input shapes, oldest first: +/// - **v0.3.1–v0.3.21 "modular multi-server"**: `inbounds`/`outbounds` arrays +/// + `routing.rules`. Only the first `ostp`-type outbound is kept (this +/// line no longer supports multiple simultaneous servers); every other +/// `ostp` outbound is reported by tag+address so nothing vanishes +/// invisibly. `urltest`/`selector`/`direct`/`block` outbounds have no +/// equivalent and are dropped (reported). +/// - **pre-0.3.1 flat (up to v0.2.98)**: same field names as today +/// (`server`, `access_key`, `tun`, `exclude`, `mux`, `transport`, ...) +/// except `tun.wintun_path`/`tun.ipv4_address` (internal driver detail, +/// never user-meaningful data) and `transport.wss` (the WSS framing +/// feature removed entirely in the 0.4.0 rebuild) — both dropped with an +/// explicit note; everything else maps 1:1, nothing to convert. +/// - **current flat schema**: no-op, `changed = false`. +pub fn migrate_client_json(json: Value) -> (Value, MigrationReport) { + let mut report = MigrationReport::default(); + + let has_inbounds = json.get("inbounds").and_then(|v| v.as_array()).is_some(); + let has_outbounds = json.get("outbounds").and_then(|v| v.as_array()).is_some(); + + if has_inbounds && has_outbounds { + return migrate_client_from_modular(json, report); + } + + // Flat shape already (current or pre-0.3.1) — normalize obsolete fields + // in place rather than rebuilding the whole document from scratch, so + // any field this module doesn't know about yet still survives untouched. + let mut out = json; + + if let Some(tun) = out.get_mut("tun").and_then(|t| t.as_object_mut()) { + for dead_field in ["wintun_path", "ipv4_address"] { + if tun.remove(dead_field).is_some() { + report.note(format!( + "Dropped tun.{dead_field} — internal driver detail from an older WinTun \ + integration, not applicable to the current TUN implementation." + )); + } + } + } + if let Some(transport) = out.get_mut("transport").and_then(|t| t.as_object_mut()) { + if transport.remove("wss").is_some() { + report.note( + "Dropped transport.wss — WSS framing was removed in the 0.4.0 rebuild \ + (the project follows a zapret-like approach: no protocol mimicry, \ + just packet-level obfuscation/manipulation, so there is no successor field)." + .to_string(), + ); + } + } + + (out, report) +} + +fn migrate_client_from_modular(json: Value, mut report: MigrationReport) -> (Value, MigrationReport) { + report.changed = true; // the shape itself is being replaced regardless of field-level detail + + let inbounds = json.get("inbounds").and_then(|v| v.as_array()).cloned().unwrap_or_default(); + let outbounds = json.get("outbounds").and_then(|v| v.as_array()).cloned().unwrap_or_default(); + let routing = json.get("routing").cloned().unwrap_or(json!({})); + let default_outbound = routing.get("default_outbound").and_then(|v| v.as_str()).map(String::from); + + // ── Pick the primary "ostp" outbound ──────────────────────────────── + // Prefer the one routing.default_outbound points at (directly, or via a + // urltest/selector group that references it); otherwise take the first + // ostp outbound in file order. Every other ostp outbound is reported by + // tag+address, not silently discarded. + let ostp_outbounds: Vec<&Value> = outbounds + .iter() + .filter(|o| o.get("type").and_then(|t| t.as_str()) == Some("ostp")) + .collect(); + + // default_outbound might name an ostp outbound directly, OR name a + // urltest/selector GROUP whose first member is the one to actually use — + // check both, since a plain `.or_else` here would never even attempt the + // group lookup while default_outbound is Some(_) (which it almost always + // is), silently falling through to "just take the first ostp outbound in + // file order" instead — exactly the kind of silent wrong answer this + // migrator exists to avoid. + let primary_tag: Option = default_outbound.as_deref().and_then(|def_tag| { + if ostp_outbounds.iter().any(|o| o.get("tag").and_then(|t| t.as_str()) == Some(def_tag)) { + return Some(def_tag.to_string()); + } + outbounds.iter().find_map(|o| { + let is_group = matches!(o.get("type").and_then(|t| t.as_str()), Some("urltest") | Some("selector")); + let tag_matches = o.get("tag").and_then(|t| t.as_str()) == Some(def_tag); + if is_group && tag_matches { + o.get("outbounds") + .and_then(|v| v.as_array()) + .and_then(|arr| arr.first()) + .and_then(|v| v.as_str()) + .map(String::from) + } else { + None + } + }) + }); + + let primary = primary_tag + .as_deref() + .and_then(|tag| ostp_outbounds.iter().find(|o| o.get("tag").and_then(|t| t.as_str()) == Some(tag))) + .copied() + .or_else(|| ostp_outbounds.first().copied()); + + let Some(primary) = primary else { + report.note( + "No 'ostp'-type outbound found in the old modular config — nothing to migrate \ + the server connection from. Wrote a placeholder; you MUST fill in server/access_key \ + by hand or re-import a share link." + .to_string(), + ); + return ( + json!({ + "server": "127.0.0.1:50000", + "access_key": "", + }), + report, + ); + }; + + for other in &ostp_outbounds { + if !std::ptr::eq(*other, primary) { + let tag = other.get("tag").and_then(|t| t.as_str()).unwrap_or("?"); + let addr = other.get("server").and_then(|t| t.as_str()).unwrap_or("?"); + let port = other.get("port").and_then(|t| t.as_u64()).unwrap_or(0); + report.note(format!( + "Dropped additional server '{tag}' ({addr}:{port}) — multi-server / urltest \ + failover is no longer supported; only one server per config now. Kept the \ + one from routing.default_outbound (or the first one if that wasn't set)." + )); + } + } + + let server = primary.get("server").and_then(|v| v.as_str()).unwrap_or("127.0.0.1").to_string(); + let port = primary.get("port").and_then(|v| v.as_u64()).unwrap_or(50000); + let access_key = primary.get("access_key").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let transport_type = primary + .get("transport") + .and_then(|t| t.get("type").or_else(|| t.get("mode"))) + .and_then(|v| v.as_str()) + .unwrap_or("udp") + .to_string(); + let stealth_sni = primary + .get("transport") + .and_then(|t| t.get("stealth_sni")) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let tcp_fragmentation = primary + .get("transport") + .and_then(|t| t.get("tcp_fragmentation")) + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let mux_enabled = primary.get("multiplex").and_then(|m| m.get("enabled")).and_then(|v| v.as_bool()).unwrap_or(false); + let mux_sessions = primary.get("multiplex").and_then(|m| m.get("sessions")).and_then(|v| v.as_u64()).unwrap_or(1); + + // ── TUN + local proxy inbounds ─────────────────────────────────────── + let tun_inbound = inbounds.iter().find(|i| i.get("type").and_then(|t| t.as_str()) == Some("tun")); + let proxy_inbound = inbounds.iter().find(|i| i.get("type").and_then(|t| t.as_str()) == Some("local_proxy")); + + let tun_enable = tun_inbound.is_some(); + let mtu = tun_inbound.and_then(|t| t.get("mtu")).and_then(|v| v.as_u64()); + + let socks5_bind = proxy_inbound + .map(|p| { + let listen = p.get("listen").and_then(|v| v.as_str()).unwrap_or("127.0.0.1"); + let port = p.get("port").and_then(|v| v.as_u64()).unwrap_or(1088); + format!("{listen}:{port}") + }) + .unwrap_or_else(|| "127.0.0.1:1088".to_string()); + + // ── Exclusions from routing.rules → direct ────────────────────────── + let mut ex_domains: Vec = Vec::new(); + let mut ex_ips: Vec = Vec::new(); + let mut ex_processes: Vec = Vec::new(); + if let Some(rules) = routing.get("rules").and_then(|v| v.as_array()) { + for rule in rules { + if rule.get("outbound").and_then(|v| v.as_str()) != Some("direct") { + continue; // only "route to direct" rules were ever exclusions in the old format + } + if let Some(v) = rule.get("domain_suffix").and_then(|v| v.as_array()) { + ex_domains.extend(v.iter().filter_map(|s| s.as_str().map(String::from))); + } + if let Some(v) = rule.get("ip_cidr").and_then(|v| v.as_array()) { + ex_ips.extend(v.iter().filter_map(|s| s.as_str().map(String::from))); + } + if let Some(v) = rule.get("process_name").and_then(|v| v.as_array()) { + ex_processes.extend(v.iter().filter_map(|s| s.as_str().map(String::from))); + } + } + } + for other_rule_outbound in routing + .get("rules") + .and_then(|v| v.as_array()) + .into_iter() + .flatten() + .filter_map(|r| r.get("outbound").and_then(|v| v.as_str())) + .filter(|o| *o != "direct") + { + report.note(format!( + "Dropped a routing rule targeting outbound '{other_rule_outbound}' — only \ + \"route to direct\" rules map to today's exclusions; anything else \ + (custom per-domain outbound selection) has no equivalent anymore." + )); + } + + let debug = json.get("log").and_then(|l| l.get("level")).and_then(|v| v.as_str()) == Some("debug"); + + let mut client = json!({ + "server": server, + "port": port, + "access_key": access_key, + "socks5_bind": socks5_bind, + "debug": debug, + "tun": { + "enable": tun_enable, + "dns": null, + "kill_switch": false, + }, + "exclude": { + "domains": ex_domains, + "ips": ex_ips, + "processes": ex_processes, + }, + "mux": { + "enabled": mux_enabled, + "sessions": mux_sessions, + }, + "transport": { + "mode": transport_type, + "stealth_sni": stealth_sni, + "tcp_fragmentation": tcp_fragmentation, + }, + }); + if let Some(mtu) = mtu { + client["mtu"] = json!(mtu); + } + if let Some(gui) = json.get("gui") { + client["gui"] = gui.clone(); + } + + (client, report) +} + +/// Migrates a server config. The server shape has stayed structurally +/// identical since the earliest surviving version — this only backfills the +/// `api` section (added after some configs already existed) and drops the +/// legacy `api.token` field. Ported from the ad-hoc Python snippet that used +/// to live in `scripts/install.sh` and only ran at install/update time. +pub fn migrate_server_json(json: Value) -> (Value, MigrationReport) { + let mut report = MigrationReport::default(); + let mut out = json; + + let obj = match out.as_object_mut() { + Some(o) => o, + None => return (out, report), + }; + + let api = obj.entry("api").or_insert_with(|| json!({})); + if let Some(api_obj) = api.as_object_mut() { + let defaults: [(&str, Value); 5] = [ + ("enabled", json!(false)), + ("bind", json!("0.0.0.0:9090")), + ("webpath", json!("")), + ("username", json!("")), + ("password_hash", json!("")), + ]; + for (key, default) in defaults { + if !api_obj.contains_key(key) { + report.note(format!("Added api.{key} = {default} (missing default)")); + api_obj.insert(key.to_string(), default); + } + } + if api_obj.remove("token").is_some() { + report.note( + "Dropped legacy api.token — superseded by api.password_hash; \ + set a new admin password with the management API or panel." + .to_string(), + ); + } + } + + (out, report) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A realistic v0.3.21-shaped modular config (TUN + local_proxy inbounds, + /// a single ostp outbound, exclusion rules, mux) — mirrors the actual + /// shape from that tag, field for field. + #[test] + fn modular_single_server_preserves_every_field() { + let old = json!({ + "version": "0.3.21", + "log": { "level": "debug" }, + "inbounds": [ + { "type": "tun", "tag": "tun-in", "auto_route": true, "mtu": 1350 }, + { "type": "local_proxy", "tag": "socks-in", "protocol": "socks", "listen": "127.0.0.1", "port": 1088 } + ], + "outbounds": [ + { + "type": "ostp", "tag": "proxy", + "server": "203.0.113.5", "port": 50000, "access_key": "sekrit123", + "transport": { "type": "uot", "stealth_sni": "vk.com", "tcp_fragmentation": true }, + "multiplex": { "enabled": true, "sessions": 4 } + }, + { "type": "direct", "tag": "direct" }, + { "type": "block", "tag": "block" } + ], + "routing": { + "rules": [ + { "domain_suffix": ["local.lan", "internal.corp"], "outbound": "direct" }, + { "ip_cidr": ["192.168.0.0/16"], "outbound": "direct" }, + { "process_name": ["steam.exe"], "outbound": "direct" } + ], + "default_outbound": "proxy" + } + }); + + let (new, report) = migrate_client_json(old); + assert!(report.changed); + assert_eq!(new["server"], "203.0.113.5"); + assert_eq!(new["port"], 50000); + assert_eq!(new["access_key"], "sekrit123"); + assert_eq!(new["socks5_bind"], "127.0.0.1:1088"); + assert_eq!(new["mtu"], 1350); + assert_eq!(new["debug"], true); + assert_eq!(new["tun"]["enable"], true); + assert_eq!(new["transport"]["mode"], "uot"); + assert_eq!(new["transport"]["stealth_sni"], "vk.com"); + assert_eq!(new["transport"]["tcp_fragmentation"], true); + assert_eq!(new["mux"]["enabled"], true); + assert_eq!(new["mux"]["sessions"], 4); + assert_eq!(new["exclude"]["domains"], json!(["local.lan", "internal.corp"])); + assert_eq!(new["exclude"]["ips"], json!(["192.168.0.0/16"])); + assert_eq!(new["exclude"]["processes"], json!(["steam.exe"])); + } + + /// Old modular configs that had MULTIPLE ostp outbounds (multi-server) — + /// must keep the one routing.default_outbound points at and report every + /// other one by name/address rather than picking silently. + #[test] + fn modular_multi_server_keeps_default_and_reports_the_rest() { + let old = json!({ + "inbounds": [], + "outbounds": [ + { "type": "ostp", "tag": "proxy-0", "server": "1.1.1.1", "port": 50000, "access_key": "k1" }, + { "type": "ostp", "tag": "proxy-1", "server": "2.2.2.2", "port": 50000, "access_key": "k2" }, + { + "type": "urltest", "tag": "proxy", + "outbounds": ["proxy-1", "proxy-0"], "url": "http://cp.cloudflare.com" + } + ], + "routing": { "rules": [], "default_outbound": "proxy" } + }); + + let (new, report) = migrate_client_json(old); + // urltest's first member (proxy-1 / 2.2.2.2) is the one actually picked. + assert_eq!(new["server"], "2.2.2.2"); + assert_eq!(new["access_key"], "k2"); + assert!(report.notes.iter().any(|n| n.contains("proxy-0") && n.contains("1.1.1.1"))); + } + + /// Pre-0.3.1 flat config carrying fields that no longer exist + /// (tun.wintun_path, tun.ipv4_address, transport.wss) — those get + /// dropped with a note; every field that's still meaningful passes + /// through untouched, byte for byte. + #[test] + fn flat_legacy_drops_only_dead_fields() { + let old = json!({ + "server": "198.51.100.9:50000", + "access_key": "oldkey", + "mtu": 1200, + "socks5_bind": "127.0.0.1:1090", + "tun": { + "enable": true, + "wintun_path": "C:\\Program Files\\wintun\\wintun.dll", + "ipv4_address": "10.0.0.2", + "dns": "1.1.1.1", + "kill_switch": true + }, + "exclude": { "domains": ["a.com"], "ips": null, "processes": null }, + "mux": { "enabled": false, "sessions": 1 }, + "transport": { "mode": "udp", "stealth_sni": "bing.com", "wss": true } + }); + + let (new, report) = migrate_client_json(old); + assert!(report.changed); + // Untouched fields survive exactly as they were. + assert_eq!(new["server"], "198.51.100.9:50000"); + assert_eq!(new["access_key"], "oldkey"); + assert_eq!(new["mtu"], 1200); + assert_eq!(new["tun"]["enable"], true); + assert_eq!(new["tun"]["dns"], "1.1.1.1"); + assert_eq!(new["tun"]["kill_switch"], true); + assert_eq!(new["exclude"]["domains"], json!(["a.com"])); + assert_eq!(new["transport"]["stealth_sni"], "bing.com"); + // Dead fields are gone... + assert!(new["tun"].get("wintun_path").is_none()); + assert!(new["tun"].get("ipv4_address").is_none()); + assert!(new["transport"].get("wss").is_none()); + // ...and their removal was reported, not silent. + assert!(report.notes.iter().any(|n| n.contains("wintun_path"))); + assert!(report.notes.iter().any(|n| n.contains("ipv4_address"))); + assert!(report.notes.iter().any(|n| n.contains("wss"))); + } + + /// A config already in the current shape must be a true no-op: report + /// says nothing changed, and every field is untouched. + #[test] + fn current_flat_config_is_a_no_op() { + let current = json!({ + "server": "example.com:50000", + "access_key": "k", + "tun": { "enable": false, "dns": null, "kill_switch": false }, + "exclude": { "domains": [], "ips": [], "processes": [] }, + "mux": { "enabled": false, "sessions": 1 }, + "transport": { "mode": "udp", "stealth_sni": "", "tcp_fragmentation": false } + }); + let (new, report) = migrate_client_json(current.clone()); + assert!(!report.changed); + assert_eq!(new, current); + } + + #[test] + fn server_config_backfills_api_defaults_and_drops_legacy_token() { + let old = json!({ + "listen": "0.0.0.0:50000", + "access_keys": ["k1"], + "api": { "token": "old-plain-token" } + }); + let (new, report) = migrate_server_json(old); + assert!(report.changed); + assert_eq!(new["api"]["enabled"], false); + assert_eq!(new["api"]["bind"], "0.0.0.0:9090"); + assert!(new["api"].get("token").is_none()); + assert!(report.notes.iter().any(|n| n.contains("api.token"))); + } + + #[test] + fn detect_kind_falls_back_to_structural_sniffing_without_mode_tag() { + assert_eq!(detect_kind(&json!({"access_key": "x", "server": "y"})), Some(ConfigKind::Client)); + assert_eq!(detect_kind(&json!({"access_keys": ["x"], "listen": "y"})), Some(ConfigKind::Server)); + assert_eq!(detect_kind(&json!({"upstream_tcp": "x", "upstream_api_url": "y"})), Some(ConfigKind::Relay)); + assert_eq!(detect_kind(&json!({"mode": "client", "server": "x"})), Some(ConfigKind::Client)); + } +} diff --git a/ostp/src/main.rs b/ostp/src/main.rs index 7fee106..61dc6e2 100644 --- a/ostp/src/main.rs +++ b/ostp/src/main.rs @@ -71,6 +71,11 @@ enum Commands { ProxyEnv, /// Output shell export commands to clear proxy (eval $(ostp proxy-env-clear)) ProxyEnvClear, + /// Upgrade the configuration file to the current schema. This is the + /// ONLY place config migration ever runs — never automatically at + /// startup or during install/update, so a config never changes shape + /// without you asking it to. + Migrate, } /// Bridges the new subcommand-based CLI onto the original flat-flag dispatch @@ -93,6 +98,60 @@ struct LegacyArgs { import: Option, proxy_env: bool, proxy_env_clear: bool, + migrate: bool, +} + +/// Asks the same TUN/mux/debug questions regardless of how a share link +/// reached this config — connecting directly (`ostp connect `) or +/// importing it to disk (`ostp import `). Previously only the connect +/// path asked; `import` just wrote flat defaults with no way to turn any of +/// this on short of hand-editing the resulting config.json. +fn prompt_client_options(client_cfg: &mut ClientConfig) { + use std::io::Write; + let mut input = String::new(); + + print!("{} Enable TUN (VPN) mode? [y/N]: ", "?".blue().bold()); + std::io::stdout().flush().unwrap(); + std::io::stdin().read_line(&mut input).unwrap(); + if input.trim().eq_ignore_ascii_case("y") { + if let Some(tun) = &mut client_cfg.tun { + tun.enable = true; + } + } + + print!("{} Enable connection multiplexing (mux)? [y/N]: ", "?".blue().bold()); + std::io::stdout().flush().unwrap(); + input.clear(); + std::io::stdin().read_line(&mut input).unwrap(); + if input.trim().eq_ignore_ascii_case("y") { + print!("How many sessions? [5]: "); + std::io::stdout().flush().unwrap(); + input.clear(); + std::io::stdin().read_line(&mut input).unwrap(); + let mut sessions = 5; + if !input.trim().is_empty() { + if let Ok(s) = input.trim().parse() { + sessions = s; + } + } + if client_cfg.mux.is_none() { + client_cfg.mux = Some(MuxConfig { + enabled: Some(true), + sessions: Some(sessions), + }); + } else if let Some(mux) = &mut client_cfg.mux { + mux.enabled = Some(true); + mux.sessions = Some(sessions); + } + } + + print!("Enable debug mode? [y/N]: "); + std::io::stdout().flush().unwrap(); + input.clear(); + std::io::stdin().read_line(&mut input).unwrap(); + if input.trim().eq_ignore_ascii_case("y") { + client_cfg.debug = Some(true); + } } fn parse_ostp_link(link: &str) -> Result { @@ -1070,6 +1129,7 @@ async fn run_app() -> Result<()> { import: None, proxy_env: false, proxy_env_clear: false, + migrate: false, }; if let Some(cmd) = raw_args.command { @@ -1085,6 +1145,7 @@ async fn run_app() -> Result<()> { Commands::Import { url } => { args.import = Some(url); } Commands::ProxyEnv => { args.proxy_env = true; } Commands::ProxyEnvClear => { args.proxy_env_clear = true; } + Commands::Migrate => { args.migrate = true; } } } @@ -1096,6 +1157,10 @@ async fn run_app() -> Result<()> { return cmd_update(args.update_branch, args.target_version); } + if args.migrate { + return cmd_migrate(&args.config); + } + // ── Setup wizard: explicit flag or first-time (no config) ──────── if args.setup { return run_setup_wizard(&args.config); @@ -1181,8 +1246,9 @@ async fn run_app() -> Result<()> { if let Some(import_url) = args.import { println!("{} Importing configuration from share link...", "[ostp]".cyan().bold()); - let client_cfg = parse_ostp_link(&import_url) + let mut client_cfg = parse_ostp_link(&import_url) .map_err(|e| anyhow!("Share Link Error: {e}"))?; + prompt_client_options(&mut client_cfg); let unified = UnifiedConfig { mode: AppMode::Client(client_cfg), log_level: Some("info".to_string()), @@ -1202,53 +1268,7 @@ async fn run_app() -> Result<()> { println!("{} Connecting via share link...", "[ostp]".cyan().bold()); let mut client_cfg = parse_ostp_link(&url) .map_err(|e| anyhow!("Share Link Error: {e}"))?; - - // Interactive prompt for URL launch - use std::io::Write; - - print!("{} Enable TUN (VPN) mode? [y/N]: ", "?".blue().bold()); - std::io::stdout().flush().unwrap(); - let mut input = String::new(); - std::io::stdin().read_line(&mut input).unwrap(); - if input.trim().eq_ignore_ascii_case("y") { - if let Some(tun) = &mut client_cfg.tun { - tun.enable = true; - } - } - - print!("{} Enable connection multiplexing (mux)? [y/N]: ", "?".blue().bold()); - std::io::stdout().flush().unwrap(); - input.clear(); - std::io::stdin().read_line(&mut input).unwrap(); - if input.trim().eq_ignore_ascii_case("y") { - print!("How many sessions? [5]: "); - std::io::stdout().flush().unwrap(); - input.clear(); - std::io::stdin().read_line(&mut input).unwrap(); - let mut sessions = 5; - if !input.trim().is_empty() { - if let Ok(s) = input.trim().parse() { - sessions = s; - } - } - if client_cfg.mux.is_none() { - client_cfg.mux = Some(MuxConfig { - enabled: Some(true), - sessions: Some(sessions), - }); - } else if let Some(mux) = &mut client_cfg.mux { - mux.enabled = Some(true); - mux.sessions = Some(sessions); - } - } - - print!("Enable debug mode? [y/N]: "); - std::io::stdout().flush().unwrap(); - input.clear(); - std::io::stdin().read_line(&mut input).unwrap(); - if input.trim().eq_ignore_ascii_case("y") { - client_cfg.debug = Some(true); - } + prompt_client_options(&mut client_cfg); return run_client_directly(client_cfg).await; } @@ -1675,6 +1695,66 @@ fn cmd_update(_branch: String, _version: Option) -> Result<()> { anyhow::bail!("The 'update' command is only supported on Linux/Unix systems."); } +/// The ONLY place config migration ever runs — see ostp_client::migrate for +/// why (and for the actual field-by-field mapping). Never called +/// automatically; only this explicit command touches an existing config's +/// shape. +fn cmd_migrate(config_path: &std::path::Path) -> Result<()> { + if !config_path.exists() { + anyhow::bail!("Configuration file not found at {:?}", config_path); + } + + let raw_content = fs::read_to_string(config_path)?; + let mut stripped = json_comments::StripComments::new(raw_content.as_bytes()); + let mut content_str = String::new(); + { + use std::io::Read; + stripped.read_to_string(&mut content_str)?; + } + let parsed: serde_json::Value = serde_json::from_str(&content_str) + .map_err(|e| anyhow!("Failed to parse {:?} as JSON: {}", config_path, e))?; + + let kind = ostp_client::migrate::detect_kind(&parsed) + .ok_or_else(|| anyhow!("Could not determine whether {:?} is a client, server, or relay config.", config_path))?; + + let (migrated, report) = match kind { + ostp_client::migrate::ConfigKind::Client => { + let (mut v, r) = ostp_client::migrate::migrate_client_json(parsed); + if v.get("mode").is_none() { v["mode"] = serde_json::json!("client"); } + (v, r) + } + ostp_client::migrate::ConfigKind::Server => { + let (mut v, r) = ostp_client::migrate::migrate_server_json(parsed); + if v.get("mode").is_none() { v["mode"] = serde_json::json!("server"); } + (v, r) + } + ostp_client::migrate::ConfigKind::Relay => { + // The relay shape hasn't changed since it was introduced — nothing to migrate yet. + (parsed, ostp_client::migrate::MigrationReport::default()) + } + }; + + if !report.changed { + println!("{} Config is already up to date, nothing to migrate.", "[ostp]".green().bold()); + return Ok(()); + } + + let backup_path = config_path.with_extension("json.bak"); + fs::copy(config_path, &backup_path)?; + println!("{} Original config backed up to {:?}", "[ostp]".cyan().bold(), backup_path); + + let new_content = serde_json::to_string_pretty(&migrated)?; + fs::write(config_path, new_content)?; + + println!("{} Migrated {:?} — changes made:", "[ostp]".green().bold(), config_path); + for note in &report.notes { + println!(" - {note}"); + } + println!("\n{} Run 'ostp check' to validate the migrated config.", "[ostp]".cyan().bold()); + + Ok(()) +} + #[cfg(target_os = "windows")] fn ensure_elevated_for_tun() -> Result<()> { #[link(name = "shell32")] diff --git a/scripts/install.sh b/scripts/install.sh index b244a2d..b49148b 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -178,52 +178,15 @@ if [ -f "$CONFIG_FILE" ]; then echo "Existing configuration found at $CONFIG_FILE." echo "Binary updated to ${LATEST_RELEASE:-latest}." - # ── Config migration: add new fields, preserve existing values ── - echo "Checking for new config fields..." - python3 << 'PYEOF' -import json, sys - -CONFIG = '/etc/ostp/config.json' - -with open(CONFIG) as f: - raw = f.read() -lines = [l for l in raw.split('\n') if not l.strip().startswith('//')] -cfg = json.loads('\n'.join(lines)) - -changed = False - -# Ensure api section has all modern fields -if cfg.get('mode') == 'server': - if 'api' not in cfg: - cfg['api'] = {} - changed = True - - api_defaults = { - 'enabled': False, - 'bind': '0.0.0.0:9090', - 'webpath': '', - 'username': '', - 'password_hash': '', - } - for k, v in api_defaults.items(): - if k not in cfg['api']: - cfg['api'][k] = v - changed = True - print(f'[migration] Added api.{k} = {json.dumps(v)}') - - # Remove legacy "token" field if present - if 'token' in cfg['api']: - del cfg['api']['token'] - changed = True - print('[migration] Removed legacy api.token field') - -if changed: - with open(CONFIG, 'w') as f: - json.dump(cfg, f, indent=2, ensure_ascii=False) - print('[ok] Config migrated: new fields added, existing data preserved.') -else: - print('[ok] Config is up to date, no migration needed.') -PYEOF + # Config SCHEMA migration does NOT happen here (or anywhere automatic) — + # it used to be an ad-hoc Python snippet embedded right in this script, + # silently rewriting config.json on every update. That's exactly the kind + # of surprise this project no longer does: the ONE place a config's shape + # is ever changed is the explicit `ostp migrate` command (see + # ostp-client::migrate), which backs up the original file first and + # prints exactly what it changed. If your config predates this install, + # run it yourself: + echo "If this config is from an older OSTP version, run 'ostp migrate' to upgrade it." # Update systemd service to use new paths if [ -f "/etc/systemd/system/ostp.service" ]; then