mirror of https://github.com/ospab/ostp.git
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:
parent
7b43e1dcf7
commit
26665a826f
|
|
@ -1,6 +1,7 @@
|
||||||
pub mod app;
|
pub mod app;
|
||||||
pub mod bridge;
|
pub mod bridge;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod migrate;
|
||||||
pub mod signal;
|
pub mod signal;
|
||||||
pub mod sysproxy;
|
pub mod sysproxy;
|
||||||
pub mod transport;
|
pub mod transport;
|
||||||
|
|
|
||||||
|
|
@ -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))))
|
.and_then(|p| p.parent().map(|d| d.join(format!("{}.log", app_name))))
|
||||||
.unwrap_or_else(|| PathBuf::from(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 (file_writer, guard) = tracing_appender::non_blocking(file);
|
||||||
|
|
||||||
let fmt_layer = tracing_subscriber::fmt::layer()
|
let fmt_layer = tracing_subscriber::fmt::layer()
|
||||||
.with_target(true)
|
.with_target(true)
|
||||||
.with_line_number(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_thread_names(false)
|
||||||
.with_ansi(false)
|
.with_ansi(false)
|
||||||
.with_writer(file_writer);
|
.with_writer(file_writer);
|
||||||
|
|
||||||
let stderr_layer = tracing_subscriber::fmt::layer()
|
let stderr_layer = tracing_subscriber::fmt::layer()
|
||||||
.with_target(true)
|
.with_target(true)
|
||||||
.with_writer(std::io::stderr);
|
.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(fmt_layer)
|
||||||
.with(stderr_layer)
|
.with(stderr_layer)
|
||||||
.try_init();
|
.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)
|
Some(guard)
|
||||||
} else {
|
} else {
|
||||||
// Fallback: stderr only
|
// Fallback: stderr only
|
||||||
|
|
|
||||||
|
|
@ -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.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<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));
|
||||||
|
}
|
||||||
|
}
|
||||||
176
ostp/src/main.rs
176
ostp/src/main.rs
|
|
@ -71,6 +71,11 @@ enum Commands {
|
||||||
ProxyEnv,
|
ProxyEnv,
|
||||||
/// Output shell export commands to clear proxy (eval $(ostp proxy-env-clear))
|
/// Output shell export commands to clear proxy (eval $(ostp proxy-env-clear))
|
||||||
ProxyEnvClear,
|
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
|
/// Bridges the new subcommand-based CLI onto the original flat-flag dispatch
|
||||||
|
|
@ -93,6 +98,60 @@ struct LegacyArgs {
|
||||||
import: Option<String>,
|
import: Option<String>,
|
||||||
proxy_env: bool,
|
proxy_env: bool,
|
||||||
proxy_env_clear: 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> {
|
fn parse_ostp_link(link: &str) -> Result<ClientConfig> {
|
||||||
|
|
@ -1070,6 +1129,7 @@ async fn run_app() -> Result<()> {
|
||||||
import: None,
|
import: None,
|
||||||
proxy_env: false,
|
proxy_env: false,
|
||||||
proxy_env_clear: false,
|
proxy_env_clear: false,
|
||||||
|
migrate: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(cmd) = raw_args.command {
|
if let Some(cmd) = raw_args.command {
|
||||||
|
|
@ -1085,6 +1145,7 @@ async fn run_app() -> Result<()> {
|
||||||
Commands::Import { url } => { args.import = Some(url); }
|
Commands::Import { url } => { args.import = Some(url); }
|
||||||
Commands::ProxyEnv => { args.proxy_env = true; }
|
Commands::ProxyEnv => { args.proxy_env = true; }
|
||||||
Commands::ProxyEnvClear => { args.proxy_env_clear = 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);
|
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) ────────
|
// ── Setup wizard: explicit flag or first-time (no config) ────────
|
||||||
if args.setup {
|
if args.setup {
|
||||||
return run_setup_wizard(&args.config);
|
return run_setup_wizard(&args.config);
|
||||||
|
|
@ -1181,8 +1246,9 @@ async fn run_app() -> Result<()> {
|
||||||
|
|
||||||
if let Some(import_url) = args.import {
|
if let Some(import_url) = args.import {
|
||||||
println!("{} Importing configuration from share link...", "[ostp]".cyan().bold());
|
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}"))?;
|
.map_err(|e| anyhow!("Share Link Error: {e}"))?;
|
||||||
|
prompt_client_options(&mut client_cfg);
|
||||||
let unified = UnifiedConfig {
|
let unified = UnifiedConfig {
|
||||||
mode: AppMode::Client(client_cfg),
|
mode: AppMode::Client(client_cfg),
|
||||||
log_level: Some("info".to_string()),
|
log_level: Some("info".to_string()),
|
||||||
|
|
@ -1202,53 +1268,7 @@ async fn run_app() -> Result<()> {
|
||||||
println!("{} Connecting via share link...", "[ostp]".cyan().bold());
|
println!("{} Connecting via share link...", "[ostp]".cyan().bold());
|
||||||
let mut client_cfg = parse_ostp_link(&url)
|
let mut client_cfg = parse_ostp_link(&url)
|
||||||
.map_err(|e| anyhow!("Share Link Error: {e}"))?;
|
.map_err(|e| anyhow!("Share Link Error: {e}"))?;
|
||||||
|
prompt_client_options(&mut client_cfg);
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
return run_client_directly(client_cfg).await;
|
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.");
|
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")]
|
#[cfg(target_os = "windows")]
|
||||||
fn ensure_elevated_for_tun() -> Result<()> {
|
fn ensure_elevated_for_tun() -> Result<()> {
|
||||||
#[link(name = "shell32")]
|
#[link(name = "shell32")]
|
||||||
|
|
|
||||||
|
|
@ -178,52 +178,15 @@ if [ -f "$CONFIG_FILE" ]; then
|
||||||
echo "Existing configuration found at $CONFIG_FILE."
|
echo "Existing configuration found at $CONFIG_FILE."
|
||||||
echo "Binary updated to ${LATEST_RELEASE:-latest}."
|
echo "Binary updated to ${LATEST_RELEASE:-latest}."
|
||||||
|
|
||||||
# ── Config migration: add new fields, preserve existing values ──
|
# Config SCHEMA migration does NOT happen here (or anywhere automatic) —
|
||||||
echo "Checking for new config fields..."
|
# it used to be an ad-hoc Python snippet embedded right in this script,
|
||||||
python3 << 'PYEOF'
|
# silently rewriting config.json on every update. That's exactly the kind
|
||||||
import json, sys
|
# of surprise this project no longer does: the ONE place a config's shape
|
||||||
|
# is ever changed is the explicit `ostp migrate` command (see
|
||||||
CONFIG = '/etc/ostp/config.json'
|
# ostp-client::migrate), which backs up the original file first and
|
||||||
|
# prints exactly what it changed. If your config predates this install,
|
||||||
with open(CONFIG) as f:
|
# run it yourself:
|
||||||
raw = f.read()
|
echo "If this config is from an older OSTP version, run 'ostp migrate' to upgrade it."
|
||||||
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
|
|
||||||
|
|
||||||
# Update systemd service to use new paths
|
# Update systemd service to use new paths
|
||||||
if [ -f "/etc/systemd/system/ostp.service" ]; then
|
if [ -f "/etc/systemd/system/ostp.service" ]; then
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue