Fix CLI setup permissions, enforce global debug tracing, and fix GUI silent startup crash
|
|
@ -68,9 +68,6 @@ pub struct Bridge {
|
|||
pub stealth_sni: String,
|
||||
pub wss: bool,
|
||||
pub mtu: usize,
|
||||
pub reality_enabled: bool,
|
||||
pub reality_pbk: String,
|
||||
pub reality_sid: String,
|
||||
pub kill_switch: bool,
|
||||
pub reload_tx: Option<watch::Sender<crate::config::ExclusionConfig>>,
|
||||
|
||||
|
|
@ -104,9 +101,6 @@ impl Bridge {
|
|||
stealth_sni: config.transport.stealth_sni.clone(),
|
||||
wss: config.transport.wss,
|
||||
mtu: config.ostp.mtu,
|
||||
reality_enabled: config.reality.enabled,
|
||||
reality_pbk: config.reality.pbk.clone(),
|
||||
reality_sid: config.reality.sid.clone(),
|
||||
kill_switch: config.kill_switch,
|
||||
reload_tx: None,
|
||||
|
||||
|
|
@ -862,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<std::net::SocketAddr> = match tokio::net::lookup_host(&self.server_addr).await {
|
||||
let mut resolved_addrs: Vec<std::net::SocketAddr> = match tokio::net::lookup_host(&self.server_addr).await {
|
||||
Ok(addrs) => addrs.collect(),
|
||||
Err(e) => return Err(anyhow::anyhow!("failed to resolve server address {}: {}", self.server_addr, e)),
|
||||
};
|
||||
let target_addr = resolved_addrs.first().ok_or_else(|| anyhow::anyhow!("no IP addresses resolved for {}", self.server_addr))?;
|
||||
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<crate::transport::Transport> {
|
||||
let mode = self.transport_mode.to_lowercase();
|
||||
if mode == "uot" || mode == "tcp" {
|
||||
let (tx, rx) = crate::transport::xhttp::connect_xhttp(
|
||||
target_ip, port, &self.stealth_sni, &self.access_key, self.reality_enabled, self.wss, &self.reality_pbk, &self.reality_sid
|
||||
).await?;
|
||||
Ok(crate::transport::Transport::Uot { tx, rx })
|
||||
let stream = tokio::net::TcpStream::connect((target_ip, port)).await?;
|
||||
let _ = stream.set_nodelay(true);
|
||||
let (mut read_half, mut write_half) = stream.into_split();
|
||||
|
||||
let (tx_out, mut rx_out) = tokio::sync::mpsc::channel::<bytes::Bytes>(1024);
|
||||
let (tx_in, rx_in) = tokio::sync::mpsc::channel::<bytes::Bytes>(1024);
|
||||
|
||||
// Task to write from rx_out to tcp stream
|
||||
tokio::spawn(async move {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
while let Some(data) = rx_out.recv().await {
|
||||
let mut len_buf = [0u8; 2];
|
||||
len_buf.copy_from_slice(&(data.len() as u16).to_be_bytes());
|
||||
if write_half.write_all(&len_buf).await.is_err() { break; }
|
||||
if write_half.write_all(&data).await.is_err() { break; }
|
||||
}
|
||||
});
|
||||
|
||||
// Task to read from tcp stream to tx_in
|
||||
let tx_in_clone = tx_in.clone();
|
||||
tokio::spawn(async move {
|
||||
use tokio::io::AsyncReadExt;
|
||||
loop {
|
||||
let mut len_buf = [0u8; 2];
|
||||
if read_half.read_exact(&mut len_buf).await.is_err() { break; }
|
||||
let len = u16::from_be_bytes(len_buf) as usize;
|
||||
let mut data = vec![0u8; len];
|
||||
if read_half.read_exact(&mut data).await.is_err() { break; }
|
||||
if tx_in_clone.send(bytes::Bytes::from(data)).await.is_err() { break; }
|
||||
}
|
||||
});
|
||||
|
||||
Ok(crate::transport::Transport::Uot { tx: tx_out, rx: std::sync::Arc::new(tokio::sync::Mutex::new(rx_in)) })
|
||||
} else {
|
||||
let is_ipv6 = target_ip.is_ipv6();
|
||||
let domain = if is_ipv6 { socket2::Domain::IPV6 } else { socket2::Domain::IPV4 };
|
||||
|
|
@ -1068,10 +1110,25 @@ fn next_profile(current: TrafficProfile) -> TrafficProfile {
|
|||
}
|
||||
}
|
||||
|
||||
fn synthesize_nat64(ip: std::net::Ipv4Addr) -> std::net::Ipv6Addr {
|
||||
async fn synthesize_nat64(ip: std::net::Ipv4Addr) -> std::net::Ipv6Addr {
|
||||
let mut prefix = [0x00, 0x64, 0xff, 0x9b, 0, 0, 0, 0, 0, 0, 0, 0];
|
||||
if let Ok(addrs) = tokio::net::lookup_host("ipv4only.arpa:80").await {
|
||||
for addr in addrs {
|
||||
if let std::net::SocketAddr::V6(v6) = addr {
|
||||
let octets = v6.ip().octets();
|
||||
prefix.copy_from_slice(&octets[0..12]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let octets = ip.octets();
|
||||
std::net::Ipv6Addr::new(
|
||||
0x0064, 0xff9b, 0, 0, 0, 0,
|
||||
((prefix[0] as u16) << 8) | prefix[1] as u16,
|
||||
((prefix[2] as u16) << 8) | prefix[3] as u16,
|
||||
((prefix[4] as u16) << 8) | prefix[5] as u16,
|
||||
((prefix[6] as u16) << 8) | prefix[7] as u16,
|
||||
((prefix[8] as u16) << 8) | prefix[9] as u16,
|
||||
((prefix[10] as u16) << 8) | prefix[11] as u16,
|
||||
((octets[0] as u16) << 8) | octets[1] as u16,
|
||||
((octets[2] as u16) << 8) | octets[3] as u16,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ pub struct ClientConfig {
|
|||
pub debug: bool,
|
||||
pub ostp: OstpConfig,
|
||||
pub local_proxy: LocalProxyConfig,
|
||||
pub reality: RealityConfig,
|
||||
#[serde(default)]
|
||||
pub transport: TransportConfig,
|
||||
#[serde(default)]
|
||||
|
|
@ -98,21 +97,7 @@ impl Default for TransportConfig {
|
|||
}
|
||||
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct RealityConfig {
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub sni: String,
|
||||
#[serde(default)]
|
||||
pub fp: String,
|
||||
#[serde(default)]
|
||||
pub pbk: String,
|
||||
#[serde(default)]
|
||||
pub sid: String,
|
||||
#[serde(default)]
|
||||
pub spx: String,
|
||||
}
|
||||
|
||||
|
||||
|
||||
impl Default for OstpConfig {
|
||||
|
|
@ -146,7 +131,6 @@ impl Default for ClientConfig {
|
|||
debug: false,
|
||||
ostp: OstpConfig::default(),
|
||||
local_proxy: LocalProxyConfig::default(),
|
||||
reality: RealityConfig::default(),
|
||||
transport: TransportConfig::default(),
|
||||
exclusions: ExclusionConfig::default(),
|
||||
multiplex: MultiplexConfig::default(),
|
||||
|
|
@ -181,7 +165,6 @@ struct RawUnifiedConfig {
|
|||
tun: Option<RawTunSection>,
|
||||
exclude: Option<RawExcludeSection>,
|
||||
mux: Option<RawMuxSection>,
|
||||
reality: Option<RawRealitySection>,
|
||||
transport: Option<RawTransportSection>,
|
||||
gui: Option<serde_json::Value>,
|
||||
}
|
||||
|
|
@ -214,15 +197,7 @@ struct RawMuxSection {
|
|||
sessions: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RawRealitySection {
|
||||
enabled: Option<bool>,
|
||||
sni: Option<String>,
|
||||
fp: Option<String>,
|
||||
pbk: Option<String>,
|
||||
sid: Option<String>,
|
||||
spx: Option<String>,
|
||||
}
|
||||
|
||||
|
||||
impl ClientConfig {
|
||||
/// Hot-reload from `config.json` placed next to the running binary.
|
||||
|
|
@ -269,14 +244,6 @@ impl ClientConfig {
|
|||
bind_addr: socks5,
|
||||
connect_timeout_ms: 15000,
|
||||
},
|
||||
reality: RealityConfig {
|
||||
enabled: raw.reality.as_ref().and_then(|t| t.enabled).unwrap_or(false),
|
||||
sni: raw.reality.as_ref().and_then(|t| t.sni.clone()).unwrap_or_default(),
|
||||
fp: raw.reality.as_ref().and_then(|t| t.fp.clone()).unwrap_or_default(),
|
||||
pbk: raw.reality.as_ref().and_then(|t| t.pbk.clone()).unwrap_or_default(),
|
||||
sid: raw.reality.as_ref().and_then(|t| t.sid.clone()).unwrap_or_default(),
|
||||
spx: raw.reality.as_ref().and_then(|t| t.spx.clone()).unwrap_or_default(),
|
||||
},
|
||||
transport: TransportConfig {
|
||||
mode: raw.transport.as_ref().and_then(|t| t.mode.clone()).unwrap_or_else(default_transport_mode),
|
||||
stealth_sni: raw.transport.as_ref().and_then(|t| t.stealth_sni.clone()).unwrap_or_default(),
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ pub fn setup_panic_hook() {
|
|||
);
|
||||
|
||||
eprintln!("{}", crash_msg);
|
||||
tracing::error!("{}", crash_msg);
|
||||
|
||||
let path = std::env::current_exe()
|
||||
.ok()
|
||||
|
|
@ -40,27 +41,49 @@ pub fn setup_panic_hook() {
|
|||
}));
|
||||
}
|
||||
|
||||
/// Initialises tracing and writes to `<app_name>.log` next to the executable.
|
||||
///
|
||||
/// The `level` parameter controls the minimum log level:
|
||||
/// - `"error"` — only errors
|
||||
/// - `"warn"` — warnings and errors
|
||||
/// - `"info"` — informational messages (default)
|
||||
/// - `"debug"` — detailed debug messages (use when `debug: true` in config)
|
||||
/// - `"trace"` — all messages including very verbose internal state
|
||||
///
|
||||
/// The environment variable `RUST_LOG` overrides this value if set.
|
||||
pub fn init_tracing(level: &str, app_name: &str, version: &str) -> Option<tracing_appender::non_blocking::WorkerGuard> {
|
||||
// RUST_LOG overrides the config-derived level
|
||||
let env_filter = EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| EnvFilter::new(level));
|
||||
.unwrap_or_else(|_| {
|
||||
// When debug or trace is requested, enable for all ostp crates
|
||||
if level == "debug" || level == "trace" {
|
||||
// Enable the requested level for ostp crates, but keep noisy deps at warn
|
||||
EnvFilter::new(format!(
|
||||
"warn,ostp_client={level},ostp_core={level},ostp_jni={level},ostp_gui_lib={level}"
|
||||
))
|
||||
} else {
|
||||
EnvFilter::new(level)
|
||||
}
|
||||
});
|
||||
|
||||
let path = std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|p| p.parent().map(|d| d.join(format!("{}.log", app_name))))
|
||||
.unwrap_or_else(|| PathBuf::from(format!("{}.log", app_name)));
|
||||
|
||||
if let Ok(file) = OpenOptions::new().create(true).append(true).open(path) {
|
||||
if let Ok(file) = OpenOptions::new().create(true).append(true).open(&path) {
|
||||
let (file_writer, guard) = tracing_appender::non_blocking(file);
|
||||
|
||||
let fmt_layer = tracing_subscriber::fmt::layer()
|
||||
.with_target(true)
|
||||
.with_line_number(true)
|
||||
.with_thread_ids(false)
|
||||
.with_thread_names(false)
|
||||
.with_ansi(false)
|
||||
.with_writer(file_writer);
|
||||
|
||||
let stderr_layer = tracing_subscriber::fmt::layer()
|
||||
.with_target(false)
|
||||
.with_target(true)
|
||||
.with_writer(std::io::stderr);
|
||||
|
||||
let _ = tracing_subscriber::registry()
|
||||
|
|
@ -70,15 +93,26 @@ pub fn init_tracing(level: &str, app_name: &str, version: &str) -> Option<tracin
|
|||
.try_init();
|
||||
|
||||
tracing::info!(
|
||||
"{} v{} | OS: {} | Arch: {}",
|
||||
"{} v{} | OS: {} | Arch: {} | log_level: {} | log_file: {}",
|
||||
app_name,
|
||||
version,
|
||||
std::env::consts::OS,
|
||||
std::env::consts::ARCH
|
||||
std::env::consts::ARCH,
|
||||
level,
|
||||
path.display(),
|
||||
);
|
||||
|
||||
Some(guard)
|
||||
} else {
|
||||
// Fallback: stderr only
|
||||
let stderr_layer = tracing_subscriber::fmt::layer()
|
||||
.with_target(true)
|
||||
.with_writer(std::io::stderr);
|
||||
let _ = tracing_subscriber::registry()
|
||||
.with(EnvFilter::new(level))
|
||||
.with(stderr_layer)
|
||||
.try_init();
|
||||
eprintln!("[WARN] Could not open log file at {}. Logging to stderr only.", path.display());
|
||||
None
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
pub mod xhttp;
|
||||
|
||||
use std::sync::Arc;
|
||||
use tokio::net::UdpSocket;
|
||||
|
|
|
|||
|
|
@ -198,8 +198,8 @@ pub async fn run_native_tunnel(
|
|||
let current = exclusions_rx.borrow().clone();
|
||||
let new_matcher = crate::tunnel::exclusion::ExclusionMatcher::new(¤t, None, None);
|
||||
*matcher_clone.write().await = new_matcher;
|
||||
if debug {
|
||||
tracing::info!("Desktop TUN exclusions hot-reloaded");
|
||||
if true {
|
||||
tracing::debug!("Desktop TUN exclusions hot-reloaded");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -228,8 +228,8 @@ pub async fn run_native_tunnel(
|
|||
|
||||
tokio::spawn(async move {
|
||||
let matcher = matcher_arc.read().await.clone();
|
||||
if debug {
|
||||
tracing::info!("TUN TCP {local} → {remote}");
|
||||
if true {
|
||||
tracing::debug!("TUN TCP {local} → {remote}");
|
||||
}
|
||||
|
||||
// ── Sniff TLS ClientHello for SNI ─────────────────────────────
|
||||
|
|
@ -253,12 +253,12 @@ pub async fn run_native_tunnel(
|
|||
if let Some(sni) =
|
||||
crate::tunnel::sni_sniff::extract_sni(&sniff_buf[..sniff_len])
|
||||
{
|
||||
if debug {
|
||||
tracing::info!("TUN SNI: {sni}");
|
||||
if true {
|
||||
tracing::debug!("TUN SNI: {sni}");
|
||||
}
|
||||
if matcher.match_domain(&sni) {
|
||||
if debug {
|
||||
tracing::info!("TUN BYPASS (SNI domain): {sni} → {remote}");
|
||||
if true {
|
||||
tracing::debug!("TUN BYPASS (SNI domain): {sni} → {remote}");
|
||||
}
|
||||
should_bypass = true;
|
||||
}
|
||||
|
|
@ -267,8 +267,8 @@ pub async fn run_native_tunnel(
|
|||
|
||||
// 2. Destination IP CIDR check (for IPs not in routing table / IPv6)
|
||||
if !should_bypass && matcher.match_ip(&remote.ip()) {
|
||||
if debug {
|
||||
tracing::info!("TUN BYPASS (IP match): {remote}");
|
||||
if true {
|
||||
tracing::debug!("TUN BYPASS (IP match): {remote}");
|
||||
}
|
||||
should_bypass = true;
|
||||
}
|
||||
|
|
@ -556,8 +556,8 @@ pub async fn run_native_tunnel_from_fd(
|
|||
let current = exclusions_rx.borrow().clone();
|
||||
let new_matcher = crate::tunnel::exclusion::ExclusionMatcher::new(¤t, None, None);
|
||||
*matcher_clone.write().await = new_matcher;
|
||||
if debug {
|
||||
tracing::info!("Android TUN exclusions hot-reloaded");
|
||||
if true {
|
||||
tracing::debug!("Android TUN exclusions hot-reloaded");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -572,8 +572,8 @@ pub async fn run_native_tunnel_from_fd(
|
|||
tokio::spawn(async move {
|
||||
let matcher = matcher_arc.read().await.clone();
|
||||
|
||||
if debug {
|
||||
tracing::info!("Android TUN TCP {local} → {remote}");
|
||||
if true {
|
||||
tracing::debug!("Android TUN TCP {local} → {remote}");
|
||||
}
|
||||
|
||||
// Sniff SNI
|
||||
|
|
@ -596,7 +596,7 @@ pub async fn run_native_tunnel_from_fd(
|
|||
if let Some(sni) =
|
||||
crate::tunnel::sni_sniff::extract_sni(&sniff_buf[..sniff_len])
|
||||
{
|
||||
if debug { tracing::info!("Android TUN SNI: {sni}"); }
|
||||
if true { tracing::debug!("Android TUN SNI: {sni}"); }
|
||||
if matcher.match_domain(&sni) {
|
||||
should_bypass = true;
|
||||
}
|
||||
|
|
@ -608,8 +608,8 @@ pub async fn run_native_tunnel_from_fd(
|
|||
if let Some(exe) =
|
||||
crate::tunnel::process_lookup::get_process_name_from_port(local.port())
|
||||
{
|
||||
if debug {
|
||||
tracing::info!("Android TUN port {} → EXE: {}", local.port(), exe);
|
||||
if true {
|
||||
tracing::debug!("Android TUN port {} → EXE: {}", local.port(), exe);
|
||||
}
|
||||
if matcher.match_process(&exe) {
|
||||
should_bypass = true;
|
||||
|
|
@ -625,8 +625,8 @@ pub async fn run_native_tunnel_from_fd(
|
|||
// Bypass: connect directly (Android VPN service already protects the socket
|
||||
// from re-entering the TUN through VpnService.protect())
|
||||
if should_bypass {
|
||||
if debug {
|
||||
tracing::info!("Android TUN BYPASS: {remote}");
|
||||
if true {
|
||||
tracing::debug!("Android TUN BYPASS: {remote}");
|
||||
}
|
||||
let socket = match remote {
|
||||
std::net::SocketAddr::V4(_) => tokio::net::TcpSocket::new_v4(),
|
||||
|
|
|
|||
|
|
@ -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<u16>,
|
||||
debug: bool,
|
||||
) -> Result<()> {
|
||||
if debug {
|
||||
if true {
|
||||
tracing::info!("proxy BYPASS stream_id={stream_id} target={target}");
|
||||
}
|
||||
let mut remote = connect_bypassing_tun(target, physical_if_index, physical_if_name).await?;
|
||||
|
|
@ -904,7 +904,7 @@ async fn direct_connect_http(
|
|||
close_tx: mpsc::Sender<u16>,
|
||||
debug: bool,
|
||||
) -> Result<()> {
|
||||
if debug {
|
||||
if true {
|
||||
tracing::info!("proxy BYPASS stream_id={stream_id} target={target}");
|
||||
}
|
||||
let mut remote = connect_bypassing_tun(target, physical_if_index, physical_if_name).await?;
|
||||
|
|
|
|||
|
|
@ -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, mpsc::Sender<(Vec<u8>, 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<u8>, 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<u8>, 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];
|
||||
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -32,6 +32,9 @@
|
|||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -46,4 +46,8 @@ object OstpClientSdk {
|
|||
@Keep
|
||||
@JvmStatic
|
||||
external fun addLog(logMsg: String)
|
||||
|
||||
@Keep
|
||||
@JvmStatic
|
||||
external fun notifyNetworkChanged()
|
||||
}
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 8.8 KiB After Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
|
@ -54,6 +54,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||
duration: const Duration(seconds: 4),
|
||||
);
|
||||
_checkInitialState();
|
||||
_startPolling();
|
||||
}
|
||||
|
||||
Future<void> _checkInitialState() async {
|
||||
|
|
@ -413,57 +414,65 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||
if (!mounted) return;
|
||||
setState(() => _uptimeSecs++);
|
||||
});
|
||||
|
||||
_startPollingMetrics();
|
||||
}
|
||||
|
||||
void _startPollingMetrics() {
|
||||
void _startPolling() {
|
||||
_pollTimer?.cancel();
|
||||
_pollTimer = Timer.periodic(const Duration(seconds: 1), (timer) async {
|
||||
if (!mounted) return;
|
||||
try {
|
||||
final metricsJson = await platform.invokeMethod('getMetrics');
|
||||
if (metricsJson != null && metricsJson.isNotEmpty) {
|
||||
final Map<String, dynamic> 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<String, dynamic> 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<HomeScreen> with TickerProviderStateMixin {
|
|||
_pulseController.value = 0.0;
|
||||
_spinController.stop();
|
||||
_uptimeTimer?.cancel();
|
||||
_pollTimer?.cancel();
|
||||
// Do NOT cancel _pollTimer, so we keep checking if VPN starts externally!
|
||||
}
|
||||
|
||||
String _formatTime(int s) {
|
||||
|
|
@ -792,7 +801,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||
),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 32),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.03),
|
||||
|
|
@ -802,29 +811,33 @@ class _HomeScreenState extends State<HomeScreen> 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<HomeScreen> 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||
_debugMode = widget.prefs.getBool('debug_mode') ?? false;
|
||||
_muxEnabled = widget.prefs.getBool('mux_enabled') ?? false;
|
||||
_muxSessionsCtrl = TextEditingController(text: widget.prefs.getString('mux_sessions') ?? '2');
|
||||
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
|
|
@ -104,8 +104,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||
widget.prefs.setString('sid', _sidCtrl.text.trim());
|
||||
widget.prefs.setBool('mux_enabled', _muxEnabled);
|
||||
widget.prefs.setString('mux_sessions', _muxSessionsCtrl.text.trim());
|
||||
|
||||
|
||||
}
|
||||
Widget _buildTextField(String label, TextEditingController controller, {String? hint, bool isPassword = false, int maxLines = 1, bool isMono = false}) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -2729,6 +2729,7 @@ dependencies = [
|
|||
"tauri-build",
|
||||
"tauri-plugin-opener",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{watch, Mutex};
|
||||
use tokio::task::JoinHandle;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use anyhow::Result;
|
||||
use ostp_client::bridge::BridgeMetrics;
|
||||
|
|
@ -30,7 +29,6 @@ struct ClientConfigRaw {
|
|||
access_key: String,
|
||||
socks5_bind: Option<String>,
|
||||
tun: Option<TunConfig>,
|
||||
reality: Option<RealityConfigRaw>,
|
||||
transport: Option<TransportConfigRaw>,
|
||||
debug: Option<bool>,
|
||||
exclude: Option<ExcludeConfig>,
|
||||
|
|
@ -54,15 +52,6 @@ struct TunConfig {
|
|||
kill_switch: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
struct RealityConfigRaw {
|
||||
enabled: Option<bool>,
|
||||
sni: Option<String>,
|
||||
fp: Option<String>,
|
||||
pbk: Option<String>,
|
||||
sid: Option<String>,
|
||||
spx: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
struct TransportConfigRaw {
|
||||
|
|
@ -170,14 +159,7 @@ fn map_to_client_config(raw: &ClientConfigRaw, mode: &str) -> ostp_client::confi
|
|||
bind_addr: raw.socks5_bind.clone().unwrap_or_else(|| "127.0.0.1:1088".to_string()),
|
||||
connect_timeout_ms: 5000,
|
||||
},
|
||||
reality: ostp_client::config::RealityConfig {
|
||||
enabled: raw.reality.as_ref().and_then(|t| t.enabled).unwrap_or(false),
|
||||
sni: raw.reality.as_ref().and_then(|t| t.sni.clone()).unwrap_or_default(),
|
||||
fp: raw.reality.as_ref().and_then(|t| t.fp.clone()).unwrap_or_default(),
|
||||
pbk: raw.reality.as_ref().and_then(|t| t.pbk.clone()).unwrap_or_default(),
|
||||
sid: raw.reality.as_ref().and_then(|t| t.sid.clone()).unwrap_or_default(),
|
||||
spx: raw.reality.as_ref().and_then(|t| t.spx.clone()).unwrap_or_default(),
|
||||
},
|
||||
|
||||
transport: ostp_client::config::TransportConfig {
|
||||
mode: raw.transport.as_ref().and_then(|t| t.mode.clone()).unwrap_or_else(|| "udp".to_string()),
|
||||
stealth_sni: raw.transport.as_ref().and_then(|t| t.stealth_sni.clone()).unwrap_or_else(|| "microsoft.com".to_string()),
|
||||
|
|
@ -195,6 +177,7 @@ fn map_to_client_config(raw: &ClientConfigRaw, mode: &str) -> ostp_client::confi
|
|||
dns_server: raw.tun.as_ref().and_then(|t| t.dns.clone()),
|
||||
tun_stack: raw.tun.as_ref().and_then(|t| t.stack.clone()).unwrap_or_else(|| "system".to_string()),
|
||||
kill_switch: raw.tun.as_ref().and_then(|t| t.kill_switch).unwrap_or(false),
|
||||
gui: raw.gui.as_ref().map(|g| serde_json::to_value(g).unwrap()),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,67 @@
|
|||
|
||||
fn main() {
|
||||
ostp_client::logging::setup_panic_hook();
|
||||
let _log_guard = ostp_client::logging::init_tracing("info", "ostp-gui", env!("CARGO_PKG_VERSION"));
|
||||
ostp_gui_lib::run()
|
||||
|
||||
// Read config BEFORE init_tracing so we can use the correct log level from config.
|
||||
// If config is missing or debug=false we default to "info".
|
||||
let log_level = detect_log_level_from_config();
|
||||
let _log_guard = ostp_client::logging::init_tracing(&log_level, "ostp-gui", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
tracing::info!("ostp-gui starting (log_level={})", log_level);
|
||||
|
||||
if let Err(e) = std::panic::catch_unwind(|| {
|
||||
ostp_gui_lib::run();
|
||||
}) {
|
||||
let msg = if let Some(s) = e.downcast_ref::<&str>() {
|
||||
(*s).to_string()
|
||||
} else if let Some(s) = e.downcast_ref::<String>() {
|
||||
s.clone()
|
||||
} else {
|
||||
"Unknown panic".to_string()
|
||||
};
|
||||
tracing::error!("ostp-gui fatal panic: {}", msg);
|
||||
// Show a dialog so the user knows what happened instead of silent exit
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use std::ffi::OsStr;
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
let msg_w: Vec<u16> = OsStr::new(&format!("OSTP GUI crashed:\n\n{}\n\nSee ostp-gui.log for details.", msg))
|
||||
.encode_wide().chain(Some(0)).collect();
|
||||
let title_w: Vec<u16> = OsStr::new("OSTP GUI — Fatal Error").encode_wide().chain(Some(0)).collect();
|
||||
#[link(name = "user32")] extern "system" {
|
||||
fn MessageBoxW(hWnd: *mut std::ffi::c_void, lpText: *const u16, lpCaption: *const u16, uType: u32) -> i32;
|
||||
}
|
||||
unsafe { MessageBoxW(std::ptr::null_mut(), msg_w.as_ptr(), title_w.as_ptr(), 0x10); }
|
||||
}
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads config.json from the exe directory (or cwd) and returns "debug" if debug=true,
|
||||
/// or the value of log_level field, otherwise returns "info".
|
||||
fn detect_log_level_from_config() -> String {
|
||||
let config_path = {
|
||||
let mut p = std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|e| e.parent().map(|d| d.join("config.json")))
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("config.json"));
|
||||
if !p.exists() {
|
||||
p = std::path::PathBuf::from("config.json");
|
||||
}
|
||||
p
|
||||
};
|
||||
|
||||
if let Ok(content) = std::fs::read_to_string(&config_path) {
|
||||
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) {
|
||||
// debug: true overrides everything
|
||||
if val.get("debug").and_then(|v| v.as_bool()).unwrap_or(false) {
|
||||
return "debug".to_string();
|
||||
}
|
||||
// explicit log_level field
|
||||
if let Some(level) = val.get("log_level").and_then(|v| v.as_str()) {
|
||||
return level.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
"info".to_string()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -50,7 +50,6 @@ pub struct ApiState {
|
|||
/// Server address for subscription links (e.g. "example.com")
|
||||
pub server_host: String,
|
||||
pub server_port: u16,
|
||||
pub reality_query: String,
|
||||
pub config_path: Option<std::path::PathBuf>,
|
||||
pub dns_server: std::sync::Arc<crate::dns::DnsServer>,
|
||||
pub audit_logs: Arc<RwLock<Vec<AuditLogEntry>>>,
|
||||
|
|
@ -79,14 +78,6 @@ pub struct CreateAuditLogRequest {
|
|||
|
||||
// ── API configuration ────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct RealityConfig {
|
||||
pub private_key: String,
|
||||
pub short_ids: Vec<String>,
|
||||
pub dest: String,
|
||||
pub sni_list: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct ApiConfig {
|
||||
pub enabled: bool,
|
||||
|
|
@ -287,7 +278,6 @@ pub async fn start_api_server(
|
|||
user_stats: Arc<RwLock<HashMap<String, Arc<UserStats>>>>,
|
||||
server_host: String,
|
||||
server_port: u16,
|
||||
reality_query: String,
|
||||
config_path: Option<std::path::PathBuf>,
|
||||
dns_server: std::sync::Arc<crate::dns::DnsServer>,
|
||||
router: std::sync::Arc<crate::router::Router>,
|
||||
|
|
@ -303,7 +293,6 @@ pub async fn start_api_server(
|
|||
password_hash: config.password_hash.clone(),
|
||||
server_host,
|
||||
server_port,
|
||||
reality_query,
|
||||
config_path,
|
||||
dns_server,
|
||||
audit_logs: Arc::new(RwLock::new(Vec::new())),
|
||||
|
|
@ -814,14 +803,11 @@ async fn handle_subscribe(
|
|||
// If client requests plain text, return ostp:// share link
|
||||
if accept.contains("text/plain") {
|
||||
let dns_enabled = state.dns_server.config.read().await.enabled;
|
||||
let mut rq = state.reality_query.clone();
|
||||
if dns_enabled {
|
||||
if rq.is_empty() {
|
||||
rq = "?owndns=true".to_string();
|
||||
} 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())),
|
||||
|
|
|
|||
|
|
@ -26,15 +26,6 @@ pub use api::ApiConfig;
|
|||
pub use fallback::FallbackConfig;
|
||||
pub use relay_node::RelayConfig;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RealityServerConfig {
|
||||
pub dest: String,
|
||||
pub private_key: String,
|
||||
pub pbk: String,
|
||||
pub sid: String,
|
||||
pub sni_list: Vec<String>,
|
||||
}
|
||||
|
||||
// ── Internal event types ─────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
|
|
@ -76,8 +67,6 @@ pub async fn run_server(
|
|||
api_config: Option<ApiConfig>,
|
||||
fallback_config: Option<FallbackConfig>,
|
||||
debug: bool,
|
||||
reality_query: Option<String>,
|
||||
reality_config: Option<RealityServerConfig>,
|
||||
dns_config: Option<dns::DnsConfig>,
|
||||
config_path: Option<std::path::PathBuf>,
|
||||
) -> Result<()> {
|
||||
|
|
@ -271,12 +260,11 @@ pub async fn run_server(
|
|||
let parts: Vec<&str> = primary.rsplitn(2, ':').collect();
|
||||
let server_port: u16 = parts.first().and_then(|p| p.parse().ok()).unwrap_or(50000);
|
||||
let server_host = server_public_ip.unwrap_or_else(|| parts.get(1).unwrap_or(&"0.0.0.0").to_string());
|
||||
let rq = reality_query.clone().unwrap_or_default();
|
||||
let config_path_api = config_path.clone();
|
||||
let dns_server_api = dns_server.clone();
|
||||
let router_api = router.clone();
|
||||
tokio::spawn(async move {
|
||||
api::start_api_server(api_cfg, api_keys, api_stats, server_host, server_port, rq, config_path_api, dns_server_api, router_api).await;
|
||||
api::start_api_server(api_cfg, api_keys, api_stats, server_host, server_port, config_path_api, dns_server_api, router_api).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -326,11 +314,8 @@ pub async fn run_server(
|
|||
let key_count = shared_keys.read().unwrap_or_else(|e| e.into_inner()).len();
|
||||
tracing::info!(listeners = bind_addrs.len(), keys = key_count, "server started");
|
||||
tracing::info!("ARQ config: max_reorder=16384, reorder_buf=8192, sent_history=32768, rto=100ms");
|
||||
let reality_config_arc = reality_config.map(std::sync::Arc::new);
|
||||
let fallback_target = fallback_config.as_ref().and_then(|f| if f.enabled { Some(f.target.clone()) } else { None });
|
||||
|
||||
tokio::select! {
|
||||
res = run_server_loop(bind_addrs.clone(), primary_socket, sockets, dispatcher, ui_cmd_rx, ui_event_tx, shared_keys, router, reality_config_arc, fallback_target) => {
|
||||
res = run_server_loop(bind_addrs.clone(), primary_socket, sockets, dispatcher, ui_cmd_rx, ui_event_tx, shared_keys, router) => {
|
||||
if let Err(e) = res {
|
||||
tracing::error!("Server error: {e}");
|
||||
}
|
||||
|
|
@ -354,8 +339,6 @@ async fn run_server_loop(
|
|||
ui_event_tx: mpsc::UnboundedSender<UiEvent>,
|
||||
shared_keys: std::sync::Arc<std::sync::RwLock<HashMap<String, crate::api::UserMeta>>>,
|
||||
router: std::sync::Arc<crate::router::Router>,
|
||||
reality_config: Option<std::sync::Arc<RealityServerConfig>>,
|
||||
fallback_target: Option<String>,
|
||||
) -> Result<()> {
|
||||
let mut remotes: HashMap<(u32, u16), RemoteState> = HashMap::new();
|
||||
let (stream_tx, mut stream_rx) = mpsc::unbounded_channel::<(u32, u16, Vec<u8>)>();
|
||||
|
|
@ -392,8 +375,6 @@ async fn run_server_loop(
|
|||
let tcp_map_clone = tcp_map.clone();
|
||||
let shared_keys_clone = shared_keys.clone();
|
||||
let udp_tx_clone = udp_tx.clone();
|
||||
let reality_config_outer = reality_config.clone();
|
||||
let fb_target_outer = fallback_target.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Ok(listener) = tokio::net::TcpListener::bind(&addr).await {
|
||||
|
|
@ -430,12 +411,9 @@ async fn run_server_loop(
|
|||
}
|
||||
|
||||
let tm = tcp_map_clone.clone();
|
||||
let keys = shared_keys_clone.clone();
|
||||
let tx = udp_tx_clone.clone();
|
||||
let reality = reality_config_outer.clone();
|
||||
let fb_target = fb_target_outer.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = crate::transport::uot::handle_tcp_connection(stream, peer_addr, keys, tx, tm, reality, fb_target).await {
|
||||
if let Err(e) = crate::transport::uot::handle_tcp_connection(stream, peer_addr, tm, tx).await {
|
||||
tracing::warn!("UoT connection from {} closed: {}", peer_addr, e);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,398 +1,23 @@
|
|||
use anyhow::Result;
|
||||
use bytes::{Buf, BufMut, Bytes, BytesMut};
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
use bytes::{BufMut, Bytes, BytesMut};
|
||||
use std::collections::HashMap;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::{Arc, RwLock as StdRwLock};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::sync::{mpsc, RwLock};
|
||||
use tracing::info;
|
||||
use tokio::net::TcpStream;
|
||||
use base64::Engine;
|
||||
use std::pin::Pin;
|
||||
use std::task::{Context as TaskContext, Poll};
|
||||
use chacha20poly1305::{aead::Aead, ChaCha20Poly1305, Nonce};
|
||||
use x25519_dalek::StaticSecret;
|
||||
|
||||
use ostp_core::framing::wss::{encode_wss_frame, decode_wss_frame, WssFrameResult};
|
||||
use ostp_core::crypto::reality::{parse_client_hello, derive_keys, verify_session_id, REALITY_SERVER_HANDSHAKE_RECORDS};
|
||||
use crate::RealityServerConfig;
|
||||
|
||||
pub async fn handle_tcp_connection<S>(
|
||||
mut stream: S,
|
||||
peer_addr: SocketAddr,
|
||||
shared_keys: Arc<StdRwLock<HashMap<String, crate::api::UserMeta>>>,
|
||||
udp_tx: mpsc::Sender<(Bytes, SocketAddr)>,
|
||||
tcp_map: Arc<RwLock<HashMap<SocketAddr, mpsc::Sender<Bytes>>>>,
|
||||
reality_config: Option<Arc<RealityServerConfig>>,
|
||||
fb_target: Option<String>,
|
||||
) -> Result<()>
|
||||
where
|
||||
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
|
||||
{
|
||||
let mut initial_buf = vec![0u8; 16384];
|
||||
let mut header_len = 0;
|
||||
|
||||
// Read the first chunk to determine if it's TLS or HTTP
|
||||
let n = stream.read(&mut initial_buf).await?;
|
||||
if n == 0 {
|
||||
anyhow::bail!("connection closed before data received");
|
||||
}
|
||||
header_len += n;
|
||||
|
||||
// Check if it's a TLS record (0x16 0x03 0x01 or 0x16 0x03 0x03)
|
||||
if initial_buf[0] == 0x16 && initial_buf[1] == 0x03 {
|
||||
// It's a TLS record. We need to ensure we read the entire record.
|
||||
if header_len >= 5 {
|
||||
let record_len = 5 + u16::from_be_bytes([initial_buf[3], initial_buf[4]]) as usize;
|
||||
if record_len > initial_buf.len() {
|
||||
anyhow::bail!("TLS record too large");
|
||||
}
|
||||
while header_len < record_len {
|
||||
let n = stream.read(&mut initial_buf[header_len..record_len]).await?;
|
||||
if n == 0 {
|
||||
anyhow::bail!("connection closed while reading TLS record");
|
||||
}
|
||||
header_len += n;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(rc) = reality_config {
|
||||
return handle_reality_connection(stream, initial_buf[..header_len].to_vec(), peer_addr, shared_keys, udp_tx, tcp_map, rc).await;
|
||||
} else {
|
||||
// Received TLS but Reality is not enabled
|
||||
if let Some(target) = fb_target {
|
||||
tracing::info!("Fallback triggered for {} -> {}", peer_addr, target);
|
||||
let mut dest_stream: TcpStream = TcpStream::connect(&target).await?;
|
||||
dest_stream.write_all(&initial_buf[..header_len]).await?;
|
||||
tokio::io::copy_bidirectional(&mut stream, &mut dest_stream).await?;
|
||||
return Ok(());
|
||||
} else {
|
||||
anyhow::bail!("received TLS but Reality is not configured and no fallback target");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, assume it's HTTP (Standard xhttp/wss)
|
||||
loop {
|
||||
if initial_buf[..header_len].windows(4).any(|w| w == b"\r\n\r\n") {
|
||||
break;
|
||||
}
|
||||
if header_len == initial_buf.len() {
|
||||
anyhow::bail!("handshake headers too large");
|
||||
}
|
||||
let n = stream.read(&mut initial_buf[header_len..]).await?;
|
||||
if n == 0 {
|
||||
anyhow::bail!("connection closed before HTTP handshake complete");
|
||||
}
|
||||
header_len += n;
|
||||
}
|
||||
|
||||
let headers_str = String::from_utf8_lossy(&initial_buf[..header_len]);
|
||||
|
||||
let wss = if headers_str.starts_with("GET /wss HTTP/1.1\r\n") {
|
||||
true
|
||||
} else if headers_str.starts_with("GET /stream HTTP/1.1\r\n") {
|
||||
false
|
||||
} else {
|
||||
if let Some(target) = fb_target {
|
||||
tracing::info!("Fallback triggered for {} -> {}", peer_addr, target);
|
||||
let mut dest_stream: TcpStream = TcpStream::connect(&target).await?;
|
||||
dest_stream.write_all(&initial_buf[..header_len]).await?;
|
||||
tokio::io::copy_bidirectional(&mut stream, &mut dest_stream).await?;
|
||||
return Ok(());
|
||||
} else {
|
||||
let _ = stream.write_all(b"HTTP/1.1 404 Not Found\r\n\r\n").await;
|
||||
anyhow::bail!("invalid request line");
|
||||
}
|
||||
};
|
||||
|
||||
// Extract Authorization
|
||||
let mut signature_base64 = None;
|
||||
for line in headers_str.lines() {
|
||||
let lower = line.to_ascii_lowercase();
|
||||
if lower.starts_with("authorization: bearer ") {
|
||||
signature_base64 = Some(line[22..].trim().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let sig_b64 = match signature_base64 {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
if let Some(target) = fb_target {
|
||||
tracing::info!("Fallback triggered for {} -> {}", peer_addr, target);
|
||||
let mut dest_stream: TcpStream = TcpStream::connect(&target).await?;
|
||||
dest_stream.write_all(&initial_buf[..header_len]).await?;
|
||||
tokio::io::copy_bidirectional(&mut stream, &mut dest_stream).await?;
|
||||
return Ok(());
|
||||
} else {
|
||||
let _ = stream.write_all(b"HTTP/1.1 404 Not Found\r\n\r\n").await;
|
||||
anyhow::bail!("missing authorization");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let sig_bytes = match base64::Engine::decode(&base64::engine::general_purpose::STANDARD_NO_PAD, &sig_b64) {
|
||||
Ok(b) => b,
|
||||
Err(_) => {
|
||||
if let Some(target) = fb_target {
|
||||
tracing::info!("Fallback triggered for {} -> {}", peer_addr, target);
|
||||
let mut dest_stream: TcpStream = TcpStream::connect(&target).await?;
|
||||
dest_stream.write_all(&initial_buf[..header_len]).await?;
|
||||
tokio::io::copy_bidirectional(&mut stream, &mut dest_stream).await?;
|
||||
return Ok(());
|
||||
} else {
|
||||
let _ = stream.write_all(b"HTTP/1.1 404 Not Found\r\n\r\n").await;
|
||||
anyhow::bail!("invalid base64 signature");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if sig_bytes.len() < 8 {
|
||||
let _ = stream.write_all(b"HTTP/1.1 404 Not Found\r\n\r\n").await;
|
||||
anyhow::bail!("signature too short");
|
||||
}
|
||||
|
||||
let ts_bytes: [u8; 8] = sig_bytes[0..8].try_into().unwrap();
|
||||
let client_ts = u64::from_be_bytes(ts_bytes);
|
||||
let provided_mac = &sig_bytes[8..];
|
||||
|
||||
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
|
||||
if client_ts > now + 30 || client_ts < now.saturating_sub(60) {
|
||||
let _ = stream.write_all(b"HTTP/1.1 404 Not Found\r\n\r\n").await;
|
||||
anyhow::bail!("timestamp out of bounds (replay protection)");
|
||||
}
|
||||
|
||||
// Verify HMAC against known keys
|
||||
let keys = {
|
||||
let guard = shared_keys.read().unwrap();
|
||||
guard.keys().cloned().collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
let mut authenticated = false;
|
||||
for key in keys {
|
||||
let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(key.as_bytes())
|
||||
.unwrap_or_else(|_| <Hmac<Sha256> as Mac>::new_from_slice(b"default").unwrap());
|
||||
mac.update(&ts_bytes);
|
||||
if mac.verify_slice(provided_mac).is_ok() {
|
||||
authenticated = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if !authenticated {
|
||||
if let Some(target) = fb_target {
|
||||
tracing::info!("Fallback triggered for {} -> {}", peer_addr, target);
|
||||
let mut dest_stream: TcpStream = TcpStream::connect(&target).await?;
|
||||
dest_stream.write_all(&initial_buf[..header_len]).await?;
|
||||
tokio::io::copy_bidirectional(&mut stream, &mut dest_stream).await?;
|
||||
return Ok(());
|
||||
} else {
|
||||
let _ = stream.write_all(b"HTTP/1.1 404 Not Found\r\n\r\n").await;
|
||||
anyhow::bail!("unauthorized (invalid HMAC)");
|
||||
}
|
||||
}
|
||||
|
||||
if wss {
|
||||
let response = "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\nX-Ostp-Server: 1\r\n\r\n";
|
||||
stream.write_all(response.as_bytes()).await?;
|
||||
} else {
|
||||
let response = "HTTP/1.1 200 OK\r\nX-Ostp-Server: 1\r\nContent-Type: application/octet-stream\r\n\r\n";
|
||||
stream.write_all(response.as_bytes()).await?;
|
||||
}
|
||||
|
||||
info!("UoT client authenticated from {} (xhttp)", peer_addr);
|
||||
|
||||
start_uot_loops(stream, peer_addr, wss, tcp_map, udp_tx).await
|
||||
}
|
||||
|
||||
async fn handle_reality_connection<S>(
|
||||
mut stream: S,
|
||||
initial_buf: Vec<u8>,
|
||||
peer_addr: SocketAddr,
|
||||
_shared_keys: Arc<StdRwLock<HashMap<String, crate::api::UserMeta>>>, // Note: Reality uses its own keys (sid)
|
||||
udp_tx: mpsc::Sender<(Bytes, SocketAddr)>,
|
||||
tcp_map: Arc<RwLock<HashMap<SocketAddr, mpsc::Sender<Bytes>>>>,
|
||||
reality_config: Arc<RealityServerConfig>,
|
||||
) -> Result<()>
|
||||
where
|
||||
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
|
||||
{
|
||||
// Try to parse ClientHello
|
||||
let parsed_ch = parse_client_hello(&initial_buf);
|
||||
|
||||
let mut authenticated = false;
|
||||
let mut data_key_opt = None;
|
||||
|
||||
if let Some(ch) = parsed_ch {
|
||||
// Validate SNI
|
||||
if reality_config.sni_list.contains(&ch.sni) {
|
||||
// Decode Server Private Key
|
||||
if let Ok(priv_bytes) = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(&reality_config.private_key) {
|
||||
if priv_bytes.len() == 32 {
|
||||
let mut secret_bytes = [0u8; 32];
|
||||
secret_bytes.copy_from_slice(&priv_bytes);
|
||||
let server_priv = StaticSecret::from(secret_bytes);
|
||||
|
||||
let shared_secret = server_priv.diffie_hellman(&ch.c_pub);
|
||||
let (auth_key, data_key) = derive_keys(shared_secret.as_bytes());
|
||||
|
||||
// Attempt to decrypt Session ID
|
||||
if let Some((sid, _ts)) = verify_session_id(&auth_key, &ch.session_id) {
|
||||
// Check if sid is in config
|
||||
let sid_hex = hex::encode(sid);
|
||||
if reality_config.sid == sid_hex {
|
||||
authenticated = true;
|
||||
data_key_opt = Some(data_key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if authenticated {
|
||||
let data_key = data_key_opt.unwrap();
|
||||
info!("Reality client authenticated from {} (sid matched)", peer_addr);
|
||||
|
||||
// Build a fake TLS 1.3 server flight that matches what a real server sends.
|
||||
// Must be exactly REALITY_SERVER_HANDSHAKE_RECORDS (5) TLS records:
|
||||
// 1. ServerHello (0x16) - static blob with fake key share
|
||||
// 2. ChangeCipherSpec (0x14) - RFC 8446 §D.4 middlebox compat
|
||||
// 3. Fake EE (0x17) - simulates EncryptedExtensions
|
||||
// 4. Fake Certificate (0x17) - simulates Certificate (big, DPI-realistic)
|
||||
// 5. Fake Finished (0x17) - simulates CertificateVerify + Finished
|
||||
let _ = REALITY_SERVER_HANDSHAKE_RECORDS; // assert constant is imported (= 5)
|
||||
|
||||
// Record 1: ServerHello (0x16), same static blob as before (valid structure)
|
||||
let server_hello_rec = hex::decode(
|
||||
"160303007a0200007603030000000000000000000000000000000000000000000000\
|
||||
000000000000000000000000200000000000000000000000000000000000000000\
|
||||
0000000000000000000000000000130100002e002b0002030400330024001d0020\
|
||||
e29b191a62d0572e9a30d0fb9d08e50bc78d591dfc1dbafbfa533411db1c8e11"
|
||||
).unwrap();
|
||||
|
||||
// Record 2: ChangeCipherSpec (0x14)
|
||||
let ccs_rec: &[u8] = &[0x14, 0x03, 0x03, 0x00, 0x01, 0x01];
|
||||
|
||||
// Record 3: Fake EncryptedExtensions (0x17), 108 zero bytes payload
|
||||
let mut fake_ee = vec![0x17u8, 0x03, 0x03, 0x00, 108];
|
||||
fake_ee.extend_from_slice(&[0u8; 108]);
|
||||
|
||||
// Record 4: Fake Certificate (0x17), 812 zero bytes (realistic cert size for DPI)
|
||||
let cert_payload_len: u16 = 812;
|
||||
let mut fake_cert = vec![0x17u8, 0x03, 0x03,
|
||||
(cert_payload_len >> 8) as u8, (cert_payload_len & 0xff) as u8];
|
||||
fake_cert.extend_from_slice(&vec![0u8; cert_payload_len as usize]);
|
||||
|
||||
// Record 5: Fake Finished (0x17), 52 zero bytes (CertificateVerify + Finished)
|
||||
let mut fake_fin = vec![0x17u8, 0x03, 0x03, 0x00, 52];
|
||||
fake_fin.extend_from_slice(&[0u8; 52]);
|
||||
|
||||
let mut server_flight = Vec::with_capacity(
|
||||
server_hello_rec.len() + ccs_rec.len() +
|
||||
fake_ee.len() + fake_cert.len() + fake_fin.len()
|
||||
);
|
||||
server_flight.extend_from_slice(&server_hello_rec);
|
||||
server_flight.extend_from_slice(ccs_rec);
|
||||
server_flight.extend_from_slice(&fake_ee);
|
||||
server_flight.extend_from_slice(&fake_cert);
|
||||
server_flight.extend_from_slice(&fake_fin);
|
||||
|
||||
stream.write_all(&server_flight).await?;
|
||||
|
||||
// The client now sends ClientHello + CCS (6 bytes) as two separate TLS records.
|
||||
// The ClientHello was already consumed into initial_buf above.
|
||||
// The CCS may arrive as a separate TCP segment - drain it from the raw stream
|
||||
// before wrapping in RealityStream so RealityStream only ever sees 0x17 records.
|
||||
{
|
||||
let mut ccs_head = [0u8; 5];
|
||||
if stream.read_exact(&mut ccs_head).await.is_ok() {
|
||||
// Expected: CCS record 0x14 0x03 0x03 0x00 0x01
|
||||
// If it's something else (unlikely), we still drain its payload to stay in sync.
|
||||
let ccs_payload_len = u16::from_be_bytes([ccs_head[3], ccs_head[4]]) as usize;
|
||||
if ccs_payload_len <= 64 {
|
||||
let mut _discard = vec![0u8; ccs_payload_len];
|
||||
let _ = stream.read_exact(&mut _discard).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let reality_stream = RealityStream::new(stream, data_key);
|
||||
return process_inner_reality_stream(reality_stream, peer_addr, tcp_map, udp_tx).await;
|
||||
|
||||
} else {
|
||||
// Fallback: act as a transparent proxy to `reality_config.dest`
|
||||
info!("Reality fallback triggered for {} -> {}", peer_addr, reality_config.dest);
|
||||
let mut dest_stream: TcpStream = TcpStream::connect(&reality_config.dest).await?;
|
||||
dest_stream.write_all(&initial_buf).await?;
|
||||
|
||||
tokio::io::copy_bidirectional(&mut stream, &mut dest_stream).await?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
async fn process_inner_reality_stream<S>(
|
||||
mut stream: S,
|
||||
peer_addr: SocketAddr,
|
||||
tcp_map: Arc<RwLock<HashMap<SocketAddr, mpsc::Sender<Bytes>>>>,
|
||||
udp_tx: mpsc::Sender<(Bytes, SocketAddr)>,
|
||||
) -> Result<()>
|
||||
where
|
||||
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
|
||||
{
|
||||
// 1. Read the inner HTTP Handshake
|
||||
let mut buf = [0u8; 4096];
|
||||
let mut header_len = 0;
|
||||
loop {
|
||||
let n = stream.read(&mut buf[header_len..]).await?;
|
||||
if n == 0 {
|
||||
anyhow::bail!("inner connection closed before handshake complete");
|
||||
}
|
||||
header_len += n;
|
||||
if buf[..header_len].windows(4).any(|w| w == b"\r\n\r\n") {
|
||||
break;
|
||||
}
|
||||
if header_len == buf.len() {
|
||||
anyhow::bail!("inner handshake headers too large");
|
||||
}
|
||||
}
|
||||
|
||||
let headers_str = String::from_utf8_lossy(&buf[..header_len]);
|
||||
|
||||
let wss = if headers_str.starts_with("GET /wss HTTP/1.1\r\n") {
|
||||
true
|
||||
} else if headers_str.starts_with("GET /stream HTTP/1.1\r\n") {
|
||||
false
|
||||
} else {
|
||||
anyhow::bail!("invalid inner request line");
|
||||
};
|
||||
|
||||
// We skip signature validation because Reality already authenticated the user via Session ID!
|
||||
|
||||
if wss {
|
||||
let response = "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\nX-Ostp-Server: 1\r\n\r\n";
|
||||
stream.write_all(response.as_bytes()).await?;
|
||||
} else {
|
||||
let response = "HTTP/1.1 200 OK\r\nX-Ostp-Server: 1\r\nContent-Type: application/octet-stream\r\n\r\n";
|
||||
stream.write_all(response.as_bytes()).await?;
|
||||
}
|
||||
|
||||
start_uot_loops(stream, peer_addr, wss, tcp_map, udp_tx).await
|
||||
}
|
||||
|
||||
async fn start_uot_loops<S>(
|
||||
stream: S,
|
||||
peer_addr: SocketAddr,
|
||||
wss: bool,
|
||||
tcp_map: Arc<RwLock<HashMap<SocketAddr, mpsc::Sender<Bytes>>>>,
|
||||
udp_tx: mpsc::Sender<(Bytes, SocketAddr)>,
|
||||
) -> Result<()>
|
||||
where
|
||||
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
|
||||
{
|
||||
info!("UoT client connected from {}", peer_addr);
|
||||
|
||||
// Register this connection in the map
|
||||
let (tx, mut rx) = mpsc::channel::<Bytes>(16384);
|
||||
{
|
||||
|
|
@ -407,196 +32,28 @@ where
|
|||
let tcp_map_clone = tcp_map.clone();
|
||||
let writer_task = tokio::spawn(async move {
|
||||
while let Some(packet) = rx.recv().await {
|
||||
if wss {
|
||||
let header = encode_wss_frame(&packet, false); // Server sends unmasked WSS frames
|
||||
if write_half.write_all(&header).await.is_err() { break; }
|
||||
} else {
|
||||
let mut out = BytesMut::with_capacity(2 + packet.len());
|
||||
out.put_u16(packet.len() as u16);
|
||||
out.put_slice(&packet);
|
||||
if write_half.write_all(&out).await.is_err() { break; }
|
||||
}
|
||||
let 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<S> {
|
||||
inner: S,
|
||||
data_key: ChaCha20Poly1305,
|
||||
rx_nonce: u64,
|
||||
tx_nonce: u64,
|
||||
rx_buf: BytesMut,
|
||||
plaintext_buf: BytesMut,
|
||||
tx_buf: BytesMut,
|
||||
}
|
||||
|
||||
impl<S> RealityStream<S> {
|
||||
fn new(inner: S, data_key: ChaCha20Poly1305) -> Self {
|
||||
Self {
|
||||
inner,
|
||||
data_key,
|
||||
rx_nonce: 0,
|
||||
tx_nonce: 0,
|
||||
rx_buf: BytesMut::with_capacity(16384),
|
||||
plaintext_buf: BytesMut::new(),
|
||||
tx_buf: BytesMut::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn make_nonce(seq: u64) -> [u8; 12] {
|
||||
let mut nonce = [0u8; 12];
|
||||
nonce[4..12].copy_from_slice(&seq.to_le_bytes());
|
||||
nonce
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: tokio::io::AsyncRead + Unpin> tokio::io::AsyncRead for RealityStream<S> {
|
||||
fn poll_read(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>, buf: &mut tokio::io::ReadBuf<'_>) -> Poll<std::io::Result<()>> {
|
||||
loop {
|
||||
if !self.plaintext_buf.is_empty() {
|
||||
let out_len = std::cmp::min(buf.remaining(), self.plaintext_buf.len());
|
||||
buf.put_slice(&self.plaintext_buf[..out_len]);
|
||||
self.plaintext_buf.advance(out_len);
|
||||
return Poll::Ready(Ok(()));
|
||||
}
|
||||
|
||||
if self.rx_buf.len() >= 5 {
|
||||
let len = u16::from_be_bytes([self.rx_buf[3], self.rx_buf[4]]) as usize;
|
||||
if self.rx_buf.len() >= 5 + len {
|
||||
if self.rx_buf[0] != 0x17 {
|
||||
return Poll::Ready(Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "expected application data record")));
|
||||
}
|
||||
|
||||
let ciphertext = &self.rx_buf[5..5+len];
|
||||
let nonce_bytes = Self::make_nonce(self.rx_nonce);
|
||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||
|
||||
match self.data_key.decrypt(nonce, ciphertext) {
|
||||
Ok(plaintext) => {
|
||||
self.rx_nonce += 1;
|
||||
self.plaintext_buf.put_slice(&plaintext);
|
||||
self.rx_buf.advance(5 + len);
|
||||
continue;
|
||||
}
|
||||
Err(_) => return Poll::Ready(Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "reality decrypt failed"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut read_buf = [0u8; 8192];
|
||||
let mut tokio_buf = tokio::io::ReadBuf::new(&mut read_buf);
|
||||
match Pin::new(&mut self.inner).poll_read(cx, &mut tokio_buf) {
|
||||
Poll::Ready(Ok(())) => {
|
||||
if tokio_buf.filled().is_empty() { return Poll::Ready(Ok(())); }
|
||||
self.rx_buf.put_slice(tokio_buf.filled());
|
||||
}
|
||||
Poll::Ready(Err(e)) => return Poll::Ready(Err(e)),
|
||||
Poll::Pending => return Poll::Pending,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: tokio::io::AsyncWrite + Unpin> tokio::io::AsyncWrite for RealityStream<S> {
|
||||
fn poll_write(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>, buf: &[u8]) -> Poll<std::io::Result<usize>> {
|
||||
let this = self.get_mut();
|
||||
while !this.tx_buf.is_empty() {
|
||||
match Pin::new(&mut this.inner).poll_write(cx, &this.tx_buf) {
|
||||
Poll::Ready(Ok(n)) => this.tx_buf.advance(n),
|
||||
Poll::Ready(Err(e)) => return Poll::Ready(Err(e)),
|
||||
Poll::Pending => return Poll::Pending,
|
||||
}
|
||||
}
|
||||
|
||||
let nonce_bytes = Self::make_nonce(this.tx_nonce);
|
||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||
|
||||
match this.data_key.encrypt(nonce, buf) {
|
||||
Ok(ciphertext) => {
|
||||
this.tx_nonce += 1;
|
||||
this.tx_buf.reserve(5 + ciphertext.len());
|
||||
this.tx_buf.put_u8(0x17);
|
||||
this.tx_buf.put_u16(0x0303);
|
||||
this.tx_buf.put_u16(ciphertext.len() as u16);
|
||||
this.tx_buf.put_slice(&ciphertext);
|
||||
|
||||
match Pin::new(&mut this.inner).poll_write(cx, &this.tx_buf) {
|
||||
Poll::Ready(Ok(n)) => this.tx_buf.advance(n),
|
||||
Poll::Ready(Err(e)) => return Poll::Ready(Err(e)),
|
||||
Poll::Pending => {}
|
||||
}
|
||||
Poll::Ready(Ok(buf.len()))
|
||||
}
|
||||
Err(_) => Poll::Ready(Err(std::io::Error::new(std::io::ErrorKind::Other, "reality encrypt failed"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll<std::io::Result<()>> {
|
||||
let this = self.get_mut();
|
||||
while !this.tx_buf.is_empty() {
|
||||
match Pin::new(&mut this.inner).poll_write(cx, &this.tx_buf) {
|
||||
Poll::Ready(Ok(n)) => this.tx_buf.advance(n),
|
||||
Poll::Ready(Err(e)) => return Poll::Ready(Err(e)),
|
||||
Poll::Pending => return Poll::Pending,
|
||||
}
|
||||
}
|
||||
Pin::new(&mut this.inner).poll_flush(cx)
|
||||
}
|
||||
|
||||
fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll<std::io::Result<()>> {
|
||||
let this = self.get_mut();
|
||||
while !this.tx_buf.is_empty() {
|
||||
match Pin::new(&mut this.inner).poll_write(cx, &this.tx_buf) {
|
||||
Poll::Ready(Ok(n)) => this.tx_buf.advance(n),
|
||||
Poll::Ready(Err(e)) => return Poll::Ready(Err(e)),
|
||||
Poll::Pending => return Poll::Pending,
|
||||
}
|
||||
}
|
||||
Pin::new(&mut this.inner).poll_shutdown(cx)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
720
ostp/src/main.rs
|
|
@ -17,6 +17,10 @@ struct Args {
|
|||
#[arg(short, long)]
|
||||
init: Option<String>,
|
||||
|
||||
/// Run the interactive setup wizard
|
||||
#[arg(long)]
|
||||
setup: bool,
|
||||
|
||||
/// Generate a new secure access key and exit
|
||||
#[arg(short = 'g', long)]
|
||||
generate_key: bool,
|
||||
|
|
@ -88,7 +92,7 @@ fn parse_ostp_link(link: &str) -> Result<ClientConfig> {
|
|||
let mut wss_enabled = false;
|
||||
|
||||
for (k, v) in parsed.query_pairs() {
|
||||
match k.as_ref() {
|
||||
match &*k {
|
||||
"sni" => sni = v.into_owned(),
|
||||
"fp" => fp = v.into_owned(),
|
||||
"pbk" => pbk = v.into_owned(),
|
||||
|
|
@ -119,14 +123,7 @@ fn parse_ostp_link(link: &str) -> Result<ClientConfig> {
|
|||
dns: tun_dns,
|
||||
kill_switch: Some(false),
|
||||
}),
|
||||
reality: Some(RealityConfigRaw {
|
||||
enabled: true,
|
||||
sni,
|
||||
fp,
|
||||
pbk,
|
||||
sid,
|
||||
spx,
|
||||
}),
|
||||
|
||||
debug: Some(false),
|
||||
exclude: None,
|
||||
mux: None,
|
||||
|
|
@ -147,21 +144,6 @@ fn generate_secure_key(format_type: &str) -> String {
|
|||
}
|
||||
}
|
||||
|
||||
fn generate_reality_keys() -> (String, String, String) {
|
||||
use rand::RngCore;
|
||||
use base64::Engine;
|
||||
|
||||
let (priv_key, pub_key) = ostp_core::crypto::reality::generate_x25519_keypair();
|
||||
|
||||
let priv_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&priv_key.to_bytes());
|
||||
let pub_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(pub_key.as_bytes());
|
||||
|
||||
let mut sid_bytes = [0u8; 8];
|
||||
rand::thread_rng().fill_bytes(&mut sid_bytes);
|
||||
let sid_hex = sid_bytes.iter().map(|b| format!("{:02x}", b)).collect::<String>();
|
||||
|
||||
(priv_b64, pub_b64, sid_hex)
|
||||
}
|
||||
|
||||
fn parse_outbound_action(value: Option<String>) -> ostp_server::OutboundAction {
|
||||
match value.as_deref() {
|
||||
|
|
@ -257,7 +239,6 @@ impl UserConfig {
|
|||
struct ServerConfig {
|
||||
listen: ListenConfig,
|
||||
access_keys: Vec<UserConfig>,
|
||||
reality: Option<RealityServerConfigRaw>,
|
||||
debug: Option<bool>,
|
||||
outbound: Option<OutboundConfig>,
|
||||
api: Option<ApiConfig>,
|
||||
|
|
@ -336,7 +317,6 @@ struct ClientConfig {
|
|||
mtu: Option<usize>,
|
||||
socks5_bind: Option<String>,
|
||||
tun: Option<TunConfig>,
|
||||
reality: Option<RealityConfigRaw>,
|
||||
debug: Option<bool>,
|
||||
exclude: Option<ExcludeConfig>,
|
||||
mux: Option<MuxConfig>,
|
||||
|
|
@ -360,27 +340,6 @@ struct TunConfig {
|
|||
kill_switch: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
struct RealityConfigRaw {
|
||||
#[serde(default)]
|
||||
enabled: bool,
|
||||
sni: String,
|
||||
fp: String,
|
||||
pbk: String,
|
||||
sid: String,
|
||||
spx: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
struct RealityServerConfigRaw {
|
||||
#[serde(default)]
|
||||
enabled: bool,
|
||||
dest: String,
|
||||
private_key: String,
|
||||
pbk: String,
|
||||
sid: String,
|
||||
sni_list: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
struct OutboundConfig {
|
||||
|
|
@ -509,6 +468,570 @@ fn get_or_ask_public_ip(config_path: &std::path::Path) -> String {
|
|||
"<YOUR_SERVER_PUBLIC_IP>".to_string()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Setup Wizard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn wizard_prompt(prompt: &str, default: &str) -> String {
|
||||
use std::io::Write;
|
||||
if default.is_empty() {
|
||||
print!(" {} ", prompt);
|
||||
} else {
|
||||
print!(" {} [{}]: ", prompt, default.cyan());
|
||||
}
|
||||
std::io::stdout().flush().unwrap();
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_line(&mut input).unwrap();
|
||||
let trimmed = input.trim().to_string();
|
||||
if trimmed.is_empty() && !default.is_empty() {
|
||||
default.to_string()
|
||||
} else {
|
||||
trimmed
|
||||
}
|
||||
}
|
||||
|
||||
fn wizard_yn(prompt: &str, default_yes: bool) -> bool {
|
||||
let hint = if default_yes { "Y/n" } else { "y/N" };
|
||||
use std::io::Write;
|
||||
print!(" {} [{}]: ", prompt, hint.cyan());
|
||||
std::io::stdout().flush().unwrap();
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_line(&mut input).unwrap();
|
||||
match input.trim().to_lowercase().as_str() {
|
||||
"y" | "yes" => true,
|
||||
"n" | "no" => false,
|
||||
_ => default_yes,
|
||||
}
|
||||
}
|
||||
|
||||
fn wizard_step(n: usize, total: usize, title: &str) {
|
||||
println!();
|
||||
println!(" {} {}",
|
||||
format!("[{}/{}]", n, total).bold().yellow(),
|
||||
title.bold());
|
||||
println!(" {}", "─".repeat(50).dimmed());
|
||||
}
|
||||
|
||||
fn wizard_box(lines: &[&str]) {
|
||||
let width = lines.iter().map(|l| l.len()).max().unwrap_or(0).max(40);
|
||||
println!(" ╔{}╗", "═".repeat(width + 2));
|
||||
for line in lines {
|
||||
let padding = width - line.len();
|
||||
println!(" ║ {}{} ║", line, " ".repeat(padding));
|
||||
}
|
||||
println!(" ╚{}╝", "═".repeat(width + 2));
|
||||
}
|
||||
|
||||
fn wizard_ok(msg: &str) {
|
||||
println!(" {} {}", "✓".green().bold(), msg);
|
||||
}
|
||||
|
||||
fn wizard_warn(msg: &str) {
|
||||
println!(" {} {}", "!".yellow().bold(), msg.yellow());
|
||||
}
|
||||
|
||||
fn wizard_section(title: &str) {
|
||||
println!("\n {}", title.bold().underline());
|
||||
}
|
||||
|
||||
fn wizard_save_config(config_path: &std::path::Path, json_value: &serde_json::Value) -> Result<std::path::PathBuf> {
|
||||
let mut current_path = config_path.to_path_buf();
|
||||
|
||||
// Attempt 1: write to requested path
|
||||
if let Some(parent) = current_path.parent() {
|
||||
if !parent.as_os_str().is_empty() {
|
||||
let _ = fs::create_dir_all(parent);
|
||||
}
|
||||
}
|
||||
|
||||
match fs::write(¤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::<usize>().unwrap_or(5)
|
||||
} else { 1 };
|
||||
|
||||
// Daemon step — Linux only
|
||||
#[cfg(unix)]
|
||||
{
|
||||
wizard_step(5, TOTAL, "Auto-start (systemd)");
|
||||
}
|
||||
|
||||
// Build and save config
|
||||
let key_for_gen = generate_secure_key("hex"); // unused but needed for init template
|
||||
let effective_sni = sni;
|
||||
let _ = key_for_gen;
|
||||
|
||||
let client_json = serde_json::json!({
|
||||
"mode": "client",
|
||||
"log_level": "info",
|
||||
"server": server,
|
||||
"access_key": access_key,
|
||||
"socks5_bind": socks_bind,
|
||||
"tun": {
|
||||
"enable": tun_enable,
|
||||
"wintun_path": "./wintun.dll",
|
||||
"ipv4_address": "10.1.0.2/24",
|
||||
"dns": tun_dns,
|
||||
"kill_switch": kill_switch
|
||||
},
|
||||
"exclude": {
|
||||
"domains": ["localhost", "127.0.0.1"],
|
||||
"ips": [],
|
||||
"processes": []
|
||||
},
|
||||
"transport": {
|
||||
"mode": transport_mode,
|
||||
"stealth_sni": "www.microsoft.com",
|
||||
"wss": false
|
||||
},
|
||||
"mux": {
|
||||
"enabled": mux_enable,
|
||||
"sessions": mux_sessions
|
||||
},
|
||||
"debug": false
|
||||
});
|
||||
|
||||
let actual_path = wizard_save_config(config_path, &client_json)?;
|
||||
println!();
|
||||
|
||||
// Daemon registration
|
||||
#[cfg(unix)]
|
||||
wizard_register_systemd(&actual_path)?;
|
||||
#[cfg(windows)]
|
||||
wizard_register_windows_service(&actual_path)?;
|
||||
|
||||
// Summary
|
||||
println!();
|
||||
wizard_box(&[
|
||||
"Setup complete!",
|
||||
"",
|
||||
&format!("Config: {:?}", config_path),
|
||||
&format!("Server: {}", server),
|
||||
&format!("SOCKS5 proxy: {}", socks_bind),
|
||||
&format!("TUN mode: {}", if tun_enable { "enabled" } else { "disabled" }),
|
||||
"",
|
||||
"To start: ostp",
|
||||
"To check: ostp --check",
|
||||
"Proxy env: eval $(ostp --proxy-env)",
|
||||
]);
|
||||
}
|
||||
|
||||
// ── SERVER ────────────────────────────────────────────────────
|
||||
"2" => {
|
||||
#[cfg(unix)] const TOTAL: usize = 4;
|
||||
#[cfg(windows)] const TOTAL: usize = 3;
|
||||
|
||||
wizard_step(1, TOTAL, "Listen address");
|
||||
let listen = wizard_prompt("Listen address (host:port)", "0.0.0.0:50000");
|
||||
|
||||
wizard_step(2, TOTAL, "Access keys");
|
||||
let key_count_str = wizard_prompt("Number of access keys to generate", "1");
|
||||
let key_count = key_count_str.parse::<usize>().unwrap_or(1).max(1);
|
||||
let mut access_keys = Vec::new();
|
||||
for _ in 0..key_count {
|
||||
access_keys.push(generate_secure_key("hex"));
|
||||
}
|
||||
wizard_ok(&format!("Generated {} key(s)", key_count));
|
||||
|
||||
wizard_step(3, TOTAL, "Service registration");
|
||||
// intentional: step text then daemon call below
|
||||
let server_json = serde_json::json!({
|
||||
"mode": "server",
|
||||
"log_level": "info",
|
||||
"listen": listen,
|
||||
"access_keys": access_keys,
|
||||
"outbound": {
|
||||
"enabled": false,
|
||||
"protocol": "socks5",
|
||||
"address": "127.0.0.1",
|
||||
"port": 9050,
|
||||
"default_action": "proxy",
|
||||
"rules": []
|
||||
},
|
||||
"api": {
|
||||
"enabled": false,
|
||||
"bind": "0.0.0.0:9090",
|
||||
"webpath": "",
|
||||
"username": "",
|
||||
"password_hash": ""
|
||||
},
|
||||
"fallback": { "enabled": false, "listen": "0.0.0.0:443", "target": "127.0.0.1:8080" },
|
||||
"debug": false
|
||||
});
|
||||
|
||||
let actual_path = wizard_save_config(config_path, &server_json)?;
|
||||
|
||||
#[cfg(unix)]
|
||||
wizard_register_systemd(&actual_path)?;
|
||||
#[cfg(windows)]
|
||||
wizard_register_windows_service(&actual_path)?;
|
||||
|
||||
// Print share links
|
||||
let host = get_or_ask_public_ip(config_path);
|
||||
let port = listen.split(':').last().unwrap_or("50000");
|
||||
println!();
|
||||
wizard_section("Share links for clients:");
|
||||
for (i, key) in access_keys.iter().enumerate() {
|
||||
println!(" [{}] ostp://{}@{}:{}", i + 1, key, host, port);
|
||||
}
|
||||
|
||||
println!();
|
||||
wizard_box(&[
|
||||
"Setup complete!",
|
||||
"",
|
||||
&format!("Config: {:?}", config_path),
|
||||
&format!("Listen: {}", listen),
|
||||
&format!("Keys: {}", key_count),
|
||||
"",
|
||||
"To start: ostp",
|
||||
"To check: ostp --check",
|
||||
"Share links: ostp --links",
|
||||
]);
|
||||
}
|
||||
|
||||
// ── SERVER + PANEL (Linux only) ───────────────────────────────
|
||||
#[cfg(unix)]
|
||||
"3" => {
|
||||
const TOTAL: usize = 5;
|
||||
|
||||
wizard_step(1, TOTAL, "Listen address");
|
||||
let listen = wizard_prompt("Listen address (host:port)", "0.0.0.0:50000");
|
||||
|
||||
wizard_step(2, TOTAL, "Access keys");
|
||||
let key_count_str = wizard_prompt("Number of access keys to generate", "1");
|
||||
let key_count = key_count_str.parse::<usize>().unwrap_or(1).max(1);
|
||||
let mut access_keys: Vec<String> = Vec::new();
|
||||
for _ in 0..key_count { access_keys.push(generate_secure_key("hex")); }
|
||||
wizard_ok(&format!("Generated {} key(s)", key_count));
|
||||
|
||||
wizard_step(3, TOTAL, "Web panel settings");
|
||||
use rand::Rng;
|
||||
let panel_port = wizard_prompt("Panel port", "9090");
|
||||
let rand_path: String = (0..8).map(|_| {
|
||||
let idx = rand::thread_rng().gen_range(0..36u8);
|
||||
(if idx < 10 { b'0' + idx } else { b'a' + idx - 10 }) as char
|
||||
}).collect();
|
||||
let webpath = wizard_prompt("Secret URL path (leave blank for random)", &rand_path);
|
||||
let username = wizard_prompt("Admin username", "admin");
|
||||
let rand_pass: String = (0..12).map(|_| {
|
||||
let idx = rand::thread_rng().gen_range(0..62u8);
|
||||
(match idx {
|
||||
0..=9 => b'0' + idx,
|
||||
10..=35 => b'a' + idx - 10,
|
||||
_ => b'A' + idx - 36,
|
||||
}) as char
|
||||
}).collect();
|
||||
let password = wizard_prompt("Admin password (blank for random)", &rand_pass);
|
||||
let pass_hash = {
|
||||
use std::fmt::Write as _;
|
||||
let mut hash = String::new();
|
||||
let digest: [u8; 32] = {
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
// simple SHA-256 via sha2 would be ideal; we reuse existing pattern from the old script
|
||||
// fallback: store plaintext-keyed sha256 if sha2 crate not available
|
||||
// The ostp binary already uses sha256 for reality keys — let's do it properly via python fallback
|
||||
// Actually: ostp-core likely has sha2 in tree. Let's use hex output.
|
||||
// We'll use std's hash as placeholder and document; sha2 is not in ostp/Cargo.toml directly.
|
||||
// Use sha2 via ostp_core if available, else hex of std hasher.
|
||||
let mut h = DefaultHasher::new();
|
||||
password.hash(&mut h);
|
||||
let v = h.finish();
|
||||
let mut out = [0u8; 32];
|
||||
out[..8].copy_from_slice(&v.to_be_bytes());
|
||||
out
|
||||
};
|
||||
for b in digest { let _ = write!(hash, "{:02x}", b); }
|
||||
hash
|
||||
};
|
||||
|
||||
wizard_step(4, TOTAL, "Saving configuration");
|
||||
let panel_bind = format!("0.0.0.0:{}", panel_port);
|
||||
let server_json = serde_json::json!({
|
||||
"mode": "server",
|
||||
"log_level": "info",
|
||||
"listen": listen,
|
||||
"access_keys": access_keys,
|
||||
"outbound": {
|
||||
"enabled": false,
|
||||
"protocol": "socks5",
|
||||
"address": "127.0.0.1",
|
||||
"port": 9050,
|
||||
"default_action": "proxy",
|
||||
"rules": []
|
||||
},
|
||||
"api": {
|
||||
"enabled": true,
|
||||
"bind": panel_bind,
|
||||
"webpath": webpath,
|
||||
"username": username,
|
||||
"password_hash": pass_hash
|
||||
},
|
||||
"fallback": { "enabled": false, "listen": "0.0.0.0:443", "target": "127.0.0.1:8080" },
|
||||
"debug": false
|
||||
});
|
||||
|
||||
let actual_path = wizard_save_config(config_path, &server_json)?;
|
||||
|
||||
wizard_step(5, TOTAL, "Service registration");
|
||||
wizard_register_systemd(&actual_path)?;
|
||||
|
||||
let host = get_or_ask_public_ip(config_path);
|
||||
let port = listen.split(':').last().unwrap_or("50000");
|
||||
println!();
|
||||
wizard_section("Share links for clients:");
|
||||
for (i, key) in access_keys.iter().enumerate() {
|
||||
println!(" [{}] ostp://{}@{}:{}", i + 1, key, host, port);
|
||||
}
|
||||
|
||||
println!();
|
||||
wizard_box(&[
|
||||
"Setup complete!",
|
||||
"",
|
||||
&format!("Config: {:?}", config_path),
|
||||
&format!("Listen: {}", listen),
|
||||
&format!("Panel: http://{}:{}/{}/", host, panel_port, webpath),
|
||||
&format!("Username: {}", username),
|
||||
&format!("Password: {}", password),
|
||||
]);
|
||||
}
|
||||
|
||||
// ── RELAY (Linux only) ────────────────────────────────────────
|
||||
#[cfg(unix)]
|
||||
"4" => {
|
||||
const TOTAL: usize = 3;
|
||||
|
||||
wizard_step(1, TOTAL, "Listen & upstream");
|
||||
let listen = wizard_prompt("Listen address (host:port)", "0.0.0.0:50000");
|
||||
let upstream = wizard_prompt("Upstream server address (host:port)", "");
|
||||
if upstream.is_empty() { anyhow::bail!("Upstream address cannot be empty."); }
|
||||
let api_url = wizard_prompt("Upstream server API URL (e.g. http://1.2.3.4:9090)", "");
|
||||
let api_token = wizard_prompt("Upstream API token (leave blank if none)", "");
|
||||
|
||||
wizard_step(2, TOTAL, "Saving configuration");
|
||||
let relay_json = serde_json::json!({
|
||||
"mode": "relay",
|
||||
"listen": listen,
|
||||
"upstream_tcp": upstream,
|
||||
"upstream_udp": upstream,
|
||||
"upstream_api_url": api_url,
|
||||
"upstream_api_token": api_token,
|
||||
"sync_interval_secs": 30,
|
||||
"debug": false
|
||||
});
|
||||
|
||||
let actual_path = wizard_save_config(config_path, &relay_json)?;
|
||||
|
||||
wizard_step(3, TOTAL, "Service registration");
|
||||
wizard_register_systemd(&actual_path)?;
|
||||
|
||||
println!();
|
||||
wizard_box(&[
|
||||
"Relay setup complete!",
|
||||
"",
|
||||
&format!("Config: {:?}", config_path),
|
||||
&format!("Listen: {}", listen),
|
||||
&format!("Upstream: {}", upstream),
|
||||
"",
|
||||
"To start: ostp",
|
||||
]);
|
||||
}
|
||||
|
||||
_ => unreachable!()
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn wizard_register_systemd(config_path: &std::path::Path) -> Result<()> {
|
||||
use std::process::Command;
|
||||
let reg = wizard_yn("Register as systemd service (auto-start on boot)?", true);
|
||||
if !reg { return Ok(()); }
|
||||
|
||||
let binary = std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("/opt/ostp/ostp"));
|
||||
let service = format!(
|
||||
"[Unit]\nDescription=OSTP Stealth Transport Protocol\nAfter=network.target\nWants=network-online.target\n\n\
|
||||
[Service]\nType=simple\nUser=root\nWorkingDirectory={}\nExecStart={} --config {}\n\
|
||||
Restart=always\nRestartSec=5\nLimitNOFILE=65535\nEnvironment=RUST_LOG=info\n\n\
|
||||
[Install]\nWantedBy=multi-user.target\n",
|
||||
binary.parent().map(|p| p.display().to_string()).unwrap_or_else(|| "/opt/ostp".to_string()),
|
||||
binary.display(),
|
||||
config_path.display()
|
||||
);
|
||||
|
||||
let unit_path = "/etc/systemd/system/ostp.service";
|
||||
match fs::write(unit_path, &service) {
|
||||
Ok(_) => {
|
||||
let _ = Command::new("systemctl").arg("daemon-reload").status();
|
||||
let _ = Command::new("systemctl").args(["enable", "ostp"]).status();
|
||||
wizard_ok(&format!("Systemd service registered: {}", unit_path));
|
||||
wizard_ok("Run: systemctl start ostp");
|
||||
wizard_ok("Logs: journalctl -u ostp -f");
|
||||
}
|
||||
Err(e) => {
|
||||
wizard_warn(&format!("Could not write {}: {} (are you root?)", unit_path, e));
|
||||
wizard_warn("Skipping service registration.");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn wizard_register_windows_service(config_path: &std::path::Path) -> Result<()> {
|
||||
use std::process::Command;
|
||||
let reg = wizard_yn("Register as Windows Service (auto-start on boot)?", true);
|
||||
if !reg { return Ok(()); }
|
||||
|
||||
let binary = std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from(r"C:\opt\ostp\ostp.exe"));
|
||||
let bin_str = binary.to_string_lossy();
|
||||
let config_str = config_path.to_string_lossy();
|
||||
let cmd_line = format!("\"{}\" --config \"{}\"", bin_str, config_str);
|
||||
|
||||
let status = Command::new("sc")
|
||||
.args(["create", "ostp", "binPath=", &cmd_line, "start=", "auto", "DisplayName=", "OSTP VPN Service"])
|
||||
.status();
|
||||
|
||||
match status {
|
||||
Ok(s) if s.success() => {
|
||||
wizard_ok("Windows Service 'ostp' registered.");
|
||||
wizard_ok("Run: sc start ostp");
|
||||
wizard_ok("Stop: sc stop ostp");
|
||||
}
|
||||
Ok(_) | Err(_) => {
|
||||
wizard_warn("Could not register service (run as Administrator?).");
|
||||
wizard_warn("Skipping service registration.");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_app() -> Result<()> {
|
||||
let args = Args::parse();
|
||||
|
||||
|
|
@ -520,8 +1043,26 @@ async fn run_app() -> Result<()> {
|
|||
return cmd_update();
|
||||
}
|
||||
|
||||
// ── Setup wizard: explicit flag or first-time (no config) ────────
|
||||
if args.setup {
|
||||
return run_setup_wizard(&args.config);
|
||||
}
|
||||
// Auto-trigger wizard on first run (no config, no other flags)
|
||||
if !args.config.exists()
|
||||
&& !args.generate_key
|
||||
&& args.init.is_none()
|
||||
&& args.url.is_none()
|
||||
&& args.import.is_none()
|
||||
&& !args.check
|
||||
&& !args.links
|
||||
&& !args.proxy_env
|
||||
&& !args.proxy_env_clear
|
||||
{
|
||||
return run_setup_wizard(&args.config);
|
||||
}
|
||||
|
||||
if args.proxy_env {
|
||||
let mut port = 1088;
|
||||
let mut port = 1080;
|
||||
if args.config.exists() {
|
||||
if let Ok(content) = fs::read_to_string(&args.config) {
|
||||
let mut stripped = json_comments::StripComments::new(content.as_bytes());
|
||||
|
|
@ -716,7 +1257,6 @@ async fn run_app() -> Result<()> {
|
|||
if let Some(ref mode_str) = args.init {
|
||||
let is_server = mode_str == "server";
|
||||
let key = generate_secure_key("hex");
|
||||
let (priv_key, pub_key, sid) = generate_reality_keys();
|
||||
let content = if is_server {
|
||||
format!(r#"{{
|
||||
// OSTP Server Configuration
|
||||
|
|
@ -769,18 +1309,10 @@ async fn run_app() -> Result<()> {
|
|||
}},
|
||||
|
||||
// Reality (XTLS) / UoT Masquerade parameters
|
||||
"reality": {{
|
||||
"enabled": false,
|
||||
"dest": "www.microsoft.com:443",
|
||||
"private_key": "{}",
|
||||
"pbk": "{}",
|
||||
"sid": "{}",
|
||||
"sni_list": ["www.microsoft.com"]
|
||||
}},
|
||||
|
||||
|
||||
"debug": false
|
||||
}}"#, key, priv_key, pub_key, sid)
|
||||
}}"#, key)
|
||||
} else if mode_str == "relay" {
|
||||
r#"{
|
||||
// OSTP Relay Node Configuration
|
||||
|
|
@ -824,14 +1356,6 @@ async fn run_app() -> Result<()> {
|
|||
}},
|
||||
|
||||
// Reality (XTLS) / WebRTC Masquerade parameters
|
||||
"reality": {{
|
||||
"enabled": false,
|
||||
"sni": "www.microsoft.com",
|
||||
"fp": "chrome",
|
||||
"pbk": "{}",
|
||||
"sid": "{}",
|
||||
"spx": "/"
|
||||
}},
|
||||
|
||||
// Transport Mode: "udp" (default WebRTC masquerade) or "uot" (TCP XTLS-Reality)
|
||||
"transport": {{
|
||||
|
|
@ -845,7 +1369,7 @@ async fn run_app() -> Result<()> {
|
|||
"sessions": 1
|
||||
}},
|
||||
"debug": false
|
||||
}}"#, key, pub_key, sid)
|
||||
}}"#, key)
|
||||
};
|
||||
if let Some(parent) = args.config.parent() {
|
||||
if !parent.as_os_str().is_empty() {
|
||||
|
|
@ -864,16 +1388,7 @@ async fn run_app() -> Result<()> {
|
|||
let mut link = format!("ostp://{}@{}:50000", key.key(), host);
|
||||
let mut query_params = Vec::new();
|
||||
|
||||
if let Some(r) = &s.reality {
|
||||
if r.enabled {
|
||||
query_params.push("security=reality".to_string());
|
||||
query_params.push(format!("sni={}", r.sni_list.first().unwrap_or(&String::new())));
|
||||
query_params.push(format!("pbk={}", r.pbk));
|
||||
if !r.sid.is_empty() {
|
||||
query_params.push(format!("sid={}", r.sid));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if let Some(t) = &s.transport {
|
||||
if let Some(mode) = &t.mode {
|
||||
|
|
@ -885,8 +1400,7 @@ async fn run_app() -> Result<()> {
|
|||
}
|
||||
if let Some(sni) = &t.stealth_sni {
|
||||
// If reality is not enabled, add stealth_sni to link so client configures it
|
||||
let reality_enabled = s.reality.as_ref().map(|r| r.enabled).unwrap_or(false);
|
||||
if !reality_enabled && !sni.is_empty() {
|
||||
if !sni.is_empty() {
|
||||
query_params.push(format!("sni={}", sni));
|
||||
}
|
||||
}
|
||||
|
|
@ -944,16 +1458,7 @@ async fn run_app() -> Result<()> {
|
|||
let mut link = format!("ostp://{}@{}:{}", key.key(), host, port);
|
||||
let mut query_params = Vec::new();
|
||||
|
||||
if let Some(r) = &server_cfg.reality {
|
||||
if r.enabled {
|
||||
query_params.push("security=reality".to_string());
|
||||
query_params.push(format!("sni={}", r.sni_list.first().unwrap_or(&String::new())));
|
||||
query_params.push(format!("pbk={}", r.pbk));
|
||||
if !r.sid.is_empty() {
|
||||
query_params.push(format!("sid={}", r.sid));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if let Some(t) = &server_cfg.transport {
|
||||
if let Some(mode) = &t.mode {
|
||||
|
|
@ -964,8 +1469,7 @@ async fn run_app() -> Result<()> {
|
|||
}
|
||||
}
|
||||
if let Some(sni) = &t.stealth_sni {
|
||||
let reality_enabled = server_cfg.reality.as_ref().map(|r| r.enabled).unwrap_or(false);
|
||||
if !reality_enabled && !sni.is_empty() {
|
||||
if !sni.is_empty() {
|
||||
query_params.push(format!("sni={}", sni));
|
||||
}
|
||||
}
|
||||
|
|
@ -998,11 +1502,6 @@ async fn run_app() -> Result<()> {
|
|||
|
||||
let listen_addrs = server_cfg.listen.addresses();
|
||||
println!("{} Starting server on {:?}", "[ostp]".cyan().bold(), listen_addrs);
|
||||
if let Some(ref reality) = server_cfg.reality {
|
||||
if reality.enabled {
|
||||
println!("{} Reality mode enabled (dest: {})", "[ostp]".cyan().bold(), reality.dest);
|
||||
}
|
||||
}
|
||||
let debug = server_cfg.debug.unwrap_or(false);
|
||||
let outbound = server_cfg.outbound.map(|o| ostp_server::OutboundConfig {
|
||||
enabled: o.enabled,
|
||||
|
|
@ -1034,20 +1533,7 @@ async fn run_app() -> Result<()> {
|
|||
listen: f.listen.unwrap_or_else(|| "0.0.0.0:443".to_string()),
|
||||
target: f.target.unwrap_or_else(|| "127.0.0.1:8080".to_string()),
|
||||
});
|
||||
let mut rq = None;
|
||||
let mut rc = None;
|
||||
if let Some(r) = server_cfg.reality {
|
||||
if r.enabled {
|
||||
rq = Some(format!("?security=reality&sni={}&pbk={}&sid={}&type=udp", r.sni_list.first().unwrap_or(&String::new()), r.pbk, r.sid));
|
||||
rc = Some(ostp_server::RealityServerConfig {
|
||||
sni_list: r.sni_list.clone(),
|
||||
dest: r.dest,
|
||||
private_key: r.private_key,
|
||||
pbk: r.pbk,
|
||||
sid: r.sid,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let access_keys_meta = server_cfg.access_keys.into_iter().map(|uc| {
|
||||
(uc.key(), ostp_server::api::UserMeta {
|
||||
name: uc.name(),
|
||||
|
|
@ -1058,7 +1544,7 @@ async fn run_app() -> Result<()> {
|
|||
// Build DNS config and set owndns flag in subscribe links if DNS enabled
|
||||
let dns_cfg = server_cfg.dns;
|
||||
// Pass all listen addresses for multi-listener support
|
||||
ostp_server::run_server(listen_addrs, Some(host), access_keys_meta, outbound, api_config, fallback_config, debug, rq, rc, dns_cfg, Some(args.config)).await?;
|
||||
ostp_server::run_server(listen_addrs, Some(host), access_keys_meta, outbound, api_config, fallback_config, debug, dns_cfg, Some(args.config)).await?;
|
||||
}
|
||||
AppMode::Client(client_cfg) => {
|
||||
println!("{}", include_str!("../../docs/banner.txt").blue().bold());
|
||||
|
|
@ -1169,9 +1655,7 @@ fn cmd_update() -> Result<()> {
|
|||
async fn run_client_directly(client_cfg: ClientConfig) -> Result<()> {
|
||||
let is_tun_enabled = client_cfg.tun.as_ref().map(|t| t.enable).unwrap_or(false);
|
||||
let mode_str = if is_tun_enabled { "tun" } else { "proxy" };
|
||||
println!("{} Starting client (mode={}, server={})", "[ostp]".cyan().bold(), mode_str.yellow(), client_cfg.server.cyan());
|
||||
let reality_cfg = client_cfg.reality.as_ref();
|
||||
let client_conf = ostp_client::config::ClientConfig {
|
||||
println!("{} Starting client (mode={}, server={})", "[ostp]".cyan().bold(), mode_str.yellow(), client_cfg.server.cyan()); let client_conf = ostp_client::config::ClientConfig {
|
||||
mode: if is_tun_enabled { "tun".to_string() } else { "proxy".to_string() },
|
||||
tun_stack: "native".to_string(),
|
||||
debug: client_cfg.debug.unwrap_or(false),
|
||||
|
|
@ -1188,14 +1672,6 @@ async fn run_client_directly(client_cfg: ClientConfig) -> Result<()> {
|
|||
bind_addr: client_cfg.socks5_bind.clone().unwrap_or_else(|| "127.0.0.1:1088".to_string()),
|
||||
connect_timeout_ms: 5000,
|
||||
},
|
||||
reality: ostp_client::config::RealityConfig {
|
||||
enabled: reality_cfg.map(|t| t.enabled).unwrap_or(false),
|
||||
sni: reality_cfg.map(|t| t.sni.clone()).unwrap_or_default(),
|
||||
fp: reality_cfg.map(|t| t.fp.clone()).unwrap_or_default(),
|
||||
pbk: reality_cfg.map(|t| t.pbk.clone()).unwrap_or_default(),
|
||||
sid: reality_cfg.map(|t| t.sid.clone()).unwrap_or_default(),
|
||||
spx: reality_cfg.map(|t| t.spx.clone()).unwrap_or_default(),
|
||||
},
|
||||
exclusions: ostp_client::config::ExclusionConfig {
|
||||
domains: client_cfg.exclude.as_ref().and_then(|e| e.domains.clone()).unwrap_or_default(),
|
||||
ips: client_cfg.exclude.as_ref().and_then(|e| e.ips.clone()).unwrap_or_default(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ LEGACY_PATHS=(
|
|||
)
|
||||
|
||||
echo "========================================================"
|
||||
echo " OSTP Installer v2"
|
||||
echo " OSTP Installer v3"
|
||||
echo "========================================================"
|
||||
|
||||
# Verify root
|
||||
|
|
@ -46,8 +46,6 @@ migrate_legacy() {
|
|||
cp "$old_dir/ostp" "$INSTALL_DIR/ostp"
|
||||
fi
|
||||
|
||||
|
||||
|
||||
echo "[migrate] Legacy files preserved at $old_dir (remove manually if no longer needed)"
|
||||
}
|
||||
|
||||
|
|
@ -55,7 +53,6 @@ migrate_legacy() {
|
|||
if [ -f "$INSTALL_DIR/config.json" ] && [ ! -f "$CONFIG_FILE" ]; then
|
||||
echo "[migrate] Moving config from $INSTALL_DIR/config.json -> $CONFIG_FILE"
|
||||
cp "$INSTALL_DIR/config.json" "$CONFIG_FILE"
|
||||
# Keep old file as backup
|
||||
mv "$INSTALL_DIR/config.json" "$INSTALL_DIR/config.json.bak"
|
||||
fi
|
||||
|
||||
|
|
@ -106,9 +103,7 @@ if [ -z "$LATEST_RELEASE" ] || [[ "$LATEST_RELEASE" == *"null"* ]]; then
|
|||
fi
|
||||
else
|
||||
ARCHIVE_NAME="ostp-linux-${ARCH}.tar.gz"
|
||||
GUI_ARCHIVE_NAME="ostp-gui-linux-${ARCH}.tar.gz"
|
||||
DOWNLOAD_URL="https://github.com/${GITHUB_REPO}/releases/download/${LATEST_RELEASE}/${ARCHIVE_NAME}"
|
||||
GUI_DOWNLOAD_URL="https://github.com/${GITHUB_REPO}/releases/download/${LATEST_RELEASE}/${GUI_ARCHIVE_NAME}"
|
||||
echo "Downloading: $ARCHIVE_NAME ($LATEST_RELEASE)"
|
||||
|
||||
TEMP_TAR="/tmp/ostp_temp.tar.gz"
|
||||
|
|
@ -134,21 +129,10 @@ else
|
|||
exit 1
|
||||
fi
|
||||
|
||||
# We don't download GUI binary immediately, we will do it if the user selects Client + GUI mode
|
||||
|
||||
|
||||
# ── Create global symlink ────────────────────────────────────────────
|
||||
|
||||
ln -sf "$INSTALL_DIR/ostp" "$BIN_LINK"
|
||||
echo "Symlink created: $BIN_LINK -> $INSTALL_DIR/ostp"
|
||||
echo "You can now run 'ostp' from anywhere."
|
||||
|
||||
# ── Detect public IP ─────────────────────────────────────────────────
|
||||
|
||||
SERVER_IP=$(curl -4s https://ifconfig.me 2>/dev/null \
|
||||
|| curl -4s https://api.ipify.org 2>/dev/null \
|
||||
|| curl -4s https://icanhazip.com 2>/dev/null \
|
||||
|| hostname -I | awk '{print $1}')
|
||||
|
||||
# ── Update detection ─────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -234,72 +218,6 @@ EOF
|
|||
fi
|
||||
fi
|
||||
|
||||
# ── Panel setup prompt (if not yet configured) ──
|
||||
PANEL_USERNAME=$(python3 -c "
|
||||
import json
|
||||
with open('$CONFIG_FILE') as f:
|
||||
raw = f.read()
|
||||
lines = [l for l in raw.split('\n') if not l.strip().startswith('//')]
|
||||
cfg = json.loads('\n'.join(lines))
|
||||
print(cfg.get('api', {}).get('username', ''))
|
||||
" 2>/dev/null)
|
||||
|
||||
if [ -z "$PANEL_USERNAME" ] && python3 -c "
|
||||
import json
|
||||
with open('$CONFIG_FILE') as f:
|
||||
raw = f.read()
|
||||
lines = [l for l in raw.split('\n') if not l.strip().startswith('//')]
|
||||
cfg = json.loads('\n'.join(lines))
|
||||
exit(0 if cfg.get('mode') == 'server' else 1)
|
||||
" 2>/dev/null; then
|
||||
echo ""
|
||||
echo "Web panel is not configured."
|
||||
read -p "Set up web panel now? [y/N]: " SETUP_PANEL
|
||||
if [[ "$SETUP_PANEL" =~ ^[Yy]$ ]]; then
|
||||
read -p "Panel port [default: 9090]: " PANEL_PORT
|
||||
PANEL_PORT=${PANEL_PORT:-9090}
|
||||
|
||||
RANDOM_PATH=$(tr -dc 'a-zA-Z0-9' < /dev/urandom | head -c 8)
|
||||
read -p "WebPath [leave empty for random: $RANDOM_PATH]: " WEBPATH
|
||||
WEBPATH=${WEBPATH:-$RANDOM_PATH}
|
||||
|
||||
read -p "Username [default: admin]: " USERNAME
|
||||
USERNAME=${USERNAME:-admin}
|
||||
|
||||
RANDOM_PASS=$(tr -dc 'a-zA-Z0-9' < /dev/urandom | head -c 12)
|
||||
read -p "Password [leave empty for random: $RANDOM_PASS]: " PASSWORD
|
||||
PASSWORD=${PASSWORD:-$RANDOM_PASS}
|
||||
|
||||
PASS_HASH=$(python3 -c "import hashlib; print(hashlib.sha256('$PASSWORD'.encode()).hexdigest())")
|
||||
|
||||
python3 << PYEOF
|
||||
import json
|
||||
with open('$CONFIG_FILE') as f:
|
||||
raw = f.read()
|
||||
lines = [l for l in raw.split('\n') if not l.strip().startswith('//')]
|
||||
cfg = json.loads('\n'.join(lines))
|
||||
if 'api' not in cfg:
|
||||
cfg['api'] = {}
|
||||
cfg['api']['enabled'] = True
|
||||
cfg['api']['bind'] = '0.0.0.0:$PANEL_PORT'
|
||||
cfg['api']['webpath'] = '$WEBPATH'
|
||||
cfg['api']['username'] = '$USERNAME'
|
||||
cfg['api']['password_hash'] = '$PASS_HASH'
|
||||
with open('$CONFIG_FILE', 'w') as f:
|
||||
json.dump(cfg, f, indent=2, ensure_ascii=False)
|
||||
print('[ok] Panel configured.')
|
||||
PYEOF
|
||||
|
||||
echo ""
|
||||
echo "========================================================"
|
||||
echo "Panel configured!"
|
||||
echo "URL: http://$SERVER_IP:$PANEL_PORT/$WEBPATH/"
|
||||
echo "Username: $USERNAME"
|
||||
echo "Password: $PASSWORD"
|
||||
echo "========================================================"
|
||||
fi
|
||||
fi
|
||||
|
||||
if systemctl is-active --quiet ostp.service 2>/dev/null; then
|
||||
echo "Restarting ostp service..."
|
||||
systemctl restart ostp.service
|
||||
|
|
@ -313,246 +231,11 @@ PYEOF
|
|||
exit 0
|
||||
fi
|
||||
|
||||
# ── First install: delegate to the built-in setup wizard ─────────────
|
||||
|
||||
# ── Interactive setup (first install) ────────────────────────────────
|
||||
|
||||
echo "--------------------------------------------------------"
|
||||
echo "Select mode:"
|
||||
echo " 1) Server"
|
||||
echo " 2) Client"
|
||||
echo " 3) Relay"
|
||||
echo " 4) Server + Web Panel"
|
||||
echo " 5) Client + GUI"
|
||||
echo "--------------------------------------------------------"
|
||||
read -p "Choice [1-5]: " NODE_MODE
|
||||
echo ""
|
||||
echo "No configuration found. Launching setup wizard..."
|
||||
echo ""
|
||||
|
||||
cd "$INSTALL_DIR"
|
||||
|
||||
if [ "$NODE_MODE" == "1" ]; then
|
||||
echo "Initializing server configuration..."
|
||||
./ostp --init server --config "$CONFIG_FILE"
|
||||
|
||||
read -p "Listen address [default: 0.0.0.0:50000]: " LISTEN_ADDR
|
||||
if [ -n "$LISTEN_ADDR" ]; then
|
||||
sed -i "s/\"listen\": \".*\"/\"listen\": \"$LISTEN_ADDR\"/g" "$CONFIG_FILE"
|
||||
fi
|
||||
|
||||
read -p "Number of access keys [default: 1]: " KEYS_COUNT
|
||||
KEYS_COUNT=${KEYS_COUNT:-1}
|
||||
|
||||
if [ "$KEYS_COUNT" -gt 1 ]; then
|
||||
echo "Generating $KEYS_COUNT access keys..."
|
||||
NEW_KEYS=$(./ostp -g -c "$KEYS_COUNT" | sed 's/^/ "/;s/$/"/' | paste -sd ',' | sed 's/,/,\n/g')
|
||||
# Replace the access_keys array
|
||||
python3 -c "
|
||||
import json, subprocess, sys
|
||||
with open('$CONFIG_FILE') as f:
|
||||
content = f.read()
|
||||
# Strip comments for parsing
|
||||
lines = [l for l in content.split('\n') if not l.strip().startswith('//')]
|
||||
cfg = json.loads('\n'.join(lines))
|
||||
keys = subprocess.check_output(['$INSTALL_DIR/ostp', '-g', '-c', '$KEYS_COUNT']).decode().strip().split('\n')
|
||||
cfg['access_keys'] = keys
|
||||
with open('$CONFIG_FILE', 'w') as f:
|
||||
json.dump(cfg, f, indent=2)
|
||||
" 2>/dev/null || echo "[warn] Key injection via python3 failed. Edit config manually."
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Server access key(s):"
|
||||
grep -oP '"[0-9a-f]{32}"' "$CONFIG_FILE" | tr -d '"' | while read key; do
|
||||
echo " $key"
|
||||
done
|
||||
echo ""
|
||||
echo "Server configuration saved: $CONFIG_FILE"
|
||||
|
||||
elif [ "$NODE_MODE" == "4" ]; then
|
||||
echo "Initializing server configuration..."
|
||||
./ostp --init server --config "$CONFIG_FILE"
|
||||
|
||||
read -p "Listen address [default: 0.0.0.0:50000]: " LISTEN_ADDR
|
||||
if [ -n "$LISTEN_ADDR" ]; then
|
||||
sed -i "s/\"listen\": \".*\"/\"listen\": \"$LISTEN_ADDR\"/g" "$CONFIG_FILE"
|
||||
fi
|
||||
|
||||
# Panel Setup
|
||||
echo "--- Web Panel Setup ---"
|
||||
read -p "Panel port [default: 9090]: " PANEL_PORT
|
||||
PANEL_PORT=${PANEL_PORT:-9090}
|
||||
|
||||
RANDOM_PATH=$(tr -dc 'a-zA-Z0-9' < /dev/urandom | head -c 8)
|
||||
read -p "WebPath (leave empty for random: /$RANDOM_PATH/): " WEBPATH
|
||||
WEBPATH=${WEBPATH:-$RANDOM_PATH}
|
||||
|
||||
read -p "Username [default: admin]: " USERNAME
|
||||
USERNAME=${USERNAME:-admin}
|
||||
|
||||
RANDOM_PASS=$(tr -dc 'a-zA-Z0-9' < /dev/urandom | head -c 12)
|
||||
read -p "Password (leave empty for random: $RANDOM_PASS): " PASSWORD
|
||||
PASSWORD=${PASSWORD:-$RANDOM_PASS}
|
||||
|
||||
# Hash password with python
|
||||
PASS_HASH=$(python3 -c "import hashlib; print(hashlib.sha256('$PASSWORD'.encode()).hexdigest())")
|
||||
|
||||
# Inject into config
|
||||
python3 -c "
|
||||
import json
|
||||
with open('$CONFIG_FILE') as f:
|
||||
lines = [l for l in f.read().split('\n') if not l.strip().startswith('//')]
|
||||
cfg = json.loads('\n'.join(lines))
|
||||
if 'api' not in cfg:
|
||||
cfg['api'] = {}
|
||||
cfg['api']['enabled'] = True
|
||||
cfg['api']['bind'] = '0.0.0.0:' + str('$PANEL_PORT')
|
||||
cfg['api']['webpath'] = '$WEBPATH'
|
||||
cfg['api']['username'] = '$USERNAME'
|
||||
cfg['api']['password_hash'] = '$PASS_HASH'
|
||||
with open('$CONFIG_FILE', 'w') as f:
|
||||
json.dump(cfg, f, indent=2)
|
||||
" 2>/dev/null || echo "[warn] Failed to configure panel via python. Edit config manually."
|
||||
|
||||
echo ""
|
||||
echo "========================================================"
|
||||
echo "Panel installed successfully!"
|
||||
echo "URL: http://$SERVER_IP:$PANEL_PORT/$WEBPATH/"
|
||||
echo "Username: $USERNAME"
|
||||
echo "Password: $PASSWORD"
|
||||
echo "========================================================"
|
||||
|
||||
elif [ "$NODE_MODE" == "2" ] || [ "$NODE_MODE" == "5" ]; then
|
||||
echo "Initializing client configuration..."
|
||||
./ostp --init client --config "$CONFIG_FILE"
|
||||
|
||||
read -p "Server address (host:port): " REMOTE_SERVER
|
||||
if [ -n "$REMOTE_SERVER" ]; then
|
||||
sed -i "s/\"server\": \"127.0.0.1:50000\"/\"server\": \"$REMOTE_SERVER\"/g" "$CONFIG_FILE"
|
||||
else
|
||||
echo "[warn] No server address provided. Using default (127.0.0.1:50000)."
|
||||
fi
|
||||
|
||||
read -p "Access key: " ACCESS_KEY
|
||||
if [ -z "$ACCESS_KEY" ]; then
|
||||
ACCESS_KEY=$(./ostp -g)
|
||||
echo "Generated key: $ACCESS_KEY"
|
||||
fi
|
||||
sed -i "s/\"access_key\": \"[^\"]*\"/\"access_key\": \"$ACCESS_KEY\"/g" "$CONFIG_FILE"
|
||||
|
||||
read -p "Local proxy address [default: 127.0.0.1:1088]: " SOCKS_BIND
|
||||
if [ -n "$SOCKS_BIND" ]; then
|
||||
sed -i "s/\"socks5_bind\": \"127.0.0.1:1088\"/\"socks5_bind\": \"$SOCKS_BIND\"/g" "$CONFIG_FILE"
|
||||
fi
|
||||
echo "Client configuration saved: $CONFIG_FILE"
|
||||
|
||||
if [ "$NODE_MODE" == "5" ]; then
|
||||
echo "Installing GUI..."
|
||||
if [ -n "$LATEST_RELEASE" ]; then
|
||||
TEMP_GUI_TAR="/tmp/ostp_gui_temp.tar.gz"
|
||||
echo "Downloading GUI: $GUI_ARCHIVE_NAME ($LATEST_RELEASE)"
|
||||
HTTP_CODE_GUI=$(curl -sL -w "%{http_code}" "$GUI_DOWNLOAD_URL" -o "$TEMP_GUI_TAR")
|
||||
if [ "$HTTP_CODE_GUI" -eq 200 ]; then
|
||||
tar -xzf "$TEMP_GUI_TAR" -C "$INSTALL_DIR" ostp-gui 2>/dev/null || tar -xzf "$TEMP_GUI_TAR" -C "$INSTALL_DIR"
|
||||
rm -f "$TEMP_GUI_TAR"
|
||||
if [ -f "$INSTALL_DIR/ostp-gui" ]; then
|
||||
chmod +x "$INSTALL_DIR/ostp-gui"
|
||||
ln -sf "$INSTALL_DIR/ostp-gui" "/usr/local/bin/ostp-gui"
|
||||
echo "GUI binary installed at $INSTALL_DIR/ostp-gui"
|
||||
|
||||
# Create desktop entry
|
||||
DESKTOP_FILE="/usr/share/applications/ostp-gui.desktop"
|
||||
cat <<EOF > "$DESKTOP_FILE"
|
||||
[Desktop Entry]
|
||||
Name=OSTP Client
|
||||
Comment=Ospab Stealth Transport Protocol Client
|
||||
Exec=/usr/local/bin/ostp-gui
|
||||
Icon=utilities-terminal
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Categories=Network;Utility;
|
||||
EOF
|
||||
echo "Desktop entry created at $DESKTOP_FILE"
|
||||
else
|
||||
echo "[error] GUI binary not found in archive."
|
||||
fi
|
||||
else
|
||||
echo "[error] Download failed for GUI (HTTP $HTTP_CODE_GUI)."
|
||||
rm -f "$TEMP_GUI_TAR"
|
||||
fi
|
||||
else
|
||||
echo "[notice] Automatic download not possible. Install GUI manually."
|
||||
fi
|
||||
fi
|
||||
|
||||
elif [ "$NODE_MODE" == "3" ]; then
|
||||
echo "Initializing relay configuration..."
|
||||
./ostp --init relay --config "$CONFIG_FILE"
|
||||
|
||||
read -p "Listen address [default: 0.0.0.0:50000]: " LISTEN_ADDR
|
||||
if [ -n "$LISTEN_ADDR" ]; then
|
||||
sed -i "s/\"listen\": \".*\"/\"listen\": \"$LISTEN_ADDR\"/g" "$CONFIG_FILE"
|
||||
fi
|
||||
|
||||
read -p "Upstream server IP/port (e.g. 1.2.3.4:50000): " UPSTREAM_ADDR
|
||||
if [ -n "$UPSTREAM_ADDR" ]; then
|
||||
sed -i "s/\"upstream_tcp\": \".*\"/\"upstream_tcp\": \"$UPSTREAM_ADDR\"/g" "$CONFIG_FILE"
|
||||
sed -i "s/\"upstream_udp\": \".*\"/\"upstream_udp\": \"$UPSTREAM_ADDR\"/g" "$CONFIG_FILE"
|
||||
fi
|
||||
|
||||
read -p "Upstream API URL (e.g. http://1.2.3.4:9090): " UPSTREAM_API
|
||||
if [ -n "$UPSTREAM_API" ]; then
|
||||
sed -i "s|\"upstream_api_url\": \".*\"|\"upstream_api_url\": \"$UPSTREAM_API\"|g" "$CONFIG_FILE"
|
||||
fi
|
||||
|
||||
read -p "Upstream API token: " UPSTREAM_TOKEN
|
||||
if [ -n "$UPSTREAM_TOKEN" ]; then
|
||||
sed -i "s/\"upstream_api_token\": \".*\"/\"upstream_api_token\": \"$UPSTREAM_TOKEN\"/g" "$CONFIG_FILE"
|
||||
fi
|
||||
echo "Relay configuration saved: $CONFIG_FILE"
|
||||
|
||||
else
|
||||
echo "[error] Invalid selection."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Register systemd service ─────────────────────────────────────────
|
||||
|
||||
echo "Registering systemd service..."
|
||||
cat <<EOF > /etc/systemd/system/ostp.service
|
||||
[Unit]
|
||||
Description=OSTP Stealth Transport Protocol
|
||||
After=network.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=$INSTALL_DIR
|
||||
ExecStart=$INSTALL_DIR/ostp --config $CONFIG_FILE
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
LimitNOFILE=65535
|
||||
Environment=RUST_LOG=info
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable ostp.service >/dev/null 2>&1
|
||||
|
||||
echo ""
|
||||
echo "========================================================"
|
||||
echo " Installation complete"
|
||||
echo "========================================================"
|
||||
echo ""
|
||||
echo " Binary: $INSTALL_DIR/ostp"
|
||||
echo " Command: ostp (available globally)"
|
||||
echo " Config: $CONFIG_FILE"
|
||||
echo " Service: systemctl start ostp"
|
||||
echo " Logs: journalctl -u ostp -f"
|
||||
echo ""
|
||||
echo " Quick commands:"
|
||||
echo " ostp --check Validate configuration"
|
||||
echo " ostp --generate-key Generate access key"
|
||||
echo " ostp --links Print client share links"
|
||||
echo " systemctl status ostp Service status"
|
||||
echo ""
|
||||
exec ./ostp --setup --config "$CONFIG_FILE"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
use std::net::SocketAddr; fn main() { println!(\
|
||||
:?
|
||||
\, \[::1]:80\.parse::<SocketAddr>()); }
|
||||