feat(client): one authoritative config migrator, manual-only

- 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 <url>` now asks the same TUN/mux/debug questions
  `ostp connect <url>` 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.
This commit is contained in:
ospab 2026-07-08 18:45:04 +03:00
parent 7b43e1dcf7
commit 26665a826f
5 changed files with 669 additions and 108 deletions

View File

@ -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;

View File

@ -71,9 +71,28 @@ pub fn init_tracing(level: &str, app_name: &str, version: &str) -> Option<tracin
.and_then(|p| p.parent().map(|d| d.join(format!("{}.log", app_name))))
.unwrap_or_else(|| PathBuf::from(format!("{}.log", app_name)));
if let Ok(file) = OpenOptions::new().create(true).append(true).open(&path) {
if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(&path) {
// Write the startup banner directly to the log file, bypassing the
// tracing subscriber entirely. Emitting it via tracing::info!() hits
// BOTH layers below (file AND stderr), so every one-shot CLI command
// (`ostp -V`, `ostp gk`, `ostp check`, ...) printed this banner to the
// terminal on every single invocation — pure noise for anything that
// isn't the long-running daemon. It's still genuinely useful for
// whoever's reading the log file later, so keep it there, just not on
// screen for commands that aren't the daemon.
let _ = writeln!(
file,
"{} v{} | OS: {} | Arch: {} | log_level: {} | log_file: {}",
app_name,
version,
std::env::consts::OS,
std::env::consts::ARCH,
level,
path.display(),
);
let (file_writer, guard) = tracing_appender::non_blocking(file);
let fmt_layer = tracing_subscriber::fmt::layer()
.with_target(true)
.with_line_number(true)
@ -81,7 +100,7 @@ pub fn init_tracing(level: &str, app_name: &str, version: &str) -> Option<tracin
.with_thread_names(false)
.with_ansi(false)
.with_writer(file_writer);
let stderr_layer = tracing_subscriber::fmt::layer()
.with_target(true)
.with_writer(std::io::stderr);
@ -91,17 +110,7 @@ pub fn init_tracing(level: &str, app_name: &str, version: &str) -> Option<tracin
.with(fmt_layer)
.with(stderr_layer)
.try_init();
tracing::info!(
"{} v{} | OS: {} | Arch: {} | log_level: {} | log_file: {}",
app_name,
version,
std::env::consts::OS,
std::env::consts::ARCH,
level,
path.display(),
);
Some(guard)
} else {
// Fallback: stderr only

508
ostp-client/src/migrate.rs Normal file
View File

@ -0,0 +1,508 @@
//! The ONE authoritative place that upgrades an old `config.json` to the
//! current schema. Reachable only via the explicit `ostp migrate` command —
//! nothing else in this codebase silently rewrites a user's config on their
//! behalf (the old 0.3.x line used to auto-migrate on every load with just a
//! log warning; that's exactly the kind of "invisible until something looks
//! wrong" behavior this module replaces).
//!
//! Every field this module cannot map forward is reported explicitly in
//! `MigrationReport.notes`, never silently dropped without a trace.
use serde_json::{json, Value};
#[derive(Debug, Default)]
pub struct MigrationReport {
/// Whether anything was actually different from the current schema.
pub changed: bool,
/// Human-readable line per field added, converted, or dropped.
pub notes: Vec<String>,
}
impl MigrationReport {
fn note(&mut self, msg: impl Into<String>) {
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<ConfigKind> {
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.1v0.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<String> = 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<String> = Vec::new();
let mut ex_ips: Vec<String> = Vec::new();
let mut ex_processes: Vec<String> = 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));
}
}

View File

@ -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<String>,
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 <url>`) or
/// importing it to disk (`ostp import <url>`). 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<ClientConfig> {
@ -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<String>) -> 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")]

View File

@ -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