diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6a66f50..b43f29d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -146,11 +146,17 @@ jobs: uses: actions/setup-node@v4 with: node-version: 20 - - name: Build Web Panel + - name: Build Web Panel (skip if no source; use committed dist/) working-directory: ostp-control + shell: bash run: | - npm install - npm run build + if [ -f package.json ]; then + npm install && npm run build + else + echo "ostp-control has no package.json — using committed dist/" + mkdir -p dist + [ -f dist/index.html ] || echo 'OSTP' > dist/index.html + fi # ── Rust toolchain ───────────────────────────────────────────────────── - name: Setup Rust toolchain @@ -236,6 +242,7 @@ jobs: if: ${{ startsWith(github.ref, 'refs/tags/') }} uses: softprops/action-gh-release@v2 with: + prerelease: ${{ contains(github.ref_name, '-') }} files: ${{ matrix.release_name }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -310,6 +317,7 @@ jobs: if: ${{ startsWith(github.ref, 'refs/tags/') }} uses: softprops/action-gh-release@v2 with: + prerelease: ${{ contains(github.ref_name, '-') }} files: ostp-windows-gui-${{ matrix.arch }}.zip env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -370,6 +378,7 @@ jobs: if: ${{ startsWith(github.ref, 'refs/tags/') }} uses: softprops/action-gh-release@v2 with: + prerelease: ${{ contains(github.ref_name, '-') }} files: ostp-linux-gui-${{ matrix.arch }}.tar.gz env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -427,6 +436,7 @@ jobs: if: ${{ startsWith(github.ref, 'refs/tags/') }} uses: softprops/action-gh-release@v2 with: + prerelease: ${{ contains(github.ref_name, '-') }} files: ostp-macos-gui-${{ matrix.arch }}.tar.gz env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -495,6 +505,7 @@ jobs: if: ${{ startsWith(github.ref, 'refs/tags/') }} uses: softprops/action-gh-release@v2 with: + prerelease: ${{ contains(github.ref_name, '-') }} files: ostp-flutter/ostp-android-${{ matrix.arch }}.apk env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/Cargo.lock b/Cargo.lock index ac5a771..19a3cb0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1384,7 +1384,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "ostp" -version = "0.4.0" +version = "0.4.1" dependencies = [ "anyhow", "base64", @@ -1406,7 +1406,7 @@ dependencies = [ [[package]] name = "ostp-client" -version = "0.4.0" +version = "0.4.1" dependencies = [ "anyhow", "base64", @@ -1437,7 +1437,7 @@ dependencies = [ [[package]] name = "ostp-core" -version = "0.4.0" +version = "0.4.1" dependencies = [ "anyhow", "bytes", @@ -1471,7 +1471,7 @@ dependencies = [ [[package]] name = "ostp-server" -version = "0.4.0" +version = "0.4.1" dependencies = [ "anyhow", "axum", @@ -1503,7 +1503,7 @@ dependencies = [ [[package]] name = "ostp-tun" -version = "0.4.0" +version = "0.4.1" dependencies = [ "anyhow", "libc", @@ -1515,7 +1515,7 @@ dependencies = [ [[package]] name = "ostp-tun-helper" -version = "0.4.0" +version = "0.4.1" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 96df4c4..1eb2676 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ resolver = "2" [workspace.package] edition = "2021" license = "AGPL-3.0" -version = "0.4.0" +version = "0.4.1" [workspace.dependencies] anyhow = "1.0" diff --git a/README.md b/README.md index 8c74a50..50a63fc 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [Русский язык](README.ru.md) · [Wiki](https://github.com/ospab/ostp/wiki) · [Contributing](CONTRIBUTING.md) · [Releases](https://github.com/ospab/ostp/releases) ![GitHub Release](https://img.shields.io/github/v/release/ospab/ostp?style=for-the-badge&color=blue) -![License: BSL 1.1](https://img.shields.io/badge/License-BSL%201.1-orange.svg?style=for-the-badge) +![License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg?style=for-the-badge) ![Platform: Windows | Linux | macOS | Android](https://img.shields.io/badge/Platform-Windows%20%7C%20Linux%20%7C%20macOS%20%7C%20Android-green.svg?style=for-the-badge) ![Crypto](https://img.shields.io/badge/Crypto-Noise__NNpsk0-blueviolet?style=for-the-badge) ![Transport](https://img.shields.io/badge/Transport-UDP%20ARQ-informational?style=for-the-badge) diff --git a/README.ru.md b/README.ru.md index 0445759..1fba266 100644 --- a/README.ru.md +++ b/README.ru.md @@ -3,7 +3,7 @@ [English](README.md) · [Contributing](CONTRIBUTING.ru.md) ![GitHub Release](https://img.shields.io/github/v/release/ospab/ostp?style=for-the-badge&color=blue) -![License: BSL 1.1](https://img.shields.io/badge/License-BSL%201.1-orange.svg?style=for-the-badge) +![License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg?style=for-the-badge) ![Platform: Windows | Linux | macOS | Android](https://img.shields.io/badge/Platform-Windows%20%7C%20Linux%20%7C%20macOS%20%7C%20Android-green.svg?style=for-the-badge) ![Crypto](https://img.shields.io/badge/Crypto-Noise__NNpsk0-blueviolet?style=for-the-badge) ![Transport](https://img.shields.io/badge/Transport-UDP%20ARQ-informational?style=for-the-badge) diff --git a/ostp-client/src/bridge.rs b/ostp-client/src/bridge.rs index e03bb6a..644b45a 100644 --- a/ostp-client/src/bridge.rs +++ b/ostp-client/src/bridge.rs @@ -67,6 +67,10 @@ pub struct Bridge { pub transport_mode: String, pub stealth_sni: String, pub tcp_fragmentation: bool, + pub frag_chunk: usize, + pub frag_sleep: u64, + pub junk_pc: [usize; 2], + pub junk_ps: [usize; 2], pub mtu: usize, pub kill_switch: bool, pub reload_tx: Option>, @@ -100,6 +104,10 @@ impl Bridge { transport_mode: config.transport.mode.clone(), stealth_sni: config.transport.stealth_sni.clone(), tcp_fragmentation: config.transport.tcp_fragmentation, + frag_chunk: config.transport.frag_chunk, + frag_sleep: config.transport.frag_sleep, + junk_pc: config.transport.junk_pc, + junk_ps: config.transport.junk_ps, mtu: config.ostp.mtu, kill_switch: config.kill_switch, reload_tx: None, @@ -1027,6 +1035,10 @@ impl Bridge { self.transport_mode = cfg.transport.mode.clone(); self.stealth_sni = cfg.transport.stealth_sni.clone(); self.tcp_fragmentation = cfg.transport.tcp_fragmentation; + self.frag_chunk = cfg.transport.frag_chunk.max(1); + self.frag_sleep = cfg.transport.frag_sleep; + self.junk_pc = cfg.transport.junk_pc; + self.junk_ps = cfg.transport.junk_ps; self.mtu = cfg.ostp.mtu; self.keepalive_interval_sec = cfg.ostp.keepalive_interval_sec; self.kill_switch = cfg.kill_switch; @@ -1044,31 +1056,37 @@ impl Bridge { let (mut read_half, mut write_half) = stream.into_split(); let tcp_fragmentation = self.tcp_fragmentation; + let frag_chunk = self.frag_chunk; + let frag_sleep = self.frag_sleep; + let [junk_pc_min, junk_pc_max] = self.junk_pc; + let [junk_ps_min, junk_ps_max] = self.junk_ps; + // Per-key junk marker (derived from the access key) — NOT a global + // constant, so junk frames carry no universal DPI signature. + let junk_marker = ostp_core::crypto::derive_all_secrets(&self.access_key).junk_marker; - // Amnezia-style junk to perturb DPI heuristics — ONLY over stream - // transports, where each junk frame rides inside the connection. The - // server reads it as a length-prefixed frame, fails to authenticate - // it, drops it, and keeps reading (drop-and-continue), so junk does - // not break the connection. Over plain UDP each junk would be a lone - // datagram indistinguishable from a port scan (probe-flood / wasted - // CPU), so junk is NEVER sent over UDP. Ranges are hardcoded for now; - // §E will make Jc/Jmin/Jmax configurable. (Ported from 0.3.x.) { use tokio::io::AsyncWriteExt; // Build all junk frames up front so ThreadRng isn't held across an // await point (keeps this future Send). let junk_frames: Vec> = { - use rand::Rng; let mut rng = rand::thread_rng(); - let num_junk = rng.gen_range(2..=5); + let min_c = junk_pc_min; + let max_c = junk_pc_max.max(min_c); + let num_junk = rng.gen_range(min_c..=max_c); (0..num_junk) .map(|_| { - let junk_len = rng.gen_range(100..=1000usize); + let min_s = junk_ps_min.max(1); + let max_s = junk_ps_max.max(min_s); + let junk_len = rng.gen_range(min_s..=max_s); let mut frame = Vec::with_capacity(2 + junk_len); frame.extend_from_slice(&(junk_len as u16).to_be_bytes()); let start = frame.len(); frame.resize(start + junk_len, 0); rng.fill(&mut frame[start..]); + // Stamp this key's derived junk marker so the server drops it silently. + if junk_len >= 4 { + frame[start..start+4].copy_from_slice(&junk_marker); + } frame }) .collect() @@ -1098,9 +1116,9 @@ impl Bridge { if write_half.write_all(&len_buf[1..2]).await.is_err() { break; } tokio::time::sleep(std::time::Duration::from_millis(5)).await; let mut broke = false; - for chunk in data.chunks(2) { + for chunk in data.chunks(frag_chunk) { if write_half.write_all(chunk).await.is_err() { broke = true; break; } - tokio::time::sleep(std::time::Duration::from_millis(2)).await; + tokio::time::sleep(std::time::Duration::from_millis(frag_sleep)).await; } if broke { break; } } else { diff --git a/ostp-client/src/config.rs b/ostp-client/src/config.rs index c6715d4..b0033b6 100644 --- a/ostp-client/src/config.rs +++ b/ostp-client/src/config.rs @@ -81,11 +81,26 @@ pub struct TransportConfig { pub stealth_sni: String, /// Split the first UoT/TCP packet (handshake) into tiny TCP segments to /// break DPI that inspects the first packet. UoT/TCP only; ignored for UDP. - #[serde(default)] pub tcp_fragmentation: bool, + /// TCP chunk size (bytes) + #[serde(default = "default_frag_chunk")] + pub frag_chunk: usize, + /// TCP sleep duration between chunks (ms) + #[serde(default = "default_frag_sleep")] + pub frag_sleep: u64, + /// [min, max] junk packet count + #[serde(default = "default_junk_count")] + pub junk_pc: [usize; 2], + /// [min, max] junk packet size in bytes + #[serde(default = "default_junk_size")] + pub junk_ps: [usize; 2], } fn default_transport_mode() -> String { "udp".to_string() } +fn default_frag_chunk() -> usize { 2 } +fn default_frag_sleep() -> u64 { 2 } +fn default_junk_count() -> [usize; 2] { [2, 5] } +fn default_junk_size() -> [usize; 2] { [100, 1000] } impl Default for TransportConfig { fn default() -> Self { @@ -93,6 +108,10 @@ impl Default for TransportConfig { mode: default_transport_mode(), stealth_sni: String::new(), tcp_fragmentation: false, + frag_chunk: default_frag_chunk(), + frag_sleep: default_frag_sleep(), + junk_pc: default_junk_count(), + junk_ps: default_junk_size(), } } } @@ -175,6 +194,10 @@ struct RawTransportSection { mode: Option, stealth_sni: Option, tcp_fragmentation: Option, + frag_chunk: Option, + frag_sleep: Option, + junk_pc: Option<[usize; 2]>, + junk_ps: Option<[usize; 2]>, } #[derive(Debug, Deserialize)] @@ -249,6 +272,10 @@ impl ClientConfig { 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(), tcp_fragmentation: raw.transport.as_ref().and_then(|t| t.tcp_fragmentation).unwrap_or(false), + frag_chunk: raw.transport.as_ref().and_then(|t| t.frag_chunk).unwrap_or_else(default_frag_chunk), + frag_sleep: raw.transport.as_ref().and_then(|t| t.frag_sleep).unwrap_or_else(default_frag_sleep), + junk_pc: raw.transport.as_ref().and_then(|t| t.junk_pc).unwrap_or_else(default_junk_count), + junk_ps: raw.transport.as_ref().and_then(|t| t.junk_ps).unwrap_or_else(default_junk_size), }, exclusions: ExclusionConfig { domains: exclusions.domains.unwrap_or_default(), diff --git a/ostp-client/src/runner.rs b/ostp-client/src/runner.rs index 44a2055..a9f4f51 100644 --- a/ostp-client/src/runner.rs +++ b/ostp-client/src/runner.rs @@ -250,10 +250,6 @@ pub async fn run_client_core( None }; - if config.mode == "tun" && !config.exclusions.processes.is_empty() { - println!("[ostp] Process exclusions are not supported in TUN mode"); - } - let (proxy_events_tx, proxy_events_rx) = mpsc::channel(256); let (client_msgs_tx, client_msgs_rx) = mpsc::unbounded_channel(); diff --git a/ostp-core/src/crypto/obfuscation.rs b/ostp-core/src/crypto/obfuscation.rs index ffb519f..e52fcbe 100644 --- a/ostp-core/src/crypto/obfuscation.rs +++ b/ostp-core/src/crypto/obfuscation.rs @@ -59,6 +59,10 @@ pub struct DerivedSecrets { pub psk: [u8; 32], pub handshake_pad_min: usize, pub handshake_pad_max: usize, + /// Per-key 4-byte prefix stamped on junk frames so the server can drop them + /// without a GLOBAL constant marker (which would be a universal DPI signature + /// for all OSTP users — exactly what the version gate avoids for the handshake). + pub junk_marker: [u8; 4], } /// OSTP wire protocol version. Mixed into key derivation (NOT sent on the @@ -125,11 +129,22 @@ pub(crate) fn derive_all_secrets_versioned(access_key: &[u8], version: u8) -> De let pad_min = 16 + (pad_bytes[0] as usize % 64); // 16-79 let pad_max = pad_min + 48 + (pad_bytes[1] as usize % 128); // +48..+175 + // Derive junk marker (4 bytes) — info = key_hash[16..] || 0x04. + // Per-key: to an outsider it is indistinguishable from the random junk + // payload, so there is no cross-user signature; the server, knowing the key, + // derives the same marker and drops the junk silently. + let mut junk_info = info_base.to_vec(); + junk_info.push(0x04); + let junk_bytes = hkdf_expand(&prk, &junk_info, 4); + let mut junk_marker = [0u8; 4]; + junk_marker.copy_from_slice(&junk_bytes); + DerivedSecrets { obfuscation_key, psk, handshake_pad_min: pad_min, handshake_pad_max: pad_max, + junk_marker, } } diff --git a/ostp-gui/package.json b/ostp-gui/package.json index 2e89be6..61dee32 100644 --- a/ostp-gui/package.json +++ b/ostp-gui/package.json @@ -1,7 +1,7 @@ { "name": "ostp-gui", "private": true, - "version": "0.1.0", + "version": "0.4.1", "type": "module", "scripts": { "tauri": "tauri", diff --git a/ostp-gui/src-tauri/Cargo.lock b/ostp-gui/src-tauri/Cargo.lock index 7820164..636b7e2 100644 --- a/ostp-gui/src-tauri/Cargo.lock +++ b/ostp-gui/src-tauri/Cargo.lock @@ -2665,7 +2665,7 @@ dependencies = [ [[package]] name = "ostp-client" -version = "0.4.0" +version = "0.4.1" dependencies = [ "anyhow", "base64 0.22.1", @@ -2696,7 +2696,7 @@ dependencies = [ [[package]] name = "ostp-core" -version = "0.4.0" +version = "0.4.1" dependencies = [ "anyhow", "bytes", @@ -2713,7 +2713,7 @@ dependencies = [ [[package]] name = "ostp-gui" -version = "0.1.0" +version = "0.4.1" dependencies = [ "anyhow", "json_comments", @@ -2721,6 +2721,7 @@ dependencies = [ "portable-atomic", "qrcode", "rand", + "rlimit", "serde", "serde_json", "tauri", @@ -2732,7 +2733,7 @@ dependencies = [ [[package]] name = "ostp-tun" -version = "0.4.0" +version = "0.4.1" dependencies = [ "anyhow", "libc", @@ -3265,6 +3266,15 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rlimit" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f35ee2729c56bb610f6dba436bf78135f728b7373bdffae2ec815b2d3eb98cc3" +dependencies = [ + "libc", +] + [[package]] name = "rustc-hash" version = "2.1.2" diff --git a/ostp-gui/src-tauri/Cargo.toml b/ostp-gui/src-tauri/Cargo.toml index 9ad8d70..b3e8424 100644 --- a/ostp-gui/src-tauri/Cargo.toml +++ b/ostp-gui/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ostp-gui" -version = "0.1.0" +version = "0.4.1" description = "A Tauri App" authors = ["you"] edition = "2021" diff --git a/ostp-gui/src-tauri/src/lib.rs b/ostp-gui/src-tauri/src/lib.rs index 30bbe7e..1fa92fa 100644 --- a/ostp-gui/src-tauri/src/lib.rs +++ b/ostp-gui/src-tauri/src/lib.rs @@ -58,6 +58,10 @@ struct TransportConfigRaw { mode: Option, stealth_sni: Option, tcp_fragmentation: Option, + frag_chunk: Option, + frag_sleep: Option, + junk_pc: Option<[usize; 2]>, + junk_ps: Option<[usize; 2]>, } #[derive(Debug, Deserialize, Serialize, Clone)] @@ -165,6 +169,10 @@ fn map_to_client_config(raw: &ClientConfigRaw, mode: &str) -> ostp_client::confi 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()), tcp_fragmentation: raw.transport.as_ref().and_then(|t| t.tcp_fragmentation).unwrap_or(false), + frag_chunk: raw.transport.as_ref().and_then(|t| t.frag_chunk).unwrap_or(2), + frag_sleep: raw.transport.as_ref().and_then(|t| t.frag_sleep).unwrap_or(2), + junk_pc: raw.transport.as_ref().and_then(|t| t.junk_pc).unwrap_or([2, 5]), + junk_ps: raw.transport.as_ref().and_then(|t| t.junk_ps).unwrap_or([100, 1000]), }, exclusions: ostp_client::config::ExclusionConfig { domains: raw.exclude.as_ref().and_then(|e| e.domains.clone()).unwrap_or_default(), diff --git a/ostp-gui/src-tauri/tauri.conf.json b/ostp-gui/src-tauri/tauri.conf.json index a1878b1..b273271 100644 --- a/ostp-gui/src-tauri/tauri.conf.json +++ b/ostp-gui/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ostp-gui", - "version": "0.2.97", + "version": "0.4.1", "identifier": "com.ospab.ostp", "build": { "frontendDist": "../src" diff --git a/ostp-gui/src/index.html b/ostp-gui/src/index.html index 88bdc22..411112b 100644 --- a/ostp-gui/src/index.html +++ b/ostp-gui/src/index.html @@ -31,6 +31,14 @@ + diff --git a/ostp-gui/src/main.js b/ostp-gui/src/main.js index 1389308..3d83cfe 100644 --- a/ostp-gui/src/main.js +++ b/ostp-gui/src/main.js @@ -4,6 +4,11 @@ if (window.__TAURI__?.core) { invoke = window.__TAURI__.core.invoke; } +// ── Theme: apply saved theme ASAP (before first paint) to avoid a flash ─ +if (localStorage.getItem('ostp_theme') === 'light') { + document.documentElement.classList.add('light'); +} + // ── PROFILE STORE ───────────────────────────────────────────────────── // Profiles are stored in localStorage only — the core never knows about them. // Only the active profile is compiled into a config and passed to Tauri. @@ -108,6 +113,15 @@ const pmName = $('pm-name'); const pmServer = $('pm-server'); const pmKey = $('pm-key'); const pmTransport = $('pm-transport'); +const pmTcpFrag = $('pm-tcp-frag'); +const pmFragChunk = $('pm-frag-chunk'); +const pmFragSleep = $('pm-frag-sleep'); +const pmJunkPcMin = $('pm-junk-pc-min'); +const pmJunkPcMax = $('pm-junk-pc-max'); +const pmJunkPsMin = $('pm-junk-ps-min'); +const pmJunkPsMax = $('pm-junk-ps-max'); +const pmTcpSettings = $('pm-tcp-settings'); +const pmFragDetails = $('pm-frag-details'); const btnProfileCancel = $('btn-profile-cancel'); const btnProfileSave = $('btn-profile-save'); const btnProfileDelete = $('btn-profile-delete'); @@ -291,6 +305,11 @@ function buildConfig() { debug: !!s.debug, transport: { mode: active.transport || 'udp', + tcp_fragmentation: !!active.tcp_fragmentation, + frag_chunk: active.frag_chunk || 2, + frag_sleep: active.frag_sleep || 2, + junk_pc: active.junk_pc || [2, 5], + junk_ps: active.junk_ps || [100, 1000] }, tun: { enable: !!s.tun, @@ -485,15 +504,31 @@ function openProfileEditor(id) { pmServer.value = p.server || ''; pmKey.value = p.key || ''; pmTransport.value = p.transport || 'udp'; + pmTcpFrag.checked = !!p.tcp_fragmentation; + pmFragChunk.value = p.frag_chunk || 2; + pmFragSleep.value = p.frag_sleep || 2; + pmJunkPcMin.value = p.junk_pc ? p.junk_pc[0] : 2; + pmJunkPcMax.value = p.junk_pc ? p.junk_pc[1] : 5; + pmJunkPsMin.value = p.junk_ps ? p.junk_ps[0] : 100; + pmJunkPsMax.value = p.junk_ps ? p.junk_ps[1] : 1000; btnProfileDelete.style.display = ''; } else { profileModalTitle.textContent = 'New Profile'; pmName.value = pmServer.value = pmKey.value = ''; pmTransport.value = 'udp'; + pmTcpFrag.checked = false; + pmFragChunk.value = 2; + pmFragSleep.value = 2; + pmJunkPcMin.value = 2; + pmJunkPcMax.value = 5; + pmJunkPsMin.value = 100; + pmJunkPsMax.value = 1000; btnProfileDelete.style.display = 'none'; } pmKey.type = 'password'; profileModal.classList.remove('hidden'); + pmTransport.dispatchEvent(new Event('change')); + pmTcpFrag.dispatchEvent(new Event('change')); setTimeout(() => pmName.focus(), 80); } @@ -511,6 +546,11 @@ function saveProfileFromEditor() { server, key, transport: pmTransport.value, + tcp_fragmentation: pmTcpFrag.checked, + frag_chunk: parseInt(pmFragChunk.value) || 2, + frag_sleep: parseInt(pmFragSleep.value) || 2, + junk_pc: [parseInt(pmJunkPcMin.value)||2, parseInt(pmJunkPcMax.value)||5], + junk_ps: [parseInt(pmJunkPsMin.value)||100, parseInt(pmJunkPsMax.value)||1000], }; } } else { @@ -520,6 +560,11 @@ function saveProfileFromEditor() { server, key, transport: pmTransport.value, + tcp_fragmentation: pmTcpFrag.checked, + frag_chunk: parseInt(pmFragChunk.value) || 2, + frag_sleep: parseInt(pmFragSleep.value) || 2, + junk_pc: [parseInt(pmJunkPcMin.value)||2, parseInt(pmJunkPcMax.value)||5], + junk_ps: [parseInt(pmJunkPsMin.value)||100, parseInt(pmJunkPsMax.value)||1000], }; profiles.push(p); if (!activeId) { activeId = p.id; saveActiveId(activeId); } @@ -707,6 +752,24 @@ window.addEventListener('DOMContentLoaded', async () => { btnGoSettings.addEventListener('click', () => showScreen('settings')); btnBack.addEventListener('click', () => showScreen('home')); + // Theme toggle (dark ⇄ light), persisted in localStorage + const btnTheme = $('btn-theme'); + if (btnTheme) btnTheme.addEventListener('click', () => { + const isLight = document.documentElement.classList.toggle('light'); + localStorage.setItem('ostp_theme', isLight ? 'light' : 'dark'); + }); + + // GUI version shown at the bottom of Settings + const appVersionEl = $('app-version'); + if (appVersionEl) { + const setV = v => { appVersionEl.textContent = 'OSTP GUI v' + v; }; + if (window.__TAURI__?.app?.getVersion) { + window.__TAURI__.app.getVersion().then(setV).catch(() => setV('0.4.1')); + } else { + setV('0.4.1'); + } + } + // Add-profile button → dropdown btnAddProfile.addEventListener('click', e => { e.stopPropagation(); @@ -757,6 +820,12 @@ window.addEventListener('DOMContentLoaded', async () => { btnProfileCancel.addEventListener('click', () => profileModal.classList.add('hidden')); btnProfileSave.addEventListener('click', saveProfileFromEditor); btnProfileDelete.addEventListener('click', deleteEditingProfile); + pmTransport.addEventListener('change', () => { + pmTcpSettings.style.display = pmTransport.value === 'uot' ? 'block' : 'none'; + }); + pmTcpFrag.addEventListener('change', () => { + pmFragDetails.style.display = pmTcpFrag.checked ? 'block' : 'none'; + }); btnPeekPm.addEventListener('click', () => { pmKey.type = pmKey.type === 'password' ? 'text' : 'password'; }); diff --git a/ostp-gui/src/styles.css b/ostp-gui/src/styles.css index 25283c3..e572998 100644 --- a/ostp-gui/src/styles.css +++ b/ostp-gui/src/styles.css @@ -43,6 +43,45 @@ color: var(--c-txt-1); } +/* ── Light theme (inverted monochrome) ──────────────────────────────── */ +:root.light { + --c-bg: #f4f4f5; + --c-surface: #ffffff; + --c-card: rgba(0,0,0,0.03); + --c-card-border: rgba(0,0,0,0.10); + --c-card-hover: rgba(0,0,0,0.05); + --c-accent: #18181b; + --c-accent-dim: rgba(0,0,0,0.08); + --c-accent-glow: rgba(0,0,0,0.14); + --c-green: #18181b; + --c-green-glow: rgba(0,0,0,0.16); + --c-green-dim: rgba(0,0,0,0.05); + --c-red: #dc2626; + --c-amber: #d97706; + --c-txt-1: #18181b; + --c-txt-2: #71717a; + --c-txt-3: #c4c4cc; + color: var(--c-txt-1); +} +/* Eagle watermark is a light asset — invert it so it stays visible on light bg */ +:root.light .watermark img { filter: invert(1); } + +/* Theme toggle: show the icon of the theme you'd switch TO */ +#icon-moon { display: none; } +#icon-sun { display: block; } +:root.light #icon-moon { display: block; } +:root.light #icon-sun { display: none; } + +/* GUI version footer at the bottom of Settings */ +.app-version { + text-align: center; + font-size: 0.68rem; + letter-spacing: 0.05em; + color: var(--c-txt-2); + opacity: 0.7; + padding: 16px 0 20px; +} + /* ── Reset ───────────────────────────────────────────────────────────── */ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } html, body { width: 100%; height: 100%; background: var(--c-bg); overflow: hidden; user-select: none; } @@ -368,6 +407,7 @@ a { text-decoration: none; } /* ── Settings body ───────────────────────────────────────────────────── */ .settings-body { flex: 1; + min-height: 0; display: flex; flex-direction: column; gap: 0; diff --git a/ostp-server/src/dispatcher.rs b/ostp-server/src/dispatcher.rs index 5052aa8..84e0606 100644 --- a/ostp-server/src/dispatcher.rs +++ b/ostp-server/src/dispatcher.rs @@ -13,6 +13,8 @@ const MAX_SESSIONS: usize = 1024; pub enum DispatchOutcome { Unauthorized, + /// Packet matched a registered key's per-key junk marker — drop silently. + Junk, Accepted { responses: Vec, app_payloads: Vec<(u32, u16, Bytes)>, // session_id, stream_id, payload @@ -306,6 +308,13 @@ impl Dispatcher { for candidate_key in keys_snapshot { let secrets = ostp_core::crypto::derive_all_secrets(candidate_key.as_bytes()); + // Junk frames carry this key's per-key derived marker (no global + // constant → no universal DPI signature). Drop silently — the secrets + // for this key are already derived here, so the check is free. + if packet.len() >= 4 && packet[0..4] == secrets.junk_marker { + return Ok(DispatchOutcome::Junk); + } + // Decode the session_id using this key's obfuscation // The handshake mask is derived from the Noise payload at bytes [6..], // so we must deobfuscate the full packet, not just the header. diff --git a/ostp-server/src/lib.rs b/ostp-server/src/lib.rs index 48e326d..2177eb9 100644 --- a/ostp-server/src/lib.rs +++ b/ostp-server/src/lib.rs @@ -551,7 +551,8 @@ async fn handle_udp_packet( last_empty_app_log: &mut Instant, ) -> Result<()> { let size = packet.len(); - match dispatcher.on_datagram(peer, packet) { + match dispatcher.on_datagram(peer, packet.clone()) { + Ok(DispatchOutcome::Junk) => return Ok(()), Ok(DispatchOutcome::Unauthorized) => { let _ = ui_event_tx.send(UiEvent::UnauthorizedProbe { peer: peer.ip(), bytes: size }); } diff --git a/ostp/src/main.rs b/ostp/src/main.rs index 6355374..9572a98 100644 --- a/ostp/src/main.rs +++ b/ostp/src/main.rs @@ -1630,6 +1630,10 @@ async fn run_client_directly(client_cfg: ClientConfig) -> Result<()> { mode: client_cfg.transport.as_ref().and_then(|t| t.mode.clone()).unwrap_or_else(|| "udp".to_string()), stealth_sni: client_cfg.transport.as_ref().and_then(|t| t.stealth_sni.clone()).unwrap_or_else(|| "microsoft.com".to_string()), tcp_fragmentation: client_cfg.transport.as_ref().and_then(|t| t.tcp_fragmentation).unwrap_or(false), + frag_chunk: 2, + frag_sleep: 2, + junk_pc: [2, 5], + junk_ps: [100, 1000], }, dns_server: client_cfg.tun.as_ref().and_then(|t| t.dns.clone()), kill_switch: client_cfg.tun.as_ref().and_then(|t| t.kill_switch).unwrap_or(false), diff --git a/scripts/build.ps1 b/scripts/build.ps1 index fab2b9c..028c267 100644 --- a/scripts/build.ps1 +++ b/scripts/build.ps1 @@ -1,13 +1,15 @@ # OSTP Build & Release Pipeline # Usage: -# .\scripts\build.ps1 Build locally + trigger CI/CD -# .\scripts\build.ps1 -TriggerOnly Skip local builds, trigger CI/CD only -# .\scripts\build.ps1 -Check Run cargo check only (no build, no release) +# .\scripts\build.ps1 Build locally + trigger CI/CD (stable release) +# .\scripts\build.ps1 -TriggerOnly Skip local builds, trigger CI/CD only +# .\scripts\build.ps1 -TriggerOnly -PreRelease Beta: tag CURRENT version as pre-release (no bump, no master commit) +# .\scripts\build.ps1 -Check Run cargo check only (no build, no release) param( [switch]$Flatten, [switch]$TriggerOnly, - [switch]$Check + [switch]$Check, + [switch]$PreRelease ) $ProjectRoot = Split-Path -Parent $PSScriptRoot @@ -17,22 +19,27 @@ Push-Location $ProjectRoot Write-Output "Synchronizing with origin master..." & git pull origin master --rebase --autostash | Out-Null -# --- Version bump --- +# --- Version resolution / bump --- $CargoToml = Join-Path $ProjectRoot "Cargo.toml" $Version = "0.2.0" -if (Test-Path $CargoToml) { - $Content = [System.IO.File]::ReadAllText($CargoToml) - # Match version only in [workspace.package] section (first occurrence) - if ($Content -match '\[workspace\.package\][\s\S]*?version\s*=\s*"(\d+)\.(\d+)\.(\d+)"') { - $Major = [int]$Matches[1] - $Minor = [int]$Matches[2] - $Patch = [int]$Matches[3] +$Content = if (Test-Path $CargoToml) { [System.IO.File]::ReadAllText($CargoToml) } else { "" } + +if ($Content -match '\[workspace\.package\][\s\S]*?version\s*=\s*"(\d+)\.(\d+)\.(\d+)"') { + $Major = [int]$Matches[1] + $Minor = [int]$Matches[2] + $Patch = [int]$Matches[3] + + if ($PreRelease) { + # Beta: build the CURRENT version as a pre-release. No bump, no manifest rewrites. + $Version = "{0}.{1}.{2}" -f $Major, $Minor, $Patch + Write-Output "[ok] Pre-release build of current v$Version (no version bump)" + } else { $NewPatch = $Patch + 1 $Version = "{0}.{1}.{2}" -f $Major, $Minor, $NewPatch - # Replace only the workspace version line, not dependency versions + + # Replace only the workspace version line (first occurrence), not dependency versions $OldVersionStr = 'version = "{0}.{1}.{2}"' -f $Major, $Minor, $Patch $NewVersionStr = 'version = "' + $Version + '"' - # Use .NET Replace to swap only the first occurrence $idx = $Content.IndexOf($OldVersionStr) if ($idx -ge 0) { $NewContent = $Content.Remove($idx, $OldVersionStr.Length).Insert($idx, $NewVersionStr) @@ -40,24 +47,22 @@ if (Test-Path $CargoToml) { } Write-Output "[ok] Version: v$Version" - # Bump Tauri GUI + # Bump Tauri GUI config $TauriConf = Join-Path $ProjectRoot "ostp-gui\src-tauri\tauri.conf.json" if (Test-Path $TauriConf) { $TauriContent = [System.IO.File]::ReadAllText($TauriConf) - $TauriRegex = [regex] '"version":\s*"[^"]+"' - $TauriContent = $TauriRegex.Replace($TauriContent, ('"version": "' + $Version + '"'), 1) + $TauriContent = ([regex]'"version":\s*"[^"]+"').Replace($TauriContent, ('"version": "' + $Version + '"'), 1) [System.IO.File]::WriteAllText($TauriConf, $TauriContent) Write-Output " [ok] Updated tauri.conf.json" } - # Bump React Control Panel - $PackageJson = Join-Path $ProjectRoot "ostp-control\package.json" - if (Test-Path $PackageJson) { - $PkgContent = [System.IO.File]::ReadAllText($PackageJson) - $PkgRegex = [regex] '"version":\s*"[^"]+"' - $PkgContent = $PkgRegex.Replace($PkgContent, ('"version": "' + $Version + '"'), 1) - [System.IO.File]::WriteAllText($PackageJson, $PkgContent) - Write-Output " [ok] Updated package.json" + # Bump GUI package.json + $GuiPkg = Join-Path $ProjectRoot "ostp-gui\package.json" + if (Test-Path $GuiPkg) { + $GuiContent = [System.IO.File]::ReadAllText($GuiPkg) + $GuiContent = ([regex]'"version":\s*"[^"]+"').Replace($GuiContent, ('"version": "' + $Version + '"'), 1) + [System.IO.File]::WriteAllText($GuiPkg, $GuiContent) + Write-Output " [ok] Updated ostp-gui/package.json" } # Bump Flutter App @@ -66,8 +71,7 @@ if (Test-Path $CargoToml) { $PubContent = [System.IO.File]::ReadAllText($Pubspec) if ($PubContent -match 'version:\s*(\d+\.\d+\.\d+)\+(\d+)') { $BuildNumber = [int]$Matches[2] + 1 - $PubRegex = [regex] 'version:\s*\d+\.\d+\.\d+\+\d+' - $PubContent = $PubRegex.Replace($PubContent, ("version: $Version+$BuildNumber"), 1) + $PubContent = ([regex]'version:\s*\d+\.\d+\.\d+\+\d+').Replace($PubContent, ("version: $Version+$BuildNumber"), 1) [System.IO.File]::WriteAllText($Pubspec, $PubContent) Write-Output " [ok] Updated pubspec.yaml" } @@ -75,13 +79,18 @@ if (Test-Path $CargoToml) { } } -# --- Pre-flight: frontend build --- -Write-Output "" -Write-Output "Building frontend control panel..." -Push-Location (Join-Path $ProjectRoot "ostp-control") -& npm install | Out-Null -& npm run build | Out-Null -Pop-Location +# --- Pre-flight: frontend build (only if the panel ships source) --- +$ControlDir = Join-Path $ProjectRoot "ostp-control" +if (Test-Path (Join-Path $ControlDir "package.json")) { + Write-Output "" + Write-Output "Building frontend control panel..." + Push-Location $ControlDir + & npm install | Out-Null + & npm run build | Out-Null + Pop-Location +} else { + Write-Output "[skip] ostp-control has no package.json — using prebuilt dist/." +} # --- Pre-flight: cargo check --- Write-Output "" @@ -259,25 +268,46 @@ if (-not $TriggerOnly) { Write-Output "" Write-Output "--- Phase 3: CI/CD release ---" -Write-Output "Pushing version metadata..." -& git add Cargo.toml Cargo.lock -& git commit -m "CI/CD: release version v$Version" --allow-empty | Out-Null -& git push origin master | Out-Null +if ($PreRelease) { + # Beta: tag the CURRENT commit as a pre-release. Do NOT bump/commit master. + # The workflow marks any tag containing '-' as a GitHub pre-release. + $existingBetas = @(& git tag -l "v$Version-beta.*") + $BetaNum = $existingBetas.Count + 1 + $Tag = "v$Version-beta.$BetaNum" + Write-Output "Creating pre-release tag: $Tag" + & git tag $Tag + Write-Output "Pushing tag to GitHub..." + & git push origin $Tag -Write-Output "Creating release tag: v$Version" -& git tag -d "v$Version" 2>&1 | Out-Null -& git tag "v$Version" - -Write-Output "Pushing tag to GitHub..." -& git push origin "v$Version" --force - -if ($LASTEXITCODE -eq 0) { - Write-Output "" - Write-Output "[ok] Release v$Version triggered on GitHub Actions." - Write-Output " Monitor: https://github.com/ospab/ostp/actions" + if ($LASTEXITCODE -eq 0) { + Write-Output "" + Write-Output "[ok] Pre-release $Tag triggered on GitHub Actions (marked as pre-release)." + Write-Output " Monitor: https://github.com/ospab/ostp/actions" + } else { + Write-Output "" + Write-Output "[error] Failed to push pre-release tag." + } } else { - Write-Output "" - Write-Output "[error] Failed to push release tag." + Write-Output "Pushing version metadata..." + & git add Cargo.toml Cargo.lock + & git commit -m "CI/CD: release version v$Version" --allow-empty | Out-Null + & git push origin master | Out-Null + + Write-Output "Creating release tag: v$Version" + & git tag -d "v$Version" 2>&1 | Out-Null + & git tag "v$Version" + + Write-Output "Pushing tag to GitHub..." + & git push origin "v$Version" --force + + if ($LASTEXITCODE -eq 0) { + Write-Output "" + Write-Output "[ok] Release v$Version triggered on GitHub Actions." + Write-Output " Monitor: https://github.com/ospab/ostp/actions" + } else { + Write-Output "" + Write-Output "[error] Failed to push release tag." + } } Pop-Location