mirror of https://github.com/ospab/ostp.git
Compare commits
No commits in common. "v0.4.5-beta.8" and "master" have entirely different histories.
v0.4.5-bet
...
master
|
|
@ -417,29 +417,6 @@ jobs:
|
||||||
|
|
||||||
Compress-Archive -Path "$dir/*" -DestinationPath "ostp-windows-gui-${{ matrix.arch }}.zip" -Force
|
Compress-Archive -Path "$dir/*" -DestinationPath "ostp-windows-gui-${{ matrix.arch }}.zip" -Force
|
||||||
|
|
||||||
# The installer is what removes the per-connect consent prompt: it runs
|
|
||||||
# elevated, so its hook can register the helper's Scheduled Task once.
|
|
||||||
# The portable zip above cannot, and falls back to asking on first connect.
|
|
||||||
# The sidecar and its config are confined to this step: declaring
|
|
||||||
# externalBin in an auto-merged tauri.windows.conf.json would force every
|
|
||||||
# Windows build, down to a bare `cargo check`, to have the helper staged
|
|
||||||
# first, and fail the build script when it is not.
|
|
||||||
- name: Build NSIS Installer
|
|
||||||
working-directory: ostp-gui
|
|
||||||
# Chained, not two lines: pwsh does not abort a run block when a native
|
|
||||||
# command fails, so a staging failure would otherwise be reported far
|
|
||||||
# downstream as a missing sidecar rather than as itself.
|
|
||||||
run: node stage-sidecar.cjs --release --target ${{ matrix.target }} && npx tauri build --bundles nsis --target ${{ matrix.target }} --config src-tauri/tauri.installer.conf.json
|
|
||||||
|
|
||||||
- name: Collect installer
|
|
||||||
shell: pwsh
|
|
||||||
run: |
|
|
||||||
$nsis = Get-ChildItem -Path "ostp-gui/src-tauri/target/${{ matrix.target }}/release/bundle/nsis" -Filter *-setup.exe -ErrorAction SilentlyContinue |
|
|
||||||
Select-Object -First 1
|
|
||||||
if (-not $nsis) { Write-Error "NSIS installer was not produced"; exit 1 }
|
|
||||||
Copy-Item $nsis.FullName "ostp-windows-gui-${{ matrix.arch }}-setup.exe"
|
|
||||||
Write-Host "installer: $($nsis.Name) -> ostp-windows-gui-${{ matrix.arch }}-setup.exe"
|
|
||||||
|
|
||||||
- name: Upload to GitHub Release
|
- name: Upload to GitHub Release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
|
|
@ -449,9 +426,7 @@ jobs:
|
||||||
# real stable release.
|
# real stable release.
|
||||||
tag_name: ${{ needs.resolve-channel.outputs.tag_name }}
|
tag_name: ${{ needs.resolve-channel.outputs.tag_name }}
|
||||||
prerelease: ${{ needs.resolve-channel.outputs.prerelease }}
|
prerelease: ${{ needs.resolve-channel.outputs.prerelease }}
|
||||||
files: |
|
files: ostp-windows-gui-${{ matrix.arch }}.zip
|
||||||
ostp-windows-gui-${{ matrix.arch }}.zip
|
|
||||||
ostp-windows-gui-${{ matrix.arch }}-setup.exe
|
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,3 @@ ostp-control/
|
||||||
netstack-smoltcp/
|
netstack-smoltcp/
|
||||||
dnstt/
|
dnstt/
|
||||||
ostp-web/
|
ostp-web/
|
||||||
|
|
||||||
# Tauri sidecar staging area (copied from target/ at build time)
|
|
||||||
ostp-gui/src-tauri/binaries/
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"target_version": "0.4.5",
|
"target_version": "0.4.4",
|
||||||
"branch": "beta",
|
"branch": "master",
|
||||||
"alpha_iteration": 0,
|
"alpha_iteration": 0,
|
||||||
"beta_iteration": 8
|
"beta_iteration": 0
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1386,7 +1386,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostp"
|
name = "ostp"
|
||||||
version = "0.4.5"
|
version = "0.4.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
|
|
@ -1409,7 +1409,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostp-client"
|
name = "ostp-client"
|
||||||
version = "0.4.5"
|
version = "0.4.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
|
|
@ -1440,7 +1440,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostp-core"
|
name = "ostp-core"
|
||||||
version = "0.4.5"
|
version = "0.4.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
|
@ -1474,7 +1474,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostp-server"
|
name = "ostp-server"
|
||||||
version = "0.4.5"
|
version = "0.4.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
|
|
@ -1507,7 +1507,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostp-tun"
|
name = "ostp-tun"
|
||||||
version = "0.4.5"
|
version = "0.4.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"libc",
|
"libc",
|
||||||
|
|
@ -1519,7 +1519,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostp-tun-helper"
|
name = "ostp-tun-helper"
|
||||||
version = "0.4.5"
|
version = "0.4.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ resolver = "2"
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "AGPL-3.0"
|
license = "AGPL-3.0"
|
||||||
version = "0.4.5"
|
version = "0.4.4"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
|
|
|
||||||
|
|
@ -133,9 +133,6 @@ pub struct Bridge {
|
||||||
pub frag_sleep: u64,
|
pub frag_sleep: u64,
|
||||||
pub junk_pc: [usize; 2],
|
pub junk_pc: [usize; 2],
|
||||||
pub junk_ps: [usize; 2],
|
pub junk_ps: [usize; 2],
|
||||||
pub ttl_desync: bool,
|
|
||||||
pub ttl_desync_ttl: u8,
|
|
||||||
pub ttl_desync_count: u8,
|
|
||||||
pub mtu: usize,
|
pub mtu: usize,
|
||||||
pub kill_switch: bool,
|
pub kill_switch: bool,
|
||||||
pub reload_tx: Option<watch::Sender<crate::config::ExclusionConfig>>,
|
pub reload_tx: Option<watch::Sender<crate::config::ExclusionConfig>>,
|
||||||
|
|
@ -187,9 +184,6 @@ impl Bridge {
|
||||||
frag_sleep: config.transport.frag_sleep,
|
frag_sleep: config.transport.frag_sleep,
|
||||||
junk_pc: config.transport.junk_pc,
|
junk_pc: config.transport.junk_pc,
|
||||||
junk_ps: config.transport.junk_ps,
|
junk_ps: config.transport.junk_ps,
|
||||||
ttl_desync: config.transport.ttl_desync,
|
|
||||||
ttl_desync_ttl: config.transport.ttl_desync_ttl,
|
|
||||||
ttl_desync_count: config.transport.ttl_desync_count,
|
|
||||||
mtu: config.ostp.mtu,
|
mtu: config.ostp.mtu,
|
||||||
kill_switch: config.kill_switch,
|
kill_switch: config.kill_switch,
|
||||||
reload_tx: None,
|
reload_tx: None,
|
||||||
|
|
@ -1114,34 +1108,6 @@ impl Bridge {
|
||||||
let is_uot = matches!(socket, crate::transport::Transport::Uot { .. });
|
let is_uot = matches!(socket, crate::transport::Transport::Uot { .. });
|
||||||
let (attempt_limit, attempt_timeout_ms) = if is_uot { (1, 8000) } else { (4, 1200) };
|
let (attempt_limit, attempt_timeout_ms) = if is_uot { (1, 8000) } else { (4, 1200) };
|
||||||
|
|
||||||
// TTL-desync (UDP only, opt-in): fire decoy datagrams that reach an
|
|
||||||
// on-path DPI box but expire before the server, so the box classifies
|
|
||||||
// the flow on the decoys rather than the real handshake that follows.
|
|
||||||
// Each carries the key's junk marker, so any decoy that does reach
|
|
||||||
// the server is dropped there silently.
|
|
||||||
if self.ttl_desync && !is_uot && self.ttl_desync_count > 0 {
|
|
||||||
let marker = ostp_core::crypto::derive_junk_marker(
|
|
||||||
&self.access_key,
|
|
||||||
ostp_core::crypto::current_junk_window(),
|
|
||||||
);
|
|
||||||
let decoys: Vec<bytes::Bytes> = {
|
|
||||||
let mut rng = rand::thread_rng();
|
|
||||||
let [min_s, max_s] = self.junk_ps;
|
|
||||||
let min_s = min_s.max(4);
|
|
||||||
let max_s = max_s.max(min_s);
|
|
||||||
(0..self.ttl_desync_count)
|
|
||||||
.map(|_| {
|
|
||||||
let len = rng.gen_range(min_s..=max_s);
|
|
||||||
let mut b = vec![0u8; len];
|
|
||||||
rng.fill(&mut b[..]);
|
|
||||||
b[..4].copy_from_slice(&marker);
|
|
||||||
bytes::Bytes::from(b)
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
};
|
|
||||||
socket.send_ttl_decoys(&decoys, self.ttl_desync_ttl).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
for attempt in 0..attempt_limit {
|
for attempt in 0..attempt_limit {
|
||||||
if attempt > 0 {
|
if attempt > 0 {
|
||||||
tx.send(UiEvent::Log(format!("Handshake attempt {} lost. Retransmitting...", attempt))).await.ok();
|
tx.send(UiEvent::Log(format!("Handshake attempt {} lost. Retransmitting...", attempt))).await.ok();
|
||||||
|
|
@ -1239,9 +1205,6 @@ impl Bridge {
|
||||||
self.frag_sleep = cfg.transport.frag_sleep;
|
self.frag_sleep = cfg.transport.frag_sleep;
|
||||||
self.junk_pc = cfg.transport.junk_pc;
|
self.junk_pc = cfg.transport.junk_pc;
|
||||||
self.junk_ps = cfg.transport.junk_ps;
|
self.junk_ps = cfg.transport.junk_ps;
|
||||||
self.ttl_desync = cfg.transport.ttl_desync;
|
|
||||||
self.ttl_desync_ttl = cfg.transport.ttl_desync_ttl;
|
|
||||||
self.ttl_desync_count = cfg.transport.ttl_desync_count;
|
|
||||||
self.mtu = cfg.ostp.mtu;
|
self.mtu = cfg.ostp.mtu;
|
||||||
self.keepalive_interval_sec = cfg.ostp.keepalive_interval_sec;
|
self.keepalive_interval_sec = cfg.ostp.keepalive_interval_sec;
|
||||||
self.kill_switch = cfg.kill_switch;
|
self.kill_switch = cfg.kill_switch;
|
||||||
|
|
|
||||||
|
|
@ -92,19 +92,6 @@ pub struct TransportConfig {
|
||||||
/// [min, max] junk packet size in bytes
|
/// [min, max] junk packet size in bytes
|
||||||
#[serde(default = "default_junk_size")]
|
#[serde(default = "default_junk_size")]
|
||||||
pub junk_ps: [usize; 2],
|
pub junk_ps: [usize; 2],
|
||||||
/// TTL-desync (UDP only): before the handshake, send decoy datagrams with a
|
|
||||||
/// lowered IP TTL so they reach an on-path DPI box but expire before the
|
|
||||||
/// server, poisoning the box's classification of the flow. Off by default —
|
|
||||||
/// it needs the TTL calibrated to the network, and the wrong value is inert.
|
|
||||||
#[serde(default)]
|
|
||||||
pub ttl_desync: bool,
|
|
||||||
/// TTL the decoy datagrams are sent with. Set it to one or two hops past the
|
|
||||||
/// injector distance the prober reports, so decoys die just beyond the DPI.
|
|
||||||
#[serde(default = "default_ttl_desync_ttl")]
|
|
||||||
pub ttl_desync_ttl: u8,
|
|
||||||
/// How many decoy datagrams to send per handshake.
|
|
||||||
#[serde(default = "default_ttl_desync_count")]
|
|
||||||
pub ttl_desync_count: u8,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_transport_mode() -> String { "udp".to_string() }
|
fn default_transport_mode() -> String { "udp".to_string() }
|
||||||
|
|
@ -112,8 +99,6 @@ fn default_frag_chunk() -> usize { 2 }
|
||||||
fn default_frag_sleep() -> u64 { 2 }
|
fn default_frag_sleep() -> u64 { 2 }
|
||||||
fn default_junk_count() -> [usize; 2] { [2, 5] }
|
fn default_junk_count() -> [usize; 2] { [2, 5] }
|
||||||
fn default_junk_size() -> [usize; 2] { [100, 1000] }
|
fn default_junk_size() -> [usize; 2] { [100, 1000] }
|
||||||
fn default_ttl_desync_ttl() -> u8 { 8 }
|
|
||||||
fn default_ttl_desync_count() -> u8 { 2 }
|
|
||||||
|
|
||||||
impl Default for TransportConfig {
|
impl Default for TransportConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
|
|
@ -124,9 +109,6 @@ impl Default for TransportConfig {
|
||||||
frag_sleep: default_frag_sleep(),
|
frag_sleep: default_frag_sleep(),
|
||||||
junk_pc: default_junk_count(),
|
junk_pc: default_junk_count(),
|
||||||
junk_ps: default_junk_size(),
|
junk_ps: default_junk_size(),
|
||||||
ttl_desync: false,
|
|
||||||
ttl_desync_ttl: default_ttl_desync_ttl(),
|
|
||||||
ttl_desync_count: default_ttl_desync_count(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -212,9 +194,6 @@ struct RawTransportSection {
|
||||||
frag_sleep: Option<u64>,
|
frag_sleep: Option<u64>,
|
||||||
junk_pc: Option<[usize; 2]>,
|
junk_pc: Option<[usize; 2]>,
|
||||||
junk_ps: Option<[usize; 2]>,
|
junk_ps: Option<[usize; 2]>,
|
||||||
ttl_desync: Option<bool>,
|
|
||||||
ttl_desync_ttl: Option<u8>,
|
|
||||||
ttl_desync_count: Option<u8>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|
@ -292,9 +271,6 @@ impl ClientConfig {
|
||||||
frag_sleep: raw.transport.as_ref().and_then(|t| t.frag_sleep).unwrap_or_else(default_frag_sleep),
|
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_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),
|
junk_ps: raw.transport.as_ref().and_then(|t| t.junk_ps).unwrap_or_else(default_junk_size),
|
||||||
ttl_desync: raw.transport.as_ref().and_then(|t| t.ttl_desync).unwrap_or(false),
|
|
||||||
ttl_desync_ttl: raw.transport.as_ref().and_then(|t| t.ttl_desync_ttl).unwrap_or_else(default_ttl_desync_ttl),
|
|
||||||
ttl_desync_count: raw.transport.as_ref().and_then(|t| t.ttl_desync_count).unwrap_or_else(default_ttl_desync_count),
|
|
||||||
},
|
},
|
||||||
exclusions: ExclusionConfig {
|
exclusions: ExclusionConfig {
|
||||||
domains: exclusions.domains.unwrap_or_default(),
|
domains: exclusions.domains.unwrap_or_default(),
|
||||||
|
|
@ -373,18 +349,11 @@ impl UnifiedConfig {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AppMode::Relay(cfg) => {
|
AppMode::Relay(cfg) => {
|
||||||
// The relay forwards to a fixed next hop on both carriers, so it
|
|
||||||
// needs both upstream addresses. It does NOT need upstream_api_url:
|
|
||||||
// that field belonged to the old design where the relay
|
|
||||||
// authenticated clients itself, which it no longer does. Requiring
|
|
||||||
// it here was the bug that made every generated relay config
|
|
||||||
// (wizard and template alike write no api_url) fail to load with
|
|
||||||
// "must specify upstream_api_url" — a relay that could never start.
|
|
||||||
if cfg.upstream_tcp.is_empty() {
|
if cfg.upstream_tcp.is_empty() {
|
||||||
anyhow::bail!("Relay configuration must specify upstream_tcp (the next hop's TCP/UoT address).");
|
anyhow::bail!("Relay configuration must specify upstream_tcp address.");
|
||||||
}
|
}
|
||||||
if cfg.upstream_udp.is_empty() {
|
if cfg.upstream_api_url.is_empty() {
|
||||||
anyhow::bail!("Relay configuration must specify upstream_udp (the next hop's UDP address).");
|
anyhow::bail!("Relay configuration must specify upstream_api_url.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -567,76 +536,3 @@ pub struct MuxConfig {
|
||||||
pub enabled: Option<bool>,
|
pub enabled: Option<bool>,
|
||||||
pub sessions: Option<usize>,
|
pub sessions: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
/// Loads a config.json exactly as the daemon does: parse the JSON into the
|
|
||||||
/// canonical `UnifiedConfig`, then validate. This is the real drift-catcher —
|
|
||||||
/// if the wizard/template and the validator ever disagree on required fields,
|
|
||||||
/// this fails instead of a user's relay refusing to start.
|
|
||||||
fn load(json: &str) -> Result<UnifiedConfig> {
|
|
||||||
let cfg: UnifiedConfig = serde_json::from_str(json)?;
|
|
||||||
cfg.validate()?;
|
|
||||||
Ok(cfg)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Regression: the relay used to authenticate clients and so its config
|
|
||||||
/// carried `upstream_api_url`. The relay is a transparent pipe now and both
|
|
||||||
/// the wizard and the `init` template write NO api_url — yet validation kept
|
|
||||||
/// demanding it, so every generated relay config failed to load with
|
|
||||||
/// "must specify upstream_api_url". A relay that could never start.
|
|
||||||
#[test]
|
|
||||||
fn relay_config_without_api_url_loads() {
|
|
||||||
// Byte-for-byte the shape the wizard (main.rs) emits.
|
|
||||||
let json = r#"{
|
|
||||||
"mode": "relay",
|
|
||||||
"listen": "0.0.0.0:50000",
|
|
||||||
"upstream_tcp": "203.0.113.10:50000",
|
|
||||||
"upstream_udp": "203.0.113.10:50000",
|
|
||||||
"debug": false
|
|
||||||
}"#;
|
|
||||||
load(json).expect("a transparent-relay config must load without upstream_api_url");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A relay still needs somewhere to forward to on both carriers, so an
|
|
||||||
/// incomplete relay config must fail loudly at load, not connect-to-empty
|
|
||||||
/// per session at runtime.
|
|
||||||
#[test]
|
|
||||||
fn relay_config_missing_upstream_udp_is_rejected() {
|
|
||||||
let json = r#"{
|
|
||||||
"mode": "relay",
|
|
||||||
"listen": "0.0.0.0:50000",
|
|
||||||
"upstream_tcp": "203.0.113.10:50000",
|
|
||||||
"upstream_udp": "",
|
|
||||||
"debug": false
|
|
||||||
}"#;
|
|
||||||
assert!(load(json).is_err(), "a relay with no UDP upstream must be rejected");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A deprecated api_url left in an OLD config must not break loading — it is
|
|
||||||
/// ignored, not required and not forbidden.
|
|
||||||
#[test]
|
|
||||||
fn relay_config_with_leftover_api_url_still_loads() {
|
|
||||||
let json = r#"{
|
|
||||||
"mode": "relay",
|
|
||||||
"listen": "0.0.0.0:50000",
|
|
||||||
"upstream_tcp": "203.0.113.10:50000",
|
|
||||||
"upstream_udp": "203.0.113.10:50000",
|
|
||||||
"upstream_api_url": "http://old.example:8080",
|
|
||||||
"debug": false
|
|
||||||
}"#;
|
|
||||||
load(json).expect("a stale api_url must be tolerated, not rejected");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The minimal client and server shapes the template emits must also load,
|
|
||||||
/// so this test guards all three modes against generator/validator drift.
|
|
||||||
#[test]
|
|
||||||
fn minimal_client_and_server_configs_load() {
|
|
||||||
load(r#"{"mode":"client","server":"127.0.0.1:50000","access_key":"k"}"#)
|
|
||||||
.expect("minimal client config must load");
|
|
||||||
load(r#"{"mode":"server","listen":"0.0.0.0:50000","access_keys":["k"]}"#)
|
|
||||||
.expect("minimal server config must load");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -53,29 +53,4 @@ impl Transport {
|
||||||
Self::Uot { .. } => Ok("0.0.0.0:0".parse().unwrap()),
|
Self::Uot { .. } => Ok("0.0.0.0:0".parse().unwrap()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// TTL-desync: send `decoys` as datagrams with the IP TTL lowered to `ttl`,
|
|
||||||
/// then restore the socket's original TTL. The decoys are meant to reach an
|
|
||||||
/// on-path DPI box and expire before the server — poisoning the box's view
|
|
||||||
/// of the flow (it classifies on the decoy) while the server never sees
|
|
||||||
/// them. Calibrate `ttl` to the injector hop distance the prober reports.
|
|
||||||
///
|
|
||||||
/// UDP only: this manipulates individual datagrams' TTL. On UoT the carrier
|
|
||||||
/// is one TCP stream, so a socket-level TTL change would apply to the real
|
|
||||||
/// traffic too — proper TCP desync needs injected packets (a driver), which
|
|
||||||
/// this deliberately does not attempt. No-op there.
|
|
||||||
pub async fn send_ttl_decoys(&self, decoys: &[Bytes], ttl: u8) {
|
|
||||||
let Self::Udp(sock) = self else { return };
|
|
||||||
if decoys.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let restore = sock.ttl().unwrap_or(128);
|
|
||||||
if sock.set_ttl(ttl as u32).is_err() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for d in decoys {
|
|
||||||
let _ = sock.send(d).await;
|
|
||||||
}
|
|
||||||
let _ = sock.set_ttl(restore);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,307 +0,0 @@
|
||||||
//! IPv4 fragment reassembly for the TUN → netstack path.
|
|
||||||
//!
|
|
||||||
//! Why this exists: the userspace netstack (netstack-smoltcp) parses each IP
|
|
||||||
//! packet it receives and, for UDP, runs `UdpPacket::new_checked` on the IP
|
|
||||||
//! payload. An IP *fragment* passes the IP-level check but fails the UDP one —
|
|
||||||
//! the UDP length field describes the whole datagram while the fragment carries
|
|
||||||
//! only a slice — so the netstack drops it with `wire::Error` and the datagram
|
|
||||||
//! never reaches the tunnel. Large UDP datagrams (game traffic, e.g. Roblox
|
|
||||||
//! sending >MTU packets that the OS fragments on the way to the TUN) therefore
|
|
||||||
//! vanish entirely, and the app times out.
|
|
||||||
//!
|
|
||||||
//! smoltcp 0.2.2 does no reassembly of its own, so we do it here, between the
|
|
||||||
//! TUN read and the netstack: fragments are buffered by (src, dst, id, proto),
|
|
||||||
//! and only a fully reassembled datagram is handed on. Non-fragmented packets
|
|
||||||
//! pass straight through untouched.
|
|
||||||
|
|
||||||
use std::collections::{BTreeMap, HashMap};
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
/// A fragment group is discarded if not completed within this window, matching
|
|
||||||
/// the usual IP reassembly timeout. Prevents a lost tail fragment from pinning
|
|
||||||
/// memory forever.
|
|
||||||
const REASM_TIMEOUT: Duration = Duration::from_secs(3);
|
|
||||||
/// Cap on concurrently tracked fragment groups, so a flood of first-fragments
|
|
||||||
/// with no tail cannot grow memory without bound.
|
|
||||||
const MAX_GROUPS: usize = 4096;
|
|
||||||
/// A reassembled IPv4 datagram cannot exceed this (total-length is 16-bit).
|
|
||||||
const MAX_DATAGRAM: usize = 65_535;
|
|
||||||
|
|
||||||
type Key = (u32, u32, u16, u8); // src, dst, identification, protocol
|
|
||||||
|
|
||||||
struct Group {
|
|
||||||
/// fragment_offset (bytes) → that fragment's IP payload.
|
|
||||||
parts: BTreeMap<usize, Vec<u8>>,
|
|
||||||
/// IP header of the offset-0 fragment, reused for the reassembled packet.
|
|
||||||
header: Option<Vec<u8>>,
|
|
||||||
/// Total payload length, known once the last fragment (MF=0) is seen.
|
|
||||||
total_len: Option<usize>,
|
|
||||||
first_seen: Instant,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct Reassembler {
|
|
||||||
groups: HashMap<Key, Group>,
|
|
||||||
last_sweep: Instant,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Reassembler {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self { groups: HashMap::new(), last_sweep: Instant::now() }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Feed one frame read from the TUN. Returns the packet(s) to forward to the
|
|
||||||
/// netstack: the frame itself when it is not a fragment, a single fully
|
|
||||||
/// reassembled datagram when this frame completes one, or nothing when the
|
|
||||||
/// frame was buffered as an incomplete fragment.
|
|
||||||
pub fn process(&mut self, frame: &[u8]) -> Option<Vec<u8>> {
|
|
||||||
self.maybe_sweep();
|
|
||||||
|
|
||||||
let Some(v4) = Ipv4View::parse(frame) else {
|
|
||||||
// Not a parseable IPv4 packet (e.g. IPv6) — pass through unchanged;
|
|
||||||
// reassembly is not our job for it.
|
|
||||||
return Some(frame.to_vec());
|
|
||||||
};
|
|
||||||
|
|
||||||
// A packet is fragmented iff MF is set or it carries a non-zero offset.
|
|
||||||
if !v4.more_fragments && v4.frag_offset == 0 {
|
|
||||||
return Some(frame.to_vec());
|
|
||||||
}
|
|
||||||
|
|
||||||
let key = (v4.src, v4.dst, v4.id, v4.protocol);
|
|
||||||
let now = Instant::now();
|
|
||||||
|
|
||||||
if self.groups.len() >= MAX_GROUPS && !self.groups.contains_key(&key) {
|
|
||||||
// Under pressure, drop the oldest incomplete group to make room
|
|
||||||
// rather than refusing the new one outright.
|
|
||||||
if let Some(oldest) = self
|
|
||||||
.groups
|
|
||||||
.iter()
|
|
||||||
.min_by_key(|(_, g)| g.first_seen)
|
|
||||||
.map(|(k, _)| *k)
|
|
||||||
{
|
|
||||||
self.groups.remove(&oldest);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let group = self.groups.entry(key).or_insert_with(|| Group {
|
|
||||||
parts: BTreeMap::new(),
|
|
||||||
header: None,
|
|
||||||
total_len: None,
|
|
||||||
first_seen: now,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Ignore a payload that would push the datagram past the legal maximum.
|
|
||||||
if v4.frag_offset + v4.payload.len() > MAX_DATAGRAM {
|
|
||||||
self.groups.remove(&key);
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
group.parts.insert(v4.frag_offset, v4.payload.to_vec());
|
|
||||||
if v4.frag_offset == 0 {
|
|
||||||
group.header = Some(v4.header.to_vec());
|
|
||||||
}
|
|
||||||
if !v4.more_fragments {
|
|
||||||
// The last fragment fixes the total length.
|
|
||||||
group.total_len = Some(v4.frag_offset + v4.payload.len());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Complete? Walk fragments from offset 0 and require they tile the whole
|
|
||||||
// datagram with no hole. Overlaps are tolerated as long as coverage is
|
|
||||||
// contiguous (BTreeMap keeps them offset-ordered).
|
|
||||||
let (Some(total), Some(header)) = (group.total_len, group.header.clone()) else {
|
|
||||||
return None;
|
|
||||||
};
|
|
||||||
let mut expected = 0usize;
|
|
||||||
for (&off, part) in &group.parts {
|
|
||||||
if off > expected {
|
|
||||||
return None; // hole before this fragment
|
|
||||||
}
|
|
||||||
let end = off + part.len();
|
|
||||||
if end > expected {
|
|
||||||
expected = end;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if expected < total {
|
|
||||||
return None; // not fully covered yet
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reassemble: header + payload bytes [0, total), then fix the header so
|
|
||||||
// it describes a single unfragmented datagram.
|
|
||||||
let mut payload = vec![0u8; total];
|
|
||||||
for (&off, part) in &group.parts {
|
|
||||||
let end = (off + part.len()).min(total);
|
|
||||||
if off < total {
|
|
||||||
payload[off..end].copy_from_slice(&part[..end - off]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.groups.remove(&key);
|
|
||||||
Some(build_reassembled(&header, &payload))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn maybe_sweep(&mut self) {
|
|
||||||
let now = Instant::now();
|
|
||||||
if now.duration_since(self.last_sweep) < Duration::from_secs(1) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
self.last_sweep = now;
|
|
||||||
self.groups.retain(|_, g| now.duration_since(g.first_seen) < REASM_TIMEOUT);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A read-only view over an IPv4 header and its payload.
|
|
||||||
struct Ipv4View<'a> {
|
|
||||||
header: &'a [u8],
|
|
||||||
payload: &'a [u8],
|
|
||||||
src: u32,
|
|
||||||
dst: u32,
|
|
||||||
id: u16,
|
|
||||||
protocol: u8,
|
|
||||||
more_fragments: bool,
|
|
||||||
frag_offset: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> Ipv4View<'a> {
|
|
||||||
fn parse(frame: &'a [u8]) -> Option<Self> {
|
|
||||||
if frame.len() < 20 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
if frame[0] >> 4 != 4 {
|
|
||||||
return None; // not IPv4
|
|
||||||
}
|
|
||||||
let ihl = ((frame[0] & 0x0f) as usize) * 4;
|
|
||||||
if ihl < 20 || frame.len() < ihl {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let total_len = u16::from_be_bytes([frame[2], frame[3]]) as usize;
|
|
||||||
// Trust the smaller of declared length and what we actually read.
|
|
||||||
let total_len = total_len.min(frame.len()).max(ihl);
|
|
||||||
let id = u16::from_be_bytes([frame[4], frame[5]]);
|
|
||||||
let flags_frag = u16::from_be_bytes([frame[6], frame[7]]);
|
|
||||||
let more_fragments = flags_frag & 0x2000 != 0;
|
|
||||||
let frag_offset = ((flags_frag & 0x1fff) as usize) * 8;
|
|
||||||
let protocol = frame[9];
|
|
||||||
let src = u32::from_be_bytes([frame[12], frame[13], frame[14], frame[15]]);
|
|
||||||
let dst = u32::from_be_bytes([frame[16], frame[17], frame[18], frame[19]]);
|
|
||||||
Some(Ipv4View {
|
|
||||||
header: &frame[..ihl],
|
|
||||||
payload: &frame[ihl..total_len],
|
|
||||||
src,
|
|
||||||
dst,
|
|
||||||
id,
|
|
||||||
protocol,
|
|
||||||
more_fragments,
|
|
||||||
frag_offset,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stitch the offset-0 header onto a full payload, clearing the fragment fields
|
|
||||||
/// and fixing total-length and header checksum so the netstack sees one clean
|
|
||||||
/// datagram.
|
|
||||||
fn build_reassembled(header0: &[u8], payload: &[u8]) -> Vec<u8> {
|
|
||||||
let ihl = header0.len();
|
|
||||||
let mut out = Vec::with_capacity(ihl + payload.len());
|
|
||||||
out.extend_from_slice(header0);
|
|
||||||
out.extend_from_slice(payload);
|
|
||||||
|
|
||||||
let total = (ihl + payload.len()) as u16;
|
|
||||||
out[2..4].copy_from_slice(&total.to_be_bytes());
|
|
||||||
// Clear flags (except keep DF? no — a reassembled datagram is not a
|
|
||||||
// fragment and DF is irrelevant here) and the fragment offset.
|
|
||||||
out[6] = 0;
|
|
||||||
out[7] = 0;
|
|
||||||
// Recompute the IPv4 header checksum over the (possibly options-bearing)
|
|
||||||
// header only.
|
|
||||||
out[10] = 0;
|
|
||||||
out[11] = 0;
|
|
||||||
let cksum = ipv4_checksum(&out[..ihl]);
|
|
||||||
out[10..12].copy_from_slice(&cksum.to_be_bytes());
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ipv4_checksum(header: &[u8]) -> u16 {
|
|
||||||
let mut sum: u32 = 0;
|
|
||||||
let mut i = 0;
|
|
||||||
while i + 1 < header.len() {
|
|
||||||
sum += u16::from_be_bytes([header[i], header[i + 1]]) as u32;
|
|
||||||
i += 2;
|
|
||||||
}
|
|
||||||
if i < header.len() {
|
|
||||||
sum += (header[i] as u32) << 8;
|
|
||||||
}
|
|
||||||
while sum >> 16 != 0 {
|
|
||||||
sum = (sum & 0xffff) + (sum >> 16);
|
|
||||||
}
|
|
||||||
!(sum as u16)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
// Build a minimal IPv4 header for tests. `mf` = more-fragments, `offset`
|
|
||||||
// in bytes (must be /8), `payload_len` fills total_length.
|
|
||||||
fn ipv4(id: u16, mf: bool, offset: usize, payload: &[u8]) -> Vec<u8> {
|
|
||||||
let total = 20 + payload.len();
|
|
||||||
let mut h = vec![0u8; 20];
|
|
||||||
h[0] = 0x45; // v4, ihl 5
|
|
||||||
h[2..4].copy_from_slice(&(total as u16).to_be_bytes());
|
|
||||||
h[4..6].copy_from_slice(&id.to_be_bytes());
|
|
||||||
let flags_frag = (if mf { 0x2000u16 } else { 0 }) | ((offset / 8) as u16 & 0x1fff);
|
|
||||||
h[6..8].copy_from_slice(&flags_frag.to_be_bytes());
|
|
||||||
h[9] = 17; // UDP
|
|
||||||
h[12..16].copy_from_slice(&[10, 1, 0, 2]);
|
|
||||||
h[16..20].copy_from_slice(&[13, 249, 8, 109]);
|
|
||||||
h.extend_from_slice(payload);
|
|
||||||
h
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn passes_non_fragmented_through() {
|
|
||||||
let mut r = Reassembler::new();
|
|
||||||
let pkt = ipv4(1, false, 0, &[1, 2, 3, 4]);
|
|
||||||
assert_eq!(r.process(&pkt), Some(pkt));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn reassembles_two_fragments() {
|
|
||||||
let mut r = Reassembler::new();
|
|
||||||
// 16 bytes of "UDP" payload split as 8 + 8.
|
|
||||||
let first = ipv4(42, true, 0, &[0, 1, 2, 3, 4, 5, 6, 7]);
|
|
||||||
let second = ipv4(42, false, 8, &[8, 9, 10, 11, 12, 13, 14, 15]);
|
|
||||||
|
|
||||||
assert_eq!(r.process(&first), None, "first fragment must be buffered");
|
|
||||||
let whole = r.process(&second).expect("second fragment completes it");
|
|
||||||
|
|
||||||
// Header says unfragmented, total length 36, payload is the full 16.
|
|
||||||
assert_eq!(whole[0] >> 4, 4);
|
|
||||||
assert_eq!(u16::from_be_bytes([whole[2], whole[3]]), 36);
|
|
||||||
assert_eq!(whole[6] & 0x20, 0, "MF must be cleared");
|
|
||||||
assert_eq!(u16::from_be_bytes([whole[6], whole[7]]) & 0x1fff, 0, "offset cleared");
|
|
||||||
assert_eq!(&whole[20..], &(0u8..16).collect::<Vec<_>>()[..]);
|
|
||||||
// A correctly checksummed header sums to zero when the check field is
|
|
||||||
// included in the computation.
|
|
||||||
assert_eq!(ipv4_checksum(&whole[..20]), 0, "header checksum must verify");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn out_of_order_fragments_reassemble() {
|
|
||||||
let mut r = Reassembler::new();
|
|
||||||
let first = ipv4(7, true, 0, &[0, 1, 2, 3, 4, 5, 6, 7]);
|
|
||||||
let last = ipv4(7, false, 16, &[16, 17, 18, 19]);
|
|
||||||
let mid = ipv4(7, true, 8, &[8, 9, 10, 11, 12, 13, 14, 15]);
|
|
||||||
|
|
||||||
assert_eq!(r.process(&last), None);
|
|
||||||
assert_eq!(r.process(&first), None);
|
|
||||||
let whole = r.process(&mid).expect("last piece completes it");
|
|
||||||
assert_eq!(&whole[20..], &(0u8..20).collect::<Vec<_>>()[..]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn incomplete_group_yields_nothing() {
|
|
||||||
let mut r = Reassembler::new();
|
|
||||||
let first = ipv4(9, true, 0, &[0; 8]);
|
|
||||||
// Tail never arrives.
|
|
||||||
assert_eq!(r.process(&first), None);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
mod proxy;
|
mod proxy;
|
||||||
mod ip_reasm;
|
|
||||||
pub mod native_handler;
|
pub mod native_handler;
|
||||||
|
|
||||||
mod udp_nat;
|
mod udp_nat;
|
||||||
|
|
|
||||||
|
|
@ -123,18 +123,12 @@ pub async fn run_native_tunnel(
|
||||||
let (mut tun_read, mut tun_write) = tokio::io::split(dev);
|
let (mut tun_read, mut tun_write) = tokio::io::split(dev);
|
||||||
|
|
||||||
let mut tun_to_stack = tokio::spawn(async move {
|
let mut tun_to_stack = tokio::spawn(async move {
|
||||||
// Reassemble IPv4 fragments before the netstack sees them: smoltcp drops
|
|
||||||
// UDP fragments with a wire::Error, which silently kills any >MTU UDP
|
|
||||||
// datagram (game traffic in particular). See ip_reasm for the details.
|
|
||||||
let mut reasm = super::ip_reasm::Reassembler::new();
|
|
||||||
let mut buf = vec![0u8; 65536];
|
let mut buf = vec![0u8; 65536];
|
||||||
loop {
|
loop {
|
||||||
match tun_read.read(&mut buf).await {
|
match tun_read.read(&mut buf).await {
|
||||||
Ok(0) => break,
|
Ok(0) => break,
|
||||||
Ok(n) => {
|
Ok(n) => {
|
||||||
let Some(frame) = reasm.process(&buf[..n]) else {
|
let frame = buf[..n].to_vec();
|
||||||
continue; // fragment buffered; nothing to forward yet
|
|
||||||
};
|
|
||||||
if let Err(e) = stack_sink.send(frame).await {
|
if let Err(e) = stack_sink.send(frame).await {
|
||||||
if e.kind() == std::io::ErrorKind::BrokenPipe {
|
if e.kind() == std::io::ErrorKind::BrokenPipe {
|
||||||
break;
|
break;
|
||||||
|
|
@ -477,9 +471,6 @@ pub async fn run_native_tunnel_from_fd(
|
||||||
let (mut stack_sink, mut stack_stream) = stack.split();
|
let (mut stack_sink, mut stack_stream) = stack.split();
|
||||||
|
|
||||||
let _tun_to_stack = tokio::spawn(async move {
|
let _tun_to_stack = tokio::spawn(async move {
|
||||||
// See the Windows path above: reassemble IPv4 fragments so smoltcp does
|
|
||||||
// not drop >MTU UDP datagrams.
|
|
||||||
let mut reasm = super::ip_reasm::Reassembler::new();
|
|
||||||
let mut buf = vec![0u8; 65536];
|
let mut buf = vec![0u8; 65536];
|
||||||
loop {
|
loop {
|
||||||
let mut guard = match tun_stream.readable().await {
|
let mut guard = match tun_stream.readable().await {
|
||||||
|
|
@ -511,9 +502,7 @@ pub async fn run_native_tunnel_from_fd(
|
||||||
Err(_) => continue,
|
Err(_) => continue,
|
||||||
};
|
};
|
||||||
|
|
||||||
let Some(frame) = reasm.process(&buf[..n]) else {
|
let frame = buf[..n].to_vec();
|
||||||
continue; // fragment buffered; nothing to forward yet
|
|
||||||
};
|
|
||||||
if let Err(e) = stack_sink.send(frame).await {
|
if let Err(e) = stack_sink.send(frame).await {
|
||||||
if e.kind() == std::io::ErrorKind::BrokenPipe {
|
if e.kind() == std::io::ErrorKind::BrokenPipe {
|
||||||
break;
|
break;
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
# In Windows, build-name is used as the major, minor, and patch parts
|
# In Windows, build-name is used as the major, minor, and patch parts
|
||||||
# of the product and file versions while build-number is used as the build suffix.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 0.4.5+39
|
version: 0.4.4+31
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.11.4
|
sdk: ^3.11.4
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,14 @@
|
||||||
{
|
{
|
||||||
"name": "ostp-gui",
|
"name": "ostp-gui",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.4.5",
|
"version": "0.4.4",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"tauri": "tauri",
|
"tauri": "tauri",
|
||||||
"dev": "cargo build -p ostp-tun-helper && npx tauri dev",
|
"dev": "cargo build -p ostp-tun-helper && npx tauri dev",
|
||||||
"build": "cargo build -p ostp-tun-helper --release && npx tauri build --no-bundle",
|
"build": "cargo build -p ostp-tun-helper --release && npx tauri build --no-bundle",
|
||||||
"build:installer": "cargo build -p ostp-tun-helper --release && node stage-sidecar.cjs --release && npx tauri build --bundles nsis --config src-tauri/tauri.installer.conf.json",
|
"build:installer": "cargo build -p ostp-tun-helper --release && npx tauri build",
|
||||||
"build:dist": "npm run build && node build_dist.js",
|
"build:dist": "npm run build && node build_dist.js"
|
||||||
"sidecar": "node stage-sidecar.cjs"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2"
|
"@tauri-apps/cli": "^2"
|
||||||
|
|
|
||||||
|
|
@ -2665,7 +2665,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostp-client"
|
name = "ostp-client"
|
||||||
version = "0.4.5"
|
version = "0.4.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
|
|
@ -2696,7 +2696,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostp-core"
|
name = "ostp-core"
|
||||||
version = "0.4.5"
|
version = "0.4.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
|
@ -2713,7 +2713,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostp-gui"
|
name = "ostp-gui"
|
||||||
version = "0.4.5"
|
version = "0.4.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"json_comments",
|
"json_comments",
|
||||||
|
|
@ -2733,7 +2733,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostp-tun"
|
name = "ostp-tun"
|
||||||
version = "0.4.5"
|
version = "0.4.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"libc",
|
"libc",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "ostp-gui"
|
name = "ostp-gui"
|
||||||
version = "0.4.5"
|
version = "0.4.4"
|
||||||
description = "OSTP desktop GUI"
|
description = "OSTP desktop GUI"
|
||||||
authors = ["ospab"]
|
authors = ["ospab"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
|
||||||
|
|
@ -61,9 +61,6 @@ struct TransportConfigRaw {
|
||||||
frag_sleep: Option<u64>,
|
frag_sleep: Option<u64>,
|
||||||
junk_pc: Option<[usize; 2]>,
|
junk_pc: Option<[usize; 2]>,
|
||||||
junk_ps: Option<[usize; 2]>,
|
junk_ps: Option<[usize; 2]>,
|
||||||
ttl_desync: Option<bool>,
|
|
||||||
ttl_desync_ttl: Option<u8>,
|
|
||||||
ttl_desync_count: Option<u8>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||||
|
|
@ -137,84 +134,16 @@ struct AppState(Mutex<AppStateInner>);
|
||||||
|
|
||||||
// ── Config helpers ────────────────────────────────────────────────────────────
|
// ── Config helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Per-user config location, used whenever the config cannot live next to the
|
|
||||||
/// executable.
|
|
||||||
fn user_config_path() -> PathBuf {
|
|
||||||
let base = std::env::var_os(if cfg!(windows) { "APPDATA" } else { "HOME" })
|
|
||||||
.map(PathBuf::from)
|
|
||||||
.unwrap_or_else(std::env::temp_dir);
|
|
||||||
let dir = if cfg!(windows) { base.join("OSTP") } else { base.join(".config").join("ostp") };
|
|
||||||
dir.join("config.json")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Where the GUI reads and writes its configuration.
|
|
||||||
///
|
|
||||||
/// Portable installs keep the config beside the executable, which is what the
|
|
||||||
/// zip has always done, and that is preserved wherever the directory is
|
|
||||||
/// actually writable.
|
|
||||||
///
|
|
||||||
/// What it must never do again is fall back to a bare relative `config.json`.
|
|
||||||
/// That resolves against the process working directory, which for a Start Menu
|
|
||||||
/// shortcut is whatever Windows chose — often `C:\Windows\System32`. Reading
|
|
||||||
/// and saving settings then failed with "Access is denied" (os error 5), and on
|
|
||||||
/// a writable working directory it would have been worse still: settings would
|
|
||||||
/// silently persist somewhere unrelated and appear to vanish.
|
|
||||||
///
|
|
||||||
/// Writability is measured rather than inferred from the install location. An
|
|
||||||
/// installer can put the app anywhere — a per-machine install onto a data drive
|
|
||||||
/// may well be writable, while Program Files is not — so the location alone
|
|
||||||
/// says nothing.
|
|
||||||
fn get_config_path() -> PathBuf {
|
fn get_config_path() -> PathBuf {
|
||||||
if let Ok(exe_path) = std::env::current_exe() {
|
if let Ok(exe_path) = std::env::current_exe() {
|
||||||
if let Some(parent) = exe_path.parent() {
|
if let Some(parent) = exe_path.parent() {
|
||||||
let portable = parent.join("config.json");
|
let path = parent.join("config.json");
|
||||||
if portable.exists() {
|
if path.exists() {
|
||||||
if is_file_writable(&portable) {
|
return path;
|
||||||
return portable;
|
|
||||||
}
|
|
||||||
// Read-only beside the exe: unusable as the live file, but its
|
|
||||||
// contents are still worth carrying over once.
|
|
||||||
let user = user_config_path();
|
|
||||||
if !user.exists() {
|
|
||||||
if let Some(dir) = user.parent() {
|
|
||||||
let _ = std::fs::create_dir_all(dir);
|
|
||||||
}
|
|
||||||
let _ = std::fs::copy(&portable, &user);
|
|
||||||
}
|
|
||||||
} else if is_dir_writable(parent) {
|
|
||||||
// No config yet and the directory takes writes: a portable
|
|
||||||
// unzip, so keep the config travelling with the folder.
|
|
||||||
return portable;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
PathBuf::from("config.json")
|
||||||
let path = user_config_path();
|
|
||||||
if let Some(dir) = path.parent() {
|
|
||||||
let _ = std::fs::create_dir_all(dir);
|
|
||||||
}
|
|
||||||
path
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether an existing file can actually be written to.
|
|
||||||
///
|
|
||||||
/// Answered by opening it, not by reading permission bits: on Windows the
|
|
||||||
/// effective answer depends on the ACL and on virtualization, and `readonly()`
|
|
||||||
/// reflects neither.
|
|
||||||
fn is_file_writable(path: &std::path::Path) -> bool {
|
|
||||||
std::fs::OpenOptions::new().append(true).open(path).is_ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether new files can be created in a directory, tested by doing it.
|
|
||||||
fn is_dir_writable(dir: &std::path::Path) -> bool {
|
|
||||||
let probe = dir.join(format!(".ostp-write-test-{}", std::process::id()));
|
|
||||||
match std::fs::File::create(&probe) {
|
|
||||||
Ok(_) => {
|
|
||||||
let _ = std::fs::remove_file(&probe);
|
|
||||||
true
|
|
||||||
}
|
|
||||||
Err(_) => false,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn map_to_client_config(raw: &ClientConfigRaw, mode: &str) -> ostp_client::config::ClientConfig {
|
fn map_to_client_config(raw: &ClientConfigRaw, mode: &str) -> ostp_client::config::ClientConfig {
|
||||||
|
|
@ -242,9 +171,6 @@ fn map_to_client_config(raw: &ClientConfigRaw, mode: &str) -> ostp_client::confi
|
||||||
frag_sleep: raw.transport.as_ref().and_then(|t| t.frag_sleep).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_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]),
|
junk_ps: raw.transport.as_ref().and_then(|t| t.junk_ps).unwrap_or([100, 1000]),
|
||||||
ttl_desync: raw.transport.as_ref().and_then(|t| t.ttl_desync).unwrap_or(false),
|
|
||||||
ttl_desync_ttl: raw.transport.as_ref().and_then(|t| t.ttl_desync_ttl).unwrap_or(8),
|
|
||||||
ttl_desync_count: raw.transport.as_ref().and_then(|t| t.ttl_desync_count).unwrap_or(2),
|
|
||||||
},
|
},
|
||||||
exclusions: ostp_client::config::ExclusionConfig {
|
exclusions: ostp_client::config::ExclusionConfig {
|
||||||
domains: raw.exclude.as_ref().and_then(|e| e.domains.clone()).unwrap_or_default(),
|
domains: raw.exclude.as_ref().and_then(|e| e.domains.clone()).unwrap_or_default(),
|
||||||
|
|
@ -910,8 +836,21 @@ fn helper_args_file() -> PathBuf {
|
||||||
base.join("OSTP").join("helper-args.json")
|
base.join("OSTP").join("helper-args.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Undoes XML entity escaping. `&` must be handled last, or `&lt;`
|
/// Minimal XML text escaping for the values interpolated into the task
|
||||||
/// would come back as `<`.
|
/// definition. Paths and usernames are attacker-irrelevant here but can easily
|
||||||
|
/// contain `&`, which would otherwise produce invalid XML and a confusing
|
||||||
|
/// schtasks parse failure.
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn xml_escape(s: &str) -> String {
|
||||||
|
s.replace('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
.replace('"', """)
|
||||||
|
.replace('\'', "'")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reverse of [`xml_escape`]. `&` must be undone last or `&lt;` would
|
||||||
|
/// come back as `<`.
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
fn xml_unescape(s: &str) -> String {
|
fn xml_unescape(s: &str) -> String {
|
||||||
s.replace(""", "\"")
|
s.replace(""", "\"")
|
||||||
|
|
@ -925,12 +864,8 @@ fn xml_unescape(s: &str) -> String {
|
||||||
///
|
///
|
||||||
/// Queried as XML rather than `/FO LIST /V`: the list format's field labels are
|
/// Queried as XML rather than `/FO LIST /V`: the list format's field labels are
|
||||||
/// localized (on a Russian Windows "Task To Run" is "Задача для запуска"),
|
/// localized (on a Russian Windows "Task To Run" is "Задача для запуска"),
|
||||||
/// whereas XML tag names are fixed.
|
/// whereas XML tag names are fixed. schtasks writes UTF-16LE with a BOM here,
|
||||||
///
|
/// but tolerate UTF-8 in case that ever changes.
|
||||||
/// Encoding depends on where the output goes, which is measured rather than
|
|
||||||
/// assumed: to a console schtasks writes UTF-16LE with a BOM, but into a
|
|
||||||
/// redirected pipe — our case — it writes UTF-8 with no BOM. Both are handled,
|
|
||||||
/// keyed off the BOM, so this keeps working if that ever flips.
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
fn helper_task_command() -> Option<String> {
|
fn helper_task_command() -> Option<String> {
|
||||||
let out = quiet_command("schtasks")
|
let out = quiet_command("schtasks")
|
||||||
|
|
@ -968,48 +903,140 @@ fn helper_task_command() -> Option<String> {
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
fn helper_task_matches(exe: &std::path::Path) -> bool {
|
fn helper_task_matches(exe: &std::path::Path) -> bool {
|
||||||
let Some(registered) = helper_task_command() else {
|
let Some(registered) = helper_task_command() else {
|
||||||
diag_log("task: schtasks /Query returned nothing usable — no task, or its XML had no <Command>");
|
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
let registered = registered.trim().trim_matches('"');
|
let registered = registered.trim().trim_matches('"');
|
||||||
let path = std::path::Path::new(registered);
|
|
||||||
|
|
||||||
// Requiring the registered path to equal the helper we would have launched
|
// Canonicalize both sides when possible so `..`, short 8.3 names and
|
||||||
// was too strict, and bought nothing. What the check exists to catch is a
|
// casing differences do not read as a mismatch. A missing file cannot be
|
||||||
// task left pointing at a binary that is gone — `schtasks /Run` reports
|
// canonicalized — which is itself a mismatch worth re-registering over.
|
||||||
// success merely for accepting such a request, so the app would then wait
|
match (
|
||||||
// on a helper that never starts. Testing that the file exists catches
|
std::fs::canonicalize(registered),
|
||||||
// exactly that, while a task registered by the installer against an
|
std::fs::canonicalize(exe),
|
||||||
// equivalent copy of the helper no longer costs the user a prompt.
|
) {
|
||||||
let same_program = path
|
(Ok(a), Ok(b)) => a == b,
|
||||||
.file_name()
|
_ => registered.eq_ignore_ascii_case(&exe.display().to_string()),
|
||||||
.map(|n| n.eq_ignore_ascii_case(HELPER_EXE_NAME))
|
}
|
||||||
.unwrap_or(false);
|
|
||||||
let exists = path.is_file();
|
|
||||||
|
|
||||||
diag_log(&format!(
|
|
||||||
"task: registered={registered:?} exists={exists} same_program={same_program} wanted={:?}",
|
|
||||||
exe.display().to_string()
|
|
||||||
));
|
|
||||||
exists && same_program
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Appends a line to a small log beside the helper's argument file.
|
/// Register the Scheduled Task. This is the ONLY step that needs elevation, and
|
||||||
|
/// it happens once per machine; every later tunnel start reuses the task.
|
||||||
///
|
///
|
||||||
/// The GUI is a windowed binary with no console, so every `eprintln!` on this
|
/// RunLevel=HIGHEST makes the task run elevated, and because a task launch is
|
||||||
/// path went nowhere — which left the one decision that matters, whether the
|
/// not an elevation request, Windows shows no consent dialog for it.
|
||||||
/// scheduled task gets used or the user gets a consent prompt, completely
|
|
||||||
/// unobservable from a user's machine.
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
fn diag_log(msg: &str) {
|
fn install_helper_task(exe: &std::path::Path) -> anyhow::Result<()> {
|
||||||
let path = helper_args_file().with_file_name("helper-launch.log");
|
let args_file = helper_args_file();
|
||||||
if let Some(dir) = path.parent() {
|
if let Some(dir) = args_file.parent() {
|
||||||
let _ = std::fs::create_dir_all(dir);
|
std::fs::create_dir_all(dir)?;
|
||||||
}
|
}
|
||||||
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&path) {
|
|
||||||
use std::io::Write;
|
// Register from an XML definition rather than /TR. The command line would
|
||||||
let _ = writeln!(f, "{msg}");
|
// otherwise need the exe path and the args path quoted INSIDE an already
|
||||||
|
// quoted /TR value, escaped again through ShellExecuteW — a notoriously
|
||||||
|
// brittle chain when either path contains a space, which both of these do
|
||||||
|
// by default (Program Files, and usernames with spaces). XML also lets the
|
||||||
|
// battery and time-limit settings below be stated explicitly.
|
||||||
|
let user = format!(
|
||||||
|
"{}\\{}",
|
||||||
|
std::env::var("USERDOMAIN").unwrap_or_else(|_| "%COMPUTERNAME%".into()),
|
||||||
|
std::env::var("USERNAME").unwrap_or_default()
|
||||||
|
);
|
||||||
|
let xml = format!(
|
||||||
|
r#"<?xml version="1.0" encoding="UTF-16"?>
|
||||||
|
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
||||||
|
<RegistrationInfo>
|
||||||
|
<Description>Runs the OSTP TUN helper elevated so enabling the tunnel does not prompt for consent every time.</Description>
|
||||||
|
</RegistrationInfo>
|
||||||
|
<Principals>
|
||||||
|
<Principal id="Author">
|
||||||
|
<UserId>{user}</UserId>
|
||||||
|
<LogonType>InteractiveToken</LogonType>
|
||||||
|
<RunLevel>HighestAvailable</RunLevel>
|
||||||
|
</Principal>
|
||||||
|
</Principals>
|
||||||
|
<Settings>
|
||||||
|
<MultipleInstancesPolicy>Parallel</MultipleInstancesPolicy>
|
||||||
|
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
|
||||||
|
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
||||||
|
<StartWhenAvailable>false</StartWhenAvailable>
|
||||||
|
<RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
|
||||||
|
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
|
||||||
|
<Enabled>true</Enabled>
|
||||||
|
<Hidden>false</Hidden>
|
||||||
|
<AllowHardTerminate>true</AllowHardTerminate>
|
||||||
|
</Settings>
|
||||||
|
<Actions Context="Author">
|
||||||
|
<Exec>
|
||||||
|
<Command>{exe}</Command>
|
||||||
|
<Arguments>--args-file "{args}"</Arguments>
|
||||||
|
</Exec>
|
||||||
|
</Actions>
|
||||||
|
</Task>
|
||||||
|
"#,
|
||||||
|
user = xml_escape(&user),
|
||||||
|
exe = xml_escape(&exe.display().to_string()),
|
||||||
|
args = xml_escape(&args_file.display().to_string()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// schtasks /Create /XML expects UTF-16LE with a BOM.
|
||||||
|
let xml_path = std::env::temp_dir().join(format!("ostp_task_{}.xml", rand::random::<u32>()));
|
||||||
|
let mut utf16: Vec<u8> = vec![0xFF, 0xFE];
|
||||||
|
for unit in xml.encode_utf16() {
|
||||||
|
utf16.extend_from_slice(&unit.to_le_bytes());
|
||||||
}
|
}
|
||||||
|
std::fs::write(&xml_path, &utf16)?;
|
||||||
|
|
||||||
|
// Registering a HighestAvailable task is itself privileged: this is the one
|
||||||
|
// prompt, and it happens once per machine.
|
||||||
|
//
|
||||||
|
// Elevate through PowerShell's Start-Process -Wait rather than
|
||||||
|
// ShellExecuteW. ShellExecuteW returns as soon as the elevated process is
|
||||||
|
// LAUNCHED, so the XML below was being deleted while schtasks was still
|
||||||
|
// starting up — registration then failed, leaving the user with a consent
|
||||||
|
// prompt that accomplished nothing, followed by a second prompt from the
|
||||||
|
// fallback path. -Wait makes the deletion safe and lets the exit code be
|
||||||
|
// checked instead of guessed at by polling.
|
||||||
|
//
|
||||||
|
// ArgumentList takes an array, so the task name and XML path never need
|
||||||
|
// quoting or escaping through a command line, only PowerShell's own
|
||||||
|
// single-quote doubling.
|
||||||
|
let ps = format!(
|
||||||
|
"$p = Start-Process -FilePath 'schtasks.exe' -Verb RunAs -Wait -PassThru \
|
||||||
|
-WindowStyle Hidden -ArgumentList @('/Create','/TN','{}','/XML','{}','/F'); \
|
||||||
|
exit $p.ExitCode",
|
||||||
|
ps_quote(HELPER_TASK_NAME),
|
||||||
|
ps_quote(&xml_path.display().to_string()),
|
||||||
|
);
|
||||||
|
|
||||||
|
let status = quiet_command("powershell")
|
||||||
|
.args(["-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-Command", &ps])
|
||||||
|
.status();
|
||||||
|
|
||||||
|
// schtasks has exited by now, so this is safe.
|
||||||
|
let _ = std::fs::remove_file(&xml_path);
|
||||||
|
|
||||||
|
match status {
|
||||||
|
Ok(s) if s.success() => {}
|
||||||
|
Ok(s) => anyhow::bail!(
|
||||||
|
"registering the scheduled task failed (exit code {:?}). A declined consent prompt \
|
||||||
|
reports 1223.",
|
||||||
|
s.code()
|
||||||
|
),
|
||||||
|
Err(e) => anyhow::bail!("could not run powershell to register the task: {e}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if helper_task_matches(exe) {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
anyhow::bail!("schtasks reported success but the task does not point at {}", exe.display())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Escape a value for embedding in a PowerShell single-quoted string.
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn ps_quote(s: &str) -> String {
|
||||||
|
s.replace('\'', "''")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
|
|
@ -1027,35 +1054,28 @@ fn launch_as_admin(exe: &std::path::PathBuf, token: &str, port: u16) -> anyhow::
|
||||||
let wrote_args = std::fs::write(&args_file, payload.to_string()).is_ok();
|
let wrote_args = std::fs::write(&args_file, payload.to_string()).is_ok();
|
||||||
|
|
||||||
if wrote_args {
|
if wrote_args {
|
||||||
// Deliberately does NOT create the task when it is missing. Registering
|
if !helper_task_matches(exe) {
|
||||||
// one is privileged, so the app could only do it by raising the very
|
if let Err(e) = install_helper_task(exe) {
|
||||||
// prompt this exists to avoid — and it would then charge the user two
|
eprintln!("[OSTP] could not register the helper task ({e}); falling back to a direct elevated launch");
|
||||||
// prompts for the privilege. Creating it belongs to the installer,
|
}
|
||||||
// which is already elevated. Without it we simply fall through to the
|
}
|
||||||
// direct elevated launch, which prompts once per connect as before.
|
|
||||||
if helper_task_matches(exe) {
|
if helper_task_matches(exe) {
|
||||||
let run = quiet_command("schtasks")
|
let run = quiet_command("schtasks")
|
||||||
.args(["/Run", "/TN", HELPER_TASK_NAME])
|
.args(["/Run", "/TN", HELPER_TASK_NAME])
|
||||||
.output();
|
.output();
|
||||||
match run {
|
match run {
|
||||||
Ok(o) if o.status.success() => {
|
Ok(o) if o.status.success() => return Ok(()),
|
||||||
diag_log("run: schtasks /Run accepted — no consent prompt");
|
Ok(o) => eprintln!(
|
||||||
return Ok(());
|
"[OSTP] schtasks /Run failed: {}",
|
||||||
}
|
|
||||||
Ok(o) => diag_log(&format!(
|
|
||||||
"run: schtasks /Run failed ({:?}): {} {}",
|
|
||||||
o.status.code(),
|
|
||||||
String::from_utf8_lossy(&o.stdout).trim(),
|
|
||||||
String::from_utf8_lossy(&o.stderr).trim()
|
String::from_utf8_lossy(&o.stderr).trim()
|
||||||
)),
|
),
|
||||||
Err(e) => diag_log(&format!("run: schtasks /Run could not start: {e}")),
|
Err(e) => eprintln!("[OSTP] schtasks /Run could not start: {e}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Falling through: remove the file so a stale token is not left behind.
|
// Falling through: remove the file so a stale token is not left behind.
|
||||||
let _ = std::fs::remove_file(&args_file);
|
let _ = std::fs::remove_file(&args_file);
|
||||||
}
|
}
|
||||||
|
|
||||||
diag_log("falling back to a direct elevated launch — this is the consent prompt");
|
|
||||||
launch_as_admin_direct(exe, token, port)
|
launch_as_admin_direct(exe, token, port)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "ostp-gui",
|
"productName": "ostp-gui",
|
||||||
"version": "0.4.5",
|
"version": "0.4.4",
|
||||||
"identifier": "com.ospab.ostp",
|
"identifier": "com.ospab.ostp",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../src"
|
"frontendDist": "../src"
|
||||||
|
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
{
|
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
|
||||||
"bundle": {
|
|
||||||
"externalBin": ["binaries/ostp-tun-helper"],
|
|
||||||
"resources": { "binaries/wintun.dll": "wintun.dll" },
|
|
||||||
"windows": {
|
|
||||||
"nsis": {
|
|
||||||
"installMode": "perMachine",
|
|
||||||
"installerHooks": "./windows/hooks.nsh"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,93 +0,0 @@
|
||||||
; Registers the Scheduled Task that lets the GUI start the TUN helper elevated
|
|
||||||
; without a consent prompt.
|
|
||||||
;
|
|
||||||
; This belongs in the installer, not in the app. Registering a task that runs
|
|
||||||
; elevated is itself a privileged operation, so an unprivileged GUI could only
|
|
||||||
; obtain one by raising the very prompt we are trying to remove. The installer
|
|
||||||
; already runs elevated (installMode is perMachine), so here it costs nothing:
|
|
||||||
; the user consents once, to the install, and never again per connect.
|
|
||||||
;
|
|
||||||
; The task carries no trigger at all — it exists solely to be started on demand.
|
|
||||||
|
|
||||||
!macro NSIS_HOOK_POSTINSTALL
|
|
||||||
; Bundled resources land in $INSTDIR\resources, but the helper loads wintun
|
|
||||||
; with a plain LoadLibrary, which searches its own directory — so put a copy
|
|
||||||
; beside the executables. The destination is the directory, not a file path:
|
|
||||||
; CopyFiles takes a target directory, and naming the file made it fail.
|
|
||||||
${If} ${FileExists} "$INSTDIR\resources\wintun.dll"
|
|
||||||
DetailPrint "Placing wintun.dll next to the helper..."
|
|
||||||
CopyFiles /SILENT "$INSTDIR\resources\wintun.dll" "$INSTDIR"
|
|
||||||
${Else}
|
|
||||||
DetailPrint "WARNING: resources\wintun.dll is missing; TUN mode will not start."
|
|
||||||
${EndIf}
|
|
||||||
|
|
||||||
; Registered through PowerShell's ScheduledTasks module rather than
|
|
||||||
; `schtasks /XML`. Generating the XML from NSIS wrote a UTF-16 byte-order mark
|
|
||||||
; ahead of content whose encoding depended on whether makensis was built in
|
|
||||||
; Unicode mode, and schtasks rejected the result outright:
|
|
||||||
; "The task XML is malformed. (1,2)::ERROR: incorrect document syntax"
|
|
||||||
; The cmdlets take the same settings as arguments, so no file is written and
|
|
||||||
; there is no encoding to get wrong.
|
|
||||||
;
|
|
||||||
; The command is delimited with backticks, NSIS's third quote character, so
|
|
||||||
; that PowerShell's own single quotes and the shell's double quotes can both
|
|
||||||
; appear literally — inside a single-quoted NSIS string the first PowerShell
|
|
||||||
; quote would have terminated the argument early.
|
|
||||||
;
|
|
||||||
; $$ is an escaped literal dollar for PowerShell's variables; a bare $ would
|
|
||||||
; be read by NSIS as one of its own. The helper argument is assembled with
|
|
||||||
; [char]34 instead of nested quotes so that a username containing a space
|
|
||||||
; still yields a correctly quoted path, without three levels of escaping.
|
|
||||||
;
|
|
||||||
; The principal is the SID S-1-5-32-545 (BUILTIN\Users) rather than the
|
|
||||||
; installing user, so a per-machine install serves every account instead of
|
|
||||||
; only whoever ran the installer. The SID is used because the name is
|
|
||||||
; localized and would not resolve. %LOCALAPPDATA% is likewise left unexpanded
|
|
||||||
; for Task Scheduler to resolve per running user.
|
|
||||||
DetailPrint "Registering the OSTP TUN helper task..."
|
|
||||||
nsExec::ExecToLog `powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "$$act = New-ScheduledTaskAction -Execute '$INSTDIR\ostp-tun-helper.exe' -Argument ('--args-file ' + [char]34 + '%LOCALAPPDATA%\OSTP\helper-args.json' + [char]34); $$prn = New-ScheduledTaskPrincipal -GroupId 'S-1-5-32-545' -RunLevel Highest; $$set = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit ([TimeSpan]::Zero) -MultipleInstances Parallel; Register-ScheduledTask -TaskName 'OSTP TUN Helper' -Action $$act -Principal $$prn -Settings $$set -Force | Out-Null"`
|
|
||||||
Pop $R0
|
|
||||||
|
|
||||||
${If} $R0 == 0
|
|
||||||
; Registering the task is not enough to make it usable. The principal above
|
|
||||||
; decides WHO THE TASK RUNS AS; the task's security descriptor decides who
|
|
||||||
; is allowed to START it, and they are not the same thing. A task created by
|
|
||||||
; an elevated installer defaults to a DACL granting execution to
|
|
||||||
; Administrators only, so the unprivileged GUI got
|
|
||||||
; schtasks /Run -> ERROR: Access is denied
|
|
||||||
; and fell back to prompting on every single connect. Running it by hand
|
|
||||||
; from an elevated console worked, which is what made this look for a while
|
|
||||||
; like the app was at fault.
|
|
||||||
;
|
|
||||||
; Register-ScheduledTask cannot set a descriptor, so this goes through the
|
|
||||||
; Task Scheduler COM object. GA for Administrators and SYSTEM, GR+GX —
|
|
||||||
; read and execute — for BUILTIN\Users (BU), which is what lets a normal
|
|
||||||
; user start it without being elevated.
|
|
||||||
DetailPrint "Granting users permission to start the task..."
|
|
||||||
nsExec::ExecToLog `powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "$$svc = New-Object -ComObject Schedule.Service; $$svc.Connect(); $$t = $$svc.GetFolder('\').GetTask('OSTP TUN Helper'); $$t.SetSecurityDescriptor('D:(A;;GA;;;BA)(A;;GA;;;SY)(A;;GRGX;;;BU)', 0)"`
|
|
||||||
Pop $R1
|
|
||||||
${If} $R1 == 0
|
|
||||||
DetailPrint "Helper task registered; connecting will not ask for consent."
|
|
||||||
${Else}
|
|
||||||
DetailPrint "Task registered but its permissions could not be set (exit $R1)."
|
|
||||||
DetailPrint "Every connect will ask for consent."
|
|
||||||
${EndIf}
|
|
||||||
${Else}
|
|
||||||
; Not fatal: the app still works, it just falls back to an elevated launch
|
|
||||||
; that asks for consent on each connect.
|
|
||||||
DetailPrint "Could not register the helper task (exit $R0)."
|
|
||||||
DetailPrint "OSTP will still work, but every connect will ask for consent."
|
|
||||||
${EndIf}
|
|
||||||
!macroend
|
|
||||||
|
|
||||||
!macro NSIS_HOOK_PREUNINSTALL
|
|
||||||
; Leaving the task behind would point it at a deleted executable, and
|
|
||||||
; `schtasks /Run` reports success for merely accepting such a request — the
|
|
||||||
; app would wait on a helper that never starts.
|
|
||||||
DetailPrint "Removing the OSTP TUN helper task..."
|
|
||||||
nsExec::ExecToLog 'schtasks.exe /Delete /TN "OSTP TUN Helper" /F'
|
|
||||||
Pop $R0
|
|
||||||
|
|
||||||
; Copied by the install hook, so the uninstaller has no record of it.
|
|
||||||
Delete "$INSTDIR\wintun.dll"
|
|
||||||
!macroend
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
// Stages ostp-tun-helper where Tauri expects a sidecar.
|
|
||||||
//
|
|
||||||
// tauri.installer.conf.json declares `externalBin: ["binaries/ostp-tun-helper"]`,
|
|
||||||
// and Tauri resolves that to `binaries/ostp-tun-helper-<target-triple>.exe` at
|
|
||||||
// build time, failing the build outright when the file is absent. Cargo writes
|
|
||||||
// the plain name instead, so it has to be copied across first.
|
|
||||||
//
|
|
||||||
// Only the installer build needs this. That config is passed explicitly with
|
|
||||||
// --config rather than being named tauri.windows.conf.json, which Tauri would
|
|
||||||
// merge into every Windows build automatically — and then even a bare
|
|
||||||
// `cargo check` would fail on the missing sidecar.
|
|
||||||
//
|
|
||||||
// A no-op off Windows: the Linux and macOS GUI builds have no helper sidecar.
|
|
||||||
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const { execFileSync } = require('child_process');
|
|
||||||
|
|
||||||
if (process.platform !== 'win32') {
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --target may be passed through; fall back to the host triple rustc reports.
|
|
||||||
const targetFlag = process.argv.indexOf('--target');
|
|
||||||
const triple =
|
|
||||||
targetFlag !== -1 && process.argv[targetFlag + 1]
|
|
||||||
? process.argv[targetFlag + 1]
|
|
||||||
: execFileSync('rustc', ['-vV'], { encoding: 'utf8' })
|
|
||||||
.split('\n')
|
|
||||||
.find((l) => l.startsWith('host:'))
|
|
||||||
.slice('host:'.length)
|
|
||||||
.trim();
|
|
||||||
|
|
||||||
const profile = process.argv.includes('--release') ? 'release' : 'debug';
|
|
||||||
const repoRoot = path.resolve(__dirname, '..');
|
|
||||||
|
|
||||||
// Cargo drops a --target build under target/<triple>/, and a host build
|
|
||||||
// straight into target/. CI always passes --target; local builds usually do not.
|
|
||||||
const candidates = [
|
|
||||||
path.join(repoRoot, 'target', triple, profile, 'ostp-tun-helper.exe'),
|
|
||||||
path.join(repoRoot, 'target', profile, 'ostp-tun-helper.exe'),
|
|
||||||
];
|
|
||||||
const src = candidates.find((p) => fs.existsSync(p));
|
|
||||||
if (!src) {
|
|
||||||
console.error(
|
|
||||||
'stage-sidecar: ostp-tun-helper.exe not found. Looked in:\n ' +
|
|
||||||
candidates.join('\n ') +
|
|
||||||
`\nBuild it first: cargo build -p ostp-tun-helper${profile === 'release' ? ' --release' : ''}`
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const destDir = path.join(__dirname, 'src-tauri', 'binaries');
|
|
||||||
fs.mkdirSync(destDir, { recursive: true });
|
|
||||||
const dest = path.join(destDir, `ostp-tun-helper-${triple}.exe`);
|
|
||||||
fs.copyFileSync(src, dest);
|
|
||||||
console.log(`stage-sidecar: ${path.relative(repoRoot, src)} -> ${path.relative(repoRoot, dest)}`);
|
|
||||||
|
|
||||||
// wintun.dll rides along as a bundled resource. It is only fetched by the
|
|
||||||
// release workflow, so a local build without it should warn rather than fail —
|
|
||||||
// the installer just ends up unable to bring a tunnel up.
|
|
||||||
const dllSrc = [
|
|
||||||
path.join(repoRoot, 'target', triple, profile, 'wintun.dll'),
|
|
||||||
path.join(repoRoot, 'target', profile, 'wintun.dll'),
|
|
||||||
].find((p) => fs.existsSync(p));
|
|
||||||
if (dllSrc) {
|
|
||||||
fs.copyFileSync(dllSrc, path.join(destDir, 'wintun.dll'));
|
|
||||||
console.log(`stage-sidecar: ${path.relative(repoRoot, dllSrc)} -> binaries/wintun.dll`);
|
|
||||||
} else if (fs.existsSync(path.join(destDir, 'wintun.dll'))) {
|
|
||||||
console.log('stage-sidecar: reusing the previously staged binaries/wintun.dll');
|
|
||||||
} else {
|
|
||||||
console.warn('stage-sidecar: WARNING wintun.dll not found; a bundle build will fail on the missing resource');
|
|
||||||
}
|
|
||||||
|
|
@ -99,34 +99,18 @@ pub async fn create(opts: OstpTunOptions) -> Result<OstpTunInterface> {
|
||||||
|
|
||||||
let current_exe = std::env::current_exe()?.to_string_lossy().into_owned();
|
let current_exe = std::env::current_exe()?.to_string_lossy().into_owned();
|
||||||
|
|
||||||
// Take the interface index straight from the adapter WinTun just created,
|
// A freshly created WinTun adapter can take several seconds to appear in
|
||||||
// via the tun crate. The old code looked it up by FriendlyName == "ostp_tun"
|
// GetAdaptersAddresses (it only shows up once it has an operational IPv4
|
||||||
// through GetAdaptersAddresses — but WinTun does NOT set the FriendlyName to
|
// binding). The default route via the TUN is what actually captures
|
||||||
// the adapter name, so that match never succeeded: on every single connect
|
// traffic, so this lookup is critical — give it a generous window (~15s).
|
||||||
// it spun the full 15s and then gave up with "traffic will NOT be captured",
|
let mut tun_index = None;
|
||||||
// leaving the default route (and, above, the server-IP bypass) uninstalled.
|
|
||||||
// get_adapter_index() is instant and correct.
|
|
||||||
use tun::AbstractDevice;
|
|
||||||
let tun_index = match dev.tun_index() {
|
|
||||||
Ok(idx) if idx > 0 => Some(idx as u32),
|
|
||||||
Ok(idx) => {
|
|
||||||
tracing::error!("WinTun reported a non-positive interface index ({idx})");
|
|
||||||
None
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
// Fall back to the old name lookup rather than fail outright.
|
|
||||||
tracing::warn!("Could not read TUN index from the adapter ({e}); falling back to name lookup");
|
|
||||||
let mut idx = None;
|
|
||||||
for _ in 0..75 {
|
for _ in 0..75 {
|
||||||
if let Some(i) = windows_route::sys::get_interface_index("ostp_tun") {
|
if let Some(idx) = windows_route::sys::get_interface_index("ostp_tun") {
|
||||||
idx = Some(i);
|
tun_index = Some(idx);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
||||||
}
|
}
|
||||||
idx
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(idx) = tun_index {
|
if let Some(idx) = tun_index {
|
||||||
match windows_route::sys::add_ipv4_route(
|
match windows_route::sys::add_ipv4_route(
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ pub mod sys {
|
||||||
use winapi::shared::minwindef::{DWORD, ULONG};
|
use winapi::shared::minwindef::{DWORD, ULONG};
|
||||||
use winapi::shared::winerror::{ERROR_INSUFFICIENT_BUFFER, NO_ERROR};
|
use winapi::shared::winerror::{ERROR_INSUFFICIENT_BUFFER, NO_ERROR};
|
||||||
use winapi::um::iphlpapi::{
|
use winapi::um::iphlpapi::{
|
||||||
DeleteIpForwardEntry, GetAdaptersAddresses, GetIpForwardTable,
|
CreateIpForwardEntry, DeleteIpForwardEntry, GetAdaptersAddresses, GetIpForwardTable,
|
||||||
};
|
};
|
||||||
use winapi::um::iptypes::{
|
use winapi::um::iptypes::{
|
||||||
GAA_FLAG_SKIP_ANYCAST, GAA_FLAG_SKIP_DNS_SERVER, GAA_FLAG_SKIP_MULTICAST, IP_ADAPTER_ADDRESSES,
|
GAA_FLAG_SKIP_ANYCAST, GAA_FLAG_SKIP_DNS_SERVER, GAA_FLAG_SKIP_MULTICAST, IP_ADAPTER_ADDRESSES,
|
||||||
|
|
@ -88,47 +88,20 @@ pub mod sys {
|
||||||
if_index: u32,
|
if_index: u32,
|
||||||
metric: u32,
|
metric: u32,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
// Installed through route.exe rather than CreateIpForwardEntry.
|
let mut row: MIB_IPFORWARDROW = unsafe { mem::zeroed() };
|
||||||
//
|
row.dwForwardDest = ipv4_to_dword(dest);
|
||||||
// The legacy CreateIpForwardEntry API was failing here with error 160
|
row.dwForwardMask = ipv4_to_dword(mask);
|
||||||
// (ERROR_BAD_ARGUMENTS) on every single route — server-IP bypass and TUN
|
row.dwForwardNextHop = ipv4_to_dword(nexthop);
|
||||||
// default route alike — which left the server IP routed INTO the tunnel
|
row.dwForwardIfIndex = if_index;
|
||||||
// (a loop that froze the link for seconds under load) and the default
|
row.ForwardType = if nexthop == Ipv4Addr::UNSPECIFIED || dest == nexthop { 3 } else { 4 };
|
||||||
// route uninstalled. route.exe resolves the interface and validates the
|
row.ForwardProto = 3; // MIB_IPPROTO_NETMGMT
|
||||||
// gateway itself, and is what the teardown path already uses, so it
|
row.dwForwardMetric1 = metric;
|
||||||
// succeeds where the hand-built MIB_IPFORWARDROW did not.
|
|
||||||
use std::os::windows::process::CommandExt;
|
|
||||||
use std::process::Command;
|
|
||||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
|
||||||
|
|
||||||
// route add <dest> mask <mask> <gateway> metric <m> if <ifindex>
|
let ret = unsafe { CreateIpForwardEntry(&mut row) };
|
||||||
let out = Command::new("route")
|
if ret == NO_ERROR {
|
||||||
.args([
|
|
||||||
"add",
|
|
||||||
&dest.to_string(),
|
|
||||||
"mask",
|
|
||||||
&mask.to_string(),
|
|
||||||
&nexthop.to_string(),
|
|
||||||
"metric",
|
|
||||||
&metric.to_string(),
|
|
||||||
"if",
|
|
||||||
&if_index.to_string(),
|
|
||||||
])
|
|
||||||
.creation_flags(CREATE_NO_WINDOW)
|
|
||||||
.output()
|
|
||||||
.map_err(|e| format!("could not run route.exe: {e}"))?;
|
|
||||||
|
|
||||||
if out.status.success() {
|
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
// route.exe prints its diagnostics to stdout, not stderr.
|
Err(format!("CreateIpForwardEntry failed: {}", ret))
|
||||||
let msg = String::from_utf8_lossy(&out.stdout);
|
|
||||||
let msg = msg.trim();
|
|
||||||
Err(format!(
|
|
||||||
"route add failed (exit {:?}): {}",
|
|
||||||
out.status.code(),
|
|
||||||
if msg.is_empty() { "no output" } else { msg }
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1713,9 +1713,6 @@ async fn run_client_directly(client_cfg: ClientConfig) -> Result<()> {
|
||||||
frag_sleep: 2,
|
frag_sleep: 2,
|
||||||
junk_pc: [2, 5],
|
junk_pc: [2, 5],
|
||||||
junk_ps: [100, 1000],
|
junk_ps: [100, 1000],
|
||||||
ttl_desync: false,
|
|
||||||
ttl_desync_ttl: 8,
|
|
||||||
ttl_desync_count: 2,
|
|
||||||
},
|
},
|
||||||
dns_server: client_cfg.tun.as_ref().and_then(|t| t.dns.clone()),
|
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),
|
kill_switch: client_cfg.tun.as_ref().and_then(|t| t.kill_switch).unwrap_or(false),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue