diff --git a/ostp-client/src/bridge.rs b/ostp-client/src/bridge.rs index 8252c2b..a5a0565 100644 --- a/ostp-client/src/bridge.rs +++ b/ostp-client/src/bridge.rs @@ -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>, @@ -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,139 +856,161 @@ 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 = match tokio::net::lookup_host(&self.server_addr).await { + let mut resolved_addrs: Vec = 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))?; - let target_ip = target_addr.ip(); - let port = target_addr.port(); + resolved_addrs.sort_by_key(|addr| if addr.is_ipv6() { 0 } else { 1 }); - tx.send(UiEvent::Log(format!("Connecting to remote server: {}...", target_addr))).await.ok(); + let mut last_err = anyhow::anyhow!("no IP addresses resolved for {}", self.server_addr); - let socket = match self.try_connect_transport(target_ip, port).await { - Ok(sock) => sock, - Err(e) => { + for target_addr in resolved_addrs { + let target_ip = target_addr.ip(); + let port = target_addr.port(); + + tx.send(UiEvent::Log(format!("Connecting to remote server: {}...", target_addr))).await.ok(); + + let socket = match self.try_connect_transport(target_ip, port).await { + Ok(sock) => sock, + 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).await; + match self.try_connect_transport(std::net::IpAddr::V6(nat64_ipv6), port).await { + Ok(sock) => sock, + Err(fallback_err) => { + last_err = anyhow::anyhow!("Direct IPv4 failed: {}. NAT64 fallback failed: {}", e, fallback_err); + continue; + } + } + } else { + last_err = anyhow::anyhow!("Connection to {} failed: {}", target_addr, e); + continue; + } + } + }; + + 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 = 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, + _ => { + 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; + + let is_uot = matches!(socket, crate::transport::Transport::Uot { .. }); + 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(); + } + 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)) => { + size = n; + success = true; + break; + } + _ => {} + } + } + + let (final_socket, size) = if success { + (socket, size) + } else { 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); + tx.send(UiEvent::Log("Direct IPv4 handshake timed out. Trying NAT64 fallback...".to_string())).await.ok(); + 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)); + Ok(fallback_socket) => { + let mut fallback_success = false; + for attempt in 0..4 { + if attempt > 0 { + tx.send(UiEvent::Log(format!("NAT64 handshake attempt {} lost. Retransmitting...", attempt))).await.ok(); + } + 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; + fallback_success = true; + break; + } + _ => {} + } + } + if fallback_success { + tx.send(UiEvent::Log("NAT64 fallback handshake successful!".to_string())).await.ok(); + (fallback_socket, size) + } else { + last_err = anyhow::anyhow!("NAT64 handshake failed after 4 attempts"); + continue; + } + } + Err(e) => { + last_err = anyhow::anyhow!("NAT64 fallback socket creation failed: {}", e); + continue; } } } else { - return Err(e); + 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]); + 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); - // Connection to remote is handled inside try_connect_transport - - let start = Instant::now(); - let action = machine.on_event(OstpEvent::Start)?; - let handshake_frame = match action { - ProtocolAction::SendDatagram(frame) => frame, - _ => anyhow::bail!("protocol did not emit handshake datagram"), - }; - 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?; - 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)) => { - size = n; - success = true; - break; - } - _ => {} // retry on timeout or error - } + return Ok((socket, machine, rtt_ms)); } - let (final_socket, size) = if success { - (socket, size) - } 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); - match self.try_connect_transport(std::net::IpAddr::V6(nat64_ipv6), port).await { - Ok(fallback_socket) => { - let mut fallback_success = false; - for attempt in 0..4 { - 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?; - match timeout(Duration::from_millis(1200), fallback_socket.recv(&mut buf)).await { - Ok(Ok(n)) => { - size = n; - fallback_success = true; - break; - } - _ => {} - } - } - if fallback_success { - 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")); - } - } - Err(e) => return Err(anyhow::anyhow!("NAT64 fallback socket creation failed: {}", e)), - } - } else { - return Err(anyhow::anyhow!("Direct handshake failed after 3 attempts")); - } - }; - 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))?; - 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)) + 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 { 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::(1024); + let (tx_in, rx_in) = tokio::sync::mpsc::channel::(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, ) diff --git a/ostp-client/src/config.rs b/ostp-client/src/config.rs index 0a9b36f..45bf19b 100644 --- a/ostp-client/src/config.rs +++ b/ostp-client/src/config.rs @@ -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, exclude: Option, mux: Option, - reality: Option, transport: Option, gui: Option, } @@ -214,15 +197,7 @@ struct RawMuxSection { sessions: Option, } -#[derive(Debug, Deserialize)] -struct RawRealitySection { - enabled: Option, - sni: Option, - fp: Option, - pbk: Option, - sid: Option, - spx: Option, -} + 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(), diff --git a/ostp-client/src/logging.rs b/ostp-client/src/logging.rs index 4380c5d..661962e 100644 --- a/ostp-client/src/logging.rs +++ b/ostp-client/src/logging.rs @@ -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 `.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 { + // 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 tokio::net::TcpSocket::new_v4(), diff --git a/ostp-client/src/tunnel/proxy.rs b/ostp-client/src/tunnel/proxy.rs index 7d3e4ab..c785782 100644 --- a/ostp-client/src/tunnel/proxy.rs +++ b/ostp-client/src/tunnel/proxy.rs @@ -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(¤t_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, 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, 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?; diff --git a/ostp-client/src/tunnel/udp_nat.rs b/ostp-client/src/tunnel/udp_nat.rs index 52a6d9d..e478f6d 100644 --- a/ostp-client/src/tunnel/udp_nat.rs +++ b/ostp-client/src/tunnel/udp_nat.rs @@ -17,28 +17,42 @@ pub async fn run_udp_nat( // map from internal client src to a channel that sends (payload, external_dst) let mut sessions: HashMap, SocketAddr)>> = HashMap::new(); - while let Some((payload, src, dst)) = rx.next().await { - if payload.is_empty() { continue; } + let mut cleanup_tick = tokio::time::interval(std::time::Duration::from_secs(60)); - if !sessions.contains_key(&src) { - let (session_tx, mut session_rx) = mpsc::channel::<(Vec, SocketAddr)>(100000); - sessions.insert(src, session_tx); + loop { + tokio::select! { + packet = rx.next() => { + match packet { + Some((payload, src, dst)) => { + if payload.is_empty() { continue; } - let proxy_addr_clone = proxy_addr.clone(); - let tx_clone = tx.clone(); - - tokio::spawn(async move { - if debug { tracing::info!("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 !sessions.contains_key(&src) { + let (session_tx, mut session_rx) = mpsc::channel::<(Vec, SocketAddr)>(100000); + sessions.insert(src, session_tx); + + let proxy_addr_clone = proxy_addr.clone(); + let tx_clone = tx.clone(); + + tokio::spawn(async move { + tracing::debug!("Starting UDP NAT session for {}", src); + let res = start_udp_session(src, proxy_addr_clone, &mut session_rx, tx_clone).await; + if res.is_err() { + tracing::debug!("UDP NAT session for {} ended: {:?}", src, res.err()); + } + }); + } + + if let Some(sender) = sessions.get(&src) { + if sender.send((payload, dst)).await.is_err() { + sessions.remove(&src); + } + } + } + None => break, } - }); - } - - if let Some(sender) = sessions.get(&src) { - if sender.send((payload, dst)).await.is_err() { - sessions.remove(&src); + } + _ = cleanup_tick.tick() => { + sessions.retain(|_, sender| !sender.is_closed()); } } } @@ -98,6 +112,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]; diff --git a/ostp-core/src/crypto/mod.rs b/ostp-core/src/crypto/mod.rs index a08deb4..deb9b31 100644 --- a/ostp-core/src/crypto/mod.rs +++ b/ostp-core/src/crypto/mod.rs @@ -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}; diff --git a/ostp-core/src/protocol.rs b/ostp-core/src/protocol.rs index d421e07..a2dd6bb 100644 --- a/ostp-core/src/protocol.rs +++ b/ostp-core/src/protocol.rs @@ -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, diff --git a/ostp-flutter/android/app/src/main/AndroidManifest.xml b/ostp-flutter/android/app/src/main/AndroidManifest.xml index 99a2676..7fb4afc 100644 --- a/ostp-flutter/android/app/src/main/AndroidManifest.xml +++ b/ostp-flutter/android/app/src/main/AndroidManifest.xml @@ -32,6 +32,9 @@ + + + diff --git a/ostp-flutter/android/app/src/main/kotlin/com/ospab/ostp_client/OstpTileService.kt b/ostp-flutter/android/app/src/main/kotlin/com/ospab/ostp_client/OstpTileService.kt index 8650032..4010029 100644 --- a/ostp-flutter/android/app/src/main/kotlin/com/ospab/ostp_client/OstpTileService.kt +++ b/ostp-flutter/android/app/src/main/kotlin/com/ospab/ostp_client/OstpTileService.kt @@ -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) diff --git a/ostp-flutter/android/app/src/main/kotlin/com/ospab/ostp_client/OstpVpnService.kt b/ostp-flutter/android/app/src/main/kotlin/com/ospab/ostp_client/OstpVpnService.kt index 8633383..098d243 100644 --- a/ostp-flutter/android/app/src/main/kotlin/com/ospab/ostp_client/OstpVpnService.kt +++ b/ostp-flutter/android/app/src/main/kotlin/com/ospab/ostp_client/OstpVpnService.kt @@ -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 @@ -162,8 +198,13 @@ class OstpVpnService : VpnService() { .addRoute("::", 0) .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() } diff --git a/ostp-flutter/android/app/src/main/kotlin/net/ostp/client/OstpClientSdk.kt b/ostp-flutter/android/app/src/main/kotlin/net/ostp/client/OstpClientSdk.kt index 173370b..a05634b 100644 --- a/ostp-flutter/android/app/src/main/kotlin/net/ostp/client/OstpClientSdk.kt +++ b/ostp-flutter/android/app/src/main/kotlin/net/ostp/client/OstpClientSdk.kt @@ -46,4 +46,8 @@ object OstpClientSdk { @Keep @JvmStatic external fun addLog(logMsg: String) + + @Keep + @JvmStatic + external fun notifyNetworkChanged() } diff --git a/ostp-flutter/android/app/src/main/res/mipmap-hdpi/launcher_icon.png b/ostp-flutter/android/app/src/main/res/mipmap-hdpi/launcher_icon.png index 65ca8b0..d35010e 100644 Binary files a/ostp-flutter/android/app/src/main/res/mipmap-hdpi/launcher_icon.png and b/ostp-flutter/android/app/src/main/res/mipmap-hdpi/launcher_icon.png differ diff --git a/ostp-flutter/android/app/src/main/res/mipmap-mdpi/launcher_icon.png b/ostp-flutter/android/app/src/main/res/mipmap-mdpi/launcher_icon.png index 1a2e743..efd0319 100644 Binary files a/ostp-flutter/android/app/src/main/res/mipmap-mdpi/launcher_icon.png and b/ostp-flutter/android/app/src/main/res/mipmap-mdpi/launcher_icon.png differ diff --git a/ostp-flutter/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png b/ostp-flutter/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png index d4af7ca..a05c278 100644 Binary files a/ostp-flutter/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png and b/ostp-flutter/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png differ diff --git a/ostp-flutter/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png b/ostp-flutter/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png index 9f2b10b..6bcf903 100644 Binary files a/ostp-flutter/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png and b/ostp-flutter/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png differ diff --git a/ostp-flutter/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png b/ostp-flutter/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png index f7e472b..d06daed 100644 Binary files a/ostp-flutter/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png and b/ostp-flutter/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png differ diff --git a/ostp-flutter/android_icon.png b/ostp-flutter/android_icon.png new file mode 100644 index 0000000..c4ba45b Binary files /dev/null and b/ostp-flutter/android_icon.png differ diff --git a/ostp-flutter/lib/ui/home_screen.dart b/ostp-flutter/lib/ui/home_screen.dart index df4e814..273fd0a 100644 --- a/ostp-flutter/lib/ui/home_screen.dart +++ b/ostp-flutter/lib/ui/home_screen.dart @@ -54,6 +54,7 @@ class _HomeScreenState extends State with TickerProviderStateMixin { duration: const Duration(seconds: 4), ); _checkInitialState(); + _startPolling(); } Future _checkInitialState() async { @@ -413,57 +414,65 @@ class _HomeScreenState extends State 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 metricsJson = await platform.invokeMethod('getMetrics'); - if (metricsJson != null && metricsJson.isNotEmpty) { - final Map parsed = jsonDecode(metricsJson); - final bytesSent = parsed['bytes_sent'] as int? ?? 0; - final bytesRecv = parsed['bytes_recv'] as int? ?? 0; - final connState = parsed['connection_state'] as int? ?? 2; - final rttMs = parsed['rtt_ms'] as int? ?? 0; - - if (connState == 0 && _state != ConnectionStateEnum.disconnected) { - try { - await platform.invokeMethod('stopTunnel'); - } catch (e) { - debugPrint("Failed to stop background tunnel: $e"); - } - _setDisconnected(); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Connection failed. Check logs for details.')), - ); - } - return; - } - - if (mounted) { - setState(() { - _download = _formatBytes(bytesRecv); - _upload = _formatBytes(bytesSent); - if (rttMs > 0 && !_isCheckingPing) { - _pingText = 'Server Ping: $rttMs ms'; - if (rttMs < 100) { - _pingColor = const Color(0xFF22D3A5); - } else if (rttMs < 250) { - _pingColor = Colors.amberAccent; - } else { - _pingColor = Colors.redAccent; - } + 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 parsed = jsonDecode(metricsJson); + final bytesSent = parsed['bytes_sent'] as int? ?? 0; + final bytesRecv = parsed['bytes_recv'] as int? ?? 0; + final connState = parsed['connection_state'] as int? ?? 2; + final rttMs = parsed['rtt_ms'] as int? ?? 0; + + if (connState == 0) { + try { + await platform.invokeMethod('stopTunnel'); + } catch (e) { + debugPrint("Failed to stop background tunnel: $e"); } - }); + _setDisconnected(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Connection failed. Check logs for details.')), + ); + } + return; + } + + if (mounted) { + setState(() { + _download = _formatBytes(bytesRecv); + _upload = _formatBytes(bytesSent); + if (rttMs > 0 && !_isCheckingPing) { + _pingText = 'Server Ping: $rttMs ms'; + if (rttMs < 100) { + _pingColor = const Color(0xFF22D3A5); + } else if (rttMs < 250) { + _pingColor = Colors.amberAccent; + } else { + _pingColor = Colors.redAccent; + } + } + }); + } } } } catch (e) { - debugPrint("Failed to get metrics: $e"); + debugPrint("Failed to get state/metrics: $e"); } }); } @@ -507,7 +516,7 @@ class _HomeScreenState extends State 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 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,29 +811,33 @@ class _HomeScreenState extends State with TickerProviderStateMixin { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'CONNECTION TEST', - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.bold, - color: Colors.white38, - letterSpacing: 0.8, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'CONNECTION TEST', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: Colors.white38, + letterSpacing: 0.8, + ), ), - ), - const SizedBox(height: 4), - Text( - _pingText, - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.bold, - color: _pingColor, + const SizedBox(height: 4), + Text( + _pingText, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + color: _pingColor, + ), ), - ), - ], + ], + ), ), + const SizedBox(width: 8), _isCheckingPing ? const SizedBox( width: 20, height: 20, @@ -876,42 +889,49 @@ class _HomeScreenState extends State with TickerProviderStateMixin { } Widget _buildMetricItem(IconData icon, String label, String value, Color color) { - return Row( - children: [ - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: color.withOpacity(0.15), - borderRadius: BorderRadius.circular(10), + return Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: color.withOpacity(0.15), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icon, size: 20, color: color), ), - child: Icon(icon, size: 20, color: color), - ), - const SizedBox(width: 12), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label.toUpperCase(), - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w700, - color: Colors.white54, - letterSpacing: 0.8, - ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label.toUpperCase(), + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: Colors.white54, + letterSpacing: 0.8, + ), + ), + const SizedBox(height: 4), + Text( + value, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 16, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + ], ), - const SizedBox(height: 4), - Text( - value, - style: const TextStyle( - fontFamily: 'monospace', - fontSize: 16, - fontWeight: FontWeight.w700, - color: Colors.white, - ), - ), - ], - ) - ], + ) + ], + ), ); } } diff --git a/ostp-flutter/lib/ui/settings_screen.dart b/ostp-flutter/lib/ui/settings_screen.dart index 351a344..1593d02 100644 --- a/ostp-flutter/lib/ui/settings_screen.dart +++ b/ostp-flutter/lib/ui/settings_screen.dart @@ -64,7 +64,7 @@ class _SettingsScreenState extends State { _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 { 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, diff --git a/ostp-flutter/pubspec.yaml b/ostp-flutter/pubspec.yaml index ecb913b..549e783 100644 --- a/ostp-flutter/pubspec.yaml +++ b/ostp-flutter/pubspec.yaml @@ -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 diff --git a/ostp-gui/src-tauri/Cargo.lock b/ostp-gui/src-tauri/Cargo.lock index 60937fc..7acac64 100644 --- a/ostp-gui/src-tauri/Cargo.lock +++ b/ostp-gui/src-tauri/Cargo.lock @@ -2729,6 +2729,7 @@ dependencies = [ "tauri-build", "tauri-plugin-opener", "tokio", + "tracing", ] [[package]] diff --git a/ostp-gui/src-tauri/Cargo.toml b/ostp-gui/src-tauri/Cargo.toml index 1cacbe6..99485a1 100644 --- a/ostp-gui/src-tauri/Cargo.toml +++ b/ostp-gui/src-tauri/Cargo.toml @@ -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" diff --git a/ostp-gui/src-tauri/src/lib.rs b/ostp-gui/src-tauri/src/lib.rs index a28a3c0..53abe5d 100644 --- a/ostp-gui/src-tauri/src/lib.rs +++ b/ostp-gui/src-tauri/src/lib.rs @@ -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, tun: Option, - reality: Option, transport: Option, debug: Option, exclude: Option, @@ -54,15 +52,6 @@ struct TunConfig { kill_switch: Option, } -#[derive(Debug, Deserialize, Serialize, Clone)] -struct RealityConfigRaw { - enabled: Option, - sni: Option, - fp: Option, - pbk: Option, - sid: Option, - spx: Option, -} #[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()), } } diff --git a/ostp-gui/src-tauri/src/main.rs b/ostp-gui/src-tauri/src/main.rs index 56c2b46..83ad4ec 100644 --- a/ostp-gui/src-tauri/src/main.rs +++ b/ostp-gui/src-tauri/src/main.rs @@ -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::() { + 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 = 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 = 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::(&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() } diff --git a/ostp-gui/src/main.js b/ostp-gui/src/main.js index 5427c80..613b957 100644 --- a/ostp-gui/src/main.js +++ b/ostp-gui/src/main.js @@ -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'); } } diff --git a/ostp-jni/src/lib.rs b/ostp-jni/src/lib.rs index ee93cad..71a1dd3 100644 --- a/ostp-jni/src/lib.rs +++ b/ostp-jni/src/lib.rs @@ -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); diff --git a/ostp-server/src/api.rs b/ostp-server/src/api.rs index 2bc8829..4d09742 100644 --- a/ostp-server/src/api.rs +++ b/ostp-server/src/api.rs @@ -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, pub dns_server: std::sync::Arc, pub audit_logs: Arc>>, @@ -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, - pub dest: String, - pub sni_list: Vec, -} - #[derive(Debug, Clone, Deserialize, Serialize)] pub struct ApiConfig { pub enabled: bool, @@ -287,7 +278,6 @@ pub async fn start_api_server( user_stats: Arc>>>, server_host: String, server_port: u16, - reality_query: String, config_path: Option, dns_server: std::sync::Arc, router: std::sync::Arc, @@ -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(); - } else { - rq = format!("{}&owndns=true", rq); - } - } + let rq = if dns_enabled { + "?type=udp&owndns=true".to_string() + } else { + "?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())), diff --git a/ostp-server/src/lib.rs b/ostp-server/src/lib.rs index e7260bf..3601b49 100644 --- a/ostp-server/src/lib.rs +++ b/ostp-server/src/lib.rs @@ -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, -} - // ── Internal event types ───────────────────────────────────────────────────── #[derive(Debug, Clone)] @@ -76,8 +67,6 @@ pub async fn run_server( api_config: Option, fallback_config: Option, debug: bool, - reality_query: Option, - reality_config: Option, dns_config: Option, config_path: Option, ) -> 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, shared_keys: std::sync::Arc>>, router: std::sync::Arc, - reality_config: Option>, - fallback_target: Option, ) -> Result<()> { let mut remotes: HashMap<(u32, u16), RemoteState> = HashMap::new(); let (stream_tx, mut stream_rx) = mpsc::unbounded_channel::<(u32, u16, Vec)>(); @@ -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); } }); diff --git a/ostp-server/src/transport/uot.rs b/ostp-server/src/transport/uot.rs index e6b2f4c..13d0a56 100644 --- a/ostp-server/src/transport/uot.rs +++ b/ostp-server/src/transport/uot.rs @@ -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( - mut stream: S, - peer_addr: SocketAddr, - shared_keys: Arc>>, - udp_tx: mpsc::Sender<(Bytes, SocketAddr)>, - tcp_map: Arc>>>, - reality_config: Option>, - fb_target: Option, -) -> 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::>() - }; - - let mut authenticated = false; - for key in keys { - let mut mac = as Mac>::new_from_slice(key.as_bytes()) - .unwrap_or_else(|_| 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( - mut stream: S, - initial_buf: Vec, - peer_addr: SocketAddr, - _shared_keys: Arc>>, // Note: Reality uses its own keys (sid) - udp_tx: mpsc::Sender<(Bytes, SocketAddr)>, - tcp_map: Arc>>>, - reality_config: Arc, -) -> 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( - mut stream: S, - peer_addr: SocketAddr, - tcp_map: Arc>>>, - 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( stream: S, peer_addr: SocketAddr, - wss: bool, tcp_map: Arc>>>, 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::(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 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; } - 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; } - } + 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 > 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() { 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 { - inner: S, - data_key: ChaCha20Poly1305, - rx_nonce: u64, - tx_nonce: u64, - rx_buf: BytesMut, - plaintext_buf: BytesMut, - tx_buf: BytesMut, -} - -impl RealityStream { - 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 tokio::io::AsyncRead for RealityStream { - fn poll_read(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>, buf: &mut tokio::io::ReadBuf<'_>) -> Poll> { - 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 tokio::io::AsyncWrite for RealityStream { - fn poll_write(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>, buf: &[u8]) -> Poll> { - 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> { - 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> { - 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) - } -} diff --git a/ostp/src/main.rs b/ostp/src/main.rs index f2902ee..8fb4dfc 100644 --- a/ostp/src/main.rs +++ b/ostp/src/main.rs @@ -17,6 +17,10 @@ struct Args { #[arg(short, long)] init: Option, + /// 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 { 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 { 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::(); - - (priv_b64, pub_b64, sid_hex) -} fn parse_outbound_action(value: Option) -> ostp_server::OutboundAction { match value.as_deref() { @@ -257,7 +239,6 @@ impl UserConfig { struct ServerConfig { listen: ListenConfig, access_keys: Vec, - reality: Option, debug: Option, outbound: Option, api: Option, @@ -336,7 +317,6 @@ struct ClientConfig { mtu: Option, socks5_bind: Option, tun: Option, - reality: Option, debug: Option, exclude: Option, mux: Option, @@ -360,27 +340,6 @@ struct TunConfig { kill_switch: Option, } -#[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, -} #[derive(Debug, Deserialize, Serialize)] struct OutboundConfig { @@ -509,6 +468,570 @@ fn get_or_ask_public_ip(config_path: &std::path::Path) -> String { "".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 { + 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(¤t_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::().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::().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::().unwrap_or(1).max(1); + let mut access_keys: Vec = 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(), diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 76bd38a..250a1de 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -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 diff --git a/scripts/install.sh b/scripts/install.sh index 7aa275d..bd335a8 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -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 < "$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 < /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" diff --git a/test_addr.rs b/test_addr.rs new file mode 100644 index 0000000..6722f57 --- /dev/null +++ b/test_addr.rs @@ -0,0 +1,3 @@ +use std::net::SocketAddr; fn main() { println!(\ +:? +\, \[::1]:80\.parse::()); }