chore: implement keep-alive, config comments, validation and CI/CD improvements

This commit is contained in:
ospab 2026-05-16 18:20:53 +03:00
parent 5d092340be
commit 4970b661db
10 changed files with 325 additions and 310 deletions

View File

@ -99,6 +99,7 @@ jobs:
target: riscv64gc-unknown-linux-gnu
artifact_name: ostp
release_name: ostp-linux-riscv64.tar.gz
tun2socks_arch: linux-riscv64
use_cross: true
# ==========================================
@ -138,7 +139,13 @@ jobs:
- name: Execute Standard Native Compilation (Windows)
if: ${{ !matrix.use_cross && matrix.os == 'windows-latest' }}
run: cargo build --release --target ${{ matrix.target }} --bin ostp
run: |
cd ostp-gui
npm install
npx tauri build --no-bundle --target ${{ matrix.target }}
cd ..
cargo build --release --target ${{ matrix.target }} --bin ostp
cargo build --release --target ${{ matrix.target }} --bin ostp-tun-helper
- name: Execute Standard Native Compilation (Unix)
if: ${{ !matrix.use_cross && matrix.os != 'windows-latest' }}
@ -186,11 +193,24 @@ jobs:
if: ${{ matrix.os == 'windows-latest' }}
shell: pwsh
run: |
cd target/${{ matrix.target }}/release
$files = @("${{ matrix.artifact_name }}")
if (Test-Path "tun2socks.exe") { $files += "tun2socks.exe" }
if (Test-Path "wintun.dll") { $files += "wintun.dll" }
Compress-Archive -Path $files -DestinationPath ../../../${{ matrix.release_name }}
$build_dir = "target/${{ matrix.target }}/release"
$staging = "staging_dir"
New-Item -ItemType Directory -Path "$staging/gui" -Force
New-Item -ItemType Directory -Path "$staging/ostp" -Force
# 1. Fill OSTP (CLI) folder
Copy-Item "$build_dir/ostp.exe" -Destination "$staging/ostp/"
if (Test-Path "$build_dir/tun2socks.exe") { Copy-Item "$build_dir/tun2socks.exe" -Destination "$staging/ostp/" }
if (Test-Path "$build_dir/wintun.dll") { Copy-Item "$build_dir/wintun.dll" -Destination "$staging/ostp/" }
# 2. Fill GUI folder
Copy-Item "$build_dir/ostp-gui.exe" -Destination "$staging/gui/"
Copy-Item "$build_dir/ostp-tun-helper.exe" -Destination "$staging/gui/"
if (Test-Path "$build_dir/tun2socks.exe") { Copy-Item "$build_dir/tun2socks.exe" -Destination "$staging/gui/" }
if (Test-Path "$build_dir/wintun.dll") { Copy-Item "$build_dir/wintun.dll" -Destination "$staging/gui/" }
# 3. Zip it up
Compress-Archive -Path "$staging/*" -DestinationPath "${{ matrix.release_name }}" -Force
- name: Package release artifact (Unix Systems)
if: ${{ matrix.os != 'windows-latest' }}

3
Cargo.lock generated
View File

@ -578,8 +578,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
dependencies = [
"iana-time-zone",
"js-sys",
"num-traits",
"serde",
"wasm-bindgen",
"windows-link 0.2.1",
]
@ -2692,6 +2694,7 @@ name = "ostp-tun-helper"
version = "0.1.43"
dependencies = [
"anyhow",
"chrono",
"ostp-client",
"portable-atomic",
"serde",

View File

@ -14,3 +14,4 @@ rand.workspace = true
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
portable-atomic.workspace = true
chrono = "0.4"

View File

@ -105,7 +105,7 @@ impl Bridge {
proxy_tx: mpsc::Sender<(u16, ProxyToClientMsg)>,
) -> Result<()> {
let mut metrics_tick = interval(Duration::from_millis(500));
let mut keepalive_tick = tokio::time::interval(Duration::from_secs(10));
let mut keepalive_tick = tokio::time::interval(Duration::from_secs(5));
let mut retransmit_tick = tokio::time::interval(Duration::from_millis(50));
let init_msg = if self.mode == "tun" {
"Bridge & TUN Tunnel Manager initialized".to_string()
@ -413,6 +413,18 @@ impl Bridge {
}
}
}
_ = keepalive_tick.tick(), if self.running => {
if let Some(sessions) = sessions_opt.as_mut() {
for session in sessions.iter_mut() {
let payload = Bytes::from(RelayMessage::KeepAlive.encode());
// stream_id 0 is reserved for control messages
if let Ok(ProtocolAction::SendDatagram(frame)) = session.machine.on_event(OstpEvent::Outbound(0, payload)) {
let _ = send_datagram(&session.socket, &frame, self.turn_enabled).await;
self.metrics.bytes_sent.fetch_add(frame.len() as u64, Ordering::Relaxed);
}
}
}
}
udp_msg = async {
match udp_rx_opt.as_mut() {
Some(rx) => rx.recv().await,

View File

@ -6,6 +6,14 @@ use crate::bridge::{Bridge, BridgeMetrics};
use crate::signal::wait_for_shutdown_signal;
use crate::tunnel;
use std::sync::Arc;
use std::fs::OpenOptions;
use std::io::Write as _;
fn log_to_core_file(msg: &str) {
if let Ok(mut file) = OpenOptions::new().create(true).append(true).open("ostp-core.log") {
let _ = writeln!(file, "[{}] {}", chrono::Local::now().format("%Y-%m-%d %H:%M:%S"), msg);
}
}
#[cfg(target_os = "windows")]
#[link(name = "kernel32")]
@ -139,6 +147,8 @@ pub async fn run_client_core(
return Err(anyhow::anyhow!("Administrator privileges are required to initialize TUN mode. Please run the application as Administrator."));
}
log_to_core_file(&format!("[core] Starting run_client_core in mode: {}", config.mode));
if config.mode == "tun" && !config.exclusions.processes.is_empty() {
println!("[ostp-client] WARNING: process exclusions are not supported in the current TUN implementation");
}
@ -168,6 +178,7 @@ pub async fn run_client_core(
match msg {
crate::app::UiEvent::Log(text) => {
if debug_enabled || is_essential_log(&text) {
log_to_core_file(&format!("[client] {text}"));
println!("[client] {text}");
}
}
@ -227,16 +238,31 @@ pub async fn run_client_core(
None
};
// Wait for external / UI shutdown signal
let _ = shutdown_rx_ext.changed().await;
let _ = cmd_tx.send(BridgeCommand::Shutdown).await;
let _ = shutdown_tx.send(true);
let _ = bridge_task.await?;
let _ = proxy_task.await?;
if let Some(task) = wintun_task {
let _ = task.await?;
// Wait for either external shutdown OR any task to fail
tokio::select! {
_ = shutdown_rx_ext.changed() => {
let _ = cmd_tx.send(BridgeCommand::Shutdown).await;
let _ = shutdown_tx.send(true);
let _ = bridge_task.await;
let _ = proxy_task.await;
if let Some(task) = wintun_task {
let _ = task.await;
}
}
res = bridge_task => {
let _ = shutdown_tx.send(true);
res.map_err(|e| anyhow::anyhow!("Bridge task panicked: {}", e))??;
}
res = proxy_task => {
let _ = shutdown_tx.send(true);
res.map_err(|e| anyhow::anyhow!("Proxy task panicked: {}", e))??;
}
res = async {
if let Some(t) = wintun_task { t.await } else { std::future::pending().await }
} => {
let _ = shutdown_tx.send(true);
res.map_err(|e| anyhow::anyhow!("TUN task panicked: {}", e))??;
}
}
Ok(())

View File

@ -5,8 +5,9 @@
"type": "module",
"scripts": {
"tauri": "tauri",
"dev": "cargo build -p ostp-tun-helper && tauri dev",
"build": "cargo build -p ostp-tun-helper --release && tauri build"
"dev": "cargo build -p ostp-tun-helper && npx tauri dev",
"build": "cargo build -p ostp-tun-helper --release && npx tauri build --no-bundle",
"build:installer": "cargo build -p ostp-tun-helper --release && npx tauri build"
},
"devDependencies": {
"@tauri-apps/cli": "^2"

View File

@ -1,4 +1,4 @@
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::sync::{watch, Mutex};
use tokio::task::JoinHandle;
@ -83,18 +83,14 @@ enum HelperMsg {
// ── Application state ─────────────────────────────────────────────────────────
// For proxy (non-TUN) mode: runs in-process.
struct InProcessState {
shutdown_tx: Option<watch::Sender<bool>>,
metrics: Arc<BridgeMetrics>,
handle: JoinHandle<Result<(), String>>,
}
// For TUN mode: communicates with the privileged helper via named pipe.
struct HelperState {
/// Shared state updated by pipe reader task
pipe_state: Arc<Mutex<HelperPipeState>>,
/// Send commands to helper over named pipe
cmd_tx: tokio::sync::mpsc::Sender<String>,
}
@ -133,6 +129,41 @@ fn get_config_path() -> PathBuf {
PathBuf::from("config.json")
}
fn map_to_client_config(raw: &ClientConfigRaw, mode: &str) -> ostp_client::config::ClientConfig {
let turn_cfg = raw.turn.as_ref();
ostp_client::config::ClientConfig {
mode: mode.to_string(),
debug: raw.debug.unwrap_or(false),
ostp: ostp_client::config::OstpConfig {
server_addr: raw.server.clone(),
local_bind_addr: "0.0.0.0:0".to_string(),
access_key: raw.access_key.clone(),
handshake_timeout_ms: 5000,
io_timeout_ms: 5000,
},
local_proxy: ostp_client::config::LocalProxyConfig {
bind_addr: raw.socks5_bind.clone().unwrap_or_else(|| "127.0.0.1:1088".to_string()),
connect_timeout_ms: 5000,
},
turn: ostp_client::config::TurnConfig {
enabled: turn_cfg.map(|t| t.enabled).unwrap_or(false),
server_addr: turn_cfg.and_then(|t| Some(t.server_addr.clone())).unwrap_or_default(),
username: turn_cfg.and_then(|t| t.username.clone()).unwrap_or_default(),
access_key: turn_cfg.and_then(|t| t.access_key.clone()).unwrap_or_default(),
},
exclusions: ostp_client::config::ExclusionConfig {
domains: raw.exclude.as_ref().and_then(|e| e.domains.clone()).unwrap_or_default(),
ips: raw.exclude.as_ref().and_then(|e| e.ips.clone()).unwrap_or_default(),
processes: raw.exclude.as_ref().and_then(|e| e.processes.clone()).unwrap_or_default(),
},
multiplex: ostp_client::config::MultiplexConfig {
enabled: raw.mux.as_ref().and_then(|m| m.enabled).unwrap_or(false),
sessions: raw.mux.as_ref().and_then(|m| m.sessions).unwrap_or(1),
},
dns_server: raw.tun.as_ref().and_then(|t| t.dns.clone()),
}
}
// ── Tauri commands ────────────────────────────────────────────────────────────
#[tauri::command]
@ -140,15 +171,37 @@ async fn get_config() -> Result<String, String> {
let path = get_config_path();
if !path.exists() {
return Ok(r#"{
"_comment": "OSTP Client Configuration",
"mode": "client",
"log_level": "info",
"_comment_server": "Address of the remote OSTP server",
"server": "127.0.0.1:50000",
"_comment_access_key": "Must match one of the access_keys on the server",
"access_key": "your-secret-access-key-hex-or-base64",
"_comment_socks5_bind": "The local port where the system/browser should connect (HTTP/SOCKS5)",
"socks5_bind": "127.0.0.1:1088",
"_comment_tun": "Virtual network adapter settings (requires tun2socks.exe to be present)",
"tun": {
"enable": true,
"enable": false,
"wintun_path": "./wintun.dll",
"ipv4_address": "10.1.0.2/24"
"ipv4_address": "10.1.0.2/24",
"dns": "1.1.1.1"
},
"_comment_exclude": "Bypass tunnel for these domains/IPs (only works in proxy mode)",
"exclude": {
"domains": ["localhost", "127.0.0.1"],
"ips": [],
"processes": []
},
"mux": {
"enabled": false,
"sessions": 1
},
"debug": false
}"#.into());
@ -171,9 +224,7 @@ async fn get_tunnel_status(state: tauri::State<'_, AppState>) -> Result<u8, Stri
match &guard.tunnel {
None => Ok(0),
Some(TunnelHandle::InProcess(s)) => {
if s.handle.is_finished() {
return Ok(0);
}
if s.handle.is_finished() { return Ok(0); }
Ok(s.metrics.connection_state.load(Ordering::Relaxed))
}
Some(TunnelHandle::Helper(h)) => {
@ -208,9 +259,7 @@ async fn stop_tunnel(state: tauri::State<'_, AppState>) -> Result<bool, String>
match guard.tunnel.take() {
None => {}
Some(TunnelHandle::InProcess(mut s)) => {
if let Some(tx) = s.shutdown_tx.take() {
let _ = tx.send(true);
}
if let Some(tx) = s.shutdown_tx.take() { let _ = tx.send(true); }
drop(s.handle);
}
Some(TunnelHandle::Helper(h)) => {
@ -224,79 +273,38 @@ async fn stop_tunnel(state: tauri::State<'_, AppState>) -> Result<bool, String>
async fn start_tunnel(state: tauri::State<'_, AppState>) -> Result<bool, String> {
let mut guard = state.0.lock().await;
// Already running?
match &guard.tunnel {
Some(TunnelHandle::InProcess(s)) if !s.handle.is_finished() => return Ok(true),
Some(TunnelHandle::Helper(_)) => return Ok(true),
_ => {}
if let Some(ref t) = guard.tunnel {
match t {
TunnelHandle::InProcess(s) if !s.handle.is_finished() => return Ok(true),
TunnelHandle::Helper(_) => return Ok(true),
_ => {}
}
}
// Clean up finished handle
guard.tunnel = None;
let path = get_config_path();
if !path.exists() {
return Err("config.json not found. Go to Settings and configure your connection first.".into());
}
let content = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
let unified: UnifiedConfig = serde_json::from_str(&content)
.map_err(|e| format!("Config parse error: {}", e))?;
let unified: UnifiedConfig = serde_json::from_str(&content).map_err(|e| format!("Config parse error: {}", e))?;
let client_cfg = match unified.mode {
AppMode::Client(c) => c,
AppMode::Server(_) => return Err("Configuration is in Server mode. GUI only supports Client mode.".into()),
AppMode::Server(_) => return Err("GUI only supports Client mode.".into()),
};
let is_tun_enabled = client_cfg.tun.as_ref().map(|t| t.enable).unwrap_or(false);
if is_tun_enabled {
// ── TUN mode: launch privileged helper ────────────────────────────────
start_tun_via_helper(&mut guard, client_cfg, content).await
start_tun_via_helper(&mut guard, &client_cfg).await
} else {
// ── Proxy mode: run in-process ────────────────────────────────────────
start_proxy_in_process(&mut guard, client_cfg).await
start_proxy_in_process(&mut guard, &client_cfg).await
}
}
// ── In-process proxy tunnel ──────────────────────────────────────────────────
async fn start_proxy_in_process(
guard: &mut AppStateInner,
client_cfg: ClientConfigRaw,
raw: &ClientConfigRaw,
) -> Result<bool, String> {
let turn_cfg = client_cfg.turn.as_ref();
let mapped = ostp_client::config::ClientConfig {
mode: "proxy".to_string(),
debug: client_cfg.debug.unwrap_or(false),
ostp: ostp_client::config::OstpConfig {
server_addr: client_cfg.server.clone(),
local_bind_addr: "0.0.0.0:0".to_string(),
access_key: client_cfg.access_key.clone(),
handshake_timeout_ms: 5000,
io_timeout_ms: 5000,
},
local_proxy: ostp_client::config::LocalProxyConfig {
bind_addr: client_cfg.socks5_bind.clone().unwrap_or_else(|| "127.0.0.1:1088".to_string()),
connect_timeout_ms: 5000,
},
turn: ostp_client::config::TurnConfig {
enabled: turn_cfg.map(|t| t.enabled).unwrap_or(false),
server_addr: turn_cfg.and_then(|t| Some(t.server_addr.clone())).unwrap_or_default(),
username: turn_cfg.and_then(|t| t.username.clone()).unwrap_or_default(),
access_key: turn_cfg.and_then(|t| t.access_key.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(),
processes: client_cfg.exclude.as_ref().and_then(|e| e.processes.clone()).unwrap_or_default(),
},
multiplex: ostp_client::config::MultiplexConfig {
enabled: client_cfg.mux.as_ref().and_then(|m| m.enabled).unwrap_or(false),
sessions: client_cfg.mux.as_ref().and_then(|m| m.sessions).unwrap_or(1),
},
dns_server: None,
};
let mapped = map_to_client_config(raw, "proxy");
let metrics = Arc::new(BridgeMetrics {
bytes_sent: portable_atomic::AtomicU64::new(0),
bytes_recv: portable_atomic::AtomicU64::new(0),
@ -320,114 +328,67 @@ async fn start_proxy_in_process(
Ok(true)
}
// ── Privileged TUN helper via named pipe ─────────────────────────────────────
const PIPE_NAME: &str = r"\\.\pipe\ostp-tun-helper";
async fn start_tun_via_helper(
guard: &mut AppStateInner,
_client_cfg: ClientConfigRaw,
raw_config_json: String,
raw: &ClientConfigRaw,
) -> Result<bool, String> {
// Find the helper binary next to our exe
let helper_exe = find_helper_exe().ok_or_else(|| {
"ostp-tun-helper.exe not found next to the application. Please reinstall.".to_string()
})?;
// Launch with UAC elevation via ShellExecuteW("runas")
let helper_exe = find_helper_exe().ok_or_else(|| "ostp-tun-helper.exe not found.".to_string())?;
launch_as_admin(&helper_exe).map_err(|e| format!("Failed to launch helper: {}", e))?;
// Give the helper time to start and create the pipe
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
// Connect to the helper's named pipe
let pipe = tokio::time::timeout(
std::time::Duration::from_secs(10),
async {
loop {
match tokio::net::windows::named_pipe::ClientOptions::new().open(PIPE_NAME) {
Ok(p) => return Ok::<_, std::io::Error>(p),
Err(_) => tokio::time::sleep(std::time::Duration::from_millis(200)).await,
}
let socket = tokio::time::timeout(std::time::Duration::from_secs(60), async {
loop {
match tokio::net::TcpStream::connect("127.0.0.1:53211").await {
Ok(s) => return Ok::<_, std::io::Error>(s),
Err(_) => tokio::time::sleep(std::time::Duration::from_millis(200)).await,
}
}
).await.map_err(|_| "Timed out connecting to TUN helper. It may have been denied by UAC.".to_string())?
.map_err(|e| format!("Pipe connection error: {}", e))?;
}).await.map_err(|_| "Timeout connecting to helper.".to_string())?
.map_err(|e| e.to_string())?;
// Build the config JSON and send start command
let mut mapped_config = serde_json::from_str::<serde_json::Value>(&raw_config_json)
.map_err(|e| e.to_string())?;
// Ensure mode is set
if let Some(obj) = mapped_config.as_object_mut() {
obj.insert("mode".to_string(), serde_json::Value::String("tun".to_string()));
}
// Send the correctly MAPPED config
let mapped = map_to_client_config(raw, "tun");
let start_cmd = serde_json::json!({
"cmd": "start",
"config": serde_json::to_string(&mapped_config).unwrap_or_default()
"config": serde_json::to_string(&mapped).unwrap_or_default()
}).to_string();
// Set up channel for sending commands to helper task
let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::channel::<String>(16);
// Spawn a task that manages the pipe I/O
let pipe_state: Arc<Mutex<HelperPipeState>> = Arc::new(Mutex::new(HelperPipeState {
connection_state: 1,
bytes_sent: 0,
bytes_recv: 0,
}));
let pipe_state = Arc::new(Mutex::new(HelperPipeState { connection_state: 1, bytes_sent: 0, bytes_recv: 0 }));
let state_for_task = pipe_state.clone();
tokio::spawn(async move {
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::io::split;
let (reader_half, mut writer_half) = split(pipe);
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, split};
let (reader_half, mut writer_half) = split(socket);
let mut reader = BufReader::new(reader_half);
// Send the start command
let _ = writer_half.write_all(format!("{}\n", start_cmd).as_bytes()).await;
// Concurrently: read from pipe, write commands from channel
let mut line = String::new();
loop {
tokio::select! {
result = reader.read_line(&mut line) => {
let n = result.unwrap_or(0);
if n == 0 { break; } // Helper disconnected
if result.unwrap_or(0) == 0 { break; }
let trimmed = line.trim().to_string();
line.clear();
if trimmed.is_empty() { continue; }
if let Ok(msg) = serde_json::from_str::<HelperMsg>(&trimmed) {
let mut s = state_for_task.lock().await;
match msg {
HelperMsg::Status { value } => s.connection_state = value,
HelperMsg::Metrics { bytes_sent, bytes_recv } => {
s.bytes_sent = bytes_sent;
s.bytes_recv = bytes_recv;
}
HelperMsg::Error { .. } => s.connection_state = 0,
HelperMsg::Log { .. } => {}
HelperMsg::Metrics { bytes_sent, bytes_recv } => { s.bytes_sent = bytes_sent; s.bytes_recv = bytes_recv; }
HelperMsg::Error { message } => { s.connection_state = 0; eprintln!("Helper error: {}", message); }
_ => {}
}
}
}
cmd = cmd_rx.recv() => {
match cmd {
Some(c) => { let _ = writer_half.write_all(c.as_bytes()).await; }
None => break,
}
if let Some(c) = cmd { let _ = writer_half.write_all(c.as_bytes()).await; } else { break; }
}
}
}
// Mark stopped
let mut s = state_for_task.lock().await;
s.connection_state = 0;
state_for_task.lock().await.connection_state = 0;
});
guard.tunnel = Some(TunnelHandle::Helper(HelperState {
pipe_state,
cmd_tx,
}));
guard.tunnel = Some(TunnelHandle::Helper(HelperState { pipe_state, cmd_tx }));
Ok(true)
}
@ -438,16 +399,10 @@ struct HelperPipeState {
}
fn find_helper_exe() -> Option<PathBuf> {
// The helper is always built to the same target dir as the GUI exe.
// In dev mode: target/debug/ostp-tun-helper.exe (same dir as ostp-gui.exe)
// In release: target/release/ostp-tun-helper.exe (same dir as ostp-gui.exe)
// In installed build: next to ostp-gui.exe
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
let candidate = dir.join("ostp-tun-helper.exe");
if candidate.exists() {
return Some(candidate);
}
if candidate.exists() { return Some(candidate); }
}
}
None
@ -458,61 +413,25 @@ fn launch_as_admin(exe: &PathBuf) -> Result<()> {
use std::ffi::OsStr;
use std::os::windows::ffi::OsStrExt;
use std::ptr::null_mut;
let exe_wstr: Vec<u16> = exe.as_os_str().encode_wide().chain(Some(0)).collect();
let verb_wstr: Vec<u16> = OsStr::new("runas").encode_wide().chain(Some(0)).collect();
#[link(name = "shell32")]
extern "system" {
fn ShellExecuteW(
hwnd: *mut std::ffi::c_void,
lpOperation: *const u16,
lpFile: *const u16,
lpParameters: *const u16,
lpDirectory: *const u16,
nShowCmd: i32,
) -> isize;
}
let ret = unsafe {
ShellExecuteW(
null_mut(),
verb_wstr.as_ptr(),
exe_wstr.as_ptr(),
null_mut(),
null_mut(),
0, // SW_HIDE
)
};
if ret <= 32 {
anyhow::bail!("ShellExecuteW failed (code {}). UAC was denied or helper not found.", ret);
}
#[link(name = "shell32")] extern "system" { fn ShellExecuteW(h: *mut std::ffi::c_void, op: *const u16, f: *const u16, p: *const u16, d: *const u16, s: i32) -> isize; }
let dir_wstr: Vec<u16> = exe.parent().unwrap_or(Path::new(".")).as_os_str().encode_wide().chain(Some(0)).collect();
let ret = unsafe { ShellExecuteW(null_mut(), verb_wstr.as_ptr(), exe_wstr.as_ptr(), null_mut(), dir_wstr.as_ptr(), 0) };
if ret <= 32 { anyhow::bail!("UAC denied or helper missing."); }
Ok(())
}
#[cfg(not(target_os = "windows"))]
fn launch_as_admin(_exe: &PathBuf) -> Result<()> {
anyhow::bail!("TUN mode via helper is only supported on Windows");
}
// ── Tauri setup ───────────────────────────────────────────────────────────────
fn launch_as_admin(_exe: &PathBuf) -> Result<()> { anyhow::bail!("Windows only."); }
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let state = AppState(Mutex::new(AppStateInner { tunnel: None }));
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.manage(state)
.invoke_handler(tauri::generate_handler![
start_tunnel,
stop_tunnel,
get_tunnel_status,
get_metrics,
get_config,
save_config
])
.invoke_handler(tauri::generate_handler![start_tunnel, stop_tunnel, get_tunnel_status, get_metrics, get_config, save_config])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

View File

@ -14,6 +14,7 @@ anyhow = { workspace = true }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
portable-atomic = { workspace = true }
chrono = "0.4"
[build-dependencies]
# no extra build deps needed; manifest is embedded via build.rs

View File

@ -1,26 +1,24 @@
// ostp-tun-helper/src/main.rs
//
// Privileged helper for TUN mode. Runs with Administrator rights.
// Communicates with ostp-gui via a named pipe IPC channel.
//
// Protocol over the named pipe (newline-delimited JSON):
// GUI -> Helper: {"cmd":"start","config":<config json string>}
// GUI -> Helper: {"cmd":"stop"}
// Helper -> GUI: {"type":"status","value":0|1|2} (0=stopped,1=connecting,2=connected)
// Helper -> GUI: {"type":"log","message":"..."}
// Helper -> GUI: {"type":"metrics","bytes_sent":N,"bytes_recv":N}
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use std::time::Duration;
use std::fs::OpenOptions;
use std::io::Write as _;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::sync::{watch, Mutex};
use tokio::net::TcpListener;
use portable_atomic::Ordering;
const PIPE_NAME: &str = r"\\.\pipe\ostp-tun-helper";
fn log_to_file(msg: &str) {
if let Ok(mut file) = OpenOptions::new().create(true).append(true).open("ostp-helper.log") {
let _ = writeln!(file, "[{}] {}", chrono::Local::now().format("%Y-%m-%d %H:%M:%S"), msg);
}
}
const BIND_ADDR: &str = "127.0.0.1:53211";
#[derive(Deserialize)]
#[serde(tag = "cmd", rename_all = "lowercase")]
@ -45,43 +43,42 @@ struct TunnelState {
#[tokio::main]
async fn main() -> Result<()> {
// The helper is always launched by the GUI. If no client connects within
// 60 seconds, exit to avoid lingering admin processes.
run_pipe_server().await
log_to_file("Helper started (TCP mode)");
if let Err(e) = run_server().await {
log_to_file(&format!("Fatal error: {}", e));
}
log_to_file("Helper exiting");
Ok(())
}
async fn run_pipe_server() -> Result<()> {
use tokio::net::windows::named_pipe::{ServerOptions};
async fn run_server() -> Result<()> {
let state = Arc::new(Mutex::new(TunnelState {
shutdown_tx: None,
metrics: None,
}));
// Create the named pipe server
let server = ServerOptions::new()
.first_pipe_instance(true)
.create(PIPE_NAME)?;
log_to_file(&format!("Attempting to bind to {}", BIND_ADDR));
let listener = TcpListener::bind(BIND_ADDR).await.map_err(|e| {
log_to_file(&format!("Bind failed: {}", e));
e
})?;
log_to_file("Listening successfully");
// Wait for GUI to connect (60 second timeout)
let connect_timeout = tokio::time::timeout(
Duration::from_secs(60),
server.connect()
).await;
let pipe = match connect_timeout {
Ok(Ok(())) => server,
let (socket, _) = match tokio::time::timeout(Duration::from_secs(60), listener.accept()).await {
Ok(Ok(s)) => s,
_ => {
// No client connected — exit silently
log_to_file("No connection from GUI within 60s, exiting");
return Ok(());
}
};
let (reader_half, writer_half) = tokio::io::split(pipe);
log_to_file("GUI connected via TCP");
let (reader_half, writer_half) = tokio::io::split(socket);
let writer = Arc::new(Mutex::new(writer_half));
let mut reader = BufReader::new(reader_half);
// Helper to send a message back to GUI
let send_msg = {
let writer = writer.clone();
move |msg: HelperMsg| {
@ -94,13 +91,12 @@ async fn run_pipe_server() -> Result<()> {
}
};
// Read commands from GUI
let mut line = String::new();
loop {
line.clear();
let n = reader.read_line(&mut line).await.unwrap_or(0);
if n == 0 {
// GUI disconnected — stop tunnel and exit
log_to_file("GUI disconnected, stopping tunnel");
let mut st = state.lock().await;
if let Some(tx) = st.shutdown_tx.take() {
let _ = tx.send(true);
@ -109,9 +105,7 @@ async fn run_pipe_server() -> Result<()> {
}
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if trimmed.is_empty() { continue; }
let cmd: GuiCmd = match serde_json::from_str(trimmed) {
Ok(c) => c,
@ -123,7 +117,7 @@ async fn run_pipe_server() -> Result<()> {
match cmd {
GuiCmd::Start { config } => {
// Stop any existing tunnel first
log_to_file("Received START command");
{
let mut st = state.lock().await;
if let Some(tx) = st.shutdown_tx.take() {
@ -132,10 +126,10 @@ async fn run_pipe_server() -> Result<()> {
tokio::time::sleep(Duration::from_millis(100)).await;
}
// Parse config
let cfg: ostp_client::config::ClientConfig = match serde_json::from_str(&config) {
Ok(c) => c,
Err(e) => {
log_to_file(&format!("Config parse error: {}", e));
send_msg(HelperMsg::Error { message: format!("Config parse error: {}", e) });
continue;
}
@ -155,37 +149,23 @@ async fn run_pipe_server() -> Result<()> {
st.metrics = Some(metrics.clone());
}
// Spawn the tunnel
let metrics_for_runner = metrics.clone();
let send_log = {
let writer = writer.clone();
move |msg: String| {
let writer = writer.clone();
let json = serde_json::to_string(&HelperMsg::Log { message: msg }).unwrap_or_default();
tokio::spawn(async move {
let mut w = writer.lock().await;
let _ = w.write_all(format!("{}\n", json).as_bytes()).await;
});
}
};
let writer_for_tick = writer.clone();
let metrics_for_tick = metrics.clone();
let writer_for_err = writer.clone();
tokio::spawn(async move {
log_to_file("Starting tunnel core...");
match ostp_client::runner::run_client_core(cfg, metrics_for_runner, shutdown_rx).await {
Ok(_) => {}
Ok(_) => { log_to_file("Tunnel core stopped normally"); }
Err(e) => {
log_to_file(&format!("Tunnel core error: {}", e));
let json = serde_json::to_string(&HelperMsg::Error { message: e.to_string() }).unwrap_or_default();
let mut w = writer_for_tick.lock().await;
let mut w = writer_for_err.lock().await;
let _ = w.write_all(format!("{}\n", json).as_bytes()).await;
}
}
});
// Spawn a tick that forwards status + metrics to GUI every second
let writer_tick = writer.clone();
let metrics_tick = metrics_for_tick.clone();
let metrics_tick = metrics.clone();
tokio::spawn(async move {
let mut last_state = 99u8;
loop {
@ -195,13 +175,11 @@ async fn run_pipe_server() -> Result<()> {
let recv = metrics_tick.bytes_recv.load(Ordering::Relaxed);
let mut w = writer_tick.lock().await;
// Only send status change events
if cs != last_state {
last_state = cs;
let json = serde_json::to_string(&HelperMsg::Status { value: cs }).unwrap_or_default();
if w.write_all(format!("{}\n", json).as_bytes()).await.is_err() { break; }
}
// Always send metrics
let json = serde_json::to_string(&HelperMsg::Metrics { bytes_sent: sent, bytes_recv: recv }).unwrap_or_default();
if w.write_all(format!("{}\n", json).as_bytes()).await.is_err() { break; }
drop(w);
@ -210,8 +188,8 @@ async fn run_pipe_server() -> Result<()> {
send_msg(HelperMsg::Status { value: 1 });
}
GuiCmd::Stop => {
log_to_file("Received STOP command");
let mut st = state.lock().await;
if let Some(tx) = st.shutdown_tx.take() {
let _ = tx.send(true);
@ -221,6 +199,5 @@ async fn run_pipe_server() -> Result<()> {
}
}
}
Ok(())
}

View File

@ -103,6 +103,34 @@ struct UnifiedConfig {
log_level: Option<String>,
}
impl UnifiedConfig {
fn validate(&self) -> Result<()> {
match &self.mode {
AppMode::Server(cfg) => {
if cfg.access_keys.is_empty() {
anyhow::bail!("Server configuration must contain at least one access_key.");
}
if let Some(outbound) = &cfg.outbound {
if outbound.enabled {
let action = outbound.default_action.as_deref().unwrap_or("direct");
if action == "direct" && outbound.rules.is_empty() {
println!("\n[WARNING] Server outbound proxy is ENABLED, but default_action is 'direct' and there are no rules!");
println!(" This means ALL traffic will bypass the proxy and go out directly from the server IP.");
println!(" If you want all traffic to be proxied, change 'default_action' to 'proxy'.\n");
}
}
}
}
AppMode::Client(cfg) => {
if cfg.access_key.is_empty() {
anyhow::bail!("Client configuration must contain an access_key.");
}
}
}
Ok(())
}
}
#[derive(Debug, Deserialize, Serialize)]
struct ServerConfig {
listen: String,
@ -284,52 +312,77 @@ async fn run_app() -> Result<()> {
// Handle explicit configuration initialization
if let Some(ref mode_str) = args.init {
let is_server = mode_str == "server";
let dummy = if is_server {
UnifiedConfig {
log_level: Some("info".to_string()),
mode: AppMode::Server(ServerConfig {
listen: "0.0.0.0:50000".to_string(),
access_keys: vec![generate_secure_key("hex")],
turn_server: None,
debug: Some(false),
outbound: Some(OutboundConfig {
enabled: false,
protocol: "".to_string(),
address: "".to_string(),
port: 0,
rules: Vec::new(),
default_action: Some("direct".to_string()),
}),
}),
}
let key = generate_secure_key("hex");
let content = if is_server {
format!(r#"{{
"_comment": "OSTP Server Configuration",
"mode": "server",
"log_level": "info",
"_comment_listen": "The address and port the server listens on for incoming OSTP connections.",
"listen": "0.0.0.0:50000",
"_comment_access_keys": "List of valid keys. Clients must use one of these to connect.",
"access_keys": [
"{}"
],
"_comment_outbound": "Optional proxy for outbound traffic. If enabled, the server routes traffic through this proxy.",
"outbound": {{
"enabled": false,
"protocol": "socks5",
"address": "127.0.0.1",
"port": 9050,
"_comment_default_action": "Can be 'proxy' (route all traffic through proxy by default) or 'direct' (bypass proxy by default).",
"default_action": "proxy",
"_comment_rules": "Specific routing rules. Action can be 'proxy', 'direct', or 'block'.",
"rules": [
{{
"domain_suffix": [".onion"],
"action": "proxy"
}}
]
}},
"debug": false
}}"#, key)
} else {
UnifiedConfig {
log_level: Some("info".to_string()),
mode: AppMode::Client(ClientConfig {
server: "127.0.0.1:50000".to_string(),
access_key: generate_secure_key("hex"),
socks5_bind: Some("127.0.0.1:1088".to_string()),
tun: Some(TunConfig {
enable: false,
wintun_path: Some("./wintun.dll".to_string()),
ipv4_address: Some("10.1.0.2/24".to_string()),
dns: None,
}),
turn: None,
debug: Some(false),
exclude: Some(ExcludeConfig {
domains: Some(Vec::new()),
ips: Some(Vec::new()),
processes: Some(Vec::new()),
}),
mux: Some(MuxConfig {
enabled: Some(false),
sessions: Some(1),
}),
}),
}
format!(r#"{{
"_comment": "OSTP Client Configuration",
"mode": "client",
"log_level": "info",
"_comment_server": "Address of the remote OSTP server",
"server": "127.0.0.1:50000",
"_comment_access_key": "Must match one of the access_keys on the server",
"access_key": "{}",
"_comment_socks5_bind": "The local port where the system/browser should connect (HTTP/SOCKS5)",
"socks5_bind": "127.0.0.1:1088",
"_comment_tun": "Virtual network adapter settings (requires tun2socks.exe to be present)",
"tun": {{
"enable": false,
"wintun_path": "./wintun.dll",
"ipv4_address": "10.1.0.2/24",
"dns": "1.1.1.1"
}},
"_comment_exclude": "Bypass tunnel for these domains/IPs (only works in proxy mode)",
"exclude": {{
"domains": ["localhost", "127.0.0.1"],
"ips": [],
"processes": []
}},
"mux": {{
"enabled": false,
"sessions": 1
}},
"debug": false
}}"#, key)
};
fs::write(&args.config, serde_json::to_string_pretty(&dummy)?)?;
fs::write(&args.config, content)?;
println!("Successfully initialized configuration at {:?}", args.config);
if is_server {
@ -361,6 +414,8 @@ async fn run_app() -> Result<()> {
let config: UnifiedConfig = serde_json::from_str(&config_content)
.map_err(|e| anyhow!("Failed to parse config: {}", e))?;
config.validate()?;
if args.links {
match config.mode {
AppMode::Server(server_cfg) => {