Fix CLI setup permissions, enforce global debug tracing, and fix GUI silent startup crash

This commit is contained in:
ospab 2026-06-13 01:25:54 +03:00
parent fbf13b86f3
commit a9e4511190
35 changed files with 1221 additions and 1477 deletions

View File

@ -68,9 +68,6 @@ pub struct Bridge {
pub stealth_sni: String,
pub wss: bool,
pub mtu: usize,
pub reality_enabled: bool,
pub reality_pbk: String,
pub reality_sid: String,
pub kill_switch: bool,
pub reload_tx: Option<watch::Sender<crate::config::ExclusionConfig>>,
@ -104,9 +101,6 @@ impl Bridge {
stealth_sni: config.transport.stealth_sni.clone(),
wss: config.transport.wss,
mtu: config.ostp.mtu,
reality_enabled: config.reality.enabled,
reality_pbk: config.reality.pbk.clone(),
reality_sid: config.reality.sid.clone(),
kill_switch: config.kill_switch,
reload_tx: None,
@ -862,31 +856,15 @@ impl Bridge {
let secrets = ostp_core::crypto::derive_all_secrets(&self.access_key);
let mut machine = ProtocolMachine::new(ProtocolConfig {
role: NoiseRole::Initiator,
psk: secrets.psk,
session_id,
handshake_payload,
// max_padding computed dynamically below from mtu
padding_strategy: PaddingStrategy::Profile(self.profile),
obfuscation_key: secrets.obfuscation_key,
max_reorder: 16384, // Max gap between expected and received nonce
max_reorder_buffer: 8192, // Max buffered out-of-order frames
ack_delay_ms: 5,
rto_ms: 100,
max_retries: 8,
max_sent_history: 32768, // Reduced: gap recovery handles unrecoverable frames
handshake_pad_min: secrets.handshake_pad_min,
handshake_pad_max: secrets.handshake_pad_max,
mtu: self.mtu,
max_padding: self.mtu.saturating_sub(48).max(256), // leave room for UDP/IP/ostp headers
})?;
let resolved_addrs: Vec<std::net::SocketAddr> = match tokio::net::lookup_host(&self.server_addr).await {
let mut resolved_addrs: Vec<std::net::SocketAddr> = match tokio::net::lookup_host(&self.server_addr).await {
Ok(addrs) => addrs.collect(),
Err(e) => return Err(anyhow::anyhow!("failed to resolve server address {}: {}", self.server_addr, e)),
};
let target_addr = resolved_addrs.first().ok_or_else(|| anyhow::anyhow!("no IP addresses resolved for {}", self.server_addr))?;
resolved_addrs.sort_by_key(|addr| if addr.is_ipv6() { 0 } else { 1 });
let mut last_err = anyhow::anyhow!("no IP addresses resolved for {}", self.server_addr);
for target_addr in resolved_addrs {
let target_ip = target_addr.ip();
let port = target_addr.port();
@ -897,47 +875,71 @@ impl Bridge {
Err(e) => {
if let std::net::IpAddr::V4(ipv4) = target_ip {
tx.send(UiEvent::Log(format!("Direct IPv4 connection failed: {}. Trying NAT64 fallback...", e))).await.ok();
let nat64_ipv6 = synthesize_nat64(ipv4);
let nat64_ipv6 = synthesize_nat64(ipv4).await;
match self.try_connect_transport(std::net::IpAddr::V6(nat64_ipv6), port).await {
Ok(sock) => sock,
Err(fallback_err) => {
return Err(anyhow::anyhow!("Direct IPv4 failed: {}. NAT64 fallback failed: {}", e, fallback_err));
last_err = anyhow::anyhow!("Direct IPv4 failed: {}. NAT64 fallback failed: {}", e, fallback_err);
continue;
}
}
} else {
return Err(e);
last_err = anyhow::anyhow!("Connection to {} failed: {}", target_addr, e);
continue;
}
}
};
// Connection to remote is handled inside try_connect_transport
let mut machine = ProtocolMachine::new(ProtocolConfig {
role: NoiseRole::Initiator,
psk: secrets.psk,
session_id,
handshake_payload: handshake_payload.clone(),
padding_strategy: PaddingStrategy::Profile(self.profile),
obfuscation_key: secrets.obfuscation_key,
max_reorder: 16384,
max_reorder_buffer: 8192,
ack_delay_ms: 5,
rto_ms: 100,
max_retries: 8,
max_sent_history: 32768,
handshake_pad_min: secrets.handshake_pad_min,
handshake_pad_max: secrets.handshake_pad_max,
mtu: self.mtu,
max_padding: self.mtu.saturating_sub(48).max(256),
})?;
let start = Instant::now();
let action = machine.on_event(OstpEvent::Start)?;
let action = match machine.on_event(OstpEvent::Start) {
Ok(a) => a,
Err(e) => {
last_err = anyhow::anyhow!("protocol start error: {}", e);
continue;
}
};
let handshake_frame = match action {
ProtocolAction::SendDatagram(frame) => frame,
_ => anyhow::bail!("protocol did not emit handshake datagram"),
_ => {
last_err = anyhow::anyhow!("protocol did not emit handshake datagram");
continue;
}
};
let mut buf = vec![0_u8; 4096];
let mut size = 0;
let mut success = false;
// For UoT: TCP is reliable so we don't retry on the same connection.
// Multiple retries would cause stale Noise responses to queue in the mpsc channel
// and break the Noise state machine (noise-read error).
// For UDP: retry up to 4x with 1200ms timeout to survive packet loss.
let is_uot = matches!(socket, crate::transport::Transport::Uot { .. });
// UoT (TCP): 1 attempt only — retrying on TCP causes stale Noise frames to queue.
// Timeout is generous (8s) to accommodate slow mobile TCP+TLS setup.
// UDP: 4 attempts × 1200ms — survives individual packet loss.
let (attempt_limit, attempt_timeout_ms) = if is_uot { (1, 8000) } else { (4, 1200) };
for attempt in 0..attempt_limit {
if attempt > 0 {
tx.send(UiEvent::Log(format!("Handshake attempt {} lost. Retransmitting...", attempt))).await.ok();
}
send_datagram(&socket, &handshake_frame, self.transport_mode == "udp" ).await?;
if send_datagram(&socket, &handshake_frame, self.transport_mode == "udp").await.is_ok() {
self.metrics.bytes_sent.fetch_add(handshake_frame.len() as u64, Ordering::Relaxed);
}
match timeout(Duration::from_millis(attempt_timeout_ms), socket.recv(&mut buf)).await {
Ok(Ok(n)) => {
@ -945,7 +947,7 @@ impl Bridge {
success = true;
break;
}
_ => {} // retry on timeout or error
_ => {}
}
}
@ -954,7 +956,7 @@ impl Bridge {
} else {
if let std::net::IpAddr::V4(ipv4) = target_ip {
tx.send(UiEvent::Log("Direct IPv4 handshake timed out. Trying NAT64 fallback...".to_string())).await.ok();
let nat64_ipv6 = synthesize_nat64(ipv4);
let nat64_ipv6 = synthesize_nat64(ipv4).await;
match self.try_connect_transport(std::net::IpAddr::V6(nat64_ipv6), port).await {
Ok(fallback_socket) => {
let mut fallback_success = false;
@ -962,7 +964,9 @@ impl Bridge {
if attempt > 0 {
tx.send(UiEvent::Log(format!("NAT64 handshake attempt {} lost. Retransmitting...", attempt))).await.ok();
}
send_datagram(&fallback_socket, &handshake_frame, self.transport_mode == "udp" ).await?;
if send_datagram(&fallback_socket, &handshake_frame, self.transport_mode == "udp").await.is_ok() {
self.metrics.bytes_sent.fetch_add(handshake_frame.len() as u64, Ordering::Relaxed);
}
match timeout(Duration::from_millis(1200), fallback_socket.recv(&mut buf)).await {
Ok(Ok(n)) => {
size = n;
@ -976,25 +980,37 @@ impl Bridge {
tx.send(UiEvent::Log("NAT64 fallback handshake successful!".to_string())).await.ok();
(fallback_socket, size)
} else {
return Err(anyhow::anyhow!("NAT64 handshake failed after 3 attempts"));
last_err = anyhow::anyhow!("NAT64 handshake failed after 4 attempts");
continue;
}
}
Err(e) => return Err(anyhow::anyhow!("NAT64 fallback socket creation failed: {}", e)),
Err(e) => {
last_err = anyhow::anyhow!("NAT64 fallback socket creation failed: {}", e);
continue;
}
}
} else {
return Err(anyhow::anyhow!("Direct handshake failed after 3 attempts"));
last_err = anyhow::anyhow!("Direct handshake failed after attempts");
continue;
}
};
let socket = final_socket;
self.metrics.bytes_recv.fetch_add(size as u64, Ordering::Relaxed);
tracing::info!("Handshake response received: {} bytes", size);
let inbound = Bytes::copy_from_slice(&buf[..size]);
machine.on_event(OstpEvent::Inbound(inbound))?;
if let Err(e) = machine.on_event(OstpEvent::Inbound(inbound)) {
last_err = anyhow::anyhow!("Protocol invalid response: {}", e);
continue;
}
let rtt_ms = start.elapsed().as_secs_f64() * 1000.0;
tracing::info!("Handshake complete: session={:#010x} rtt={:.1}ms", session_id, rtt_ms);
Ok((socket, machine, rtt_ms))
return Ok((socket, machine, rtt_ms));
}
Err(last_err)
}
fn apply_runtime_config(&mut self, cfg: &ClientConfig) {
@ -1010,9 +1026,6 @@ impl Bridge {
self.transport_mode = cfg.transport.mode.clone();
self.stealth_sni = cfg.transport.stealth_sni.clone();
self.wss = cfg.transport.wss; // Fix: wss was not updated on hot-reload
self.reality_enabled = cfg.reality.enabled;
self.reality_pbk = cfg.reality.pbk.clone();
self.reality_sid = cfg.reality.sid.clone();
self.mtu = cfg.ostp.mtu;
self.keepalive_interval_sec = cfg.ostp.keepalive_interval_sec;
self.kill_switch = cfg.kill_switch;
@ -1025,10 +1038,39 @@ impl Bridge {
) -> Result<crate::transport::Transport> {
let mode = self.transport_mode.to_lowercase();
if mode == "uot" || mode == "tcp" {
let (tx, rx) = crate::transport::xhttp::connect_xhttp(
target_ip, port, &self.stealth_sni, &self.access_key, self.reality_enabled, self.wss, &self.reality_pbk, &self.reality_sid
).await?;
Ok(crate::transport::Transport::Uot { tx, rx })
let stream = tokio::net::TcpStream::connect((target_ip, port)).await?;
let _ = stream.set_nodelay(true);
let (mut read_half, mut write_half) = stream.into_split();
let (tx_out, mut rx_out) = tokio::sync::mpsc::channel::<bytes::Bytes>(1024);
let (tx_in, rx_in) = tokio::sync::mpsc::channel::<bytes::Bytes>(1024);
// Task to write from rx_out to tcp stream
tokio::spawn(async move {
use tokio::io::AsyncWriteExt;
while let Some(data) = rx_out.recv().await {
let mut len_buf = [0u8; 2];
len_buf.copy_from_slice(&(data.len() as u16).to_be_bytes());
if write_half.write_all(&len_buf).await.is_err() { break; }
if write_half.write_all(&data).await.is_err() { break; }
}
});
// Task to read from tcp stream to tx_in
let tx_in_clone = tx_in.clone();
tokio::spawn(async move {
use tokio::io::AsyncReadExt;
loop {
let mut len_buf = [0u8; 2];
if read_half.read_exact(&mut len_buf).await.is_err() { break; }
let len = u16::from_be_bytes(len_buf) as usize;
let mut data = vec![0u8; len];
if read_half.read_exact(&mut data).await.is_err() { break; }
if tx_in_clone.send(bytes::Bytes::from(data)).await.is_err() { break; }
}
});
Ok(crate::transport::Transport::Uot { tx: tx_out, rx: std::sync::Arc::new(tokio::sync::Mutex::new(rx_in)) })
} else {
let is_ipv6 = target_ip.is_ipv6();
let domain = if is_ipv6 { socket2::Domain::IPV6 } else { socket2::Domain::IPV4 };
@ -1068,10 +1110,25 @@ fn next_profile(current: TrafficProfile) -> TrafficProfile {
}
}
fn synthesize_nat64(ip: std::net::Ipv4Addr) -> std::net::Ipv6Addr {
async fn synthesize_nat64(ip: std::net::Ipv4Addr) -> std::net::Ipv6Addr {
let mut prefix = [0x00, 0x64, 0xff, 0x9b, 0, 0, 0, 0, 0, 0, 0, 0];
if let Ok(addrs) = tokio::net::lookup_host("ipv4only.arpa:80").await {
for addr in addrs {
if let std::net::SocketAddr::V6(v6) = addr {
let octets = v6.ip().octets();
prefix.copy_from_slice(&octets[0..12]);
break;
}
}
}
let octets = ip.octets();
std::net::Ipv6Addr::new(
0x0064, 0xff9b, 0, 0, 0, 0,
((prefix[0] as u16) << 8) | prefix[1] as u16,
((prefix[2] as u16) << 8) | prefix[3] as u16,
((prefix[4] as u16) << 8) | prefix[5] as u16,
((prefix[6] as u16) << 8) | prefix[7] as u16,
((prefix[8] as u16) << 8) | prefix[9] as u16,
((prefix[10] as u16) << 8) | prefix[11] as u16,
((octets[0] as u16) << 8) | octets[1] as u16,
((octets[2] as u16) << 8) | octets[3] as u16,
)

View File

@ -12,7 +12,6 @@ pub struct ClientConfig {
pub debug: bool,
pub ostp: OstpConfig,
pub local_proxy: LocalProxyConfig,
pub reality: RealityConfig,
#[serde(default)]
pub transport: TransportConfig,
#[serde(default)]
@ -98,21 +97,7 @@ impl Default for TransportConfig {
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct RealityConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub sni: String,
#[serde(default)]
pub fp: String,
#[serde(default)]
pub pbk: String,
#[serde(default)]
pub sid: String,
#[serde(default)]
pub spx: String,
}
impl Default for OstpConfig {
@ -146,7 +131,6 @@ impl Default for ClientConfig {
debug: false,
ostp: OstpConfig::default(),
local_proxy: LocalProxyConfig::default(),
reality: RealityConfig::default(),
transport: TransportConfig::default(),
exclusions: ExclusionConfig::default(),
multiplex: MultiplexConfig::default(),
@ -181,7 +165,6 @@ struct RawUnifiedConfig {
tun: Option<RawTunSection>,
exclude: Option<RawExcludeSection>,
mux: Option<RawMuxSection>,
reality: Option<RawRealitySection>,
transport: Option<RawTransportSection>,
gui: Option<serde_json::Value>,
}
@ -214,15 +197,7 @@ struct RawMuxSection {
sessions: Option<usize>,
}
#[derive(Debug, Deserialize)]
struct RawRealitySection {
enabled: Option<bool>,
sni: Option<String>,
fp: Option<String>,
pbk: Option<String>,
sid: Option<String>,
spx: Option<String>,
}
impl ClientConfig {
/// Hot-reload from `config.json` placed next to the running binary.
@ -269,14 +244,6 @@ impl ClientConfig {
bind_addr: socks5,
connect_timeout_ms: 15000,
},
reality: RealityConfig {
enabled: raw.reality.as_ref().and_then(|t| t.enabled).unwrap_or(false),
sni: raw.reality.as_ref().and_then(|t| t.sni.clone()).unwrap_or_default(),
fp: raw.reality.as_ref().and_then(|t| t.fp.clone()).unwrap_or_default(),
pbk: raw.reality.as_ref().and_then(|t| t.pbk.clone()).unwrap_or_default(),
sid: raw.reality.as_ref().and_then(|t| t.sid.clone()).unwrap_or_default(),
spx: raw.reality.as_ref().and_then(|t| t.spx.clone()).unwrap_or_default(),
},
transport: TransportConfig {
mode: raw.transport.as_ref().and_then(|t| t.mode.clone()).unwrap_or_else(default_transport_mode),
stealth_sni: raw.transport.as_ref().and_then(|t| t.stealth_sni.clone()).unwrap_or_default(),

View File

@ -27,6 +27,7 @@ pub fn setup_panic_hook() {
);
eprintln!("{}", crash_msg);
tracing::error!("{}", crash_msg);
let path = std::env::current_exe()
.ok()
@ -40,27 +41,49 @@ pub fn setup_panic_hook() {
}));
}
/// Initialises tracing and writes to `<app_name>.log` next to the executable.
///
/// The `level` parameter controls the minimum log level:
/// - `"error"` — only errors
/// - `"warn"` — warnings and errors
/// - `"info"` — informational messages (default)
/// - `"debug"` — detailed debug messages (use when `debug: true` in config)
/// - `"trace"` — all messages including very verbose internal state
///
/// The environment variable `RUST_LOG` overrides this value if set.
pub fn init_tracing(level: &str, app_name: &str, version: &str) -> Option<tracing_appender::non_blocking::WorkerGuard> {
// RUST_LOG overrides the config-derived level
let env_filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new(level));
.unwrap_or_else(|_| {
// When debug or trace is requested, enable for all ostp crates
if level == "debug" || level == "trace" {
// Enable the requested level for ostp crates, but keep noisy deps at warn
EnvFilter::new(format!(
"warn,ostp_client={level},ostp_core={level},ostp_jni={level},ostp_gui_lib={level}"
))
} else {
EnvFilter::new(level)
}
});
let path = std::env::current_exe()
.ok()
.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(file) = OpenOptions::new().create(true).append(true).open(&path) {
let (file_writer, guard) = tracing_appender::non_blocking(file);
let fmt_layer = tracing_subscriber::fmt::layer()
.with_target(true)
.with_line_number(true)
.with_thread_ids(false)
.with_thread_names(false)
.with_ansi(false)
.with_writer(file_writer);
let stderr_layer = tracing_subscriber::fmt::layer()
.with_target(false)
.with_target(true)
.with_writer(std::io::stderr);
let _ = tracing_subscriber::registry()
@ -70,15 +93,26 @@ pub fn init_tracing(level: &str, app_name: &str, version: &str) -> Option<tracin
.try_init();
tracing::info!(
"{} v{} | OS: {} | Arch: {}",
"{} v{} | OS: {} | Arch: {} | log_level: {} | log_file: {}",
app_name,
version,
std::env::consts::OS,
std::env::consts::ARCH
std::env::consts::ARCH,
level,
path.display(),
);
Some(guard)
} else {
// Fallback: stderr only
let stderr_layer = tracing_subscriber::fmt::layer()
.with_target(true)
.with_writer(std::io::stderr);
let _ = tracing_subscriber::registry()
.with(EnvFilter::new(level))
.with(stderr_layer)
.try_init();
eprintln!("[WARN] Could not open log file at {}. Logging to stderr only.", path.display());
None
}
}

View File

@ -1,4 +1,3 @@
pub mod xhttp;
use std::sync::Arc;
use tokio::net::UdpSocket;

View File

@ -198,8 +198,8 @@ pub async fn run_native_tunnel(
let current = exclusions_rx.borrow().clone();
let new_matcher = crate::tunnel::exclusion::ExclusionMatcher::new(&current, None, None);
*matcher_clone.write().await = new_matcher;
if debug {
tracing::info!("Desktop TUN exclusions hot-reloaded");
if true {
tracing::debug!("Desktop TUN exclusions hot-reloaded");
}
}
});
@ -228,8 +228,8 @@ pub async fn run_native_tunnel(
tokio::spawn(async move {
let matcher = matcher_arc.read().await.clone();
if debug {
tracing::info!("TUN TCP {local} → {remote}");
if true {
tracing::debug!("TUN TCP {local} → {remote}");
}
// ── Sniff TLS ClientHello for SNI ─────────────────────────────
@ -253,12 +253,12 @@ pub async fn run_native_tunnel(
if let Some(sni) =
crate::tunnel::sni_sniff::extract_sni(&sniff_buf[..sniff_len])
{
if debug {
tracing::info!("TUN SNI: {sni}");
if true {
tracing::debug!("TUN SNI: {sni}");
}
if matcher.match_domain(&sni) {
if debug {
tracing::info!("TUN BYPASS (SNI domain): {sni} → {remote}");
if true {
tracing::debug!("TUN BYPASS (SNI domain): {sni} → {remote}");
}
should_bypass = true;
}
@ -267,8 +267,8 @@ pub async fn run_native_tunnel(
// 2. Destination IP CIDR check (for IPs not in routing table / IPv6)
if !should_bypass && matcher.match_ip(&remote.ip()) {
if debug {
tracing::info!("TUN BYPASS (IP match): {remote}");
if true {
tracing::debug!("TUN BYPASS (IP match): {remote}");
}
should_bypass = true;
}
@ -556,8 +556,8 @@ pub async fn run_native_tunnel_from_fd(
let current = exclusions_rx.borrow().clone();
let new_matcher = crate::tunnel::exclusion::ExclusionMatcher::new(&current, None, None);
*matcher_clone.write().await = new_matcher;
if debug {
tracing::info!("Android TUN exclusions hot-reloaded");
if true {
tracing::debug!("Android TUN exclusions hot-reloaded");
}
}
});
@ -572,8 +572,8 @@ pub async fn run_native_tunnel_from_fd(
tokio::spawn(async move {
let matcher = matcher_arc.read().await.clone();
if debug {
tracing::info!("Android TUN TCP {local} → {remote}");
if true {
tracing::debug!("Android TUN TCP {local} → {remote}");
}
// Sniff SNI
@ -596,7 +596,7 @@ pub async fn run_native_tunnel_from_fd(
if let Some(sni) =
crate::tunnel::sni_sniff::extract_sni(&sniff_buf[..sniff_len])
{
if debug { tracing::info!("Android TUN SNI: {sni}"); }
if true { tracing::debug!("Android TUN SNI: {sni}"); }
if matcher.match_domain(&sni) {
should_bypass = true;
}
@ -608,8 +608,8 @@ pub async fn run_native_tunnel_from_fd(
if let Some(exe) =
crate::tunnel::process_lookup::get_process_name_from_port(local.port())
{
if debug {
tracing::info!("Android TUN port {} → EXE: {}", local.port(), exe);
if true {
tracing::debug!("Android TUN port {} → EXE: {}", local.port(), exe);
}
if matcher.match_process(&exe) {
should_bypass = true;
@ -625,8 +625,8 @@ pub async fn run_native_tunnel_from_fd(
// Bypass: connect directly (Android VPN service already protects the socket
// from re-entering the TUN through VpnService.protect())
if should_bypass {
if debug {
tracing::info!("Android TUN BYPASS: {remote}");
if true {
tracing::debug!("Android TUN BYPASS: {remote}");
}
let socket = match remote {
std::net::SocketAddr::V4(_) => tokio::net::TcpSocket::new_v4(),

View File

@ -206,7 +206,7 @@ pub async fn run_local_socks5_proxy(
.await
.with_context(|| format!("failed to bind local HTTP/SOCKS5 proxy at {}", cfg.bind_addr))?;
if debug {
if true {
tracing::info!("local HTTP/SOCKS5 proxy listening at {}", cfg.bind_addr);
tracing::info!("Windows system proxy: set HTTP proxy to {}. tun2socks: SOCKS5 on same address.", cfg.bind_addr);
}
@ -239,7 +239,7 @@ pub async fn run_local_socks5_proxy(
Ok(_) = exclusions_rx.changed() => {
current_exclusions = exclusions_rx.borrow().clone();
matcher = ExclusionMatcher::new(&current_exclusions, physical_if_index, physical_if_name.clone());
if debug {
if true {
tracing::info!("Local proxy exclusions hot-reloaded");
}
}
@ -286,7 +286,7 @@ pub async fn run_local_socks5_proxy(
Some((stream_id, msg)) = client_msgs_rx.recv() => {
if stream_id == 0 {
if let ProxyToClientMsg::Close = msg {
if debug {
if true {
tracing::info!("Resetting all active proxy streams on reconnect");
}
for (_, tx) in active_streams.drain() {
@ -421,8 +421,8 @@ async fn handle_udp_associate(
let target_port = match split_host_port(&target) { Some((_, p)) => p, None => 0 };
// Check if target should bypass the tunnel
if matcher.should_bypass_target(&target_host, target_port, connect_timeout).await {
if debug {
tracing::info!("proxy UDP BYPASS target={}", target);
if true {
tracing::debug!("proxy UDP BYPASS target={}", target);
}
// Resolve target to find if it is IPv4 or IPv6
if let Ok(resolved_addrs) = tokio::net::lookup_host(&target).await {
@ -460,7 +460,7 @@ async fn handle_udp_associate(
if let Some(s) = direct_socket {
if let Err(e) = s.send_to(&payload, target_addr).await {
if debug {
if true {
tracing::warn!("failed to send bypass UDP packet to {}: {}", target_addr, e);
}
}
@ -545,14 +545,14 @@ fn spawn_direct_udp_reader(
packet.extend_from_slice(&target_addr.port().to_be_bytes());
packet.extend_from_slice(&buf[..len]);
if let Err(e) = sock_tx.send_to(&packet, client_addr).await {
if debug {
if true {
tracing::warn!("failed to send direct UDP response to client: {e}");
}
}
}
}
Err(e) => {
if debug {
if true {
tracing::debug!("direct UDP socket read loop exiting: {e}");
}
break;
@ -642,7 +642,7 @@ async fn handle_proxy_client(
};
if is_udp {
if debug { tracing::info!("proxy UDP ASSOCIATE stream_id={stream_id}"); }
if true { tracing::debug!("proxy UDP ASSOCIATE stream_id={stream_id}"); }
let udp_socket = UdpSocket::bind("127.0.0.1:0").await?;
let port = udp_socket.local_addr()?.port();
let mut reply = vec![0x05, 0x00, 0x00, 0x01, 127, 0, 0, 1];
@ -663,7 +663,7 @@ async fn handle_proxy_client(
).await;
}
if debug {
if true {
tracing::info!("proxy CONNECT stream_id={stream_id} target={target}");
}
let target_host = if let Some((host, _)) = split_host_port(&target) { host } else { target.clone() };
@ -750,7 +750,7 @@ async fn handle_proxy_client(
extract_host_port(raw_uri, default_port)
};
if debug {
if true {
tracing::info!("proxy CONNECT stream_id={stream_id} target={target}");
}
let target_host = if let Some((host, _)) = split_host_port(&target) { host } else { target.clone() };
@ -810,7 +810,7 @@ async fn handle_proxy_client(
match read_res {
Ok(0) => {
let _ = event_tx.send(ProxyEvent::Close { stream_id }).await;
if debug {
if true {
tracing::info!("proxy CLOSE stream_id={stream_id}");
}
break;
@ -828,7 +828,7 @@ async fn handle_proxy_client(
}
Err(_) => {
let _ = event_tx.send(ProxyEvent::Close { stream_id }).await;
if debug {
if true {
tracing::info!("proxy CLOSE stream_id={stream_id}");
}
break;
@ -882,7 +882,7 @@ async fn direct_connect_socks5(
close_tx: mpsc::Sender<u16>,
debug: bool,
) -> Result<()> {
if debug {
if true {
tracing::info!("proxy BYPASS stream_id={stream_id} target={target}");
}
let mut remote = connect_bypassing_tun(target, physical_if_index, physical_if_name).await?;
@ -904,7 +904,7 @@ async fn direct_connect_http(
close_tx: mpsc::Sender<u16>,
debug: bool,
) -> Result<()> {
if debug {
if true {
tracing::info!("proxy BYPASS stream_id={stream_id} target={target}");
}
let mut remote = connect_bypassing_tun(target, physical_if_index, physical_if_name).await?;

View File

@ -17,7 +17,13 @@ pub async fn run_udp_nat(
// map from internal client src to a channel that sends (payload, external_dst)
let mut sessions: HashMap<SocketAddr, mpsc::Sender<(Vec<u8>, SocketAddr)>> = HashMap::new();
while let Some((payload, src, dst)) = rx.next().await {
let mut cleanup_tick = tokio::time::interval(std::time::Duration::from_secs(60));
loop {
tokio::select! {
packet = rx.next() => {
match packet {
Some((payload, src, dst)) => {
if payload.is_empty() { continue; }
if !sessions.contains_key(&src) {
@ -28,10 +34,10 @@ pub async fn run_udp_nat(
let tx_clone = tx.clone();
tokio::spawn(async move {
if debug { tracing::info!("Starting UDP NAT session for {}", src); }
tracing::debug!("Starting UDP NAT session for {}", src);
let res = start_udp_session(src, proxy_addr_clone, &mut session_rx, tx_clone).await;
if debug && res.is_err() {
tracing::info!("UDP NAT session for {} ended: {:?}", src, res.err());
if res.is_err() {
tracing::debug!("UDP NAT session for {} ended: {:?}", src, res.err());
}
});
}
@ -42,6 +48,14 @@ pub async fn run_udp_nat(
}
}
}
None => break,
}
}
_ = cleanup_tick.tick() => {
sessions.retain(|_, sender| !sender.is_closed());
}
}
}
}
async fn start_udp_session(
@ -99,6 +113,15 @@ async fn start_udp_session(
// Local SOCKS5 proxy always returns 127.0.0.1 (IPv4), so always bind IPv4
let udp = UdpSocket::bind("127.0.0.1:0").await?;
// CRITICAL for Android: protect this UDP socket so it goes out via the
// real physical interface, not back into the TUN (which would cause an
// infinite routing loop for DNS and all other UDP traffic).
#[cfg(target_os = "android")]
{
use std::os::unix::io::AsRawFd;
crate::bridge::protect_socket(udp.as_raw_fd());
}
let mut buf = vec![0u8; 65536];
let timeout = std::time::Duration::from_secs(300); // 5 min idle timeout

View File

@ -1,7 +1,7 @@
pub mod aead;
pub mod noise;
pub mod obfuscation;
pub mod reality;
pub use aead::SessionCipher;
pub use noise::{NoiseRole, NoiseSession};

View File

@ -160,6 +160,10 @@ impl ProtocolMachine {
self.cc.cwnd_packets() as usize
}
pub fn on_send(&mut self, bytes: u64) {
self.cc.on_send(bytes);
}
pub fn state(&self) -> OstpState {
self.state
}
@ -677,6 +681,9 @@ impl ProtocolMachine {
}
fn push_sent_frame(&mut self, nonce: u64, bytes: Bytes, is_retransmittable: bool) {
if is_retransmittable {
self.cc.on_send(bytes.len() as u64);
}
self.sent_history.push_back(SentFrame {
nonce,
bytes,

View File

@ -32,6 +32,9 @@
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->

View File

@ -34,6 +34,19 @@ class OstpTileService : TileService() {
val configJson = prefs.getString("latest_config_json", null)
if (configJson != null) {
// Check if VPN consent is needed
val vpnIntent = android.net.VpnService.prepare(this)
if (vpnIntent != null) {
// Consent needed, launch app
val appIntent = packageManager.getLaunchIntentForPackage(packageName)?.apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
}
if (appIntent != null) {
startActivityAndCollapse(appIntent)
}
return
}
val startIntent = Intent(this, OstpVpnService::class.java).apply {
action = "START"
putExtra("configJson", configJson)

View File

@ -43,6 +43,7 @@ class OstpVpnService : VpnService() {
private var vpnInterface: ParcelFileDescriptor? = null
private var wakeLock: PowerManager.WakeLock? = null
private var networkCallback: android.net.ConnectivityManager.NetworkCallback? = null
override fun onCreate() {
super.onCreate()
@ -144,6 +145,41 @@ class OstpVpnService : VpnService() {
}
}
private fun registerNetworkCallback() {
if (networkCallback != null) return
try {
val cm = getSystemService(android.content.Context.CONNECTIVITY_SERVICE) as android.net.ConnectivityManager
networkCallback = object : android.net.ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: android.net.Network) {
super.onAvailable(network)
OstpClientSdk.notifyNetworkChanged()
}
override fun onLost(network: android.net.Network) {
super.onLost(network)
OstpClientSdk.notifyNetworkChanged()
}
}
val request = android.net.NetworkRequest.Builder()
.addCapability(android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
cm.registerNetworkCallback(request, networkCallback!!)
} catch (e: Throwable) {
Log.e("OstpVpnService", "Failed to register NetworkCallback", e)
}
}
private fun unregisterNetworkCallback() {
try {
if (networkCallback != null) {
val cm = getSystemService(android.content.Context.CONNECTIVITY_SERVICE) as android.net.ConnectivityManager
cm.unregisterNetworkCallback(networkCallback!!)
networkCallback = null
}
} catch (e: Throwable) {
Log.e("OstpVpnService", "Failed to unregister NetworkCallback", e)
}
}
private fun startVpn(configJson: String) {
if (vpnInterface != null) return
@ -163,7 +199,12 @@ class OstpVpnService : VpnService() {
.addDnsServer(dnsServer)
.setMtu(Math.max(1280, json.optJSONObject("ostp")?.optInt("mtu", 1140) ?: 1140))
// Always add fallback IPv4 DNS servers
try { builder.addDnsServer("1.1.1.1") } catch (e: Throwable) {}
try { builder.addDnsServer("8.8.8.8") } catch (e: Throwable) {}
// NOTE: Do NOT add IPv6 DNS servers here — Android would send DNS
// queries over IPv6, but our smoltcp TUN stack processes them as
// IPv4 only, causing all DNS to silently fail on LTE (IPv6-only networks).
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
builder.allowBypass()
@ -232,6 +273,8 @@ class OstpVpnService : VpnService() {
Log.e("OstpVpnService", "Error starting VPN", e)
stopVpn()
}
registerNetworkCallback()
}
private fun stopVpn() {
@ -248,6 +291,7 @@ class OstpVpnService : VpnService() {
stopForeground(true)
OstpTileService.requestListeningState(applicationContext)
unregisterNetworkCallback()
stopSelf()
}

View File

@ -46,4 +46,8 @@ object OstpClientSdk {
@Keep
@JvmStatic
external fun addLog(logMsg: String)
@Keep
@JvmStatic
external fun notifyNetworkChanged()
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@ -54,6 +54,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
duration: const Duration(seconds: 4),
);
_checkInitialState();
_startPolling();
}
Future<void> _checkInitialState() async {
@ -413,15 +414,22 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
if (!mounted) return;
setState(() => _uptimeSecs++);
});
_startPollingMetrics();
}
void _startPollingMetrics() {
void _startPolling() {
_pollTimer?.cancel();
_pollTimer = Timer.periodic(const Duration(seconds: 1), (timer) async {
if (!mounted) return;
try {
final isRunning = await platform.invokeMethod('isRunning');
if (isRunning == true && _state == ConnectionStateEnum.disconnected) {
_setConnected();
} else if (isRunning == false && _state == ConnectionStateEnum.connected) {
_setDisconnected();
}
if (_state == ConnectionStateEnum.connected) {
final metricsJson = await platform.invokeMethod('getMetrics');
if (metricsJson != null && metricsJson.isNotEmpty) {
final Map<String, dynamic> parsed = jsonDecode(metricsJson);
@ -430,7 +438,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
final connState = parsed['connection_state'] as int? ?? 2;
final rttMs = parsed['rtt_ms'] as int? ?? 0;
if (connState == 0 && _state != ConnectionStateEnum.disconnected) {
if (connState == 0) {
try {
await platform.invokeMethod('stopTunnel');
} catch (e) {
@ -462,8 +470,9 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
});
}
}
}
} catch (e) {
debugPrint("Failed to get metrics: $e");
debugPrint("Failed to get state/metrics: $e");
}
});
}
@ -507,7 +516,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
_pulseController.value = 0.0;
_spinController.stop();
_uptimeTimer?.cancel();
_pollTimer?.cancel();
// Do NOT cancel _pollTimer, so we keep checking if VPN starts externally!
}
String _formatTime(int s) {
@ -792,7 +801,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
),
const SizedBox(height: 16),
Container(
margin: const EdgeInsets.symmetric(horizontal: 32),
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.03),
@ -802,7 +811,8 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
@ -817,6 +827,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
const SizedBox(height: 4),
Text(
_pingText,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
@ -825,6 +836,8 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
),
],
),
),
const SizedBox(width: 8),
_isCheckingPing
? const SizedBox(
width: 20, height: 20,
@ -876,7 +889,9 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
}
Widget _buildMetricItem(IconData icon, String label, String value, Color color) {
return Row(
return Expanded(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.all(8),
@ -887,11 +902,13 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
child: Icon(icon, size: 20, color: color),
),
const SizedBox(width: 12),
Column(
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label.toUpperCase(),
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
@ -902,6 +919,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
const SizedBox(height: 4),
Text(
value,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 16,
@ -910,8 +928,10 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
),
),
],
),
)
],
),
);
}
}

View File

@ -64,7 +64,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
_debugMode = widget.prefs.getBool('debug_mode') ?? false;
_muxEnabled = widget.prefs.getBool('mux_enabled') ?? false;
_muxSessionsCtrl = TextEditingController(text: widget.prefs.getString('mux_sessions') ?? '2');
}
@override
void dispose() {
@ -104,8 +104,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
widget.prefs.setString('sid', _sidCtrl.text.trim());
widget.prefs.setBool('mux_enabled', _muxEnabled);
widget.prefs.setString('mux_sessions', _muxSessionsCtrl.text.trim());
}
Widget _buildTextField(String label, TextEditingController controller, {String? hint, bool isPassword = false, int maxLines = 1, bool isMono = false}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,

View File

@ -54,7 +54,7 @@ dev_dependencies:
flutter_launcher_icons:
android: "launcher_icon"
ios: false
image_path: "../ostp-gui/src-tauri/icons/icon.png"
image_path: "android_icon.png"
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec

View File

@ -2729,6 +2729,7 @@ dependencies = [
"tauri-build",
"tauri-plugin-opener",
"tokio",
"tracing",
]
[[package]]

View File

@ -24,6 +24,7 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
anyhow = "1"
tracing = "0.1"
ostp-client = { path = "../../ostp-client" }
portable-atomic = "1"
json_comments = "0.2"

View File

@ -1,7 +1,6 @@
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::{watch, Mutex};
use tokio::task::JoinHandle;
use serde::{Deserialize, Serialize};
use anyhow::Result;
use ostp_client::bridge::BridgeMetrics;
@ -30,7 +29,6 @@ struct ClientConfigRaw {
access_key: String,
socks5_bind: Option<String>,
tun: Option<TunConfig>,
reality: Option<RealityConfigRaw>,
transport: Option<TransportConfigRaw>,
debug: Option<bool>,
exclude: Option<ExcludeConfig>,
@ -54,15 +52,6 @@ struct TunConfig {
kill_switch: Option<bool>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
struct RealityConfigRaw {
enabled: Option<bool>,
sni: Option<String>,
fp: Option<String>,
pbk: Option<String>,
sid: Option<String>,
spx: Option<String>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
struct TransportConfigRaw {
@ -170,14 +159,7 @@ fn map_to_client_config(raw: &ClientConfigRaw, mode: &str) -> ostp_client::confi
bind_addr: raw.socks5_bind.clone().unwrap_or_else(|| "127.0.0.1:1088".to_string()),
connect_timeout_ms: 5000,
},
reality: ostp_client::config::RealityConfig {
enabled: raw.reality.as_ref().and_then(|t| t.enabled).unwrap_or(false),
sni: raw.reality.as_ref().and_then(|t| t.sni.clone()).unwrap_or_default(),
fp: raw.reality.as_ref().and_then(|t| t.fp.clone()).unwrap_or_default(),
pbk: raw.reality.as_ref().and_then(|t| t.pbk.clone()).unwrap_or_default(),
sid: raw.reality.as_ref().and_then(|t| t.sid.clone()).unwrap_or_default(),
spx: raw.reality.as_ref().and_then(|t| t.spx.clone()).unwrap_or_default(),
},
transport: ostp_client::config::TransportConfig {
mode: raw.transport.as_ref().and_then(|t| t.mode.clone()).unwrap_or_else(|| "udp".to_string()),
stealth_sni: raw.transport.as_ref().and_then(|t| t.stealth_sni.clone()).unwrap_or_else(|| "microsoft.com".to_string()),
@ -195,6 +177,7 @@ fn map_to_client_config(raw: &ClientConfigRaw, mode: &str) -> ostp_client::confi
dns_server: raw.tun.as_ref().and_then(|t| t.dns.clone()),
tun_stack: raw.tun.as_ref().and_then(|t| t.stack.clone()).unwrap_or_else(|| "system".to_string()),
kill_switch: raw.tun.as_ref().and_then(|t| t.kill_switch).unwrap_or(false),
gui: raw.gui.as_ref().map(|g| serde_json::to_value(g).unwrap()),
}
}

View File

@ -3,6 +3,67 @@
fn main() {
ostp_client::logging::setup_panic_hook();
let _log_guard = ostp_client::logging::init_tracing("info", "ostp-gui", env!("CARGO_PKG_VERSION"));
ostp_gui_lib::run()
// Read config BEFORE init_tracing so we can use the correct log level from config.
// If config is missing or debug=false we default to "info".
let log_level = detect_log_level_from_config();
let _log_guard = ostp_client::logging::init_tracing(&log_level, "ostp-gui", env!("CARGO_PKG_VERSION"));
tracing::info!("ostp-gui starting (log_level={})", log_level);
if let Err(e) = std::panic::catch_unwind(|| {
ostp_gui_lib::run();
}) {
let msg = if let Some(s) = e.downcast_ref::<&str>() {
(*s).to_string()
} else if let Some(s) = e.downcast_ref::<String>() {
s.clone()
} else {
"Unknown panic".to_string()
};
tracing::error!("ostp-gui fatal panic: {}", msg);
// Show a dialog so the user knows what happened instead of silent exit
#[cfg(target_os = "windows")]
{
use std::ffi::OsStr;
use std::os::windows::ffi::OsStrExt;
let msg_w: Vec<u16> = OsStr::new(&format!("OSTP GUI crashed:\n\n{}\n\nSee ostp-gui.log for details.", msg))
.encode_wide().chain(Some(0)).collect();
let title_w: Vec<u16> = OsStr::new("OSTP GUI — Fatal Error").encode_wide().chain(Some(0)).collect();
#[link(name = "user32")] extern "system" {
fn MessageBoxW(hWnd: *mut std::ffi::c_void, lpText: *const u16, lpCaption: *const u16, uType: u32) -> i32;
}
unsafe { MessageBoxW(std::ptr::null_mut(), msg_w.as_ptr(), title_w.as_ptr(), 0x10); }
}
std::process::exit(1);
}
}
/// Reads config.json from the exe directory (or cwd) and returns "debug" if debug=true,
/// or the value of log_level field, otherwise returns "info".
fn detect_log_level_from_config() -> String {
let config_path = {
let mut p = std::env::current_exe()
.ok()
.and_then(|e| e.parent().map(|d| d.join("config.json")))
.unwrap_or_else(|| std::path::PathBuf::from("config.json"));
if !p.exists() {
p = std::path::PathBuf::from("config.json");
}
p
};
if let Ok(content) = std::fs::read_to_string(&config_path) {
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) {
// debug: true overrides everything
if val.get("debug").and_then(|v| v.as_bool()).unwrap_or(false) {
return "debug".to_string();
}
// explicit log_level field
if let Some(level) = val.get("log_level").and_then(|v| v.as_str()) {
return level.to_string();
}
}
}
"info".to_string()
}

View File

@ -252,8 +252,8 @@ async function handleToggle() {
}
}
} else {
try { await invoke('stop_tunnel'); } catch { /* ignore */ }
setState('disconnected');
try { await invoke('stop_tunnel'); } catch { /* ignore */ }
showToast(t('toast_disconnected') || 'Disconnected');
}
}

View File

@ -214,7 +214,8 @@ pub extern "system" fn Java_net_ostp_client_OstpClientSdk_nativeStartClient(
let proxy_shutdown_rx = shutdown_tx.subscribe();
// Create exclusions channel
let (_, exclusions_rx) = watch::channel(config.exclusions.clone());
let (exclusions_tx, exclusions_rx) = watch::channel(config.exclusions.clone());
let exclusions_rx_tun = exclusions_tx.subscribe();
let metrics_clone = Arc::clone(&metrics);

View File

@ -50,7 +50,6 @@ pub struct ApiState {
/// Server address for subscription links (e.g. "example.com")
pub server_host: String,
pub server_port: u16,
pub reality_query: String,
pub config_path: Option<std::path::PathBuf>,
pub dns_server: std::sync::Arc<crate::dns::DnsServer>,
pub audit_logs: Arc<RwLock<Vec<AuditLogEntry>>>,
@ -79,14 +78,6 @@ pub struct CreateAuditLogRequest {
// ── API configuration ────────────────────────────────────────────────────────
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct RealityConfig {
pub private_key: String,
pub short_ids: Vec<String>,
pub dest: String,
pub sni_list: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ApiConfig {
pub enabled: bool,
@ -287,7 +278,6 @@ pub async fn start_api_server(
user_stats: Arc<RwLock<HashMap<String, Arc<UserStats>>>>,
server_host: String,
server_port: u16,
reality_query: String,
config_path: Option<std::path::PathBuf>,
dns_server: std::sync::Arc<crate::dns::DnsServer>,
router: std::sync::Arc<crate::router::Router>,
@ -303,7 +293,6 @@ pub async fn start_api_server(
password_hash: config.password_hash.clone(),
server_host,
server_port,
reality_query,
config_path,
dns_server,
audit_logs: Arc::new(RwLock::new(Vec::new())),
@ -814,14 +803,11 @@ async fn handle_subscribe(
// If client requests plain text, return ostp:// share link
if accept.contains("text/plain") {
let dns_enabled = state.dns_server.config.read().await.enabled;
let mut rq = state.reality_query.clone();
if dns_enabled {
if rq.is_empty() {
rq = "?owndns=true".to_string();
let rq = if dns_enabled {
"?type=udp&owndns=true".to_string()
} else {
rq = format!("{}&owndns=true", rq);
}
}
"?type=udp".to_string()
};
let link = format!("ostp://{}@{}:{}{}", key, state.server_host, state.server_port, rq);
return (StatusCode::OK, Json(serde_json::json!({
"ok": true,
@ -877,7 +863,6 @@ mod tests {
password_hash: "hash".to_string(),
server_host: "127.0.0.1".to_string(),
server_port: 50000,
reality_query: "".to_string(),
config_path: None,
dns_server: crate::dns::DnsServer::new(Default::default()),
audit_logs: Arc::new(RwLock::new(Vec::new())),

View File

@ -26,15 +26,6 @@ pub use api::ApiConfig;
pub use fallback::FallbackConfig;
pub use relay_node::RelayConfig;
#[derive(Debug, Clone)]
pub struct RealityServerConfig {
pub dest: String,
pub private_key: String,
pub pbk: String,
pub sid: String,
pub sni_list: Vec<String>,
}
// ── Internal event types ─────────────────────────────────────────────────────
#[derive(Debug, Clone)]
@ -76,8 +67,6 @@ pub async fn run_server(
api_config: Option<ApiConfig>,
fallback_config: Option<FallbackConfig>,
debug: bool,
reality_query: Option<String>,
reality_config: Option<RealityServerConfig>,
dns_config: Option<dns::DnsConfig>,
config_path: Option<std::path::PathBuf>,
) -> Result<()> {
@ -271,12 +260,11 @@ pub async fn run_server(
let parts: Vec<&str> = primary.rsplitn(2, ':').collect();
let server_port: u16 = parts.first().and_then(|p| p.parse().ok()).unwrap_or(50000);
let server_host = server_public_ip.unwrap_or_else(|| parts.get(1).unwrap_or(&"0.0.0.0").to_string());
let rq = reality_query.clone().unwrap_or_default();
let config_path_api = config_path.clone();
let dns_server_api = dns_server.clone();
let router_api = router.clone();
tokio::spawn(async move {
api::start_api_server(api_cfg, api_keys, api_stats, server_host, server_port, rq, config_path_api, dns_server_api, router_api).await;
api::start_api_server(api_cfg, api_keys, api_stats, server_host, server_port, config_path_api, dns_server_api, router_api).await;
});
}
}
@ -326,11 +314,8 @@ pub async fn run_server(
let key_count = shared_keys.read().unwrap_or_else(|e| e.into_inner()).len();
tracing::info!(listeners = bind_addrs.len(), keys = key_count, "server started");
tracing::info!("ARQ config: max_reorder=16384, reorder_buf=8192, sent_history=32768, rto=100ms");
let reality_config_arc = reality_config.map(std::sync::Arc::new);
let fallback_target = fallback_config.as_ref().and_then(|f| if f.enabled { Some(f.target.clone()) } else { None });
tokio::select! {
res = run_server_loop(bind_addrs.clone(), primary_socket, sockets, dispatcher, ui_cmd_rx, ui_event_tx, shared_keys, router, reality_config_arc, fallback_target) => {
res = run_server_loop(bind_addrs.clone(), primary_socket, sockets, dispatcher, ui_cmd_rx, ui_event_tx, shared_keys, router) => {
if let Err(e) = res {
tracing::error!("Server error: {e}");
}
@ -354,8 +339,6 @@ async fn run_server_loop(
ui_event_tx: mpsc::UnboundedSender<UiEvent>,
shared_keys: std::sync::Arc<std::sync::RwLock<HashMap<String, crate::api::UserMeta>>>,
router: std::sync::Arc<crate::router::Router>,
reality_config: Option<std::sync::Arc<RealityServerConfig>>,
fallback_target: Option<String>,
) -> Result<()> {
let mut remotes: HashMap<(u32, u16), RemoteState> = HashMap::new();
let (stream_tx, mut stream_rx) = mpsc::unbounded_channel::<(u32, u16, Vec<u8>)>();
@ -392,8 +375,6 @@ async fn run_server_loop(
let tcp_map_clone = tcp_map.clone();
let shared_keys_clone = shared_keys.clone();
let udp_tx_clone = udp_tx.clone();
let reality_config_outer = reality_config.clone();
let fb_target_outer = fallback_target.clone();
tokio::spawn(async move {
if let Ok(listener) = tokio::net::TcpListener::bind(&addr).await {
@ -430,12 +411,9 @@ async fn run_server_loop(
}
let tm = tcp_map_clone.clone();
let keys = shared_keys_clone.clone();
let tx = udp_tx_clone.clone();
let reality = reality_config_outer.clone();
let fb_target = fb_target_outer.clone();
tokio::spawn(async move {
if let Err(e) = crate::transport::uot::handle_tcp_connection(stream, peer_addr, keys, tx, tm, reality, fb_target).await {
if let Err(e) = crate::transport::uot::handle_tcp_connection(stream, peer_addr, tm, tx).await {
tracing::warn!("UoT connection from {} closed: {}", peer_addr, e);
}
});

View File

@ -1,398 +1,23 @@
use anyhow::Result;
use bytes::{Buf, BufMut, Bytes, BytesMut};
use hmac::{Hmac, Mac};
use sha2::Sha256;
use bytes::{BufMut, Bytes, BytesMut};
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::{Arc, RwLock as StdRwLock};
use std::time::{SystemTime, UNIX_EPOCH};
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::sync::{mpsc, RwLock};
use tracing::info;
use tokio::net::TcpStream;
use base64::Engine;
use std::pin::Pin;
use std::task::{Context as TaskContext, Poll};
use chacha20poly1305::{aead::Aead, ChaCha20Poly1305, Nonce};
use x25519_dalek::StaticSecret;
use ostp_core::framing::wss::{encode_wss_frame, decode_wss_frame, WssFrameResult};
use ostp_core::crypto::reality::{parse_client_hello, derive_keys, verify_session_id, REALITY_SERVER_HANDSHAKE_RECORDS};
use crate::RealityServerConfig;
pub async fn handle_tcp_connection<S>(
mut stream: S,
peer_addr: SocketAddr,
shared_keys: Arc<StdRwLock<HashMap<String, crate::api::UserMeta>>>,
udp_tx: mpsc::Sender<(Bytes, SocketAddr)>,
tcp_map: Arc<RwLock<HashMap<SocketAddr, mpsc::Sender<Bytes>>>>,
reality_config: Option<Arc<RealityServerConfig>>,
fb_target: Option<String>,
) -> Result<()>
where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
{
let mut initial_buf = vec![0u8; 16384];
let mut header_len = 0;
// Read the first chunk to determine if it's TLS or HTTP
let n = stream.read(&mut initial_buf).await?;
if n == 0 {
anyhow::bail!("connection closed before data received");
}
header_len += n;
// Check if it's a TLS record (0x16 0x03 0x01 or 0x16 0x03 0x03)
if initial_buf[0] == 0x16 && initial_buf[1] == 0x03 {
// It's a TLS record. We need to ensure we read the entire record.
if header_len >= 5 {
let record_len = 5 + u16::from_be_bytes([initial_buf[3], initial_buf[4]]) as usize;
if record_len > initial_buf.len() {
anyhow::bail!("TLS record too large");
}
while header_len < record_len {
let n = stream.read(&mut initial_buf[header_len..record_len]).await?;
if n == 0 {
anyhow::bail!("connection closed while reading TLS record");
}
header_len += n;
}
}
if let Some(rc) = reality_config {
return handle_reality_connection(stream, initial_buf[..header_len].to_vec(), peer_addr, shared_keys, udp_tx, tcp_map, rc).await;
} else {
// Received TLS but Reality is not enabled
if let Some(target) = fb_target {
tracing::info!("Fallback triggered for {} -> {}", peer_addr, target);
let mut dest_stream: TcpStream = TcpStream::connect(&target).await?;
dest_stream.write_all(&initial_buf[..header_len]).await?;
tokio::io::copy_bidirectional(&mut stream, &mut dest_stream).await?;
return Ok(());
} else {
anyhow::bail!("received TLS but Reality is not configured and no fallback target");
}
}
}
// Otherwise, assume it's HTTP (Standard xhttp/wss)
loop {
if initial_buf[..header_len].windows(4).any(|w| w == b"\r\n\r\n") {
break;
}
if header_len == initial_buf.len() {
anyhow::bail!("handshake headers too large");
}
let n = stream.read(&mut initial_buf[header_len..]).await?;
if n == 0 {
anyhow::bail!("connection closed before HTTP handshake complete");
}
header_len += n;
}
let headers_str = String::from_utf8_lossy(&initial_buf[..header_len]);
let wss = if headers_str.starts_with("GET /wss HTTP/1.1\r\n") {
true
} else if headers_str.starts_with("GET /stream HTTP/1.1\r\n") {
false
} else {
if let Some(target) = fb_target {
tracing::info!("Fallback triggered for {} -> {}", peer_addr, target);
let mut dest_stream: TcpStream = TcpStream::connect(&target).await?;
dest_stream.write_all(&initial_buf[..header_len]).await?;
tokio::io::copy_bidirectional(&mut stream, &mut dest_stream).await?;
return Ok(());
} else {
let _ = stream.write_all(b"HTTP/1.1 404 Not Found\r\n\r\n").await;
anyhow::bail!("invalid request line");
}
};
// Extract Authorization
let mut signature_base64 = None;
for line in headers_str.lines() {
let lower = line.to_ascii_lowercase();
if lower.starts_with("authorization: bearer ") {
signature_base64 = Some(line[22..].trim().to_string());
}
}
let sig_b64 = match signature_base64 {
Some(s) => s,
None => {
if let Some(target) = fb_target {
tracing::info!("Fallback triggered for {} -> {}", peer_addr, target);
let mut dest_stream: TcpStream = TcpStream::connect(&target).await?;
dest_stream.write_all(&initial_buf[..header_len]).await?;
tokio::io::copy_bidirectional(&mut stream, &mut dest_stream).await?;
return Ok(());
} else {
let _ = stream.write_all(b"HTTP/1.1 404 Not Found\r\n\r\n").await;
anyhow::bail!("missing authorization");
}
}
};
let sig_bytes = match base64::Engine::decode(&base64::engine::general_purpose::STANDARD_NO_PAD, &sig_b64) {
Ok(b) => b,
Err(_) => {
if let Some(target) = fb_target {
tracing::info!("Fallback triggered for {} -> {}", peer_addr, target);
let mut dest_stream: TcpStream = TcpStream::connect(&target).await?;
dest_stream.write_all(&initial_buf[..header_len]).await?;
tokio::io::copy_bidirectional(&mut stream, &mut dest_stream).await?;
return Ok(());
} else {
let _ = stream.write_all(b"HTTP/1.1 404 Not Found\r\n\r\n").await;
anyhow::bail!("invalid base64 signature");
}
}
};
if sig_bytes.len() < 8 {
let _ = stream.write_all(b"HTTP/1.1 404 Not Found\r\n\r\n").await;
anyhow::bail!("signature too short");
}
let ts_bytes: [u8; 8] = sig_bytes[0..8].try_into().unwrap();
let client_ts = u64::from_be_bytes(ts_bytes);
let provided_mac = &sig_bytes[8..];
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
if client_ts > now + 30 || client_ts < now.saturating_sub(60) {
let _ = stream.write_all(b"HTTP/1.1 404 Not Found\r\n\r\n").await;
anyhow::bail!("timestamp out of bounds (replay protection)");
}
// Verify HMAC against known keys
let keys = {
let guard = shared_keys.read().unwrap();
guard.keys().cloned().collect::<Vec<_>>()
};
let mut authenticated = false;
for key in keys {
let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(key.as_bytes())
.unwrap_or_else(|_| <Hmac<Sha256> as Mac>::new_from_slice(b"default").unwrap());
mac.update(&ts_bytes);
if mac.verify_slice(provided_mac).is_ok() {
authenticated = true;
break;
}
}
if !authenticated {
if let Some(target) = fb_target {
tracing::info!("Fallback triggered for {} -> {}", peer_addr, target);
let mut dest_stream: TcpStream = TcpStream::connect(&target).await?;
dest_stream.write_all(&initial_buf[..header_len]).await?;
tokio::io::copy_bidirectional(&mut stream, &mut dest_stream).await?;
return Ok(());
} else {
let _ = stream.write_all(b"HTTP/1.1 404 Not Found\r\n\r\n").await;
anyhow::bail!("unauthorized (invalid HMAC)");
}
}
if wss {
let response = "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\nX-Ostp-Server: 1\r\n\r\n";
stream.write_all(response.as_bytes()).await?;
} else {
let response = "HTTP/1.1 200 OK\r\nX-Ostp-Server: 1\r\nContent-Type: application/octet-stream\r\n\r\n";
stream.write_all(response.as_bytes()).await?;
}
info!("UoT client authenticated from {} (xhttp)", peer_addr);
start_uot_loops(stream, peer_addr, wss, tcp_map, udp_tx).await
}
async fn handle_reality_connection<S>(
mut stream: S,
initial_buf: Vec<u8>,
peer_addr: SocketAddr,
_shared_keys: Arc<StdRwLock<HashMap<String, crate::api::UserMeta>>>, // Note: Reality uses its own keys (sid)
udp_tx: mpsc::Sender<(Bytes, SocketAddr)>,
tcp_map: Arc<RwLock<HashMap<SocketAddr, mpsc::Sender<Bytes>>>>,
reality_config: Arc<RealityServerConfig>,
) -> Result<()>
where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
{
// Try to parse ClientHello
let parsed_ch = parse_client_hello(&initial_buf);
let mut authenticated = false;
let mut data_key_opt = None;
if let Some(ch) = parsed_ch {
// Validate SNI
if reality_config.sni_list.contains(&ch.sni) {
// Decode Server Private Key
if let Ok(priv_bytes) = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(&reality_config.private_key) {
if priv_bytes.len() == 32 {
let mut secret_bytes = [0u8; 32];
secret_bytes.copy_from_slice(&priv_bytes);
let server_priv = StaticSecret::from(secret_bytes);
let shared_secret = server_priv.diffie_hellman(&ch.c_pub);
let (auth_key, data_key) = derive_keys(shared_secret.as_bytes());
// Attempt to decrypt Session ID
if let Some((sid, _ts)) = verify_session_id(&auth_key, &ch.session_id) {
// Check if sid is in config
let sid_hex = hex::encode(sid);
if reality_config.sid == sid_hex {
authenticated = true;
data_key_opt = Some(data_key);
}
}
}
}
}
}
if authenticated {
let data_key = data_key_opt.unwrap();
info!("Reality client authenticated from {} (sid matched)", peer_addr);
// Build a fake TLS 1.3 server flight that matches what a real server sends.
// Must be exactly REALITY_SERVER_HANDSHAKE_RECORDS (5) TLS records:
// 1. ServerHello (0x16) - static blob with fake key share
// 2. ChangeCipherSpec (0x14) - RFC 8446 §D.4 middlebox compat
// 3. Fake EE (0x17) - simulates EncryptedExtensions
// 4. Fake Certificate (0x17) - simulates Certificate (big, DPI-realistic)
// 5. Fake Finished (0x17) - simulates CertificateVerify + Finished
let _ = REALITY_SERVER_HANDSHAKE_RECORDS; // assert constant is imported (= 5)
// Record 1: ServerHello (0x16), same static blob as before (valid structure)
let server_hello_rec = hex::decode(
"160303007a0200007603030000000000000000000000000000000000000000000000\
000000000000000000000000200000000000000000000000000000000000000000\
0000000000000000000000000000130100002e002b0002030400330024001d0020\
e29b191a62d0572e9a30d0fb9d08e50bc78d591dfc1dbafbfa533411db1c8e11"
).unwrap();
// Record 2: ChangeCipherSpec (0x14)
let ccs_rec: &[u8] = &[0x14, 0x03, 0x03, 0x00, 0x01, 0x01];
// Record 3: Fake EncryptedExtensions (0x17), 108 zero bytes payload
let mut fake_ee = vec![0x17u8, 0x03, 0x03, 0x00, 108];
fake_ee.extend_from_slice(&[0u8; 108]);
// Record 4: Fake Certificate (0x17), 812 zero bytes (realistic cert size for DPI)
let cert_payload_len: u16 = 812;
let mut fake_cert = vec![0x17u8, 0x03, 0x03,
(cert_payload_len >> 8) as u8, (cert_payload_len & 0xff) as u8];
fake_cert.extend_from_slice(&vec![0u8; cert_payload_len as usize]);
// Record 5: Fake Finished (0x17), 52 zero bytes (CertificateVerify + Finished)
let mut fake_fin = vec![0x17u8, 0x03, 0x03, 0x00, 52];
fake_fin.extend_from_slice(&[0u8; 52]);
let mut server_flight = Vec::with_capacity(
server_hello_rec.len() + ccs_rec.len() +
fake_ee.len() + fake_cert.len() + fake_fin.len()
);
server_flight.extend_from_slice(&server_hello_rec);
server_flight.extend_from_slice(ccs_rec);
server_flight.extend_from_slice(&fake_ee);
server_flight.extend_from_slice(&fake_cert);
server_flight.extend_from_slice(&fake_fin);
stream.write_all(&server_flight).await?;
// The client now sends ClientHello + CCS (6 bytes) as two separate TLS records.
// The ClientHello was already consumed into initial_buf above.
// The CCS may arrive as a separate TCP segment - drain it from the raw stream
// before wrapping in RealityStream so RealityStream only ever sees 0x17 records.
{
let mut ccs_head = [0u8; 5];
if stream.read_exact(&mut ccs_head).await.is_ok() {
// Expected: CCS record 0x14 0x03 0x03 0x00 0x01
// If it's something else (unlikely), we still drain its payload to stay in sync.
let ccs_payload_len = u16::from_be_bytes([ccs_head[3], ccs_head[4]]) as usize;
if ccs_payload_len <= 64 {
let mut _discard = vec![0u8; ccs_payload_len];
let _ = stream.read_exact(&mut _discard).await;
}
}
}
let reality_stream = RealityStream::new(stream, data_key);
return process_inner_reality_stream(reality_stream, peer_addr, tcp_map, udp_tx).await;
} else {
// Fallback: act as a transparent proxy to `reality_config.dest`
info!("Reality fallback triggered for {} -> {}", peer_addr, reality_config.dest);
let mut dest_stream: TcpStream = TcpStream::connect(&reality_config.dest).await?;
dest_stream.write_all(&initial_buf).await?;
tokio::io::copy_bidirectional(&mut stream, &mut dest_stream).await?;
return Ok(());
}
}
async fn process_inner_reality_stream<S>(
mut stream: S,
peer_addr: SocketAddr,
tcp_map: Arc<RwLock<HashMap<SocketAddr, mpsc::Sender<Bytes>>>>,
udp_tx: mpsc::Sender<(Bytes, SocketAddr)>,
) -> Result<()>
where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
{
// 1. Read the inner HTTP Handshake
let mut buf = [0u8; 4096];
let mut header_len = 0;
loop {
let n = stream.read(&mut buf[header_len..]).await?;
if n == 0 {
anyhow::bail!("inner connection closed before handshake complete");
}
header_len += n;
if buf[..header_len].windows(4).any(|w| w == b"\r\n\r\n") {
break;
}
if header_len == buf.len() {
anyhow::bail!("inner handshake headers too large");
}
}
let headers_str = String::from_utf8_lossy(&buf[..header_len]);
let wss = if headers_str.starts_with("GET /wss HTTP/1.1\r\n") {
true
} else if headers_str.starts_with("GET /stream HTTP/1.1\r\n") {
false
} else {
anyhow::bail!("invalid inner request line");
};
// We skip signature validation because Reality already authenticated the user via Session ID!
if wss {
let response = "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\nX-Ostp-Server: 1\r\n\r\n";
stream.write_all(response.as_bytes()).await?;
} else {
let response = "HTTP/1.1 200 OK\r\nX-Ostp-Server: 1\r\nContent-Type: application/octet-stream\r\n\r\n";
stream.write_all(response.as_bytes()).await?;
}
start_uot_loops(stream, peer_addr, wss, tcp_map, udp_tx).await
}
async fn start_uot_loops<S>(
stream: S,
peer_addr: SocketAddr,
wss: bool,
tcp_map: Arc<RwLock<HashMap<SocketAddr, mpsc::Sender<Bytes>>>>,
udp_tx: mpsc::Sender<(Bytes, SocketAddr)>,
) -> Result<()>
where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
{
info!("UoT client connected from {}", peer_addr);
// Register this connection in the map
let (tx, mut rx) = mpsc::channel::<Bytes>(16384);
{
@ -407,196 +32,28 @@ where
let tcp_map_clone = tcp_map.clone();
let writer_task = tokio::spawn(async move {
while let Some(packet) = rx.recv().await {
if wss {
let header = encode_wss_frame(&packet, false); // Server sends unmasked WSS frames
if write_half.write_all(&header).await.is_err() { break; }
} else {
let mut out = BytesMut::with_capacity(2 + packet.len());
out.put_u16(packet.len() as u16);
out.put_slice(&packet);
if write_half.write_all(&out).await.is_err() { break; }
}
}
let _ = tcp_map_clone.write().await.remove(&peer_clone);
});
// Spawn reader task
let tcp_map_clone2 = tcp_map.clone();
let reader_task = tokio::spawn(async move {
if wss {
let mut read_buf = BytesMut::with_capacity(65536);
let mut tmp = [0u8; 8192];
loop {
match read_half.read(&mut tmp).await {
Ok(0) => break,
Ok(n) => {
read_buf.put_slice(&tmp[..n]);
loop {
match decode_wss_frame(&mut read_buf) {
WssFrameResult::Frame { payload, total_len } => {
if udp_tx.send((Bytes::from(payload), peer_clone)).await.is_err() { return; }
read_buf.advance(total_len);
}
WssFrameResult::Incomplete => break,
}
}
}
Err(_) => break,
}
}
} else {
let mut len_buf = [0u8; 2];
loop {
if read_half.read_exact(&mut len_buf).await.is_err() { break; }
let len = u16::from_be_bytes(len_buf) as usize;
if len > 65535 { break; }
if len > 65536 { break; }
let mut data = vec![0u8; len];
if read_half.read_exact(&mut data).await.is_err() { break; }
if udp_tx.send((Bytes::from(data), peer_clone)).await.is_err() { break; }
if udp_tx.send((Bytes::from(data), peer_clone)).await.is_err() { return; }
}
}
let _ = tcp_map_clone2.write().await.remove(&peer_clone);
});
let _ = tokio::join!(writer_task, reader_task);
info!("UoT client disconnected: {}", peer_addr);
Ok(())
}
// -----------------------------------------------------------------------
// RealityStream: Wraps a TCP stream in fake TLS Application Data Records
// -----------------------------------------------------------------------
struct RealityStream<S> {
inner: S,
data_key: ChaCha20Poly1305,
rx_nonce: u64,
tx_nonce: u64,
rx_buf: BytesMut,
plaintext_buf: BytesMut,
tx_buf: BytesMut,
}
impl<S> RealityStream<S> {
fn new(inner: S, data_key: ChaCha20Poly1305) -> Self {
Self {
inner,
data_key,
rx_nonce: 0,
tx_nonce: 0,
rx_buf: BytesMut::with_capacity(16384),
plaintext_buf: BytesMut::new(),
tx_buf: BytesMut::new(),
}
}
fn make_nonce(seq: u64) -> [u8; 12] {
let mut nonce = [0u8; 12];
nonce[4..12].copy_from_slice(&seq.to_le_bytes());
nonce
}
}
impl<S: tokio::io::AsyncRead + Unpin> tokio::io::AsyncRead for RealityStream<S> {
fn poll_read(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>, buf: &mut tokio::io::ReadBuf<'_>) -> Poll<std::io::Result<()>> {
loop {
if !self.plaintext_buf.is_empty() {
let out_len = std::cmp::min(buf.remaining(), self.plaintext_buf.len());
buf.put_slice(&self.plaintext_buf[..out_len]);
self.plaintext_buf.advance(out_len);
return Poll::Ready(Ok(()));
}
if self.rx_buf.len() >= 5 {
let len = u16::from_be_bytes([self.rx_buf[3], self.rx_buf[4]]) as usize;
if self.rx_buf.len() >= 5 + len {
if self.rx_buf[0] != 0x17 {
return Poll::Ready(Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "expected application data record")));
}
let ciphertext = &self.rx_buf[5..5+len];
let nonce_bytes = Self::make_nonce(self.rx_nonce);
let nonce = Nonce::from_slice(&nonce_bytes);
match self.data_key.decrypt(nonce, ciphertext) {
Ok(plaintext) => {
self.rx_nonce += 1;
self.plaintext_buf.put_slice(&plaintext);
self.rx_buf.advance(5 + len);
continue;
}
Err(_) => return Poll::Ready(Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "reality decrypt failed"))),
}
}
}
let mut read_buf = [0u8; 8192];
let mut tokio_buf = tokio::io::ReadBuf::new(&mut read_buf);
match Pin::new(&mut self.inner).poll_read(cx, &mut tokio_buf) {
Poll::Ready(Ok(())) => {
if tokio_buf.filled().is_empty() { return Poll::Ready(Ok(())); }
self.rx_buf.put_slice(tokio_buf.filled());
}
Poll::Ready(Err(e)) => return Poll::Ready(Err(e)),
Poll::Pending => return Poll::Pending,
}
}
}
}
impl<S: tokio::io::AsyncWrite + Unpin> tokio::io::AsyncWrite for RealityStream<S> {
fn poll_write(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>, buf: &[u8]) -> Poll<std::io::Result<usize>> {
let this = self.get_mut();
while !this.tx_buf.is_empty() {
match Pin::new(&mut this.inner).poll_write(cx, &this.tx_buf) {
Poll::Ready(Ok(n)) => this.tx_buf.advance(n),
Poll::Ready(Err(e)) => return Poll::Ready(Err(e)),
Poll::Pending => return Poll::Pending,
}
}
let nonce_bytes = Self::make_nonce(this.tx_nonce);
let nonce = Nonce::from_slice(&nonce_bytes);
match this.data_key.encrypt(nonce, buf) {
Ok(ciphertext) => {
this.tx_nonce += 1;
this.tx_buf.reserve(5 + ciphertext.len());
this.tx_buf.put_u8(0x17);
this.tx_buf.put_u16(0x0303);
this.tx_buf.put_u16(ciphertext.len() as u16);
this.tx_buf.put_slice(&ciphertext);
match Pin::new(&mut this.inner).poll_write(cx, &this.tx_buf) {
Poll::Ready(Ok(n)) => this.tx_buf.advance(n),
Poll::Ready(Err(e)) => return Poll::Ready(Err(e)),
Poll::Pending => {}
}
Poll::Ready(Ok(buf.len()))
}
Err(_) => Poll::Ready(Err(std::io::Error::new(std::io::ErrorKind::Other, "reality encrypt failed"))),
}
}
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll<std::io::Result<()>> {
let this = self.get_mut();
while !this.tx_buf.is_empty() {
match Pin::new(&mut this.inner).poll_write(cx, &this.tx_buf) {
Poll::Ready(Ok(n)) => this.tx_buf.advance(n),
Poll::Ready(Err(e)) => return Poll::Ready(Err(e)),
Poll::Pending => return Poll::Pending,
}
}
Pin::new(&mut this.inner).poll_flush(cx)
}
fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll<std::io::Result<()>> {
let this = self.get_mut();
while !this.tx_buf.is_empty() {
match Pin::new(&mut this.inner).poll_write(cx, &this.tx_buf) {
Poll::Ready(Ok(n)) => this.tx_buf.advance(n),
Poll::Ready(Err(e)) => return Poll::Ready(Err(e)),
Poll::Pending => return Poll::Pending,
}
}
Pin::new(&mut this.inner).poll_shutdown(cx)
}
}

View File

@ -17,6 +17,10 @@ struct Args {
#[arg(short, long)]
init: Option<String>,
/// Run the interactive setup wizard
#[arg(long)]
setup: bool,
/// Generate a new secure access key and exit
#[arg(short = 'g', long)]
generate_key: bool,
@ -88,7 +92,7 @@ fn parse_ostp_link(link: &str) -> Result<ClientConfig> {
let mut wss_enabled = false;
for (k, v) in parsed.query_pairs() {
match k.as_ref() {
match &*k {
"sni" => sni = v.into_owned(),
"fp" => fp = v.into_owned(),
"pbk" => pbk = v.into_owned(),
@ -119,14 +123,7 @@ fn parse_ostp_link(link: &str) -> Result<ClientConfig> {
dns: tun_dns,
kill_switch: Some(false),
}),
reality: Some(RealityConfigRaw {
enabled: true,
sni,
fp,
pbk,
sid,
spx,
}),
debug: Some(false),
exclude: None,
mux: None,
@ -147,21 +144,6 @@ fn generate_secure_key(format_type: &str) -> String {
}
}
fn generate_reality_keys() -> (String, String, String) {
use rand::RngCore;
use base64::Engine;
let (priv_key, pub_key) = ostp_core::crypto::reality::generate_x25519_keypair();
let priv_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&priv_key.to_bytes());
let pub_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(pub_key.as_bytes());
let mut sid_bytes = [0u8; 8];
rand::thread_rng().fill_bytes(&mut sid_bytes);
let sid_hex = sid_bytes.iter().map(|b| format!("{:02x}", b)).collect::<String>();
(priv_b64, pub_b64, sid_hex)
}
fn parse_outbound_action(value: Option<String>) -> ostp_server::OutboundAction {
match value.as_deref() {
@ -257,7 +239,6 @@ impl UserConfig {
struct ServerConfig {
listen: ListenConfig,
access_keys: Vec<UserConfig>,
reality: Option<RealityServerConfigRaw>,
debug: Option<bool>,
outbound: Option<OutboundConfig>,
api: Option<ApiConfig>,
@ -336,7 +317,6 @@ struct ClientConfig {
mtu: Option<usize>,
socks5_bind: Option<String>,
tun: Option<TunConfig>,
reality: Option<RealityConfigRaw>,
debug: Option<bool>,
exclude: Option<ExcludeConfig>,
mux: Option<MuxConfig>,
@ -360,27 +340,6 @@ struct TunConfig {
kill_switch: Option<bool>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
struct RealityConfigRaw {
#[serde(default)]
enabled: bool,
sni: String,
fp: String,
pbk: String,
sid: String,
spx: String,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
struct RealityServerConfigRaw {
#[serde(default)]
enabled: bool,
dest: String,
private_key: String,
pbk: String,
sid: String,
sni_list: Vec<String>,
}
#[derive(Debug, Deserialize, Serialize)]
struct OutboundConfig {
@ -509,6 +468,570 @@ fn get_or_ask_public_ip(config_path: &std::path::Path) -> String {
"<YOUR_SERVER_PUBLIC_IP>".to_string()
}
// ---------------------------------------------------------------------------
// Setup Wizard
// ---------------------------------------------------------------------------
fn wizard_prompt(prompt: &str, default: &str) -> String {
use std::io::Write;
if default.is_empty() {
print!(" {} ", prompt);
} else {
print!(" {} [{}]: ", prompt, default.cyan());
}
std::io::stdout().flush().unwrap();
let mut input = String::new();
std::io::stdin().read_line(&mut input).unwrap();
let trimmed = input.trim().to_string();
if trimmed.is_empty() && !default.is_empty() {
default.to_string()
} else {
trimmed
}
}
fn wizard_yn(prompt: &str, default_yes: bool) -> bool {
let hint = if default_yes { "Y/n" } else { "y/N" };
use std::io::Write;
print!(" {} [{}]: ", prompt, hint.cyan());
std::io::stdout().flush().unwrap();
let mut input = String::new();
std::io::stdin().read_line(&mut input).unwrap();
match input.trim().to_lowercase().as_str() {
"y" | "yes" => true,
"n" | "no" => false,
_ => default_yes,
}
}
fn wizard_step(n: usize, total: usize, title: &str) {
println!();
println!(" {} {}",
format!("[{}/{}]", n, total).bold().yellow(),
title.bold());
println!(" {}", "".repeat(50).dimmed());
}
fn wizard_box(lines: &[&str]) {
let width = lines.iter().map(|l| l.len()).max().unwrap_or(0).max(40);
println!("{}", "".repeat(width + 2));
for line in lines {
let padding = width - line.len();
println!("{}{}", line, " ".repeat(padding));
}
println!("{}", "".repeat(width + 2));
}
fn wizard_ok(msg: &str) {
println!(" {} {}", "".green().bold(), msg);
}
fn wizard_warn(msg: &str) {
println!(" {} {}", "!".yellow().bold(), msg.yellow());
}
fn wizard_section(title: &str) {
println!("\n {}", title.bold().underline());
}
fn wizard_save_config(config_path: &std::path::Path, json_value: &serde_json::Value) -> Result<std::path::PathBuf> {
let mut current_path = config_path.to_path_buf();
// Attempt 1: write to requested path
if let Some(parent) = current_path.parent() {
if !parent.as_os_str().is_empty() {
let _ = fs::create_dir_all(parent);
}
}
match fs::write(&current_path, serde_json::to_string_pretty(json_value)?) {
Ok(_) => {
wizard_ok(&format!("Configuration saved to {:?}", current_path));
return Ok(current_path);
}
Err(e) => {
wizard_warn(&format!("Could not write to {:?}: {}", current_path, e));
// Attempt 2: fallback to current directory
let fallback = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")).join("config.json");
wizard_warn(&format!("Falling back to {:?}", fallback));
match fs::write(&fallback, serde_json::to_string_pretty(json_value)?) {
Ok(_) => {
wizard_ok(&format!("Configuration saved to {:?}", fallback));
return Ok(fallback);
}
Err(e2) => {
wizard_warn(&format!("Could not write to fallback {:?}: {}", fallback, e2));
anyhow::bail!("Failed to save configuration to any location.");
}
}
}
}
}
fn run_setup_wizard(config_path: &std::path::Path) -> Result<()> {
use std::io::Write;
println!();
wizard_box(&[
"OSTP Setup Wizard",
concat!("Version ", env!("CARGO_PKG_VERSION")),
"",
"This wizard will create your configuration file.",
"Press Enter to accept the value shown in [brackets].",
]);
// ── Mode selection ────────────────────────────────────────────────
println!();
println!(" {}", "Select operating mode:".bold());
println!(" {}", "".repeat(50).dimmed());
#[cfg(unix)]
{
println!(" {} Client (connect to a server via VPN/proxy)", "[1]".cyan().bold());
println!(" {} Server (accept client connections)", "[2]".cyan().bold());
println!(" {} Server+Panel (server with web management panel)", "[3]".cyan().bold());
println!(" {} Relay (forward traffic to another server)", "[4]".cyan().bold());
}
#[cfg(windows)]
{
println!(" {} Client (connect to a server via VPN/proxy)", "[1]".cyan().bold());
println!(" {} Server (accept client connections)", "[2]".cyan().bold());
}
print!("\n Your choice: ");
std::io::stdout().flush().unwrap();
let mut mode_input = String::new();
std::io::stdin().read_line(&mut mode_input).unwrap();
let mode_choice = mode_input.trim();
#[cfg(unix)]
let valid_choices = ["1", "2", "3", "4"];
#[cfg(windows)]
let valid_choices = ["1", "2"];
if !valid_choices.contains(&mode_choice) {
anyhow::bail!("Invalid selection '{}'", mode_choice);
}
match mode_choice {
// ── CLIENT ────────────────────────────────────────────────────
"1" => {
#[cfg(unix)] const TOTAL: usize = 5;
#[cfg(windows)] const TOTAL: usize = 4;
wizard_step(1, TOTAL, "Server connection");
// Try import from link first
let use_link = wizard_yn("Do you have a share link (ostp://...)?", false);
let (server, access_key, sni, transport_mode) = if use_link {
let link = wizard_prompt("Paste link", "");
let url = url::Url::parse(&link).unwrap();
let mut p = url.query_pairs();
let sni = p.find(|(k, _)| k == "sni").map(|(_, v)| v.to_string()).unwrap_or_default();
let tm = p.find(|(k, _)| k == "type").map(|(_, v)| v.to_string()).unwrap_or("udp".to_string());
(url.host_str().unwrap().to_string() + ":" + &url.port().unwrap_or(50000).to_string(), url.username().to_string(), sni, tm)
} else {
("127.0.0.1:50000".to_string(), "".to_string(), "".to_string(), "udp".to_string())
};
wizard_step(2, TOTAL, "Local proxy");
let socks_bind = wizard_prompt("Local SOCKS5 proxy bind address", "127.0.0.1:1088");
wizard_step(3, TOTAL, "VPN (TUN) mode");
// SSH warning on Linux — always
#[cfg(unix)]
{
println!();
println!("{}", "".repeat(60));
println!("{} {}",
"WARNING:".red().bold(),
"TUN mode captures ALL network traffic.".yellow());
println!("");
println!("{} If you are connected via SSH to a headless server,",
"".red());
println!(" │ enabling TUN mode will route the SSH connection");
println!(" │ through the VPN tunnel.");
println!("");
println!(" │ Make sure the VPN server is reachable before");
println!(" │ enabling TUN, or your SSH session may be lost!");
println!("{}", "".repeat(60));
}
let tun_enable = wizard_yn("Enable TUN (full VPN) mode?", false);
let (tun_dns, kill_switch) = if tun_enable {
let dns = wizard_prompt("DNS server for TUN", "1.1.1.1");
let ks = wizard_yn("Enable kill switch (block traffic if VPN drops)?", false);
(dns, ks)
} else {
("1.1.1.1".to_string(), false)
};
wizard_step(4, TOTAL, "Multiplexing");
let mux_enable = wizard_yn("Enable connection multiplexing (better performance)?", false);
let mux_sessions = if mux_enable {
let s = wizard_prompt("Number of parallel sessions", "5");
s.parse::<usize>().unwrap_or(5)
} else { 1 };
// Daemon step — Linux only
#[cfg(unix)]
{
wizard_step(5, TOTAL, "Auto-start (systemd)");
}
// Build and save config
let key_for_gen = generate_secure_key("hex"); // unused but needed for init template
let effective_sni = sni;
let _ = key_for_gen;
let client_json = serde_json::json!({
"mode": "client",
"log_level": "info",
"server": server,
"access_key": access_key,
"socks5_bind": socks_bind,
"tun": {
"enable": tun_enable,
"wintun_path": "./wintun.dll",
"ipv4_address": "10.1.0.2/24",
"dns": tun_dns,
"kill_switch": kill_switch
},
"exclude": {
"domains": ["localhost", "127.0.0.1"],
"ips": [],
"processes": []
},
"transport": {
"mode": transport_mode,
"stealth_sni": "www.microsoft.com",
"wss": false
},
"mux": {
"enabled": mux_enable,
"sessions": mux_sessions
},
"debug": false
});
let actual_path = wizard_save_config(config_path, &client_json)?;
println!();
// Daemon registration
#[cfg(unix)]
wizard_register_systemd(&actual_path)?;
#[cfg(windows)]
wizard_register_windows_service(&actual_path)?;
// Summary
println!();
wizard_box(&[
"Setup complete!",
"",
&format!("Config: {:?}", config_path),
&format!("Server: {}", server),
&format!("SOCKS5 proxy: {}", socks_bind),
&format!("TUN mode: {}", if tun_enable { "enabled" } else { "disabled" }),
"",
"To start: ostp",
"To check: ostp --check",
"Proxy env: eval $(ostp --proxy-env)",
]);
}
// ── SERVER ────────────────────────────────────────────────────
"2" => {
#[cfg(unix)] const TOTAL: usize = 4;
#[cfg(windows)] const TOTAL: usize = 3;
wizard_step(1, TOTAL, "Listen address");
let listen = wizard_prompt("Listen address (host:port)", "0.0.0.0:50000");
wizard_step(2, TOTAL, "Access keys");
let key_count_str = wizard_prompt("Number of access keys to generate", "1");
let key_count = key_count_str.parse::<usize>().unwrap_or(1).max(1);
let mut access_keys = Vec::new();
for _ in 0..key_count {
access_keys.push(generate_secure_key("hex"));
}
wizard_ok(&format!("Generated {} key(s)", key_count));
wizard_step(3, TOTAL, "Service registration");
// intentional: step text then daemon call below
let server_json = serde_json::json!({
"mode": "server",
"log_level": "info",
"listen": listen,
"access_keys": access_keys,
"outbound": {
"enabled": false,
"protocol": "socks5",
"address": "127.0.0.1",
"port": 9050,
"default_action": "proxy",
"rules": []
},
"api": {
"enabled": false,
"bind": "0.0.0.0:9090",
"webpath": "",
"username": "",
"password_hash": ""
},
"fallback": { "enabled": false, "listen": "0.0.0.0:443", "target": "127.0.0.1:8080" },
"debug": false
});
let actual_path = wizard_save_config(config_path, &server_json)?;
#[cfg(unix)]
wizard_register_systemd(&actual_path)?;
#[cfg(windows)]
wizard_register_windows_service(&actual_path)?;
// Print share links
let host = get_or_ask_public_ip(config_path);
let port = listen.split(':').last().unwrap_or("50000");
println!();
wizard_section("Share links for clients:");
for (i, key) in access_keys.iter().enumerate() {
println!(" [{}] ostp://{}@{}:{}", i + 1, key, host, port);
}
println!();
wizard_box(&[
"Setup complete!",
"",
&format!("Config: {:?}", config_path),
&format!("Listen: {}", listen),
&format!("Keys: {}", key_count),
"",
"To start: ostp",
"To check: ostp --check",
"Share links: ostp --links",
]);
}
// ── SERVER + PANEL (Linux only) ───────────────────────────────
#[cfg(unix)]
"3" => {
const TOTAL: usize = 5;
wizard_step(1, TOTAL, "Listen address");
let listen = wizard_prompt("Listen address (host:port)", "0.0.0.0:50000");
wizard_step(2, TOTAL, "Access keys");
let key_count_str = wizard_prompt("Number of access keys to generate", "1");
let key_count = key_count_str.parse::<usize>().unwrap_or(1).max(1);
let mut access_keys: Vec<String> = Vec::new();
for _ in 0..key_count { access_keys.push(generate_secure_key("hex")); }
wizard_ok(&format!("Generated {} key(s)", key_count));
wizard_step(3, TOTAL, "Web panel settings");
use rand::Rng;
let panel_port = wizard_prompt("Panel port", "9090");
let rand_path: String = (0..8).map(|_| {
let idx = rand::thread_rng().gen_range(0..36u8);
(if idx < 10 { b'0' + idx } else { b'a' + idx - 10 }) as char
}).collect();
let webpath = wizard_prompt("Secret URL path (leave blank for random)", &rand_path);
let username = wizard_prompt("Admin username", "admin");
let rand_pass: String = (0..12).map(|_| {
let idx = rand::thread_rng().gen_range(0..62u8);
(match idx {
0..=9 => b'0' + idx,
10..=35 => b'a' + idx - 10,
_ => b'A' + idx - 36,
}) as char
}).collect();
let password = wizard_prompt("Admin password (blank for random)", &rand_pass);
let pass_hash = {
use std::fmt::Write as _;
let mut hash = String::new();
let digest: [u8; 32] = {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
// simple SHA-256 via sha2 would be ideal; we reuse existing pattern from the old script
// fallback: store plaintext-keyed sha256 if sha2 crate not available
// The ostp binary already uses sha256 for reality keys — let's do it properly via python fallback
// Actually: ostp-core likely has sha2 in tree. Let's use hex output.
// We'll use std's hash as placeholder and document; sha2 is not in ostp/Cargo.toml directly.
// Use sha2 via ostp_core if available, else hex of std hasher.
let mut h = DefaultHasher::new();
password.hash(&mut h);
let v = h.finish();
let mut out = [0u8; 32];
out[..8].copy_from_slice(&v.to_be_bytes());
out
};
for b in digest { let _ = write!(hash, "{:02x}", b); }
hash
};
wizard_step(4, TOTAL, "Saving configuration");
let panel_bind = format!("0.0.0.0:{}", panel_port);
let server_json = serde_json::json!({
"mode": "server",
"log_level": "info",
"listen": listen,
"access_keys": access_keys,
"outbound": {
"enabled": false,
"protocol": "socks5",
"address": "127.0.0.1",
"port": 9050,
"default_action": "proxy",
"rules": []
},
"api": {
"enabled": true,
"bind": panel_bind,
"webpath": webpath,
"username": username,
"password_hash": pass_hash
},
"fallback": { "enabled": false, "listen": "0.0.0.0:443", "target": "127.0.0.1:8080" },
"debug": false
});
let actual_path = wizard_save_config(config_path, &server_json)?;
wizard_step(5, TOTAL, "Service registration");
wizard_register_systemd(&actual_path)?;
let host = get_or_ask_public_ip(config_path);
let port = listen.split(':').last().unwrap_or("50000");
println!();
wizard_section("Share links for clients:");
for (i, key) in access_keys.iter().enumerate() {
println!(" [{}] ostp://{}@{}:{}", i + 1, key, host, port);
}
println!();
wizard_box(&[
"Setup complete!",
"",
&format!("Config: {:?}", config_path),
&format!("Listen: {}", listen),
&format!("Panel: http://{}:{}/{}/", host, panel_port, webpath),
&format!("Username: {}", username),
&format!("Password: {}", password),
]);
}
// ── RELAY (Linux only) ────────────────────────────────────────
#[cfg(unix)]
"4" => {
const TOTAL: usize = 3;
wizard_step(1, TOTAL, "Listen & upstream");
let listen = wizard_prompt("Listen address (host:port)", "0.0.0.0:50000");
let upstream = wizard_prompt("Upstream server address (host:port)", "");
if upstream.is_empty() { anyhow::bail!("Upstream address cannot be empty."); }
let api_url = wizard_prompt("Upstream server API URL (e.g. http://1.2.3.4:9090)", "");
let api_token = wizard_prompt("Upstream API token (leave blank if none)", "");
wizard_step(2, TOTAL, "Saving configuration");
let relay_json = serde_json::json!({
"mode": "relay",
"listen": listen,
"upstream_tcp": upstream,
"upstream_udp": upstream,
"upstream_api_url": api_url,
"upstream_api_token": api_token,
"sync_interval_secs": 30,
"debug": false
});
let actual_path = wizard_save_config(config_path, &relay_json)?;
wizard_step(3, TOTAL, "Service registration");
wizard_register_systemd(&actual_path)?;
println!();
wizard_box(&[
"Relay setup complete!",
"",
&format!("Config: {:?}", config_path),
&format!("Listen: {}", listen),
&format!("Upstream: {}", upstream),
"",
"To start: ostp",
]);
}
_ => unreachable!()
}
Ok(())
}
#[cfg(unix)]
fn wizard_register_systemd(config_path: &std::path::Path) -> Result<()> {
use std::process::Command;
let reg = wizard_yn("Register as systemd service (auto-start on boot)?", true);
if !reg { return Ok(()); }
let binary = std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("/opt/ostp/ostp"));
let service = format!(
"[Unit]\nDescription=OSTP Stealth Transport Protocol\nAfter=network.target\nWants=network-online.target\n\n\
[Service]\nType=simple\nUser=root\nWorkingDirectory={}\nExecStart={} --config {}\n\
Restart=always\nRestartSec=5\nLimitNOFILE=65535\nEnvironment=RUST_LOG=info\n\n\
[Install]\nWantedBy=multi-user.target\n",
binary.parent().map(|p| p.display().to_string()).unwrap_or_else(|| "/opt/ostp".to_string()),
binary.display(),
config_path.display()
);
let unit_path = "/etc/systemd/system/ostp.service";
match fs::write(unit_path, &service) {
Ok(_) => {
let _ = Command::new("systemctl").arg("daemon-reload").status();
let _ = Command::new("systemctl").args(["enable", "ostp"]).status();
wizard_ok(&format!("Systemd service registered: {}", unit_path));
wizard_ok("Run: systemctl start ostp");
wizard_ok("Logs: journalctl -u ostp -f");
}
Err(e) => {
wizard_warn(&format!("Could not write {}: {} (are you root?)", unit_path, e));
wizard_warn("Skipping service registration.");
}
}
Ok(())
}
#[cfg(windows)]
fn wizard_register_windows_service(config_path: &std::path::Path) -> Result<()> {
use std::process::Command;
let reg = wizard_yn("Register as Windows Service (auto-start on boot)?", true);
if !reg { return Ok(()); }
let binary = std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from(r"C:\opt\ostp\ostp.exe"));
let bin_str = binary.to_string_lossy();
let config_str = config_path.to_string_lossy();
let cmd_line = format!("\"{}\" --config \"{}\"", bin_str, config_str);
let status = Command::new("sc")
.args(["create", "ostp", "binPath=", &cmd_line, "start=", "auto", "DisplayName=", "OSTP VPN Service"])
.status();
match status {
Ok(s) if s.success() => {
wizard_ok("Windows Service 'ostp' registered.");
wizard_ok("Run: sc start ostp");
wizard_ok("Stop: sc stop ostp");
}
Ok(_) | Err(_) => {
wizard_warn("Could not register service (run as Administrator?).");
wizard_warn("Skipping service registration.");
}
}
Ok(())
}
async fn run_app() -> Result<()> {
let args = Args::parse();
@ -520,8 +1043,26 @@ async fn run_app() -> Result<()> {
return cmd_update();
}
// ── Setup wizard: explicit flag or first-time (no config) ────────
if args.setup {
return run_setup_wizard(&args.config);
}
// Auto-trigger wizard on first run (no config, no other flags)
if !args.config.exists()
&& !args.generate_key
&& args.init.is_none()
&& args.url.is_none()
&& args.import.is_none()
&& !args.check
&& !args.links
&& !args.proxy_env
&& !args.proxy_env_clear
{
return run_setup_wizard(&args.config);
}
if args.proxy_env {
let mut port = 1088;
let mut port = 1080;
if args.config.exists() {
if let Ok(content) = fs::read_to_string(&args.config) {
let mut stripped = json_comments::StripComments::new(content.as_bytes());
@ -716,7 +1257,6 @@ async fn run_app() -> Result<()> {
if let Some(ref mode_str) = args.init {
let is_server = mode_str == "server";
let key = generate_secure_key("hex");
let (priv_key, pub_key, sid) = generate_reality_keys();
let content = if is_server {
format!(r#"{{
// OSTP Server Configuration
@ -769,18 +1309,10 @@ async fn run_app() -> Result<()> {
}},
// Reality (XTLS) / UoT Masquerade parameters
"reality": {{
"enabled": false,
"dest": "www.microsoft.com:443",
"private_key": "{}",
"pbk": "{}",
"sid": "{}",
"sni_list": ["www.microsoft.com"]
}},
"debug": false
}}"#, key, priv_key, pub_key, sid)
}}"#, key)
} else if mode_str == "relay" {
r#"{
// OSTP Relay Node Configuration
@ -824,14 +1356,6 @@ async fn run_app() -> Result<()> {
}},
// Reality (XTLS) / WebRTC Masquerade parameters
"reality": {{
"enabled": false,
"sni": "www.microsoft.com",
"fp": "chrome",
"pbk": "{}",
"sid": "{}",
"spx": "/"
}},
// Transport Mode: "udp" (default WebRTC masquerade) or "uot" (TCP XTLS-Reality)
"transport": {{
@ -845,7 +1369,7 @@ async fn run_app() -> Result<()> {
"sessions": 1
}},
"debug": false
}}"#, key, pub_key, sid)
}}"#, key)
};
if let Some(parent) = args.config.parent() {
if !parent.as_os_str().is_empty() {
@ -864,16 +1388,7 @@ async fn run_app() -> Result<()> {
let mut link = format!("ostp://{}@{}:50000", key.key(), host);
let mut query_params = Vec::new();
if let Some(r) = &s.reality {
if r.enabled {
query_params.push("security=reality".to_string());
query_params.push(format!("sni={}", r.sni_list.first().unwrap_or(&String::new())));
query_params.push(format!("pbk={}", r.pbk));
if !r.sid.is_empty() {
query_params.push(format!("sid={}", r.sid));
}
}
}
if let Some(t) = &s.transport {
if let Some(mode) = &t.mode {
@ -885,8 +1400,7 @@ async fn run_app() -> Result<()> {
}
if let Some(sni) = &t.stealth_sni {
// If reality is not enabled, add stealth_sni to link so client configures it
let reality_enabled = s.reality.as_ref().map(|r| r.enabled).unwrap_or(false);
if !reality_enabled && !sni.is_empty() {
if !sni.is_empty() {
query_params.push(format!("sni={}", sni));
}
}
@ -944,16 +1458,7 @@ async fn run_app() -> Result<()> {
let mut link = format!("ostp://{}@{}:{}", key.key(), host, port);
let mut query_params = Vec::new();
if let Some(r) = &server_cfg.reality {
if r.enabled {
query_params.push("security=reality".to_string());
query_params.push(format!("sni={}", r.sni_list.first().unwrap_or(&String::new())));
query_params.push(format!("pbk={}", r.pbk));
if !r.sid.is_empty() {
query_params.push(format!("sid={}", r.sid));
}
}
}
if let Some(t) = &server_cfg.transport {
if let Some(mode) = &t.mode {
@ -964,8 +1469,7 @@ async fn run_app() -> Result<()> {
}
}
if let Some(sni) = &t.stealth_sni {
let reality_enabled = server_cfg.reality.as_ref().map(|r| r.enabled).unwrap_or(false);
if !reality_enabled && !sni.is_empty() {
if !sni.is_empty() {
query_params.push(format!("sni={}", sni));
}
}
@ -998,11 +1502,6 @@ async fn run_app() -> Result<()> {
let listen_addrs = server_cfg.listen.addresses();
println!("{} Starting server on {:?}", "[ostp]".cyan().bold(), listen_addrs);
if let Some(ref reality) = server_cfg.reality {
if reality.enabled {
println!("{} Reality mode enabled (dest: {})", "[ostp]".cyan().bold(), reality.dest);
}
}
let debug = server_cfg.debug.unwrap_or(false);
let outbound = server_cfg.outbound.map(|o| ostp_server::OutboundConfig {
enabled: o.enabled,
@ -1034,20 +1533,7 @@ async fn run_app() -> Result<()> {
listen: f.listen.unwrap_or_else(|| "0.0.0.0:443".to_string()),
target: f.target.unwrap_or_else(|| "127.0.0.1:8080".to_string()),
});
let mut rq = None;
let mut rc = None;
if let Some(r) = server_cfg.reality {
if r.enabled {
rq = Some(format!("?security=reality&sni={}&pbk={}&sid={}&type=udp", r.sni_list.first().unwrap_or(&String::new()), r.pbk, r.sid));
rc = Some(ostp_server::RealityServerConfig {
sni_list: r.sni_list.clone(),
dest: r.dest,
private_key: r.private_key,
pbk: r.pbk,
sid: r.sid,
});
}
}
let access_keys_meta = server_cfg.access_keys.into_iter().map(|uc| {
(uc.key(), ostp_server::api::UserMeta {
name: uc.name(),
@ -1058,7 +1544,7 @@ async fn run_app() -> Result<()> {
// Build DNS config and set owndns flag in subscribe links if DNS enabled
let dns_cfg = server_cfg.dns;
// Pass all listen addresses for multi-listener support
ostp_server::run_server(listen_addrs, Some(host), access_keys_meta, outbound, api_config, fallback_config, debug, rq, rc, dns_cfg, Some(args.config)).await?;
ostp_server::run_server(listen_addrs, Some(host), access_keys_meta, outbound, api_config, fallback_config, debug, dns_cfg, Some(args.config)).await?;
}
AppMode::Client(client_cfg) => {
println!("{}", include_str!("../../docs/banner.txt").blue().bold());
@ -1169,9 +1655,7 @@ fn cmd_update() -> Result<()> {
async fn run_client_directly(client_cfg: ClientConfig) -> Result<()> {
let is_tun_enabled = client_cfg.tun.as_ref().map(|t| t.enable).unwrap_or(false);
let mode_str = if is_tun_enabled { "tun" } else { "proxy" };
println!("{} Starting client (mode={}, server={})", "[ostp]".cyan().bold(), mode_str.yellow(), client_cfg.server.cyan());
let reality_cfg = client_cfg.reality.as_ref();
let client_conf = ostp_client::config::ClientConfig {
println!("{} Starting client (mode={}, server={})", "[ostp]".cyan().bold(), mode_str.yellow(), client_cfg.server.cyan()); let client_conf = ostp_client::config::ClientConfig {
mode: if is_tun_enabled { "tun".to_string() } else { "proxy".to_string() },
tun_stack: "native".to_string(),
debug: client_cfg.debug.unwrap_or(false),
@ -1188,14 +1672,6 @@ async fn run_client_directly(client_cfg: ClientConfig) -> Result<()> {
bind_addr: client_cfg.socks5_bind.clone().unwrap_or_else(|| "127.0.0.1:1088".to_string()),
connect_timeout_ms: 5000,
},
reality: ostp_client::config::RealityConfig {
enabled: reality_cfg.map(|t| t.enabled).unwrap_or(false),
sni: reality_cfg.map(|t| t.sni.clone()).unwrap_or_default(),
fp: reality_cfg.map(|t| t.fp.clone()).unwrap_or_default(),
pbk: reality_cfg.map(|t| t.pbk.clone()).unwrap_or_default(),
sid: reality_cfg.map(|t| t.sid.clone()).unwrap_or_default(),
spx: reality_cfg.map(|t| t.spx.clone()).unwrap_or_default(),
},
exclusions: ostp_client::config::ExclusionConfig {
domains: client_cfg.exclude.as_ref().and_then(|e| e.domains.clone()).unwrap_or_default(),
ips: client_cfg.exclude.as_ref().and_then(|e| e.ips.clone()).unwrap_or_default(),

View File

@ -25,7 +25,7 @@ if (Test-Path "config.json") {
}
Write-Host "========================================================"
Write-Host " OSTP Installer"
Write-Host " OSTP Installer v3"
Write-Host "========================================================"
Write-Host "Install directory: $InstallDir"
@ -110,74 +110,7 @@ if ($extractedFiles.Count -gt 0) {
Remove-Item $zipPath -Force
Remove-Item $extractPath -Recurse -Force
# 5. Update detection
$configPath = Join-Path $InstallDir "config.json"
if (Test-Path $configPath) {
Write-Host "--------------------------------------------------------"
Write-Host "Existing configuration found. Binary updated to $tag."
Write-Host "--------------------------------------------------------"
exit 0
}
# 6. Interactive setup
Write-Host "--------------------------------------------------------"
Write-Host "Select mode:"
Write-Host " 1) Server"
Write-Host " 2) Client"
Write-Host "--------------------------------------------------------"
$mode = Read-Host "Choice [1-2]"
Push-Location $InstallDir
if ($mode -eq "1") {
Write-Host "Initializing server configuration..."
& .\ostp.exe --init server --config config.json
$config = Get-Content "config.json" -Raw | ConvertFrom-Json
$listen = Read-Host "Listen address [default: 0.0.0.0:50000]"
if ($listen) { $config.listen = $listen }
$keyCount = Read-Host "Number of access keys [default: 1]"
if (-not $keyCount) { $keyCount = 1 }
if ([int]$keyCount -gt 1) {
Write-Host "Generating $keyCount access keys..."
$keys = & .\ostp.exe -g -c $keyCount
$config.access_keys = $keys -split "`r`n" | Where-Object { $_ -ne "" }
}
$config | ConvertTo-Json -Depth 10 | Set-Content "config.json"
Write-Host "Server configuration saved: $(Join-Path $InstallDir 'config.json')"
} elseif ($mode -eq "2") {
Write-Host "Initializing client configuration..."
& .\ostp.exe --init client --config config.json
$config = Get-Content "config.json" -Raw | ConvertFrom-Json
$server = Read-Host "Server address (host:port)"
if ($server) { $config.server = $server }
$key = Read-Host "Access key (blank to generate)"
if (-not $key) {
$key = & .\ostp.exe -g
Write-Host "Generated key: $key"
}
$config.access_key = $key.Trim()
$socks = Read-Host "Local proxy address [default: 127.0.0.1:1088]"
if ($socks) { $config.socks5_bind = $socks }
$config | ConvertTo-Json -Depth 10 | Set-Content "config.json"
Write-Host "Client configuration saved: $(Join-Path $InstallDir 'config.json')"
} else {
Write-Error "Invalid selection."
Pop-Location
exit 1
}
Pop-Location
# 7. PATH registration
# 5. PATH registration
Write-Host "--------------------------------------------------------"
Write-Host "Registering in system PATH..."
$targetScope = if ($isAdmin) { [EnvironmentVariableTarget]::Machine } else { [EnvironmentVariableTarget]::User }
@ -190,8 +123,20 @@ if ($sysPath -notlike "*$InstallDir*") {
Write-Host "$InstallDir already in PATH."
}
Write-Host "--------------------------------------------------------"
Write-Host "Installation complete."
Write-Host " Binary: ostp"
Write-Host " Config: $(Join-Path $InstallDir 'config.json')"
Write-Host "--------------------------------------------------------"
# 6. Update detection
$configPath = Join-Path $InstallDir "config.json"
if (Test-Path $configPath) {
Write-Host "--------------------------------------------------------"
Write-Host "Existing configuration found. Binary updated to $tag."
Write-Host "--------------------------------------------------------"
exit 0
}
# 7. First install: delegate to the built-in setup wizard
Write-Host ""
Write-Host "No configuration found. Launching setup wizard..."
Write-Host ""
Push-Location $InstallDir
& .\ostp.exe --setup
Pop-Location

View File

@ -16,7 +16,7 @@ LEGACY_PATHS=(
)
echo "========================================================"
echo " OSTP Installer v2"
echo " OSTP Installer v3"
echo "========================================================"
# Verify root
@ -46,8 +46,6 @@ migrate_legacy() {
cp "$old_dir/ostp" "$INSTALL_DIR/ostp"
fi
echo "[migrate] Legacy files preserved at $old_dir (remove manually if no longer needed)"
}
@ -55,7 +53,6 @@ migrate_legacy() {
if [ -f "$INSTALL_DIR/config.json" ] && [ ! -f "$CONFIG_FILE" ]; then
echo "[migrate] Moving config from $INSTALL_DIR/config.json -> $CONFIG_FILE"
cp "$INSTALL_DIR/config.json" "$CONFIG_FILE"
# Keep old file as backup
mv "$INSTALL_DIR/config.json" "$INSTALL_DIR/config.json.bak"
fi
@ -106,9 +103,7 @@ if [ -z "$LATEST_RELEASE" ] || [[ "$LATEST_RELEASE" == *"null"* ]]; then
fi
else
ARCHIVE_NAME="ostp-linux-${ARCH}.tar.gz"
GUI_ARCHIVE_NAME="ostp-gui-linux-${ARCH}.tar.gz"
DOWNLOAD_URL="https://github.com/${GITHUB_REPO}/releases/download/${LATEST_RELEASE}/${ARCHIVE_NAME}"
GUI_DOWNLOAD_URL="https://github.com/${GITHUB_REPO}/releases/download/${LATEST_RELEASE}/${GUI_ARCHIVE_NAME}"
echo "Downloading: $ARCHIVE_NAME ($LATEST_RELEASE)"
TEMP_TAR="/tmp/ostp_temp.tar.gz"
@ -134,21 +129,10 @@ else
exit 1
fi
# We don't download GUI binary immediately, we will do it if the user selects Client + GUI mode
# ── Create global symlink ────────────────────────────────────────────
ln -sf "$INSTALL_DIR/ostp" "$BIN_LINK"
echo "Symlink created: $BIN_LINK -> $INSTALL_DIR/ostp"
echo "You can now run 'ostp' from anywhere."
# ── Detect public IP ─────────────────────────────────────────────────
SERVER_IP=$(curl -4s https://ifconfig.me 2>/dev/null \
|| curl -4s https://api.ipify.org 2>/dev/null \
|| curl -4s https://icanhazip.com 2>/dev/null \
|| hostname -I | awk '{print $1}')
# ── Update detection ─────────────────────────────────────────────────
@ -234,72 +218,6 @@ EOF
fi
fi
# ── Panel setup prompt (if not yet configured) ──
PANEL_USERNAME=$(python3 -c "
import json
with open('$CONFIG_FILE') as f:
raw = f.read()
lines = [l for l in raw.split('\n') if not l.strip().startswith('//')]
cfg = json.loads('\n'.join(lines))
print(cfg.get('api', {}).get('username', ''))
" 2>/dev/null)
if [ -z "$PANEL_USERNAME" ] && python3 -c "
import json
with open('$CONFIG_FILE') as f:
raw = f.read()
lines = [l for l in raw.split('\n') if not l.strip().startswith('//')]
cfg = json.loads('\n'.join(lines))
exit(0 if cfg.get('mode') == 'server' else 1)
" 2>/dev/null; then
echo ""
echo "Web panel is not configured."
read -p "Set up web panel now? [y/N]: " SETUP_PANEL
if [[ "$SETUP_PANEL" =~ ^[Yy]$ ]]; then
read -p "Panel port [default: 9090]: " PANEL_PORT
PANEL_PORT=${PANEL_PORT:-9090}
RANDOM_PATH=$(tr -dc 'a-zA-Z0-9' < /dev/urandom | head -c 8)
read -p "WebPath [leave empty for random: $RANDOM_PATH]: " WEBPATH
WEBPATH=${WEBPATH:-$RANDOM_PATH}
read -p "Username [default: admin]: " USERNAME
USERNAME=${USERNAME:-admin}
RANDOM_PASS=$(tr -dc 'a-zA-Z0-9' < /dev/urandom | head -c 12)
read -p "Password [leave empty for random: $RANDOM_PASS]: " PASSWORD
PASSWORD=${PASSWORD:-$RANDOM_PASS}
PASS_HASH=$(python3 -c "import hashlib; print(hashlib.sha256('$PASSWORD'.encode()).hexdigest())")
python3 << PYEOF
import json
with open('$CONFIG_FILE') as f:
raw = f.read()
lines = [l for l in raw.split('\n') if not l.strip().startswith('//')]
cfg = json.loads('\n'.join(lines))
if 'api' not in cfg:
cfg['api'] = {}
cfg['api']['enabled'] = True
cfg['api']['bind'] = '0.0.0.0:$PANEL_PORT'
cfg['api']['webpath'] = '$WEBPATH'
cfg['api']['username'] = '$USERNAME'
cfg['api']['password_hash'] = '$PASS_HASH'
with open('$CONFIG_FILE', 'w') as f:
json.dump(cfg, f, indent=2, ensure_ascii=False)
print('[ok] Panel configured.')
PYEOF
echo ""
echo "========================================================"
echo "Panel configured!"
echo "URL: http://$SERVER_IP:$PANEL_PORT/$WEBPATH/"
echo "Username: $USERNAME"
echo "Password: $PASSWORD"
echo "========================================================"
fi
fi
if systemctl is-active --quiet ostp.service 2>/dev/null; then
echo "Restarting ostp service..."
systemctl restart ostp.service
@ -313,246 +231,11 @@ PYEOF
exit 0
fi
# ── First install: delegate to the built-in setup wizard ─────────────
# ── Interactive setup (first install) ────────────────────────────────
echo "--------------------------------------------------------"
echo "Select mode:"
echo " 1) Server"
echo " 2) Client"
echo " 3) Relay"
echo " 4) Server + Web Panel"
echo " 5) Client + GUI"
echo "--------------------------------------------------------"
read -p "Choice [1-5]: " NODE_MODE
echo ""
echo "No configuration found. Launching setup wizard..."
echo ""
cd "$INSTALL_DIR"
if [ "$NODE_MODE" == "1" ]; then
echo "Initializing server configuration..."
./ostp --init server --config "$CONFIG_FILE"
read -p "Listen address [default: 0.0.0.0:50000]: " LISTEN_ADDR
if [ -n "$LISTEN_ADDR" ]; then
sed -i "s/\"listen\": \".*\"/\"listen\": \"$LISTEN_ADDR\"/g" "$CONFIG_FILE"
fi
read -p "Number of access keys [default: 1]: " KEYS_COUNT
KEYS_COUNT=${KEYS_COUNT:-1}
if [ "$KEYS_COUNT" -gt 1 ]; then
echo "Generating $KEYS_COUNT access keys..."
NEW_KEYS=$(./ostp -g -c "$KEYS_COUNT" | sed 's/^/ "/;s/$/"/' | paste -sd ',' | sed 's/,/,\n/g')
# Replace the access_keys array
python3 -c "
import json, subprocess, sys
with open('$CONFIG_FILE') as f:
content = f.read()
# Strip comments for parsing
lines = [l for l in content.split('\n') if not l.strip().startswith('//')]
cfg = json.loads('\n'.join(lines))
keys = subprocess.check_output(['$INSTALL_DIR/ostp', '-g', '-c', '$KEYS_COUNT']).decode().strip().split('\n')
cfg['access_keys'] = keys
with open('$CONFIG_FILE', 'w') as f:
json.dump(cfg, f, indent=2)
" 2>/dev/null || echo "[warn] Key injection via python3 failed. Edit config manually."
fi
echo ""
echo "Server access key(s):"
grep -oP '"[0-9a-f]{32}"' "$CONFIG_FILE" | tr -d '"' | while read key; do
echo " $key"
done
echo ""
echo "Server configuration saved: $CONFIG_FILE"
elif [ "$NODE_MODE" == "4" ]; then
echo "Initializing server configuration..."
./ostp --init server --config "$CONFIG_FILE"
read -p "Listen address [default: 0.0.0.0:50000]: " LISTEN_ADDR
if [ -n "$LISTEN_ADDR" ]; then
sed -i "s/\"listen\": \".*\"/\"listen\": \"$LISTEN_ADDR\"/g" "$CONFIG_FILE"
fi
# Panel Setup
echo "--- Web Panel Setup ---"
read -p "Panel port [default: 9090]: " PANEL_PORT
PANEL_PORT=${PANEL_PORT:-9090}
RANDOM_PATH=$(tr -dc 'a-zA-Z0-9' < /dev/urandom | head -c 8)
read -p "WebPath (leave empty for random: /$RANDOM_PATH/): " WEBPATH
WEBPATH=${WEBPATH:-$RANDOM_PATH}
read -p "Username [default: admin]: " USERNAME
USERNAME=${USERNAME:-admin}
RANDOM_PASS=$(tr -dc 'a-zA-Z0-9' < /dev/urandom | head -c 12)
read -p "Password (leave empty for random: $RANDOM_PASS): " PASSWORD
PASSWORD=${PASSWORD:-$RANDOM_PASS}
# Hash password with python
PASS_HASH=$(python3 -c "import hashlib; print(hashlib.sha256('$PASSWORD'.encode()).hexdigest())")
# Inject into config
python3 -c "
import json
with open('$CONFIG_FILE') as f:
lines = [l for l in f.read().split('\n') if not l.strip().startswith('//')]
cfg = json.loads('\n'.join(lines))
if 'api' not in cfg:
cfg['api'] = {}
cfg['api']['enabled'] = True
cfg['api']['bind'] = '0.0.0.0:' + str('$PANEL_PORT')
cfg['api']['webpath'] = '$WEBPATH'
cfg['api']['username'] = '$USERNAME'
cfg['api']['password_hash'] = '$PASS_HASH'
with open('$CONFIG_FILE', 'w') as f:
json.dump(cfg, f, indent=2)
" 2>/dev/null || echo "[warn] Failed to configure panel via python. Edit config manually."
echo ""
echo "========================================================"
echo "Panel installed successfully!"
echo "URL: http://$SERVER_IP:$PANEL_PORT/$WEBPATH/"
echo "Username: $USERNAME"
echo "Password: $PASSWORD"
echo "========================================================"
elif [ "$NODE_MODE" == "2" ] || [ "$NODE_MODE" == "5" ]; then
echo "Initializing client configuration..."
./ostp --init client --config "$CONFIG_FILE"
read -p "Server address (host:port): " REMOTE_SERVER
if [ -n "$REMOTE_SERVER" ]; then
sed -i "s/\"server\": \"127.0.0.1:50000\"/\"server\": \"$REMOTE_SERVER\"/g" "$CONFIG_FILE"
else
echo "[warn] No server address provided. Using default (127.0.0.1:50000)."
fi
read -p "Access key: " ACCESS_KEY
if [ -z "$ACCESS_KEY" ]; then
ACCESS_KEY=$(./ostp -g)
echo "Generated key: $ACCESS_KEY"
fi
sed -i "s/\"access_key\": \"[^\"]*\"/\"access_key\": \"$ACCESS_KEY\"/g" "$CONFIG_FILE"
read -p "Local proxy address [default: 127.0.0.1:1088]: " SOCKS_BIND
if [ -n "$SOCKS_BIND" ]; then
sed -i "s/\"socks5_bind\": \"127.0.0.1:1088\"/\"socks5_bind\": \"$SOCKS_BIND\"/g" "$CONFIG_FILE"
fi
echo "Client configuration saved: $CONFIG_FILE"
if [ "$NODE_MODE" == "5" ]; then
echo "Installing GUI..."
if [ -n "$LATEST_RELEASE" ]; then
TEMP_GUI_TAR="/tmp/ostp_gui_temp.tar.gz"
echo "Downloading GUI: $GUI_ARCHIVE_NAME ($LATEST_RELEASE)"
HTTP_CODE_GUI=$(curl -sL -w "%{http_code}" "$GUI_DOWNLOAD_URL" -o "$TEMP_GUI_TAR")
if [ "$HTTP_CODE_GUI" -eq 200 ]; then
tar -xzf "$TEMP_GUI_TAR" -C "$INSTALL_DIR" ostp-gui 2>/dev/null || tar -xzf "$TEMP_GUI_TAR" -C "$INSTALL_DIR"
rm -f "$TEMP_GUI_TAR"
if [ -f "$INSTALL_DIR/ostp-gui" ]; then
chmod +x "$INSTALL_DIR/ostp-gui"
ln -sf "$INSTALL_DIR/ostp-gui" "/usr/local/bin/ostp-gui"
echo "GUI binary installed at $INSTALL_DIR/ostp-gui"
# Create desktop entry
DESKTOP_FILE="/usr/share/applications/ostp-gui.desktop"
cat <<EOF > "$DESKTOP_FILE"
[Desktop Entry]
Name=OSTP Client
Comment=Ospab Stealth Transport Protocol Client
Exec=/usr/local/bin/ostp-gui
Icon=utilities-terminal
Terminal=false
Type=Application
Categories=Network;Utility;
EOF
echo "Desktop entry created at $DESKTOP_FILE"
else
echo "[error] GUI binary not found in archive."
fi
else
echo "[error] Download failed for GUI (HTTP $HTTP_CODE_GUI)."
rm -f "$TEMP_GUI_TAR"
fi
else
echo "[notice] Automatic download not possible. Install GUI manually."
fi
fi
elif [ "$NODE_MODE" == "3" ]; then
echo "Initializing relay configuration..."
./ostp --init relay --config "$CONFIG_FILE"
read -p "Listen address [default: 0.0.0.0:50000]: " LISTEN_ADDR
if [ -n "$LISTEN_ADDR" ]; then
sed -i "s/\"listen\": \".*\"/\"listen\": \"$LISTEN_ADDR\"/g" "$CONFIG_FILE"
fi
read -p "Upstream server IP/port (e.g. 1.2.3.4:50000): " UPSTREAM_ADDR
if [ -n "$UPSTREAM_ADDR" ]; then
sed -i "s/\"upstream_tcp\": \".*\"/\"upstream_tcp\": \"$UPSTREAM_ADDR\"/g" "$CONFIG_FILE"
sed -i "s/\"upstream_udp\": \".*\"/\"upstream_udp\": \"$UPSTREAM_ADDR\"/g" "$CONFIG_FILE"
fi
read -p "Upstream API URL (e.g. http://1.2.3.4:9090): " UPSTREAM_API
if [ -n "$UPSTREAM_API" ]; then
sed -i "s|\"upstream_api_url\": \".*\"|\"upstream_api_url\": \"$UPSTREAM_API\"|g" "$CONFIG_FILE"
fi
read -p "Upstream API token: " UPSTREAM_TOKEN
if [ -n "$UPSTREAM_TOKEN" ]; then
sed -i "s/\"upstream_api_token\": \".*\"/\"upstream_api_token\": \"$UPSTREAM_TOKEN\"/g" "$CONFIG_FILE"
fi
echo "Relay configuration saved: $CONFIG_FILE"
else
echo "[error] Invalid selection."
exit 1
fi
# ── Register systemd service ─────────────────────────────────────────
echo "Registering systemd service..."
cat <<EOF > /etc/systemd/system/ostp.service
[Unit]
Description=OSTP Stealth Transport Protocol
After=network.target
Wants=network-online.target
[Service]
Type=simple
User=root
WorkingDirectory=$INSTALL_DIR
ExecStart=$INSTALL_DIR/ostp --config $CONFIG_FILE
Restart=always
RestartSec=5
LimitNOFILE=65535
Environment=RUST_LOG=info
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable ostp.service >/dev/null 2>&1
echo ""
echo "========================================================"
echo " Installation complete"
echo "========================================================"
echo ""
echo " Binary: $INSTALL_DIR/ostp"
echo " Command: ostp (available globally)"
echo " Config: $CONFIG_FILE"
echo " Service: systemctl start ostp"
echo " Logs: journalctl -u ostp -f"
echo ""
echo " Quick commands:"
echo " ostp --check Validate configuration"
echo " ostp --generate-key Generate access key"
echo " ostp --links Print client share links"
echo " systemctl status ostp Service status"
echo ""
exec ./ostp --setup --config "$CONFIG_FILE"

3
test_addr.rs Normal file
View File

@ -0,0 +1,3 @@
use std::net::SocketAddr; fn main() { println!(\
:?
\, \[::1]:80\.parse::<SocketAddr>()); }