Compare commits
429 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
cf14a4243c | |
|
|
66368c9d0f | |
|
|
bc61b47817 | |
|
|
5a33ed69c4 | |
|
|
c5e703c144 | |
|
|
05f25155bd | |
|
|
e6e0a7b28c | |
|
|
8f0ffd08c0 | |
|
|
8a1426ecf5 | |
|
|
e483af541f | |
|
|
df1a14d15c | |
|
|
d915efc715 | |
|
|
b673219894 | |
|
|
a1c146aff3 | |
|
|
365b4ccbf5 | |
|
|
4a3fb8b944 | |
|
|
f789167a22 | |
|
|
108bab6a90 | |
|
|
f7e9215331 | |
|
|
ebfc751471 | |
|
|
3cda1a9bd4 | |
|
|
77a45d7642 | |
|
|
6abae68f35 | |
|
|
cb57347d51 | |
|
|
32c36afc3b | |
|
|
a8aba8f4b8 | |
|
|
2ede607027 | |
|
|
0c69617725 | |
|
|
88e0634f09 | |
|
|
7473278cc2 | |
|
|
77e42b77f7 | |
|
|
e7a4f2b4a4 | |
|
|
6bc646c8a5 | |
|
|
d9fe749cd4 | |
|
|
cdfd2babc0 | |
|
|
2092e22a7c | |
|
|
5278f58903 | |
|
|
340819745a | |
|
|
e31c4b2268 | |
|
|
e46c863ef0 | |
|
|
cddd623ad0 | |
|
|
9a891310f9 | |
|
|
d9686c9344 | |
|
|
dbf923fb16 | |
|
|
51b947e6ff | |
|
|
f01ed4ec25 | |
|
|
c2a1a53b4d | |
|
|
cd12b01bc3 | |
|
|
de5cee103b | |
|
|
c523b083cb | |
|
|
c6a130673d | |
|
|
c756e02b63 | |
|
|
70a669d3c6 | |
|
|
66a1e97840 | |
|
|
b6bdd53066 | |
|
|
1c291d9c88 | |
|
|
2660a37249 | |
|
|
108ab8468b | |
|
|
5fcc0ba7f4 | |
|
|
7e3ada8d4d | |
|
|
4fd4f7d435 | |
|
|
b166f13d59 | |
|
|
a69ffae750 | |
|
|
90a919df59 | |
|
|
4ac2e79e14 | |
|
|
a9509a235d | |
|
|
5754689e09 | |
|
|
b5735fe8c2 | |
|
|
f904695760 | |
|
|
29554a71f1 | |
|
|
271a39c664 | |
|
|
44f9067222 | |
|
|
dee0288f2a | |
|
|
10ec253fa0 | |
|
|
cb59a5343f | |
|
|
b7bd8c20a5 | |
|
|
d725a4440b | |
|
|
caab8698ba | |
|
|
3e9e8845f1 | |
|
|
e52087ee8e | |
|
|
2092f6c716 | |
|
|
223f02287a | |
|
|
1d1a1ea5af | |
|
|
1b3390a3cf | |
|
|
7d9e5faeec | |
|
|
eda2a0eba7 | |
|
|
22c2d5edd8 | |
|
|
fa7ec2cd9a | |
|
|
794ea5251b | |
|
|
c6d506e6c0 | |
|
|
61091b6d56 | |
|
|
aa1c4ccd52 | |
|
|
5ab6833eab | |
|
|
5dc3a60017 | |
|
|
a33e5d3874 | |
|
|
1b536e9cf3 | |
|
|
5883f5105b | |
|
|
e21acc2ee1 | |
|
|
1568db3323 | |
|
|
edb2d8e229 | |
|
|
d609a3e883 | |
|
|
43914055b3 | |
|
|
3df5d5fccf | |
|
|
3d531ee0d9 | |
|
|
2819e2b3c2 | |
|
|
244d3ad374 | |
|
|
b89c6b0950 | |
|
|
992c212c76 | |
|
|
6361a87072 | |
|
|
fbc37e9d39 | |
|
|
db581ca391 | |
|
|
a547ebff17 | |
|
|
d065f6ceca | |
|
|
d822f48891 | |
|
|
26665a826f | |
|
|
7b43e1dcf7 | |
|
|
b17e5499eb | |
|
|
ec947ec9d1 | |
|
|
0ec09d1311 | |
|
|
f81610f939 | |
|
|
114011df5a | |
|
|
f96daaf57d | |
|
|
6929d42736 | |
|
|
5e0ff4a7ef | |
|
|
c330a0abe3 | |
|
|
b2ee9eb010 | |
|
|
8ca411a64b | |
|
|
dbd4ebc4e3 | |
|
|
acab38c551 | |
|
|
e1bf18e653 | |
|
|
39127d30f3 | |
|
|
5c9ec89821 | |
|
|
4f7f1ca838 | |
|
|
2a9b099b24 | |
|
|
db65e3367f | |
|
|
89a5eea20d | |
|
|
38f2d9e659 | |
|
|
7704e0bdb1 | |
|
|
92d6b06d75 | |
|
|
31d0020483 | |
|
|
04761fb6a3 | |
|
|
feaac0c713 | |
|
|
b841053628 | |
|
|
cf92089005 | |
|
|
e0a13702ea | |
|
|
c36e7373e8 | |
|
|
3671a83971 | |
|
|
c7bca41616 | |
|
|
486d745d47 | |
|
|
74b6648db1 | |
|
|
4543fa82f8 | |
|
|
83ba39e59a | |
|
|
533466b63a | |
|
|
6dee7613a5 | |
|
|
4c0263f7f7 | |
|
|
4d228cf1e1 | |
|
|
55215567dd | |
|
|
ab8d2c2185 | |
|
|
875177f779 | |
|
|
2a24ac34d0 | |
|
|
8fc61f986f | |
|
|
ee6768dee1 | |
|
|
091bb2c707 | |
|
|
2d05fb282d | |
|
|
3c54aba63f | |
|
|
a9e4511190 | |
|
|
fbf13b86f3 | |
|
|
9f35caf4ca | |
|
|
7bb7d211fa | |
|
|
430ab8a743 | |
|
|
04c31c7f53 | |
|
|
60282d730f | |
|
|
da238fad5c | |
|
|
85f0cb19cf | |
|
|
c95720f3da | |
|
|
730eab8553 | |
|
|
4d0249e8ef | |
|
|
4cd7321cc2 | |
|
|
fe1333621b | |
|
|
8dbf52cba3 | |
|
|
29e9ef739c | |
|
|
84797f55ab | |
|
|
53ce4f21a0 | |
|
|
ca9dd9ec06 | |
|
|
f9e272d6bf | |
|
|
ee539ea4a6 | |
|
|
5952fbe3cc | |
|
|
dfbaff2c21 | |
|
|
c2bc764613 | |
|
|
0951afa499 | |
|
|
ba5fe72873 | |
|
|
eb0a193fee | |
|
|
95e72f6136 | |
|
|
472fb8dc11 | |
|
|
8825cf0838 | |
|
|
0fdea7ee21 | |
|
|
9f143f730a | |
|
|
355a9f789a | |
|
|
53132036c5 | |
|
|
95a36e2bdf | |
|
|
9095f0dacd | |
|
|
a82c664e5b | |
|
|
4f34f7f19c | |
|
|
f20618400e | |
|
|
38f1752fda | |
|
|
6b58e0e8f3 | |
|
|
6fa6170c75 | |
|
|
02de5456aa | |
|
|
b67bd18eee | |
|
|
5ce4ed559a | |
|
|
f7cc555567 | |
|
|
e27378574c | |
|
|
902e762c91 | |
|
|
7257da174a | |
|
|
585c74556e | |
|
|
0a022a4763 | |
|
|
f88de11d98 | |
|
|
907d03ca38 | |
|
|
6d8e5dd68d | |
|
|
af7e148874 | |
|
|
2f15a90f15 | |
|
|
7986b1ca5b | |
|
|
cd218c9cf8 | |
|
|
8577824a3f | |
|
|
7656f3a3ce | |
|
|
f4830f043f | |
|
|
2870569c55 | |
|
|
8cfb7e9c17 | |
|
|
0ef43bb823 | |
|
|
ba71af2abb | |
|
|
6a685f8226 | |
|
|
da06cbc8f3 | |
|
|
4650947b00 | |
|
|
4ee2007754 | |
|
|
cb797c42d0 | |
|
|
0334322aae | |
|
|
2ba9a3694d | |
|
|
fe5db7cb10 | |
|
|
ebbe96e4e1 | |
|
|
57a5464103 | |
|
|
1b836b26ab | |
|
|
a0292b6087 | |
|
|
36ef6f2d04 | |
|
|
5fa957830c | |
|
|
c13642fa3b | |
|
|
3c687aad46 | |
|
|
f90607e471 | |
|
|
aeba340405 | |
|
|
ddb9ac2123 | |
|
|
360f84e5bd | |
|
|
c7a614958e | |
|
|
33145febbb | |
|
|
6d9b7d8a26 | |
|
|
532bdc7e76 | |
|
|
7bc31d2bac | |
|
|
25fa74eab6 | |
|
|
d8d3e858e9 | |
|
|
19f2c36400 | |
|
|
543e36e60e | |
|
|
54fdd444c9 | |
|
|
cbdb20402d | |
|
|
18899db1b2 | |
|
|
db1f8a5b89 | |
|
|
d63c039181 | |
|
|
05d4fe166c | |
|
|
5c39f24bee | |
|
|
3b88359746 | |
|
|
4155e48224 | |
|
|
6d57b3ef00 | |
|
|
38c4f242e4 | |
|
|
13128c510a | |
|
|
d018d68b79 | |
|
|
3920665d89 | |
|
|
d8930fd96a | |
|
|
43d28b2c81 | |
|
|
cea8ebaa5c | |
|
|
ba1a5cd16c | |
|
|
9ac0908c1e | |
|
|
ac91665263 | |
|
|
2bff6623d9 | |
|
|
85bac8f70a | |
|
|
800c07de5d | |
|
|
8e7c1e58e6 | |
|
|
55912832bf | |
|
|
b46be0d4be | |
|
|
24aa6dc0b2 | |
|
|
44bc2339d0 | |
|
|
def11a631c | |
|
|
49c3bce029 | |
|
|
04dc133453 | |
|
|
352253b95f | |
|
|
07ee8e85fe | |
|
|
d738caaaa1 | |
|
|
d3a07f3d32 | |
|
|
7f499d6263 | |
|
|
8c03903524 | |
|
|
abcb8999ce | |
|
|
9c59cabfc7 | |
|
|
89380ef70b | |
|
|
3564747c1b | |
|
|
46c1ac4519 | |
|
|
4ab0f04a1b | |
|
|
097a67e214 | |
|
|
f65fce3144 | |
|
|
65baa4ed7e | |
|
|
cba7be4b75 | |
|
|
951e597d46 | |
|
|
d79b6f2384 | |
|
|
2228faa550 | |
|
|
fffb67fbde | |
|
|
77c0701695 | |
|
|
87540166f6 | |
|
|
164c36ed3e | |
|
|
c3b80eb12c | |
|
|
d482369ced | |
|
|
318cdb29fb | |
|
|
743ede0602 | |
|
|
fb1dadc4df | |
|
|
ed3196be2e | |
|
|
f24c7ca481 | |
|
|
4dfe5fd3ca | |
|
|
aa09554881 | |
|
|
9e50984549 | |
|
|
1865f66e48 | |
|
|
270cd91d71 | |
|
|
7a9c32969c | |
|
|
3e511f1fc5 | |
|
|
ef242bf6f4 | |
|
|
cd154d4418 | |
|
|
3dd9490ecc | |
|
|
3ffa057d03 | |
|
|
6c4006c48c | |
|
|
7c84c17336 | |
|
|
b57a3180bd | |
|
|
855ef7655f | |
|
|
b9c6022b6c | |
|
|
1cff291fdd | |
|
|
be55aa6c6f | |
|
|
09b6f202d0 | |
|
|
41562707ec | |
|
|
02d0665edd | |
|
|
cc3b0b689d | |
|
|
3685ecac5c | |
|
|
3febe79b15 | |
|
|
9ef2282b31 | |
|
|
834c244f94 | |
|
|
975a0dc0d9 | |
|
|
960382e93b | |
|
|
9e2b29723c | |
|
|
1bc63c4094 | |
|
|
e7ad24bb13 | |
|
|
92fc73756f | |
|
|
3eb547db9d | |
|
|
a81625d721 | |
|
|
1c98bf9a51 | |
|
|
921533f560 | |
|
|
c957a3a395 | |
|
|
5fa110d962 | |
|
|
a5a0a17cfd | |
|
|
f55769bae0 | |
|
|
b87e87a7bd | |
|
|
aa3fb70933 | |
|
|
d9c3ba875c | |
|
|
8bc8a3ce51 | |
|
|
81293a9071 | |
|
|
30dea79197 | |
|
|
ceb760e4ce | |
|
|
112ddfee59 | |
|
|
83f7ff2119 | |
|
|
9329bcef45 | |
|
|
0cc5cf47ef | |
|
|
baff58c7fb | |
|
|
a0e38c462e | |
|
|
4384125bf8 | |
|
|
8a2af5d73d | |
|
|
3a4b5a8c63 | |
|
|
990af12fbe | |
|
|
ee14a60348 | |
|
|
3a16373a31 | |
|
|
9b01466953 | |
|
|
bd3def32bb | |
|
|
73f84a951a | |
|
|
ec8aab22f7 | |
|
|
3e6baf5a06 | |
|
|
05583e189e | |
|
|
a24d5d75d1 | |
|
|
c82ec93ea7 | |
|
|
a31319a80a | |
|
|
b342508932 | |
|
|
0306cbaccd | |
|
|
6ccaf3a303 | |
|
|
ad87c80e8d | |
|
|
e8a92059d2 | |
|
|
e20e4f2533 | |
|
|
49d97dbee3 | |
|
|
69e4426152 | |
|
|
074a3f6371 | |
|
|
a4d8da2460 | |
|
|
0418e5728c | |
|
|
8abffde0fd | |
|
|
a6640e1344 | |
|
|
8fe0589ea6 | |
|
|
bb7d471864 | |
|
|
77ec0e3a44 | |
|
|
032f694821 | |
|
|
f8aa8906ff | |
|
|
dc6635e248 | |
|
|
e36d743ad5 | |
|
|
aa9a93fcbf | |
|
|
696d416eff | |
|
|
07511debbd | |
|
|
31f3fff187 | |
|
|
8eb3fc72cb | |
|
|
7424ccc0ff | |
|
|
a9ba941782 | |
|
|
5bd653e9d2 | |
|
|
b670ba9e48 | |
|
|
5c33f08a9b | |
|
|
9c05f130ac | |
|
|
f0a93b4161 | |
|
|
ecba33e6d8 | |
|
|
9c685c8e43 | |
|
|
684b50f779 | |
|
|
b1dfb335c9 | |
|
|
6a474c8f00 | |
|
|
4cc1f0079c | |
|
|
a46b6eb0b6 | |
|
|
bfa858ff93 | |
|
|
ff207112d8 |
|
|
@ -1,213 +1,726 @@
|
|||
name: Universal CI/CD Release Matrix
|
||||
name: CI/CD
|
||||
|
||||
|
||||
# `run-name` is evaluated at workflow-start, BEFORE any job runs - it cannot
|
||||
# see resolve-channel's computed tag_name (e.g. "0.4.3-alpha"), only the
|
||||
# `github.*` context. The old "release version ${{ github.ref_name }}" showed
|
||||
# the bare branch name ("alpha"/"beta") for every run, which reads
|
||||
# exactly like a literal release tag and caused real confusion - the actual
|
||||
# release tag has been correct (versioned) all along; only this label lied
|
||||
# about it. Spell out "channel" so nobody mistakes one for the other again.
|
||||
# NOTE: this value MUST be quoted. The GHA string literal below contains
|
||||
# "Release build: {0}" - an unquoted YAML plain scalar treats ": " (colon
|
||||
# then space) as starting a nested mapping, which is exactly what broke every
|
||||
# single push since this line was introduced: GitHub rejected the whole
|
||||
# workflow file at parse time (before any job runs), silently burning an
|
||||
# Actions-minutes-billed run per push for nothing.
|
||||
run-name: "${{ startsWith(github.ref, 'refs/tags/') && (contains(github.ref_name, 'beta') && format('CI/CD: beta version {0}', github.ref_name) || contains(github.ref_name, 'alpha') && format('CI/CD: alpha version {0}', github.ref_name) || format('CI/CD: release version {0}', github.ref_name)) || format('CI/CD: {0} channel build', github.ref_name) }}"
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
channel:
|
||||
description: >-
|
||||
Manually build+release just this rolling channel. Stable releases
|
||||
are NEVER picked here on purpose - cut those only via a real
|
||||
"vX.Y.Z" tag push, so a manual dispatch can't accidentally publish
|
||||
a "stable" release.
|
||||
type: choice
|
||||
required: true
|
||||
default: alpha
|
||||
options:
|
||||
- alpha
|
||||
- beta
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
# -- Global defaults ---------------------------------------------------------
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
RUST_BACKTRACE: short
|
||||
|
||||
jobs:
|
||||
# Computes ONE channel + release tag for this whole run, so every build
|
||||
# job (native matrix + all 3 GUI platforms + Android) uploads to the exact
|
||||
# same release under the exact same tag, instead of repeating this logic
|
||||
# (and risking it drifting out of sync) in five separate places.
|
||||
#
|
||||
# Tag shape:
|
||||
# - real "vX.Y.Z" / "vX.Y.Z-beta.N" tag push -> tag used as-is (stable promotion)
|
||||
# - push to `alpha` -> "{version}-alpha" (rolling, same tag every push)
|
||||
# - push to `beta` -> "{version}-beta" (rolling, same tag every push)
|
||||
# - workflow_dispatch -> forced by the `channel` input (alpha|beta only)
|
||||
resolve-channel:
|
||||
name: Resolve release channel
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
channel: ${{ steps.resolve.outputs.channel }}
|
||||
tag_name: ${{ steps.resolve.outputs.tag_name }}
|
||||
prerelease: ${{ steps.resolve.outputs.prerelease }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Resolve channel, version, and release tag
|
||||
id: resolve
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
BASE_VERSION=$(grep -m1 '^version' Cargo.toml | sed -E 's/version *= *"([^"]+)"/\1/')
|
||||
|
||||
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
|
||||
# A pushed tag is authoritative — use it AS-IS (never recompute it
|
||||
# from Cargo.toml, or the release would upload to a different tag than
|
||||
# the one that triggered this run). The channel, and thus prerelease,
|
||||
# is decided by the tag's suffix: v0.4.7-beta / v0.4.7-alpha are
|
||||
# prereleases; a bare vX.Y.Z is the only thing that becomes stable.
|
||||
TAG="${{ github.ref_name }}"
|
||||
case "$TAG" in
|
||||
*-alpha*) CHANNEL="alpha" ;;
|
||||
*-beta*) CHANNEL="beta" ;;
|
||||
*) CHANNEL="stable" ;;
|
||||
esac
|
||||
else
|
||||
# No tag (workflow_dispatch, or a legacy branch push): pick the
|
||||
# channel, then synthesize the rolling tag from Cargo.toml's version.
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
CHANNEL="${{ github.event.inputs.channel }}"
|
||||
elif [ "${{ github.ref_name }}" = "beta" ]; then
|
||||
CHANNEL="beta"
|
||||
else
|
||||
CHANNEL="alpha"
|
||||
fi
|
||||
TAG="v${BASE_VERSION}-${CHANNEL}"
|
||||
fi
|
||||
|
||||
echo "Resolved channel=$CHANNEL tag=$TAG (base version $BASE_VERSION)"
|
||||
echo "channel=$CHANNEL" >> "$GITHUB_OUTPUT"
|
||||
echo "tag_name=$TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "prerelease=$([ "$CHANNEL" = "stable" ] && echo false || echo true)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
check-and-test:
|
||||
name: Check & Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: stable
|
||||
|
||||
- name: Restore Cargo cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
target/
|
||||
key: cargo-check-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: cargo-check-
|
||||
|
||||
- name: Install musl-tools
|
||||
run: sudo apt-get update && sudo apt-get install -y musl-tools
|
||||
|
||||
- name: Create dummy dist for rust-embed
|
||||
run: mkdir -p ostp-control/dist && touch ostp-control/dist/index.html
|
||||
|
||||
- name: cargo check
|
||||
run: cargo check --workspace
|
||||
|
||||
- name: cargo test
|
||||
run: cargo test --workspace --lib
|
||||
|
||||
publish-release-matrix:
|
||||
name: Release for ${{ matrix.target }}
|
||||
needs: [check-and-test, resolve-channel]
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
# ==========================================
|
||||
# 🏁 WINDOWS ECOSYSTEM
|
||||
# ==========================================
|
||||
# -- Windows ------------------------------------------------------
|
||||
- os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
artifact_name: ostp.exe
|
||||
release_name: ostp-windows-amd64.zip
|
||||
tun2socks_arch: windows-amd64
|
||||
wintun_arch: amd64
|
||||
|
||||
- os: windows-latest
|
||||
target: i686-pc-windows-msvc
|
||||
artifact_name: ostp.exe
|
||||
release_name: ostp-windows-386.zip
|
||||
tun2socks_arch: windows-386
|
||||
wintun_arch: x86
|
||||
|
||||
- os: windows-latest
|
||||
target: aarch64-pc-windows-msvc
|
||||
artifact_name: ostp.exe
|
||||
release_name: ostp-windows-arm64.zip
|
||||
tun2socks_arch: windows-arm64
|
||||
wintun_arch: arm64
|
||||
|
||||
# ==========================================
|
||||
# 🍏 APPLE DARWIN (macOS)
|
||||
# ==========================================
|
||||
# -- macOS ---------------------------------------------------------
|
||||
- os: macos-latest
|
||||
target: x86_64-apple-darwin
|
||||
artifact_name: ostp
|
||||
release_name: ostp-darwin-amd64.tar.gz
|
||||
tun2socks_arch: darwin-amd64
|
||||
|
||||
- os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
artifact_name: ostp
|
||||
release_name: ostp-darwin-arm64.tar.gz
|
||||
tun2socks_arch: darwin-arm64
|
||||
|
||||
# ==========================================
|
||||
# 🐧 LINUX & FreeBSD STANDARD
|
||||
# ==========================================
|
||||
# -- Linux native --------------------------------------------------
|
||||
- os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-musl
|
||||
artifact_name: ostp
|
||||
release_name: ostp-linux-amd64.tar.gz
|
||||
tun2socks_arch: linux-amd64
|
||||
|
||||
- os: ubuntu-latest
|
||||
target: i686-unknown-linux-musl
|
||||
artifact_name: ostp
|
||||
release_name: ostp-linux-386.tar.gz
|
||||
tun2socks_arch: linux-386
|
||||
use_cross: true
|
||||
|
||||
# -- Linux cross ---------------------------------------------------
|
||||
- os: ubuntu-latest
|
||||
target: aarch64-unknown-linux-musl
|
||||
artifact_name: ostp
|
||||
release_name: ostp-linux-arm64.tar.gz
|
||||
tun2socks_arch: linux-arm64
|
||||
use_cross: true
|
||||
|
||||
- os: ubuntu-latest
|
||||
target: armv7-unknown-linux-musleabihf
|
||||
artifact_name: ostp
|
||||
release_name: ostp-linux-armv7.tar.gz
|
||||
tun2socks_arch: linux-armv7
|
||||
use_cross: true
|
||||
|
||||
- os: ubuntu-latest
|
||||
target: x86_64-unknown-freebsd
|
||||
artifact_name: ostp
|
||||
release_name: ostp-freebsd-amd64.tar.gz
|
||||
tun2socks_arch: freebsd-amd64
|
||||
use_cross: true
|
||||
|
||||
# ==========================================
|
||||
# 🛰️ ROUTER & SPECIAL ARCHITECTURES (Cross)
|
||||
# ==========================================
|
||||
- os: ubuntu-latest
|
||||
target: mipsel-unknown-linux-musl
|
||||
artifact_name: ostp
|
||||
release_name: ostp-linux-mipsle.tar.gz
|
||||
tun2socks_arch: linux-mipsle-softfloat
|
||||
use_cross: true
|
||||
toolchain: nightly
|
||||
|
||||
- os: ubuntu-latest
|
||||
target: riscv64gc-unknown-linux-gnu
|
||||
artifact_name: ostp
|
||||
release_name: ostp-linux-riscv64.tar.gz
|
||||
tun2socks_arch: linux-riscv64
|
||||
use_cross: true
|
||||
|
||||
# ==========================================
|
||||
# 🤖 MOBILE & EMBEDDED SUITE (Cross)
|
||||
# ==========================================
|
||||
- os: ubuntu-latest
|
||||
target: aarch64-linux-android
|
||||
artifact_name: ostp
|
||||
release_name: ostp-android-arm64.tar.gz
|
||||
tun2socks_arch: linux-arm64
|
||||
use_cross: true
|
||||
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Initialize Rust ecosystem (Native Host)
|
||||
if: ${{ !matrix.use_cross }}
|
||||
# -- Frontend Build -----------------------------------------------------
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: Build Web Panel (skip if no source; use committed dist/)
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p ostp-control/dist
|
||||
cd ostp-control
|
||||
if [ -f package.json ]; then
|
||||
npm install && npm run build
|
||||
else
|
||||
echo "ostp-control has no package.json - using committed dist/"
|
||||
[ -f dist/index.html ] || echo '<!doctype html><title>OSTP</title>' > dist/index.html
|
||||
fi
|
||||
|
||||
# -- Rust toolchain -----------------------------------------------------
|
||||
- name: Setup Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: ${{ matrix.toolchain || 'stable' }}
|
||||
targets: ${{ matrix.target }}
|
||||
targets: ${{ !matrix.use_cross && matrix.target || '' }}
|
||||
|
||||
- name: Initialize Rust ecosystem (Cross Container Host)
|
||||
if: ${{ matrix.use_cross }}
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
# -- Cargo cache (shared per target) -----------------------------------
|
||||
- name: Restore Cargo cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
toolchain: ${{ matrix.toolchain || 'stable' }}
|
||||
path: |
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
target/
|
||||
key: cargo-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
cargo-${{ matrix.target }}-
|
||||
|
||||
- name: Activate rust compilation caching
|
||||
if: ${{ !matrix.use_cross }}
|
||||
uses: swatinem/rust-cache@v2
|
||||
|
||||
- name: Setup local MUSL linker dependencies
|
||||
# -- MUSL tools for native Linux musl builds ----------------------------
|
||||
- name: Install musl-tools
|
||||
if: ${{ matrix.os == 'ubuntu-latest' && !matrix.use_cross }}
|
||||
run: sudo apt-get update && sudo apt-get install -y musl-tools
|
||||
|
||||
- name: Execute Standard Native Compilation (Windows)
|
||||
if: ${{ !matrix.use_cross && matrix.os == 'windows-latest' }}
|
||||
run: |
|
||||
cargo build --release --target ${{ matrix.target }} --bin ostp
|
||||
|
||||
- name: Execute Standard Native Compilation (Unix)
|
||||
if: ${{ !matrix.use_cross && matrix.os != 'windows-latest' }}
|
||||
# -- Native build -------------------------------------------------------
|
||||
- name: Build (native)
|
||||
if: ${{ !matrix.use_cross }}
|
||||
shell: bash
|
||||
run: |
|
||||
cargo build --release --target ${{ matrix.target }} --bin ostp
|
||||
run: cargo build --release --target ${{ matrix.target }} --bin ostp
|
||||
|
||||
- name: Execute Specialized Cross-Compilation
|
||||
# -- Cross build --------------------------------------------------------
|
||||
- name: Restore cross binary cache
|
||||
if: ${{ matrix.use_cross }}
|
||||
run: |
|
||||
cargo install cross --git https://github.com/cross-rs/cross.git
|
||||
cross build --release --target ${{ matrix.target }} --bin ostp
|
||||
id: cross-cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cargo/bin/cross
|
||||
key: cross-bin-${{ runner.os }}-v1
|
||||
|
||||
- name: Inject Win32 Driver Dependencies (Windows)
|
||||
if: ${{ matrix.os == 'windows-latest' && matrix.tun2socks_arch }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
cd target/${{ matrix.target }}/release
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
# 1. Acquire tun2socks
|
||||
Invoke-WebRequest -Uri "https://github.com/xjasonlyu/tun2socks/releases/download/v2.6.0/tun2socks-${{ matrix.tun2socks_arch }}.zip" -OutFile "tun2socks.zip"
|
||||
Expand-Archive -Path "tun2socks.zip" -DestinationPath "tun_temp" -Force
|
||||
Get-ChildItem -Path "tun_temp" -Filter "*.exe" -Recurse | Copy-Item -Destination "tun2socks.exe" -Force
|
||||
# 2. Acquire wintun
|
||||
Invoke-WebRequest -Uri "https://www.wintun.net/builds/wintun-0.14.1.zip" -OutFile "wintun.zip"
|
||||
Expand-Archive -Path "wintun.zip" -DestinationPath "wintun_temp" -Force
|
||||
Get-ChildItem -Path "wintun_temp" -Filter "wintun.dll" -Recurse | Where-Object { $_.FullName -match 'bin[\\/]${{ matrix.wintun_arch }}[\\/]' } | Copy-Item -Destination "." -Force
|
||||
# Cleanup
|
||||
Remove-Item "tun2socks.zip", "tun_temp", "wintun.zip", "wintun_temp" -Recurse -Force
|
||||
- name: Install cross (if not cached)
|
||||
if: ${{ matrix.use_cross && steps.cross-cache.outputs.cache-hit != 'true' }}
|
||||
# cross-rs's own source (not ours, not a dependency of ours) uses a
|
||||
# macro-at-end-of-block pattern that trips rustc's
|
||||
# semicolon_in_expressions_from_macros lint on current toolchains -
|
||||
# harmless in cross's actual behavior, but `cargo install` compiles
|
||||
# the installed package as the "local" crate, so dependency lint
|
||||
# capping doesn't shield it. --cap-lints=warn is the standard escape
|
||||
# hatch for building a third-party tool against a newer compiler than
|
||||
# its own lint config assumed; it doesn't touch our own build.
|
||||
run: RUSTFLAGS="--cap-lints=warn" cargo install cross --git https://github.com/cross-rs/cross.git --locked
|
||||
|
||||
- name: Inject Unix Driver Dependencies (Unix)
|
||||
if: ${{ matrix.os != 'windows-latest' && matrix.tun2socks_arch }}
|
||||
shell: bash
|
||||
run: |
|
||||
cd target/${{ matrix.target }}/release
|
||||
# All platforms in tun2socks v2.6.0 use .zip packaging
|
||||
URL="https://github.com/xjasonlyu/tun2socks/releases/download/v2.6.0/tun2socks-${{ matrix.tun2socks_arch }}.zip"
|
||||
curl -f -L "$URL" -o "tun2socks.zip" || { echo "Failed to download tun2socks"; exit 0; }
|
||||
unzip -o "tun2socks.zip"
|
||||
find . -maxdepth 2 -name "tun2socks*" ! -name "*.zip" -type f -exec mv {} ./tun2socks \;
|
||||
rm -f "tun2socks.zip"
|
||||
chmod +x tun2socks || true
|
||||
- name: Build (cross)
|
||||
if: ${{ matrix.use_cross }}
|
||||
run: cross build --release --target ${{ matrix.target }} --bin ostp
|
||||
|
||||
- name: Package release artifact (Windows)
|
||||
# -- Driver dependencies ------------------------------------------------
|
||||
- name: Download wintun (Windows)
|
||||
if: ${{ matrix.os == 'windows-latest' }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
cd target/${{ matrix.target }}/release
|
||||
$files = @("ostp.exe")
|
||||
if (Test-Path "tun2socks.exe") { $files += "tun2socks.exe" }
|
||||
if (Test-Path "wintun.dll") { $files += "wintun.dll" }
|
||||
Compress-Archive -Path $files -DestinationPath ../../../${{ matrix.release_name }} -Force
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
$dir = "target/${{ matrix.target }}/release"
|
||||
Invoke-WebRequest -Uri "https://www.wintun.net/builds/wintun-0.14.1.zip" -OutFile "$dir/wt.zip"
|
||||
Expand-Archive "$dir/wt.zip" -DestinationPath "$dir/wt_tmp" -Force
|
||||
Get-ChildItem "$dir/wt_tmp" -Filter "wintun.dll" -Recurse | Where-Object { $_.FullName -match 'bin[\\/]${{ matrix.wintun_arch }}[\\/]' } | Copy-Item -Destination "$dir/"
|
||||
Remove-Item "$dir/wt.zip","$dir/wt_tmp" -Recurse -Force
|
||||
|
||||
- name: Package release artifact (Unix Systems)
|
||||
# -- Package ------------------------------------------------------------
|
||||
- name: Package (Windows)
|
||||
if: ${{ matrix.os == 'windows-latest' }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
$dir = "target/${{ matrix.target }}/release"
|
||||
$files = @("ostp.exe")
|
||||
if (Test-Path "$dir/wintun.dll") { $files += "wintun.dll" }
|
||||
Push-Location $dir
|
||||
Compress-Archive -Path $files -DestinationPath "../../../${{ matrix.release_name }}" -Force
|
||||
Pop-Location
|
||||
|
||||
- name: Package (Unix)
|
||||
if: ${{ matrix.os != 'windows-latest' }}
|
||||
run: |
|
||||
cd target/${{ matrix.target }}/release
|
||||
dir="target/${{ matrix.target }}/release"
|
||||
FILES="${{ matrix.artifact_name }}"
|
||||
if [ -f "tun2socks" ]; then
|
||||
FILES="$FILES tun2socks"
|
||||
fi
|
||||
tar -czf ../../../${{ matrix.release_name }} $FILES
|
||||
tar -czf "${{ matrix.release_name }}" -C "$dir" $FILES
|
||||
|
||||
- name: Inject artifact to Global GitHub Release Assets
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/') }}
|
||||
# -- Upload -------------------------------------------------------------
|
||||
- name: Upload to GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
# Computed once in resolve-channel so every platform/job in this run
|
||||
# lands on the exact same tag: "{version}-alpha" / "{version}-beta"
|
||||
# for rolling channel pushes, or the pushed "vX.Y.Z" tag as-is for a
|
||||
# real stable release.
|
||||
tag_name: ${{ needs.resolve-channel.outputs.tag_name }}
|
||||
prerelease: ${{ needs.resolve-channel.outputs.prerelease }}
|
||||
files: ${{ matrix.release_name }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
build-windows-gui:
|
||||
name: Build Windows GUI (Tauri) - ${{ matrix.arch }}
|
||||
needs: [check-and-test, resolve-channel]
|
||||
runs-on: windows-latest
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
target: x86_64-pc-windows-msvc
|
||||
- arch: arm64
|
||||
target: aarch64-pc-windows-msvc
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Setup Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Install Tauri CLI
|
||||
run: npm install -g @tauri-apps/cli
|
||||
|
||||
- name: Cache cargo
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
target/
|
||||
ostp-gui/src-tauri/target/
|
||||
key: cargo-windows-gui-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }}
|
||||
# Without a prefix fallback this cache NEVER restored on a release:
|
||||
# cutting a release rewrites every Cargo.lock (version bump), which
|
||||
# changes hashFiles(), which misses the exact key — so each release
|
||||
# rebuilt every dependency from scratch. That is why the GUI jobs ran
|
||||
# 2-4x longer than the plain release targets, which had this all along.
|
||||
restore-keys: |
|
||||
cargo-windows-gui-${{ matrix.target }}-
|
||||
|
||||
- name: Download wintun
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
|
||||
# Download wintun
|
||||
New-Item -ItemType Directory -Force -Path "target/${{ matrix.target }}/release"
|
||||
Invoke-WebRequest -Uri "https://www.wintun.net/builds/wintun-0.14.1.zip" -OutFile "target/wt.zip"
|
||||
Expand-Archive "target/wt.zip" -DestinationPath "target/wt_tmp" -Force
|
||||
Get-ChildItem "target/wt_tmp" -Filter "wintun.dll" -Recurse | Where-Object { $_.FullName -match 'bin[\\/]${{ matrix.arch }}[\\/]' } | Copy-Item -Destination "target/${{ matrix.target }}/release/wintun.dll" -Force
|
||||
|
||||
- name: Build Tauri App
|
||||
working-directory: ostp-gui
|
||||
run: |
|
||||
npm install
|
||||
cargo build -p ostp-tun-helper --release --target ${{ matrix.target }}
|
||||
npx tauri build --no-bundle --target ${{ matrix.target }}
|
||||
|
||||
- name: Package Portable ZIP
|
||||
shell: pwsh
|
||||
run: |
|
||||
$dir = "ostp-gui-dist"
|
||||
New-Item -ItemType Directory -Force -Path $dir
|
||||
Copy-Item "ostp-gui/src-tauri/target/${{ matrix.target }}/release/ostp-gui.exe" $dir
|
||||
Copy-Item "target/${{ matrix.target }}/release/ostp-tun-helper.exe" $dir
|
||||
Copy-Item "target/${{ matrix.target }}/release/wintun.dll" $dir
|
||||
|
||||
Compress-Archive -Path "$dir/*" -DestinationPath "ostp-windows-gui-${{ matrix.arch }}.zip" -Force
|
||||
|
||||
- name: Upload to GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
# Computed once in resolve-channel so every platform/job in this run
|
||||
# lands on the exact same tag: "{version}-alpha" / "{version}-beta"
|
||||
# for rolling channel pushes, or the pushed "vX.Y.Z" tag as-is for a
|
||||
# real stable release.
|
||||
tag_name: ${{ needs.resolve-channel.outputs.tag_name }}
|
||||
prerelease: ${{ needs.resolve-channel.outputs.prerelease }}
|
||||
files: ostp-windows-gui-${{ matrix.arch }}.zip
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
build-linux-gui:
|
||||
name: Build Linux GUI (Tauri) - ${{ matrix.arch }}
|
||||
needs: [check-and-test, resolve-channel]
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
target: x86_64-unknown-linux-gnu
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Setup Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Install Linux Dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
|
||||
|
||||
- name: Install Tauri CLI
|
||||
run: npm install -g @tauri-apps/cli
|
||||
|
||||
- name: Cache cargo
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
target/
|
||||
ostp-gui/src-tauri/target/
|
||||
key: cargo-linux-gui-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
cargo-linux-gui-${{ matrix.target }}-
|
||||
|
||||
- name: Build Tauri App
|
||||
working-directory: ostp-gui
|
||||
run: |
|
||||
npm install
|
||||
# TUN mode shells out to this helper, elevated via pkexec. Only the
|
||||
# Windows job used to build it, so the Linux package shipped without
|
||||
# it and TUN could never start.
|
||||
cargo build -p ostp-tun-helper --release --target ${{ matrix.target }} --manifest-path ../Cargo.toml
|
||||
npx tauri build --no-bundle --target ${{ matrix.target }}
|
||||
|
||||
- name: Package Portable Tarball
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir ostp-linux-gui-${{ matrix.arch }}
|
||||
cp ostp-gui/src-tauri/target/${{ matrix.target }}/release/ostp-gui ostp-linux-gui-${{ matrix.arch }}/
|
||||
# The GUI looks for the helper next to its own executable first.
|
||||
cp target/${{ matrix.target }}/release/ostp-tun-helper ostp-linux-gui-${{ matrix.arch }}/
|
||||
tar -czf ostp-linux-gui-${{ matrix.arch }}.tar.gz ostp-linux-gui-${{ matrix.arch }}
|
||||
|
||||
- name: Upload to GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
# Computed once in resolve-channel so every platform/job in this run
|
||||
# lands on the exact same tag: "{version}-alpha" / "{version}-beta"
|
||||
# for rolling channel pushes, or the pushed "vX.Y.Z" tag as-is for a
|
||||
# real stable release.
|
||||
tag_name: ${{ needs.resolve-channel.outputs.tag_name }}
|
||||
prerelease: ${{ needs.resolve-channel.outputs.prerelease }}
|
||||
files: ostp-linux-gui-${{ matrix.arch }}.tar.gz
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
build-macos-gui:
|
||||
name: Build macOS GUI (Tauri) - ${{ matrix.arch }}
|
||||
needs: [check-and-test, resolve-channel]
|
||||
runs-on: macos-latest
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
target: x86_64-apple-darwin
|
||||
- arch: arm64
|
||||
target: aarch64-apple-darwin
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Setup Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Install Tauri CLI
|
||||
run: npm install -g @tauri-apps/cli
|
||||
|
||||
- name: Cache cargo
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
target/
|
||||
ostp-gui/src-tauri/target/
|
||||
key: cargo-macos-gui-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
cargo-macos-gui-${{ matrix.target }}-
|
||||
|
||||
- name: Build Tauri App
|
||||
working-directory: ostp-gui
|
||||
run: |
|
||||
npm install
|
||||
npx tauri build --no-bundle --target ${{ matrix.target }}
|
||||
|
||||
- name: Package Portable Tarball
|
||||
run: |
|
||||
mkdir ostp-macos-gui-${{ matrix.arch }}
|
||||
cp ostp-gui/src-tauri/target/${{ matrix.target }}/release/ostp-gui ostp-macos-gui-${{ matrix.arch }}/
|
||||
tar -czf ostp-macos-gui-${{ matrix.arch }}.tar.gz ostp-macos-gui-${{ matrix.arch }}
|
||||
|
||||
- name: Upload to GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
# Computed once in resolve-channel so every platform/job in this run
|
||||
# lands on the exact same tag: "{version}-alpha" / "{version}-beta"
|
||||
# for rolling channel pushes, or the pushed "vX.Y.Z" tag as-is for a
|
||||
# real stable release.
|
||||
tag_name: ${{ needs.resolve-channel.outputs.tag_name }}
|
||||
prerelease: ${{ needs.resolve-channel.outputs.prerelease }}
|
||||
files: ostp-macos-gui-${{ matrix.arch }}.tar.gz
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
build-android:
|
||||
name: Build Android Client (Flutter) - ${{ matrix.arch }}
|
||||
needs: [check-and-test, resolve-channel]
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- arch: arm64-v8a
|
||||
rust_target: aarch64-linux-android
|
||||
flutter_target: android-arm64
|
||||
- arch: armeabi-v7a
|
||||
rust_target: armv7-linux-androideabi
|
||||
flutter_target: android-arm
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v3
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: '17'
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: '3.41.6'
|
||||
channel: 'stable'
|
||||
|
||||
- name: Setup Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.rust_target }}
|
||||
|
||||
- name: Setup Android NDK
|
||||
uses: nttld/setup-ndk@v1
|
||||
with:
|
||||
ndk-version: r26b
|
||||
|
||||
# The Android jobs had no Rust caching at all, so every release recompiled
|
||||
# the whole ostp-jni dependency graph from scratch — the main reason these
|
||||
# were among the slowest jobs in the matrix.
|
||||
- name: Cache cargo
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
target/
|
||||
key: cargo-android-${{ matrix.arch }}-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
cargo-android-${{ matrix.arch }}-
|
||||
|
||||
# cargo-ndk was built from source on every run. Cache the binary the same
|
||||
# way the cross-compilation jobs already cache `cross`.
|
||||
- name: Restore cargo-ndk binary cache
|
||||
id: cargo-ndk-cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cargo/bin/cargo-ndk
|
||||
key: cargo-ndk-bin-${{ runner.os }}-v1
|
||||
|
||||
- name: Install cargo-ndk (if not cached)
|
||||
if: steps.cargo-ndk-cache.outputs.cache-hit != 'true'
|
||||
run: cargo install cargo-ndk --locked
|
||||
|
||||
- name: Build Android APK
|
||||
shell: bash
|
||||
working-directory: ostp-flutter
|
||||
env:
|
||||
OSTP_KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
OSTP_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
OSTP_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
OSTP_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# 1. Materialise the upload keystore from secrets. Android keys an app
|
||||
# by applicationId + signing key and refuses to update across a key
|
||||
# change, so every published build MUST use this one key. Releases
|
||||
# used to fall through to the per-machine debug keystore, which on
|
||||
# ephemeral CI runners meant a different random key every build -
|
||||
# hence "App not installed" on upgrade.
|
||||
if [ -z "${OSTP_KEYSTORE_B64:-}" ]; then
|
||||
echo "::error::ANDROID_KEYSTORE_BASE64 secret is not set. Refusing to publish a"
|
||||
echo "::error::debug-signed APK: users could not update over it and the key is"
|
||||
echo "::error::not reproducible. See docs for the one-time keystore setup."
|
||||
exit 1
|
||||
fi
|
||||
export OSTP_KEYSTORE_PATH="$RUNNER_TEMP/ostp-upload.jks"
|
||||
# Strip any stray CR/LF before decoding: the secret is pasted from a
|
||||
# shell whose line endings we don't control, and a single trailing \r
|
||||
# is enough to corrupt the decode.
|
||||
printf '%s' "$OSTP_KEYSTORE_B64" | tr -d '\r\n' | base64 -d > "$OSTP_KEYSTORE_PATH"
|
||||
|
||||
# Verify the keystore opens BEFORE spending four minutes on Gradle only
|
||||
# to fail at the packaging step. The size/SHA-256 are safe to print (a
|
||||
# hash reveals nothing) and let the operator compare against the local
|
||||
# file to tell a transport problem apart from a wrong password.
|
||||
echo "keystore: $(stat -c%s "$OSTP_KEYSTORE_PATH") bytes, sha256 $(sha256sum "$OSTP_KEYSTORE_PATH" | cut -d' ' -f1)"
|
||||
if ! keytool -list -keystore "$OSTP_KEYSTORE_PATH" \
|
||||
-storepass "$OSTP_KEYSTORE_PASSWORD" >/dev/null 2>&1; then
|
||||
echo "::error::The keystore did not open with ANDROID_KEYSTORE_PASSWORD."
|
||||
echo "::error::If the SHA-256 above matches your local ostp-upload.jks, the file"
|
||||
echo "::error::arrived intact and the password secret itself is wrong - note that"
|
||||
echo "::error::PowerShell expands \$ inside double quotes, so a password containing"
|
||||
echo "::error::one gets mangled unless it was set with single quotes."
|
||||
exit 1
|
||||
fi
|
||||
if ! keytool -list -keystore "$OSTP_KEYSTORE_PATH" \
|
||||
-storepass "$OSTP_KEYSTORE_PASSWORD" -alias "$OSTP_KEY_ALIAS" >/dev/null 2>&1; then
|
||||
echo "::error::Keystore opened, but it has no key under ANDROID_KEY_ALIAS."
|
||||
echo "::error::Aliases present in the keystore:"
|
||||
keytool -list -keystore "$OSTP_KEYSTORE_PATH" -storepass "$OSTP_KEYSTORE_PASSWORD" \
|
||||
| grep -i "PrivateKeyEntry" || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 2. Compile JNI
|
||||
mkdir -p android/app/src/main/jniLibs/${{ matrix.arch }}
|
||||
|
||||
cd ../ostp-jni
|
||||
cargo ndk -t ${{ matrix.arch }} -o "../ostp-flutter/android/app/src/main/jniLibs" build --release
|
||||
cd ../ostp-flutter
|
||||
|
||||
# 3. Build Flutter APK
|
||||
flutter build apk --release --target-platform ${{ matrix.flutter_target }}
|
||||
|
||||
# 4. Fail loudly if the APK somehow still came out debug-signed, rather
|
||||
# than shipping another un-updatable build.
|
||||
APK=build/app/outputs/flutter-apk/app-release.apk
|
||||
if "$ANDROID_HOME"/build-tools/*/apksigner verify --print-certs "$APK" 2>/dev/null \
|
||||
| grep -qi "CN=Android Debug"; then
|
||||
echo "::error::APK is signed with the Android debug certificate - aborting."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 5. Copy to output
|
||||
cp "$APK" ostp-android-${{ matrix.arch }}.apk
|
||||
|
||||
- name: Upload to GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
# Computed once in resolve-channel so every platform/job in this run
|
||||
# lands on the exact same tag: "{version}-alpha" / "{version}-beta"
|
||||
# for rolling channel pushes, or the pushed "vX.Y.Z" tag as-is for a
|
||||
# real stable release.
|
||||
tag_name: ${{ needs.resolve-channel.outputs.tag_name }}
|
||||
prerelease: ${{ needs.resolve-channel.outputs.prerelease }}
|
||||
files: ostp-flutter/ostp-android-${{ matrix.arch }}.apk
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
/target/
|
||||
/target_build/
|
||||
/target_linux/
|
||||
/ostp-gui/
|
||||
/dist/
|
||||
**/*.rs.bk
|
||||
.idea/
|
||||
.vscode/
|
||||
**/node_modules/
|
||||
|
||||
# Binaries & libraries
|
||||
*.exe
|
||||
|
|
@ -26,9 +26,34 @@ test_route.ps1
|
|||
config.json
|
||||
wintun.dll
|
||||
|
||||
# Android signing keys. The upload keystore is the ONE key every published APK
|
||||
# must be signed with (Android refuses to update an app across a key change),
|
||||
# so losing or leaking it is unrecoverable — it can never be committed.
|
||||
*.jks
|
||||
*.keystore
|
||||
key.properties
|
||||
|
||||
# Server runtime cache (public IP autodetect) — must never be committed,
|
||||
# it's regenerated locally and leaks whatever host it ran on last.
|
||||
.ostp_public_ip
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Dev notes (not for repo)
|
||||
.ai-rules.md
|
||||
turn-harvesting-idea.md
|
||||
|
||||
# Private tooling (closed-source)
|
||||
ostp-prober/
|
||||
ostp-lab/
|
||||
|
||||
ostp-brain/
|
||||
|
||||
# Management panel built assets (built separately; dummy dist created for rust-embed build)
|
||||
ostp-control/
|
||||
|
||||
.agents/
|
||||
netstack-smoltcp/
|
||||
dnstt/
|
||||
ostp-web/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"target_version": "0.4.4",
|
||||
"branch": "master",
|
||||
"alpha_iteration": 0,
|
||||
"beta_iteration": 0
|
||||
}
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
# Contributing to OSTP
|
||||
|
||||
Thank you for your interest in contributing to **OSTP (Ospab Stealth Transport Protocol)**! We welcome contributions from developers, security researchers, testers, and documentation writers of all skill levels.
|
||||
|
||||
By contributing to this project, you agree to abide by our code of conduct and license terms.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Development Setup](#development-setup)
|
||||
2. [Project Structure](#project-structure)
|
||||
3. [Branch Strategy](#branch-strategy)
|
||||
4. [Development Workflow](#development-workflow)
|
||||
5. [Commit Message Conventions](#commit-message-conventions)
|
||||
6. [Coding Guidelines](#coding-guidelines)
|
||||
7. [Submitting Pull Requests](#submitting-pull-requests)
|
||||
8. [Security Vulnerabilities](#security-vulnerabilities)
|
||||
|
||||
---
|
||||
|
||||
## Development Setup
|
||||
|
||||
To build and test OSTP locally, you will need:
|
||||
|
||||
* **Rust Toolchain (1.75+)**: Install via [rustup](https://rustup.rs/).
|
||||
* **Node.js (18+) & npm**: Required to build the frontend control panel (`ostp-control`) and compile Tauri GUI resources.
|
||||
* **Git**: For version control.
|
||||
|
||||
### Building the Project
|
||||
|
||||
1. **Clone the repository**:
|
||||
```bash
|
||||
git clone https://github.com/ospab/ostp.git
|
||||
cd ostp
|
||||
```
|
||||
|
||||
2. **Build the entire Cargo workspace**:
|
||||
```bash
|
||||
cargo build
|
||||
```
|
||||
`ostp-control` (the web panel) is only needed if you're working on it
|
||||
specifically - the server build embeds a dummy `dist/` via `rust-embed`
|
||||
otherwise, so this step is not required for day-to-day core/client/server
|
||||
work. If you *are* touching the panel:
|
||||
```bash
|
||||
cd ostp-control && npm install && npm run build && cd ..
|
||||
```
|
||||
|
||||
3. **Run tests**:
|
||||
```bash
|
||||
cargo test --workspace
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
The repository is organized as a Cargo workspace containing the following crates:
|
||||
|
||||
* [`ostp-core/`](file:///d:/ospab-projects/ostp/ostp-core): Core protocol logic, including packet formatting, serialization, selective ACK/NACK (ARQ) state machine, and the Noise protocol (`Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s`) handshake.
|
||||
* [`ostp-client/`](file:///d:/ospab-projects/ostp/ostp-client): Client implementations, including SOCKS5/HTTP local proxies, the native OSTP TUN interface routing, and split-tunneling bypass mechanisms.
|
||||
* [`ostp-server/`](file:///d:/ospab-projects/ostp/ostp-server): Server logic, session dispatcher, anti-probing fallback server proxying, access key database, and the REST API for control panel communication.
|
||||
* [`ostp-control/`](file:///d:/ospab-projects/ostp/ostp-control): A modern web dashboard for server administration (user management, real-time metrics, bandwidth limits).
|
||||
* [`ostp-gui/`](file:///d:/ospab-projects/ostp/ostp-gui): Tauri-based desktop GUI application for Windows and Linux.
|
||||
* [`ostp-flutter/`](file:///d:/ospab-projects/ostp/ostp-flutter): Mobile client code for Android platforms.
|
||||
|
||||
---
|
||||
|
||||
## Branch Strategy
|
||||
|
||||
The repository runs three long-lived branches, in increasing order of stability:
|
||||
|
||||
| Branch | Role |
|
||||
|---|---|
|
||||
| `alpha` | Active development. All feature work and fixes land here first. |
|
||||
| `beta` | Periodically fast-forwarded from `alpha` once it's had some soak time. Ships as the `{version}-beta` release channel. |
|
||||
| `master` | Fast-forwarded from `beta` when it's proven stable. Real, tagged releases (`vX.Y.Z`) are cut from here. |
|
||||
|
||||
`beta` and `master` are **never** committed to directly - they only ever move forward by fast-forwarding from the branch below them. This means promotion is always a plain `git merge` with zero conflicts by construction: don't `git merge`/rebase feature work directly onto `beta` or `master`.
|
||||
|
||||
**Contributor PRs target `alpha`**, not `master`.
|
||||
|
||||
---
|
||||
|
||||
## Development Workflow
|
||||
|
||||
1. **Check for existing issues** or open a new one to discuss proposed changes before starting work.
|
||||
2. **Fork the repository** and create a new branch from `alpha`:
|
||||
```bash
|
||||
git checkout alpha
|
||||
git checkout -b feat/your-feature-name
|
||||
```
|
||||
3. **Implement your changes**, ensuring you write appropriate unit or integration tests.
|
||||
4. **Format your code**:
|
||||
```bash
|
||||
cargo fmt --all
|
||||
```
|
||||
5. **Run linter checks**:
|
||||
```bash
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
```
|
||||
6. **Ensure all tests pass**:
|
||||
```bash
|
||||
cargo test --workspace
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Commit Message Conventions
|
||||
|
||||
```
|
||||
<type>(<scope>): <short, imperative summary>
|
||||
|
||||
<optional body - explain WHY, not what; the diff already shows what changed>
|
||||
```
|
||||
|
||||
- **Type** - one of: `feat` (new capability), `fix` (bug fix), `docs`, `refactor` (no behavior change), `perf`, `test`, `chore` (deps/tooling/version bumps), `ci`, `security`.
|
||||
- **Scope** (optional) - the crate or area touched: `client`, `server`, `core`, `gui`, `flutter`, `ci`, `docs`, etc. e.g. `fix(client): ...`.
|
||||
- **Summary** - imperative mood ("add", not "added"/"adds"), no trailing period, ideally under ~70 characters.
|
||||
- **Body** - only when the *why* isn't obvious from the diff: a prior bug this fixes, a constraint that shaped the approach, a tradeoff you made. Don't restate what the diff already shows. Wrap at ~72 columns.
|
||||
|
||||
```
|
||||
fix(server): drop junk frames by per-key marker instead of a global one
|
||||
|
||||
A fixed 4-byte marker on every junk packet is itself a DPI signature any
|
||||
observer can filter on across every OSTP deployment. Derive the marker
|
||||
from the access key (HKDF, same scheme as obfuscation_key/psk) so it's
|
||||
per-user and indistinguishable from the packet's own random payload.
|
||||
```
|
||||
|
||||
Multiple unrelated changes belong in separate commits, not one bundled commit - it keeps `git bisect` and review useful. Squash-merge is fine for a PR with a few "fix typo" / "address review" commits, but don't squash logically distinct changes together.
|
||||
|
||||
---
|
||||
|
||||
## Coding Guidelines
|
||||
|
||||
* **Safety**: Avoid using `unsafe` blocks unless absolutely necessary for low-level system bindings (e.g., FFI configurations like `setsockopt`). When using `unsafe`, add safety doc comments explaining why it is safe.
|
||||
* **Documentation**: Document public modules, structs, and functions. Maintain comment integrity across codebase changes.
|
||||
* **Logging**: Use the `tracing` framework for structured logging. Avoid `println!` for production logs.
|
||||
* **Aesthetics**: When editing GUI or Web components, adhere to premium, modern web design aesthetics (vibrant color palettes, glassmorphism, responsive grids).
|
||||
|
||||
---
|
||||
|
||||
## Submitting Pull Requests
|
||||
|
||||
1. Push your branch to your GitHub fork:
|
||||
```bash
|
||||
git push origin feat/your-feature-name
|
||||
```
|
||||
2. Open a Pull Request (PR) targeting the `alpha` branch (see [Branch Strategy](#branch-strategy) - `master` only receives fast-forwards from `beta`, never direct PRs).
|
||||
3. In your PR description, explain the rationale behind your changes, what was fixed/added, and how it was tested.
|
||||
4. Verify that GitHub Actions CI runs successfully on your PR.
|
||||
|
||||
---
|
||||
|
||||
## Security Vulnerabilities
|
||||
|
||||
If you discover a security-related vulnerability, please do **not** open a public issue. Instead, report it privately by emailing the core maintainers at [gvoprgrg@gmail.com](mailto:gvoprgrg@gmail.com). We will coordinate a swift disclosure and fix.
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
# Участие в разработке OSTP
|
||||
|
||||
Спасибо за интерес к участию в разработке **OSTP (Ospab Stealth Transport Protocol)**! Мы рады любой помощи: от написания кода и тестирования до работы над документацией и проведения аудита безопасности.
|
||||
|
||||
Присылая изменения в проект, вы соглашаетесь соблюдать правила нашего сообщества и условия лицензии.
|
||||
|
||||
---
|
||||
|
||||
## Содержание
|
||||
|
||||
1. [Подготовка окружения](#подготовка-окружения)
|
||||
2. [Структура проекта](#структура-проекта)
|
||||
3. [Стратегия веток](#стратегия-веток)
|
||||
4. [Процесс разработки](#процесс-разработки)
|
||||
5. [Оформление коммитов](#оформление-коммитов)
|
||||
6. [Правила оформления кода](#правила-оформления-кода)
|
||||
7. [Создание Pull Request](#создание-pull-request)
|
||||
8. [Уязвимости безопасности](#уязвимости-безопасности)
|
||||
|
||||
---
|
||||
|
||||
## Подготовка окружения
|
||||
|
||||
Для локальной сборки и тестирования OSTP вам понадобятся:
|
||||
|
||||
* **Rust Toolchain (1.75+)**: Рекомендуется установить через [rustup](https://rustup.rs/).
|
||||
* **Node.js (18+) и npm**: Необходимы для сборки веб-панели управления (`ostp-control`) и сборки интерфейса Tauri.
|
||||
* **Git**: Для контроля версий.
|
||||
|
||||
### Сборка проекта
|
||||
|
||||
1. **Клонируйте репозиторий**:
|
||||
```bash
|
||||
git clone https://github.com/ospab/ostp.git
|
||||
cd ostp
|
||||
```
|
||||
|
||||
2. **Соберите весь Cargo-workspace**:
|
||||
```bash
|
||||
cargo build
|
||||
```
|
||||
`ostp-control` (веб-панель) нужна только если вы работаете конкретно над
|
||||
ней - в остальных случаях сервер собирается с пустым `dist/` через
|
||||
`rust-embed`, и этот шаг не нужен для повседневной работы над
|
||||
core/client/server. Если вы всё же трогаете панель:
|
||||
```bash
|
||||
cd ostp-control && npm install && npm run build && cd ..
|
||||
```
|
||||
|
||||
3. **Запустите тесты**:
|
||||
```bash
|
||||
cargo test --workspace
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Структура проекта
|
||||
|
||||
Репозиторий представляет собой единый Cargo-workspace со следующими компонентами:
|
||||
|
||||
* [`ostp-core/`](file:///d:/ospab-projects/ostp/ostp-core): Базовая логика протокола: форматирование пакетов, сериализация, конечный автомат выборочного подтверждения (ARQ/ACK/NACK) и рукопожатие Noise (`Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s`).
|
||||
* [`ostp-client/`](file:///d:/ospab-projects/ostp/ostp-client): Клиентская часть: локальные SOCKS5/HTTP прокси-серверы, нативный OSTP TUN-интерфейс (через драйвер `wintun`) и реализация раздельного туннелирования для прямого обхода трафика.
|
||||
* [`ostp-server/`](file:///d:/ospab-projects/ostp/ostp-server): Серверная часть: диспетчеризация сессий, маскировка под классические веб-серверы при активном сканировании, база данных ключей доступа и REST API панели управления.
|
||||
* [`ostp-control/`](file:///d:/ospab-projects/ostp/ostp-control): Панель администратора (пользователи, статистика трафика в реальном времени, лимиты скорости и объема данных).
|
||||
* [`ostp-gui/`](file:///d:/ospab-projects/ostp/ostp-gui): Настольное приложение-клиент для Windows и Linux на платформе Tauri.
|
||||
* [`ostp-flutter/`](file:///d:/ospab-projects/ostp/ostp-flutter): Мобильный клиент для платформы Android.
|
||||
|
||||
---
|
||||
|
||||
## Стратегия веток
|
||||
|
||||
В репозитории три долгоживущие ветки, по возрастанию стабильности:
|
||||
|
||||
| Ветка | Роль |
|
||||
|---|---|
|
||||
| `alpha` | Активная разработка. Вся новая работа и фиксы попадают сюда первыми. |
|
||||
| `beta` | Периодически перематывается вперёд (fast-forward) от `alpha`, когда та немного «отлежалась». Собирается в канал релиза `{версия}-beta`. |
|
||||
| `master` | Перематывается вперёд от `beta`, когда та доказала стабильность. Настоящие тегированные релизы (`vX.Y.Z`) режутся отсюда. |
|
||||
|
||||
В `beta` и `master` **никогда** не коммитят напрямую - они только перематываются вперёд от ветки уровнем ниже. Это значит, что промоушен - всегда обычный `git merge` без единого конфликта по построению: не мержите/не ребейзьте свою фичу прямо в `beta` или `master`.
|
||||
|
||||
**PR от контрибьюторов нацелены на `alpha`**, не на `master`.
|
||||
|
||||
---
|
||||
|
||||
## Процесс разработки
|
||||
|
||||
1. **Проверьте существующие задачи** или откройте новую тему (Issue) для обсуждения предлагаемых изменений.
|
||||
2. **Сделайте fork репозитория** и создайте новую ветку от `alpha`:
|
||||
```bash
|
||||
git checkout alpha
|
||||
git checkout -b feat/имя-вашей-фичи
|
||||
```
|
||||
3. **Внесите необходимые изменения** и добавьте соответствующие модульные или интеграционные тесты.
|
||||
4. **Выровняйте форматирование кода**:
|
||||
```bash
|
||||
cargo fmt --all
|
||||
```
|
||||
5. **Запустите статический анализатор**:
|
||||
```bash
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
```
|
||||
6. **Убедитесь, что все тесты проходят**:
|
||||
```bash
|
||||
cargo test --workspace
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Оформление коммитов
|
||||
|
||||
```
|
||||
<тип>(<область>): <краткое описание в повелительном наклонении>
|
||||
|
||||
<опционально: тело - объясняет ПОЧЕМУ, а не что; диф и так показывает что изменилось>
|
||||
```
|
||||
|
||||
- **Тип** - один из: `feat` (новая функциональность), `fix` (исправление бага), `docs`, `refactor` (без изменения поведения), `perf`, `test`, `chore` (зависимости/тулинг/версии), `ci`, `security`.
|
||||
- **Область** (опционально) - крейт или часть проекта: `client`, `server`, `core`, `gui`, `flutter`, `ci`, `docs` и т.д., например `fix(client): ...`.
|
||||
- **Краткое описание** - повелительное наклонение ("добавь", а не "добавил"/"добавляет"), без точки в конце, желательно до ~70 символов.
|
||||
- **Тело** - только когда причина не очевидна из дифа: какой баг это чинит, какое ограничение определило подход, на какой trade-off вы пошли. Не пересказывайте то, что и так видно в дифе. Перенос строк на ~72 символах.
|
||||
|
||||
```
|
||||
fix(server): отбрасывать junk-фреймы по маркеру для каждого ключа, а не глобальному
|
||||
|
||||
Фиксированный 4-байтовый маркер на каждом junk-пакете сам по себе - сигнатура
|
||||
DPI, по которой можно фильтровать любого наблюдателя во всех деплойментах OSTP
|
||||
сразу. Выводим маркер из access_key (HKDF, та же схема что у
|
||||
obfuscation_key/psk), чтобы он был индивидуальным для ключа и неотличимым от
|
||||
случайной полезной нагрузки пакета.
|
||||
```
|
||||
|
||||
Несколько несвязанных изменений - это несколько отдельных коммитов, а не один сборный. Это сохраняет пользу от `git bisect` и код-ревью. Squash-merge подходит для PR с парой коммитов вроде "fix typo" / "address review", но не сквошьте вместе логически разные изменения.
|
||||
|
||||
---
|
||||
|
||||
## Правила оформления кода
|
||||
|
||||
* **Безопасность (Safety)**: Избегайте использования блоков `unsafe` везде, где это возможно. Допускается их использование только для низкоуровневых системных вызовов (например, FFI-настройки сокетов `setsockopt`). Любой блок `unsafe` должен сопровождаться комментарием `// SAFETY: ...`.
|
||||
* **Документация**: Пишите документацию для публичных модулей, структур и методов. Сохраняйте целостность комментариев при рефакторинге.
|
||||
* **Логирование**: Используйте фреймворк `tracing` для структурированного логирования. Не используйте `println!` в рабочем коде.
|
||||
* **Дизайн**: При изменении веб-интерфейсов или GUI следуйте современным визуальным трендам (плавные анимации, сбалансированная цветовая гамма, адаптивная верстка).
|
||||
|
||||
---
|
||||
|
||||
## Создание Pull Request
|
||||
|
||||
1. Отправьте ветку в ваш fork-репозиторий:
|
||||
```bash
|
||||
git push origin feat/имя-вашей-фичи
|
||||
```
|
||||
2. Создайте Pull Request (PR) в ветку `alpha` основного репозитория (см. [Стратегия веток](#стратегия-веток) - `master` получает только fast-forward от `beta`, PR туда не принимаются напрямую).
|
||||
3. Подробно опишите внесенные изменения: какая проблема решается, как проводилось тестирование и на каких платформах проверялась сборка.
|
||||
4. Убедитесь, что автоматическое тестирование (GitHub Actions CI) завершилось успешно.
|
||||
|
||||
---
|
||||
|
||||
## Уязвимости безопасности
|
||||
|
||||
Если вы обнаружили уязвимость, пожалуйста, **не** публикуйте её в открытых Issue. Вместо этого отправьте отчёт разработчикам на почту [gvoprgrg@gmail.com](mailto:gvoprgrg@gmail.com) для координации закрытого исправления.
|
||||
13
Cargo.toml
|
|
@ -5,27 +5,24 @@ members = [
|
|||
"ostp-server",
|
||||
"ostp-jni", "ostp",
|
||||
"ostp-tun-helper"
|
||||
]
|
||||
exclude = ["ostp-gui/src-tauri"]
|
||||
, "ostp-tun"]
|
||||
exclude = ["ostp-gui/src-tauri", "ostp-brain", "ostp-prober"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
edition = "2021"
|
||||
license = "BSL 1.1"
|
||||
version = "0.1.52"
|
||||
license = "AGPL-3.0"
|
||||
version = "0.4.4"
|
||||
|
||||
[workspace.dependencies]
|
||||
anyhow = "1.0"
|
||||
async-trait = "0.1"
|
||||
bytes = "1.6"
|
||||
chacha20poly1305 = "0.10"
|
||||
rand = "0.8"
|
||||
rand_distr = "0.4"
|
||||
snow = "0.9"
|
||||
snow = { version = "0.9", features = ["risky-raw-split"] }
|
||||
thiserror = "1.0"
|
||||
tokio = { version = "1.37", features = ["rt-multi-thread", "macros", "net", "time", "io-util", "sync", "signal"] }
|
||||
tracing = "0.1"
|
||||
x25519-dalek = "2"
|
||||
sha2 = "0.10"
|
||||
hmac = "0.12"
|
||||
portable-atomic = "1.10"
|
||||
|
|
|
|||
701
LICENSE
|
|
@ -1,74 +1,661 @@
|
|||
Business Source License 1.1
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Parameters
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Licensor: Ospab Foundation (represented by Syralev Georgiy)
|
||||
Licensed Work: The Ospab Stealth Transport Protocol (OSTP) and all
|
||||
associated workspace crates, utilities, and documents.
|
||||
Additional Use Grant: The Licensor hereby grants you the right to copy,
|
||||
modify, create derivative works, redistribute, and
|
||||
make non-production and non-commercial use of the
|
||||
Licensed Work. You are also permitted to use the
|
||||
Licensed Work in production for personal, private
|
||||
utility and non-profit organizations.
|
||||
Change Date: May 14, 2030
|
||||
Change License: MIT License (as defined below)
|
||||
Preamble
|
||||
|
||||
-----------------------------------------------------------------------------------
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
Terms
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
1. The Licensor hereby grants you the right to copy, modify, create derivative works,
|
||||
redistribute, and make use of the Licensed Work only as permitted by the
|
||||
Additional Use Grant.
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
2. The Licensor hereby grants you the right to copy, modify, create derivative works,
|
||||
redistribute, and make use of the Licensed Work under the terms of the Change
|
||||
License on and after the Change Date.
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
3. To the extent that any term of this License (including the Additional Use Grant
|
||||
and the Change License) is in conflict with the Terms of this License, these
|
||||
Terms shall take precedence.
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
4. Every copy of the Licensed Work and any derivative work must include this
|
||||
License and all other copyright, trademark, and proprietary notices included
|
||||
with the Licensed Work.
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
5. Any use of the Licensed Work that is not permitted by this License is a breach
|
||||
of this License and may terminate your rights under this License.
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
6. DISCLAIMER OF WARRANTY. TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED
|
||||
WORK IS PROVIDED ON AN "AS IS" BASIS. THE LICENSOR MAKES NO REPRESENTATIONS OR
|
||||
WARRANTIES OF ANY KIND CONCERNING THE LICENSED WORK, EXPRESS OR IMPLIED, STATUTORY
|
||||
OR OTHERWISE, INCLUDING, WITHOUT LIMITATION, WARRANTIES OF TITLE,
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, OR NONINFRINGEMENT.
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
7. LIMITATION OF LIABILITY. TO THE EXTENT PERMITTED BY APPLICABLE LAW, IN NO EVENT
|
||||
WILL THE LICENSOR BE LIABLE TO YOU ON ANY LEGAL THEORY FOR ANY SPECIAL, INCIDENTAL,
|
||||
CONSEQUENTIAL, PUNITIVE, OR EXEMPLARY DAMAGES ARISING OUT OF THIS LICENSE OR THE
|
||||
USE OF THE LICENSED WORK, EVEN IF THE LICENSOR HAS BEEN ADVISED OF THE
|
||||
POSSIBILITY OF SUCH DAMAGES.
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
-----------------------------------------------------------------------------------
|
||||
0. Definitions.
|
||||
|
||||
Change License Text (MIT License)
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
Copyright (c) 2026 Syralev Georgiy (Ospab Foundation)
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
|
|
|||
271
README.md
|
|
@ -1,98 +1,259 @@
|
|||
# OSTP (Ospab Stealth Transport Protocol)
|
||||
# OSTP - Ospab Stealth Transport Protocol
|
||||
|
||||
[Русский язык](README.ru.md)
|
||||
[Русский язык](README.ru.md) · [Wiki](https://github.com/ospab/ostp/wiki) · [Contributing](CONTRIBUTING.md) · [Releases](https://github.com/ospab/ostp/releases)
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
OSTP is a fast and secure transport protocol designed to bypass DPI and network restrictions. It masks traffic as high-entropy data, making it difficult to detect or block.
|
||||
> A fast, custom encrypted transport protocol written in Rust.
|
||||
|
||||
**OSTP** (Ospab Stealth Transport Protocol) is a high-performance transport protocol. It implements a custom ARQ transport over UDP, as well as a UoT (UDP-over-TCP) mode. Every byte on the wire - including packet headers - is cryptographically indistinguishable from random noise, making it highly resistant to Deep Packet Inspection (DPI).
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- **Traffic Obfuscation**: Hides VPN/proxy signatures from network analysis.
|
||||
- **High Performance**: Written in Rust using the gVisor network stack for low latency.
|
||||
- **Reliable Connectivity**: Built-in keep-alive mechanism for stable operation on mobile networks.
|
||||
- **Flexible Modes**: Supports SOCKS5/HTTP proxying and full-system TUN (VPN) mode.
|
||||
- **Multi-platform**: Compatible with Windows, Linux, macOS, and Android.
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
## Quick Install
|
||||
|
||||
### Linux
|
||||
Run the installer script to set up OSTP as a system service:
|
||||
```bash
|
||||
bash <(curl -Ls https://raw.githubusercontent.com/ospab/ostp/master/scripts/install.sh)
|
||||
```
|
||||
|
||||
### Windows
|
||||
Run the following in PowerShell as Administrator:
|
||||
### Windows (PowerShell, run as Administrator)
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/ospab/ostp/master/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
### Manual Download
|
||||
Download pre-built binaries for your platform from [GitHub Releases](https://github.com/ospab/ostp/releases).
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
## Key Features
|
||||
|
||||
Initialize a default config file:
|
||||
```bash
|
||||
./ostp --init server # For VPS
|
||||
./ostp --init client # For local machine
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| **Full Traffic Obfuscation** | Every packet - including headers - is indistinguishable from random noise. Session IDs and nonces are masked with per-packet HMAC-derived keys. |
|
||||
| **Noise Protocol Handshake** | `Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s` - PSK-authenticated, forward-secret key exchange with no static identity exposure. |
|
||||
| **Reliable UDP (ARQ)** | Selective ACK/NACK with rate-limited retransmission, configurable reorder buffer, and exponential backoff. |
|
||||
| **Multiplexed Streams** | Multiple logical TCP streams over a single encrypted UDP session with per-stream flow control. |
|
||||
| **Seamless Roaming** | Clients can switch networks (WiFi ↔ LTE) without session interruption - tracked by session-ID, not IP. |
|
||||
| **Management API** | Built-in REST API for third-party panels (3x-ui, custom dashboards). Per-user stats, traffic limits, key CRUD. |
|
||||
| **Fallback Server** | TCP fallback proxy to a web server - makes OSTP indistinguishable from nginx during active probing. |
|
||||
| **Multi-Listener** | Bind to multiple addresses simultaneously (dual-stack IPv4/IPv6, multi-port). |
|
||||
| **TUN Mode** | Full-system VPN via native `smoltcp` network stack without external dependencies. All traffic transparently routed through the tunnel. |
|
||||
| **UoT (UDP-over-TCP)** | Bare UDP-over-TCP tunnel, no protocol mimicry. Since all data is fully encrypted and length-prefixed, it bypasses DPI filters that block unknown UDP traffic by riding over a plain TCP connection. |
|
||||
| **Mobile & Web Apps** | Beautiful cross-platform mobile client (Flutter) and a modern Web Control Panel (React/Vite) for effortless server and client management. |
|
||||
| **TURN Relay** | RFC 5766 TURN support for environments where direct UDP is blocked. |
|
||||
| **Hot-Reload** | Runtime config reload without restart (access keys, exclusions, mux settings). |
|
||||
| **Structured Logging** | `tracing`-based logging with `RUST_LOG` filtering. JSON/file/syslog output support. |
|
||||
| **Cross-Platform** | Windows, Linux, macOS, Android, FreeBSD, MIPS, RISC-V. Single binary, no runtime dependencies. |
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
%% Styles
|
||||
classDef userApp fill:#e1f5fe,stroke:#01579b,stroke-width:2px,color:#01579b
|
||||
classDef ostpCore fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px,color:#2e7d32
|
||||
classDef network fill:#fff3e0,stroke:#e65100,stroke-width:2px,color:#e65100,stroke-dasharray: 5 5
|
||||
classDef external fill:#f3e5f5,stroke:#4a148c,stroke-width:2px,color:#4a148c
|
||||
classDef fallback fill:#ffebee,stroke:#c62828,stroke-width:2px,color:#c62828
|
||||
|
||||
subgraph Local["💻 Client Device"]
|
||||
Apps["Web Browser / Apps"]:::userApp
|
||||
Socks["SOCKS5 / HTTP Proxy"]:::ostpCore
|
||||
Tun["Global TUN (VPN)"]:::ostpCore
|
||||
Client["OSTP Client Protocol Engine\n(Noise + ChaCha20 + ARQ)"]:::ostpCore
|
||||
|
||||
Apps -->|TCP/UDP| Socks
|
||||
Apps -->|IP Packets| Tun
|
||||
Socks --> Client
|
||||
Tun --> Client
|
||||
end
|
||||
|
||||
subgraph Internet["🌐 Hostile Network (DPI/Firewall)"]
|
||||
Tunnel{"Fully Obfuscated\nEncrypted UDP\n(Looks like noise)"}:::network
|
||||
end
|
||||
|
||||
subgraph Remote["🖥️ Remote VPS (Server)"]
|
||||
Server["OSTP Server Protocol Engine\n(Authentication & Decryption)"]:::ostpCore
|
||||
Relay["Connection Multiplexer"]:::ostpCore
|
||||
Fallback["Fake Website\n(Nginx/Caddy)"]:::fallback
|
||||
Target["Open Internet\n(YouTube, Google, etc)"]:::external
|
||||
|
||||
Server -->|Decrypted Traffic| Relay
|
||||
Server -->|Active Probe / Scanner| Fallback
|
||||
Relay -->|Clear Traffic| Target
|
||||
end
|
||||
|
||||
Client <==> Tunnel <==> Server
|
||||
```
|
||||
|
||||
### Server (config.json)
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Generate config
|
||||
|
||||
```bash
|
||||
# On your VPS (server):
|
||||
./ostp init server
|
||||
|
||||
# On your machine (client):
|
||||
./ostp init client
|
||||
```
|
||||
|
||||
### 2. Edit config
|
||||
|
||||
**Server** - set your access keys:
|
||||
```jsonc
|
||||
{
|
||||
// OSTP Server Configuration
|
||||
"mode": "server",
|
||||
"listen": "0.0.0.0:50000",
|
||||
"access_keys": ["YOUR_KEY"],
|
||||
// Optional: forward traffic to another proxy
|
||||
"outbound": {
|
||||
"enabled": false,
|
||||
"protocol": "socks5",
|
||||
"address": "127.0.0.1",
|
||||
"port": 9050,
|
||||
"default_action": "proxy"
|
||||
}
|
||||
"access_keys": ["YOUR_SECRET_KEY"],
|
||||
"api": { "enabled": true, "bind": "127.0.0.1:9090", "token": "admin-token" },
|
||||
"fallback": { "enabled": false, "listen": "0.0.0.0:443", "target": "127.0.0.1:8080" }
|
||||
}
|
||||
```
|
||||
|
||||
### Client (config.json)
|
||||
**Client** - point to your server:
|
||||
```jsonc
|
||||
{
|
||||
// OSTP Client Configuration
|
||||
"mode": "client",
|
||||
"server": "SERVER_IP:50000",
|
||||
"access_key": "YOUR_KEY",
|
||||
"server": "YOUR_SERVER_IP:50000",
|
||||
"access_key": "YOUR_SECRET_KEY",
|
||||
"socks5_bind": "127.0.0.1:1088",
|
||||
// Virtual network adapter settings
|
||||
"tun": {
|
||||
"enable": false,
|
||||
"wintun_path": "./wintun.dll",
|
||||
"ipv4_address": "10.1.0.2/24",
|
||||
"dns": "1.1.1.1"
|
||||
}
|
||||
"transport": { "mode": "udp" },
|
||||
"tun": { "enable": false, "dns": "1.1.1.1" }
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Run
|
||||
|
||||
```bash
|
||||
./ostp # Uses config.json in current directory
|
||||
./ostp --config /path/to.json # Custom config path
|
||||
./ostp check # Validate config without running
|
||||
./ostp gk # Generate a new access key
|
||||
./ostp links # Print client share links
|
||||
```
|
||||
|
||||
### 4. Connect via share link (one-liner)
|
||||
```bash
|
||||
./ostp connect "ostp://ACCESS_KEY@server.com:50000?..."
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> Always wrap the `ostp://...` link in quotes (`"`) so your terminal doesn't misinterpret special characters like `&` or `?`.
|
||||
|
||||
---
|
||||
|
||||
## Management API
|
||||
|
||||
Built-in REST API for building panels and dashboards.
|
||||
|
||||
```bash
|
||||
# Server status
|
||||
curl -H "Authorization: Bearer mytoken" http://127.0.0.1:9090/api/server/status
|
||||
|
||||
# List all users with traffic stats
|
||||
curl -H "Authorization: Bearer mytoken" http://127.0.0.1:9090/api/users
|
||||
|
||||
# Create a user with 10GB traffic limit
|
||||
curl -X POST -H "Authorization: Bearer mytoken" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"limit_bytes": 10737418240}' \
|
||||
http://127.0.0.1:9090/api/users
|
||||
```
|
||||
|
||||
Full API reference: [Management API](https://github.com/ospab/ostp/wiki/Management-API)
|
||||
|
||||
---
|
||||
|
||||
## CLI Reference
|
||||
|
||||
```
|
||||
ostp [--config <PATH>] [COMMAND]
|
||||
|
||||
Commands:
|
||||
run Run the daemon using the config file (default when no command is given)
|
||||
connect <URL> Connect once using a share link: ostp://KEY@HOST:PORT
|
||||
setup Interactive setup wizard
|
||||
init <MODE> Generate a template config (server/client/relay)
|
||||
check Validate the configuration file and exit
|
||||
gk Generate a secure access key (alias: generate-key)
|
||||
--format <FMT> Key format: hex, base64 (default: hex)
|
||||
-n, --count <N> Number of keys to generate (default: 1)
|
||||
links Print client share links from the server config
|
||||
import <URL> Import a share link into the config file
|
||||
update Update OSTP to the latest release
|
||||
-b, --branch <NAME> Release channel: stable, beta, alpha (default: stable)
|
||||
-v, --version <VER> Update to an exact version instead of the channel's latest
|
||||
migrate Force-migrate the configuration file to the current format
|
||||
proxy-env Print shell export commands for the local SOCKS proxy
|
||||
proxy-env-clear Print shell export commands to unset it
|
||||
uninstall Stop the service and remove the binary and config
|
||||
|
||||
Global options:
|
||||
--config <PATH> Config file path (default: config.json)
|
||||
```
|
||||
|
||||
Every subcommand also accepts `-h`/`--help` for its own option list.
|
||||
|
||||
---
|
||||
|
||||
## Protocol Summary
|
||||
|
||||
| Layer | Mechanism |
|
||||
|-------|-----------|
|
||||
| Key Exchange | Noise NNpsk0 (X25519 + ChaChaPoly + BLAKE2s) zero-RTT |
|
||||
| Encryption | ChaCha20-Poly1305 AEAD per-packet |
|
||||
| Header Obfuscation | HMAC-SHA256 derived per-packet mask |
|
||||
| Reliability | Selective ACK with cumulative + SACK ranges |
|
||||
| Retransmission | Rate-limited NACK + exponential backoff RTO |
|
||||
| Keepalive | Ping/Pong with RTT measurement every 5s |
|
||||
|
||||
---
|
||||
|
||||
## Building from Source
|
||||
|
||||
```bash
|
||||
# Prerequisites: Rust 1.75+
|
||||
cargo build --release
|
||||
|
||||
# Cross-compile for Linux
|
||||
cross build --release --target x86_64-unknown-linux-gnu
|
||||
|
||||
# Run tests
|
||||
cargo test -p ostp-core -p ostp-server
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
## Documentation
|
||||
|
||||
Start the node with your configuration:
|
||||
```bash
|
||||
./ostp --config config.json
|
||||
```
|
||||
|
||||
For TUN mode on Windows, ensure `tun2socks.exe` and `wintun.dll` are in the same directory.
|
||||
- **[Wiki](https://github.com/ospab/ostp/wiki)** - Full documentation
|
||||
- [Installation](https://github.com/ospab/ostp/wiki/Installation)
|
||||
- [Configuration Reference](https://github.com/ospab/ostp/wiki/Configuration)
|
||||
- [Management API](https://github.com/ospab/ostp/wiki/Management-API)
|
||||
- [Protocol Design](https://github.com/ospab/ostp/wiki/Protocol-Design)
|
||||
- [Building from Source](https://github.com/ospab/ostp/wiki/Building-from-Source)
|
||||
- [FAQ](https://github.com/ospab/ostp/wiki/FAQ)
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
Business Source License 1.1. Free for personal and non-commercial use. Converts to MIT License on May 14, 2030.
|
||||
GNU Affero General Public License v3.0 (AGPL-3.0). See [LICENSE](LICENSE) for the full text.
|
||||
|
||||
---
|
||||
|
||||
## Contact
|
||||
|
||||
- **Telegram**: [@ospab0](https://t.me/ospab0)
|
||||
- **Email**: gvoprgrg@gmail.com
|
||||
|
|
|
|||
205
README.ru.md
|
|
@ -1,57 +1,110 @@
|
|||
# OSTP (Ospab Stealth Transport Protocol)
|
||||
# OSTP - Ospab Stealth Transport Protocol
|
||||
|
||||
[English](README.md)
|
||||
[English](README.md) · [Contributing](CONTRIBUTING.ru.md)
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
OSTP — это быстрый и безопасный транспортный протокол для обхода DPI и сетевых ограничений. Он маскирует трафик под высокоэнтропийные данные, что делает его труднообнаружимым для систем блокировки.
|
||||
> Быстрый кастомный зашифрованный транспортный протокол на Rust.
|
||||
|
||||
**OSTP** (Ospab Stealth Transport Protocol) - кастомный транспортный протокол. Реализует собственный ARQ-транспорт поверх UDP, а также режим UoT (UDP-over-TCP). Каждый байт, включая заголовки пакетов, криптографически неотличим от случайного шума, что делает его устойчивым к системам глубокого анализа трафика (DPI).
|
||||
|
||||
---
|
||||
|
||||
## Возможности
|
||||
|
||||
- **Обфускация трафика**: Скрывает сигнатуры VPN и прокси от сетевого анализа.
|
||||
- **Высокая производительность**: Написан на Rust с использованием сетевого стека gVisor.
|
||||
- **Стабильность**: Встроенный механизм keep-alive для надежной работы в мобильных сетях.
|
||||
- **Гибкость**: Поддержка проксирования SOCKS5/HTTP и полнофункционального TUN (VPN) режима.
|
||||
- **Кроссплатформенность**: Работает на Windows, Linux, macOS и Android.
|
||||
| Возможность | Описание |
|
||||
|-------------|----------|
|
||||
| **Обфускация трафика** | Каждый пакет, включая заголовки, неотличим от случайного шума. Session ID и nonce маскируются HMAC-ключами, уникальными для каждого пакета. |
|
||||
| **Noise Protocol** | `Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s` - аутентификация через PSK, forward secrecy, без раскрытия идентичности. |
|
||||
| **Reliable UDP (ARQ)** | Selective ACK/NACK с rate-limited ретрансмиссией, настраиваемым reorder-буфером и exponential backoff. Разработан для 10 Гбит/с. |
|
||||
| **Мультиплексирование** | Несколько логических TCP-потоков поверх одной зашифрованной UDP-сессии с per-stream flow control. |
|
||||
| **Бесшовный роуминг** | Клиент может менять сети (WiFi ↔ 4G) без разрыва сессии - сервер отслеживает session-ID, а не IP-адрес. |
|
||||
| **TUN-режим** | Полносистемный VPN без внешних зависимостей (встроенный network stack на базе `smoltcp`). |
|
||||
| **UoT (UDP-over-TCP)** | Голый туннель UDP-over-TCP, без имитации протоколов. Поскольку все данные полностью зашифрованы и имеют префикс длины, он обходит DPI фильтры, блокирующие неизвестный UDP трафик, передавая всё по обычному TCP соединению. |
|
||||
| **Мобильные и Web приложения** | Красивый кроссплатформенный мобильный клиент (Flutter) и современная Web панель управления (React/Vite) для удобного администрирования. |
|
||||
| **TURN Relay** | RFC 5766 TURN для окружений, где прямой UDP заблокирован. |
|
||||
| **Hot-Reload** | Перезагрузка конфига в рантайме без перезапуска (ключи, исключения, mux, TURN). |
|
||||
| **Кросс-платформа** | Windows, Linux, macOS, Android. Один бинарник, без зависимостей. |
|
||||
|
||||
---
|
||||
|
||||
## Архитектура
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
%% Styles
|
||||
classDef userApp fill:#e1f5fe,stroke:#01579b,stroke-width:2px,color:#01579b
|
||||
classDef ostpCore fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px,color:#2e7d32
|
||||
classDef network fill:#fff3e0,stroke:#e65100,stroke-width:2px,color:#e65100,stroke-dasharray: 5 5
|
||||
classDef external fill:#f3e5f5,stroke:#4a148c,stroke-width:2px,color:#4a148c
|
||||
classDef fallback fill:#ffebee,stroke:#c62828,stroke-width:2px,color:#c62828
|
||||
|
||||
subgraph Local["💻 Устройство клиента"]
|
||||
Apps["Браузер / Приложения"]:::userApp
|
||||
Socks["SOCKS5 / HTTP Прокси"]:::ostpCore
|
||||
Tun["Global TUN (VPN)"]:::ostpCore
|
||||
Client["OSTP Клиент\n(Noise + ChaCha20 + ARQ)"]:::ostpCore
|
||||
|
||||
Apps -->|TCP/UDP| Socks
|
||||
Apps -->|IP Пакеты| Tun
|
||||
Socks --> Client
|
||||
Tun --> Client
|
||||
end
|
||||
|
||||
subgraph Internet["🌐 Сеть с цензурой (DPI)"]
|
||||
Tunnel{"Зашифрованный UDP\n(Выглядит как белый шум)"}:::network
|
||||
end
|
||||
|
||||
subgraph Remote["🖥️ Удаленный сервер (VPS)"]
|
||||
Server["OSTP Сервер\n(Аутентификация)"]:::ostpCore
|
||||
Relay["Мультиплексор соединений"]:::ostpCore
|
||||
Fallback["Фейковый сайт\n(Nginx/Caddy)"]:::fallback
|
||||
Target["Свободный интернет\n(YouTube, Google и т.д.)"]:::external
|
||||
|
||||
Server -->|Расшифрованный трафик| Relay
|
||||
Server -->|Сканеры цензоров| Fallback
|
||||
Relay -->|Чистый трафик| Target
|
||||
end
|
||||
|
||||
Client <==> Tunnel <==> Server
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Установка
|
||||
|
||||
### Linux
|
||||
Используйте скрипт для автоматической установки и настройки сервиса:
|
||||
```bash
|
||||
bash <(curl -Ls https://raw.githubusercontent.com/ospab/ostp/master/scripts/install.sh)
|
||||
```
|
||||
|
||||
### Windows
|
||||
Запустите в PowerShell от имени администратора:
|
||||
### Windows (PowerShell от Администратора)
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/ospab/ostp/master/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Настройка
|
||||
## Конфигурация
|
||||
|
||||
Создайте файл конфигурации по умолчанию:
|
||||
Создать конфиг по умолчанию:
|
||||
```bash
|
||||
./ostp --init server # Для сервера (VPS)
|
||||
./ostp --init client # Для клиента (ПК)
|
||||
./ostp init server # VPS
|
||||
./ostp init client # Локальная машина
|
||||
```
|
||||
|
||||
### Сервер (config.json)
|
||||
### Сервер (`config.json`)
|
||||
```jsonc
|
||||
{
|
||||
// Конфигурация Сервера OSTP
|
||||
"mode": "server",
|
||||
"listen": "0.0.0.0:50000",
|
||||
"access_keys": ["ВАШ_КЛЮЧ"],
|
||||
// Опционально: пересылка трафика через другой прокси
|
||||
"debug": false,
|
||||
// Опционально: проксировать трафик через upstream
|
||||
"outbound": {
|
||||
"enabled": false,
|
||||
"protocol": "socks5",
|
||||
|
|
@ -62,20 +115,39 @@ irm https://raw.githubusercontent.com/ospab/ostp/master/scripts/install.ps1 | ie
|
|||
}
|
||||
```
|
||||
|
||||
### Клиент (config.json)
|
||||
### Клиент (`config.json`)
|
||||
```jsonc
|
||||
{
|
||||
// Конфигурация Клиента OSTP
|
||||
"mode": "client",
|
||||
"server": "IP_СЕРВЕРА:50000",
|
||||
"access_key": "ВАШ_КЛЮЧ",
|
||||
"socks5_bind": "127.0.0.1:1088",
|
||||
// Настройки виртуального сетевого адаптера
|
||||
"debug": false,
|
||||
// Настройки транспорта (udp или uot)
|
||||
"transport": {
|
||||
"mode": "udp"
|
||||
},
|
||||
// TUN-режим (полносистемный VPN)
|
||||
"tun": {
|
||||
"enable": false,
|
||||
"wintun_path": "./wintun.dll",
|
||||
"ipv4_address": "10.1.0.2/24",
|
||||
"dns": "1.1.1.1"
|
||||
},
|
||||
// Мультиплексирование: несколько UDP-сессий
|
||||
"mux": {
|
||||
"enabled": false,
|
||||
"sessions": 2
|
||||
},
|
||||
// TURN-реле для заблокированных сетей
|
||||
"turn": {
|
||||
"enabled": false,
|
||||
"server_addr": "turn.example.com:3478",
|
||||
"username": "user",
|
||||
"access_key": "pass"
|
||||
},
|
||||
// Исключения (идут напрямую, минуя туннель)
|
||||
"exclude": {
|
||||
"domains": ["example.local"],
|
||||
"ips": ["192.168.0.0/16"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
|
@ -84,15 +156,90 @@ irm https://raw.githubusercontent.com/ospab/ostp/master/scripts/install.ps1 | ie
|
|||
|
||||
## Использование
|
||||
|
||||
Запустите программу с вашим конфигом:
|
||||
```bash
|
||||
# Запуск с конфигом
|
||||
./ostp --config config.json
|
||||
|
||||
# Или просто (ищет config.json рядом с бинарником)
|
||||
./ostp
|
||||
```
|
||||
|
||||
Для работы TUN режима в Windows файлы `tun2socks.exe` и `wintun.dll` должны находиться в одной папке с бинарным файлом.
|
||||
### Справка по командам
|
||||
|
||||
```
|
||||
ostp [--config <PATH>] [КОМАНДА]
|
||||
|
||||
Команды:
|
||||
run Запустить демон по конфигу (по умолчанию, если команда не указана)
|
||||
connect <URL> Подключиться по share-ссылке: ostp://KEY@HOST:PORT
|
||||
setup Интерактивный мастер настройки
|
||||
init <MODE> Сгенерировать шаблон конфига (server/client/relay)
|
||||
check Проверить конфиг и выйти
|
||||
gk Сгенерировать access-key (алиас: generate-key)
|
||||
--format <FMT> Формат ключа: hex, base64 (по умолчанию hex)
|
||||
-n, --count <N> Количество ключей (по умолчанию 1)
|
||||
links Вывести client-share-ссылки из серверного конфига
|
||||
import <URL> Импортировать share-ссылку в конфиг
|
||||
update Обновить OSTP до актуального релиза
|
||||
-b, --branch <NAME> Канал релиза: stable, beta, alpha (по умолчанию stable)
|
||||
-v, --version <VER> Обновиться на точную версию вместо последней в канале
|
||||
migrate Принудительно мигрировать конфиг к текущему формату
|
||||
proxy-env Вывести shell-команды для локального SOCKS-прокси
|
||||
proxy-env-clear Вывести shell-команды для их отмены
|
||||
uninstall Остановить сервис и удалить бинарник с конфигом
|
||||
|
||||
Глобальные опции:
|
||||
--config <PATH> Путь к конфигу (по умолчанию config.json)
|
||||
```
|
||||
|
||||
У каждой подкоманды есть своя справка через `-h`/`--help`.
|
||||
|
||||
### TUN-режим (Windows)
|
||||
Использует встроенный сетевой стек `smoltcp` и виртуальный адаптер `wintun` (необходима `wintun.dll`). Требует запуска с правами Администратора.
|
||||
|
||||
### TUN-режим (Linux)
|
||||
Использует встроенный сетевой стек `smoltcp` и `/dev/net/tun`. Требует запуска от имени `root` (или наличия `CAP_NET_ADMIN`).
|
||||
|
||||
---
|
||||
|
||||
## Спецификация протокола
|
||||
|
||||
| Уровень | Механизм |
|
||||
|---------|----------|
|
||||
| Обмен ключами | Noise NNpsk0 (X25519 + ChaChaPoly + BLAKE2s) zero-RTT |
|
||||
| Шифрование | ChaCha20-Poly1305 AEAD на каждый пакет |
|
||||
| Обфускация заголовков | HMAC-SHA256 маска session_id + nonce, уникальная для каждого пакета |
|
||||
| Надёжность | Selective ACK с cumulative + SACK диапазонами |
|
||||
| Ретрансмиссия | Rate-limited NACK (30мс cooldown) + exponential backoff RTO |
|
||||
| Flow Control | Окно in-flight (только retransmittable фреймы) |
|
||||
| Keepalive | Ping/Pong с измерением RTT каждые 5с |
|
||||
| Таймаут сессии | 60с на клиенте, 300с на сервере |
|
||||
|
||||
---
|
||||
|
||||
## Сборка из исходников
|
||||
|
||||
```bash
|
||||
# Требования: Rust toolchain (1.75+)
|
||||
cargo build --release
|
||||
|
||||
# Кросс-компиляция для Linux
|
||||
cross build --release --target x86_64-unknown-linux-gnu
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Документация
|
||||
|
||||
- [Архитектура](docs/ru/architecture.md)
|
||||
- [Спецификация протокола](docs/ru/specification.md)
|
||||
- [Дизайн обфускации](docs/ru/obfuscation.md)
|
||||
- [Администрирование сервера](docs/ru/server.md)
|
||||
- [Настройка клиента](docs/ru/client.md)
|
||||
- [Интеграции](docs/ru/integrations.md)
|
||||
|
||||
---
|
||||
|
||||
## Лицензия
|
||||
|
||||
Business Source License 1.1. Бесплатно для личного и некоммерческого использования. Переходит в MIT License 14 мая 2030 года.
|
||||
GNU Affero General Public License v3.0 (AGPL-3.0). Полный текст - в файле [LICENSE](LICENSE).
|
||||
|
|
|
|||
|
|
@ -0,0 +1,185 @@
|
|||
# Чистая переборка на базе v0.2.98
|
||||
|
||||
База: `v0.2.98` (commit `31d0020`) — последняя версия, которая **стабильно работает**.
|
||||
Ветка: `clean-rebuild`. Всё, что появилось после (0.3.1 … 0.3.21), переносим
|
||||
**выборочно и с чистой головой**, а не копируем рефактор целиком.
|
||||
|
||||
Принцип: 0.3.1 принёс «модульный multi-server рефактор» + лавину фич — и вместе с
|
||||
ними нестабильность. Берём только проверенное и нужное.
|
||||
|
||||
---
|
||||
|
||||
## Решения (зафиксировано пользователем)
|
||||
- **Junk-пакеты + TCP-фрагментация — ОСТАВЛЯЕМ** (нравятся). НО починить вредную
|
||||
часть: junk по UDP не должен выглядеть для сервера как `Unauthorized probe`
|
||||
(rate-limit/гейт на сервере), иначе флуд лога и риск самобана клиента. Фича
|
||||
остаётся — чиним поведение, а не выпиливаем. Тонкая настройка — §E.
|
||||
- **Версия переборки — 0.4.0** (решено; 0.3.x сожжены в pre-release).
|
||||
- **WSS и Reality (TLS-мимикрия) — ВЫКИНУТЬ.** Путь проекта — **zapret-like**:
|
||||
обфускация/DPI-evasion на уровне пакетов (junk, фрагментация, обфускация), а НЕ
|
||||
мимикрия под TLS. Reality с нуля тяжела и не вписывается.
|
||||
- **Multi-server — НЕ НУЖЕН.** Режем до одного сервера → уходит urltest-группа и
|
||||
половина сложности 0.3.1.
|
||||
- **Конфиг — ПЛОСКИЙ по сути, но оформлен красиво/секционно как сейчас** (решено).
|
||||
Сохраняем читаемую секционную структуру (server / transport / tun / dns / exclude
|
||||
и т.п.), но **выпиливаем модульную машинерию**: массивы `inbounds[]`/`outbounds[]`,
|
||||
`routing.rules[]` с тегами, `default_outbound`, urltest, мульти-сервер. Один сервер
|
||||
на конфиг. Исключения = плоский список внутри секции `exclude`.
|
||||
- **Профили — ОСТАВЛЯЕМ, single-select, в UI-слое** (решено). Профиль = сохранённый
|
||||
конфиг одного сервера; активен ровно один (radio). Список/выбор/share живут во
|
||||
фронте (prefs GUI / Flutter); **ядро о профилях не знает** — на «Подключить» из
|
||||
выбранного профиля генерится плоский конфиг на один сервер. Никаких чекбоксов/
|
||||
мульти-актив/urltest.
|
||||
- **Derived-secrets — ОСТАВЛЯЕМ, но ОБЯЗАТЕЛЬНО проверить, что он РЕАЛЬНО работает:**
|
||||
старый клиент НЕ должен подключаться к новому серверу. В прошлой реализации это
|
||||
НЕ соблюдалось (старый клиент → новый сервер подключался) — значит сервер всё ещё
|
||||
принимал старый формат handshake / obfuscation-key. Это **баг**, закрыть в первую
|
||||
очередь: сервер обязан отвергать всё, что не прошло derived-secrets.
|
||||
- **Лицензия — AGPLv3.**
|
||||
- **Брендинг — ОСТАВЛЯЕМ**: тёмная тема + орёл на фоне (watermark/логотип).
|
||||
- **Стелс-философия (north-star): zapret-like** — «нет узнаваемого заголовка +
|
||||
манипуляции пакетами» (обфускация, junk, фрагментация, DNS/UoT-транспорты), а НЕ
|
||||
«притворись известным протоколом» (Reality/WSS — выкинуты).
|
||||
|
||||
---
|
||||
|
||||
## 0. Корневая причина нестабильности 0.3.x
|
||||
**Модульный multi-server рефактор (0.3.1)** — `580faf6`, `8ed66f9`, `67f9c06`.
|
||||
Сменил формат конфига (inbounds/outbounds/routing/urltest), session-модель,
|
||||
hot-reload. Источник большинства багов (мёртвые маршруты, фейк-коннект,
|
||||
рассинхрон конфига). **НЕ копировать целиком.** Если multi-server реально нужен —
|
||||
добавлять минимально и поверх рабочей одно-серверной модели 0.2.98.
|
||||
|
||||
---
|
||||
|
||||
## A. ВЫКИНУТЬ / не переносить
|
||||
1. **WSS-фрейминг и Reality (TLS-мимикрия)** — оба выкинуть. Путь zapret-like, а не
|
||||
маскировка под TLS-сайт; Reality (`reality.rs`) к тому же сложно сделать корректно
|
||||
с нуля. Удалить из базы 0.2.98 целиком.
|
||||
2. **Multi-server / urltest-группа** — не нужен. Один сервер на конфиг.
|
||||
3. Остатки **tun2socks** на Android (`libtun2socks.so`, `tun2socks-arm64`,
|
||||
`tun_child`, `t2sBinPath`) — давно мёртвый код, только раздувает APK. Не тащить.
|
||||
|
||||
> ⚠️ Junk-пакеты и TCP-фрагментация **ОСТАЮТСЯ** (см. Решения и §E) — это уже не
|
||||
> «мусор». Но junk по UDP нужно сделать так, чтобы сервер его не считал
|
||||
> `Unauthorized probe` (rate-limit/гейт), иначе лог-флуд и риск самобана.
|
||||
|
||||
---
|
||||
|
||||
## B. ОБЯЗАТЕЛЬНО перенести (фиксы стабильности)
|
||||
- **fd limits / EMFILE** — `922cf0b`.
|
||||
- **Lifecycle хелпера**: принудительный `std::process::exit` после остановки, чтобы
|
||||
не оставался зомби-процесс, держащий адаптер `ostp_tun` и дефолтный маршрут — `b6e78c1`.
|
||||
- **Bypass-маршрут сервера через `route.exe` по шлюзу** (а не legacy
|
||||
`CreateIpForwardEntry`, который падал с err 160 из-за рассинхрона индексов
|
||||
интерфейсов) — `b6e78c1`.
|
||||
- **IPC хелпера** (ChaCha20Poly1305 + hex) + **единый формат логов** — `ee38b15`.
|
||||
- **Closing-state fix** + `sent_history` на `BTreeMap` (O(log n) NACK) — `47d44fa`.
|
||||
- **Handshake timeout fixes** — `d65af35`, `6eb7b36` (ждать ответ до отправки данных).
|
||||
- **Buffer / UDP handler** — `b5e830a`.
|
||||
- **Логи**: UoT и unauthorized-probe → debug; rate-limit probe-лога — `1151726`, `fc339b3`.
|
||||
|
||||
---
|
||||
|
||||
## C. Протокол / крипто — решить и перенести
|
||||
- **Derived secrets handshake** — `f8f27d3`. PSK и obfuscation-key выводятся из
|
||||
access-key через HKDF; handshake-payload = `[timestamp][session_id][access_key]`;
|
||||
параметры паддинга деривируются; timestamp anti-replay (±300с).
|
||||
⚠️ **Ломает совместимость с 0.2.98 wire** (старый клиент не подключится).
|
||||
Безопаснее старого (raw-PSK + нулевой obfuscation-key). **РЕШЕНИЕ:** переносим ли
|
||||
(тогда нужен ребилд всех клиентов) — ДА, скорее всего, но осознанно.
|
||||
- **l4_protocol** для server outbound — `2997bfd`, `ad3a8cb`, `aae9d22`.
|
||||
|
||||
---
|
||||
|
||||
## D. Транспорты — перенести аккуратно (большие куски)
|
||||
- **DNS transport (dnstt)** как fallback — `3f1adbc`, `3ced4a1`, `d031b15`,
|
||||
`10c1772`, `b31da29`. Полезно против блокировок, но объёмно и со своей
|
||||
фрагментацией/reassembly. Переносить отдельным изолированным модулем.
|
||||
- UoT (UDP-over-TCP) — уже есть в 0.2.98, проверить что не сломан.
|
||||
|
||||
---
|
||||
|
||||
## E. Тонкая настройка junk/фрагментации (как в AmneziaWG)
|
||||
Junk и фрагментацию **оставляем** (Решения), а это — их параметризация. Главное
|
||||
условие: **координация клиент↔сервер**, иначе junk превращается в probe-флуд.
|
||||
- `Jc` — кол-во junk-пакетов, `Jmin`/`Jmax` — размеры; **сервер знает и молча отбрасывает**.
|
||||
- `S1`/`S2` — размеры init/response подгоняются.
|
||||
- Магические заголовки/сигнатуры пакетов (`H1..H4`).
|
||||
Реализовать как явные настраиваемые поля (не хардкод). Сервер ОБЯЗАН их понимать.
|
||||
Сам факт junk/frag — в базе; это «желание» — сделать их настраиваемыми. Можно потом.
|
||||
|
||||
---
|
||||
|
||||
## F. GUI (desktop) — перенести нужное, без хаоса
|
||||
- Профили на странице **настроек** (пусто + «Create a new profile» + «+» когда нет
|
||||
профиля; «+» → меню «из ссылки / вручную»). Главный экран не усложнять.
|
||||
- **Share** профиля: QR (генерить локально, ключ наружу не отдавать — крейт `qrcode`)
|
||||
+ копируемая `ostp://` ссылка.
|
||||
- **Метрики**: байты считать в TUN-инбаунде; rtt брать из round-trip handshake
|
||||
(а не отдельным TCP-probe).
|
||||
- **Health/состояние**: «connected» по реальной достижимости сервера на ПРАВИЛЬНОМ
|
||||
порту (не хардкод :443), а не по факту «процесс запустился».
|
||||
- **routing**: всегда задавать `default_outbound: "proxy"`; ключи правил —
|
||||
`domain_suffix` / `ip_cidr` / `process_name` (не `domains/ips/processes`).
|
||||
- Смена сервера = полный **stop+start**, а не hot-reload (иначе остаётся старый сервер).
|
||||
- Никаких непрогарженных `addEventListener` на удалённые элементы (краш init).
|
||||
|
||||
---
|
||||
|
||||
## G. Мобилка (Flutter + JNI) — перенести нужное
|
||||
- **routing**: тот же `default_outbound` + правильные ключи правил
|
||||
(без них трафик шёл мимо туннеля — реальный IP).
|
||||
- **Байты на Android**: считать в обеих задачах fd-пути (read=upload, write=download).
|
||||
- **rtt**: из handshake (health-probe сокет на Android не protected → до сервера не доходит).
|
||||
- **Смена сети (WiFi↔LTE)**: реальный reconnect (сейчас `notifyNetworkChanged` — no-op).
|
||||
- **fd ownership**: НЕ двойное закрытие (Rust `OwnedFd` + Kotlin `close()`) → `detachFd()`.
|
||||
- **Share** профиля: QR (`qr_flutter`) + ссылка.
|
||||
- Выкинуть tun2socks (см. §A.3).
|
||||
|
||||
---
|
||||
|
||||
## H. Инфра / лицензия / брендинг
|
||||
- Лицензия: **AGPLv3** ✅ (зафиксировано). В 0.2.98 был BSL 1.1 → заменить (`9ce9e6d`).
|
||||
- **Брендинг — ОСТАВЛЯЕМ** ✅: тёмная тема + орёл на фоне (watermark/логотип) в GUI.
|
||||
Перенести из текущего `ostp-gui` (assets/logo.svg, тёмная палитра) в чистую переборку.
|
||||
- Панель/license-check: open-source без license-check — `5782107`, `99ff76d` (если нужно).
|
||||
- Версионирование/CI build-script — `774d926` и пр.
|
||||
|
||||
---
|
||||
|
||||
## Инвентаризация базы 0.2.98 (что уже есть / что портировать)
|
||||
- **Есть в 0.2.98**: WSS (→ удалить), Reality/`reality.rs` (→ удалить),
|
||||
инфра derived-secrets (`derive_all_secrets`, `obfuscation_key`) — но клиент юзал
|
||||
dummy-ключи до `f8f27d3`.
|
||||
- **Нет в 0.2.98 — портировать из пост-0.2.98 кода**: junk-пакеты, TCP-фрагментация,
|
||||
DNS-transport (dnstt), фикс derived-secrets `f8f27d3`, все фиксы §B, GUI/мобилка §F/§G.
|
||||
|
||||
## Что легко упустить (решить до старта)
|
||||
1. **Версия переборки — 0.4.0** (решено). Сожжённые 0.3.x не переиспользуем.
|
||||
2. **Серверный конфиг — тоже плоский** и согласован с клиентским. Сервер обязан
|
||||
поддерживать всё оставленное: derived-secrets (и **отвергать** старый формат),
|
||||
корректную обработку junk (не probe-флуд), UoT, DNS-transport, management API.
|
||||
3. **Версия/магический байт протокола ДО крипто-слоя.** Сейчас нельзя отличить старый
|
||||
handshake от нового — отсюда баг «старый клиент → новый сервер подключился».
|
||||
Добавить версию в wire → будущие изменения управляемы, сервер чётко режет
|
||||
несовместимое. Это системный фикс проблемы derived-secrets.
|
||||
4. **Клиент и сервер обновляются ВМЕСТЕ** — derived-secrets ломает совместимость,
|
||||
смешивать старое и новое нельзя. Координировать выкладку.
|
||||
5. **Reality — выкинуть** (решено; в базе 0.2.98 есть `reality.rs` → удалить целиком).
|
||||
6. **Verify-loop = критерий «готово».** Каждая фича проверяется реальным тестом, не
|
||||
«на словах»: connect → `curl` показывает IP **сервера**; старый клиент к новому
|
||||
серверу **не** подключается; смена сети на мобилке восстанавливает туннель.
|
||||
|
||||
## Порядок переборки (предложение, 1 сессия)
|
||||
1. §B (фиксы стабильности) — на чистый 0.2.98.
|
||||
2. §C (derived-secrets) — и СРАЗУ проверить: старый клиент к новому серверу НЕ
|
||||
подключается (в прошлый раз был баг — подключался).
|
||||
3. **Junk + TCP-фрагментация** — перенести (оставляем), но junk по UDP не должен
|
||||
читаться сервером как `Unauthorized probe` (rate-limit/гейт на сервере).
|
||||
4. §F/§G по минимуму (routing, метрики, состояние, share) **+ брендинг** (тёмная
|
||||
тема, орёл на фоне).
|
||||
5. §D (DNS transport) — если нужно.
|
||||
6. ВЫКИНУТЬ: **WSS, multi-server, tun2socks** (§A). §E (тюнинг junk) — позже.
|
||||
7. Конфиг: плоский по сути, секционно-оформленный, один сервер (РЕШЕНО — без
|
||||
inbounds/outbounds/routing-движка).
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<defs>
|
||||
<linearGradient id="g2" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#111827" />
|
||||
<stop offset="100%" stop-color="#374151" />
|
||||
</linearGradient>
|
||||
<linearGradient id="g2_path" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#3B82F6" />
|
||||
<stop offset="100%" stop-color="#14B8A6" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="512" height="512" rx="120" fill="url(#g2)" />
|
||||
<path d="M144 256c0-61.9 50.1-112 112-112s112 50.1 112 112-50.1 112-112 112S144 317.9 144 256zm-48 0c0 88.4 71.6 160 160 160s160-71.6 160-160S344.4 96 256 96 96 167.6 96 256z" fill="url(#g2_path)"/>
|
||||
<circle cx="256" cy="256" r="40" fill="#F59E0B" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 779 B |
|
|
@ -0,0 +1,7 @@
|
|||
|
||||
____ _____ _______ _____
|
||||
/ __ \ / ____|__ __| __ \
|
||||
| | | | (___ | | | |__) |
|
||||
| | | |\___ \ | | | ___/
|
||||
| |__| |____) | | | | |
|
||||
\____/|_____/ |_| |_|
|
||||
|
|
@ -5,40 +5,38 @@ Traditional tunneling protocols (such as TLS, OpenVPN, and WireGuard) exhibit di
|
|||
|
||||
---
|
||||
|
||||
## Obfuscation Key Derivation
|
||||
## Secret Derivation
|
||||
|
||||
To dynamically mask protocol data, an 8-byte obfuscation key is statically derived from the shared `access_key` configured on both the client and the server:
|
||||
Every protocol secret — the obfuscation key, the Noise PSK, the handshake padding range, and the per-key junk marker (see below) — is derived from the shared `access_key` via a single HKDF-SHA256 pass, domain-separated by a trailing info byte per output:
|
||||
|
||||
$$\text{Key} = \text{SHA-256}(\text{access\_key})[0..8]$$
|
||||
```
|
||||
PRK = HKDF-Extract(salt = SHA-256(access_key)[0..16], IKM = access_key || PROTOCOL_VERSION)
|
||||
obfuscation_key = HKDF-Expand(PRK, info = SHA-256(access_key)[16..] || 0x01, 8 bytes)
|
||||
psk = HKDF-Expand(PRK, info = SHA-256(access_key)[16..] || 0x02, 32 bytes)
|
||||
handshake_pad = HKDF-Expand(PRK, info = SHA-256(access_key)[16..] || 0x03, 2 bytes)
|
||||
junk_marker = HKDF-Expand(PRK, info = SHA-256(access_key)[16..] || 0x04, 4 bytes)
|
||||
```
|
||||
|
||||
This key is established pre-session and is never transmitted across the wire in any capacity.
|
||||
The wire protocol version is mixed into the IKM, not sent as a plaintext byte: peers on a different protocol version derive an entirely different `obfuscation_key`, so they simply cannot deobfuscate each other's packets and are rejected as unauthorized — a hard version gate with no recognizable marker ever appearing on the wire. No secret is ever transmitted; both sides derive the same values independently from the shared access key.
|
||||
|
||||
---
|
||||
|
||||
## Dynamic In-Place Masking Algorithm
|
||||
|
||||
OSTP datagrams are processed "in-place" immediately prior to transmission and right after arrival. Two distinct mathematical modes are utilized based on the current handshake phase:
|
||||
OSTP datagrams are masked "in-place" immediately prior to transmission and right after arrival. The mask itself is **derived from the packet's own ciphertext**, not from a fixed keystream or a counter, so it changes with every packet automatically:
|
||||
|
||||
```
|
||||
mask = HMAC-SHA256(key = obfuscation_key, message = ciphertext[0..min(32, len)])
|
||||
```
|
||||
|
||||
### 1. Handshake Phase Mode (`is_handshake = true`)
|
||||
During connection initiation (Noise Handshake), the wire packet consists of a 4-byte `session_id` prefixed to the Noise payload. To mask the fixed session ID:
|
||||
|
||||
* **Masking**: The first 4 bytes are XORed with the first 4 bytes of the derived obfuscation key:
|
||||
$$\text{raw}[i] = \text{raw}[i] \oplus \text{Key}[i \pmod 8], \quad i \in [0..3]$$
|
||||
* **De-masking**: A repeated XOR with the identical key bytes recovers the original `session_id`.
|
||||
The wire packet is `[4-byte session_id][2-byte noise_len][Noise payload]`. The mask is computed over the Noise payload (`raw[6..]`), and its first 6 bytes are XORed onto `session_id || noise_len`.
|
||||
|
||||
### 2. Data Transmission Mode (`is_handshake = false`)
|
||||
Post-handshake, the wire layout contains:
|
||||
`[4-byte session_id]` + `[8-byte nonce]` + `[AEAD Ciphertext]`
|
||||
The wire packet is `[4-byte session_id][8-byte nonce][AEAD ciphertext]`. The mask is computed over the AEAD ciphertext, and its first 12 bytes are XORed onto `session_id || nonce`.
|
||||
|
||||
To completely randomize metadata, a two-tiered dynamic XOR masking process is applied:
|
||||
|
||||
1. **Nonce Masking**: The 8-byte `nonce` (sequence counter) is XORed with the full 8-byte static key:
|
||||
$$\text{nonce\_bytes}[i] = \text{nonce\_bytes}[i] \oplus \text{Key}[i], \quad i \in [0..7]$$
|
||||
2. **Session ID Masking**: The 4-byte `session_id` is masked using high dynamic entropy — the lower 32 bits of the **original (unmasked)** `nonce` value:
|
||||
$$\text{session\_id\_bytes}[i] = \text{session\_id\_bytes}[i] \oplus \text{real\_nonce\_low32\_bytes}[i], \quad i \in [0..3]$$
|
||||
|
||||
#### Impact of the Scheme:
|
||||
Because the `nonce` increments strictly with each outgoing datagram, the session ID's masking keystream continuously changes. This breaks all packet header correlations and eliminates repeating byte patterns, rendering statistical fingerprinting futile.
|
||||
#### Impact of the Scheme
|
||||
Because the mask is keyed on both the shared secret and the packet's own ciphertext, no two packets — even consecutive ones from the same session — share a keystream, without needing an explicit counter-based scheme. This breaks all packet header correlations and eliminates repeating byte patterns, rendering statistical fingerprinting futile.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -49,3 +47,14 @@ The `AdaptivePadder` calculates dynamic dummy byte quantities to append to the p
|
|||
|
||||
- **Dynamic Distributions**: The padding algorithms emulate length profiles commonly seen in whitelisted HTTPS or real-time video streams.
|
||||
- **Encrypted Overheads**: The appended padding resides within the AEAD cipher scope. Consequently, passive observers cannot distinguish padding bytes from useful application payload, hiding the true message boundary lengths.
|
||||
|
||||
---
|
||||
|
||||
## Junk Packets & TCP Fragmentation
|
||||
|
||||
OSTP does not try to impersonate a known protocol (TLS, HTTP, or otherwise) — a fingerprint-matching filter can always be updated to catch an impersonation attempt. Instead it follows a **zapret-like** approach: no recognizable header at all, plus active manipulation of packet boundaries, so there is nothing distinctive to fingerprint in the first place.
|
||||
|
||||
- **Junk packets**: before the handshake, the client sends a configurable number (`junk_pc`) of random-size (`junk_ps`) filler datagrams. Each carries a 4-byte marker **derived from the access key** (the `junk_marker` above) rather than a fixed constant — a fixed marker would itself be a universal signature any observer could filter on across every OSTP deployment. The server derives the same per-key marker while trying candidate keys and drops matching junk silently, before it ever reaches the "unauthorized probe" logging path.
|
||||
- **TCP fragmentation** (UoT/TCP transport only): the first packet (the handshake) is split into small chunks (`frag_chunk` bytes) with short delays (`frag_sleep` ms) between writes, so DPI that inspects only the first TCP segment never sees a complete handshake to fingerprint.
|
||||
|
||||
Both are configurable per-profile; neither is sent over plain UDP transport, where a standalone junk datagram would look exactly like a random one-off probe to the server.
|
||||
|
|
@ -90,11 +90,22 @@ Because the `Nonce` is unique per packet, the mask is cryptographically independ
|
|||
|
||||
OSTP executes a Noise Protocol Framework exchange utilizing the `Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s` pattern.
|
||||
|
||||
1. The Registration Key (`access_key`) is converted to a 32-octet strong pre-shared key (PSK) via SHA-256.
|
||||
1. The Registration Key (`access_key`) is converted to a 32-octet strong pre-shared key (PSK) via HKDF-SHA-256.
|
||||
2. The PSK is integrated into the state at pattern position zero, authorizing and encrypting the very first handshaking datagram.
|
||||
3. Ephemeral Curve25519 key exchange is evaluated to synthesize autonomous symmetric keys for subsequent read/write channels.
|
||||
3. Ephemeral Curve25519 key exchange (`ee`) is evaluated, and the two directional transport keys are taken from Noise's `Split()` over the final chaining key `ck`.
|
||||
|
||||
The initial handshake payload includes a Unix timestamp to mitigate replay attacks. The server enforces a strict ±30-second synchronization window.
|
||||
> **Forward secrecy.** The transport keys are derived from the chaining key
|
||||
> `ck`, which absorbs the ephemeral `ee` Diffie-Hellman result. They are **not**
|
||||
> derived from the Noise handshake hash `h` — `h` only ever absorbs public
|
||||
> transcript data (ephemeral public keys and on-wire ciphertexts) and never the
|
||||
> DH secret, so keys derived from it would give an access-key holder the ability
|
||||
> to decrypt any recorded session. Deriving from `ck` binds each session to its
|
||||
> ephemeral private keys, which are discarded after the handshake: an adversary
|
||||
> who later compromises the PSK still cannot decrypt past traffic. This is a
|
||||
> wire-breaking property gated by the internal protocol version (currently 5);
|
||||
> peers on an older version derive different keys and cannot interoperate.
|
||||
|
||||
The initial handshake payload includes a Unix timestamp to mitigate replay attacks. The server enforces a ±300-second (5-minute) synchronization window and additionally records accepted handshakes in an anti-replay set for that window.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -102,16 +113,23 @@ The initial handshake payload includes a Unix timestamp to mitigate replay attac
|
|||
|
||||
### 7.1 Selective-Repeat ARQ
|
||||
OSTP provides reliability over UDP using a **Selective-Repeat ARQ** mechanism:
|
||||
* The receiver maintains a reorder buffer (default: 8192 packets).
|
||||
* Unacknowledged packets are retransmitted after an adaptive Retransmission Time Out (RTO).
|
||||
* Acknowledgments (ACKs) are piggybacked onto outbound data frames to minimize overhead.
|
||||
* Backpressure is applied dynamically based on the number of in-flight unacknowledged frames.
|
||||
* The receiver maintains a reorder buffer (default: 32768 packets) for out-of-order packet reassembly.
|
||||
* Acknowledgments use a **Cumulative + SACK** scheme: the ACK payload contains a cumulative range `(0, expected_recv_nonce - 1)` confirming all contiguous packets received, plus up to 7 additional Selective ACK ranges for non-contiguous blocks in the reorder buffer.
|
||||
* **Rate-limited NACK:** When a gap is detected, the receiver emits a NACK for the lowest missing nonce, but no more than once per 30ms. This prevents retransmission storms under normal UDP jitter.
|
||||
* **Retransmission:** Unacknowledged data frames are retransmitted after an adaptive Retransmission Timeout (RTO, default: 100ms) with exponential backoff (up to 64× base RTO).
|
||||
* **Zombie Frame Eviction:** Frames exceeding `max_retries + 4` attempts are automatically dropped from the send history, preventing unbounded memory consumption and stale retransmissions.
|
||||
* **In-flight Counting:** Backpressure is based only on retransmittable (data) frames; control frames (ACK/NACK) are excluded from the in-flight count to prevent false backpressure under high load.
|
||||
* **Graceful Close:** The `Closing` state processes all remaining in-flight packets before transitioning to `Closed`, preventing data loss during session teardown.
|
||||
|
||||
### 7.2 Adaptive Padding
|
||||
To resist traffic analysis via Packet Length Analysis (PLA), OSTP pads plaintext payloads before AEAD encryption. Padding bytes are drawn from a cryptographically secure random source. The protocol supports dynamic padding boundaries up to the maximum MTU (e.g., 1400 bytes), smoothing out recognizable application traffic bursts into constant-bitrate-like streams.
|
||||
|
||||
### 7.3 IP Roaming
|
||||
The server supports seamless network handoffs (e.g., transitioning from Wi-Fi to cellular networks). If a packet successfully passes AEAD authentication, the server automatically binds the Session ID to the new source IP address without requiring a session restart.
|
||||
The server supports seamless network handoffs (e.g., transitioning from Wi-Fi to cellular networks). If a packet successfully passes AEAD authentication, the server automatically binds the Session ID to the new source IP address without requiring a session restart. The server maintains a rate-limited roaming scanner (50 tokens/sec) to prevent CPU exhaustion from probing attacks.
|
||||
|
||||
### 7.4 Session Keepalive
|
||||
* **Client-side:** Ping/Pong frames with RTT measurement are sent every 5 seconds. If no valid UDP packet is received for 60 seconds, the client initiates reconnection.
|
||||
* **Server-side:** Sessions with no activity for 300 seconds are automatically evicted.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -119,4 +137,5 @@ The server supports seamless network handoffs (e.g., transitioning from Wi-Fi to
|
|||
|
||||
* **Nonce Exhaustion:** The Nonce field is 64 bits. Implementations MUST terminate and re-key a session before the Nonce overflows to prevent AEAD keystream reuse.
|
||||
* **Session Exhaustion (DoS):** Servers MUST enforce a strict cap on concurrent sessions (e.g., 1024) and silently drop handshake attempts exceeding this limit to prevent memory exhaustion attacks.
|
||||
* **Handshake-trial CPU DoS:** Because there is no cleartext key identifier on the wire (a deliberate stealth property), a datagram from an unknown source must be trial-decrypted against every registered key. Servers MUST bound this work: OSTP caches each key's derived secrets and time-windowed junk markers (so a trial is a cheap comparison plus one AEAD attempt per key, not a fresh HKDF/HMAC), and gates the trial path behind a global token bucket (default 100/s) so a spoofed-source flood cannot force unbounded per-packet crypto. The established-session fast path and IP-roaming path are not subject to this bucket.
|
||||
* **Header Authentication:** The header obfuscation mechanism provides privacy, not integrity. Header integrity is mathematically guaranteed by the Poly1305 Authentication Tag, which covers the entire 12-byte header as Additional Authenticated Data (AAD).
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
{
|
||||
// OSTP Relay Node Configuration
|
||||
// Этот узел принимает соединения от клиентов, проверяет их аутентификацию
|
||||
// и пробрасывает трафик к целевому серверу.
|
||||
//
|
||||
// Архитектура цепочки:
|
||||
// Клиент -> [Этот Relay] -> [Relay 2] -> ... -> [Target Server]
|
||||
//
|
||||
// Ключи синхронизируются напрямую с API Target Server каждые N секунд.
|
||||
// Relay не знает содержимого трафика — только проверяет HMAC-подпись.
|
||||
|
||||
"mode": "relay",
|
||||
|
||||
// Адрес, на котором relay слушает входящие соединения от клиентов
|
||||
"listen": "0.0.0.0:50000",
|
||||
|
||||
// Адрес следующего узла в цепочке (другой relay или конечный сервер) — TCP (UoT)
|
||||
"upstream_tcp": "TARGET_SERVER_IP:50000",
|
||||
|
||||
// Адрес следующего узла в цепочке — UDP
|
||||
"upstream_udp": "TARGET_SERVER_IP:50000",
|
||||
|
||||
// URL API конечного (целевого) сервера для синхронизации access_keys.
|
||||
// Должен быть доступен с этого relay-сервера (можно через SSH-туннель).
|
||||
//
|
||||
// ВАЖНО: URL обязан включать секретный путь панели (api.webpath целевого
|
||||
// сервера). Management API смонтирован ВНУТРИ этого пути — именно он скрывает
|
||||
// панель от сканеров, — поэтому голый host:port попадает в несуществующий
|
||||
// маршрут, и синхронизация падает с 404 ещё до проверки токена.
|
||||
// Это тот же адрес, по которому вы открываете веб-панель.
|
||||
"upstream_api_url": "http://TARGET_SERVER_IP:9090/TARGET_SERVER_WEBPATH",
|
||||
|
||||
// Bearer-токен для доступа к API целевого сервера
|
||||
// Должен совпадать с api.token в конфиге target-сервера
|
||||
"upstream_api_token": "YOUR_API_TOKEN_HERE",
|
||||
|
||||
// Интервал синхронизации ключей в секундах (по умолчанию: 30)
|
||||
"sync_interval_secs": 30,
|
||||
|
||||
"debug": false
|
||||
}
|
||||
|
|
@ -1,51 +1,60 @@
|
|||
# Маскирование энтропии сигналов OSTP
|
||||
# Обфускация трафика OSTP
|
||||
|
||||
## Философия структуры канала
|
||||
## Философия
|
||||
|
||||
Традиционные сетевые протоколы промышленного сбора данных могут обладать фиксированными заголовками, что при анализе статистического распределения байт ведет к предвзятости выборок и искажению телеметрического профиля. Задача механизмов энтропийного маскирования OSTP — достижение **равномерного вероятностного распределения значений байт**, начиная с самого первого пакета. Это делает сигналы шины данных абсолютно однородными и устойчивыми к корреляционному анализу и структурному мониторингу сетевых контроллеров.
|
||||
Классические туннельные протоколы (TLS, OpenVPN, WireGuard) имеют узнаваемые сигнатуры в хэндшейке или статичные заголовки пакетов. Механизм обфускации OSTP спроектирован так, чтобы **начиная с первого байта** трафик был максимально похож на случайный шум — и для DPI-систем был неотличим от него.
|
||||
|
||||
---
|
||||
|
||||
## Производная сигнатурная матрица (Keystream Initialization Vector)
|
||||
## Деривация секретов
|
||||
|
||||
Для стабилизации битового распределения используется 8-байтовый вектор, вычисляемый на базе глобального идентификатора регистрации узла (`access_key`):
|
||||
Все секреты протокола — ключ обфускации, PSK Noise-хэндшейка, диапазон паддинга хэндшейка и маркер junk-пакетов (см. ниже) — выводятся из общего `access_key` одним проходом HKDF-SHA256, с разделением по доменам через последний байт `info`:
|
||||
|
||||
$$\text{Key} = \text{SHA-256}(\text{access\_key})[0..8]$$
|
||||
```
|
||||
PRK = HKDF-Extract(salt = SHA-256(access_key)[0..16], IKM = access_key || PROTOCOL_VERSION)
|
||||
obfuscation_key = HKDF-Expand(PRK, info = SHA-256(access_key)[16..] || 0x01, 8 байт)
|
||||
psk = HKDF-Expand(PRK, info = SHA-256(access_key)[16..] || 0x02, 32 байта)
|
||||
handshake_pad = HKDF-Expand(PRK, info = SHA-256(access_key)[16..] || 0x03, 2 байта)
|
||||
junk_marker = HKDF-Expand(PRK, info = SHA-256(access_key)[16..] || 0x04, 4 байта)
|
||||
```
|
||||
|
||||
Данная последовательность фиксируется на передающем и принимающем узлах и не передается через внешние сетевые шлюзы.
|
||||
Версия протокола подмешивается в IKM, а не передаётся открытым байтом на проводе: пиры с разной версией протокола выведут разный `obfuscation_key` и просто не смогут деобфусцировать пакеты друг друга — жёсткий version gate без единого узнаваемого маркера на проводе. Ни один секрет никогда не передаётся — обе стороны независимо выводят одинаковые значения из общего access_key.
|
||||
|
||||
---
|
||||
|
||||
## Алгоритм динамического маскирования пакетов (In-place Masking)
|
||||
## Алгоритм динамического маскирования
|
||||
|
||||
Пакетные структуры OSTP проходят низкоуровневую предобработку непосредственно перед выдачей в канальный уровень (Layer 3) и при получении. В зависимости от фазы жизненного цикла сессии связи выделяют две модели:
|
||||
Датаграммы OSTP маскируются "на месте" прямо перед отправкой и сразу после получения. Сама маска **выводится из шифротекста самого пакета**, а не из статичного потока ключа или счётчика — поэтому она меняется от пакета к пакету автоматически:
|
||||
|
||||
### 1. Этап начального согласования среды (`is_handshake = true`)
|
||||
В период инициализации канала передачи пакет структурирован как 4-байтовое поле логического адреса порта `session_id` и криптографический блок согласования среды. Для подавления статических компонент ID порта применяется процедура обратимого битового сложения:
|
||||
```
|
||||
mask = HMAC-SHA256(key = obfuscation_key, message = ciphertext[0..min(32, len)])
|
||||
```
|
||||
|
||||
* **Обработка**: Первые 4 байта вектора пакета проходят побитовую операцию XOR с первыми 4 байтами сигнатурной матрицы:
|
||||
$$\text{raw}[i] = \text{raw}[i] \oplus \text{Key}[i \pmod 8], \quad i \in [0..3]$$
|
||||
* **Восстановление**: Обратное наложение сигнатурной матрицы возвращает корректное значение логического идентификатора.
|
||||
### 1. Фаза хэндшейка (`is_handshake = true`)
|
||||
Пакет на проводе — `[4 байта session_id][2 байта noise_len][Noise-полезная нагрузка]`. Маска считается по Noise-полезной нагрузке (`raw[6..]`), и её первые 6 байт накладываются XOR'ом на `session_id || noise_len`.
|
||||
|
||||
### 2. Этап высокоскоростного переноса данных (`is_handshake = false`)
|
||||
После перевода сессии в состояние активности кадр передачи принимает следующий вид:
|
||||
`[4 байта session_id]` + `[8 байт nonce]` + `[Полезная нагрузка блока]`
|
||||
### 2. Фаза передачи данных (`is_handshake = false`)
|
||||
Пакет на проводе — `[4 байта session_id][8 байт nonce][AEAD-шифротекст]`. Маска считается по шифротексту, и её первые 12 байт накладываются XOR'ом на `session_id || nonce`.
|
||||
|
||||
Для максимизации дифференциальной энтропии применяется двухступенчатое динамическое взвешивание:
|
||||
|
||||
1. **Коррекция счетчика цикла (Nonce Correction)**: 8-байтовое значение инкрементного счетчика пакета подвергается побитовому сложению с вектором матрицы:
|
||||
$$\text{nonce\_bytes}[i] = \text{nonce\_bytes}[i] \oplus \text{Key}[i], \quad i \in [0..7]$$
|
||||
2. **Маскирование ID сессии**: 4-байтовое поле логического адреса маскируется с помощью переменной высокочастотной энтропии — младших 32 бит **исходного** показателя системного счетчика пакетов:
|
||||
$$\text{session\_id\_bytes}[i] = \text{session\_id\_bytes}[i] \oplus \text{real\_nonce\_low32\_bytes}[i], \quad i \in [0..3]$$
|
||||
|
||||
#### Статистическая устойчивость:
|
||||
Благодаря инкрементации счетчика на каждом цикле отправки, маскирующий поток (keystream) для поля `session_id` постоянно видоизменяется. Это полностью нивелирует фиксированные битовые паттерны во всем спектре UDP-датаграмм и исключает появление повторяющихся префиксов.
|
||||
#### Эффект схемы
|
||||
Поскольку маска зависит одновременно от общего секрета и от содержимого шифротекста конкретного пакета, никакие два пакета — даже два подряд идущих в одной сессии — не используют одинаковый ключевой поток, и для этого не нужна явная схема на основе счётчика. Это полностью убирает корреляции между заголовками пакетов и повторяющиеся байтовые паттерны, делая статистический фингерпринтинг бесполезным.
|
||||
|
||||
---
|
||||
|
||||
## Выравнивание блоков по границам регистров (Adaptive Alignment)
|
||||
## Статистический паддинг
|
||||
|
||||
Дополнительно к маскировке заголовков, протокол OSTP исключает возможность анализа поведения системы на основе длин пакетов данных. Модуль адаптивного заполнения (`AdaptivePadder`) рассчитывает оптимальный размер буфера выравнивания (`padding`), интегрируемый в структуру пакета до момента активации шифрующего каскада:
|
||||
Помимо маскирования заголовков, OSTP защищается от анализа длин пакетов (Traffic Length Analysis). `AdaptivePadder` вычисляет случайный размер мусорных байт, добавляемых к полезной нагрузке ещё до шифрования:
|
||||
|
||||
- **Стратегия заполнения буферов**: Механизм анализирует текущую длину выборки телеметрии и производит масштабирование до типичных кратных длин промышленных сетей передачи данных и буферов потоковых агрегаторов.
|
||||
- **Изоляция выравнивания**: Данные заполнения помещаются внутрь защищенной области кадра. Внешние анализаторы топологии сети не способны определить внутренние границы между телеметрической нагрузкой и служебными полями выравнивания, видя только монолитный блок данных.
|
||||
- **Динамическое распределение**: длины паддинга подобраны так, чтобы напоминать профили длин обычного HTTPS-трафика или видеопотоков.
|
||||
- **Внутри шифротекста**: добавленный паддинг находится внутри области AEAD-шифрования — пассивный наблюдатель не может отличить паддинг от полезной нагрузки и не видит настоящую границу сообщения.
|
||||
|
||||
---
|
||||
|
||||
## Junk-пакеты и TCP-фрагментация
|
||||
|
||||
OSTP не пытается притворяться известным протоколом (TLS, HTTP и т.п.) — фильтр по сигнатуре всегда можно обновить под конкретную имитацию. Вместо этого используется подход **в духе zapret**: никакого узнаваемого заголовка вообще, плюс активная манипуляция границами пакетов — фингерпринтить попросту нечего.
|
||||
|
||||
- **Junk-пакеты**: перед хэндшейком клиент отправляет настраиваемое количество (`junk_pc`) мусорных датаграмм случайного размера (`junk_ps`). Каждая несёт 4-байтовый маркер, **выведенный из access_key** (тот самый `junk_marker` выше), а не фиксированную константу — константный маркер сам по себе стал бы универсальной сигнатурой для любого наблюдателя сразу по всем серверам OSTP. Сервер, перебирая кандидатов-ключей, выводит тот же маркер и тихо отбрасывает junk, не доходя до логирования «unauthorized probe».
|
||||
- **TCP-фрагментация** (только для транспорта UoT/TCP): первый пакет (хэндшейк) режется на мелкие куски (`frag_chunk` байт) с небольшими задержками (`frag_sleep` мс) между записями — DPI, анализирующий только первый TCP-сегмент, никогда не видит цельный хэндшейк для фингерпринтинга.
|
||||
|
||||
Обе фичи настраиваются per-профиль; ни одна не применяется поверх обычного UDP-транспорта, где отдельная junk-датаграмма выглядела бы для сервера точь-в-точь как случайный одиночный проб.
|
||||
|
|
@ -90,11 +90,23 @@ OSTP поддерживает **внутреннее криптографиче
|
|||
|
||||
OSTP использует Noise Protocol Framework с паттерном `Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s`.
|
||||
|
||||
1. Регистрационный ключ доступа (`access_key`) преобразуется в 32-байтный строгий предварительно распределенный ключ (PSK) через SHA-256.
|
||||
2. PSK применяется на нулевой позиции паттерна, обеспечивая авторизацию и шифрование самой первой датаграммы рукопожатия (Zero-RTT авторизация).
|
||||
3. Выполняется эфемерный обмен ключами Curve25519 для создания симметричных ключей передачи данных.
|
||||
1. Регистрационный ключ доступа (`access_key`) преобразуется в 32-байтный строгий предварительно распределенный ключ (PSK) через HKDF-SHA-256.
|
||||
2. PSK применяется на нулевой позиции паттерна, обеспечивая авторизацию и шифрование самой первой датаграммы рукопожатия.
|
||||
3. Выполняется эфемерный обмен ключами Curve25519 (`ee`), и два однонаправленных транспортных ключа берутся из `Split()` протокола Noise над финальным chaining key `ck`.
|
||||
|
||||
Первичная полезная нагрузка рукопожатия содержит Unix-отметку времени для защиты от атак повторного воспроизведения (Replay Attacks). Сервер строго контролирует окно синхронизации (±30 секунд).
|
||||
> **Прямая секретность (Forward Secrecy).** Транспортные ключи выводятся из
|
||||
> chaining key `ck`, который вбирает результат эфемерного обмена Диффи-Хеллмана
|
||||
> `ee`. Они **не** выводятся из handshake hash `h` протокола Noise: `h` вбирает
|
||||
> только публичные данные транскрипта (эфемерные публичные ключи и шифртексты с
|
||||
> провода) и никогда — сам DH-секрет, поэтому ключи, выведенные из `h`, дали бы
|
||||
> держателю PSK возможность расшифровать любую записанную сессию. Вывод из `ck`
|
||||
> привязывает каждую сессию к её эфемерным приватным ключам, которые
|
||||
> уничтожаются после рукопожатия: злоумышленник, скомпрометировавший PSK позже,
|
||||
> всё равно не сможет расшифровать прошлый трафик. Это свойство ломает
|
||||
> совместимость и защищено внутренней версией протокола (сейчас 5): узлы более
|
||||
> старой версии выводят другие ключи и не могут взаимодействовать.
|
||||
|
||||
Первичная полезная нагрузка рукопожатия содержит Unix-отметку времени для защиты от атак повторного воспроизведения (Replay Attacks). Сервер контролирует окно синхронизации (±300 секунд, 5 минут) и дополнительно фиксирует принятые рукопожатия в множестве защиты от повтора на время этого окна.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -119,4 +131,5 @@ OSTP обеспечивает надежную доставку поверх UDP
|
|||
|
||||
* **Исчерпание Nonce:** Поле Nonce имеет размер 64 бита. Реализации ОБЯЗАНЫ разрывать сессию до переполнения Nonce, чтобы предотвратить катастрофическое повторное использование гаммы AEAD-шифра.
|
||||
* **DDoS и исчерпание ресурсов:** Серверы ДОЛЖНЫ применять жесткий лимит на количество одновременных сессий (например, 1024) и молча отбрасывать запросы на рукопожатие при превышении лимита, предотвращая атаки на исчерпание памяти.
|
||||
* **CPU-DoS на пути перебора рукопожатия:** Поскольку на проводе нет открытого идентификатора ключа (намеренное свойство скрытности), датаграмму от неизвестного источника приходится пробно расшифровывать каждым зарегистрированным ключом. Серверы ОБЯЗАНЫ ограничивать эту работу: OSTP кэширует производные секреты каждого ключа и его junk-маркеры для текущего временно́го окна (поэтому одна попытка — это дешёвое сравнение плюс одна попытка AEAD на ключ, а не новые HKDF/HMAC), и ограничивает путь перебора глобальным token bucket (по умолчанию 100/с), так что флуд с подменённых адресов не может навязать неограниченную криптографию на пакет. Быстрый путь установленных сессий и путь IP-роуминга под этот лимит не попадают.
|
||||
* **Целостность заголовка:** Механизм маскирования обеспечивает только скрытность, а не целостность. Целостность заголовков математически гарантируется 16-байтным тегом аутентификации Poly1305, который покрывает 12-байтный заголовок как присоединенные данные (AAD).
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 769 KiB |
|
After Width: | Height: | Size: 183 KiB |
|
|
@ -0,0 +1,15 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<defs>
|
||||
<linearGradient id="g2" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#111827" />
|
||||
<stop offset="100%" stop-color="#374151" />
|
||||
</linearGradient>
|
||||
<linearGradient id="g2_path" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#3B82F6" />
|
||||
<stop offset="100%" stop-color="#14B8A6" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="512" height="512" rx="120" fill="url(#g2)" />
|
||||
<path d="M144 256c0-61.9 50.1-112 112-112s112 50.1 112 112-50.1 112-112 112S144 317.9 144 256zm-48 0c0 88.4 71.6 160 160 160s160-71.6 160-160S344.4 96 256 96 96 167.6 96 256z" fill="url(#g2_path)"/>
|
||||
<circle cx="256" cy="256" r="40" fill="#F59E0B" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 779 B |
|
|
@ -9,10 +9,24 @@ anyhow.workspace = true
|
|||
bytes.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
tracing-appender = "0.2"
|
||||
ostp-core = { path = "../ostp-core" }
|
||||
ostp-tun = { path = "../ostp-tun" }
|
||||
rand.workspace = true
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
json_comments = "0.2"
|
||||
portable-atomic.workspace = true
|
||||
chrono = "0.4"
|
||||
socket2 = "0.6.3"
|
||||
futures-util = "0.3.32"
|
||||
hmac = "0.12.1"
|
||||
sha2 = "0.10.8"
|
||||
base64 = "0.22.1"
|
||||
webpki-roots = "0.26"
|
||||
tun = { version = "0.8.9", features = ["async"] }
|
||||
netstack-smoltcp = "0.2.2"
|
||||
futures = "0.3.32"
|
||||
libc = "0.2.186"
|
||||
winapi = { version = "0.3.9", features = ["iphlpapi", "tcpmib", "processthreadsapi", "psapi", "handleapi", "winerror", "minwindef", "winnt", "iptypes", "ws2def"] }
|
||||
|
|
|
|||
|
|
@ -40,6 +40,9 @@ pub enum BridgeCommand {
|
|||
ToggleTunnel,
|
||||
NextProfile,
|
||||
ReloadConfig,
|
||||
/// Triggered by Android NetworkCallback when the active network changes (WiFi→LTE, etc.).
|
||||
/// Causes an immediate background reconnect without waiting for stall detection.
|
||||
NetworkChanged,
|
||||
Shutdown,
|
||||
}
|
||||
|
||||
|
|
@ -54,6 +57,12 @@ pub struct AppState {
|
|||
pub log_scroll: u16,
|
||||
}
|
||||
|
||||
impl Default for AppState {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
fn main() {
|
||||
let socket = std::net::UdpSocket::bind("0.0.0.0:0").unwrap();
|
||||
let port = socket.local_addr().unwrap().port();
|
||||
println!("Bound UDP to port {}", port);
|
||||
|
||||
if let Some(name) = ostp_client::tunnel::process_lookup::get_process_name_from_port_udp(port) {
|
||||
println!("Found process for UDP port {}: {}", port, name);
|
||||
} else {
|
||||
println!("Process not found for UDP port {}", port);
|
||||
}
|
||||
|
||||
let tcp_socket = std::net::TcpListener::bind("0.0.0.0:0").unwrap();
|
||||
let tcp_port = tcp_socket.local_addr().unwrap().port();
|
||||
println!("Bound TCP to port {}", tcp_port);
|
||||
|
||||
if let Some(name) = ostp_client::tunnel::process_lookup::get_process_name_from_port(tcp_port) {
|
||||
println!("Found process for TCP port {}: {}", tcp_port, name);
|
||||
} else {
|
||||
println!("Process not found for TCP port {}", tcp_port);
|
||||
}
|
||||
}
|
||||
|
|
@ -12,14 +12,23 @@ pub struct ClientConfig {
|
|||
pub debug: bool,
|
||||
pub ostp: OstpConfig,
|
||||
pub local_proxy: LocalProxyConfig,
|
||||
pub turn: TurnConfig,
|
||||
#[serde(default)]
|
||||
pub transport: TransportConfig,
|
||||
#[serde(default)]
|
||||
pub exclusions: ExclusionConfig,
|
||||
#[serde(default)]
|
||||
pub multiplex: MultiplexConfig,
|
||||
pub dns_server: Option<String>,
|
||||
#[serde(default = "default_tun_stack")]
|
||||
pub tun_stack: String,
|
||||
#[serde(default)]
|
||||
pub kill_switch: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub gui: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
fn default_tun_stack() -> String { "system".to_string() }
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ExclusionConfig {
|
||||
#[serde(default)]
|
||||
|
|
@ -44,30 +53,80 @@ pub struct OstpConfig {
|
|||
pub access_key: String,
|
||||
pub handshake_timeout_ms: u64,
|
||||
pub io_timeout_ms: u64,
|
||||
#[serde(default = "default_mtu")]
|
||||
pub mtu: usize,
|
||||
#[serde(default = "default_keepalive")]
|
||||
pub keepalive_interval_sec: u64,
|
||||
}
|
||||
|
||||
fn default_keepalive() -> u64 { 5 }
|
||||
|
||||
fn default_mtu() -> usize { 1140 }
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LocalProxyConfig {
|
||||
pub bind_addr: String,
|
||||
pub connect_timeout_ms: u64,
|
||||
}
|
||||
|
||||
/// Transport layer configuration.
|
||||
/// `mode` = "udp" (default) or "uot" (UDP over TCP, no protocol mimicry —
|
||||
/// zapret-like: no recognizable header at all, not a fake TLS/HTTP shell).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TurnConfig {
|
||||
pub enabled: bool,
|
||||
pub server_addr: String,
|
||||
pub username: String,
|
||||
pub access_key: String,
|
||||
pub struct TransportConfig {
|
||||
/// "udp" or "uot"
|
||||
#[serde(default = "default_transport_mode")]
|
||||
pub mode: 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.
|
||||
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 {
|
||||
Self {
|
||||
mode: default_transport_mode(),
|
||||
tcp_fragmentation: false,
|
||||
frag_chunk: default_frag_chunk(),
|
||||
frag_sleep: default_frag_sleep(),
|
||||
junk_pc: default_junk_count(),
|
||||
junk_ps: default_junk_size(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
impl Default for OstpConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
server_addr: "127.0.0.1:50000".to_string(),
|
||||
local_bind_addr: "0.0.0.0:0".to_string(),
|
||||
access_key: String::new(),
|
||||
handshake_timeout_ms: 10000,
|
||||
handshake_timeout_ms: 5000,
|
||||
io_timeout_ms: 2500,
|
||||
mtu: default_mtu(),
|
||||
keepalive_interval_sec: default_keepalive(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -81,16 +140,6 @@ impl Default for LocalProxyConfig {
|
|||
}
|
||||
}
|
||||
|
||||
impl Default for TurnConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
server_addr: String::new(),
|
||||
username: String::new(),
|
||||
access_key: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ClientConfig {
|
||||
fn default() -> Self {
|
||||
|
|
@ -99,10 +148,13 @@ impl Default for ClientConfig {
|
|||
debug: false,
|
||||
ostp: OstpConfig::default(),
|
||||
local_proxy: LocalProxyConfig::default(),
|
||||
turn: TurnConfig::default(),
|
||||
transport: TransportConfig::default(),
|
||||
exclusions: ExclusionConfig::default(),
|
||||
multiplex: MultiplexConfig::default(),
|
||||
dns_server: None,
|
||||
tun_stack: "system".to_string(),
|
||||
kill_switch: false,
|
||||
gui: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -125,16 +177,31 @@ struct RawUnifiedConfig {
|
|||
debug: Option<bool>,
|
||||
server: Option<String>,
|
||||
access_key: Option<String>,
|
||||
mtu: Option<usize>,
|
||||
socks5_bind: Option<String>,
|
||||
tun: Option<RawTunSection>,
|
||||
exclude: Option<RawExcludeSection>,
|
||||
mux: Option<RawMuxSection>,
|
||||
transport: Option<RawTransportSection>,
|
||||
gui: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RawTransportSection {
|
||||
mode: Option<String>,
|
||||
tcp_fragmentation: Option<bool>,
|
||||
frag_chunk: Option<usize>,
|
||||
frag_sleep: Option<u64>,
|
||||
junk_pc: Option<[usize; 2]>,
|
||||
junk_ps: Option<[usize; 2]>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RawTunSection {
|
||||
enable: Option<bool>,
|
||||
dns: Option<String>,
|
||||
stack: Option<String>,
|
||||
kill_switch: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
|
@ -150,6 +217,8 @@ struct RawMuxSection {
|
|||
sessions: Option<usize>,
|
||||
}
|
||||
|
||||
|
||||
|
||||
impl ClientConfig {
|
||||
/// Hot-reload from `config.json` placed next to the running binary.
|
||||
/// Returns a new `ClientConfig` built from the unified JSON format.
|
||||
|
|
@ -167,6 +236,7 @@ impl ClientConfig {
|
|||
let is_tun = raw.tun.as_ref().and_then(|t| t.enable).unwrap_or(false);
|
||||
let server = raw.server.unwrap_or_else(|| "127.0.0.1:50000".to_string());
|
||||
let key = raw.access_key.unwrap_or_default();
|
||||
let mtu = raw.mtu.unwrap_or(default_mtu());
|
||||
let socks5 = raw.socks5_bind.unwrap_or_else(|| "127.0.0.1:1088".to_string());
|
||||
let exclusions = raw.exclude.unwrap_or(RawExcludeSection {
|
||||
domains: None,
|
||||
|
|
@ -185,14 +255,23 @@ impl ClientConfig {
|
|||
server_addr: server,
|
||||
local_bind_addr: "0.0.0.0:0".to_string(),
|
||||
access_key: key,
|
||||
handshake_timeout_ms: 10000,
|
||||
handshake_timeout_ms: 5000,
|
||||
io_timeout_ms: 2500,
|
||||
mtu,
|
||||
keepalive_interval_sec: default_keepalive(),
|
||||
},
|
||||
local_proxy: LocalProxyConfig {
|
||||
bind_addr: socks5,
|
||||
connect_timeout_ms: 15000,
|
||||
},
|
||||
turn: TurnConfig::default(),
|
||||
transport: TransportConfig {
|
||||
mode: raw.transport.as_ref().and_then(|t| t.mode.clone()).unwrap_or_else(default_transport_mode),
|
||||
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(),
|
||||
ips: exclusions.ips.unwrap_or_default(),
|
||||
|
|
@ -203,6 +282,257 @@ impl ClientConfig {
|
|||
sessions: mux.sessions.unwrap_or(1),
|
||||
},
|
||||
dns_server: raw.tun.as_ref().and_then(|t| t.dns.clone()),
|
||||
tun_stack: raw.tun.as_ref().and_then(|t| t.stack.clone()).unwrap_or_else(|| "system".to_string()),
|
||||
kill_switch: raw.tun.as_ref().and_then(|t| t.kill_switch).unwrap_or(false),
|
||||
gui: raw.gui,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// On-disk config.json shapes — client, server, and relay.
|
||||
//
|
||||
// This is the ONE place these are defined. They used to be declared locally
|
||||
// inside ostp/src/main.rs (the CLI binary) with no other consumer able to
|
||||
// see them, which is exactly how ostp-client::migrate ended up working
|
||||
// against loosely-typed serde_json::Value instead of a real schema, and how
|
||||
// the CLI, the migrator, and this crate's own hot-reload path could each
|
||||
// silently drift out of sync with what a config.json actually looks like.
|
||||
// main.rs now imports these instead of re-declaring them (see the `use
|
||||
// ostp_client::config::{...}` at its top).
|
||||
//
|
||||
// These are DELIBERATELY separate from ClientConfig/OstpConfig/etc. above:
|
||||
// this section is the friendly, minimal shape a user actually edits by
|
||||
// hand; the types above are what the running engine needs internally
|
||||
// (handshake/io timeouts, resolved addresses, ...) and are built FROM one
|
||||
// of these via the mapping in ostp/src/main.rs::run_client_directly. Only
|
||||
// `ClientConfig` collides by name with the runtime type above, so the
|
||||
// on-disk one is `ClientFileConfig` — everything else keeps its natural name.
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(tag = "mode", rename_all = "lowercase")]
|
||||
pub enum AppMode {
|
||||
Server(ServerConfig),
|
||||
Client(ClientFileConfig),
|
||||
Relay(RelayServerConfig),
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct UnifiedConfig {
|
||||
#[serde(flatten)]
|
||||
pub mode: AppMode,
|
||||
pub log_level: Option<String>,
|
||||
}
|
||||
|
||||
impl UnifiedConfig {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
match &self.mode {
|
||||
AppMode::Server(cfg) => {
|
||||
if cfg.access_keys.is_empty() {
|
||||
anyhow::bail!("Server configuration must contain at least one access_key.");
|
||||
}
|
||||
if let Some(outbound) = &cfg.outbound {
|
||||
if outbound.enabled {
|
||||
let action = outbound.default_action.as_deref().unwrap_or("direct");
|
||||
if action == "direct" && outbound.rules.is_empty() {
|
||||
println!("\n[WARNING] Server outbound proxy is ENABLED, but default_action is 'direct' and there are no rules!");
|
||||
println!(" This means ALL traffic will bypass the proxy and go out directly from the server IP.");
|
||||
println!(" If you want all traffic to be proxied, change 'default_action' to 'proxy'.\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
AppMode::Client(cfg) => {
|
||||
if cfg.access_key.is_empty() {
|
||||
anyhow::bail!("Client configuration must contain an access_key.");
|
||||
}
|
||||
}
|
||||
AppMode::Relay(cfg) => {
|
||||
if cfg.upstream_tcp.is_empty() {
|
||||
anyhow::bail!("Relay configuration must specify upstream_tcp address.");
|
||||
}
|
||||
if cfg.upstream_api_url.is_empty() {
|
||||
anyhow::bail!("Relay configuration must specify upstream_api_url.");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
#[serde(untagged)]
|
||||
pub enum UserConfig {
|
||||
Detailed {
|
||||
access_key: String,
|
||||
name: Option<String>,
|
||||
limit_bytes: Option<u64>,
|
||||
},
|
||||
KeyOnly(String),
|
||||
}
|
||||
|
||||
impl UserConfig {
|
||||
pub fn key(&self) -> String {
|
||||
match self {
|
||||
UserConfig::KeyOnly(k) => k.clone(),
|
||||
UserConfig::Detailed { access_key, .. } => access_key.clone(),
|
||||
}
|
||||
}
|
||||
pub fn name(&self) -> Option<String> {
|
||||
match self {
|
||||
UserConfig::KeyOnly(_) => None,
|
||||
UserConfig::Detailed { name, .. } => name.clone(),
|
||||
}
|
||||
}
|
||||
pub fn limit(&self) -> Option<u64> {
|
||||
match self {
|
||||
UserConfig::KeyOnly(_) => None,
|
||||
UserConfig::Detailed { limit_bytes, .. } => *limit_bytes,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct ServerConfig {
|
||||
pub listen: ListenConfig,
|
||||
pub access_keys: Vec<UserConfig>,
|
||||
pub debug: Option<bool>,
|
||||
pub outbound: Option<OutboundConfig>,
|
||||
pub api: Option<ApiConfig>,
|
||||
pub fallback: Option<FallbackCfg>,
|
||||
pub transport: Option<TransportConfigRaw>,
|
||||
// Left untyped: ostp-client does not (and should not) depend on
|
||||
// ostp-server just to name its DnsConfig type. The CLI binary — which
|
||||
// already depends on both crates — deserializes this into
|
||||
// ostp_server::dns::DnsConfig right before handing it to run_server().
|
||||
pub dns: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Relay-node config.json shape.
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct RelayServerConfig {
|
||||
/// Listen address(es) (UDP + TCP UoT)
|
||||
pub listen: ListenConfig,
|
||||
/// Upstream address for TCP (UoT) traffic
|
||||
pub upstream_tcp: String,
|
||||
/// Upstream address for UDP traffic
|
||||
pub upstream_udp: String,
|
||||
// ── Deprecated ──────────────────────────────────────────────────────────
|
||||
// The relay used to authenticate clients itself and pulled the access-key
|
||||
// list from the target server's management API to do it. It no longer does:
|
||||
// sessions are authenticated end-to-end by the target server, and a relay
|
||||
// that re-checks credentials only adds a weaker second gate plus a copy of
|
||||
// the key list on a machine that does not need one. These are kept solely
|
||||
// so existing relay configs still parse; they are ignored.
|
||||
#[serde(default)]
|
||||
pub upstream_api_url: String,
|
||||
#[serde(default)]
|
||||
pub upstream_api_token: String,
|
||||
#[serde(default)]
|
||||
pub sync_interval_secs: u64,
|
||||
pub debug: Option<bool>,
|
||||
}
|
||||
|
||||
/// Supports both a single string "0.0.0.0:50000" and an array
|
||||
/// ["0.0.0.0:50000", "[::]:50000"].
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
#[serde(untagged)]
|
||||
pub enum ListenConfig {
|
||||
Single(String),
|
||||
Multiple(Vec<String>),
|
||||
}
|
||||
|
||||
impl ListenConfig {
|
||||
pub fn addresses(&self) -> Vec<String> {
|
||||
match self {
|
||||
ListenConfig::Single(s) => vec![s.clone()],
|
||||
ListenConfig::Multiple(v) => v.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn primary(&self) -> String {
|
||||
match self {
|
||||
ListenConfig::Single(s) => s.clone(),
|
||||
ListenConfig::Multiple(v) => v.first().cloned().unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct ApiConfig {
|
||||
pub enabled: Option<bool>,
|
||||
pub bind: Option<String>,
|
||||
pub token: Option<String>,
|
||||
pub webpath: Option<String>,
|
||||
pub username: Option<String>,
|
||||
pub password_hash: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct FallbackCfg {
|
||||
pub enabled: Option<bool>,
|
||||
pub listen: Option<String>,
|
||||
pub target: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct ClientFileConfig {
|
||||
pub server: String,
|
||||
pub access_key: String,
|
||||
pub mtu: Option<usize>,
|
||||
pub socks5_bind: Option<String>,
|
||||
pub tun: Option<TunConfig>,
|
||||
pub debug: Option<bool>,
|
||||
pub exclude: Option<ExcludeConfig>,
|
||||
pub mux: Option<MuxConfig>,
|
||||
pub transport: Option<TransportConfigRaw>,
|
||||
pub gui: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct TransportConfigRaw {
|
||||
pub mode: Option<String>,
|
||||
pub tcp_fragmentation: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct TunConfig {
|
||||
pub enable: bool,
|
||||
pub wintun_path: Option<String>,
|
||||
pub ipv4_address: Option<String>,
|
||||
pub dns: Option<String>,
|
||||
pub kill_switch: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct OutboundConfig {
|
||||
pub enabled: bool,
|
||||
pub protocol: String,
|
||||
pub address: String,
|
||||
pub port: u16,
|
||||
#[serde(default)]
|
||||
pub rules: Vec<OutboundRule>,
|
||||
pub default_action: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct OutboundRule {
|
||||
pub domain_suffix: Option<Vec<String>>,
|
||||
pub ip_cidr: Option<Vec<String>>,
|
||||
pub protocol: Option<String>,
|
||||
pub action: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct ExcludeConfig {
|
||||
pub domains: Option<Vec<String>>,
|
||||
pub ips: Option<Vec<String>>,
|
||||
pub processes: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct MuxConfig {
|
||||
pub enabled: Option<bool>,
|
||||
pub sessions: Option<usize>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,12 @@
|
|||
pub mod app;
|
||||
pub mod bridge;
|
||||
pub mod config;
|
||||
pub mod migrate;
|
||||
pub mod signal;
|
||||
pub mod sysproxy;
|
||||
pub mod transport;
|
||||
pub mod tunnel;
|
||||
|
||||
|
||||
pub mod runner;
|
||||
pub mod logging;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,189 @@
|
|||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
|
||||
|
||||
/// The single canonical log file for the whole core. Every process (CLI daemon,
|
||||
/// GUI, TUN helper) and every subsystem (tracing, the core event logger, the
|
||||
/// helper IPC, panics) writes here — no more per-binary / per-subsystem sprawl
|
||||
/// (`ostp-cli.log` + `ostp-core.log` + `ostp-helper.log` + `ostp-crash.log`).
|
||||
pub const LOG_FILE_NAME: &str = "ostp.log";
|
||||
|
||||
/// Absolute path to the shared log file, next to the running executable.
|
||||
pub fn log_file_path() -> PathBuf {
|
||||
std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|p| p.parent().map(|d| d.join(LOG_FILE_NAME)))
|
||||
.unwrap_or_else(|| PathBuf::from(LOG_FILE_NAME))
|
||||
}
|
||||
|
||||
/// True if this invocation is the long-running daemon (a client/server run),
|
||||
/// as opposed to a one-shot subcommand (`gk`, `check`, `init`, `-V`, ...).
|
||||
///
|
||||
/// Used to gate log truncation: only the daemon clears the log at startup, so a
|
||||
/// one-shot command run while a daemon is live can never wipe the daemon's log.
|
||||
/// A daemon invocation is simply one that carries none of the one-shot tokens
|
||||
/// (`ostp`, `ostp run`, `ostp connect <url>` → daemon; everything else → one-shot).
|
||||
pub fn invocation_is_daemon<I: IntoIterator<Item = String>>(args: I) -> bool {
|
||||
const ONE_SHOT: &[&str] = &[
|
||||
"gk", "generate-key", "check", "init", "setup", "links", "import",
|
||||
"update", "migrate", "prober", "proxy-env", "proxy-env-clear",
|
||||
"uninstall", "-V", "--version", "-h", "--help", "help",
|
||||
];
|
||||
!args
|
||||
.into_iter()
|
||||
.skip(1) // program name
|
||||
.any(|a| ONE_SHOT.contains(&a.as_str()))
|
||||
}
|
||||
|
||||
/// Append a single timestamped line to the shared log file. Used by the manual
|
||||
/// writers (core event logger, TUN helper IPC) so their output lands in the same
|
||||
/// `ostp.log` as the tracing subscriber instead of a separate file.
|
||||
pub fn append_line(msg: &str) {
|
||||
if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(log_file_path()) {
|
||||
let _ = writeln!(
|
||||
file,
|
||||
"[{}] {}",
|
||||
chrono::Local::now().format("%Y-%m-%d %H:%M:%S"),
|
||||
msg
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn setup_panic_hook() {
|
||||
std::panic::set_hook(Box::new(|info| {
|
||||
let payload = info.payload();
|
||||
let msg = if let Some(s) = payload.downcast_ref::<&str>() {
|
||||
*s
|
||||
} else if let Some(s) = payload.downcast_ref::<String>() {
|
||||
s.as_str()
|
||||
} else {
|
||||
"Box<dyn Any>"
|
||||
};
|
||||
|
||||
let location = info.location().unwrap_or_else(|| std::panic::Location::caller());
|
||||
let backtrace = std::backtrace::Backtrace::force_capture();
|
||||
|
||||
let crash_msg = format!(
|
||||
"[{}] PANIC at {}:{}\nMessage: {}\nBacktrace:\n{:?}",
|
||||
chrono::Local::now().format("%Y-%m-%d %H:%M:%S"),
|
||||
location.file(),
|
||||
location.line(),
|
||||
msg,
|
||||
backtrace
|
||||
);
|
||||
|
||||
eprintln!("{}", crash_msg);
|
||||
tracing::error!("{}", crash_msg);
|
||||
|
||||
// Crashes land in the same shared log file (append — a crash must never
|
||||
// truncate, and the tracing worker may already be dead so we write direct).
|
||||
if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(log_file_path()) {
|
||||
let _ = file.write_all(crash_msg.as_bytes());
|
||||
let _ = file.write_all(b"\n===================================================\n");
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/// Initialises tracing and writes to the shared `ostp.log` next to the executable.
|
||||
///
|
||||
/// The `level` parameter controls the minimum log level:
|
||||
/// - `"error"` — only errors
|
||||
/// - `"warn"` — warnings and errors
|
||||
/// - `"info"` — informational messages (default)
|
||||
/// - `"debug"` — detailed debug messages (use when `debug: true` in config)
|
||||
/// - `"trace"` — all messages including very verbose internal state
|
||||
///
|
||||
/// The environment variable `RUST_LOG` overrides this value if set.
|
||||
///
|
||||
/// `truncate`: clear the log at startup. Honoured **only on Windows** — Linux
|
||||
/// servers keep their history (OS-rotated). Pass `true` only from the daemon's
|
||||
/// own entrypoint; one-shot commands and child processes (the TUN helper) pass
|
||||
/// `false` so they append instead of wiping a running daemon's log.
|
||||
pub fn init_tracing(
|
||||
level: &str,
|
||||
app_name: &str,
|
||||
version: &str,
|
||||
truncate: bool,
|
||||
) -> Option<tracing_appender::non_blocking::WorkerGuard> {
|
||||
// RUST_LOG overrides the config-derived level
|
||||
let env_filter = EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| {
|
||||
// When debug or trace is requested, enable for all ostp crates
|
||||
if level == "debug" || level == "trace" {
|
||||
// Enable the requested level for ostp crates, but keep noisy deps at warn
|
||||
EnvFilter::new(format!(
|
||||
"warn,ostp_client={level},ostp_core={level},ostp_jni={level},ostp_gui_lib={level}"
|
||||
))
|
||||
} else {
|
||||
EnvFilter::new(level)
|
||||
}
|
||||
});
|
||||
|
||||
let path = log_file_path();
|
||||
|
||||
let mut open_opts = OpenOptions::new();
|
||||
open_opts.create(true);
|
||||
// Truncate-on-startup is Windows-only and daemon-only. Everywhere else append:
|
||||
// Linux keeps server history, and one-shot commands / the TUN helper must not
|
||||
// wipe a running daemon's log.
|
||||
if truncate && cfg!(windows) {
|
||||
open_opts.write(true).truncate(true);
|
||||
} else {
|
||||
open_opts.append(true);
|
||||
}
|
||||
|
||||
if let Ok(mut file) = open_opts.open(&path) {
|
||||
// Write the startup banner directly to the log file, bypassing the
|
||||
// tracing subscriber entirely. Emitting it via tracing::info!() hits
|
||||
// BOTH layers below (file AND stderr), so every one-shot CLI command
|
||||
// (`ostp -V`, `ostp gk`, `ostp check`, ...) printed this banner to the
|
||||
// terminal on every single invocation — pure noise for anything that
|
||||
// isn't the long-running daemon. It's still genuinely useful for
|
||||
// whoever's reading the log file later, so keep it there, just not on
|
||||
// screen for commands that aren't the daemon.
|
||||
let _ = writeln!(
|
||||
file,
|
||||
"{} v{} | OS: {} | Arch: {} | log_level: {} | log_file: {}",
|
||||
app_name,
|
||||
version,
|
||||
std::env::consts::OS,
|
||||
std::env::consts::ARCH,
|
||||
level,
|
||||
path.display(),
|
||||
);
|
||||
|
||||
let (file_writer, guard) = tracing_appender::non_blocking(file);
|
||||
|
||||
let fmt_layer = tracing_subscriber::fmt::layer()
|
||||
.with_target(true)
|
||||
.with_line_number(true)
|
||||
.with_thread_ids(false)
|
||||
.with_thread_names(false)
|
||||
.with_ansi(false)
|
||||
.with_writer(file_writer);
|
||||
|
||||
let stderr_layer = tracing_subscriber::fmt::layer()
|
||||
.with_target(true)
|
||||
.with_writer(std::io::stderr);
|
||||
|
||||
let _ = tracing_subscriber::registry()
|
||||
.with(env_filter)
|
||||
.with(fmt_layer)
|
||||
.with(stderr_layer)
|
||||
.try_init();
|
||||
|
||||
Some(guard)
|
||||
} else {
|
||||
// Fallback: stderr only
|
||||
let stderr_layer = tracing_subscriber::fmt::layer()
|
||||
.with_target(true)
|
||||
.with_writer(std::io::stderr);
|
||||
let _ = tracing_subscriber::registry()
|
||||
.with(EnvFilter::new(level))
|
||||
.with(stderr_layer)
|
||||
.try_init();
|
||||
eprintln!("[WARN] Could not open log file at {}. Logging to stderr only.", path.display());
|
||||
None
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,559 @@
|
|||
//! The ONE authoritative place that upgrades an old `config.json` to the
|
||||
//! current schema. Reachable only via the explicit `ostp migrate` command —
|
||||
//! nothing else in this codebase silently rewrites a user's config on their
|
||||
//! behalf (the old 0.3.x line used to auto-migrate on every load with just a
|
||||
//! log warning; that's exactly the kind of "invisible until something looks
|
||||
//! wrong" behavior this module replaces).
|
||||
//!
|
||||
//! Every field this module cannot map forward is reported explicitly in
|
||||
//! `MigrationReport.notes`, never silently dropped without a trace.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct MigrationReport {
|
||||
/// Whether anything was actually different from the current schema.
|
||||
pub changed: bool,
|
||||
/// Human-readable line per field added, converted, or dropped.
|
||||
pub notes: Vec<String>,
|
||||
}
|
||||
|
||||
impl MigrationReport {
|
||||
fn note(&mut self, msg: impl Into<String>) {
|
||||
self.changed = true;
|
||||
self.notes.push(msg.into());
|
||||
}
|
||||
}
|
||||
|
||||
/// Which config this file is (mirrors `AppMode`'s `"mode"` tag). Old configs
|
||||
/// from before that tag existed are sniffed structurally as a fallback.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ConfigKind {
|
||||
Client,
|
||||
Server,
|
||||
Relay,
|
||||
}
|
||||
|
||||
pub fn detect_kind(json: &Value) -> Option<ConfigKind> {
|
||||
match json.get("mode").and_then(|v| v.as_str()) {
|
||||
Some("client") => return Some(ConfigKind::Client),
|
||||
Some("server") => return Some(ConfigKind::Server),
|
||||
Some("relay") => return Some(ConfigKind::Relay),
|
||||
_ => {}
|
||||
}
|
||||
// No (or unrecognized) "mode" tag — this is an older config from before
|
||||
// it was mandatory. Sniff by the fields that have been present on each
|
||||
// shape since the earliest surviving config format.
|
||||
if json.get("upstream_tcp").is_some() || json.get("upstream_api_url").is_some() {
|
||||
Some(ConfigKind::Relay)
|
||||
} else if json.get("access_keys").is_some() || json.get("listen").is_some() {
|
||||
Some(ConfigKind::Server)
|
||||
} else if json.get("access_key").is_some() || json.get("server").is_some() {
|
||||
Some(ConfigKind::Client)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Migrates a client config of any known past shape to the current flat
|
||||
/// schema. Returns the migrated JSON and a report of every change made.
|
||||
///
|
||||
/// Known input shapes, oldest first:
|
||||
/// - **v0.3.1–v0.3.21 "modular multi-server"**: `inbounds`/`outbounds` arrays
|
||||
/// + `routing.rules`. Only the first `ostp`-type outbound is kept (this
|
||||
/// line no longer supports multiple simultaneous servers); every other
|
||||
/// `ostp` outbound is reported by tag+address so nothing vanishes
|
||||
/// invisibly. `urltest`/`selector`/`direct`/`block` outbounds have no
|
||||
/// equivalent and are dropped (reported).
|
||||
/// - **pre-0.3.1 flat (up to v0.2.98)**: same field names as today
|
||||
/// (`server`, `access_key`, `tun`, `exclude`, `mux`, `transport`, ...)
|
||||
/// except `tun.wintun_path`/`tun.ipv4_address` (internal driver detail,
|
||||
/// never user-meaningful data) and `transport.wss` (the WSS framing
|
||||
/// feature removed entirely in the 0.4.0 rebuild) — both dropped with an
|
||||
/// explicit note; everything else maps 1:1, nothing to convert.
|
||||
/// - **configs carrying a leftover `transport.stealth_sni`**: dropped with a
|
||||
/// note, same reasoning as `wss` — it never fed into anything on the wire
|
||||
/// (no TLS/HTTP mimicry exists in this project), so there is no successor
|
||||
/// field. Not tied to a specific version: it lingered in the schema well
|
||||
/// past when the mimicry work it was meant for got removed.
|
||||
/// - **current flat schema**: no-op, `changed = false`.
|
||||
pub fn migrate_client_json(json: Value) -> (Value, MigrationReport) {
|
||||
let mut report = MigrationReport::default();
|
||||
|
||||
let has_inbounds = json.get("inbounds").and_then(|v| v.as_array()).is_some();
|
||||
let has_outbounds = json.get("outbounds").and_then(|v| v.as_array()).is_some();
|
||||
|
||||
if has_inbounds && has_outbounds {
|
||||
return migrate_client_from_modular(json, report);
|
||||
}
|
||||
|
||||
// Flat shape already (current or pre-0.3.1) — normalize obsolete fields
|
||||
// in place rather than rebuilding the whole document from scratch, so
|
||||
// any field this module doesn't know about yet still survives untouched.
|
||||
let mut out = json;
|
||||
|
||||
if let Some(tun) = out.get_mut("tun").and_then(|t| t.as_object_mut()) {
|
||||
for dead_field in ["wintun_path", "ipv4_address"] {
|
||||
if tun.remove(dead_field).is_some() {
|
||||
report.note(format!(
|
||||
"Dropped tun.{dead_field} — internal driver detail from an older WinTun \
|
||||
integration, not applicable to the current TUN implementation."
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(transport) = out.get_mut("transport").and_then(|t| t.as_object_mut()) {
|
||||
if transport.remove("wss").is_some() {
|
||||
report.note(
|
||||
"Dropped transport.wss — WSS framing was removed in the 0.4.0 rebuild \
|
||||
(the project follows a zapret-like approach: no protocol mimicry, \
|
||||
just packet-level obfuscation/manipulation, so there is no successor field)."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
if transport.remove("stealth_sni").is_some() {
|
||||
report.note(
|
||||
"Dropped transport.stealth_sni — never actually used to construct any wire \
|
||||
bytes (no TLS/HTTP mimicry exists in this project — same zapret-like \
|
||||
reasoning as transport.wss), so it was unused config plumbing with no effect."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
(out, report)
|
||||
}
|
||||
|
||||
fn migrate_client_from_modular(json: Value, mut report: MigrationReport) -> (Value, MigrationReport) {
|
||||
report.changed = true; // the shape itself is being replaced regardless of field-level detail
|
||||
|
||||
let inbounds = json.get("inbounds").and_then(|v| v.as_array()).cloned().unwrap_or_default();
|
||||
let outbounds = json.get("outbounds").and_then(|v| v.as_array()).cloned().unwrap_or_default();
|
||||
let routing = json.get("routing").cloned().unwrap_or(json!({}));
|
||||
let default_outbound = routing.get("default_outbound").and_then(|v| v.as_str()).map(String::from);
|
||||
|
||||
// ── Pick the primary "ostp" outbound ────────────────────────────────
|
||||
// Prefer the one routing.default_outbound points at (directly, or via a
|
||||
// urltest/selector group that references it); otherwise take the first
|
||||
// ostp outbound in file order. Every other ostp outbound is reported by
|
||||
// tag+address, not silently discarded.
|
||||
let ostp_outbounds: Vec<&Value> = outbounds
|
||||
.iter()
|
||||
.filter(|o| o.get("type").and_then(|t| t.as_str()) == Some("ostp"))
|
||||
.collect();
|
||||
|
||||
// default_outbound might name an ostp outbound directly, OR name a
|
||||
// urltest/selector GROUP whose first member is the one to actually use —
|
||||
// check both, since a plain `.or_else` here would never even attempt the
|
||||
// group lookup while default_outbound is Some(_) (which it almost always
|
||||
// is), silently falling through to "just take the first ostp outbound in
|
||||
// file order" instead — exactly the kind of silent wrong answer this
|
||||
// migrator exists to avoid.
|
||||
let primary_tag: Option<String> = default_outbound.as_deref().and_then(|def_tag| {
|
||||
if ostp_outbounds.iter().any(|o| o.get("tag").and_then(|t| t.as_str()) == Some(def_tag)) {
|
||||
return Some(def_tag.to_string());
|
||||
}
|
||||
outbounds.iter().find_map(|o| {
|
||||
let is_group = matches!(o.get("type").and_then(|t| t.as_str()), Some("urltest") | Some("selector"));
|
||||
let tag_matches = o.get("tag").and_then(|t| t.as_str()) == Some(def_tag);
|
||||
if is_group && tag_matches {
|
||||
o.get("outbounds")
|
||||
.and_then(|v| v.as_array())
|
||||
.and_then(|arr| arr.first())
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
let primary = primary_tag
|
||||
.as_deref()
|
||||
.and_then(|tag| ostp_outbounds.iter().find(|o| o.get("tag").and_then(|t| t.as_str()) == Some(tag)))
|
||||
.copied()
|
||||
.or_else(|| ostp_outbounds.first().copied());
|
||||
|
||||
let Some(primary) = primary else {
|
||||
report.note(
|
||||
"No 'ostp'-type outbound found in the old modular config — nothing to migrate \
|
||||
the server connection from. Wrote a placeholder; you MUST fill in server/access_key \
|
||||
by hand or re-import a share link."
|
||||
.to_string(),
|
||||
);
|
||||
return (
|
||||
json!({
|
||||
"server": "127.0.0.1:50000",
|
||||
"access_key": "",
|
||||
}),
|
||||
report,
|
||||
);
|
||||
};
|
||||
|
||||
for other in &ostp_outbounds {
|
||||
if !std::ptr::eq(*other, primary) {
|
||||
let tag = other.get("tag").and_then(|t| t.as_str()).unwrap_or("?");
|
||||
let addr = other.get("server").and_then(|t| t.as_str()).unwrap_or("?");
|
||||
let port = other.get("port").and_then(|t| t.as_u64()).unwrap_or(0);
|
||||
report.note(format!(
|
||||
"Dropped additional server '{tag}' ({addr}:{port}) — multi-server / urltest \
|
||||
failover is no longer supported; only one server per config now. Kept the \
|
||||
one from routing.default_outbound (or the first one if that wasn't set)."
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let server = primary.get("server").and_then(|v| v.as_str()).unwrap_or("127.0.0.1").to_string();
|
||||
let port = primary.get("port").and_then(|v| v.as_u64()).unwrap_or(50000);
|
||||
let access_key = primary.get("access_key").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
let transport_type = primary
|
||||
.get("transport")
|
||||
.and_then(|t| t.get("type").or_else(|| t.get("mode")))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("udp")
|
||||
.to_string();
|
||||
if let Some(sni) = primary.get("transport").and_then(|t| t.get("stealth_sni")).and_then(|v| v.as_str()) {
|
||||
if !sni.is_empty() {
|
||||
report.note(format!(
|
||||
"Dropped transport.stealth_sni ({sni:?}) — never actually used to construct \
|
||||
any wire bytes; unused config plumbing with no successor field."
|
||||
));
|
||||
}
|
||||
}
|
||||
let tcp_fragmentation = primary
|
||||
.get("transport")
|
||||
.and_then(|t| t.get("tcp_fragmentation"))
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let mux_enabled = primary.get("multiplex").and_then(|m| m.get("enabled")).and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
let mux_sessions = primary.get("multiplex").and_then(|m| m.get("sessions")).and_then(|v| v.as_u64()).unwrap_or(1);
|
||||
|
||||
// ── TUN + local proxy inbounds ───────────────────────────────────────
|
||||
let tun_inbound = inbounds.iter().find(|i| i.get("type").and_then(|t| t.as_str()) == Some("tun"));
|
||||
let proxy_inbound = inbounds.iter().find(|i| i.get("type").and_then(|t| t.as_str()) == Some("local_proxy"));
|
||||
|
||||
let tun_enable = tun_inbound.is_some();
|
||||
let mtu = tun_inbound.and_then(|t| t.get("mtu")).and_then(|v| v.as_u64());
|
||||
|
||||
let socks5_bind = proxy_inbound
|
||||
.map(|p| {
|
||||
let listen = p.get("listen").and_then(|v| v.as_str()).unwrap_or("127.0.0.1");
|
||||
let port = p.get("port").and_then(|v| v.as_u64()).unwrap_or(1088);
|
||||
format!("{listen}:{port}")
|
||||
})
|
||||
.unwrap_or_else(|| "127.0.0.1:1088".to_string());
|
||||
|
||||
// ── Exclusions from routing.rules → direct ──────────────────────────
|
||||
let mut ex_domains: Vec<String> = Vec::new();
|
||||
let mut ex_ips: Vec<String> = Vec::new();
|
||||
let mut ex_processes: Vec<String> = Vec::new();
|
||||
if let Some(rules) = routing.get("rules").and_then(|v| v.as_array()) {
|
||||
for rule in rules {
|
||||
if rule.get("outbound").and_then(|v| v.as_str()) != Some("direct") {
|
||||
continue; // only "route to direct" rules were ever exclusions in the old format
|
||||
}
|
||||
if let Some(v) = rule.get("domain_suffix").and_then(|v| v.as_array()) {
|
||||
ex_domains.extend(v.iter().filter_map(|s| s.as_str().map(String::from)));
|
||||
}
|
||||
if let Some(v) = rule.get("ip_cidr").and_then(|v| v.as_array()) {
|
||||
ex_ips.extend(v.iter().filter_map(|s| s.as_str().map(String::from)));
|
||||
}
|
||||
if let Some(v) = rule.get("process_name").and_then(|v| v.as_array()) {
|
||||
ex_processes.extend(v.iter().filter_map(|s| s.as_str().map(String::from)));
|
||||
}
|
||||
}
|
||||
}
|
||||
for other_rule_outbound in routing
|
||||
.get("rules")
|
||||
.and_then(|v| v.as_array())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|r| r.get("outbound").and_then(|v| v.as_str()))
|
||||
.filter(|o| *o != "direct")
|
||||
{
|
||||
report.note(format!(
|
||||
"Dropped a routing rule targeting outbound '{other_rule_outbound}' — only \
|
||||
\"route to direct\" rules map to today's exclusions; anything else \
|
||||
(custom per-domain outbound selection) has no equivalent anymore."
|
||||
));
|
||||
}
|
||||
|
||||
let debug = json.get("log").and_then(|l| l.get("level")).and_then(|v| v.as_str()) == Some("debug");
|
||||
|
||||
let mut client = json!({
|
||||
"server": server,
|
||||
"port": port,
|
||||
"access_key": access_key,
|
||||
"socks5_bind": socks5_bind,
|
||||
"debug": debug,
|
||||
"tun": {
|
||||
"enable": tun_enable,
|
||||
"dns": null,
|
||||
"kill_switch": false,
|
||||
},
|
||||
"exclude": {
|
||||
"domains": ex_domains,
|
||||
"ips": ex_ips,
|
||||
"processes": ex_processes,
|
||||
},
|
||||
"mux": {
|
||||
"enabled": mux_enabled,
|
||||
"sessions": mux_sessions,
|
||||
},
|
||||
"transport": {
|
||||
"mode": transport_type,
|
||||
"tcp_fragmentation": tcp_fragmentation,
|
||||
},
|
||||
});
|
||||
if let Some(mtu) = mtu {
|
||||
client["mtu"] = json!(mtu);
|
||||
}
|
||||
if let Some(gui) = json.get("gui") {
|
||||
client["gui"] = gui.clone();
|
||||
}
|
||||
|
||||
(client, report)
|
||||
}
|
||||
|
||||
/// Migrates a server config. The server shape has stayed structurally
|
||||
/// identical since the earliest surviving version — this only backfills the
|
||||
/// `api` section (added after some configs already existed) and drops the
|
||||
/// legacy `api.token` field. Ported from the ad-hoc Python snippet that used
|
||||
/// to live in `scripts/install.sh` and only ran at install/update time.
|
||||
pub fn migrate_server_json(json: Value) -> (Value, MigrationReport) {
|
||||
let mut report = MigrationReport::default();
|
||||
let mut out = json;
|
||||
|
||||
let obj = match out.as_object_mut() {
|
||||
Some(o) => o,
|
||||
None => return (out, report),
|
||||
};
|
||||
|
||||
let api = obj.entry("api").or_insert_with(|| json!({}));
|
||||
if let Some(api_obj) = api.as_object_mut() {
|
||||
let defaults: [(&str, Value); 5] = [
|
||||
("enabled", json!(false)),
|
||||
("bind", json!("0.0.0.0:9090")),
|
||||
("webpath", json!("")),
|
||||
("username", json!("")),
|
||||
("password_hash", json!("")),
|
||||
];
|
||||
for (key, default) in defaults {
|
||||
if !api_obj.contains_key(key) {
|
||||
report.note(format!("Added api.{key} = {default} (missing default)"));
|
||||
api_obj.insert(key.to_string(), default);
|
||||
}
|
||||
}
|
||||
if api_obj.remove("token").is_some() {
|
||||
report.note(
|
||||
"Dropped legacy api.token — superseded by api.password_hash; \
|
||||
set a new admin password with the management API or panel."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
(out, report)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A realistic v0.3.21-shaped modular config (TUN + local_proxy inbounds,
|
||||
/// a single ostp outbound, exclusion rules, mux) — mirrors the actual
|
||||
/// shape from that tag, field for field.
|
||||
#[test]
|
||||
fn modular_single_server_preserves_every_field() {
|
||||
let old = json!({
|
||||
"version": "0.3.21",
|
||||
"log": { "level": "debug" },
|
||||
"inbounds": [
|
||||
{ "type": "tun", "tag": "tun-in", "auto_route": true, "mtu": 1350 },
|
||||
{ "type": "local_proxy", "tag": "socks-in", "protocol": "socks", "listen": "127.0.0.1", "port": 1088 }
|
||||
],
|
||||
"outbounds": [
|
||||
{
|
||||
"type": "ostp", "tag": "proxy",
|
||||
"server": "203.0.113.5", "port": 50000, "access_key": "sekrit123",
|
||||
"transport": { "type": "uot", "stealth_sni": "vk.com", "tcp_fragmentation": true },
|
||||
"multiplex": { "enabled": true, "sessions": 4 }
|
||||
},
|
||||
{ "type": "direct", "tag": "direct" },
|
||||
{ "type": "block", "tag": "block" }
|
||||
],
|
||||
"routing": {
|
||||
"rules": [
|
||||
{ "domain_suffix": ["local.lan", "internal.corp"], "outbound": "direct" },
|
||||
{ "ip_cidr": ["192.168.0.0/16"], "outbound": "direct" },
|
||||
{ "process_name": ["steam.exe"], "outbound": "direct" }
|
||||
],
|
||||
"default_outbound": "proxy"
|
||||
}
|
||||
});
|
||||
|
||||
let (new, report) = migrate_client_json(old);
|
||||
assert!(report.changed);
|
||||
assert_eq!(new["server"], "203.0.113.5");
|
||||
assert_eq!(new["port"], 50000);
|
||||
assert_eq!(new["access_key"], "sekrit123");
|
||||
assert_eq!(new["socks5_bind"], "127.0.0.1:1088");
|
||||
assert_eq!(new["mtu"], 1350);
|
||||
assert_eq!(new["debug"], true);
|
||||
assert_eq!(new["tun"]["enable"], true);
|
||||
assert_eq!(new["transport"]["mode"], "uot");
|
||||
assert_eq!(new["transport"]["tcp_fragmentation"], true);
|
||||
assert_eq!(new["mux"]["enabled"], true);
|
||||
assert_eq!(new["mux"]["sessions"], 4);
|
||||
assert_eq!(new["exclude"]["domains"], json!(["local.lan", "internal.corp"]));
|
||||
assert_eq!(new["exclude"]["ips"], json!(["192.168.0.0/16"]));
|
||||
assert_eq!(new["exclude"]["processes"], json!(["steam.exe"]));
|
||||
// stealth_sni never fed into any wire bytes — dropped, not carried forward.
|
||||
assert!(new["transport"].get("stealth_sni").is_none());
|
||||
assert!(report.notes.iter().any(|n| n.contains("stealth_sni") && n.contains("vk.com")));
|
||||
}
|
||||
|
||||
/// Old modular configs that had MULTIPLE ostp outbounds (multi-server) —
|
||||
/// must keep the one routing.default_outbound points at and report every
|
||||
/// other one by name/address rather than picking silently.
|
||||
#[test]
|
||||
fn modular_multi_server_keeps_default_and_reports_the_rest() {
|
||||
let old = json!({
|
||||
"inbounds": [],
|
||||
"outbounds": [
|
||||
{ "type": "ostp", "tag": "proxy-0", "server": "1.1.1.1", "port": 50000, "access_key": "k1" },
|
||||
{ "type": "ostp", "tag": "proxy-1", "server": "2.2.2.2", "port": 50000, "access_key": "k2" },
|
||||
{
|
||||
"type": "urltest", "tag": "proxy",
|
||||
"outbounds": ["proxy-1", "proxy-0"], "url": "http://cp.cloudflare.com"
|
||||
}
|
||||
],
|
||||
"routing": { "rules": [], "default_outbound": "proxy" }
|
||||
});
|
||||
|
||||
let (new, report) = migrate_client_json(old);
|
||||
// urltest's first member (proxy-1 / 2.2.2.2) is the one actually picked.
|
||||
assert_eq!(new["server"], "2.2.2.2");
|
||||
assert_eq!(new["access_key"], "k2");
|
||||
assert!(report.notes.iter().any(|n| n.contains("proxy-0") && n.contains("1.1.1.1")));
|
||||
}
|
||||
|
||||
/// Pre-0.3.1 flat config carrying fields that no longer exist
|
||||
/// (tun.wintun_path, tun.ipv4_address, transport.wss, transport.stealth_sni)
|
||||
/// — those get dropped with a note; every field that's still meaningful
|
||||
/// passes through untouched, byte for byte.
|
||||
#[test]
|
||||
fn flat_legacy_drops_only_dead_fields() {
|
||||
let old = json!({
|
||||
"server": "198.51.100.9:50000",
|
||||
"access_key": "oldkey",
|
||||
"mtu": 1200,
|
||||
"socks5_bind": "127.0.0.1:1090",
|
||||
"tun": {
|
||||
"enable": true,
|
||||
"wintun_path": "C:\\Program Files\\wintun\\wintun.dll",
|
||||
"ipv4_address": "10.0.0.2",
|
||||
"dns": "1.1.1.1",
|
||||
"kill_switch": true
|
||||
},
|
||||
"exclude": { "domains": ["a.com"], "ips": null, "processes": null },
|
||||
"mux": { "enabled": false, "sessions": 1 },
|
||||
"transport": { "mode": "udp", "stealth_sni": "bing.com", "wss": true }
|
||||
});
|
||||
|
||||
let (new, report) = migrate_client_json(old);
|
||||
assert!(report.changed);
|
||||
// Untouched fields survive exactly as they were.
|
||||
assert_eq!(new["server"], "198.51.100.9:50000");
|
||||
assert_eq!(new["access_key"], "oldkey");
|
||||
assert_eq!(new["mtu"], 1200);
|
||||
assert_eq!(new["tun"]["enable"], true);
|
||||
assert_eq!(new["tun"]["dns"], "1.1.1.1");
|
||||
assert_eq!(new["tun"]["kill_switch"], true);
|
||||
assert_eq!(new["exclude"]["domains"], json!(["a.com"]));
|
||||
// Dead fields are gone...
|
||||
assert!(new["tun"].get("wintun_path").is_none());
|
||||
assert!(new["tun"].get("ipv4_address").is_none());
|
||||
assert!(new["transport"].get("wss").is_none());
|
||||
assert!(new["transport"].get("stealth_sni").is_none());
|
||||
// ...and their removal was reported, not silent.
|
||||
assert!(report.notes.iter().any(|n| n.contains("wintun_path")));
|
||||
assert!(report.notes.iter().any(|n| n.contains("ipv4_address")));
|
||||
assert!(report.notes.iter().any(|n| n.contains("wss")));
|
||||
assert!(report.notes.iter().any(|n| n.contains("stealth_sni")));
|
||||
}
|
||||
|
||||
/// A config already in the current shape must be a true no-op: report
|
||||
/// says nothing changed, and every field is untouched.
|
||||
#[test]
|
||||
fn current_flat_config_is_a_no_op() {
|
||||
let current = json!({
|
||||
"server": "example.com:50000",
|
||||
"access_key": "k",
|
||||
"tun": { "enable": false, "dns": null, "kill_switch": false },
|
||||
"exclude": { "domains": [], "ips": [], "processes": [] },
|
||||
"mux": { "enabled": false, "sessions": 1 },
|
||||
"transport": { "mode": "udp", "tcp_fragmentation": false }
|
||||
});
|
||||
let (new, report) = migrate_client_json(current.clone());
|
||||
assert!(!report.changed);
|
||||
assert_eq!(new, current);
|
||||
}
|
||||
|
||||
/// Every migrated output must actually deserialize into the ONE
|
||||
/// canonical schema (`crate::config`) — this is the same check
|
||||
/// `cmd_migrate` runs at runtime before ever touching a user's file,
|
||||
/// exercised here directly so a schema/migrator drift fails a fast unit
|
||||
/// test instead of surfacing as "your migrated config won't load".
|
||||
#[test]
|
||||
fn every_migrated_output_matches_the_canonical_schema() {
|
||||
let modular = json!({
|
||||
"inbounds": [{ "type": "tun", "tag": "tun-in", "mtu": 1350 }],
|
||||
"outbounds": [
|
||||
{ "type": "ostp", "tag": "proxy", "server": "1.2.3.4", "port": 50000, "access_key": "k" },
|
||||
{ "type": "direct", "tag": "direct" }
|
||||
],
|
||||
"routing": { "rules": [], "default_outbound": "proxy" }
|
||||
});
|
||||
let (new, _) = migrate_client_json(modular);
|
||||
serde_json::from_value::<crate::config::ClientFileConfig>(new)
|
||||
.expect("modular->flat migration output must match ClientFileConfig");
|
||||
|
||||
let legacy_flat = json!({
|
||||
"server": "1.2.3.4:50000",
|
||||
"access_key": "k",
|
||||
"tun": { "enable": true, "wintun_path": "x", "ipv4_address": "y" }
|
||||
});
|
||||
let (new, _) = migrate_client_json(legacy_flat);
|
||||
serde_json::from_value::<crate::config::ClientFileConfig>(new)
|
||||
.expect("legacy-flat migration output must match ClientFileConfig");
|
||||
|
||||
let server = json!({ "listen": "0.0.0.0:50000", "access_keys": ["k"] });
|
||||
let (new, _) = migrate_server_json(server);
|
||||
serde_json::from_value::<crate::config::ServerConfig>(new)
|
||||
.expect("server migration output must match ServerConfig");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_config_backfills_api_defaults_and_drops_legacy_token() {
|
||||
let old = json!({
|
||||
"listen": "0.0.0.0:50000",
|
||||
"access_keys": ["k1"],
|
||||
"api": { "token": "old-plain-token" }
|
||||
});
|
||||
let (new, report) = migrate_server_json(old);
|
||||
assert!(report.changed);
|
||||
assert_eq!(new["api"]["enabled"], false);
|
||||
assert_eq!(new["api"]["bind"], "0.0.0.0:9090");
|
||||
assert!(new["api"].get("token").is_none());
|
||||
assert!(report.notes.iter().any(|n| n.contains("api.token")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_kind_falls_back_to_structural_sniffing_without_mode_tag() {
|
||||
assert_eq!(detect_kind(&json!({"access_key": "x", "server": "y"})), Some(ConfigKind::Client));
|
||||
assert_eq!(detect_kind(&json!({"access_keys": ["x"], "listen": "y"})), Some(ConfigKind::Server));
|
||||
assert_eq!(detect_kind(&json!({"upstream_tcp": "x", "upstream_api_url": "y"})), Some(ConfigKind::Relay));
|
||||
assert_eq!(detect_kind(&json!({"mode": "client", "server": "x"})), Some(ConfigKind::Client));
|
||||
}
|
||||
}
|
||||
|
|
@ -10,7 +10,10 @@ use std::fs::OpenOptions;
|
|||
use std::io::Write as _;
|
||||
|
||||
fn log_to_core_file(msg: &str) {
|
||||
if let Ok(mut file) = OpenOptions::new().create(true).append(true).open("ostp-core.log") {
|
||||
// Writes into the single shared ostp.log (same file as the tracing appender),
|
||||
// not a separate ostp-core.log — see logging::LOG_FILE_NAME.
|
||||
let path = crate::logging::log_file_path();
|
||||
if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) {
|
||||
let _ = writeln!(file, "[{}] {}", chrono::Local::now().format("%Y-%m-%d %H:%M:%S"), msg);
|
||||
}
|
||||
}
|
||||
|
|
@ -107,13 +110,54 @@ fn relaunch_as_admin() -> Result<()> {
|
|||
std::process::exit(0);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn is_root() -> bool {
|
||||
unsafe { libc::geteuid() == 0 }
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn relaunch_as_root() -> Result<()> {
|
||||
use std::io::IsTerminal;
|
||||
let exe = std::env::current_exe()?;
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
|
||||
let is_gui = std::env::var("DISPLAY").is_ok() || std::env::var("WAYLAND_DISPLAY").is_ok();
|
||||
let is_term = std::io::stdout().is_terminal();
|
||||
|
||||
let mut cmd = if is_gui && !is_term {
|
||||
let mut c = std::process::Command::new("pkexec");
|
||||
c.arg(exe);
|
||||
c
|
||||
} else {
|
||||
let mut c = std::process::Command::new("sudo");
|
||||
c.arg(exe);
|
||||
c
|
||||
};
|
||||
|
||||
cmd.args(&args);
|
||||
|
||||
let status = cmd.status().map_err(|e| anyhow::anyhow!("Failed to execute privilege escalation command: {}", e))?;
|
||||
|
||||
if !status.success() {
|
||||
return Err(anyhow::anyhow!("Privilege escalation failed or was denied."));
|
||||
}
|
||||
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
||||
pub async fn run_client(config: crate::config::ClientConfig) -> Result<()> {
|
||||
#[cfg(target_os = "windows")]
|
||||
if config.mode == "tun" && !is_admin() {
|
||||
println!("[ostp-client] TUN mode requires Administrator privileges. Relaunching executable as Admin...");
|
||||
println!("[ostp] TUN mode requires administrator privileges. Relaunching...");
|
||||
relaunch_as_admin()?;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
if config.mode == "tun" && !is_root() {
|
||||
println!("[ostp] TUN mode requires root privileges. Requesting sudo/pkexec elevation...");
|
||||
relaunch_as_root()?;
|
||||
}
|
||||
|
||||
let bg = std::env::args().any(|a| a == "--bg");
|
||||
|
||||
if bg {
|
||||
|
|
@ -124,39 +168,152 @@ pub async fn run_client(config: crate::config::ClientConfig) -> Result<()> {
|
|||
bytes_sent: portable_atomic::AtomicU64::new(0),
|
||||
bytes_recv: portable_atomic::AtomicU64::new(0),
|
||||
connection_state: portable_atomic::AtomicU8::new(0),
|
||||
rtt_ms: portable_atomic::AtomicU32::new(0),
|
||||
});
|
||||
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Ok(_) = wait_for_shutdown_signal().await {
|
||||
if wait_for_shutdown_signal().await.is_ok() {
|
||||
let _ = shutdown_tx.send(true);
|
||||
}
|
||||
});
|
||||
|
||||
run_client_core(config, metrics, shutdown_rx).await
|
||||
run_client_core(config, metrics, shutdown_rx, None).await
|
||||
}
|
||||
|
||||
/// Runs the client with auto-reconnect: any subsystem ending — a network
|
||||
/// change stranding the TUN adapter/UDP socket on a dead interface, the OSTP
|
||||
/// protocol connection dropping in a way the inner Bridge-level retry (see
|
||||
/// `UiEvent::TunnelStopped` below) couldn't recover from, or a proxy/TUN task
|
||||
/// crashing outright — triggers a full clean restart (fresh DNS resolution,
|
||||
/// fresh Bridge, fresh TUN/proxy) with exponential backoff, instead of the
|
||||
/// client just dying. Only an explicit shutdown request stops this loop.
|
||||
pub async fn run_client_core(
|
||||
config: crate::config::ClientConfig,
|
||||
metrics: Arc<BridgeMetrics>,
|
||||
mut shutdown_rx_ext: watch::Receiver<bool>,
|
||||
config_rx: Option<watch::Receiver<crate::config::ClientConfig>>,
|
||||
) -> Result<()> {
|
||||
use portable_atomic::Ordering;
|
||||
|
||||
const BACKOFF_SCHEDULE_SECS: [u64; 6] = [1, 2, 5, 10, 20, 30];
|
||||
// A run that stayed up at least this long counts as "was actually
|
||||
// connected", so a later drop restarts the backoff from the top instead
|
||||
// of inheriting a long delay from a previous flaky stretch.
|
||||
const STABLE_UPTIME: std::time::Duration = std::time::Duration::from_secs(60);
|
||||
let mut backoff_idx = 0usize;
|
||||
|
||||
loop {
|
||||
if *shutdown_rx_ext.borrow() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let attempt_start = std::time::Instant::now();
|
||||
let result = run_client_once(config.clone(), metrics.clone(), shutdown_rx_ext.clone(), config_rx.clone()).await;
|
||||
|
||||
if *shutdown_rx_ext.borrow() {
|
||||
// Shutdown was requested during (or right after) this attempt — honor it, don't retry.
|
||||
return result;
|
||||
}
|
||||
if let Err(ref e) = result {
|
||||
tracing::warn!("client run ended unexpectedly, will auto-reconnect: {e}");
|
||||
}
|
||||
|
||||
if attempt_start.elapsed() >= STABLE_UPTIME {
|
||||
backoff_idx = 0;
|
||||
}
|
||||
let delay = BACKOFF_SCHEDULE_SECS[backoff_idx.min(BACKOFF_SCHEDULE_SECS.len() - 1)];
|
||||
backoff_idx += 1;
|
||||
|
||||
// Reflect the retry wait as "connecting" rather than "disconnected".
|
||||
metrics.connection_state.store(1, Ordering::Relaxed);
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(std::time::Duration::from_secs(delay)) => {}
|
||||
_ = shutdown_rx_ext.changed() => {
|
||||
if *shutdown_rx_ext.borrow() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_client_once(
|
||||
mut config: crate::config::ClientConfig,
|
||||
metrics: Arc<BridgeMetrics>,
|
||||
mut shutdown_rx_ext: watch::Receiver<bool>,
|
||||
mut config_rx: Option<watch::Receiver<crate::config::ClientConfig>>,
|
||||
) -> Result<()> {
|
||||
#[cfg(target_os = "windows")]
|
||||
if config.mode == "tun" && !is_admin() {
|
||||
return Err(anyhow::anyhow!("Administrator privileges are required to initialize TUN mode. Please run the application as Administrator."));
|
||||
}
|
||||
|
||||
log_to_core_file(&format!("[core] Starting run_client_core in mode: {}", config.mode));
|
||||
|
||||
if config.mode == "tun" && !config.exclusions.processes.is_empty() {
|
||||
println!("[ostp-client] WARNING: process exclusions are not supported in the current TUN implementation");
|
||||
#[cfg(target_os = "linux")]
|
||||
if config.mode == "tun" && !is_root() {
|
||||
return Err(anyhow::anyhow!("Root privileges are required to initialize TUN mode on Linux. Please run with sudo."));
|
||||
}
|
||||
|
||||
let (proxy_events_tx, proxy_events_rx) = mpsc::channel(10000);
|
||||
let (client_msgs_tx, client_msgs_rx) = mpsc::unbounded_channel();
|
||||
log_to_core_file(&format!("[core] Starting run_client_core in mode: {}", config.mode));
|
||||
|
||||
let bridge = Bridge::new(&config, metrics)?;
|
||||
// Resolve the server IP before we override system routing and DNS.
|
||||
// This prevents DNS deadlock if the VPN disconnects and tries to reconnect,
|
||||
// and also ensures we add the direct route to the exact IP the bridge connects to.
|
||||
#[allow(unused_mut)]
|
||||
let mut resolved_addrs: Vec<std::net::SocketAddr> = tokio::net::lookup_host(&config.ostp.server_addr)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to resolve server address {}: {}", config.ostp.server_addr, e))?
|
||||
.collect();
|
||||
|
||||
|
||||
let target_addr = resolved_addrs.first()
|
||||
.ok_or_else(|| anyhow::anyhow!("No IP addresses resolved for {}", config.ostp.server_addr))?;
|
||||
|
||||
log_to_core_file(&format!("[core] Resolved server address to {}", target_addr));
|
||||
config.ostp.server_addr = target_addr.to_string();
|
||||
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
if config.mode == "tun" {
|
||||
println!("\n[ostp] ===========================================================================");
|
||||
println!("[ostp] WARNING: You are starting TUN mode on a Linux system.");
|
||||
println!("[ostp] If this is a remote headless server, routing all traffic through the TUN");
|
||||
println!("[ostp] interface WILL DROP your SSH connection and lock you out!");
|
||||
println!("[ostp] ");
|
||||
println!("[ostp] SOLUTION: Add a static route for your client IP to bypass the TUN.");
|
||||
println!("[ostp] Find your default gateway (ip route | grep default) and run:");
|
||||
println!("[ostp] sudo ip route add <your-client-ip> via <default-gateway-ip>");
|
||||
println!("[ostp] ===========================================================================\n");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
if config.mode == "proxy" {
|
||||
println!("\n[ostp] ===========================================================================");
|
||||
println!("[ostp] Proxy mode initialized on {}", config.local_proxy.bind_addr);
|
||||
println!("[ostp] ===========================================================================\n");
|
||||
}
|
||||
|
||||
let _sysproxy_guard = if config.mode == "proxy" {
|
||||
// Enable system proxy and set initial ProxyOverride with user exclusions
|
||||
let guard = Some(crate::sysproxy::SystemProxyGuard::enable(&config.local_proxy.bind_addr));
|
||||
crate::sysproxy::update_proxy_bypass_list(
|
||||
&config.exclusions.domains,
|
||||
&config.exclusions.ips,
|
||||
);
|
||||
guard
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let (proxy_events_tx, proxy_events_rx) = mpsc::channel(256);
|
||||
let (client_msgs_tx, client_msgs_rx) = mpsc::unbounded_channel();
|
||||
|
||||
// Setup exclusions hot-reload channel
|
||||
let (reload_tx, reload_rx) = watch::channel(config.exclusions.clone());
|
||||
|
||||
let mut bridge = Bridge::new(&config, metrics)?;
|
||||
bridge.reload_tx = Some(reload_tx.clone());
|
||||
|
||||
let (ui_tx, mut ui_rx) = mpsc::channel(512);
|
||||
let (cmd_tx, cmd_rx) = mpsc::channel(128);
|
||||
|
|
@ -177,25 +334,25 @@ pub async fn run_client_core(
|
|||
match msg {
|
||||
crate::app::UiEvent::Log(text) => {
|
||||
if debug_enabled || is_essential_log(&text) {
|
||||
log_to_core_file(&format!("[client] {text}"));
|
||||
println!("[client] {text}");
|
||||
log_to_core_file(&format!("[ostp] {text}"));
|
||||
println!("[ostp] {text}");
|
||||
}
|
||||
}
|
||||
crate::app::UiEvent::Metrics { status, rtt_ms, .. } => {
|
||||
let status_str = status.as_str().to_string();
|
||||
if last_status != Some(status_str.clone()) {
|
||||
last_status = Some(status_str.clone());
|
||||
println!("[client] status={status_str} rtt_ms={:.1}", rtt_ms);
|
||||
println!("[ostp] Status: {} (rtt={:.1}ms)", status_str, rtt_ms);
|
||||
}
|
||||
}
|
||||
crate::app::UiEvent::Traffic { .. } => {}
|
||||
crate::app::UiEvent::ProfileChanged(profile) => {
|
||||
if debug_enabled {
|
||||
println!("[client] profile={profile:?}");
|
||||
println!("[ostp] Obfuscation profile: {profile:?}");
|
||||
}
|
||||
}
|
||||
crate::app::UiEvent::TunnelStopped => {
|
||||
println!("[client] Connection lost or failed. Reconnecting in 5s...");
|
||||
println!("[ostp] Connection interrupted. Reconnecting in 5 seconds...");
|
||||
let cmd_tx_inner = cmd_tx_clone.clone();
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
|
||||
|
|
@ -211,11 +368,12 @@ pub async fn run_client_core(
|
|||
});
|
||||
|
||||
let config_clone = config.clone();
|
||||
let proxy_exclusions_rx = reload_rx.clone();
|
||||
let mut proxy_task = tokio::spawn(async move {
|
||||
tunnel::run_local_proxy(
|
||||
config.local_proxy,
|
||||
config.ostp,
|
||||
config.exclusions,
|
||||
proxy_exclusions_rx,
|
||||
config.debug,
|
||||
proxy_shutdown_rx,
|
||||
proxy_events_tx,
|
||||
|
|
@ -225,14 +383,49 @@ pub async fn run_client_core(
|
|||
});
|
||||
|
||||
let wintun_shutdown_rx = shutdown_tx.subscribe();
|
||||
let wintun_exclusions_rx = reload_rx.clone();
|
||||
let mut wintun_task = if config_clone.mode == "tun" {
|
||||
Some(tokio::spawn(async move {
|
||||
tunnel::run_tun_tunnel(config_clone, wintun_shutdown_rx).await
|
||||
tunnel::run_tun_tunnel(config_clone, wintun_shutdown_rx, wintun_exclusions_rx).await
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Wait for local_shutdown
|
||||
let mut local_shutdown = shutdown_rx_ext.clone();
|
||||
let cmd_tx_loop = cmd_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = local_shutdown.changed() => {
|
||||
if *local_shutdown.borrow() {
|
||||
let _ = cmd_tx_loop.send(BridgeCommand::Shutdown).await;
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(Ok(_)) = async {
|
||||
if let Some(ref mut rx) = config_rx {
|
||||
Some(rx.changed().await)
|
||||
} else {
|
||||
std::future::pending().await
|
||||
}
|
||||
} => {
|
||||
if let Some(ref rx) = config_rx {
|
||||
let new_cfg = rx.borrow().clone();
|
||||
// Update Windows ProxyOverride so excluded domains/IPs
|
||||
// bypass the system proxy immediately (proxy mode only).
|
||||
crate::sysproxy::update_proxy_bypass_list(
|
||||
&new_cfg.exclusions.domains,
|
||||
&new_cfg.exclusions.ips,
|
||||
);
|
||||
let _ = reload_tx.send(new_cfg.exclusions);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for either external shutdown OR any task to fail
|
||||
tokio::select! {
|
||||
_ = shutdown_rx_ext.changed() => {
|
||||
|
|
@ -279,15 +472,17 @@ fn format_bytes(bps: u64) -> String {
|
|||
fn is_essential_log(text: &str) -> bool {
|
||||
matches!(
|
||||
text,
|
||||
"Handshaking started"
|
||||
| "Bridge connection established"
|
||||
| "TUN Tunnel established"
|
||||
"Connection established"
|
||||
| "TUN tunnel established"
|
||||
| "TUN tunnel stopped"
|
||||
| "Bridge stopped"
|
||||
| "TUN Tunnel stopped"
|
||||
| "Runtime config reloaded"
|
||||
) || text.starts_with("Connected UDP directly to ")
|
||||
|| text.starts_with("TURN: Relay allocated")
|
||||
| "Connecting to remote server..."
|
||||
) || text.starts_with("Connected to ")
|
||||
|| text.starts_with("TURN relay allocated")
|
||||
|| text.starts_with("TURN allocation failed")
|
||||
|| text.starts_with("Handshake failed")
|
||||
|| text.starts_with("Connection timeout")
|
||||
|| text.starts_with("Allocating TURN relay")
|
||||
|| text.starts_with("Connection failed:")
|
||||
|| text.starts_with("Connection lost")
|
||||
|| text.starts_with("Protocol tick fatal error")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,8 +8,12 @@ pub async fn wait_for_shutdown_signal() -> Result<()> {
|
|||
let mut sigint = signal(SignalKind::interrupt())?;
|
||||
|
||||
tokio::select! {
|
||||
_ = sigterm.recv() => {}
|
||||
_ = sigint.recv() => {}
|
||||
_ = sigterm.recv() => {
|
||||
tracing::info!("Received SIGTERM, shutting down");
|
||||
}
|
||||
_ = sigint.recv() => {
|
||||
tracing::info!("Received SIGINT, shutting down");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
|
@ -25,9 +29,24 @@ pub async fn wait_for_shutdown_signal() -> Result<()> {
|
|||
let mut c_break = ctrl_break()?;
|
||||
|
||||
tokio::select! {
|
||||
_ = c_c.recv() => {}
|
||||
_ = c_close.recv() => {}
|
||||
_ = c_break.recv() => {}
|
||||
res = c_c.recv() => {
|
||||
tracing::info!("Received Ctrl+C, shutting down");
|
||||
if res.is_none() {
|
||||
std::future::pending::<()>().await;
|
||||
}
|
||||
}
|
||||
res = c_close.recv() => {
|
||||
tracing::info!("Received console close event, shutting down");
|
||||
if res.is_none() {
|
||||
std::future::pending::<()>().await;
|
||||
}
|
||||
}
|
||||
res = c_break.recv() => {
|
||||
tracing::info!("Received Ctrl+Break, shutting down");
|
||||
if res.is_none() {
|
||||
std::future::pending::<()>().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
#[cfg(target_os = "windows")]
|
||||
use std::process::Command;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[link(name = "wininet")]
|
||||
extern "system" {
|
||||
|
|
@ -18,50 +24,143 @@ const INTERNET_OPTION_REFRESH: u32 = 37;
|
|||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn enable_windows_proxy(proxy_addr: &str) {
|
||||
let _ = Command::new("reg")
|
||||
.args([
|
||||
"add",
|
||||
"HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings",
|
||||
"/v",
|
||||
"ProxyEnable",
|
||||
"/t",
|
||||
"REG_DWORD",
|
||||
"/d",
|
||||
"1",
|
||||
"/f",
|
||||
])
|
||||
.output();
|
||||
|
||||
let proxy_str = proxy_addr.to_string();
|
||||
let _ = Command::new("reg")
|
||||
.args([
|
||||
"add",
|
||||
"HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings",
|
||||
"/v",
|
||||
"ProxyServer",
|
||||
"/t",
|
||||
"REG_SZ",
|
||||
"/d",
|
||||
&proxy_str,
|
||||
"/f",
|
||||
])
|
||||
.output();
|
||||
tracing::info!("Enabling Windows system proxy: {}", proxy_addr);
|
||||
|
||||
let result = Command::new("reg")
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.args([
|
||||
"add",
|
||||
"HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings",
|
||||
"/v", "ProxyEnable",
|
||||
"/t", "REG_DWORD",
|
||||
"/d", "1",
|
||||
"/f",
|
||||
])
|
||||
.output();
|
||||
match result {
|
||||
Ok(out) if !out.status.success() => {
|
||||
tracing::error!("Failed to set ProxyEnable: {}", String::from_utf8_lossy(&out.stderr));
|
||||
}
|
||||
Err(e) => tracing::error!("Failed to execute reg.exe (ProxyEnable): {}", e),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let result = Command::new("reg")
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.args([
|
||||
"add",
|
||||
"HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings",
|
||||
"/v", "ProxyServer",
|
||||
"/t", "REG_SZ",
|
||||
"/d", proxy_addr,
|
||||
"/f",
|
||||
])
|
||||
.output();
|
||||
match result {
|
||||
Ok(out) if !out.status.success() => {
|
||||
tracing::error!("Failed to set ProxyServer: {}", String::from_utf8_lossy(&out.stderr));
|
||||
}
|
||||
Err(e) => tracing::error!("Failed to execute reg.exe (ProxyServer): {}", e),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Set initial bypass list (will be expanded by update_proxy_bypass_list)
|
||||
update_proxy_bypass_list_windows(&[], &[]);
|
||||
|
||||
refresh_wininet();
|
||||
tracing::info!("System proxy enabled successfully");
|
||||
}
|
||||
|
||||
/// Update the Windows ProxyOverride registry value to include user-configured
|
||||
/// excluded domains and IPs. This makes excluded hosts bypass the OSTP proxy
|
||||
/// entirely at the OS level — the most reliable split-tunneling mechanism.
|
||||
///
|
||||
/// For each domain `d`, adds both `d` and `*.d` so both the root and all
|
||||
/// subdomains bypass the proxy.
|
||||
/// For IPs, adds them verbatim (Windows supports exact IPs and wildcards like
|
||||
/// `192.168.*`).
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn update_proxy_bypass_list(domains: &[String], ips: &[String]) {
|
||||
update_proxy_bypass_list_windows(domains, ips);
|
||||
refresh_wininet();
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub fn update_proxy_bypass_list(_domains: &[String], _ips: &[String]) {
|
||||
// Linux/macOS: no-op (gnome/kde proxy bypass list update not implemented)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn disable_windows_proxy() {
|
||||
fn update_proxy_bypass_list_windows(domains: &[String], ips: &[String]) {
|
||||
// Base list: always bypass local addresses
|
||||
let mut parts: Vec<String> = vec![
|
||||
"localhost".into(),
|
||||
"127.*".into(),
|
||||
"10.*".into(),
|
||||
"172.16.*".into(),
|
||||
"172.17.*".into(),
|
||||
"172.18.*".into(),
|
||||
"172.19.*".into(),
|
||||
"172.20.*".into(),
|
||||
"172.21.*".into(),
|
||||
"172.22.*".into(),
|
||||
"172.23.*".into(),
|
||||
"172.24.*".into(),
|
||||
"172.25.*".into(),
|
||||
"172.26.*".into(),
|
||||
"172.27.*".into(),
|
||||
"172.28.*".into(),
|
||||
"172.29.*".into(),
|
||||
"172.30.*".into(),
|
||||
"172.31.*".into(),
|
||||
"192.168.*".into(),
|
||||
"<local>".into(),
|
||||
];
|
||||
|
||||
// Add excluded domains: both exact and wildcard subdomain form
|
||||
for d in domains {
|
||||
let d = d.trim().trim_start_matches('.').to_lowercase();
|
||||
if d.is_empty() { continue; }
|
||||
parts.push(d.clone());
|
||||
parts.push(format!("*.{}", d));
|
||||
}
|
||||
|
||||
// Add excluded IPs verbatim
|
||||
for ip in ips {
|
||||
let ip = ip.trim();
|
||||
if ip.is_empty() { continue; }
|
||||
// Strip CIDR suffix if present — Windows ProxyOverride doesn't support CIDR
|
||||
let host = ip.split('/').next().unwrap_or(ip);
|
||||
parts.push(host.to_string());
|
||||
}
|
||||
|
||||
let override_value = parts.join(";");
|
||||
tracing::info!("Updating ProxyOverride: {}", override_value);
|
||||
|
||||
let _ = Command::new("reg")
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.args([
|
||||
"add",
|
||||
"HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings",
|
||||
"/v",
|
||||
"ProxyEnable",
|
||||
"/t",
|
||||
"REG_DWORD",
|
||||
"/d",
|
||||
"0",
|
||||
"/v", "ProxyOverride",
|
||||
"/t", "REG_SZ",
|
||||
"/d", &override_value,
|
||||
"/f",
|
||||
])
|
||||
.output();
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn disable_system_proxy() {
|
||||
tracing::info!("Disabling Windows system proxy");
|
||||
let _ = Command::new("reg")
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.args([
|
||||
"add",
|
||||
"HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings",
|
||||
"/v", "ProxyEnable",
|
||||
"/t", "REG_DWORD",
|
||||
"/d", "0",
|
||||
"/f",
|
||||
])
|
||||
.output();
|
||||
|
|
@ -88,26 +187,92 @@ fn refresh_wininet() {
|
|||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub fn enable_windows_proxy(_proxy_addr: &str) {}
|
||||
pub fn enable_system_proxy(proxy_addr: &str) {
|
||||
let parts: Vec<&str> = proxy_addr.split(':').collect();
|
||||
let host = parts.first().unwrap_or(&"127.0.0.1");
|
||||
let port = parts.get(1).unwrap_or(&"1088");
|
||||
|
||||
let is_gui = std::env::var("DISPLAY").is_ok() || std::env::var("WAYLAND_DISPLAY").is_ok();
|
||||
|
||||
if is_gui {
|
||||
tracing::info!("Enabling Linux system proxy (GNOME/KDE): {}", proxy_addr);
|
||||
|
||||
// Try GNOME gsettings
|
||||
let gnome_res = std::process::Command::new("gsettings")
|
||||
.args(["set", "org.gnome.system.proxy", "mode", "manual"])
|
||||
.output();
|
||||
|
||||
if let Ok(out) = gnome_res {
|
||||
if out.status.success() {
|
||||
let _ = std::process::Command::new("gsettings").args(["set", "org.gnome.system.proxy.socks", "host", host]).output();
|
||||
let _ = std::process::Command::new("gsettings").args(["set", "org.gnome.system.proxy.socks", "port", port]).output();
|
||||
let _ = std::process::Command::new("gsettings").args(["set", "org.gnome.system.proxy", "ignore-hosts", "['localhost', '127.0.0.0/8', '10.0.0.0/8', '192.168.0.0/16']"]).output();
|
||||
tracing::info!("GNOME system proxy enabled.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Try KDE kwriteconfig5/6
|
||||
for cmd in ["kwriteconfig5", "kwriteconfig6"] {
|
||||
let kde_res = std::process::Command::new(cmd)
|
||||
.args(["--file", "kioslaverc", "--group", "Proxy Settings", "--key", "ProxyType", "1"])
|
||||
.output();
|
||||
|
||||
if let Ok(out) = kde_res {
|
||||
if out.status.success() {
|
||||
let socks_val = format!("socks://{}:{}", host, port);
|
||||
let _ = std::process::Command::new(cmd).args(["--file", "kioslaverc", "--group", "Proxy Settings", "--key", "socksProxy", &socks_val]).output();
|
||||
let _ = std::process::Command::new("dbus-send").args(["--type=signal", "/KIO/Scheduler", "org.kde.KIO.Scheduler.reparseSlaveConfiguration", "string:''"]).output();
|
||||
tracing::info!("KDE system proxy enabled.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Headless fallback
|
||||
println!("\n===================================================================");
|
||||
println!("OSTP Local Proxy is running at socks5://{}", proxy_addr);
|
||||
println!("Since you are in a headless/terminal environment, OSTP cannot automatically");
|
||||
println!("configure your system proxy. To route traffic from this terminal, run:");
|
||||
println!("\n eval $(ostp proxy-env)\n");
|
||||
println!("Or configure your application (e.g. curl -x socks5://{})", proxy_addr);
|
||||
println!("===================================================================\n");
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub fn disable_windows_proxy() {}
|
||||
pub fn disable_system_proxy() {
|
||||
let is_gui = std::env::var("DISPLAY").is_ok() || std::env::var("WAYLAND_DISPLAY").is_ok();
|
||||
if is_gui {
|
||||
tracing::info!("Disabling Linux system proxy...");
|
||||
let _ = std::process::Command::new("gsettings").args(["set", "org.gnome.system.proxy", "mode", "none"]).output();
|
||||
let _ = std::process::Command::new("kwriteconfig5").args(["--file", "kioslaverc", "--group", "Proxy Settings", "--key", "ProxyType", "0"]).output();
|
||||
let _ = std::process::Command::new("kwriteconfig6").args(["--file", "kioslaverc", "--group", "Proxy Settings", "--key", "ProxyType", "0"]).output();
|
||||
let _ = std::process::Command::new("dbus-send").args(["--type=signal", "/KIO/Scheduler", "org.kde.KIO.Scheduler.reparseSlaveConfiguration", "string:''"]).output();
|
||||
}
|
||||
}
|
||||
|
||||
pub struct WindowsProxyGuard {
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn enable_system_proxy(proxy_addr: &str) {
|
||||
enable_windows_proxy(proxy_addr);
|
||||
}
|
||||
|
||||
|
||||
pub struct SystemProxyGuard {
|
||||
active: bool,
|
||||
}
|
||||
|
||||
impl WindowsProxyGuard {
|
||||
impl SystemProxyGuard {
|
||||
pub fn enable(proxy_addr: &str) -> Self {
|
||||
enable_windows_proxy(proxy_addr);
|
||||
enable_system_proxy(proxy_addr);
|
||||
Self { active: true }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for WindowsProxyGuard {
|
||||
impl Drop for SystemProxyGuard {
|
||||
fn drop(&mut self) {
|
||||
if self.active {
|
||||
disable_windows_proxy();
|
||||
disable_system_proxy();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,56 @@
|
|||
|
||||
use std::sync::Arc;
|
||||
use tokio::net::UdpSocket;
|
||||
use bytes::Bytes;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum Transport {
|
||||
Udp(Arc<UdpSocket>),
|
||||
Uot {
|
||||
tx: tokio::sync::mpsc::Sender<Bytes>,
|
||||
rx: Arc<tokio::sync::Mutex<tokio::sync::mpsc::Receiver<Bytes>>>,
|
||||
}
|
||||
}
|
||||
|
||||
impl Transport {
|
||||
pub async fn send(&self, frame: &Bytes) -> std::io::Result<usize> {
|
||||
match self {
|
||||
Self::Udp(sock) => sock.send(frame).await,
|
||||
Self::Uot { tx, .. } => {
|
||||
tx.send(frame.clone()).await.map_err(|_| std::io::Error::new(std::io::ErrorKind::BrokenPipe, "uot closed"))?;
|
||||
Ok(frame.len())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn send_to(&self, frame: &Bytes, target: std::net::SocketAddr) -> std::io::Result<usize> {
|
||||
match self {
|
||||
Self::Udp(sock) => sock.send_to(frame, target).await,
|
||||
Self::Uot { .. } => self.send(frame).await,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn recv(&self, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||
match self {
|
||||
Self::Udp(sock) => sock.recv(buf).await,
|
||||
Self::Uot { rx, .. } => {
|
||||
let mut rx = rx.lock().await;
|
||||
match rx.recv().await {
|
||||
Some(bytes) => {
|
||||
let len = bytes.len().min(buf.len());
|
||||
buf[..len].copy_from_slice(&bytes[..len]);
|
||||
Ok(len)
|
||||
}
|
||||
None => Err(std::io::Error::new(std::io::ErrorKind::BrokenPipe, "uot closed")),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn local_addr(&self) -> std::io::Result<std::net::SocketAddr> {
|
||||
match self {
|
||||
Self::Udp(sock) => sock.local_addr(),
|
||||
Self::Uot { .. } => Ok("0.0.0.0:0".parse().unwrap()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
use crate::config::ExclusionConfig;
|
||||
use std::time::Duration;
|
||||
use tokio::time::timeout;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ExclusionMatcher {
|
||||
pub domain_suffix: Vec<String>,
|
||||
pub cidrs: Vec<Cidr>,
|
||||
pub processes: Vec<String>,
|
||||
pub physical_if_index: Option<u32>,
|
||||
pub physical_if_name: Option<String>,
|
||||
}
|
||||
|
||||
impl ExclusionMatcher {
|
||||
pub fn new(
|
||||
exclusions: &ExclusionConfig,
|
||||
physical_if_index: Option<u32>,
|
||||
physical_if_name: Option<String>,
|
||||
) -> Self {
|
||||
let mut cidrs = Vec::new();
|
||||
for ip in &exclusions.ips {
|
||||
if let Some(cidr) = parse_cidr(ip) {
|
||||
cidrs.push(cidr);
|
||||
}
|
||||
}
|
||||
|
||||
let processes = exclusions.processes.iter()
|
||||
.map(|p| p.trim().to_lowercase())
|
||||
.filter(|p| !p.is_empty())
|
||||
.collect();
|
||||
|
||||
Self {
|
||||
domain_suffix: exclusions
|
||||
.domains
|
||||
.iter()
|
||||
.map(|d| d.trim().trim_start_matches('.').to_lowercase())
|
||||
.filter(|d| !d.is_empty())
|
||||
.collect(),
|
||||
cidrs,
|
||||
processes,
|
||||
physical_if_index,
|
||||
physical_if_name,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn should_bypass_target(&self, host: &str, port: u16, timeout_value: Duration) -> bool {
|
||||
if self.match_domain(host) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if self.cidrs.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Ok(ip) = host.parse::<std::net::IpAddr>() {
|
||||
return self.match_ip(&ip);
|
||||
}
|
||||
|
||||
let lookup_target = (host.to_string(), port);
|
||||
match timeout(timeout_value, tokio::net::lookup_host(lookup_target)).await {
|
||||
Ok(Ok(addrs)) => addrs.into_iter().any(|addr| self.match_ip(&addr.ip())),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn match_domain(&self, host: &str) -> bool {
|
||||
if self.domain_suffix.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let host = host.trim_end_matches('.').to_lowercase();
|
||||
self.domain_suffix.iter().any(|suffix| {
|
||||
host == *suffix || host.ends_with(&format!(".{suffix}"))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn match_ip(&self, ip: &std::net::IpAddr) -> bool {
|
||||
self.cidrs.iter().any(|cidr| cidr.contains(ip))
|
||||
}
|
||||
|
||||
pub fn match_process(&self, process_name: &str) -> bool {
|
||||
if self.processes.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let p = process_name.to_lowercase();
|
||||
self.processes.iter().any(|ex| p.contains(ex))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum Cidr {
|
||||
V4(u32, u8),
|
||||
V6(u128, u8),
|
||||
}
|
||||
|
||||
impl Cidr {
|
||||
pub fn contains(&self, ip: &std::net::IpAddr) -> bool {
|
||||
match (self, ip) {
|
||||
(Cidr::V4(net, bits), std::net::IpAddr::V4(addr)) => {
|
||||
let mask = if *bits == 0 { 0 } else { u32::MAX << (32 - bits) };
|
||||
let ip = u32::from_be_bytes(addr.octets());
|
||||
(ip & mask) == (*net & mask)
|
||||
}
|
||||
(Cidr::V6(net, bits), std::net::IpAddr::V6(addr)) => {
|
||||
let mask = if *bits == 0 { 0 } else { u128::MAX << (128 - bits) };
|
||||
let ip = u128::from_be_bytes(addr.octets());
|
||||
(ip & mask) == (*net & mask)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_cidr(s: &str) -> Option<Cidr> {
|
||||
let parts: Vec<&str> = s.split('/').collect();
|
||||
if parts.is_empty() || parts.len() > 2 {
|
||||
return None;
|
||||
}
|
||||
if let Ok(ip) = parts[0].parse::<std::net::IpAddr>() {
|
||||
let bits = if parts.len() == 2 {
|
||||
parts[1].parse::<u8>().ok()?
|
||||
} else {
|
||||
match ip {
|
||||
std::net::IpAddr::V4(_) => 32,
|
||||
std::net::IpAddr::V6(_) => 128,
|
||||
}
|
||||
};
|
||||
match ip {
|
||||
std::net::IpAddr::V4(v4) => Some(Cidr::V4(u32::from_be_bytes(v4.octets()), bits)),
|
||||
std::net::IpAddr::V6(v6) => Some(Cidr::V6(u128::from_be_bytes(v6.octets()), bits)),
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
|
@ -1,233 +0,0 @@
|
|||
use anyhow::{anyhow, Result};
|
||||
use tokio::sync::watch;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
use std::net::ToSocketAddrs;
|
||||
#[cfg(target_os = "linux")]
|
||||
use std::process::{Command, Stdio, Child};
|
||||
#[cfg(target_os = "linux")]
|
||||
use std::io::{BufRead, BufReader};
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
struct LinuxRouteGuard {
|
||||
server_ip_str: String,
|
||||
default_gw: String,
|
||||
default_if: String,
|
||||
child: Option<Child>,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl Drop for LinuxRouteGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Some(mut child) = self.child.take() {
|
||||
let _ = child.kill();
|
||||
}
|
||||
let cleanup_script = format!(
|
||||
"ip route del 0.0.0.0/1 dev ostp_tun || true; \
|
||||
ip route del 128.0.0.0/1 dev ostp_tun || true; \
|
||||
ip route del {} via {} dev {} || true; \
|
||||
ip route del 1.1.1.1 via {} dev {} || true; \
|
||||
ip link set dev ostp_tun down || true; \
|
||||
ip tuntap del name ostp_tun mode tun || true",
|
||||
self.server_ip_str, self.default_gw, self.default_if,
|
||||
self.default_gw, self.default_if
|
||||
);
|
||||
let _ = Command::new("sh").args(["-c", &cleanup_script]).output();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub async fn run_linux_tunnel(
|
||||
config: crate::config::ClientConfig,
|
||||
mut shutdown: watch::Receiver<bool>,
|
||||
) -> Result<()> {
|
||||
let debug = config.debug;
|
||||
if debug {
|
||||
println!("[ostp-client] Starting Linux TUN handler initialization...");
|
||||
}
|
||||
|
||||
let exe = std::env::current_exe()?;
|
||||
let dir = exe.parent().ok_or_else(|| anyhow!("failed to get binary directory"))?;
|
||||
|
||||
let mut tun2socks_exe = dir.join("tun2socks");
|
||||
if !tun2socks_exe.exists() {
|
||||
// Try system PATH via standard command check
|
||||
let in_path = Command::new("which")
|
||||
.arg("tun2socks")
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false);
|
||||
|
||||
if in_path {
|
||||
tun2socks_exe = std::path::PathBuf::from("tun2socks");
|
||||
} else {
|
||||
return Err(anyhow!(
|
||||
"CRITICAL: 'tun2socks' binary is missing!\n\
|
||||
OSTP requires tun2socks for TUN mode on Linux. Please download the appropriate binary for your architecture from: \n\
|
||||
https://github.com/xjasonlyu/tun2socks/releases \n\
|
||||
and place it in the same directory as the ostp executable ({}), or install it globally in your PATH.",
|
||||
dir.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// 1.5. Pre-flight system checks
|
||||
let is_root = Command::new("id")
|
||||
.arg("-u")
|
||||
.output()
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim() == "0")
|
||||
.unwrap_or(false);
|
||||
|
||||
if !is_root {
|
||||
return Err(anyhow!("FATAL: OSTP TUN mode requires root privileges on Linux. Please run via sudo."));
|
||||
}
|
||||
|
||||
let has_ip_cmd = Command::new("which")
|
||||
.arg("ip")
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false);
|
||||
|
||||
if !has_ip_cmd {
|
||||
return Err(anyhow!("FATAL: 'ip' command not found. OSTP TUN mode requires 'iproute2' package to be installed."));
|
||||
}
|
||||
|
||||
// 2. Resolve Server IP for routing table exclusion
|
||||
let server_ip = config.ostp.server_addr.to_socket_addrs()
|
||||
.map_err(|e| anyhow!("Failed to resolve remote server IP: {}", e))?
|
||||
.next()
|
||||
.map(|addr| addr.ip())
|
||||
.ok_or_else(|| anyhow!("Could not resolve host IP for routing exclusion"))?;
|
||||
|
||||
let server_ip_str = server_ip.to_string();
|
||||
|
||||
if debug {
|
||||
println!("[ostp-client] Resolved remote server IP: {}", server_ip_str);
|
||||
}
|
||||
|
||||
// 3. Detect current default gateway and interface
|
||||
let route_output = Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg("ip route show default | head -n1")
|
||||
.output()?;
|
||||
|
||||
let route_str = String::from_utf8_lossy(&route_output.stdout);
|
||||
let parts: Vec<&str> = route_str.split_whitespace().collect();
|
||||
|
||||
// Expected: "default via 192.168.1.1 dev eth0 ..."
|
||||
let mut default_gw = String::new();
|
||||
let mut default_if = String::new();
|
||||
|
||||
for i in 0..parts.len() {
|
||||
if parts[i] == "via" && i + 1 < parts.len() {
|
||||
default_gw = parts[i+1].to_string();
|
||||
}
|
||||
if parts[i] == "dev" && i + 1 < parts.len() {
|
||||
default_if = parts[i+1].to_string();
|
||||
}
|
||||
}
|
||||
|
||||
if default_gw.is_empty() || default_if.is_empty() {
|
||||
return Err(anyhow!("Failed to discover active default gateway or network interface on Linux system."));
|
||||
}
|
||||
|
||||
if debug {
|
||||
println!("[ostp-client] Physical route anchor: gateway={} interface={}", default_gw, default_if);
|
||||
}
|
||||
|
||||
// 4. Setup commands (Using standard /1 routing trick for fail-proof overriding)
|
||||
let setup_script = format!(
|
||||
"ip tuntap add name ostp_tun mode tun || true; \
|
||||
ip link set dev ostp_tun mtu 1300; \
|
||||
ip addr add 10.1.0.2/24 dev ostp_tun || true; \
|
||||
ip link set dev ostp_tun up; \
|
||||
ip route add {} via {} dev {}; \
|
||||
ip route add 1.1.1.1 via {} dev {}; \
|
||||
ip route add 0.0.0.0/1 dev ostp_tun; \
|
||||
ip route add 128.0.0.0/1 dev ostp_tun",
|
||||
server_ip_str, default_gw, default_if,
|
||||
default_gw, default_if
|
||||
);
|
||||
|
||||
if debug {
|
||||
println!("[ostp-client] Executing Linux network config: {}", setup_script);
|
||||
}
|
||||
|
||||
let out = Command::new("sh")
|
||||
.args(["-c", &setup_script])
|
||||
.output()?;
|
||||
|
||||
if !out.status.success() && debug {
|
||||
println!("[ostp-client] Warning: Setup routing returned: {}", String::from_utf8_lossy(&out.stderr));
|
||||
}
|
||||
|
||||
// 5. Prepare and launch tun2socks
|
||||
// Using HTTP Proxy natively avoids any UDP Associate requests,
|
||||
// providing clean TCP proxying with maximum reliability.
|
||||
let proxy_url = format!("http://{}", config.local_proxy.bind_addr);
|
||||
|
||||
if debug {
|
||||
println!("[ostp-client] Spawning {} -device ostp_tun -proxy {}", tun2socks_exe.display(), proxy_url);
|
||||
}
|
||||
|
||||
let mut child = Command::new(&tun2socks_exe)
|
||||
.args([
|
||||
"-device", "ostp_tun",
|
||||
"-proxy", &proxy_url,
|
||||
])
|
||||
.stdout(if debug { Stdio::piped() } else { Stdio::null() })
|
||||
.stderr(if debug { Stdio::piped() } else { Stdio::null() })
|
||||
.spawn()
|
||||
.map_err(|e| anyhow!("Failed to spawn tun2socks process: {}", e))?;
|
||||
|
||||
let mut _guard = LinuxRouteGuard {
|
||||
server_ip_str: server_ip_str.clone(),
|
||||
default_gw: default_gw.clone(),
|
||||
default_if: default_if.clone(),
|
||||
child: None,
|
||||
};
|
||||
|
||||
println!("[client] TUN Tunnel established, Linux traffic is now routing through OSTP.");
|
||||
|
||||
if debug {
|
||||
let stdout = child.stdout.take().unwrap();
|
||||
let stderr = child.stderr.take().unwrap();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let reader = BufReader::new(stdout);
|
||||
for line in reader.lines().map_while(Result::ok) {
|
||||
println!("[tun2socks] {}", line);
|
||||
}
|
||||
});
|
||||
|
||||
tokio::spawn(async move {
|
||||
let reader = BufReader::new(stderr);
|
||||
for line in reader.lines().map_while(Result::ok) {
|
||||
eprintln!("[tun2socks-err] {}", line);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_guard.child = Some(child);
|
||||
|
||||
// 6. Wait for shutdown signal
|
||||
let _ = shutdown.changed().await;
|
||||
|
||||
println!("[client] Deactivating TUN tunnel and restoring Linux network topology...");
|
||||
|
||||
// Drop guard runs cleanup automatically
|
||||
drop(_guard);
|
||||
|
||||
println!("[client] Linux TUN Tunnel stopped.");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
#[allow(dead_code)]
|
||||
pub async fn run_linux_tunnel(
|
||||
_config: crate::config::ClientConfig,
|
||||
_shutdown: watch::Receiver<bool>,
|
||||
) -> Result<()> {
|
||||
Err(anyhow!("Linux tunnel driver executed on a non-Linux host!"))
|
||||
}
|
||||
|
|
@ -1,27 +1,14 @@
|
|||
mod proxy;
|
||||
mod wintun_handler;
|
||||
mod linux_handler;
|
||||
pub mod native_handler;
|
||||
|
||||
mod udp_nat;
|
||||
|
||||
pub async fn run_tun_tunnel(
|
||||
config: crate::config::ClientConfig,
|
||||
shutdown: watch::Receiver<bool>,
|
||||
shutdown: tokio::sync::watch::Receiver<bool>,
|
||||
exclusions_rx: tokio::sync::watch::Receiver<crate::config::ExclusionConfig>,
|
||||
) -> anyhow::Result<()> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
wintun_handler::run_wintun_tunnel(config, shutdown).await
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
linux_handler::run_linux_tunnel(config, shutdown).await
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
|
||||
{
|
||||
let _ = shutdown;
|
||||
let _ = config;
|
||||
anyhow::bail!("Operating system unsupported, text an issue at github.");
|
||||
}
|
||||
native_handler::run_native_tunnel(config, shutdown, exclusions_rx).await
|
||||
}
|
||||
|
||||
use tokio::sync::{mpsc, watch};
|
||||
|
|
@ -36,6 +23,14 @@ pub enum ProxyEvent {
|
|||
stream_id: u16,
|
||||
target: String,
|
||||
},
|
||||
UdpAssociate {
|
||||
stream_id: u16,
|
||||
},
|
||||
UdpData {
|
||||
stream_id: u16,
|
||||
target: String,
|
||||
payload: bytes::Bytes,
|
||||
},
|
||||
Data {
|
||||
stream_id: u16,
|
||||
payload: bytes::Bytes,
|
||||
|
|
@ -49,6 +44,7 @@ pub enum ProxyEvent {
|
|||
pub enum ProxyToClientMsg {
|
||||
ConnectOk,
|
||||
Data(bytes::Bytes),
|
||||
UdpData(String, bytes::Bytes),
|
||||
Close,
|
||||
Error(String),
|
||||
}
|
||||
|
|
@ -57,13 +53,15 @@ pub enum ProxyToClientMsg {
|
|||
pub async fn run_local_proxy(
|
||||
cfg: LocalProxyConfig,
|
||||
ostp: OstpConfig,
|
||||
exclusions: ExclusionConfig,
|
||||
exclusions_rx: watch::Receiver<ExclusionConfig>,
|
||||
debug: bool,
|
||||
shutdown: watch::Receiver<bool>,
|
||||
proxy_events_tx: mpsc::Sender<ProxyEvent>,
|
||||
client_msgs_rx: mpsc::UnboundedReceiver<(u16, ProxyToClientMsg)>,
|
||||
) -> anyhow::Result<()> {
|
||||
run_local_socks5_proxy(cfg, ostp, exclusions, debug, shutdown, proxy_events_tx, client_msgs_rx).await
|
||||
run_local_socks5_proxy(cfg, ostp, exclusions_rx, debug, shutdown, proxy_events_tx, client_msgs_rx).await
|
||||
}
|
||||
|
||||
|
||||
pub mod exclusion;
|
||||
pub mod process_lookup;
|
||||
pub mod sni_sniff;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,744 @@
|
|||
use anyhow::{anyhow, Result};
|
||||
use tokio::sync::watch;
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Windows / Linux desktop TUN
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
pub async fn run_native_tunnel(
|
||||
config: crate::config::ClientConfig,
|
||||
mut shutdown: watch::Receiver<bool>,
|
||||
mut exclusions_rx: watch::Receiver<crate::config::ExclusionConfig>,
|
||||
) -> Result<()> {
|
||||
use std::net::ToSocketAddrs;
|
||||
use netstack_smoltcp::StackBuilder;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use futures::{StreamExt, SinkExt};
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use std::io::{self, IsTerminal, Write};
|
||||
if io::stdout().is_terminal() {
|
||||
println!("\n===================================================================");
|
||||
println!("WARNING: TUN mode will modify the system routing table.");
|
||||
println!("If you are connected to a headless server via SSH, you may lose");
|
||||
println!("your connection when default routes are redirected into the tunnel.");
|
||||
println!("===================================================================\n");
|
||||
print!("Are you sure you want to initialize the TUN interface? [yes/no]: ");
|
||||
io::stdout().flush().unwrap();
|
||||
|
||||
let mut input = String::new();
|
||||
io::stdin().read_line(&mut input).unwrap();
|
||||
let ans = input.trim().to_lowercase();
|
||||
if ans != "y" && ans != "yes" {
|
||||
return Err(anyhow!("TUN initialization aborted by user."));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let debug = config.debug;
|
||||
tracing::info!("Initializing NATIVE TUN tunnel (smoltcp)...");
|
||||
|
||||
// Capture physical interface index for bypass BEFORE we create the TUN device and alter routes.
|
||||
#[cfg(target_os = "windows")]
|
||||
let phys_if_for_bypass: Option<u32> = ostp_tun::windows::windows_route::sys::get_default_ipv4_route().map(|(_, idx)| idx);
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let phys_if_for_bypass: Option<u32> = None;
|
||||
|
||||
// ── 1. Resolve server IP ──────────────────────────────────────────────────
|
||||
let server_ip = config
|
||||
.ostp
|
||||
.server_addr
|
||||
.to_socket_addrs()
|
||||
.map_err(|e| anyhow!("Failed to resolve server IP: {}", e))?
|
||||
.next()
|
||||
.map(|a| a.ip())
|
||||
.ok_or_else(|| anyhow!("Could not resolve server host"))?;
|
||||
#[allow(unused_variables)]
|
||||
let server_ip_str = server_ip.to_string();
|
||||
|
||||
// ── 2. Resolve excluded domains → IP addresses for bypass routing ─────────
|
||||
let mut bypass_ips: Vec<std::net::IpAddr> = Vec::new();
|
||||
|
||||
// Server IP always bypasses TUN
|
||||
bypass_ips.push(server_ip);
|
||||
|
||||
for ip_str in &config.exclusions.ips {
|
||||
let host = ip_str.split('/').next().unwrap_or(ip_str);
|
||||
if let Ok(ip) = host.parse() {
|
||||
bypass_ips.push(ip);
|
||||
}
|
||||
}
|
||||
|
||||
for domain in &config.exclusions.domains {
|
||||
match tokio::net::lookup_host((domain.as_str(), 443u16)).await {
|
||||
Ok(addrs) => {
|
||||
for addr in addrs {
|
||||
bypass_ips.push(addr.ip());
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to pre-resolve excluded domain {domain}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── 3. Create TUN device via ostp-tun crate ───────────────────────────────
|
||||
let opts = ostp_tun::OstpTunOptions {
|
||||
server_ip,
|
||||
bypass_ips,
|
||||
dns_server: config.dns_server.clone(),
|
||||
kill_switch: config.kill_switch,
|
||||
mtu: config.ostp.mtu as u16,
|
||||
wintun_path: None,
|
||||
};
|
||||
|
||||
let tun_interface = ostp_tun::OstpTunInterface::create(opts)
|
||||
.await
|
||||
.map_err(|e| anyhow!("Failed to create OstpTunInterface: {}", e))?;
|
||||
|
||||
let dev = tun_interface.device;
|
||||
let _route_guard = tun_interface.guard;
|
||||
|
||||
// ── 7. Build smoltcp network stack ────────────────────────────────────────
|
||||
let (stack, tcp_runner, udp_socket, tcp_listener) = StackBuilder::default()
|
||||
.stack_buffer_size(1024)
|
||||
.tcp_buffer_size(1024)
|
||||
.udp_buffer_size(1024)
|
||||
.enable_tcp(true)
|
||||
.enable_udp(true)
|
||||
.mtu(config.ostp.mtu)
|
||||
.build()?;
|
||||
|
||||
let mut runner_task = tokio::spawn(async move {
|
||||
if let Some(runner) = tcp_runner {
|
||||
let _ = runner.await;
|
||||
}
|
||||
});
|
||||
|
||||
// ── 8. Wire TUN ↔ smoltcp stack ───────────────────────────────────────────
|
||||
let (mut stack_sink, mut stack_stream) = stack.split();
|
||||
let (mut tun_read, mut tun_write) = tokio::io::split(dev);
|
||||
|
||||
let mut tun_to_stack = tokio::spawn(async move {
|
||||
let mut buf = vec![0u8; 65536];
|
||||
loop {
|
||||
match tun_read.read(&mut buf).await {
|
||||
Ok(0) => break,
|
||||
Ok(n) => {
|
||||
let frame = buf[..n].to_vec();
|
||||
if let Err(e) = stack_sink.send(frame).await {
|
||||
if e.kind() == std::io::ErrorKind::BrokenPipe {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!("tun_read error: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let mut stack_to_tun = tokio::spawn(async move {
|
||||
while let Some(Ok(frame)) = stack_stream.next().await {
|
||||
if let Err(e) = tun_write.write(&frame).await {
|
||||
tracing::debug!("tun_write error: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ── 9. UDP: forward everything through OSTP proxy ─────────────────────────
|
||||
// UDP exclusions are handled at the routing table level (step 5), so
|
||||
// UDP packets for excluded IPs never reach smoltcp at all.
|
||||
let udp_proxy_addr = {
|
||||
let mut a = config.local_proxy.bind_addr.clone();
|
||||
if a.starts_with("0.0.0.0:") {
|
||||
a = a.replace("0.0.0.0:", "127.0.0.1:");
|
||||
}
|
||||
a
|
||||
};
|
||||
// Build exclusion matcher for dynamic bypass
|
||||
let current_exclusions = exclusions_rx.borrow().clone();
|
||||
let matcher = crate::tunnel::exclusion::ExclusionMatcher::new(¤t_exclusions, None, None);
|
||||
let matcher_arc = std::sync::Arc::new(tokio::sync::RwLock::new(matcher));
|
||||
|
||||
let matcher_clone = matcher_arc.clone();
|
||||
tokio::spawn(async move {
|
||||
while let Ok(_) = exclusions_rx.changed().await {
|
||||
let current = exclusions_rx.borrow().clone();
|
||||
let new_matcher = crate::tunnel::exclusion::ExclusionMatcher::new(¤t, None, None);
|
||||
*matcher_clone.write().await = new_matcher;
|
||||
if true {
|
||||
tracing::debug!("Desktop TUN exclusions hot-reloaded");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Linux: physical interface name for SO_BINDTODEVICE
|
||||
#[cfg(target_os = "linux")]
|
||||
let linux_phys_name = crate::tunnel::proxy::get_linux_physical_if_name();
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let linux_phys_name: Option<String> = None;
|
||||
let _ = &linux_phys_name; // suppress unused warning on Windows
|
||||
|
||||
let debug_udp = debug;
|
||||
let udp_matcher = matcher_arc.clone();
|
||||
#[cfg(target_os = "linux")]
|
||||
let udp_lin_name = linux_phys_name.clone();
|
||||
|
||||
let mut udp_proxy_task = tokio::spawn(async move {
|
||||
if let Some(udp_sock) = udp_socket {
|
||||
#[cfg(target_os = "linux")]
|
||||
super::udp_nat::run_udp_nat(udp_sock, udp_proxy_addr, debug_udp, udp_matcher, phys_if_for_bypass, udp_lin_name).await;
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
super::udp_nat::run_udp_nat(udp_sock, udp_proxy_addr, debug_udp, udp_matcher, phys_if_for_bypass, None).await;
|
||||
}
|
||||
});
|
||||
|
||||
// ── 10. TCP: forward to OSTP proxy (with domain-level bypass via SNI) ─────
|
||||
//
|
||||
// For IP-based exclusions: handled by routing table → packets never arrive here.
|
||||
// For domain-based exclusions: The IP is already in routing table (pre-resolved in
|
||||
// step 3), so most traffic won't arrive. As a belt-and-suspenders fallback,
|
||||
// we also sniff TLS SNI and bypass if it matches — this covers CDN cases where
|
||||
// the IP wasn't known at startup.
|
||||
//
|
||||
// For bypassed connections we bind the outgoing socket to the physical interface
|
||||
// (IP_UNICAST_IF) so it goes out via the real NIC, not TUN.
|
||||
|
||||
let proxy_addr_tcp = {
|
||||
let mut a = config.local_proxy.bind_addr.clone();
|
||||
if a.starts_with("0.0.0.0:") {
|
||||
a = a.replace("0.0.0.0:", "127.0.0.1:");
|
||||
}
|
||||
a
|
||||
};
|
||||
|
||||
// Physical interface index was captured at the start of the function.
|
||||
|
||||
let mut tcp_accept_task = tokio::spawn(async move {
|
||||
let Some(mut listener) = tcp_listener else { return; };
|
||||
|
||||
while let Some((mut stream, local, remote)) = listener.next().await {
|
||||
let proxy_addr = proxy_addr_tcp.clone();
|
||||
let matcher_arc = matcher_arc.clone();
|
||||
#[cfg(target_os = "linux")]
|
||||
let lin_name = linux_phys_name.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let matcher = matcher_arc.read().await.clone();
|
||||
if debug {
|
||||
tracing::debug!("TUN TCP {local} → {remote}");
|
||||
}
|
||||
|
||||
// ── Sniff TLS ClientHello for SNI ─────────────────────────────
|
||||
let mut sniff_buf = [0u8; 2048];
|
||||
let sniff_len =
|
||||
match tokio::time::timeout(
|
||||
std::time::Duration::from_millis(100),
|
||||
stream.read(&mut sniff_buf),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(n)) => n,
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
// ── Decide: bypass or tunnel? ─────────────────────────────────
|
||||
let mut should_bypass = false;
|
||||
|
||||
// 1. Process match via OS Extended TCP Table (Windows)
|
||||
#[cfg(target_os = "windows")]
|
||||
if !should_bypass {
|
||||
if let Some(proc_name) = crate::tunnel::process_lookup::get_process_name_from_port(local.port()) {
|
||||
if debug {
|
||||
tracing::debug!("TUN TCP lookup: port {} -> process {}", local.port(), proc_name);
|
||||
}
|
||||
if matcher.match_process(&proc_name) {
|
||||
if debug {
|
||||
tracing::debug!("TUN TCP BYPASS (Process match): {} → {remote}", proc_name);
|
||||
}
|
||||
should_bypass = true;
|
||||
}
|
||||
} else {
|
||||
if debug {
|
||||
tracing::debug!("TUN TCP lookup: port {} -> no process found", local.port());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. SNI domain check (belt-and-suspenders for CDNs / late-resolved IPs)
|
||||
if !should_bypass && sniff_len > 0 {
|
||||
if let Some(sni) =
|
||||
crate::tunnel::sni_sniff::extract_sni(&sniff_buf[..sniff_len])
|
||||
{
|
||||
if debug {
|
||||
tracing::debug!("TUN SNI: {sni}");
|
||||
}
|
||||
if matcher.match_domain(&sni) {
|
||||
if debug {
|
||||
tracing::info!("TUN TCP BYPASS (SNI domain): {sni} → {remote}");
|
||||
}
|
||||
should_bypass = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Destination IP CIDR check (for IPs not in routing table / IPv6)
|
||||
if !should_bypass && matcher.match_ip(&remote.ip()) {
|
||||
if debug {
|
||||
tracing::info!("TUN TCP BYPASS (IP match): {remote}");
|
||||
}
|
||||
should_bypass = true;
|
||||
}
|
||||
|
||||
// ── Bypass path: direct TCP bypassing TUN ─────────────────────
|
||||
if should_bypass {
|
||||
let socket = match remote {
|
||||
std::net::SocketAddr::V4(_) => tokio::net::TcpSocket::new_v4(),
|
||||
std::net::SocketAddr::V6(_) => tokio::net::TcpSocket::new_v6(),
|
||||
};
|
||||
let Ok(socket) = socket else { return; };
|
||||
|
||||
// Bind to physical interface so packets don't loop back into TUN
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
if let Some(idx) = phys_if_for_bypass {
|
||||
if let Err(e) = crate::tunnel::proxy::bind_socket_to_interface(
|
||||
&socket,
|
||||
remote.is_ipv6(),
|
||||
idx,
|
||||
) {
|
||||
tracing::error!("TUN TCP BYPASS failed to bind to physical interface {}: {}", idx, e);
|
||||
} else {
|
||||
if debug {
|
||||
tracing::info!("TUN TCP BYPASS bound to physical interface {}", idx);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tracing::warn!("TUN TCP BYPASS has no physical interface index!");
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Some(ref name) = lin_name {
|
||||
let _ = crate::tunnel::proxy::bind_socket_to_interface(&socket, name);
|
||||
}
|
||||
|
||||
match tokio::time::timeout(
|
||||
std::time::Duration::from_secs(10),
|
||||
socket.connect(remote),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(mut direct)) => {
|
||||
if sniff_len > 0 {
|
||||
if direct.write_all(&sniff_buf[..sniff_len]).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let _ = tokio::io::copy_bidirectional(&mut stream, &mut direct).await;
|
||||
}
|
||||
_ => {
|
||||
tracing::debug!("Direct bypass connect to {remote} failed");
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Tunnel path: forward via local OSTP SOCKS5 proxy ──────────
|
||||
let Ok(mut socks) = tokio::net::TcpStream::connect(&proxy_addr).await else {
|
||||
return;
|
||||
};
|
||||
|
||||
// SOCKS5 handshake (no auth)
|
||||
if socks.write_all(&[5, 1, 0]).await.is_err() { return; }
|
||||
let mut buf2 = [0u8; 2];
|
||||
if socks.read_exact(&mut buf2).await.is_err() || buf2[0] != 5 || buf2[1] != 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
// CONNECT request
|
||||
let mut req = vec![5u8, 1, 0];
|
||||
match remote.ip() {
|
||||
std::net::IpAddr::V4(v4) => {
|
||||
req.push(1);
|
||||
req.extend_from_slice(&v4.octets());
|
||||
}
|
||||
std::net::IpAddr::V6(v6) => {
|
||||
req.push(4);
|
||||
req.extend_from_slice(&v6.octets());
|
||||
}
|
||||
}
|
||||
req.extend_from_slice(&remote.port().to_be_bytes());
|
||||
if socks.write_all(&req).await.is_err() { return; }
|
||||
|
||||
let mut rep = [0u8; 10];
|
||||
if socks.read_exact(&mut rep).await.is_err() || rep[1] != 0 { return; }
|
||||
|
||||
// Replay sniffed bytes
|
||||
if sniff_len > 0 && socks.write_all(&sniff_buf[..sniff_len]).await.is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
let _ = tokio::io::copy_bidirectional(&mut stream, &mut socks).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
tracing::info!("NATIVE TUN tunnel active.");
|
||||
|
||||
tokio::select! {
|
||||
_ = shutdown.changed() => {}
|
||||
_ = &mut runner_task => {}
|
||||
_ = &mut tun_to_stack => {}
|
||||
_ = &mut stack_to_tun => {}
|
||||
_ = &mut udp_proxy_task => {}
|
||||
_ = &mut tcp_accept_task => {}
|
||||
}
|
||||
|
||||
tracing::info!("Deactivating NATIVE TUN tunnel...");
|
||||
|
||||
// ── Cleanup ───────────────────────────────────────────────────────────────
|
||||
// Cleanup is handled automatically by the _route_guard Drop trait in ostp-tun
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Stub for unsupported platforms
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
|
||||
pub async fn run_native_tunnel(
|
||||
_config: crate::config::ClientConfig,
|
||||
_shutdown: watch::Receiver<bool>,
|
||||
_exclusions_rx: watch::Receiver<crate::config::ExclusionConfig>,
|
||||
) -> Result<()> {
|
||||
Err(anyhow!("Native TUN tunnel is only supported on Windows/Linux"))
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Android: TUN from file-descriptor (opened by VpnService)
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
pub async fn run_native_tunnel_from_fd(
|
||||
config: crate::config::ClientConfig,
|
||||
mut shutdown: watch::Receiver<bool>,
|
||||
mut exclusions_rx: watch::Receiver<crate::config::ExclusionConfig>,
|
||||
fd: i32,
|
||||
) -> Result<()> {
|
||||
use netstack_smoltcp::StackBuilder;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use futures::{StreamExt, SinkExt};
|
||||
use std::os::unix::io::{FromRawFd, AsRawFd};
|
||||
|
||||
let debug = config.debug;
|
||||
tracing::info!("Initializing NATIVE TUN tunnel on Android (FD {})", fd);
|
||||
|
||||
unsafe {
|
||||
let flags = libc::fcntl(fd, libc::F_GETFL);
|
||||
if flags >= 0 {
|
||||
libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK);
|
||||
}
|
||||
}
|
||||
|
||||
let read_fd = unsafe { libc::dup(fd) };
|
||||
if read_fd < 0 {
|
||||
return Err(anyhow!("Failed to dup tun fd for reading"));
|
||||
}
|
||||
|
||||
let file = unsafe { std::fs::File::from_raw_fd(read_fd) };
|
||||
let tun_stream = tokio::io::unix::AsyncFd::new(file)?;
|
||||
|
||||
let (stack, tcp_runner, udp_socket, tcp_listener) = StackBuilder::default()
|
||||
.stack_buffer_size(1024)
|
||||
.tcp_buffer_size(1024)
|
||||
.udp_buffer_size(1024)
|
||||
.enable_tcp(true)
|
||||
.enable_udp(true)
|
||||
.mtu(config.ostp.mtu)
|
||||
.build()?;
|
||||
|
||||
let mut runner_task = tokio::spawn(async move {
|
||||
if let Some(runner) = tcp_runner {
|
||||
let _ = runner.await;
|
||||
}
|
||||
});
|
||||
|
||||
let (mut stack_sink, mut stack_stream) = stack.split();
|
||||
|
||||
let _tun_to_stack = tokio::spawn(async move {
|
||||
let mut buf = vec![0u8; 65536];
|
||||
loop {
|
||||
let mut guard = match tun_stream.readable().await {
|
||||
Ok(g) => g,
|
||||
Err(_) => break,
|
||||
};
|
||||
let n = match guard.try_io(|inner| {
|
||||
let res = unsafe {
|
||||
libc::read(
|
||||
inner.as_raw_fd(),
|
||||
buf.as_mut_ptr() as *mut libc::c_void,
|
||||
buf.len(),
|
||||
)
|
||||
};
|
||||
if res < 0 {
|
||||
let err = std::io::Error::last_os_error();
|
||||
if err.kind() == std::io::ErrorKind::WouldBlock {
|
||||
Err(err)
|
||||
} else {
|
||||
Ok(0_isize)
|
||||
}
|
||||
} else {
|
||||
Ok(res)
|
||||
}
|
||||
}) {
|
||||
Ok(Ok(n)) if n > 0 => n as usize,
|
||||
Ok(Ok(_)) => continue,
|
||||
Ok(Err(_)) => continue,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let frame = buf[..n].to_vec();
|
||||
if let Err(e) = stack_sink.send(frame).await {
|
||||
if e.kind() == std::io::ErrorKind::BrokenPipe {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let write_fd = unsafe { libc::dup(fd) };
|
||||
if write_fd < 0 {
|
||||
return Err(anyhow!("Failed to dup tun fd for writing"));
|
||||
}
|
||||
unsafe {
|
||||
let flags = libc::fcntl(write_fd, libc::F_GETFL);
|
||||
if flags >= 0 {
|
||||
libc::fcntl(write_fd, libc::F_SETFL, flags | libc::O_NONBLOCK);
|
||||
}
|
||||
}
|
||||
let write_file = unsafe { std::fs::File::from_raw_fd(write_fd) };
|
||||
let tun_write_stream = tokio::io::unix::AsyncFd::new(write_file)?;
|
||||
|
||||
let _stack_to_tun = tokio::spawn(async move {
|
||||
while let Some(Ok(frame)) = stack_stream.next().await {
|
||||
let mut written = 0;
|
||||
while written < frame.len() {
|
||||
let mut guard = match tun_write_stream.writable().await {
|
||||
Ok(g) => g,
|
||||
Err(_) => break,
|
||||
};
|
||||
let res = guard.try_io(|inner| {
|
||||
let res = unsafe {
|
||||
libc::write(
|
||||
inner.as_raw_fd(),
|
||||
frame[written..].as_ptr() as *const libc::c_void,
|
||||
frame.len() - written,
|
||||
)
|
||||
};
|
||||
if res < 0 {
|
||||
let err = std::io::Error::last_os_error();
|
||||
if err.kind() == std::io::ErrorKind::WouldBlock {
|
||||
Err(err)
|
||||
} else {
|
||||
Ok(res)
|
||||
}
|
||||
} else {
|
||||
Ok(res)
|
||||
}
|
||||
});
|
||||
match res {
|
||||
Ok(Ok(n)) if n > 0 => written += n as usize,
|
||||
Ok(Ok(_)) => break,
|
||||
Ok(Err(_)) => break,
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let mut proxy_addr = config.local_proxy.bind_addr.clone();
|
||||
if proxy_addr.starts_with("0.0.0.0:") {
|
||||
proxy_addr = proxy_addr.replace("0.0.0.0:", "127.0.0.1:");
|
||||
}
|
||||
|
||||
let current_exclusions = exclusions_rx.borrow().clone();
|
||||
let matcher = crate::tunnel::exclusion::ExclusionMatcher::new(¤t_exclusions, None, None);
|
||||
let matcher_arc = std::sync::Arc::new(tokio::sync::RwLock::new(matcher));
|
||||
|
||||
let matcher_clone = matcher_arc.clone();
|
||||
tokio::spawn(async move {
|
||||
while let Ok(_) = exclusions_rx.changed().await {
|
||||
let current = exclusions_rx.borrow().clone();
|
||||
let new_matcher = crate::tunnel::exclusion::ExclusionMatcher::new(¤t, None, None);
|
||||
*matcher_clone.write().await = new_matcher;
|
||||
if true {
|
||||
tracing::debug!("Android TUN exclusions hot-reloaded");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let udp_proxy_addr = proxy_addr.clone();
|
||||
let debug_udp = debug;
|
||||
let udp_matcher = matcher_arc.clone();
|
||||
let mut udp_proxy_task = tokio::spawn(async move {
|
||||
if let Some(udp_sock) = udp_socket {
|
||||
super::udp_nat::run_udp_nat(udp_sock, udp_proxy_addr, debug_udp, udp_matcher, None, None).await;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
let mut tcp_accept_task = tokio::spawn(async move {
|
||||
let Some(mut listener) = tcp_listener else { return; };
|
||||
|
||||
while let Some((mut stream, local, remote)) = listener.next().await {
|
||||
let proxy_addr = proxy_addr.clone();
|
||||
let matcher_arc = matcher_arc.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let matcher = matcher_arc.read().await.clone();
|
||||
|
||||
if true {
|
||||
tracing::debug!("Android TUN TCP {local} → {remote}");
|
||||
}
|
||||
|
||||
// Sniff SNI
|
||||
let mut sniff_buf = [0u8; 2048];
|
||||
let sniff_len =
|
||||
match tokio::time::timeout(
|
||||
std::time::Duration::from_millis(100),
|
||||
stream.read(&mut sniff_buf),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(n)) => n,
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
let mut should_bypass = false;
|
||||
|
||||
// 1. SNI domain
|
||||
if sniff_len > 0 {
|
||||
if let Some(sni) =
|
||||
crate::tunnel::sni_sniff::extract_sni(&sniff_buf[..sniff_len])
|
||||
{
|
||||
if true { tracing::debug!("Android TUN SNI: {sni}"); }
|
||||
if matcher.match_domain(&sni) {
|
||||
should_bypass = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Process (Android: /proc/net lookup)
|
||||
if !should_bypass {
|
||||
if let Some(exe) =
|
||||
crate::tunnel::process_lookup::get_process_name_from_port(local.port())
|
||||
{
|
||||
if true {
|
||||
tracing::debug!("Android TUN port {} → EXE: {}", local.port(), exe);
|
||||
}
|
||||
if matcher.match_process(&exe) {
|
||||
should_bypass = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. IP CIDR
|
||||
if !should_bypass && matcher.match_ip(&remote.ip()) {
|
||||
should_bypass = true;
|
||||
}
|
||||
|
||||
// Bypass: connect directly (Android VPN service already protects the socket
|
||||
// from re-entering the TUN through VpnService.protect())
|
||||
if should_bypass {
|
||||
if true {
|
||||
tracing::debug!("Android TUN BYPASS: {remote}");
|
||||
}
|
||||
let socket = match remote {
|
||||
std::net::SocketAddr::V4(_) => tokio::net::TcpSocket::new_v4(),
|
||||
std::net::SocketAddr::V6(_) => tokio::net::TcpSocket::new_v6(),
|
||||
};
|
||||
let Ok(socket) = socket else { return; };
|
||||
|
||||
match tokio::time::timeout(
|
||||
std::time::Duration::from_secs(10),
|
||||
socket.connect(remote),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(mut direct)) => {
|
||||
if sniff_len > 0 {
|
||||
if direct.write_all(&sniff_buf[..sniff_len]).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let _ = tokio::io::copy_bidirectional(&mut stream, &mut direct).await;
|
||||
}
|
||||
_ => {
|
||||
tracing::debug!("Android bypass connect to {remote} failed");
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Tunnel via SOCKS5 proxy
|
||||
let Ok(mut socks) = tokio::net::TcpStream::connect(&proxy_addr).await else {
|
||||
return;
|
||||
};
|
||||
if socks.write_all(&[5, 1, 0]).await.is_err() { return; }
|
||||
let mut buf2 = [0u8; 2];
|
||||
if socks.read_exact(&mut buf2).await.is_err() || buf2[0] != 5 || buf2[1] != 0 {
|
||||
return;
|
||||
}
|
||||
let mut req = vec![5u8, 1, 0];
|
||||
match remote.ip() {
|
||||
std::net::IpAddr::V4(v4) => {
|
||||
req.push(1);
|
||||
req.extend_from_slice(&v4.octets());
|
||||
}
|
||||
std::net::IpAddr::V6(v6) => {
|
||||
req.push(4);
|
||||
req.extend_from_slice(&v6.octets());
|
||||
}
|
||||
}
|
||||
req.extend_from_slice(&remote.port().to_be_bytes());
|
||||
if socks.write_all(&req).await.is_err() { return; }
|
||||
let mut rep = [0u8; 10];
|
||||
if socks.read_exact(&mut rep).await.is_err() || rep[1] != 0 { return; }
|
||||
if sniff_len > 0 && socks.write_all(&sniff_buf[..sniff_len]).await.is_err() {
|
||||
return;
|
||||
}
|
||||
let _ = tokio::io::copy_bidirectional(&mut stream, &mut socks).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
tracing::info!("NATIVE TUN (Android) tunnel active.");
|
||||
|
||||
tokio::select! {
|
||||
_ = shutdown.changed() => {}
|
||||
_ = &mut runner_task => {}
|
||||
_ = _tun_to_stack => {}
|
||||
_ = _stack_to_tun => {}
|
||||
_ = &mut udp_proxy_task => {}
|
||||
_ = &mut tcp_accept_task => {}
|
||||
}
|
||||
|
||||
tracing::info!("NATIVE TUN (Android) deactivated.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "android"))]
|
||||
pub async fn run_native_tunnel_from_fd(
|
||||
_config: crate::config::ClientConfig,
|
||||
_shutdown: watch::Receiver<bool>,
|
||||
_exclusions_rx: watch::Receiver<crate::config::ExclusionConfig>,
|
||||
_fd: i32,
|
||||
) -> Result<()> {
|
||||
Err(anyhow!("Native TUN from FD is only supported on Android"))
|
||||
}
|
||||
|
|
@ -0,0 +1,194 @@
|
|||
#[cfg(target_os = "windows")]
|
||||
pub fn get_process_name_from_port(port: u16) -> Option<String> {
|
||||
use winapi::shared::minwindef::ULONG;
|
||||
use winapi::shared::winerror::ERROR_INSUFFICIENT_BUFFER;
|
||||
use winapi::um::iphlpapi::GetExtendedTcpTable;
|
||||
use winapi::shared::tcpmib::{MIB_TCPTABLE_OWNER_PID, MIB_TCPROW_OWNER_PID};
|
||||
|
||||
let mut size: ULONG = 0;
|
||||
let table_class = 5; // TCP_TABLE_OWNER_PID_ALL
|
||||
let mut table = vec![0u8; 1024];
|
||||
|
||||
unsafe {
|
||||
let mut ret = GetExtendedTcpTable(
|
||||
table.as_mut_ptr() as *mut _,
|
||||
&mut size,
|
||||
0,
|
||||
2, // AF_INET
|
||||
table_class,
|
||||
0,
|
||||
);
|
||||
|
||||
if ret == ERROR_INSUFFICIENT_BUFFER {
|
||||
table.resize(size as usize, 0);
|
||||
ret = GetExtendedTcpTable(
|
||||
table.as_mut_ptr() as *mut _,
|
||||
&mut size,
|
||||
0,
|
||||
2, // AF_INET
|
||||
table_class,
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
if ret == 0 {
|
||||
let tcp_table = &*(table.as_ptr() as *const MIB_TCPTABLE_OWNER_PID);
|
||||
let row_ptr = &tcp_table.table[0] as *const MIB_TCPROW_OWNER_PID;
|
||||
for i in 0..tcp_table.dwNumEntries {
|
||||
let row = &*row_ptr.add(i as usize);
|
||||
// Local port is in network byte order
|
||||
let local_port = u16::from_be(row.dwLocalPort as u16);
|
||||
if local_port == port {
|
||||
return get_process_name_from_pid(row.dwOwningPid);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn get_process_name_from_port_udp(port: u16) -> Option<String> {
|
||||
use winapi::shared::minwindef::ULONG;
|
||||
use winapi::shared::winerror::ERROR_INSUFFICIENT_BUFFER;
|
||||
use winapi::um::iphlpapi::GetExtendedUdpTable;
|
||||
use winapi::shared::udpmib::{MIB_UDPTABLE_OWNER_PID, MIB_UDPROW_OWNER_PID};
|
||||
|
||||
let mut size: ULONG = 0;
|
||||
let table_class = 1; // UDP_TABLE_OWNER_PID
|
||||
let mut table = vec![0u8; 1024];
|
||||
|
||||
unsafe {
|
||||
let mut ret = GetExtendedUdpTable(
|
||||
table.as_mut_ptr() as *mut _,
|
||||
&mut size,
|
||||
0,
|
||||
2, // AF_INET
|
||||
table_class,
|
||||
0,
|
||||
);
|
||||
|
||||
if ret == ERROR_INSUFFICIENT_BUFFER {
|
||||
table.resize(size as usize, 0);
|
||||
ret = GetExtendedUdpTable(
|
||||
table.as_mut_ptr() as *mut _,
|
||||
&mut size,
|
||||
0,
|
||||
2, // AF_INET
|
||||
table_class,
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
if ret == 0 {
|
||||
let udp_table = &*(table.as_ptr() as *const MIB_UDPTABLE_OWNER_PID);
|
||||
let row_ptr = &udp_table.table[0] as *const MIB_UDPROW_OWNER_PID;
|
||||
for i in 0..udp_table.dwNumEntries {
|
||||
let row = &*row_ptr.add(i as usize);
|
||||
let local_port = u16::from_be(row.dwLocalPort as u16);
|
||||
if local_port == port {
|
||||
return get_process_name_from_pid(row.dwOwningPid);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn get_process_name_from_pid(pid: u32) -> Option<String> {
|
||||
use winapi::um::processthreadsapi::OpenProcess;
|
||||
use winapi::um::psapi::GetModuleBaseNameW;
|
||||
use winapi::um::winnt::{PROCESS_QUERY_INFORMATION, PROCESS_VM_READ};
|
||||
use winapi::um::handleapi::CloseHandle;
|
||||
use std::os::windows::ffi::OsStringExt;
|
||||
|
||||
unsafe {
|
||||
let handle = OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, 0, pid);
|
||||
if handle.is_null() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut buffer = [0u16; 1024];
|
||||
let len = GetModuleBaseNameW(handle, std::ptr::null_mut(), buffer.as_mut_ptr(), buffer.len() as u32);
|
||||
CloseHandle(handle);
|
||||
|
||||
if len > 0 {
|
||||
let name = std::ffi::OsString::from_wide(&buffer[..len as usize]);
|
||||
return Some(name.to_string_lossy().into_owned());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn get_process_name_from_port(port: u16) -> Option<String> {
|
||||
use std::fs;
|
||||
use std::io::{BufRead, BufReader};
|
||||
|
||||
let hex_port = format!("{:04X}", port);
|
||||
|
||||
let check_net_file = |path: &str| -> Option<u64> {
|
||||
let file = fs::File::open(path).ok()?;
|
||||
let reader = BufReader::new(file);
|
||||
for line in reader.lines().skip(1).filter_map(Result::ok) {
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() >= 10 {
|
||||
let local_addr = parts[1];
|
||||
if local_addr.ends_with(&format!(":{}", hex_port)) {
|
||||
if let Ok(inode) = parts[9].parse::<u64>() {
|
||||
return Some(inode);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
};
|
||||
|
||||
let target_inode = check_net_file("/proc/net/tcp")
|
||||
.or_else(|| check_net_file("/proc/net/tcp6"))
|
||||
.or_else(|| check_net_file("/proc/net/udp"))
|
||||
.or_else(|| check_net_file("/proc/net/udp6"));
|
||||
|
||||
let target_inode = target_inode?;
|
||||
let socket_str = format!("socket:[{}]", target_inode);
|
||||
|
||||
for entry in fs::read_dir("/proc").ok()?.filter_map(Result::ok) {
|
||||
let file_name = entry.file_name();
|
||||
let pid_str = file_name.to_string_lossy();
|
||||
if !pid_str.chars().all(char::is_numeric) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let fd_dir = entry.path().join("fd");
|
||||
if let Ok(fd_entries) = fs::read_dir(fd_dir) {
|
||||
for fd_entry in fd_entries.filter_map(Result::ok) {
|
||||
if let Ok(target) = fs::read_link(fd_entry.path()) {
|
||||
if target.to_string_lossy() == socket_str {
|
||||
let exe_path = entry.path().join("exe");
|
||||
if let Ok(exe_link) = fs::read_link(exe_path) {
|
||||
if let Some(name) = exe_link.file_name() {
|
||||
return Some(name.to_string_lossy().into_owned());
|
||||
}
|
||||
}
|
||||
if let Ok(comm) = fs::read_to_string(entry.path().join("comm")) {
|
||||
return Some(comm.trim().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
|
||||
pub fn get_process_name_from_port(_port: u16) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub fn get_process_name_from_port_udp(port: u16) -> Option<String> {
|
||||
get_process_name_from_port(port)
|
||||
}
|
||||
|
|
@ -1,17 +1,203 @@
|
|||
use std::collections::HashMap;
|
||||
use crate::tunnel::exclusion::ExclusionMatcher;
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
use tokio::net::{TcpListener, TcpStream, UdpSocket};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{mpsc, watch};
|
||||
use tokio::time::{timeout, Duration};
|
||||
|
||||
use crate::config::{ExclusionConfig, LocalProxyConfig, OstpConfig};
|
||||
use crate::tunnel::{ProxyEvent, ProxyToClientMsg};
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::os::windows::io::AsRawSocket;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
use std::os::fd::AsRawFd;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[link(name = "ws2_32")]
|
||||
extern "system" {
|
||||
fn setsockopt(
|
||||
s: usize,
|
||||
level: i32,
|
||||
optname: i32,
|
||||
optval: *const u8,
|
||||
optlen: i32,
|
||||
) -> i32;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn bind_socket_to_interface(socket: &impl AsRawSocket, is_ipv6: bool, if_index: u32) -> std::io::Result<()> {
|
||||
let s = socket.as_raw_socket() as usize;
|
||||
if is_ipv6 {
|
||||
// IPV6_UNICAST_IF expects interface index in host byte order
|
||||
let optval = if_index;
|
||||
let ret = unsafe {
|
||||
setsockopt(
|
||||
s,
|
||||
41, // IPPROTO_IPV6
|
||||
31, // IPV6_UNICAST_IF
|
||||
&optval as *const u32 as *const u8,
|
||||
4,
|
||||
)
|
||||
};
|
||||
if ret != 0 {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
} else {
|
||||
// IP_UNICAST_IF expects interface index in NETWORK byte order (big-endian)
|
||||
let optval = if_index.to_be();
|
||||
let ret = unsafe {
|
||||
setsockopt(
|
||||
s,
|
||||
0, // IPPROTO_IP
|
||||
31, // IP_UNICAST_IF
|
||||
&optval as *const u32 as *const u8,
|
||||
4,
|
||||
)
|
||||
};
|
||||
if ret != 0 {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn bind_socket_to_interface(socket: &impl AsRawFd, if_name: &str) -> std::io::Result<()> {
|
||||
let fd = socket.as_raw_fd();
|
||||
let mut if_name_bytes = if_name.as_bytes().to_vec();
|
||||
if_name_bytes.push(0);
|
||||
let ret = unsafe {
|
||||
libc::setsockopt(
|
||||
fd,
|
||||
libc::SOL_SOCKET,
|
||||
libc::SO_BINDTODEVICE,
|
||||
if_name_bytes.as_ptr() as *const std::ffi::c_void,
|
||||
if_name_bytes.len() as libc::socklen_t,
|
||||
)
|
||||
};
|
||||
if ret != 0 {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_windows_physical_if_index() -> Option<u32> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
return ostp_tun::windows::windows_route::sys::get_default_ipv4_route().map(|(_, idx)| idx);
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_linux_physical_if_name() -> Option<String> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let output = std::process::Command::new("ip")
|
||||
.args(["route", "show", "default"])
|
||||
.output()
|
||||
.ok()?;
|
||||
if output.status.success() {
|
||||
let s = String::from_utf8_lossy(&output.stdout);
|
||||
if let Some(dev_part) = s.split_whitespace().skip_while(|w| *w != "dev").nth(1) {
|
||||
return Some(dev_part.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[allow(unused_variables)]
|
||||
async fn connect_bypassing_tun(
|
||||
target: &str,
|
||||
physical_if_index: Option<u32>,
|
||||
_physical_if_name: &Option<String>,
|
||||
) -> Result<TcpStream> {
|
||||
let resolved = tokio::net::lookup_host(target).await
|
||||
.with_context(|| format!("failed to resolve host for bypass connect: {target}"))?;
|
||||
|
||||
let mut last_err = None;
|
||||
for addr in resolved {
|
||||
let socket = if addr.is_ipv6() {
|
||||
let s = tokio::net::TcpSocket::new_v6()?;
|
||||
let _ = s.bind("[::]:0".parse().unwrap());
|
||||
s
|
||||
} else {
|
||||
let s = tokio::net::TcpSocket::new_v4()?;
|
||||
let _ = s.bind("0.0.0.0:0".parse().unwrap());
|
||||
s
|
||||
};
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
if let Some(if_index) = physical_if_index {
|
||||
if let Err(e) = bind_socket_to_interface(&socket, addr.is_ipv6(), if_index) {
|
||||
tracing::warn!("Failed to bind TCP socket to interface {}: {}", if_index, e);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Some(ref if_name) = _physical_if_name {
|
||||
if let Err(e) = bind_socket_to_interface(&socket, if_name) {
|
||||
tracing::warn!("Failed to bind TCP socket to interface {}: {}", if_name, e);
|
||||
}
|
||||
}
|
||||
|
||||
match socket.connect(addr).await {
|
||||
Ok(stream) => return Ok(stream),
|
||||
Err(e) => {
|
||||
last_err = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow!(
|
||||
"direct connect failed: {:?}",
|
||||
last_err.map(|e| e.to_string()).unwrap_or_else(|| "no addresses resolved".to_string())
|
||||
))
|
||||
}
|
||||
|
||||
#[allow(unused_variables)]
|
||||
async fn create_udp_socket_bypassing_tun(
|
||||
is_ipv6: bool,
|
||||
physical_if_index: Option<u32>,
|
||||
_physical_if_name: &Option<String>,
|
||||
) -> Result<UdpSocket> {
|
||||
let addr: std::net::SocketAddr = if is_ipv6 {
|
||||
"[::]:0".parse().unwrap()
|
||||
} else {
|
||||
"0.0.0.0:0".parse().unwrap()
|
||||
};
|
||||
|
||||
let socket = UdpSocket::bind(addr).await
|
||||
.with_context(|| format!("failed to bind direct UdpSocket to wildcard {}", addr))?;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
if let Some(if_index) = physical_if_index {
|
||||
if let Err(e) = bind_socket_to_interface(&socket, is_ipv6, if_index) {
|
||||
tracing::warn!("Failed to bind UDP socket to interface index {}: {}", if_index, e);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Some(ref if_name) = _physical_if_name {
|
||||
if let Err(e) = bind_socket_to_interface(&socket, if_name) {
|
||||
tracing::warn!("Failed to bind UDP socket to interface {}: {}", if_name, e);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(socket)
|
||||
}
|
||||
|
||||
pub async fn run_local_socks5_proxy(
|
||||
cfg: LocalProxyConfig,
|
||||
_ostp: OstpConfig,
|
||||
exclusions: ExclusionConfig,
|
||||
ostp: OstpConfig,
|
||||
mut exclusions_rx: watch::Receiver<ExclusionConfig>,
|
||||
debug: bool,
|
||||
mut shutdown: watch::Receiver<bool>,
|
||||
proxy_events_tx: mpsc::Sender<ProxyEvent>,
|
||||
|
|
@ -22,13 +208,22 @@ pub async fn run_local_socks5_proxy(
|
|||
.await
|
||||
.with_context(|| format!("failed to bind local HTTP/SOCKS5 proxy at {}", cfg.bind_addr))?;
|
||||
|
||||
if debug {
|
||||
eprintln!("[ostp-client] local HTTP/SOCKS5 proxy listening at {}", cfg.bind_addr);
|
||||
eprintln!("[ostp-client] Windows system proxy: set HTTP proxy to {}. tun2socks: SOCKS5 on same address.", cfg.bind_addr);
|
||||
tracing::info!("local HTTP/SOCKS5 proxy listening at {}", cfg.bind_addr);
|
||||
|
||||
let physical_if_index = tokio::task::spawn_blocking(get_windows_physical_if_index).await.unwrap_or(None);
|
||||
let physical_if_name = tokio::task::spawn_blocking(get_linux_physical_if_name).await.unwrap_or(None);
|
||||
|
||||
if physical_if_index.is_some() {
|
||||
tracing::info!("Local proxy physical interface index: {:?}", physical_if_index);
|
||||
}
|
||||
if physical_if_name.is_some() {
|
||||
tracing::info!("Local proxy physical interface name: {:?}", physical_if_name);
|
||||
}
|
||||
|
||||
let matcher = ExclusionMatcher::new(&exclusions);
|
||||
let mut current_exclusions = exclusions_rx.borrow().clone();
|
||||
let mut matcher = ExclusionMatcher::new(¤t_exclusions, physical_if_index, physical_if_name.clone());
|
||||
let (connect_tx, mut connect_rx) = mpsc::channel(128);
|
||||
let max_chunk = ostp.mtu.saturating_sub(150).max(512);
|
||||
|
||||
let mut next_stream_id: u16 = 1;
|
||||
let mut active_streams: HashMap<u16, mpsc::UnboundedSender<ProxyToClientMsg>> = HashMap::new();
|
||||
|
|
@ -40,11 +235,22 @@ pub async fn run_local_socks5_proxy(
|
|||
break;
|
||||
}
|
||||
}
|
||||
Ok(_) = exclusions_rx.changed() => {
|
||||
current_exclusions = exclusions_rx.borrow().clone();
|
||||
matcher = ExclusionMatcher::new(¤t_exclusions, physical_if_index, physical_if_name.clone());
|
||||
if true {
|
||||
tracing::info!("Local proxy exclusions hot-reloaded");
|
||||
}
|
||||
}
|
||||
accepted = listener.accept() => {
|
||||
let (socket, _) = accepted?;
|
||||
let stream_id = next_stream_id;
|
||||
next_stream_id = next_stream_id.wrapping_add(1);
|
||||
if next_stream_id == 0 { next_stream_id = 1; }
|
||||
// Advance, skipping zero and any stream_id still in active_streams
|
||||
loop {
|
||||
next_stream_id = next_stream_id.wrapping_add(1);
|
||||
if next_stream_id == 0 { next_stream_id = 1; }
|
||||
if !active_streams.contains_key(&next_stream_id) { break; }
|
||||
}
|
||||
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
active_streams.insert(stream_id, tx);
|
||||
|
|
@ -62,6 +268,7 @@ pub async fn run_local_socks5_proxy(
|
|||
connect_timeout,
|
||||
debug,
|
||||
matcher_clone,
|
||||
max_chunk,
|
||||
).await {
|
||||
let msg = err.to_string();
|
||||
// Suppress routine disconnects and unsupported SOCKS5 command attempts (like UDP) from spam logs
|
||||
|
|
@ -69,19 +276,17 @@ pub async fn run_local_socks5_proxy(
|
|||
&& !msg.contains("Connection reset")
|
||||
&& !msg.contains("Broken pipe")
|
||||
&& !msg.contains("unsupported SOCKS5 command")
|
||||
{
|
||||
if debug {
|
||||
eprintln!("[ostp-client] proxy client error: {err}");
|
||||
&& debug {
|
||||
tracing::warn!("proxy client error: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Some((stream_id, msg)) = client_msgs_rx.recv() => {
|
||||
if stream_id == 0 {
|
||||
if let ProxyToClientMsg::Close = msg {
|
||||
if debug {
|
||||
eprintln!("[ostp-client] Resetting all active proxy streams on reconnect");
|
||||
if true {
|
||||
tracing::info!("Resetting all active proxy streams on reconnect");
|
||||
}
|
||||
for (_, tx) in active_streams.drain() {
|
||||
let _ = tx.send(ProxyToClientMsg::Close);
|
||||
|
|
@ -121,6 +326,262 @@ fn extract_host_port(uri: &str, default_port: u16) -> String {
|
|||
}
|
||||
}
|
||||
|
||||
struct StreamGuard {
|
||||
stream_id: u16,
|
||||
close_tx: mpsc::Sender<u16>,
|
||||
}
|
||||
|
||||
impl Drop for StreamGuard {
|
||||
fn drop(&mut self) {
|
||||
let tx = self.close_tx.clone();
|
||||
let id = self.stream_id;
|
||||
tokio::spawn(async move {
|
||||
let _ = tx.send(id).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_udp_associate(
|
||||
mut client_tcp: TcpStream,
|
||||
udp_socket: tokio::net::UdpSocket,
|
||||
stream_id: u16,
|
||||
event_tx: mpsc::Sender<ProxyEvent>,
|
||||
mut rx: mpsc::UnboundedReceiver<ProxyToClientMsg>,
|
||||
close_tx: mpsc::Sender<u16>,
|
||||
debug: bool,
|
||||
matcher: ExclusionMatcher,
|
||||
connect_timeout: Duration,
|
||||
) -> Result<()> {
|
||||
let client_udp_addr = Arc::new(std::sync::Mutex::new(None));
|
||||
let mut buf = vec![0u8; 65536];
|
||||
|
||||
let udp_socket = Arc::new(udp_socket);
|
||||
let sock_rx = udp_socket.clone();
|
||||
let sock_tx = udp_socket;
|
||||
|
||||
let mut direct_udp_v4: Option<Arc<UdpSocket>> = None;
|
||||
let mut direct_udp_v6: Option<Arc<UdpSocket>> = None;
|
||||
// Held only to keep the direct-UDP readers' cancellation senders alive;
|
||||
// dropping this (on every return path from this function) is what tells
|
||||
// spawn_direct_udp_reader's tasks to stop. See its doc comment.
|
||||
let mut direct_udp_cancel_txs: Vec<tokio::sync::oneshot::Sender<()>> = Vec::new();
|
||||
|
||||
let mut tcp_buf = [0u8; 1];
|
||||
loop {
|
||||
tokio::select! {
|
||||
res = client_tcp.read(&mut tcp_buf) => {
|
||||
match res {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(_) => {}
|
||||
}
|
||||
}
|
||||
res = sock_rx.recv_from(&mut buf) => {
|
||||
let (len, addr) = match res {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::debug!("udp_associate recv_from error: {}", e);
|
||||
continue; // transient error, don't kill the session
|
||||
}
|
||||
};
|
||||
{
|
||||
let mut guard = client_udp_addr.lock().unwrap();
|
||||
if guard.is_none() {
|
||||
*guard = Some(addr);
|
||||
}
|
||||
}
|
||||
if len < 4 { continue; }
|
||||
let frag = buf[2];
|
||||
if frag != 0 { continue; } // Fragmented UDP not supported
|
||||
let atyp = buf[3];
|
||||
let (header_len, target) = match atyp {
|
||||
0x01 => {
|
||||
if len < 10 { continue; }
|
||||
let ip = std::net::Ipv4Addr::new(buf[4], buf[5], buf[6], buf[7]);
|
||||
let port = u16::from_be_bytes([buf[8], buf[9]]);
|
||||
(10, format!("{}:{}", ip, port))
|
||||
}
|
||||
0x03 => {
|
||||
if len < 5 { continue; }
|
||||
let domain_len = buf[4] as usize;
|
||||
if len < 5 + domain_len + 2 { continue; }
|
||||
let domain = String::from_utf8_lossy(&buf[5..5+domain_len]);
|
||||
let port = u16::from_be_bytes([buf[5+domain_len], buf[5+domain_len+1]]);
|
||||
(5 + domain_len + 2, format!("{}:{}", domain, port))
|
||||
}
|
||||
0x04 => {
|
||||
if len < 22 { continue; }
|
||||
let mut octets = [0u8; 16];
|
||||
octets.copy_from_slice(&buf[4..20]);
|
||||
let ip = std::net::Ipv6Addr::from(octets);
|
||||
let port = u16::from_be_bytes([buf[20], buf[21]]);
|
||||
(22, format!("[{}]:{}", ip, port))
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
let payload = bytes::Bytes::copy_from_slice(&buf[header_len..len]);
|
||||
|
||||
let target_host = if let Some((host, _)) = split_host_port(&target) { host } else { target.clone() };
|
||||
let target_port = match split_host_port(&target) { Some((_, p)) => p, None => 0 };
|
||||
// Check if target should bypass the tunnel
|
||||
if matcher.should_bypass_target(&target_host, target_port, connect_timeout).await {
|
||||
if true {
|
||||
tracing::debug!("proxy UDP BYPASS target={}", target);
|
||||
}
|
||||
// Resolve target to find if it is IPv4 or IPv6
|
||||
if let Ok(resolved_addrs) = tokio::net::lookup_host(&target).await {
|
||||
if let Some(target_addr) = resolved_addrs.into_iter().next() {
|
||||
let is_ipv6 = target_addr.is_ipv6();
|
||||
let direct_socket = if is_ipv6 {
|
||||
if direct_udp_v6.is_none() {
|
||||
match create_udp_socket_bypassing_tun(true, matcher.physical_if_index, &matcher.physical_if_name).await {
|
||||
Ok(s) => {
|
||||
let s_arc = Arc::new(s);
|
||||
let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel();
|
||||
spawn_direct_udp_reader(s_arc.clone(), sock_tx.clone(), client_udp_addr.clone(), debug, cancel_rx);
|
||||
direct_udp_cancel_txs.push(cancel_tx);
|
||||
direct_udp_v6 = Some(s_arc);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to create bypass UDP v6 socket: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
&direct_udp_v6
|
||||
} else {
|
||||
if direct_udp_v4.is_none() {
|
||||
match create_udp_socket_bypassing_tun(false, matcher.physical_if_index, &matcher.physical_if_name).await {
|
||||
Ok(s) => {
|
||||
let s_arc = Arc::new(s);
|
||||
let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel();
|
||||
spawn_direct_udp_reader(s_arc.clone(), sock_tx.clone(), client_udp_addr.clone(), debug, cancel_rx);
|
||||
direct_udp_cancel_txs.push(cancel_tx);
|
||||
direct_udp_v4 = Some(s_arc);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to create bypass UDP v4 socket: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
&direct_udp_v4
|
||||
};
|
||||
|
||||
if let Some(s) = direct_socket {
|
||||
if let Err(e) = s.send_to(&payload, target_addr).await {
|
||||
if true {
|
||||
tracing::warn!("failed to send bypass UDP packet to {}: {}", target_addr, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tracing::debug!("proxy.rs forwarding UDP DATA to server for target={} payload len={}", target, payload.len());
|
||||
let _ = event_tx.send(ProxyEvent::UdpData { stream_id, target, payload }).await;
|
||||
}
|
||||
}
|
||||
msg = rx.recv() => {
|
||||
match msg {
|
||||
Some(ProxyToClientMsg::UdpData(target, data)) => {
|
||||
if let Some(client_addr) = {
|
||||
let guard = client_udp_addr.lock().unwrap();
|
||||
*guard
|
||||
} {
|
||||
let mut packet = vec![0x00, 0x00, 0x00];
|
||||
let mut parts = target.rsplitn(2, ':');
|
||||
let port_str = parts.next().unwrap_or("0");
|
||||
let host_str = parts.next().unwrap_or(&target);
|
||||
let host_str = host_str.trim_start_matches('[').trim_end_matches(']');
|
||||
let port = port_str.parse::<u16>().unwrap_or(0);
|
||||
|
||||
if let Ok(ipv4) = host_str.parse::<std::net::Ipv4Addr>() {
|
||||
packet.push(0x01);
|
||||
packet.extend_from_slice(&ipv4.octets());
|
||||
} else if let Ok(ipv6) = host_str.parse::<std::net::Ipv6Addr>() {
|
||||
packet.push(0x04);
|
||||
packet.extend_from_slice(&ipv6.octets());
|
||||
} else {
|
||||
packet.push(0x03);
|
||||
let bytes = host_str.as_bytes();
|
||||
packet.push(bytes.len() as u8);
|
||||
packet.extend_from_slice(bytes);
|
||||
}
|
||||
packet.extend_from_slice(&port.to_be_bytes());
|
||||
packet.extend_from_slice(&data);
|
||||
tracing::debug!("proxy.rs forwarding UDP REPLY to client_addr={} from server for target={} payload len={}", client_addr, target, data.len());
|
||||
let _ = sock_tx.send_to(&packet, client_addr).await;
|
||||
} else {
|
||||
tracing::error!("proxy.rs failed to parse target string as SocketAddr: {}", target);
|
||||
}
|
||||
}
|
||||
Some(ProxyToClientMsg::Close) | Some(ProxyToClientMsg::Error(_)) | None => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = close_tx.send(stream_id).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn spawn_direct_udp_reader(
|
||||
direct_socket: Arc<UdpSocket>,
|
||||
sock_tx: Arc<UdpSocket>,
|
||||
client_udp_addr: Arc<std::sync::Mutex<Option<std::net::SocketAddr>>>,
|
||||
_debug: bool,
|
||||
mut cancel_rx: tokio::sync::oneshot::Receiver<()>,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
let mut buf = vec![0u8; 65536];
|
||||
loop {
|
||||
let recv_result = tokio::select! {
|
||||
// Fires as soon as the sender half (held by handle_udp_associate
|
||||
// for exactly this reason) is dropped - which happens the
|
||||
// instant that function returns, on every exit path, with no
|
||||
// explicit signaling needed. Without this, a UDP-associate
|
||||
// session that ever bypassed traffic direct (excluded IP/
|
||||
// domain) leaked this socket + task for the rest of the
|
||||
// process's life once the session ended: nothing else ever
|
||||
// stopped this loop.
|
||||
_ = &mut cancel_rx => break,
|
||||
res = direct_socket.recv_from(&mut buf) => res,
|
||||
};
|
||||
match recv_result {
|
||||
Ok((len, target_addr)) => {
|
||||
let client_addr = {
|
||||
let guard = client_udp_addr.lock().unwrap();
|
||||
*guard
|
||||
};
|
||||
if let Some(client_addr) = client_addr {
|
||||
let mut packet = vec![0x00, 0x00, 0x00];
|
||||
if let Ok(ipv4) = target_addr.ip().to_string().parse::<std::net::Ipv4Addr>() {
|
||||
packet.push(0x01);
|
||||
packet.extend_from_slice(&ipv4.octets());
|
||||
} else if let Ok(ipv6) = target_addr.ip().to_string().parse::<std::net::Ipv6Addr>() {
|
||||
packet.push(0x04);
|
||||
packet.extend_from_slice(&ipv6.octets());
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
packet.extend_from_slice(&target_addr.port().to_be_bytes());
|
||||
packet.extend_from_slice(&buf[..len]);
|
||||
if let Err(e) = sock_tx.send_to(&packet, client_addr).await {
|
||||
if true {
|
||||
tracing::warn!("failed to send direct UDP response to client: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
if true {
|
||||
tracing::debug!("direct UDP socket read loop exiting: {e}");
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn handle_proxy_client(
|
||||
mut client: TcpStream,
|
||||
stream_id: u16,
|
||||
|
|
@ -130,7 +591,10 @@ async fn handle_proxy_client(
|
|||
connect_timeout: Duration,
|
||||
debug: bool,
|
||||
matcher: ExclusionMatcher,
|
||||
max_chunk: usize,
|
||||
) -> Result<()> {
|
||||
let _guard = StreamGuard { stream_id, close_tx: close_tx.clone() };
|
||||
|
||||
// Peek the first byte to distinguish SOCKS5 (0x05) from HTTP (any printable ASCII)
|
||||
let mut first_byte = [0_u8; 1];
|
||||
client.read_exact(&mut first_byte).await?;
|
||||
|
|
@ -156,8 +620,10 @@ async fn handle_proxy_client(
|
|||
if req[0] != 0x05 {
|
||||
return Err(anyhow!("SOCKS5 request version mismatch"));
|
||||
}
|
||||
if req[1] != 0x01 {
|
||||
// Not CONNECT — send COMMAND NOT SUPPORTED
|
||||
|
||||
let is_udp = req[1] == 0x03;
|
||||
if req[1] != 0x01 && !is_udp {
|
||||
// Not CONNECT and Not UDP ASSOCIATE — send COMMAND NOT SUPPORTED
|
||||
client.write_all(&[0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0]).await?;
|
||||
return Err(anyhow!("unsupported SOCKS5 command {}", req[1]));
|
||||
}
|
||||
|
|
@ -195,11 +661,41 @@ async fn handle_proxy_client(
|
|||
}
|
||||
};
|
||||
|
||||
if debug {
|
||||
eprintln!("[ostp-client] proxy CONNECT stream_id={stream_id} target={target}");
|
||||
if is_udp {
|
||||
if true { tracing::debug!("proxy UDP ASSOCIATE stream_id={stream_id}"); }
|
||||
let udp_socket = UdpSocket::bind("127.0.0.1:0").await?;
|
||||
let port = udp_socket.local_addr()?.port();
|
||||
let mut reply = vec![0x05, 0x00, 0x00, 0x01, 127, 0, 0, 1];
|
||||
reply.extend_from_slice(&port.to_be_bytes());
|
||||
client.write_all(&reply).await?;
|
||||
|
||||
event_tx.send(ProxyEvent::UdpAssociate { stream_id }).await?;
|
||||
return handle_udp_associate(
|
||||
client,
|
||||
udp_socket,
|
||||
stream_id,
|
||||
event_tx,
|
||||
rx,
|
||||
close_tx,
|
||||
debug,
|
||||
matcher,
|
||||
connect_timeout,
|
||||
).await;
|
||||
}
|
||||
if matcher.should_bypass(&target, connect_timeout).await {
|
||||
return direct_connect_socks5(client, stream_id, &target, close_tx, debug).await;
|
||||
|
||||
tracing::debug!("proxy CONNECT stream_id={stream_id} target={target}");
|
||||
let target_host = if let Some((host, _)) = split_host_port(&target) { host } else { target.clone() };
|
||||
let target_port = match split_host_port(&target) { Some((_, p)) => p, None => 0 };
|
||||
if matcher.should_bypass_target(&target_host, target_port, connect_timeout).await {
|
||||
return direct_connect_socks5(
|
||||
client,
|
||||
stream_id,
|
||||
&target,
|
||||
matcher.physical_if_index,
|
||||
&matcher.physical_if_name,
|
||||
close_tx,
|
||||
debug,
|
||||
).await;
|
||||
}
|
||||
event_tx.send(ProxyEvent::NewStream { stream_id, target: target.clone() }).await?;
|
||||
|
||||
|
|
@ -229,12 +725,18 @@ async fn handle_proxy_client(
|
|||
// Read the rest of the HTTP request headers byte-by-byte
|
||||
let mut header_bytes = Vec::with_capacity(512);
|
||||
header_bytes.push(first_byte[0]);
|
||||
let mut byte = [0_u8; 1];
|
||||
let mut chunk = [0_u8; 512];
|
||||
loop {
|
||||
client.read_exact(&mut byte).await?;
|
||||
header_bytes.push(byte[0]);
|
||||
if header_bytes.ends_with(b"\r\n\r\n") {
|
||||
break;
|
||||
let n = client.read(&mut chunk).await?;
|
||||
if n == 0 {
|
||||
return Err(anyhow!("connection closed during HTTP header read"));
|
||||
}
|
||||
header_bytes.extend_from_slice(&chunk[..n]);
|
||||
if header_bytes.len() >= 4 {
|
||||
let tail = &header_bytes[header_bytes.len().saturating_sub(4)..];
|
||||
if tail.ends_with(b"\r\n\r\n") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if header_bytes.len() > 8192 {
|
||||
client.write_all(b"HTTP/1.1 431 Request Header Fields Too Large\r\n\r\n").await?;
|
||||
|
|
@ -266,16 +768,20 @@ async fn handle_proxy_client(
|
|||
extract_host_port(raw_uri, default_port)
|
||||
};
|
||||
|
||||
if debug {
|
||||
eprintln!("[ostp-client] proxy CONNECT stream_id={stream_id} target={target}");
|
||||
if true {
|
||||
tracing::info!("proxy CONNECT stream_id={stream_id} target={target}");
|
||||
}
|
||||
if matcher.should_bypass(&target, connect_timeout).await {
|
||||
let target_host = if let Some((host, _)) = split_host_port(&target) { host } else { target.clone() };
|
||||
let target_port = match split_host_port(&target) { Some((_, p)) => p, None => 443 };
|
||||
if matcher.should_bypass_target(&target_host, target_port, connect_timeout).await {
|
||||
return direct_connect_http(
|
||||
client,
|
||||
stream_id,
|
||||
&target,
|
||||
method.as_str(),
|
||||
header_bytes,
|
||||
matcher.physical_if_index,
|
||||
&matcher.physical_if_name,
|
||||
close_tx,
|
||||
debug,
|
||||
).await;
|
||||
|
|
@ -315,28 +821,33 @@ async fn handle_proxy_client(
|
|||
}
|
||||
|
||||
// ── Bidirectional raw data forwarding ─────────────────────────────
|
||||
let mut tcp_buf = vec![0_u8; 1024];
|
||||
let mut tcp_buf = vec![0_u8; 65536];
|
||||
loop {
|
||||
tokio::select! {
|
||||
read_res = client.read(&mut tcp_buf) => {
|
||||
match read_res {
|
||||
Ok(0) => {
|
||||
let _ = event_tx.send(ProxyEvent::Close { stream_id }).await;
|
||||
if debug {
|
||||
eprintln!("[ostp-client] proxy CLOSE stream_id={stream_id}");
|
||||
if true {
|
||||
tracing::info!("proxy CLOSE stream_id={stream_id}");
|
||||
}
|
||||
break;
|
||||
}
|
||||
Ok(n) => {
|
||||
let _ = event_tx.send(ProxyEvent::Data {
|
||||
stream_id,
|
||||
payload: bytes::Bytes::copy_from_slice(&tcp_buf[..n]),
|
||||
}).await;
|
||||
let mut offset = 0;
|
||||
while offset < n {
|
||||
let end = (offset + max_chunk).min(n);
|
||||
let _ = event_tx.send(ProxyEvent::Data {
|
||||
stream_id,
|
||||
payload: bytes::Bytes::copy_from_slice(&tcp_buf[offset..end]),
|
||||
}).await;
|
||||
offset = end;
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
let _ = event_tx.send(ProxyEvent::Close { stream_id }).await;
|
||||
if debug {
|
||||
eprintln!("[ostp-client] proxy CLOSE stream_id={stream_id}");
|
||||
if true {
|
||||
tracing::info!("proxy CLOSE stream_id={stream_id}");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
|
@ -353,7 +864,7 @@ async fn handle_proxy_client(
|
|||
Some(ProxyToClientMsg::Close) | Some(ProxyToClientMsg::Error(_)) | None => {
|
||||
break;
|
||||
}
|
||||
Some(ProxyToClientMsg::ConnectOk) => {} // ignored after connect phase
|
||||
Some(ProxyToClientMsg::ConnectOk) | Some(ProxyToClientMsg::UdpData(_, _)) => {} // ignored after connect phase
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -363,121 +874,6 @@ async fn handle_proxy_client(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ExclusionMatcher {
|
||||
domain_suffix: Vec<String>,
|
||||
cidrs: Vec<Cidr>,
|
||||
}
|
||||
|
||||
impl ExclusionMatcher {
|
||||
fn new(exclusions: &ExclusionConfig) -> Self {
|
||||
let mut cidrs = Vec::new();
|
||||
for ip in &exclusions.ips {
|
||||
if let Some(cidr) = parse_cidr(ip) {
|
||||
cidrs.push(cidr);
|
||||
}
|
||||
}
|
||||
|
||||
Self {
|
||||
domain_suffix: exclusions
|
||||
.domains
|
||||
.iter()
|
||||
.map(|d| d.trim().trim_start_matches('.').to_lowercase())
|
||||
.filter(|d| !d.is_empty())
|
||||
.collect(),
|
||||
cidrs,
|
||||
}
|
||||
}
|
||||
|
||||
async fn should_bypass(&self, target: &str, timeout_value: Duration) -> bool {
|
||||
let (host, port) = match split_host_port(target) {
|
||||
Some(v) => v,
|
||||
None => return false,
|
||||
};
|
||||
|
||||
if self.match_domain(&host) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if self.cidrs.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Ok(ip) = host.parse::<std::net::IpAddr>() {
|
||||
return self.match_ip(&ip);
|
||||
}
|
||||
|
||||
let lookup_target = (host.clone(), port);
|
||||
match timeout(timeout_value, tokio::net::lookup_host(lookup_target)).await {
|
||||
Ok(Ok(addrs)) => addrs.into_iter().any(|addr| self.match_ip(&addr.ip())),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn match_domain(&self, host: &str) -> bool {
|
||||
if self.domain_suffix.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let host = host.trim_end_matches('.').to_lowercase();
|
||||
self.domain_suffix.iter().any(|suffix| {
|
||||
host == *suffix || host.ends_with(&format!(".{suffix}"))
|
||||
})
|
||||
}
|
||||
|
||||
fn match_ip(&self, ip: &std::net::IpAddr) -> bool {
|
||||
self.cidrs.iter().any(|cidr| cidr.contains(ip))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum Cidr {
|
||||
V4(u32, u8),
|
||||
V6(u128, u8),
|
||||
}
|
||||
|
||||
impl Cidr {
|
||||
fn contains(&self, ip: &std::net::IpAddr) -> bool {
|
||||
match (self, ip) {
|
||||
(Cidr::V4(net, bits), std::net::IpAddr::V4(addr)) => {
|
||||
let mask = if *bits == 0 { 0 } else { u32::MAX << (32 - bits) };
|
||||
let ip = u32::from_be_bytes(addr.octets());
|
||||
(ip & mask) == (*net & mask)
|
||||
}
|
||||
(Cidr::V6(net, bits), std::net::IpAddr::V6(addr)) => {
|
||||
let mask = if *bits == 0 { 0 } else { u128::MAX << (128 - bits) };
|
||||
let ip = u128::from_be_bytes(addr.octets());
|
||||
(ip & mask) == (*net & mask)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_cidr(value: &str) -> Option<Cidr> {
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some((addr_str, bits_str)) = value.split_once('/') {
|
||||
let bits: u8 = bits_str.parse().ok()?;
|
||||
if let Ok(addr) = addr_str.parse::<std::net::IpAddr>() {
|
||||
return match addr {
|
||||
std::net::IpAddr::V4(v4) => Some(Cidr::V4(u32::from_be_bytes(v4.octets()), bits.min(32))),
|
||||
std::net::IpAddr::V6(v6) => Some(Cidr::V6(u128::from_be_bytes(v6.octets()), bits.min(128))),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(addr) = value.parse::<std::net::IpAddr>() {
|
||||
return match addr {
|
||||
std::net::IpAddr::V4(v4) => Some(Cidr::V4(u32::from_be_bytes(v4.octets()), 32)),
|
||||
std::net::IpAddr::V6(v6) => Some(Cidr::V6(u128::from_be_bytes(v6.octets()), 128)),
|
||||
};
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn split_host_port(target: &str) -> Option<(String, u16)> {
|
||||
if let Some((host, port)) = target.rsplit_once(':') {
|
||||
|
|
@ -499,14 +895,15 @@ async fn direct_connect_socks5(
|
|||
mut client: TcpStream,
|
||||
stream_id: u16,
|
||||
target: &str,
|
||||
physical_if_index: Option<u32>,
|
||||
physical_if_name: &Option<String>,
|
||||
close_tx: mpsc::Sender<u16>,
|
||||
debug: bool,
|
||||
_debug: bool,
|
||||
) -> Result<()> {
|
||||
if debug {
|
||||
eprintln!("[ostp-client] proxy BYPASS stream_id={stream_id} target={target}");
|
||||
if true {
|
||||
tracing::info!("proxy BYPASS stream_id={stream_id} target={target}");
|
||||
}
|
||||
let mut remote = TcpStream::connect(target).await
|
||||
.with_context(|| format!("direct connect failed: {target}"))?;
|
||||
let mut remote = connect_bypassing_tun(target, physical_if_index, physical_if_name).await?;
|
||||
|
||||
client.write_all(&[0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]).await?;
|
||||
let _ = tokio::io::copy_bidirectional(&mut client, &mut remote).await;
|
||||
|
|
@ -520,14 +917,15 @@ async fn direct_connect_http(
|
|||
target: &str,
|
||||
method: &str,
|
||||
header_bytes: Vec<u8>,
|
||||
physical_if_index: Option<u32>,
|
||||
physical_if_name: &Option<String>,
|
||||
close_tx: mpsc::Sender<u16>,
|
||||
debug: bool,
|
||||
_debug: bool,
|
||||
) -> Result<()> {
|
||||
if debug {
|
||||
eprintln!("[ostp-client] proxy BYPASS stream_id={stream_id} target={target}");
|
||||
if true {
|
||||
tracing::info!("proxy BYPASS stream_id={stream_id} target={target}");
|
||||
}
|
||||
let mut remote = TcpStream::connect(target).await
|
||||
.with_context(|| format!("direct connect failed: {target}"))?;
|
||||
let mut remote = connect_bypassing_tun(target, physical_if_index, physical_if_name).await?;
|
||||
|
||||
if method == "CONNECT" {
|
||||
client.write_all(b"HTTP/1.1 200 Connection Established\r\nProxy-Agent: ostp/1.0\r\n\r\n").await?;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,73 @@
|
|||
pub fn extract_sni(data: &[u8]) -> Option<String> {
|
||||
// Basic TLS ClientHello parser
|
||||
// Must be at least 43 bytes to contain anything useful
|
||||
if data.len() < 43 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// TLS Record layer: Handshake (22)
|
||||
if data[0] != 0x16 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Record layer version: 0x0301 (TLS 1.0) or 0x0303 (TLS 1.2)
|
||||
if data[1] != 0x03 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Handshake type: ClientHello (1)
|
||||
if data[5] != 0x01 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut pos = 43; // Skip fixed ClientHello header
|
||||
|
||||
// Skip Session ID
|
||||
if pos >= data.len() { return None; }
|
||||
let session_id_len = data[pos] as usize;
|
||||
pos += 1 + session_id_len;
|
||||
|
||||
// Skip Cipher Suites
|
||||
if pos + 2 > data.len() { return None; }
|
||||
let cipher_suites_len = ((data[pos] as usize) << 8) | (data[pos + 1] as usize);
|
||||
pos += 2 + cipher_suites_len;
|
||||
|
||||
// Skip Compression Methods
|
||||
if pos >= data.len() { return None; }
|
||||
let comp_methods_len = data[pos] as usize;
|
||||
pos += 1 + comp_methods_len;
|
||||
|
||||
// Extensions
|
||||
if pos + 2 > data.len() { return None; }
|
||||
let extensions_len = ((data[pos] as usize) << 8) | (data[pos + 1] as usize);
|
||||
pos += 2;
|
||||
|
||||
let extensions_end = pos + extensions_len;
|
||||
if extensions_end > data.len() { return None; }
|
||||
|
||||
while pos + 4 <= extensions_end {
|
||||
let ext_type = ((data[pos] as usize) << 8) | (data[pos + 1] as usize);
|
||||
let ext_len = ((data[pos + 2] as usize) << 8) | (data[pos + 3] as usize);
|
||||
pos += 4;
|
||||
|
||||
if ext_type == 0x0000 { // Server Name Indication (SNI)
|
||||
if pos + 5 <= extensions_end {
|
||||
let _list_len = ((data[pos] as usize) << 8) | (data[pos + 1] as usize);
|
||||
let name_type = data[pos + 2];
|
||||
if name_type == 0 { // Hostname
|
||||
let name_len = ((data[pos + 3] as usize) << 8) | (data[pos + 4] as usize);
|
||||
if pos + 5 + name_len <= extensions_end {
|
||||
let sni_bytes = &data[pos + 5..pos + 5 + name_len];
|
||||
if let Ok(sni) = std::str::from_utf8(sni_bytes) {
|
||||
return Some(sni.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
pos += ext_len;
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
|
@ -0,0 +1,313 @@
|
|||
use std::collections::HashMap;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{mpsc, Mutex};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpStream, UdpSocket};
|
||||
use futures::StreamExt;
|
||||
|
||||
pub async fn run_udp_nat(
|
||||
udp_socket: netstack_smoltcp::UdpSocket,
|
||||
proxy_addr: String,
|
||||
debug: bool,
|
||||
matcher: std::sync::Arc<tokio::sync::RwLock<crate::tunnel::exclusion::ExclusionMatcher>>,
|
||||
phys_if_index: Option<u32>,
|
||||
phys_if_name: Option<String>,
|
||||
) {
|
||||
let (mut rx, tx) = udp_socket.split();
|
||||
let tx = Arc::new(Mutex::new(tx));
|
||||
|
||||
// map from internal client src to a channel that sends (payload, external_dst)
|
||||
let mut sessions: HashMap<SocketAddr, mpsc::Sender<(Vec<u8>, SocketAddr)>> = HashMap::new();
|
||||
|
||||
let mut cleanup_tick = tokio::time::interval(std::time::Duration::from_secs(60));
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
packet = rx.next() => {
|
||||
match packet {
|
||||
Some((payload, src, dst)) => {
|
||||
if payload.is_empty() { continue; }
|
||||
|
||||
if !sessions.contains_key(&src) {
|
||||
let (session_tx, mut session_rx) = mpsc::channel::<(Vec<u8>, SocketAddr)>(1024);
|
||||
sessions.insert(src, session_tx);
|
||||
|
||||
let proxy_addr_clone = proxy_addr.clone();
|
||||
let tx_clone = tx.clone();
|
||||
|
||||
let mut should_bypass = false;
|
||||
{
|
||||
let matcher_guard = matcher.read().await;
|
||||
if matcher_guard.match_ip(&dst.ip()) {
|
||||
should_bypass = true;
|
||||
if debug {
|
||||
tracing::info!("TUN UDP BYPASS (IP match): {} → {}", src, dst);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
if !should_bypass {
|
||||
if let Some(proc_name) = crate::tunnel::process_lookup::get_process_name_from_port_udp(src.port()) {
|
||||
if debug {
|
||||
tracing::debug!("TUN UDP lookup: port {} -> process {}", src.port(), proc_name);
|
||||
}
|
||||
if matcher_guard.match_process(&proc_name) {
|
||||
should_bypass = true;
|
||||
if debug {
|
||||
tracing::debug!("TUN UDP BYPASS (Process match): {} ({} → {})", proc_name, src, dst);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if debug {
|
||||
tracing::debug!("TUN UDP lookup: port {} -> no process found", src.port());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let p_if_idx = phys_if_index;
|
||||
let p_if_name = phys_if_name.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
if should_bypass {
|
||||
if debug {
|
||||
tracing::info!("Starting UDP BYPASS session for {}", src);
|
||||
}
|
||||
let res = start_udp_bypass_session(src, p_if_idx, p_if_name, &mut session_rx, tx_clone).await;
|
||||
if res.is_err() {
|
||||
tracing::debug!("UDP BYPASS session for {} ended: {:?}", src, res.err());
|
||||
}
|
||||
} else {
|
||||
tracing::debug!("Starting UDP NAT session for {}", src);
|
||||
let res = start_udp_session(src, proxy_addr_clone, &mut session_rx, tx_clone).await;
|
||||
if res.is_err() {
|
||||
tracing::debug!("UDP NAT session for {} ended: {:?}", src, res.err());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(sender) = sessions.get(&src) {
|
||||
match sender.try_send((payload, dst)) {
|
||||
Err(mpsc::error::TrySendError::Closed(_)) => {
|
||||
sessions.remove(&src);
|
||||
}
|
||||
Err(mpsc::error::TrySendError::Full(_)) => {
|
||||
// Drop packet to avoid blocking the TUN interface loop
|
||||
}
|
||||
Ok(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
_ = cleanup_tick.tick() => {
|
||||
sessions.retain(|_, sender| !sender.is_closed());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn start_udp_bypass_session(
|
||||
client_src: SocketAddr,
|
||||
phys_if_index: Option<u32>,
|
||||
_phys_if_name: Option<String>,
|
||||
session_rx: &mut mpsc::Receiver<(Vec<u8>, SocketAddr)>,
|
||||
smoltcp_tx: Arc<Mutex<netstack_smoltcp::udp::WriteHalf>>,
|
||||
) -> anyhow::Result<()> {
|
||||
let socket = match client_src {
|
||||
SocketAddr::V4(_) => UdpSocket::bind("0.0.0.0:0").await?,
|
||||
SocketAddr::V6(_) => UdpSocket::bind("[::]:0").await?,
|
||||
};
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
if let Some(idx) = phys_if_index {
|
||||
if let Err(e) = crate::tunnel::proxy::bind_socket_to_interface(&socket, client_src.is_ipv6(), idx) {
|
||||
tracing::error!("TUN UDP BYPASS failed to bind to physical interface {}: {}", idx, e);
|
||||
} else {
|
||||
// Keep debug log
|
||||
}
|
||||
} else {
|
||||
tracing::warn!("TUN UDP BYPASS has no physical interface index!");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Some(ref name) = _phys_if_name {
|
||||
let _ = crate::tunnel::proxy::bind_socket_to_interface(&socket, name);
|
||||
}
|
||||
|
||||
// A single select! loop over both directions, rather than spawning a
|
||||
// separate task for the read side, so the whole session - physical
|
||||
// socket included - is torn down the moment this function returns
|
||||
// (e.g. when session_rx closes). The previous spawned-task version left
|
||||
// that task (and its Arc<UdpSocket> clone, keeping the OS socket fd
|
||||
// alive) running forever after this function returned: nothing ever
|
||||
// cancelled it, so every bypassed UDP flow (any excluded app/IP in TUN
|
||||
// mode) leaked one socket + one task for the lifetime of the process.
|
||||
use futures::SinkExt;
|
||||
let mut buf = [0u8; 65536];
|
||||
loop {
|
||||
tokio::select! {
|
||||
outbound = session_rx.recv() => {
|
||||
match outbound {
|
||||
Some((payload, dst)) => { socket.send_to(&payload, dst).await?; }
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
inbound = socket.recv_from(&mut buf) => {
|
||||
match inbound {
|
||||
Ok((n, peer)) => {
|
||||
let mut lock = smoltcp_tx.lock().await;
|
||||
let _ = lock.send((buf[..n].to_vec(), peer, client_src)).await;
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
async fn start_udp_session(
|
||||
client_src: SocketAddr,
|
||||
proxy_addr: String,
|
||||
session_rx: &mut mpsc::Receiver<(Vec<u8>, SocketAddr)>,
|
||||
smoltcp_tx: Arc<Mutex<netstack_smoltcp::udp::WriteHalf>>,
|
||||
) -> anyhow::Result<()> {
|
||||
// 1. TCP Connect to SOCKS5 proxy
|
||||
let mut tcp = TcpStream::connect(&proxy_addr).await?;
|
||||
|
||||
// Auth
|
||||
tcp.write_all(&[5, 1, 0]).await?;
|
||||
let mut buf = [0u8; 2];
|
||||
tcp.read_exact(&mut buf).await?;
|
||||
if buf[0] != 5 || buf[1] != 0 {
|
||||
return Err(anyhow::anyhow!("socks5 auth rejected"));
|
||||
}
|
||||
|
||||
// UDP ASSOCIATE to 0.0.0.0:0
|
||||
tcp.write_all(&[5, 3, 0, 1, 0, 0, 0, 0, 0, 0]).await?;
|
||||
let mut rep_hdr = [0u8; 4];
|
||||
tcp.read_exact(&mut rep_hdr).await?;
|
||||
if rep_hdr[1] != 0 {
|
||||
return Err(anyhow::anyhow!("socks5 udp associate rejected"));
|
||||
}
|
||||
|
||||
let mut relay_addr = match rep_hdr[3] {
|
||||
1 => {
|
||||
let mut addr_buf = [0u8; 6];
|
||||
tcp.read_exact(&mut addr_buf).await?;
|
||||
let ip = std::net::Ipv4Addr::new(addr_buf[0], addr_buf[1], addr_buf[2], addr_buf[3]);
|
||||
let port = u16::from_be_bytes([addr_buf[4], addr_buf[5]]);
|
||||
SocketAddr::new(std::net::IpAddr::V4(ip), port)
|
||||
}
|
||||
4 => {
|
||||
let mut addr_buf = [0u8; 18];
|
||||
tcp.read_exact(&mut addr_buf).await?;
|
||||
let mut octets = [0u8; 16];
|
||||
octets.copy_from_slice(&addr_buf[0..16]);
|
||||
let ip = std::net::Ipv6Addr::from(octets);
|
||||
let port = u16::from_be_bytes([addr_buf[16], addr_buf[17]]);
|
||||
SocketAddr::new(std::net::IpAddr::V6(ip), port)
|
||||
}
|
||||
_ => return Err(anyhow::anyhow!("unsupported ATYP in UDP ASSOCIATE response")),
|
||||
};
|
||||
|
||||
// If proxy returned 0.0.0.0 or ::, use the proxy's IP
|
||||
if relay_addr.ip().is_unspecified() {
|
||||
if let Ok(proxy_sock) = proxy_addr.parse::<SocketAddr>() {
|
||||
relay_addr.set_ip(proxy_sock.ip());
|
||||
}
|
||||
}
|
||||
|
||||
// Local SOCKS5 proxy always returns 127.0.0.1 (IPv4), so always bind IPv4
|
||||
let udp = UdpSocket::bind("127.0.0.1:0").await?;
|
||||
|
||||
// CRITICAL for Android: protect this UDP socket so it goes out via the
|
||||
// real physical interface, not back into the TUN (which would cause an
|
||||
// infinite routing loop for DNS and all other UDP traffic).
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
use std::os::unix::io::AsRawFd;
|
||||
crate::bridge::protect_socket(udp.as_raw_fd());
|
||||
}
|
||||
|
||||
let mut buf = vec![0u8; 65536];
|
||||
|
||||
let timeout = std::time::Duration::from_secs(300); // 5 min idle timeout
|
||||
let mut tcp_buf = [0u8; 1];
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
res = tokio::time::timeout(timeout, session_rx.recv()) => {
|
||||
match res {
|
||||
Ok(Some((payload, dst))) => {
|
||||
let mut packet = vec![0u8; 3]; // RSV, FRAG
|
||||
match dst.ip() {
|
||||
std::net::IpAddr::V4(v4) => { packet.push(1); packet.extend_from_slice(&v4.octets()); }
|
||||
std::net::IpAddr::V6(v6) => { packet.push(4); packet.extend_from_slice(&v6.octets()); }
|
||||
}
|
||||
packet.extend_from_slice(&dst.port().to_be_bytes());
|
||||
packet.extend_from_slice(&payload);
|
||||
tracing::debug!("udp_nat SENDING UDP ASSOCIATE payload len={} to relay_addr={} (original dst: {})", payload.len(), relay_addr, dst);
|
||||
let _ = udp.send_to(&packet, relay_addr).await;
|
||||
}
|
||||
Ok(None) => break,
|
||||
Err(_) => break, // timeout
|
||||
}
|
||||
}
|
||||
res = udp.recv_from(&mut buf) => {
|
||||
match res {
|
||||
Err(e) => {
|
||||
tracing::debug!("udp_nat recv_from error: {}", e);
|
||||
continue; // transient error, don't kill the session
|
||||
}
|
||||
Ok((len, _peer)) => {
|
||||
if len < 4 { continue; }
|
||||
let frag = buf[2];
|
||||
if frag != 0 { continue; } // fragment not supported
|
||||
let atyp = buf[3];
|
||||
let (header_len, remote_dst) = match atyp {
|
||||
1 => {
|
||||
if len < 10 { continue; }
|
||||
let ip = std::net::Ipv4Addr::new(buf[4], buf[5], buf[6], buf[7]);
|
||||
let port = u16::from_be_bytes([buf[8], buf[9]]);
|
||||
(10, SocketAddr::new(std::net::IpAddr::V4(ip), port))
|
||||
}
|
||||
4 => {
|
||||
if len < 22 { continue; }
|
||||
let mut octets = [0u8; 16];
|
||||
octets.copy_from_slice(&buf[4..20]);
|
||||
let ip = std::net::Ipv6Addr::from(octets);
|
||||
let port = u16::from_be_bytes([buf[20], buf[21]]);
|
||||
(22, SocketAddr::new(std::net::IpAddr::V6(ip), port))
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
let payload = buf[header_len..len].to_vec();
|
||||
tracing::debug!("udp_nat RECEIVED UDP ASSOCIATE REPLY from {} for {} len={}", remote_dst, client_src, payload.len());
|
||||
use futures::SinkExt;
|
||||
if let Err(e) = smoltcp_tx.lock().await.send((payload, remote_dst, client_src)).await {
|
||||
tracing::error!("udp_nat failed to inject packet into smoltcp: {}", e);
|
||||
} else {
|
||||
tracing::debug!("udp_nat successfully injected packet into smoltcp from {} to {}", remote_dst, client_src);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// If TCP drops, UDP association is over
|
||||
res = tcp.read(&mut tcp_buf) => {
|
||||
match res {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -1,204 +0,0 @@
|
|||
use anyhow::{anyhow, Result};
|
||||
use tokio::sync::watch;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub async fn run_wintun_tunnel(
|
||||
config: crate::config::ClientConfig,
|
||||
mut shutdown: watch::Receiver<bool>,
|
||||
) -> Result<()> {
|
||||
use std::net::ToSocketAddrs;
|
||||
use std::process::{Command, Stdio, Child};
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
struct WintunGuard {
|
||||
server_ip_str: String,
|
||||
child: Option<Child>,
|
||||
}
|
||||
|
||||
impl Drop for WintunGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Some(mut child) = self.child.take() {
|
||||
let _ = child.kill();
|
||||
}
|
||||
let cleanup_script = format!(
|
||||
"$remote_ip = '{}'\n\
|
||||
Remove-NetRoute -DestinationPrefix \"$remote_ip/32\" -Confirm:$false -ErrorAction SilentlyContinue\n\
|
||||
Remove-NetRoute -DestinationPrefix \"1.1.1.1/32\" -Confirm:$false -ErrorAction SilentlyContinue\n\
|
||||
Remove-NetFirewallRule -DisplayName 'OSTP Tunnel*' -ErrorAction SilentlyContinue\n",
|
||||
self.server_ip_str
|
||||
);
|
||||
let _ = Command::new("powershell")
|
||||
.creation_flags(0x08000000)
|
||||
.args(["-Command", &cleanup_script])
|
||||
.output();
|
||||
}
|
||||
}
|
||||
|
||||
let debug = config.debug;
|
||||
|
||||
if debug {
|
||||
println!("[ostp-client] Initializing high-performance TUN tunnel via tun2socks...");
|
||||
}
|
||||
|
||||
let exe = std::env::current_exe()?;
|
||||
let dir = exe.parent().ok_or_else(|| anyhow!("failed to get binary directory"))?;
|
||||
let tun2socks_exe = dir.join("tun2socks.exe");
|
||||
|
||||
if !tun2socks_exe.exists() {
|
||||
return Err(anyhow!(
|
||||
"CRITICAL: 'tun2socks.exe' binary is missing!\n\
|
||||
OSTP requires tun2socks for TUN mode on Windows. Please download the appropriate binary from: \n\
|
||||
https://github.com/xjasonlyu/tun2socks/releases \n\
|
||||
and place it in the same directory as the ostp executable ({}).",
|
||||
dir.display()
|
||||
));
|
||||
}
|
||||
|
||||
// 2. Resolve Server IP for routing table exclusion
|
||||
let server_ip = config.ostp.server_addr.to_socket_addrs()
|
||||
.map_err(|e| anyhow!("Failed to resolve remote server IP: {}", e))?
|
||||
.next()
|
||||
.map(|addr| addr.ip())
|
||||
.ok_or_else(|| anyhow!("Could not resolve host IP for routing exclusion"))?;
|
||||
|
||||
let server_ip_str = server_ip.to_string();
|
||||
|
||||
if debug {
|
||||
println!("[ostp-client] Resolved remote server IP: {}", server_ip_str);
|
||||
}
|
||||
|
||||
// 3. Run PowerShell script to configure system routes
|
||||
if debug {
|
||||
println!("[ostp-client] Injecting system routing tables and excluding remote proxy...");
|
||||
}
|
||||
|
||||
let current_exe = std::env::current_exe()?.to_string_lossy().into_owned();
|
||||
|
||||
let setup_script = format!(
|
||||
"$remote_ip = '{}'\n\
|
||||
$exe_path = '{}'\n\
|
||||
$route = Get-NetRoute -DestinationPrefix '0.0.0.0/0' | Where-Object {{ $_.InterfaceAlias -notmatch 'tun' -and $_.InterfaceAlias -notmatch 'wintun' }} | Sort-Object RouteMetric | Select-Object -First 1\n\
|
||||
$gw = $route.NextHop\n\
|
||||
$ifIndex = $route.InterfaceIndex\n\
|
||||
# 1. Bypass route for the proxy server itself\n\
|
||||
New-NetRoute -DestinationPrefix \"$remote_ip/32\" -NextHop $gw -InterfaceIndex $ifIndex -RouteMetric 1 -ErrorAction SilentlyContinue\n\
|
||||
# 2. Bypass routes for all current Physical DNS servers to avoid UDP associate deadlocks\n\
|
||||
$dns_ips = Get-DnsClientServerAddress -InterfaceIndex $ifIndex | Select-Object -ExpandProperty ServerAddresses\n\
|
||||
foreach ($dns in $dns_ips) {{\n\
|
||||
if ($dns -match '^\\d+\\.\\d+\\.\\d+\\.\\d+$') {{\n\
|
||||
New-NetRoute -DestinationPrefix \"$dns/32\" -NextHop $gw -InterfaceIndex $ifIndex -RouteMetric 1 -ErrorAction SilentlyContinue\n\
|
||||
}}\n\
|
||||
}}\n\
|
||||
New-NetRoute -DestinationPrefix \"1.1.1.1/32\" -NextHop $gw -InterfaceIndex $ifIndex -RouteMetric 1 -ErrorAction SilentlyContinue\n\
|
||||
# 3. Windows Firewall Rules\n\
|
||||
New-NetFirewallRule -DisplayName 'OSTP Tunnel In' -Direction Inbound -Program $exe_path -Action Allow -Enabled True -ErrorAction SilentlyContinue\n\
|
||||
New-NetFirewallRule -DisplayName 'OSTP Tunnel Out' -Direction Outbound -Program $exe_path -Action Allow -Enabled True -ErrorAction SilentlyContinue\n",
|
||||
server_ip_str, current_exe
|
||||
);
|
||||
|
||||
let out = Command::new("powershell")
|
||||
.creation_flags(0x08000000)
|
||||
.args(["-Command", &setup_script])
|
||||
.output()?;
|
||||
|
||||
if !out.status.success() && debug {
|
||||
println!("[ostp-client] Warning: Setup routing returned: {}", String::from_utf8_lossy(&out.stderr));
|
||||
}
|
||||
|
||||
// 4. Prepare and launch tun2socks.exe in the background
|
||||
// Switch from SOCKS5 to HTTP protocol. This natively forces tun2socks NOT to attempt UDP Associate,
|
||||
// preventing SOCKS5 command 3 unsupported errors while still tunneling 100% of global TCP traffic!
|
||||
let proxy_url = format!("http://{}", config.local_proxy.bind_addr);
|
||||
|
||||
if debug {
|
||||
println!("[ostp-client] Spawning tun2socks daemon pointing to {}", proxy_url);
|
||||
}
|
||||
|
||||
// Spawning buffer to allow local proxy listener to finish binding to local address
|
||||
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
|
||||
|
||||
let mut child = Command::new(&tun2socks_exe)
|
||||
.creation_flags(0x08000000)
|
||||
.args([
|
||||
"-device", "ostp_tun",
|
||||
"-proxy", &proxy_url,
|
||||
"-loglevel", if debug { "debug" } else { "error" }
|
||||
])
|
||||
.current_dir(dir)
|
||||
.stdout(if debug { Stdio::piped() } else { Stdio::null() })
|
||||
.stderr(if debug { Stdio::piped() } else { Stdio::null() })
|
||||
.spawn()
|
||||
.map_err(|e| anyhow!("Failed to launch tun2socks.exe background process: {}", e))?;
|
||||
|
||||
let mut _guard = WintunGuard {
|
||||
server_ip_str: server_ip_str.clone(),
|
||||
child: None, // Will set below
|
||||
};
|
||||
|
||||
// 5. Once tun2socks creates the interface, apply network settings (IP, metric, MTU)
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
|
||||
|
||||
if debug {
|
||||
println!("[ostp-client] Applying network configurations onto 'ostp_tun' interface...");
|
||||
}
|
||||
|
||||
let mut net_setup = String::from("\
|
||||
netsh interface ipv4 set address name=\"ostp_tun\" static 10.1.0.2 255.255.255.0 10.1.0.1\n\
|
||||
netsh interface ipv4 set subinterface \"ostp_tun\" mtu=1300 store=persistent\n\
|
||||
netsh interface ipv4 set interface name=\"ostp_tun\" metric=5\n");
|
||||
|
||||
if let Some(ref dns) = config.dns_server {
|
||||
if !dns.is_empty() {
|
||||
if debug {
|
||||
println!("[ostp-client] Applying custom DNS server: {}", dns);
|
||||
}
|
||||
net_setup.push_str(&format!("netsh interface ipv4 set dnsservers name=\"ostp_tun\" static {} primary\n", dns));
|
||||
}
|
||||
}
|
||||
|
||||
let _ = Command::new("powershell")
|
||||
.creation_flags(0x08000000)
|
||||
.args(["-Command", &net_setup])
|
||||
.output()?;
|
||||
|
||||
println!("[client] TUN Tunnel established, internet traffic is now routing through OSTP.");
|
||||
|
||||
// 6. Spawn thread to keep logging tun2socks output if in debug mode
|
||||
let mut stdout = child.stdout.take();
|
||||
let mut stderr = child.stderr.take();
|
||||
_guard.child = Some(child);
|
||||
|
||||
if debug {
|
||||
std::thread::spawn(move || {
|
||||
use std::io::{BufRead, BufReader};
|
||||
if let Some(out) = stdout.take() {
|
||||
let reader = BufReader::new(out);
|
||||
for line in reader.lines().map_while(Result::ok) {
|
||||
println!("[tun2socks] {}", line);
|
||||
}
|
||||
}
|
||||
});
|
||||
std::thread::spawn(move || {
|
||||
use std::io::{BufRead, BufReader};
|
||||
if let Some(err) = stderr.take() {
|
||||
let reader = BufReader::new(err);
|
||||
for line in reader.lines().map_while(Result::ok) {
|
||||
println!("[tun2socks err] {}", line);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 7. Wait for shutdown signal
|
||||
let _ = shutdown.changed().await;
|
||||
|
||||
println!("[client] Deactivating TUN tunnel and restoring system network topology...");
|
||||
|
||||
// Drop guard runs cleanup automatically
|
||||
drop(_guard);
|
||||
|
||||
println!("[client] TUN Tunnel stopped.");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1 @@
|
|||
fn main() { let x: () = netstack_smoltcp::StackBuilder::default().build().unwrap(); }
|
||||
|
|
@ -6,13 +6,13 @@ license.workspace = true
|
|||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
async-trait.workspace = true
|
||||
bytes.workspace = true
|
||||
chacha20poly1305.workspace = true
|
||||
rand.workspace = true
|
||||
snow.workspace = true
|
||||
thiserror.workspace = true
|
||||
tracing.workspace = true
|
||||
x25519-dalek.workspace = true
|
||||
sha2.workspace = true
|
||||
hmac.workspace = true
|
||||
x25519-dalek = { version = "2.0.1", features = ["static_secrets"] }
|
||||
hkdf = "0.12.0"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,660 @@
|
|||
//! Congestion control for the OSTP protocol.
|
||||
//!
|
||||
//! Implements a simplified BBR-inspired algorithm that estimates bottleneck
|
||||
//! bandwidth and minimum RTT to determine the optimal sending rate.
|
||||
//! This replaces the fixed `retransmit_budget = 8` with an adaptive
|
||||
//! congestion window that responds to network conditions.
|
||||
//!
|
||||
//! RTO calculation follows RFC 6298:
|
||||
//! SRTT = (1 - α) * SRTT + α * RTT (α = 1/8)
|
||||
//! RTTVAR = (1 - β) * RTTVAR + β * |SRTT - RTT| (β = 1/4)
|
||||
//! RTO = SRTT + 4 * RTTVAR
|
||||
//! clamped to [RTO_MIN, RTO_MAX]
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Congestion control state for a single OSTP session.
|
||||
pub struct CongestionController {
|
||||
/// Current congestion window in bytes (how much can be in-flight)
|
||||
cwnd: u64,
|
||||
/// Slow-start threshold in bytes
|
||||
ssthresh: u64,
|
||||
/// Current phase
|
||||
phase: Phase,
|
||||
/// Minimum RTT observed (for BBR-style bandwidth estimation)
|
||||
min_rtt: Duration,
|
||||
/// Smoothed RTT (RFC 6298 SRTT)
|
||||
srtt: Duration,
|
||||
/// RTT variance (RFC 6298 RTTVAR)
|
||||
rttvar: Duration,
|
||||
/// Whether we have received a first RTT sample
|
||||
rtt_initialized: bool,
|
||||
/// Bytes currently in flight (unacknowledged)
|
||||
bytes_in_flight: u64,
|
||||
/// Total bytes acknowledged (for bandwidth estimation)
|
||||
total_acked: u64,
|
||||
/// Last time we received an ACK
|
||||
last_ack_time: Instant,
|
||||
/// Number of loss events in the current window
|
||||
loss_count: u32,
|
||||
/// Pacing rate: bytes per second
|
||||
pacing_rate: u64,
|
||||
/// Token-bucket allowance for pacing, in bytes.
|
||||
pacing_tokens: f64,
|
||||
pacing_last_refill: Instant,
|
||||
/// MTU estimate (used for cwnd → packet count conversion)
|
||||
mtu: u64,
|
||||
/// Min RTT expiry: re-probe after 10 seconds
|
||||
min_rtt_stamp: Instant,
|
||||
/// Loss events counted toward SLOW_START_LOSS_TOLERANCE within the
|
||||
/// current SLOW_START_LOSS_WINDOW (see on_loss's SlowStart arm).
|
||||
slow_start_losses: u32,
|
||||
/// Start of the current loss-tolerance window.
|
||||
slow_start_loss_window_start: Instant,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Phase {
|
||||
/// Exponential growth until loss or ssthresh
|
||||
SlowStart,
|
||||
/// Probe bandwidth: additive increase
|
||||
ProbeBandwidth,
|
||||
}
|
||||
|
||||
/// Initial congestion window: 32 packets × MTU (IW10 is too conservative for modern links)
|
||||
const INITIAL_CWND_PACKETS: u64 = 32;
|
||||
/// Minimum cwnd: 2 packets
|
||||
const MIN_CWND_PACKETS: u64 = 2;
|
||||
/// Min RTT expiry window (after which we re-probe)
|
||||
const MIN_RTT_EXPIRY: Duration = Duration::from_secs(10);
|
||||
/// Minimum RTO (RFC 6298: 1s in TCP; we use 50ms since we own the protocol)
|
||||
/// Absolute ceiling on the congestion window, in packets. At a ~1200-byte MTU
|
||||
/// this is roughly 1.2 MB in flight — already far above the bandwidth-delay
|
||||
/// product of any link this protocol realistically runs over, so anything
|
||||
/// beyond it is standing queue, not throughput. The client previously allowed
|
||||
/// up to 16384 packets (~20 MB), which on a mobile uplink is minutes of buffer.
|
||||
const MAX_CWND_PACKETS: u64 = 1024;
|
||||
/// SRTT/min_rtt ratio at which slow start stops. Doubling is what fills a deep
|
||||
/// buffer fastest, so growth must end when the queue starts building rather
|
||||
/// than waiting for a loss that a deep buffer may never produce.
|
||||
const RTT_INFLATION_EXIT_SLOW_START: f64 = 2.0;
|
||||
/// SRTT/min_rtt ratio treated as a standing queue that must be actively drained.
|
||||
const RTT_INFLATION_BACKOFF: f64 = 4.0;
|
||||
/// How much pacing allowance may accumulate, expressed as time-at-rate.
|
||||
const PACING_BURST: Duration = Duration::from_millis(10);
|
||||
const RTO_MIN: Duration = Duration::from_millis(50);
|
||||
/// Maximum RTO
|
||||
const RTO_MAX: Duration = Duration::from_secs(16);
|
||||
/// Initial RTT estimate — 30 ms is reasonable for a well-connected VPN server.
|
||||
/// Will be replaced by first real measurement within milliseconds.
|
||||
const INITIAL_RTT: Duration = Duration::from_millis(30);
|
||||
|
||||
/// Isolated packet loss during slow start (a single dropped frame from
|
||||
/// wireless noise, a brief LTE handover blip, etc.) is normal on real
|
||||
/// mobile/Wi-Fi links and does NOT mean the link is congested. The previous
|
||||
/// behavior exited slow start and halved cwnd on the very FIRST loss, which
|
||||
/// on any link with a non-zero background loss rate permanently downgrades
|
||||
/// the session from exponential growth to linear (+1 MTU/RTT) ProbeBandwidth
|
||||
/// growth within the first few RTTs - turning what should be a sub-second
|
||||
/// ramp-up into tens of seconds to minutes before throughput opens up
|
||||
/// (observed as: a trickle of KB/s, then a sudden jump once cwnd finally
|
||||
/// claws back up). Only treat loss as a real congestion signal - and pay
|
||||
/// the full slow-start-exit + halving cost - once this many losses land
|
||||
/// within SLOW_START_LOSS_WINDOW.
|
||||
const SLOW_START_LOSS_TOLERANCE: u32 = 3;
|
||||
/// Window within which SLOW_START_LOSS_TOLERANCE losses must land to count
|
||||
/// as sustained (rather than isolated) loss. Roughly a few RTTs on a
|
||||
/// well-connected link, generous on a slow one.
|
||||
const SLOW_START_LOSS_WINDOW: Duration = Duration::from_millis(500);
|
||||
|
||||
impl CongestionController {
|
||||
pub fn new(mtu: u64) -> Self {
|
||||
let now = Instant::now();
|
||||
let initial_cwnd = INITIAL_CWND_PACKETS * mtu;
|
||||
// Initial pacing: deliver cwnd in ~2 RTTs to fill the pipe quickly
|
||||
let initial_pacing = initial_cwnd * 1_000_000 / INITIAL_RTT.as_micros().max(1) as u64;
|
||||
Self {
|
||||
cwnd: initial_cwnd,
|
||||
ssthresh: u64::MAX,
|
||||
phase: Phase::SlowStart,
|
||||
min_rtt: INITIAL_RTT,
|
||||
srtt: INITIAL_RTT,
|
||||
rttvar: INITIAL_RTT / 2,
|
||||
rtt_initialized: false,
|
||||
bytes_in_flight: 0,
|
||||
total_acked: 0,
|
||||
last_ack_time: now,
|
||||
loss_count: 0,
|
||||
pacing_rate: initial_pacing,
|
||||
mtu,
|
||||
min_rtt_stamp: now,
|
||||
slow_start_losses: 0,
|
||||
slow_start_loss_window_start: now,
|
||||
pacing_tokens: (INITIAL_CWND_PACKETS * mtu) as f64,
|
||||
pacing_last_refill: now,
|
||||
}
|
||||
}
|
||||
|
||||
/// Bytes of pacing allowance available right now, without consuming any.
|
||||
///
|
||||
/// Read-only so the send path can use it as an admission check before it
|
||||
/// commits to building a datagram.
|
||||
pub fn pacing_available(&self) -> f64 {
|
||||
let elapsed = self.pacing_last_refill.elapsed().as_secs_f64();
|
||||
(self.pacing_tokens + elapsed * self.pacing_rate as f64).min(self.pacing_burst())
|
||||
}
|
||||
|
||||
/// Whether at least one full-size packet may be released right now.
|
||||
pub fn can_pace_packet(&self) -> bool {
|
||||
self.pacing_available() >= self.mtu as f64
|
||||
}
|
||||
|
||||
/// Ceiling on accumulated allowance.
|
||||
///
|
||||
/// Pacing intervals here are fractions of a millisecond, so releasing
|
||||
/// strictly one packet at a time would need a sub-millisecond timer per
|
||||
/// packet. Instead we allow a short burst — the same trade every real
|
||||
/// pacing implementation makes — sized so the loop's existing ~10ms wakeups
|
||||
/// can still saturate the configured rate, with a small floor so a
|
||||
/// cold/low estimate can never wedge sending entirely.
|
||||
fn pacing_burst(&self) -> f64 {
|
||||
let by_rate = self.pacing_rate as f64 * PACING_BURST.as_secs_f64();
|
||||
by_rate.max((self.mtu * 4) as f64)
|
||||
}
|
||||
|
||||
/// Refill from elapsed time and deduct `bytes`. Called on the real send
|
||||
/// path; allowance is permitted to go negative so an oversized packet still
|
||||
/// pays for itself rather than being released for free.
|
||||
fn consume_pacing(&mut self, bytes: u64) {
|
||||
let now = Instant::now();
|
||||
let elapsed = now.duration_since(self.pacing_last_refill).as_secs_f64();
|
||||
self.pacing_last_refill = now;
|
||||
self.pacing_tokens =
|
||||
(self.pacing_tokens + elapsed * self.pacing_rate as f64).min(self.pacing_burst())
|
||||
- bytes as f64;
|
||||
}
|
||||
|
||||
/// Returns the current congestion window in bytes.
|
||||
pub fn cwnd(&self) -> u64 {
|
||||
self.cwnd
|
||||
}
|
||||
|
||||
/// Returns the current congestion window in packets.
|
||||
pub fn cwnd_packets(&self) -> usize {
|
||||
(self.cwnd / self.mtu).max(MIN_CWND_PACKETS) as usize
|
||||
}
|
||||
|
||||
/// Returns the current pacing rate in bytes/sec.
|
||||
pub fn pacing_rate(&self) -> u64 {
|
||||
self.pacing_rate
|
||||
}
|
||||
|
||||
/// Returns the smoothed RTT estimate (SRTT).
|
||||
pub fn smoothed_rtt(&self) -> Duration {
|
||||
self.srtt
|
||||
}
|
||||
|
||||
/// Returns the adaptive RTO computed per RFC 6298:
|
||||
/// RTO = SRTT + 4 * RTTVAR, clamped to [RTO_MIN, RTO_MAX].
|
||||
///
|
||||
/// This replaces the static `rto_ms` field in ProtocolMachine so that
|
||||
/// retransmit timers automatically track changing network conditions.
|
||||
pub fn rto(&self) -> Duration {
|
||||
let rttvar4 = self.rttvar.saturating_mul(4);
|
||||
let rto = self.srtt.saturating_add(rttvar4);
|
||||
rto.clamp(RTO_MIN, RTO_MAX)
|
||||
}
|
||||
|
||||
/// Returns how many bytes can still be sent.
|
||||
pub fn available_cwnd(&self) -> u64 {
|
||||
self.cwnd.saturating_sub(self.bytes_in_flight)
|
||||
}
|
||||
|
||||
/// Returns the recommended retransmit budget per tick.
|
||||
pub fn retransmit_budget(&self) -> usize {
|
||||
// Allow retransmitting up to 1/4 of the cwnd in packets per tick
|
||||
let budget = (self.cwnd_packets() / 4).max(2);
|
||||
budget.min(64) // cap at 64 to prevent burst
|
||||
}
|
||||
|
||||
/// Check whether we can send more data.
|
||||
pub fn can_send(&self) -> bool {
|
||||
self.bytes_in_flight < self.cwnd
|
||||
}
|
||||
|
||||
/// Record that we sent `bytes` of data.
|
||||
pub fn on_send(&mut self, bytes: u64) {
|
||||
self.bytes_in_flight = self.bytes_in_flight.saturating_add(bytes);
|
||||
// Charge the pacing bucket here rather than at the admission check, so
|
||||
// every byte that actually reaches the wire is paid for exactly once —
|
||||
// including retransmits, which are precisely what must not be allowed
|
||||
// to bypass the rate limit and pile into an already-full queue.
|
||||
self.consume_pacing(bytes);
|
||||
}
|
||||
|
||||
/// Record that `bytes` were acknowledged but WITHOUT a usable RTT sample
|
||||
/// (e.g. every acked frame was retransmitted, so Karn's algorithm forbids
|
||||
/// measuring RTT from it). The window still advances; only the RTT estimator
|
||||
/// is left untouched.
|
||||
pub fn on_ack_no_rtt(&mut self, bytes: u64) {
|
||||
let now = Instant::now();
|
||||
self.bytes_in_flight = self.bytes_in_flight.saturating_sub(bytes);
|
||||
self.total_acked = self.total_acked.saturating_add(bytes);
|
||||
self.grow_window(bytes);
|
||||
self.update_pacing_rate();
|
||||
self.last_ack_time = now;
|
||||
}
|
||||
|
||||
/// Record that `bytes` were acknowledged with the given RTT sample.
|
||||
pub fn on_ack(&mut self, bytes: u64, rtt: Duration) {
|
||||
let now = Instant::now();
|
||||
self.bytes_in_flight = self.bytes_in_flight.saturating_sub(bytes);
|
||||
self.total_acked = self.total_acked.saturating_add(bytes);
|
||||
|
||||
// Update RTT measurements
|
||||
self.update_rtt(rtt, now);
|
||||
|
||||
self.grow_window(bytes);
|
||||
self.update_pacing_rate();
|
||||
self.last_ack_time = now;
|
||||
}
|
||||
|
||||
/// Congestion-window growth shared by both ACK paths (slow start / probe).
|
||||
fn grow_window(&mut self, bytes: u64) {
|
||||
// ── Delay-based congestion signal ────────────────────────────────────
|
||||
// A loss-only controller is blind on a deeply-buffered path, and mobile
|
||||
// carrier buffers are very deep: they absorb a burst instead of dropping
|
||||
// it, so no loss is ever signalled and cwnd keeps growing. The queue —
|
||||
// not the link — is what grows, and the standing delay it adds shows up
|
||||
// as RTT inflating far above the path's floor. Left unchecked this is a
|
||||
// positive feedback loop: bigger queue -> larger RTT samples -> larger
|
||||
// SRTT -> larger RTO -> retransmits pile on -> bigger queue, which is
|
||||
// how a session ends up reporting multi-second (even multi-minute) RTT
|
||||
// and stalls video until the buffer finally drains or the user
|
||||
// reconnects. Treat sustained RTT inflation as congestion in its own
|
||||
// right, exactly as it is.
|
||||
let inflation = if self.rtt_initialized && !self.min_rtt.is_zero() {
|
||||
self.srtt.as_secs_f64() / self.min_rtt.as_secs_f64()
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
|
||||
if inflation >= RTT_INFLATION_BACKOFF {
|
||||
// Standing queue is severe — actively drain it.
|
||||
self.cwnd = (self.cwnd / 2).max(MIN_CWND_PACKETS * self.mtu);
|
||||
self.ssthresh = self.cwnd;
|
||||
self.phase = Phase::ProbeBandwidth;
|
||||
tracing::debug!(cwnd = self.cwnd, inflation, "congestion: draining standing queue");
|
||||
self.clamp_cwnd();
|
||||
return;
|
||||
}
|
||||
|
||||
match self.phase {
|
||||
Phase::SlowStart => {
|
||||
// Exponential doubling is what fills a deep buffer fastest, so
|
||||
// leave slow start as soon as the queue starts to build rather
|
||||
// than waiting for the loss that may never come.
|
||||
if inflation >= RTT_INFLATION_EXIT_SLOW_START {
|
||||
self.ssthresh = self.cwnd;
|
||||
self.phase = Phase::ProbeBandwidth;
|
||||
tracing::debug!(cwnd = self.cwnd, inflation, "congestion: RTT inflation ended slow start");
|
||||
self.clamp_cwnd();
|
||||
return;
|
||||
}
|
||||
// Exponential growth: increase cwnd by acked bytes (doubles per RTT)
|
||||
self.cwnd = self.cwnd.saturating_add(bytes);
|
||||
if self.cwnd >= self.ssthresh {
|
||||
self.phase = Phase::ProbeBandwidth;
|
||||
tracing::debug!(cwnd = self.cwnd, "congestion: exiting slow start");
|
||||
}
|
||||
}
|
||||
Phase::ProbeBandwidth => {
|
||||
// TCP Reno Additive Increase: increase cwnd by ~1 MTU per RTT
|
||||
self.cwnd = self.cwnd.saturating_add(bytes * self.mtu / self.cwnd.max(1));
|
||||
}
|
||||
}
|
||||
|
||||
self.clamp_cwnd();
|
||||
}
|
||||
|
||||
/// Hard ceiling on the congestion window.
|
||||
///
|
||||
/// Independent of any estimate: no real path this protocol runs over has a
|
||||
/// bandwidth-delay product anywhere near this, so a window above it is
|
||||
/// buffered queue rather than data in transit. Without it, slow start on a
|
||||
/// buffer that never drops could grow the window into the tens of megabytes.
|
||||
fn clamp_cwnd(&mut self) {
|
||||
let ceiling = MAX_CWND_PACKETS.saturating_mul(self.mtu);
|
||||
if self.cwnd > ceiling {
|
||||
self.cwnd = ceiling;
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a loss event.
|
||||
pub fn on_loss(&mut self, bytes_lost: u64) {
|
||||
self.bytes_in_flight = self.bytes_in_flight.saturating_sub(bytes_lost);
|
||||
self.loss_count += 1;
|
||||
|
||||
match self.phase {
|
||||
Phase::SlowStart => {
|
||||
let now = Instant::now();
|
||||
if now.duration_since(self.slow_start_loss_window_start) > SLOW_START_LOSS_WINDOW {
|
||||
// Previous window's losses have aged out - this loss starts a fresh count.
|
||||
self.slow_start_losses = 0;
|
||||
self.slow_start_loss_window_start = now;
|
||||
}
|
||||
self.slow_start_losses += 1;
|
||||
|
||||
if self.slow_start_losses >= SLOW_START_LOSS_TOLERANCE {
|
||||
// Sustained loss within the window: treat as real congestion.
|
||||
// Exit slow start, set ssthresh to half of cwnd.
|
||||
self.ssthresh = self.cwnd / 2;
|
||||
self.cwnd = self.ssthresh.max(MIN_CWND_PACKETS * self.mtu);
|
||||
self.phase = Phase::ProbeBandwidth;
|
||||
tracing::debug!(cwnd = self.cwnd, ssthresh = self.ssthresh, "congestion: sustained loss during slow start, exiting");
|
||||
} else {
|
||||
// Isolated loss: likely non-congestive noise. Take a mild,
|
||||
// temporary haircut but keep exponential growth going -
|
||||
// don't throw away slow start over a single dropped frame.
|
||||
self.cwnd = (self.cwnd * 8 / 10).max(MIN_CWND_PACKETS * self.mtu);
|
||||
tracing::debug!(cwnd = self.cwnd, count = self.slow_start_losses, "congestion: isolated loss during slow start, staying in slow start");
|
||||
}
|
||||
}
|
||||
Phase::ProbeBandwidth => {
|
||||
// Multiplicative decrease: cwnd *= 0.7 (BBR-style, less aggressive than Cubic's 0.5)
|
||||
self.cwnd = (self.cwnd * 7 / 10).max(MIN_CWND_PACKETS * self.mtu);
|
||||
tracing::debug!(cwnd = self.cwnd, "congestion: loss, cwnd reduced");
|
||||
}
|
||||
}
|
||||
|
||||
self.update_pacing_rate();
|
||||
}
|
||||
|
||||
// ── Private ──────────────────────────────────────────────────────────────
|
||||
|
||||
fn update_rtt(&mut self, rtt: Duration, now: Instant) {
|
||||
// Update windowed minimum RTT (for pacing)
|
||||
if rtt < self.min_rtt || now.duration_since(self.min_rtt_stamp) >= MIN_RTT_EXPIRY {
|
||||
self.min_rtt = rtt;
|
||||
self.min_rtt_stamp = now;
|
||||
}
|
||||
|
||||
// Update SRTT and RTTVAR per RFC 6298
|
||||
if !self.rtt_initialized {
|
||||
// First measurement: initialize directly
|
||||
self.srtt = rtt;
|
||||
self.rttvar = rtt / 2;
|
||||
self.rtt_initialized = true;
|
||||
} else {
|
||||
// RTTVAR = (3/4) * RTTVAR + (1/4) * |SRTT - R|
|
||||
let diff = if rtt > self.srtt {
|
||||
rtt - self.srtt
|
||||
} else {
|
||||
self.srtt - rtt
|
||||
};
|
||||
// Integer-safe: RTTVAR = RTTVAR - RTTVAR/4 + diff/4
|
||||
self.rttvar = self.rttvar
|
||||
.saturating_sub(self.rttvar / 4)
|
||||
.saturating_add(diff / 4);
|
||||
|
||||
// SRTT = (7/8) * SRTT + (1/8) * R
|
||||
self.srtt = self.srtt
|
||||
.saturating_sub(self.srtt / 8)
|
||||
.saturating_add(rtt / 8);
|
||||
}
|
||||
|
||||
tracing::trace!(
|
||||
srtt_ms = self.srtt.as_millis(),
|
||||
rttvar_ms = self.rttvar.as_millis(),
|
||||
rto_ms = self.rto().as_millis(),
|
||||
"congestion: RTT updated"
|
||||
);
|
||||
}
|
||||
|
||||
fn update_pacing_rate(&mut self) {
|
||||
// Pacing rate = cwnd / min_rtt (delivery rate target)
|
||||
let rtt_us = self.min_rtt.as_micros().max(1) as u64;
|
||||
self.pacing_rate = self.cwnd * 1_000_000 / rtt_us;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_initial_state() {
|
||||
let cc = CongestionController::new(1200);
|
||||
assert_eq!(cc.cwnd(), 32 * 1200); // 32 * 1200
|
||||
assert!(cc.can_send());
|
||||
assert_eq!(cc.cwnd_packets(), 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_slow_start_growth() {
|
||||
let mut cc = CongestionController::new(1200);
|
||||
let initial = cc.cwnd();
|
||||
cc.on_send(1200);
|
||||
cc.on_ack(1200, Duration::from_millis(50));
|
||||
assert!(cc.cwnd() > initial);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_loss_reduces_cwnd() {
|
||||
let mut cc = CongestionController::new(1200);
|
||||
let initial = cc.cwnd();
|
||||
cc.on_loss(1200);
|
||||
assert!(cc.cwnd() < initial);
|
||||
}
|
||||
|
||||
/// The bufferbloat case: a deep buffer absorbs everything, so NOTHING is
|
||||
/// ever lost, but the standing queue inflates RTT. A loss-only controller
|
||||
/// grows cwnd forever here — which is how a session ends up reporting
|
||||
/// multi-second RTT and stalling video.
|
||||
#[test]
|
||||
fn test_rtt_inflation_halts_growth_without_any_loss() {
|
||||
let mut cc = CongestionController::new(1200);
|
||||
|
||||
// Establish a low path floor; this becomes min_rtt.
|
||||
for _ in 0..4 {
|
||||
cc.on_send(1200);
|
||||
cc.on_ack(1200, Duration::from_millis(20));
|
||||
}
|
||||
let cwnd_before = cc.cwnd();
|
||||
|
||||
// Queue builds: RTT climbs far above the floor, still zero loss.
|
||||
for _ in 0..20 {
|
||||
cc.on_send(1200);
|
||||
cc.on_ack(1200, Duration::from_millis(400));
|
||||
}
|
||||
|
||||
assert!(
|
||||
cc.cwnd() <= cwnd_before,
|
||||
"cwnd kept growing while the queue was inflating RTT ({} -> {})",
|
||||
cwnd_before,
|
||||
cc.cwnd()
|
||||
);
|
||||
}
|
||||
|
||||
/// Pacing must actually bound the release rate: draining the bucket has to
|
||||
/// deny the next packet. Without this the congestion window alone decides,
|
||||
/// and a whole window leaves back-to-back.
|
||||
#[test]
|
||||
fn test_pacing_bucket_denies_once_drained() {
|
||||
let mut cc = CongestionController::new(1200);
|
||||
assert!(cc.can_pace_packet(), "a fresh controller must allow sending");
|
||||
|
||||
// Spend well beyond one burst allowance.
|
||||
let burst_bytes = cc.pacing_available();
|
||||
let mut spent = 0.0;
|
||||
while spent <= burst_bytes + 1200.0 {
|
||||
cc.on_send(1200);
|
||||
spent += 1200.0;
|
||||
}
|
||||
|
||||
assert!(
|
||||
!cc.can_pace_packet(),
|
||||
"pacing allowed unbounded sending: {} bytes still available after spending {}",
|
||||
cc.pacing_available(),
|
||||
spent
|
||||
);
|
||||
}
|
||||
|
||||
/// The allowance must refill over time, or sending would stall permanently
|
||||
/// once the first burst is spent.
|
||||
#[test]
|
||||
fn test_pacing_bucket_refills_over_time() {
|
||||
let mut cc = CongestionController::new(1200);
|
||||
while cc.can_pace_packet() {
|
||||
cc.on_send(1200);
|
||||
}
|
||||
assert!(!cc.can_pace_packet());
|
||||
|
||||
std::thread::sleep(Duration::from_millis(25));
|
||||
assert!(
|
||||
cc.can_pace_packet(),
|
||||
"pacing bucket never refilled; sending would be stuck forever"
|
||||
);
|
||||
}
|
||||
|
||||
/// cwnd must never exceed the absolute ceiling, however long slow start
|
||||
/// runs unopposed — above it the window is buffered queue, not throughput.
|
||||
#[test]
|
||||
fn test_cwnd_never_exceeds_absolute_ceiling() {
|
||||
let mut cc = CongestionController::new(1200);
|
||||
// Constant RTT: no inflation signal, so only the hard cap can stop this.
|
||||
for _ in 0..5000 {
|
||||
cc.on_send(1200);
|
||||
cc.on_ack(1200, Duration::from_millis(30));
|
||||
}
|
||||
assert!(
|
||||
cc.cwnd() <= MAX_CWND_PACKETS * 1200,
|
||||
"cwnd {} exceeded the {}-packet ceiling",
|
||||
cc.cwnd(),
|
||||
MAX_CWND_PACKETS
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_isolated_slow_start_loss_does_not_exit_slow_start() {
|
||||
// A single dropped packet (wireless noise, a brief handover blip) is
|
||||
// normal on real links and must not permanently downgrade the
|
||||
// session from exponential to linear growth.
|
||||
let mut cc = CongestionController::new(1200);
|
||||
cc.on_loss(1200);
|
||||
assert_eq!(cc.phase, Phase::SlowStart, "one isolated loss must not exit slow start");
|
||||
|
||||
// It should still shrink the window somewhat (not ignored entirely),
|
||||
// just far less punishing than the sustained-congestion case.
|
||||
let after_one = cc.cwnd();
|
||||
assert!(after_one < INITIAL_CWND_PACKETS * 1200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sustained_slow_start_loss_exits_slow_start() {
|
||||
// Losses landing close together (within SLOW_START_LOSS_WINDOW) are
|
||||
// a real congestion signal and must still trigger the harsher
|
||||
// exit-slow-start + halve response.
|
||||
let mut cc = CongestionController::new(1200);
|
||||
for _ in 0..SLOW_START_LOSS_TOLERANCE {
|
||||
cc.on_loss(1200);
|
||||
}
|
||||
assert_eq!(cc.phase, Phase::ProbeBandwidth, "sustained loss must exit slow start");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_slow_start_loss_window_resets_after_expiry() {
|
||||
// Two losses far enough apart (window expired between them) must
|
||||
// each be treated as isolated, not accumulated toward the sustained-
|
||||
// loss threshold.
|
||||
let mut cc = CongestionController::new(1200);
|
||||
cc.on_loss(1200);
|
||||
assert_eq!(cc.phase, Phase::SlowStart);
|
||||
|
||||
// Simulate the window having expired by resetting its start
|
||||
// directly (std::thread::sleep in a unit test would be flaky/slow).
|
||||
cc.slow_start_loss_window_start = Instant::now() - SLOW_START_LOSS_WINDOW - Duration::from_millis(1);
|
||||
cc.on_loss(1200);
|
||||
assert_eq!(cc.phase, Phase::SlowStart, "a loss after the window expired must restart the count, not accumulate");
|
||||
assert_eq!(cc.slow_start_losses, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_can_send_limits() {
|
||||
let mut cc = CongestionController::new(1200);
|
||||
// Send until cwnd is exhausted
|
||||
for _ in 0..32 {
|
||||
cc.on_send(1200);
|
||||
}
|
||||
assert!(!cc.can_send()); // cwnd exhausted
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_retransmit_budget() {
|
||||
let cc = CongestionController::new(1200);
|
||||
let budget = cc.retransmit_budget();
|
||||
assert!(budget >= 2);
|
||||
assert!(budget <= 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rtt_tracking_first_sample() {
|
||||
let mut cc = CongestionController::new(1200);
|
||||
cc.on_send(1200);
|
||||
cc.on_ack(1200, Duration::from_millis(25));
|
||||
// After first sample: SRTT = 25ms, RTTVAR = 12ms
|
||||
assert_eq!(cc.smoothed_rtt(), Duration::from_millis(25));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rto_rfc6298() {
|
||||
let mut cc = CongestionController::new(1200);
|
||||
// After first sample with RTT=50ms: SRTT=50ms, RTTVAR=25ms, RTO=150ms
|
||||
cc.on_send(1200);
|
||||
cc.on_ack(1200, Duration::from_millis(50));
|
||||
let rto = cc.rto();
|
||||
// RTO = 50 + 4*25 = 150ms; clamped to [50ms, 16s]
|
||||
assert!(rto >= RTO_MIN);
|
||||
assert!(rto <= RTO_MAX);
|
||||
assert_eq!(rto, Duration::from_millis(150));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_on_ack_no_rtt_grows_window_without_touching_srtt() {
|
||||
let mut cc = CongestionController::new(1200);
|
||||
// Establish a known SRTT with a real sample.
|
||||
cc.on_send(1200);
|
||||
cc.on_ack(1200, Duration::from_millis(40));
|
||||
let srtt_before = cc.smoothed_rtt();
|
||||
let cwnd_before = cc.cwnd();
|
||||
|
||||
// A Karn's-algorithm ACK (all acked frames were retransmitted): window
|
||||
// must advance, RTT estimate must be untouched.
|
||||
cc.on_send(1200);
|
||||
cc.on_ack_no_rtt(1200);
|
||||
assert!(cc.cwnd() > cwnd_before, "cwnd should still grow on a no-RTT ack");
|
||||
assert_eq!(cc.smoothed_rtt(), srtt_before, "SRTT must not move on a no-RTT ack");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rto_clamp_min() {
|
||||
let cc = CongestionController::new(1200);
|
||||
// Even with no RTT samples, RTO should not go below RTO_MIN
|
||||
assert!(cc.rto() >= RTO_MIN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rto_adapts_after_multiple_samples() {
|
||||
let mut cc = CongestionController::new(1200);
|
||||
// Feed several consistent RTT samples
|
||||
for _ in 0..8 {
|
||||
cc.on_send(1200);
|
||||
cc.on_ack(1200, Duration::from_millis(20));
|
||||
}
|
||||
// After convergence, RTTVAR should be small → RTO close to SRTT + small margin
|
||||
let rto = cc.rto();
|
||||
// Should be well below 100ms (the old hardcoded default)
|
||||
assert!(rto < Duration::from_millis(200));
|
||||
assert!(rto >= RTO_MIN);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
use rand::rngs::OsRng;
|
||||
use sha2::{Digest, Sha256};
|
||||
use x25519_dalek::{EphemeralSecret, PublicKey};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HybridSharedSecret {
|
||||
pub x25519_pubkey: [u8; 32],
|
||||
pub pq_ciphertext: Vec<u8>,
|
||||
pub combined_secret: [u8; 32],
|
||||
}
|
||||
|
||||
pub trait KeyExchange {
|
||||
fn client_kex() -> HybridSharedSecret;
|
||||
}
|
||||
|
||||
pub struct HybridKex;
|
||||
|
||||
impl HybridKex {
|
||||
pub fn client_offer() -> HybridSharedSecret {
|
||||
let secret = EphemeralSecret::random_from_rng(OsRng);
|
||||
let pubkey = PublicKey::from(&secret);
|
||||
|
||||
// Placeholder PQ ciphertext. Replace with ML-KEM encapsulation output.
|
||||
let pq_ciphertext = vec![0_u8; 1088];
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(pubkey.as_bytes());
|
||||
hasher.update(&pq_ciphertext);
|
||||
let digest = hasher.finalize();
|
||||
|
||||
let mut combined_secret = [0_u8; 32];
|
||||
combined_secret.copy_from_slice(&digest[..32]);
|
||||
|
||||
HybridSharedSecret {
|
||||
x25519_pubkey: *pubkey.as_bytes(),
|
||||
pq_ciphertext,
|
||||
combined_secret,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl KeyExchange for HybridKex {
|
||||
fn client_kex() -> HybridSharedSecret {
|
||||
Self::client_offer()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,12 @@
|
|||
pub mod aead;
|
||||
pub mod kex;
|
||||
pub mod noise;
|
||||
pub mod obfuscation;
|
||||
|
||||
|
||||
pub use aead::SessionCipher;
|
||||
pub use kex::{HybridSharedSecret, KeyExchange};
|
||||
pub use noise::{NoiseRole, NoiseSession};
|
||||
pub use obfuscation::{deobfuscate_header_inplace, deobfuscate_packet_inplace, obfuscate_packet_inplace, derive_obfuscation_key, derive_psk};
|
||||
pub use obfuscation::{
|
||||
deobfuscate_header_inplace, deobfuscate_packet_inplace, obfuscate_packet_inplace,
|
||||
derive_obfuscation_key, derive_psk, derive_all_secrets, DerivedSecrets,
|
||||
derive_junk_marker, current_junk_window, JUNK_MARKER_WINDOW_SECS,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use snow::{Builder, HandshakeState, TransportState};
|
||||
use snow::{Builder, HandshakeState};
|
||||
|
||||
use crate::protocol::ProtocolError;
|
||||
|
||||
|
|
@ -10,9 +10,15 @@ pub enum NoiseRole {
|
|||
Responder,
|
||||
}
|
||||
|
||||
pub enum NoiseSession {
|
||||
Handshake(HandshakeState),
|
||||
Transport(TransportState),
|
||||
/// A Noise handshake in progress. OSTP does not use snow's transport mode: once
|
||||
/// the handshake finishes we extract the raw Split() keys (see [`raw_split`])
|
||||
/// and drive our own out-of-order AEAD (see `crypto::aead`), because the wire
|
||||
/// protocol needs explicit per-frame nonces for reordering that snow's internal
|
||||
/// nonce counter can't express.
|
||||
///
|
||||
/// [`raw_split`]: NoiseSession::raw_split
|
||||
pub struct NoiseSession {
|
||||
handshake: Box<HandshakeState>,
|
||||
}
|
||||
|
||||
impl NoiseSession {
|
||||
|
|
@ -36,50 +42,92 @@ impl NoiseSession {
|
|||
.map_err(|_| ProtocolError::Crypto("noise-responder".to_string()))?,
|
||||
};
|
||||
|
||||
Ok(Self::Handshake(handshake))
|
||||
Ok(Self { handshake: Box::new(handshake) })
|
||||
}
|
||||
|
||||
pub fn write_handshake(&mut self, payload: &[u8], out: &mut [u8]) -> Result<usize, ProtocolError> {
|
||||
match self {
|
||||
NoiseSession::Handshake(hs) => hs
|
||||
.write_message(payload, out)
|
||||
.map_err(|_| ProtocolError::Crypto("noise-write".to_string())),
|
||||
NoiseSession::Transport(_) => Err(ProtocolError::State("noise already in transport".to_string())),
|
||||
}
|
||||
self.handshake
|
||||
.write_message(payload, out)
|
||||
.map_err(|_| ProtocolError::Crypto("noise-write".to_string()))
|
||||
}
|
||||
|
||||
pub fn read_handshake(&mut self, input: &[u8], out: &mut [u8]) -> Result<usize, ProtocolError> {
|
||||
match self {
|
||||
NoiseSession::Handshake(hs) => hs
|
||||
.read_message(input, out)
|
||||
.map_err(|_| ProtocolError::Crypto("noise-read".to_string())),
|
||||
NoiseSession::Transport(_) => Err(ProtocolError::State("noise already in transport".to_string())),
|
||||
}
|
||||
self.handshake
|
||||
.read_message(input, out)
|
||||
.map_err(|e| ProtocolError::Crypto(format!("noise-read: {:?}", e)))
|
||||
}
|
||||
|
||||
pub fn handshake_hash(&self, out: &mut [u8]) -> Result<(), ProtocolError> {
|
||||
match self {
|
||||
NoiseSession::Handshake(hs) => {
|
||||
let hash = hs.get_handshake_hash();
|
||||
if out.len() != hash.len() {
|
||||
return Err(ProtocolError::Crypto("handshake hash length mismatch".to_string()));
|
||||
}
|
||||
out.copy_from_slice(hash);
|
||||
Ok(())
|
||||
}
|
||||
NoiseSession::Transport(_) => Err(ProtocolError::State("noise already in transport".to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn into_transport(self) -> Result<Self, ProtocolError> {
|
||||
match self {
|
||||
NoiseSession::Handshake(hs) => {
|
||||
let transport = hs
|
||||
.into_transport_mode()
|
||||
.map_err(|_| ProtocolError::Crypto("noise-transport".to_string()))?;
|
||||
Ok(NoiseSession::Transport(transport))
|
||||
}
|
||||
NoiseSession::Transport(_) => Ok(self),
|
||||
/// Derive the two directional transport keys via Noise's Split().
|
||||
///
|
||||
/// SECURITY: keys are taken from the final chaining key `ck` (which absorbs
|
||||
/// the ephemeral `ee` DH result via MixKey), NOT from the handshake hash `h`
|
||||
/// (which only absorbs public transcript data — ephemeral pubkeys and
|
||||
/// ciphertexts — and never the DH secret). Deriving from `ck` is what gives
|
||||
/// the session forward secrecy: an adversary who later learns the PSK still
|
||||
/// cannot recompute these keys without the ephemeral private keys, which are
|
||||
/// discarded after the handshake.
|
||||
///
|
||||
/// Must only be called once the handshake is finished (both messages of the
|
||||
/// NNpsk0 exchange processed); at that point `ck` is final. Returns
|
||||
/// `(send_key, recv_key)` for the given role, matching snow's TransportState
|
||||
/// direction mapping: split output `.0` is initiator→responder, `.1` is
|
||||
/// responder→initiator.
|
||||
pub fn raw_split(&mut self, role: NoiseRole) -> Result<([u8; 32], [u8; 32]), ProtocolError> {
|
||||
if !self.handshake.is_handshake_finished() {
|
||||
return Err(ProtocolError::State("handshake not finished at key split".to_string()));
|
||||
}
|
||||
let (k0, k1) = self.handshake.dangerously_get_raw_split();
|
||||
Ok(match role {
|
||||
// Initiator sends on .0 (i→r), receives on .1 (r→i).
|
||||
NoiseRole::Initiator => (k0, k1),
|
||||
// Responder is the mirror image.
|
||||
NoiseRole::Responder => (k1, k0),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Drive a full NNpsk0 handshake and confirm both sides derive matching
|
||||
/// directional keys. This guards the .0/.1 → send/recv role mapping in
|
||||
/// `raw_split`: if it were wrong, the two sides' send/recv keys wouldn't
|
||||
/// cross-match and the transport channel would silently fail to decrypt.
|
||||
#[test]
|
||||
fn raw_split_keys_agree_across_roles() {
|
||||
let psk = [7u8; 32];
|
||||
let mut initiator = NoiseSession::new(NoiseRole::Initiator, &psk).unwrap();
|
||||
let mut responder = NoiseSession::new(NoiseRole::Responder, &psk).unwrap();
|
||||
|
||||
// msg1: initiator -> responder
|
||||
let mut buf1 = [0u8; 1024];
|
||||
let n1 = initiator.write_handshake(&[], &mut buf1).unwrap();
|
||||
let mut tmp = [0u8; 1024];
|
||||
responder.read_handshake(&buf1[..n1], &mut tmp).unwrap();
|
||||
|
||||
// msg2: responder -> initiator
|
||||
let mut buf2 = [0u8; 1024];
|
||||
let n2 = responder.write_handshake(&[], &mut buf2).unwrap();
|
||||
initiator.read_handshake(&buf2[..n2], &mut tmp).unwrap();
|
||||
|
||||
let (i_send, i_recv) = initiator.raw_split(NoiseRole::Initiator).unwrap();
|
||||
let (r_send, r_recv) = responder.raw_split(NoiseRole::Responder).unwrap();
|
||||
|
||||
// What the initiator sends with, the responder must receive with.
|
||||
assert_eq!(i_send, r_recv, "initiator send key must equal responder recv key");
|
||||
assert_eq!(r_send, i_recv, "responder send key must equal initiator recv key");
|
||||
// The two directions use distinct keys.
|
||||
assert_ne!(i_send, i_recv, "the two directions must not share a key");
|
||||
}
|
||||
|
||||
/// raw_split must refuse to hand out keys before the handshake is complete —
|
||||
/// keys taken from a half-mixed chaining key would be wrong and insecure.
|
||||
#[test]
|
||||
fn raw_split_rejected_before_handshake_finishes() {
|
||||
let psk = [9u8; 32];
|
||||
let mut initiator = NoiseSession::new(NoiseRole::Initiator, &psk).unwrap();
|
||||
// No messages exchanged yet: handshake not finished.
|
||||
assert!(initiator.raw_split(NoiseRole::Initiator).is_err());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,70 +1,250 @@
|
|||
// =============================================================================
|
||||
// OSTP Key Derivation — Kerckhoffs's Principle
|
||||
// =============================================================================
|
||||
//
|
||||
// All protocol secrets (PSK, obfuscation key, padding parameters) are derived
|
||||
// exclusively from the access key using HKDF-SHA256. There are NO hardcoded
|
||||
// salt strings, protocol identifiers, or magic constants in this module.
|
||||
//
|
||||
// An adversary who reverse-engineers the binary sees only generic HMAC/SHA-256
|
||||
// operations with no protocol-specific strings to search for. Building a DPI
|
||||
// filter requires knowledge of the access key.
|
||||
// =============================================================================
|
||||
|
||||
use sha2::Sha256;
|
||||
use hmac::{Hmac, Mac};
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
pub fn derive_obfuscation_key(access_key: &[u8]) -> [u8; 8] {
|
||||
// ── HKDF-SHA256 (RFC 5869) ──────────────────────────────────────────────────
|
||||
// Implemented inline to avoid adding a dependency. Uses only hmac + sha2.
|
||||
|
||||
/// HKDF-Extract: PRK = HMAC-SHA256(salt, IKM)
|
||||
fn hkdf_extract(salt: &[u8], ikm: &[u8]) -> [u8; 32] {
|
||||
let mut mac = HmacSha256::new_from_slice(salt).expect("HMAC accepts any key length");
|
||||
mac.update(ikm);
|
||||
let result = mac.finalize().into_bytes();
|
||||
let mut prk = [0u8; 32];
|
||||
prk.copy_from_slice(&result);
|
||||
prk
|
||||
}
|
||||
|
||||
/// HKDF-Expand: OKM = T(1) || T(2) || ... truncated to `len` bytes.
|
||||
/// T(i) = HMAC-SHA256(PRK, T(i-1) || info || i)
|
||||
fn hkdf_expand(prk: &[u8; 32], info: &[u8], len: usize) -> Vec<u8> {
|
||||
let mut okm = Vec::with_capacity(len);
|
||||
let mut t = Vec::new();
|
||||
let mut counter = 1u8;
|
||||
while okm.len() < len {
|
||||
let mut mac = HmacSha256::new_from_slice(prk).expect("HMAC accepts any key length");
|
||||
mac.update(&t);
|
||||
mac.update(info);
|
||||
mac.update(&[counter]);
|
||||
let block = mac.finalize().into_bytes();
|
||||
t = block.to_vec();
|
||||
okm.extend_from_slice(&t[..t.len().min(len - okm.len() + t.len()).min(t.len())]);
|
||||
counter = counter.wrapping_add(1);
|
||||
}
|
||||
okm.truncate(len);
|
||||
okm
|
||||
}
|
||||
|
||||
/// Derive all protocol secrets from a single access key.
|
||||
/// Returns (obfuscation_key, psk, handshake_pad_min, handshake_pad_max).
|
||||
///
|
||||
/// The derivation uses the access key as both IKM and salt material,
|
||||
/// split into two halves. No fixed strings are used — the access key
|
||||
/// alone determines all derived values.
|
||||
#[derive(Clone)]
|
||||
pub struct DerivedSecrets {
|
||||
pub obfuscation_key: [u8; 8],
|
||||
pub psk: [u8; 32],
|
||||
pub handshake_pad_min: usize,
|
||||
pub handshake_pad_max: usize,
|
||||
}
|
||||
// NOTE: the junk marker is NOT part of DerivedSecrets — it is time-rotating and
|
||||
// derived separately per window via `derive_junk_marker` (see below), so it
|
||||
// carries no static per-user signature.
|
||||
|
||||
/// OSTP wire protocol version. Mixed into key derivation (NOT sent on the
|
||||
/// wire) so peers running incompatible versions derive entirely different
|
||||
/// secrets and therefore cannot deobfuscate / decrypt each other's traffic.
|
||||
///
|
||||
/// This is a hard, deterministic version gate that needs NO plaintext version
|
||||
/// byte on the wire — a constant marker would defeat the project's stealth
|
||||
/// north-star ("no recognizable header"). A pre-0.4.0 client (which derived
|
||||
/// without a version) produces a different obfuscation key, so a 0.4.0 server
|
||||
/// cannot recover its handshake header and rejects it as an unauthorized probe.
|
||||
///
|
||||
/// Bump this on any wire-breaking protocol change. 0.4.0 = version 4;
|
||||
/// version 5 (0.4.x hardening) moved transport keys from the handshake hash to
|
||||
/// Noise's Split() output — a wire-breaking crypto change, so old peers must not
|
||||
/// interop (they would derive different session keys and fail decryption).
|
||||
pub const PROTOCOL_VERSION: u8 = 5;
|
||||
|
||||
pub fn derive_all_secrets(access_key: &[u8]) -> DerivedSecrets {
|
||||
derive_all_secrets_versioned(access_key, PROTOCOL_VERSION)
|
||||
}
|
||||
|
||||
/// Version-parameterised derivation. `derive_all_secrets` always pins the
|
||||
/// current `PROTOCOL_VERSION`; this form exists so tests can prove that a
|
||||
/// different version yields incompatible secrets (the version gate).
|
||||
pub(crate) fn derive_all_secrets_versioned(access_key: &[u8], version: u8) -> DerivedSecrets {
|
||||
// Split the key hash into two halves for salt/info separation.
|
||||
// This avoids using any hardcoded strings while still providing
|
||||
// domain separation between the derived values.
|
||||
use sha2::Digest;
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(access_key);
|
||||
let result = hasher.finalize();
|
||||
let mut key = [0u8; 8];
|
||||
key.copy_from_slice(&result[0..8]);
|
||||
key
|
||||
let key_hash = sha2::Sha256::digest(access_key);
|
||||
let salt = &key_hash[..16];
|
||||
let info_base = &key_hash[16..];
|
||||
|
||||
// Mix the protocol version into the IKM so a different version produces a
|
||||
// completely different PRK → different obf_key / psk / padding. This is the
|
||||
// wire-version gate: it is invisible on the wire (only the derived output,
|
||||
// which is already indistinguishable from random, ever leaves the host).
|
||||
let mut ikm = Vec::with_capacity(access_key.len() + 1);
|
||||
ikm.extend_from_slice(access_key);
|
||||
ikm.push(version);
|
||||
|
||||
// Extract PRK from version-tagged access key using its hash as salt
|
||||
let prk = hkdf_extract(salt, &ikm);
|
||||
|
||||
// Derive obfuscation key (8 bytes) — info = key_hash[16..] || 0x01
|
||||
let mut obf_info = info_base.to_vec();
|
||||
obf_info.push(0x01);
|
||||
let obf_bytes = hkdf_expand(&prk, &obf_info, 8);
|
||||
let mut obfuscation_key = [0u8; 8];
|
||||
obfuscation_key.copy_from_slice(&obf_bytes);
|
||||
|
||||
// Derive PSK (32 bytes) — info = key_hash[16..] || 0x02
|
||||
let mut psk_info = info_base.to_vec();
|
||||
psk_info.push(0x02);
|
||||
let psk_bytes = hkdf_expand(&prk, &psk_info, 32);
|
||||
let mut psk = [0u8; 32];
|
||||
psk.copy_from_slice(&psk_bytes);
|
||||
|
||||
// Derive handshake padding range (2 bytes) — info = key_hash[16..] || 0x03
|
||||
// This makes different access keys produce different handshake sizes,
|
||||
// preventing DPI from building a universal size-based filter.
|
||||
let mut pad_info = info_base.to_vec();
|
||||
pad_info.push(0x03);
|
||||
let pad_bytes = hkdf_expand(&prk, &pad_info, 2);
|
||||
// Map to range: min ∈ [16..80], max ∈ [min+48..min+176]
|
||||
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
|
||||
|
||||
DerivedSecrets {
|
||||
obfuscation_key,
|
||||
psk,
|
||||
handshake_pad_min: pad_min,
|
||||
handshake_pad_max: pad_max,
|
||||
}
|
||||
}
|
||||
|
||||
/// Window length (seconds) for the rotating junk marker. The marker changes
|
||||
/// every window, so junk carries no static per-user fingerprint on the wire;
|
||||
/// the server checks the current and previous window to absorb clock skew.
|
||||
pub const JUNK_MARKER_WINDOW_SECS: u64 = 60;
|
||||
|
||||
/// The current junk-marker time window (unix seconds / window length).
|
||||
pub fn current_junk_window() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() / JUNK_MARKER_WINDOW_SECS)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Derive the 4-byte junk marker for a given time `window`.
|
||||
///
|
||||
/// Uses the same version-gated HKDF scheme as [`derive_all_secrets`], with the
|
||||
/// window folded into the `info` (label byte `0x04`). Folding in the window
|
||||
/// makes the marker rotate: to an on-path observer the junk prefix changes every
|
||||
/// window (no fixed signature), and a captured marker is only valid for ~1
|
||||
/// window. Only a holder of the access key can compute it, so an outsider cannot
|
||||
/// forge a silently-dropped junk packet.
|
||||
pub fn derive_junk_marker(access_key: &[u8], window: u64) -> [u8; 4] {
|
||||
derive_junk_marker_versioned(access_key, window, PROTOCOL_VERSION)
|
||||
}
|
||||
|
||||
pub(crate) fn derive_junk_marker_versioned(access_key: &[u8], window: u64, version: u8) -> [u8; 4] {
|
||||
use sha2::Digest;
|
||||
let key_hash = sha2::Sha256::digest(access_key);
|
||||
let salt = &key_hash[..16];
|
||||
let info_base = &key_hash[16..];
|
||||
|
||||
let mut ikm = Vec::with_capacity(access_key.len() + 1);
|
||||
ikm.extend_from_slice(access_key);
|
||||
ikm.push(version);
|
||||
let prk = hkdf_extract(salt, &ikm);
|
||||
|
||||
// info = key_hash[16..] || 0x04 || window(LE) — same label byte as before,
|
||||
// now parameterised by the time window.
|
||||
let mut info = info_base.to_vec();
|
||||
info.push(0x04);
|
||||
info.extend_from_slice(&window.to_le_bytes());
|
||||
let bytes = hkdf_expand(&prk, &info, 4);
|
||||
let mut marker = [0u8; 4];
|
||||
marker.copy_from_slice(&bytes);
|
||||
marker
|
||||
}
|
||||
|
||||
// ── Legacy API (delegates to derive_all_secrets) ─────────────────────────────
|
||||
|
||||
pub fn derive_obfuscation_key(access_key: &[u8]) -> [u8; 8] {
|
||||
derive_all_secrets(access_key).obfuscation_key
|
||||
}
|
||||
|
||||
pub fn derive_psk(access_key: &[u8]) -> [u8; 32] {
|
||||
use sha2::Digest;
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(access_key);
|
||||
hasher.update(b"-ostp-psk-salt");
|
||||
let result = hasher.finalize();
|
||||
let mut psk = [0u8; 32];
|
||||
psk.copy_from_slice(&result);
|
||||
psk
|
||||
derive_all_secrets(access_key).psk
|
||||
}
|
||||
|
||||
/// Derives a unique 4-byte session_id mask using HMAC-SHA256(key, nonce).
|
||||
/// Used strictly for handshake phase obfuscation.
|
||||
fn derive_session_mask(key: &[u8; 8], nonce: u64) -> [u8; 4] {
|
||||
// ── Wire Obfuscation ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Derives a per-packet mask from the payload following the header.
|
||||
/// Used by both data and handshake packets so every mask is unique.
|
||||
fn derive_payload_mask(key: &[u8; 8], payload: &[u8]) -> [u8; 32] {
|
||||
let mut sample = [0u8; 32];
|
||||
let take_len = payload.len().min(32);
|
||||
sample[..take_len].copy_from_slice(&payload[..take_len]);
|
||||
|
||||
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length");
|
||||
mac.update(&nonce.to_be_bytes());
|
||||
mac.update(&sample);
|
||||
let result = mac.finalize().into_bytes();
|
||||
let mut mask = [0u8; 4];
|
||||
mask.copy_from_slice(&result[..4]);
|
||||
let mut mask = [0u8; 32];
|
||||
mask.copy_from_slice(&result);
|
||||
mask
|
||||
}
|
||||
|
||||
/// Wire layout for DATA packets:
|
||||
/// [0..4] = session_id XOR HMAC(obf_key, ciphertext_sample)[0..4]
|
||||
/// [4..12] = nonce XOR HMAC(obf_key, ciphertext_sample)[4..12]
|
||||
/// [12..] = AEAD ciphertext (at least 16 bytes tag)
|
||||
/// [0..4] = session_id XOR mask[0..4]
|
||||
/// [4..12] = nonce XOR mask[4..12]
|
||||
/// [12..] = AEAD ciphertext
|
||||
/// mask = HMAC-SHA256(obf_key, ciphertext_sample[0..32])
|
||||
///
|
||||
/// Because the ciphertext sample is different for every packet, the derived
|
||||
/// mask is cryptographically random and independent for each packet.
|
||||
/// Thus, both session_id and nonce are completely masked and indistinguishable
|
||||
/// from pure random noise on the wire.
|
||||
/// Wire layout for HANDSHAKE packets:
|
||||
/// [0..6] = (session_id || noise_len) XOR mask[0..6]
|
||||
/// [6..] = noise_payload || random_padding
|
||||
/// mask = HMAC-SHA256(obf_key, noise_payload_sample[0..32])
|
||||
///
|
||||
/// In both cases, the mask is derived from the payload that follows the header.
|
||||
/// Since the payload contains cryptographically random data (AEAD ciphertext
|
||||
/// or Noise ephemeral key), the mask is unique per packet, making the entire
|
||||
/// wire output indistinguishable from random noise.
|
||||
pub fn obfuscate_packet_inplace(raw: &mut [u8], key: &[u8; 8], is_handshake: bool) {
|
||||
if !is_handshake && raw.len() >= 12 {
|
||||
let header_len = 12;
|
||||
if raw.len() > header_len {
|
||||
let ciphertext = &raw[header_len..];
|
||||
let mut sample = [0u8; 32];
|
||||
let take_len = ciphertext.len().min(32);
|
||||
sample[..take_len].copy_from_slice(&ciphertext[..take_len]);
|
||||
let mask = derive_payload_mask(key, ciphertext);
|
||||
|
||||
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length");
|
||||
mac.update(&sample);
|
||||
let mask_result = mac.finalize().into_bytes();
|
||||
|
||||
// Mask the entire 12-byte header (session_id + nonce)
|
||||
for i in 0..12 {
|
||||
raw[i] ^= mask_result[i];
|
||||
raw[i] ^= mask[i];
|
||||
}
|
||||
}
|
||||
} else if raw.len() >= 4 {
|
||||
// Handshake packets: mask session_id with a fixed handshake-phase mask
|
||||
let mask = derive_session_mask(key, u64::MAX);
|
||||
for i in 0..4 {
|
||||
} else if is_handshake && raw.len() > 6 {
|
||||
let payload = &raw[6..];
|
||||
let mask = derive_payload_mask(key, payload);
|
||||
|
||||
for i in 0..6 {
|
||||
raw[i] ^= mask[i];
|
||||
}
|
||||
}
|
||||
|
|
@ -77,21 +257,8 @@ pub fn deobfuscate_header_inplace(
|
|||
is_handshake: bool,
|
||||
) {
|
||||
if !is_handshake {
|
||||
let mut sample = [0u8; 32];
|
||||
let take_len = ciphertext.len().min(32);
|
||||
sample[..take_len].copy_from_slice(&ciphertext[..take_len]);
|
||||
|
||||
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length");
|
||||
mac.update(&sample);
|
||||
let mask_result = mac.finalize().into_bytes();
|
||||
|
||||
// Unmask the entire 12-byte header
|
||||
let mask = derive_payload_mask(key, ciphertext);
|
||||
for i in 0..12 {
|
||||
header[i] ^= mask_result[i];
|
||||
}
|
||||
} else {
|
||||
let mask = derive_session_mask(key, u64::MAX);
|
||||
for i in 0..4 {
|
||||
header[i] ^= mask[i];
|
||||
}
|
||||
}
|
||||
|
|
@ -104,10 +271,16 @@ pub fn deobfuscate_packet_inplace(raw: &mut [u8], key: &[u8; 8], is_handshake: b
|
|||
header.copy_from_slice(header_slice);
|
||||
deobfuscate_header_inplace(&mut header, ciphertext, key, is_handshake);
|
||||
header_slice.copy_from_slice(&header);
|
||||
} else if raw.len() >= 4 {
|
||||
let mask = derive_session_mask(key, u64::MAX);
|
||||
for i in 0..4 {
|
||||
} else if is_handshake && raw.len() > 6 {
|
||||
let payload = &raw[6..];
|
||||
let mask = derive_payload_mask(key, payload);
|
||||
|
||||
for i in 0..6 {
|
||||
raw[i] ^= mask[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "obfuscation_tests.rs"]
|
||||
mod obfuscation_tests;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,219 @@
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::crypto::obfuscation::*;
|
||||
|
||||
/// Verifies that derive_all_secrets is deterministic — same input always
|
||||
/// produces the same output.
|
||||
#[test]
|
||||
fn test_derive_deterministic() {
|
||||
let key = b"test_access_key_12345";
|
||||
let s1 = derive_all_secrets(key);
|
||||
let s2 = derive_all_secrets(key);
|
||||
|
||||
assert_eq!(s1.obfuscation_key, s2.obfuscation_key, "obf_key must be deterministic");
|
||||
assert_eq!(s1.psk, s2.psk, "psk must be deterministic");
|
||||
assert_eq!(s1.handshake_pad_min, s2.handshake_pad_min, "pad_min must be deterministic");
|
||||
assert_eq!(s1.handshake_pad_max, s2.handshake_pad_max, "pad_max must be deterministic");
|
||||
}
|
||||
|
||||
/// Verifies that different keys produce different secrets.
|
||||
#[test]
|
||||
fn test_derive_different_keys() {
|
||||
let s1 = derive_all_secrets(b"key_alpha");
|
||||
let s2 = derive_all_secrets(b"key_beta");
|
||||
|
||||
assert_ne!(s1.obfuscation_key, s2.obfuscation_key);
|
||||
assert_ne!(s1.psk, s2.psk);
|
||||
}
|
||||
|
||||
/// Verifies that the legacy API matches derive_all_secrets output.
|
||||
#[test]
|
||||
fn test_legacy_api_consistency() {
|
||||
let key = b"consistency_check_key";
|
||||
let secrets = derive_all_secrets(key);
|
||||
assert_eq!(secrets.obfuscation_key, derive_obfuscation_key(key));
|
||||
assert_eq!(secrets.psk, derive_psk(key));
|
||||
}
|
||||
|
||||
/// Verifies handshake padding range is within valid bounds.
|
||||
#[test]
|
||||
fn test_padding_range_valid() {
|
||||
for i in 0..100 {
|
||||
let key = format!("test_key_{}", i);
|
||||
let s = derive_all_secrets(key.as_bytes());
|
||||
assert!(s.handshake_pad_min >= 16, "pad_min must be >= 16, got {}", s.handshake_pad_min);
|
||||
assert!(s.handshake_pad_min < 80, "pad_min must be < 80, got {}", s.handshake_pad_min);
|
||||
assert!(s.handshake_pad_max > s.handshake_pad_min, "pad_max must be > pad_min");
|
||||
assert!(s.handshake_pad_max <= s.handshake_pad_min + 175,
|
||||
"pad_max out of range: {} > {} + 175", s.handshake_pad_max, s.handshake_pad_min);
|
||||
}
|
||||
}
|
||||
|
||||
/// End-to-end test: obfuscate a handshake packet on the "client" side,
|
||||
/// then deobfuscate on the "server" side using the same access key.
|
||||
/// This simulates the exact flow that caused "Unauthorized probe" errors.
|
||||
#[test]
|
||||
fn test_handshake_obfuscation_roundtrip() {
|
||||
let access_key = b"my_real_access_key_v2";
|
||||
let secrets = derive_all_secrets(access_key);
|
||||
|
||||
// Simulate client building a handshake packet
|
||||
let session_id: u32 = 0xDEADBEEF;
|
||||
let fake_noise_payload = [0x42u8; 48]; // Typical Noise_NNpsk0 handshake size
|
||||
let noise_len = fake_noise_payload.len() as u16;
|
||||
|
||||
let mut packet = Vec::new();
|
||||
packet.extend_from_slice(&session_id.to_be_bytes()); // [0..4]
|
||||
packet.extend_from_slice(&noise_len.to_be_bytes()); // [4..6]
|
||||
packet.extend_from_slice(&fake_noise_payload); // [6..54]
|
||||
packet.extend_from_slice(&[0xAA; 64]); // padding
|
||||
|
||||
// Obfuscate (client side)
|
||||
obfuscate_packet_inplace(&mut packet, &secrets.obfuscation_key, true);
|
||||
|
||||
// At this point, bytes [0..6] are masked and should look random
|
||||
let masked_sid = u32::from_be_bytes([packet[0], packet[1], packet[2], packet[3]]);
|
||||
assert_ne!(masked_sid, session_id, "session_id must be masked on wire");
|
||||
|
||||
// Deobfuscate (server side) — using same key
|
||||
deobfuscate_packet_inplace(&mut packet, &secrets.obfuscation_key, true);
|
||||
|
||||
// Verify session_id is recovered
|
||||
let recovered_sid = u32::from_be_bytes([packet[0], packet[1], packet[2], packet[3]]);
|
||||
assert_eq!(recovered_sid, session_id, "session_id must be recovered after deobfuscation");
|
||||
|
||||
// Verify noise_len is recovered
|
||||
let recovered_noise_len = u16::from_be_bytes([packet[4], packet[5]]);
|
||||
assert_eq!(recovered_noise_len, noise_len, "noise_len must be recovered");
|
||||
|
||||
// Verify noise payload is intact
|
||||
assert_eq!(&packet[6..6 + noise_len as usize], &fake_noise_payload,
|
||||
"noise payload must be intact after round-trip");
|
||||
}
|
||||
|
||||
/// Verifies that deobfuscating with the WRONG key does NOT recover
|
||||
/// the session_id — this is what prevents unauthorized probes.
|
||||
#[test]
|
||||
fn test_wrong_key_produces_garbage() {
|
||||
let correct_key = b"correct_key";
|
||||
let wrong_key = b"wrong_key";
|
||||
|
||||
let correct_secrets = derive_all_secrets(correct_key);
|
||||
let wrong_secrets = derive_all_secrets(wrong_key);
|
||||
|
||||
let session_id: u32 = 0x12345678;
|
||||
let fake_noise = [0x55u8; 48];
|
||||
|
||||
let mut packet = Vec::new();
|
||||
packet.extend_from_slice(&session_id.to_be_bytes());
|
||||
packet.extend_from_slice(&(48u16).to_be_bytes());
|
||||
packet.extend_from_slice(&fake_noise);
|
||||
packet.extend_from_slice(&[0x00; 32]);
|
||||
|
||||
// Obfuscate with correct key
|
||||
obfuscate_packet_inplace(&mut packet, &correct_secrets.obfuscation_key, true);
|
||||
|
||||
// Try to deobfuscate with WRONG key
|
||||
let mut wrong_trial = packet.clone();
|
||||
deobfuscate_packet_inplace(&mut wrong_trial, &wrong_secrets.obfuscation_key, true);
|
||||
let wrong_sid = u32::from_be_bytes([wrong_trial[0], wrong_trial[1], wrong_trial[2], wrong_trial[3]]);
|
||||
|
||||
// Should NOT match — this is what the dispatcher checks
|
||||
assert_ne!(wrong_sid, session_id, "wrong key must NOT recover session_id");
|
||||
|
||||
// Deobfuscate with correct key — must work
|
||||
deobfuscate_packet_inplace(&mut packet, &correct_secrets.obfuscation_key, true);
|
||||
let correct_sid = u32::from_be_bytes([packet[0], packet[1], packet[2], packet[3]]);
|
||||
assert_eq!(correct_sid, session_id, "correct key must recover session_id");
|
||||
}
|
||||
|
||||
/// §C version gate: a peer on a different PROTOCOL_VERSION derives
|
||||
/// different secrets, so a handshake obfuscated with the OLD version's key
|
||||
/// does NOT deobfuscate to a valid session_id under the current version.
|
||||
/// This is exactly what makes an old (pre-0.4.0) client fail to connect to
|
||||
/// a new server — with no plaintext version marker on the wire.
|
||||
#[test]
|
||||
fn test_protocol_version_gates_old_clients() {
|
||||
let key = b"shared_access_key_across_versions";
|
||||
let new = derive_all_secrets(key); // == derive_all_secrets_versioned(key, PROTOCOL_VERSION)
|
||||
let old = derive_all_secrets_versioned(key, PROTOCOL_VERSION.wrapping_sub(1));
|
||||
|
||||
// Different protocol version → different derived secrets.
|
||||
assert_ne!(new.obfuscation_key, old.obfuscation_key, "version must change obf_key");
|
||||
assert_ne!(new.psk, old.psk, "version must change psk");
|
||||
|
||||
// Concretely: a handshake the old client obfuscated with its key does
|
||||
// not recover a valid session_id when the new server deobfuscates it.
|
||||
let session_id: u32 = 0x11223344;
|
||||
let noise = [0x33u8; 48];
|
||||
let mut pkt = Vec::new();
|
||||
pkt.extend_from_slice(&session_id.to_be_bytes());
|
||||
pkt.extend_from_slice(&(noise.len() as u16).to_be_bytes());
|
||||
pkt.extend_from_slice(&noise);
|
||||
pkt.extend_from_slice(&[0u8; 32]);
|
||||
|
||||
obfuscate_packet_inplace(&mut pkt, &old.obfuscation_key, true); // old client
|
||||
deobfuscate_packet_inplace(&mut pkt, &new.obfuscation_key, true); // new server
|
||||
let recovered = u32::from_be_bytes([pkt[0], pkt[1], pkt[2], pkt[3]]);
|
||||
assert_ne!(recovered, session_id, "old-version client must NOT be accepted by new server");
|
||||
}
|
||||
|
||||
/// Verifies data packet obfuscation round-trip (non-handshake path).
|
||||
#[test]
|
||||
fn test_data_packet_obfuscation_roundtrip() {
|
||||
let secrets = derive_all_secrets(b"data_test_key");
|
||||
|
||||
let session_id: u32 = 0xCAFEBABE;
|
||||
let nonce: u64 = 42;
|
||||
let ciphertext = [0x77u8; 64];
|
||||
|
||||
let mut packet = Vec::new();
|
||||
packet.extend_from_slice(&session_id.to_be_bytes()); // [0..4]
|
||||
packet.extend_from_slice(&nonce.to_be_bytes()); // [4..12]
|
||||
packet.extend_from_slice(&ciphertext); // [12..]
|
||||
|
||||
obfuscate_packet_inplace(&mut packet, &secrets.obfuscation_key, false);
|
||||
|
||||
// Masked
|
||||
let masked_sid = u32::from_be_bytes([packet[0], packet[1], packet[2], packet[3]]);
|
||||
assert_ne!(masked_sid, session_id);
|
||||
|
||||
// Deobfuscate
|
||||
deobfuscate_packet_inplace(&mut packet, &secrets.obfuscation_key, false);
|
||||
|
||||
let recovered_sid = u32::from_be_bytes([packet[0], packet[1], packet[2], packet[3]]);
|
||||
let recovered_nonce = u64::from_be_bytes([
|
||||
packet[4], packet[5], packet[6], packet[7],
|
||||
packet[8], packet[9], packet[10], packet[11],
|
||||
]);
|
||||
|
||||
assert_eq!(recovered_sid, session_id);
|
||||
assert_eq!(recovered_nonce, nonce);
|
||||
assert_eq!(&packet[12..], &ciphertext);
|
||||
}
|
||||
|
||||
/// The junk marker must: be stable within a window (client and server agree),
|
||||
/// rotate across windows (no static on-wire fingerprint), and differ per key
|
||||
/// (one user's marker never silently-drops on another user's flow).
|
||||
#[test]
|
||||
fn test_junk_marker_rotation() {
|
||||
let key_a = b"access-key-alpha";
|
||||
let key_b = b"access-key-bravo";
|
||||
|
||||
// Stable within a window.
|
||||
assert_eq!(derive_junk_marker(key_a, 1000), derive_junk_marker(key_a, 1000));
|
||||
|
||||
// Rotates across adjacent windows.
|
||||
assert_ne!(derive_junk_marker(key_a, 1000), derive_junk_marker(key_a, 1001));
|
||||
assert_ne!(derive_junk_marker(key_a, 1000), derive_junk_marker(key_a, 999));
|
||||
|
||||
// Distinct per key within the same window.
|
||||
assert_ne!(derive_junk_marker(key_a, 1000), derive_junk_marker(key_b, 1000));
|
||||
|
||||
// A different protocol version yields a different marker (version gate).
|
||||
assert_ne!(
|
||||
derive_junk_marker_versioned(key_a, 1000, PROTOCOL_VERSION),
|
||||
derive_junk_marker_versioned(key_a, 1000, PROTOCOL_VERSION.wrapping_add(1)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -44,7 +44,10 @@ impl FrameHeader {
|
|||
pub fn encode(&self, out: &mut BytesMut) {
|
||||
out.put_u8(self.version);
|
||||
out.put_u8(self.kind as u8);
|
||||
out.put_u16(0); // 2 reserved bytes
|
||||
// Anti-DPI: reserved bytes filled with random data instead of zeros
|
||||
// to prevent known-plaintext fingerprinting inside encrypted frames
|
||||
let rnd: u16 = rand::random();
|
||||
out.put_u16(rnd);
|
||||
out.put_u16(self.stream_id);
|
||||
out.put_u32(self.payload_len);
|
||||
out.put_u16(self.pad_len);
|
||||
|
|
@ -98,7 +101,15 @@ impl FramedPacket {
|
|||
let payload_len = header.payload_len as usize;
|
||||
let pad_len = header.pad_len as usize;
|
||||
|
||||
let expected = FRAME_HEADER_LEN + payload_len + pad_len;
|
||||
// Use checked arithmetic: payload_len is a u32 from the (decrypted, but
|
||||
// still to-be-trusted) header, and on 32-bit targets — MIPS/ARMv7
|
||||
// routers are supported build targets — header+payload+pad can overflow
|
||||
// usize and wrap to a small value that spuriously passes the length
|
||||
// check, causing an out-of-range slice below.
|
||||
let expected = FRAME_HEADER_LEN
|
||||
.checked_add(payload_len)
|
||||
.and_then(|v| v.checked_add(pad_len))
|
||||
.ok_or_else(|| ProtocolError::Framing("frame length overflow".to_string()))?;
|
||||
if buf.len() < expected {
|
||||
return Err(ProtocolError::Framing("frame body truncated".to_string()));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ impl TrafficProfile {
|
|||
}
|
||||
|
||||
fn align_up(v: usize, align: usize) -> usize {
|
||||
((v + align - 1) / align) * align
|
||||
v.div_ceil(align) * align
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
|
|
@ -45,11 +45,11 @@ impl AdaptivePadder {
|
|||
}
|
||||
|
||||
pub fn padding_for_len(&self, payload_len: usize) -> usize {
|
||||
match self.strategy {
|
||||
let raw_pad = match self.strategy {
|
||||
PaddingStrategy::Fixed(target) => target.saturating_sub(payload_len),
|
||||
PaddingStrategy::Adaptive => {
|
||||
let base_bucket = 64;
|
||||
let bucketized = ((payload_len + base_bucket - 1) / base_bucket) * base_bucket;
|
||||
let bucketized = payload_len.div_ceil(base_bucket) * base_bucket;
|
||||
let mut target = bucketized.clamp(base_bucket, self.mtu_hint);
|
||||
if target < payload_len {
|
||||
target = payload_len;
|
||||
|
|
@ -60,7 +60,7 @@ impl AdaptivePadder {
|
|||
let jitter = if jitter_cap == 0 {
|
||||
0
|
||||
} else {
|
||||
rand::thread_rng().gen_range(0..=jitter_cap.min(96))
|
||||
rand::thread_rng().gen_range(0..=jitter_cap.min(256))
|
||||
};
|
||||
|
||||
(base_pad + jitter).min(self.max_pad)
|
||||
|
|
@ -69,7 +69,12 @@ impl AdaptivePadder {
|
|||
let target = prof.target_size(payload_len);
|
||||
target.saturating_sub(payload_len).min(self.max_pad)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Strict clamp to ensure total packet size (including overhead) never exceeds mtu_hint
|
||||
let overhead = 38;
|
||||
let max_allowed = self.mtu_hint.saturating_sub(payload_len).saturating_sub(overhead);
|
||||
raw_pad.min(max_allowed)
|
||||
}
|
||||
|
||||
pub fn build_padding(&self, payload_len: usize) -> Vec<u8> {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
pub mod congestion;
|
||||
pub mod crypto;
|
||||
pub mod framing;
|
||||
pub mod protocol;
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ pub enum RelayMessage {
|
|||
Error(String),
|
||||
Ping(u64),
|
||||
Pong(u64),
|
||||
UdpAssociate,
|
||||
UdpData(String, Vec<u8>),
|
||||
}
|
||||
|
||||
impl RelayMessage {
|
||||
|
|
@ -23,6 +25,17 @@ impl RelayMessage {
|
|||
RelayMessage::Error(msg) => encode_with_len(6, msg.as_bytes()),
|
||||
RelayMessage::Ping(ts) => encode_with_len(7, &ts.to_be_bytes()),
|
||||
RelayMessage::Pong(ts) => encode_with_len(8, &ts.to_be_bytes()),
|
||||
RelayMessage::UdpAssociate => vec![9],
|
||||
RelayMessage::UdpData(addr, data) => {
|
||||
let addr_bytes = addr.as_bytes();
|
||||
let mut buf = Vec::with_capacity(1 + 2 + addr_bytes.len() + 2 + data.len());
|
||||
buf.push(10);
|
||||
buf.extend_from_slice(&(addr_bytes.len() as u16).to_be_bytes());
|
||||
buf.extend_from_slice(addr_bytes);
|
||||
buf.extend_from_slice(&(data.len() as u16).to_be_bytes());
|
||||
buf.extend_from_slice(data);
|
||||
buf
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -51,16 +64,34 @@ impl RelayMessage {
|
|||
7 => {
|
||||
let payload = decode_with_len(&input[1..])?;
|
||||
if payload.len() != 8 { return Err(anyhow!("invalid ping payload len")); }
|
||||
let ts = u64::from_be_bytes(payload.try_into().unwrap());
|
||||
let ts = u64::from_be_bytes(payload.try_into().map_err(|_| anyhow!("invalid ping payload size"))?);
|
||||
Ok(RelayMessage::Ping(ts))
|
||||
}
|
||||
8 => {
|
||||
let payload = decode_with_len(&input[1..])?;
|
||||
if payload.len() != 8 { return Err(anyhow!("invalid pong payload len")); }
|
||||
let ts = u64::from_be_bytes(payload.try_into().unwrap());
|
||||
Ok(RelayMessage::Pong(ts))
|
||||
if payload.len() != 8 {
|
||||
return Err(anyhow!("invalid pong payload"));
|
||||
}
|
||||
let mut ts = [0u8; 8];
|
||||
ts.copy_from_slice(payload);
|
||||
Ok(RelayMessage::Pong(u64::from_be_bytes(ts)))
|
||||
}
|
||||
t => Err(anyhow!("unknown relay message type {t}")),
|
||||
9 => Ok(RelayMessage::UdpAssociate),
|
||||
10 => {
|
||||
if input.len() < 3 { return Err(anyhow!("invalid udp data")); }
|
||||
let addr_len = u16::from_be_bytes([input[1], input[2]]) as usize;
|
||||
if input.len() < 3 + addr_len + 2 { return Err(anyhow!("invalid udp data")); }
|
||||
let addr = String::from_utf8(input[3..3+addr_len].to_vec())
|
||||
.map_err(|_| anyhow!("invalid utf8 in udp addr"))?;
|
||||
|
||||
let data_offset = 3 + addr_len;
|
||||
let data_len = u16::from_be_bytes([input[data_offset], input[data_offset+1]]) as usize;
|
||||
if input.len() < data_offset + 2 + data_len { return Err(anyhow!("invalid udp data")); }
|
||||
|
||||
let data = input[data_offset+2..data_offset+2+data_len].to_vec();
|
||||
Ok(RelayMessage::UdpData(addr, data))
|
||||
}
|
||||
_ => Err(anyhow!("unknown relay message type {}", input[0])),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -84,3 +115,83 @@ fn decode_with_len(input: &[u8]) -> Result<&[u8]> {
|
|||
}
|
||||
Ok(&input[2..2 + len])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_connect_roundtrip() {
|
||||
let msg = RelayMessage::Connect("example.com:443".to_string());
|
||||
let encoded = msg.encode();
|
||||
let decoded = RelayMessage::decode(&encoded).unwrap();
|
||||
match decoded {
|
||||
RelayMessage::Connect(addr) => assert_eq!(addr, "example.com:443"),
|
||||
_ => panic!("expected Connect"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_data_roundtrip() {
|
||||
let data = vec![1, 2, 3, 4, 5];
|
||||
let msg = RelayMessage::Data(data.clone());
|
||||
let encoded = msg.encode();
|
||||
let decoded = RelayMessage::decode(&encoded).unwrap();
|
||||
match decoded {
|
||||
RelayMessage::Data(d) => assert_eq!(d, data),
|
||||
_ => panic!("expected Data"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_simple_tags() {
|
||||
assert_eq!(RelayMessage::KeepAlive.encode(), vec![3]);
|
||||
assert_eq!(RelayMessage::Close.encode(), vec![4]);
|
||||
assert_eq!(RelayMessage::ConnectOk.encode(), vec![5]);
|
||||
|
||||
assert!(matches!(RelayMessage::decode(&[3]).unwrap(), RelayMessage::KeepAlive));
|
||||
assert!(matches!(RelayMessage::decode(&[4]).unwrap(), RelayMessage::Close));
|
||||
assert!(matches!(RelayMessage::decode(&[5]).unwrap(), RelayMessage::ConnectOk));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_roundtrip() {
|
||||
let msg = RelayMessage::Error("connection refused".to_string());
|
||||
let encoded = msg.encode();
|
||||
match RelayMessage::decode(&encoded).unwrap() {
|
||||
RelayMessage::Error(e) => assert_eq!(e, "connection refused"),
|
||||
_ => panic!("expected Error"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ping_pong_roundtrip() {
|
||||
let ts = 1234567890u64;
|
||||
match RelayMessage::decode(&RelayMessage::Ping(ts).encode()).unwrap() {
|
||||
RelayMessage::Ping(t) => assert_eq!(t, ts),
|
||||
_ => panic!("expected Ping"),
|
||||
}
|
||||
match RelayMessage::decode(&RelayMessage::Pong(ts).encode()).unwrap() {
|
||||
RelayMessage::Pong(t) => assert_eq!(t, ts),
|
||||
_ => panic!("expected Pong"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_cases() {
|
||||
assert!(RelayMessage::decode(&[]).is_err());
|
||||
assert!(RelayMessage::decode(&[255]).is_err());
|
||||
// Truncated: tag=1, len=5, only 2 bytes
|
||||
assert!(RelayMessage::decode(&[1, 0, 5, b'a', b'b']).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_data_roundtrip() {
|
||||
let encoded = RelayMessage::Data(vec![]).encode();
|
||||
match RelayMessage::decode(&encoded).unwrap() {
|
||||
RelayMessage::Data(d) => assert!(d.is_empty()),
|
||||
_ => panic!("expected Data"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
# Miscellaneous
|
||||
*.class
|
||||
*.log
|
||||
*.pyc
|
||||
*.swp
|
||||
.DS_Store
|
||||
.atom/
|
||||
.build/
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
.swiftpm/
|
||||
migrate_working_dir/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.idea/
|
||||
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
#.vscode/
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
**/doc/api/
|
||||
**/ios/Flutter/.last_build_id
|
||||
.dart_tool/
|
||||
.flutter-plugins-dependencies
|
||||
.pub-cache/
|
||||
.pub/
|
||||
/build/
|
||||
/coverage/
|
||||
|
||||
# Symbolication related
|
||||
app.*.symbols
|
||||
|
||||
# Obfuscation related
|
||||
app.*.map.json
|
||||
|
||||
# Android Studio will place build artifacts here
|
||||
/android/app/debug
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
# This file tracks properties of this Flutter project.
|
||||
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||
#
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: "db50e20168db8fee486b9abf32fc912de3bc5b6a"
|
||||
channel: "stable"
|
||||
|
||||
project_type: app
|
||||
|
||||
# Tracks metadata for the flutter migrate command
|
||||
migration:
|
||||
platforms:
|
||||
- platform: root
|
||||
create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
||||
base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
||||
- platform: android
|
||||
create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
||||
base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
||||
- platform: ios
|
||||
create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
||||
base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
||||
- platform: linux
|
||||
create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
||||
base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
||||
- platform: macos
|
||||
create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
||||
base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
||||
- platform: web
|
||||
create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
||||
base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
||||
- platform: windows
|
||||
create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
||||
base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
|
||||
|
||||
# User provided section
|
||||
|
||||
# List of Local paths (relative to this file) that should be
|
||||
# ignored by the migrate tool.
|
||||
#
|
||||
# Files that are not part of the templates will be ignored by default.
|
||||
unmanaged_files:
|
||||
- 'lib/main.dart'
|
||||
- 'ios/Runner.xcodeproj/project.pbxproj'
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
# ostp_client
|
||||
|
||||
A new Flutter project.
|
||||
|
||||
## Getting Started
|
||||
|
||||
This project is a starting point for a Flutter application.
|
||||
|
||||
A few resources to get you started if this is your first Flutter project:
|
||||
|
||||
- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
|
||||
- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
|
||||
- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
|
||||
|
||||
For help getting started with Flutter development, view the
|
||||
[online documentation](https://docs.flutter.dev/), which offers tutorials,
|
||||
samples, guidance on mobile development, and a full API reference.
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
# This file configures the analyzer, which statically analyzes Dart code to
|
||||
# check for errors, warnings, and lints.
|
||||
#
|
||||
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
|
||||
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
|
||||
# invoked from the command line by running `flutter analyze`.
|
||||
|
||||
# The following line activates a set of recommended lints for Flutter apps,
|
||||
# packages, and plugins designed to encourage good coding practices.
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
linter:
|
||||
# The lint rules applied to this project can be customized in the
|
||||
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
||||
# included above or to enable additional rules. A list of all available lints
|
||||
# and their documentation is published at https://dart.dev/lints.
|
||||
#
|
||||
# Instead of disabling a lint rule for the entire project in the
|
||||
# section below, it can also be suppressed for a single line of code
|
||||
# or a specific dart file by using the `// ignore: name_of_lint` and
|
||||
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
|
||||
# producing the lint.
|
||||
rules:
|
||||
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||
|
||||
# Additional information about this file can be found at
|
||||
# https://dart.dev/guides/language/analysis-options
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
gradle-wrapper.jar
|
||||
/.gradle
|
||||
/captures/
|
||||
/gradlew
|
||||
/gradlew.bat
|
||||
/local.properties
|
||||
GeneratedPluginRegistrant.java
|
||||
.cxx/
|
||||
|
||||
# Remember to never publicly share your keystore.
|
||||
# See https://flutter.dev/to/reference-keystore
|
||||
key.properties
|
||||
**/*.keystore
|
||||
**/*.jks
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
import java.io.FileInputStream
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("kotlin-android")
|
||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
// ── Release signing material ────────────────────────────────────────────────
|
||||
// Supplied out-of-band and never committed: either an `android/key.properties`
|
||||
// file (local release builds) or OSTP_KEYSTORE_* environment variables (CI).
|
||||
//
|
||||
// This exists because the release build used to be signed with the DEBUG
|
||||
// keystore (the stock Flutter template TODO). Android identifies an app by
|
||||
// applicationId + signing key, and refuses to update across a key change. The
|
||||
// debug keystore is auto-generated per machine, and CI runners are ephemeral,
|
||||
// so every published build carried a different random key — which is why
|
||||
// updating on top of a previous install failed with "App not installed" /
|
||||
// "unable to parse the package" and only a full uninstall+reinstall worked.
|
||||
val keystoreProperties = Properties().apply {
|
||||
val propsFile = rootProject.file("key.properties")
|
||||
if (propsFile.exists()) {
|
||||
FileInputStream(propsFile).use { load(it) }
|
||||
}
|
||||
}
|
||||
|
||||
// Blank counts as absent. GitHub Actions substitutes an EMPTY STRING (not an
|
||||
// unset variable) for a secret that doesn't exist, so `getenv(...) ?: fallback`
|
||||
// silently kept the empty value — the elvis operator only catches null. That is
|
||||
// how an unset ANDROID_KEY_PASSWORD ended up being used as the literal key
|
||||
// password instead of falling back to the store password, producing Gradle's
|
||||
// "Get Key failed: Given final block not properly padded".
|
||||
fun signingSetting(propKey: String, envKey: String): String? =
|
||||
(keystoreProperties.getProperty(propKey) ?: System.getenv(envKey))
|
||||
?.takeIf { it.isNotBlank() }
|
||||
|
||||
val releaseStorePath: String? = signingSetting("storeFile", "OSTP_KEYSTORE_PATH")
|
||||
val hasReleaseSigning: Boolean = !releaseStorePath.isNullOrBlank()
|
||||
|
||||
android {
|
||||
namespace = "com.ospab.ostp_client"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_17.toString()
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId = "com.ospab.ostp_client"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
minSdk = maxOf(flutter.minSdkVersion, 24)
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
|
||||
ndk {
|
||||
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86_64")
|
||||
}
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
if (hasReleaseSigning) {
|
||||
val store = signingSetting("storePassword", "OSTP_KEYSTORE_PASSWORD")
|
||||
storeFile = file(releaseStorePath!!)
|
||||
storePassword = store
|
||||
keyAlias = signingSetting("keyAlias", "OSTP_KEY_ALIAS")
|
||||
// PKCS12 (the keytool default since Java 9, and what our upload
|
||||
// keystore is) cannot hold a key password that differs from the
|
||||
// store password — the format simply has no place to put one. So
|
||||
// treat a missing key password as "same as the store password"
|
||||
// instead of demanding a secret that, for this keystore, can only
|
||||
// ever be a duplicate. An explicit value still wins, for the older
|
||||
// JKS format where the two genuinely can differ.
|
||||
keyPassword = signingSetting("keyPassword", "OSTP_KEY_PASSWORD") ?: store
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// Use the real upload key when one was supplied; otherwise fall back to
|
||||
// the debug keystore so a plain local `flutter build apk --release`
|
||||
// still works for development. Anything PUBLISHED must take the first
|
||||
// branch — a debug-signed build cannot be updated over, and its key is
|
||||
// machine-local, so it also can't be reproduced later.
|
||||
if (hasReleaseSigning) {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
} else {
|
||||
logger.warn(
|
||||
"OSTP: no release keystore configured (android/key.properties or " +
|
||||
"OSTP_KEYSTORE_PATH) - falling back to the DEBUG keystore. This APK " +
|
||||
"is for local use only: users cannot update over it, and the key is " +
|
||||
"not reproducible on another machine."
|
||||
)
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.core:core-ktx:1.13.1")
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
-keep class net.ostp.client.OstpClientSdk { *; }
|
||||
-keep class com.ospab.ostp_client.OstpVpnService { *; }
|
||||
-keep class com.ospab.ostp_client.MainActivity { *; }
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE"/>
|
||||
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE"/>
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<application
|
||||
android:label="ostp_client"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/launcher_icon"
|
||||
android:roundIcon="@mipmap/launcher_icon_round"
|
||||
android:extractNativeLibs="true">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:taskAffinity=""
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||
the Android process has started. This theme is visible to the user
|
||||
while the Flutter UI initializes. After that, this theme continues
|
||||
to determine the Window background behind the Flutter UI. -->
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme"
|
||||
/>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
|
||||
<service
|
||||
android:name=".OstpVpnService"
|
||||
android:permission="android.permission.BIND_VPN_SERVICE"
|
||||
android:foregroundServiceType="connectedDevice"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.net.VpnService"/>
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<!-- Quick Settings Tile -->
|
||||
<service
|
||||
android:name=".OstpTileService"
|
||||
android:icon="@mipmap/launcher_icon"
|
||||
android:label="OSTP VPN"
|
||||
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.quicksettings.action.QS_TILE"/>
|
||||
</intent-filter>
|
||||
</service>
|
||||
</application>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
|
||||
|
||||
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
</intent>
|
||||
</queries>
|
||||
</manifest>
|
||||
|
|
@ -0,0 +1,156 @@
|
|||
package com.ospab.ostp_client
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.VpnService
|
||||
import androidx.annotation.NonNull
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.util.Base64
|
||||
import java.io.ByteArrayOutputStream
|
||||
|
||||
class MainActivity : FlutterActivity() {
|
||||
private val CHANNEL = "com.ospab.ostp/vpn"
|
||||
private val VPN_REQUEST_CODE = 0x0F
|
||||
private var pendingConfigJson: String? = null
|
||||
|
||||
private fun getAppIconBase64(pm: PackageManager, appInfo: ApplicationInfo): String? {
|
||||
try {
|
||||
val drawable = pm.getApplicationIcon(appInfo)
|
||||
val width = 96
|
||||
val height = 96
|
||||
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(bitmap)
|
||||
drawable.setBounds(0, 0, width, height)
|
||||
drawable.draw(canvas)
|
||||
|
||||
val outputStream = ByteArrayOutputStream()
|
||||
bitmap.compress(Bitmap.CompressFormat.PNG, 90, outputStream)
|
||||
val byteArray = outputStream.toByteArray()
|
||||
return Base64.encodeToString(byteArray, Base64.NO_WRAP)
|
||||
} catch (e: Throwable) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"saveConfig" -> {
|
||||
val configJson = call.argument<String>("configJson")
|
||||
val prefs = getSharedPreferences("OstpPrefs", android.content.Context.MODE_PRIVATE)
|
||||
prefs.edit().putString("latest_config_json", configJson).apply()
|
||||
result.success(true)
|
||||
}
|
||||
"startTunnel" -> {
|
||||
pendingConfigJson = call.argument<String>("configJson")
|
||||
val intent = VpnService.prepare(this)
|
||||
if (intent != null) {
|
||||
startActivityForResult(intent, VPN_REQUEST_CODE)
|
||||
result.success(true)
|
||||
} else {
|
||||
startVpnService()
|
||||
result.success(true)
|
||||
}
|
||||
}
|
||||
"stopTunnel" -> {
|
||||
try {
|
||||
val intent = Intent(this, OstpVpnService::class.java)
|
||||
intent.action = "STOP"
|
||||
startService(intent)
|
||||
result.success(true)
|
||||
} catch (e: Throwable) {
|
||||
result.error("ERROR", e.message, null)
|
||||
}
|
||||
}
|
||||
"getLogs" -> {
|
||||
try {
|
||||
val logs = net.ostp.client.OstpClientSdk.getLogs()
|
||||
result.success(logs ?: "[]")
|
||||
} catch (e: Throwable) {
|
||||
result.error("ERROR", e.message ?: "Unknown JNI Error", null)
|
||||
}
|
||||
}
|
||||
"clearLogs" -> {
|
||||
try {
|
||||
net.ostp.client.OstpClientSdk.getLogs() // Drain
|
||||
result.success(true)
|
||||
} catch (e: Throwable) {
|
||||
result.error("ERROR", e.message, null)
|
||||
}
|
||||
}
|
||||
"isRunning" -> {
|
||||
result.success(OstpVpnService.isRunning)
|
||||
}
|
||||
"getMetrics" -> {
|
||||
try {
|
||||
val metrics = net.ostp.client.OstpClientSdk.getMetrics()
|
||||
result.success(metrics ?: "{}")
|
||||
} catch (e: Throwable) {
|
||||
// Surfaced into the in-app log viewer (not just logcat) so a
|
||||
// broken traffic counter is diagnosable from a user's bug
|
||||
// report without adb access.
|
||||
android.util.Log.e("MainActivity", "getMetrics failed", e)
|
||||
try {
|
||||
net.ostp.client.OstpClientSdk.addLog("getMetrics failed: ${e.javaClass.simpleName}: ${e.message}")
|
||||
} catch (_: Throwable) {}
|
||||
result.error("ERROR", e.message, null)
|
||||
}
|
||||
}
|
||||
"getInstalledApps" -> {
|
||||
// MethodChannel handlers run on the main/UI thread by default.
|
||||
// Enumerating every installed package AND decoding+re-encoding
|
||||
// each one's icon to PNG/base64 is expensive (100+ apps is
|
||||
// common) — done inline here it blocked the main thread for
|
||||
// 10-15s, during which Flutter couldn't render ANY frame, not
|
||||
// even the "loading" spinner, so the screen just appeared to
|
||||
// hang before jumping straight to the fully-loaded list.
|
||||
// Do the work on a background thread; only the final
|
||||
// `result.success(...)` needs to hop back onto the UI thread.
|
||||
val pm = packageManager
|
||||
Thread {
|
||||
try {
|
||||
val apps = pm.getInstalledApplications(PackageManager.GET_META_DATA)
|
||||
val list = apps.map { app ->
|
||||
val isSystem = ((app.flags and ApplicationInfo.FLAG_SYSTEM) != 0) &&
|
||||
(pm.getLaunchIntentForPackage(app.packageName) == null)
|
||||
val iconBase64 = getAppIconBase64(pm, app)
|
||||
mapOf(
|
||||
"name" to pm.getApplicationLabel(app).toString(),
|
||||
"package" to app.packageName,
|
||||
"isSystem" to isSystem,
|
||||
"icon" to (iconBase64 ?: "")
|
||||
)
|
||||
}
|
||||
runOnUiThread { result.success(list) }
|
||||
} catch (e: Exception) {
|
||||
runOnUiThread { result.error("ERROR", e.message, null) }
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
if (requestCode == VPN_REQUEST_CODE && resultCode == RESULT_OK) {
|
||||
startVpnService()
|
||||
}
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
}
|
||||
|
||||
private fun startVpnService() {
|
||||
val intent = Intent(this, OstpVpnService::class.java)
|
||||
intent.action = "START"
|
||||
if (pendingConfigJson != null) {
|
||||
intent.putExtra("configJson", pendingConfigJson)
|
||||
}
|
||||
androidx.core.content.ContextCompat.startForegroundService(this, intent)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
package com.ospab.ostp_client
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.service.quicksettings.Tile
|
||||
import android.service.quicksettings.TileService
|
||||
import androidx.annotation.Keep
|
||||
import androidx.annotation.RequiresApi
|
||||
|
||||
@Keep
|
||||
@RequiresApi(Build.VERSION_CODES.N)
|
||||
class OstpTileService : TileService() {
|
||||
|
||||
override fun onStartListening() {
|
||||
super.onStartListening()
|
||||
updateTile()
|
||||
}
|
||||
|
||||
override fun onClick() {
|
||||
super.onClick()
|
||||
if (OstpVpnService.isRunning) {
|
||||
// Отключить VPN
|
||||
val stopIntent = Intent(this, OstpVpnService::class.java).apply { action = "STOP" }
|
||||
startService(stopIntent)
|
||||
// Обновим плитку сразу
|
||||
qsTile?.state = Tile.STATE_INACTIVE
|
||||
qsTile?.label = "OSTP VPN"
|
||||
qsTile?.updateTile()
|
||||
} else {
|
||||
// Включить VPN напрямую
|
||||
val prefs = getSharedPreferences("OstpPrefs", Context.MODE_PRIVATE)
|
||||
val configJson = prefs.getString("latest_config_json", null)
|
||||
|
||||
if (configJson != null) {
|
||||
// Check if VPN consent is needed
|
||||
val vpnIntent = android.net.VpnService.prepare(this)
|
||||
if (vpnIntent != null) {
|
||||
// Consent needed, launch app
|
||||
val appIntent = packageManager.getLaunchIntentForPackage(packageName)?.apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
}
|
||||
if (appIntent != null) {
|
||||
startActivityAndCollapse(appIntent)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val startIntent = Intent(this, OstpVpnService::class.java).apply {
|
||||
action = "START"
|
||||
putExtra("configJson", configJson)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
startForegroundService(startIntent)
|
||||
} else {
|
||||
startService(startIntent)
|
||||
}
|
||||
qsTile?.state = Tile.STATE_ACTIVE
|
||||
qsTile?.label = "OSTP VPN"
|
||||
qsTile?.updateTile()
|
||||
} else {
|
||||
// Если конфигурация еще не сохранена, открыть приложение
|
||||
val appIntent = packageManager.getLaunchIntentForPackage(packageName)?.apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
putExtra("tile_connect", true)
|
||||
}
|
||||
if (appIntent != null) {
|
||||
startActivityAndCollapse(appIntent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateTile() {
|
||||
val tile = qsTile ?: return
|
||||
if (OstpVpnService.isRunning) {
|
||||
tile.label = "OSTP VPN"
|
||||
tile.state = Tile.STATE_ACTIVE
|
||||
} else {
|
||||
tile.label = "OSTP VPN"
|
||||
tile.state = Tile.STATE_INACTIVE
|
||||
}
|
||||
tile.updateTile()
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Запрашивает обновление плитки быстрых настроек.
|
||||
* Вызывается из OstpVpnService при изменении состояния.
|
||||
*/
|
||||
@Keep
|
||||
fun requestListeningState(context: Context) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
try {
|
||||
requestListeningState(
|
||||
context,
|
||||
ComponentName(context, OstpTileService::class.java)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
// Плитка может быть не добавлена в панель — это нормально
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,307 @@
|
|||
package com.ospab.ostp_client
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.net.VpnService
|
||||
import android.os.Build
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.os.PowerManager
|
||||
import android.util.Log
|
||||
import net.ostp.client.OstpClientSdk
|
||||
import java.io.IOException
|
||||
import androidx.annotation.Keep
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.ServiceCompat
|
||||
|
||||
@Keep
|
||||
class OstpVpnService : VpnService() {
|
||||
|
||||
@Keep
|
||||
companion object {
|
||||
@Keep
|
||||
var isRunning = false
|
||||
@Keep
|
||||
var instance: OstpVpnService? = null
|
||||
|
||||
private const val NOTIF_ID = 1001
|
||||
private const val CHANNEL_ID = "ostp_vpn_channel"
|
||||
private const val WAKE_LOCK_TAG = "ostp:vpn_wakelock"
|
||||
|
||||
/**
|
||||
* Called by OstpClientSdk.notifyNetworkChanged() JNI thunk.
|
||||
*/
|
||||
@Keep
|
||||
@JvmStatic
|
||||
fun onNetworkChanged() {
|
||||
android.util.Log.d("OstpVpnService", "onNetworkChanged() signaled to Rust bridge")
|
||||
}
|
||||
}
|
||||
|
||||
private var vpnInterface: ParcelFileDescriptor? = null
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
private var networkCallback: android.net.ConnectivityManager.NetworkCallback? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
instance = this
|
||||
createNotificationChannel()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
val action = intent?.action
|
||||
if (action == "START") {
|
||||
val configJson = intent.getStringExtra("configJson") ?: return START_NOT_STICKY
|
||||
// Launch foreground immediately so Android doesn't kill us
|
||||
ServiceCompat.startForeground(this, NOTIF_ID, buildNotification(connecting = true), ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE)
|
||||
startVpn(configJson)
|
||||
} else if (action == "STOP") {
|
||||
stopVpn()
|
||||
}
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"OSTP VPN",
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
).apply {
|
||||
description = "OSTP VPN connection status"
|
||||
setShowBadge(false)
|
||||
}
|
||||
val nm = getSystemService(NotificationManager::class.java)
|
||||
nm.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildNotification(connecting: Boolean): Notification {
|
||||
val stopIntent = PendingIntent.getService(
|
||||
this,
|
||||
0,
|
||||
Intent(this, OstpVpnService::class.java).apply { action = "STOP" },
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
val openIntent = PendingIntent.getActivity(
|
||||
this,
|
||||
1,
|
||||
packageManager.getLaunchIntentForPackage(packageName)
|
||||
?.apply { addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) },
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
val (statusText, actionLabel) = if (connecting) {
|
||||
Pair("Подключение...", "Отмена")
|
||||
} else {
|
||||
Pair("Подключено", "Отключить")
|
||||
}
|
||||
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle("OSTP VPN")
|
||||
.setContentText(statusText)
|
||||
.setSmallIcon(android.R.drawable.ic_lock_lock)
|
||||
.setOngoing(true)
|
||||
.setShowWhen(false)
|
||||
.setContentIntent(openIntent)
|
||||
.addAction(android.R.drawable.ic_delete, actionLabel, stopIntent)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun updateNotification(connected: Boolean) {
|
||||
try {
|
||||
val nm = NotificationManagerCompat.from(this)
|
||||
nm.notify(NOTIF_ID, buildNotification(connecting = !connected))
|
||||
} catch (e: Throwable) {
|
||||
Log.e("OstpVpnService", "Failed to update notification", e)
|
||||
}
|
||||
// Refresh Quick Settings tile state
|
||||
OstpTileService.requestListeningState(applicationContext)
|
||||
}
|
||||
|
||||
private fun acquireWakeLock() {
|
||||
if (wakeLock == null) {
|
||||
val pm = getSystemService(POWER_SERVICE) as PowerManager
|
||||
wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG)
|
||||
wakeLock?.acquire(24 * 60 * 60 * 1000L) // Max 24h
|
||||
Log.d("OstpVpnService", "WakeLock acquired")
|
||||
}
|
||||
}
|
||||
|
||||
private fun releaseWakeLock() {
|
||||
try {
|
||||
wakeLock?.let {
|
||||
if (it.isHeld) it.release()
|
||||
}
|
||||
wakeLock = null
|
||||
Log.d("OstpVpnService", "WakeLock released")
|
||||
} catch (e: Throwable) {
|
||||
Log.e("OstpVpnService", "Error releasing WakeLock", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun registerNetworkCallback() {
|
||||
if (networkCallback != null) return
|
||||
try {
|
||||
val cm = getSystemService(android.content.Context.CONNECTIVITY_SERVICE) as android.net.ConnectivityManager
|
||||
networkCallback = object : android.net.ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: android.net.Network) {
|
||||
super.onAvailable(network)
|
||||
OstpClientSdk.notifyNetworkChanged()
|
||||
}
|
||||
override fun onLost(network: android.net.Network) {
|
||||
super.onLost(network)
|
||||
OstpClientSdk.notifyNetworkChanged()
|
||||
}
|
||||
}
|
||||
val request = android.net.NetworkRequest.Builder()
|
||||
.addCapability(android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
.build()
|
||||
cm.registerNetworkCallback(request, networkCallback!!)
|
||||
} catch (e: Throwable) {
|
||||
Log.e("OstpVpnService", "Failed to register NetworkCallback", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun unregisterNetworkCallback() {
|
||||
try {
|
||||
if (networkCallback != null) {
|
||||
val cm = getSystemService(android.content.Context.CONNECTIVITY_SERVICE) as android.net.ConnectivityManager
|
||||
cm.unregisterNetworkCallback(networkCallback!!)
|
||||
networkCallback = null
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Log.e("OstpVpnService", "Failed to unregister NetworkCallback", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startVpn(configJson: String) {
|
||||
if (vpnInterface != null) return
|
||||
|
||||
acquireWakeLock()
|
||||
|
||||
try {
|
||||
val json = org.json.JSONObject(configJson)
|
||||
val dnsServer = json.optString("dns_server", "1.1.1.1")
|
||||
val localProxy = json.optJSONObject("local_proxy")?.optString("bind_addr", "127.0.0.1:1088") ?: "127.0.0.1:1088"
|
||||
|
||||
val builder = Builder()
|
||||
.setSession("OSTP Tunnel")
|
||||
.addAddress("10.1.0.2", 24)
|
||||
.addAddress("fd00:1:fd00:1:fd00:1:fd00:1", 128)
|
||||
.addRoute("0.0.0.0", 0)
|
||||
.addRoute("::", 0)
|
||||
.addDnsServer(dnsServer)
|
||||
.setMtu(Math.max(1280, json.optJSONObject("ostp")?.optInt("mtu", 1140) ?: 1140))
|
||||
|
||||
// Always add fallback IPv4 DNS servers
|
||||
try { builder.addDnsServer("1.1.1.1") } catch (e: Throwable) {}
|
||||
try { builder.addDnsServer("8.8.8.8") } catch (e: Throwable) {}
|
||||
// NOTE: Do NOT add IPv6 DNS servers here — Android would send DNS
|
||||
// queries over IPv6, but our smoltcp TUN stack processes them as
|
||||
// IPv4 only, causing all DNS to silently fail on LTE (IPv6-only networks).
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
builder.allowBypass()
|
||||
}
|
||||
|
||||
try {
|
||||
builder.allowFamily(android.system.OsConstants.AF_INET)
|
||||
builder.allowFamily(android.system.OsConstants.AF_INET6)
|
||||
} catch (e: Throwable) { }
|
||||
|
||||
val appRules = json.optJSONObject("app_rules")
|
||||
val mode = appRules?.optString("mode", "bypass") ?: "bypass"
|
||||
val packages = appRules?.optJSONArray("packages")
|
||||
|
||||
if (mode == "proxy") {
|
||||
if (packages != null) {
|
||||
for (i in 0 until packages.length()) {
|
||||
val pkg = packages.getString(i)
|
||||
try {
|
||||
builder.addAllowedApplication(pkg)
|
||||
} catch (e: Throwable) {
|
||||
Log.e("OstpVpnService", "Failed to add allowed application $pkg: $e")
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
builder.addDisallowedApplication(applicationContext.packageName)
|
||||
} catch (e: Throwable) {
|
||||
Log.e("OstpVpnService", "Failed to disallow our own package: $e")
|
||||
}
|
||||
|
||||
if (packages != null) {
|
||||
for (i in 0 until packages.length()) {
|
||||
val pkg = packages.getString(i)
|
||||
try {
|
||||
builder.addDisallowedApplication(pkg)
|
||||
} catch (e: Throwable) {
|
||||
Log.e("OstpVpnService", "Failed to add disallowed application $pkg: $e")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
vpnInterface = builder.establish()
|
||||
val fd = vpnInterface?.fd ?: throw Exception("Failed to get VPN FD")
|
||||
|
||||
// CRITICAL: Clear O_CLOEXEC so the child process inherits the TUN file descriptor
|
||||
try {
|
||||
android.system.Os.fcntlInt(vpnInterface!!.fileDescriptor, android.system.OsConstants.F_SETFD, 0)
|
||||
} catch (e: Throwable) {
|
||||
Log.e("OstpVpnService", "Failed to clear O_CLOEXEC", e)
|
||||
}
|
||||
|
||||
val success = OstpClientSdk.startClient(configJson, fd, "", localProxy)
|
||||
if (success) {
|
||||
Log.i("OstpVpnService", "OSTP Rust Core started successfully")
|
||||
isRunning = true
|
||||
updateNotification(connected = true)
|
||||
} else {
|
||||
Log.e("OstpVpnService", "Failed to start OSTP Rust Core")
|
||||
stopVpn()
|
||||
}
|
||||
|
||||
} catch (e: Throwable) {
|
||||
Log.e("OstpVpnService", "Error starting VPN", e)
|
||||
android.os.Handler(android.os.Looper.getMainLooper()).post {
|
||||
android.widget.Toast.makeText(applicationContext, "VPN Error: ${e.message}", android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
stopVpn()
|
||||
}
|
||||
|
||||
registerNetworkCallback()
|
||||
}
|
||||
|
||||
private fun stopVpn() {
|
||||
isRunning = false
|
||||
releaseWakeLock()
|
||||
|
||||
try {
|
||||
OstpClientSdk.stopClient()
|
||||
vpnInterface?.close()
|
||||
vpnInterface = null
|
||||
} catch (e: IOException) {
|
||||
Log.e("OstpVpnService", "Error closing VPN interface", e)
|
||||
}
|
||||
|
||||
stopForeground(true)
|
||||
OstpTileService.requestListeningState(applicationContext)
|
||||
unregisterNetworkCallback()
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
instance = null
|
||||
stopVpn()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
package net.ostp.client
|
||||
|
||||
import androidx.annotation.Keep
|
||||
|
||||
@Keep
|
||||
object OstpClientSdk {
|
||||
init {
|
||||
System.loadLibrary("ostp_jni")
|
||||
}
|
||||
|
||||
@Keep
|
||||
@JvmStatic
|
||||
fun protectSocket(fd: Int): Boolean {
|
||||
var retries = 5
|
||||
while (retries > 0) {
|
||||
val service = com.ospab.ostp_client.OstpVpnService.instance
|
||||
if (service != null) {
|
||||
val res = service.protect(fd)
|
||||
android.util.Log.i("OstpClientSdk", "VpnService.protect(socketFd=$fd) -> success=$res")
|
||||
return res
|
||||
}
|
||||
android.util.Log.w("OstpClientSdk", "VpnService instance is null! Retrying... ($retries left)")
|
||||
Thread.sleep(200)
|
||||
retries--
|
||||
}
|
||||
android.util.Log.e("OstpClientSdk", "VpnService instance is null! Cannot protect socketFd=$fd")
|
||||
return false
|
||||
}
|
||||
|
||||
@Keep
|
||||
@JvmStatic
|
||||
external fun startClient(configJson: String, fd: Int, t2sBinPath: String, localProxy: String): Boolean
|
||||
|
||||
@Keep
|
||||
@JvmStatic
|
||||
external fun stopClient(): Boolean
|
||||
|
||||
@Keep
|
||||
@JvmStatic
|
||||
external fun getMetrics(): String
|
||||
|
||||
@Keep
|
||||
@JvmStatic
|
||||
external fun getLogs(): String
|
||||
|
||||
@Keep
|
||||
@JvmStatic
|
||||
external fun addLog(logMsg: String)
|
||||
|
||||
@Keep
|
||||
@JvmStatic
|
||||
external fun notifyNetworkChanged()
|
||||
}
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
|
@ -0,0 +1,12 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="?android:colorBackground" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
|
@ -0,0 +1,12 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@android:color/white" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
</adaptive-icon>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |