16 Commits

Author SHA1 Message Date
E. Kaparulin
7d96bdac7b chore: sync konduit-platform v0.1.0-beta.12 2026-10-06 18:31:54 +03:00
E. Kaparulin
7c24351f4a fix: correct the CLI examples for --use-proxy and --listen-socks
Both sections showed

  ./konduit connect --config client.toml --use-proxy ...

which cannot work. `--config` is declared on the top-level Args struct,
while `--use-proxy` and `--listen-socks` belong to the `connect`
subcommand, so clap rejects the combination outright:

  $ konduit connect --config client.toml --use-proxy socks5://...
  error: unexpected argument '--config' found
  $ konduit --config client.toml --use-proxy socks5://...
  error: unexpected argument '--use-proxy' found

Neither ordering works — an invocation has to pick one style: config file
(everything from the file) or `connect` subcommand (everything as flags).
Replaced with the full flag form and said so explicitly, since "or via the
CLI" read as though you could bolt a flag onto a config-file run.

Both replacement command lines were run against the built binary to
confirm they parse; they get as far as the connection attempt.
2026-09-30 11:59:41 +03:00
E. Kaparulin
4a4ab9d781 Remove a false key-storage claim; fix a broken CLI example and a stale artifact table
Three defects in the README, all found while cross-checking it against the
source and the published releases.

## 1. The key-storage claim was false, not merely wrong about platforms

"Private keys are stored in the OS secure enclave on every platform (iOS
Keychain, macOS Keychain, Android Keystore). They are never written to disk
in plaintext."

There is no OS-keychain integration in Konduit at all. Nothing in the Rust
workspace or the Flutter GUI references a keyring or secure-storage crate --
the GUI's only persistence dependency is shared_preferences. And
core/src/config.rs writes the client identity key straight into the config
file the docs tell you to pass with --config:

    client.toml:  identity_key = "<hex>"

so "never written to disk in plaintext" was false by construction, for the
one thing the claim was about. It also named two platforms that do not ship.

Replaced with what actually happens, and the consequence: the key is
generated locally, never sent to K-Ops, and the config file is itself the
secret -- so restrict its permissions, do not commit it, do not paste it
into a bug report. Deliberately not reworded into another claim I could not
verify.

## 2. The CLI example does not run

    ./konduit connect --config client.toml

--config is a top-level flag on Args (cli/src/main.rs:15), not on the
Connect subcommand, and it is not marked global, so clap rejects it:

    error: unexpected argument '--config' found

Fixed to `./konduit --config client.toml`, which is the form
docs/client-quickstart.md uses and the binary accepts. Documented the
`connect` subcommand's actual flags --server/--peer-id/--identity/
--server-pub -- checked against `konduit connect --help`.

## 3. The artifact table lists assets that are not in the current release

Checked against the Gitea releases API rather than the Makefile. v0.1.0-beta.11
ships five assets: the AppImage, the three Linux binaries, and
konduit-gui-...-windows-x86_64-portable.zip. It has no Windows CLI zip and
no Android AAB; beta.9 and beta.10 had both, and the Windows GUI zip was
only named -portable.zip in beta.11.

So the Windows GUI row is now the published name, and the two rows that
appear in some releases but not others are marked as such with the
variation spelled out. The Releases page is called authoritative, since
which assets ship is a per-release decision.

No claim here is reworded from assumption -- each one was checked against
the code or the published release first.
2026-09-30 11:38:48 +03:00
E. Kaparulin
3b212bbed9 docs(www): align support/vulnerability disclosure with koridor pattern 2026-07-31 12:42:51 +03:00
E. Kaparulin
11d5305d8c chore: sync konduit-platform v0.1.0-beta.11 2026-07-25 18:00:40 +03:00
E. Kaparulin
c2d7f8d8c6 chore: sync konduit-platform v0.1.0-beta.10 2026-07-23 18:44:50 +03:00
E. Kaparulin
678ed158a6 chore: sync konduit-platform 2026-07-23 18:33:05 +03:00
E. Kaparulin
56201f6b72 docs(public): fix README support section to match actual channels (contact form, no in-app reporting/issues) 2026-07-17 10:34:10 +03:00
E. Kaparulin
b160652737 chore: sync konduit-platform v0.1.0-beta.8 2026-07-16 14:39:57 +03:00
E. Kaparulin
7df5aa4317 chore: sync konduit-platform v0.1.0-beta.7 2026-07-10 07:42:05 +03:00
E. Kaparulin
99a2993ace docs(public): remove stealth-mode content, add SOCKS5 modes and connection tuning docs 2026-07-10 06:46:44 +03:00
E. Kaparulin
fb7ad7ec40 chore: bump version to 0.1.0-beta.7 2026-06-26 23:16:30 +03:00
E. Kaparulin
87bb99c10b fix: stealth=true implies TLS+WebSocket; update public docs 2026-06-26 23:13:22 +03:00
E. Kaparulin
ba8e2b0aa1 debug: log TUN read byte count 2026-06-26 19:19:20 +03:00
E. Kaparulin
e63d41c153 docs: rewrite stealth-setup for real-TLS HAProxy mode with architecture diagram 2026-06-25 12:32:36 +03:00
57249605fb Update README.md 2026-06-19 07:44:03 +00:00
15 changed files with 1312 additions and 257 deletions

View File

@@ -35,7 +35,7 @@ Most VPNs treat TCP as a fallback. Konduit is designed for TCP from the ground u
- **QR code provisioning** — scan once, connect instantly
- **Cross-platform** — Linux, Windows, Android (macOS and iOS coming soon)
- **Modern cryptography** — X25519 key exchange, ChaCha20-Poly1305 data channel
- **Stealth mode** — port 443 deployment with decoy proxy for hostile network environments
- **SOCKS5 chaining** — egress-dial (`use_proxy`) to reach the server through your own proxy, or a post-tunnel listener (`listen_socks`) for per-app routing — see [SOCKS Modes](docs/socks-modes.md)
- **Memory safe** — written entirely in Rust
## Download
@@ -48,9 +48,14 @@ Releases are published on the [Releases](../../releases) page. Each release incl
| `konduit-*-linux-x86_64` | Linux | CLI client |
| `konduit-ctl-*-linux-x86_64` | Linux | Server provisioning tool |
| `Konduit-*-x86_64.AppImage` | Linux | GUI client (AppImage) |
| `konduit-cli-*-windows-x86_64.zip` | Windows | CLI client + wintun.dll |
| `konduit-gui-*-windows-x86_64.zip` | Windows | GUI client |
| `konduit-*-android.aab` | Android | Android app (sideload via bundletool) |
| `konduit-gui-*-windows-x86_64-portable.zip` | Windows | GUI client |
| `konduit-cli-*-windows-x86_64.zip` | Windows | CLI client + wintun.dll — **not in every release** |
| `konduit-*-android.aab` | Android | Android app for bundletool sideload — **not in every release** |
Which Windows and Android assets are attached varies between releases: v0.1.0-beta.9
and v0.1.0-beta.10 carried both, v0.1.0-beta.11 shipped the Windows GUI zip only (as
`-portable.zip`). **The [Releases](../../releases) page is authoritative** — check it
rather than assuming a row is present. Android is on Google Play either way.
### Linux (CLI)
@@ -62,7 +67,15 @@ chmod +x konduit konduit-ctl
echo "your secret mantra" | ./konduit-ctl bootstrap -l vpn.example.com:443 -p -
# Connect a client
./konduit connect --config client.toml
./konduit --config client.toml
```
`--config`/`-c` is a top-level flag, not a `connect` flag. The `connect`
subcommand takes the peer details individually instead:
```bash
./konduit connect --server vpn.example.com:443 \
--peer-id <peer-id> --identity <key-hex> --server-pub <server-pub-hex>
```
### Linux (GUI)
@@ -80,7 +93,7 @@ Extract the zip and run `konduit.exe` (CLI) or `Konduit.exe` (GUI). `wintun.dll`
### Android
Download from [Google Play](https://play.google.com/store/apps/details?id=eu.kaparulin.konduit) or sideload the `.aab` using [bundletool](https://github.com/google/bundletool).
Download from [Google Play](https://play.google.com/store/apps/details?id=eu.k_ops.konduit) or sideload the `.aab` using [bundletool](https://github.com/google/bundletool).
### macOS · iOS
@@ -95,7 +108,8 @@ Coming soon.
**CLI & server**
- [Client Quickstart](docs/client-quickstart.md) — download, configure, connect, run as a systemd service
- [Server Quickstart](docs/server-quickstart.md) — install, provision, NAT setup for iptables and firewalld
- [Stealth Mode Setup](docs/stealth-setup.md) — HAProxy TCP passthrough + camouflage configuration
- [SOCKS5 Modes](docs/socks-modes.md) — reach the server through a proxy, or expose a local SOCKS5 listener
- [Connection Tuning](docs/connection-tuning.md) — opt-in connection pooling for unstable links
- [systemd units](docs/systemd/) — service files for konduit-server, konduit (client), and konduit-admin-ui
## Architecture
@@ -124,21 +138,19 @@ Konduit engine (Rust)
The [`konduit-platform`](./konduit-platform) crate is published here for transparency and security audit. It contains the cryptographic primitives, connection statistics, and platform networking layer (TUN device, DNS, routes) — everything an auditor needs to verify what runs on your machine. It is licensed under the [PolyForm Noncommercial License 1.0.0](LICENSE) — free to read, study, and use for noncommercial purposes.
The VPN server, management UI, and stealth-mode protocol are proprietary. Keeping stealth mechanisms private makes automated DPI fingerprinting significantly harder. Source review under NDA is available for enterprise partners.
The VPN server and management UI are proprietary. Source review under NDA is available for enterprise partners.
## Security
**No UDP dependency:** Konduit does not require UDP at any layer.
**Key storage:** Private keys are stored in the OS secure enclave on every platform (iOS Keychain, macOS Keychain, Android Keystore). They are never written to disk in plaintext.
**Stealth mode:** On port 443, failed or unrecognized handshakes are proxied transparently to a configurable decoy service. From the outside, the server is indistinguishable from a standard HTTPS endpoint.
**Key handling:** Your private identity key is generated locally by `konduit-ctl` and written into the client configuration file you pass with `--config` — it is never transmitted to K-Ops, and K-Ops operates no server. **That file is the secret.** Treat it like a private key file: readable only by its owner, and the only thing standing between an attacker and a tunnel into your VPN is its permissions. Do not commit it or paste it into a bug report.
## Support
**Bug reports:** Use the in-app reporting feature or open an issue in this repository.
**Bug reports:** file an issue on [Gitea Issues](https://git.k-ops.eu/konduit/konduit-public/issues).
**Security vulnerabilities:** Do not open a public issue. Contact the maintainer directly at the address shown in the application's About screen.
**Vulnerabilities:** report directly to konduit@k-ops.eu.
**Contributing:** Core development is handled internally. We do not currently accept external pull requests.

View File

@@ -18,8 +18,6 @@ sudo cp client.toml /opt/konduit/client.toml
sudo chmod 600 /opt/konduit/client.toml
```
If your server runs in stealth mode, the config already points to port 443. No additional client-side configuration is needed.
## 3. Connect
```bash

20
docs/connection-tuning.md Normal file
View File

@@ -0,0 +1,20 @@
# Connection Tuning
Konduit is a TCP-native VPN. On stable networks, a single TCP connection works fine. On unstable links (spotty Wi-Fi, mobile data, satellite), you can opt into a small pool of pre-warmed connections so a failing connection can be replaced without a full reconnect cascade.
## Config
```toml
[connection]
pool_enabled = false # default off; opt in for unstable links
pool_size = 3 # pre-warmed idle connections when enabled
carry_duration = 1.0 # seconds an active connection carries traffic before handoff
```
- `pool_enabled` — when `true`, the client keeps `pool_size` extra pre-authenticated connections warm, so an in-use connection can fail over instantly instead of paying a fresh TCP-plus-handshake cost.
- `pool_size` — number of pre-warmed idle connections to keep, in addition to the active one. Only used when `pool_enabled = true`.
- `carry_duration` — how long (in seconds) an active connection carries traffic before handoff to a pre-warmed one. Only used when `pool_enabled = true`.
This is a resilience feature, not a stealth feature — it exists purely to make konduit more tolerant of flaky links, and has no effect on how konduit's traffic looks to a network observer.
Server-side, sticky DL-target routing keeps your active connection selected until it actually fails (detected via a write timeout), rather than rotating on a fixed timer. This needs no client-side configuration.

View File

@@ -0,0 +1,62 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 620 415" width="620" height="415">
<defs>
<marker id="stealth-ah" markerWidth="8" markerHeight="8" refX="8" refY="3" orient="auto">
<path d="M0,0 L0,6 L8,3 z" fill="#546e7a"/>
</marker>
<marker id="stealth-ahb" markerWidth="8" markerHeight="8" refX="8" refY="3" orient="auto">
<path d="M0,0 L0,6 L8,3 z" fill="#1565c0"/>
</marker>
</defs>
<!-- Background -->
<rect width="620" height="415" rx="10" fill="#fafafa" stroke="#dee2e6" stroke-width="1"/>
<!-- Title -->
<text x="310" y="27" text-anchor="middle" font-family="monospace" font-size="14" font-weight="bold" fill="#263238">Stealth Mode: Real TLS Bypass via HAProxy</text>
<!-- ── Client ── -->
<rect x="185" y="42" width="250" height="62" rx="8" fill="#e0f7fa" stroke="#00838f" stroke-width="2"/>
<text x="310" y="68" text-anchor="middle" font-family="monospace" font-size="13" font-weight="bold" fill="#00695c">Client</text>
<text x="310" y="87" text-anchor="middle" font-family="monospace" font-size="11" fill="#00695c">stealth = true (or tls = true)</text>
<!-- Arrow: Client → HAProxy -->
<line x1="310" y1="104" x2="310" y2="150" stroke="#546e7a" stroke-width="2" marker-end="url(#stealth-ah)"/>
<!-- Arrow label (right side) -->
<rect x="318" y="109" width="148" height="34" rx="3" fill="#eceff1"/>
<text x="392" y="122" text-anchor="middle" font-family="monospace" font-size="10" fill="#37474f">TLS 1.3 · port 443</text>
<text x="392" y="136" text-anchor="middle" font-family="monospace" font-size="10" fill="#37474f">real cert (certbot)</text>
<!-- DPI callout (left side of arrow) -->
<text x="302" y="120" text-anchor="end" font-family="monospace" font-size="10" font-weight="bold" fill="#e65100">DPI/TSPU:</text>
<text x="302" y="134" text-anchor="end" font-family="monospace" font-size="10" fill="#e65100">sees valid HTTPS ✓</text>
<!-- ── HAProxy ── -->
<rect x="108" y="150" width="404" height="78" rx="8" fill="#e8f5e9" stroke="#2e7d32" stroke-width="2"/>
<text x="310" y="176" text-anchor="middle" font-family="monospace" font-size="13" font-weight="bold" fill="#1b5e20">HAProxy :443</text>
<text x="310" y="194" text-anchor="middle" font-family="monospace" font-size="11" fill="#1b5e20">TLS termination · inspect first byte of payload</text>
<text x="310" y="213" text-anchor="middle" font-family="monospace" font-size="10" fill="#388e3c">HTTP method bytes → web backend · binary → konduit</text>
<!-- Arrow: HAProxy → Web (down-left) -->
<line x1="208" y1="228" x2="168" y2="275" stroke="#78909c" stroke-width="1.5" marker-end="url(#stealth-ah)"/>
<text x="163" y="258" text-anchor="end" font-family="monospace" font-size="10" fill="#546e7a">HTTP</text>
<!-- Arrow: HAProxy → Konduit (down-right) -->
<line x1="412" y1="228" x2="452" y2="275" stroke="#1565c0" stroke-width="2" marker-end="url(#stealth-ahb)"/>
<text x="456" y="258" font-family="monospace" font-size="10" fill="#1565c0">binary</text>
<!-- ── Web backend ── -->
<rect x="78" y="275" width="184" height="58" rx="8" fill="#f5f5f5" stroke="#9e9e9e" stroke-width="1.5"/>
<text x="170" y="301" text-anchor="middle" font-family="monospace" font-size="12" font-weight="bold" fill="#424242">Web backend</text>
<text x="170" y="319" text-anchor="middle" font-family="monospace" font-size="10" fill="#616161">:8080 (nginx / any HTTP)</text>
<!-- ── Konduit ── -->
<rect x="358" y="275" width="184" height="58" rx="8" fill="#e3f2fd" stroke="#1565c0" stroke-width="2"/>
<text x="450" y="301" text-anchor="middle" font-family="monospace" font-size="12" font-weight="bold" fill="#0d47a1">Konduit</text>
<text x="450" y="319" text-anchor="middle" font-family="monospace" font-size="10" fill="#1565c0">:8443 plain TCP (stealth off)</text>
<!-- Arrow: Konduit → VPN -->
<line x1="450" y1="333" x2="450" y2="357" stroke="#1565c0" stroke-width="2" marker-end="url(#stealth-ahb)"/>
<!-- ── VPN Tunnel ── -->
<rect x="358" y="357" width="184" height="40" rx="8" fill="#1565c0"/>
<text x="450" y="382" text-anchor="middle" font-family="monospace" font-size="12" font-weight="bold" fill="#ffffff">VPN Tunnel</text>
</svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -51,7 +51,6 @@ Server-side connection logs are retained for a limited period for operational pu
- All traffic between client and server is encrypted using **X25519** key exchange and **ChaCha20-Poly1305** AEAD.
- The pre-shared key (PSK) is stored in the local config file with permissions restricted to the current user.
- Stealth mode wraps the tunnel in a protocol that is indistinguishable from HTTPS, preventing deep-packet inspection from identifying Konduit traffic.
## Children

View File

@@ -34,7 +34,7 @@ echo "your secret mantra phrase here" | ./konduit-ctl bootstrap -l vpn.example.c
echo "your secret mantra phrase here" | ./konduit-ctl bootstrap -l vpn.example.com:8443 --public-port 443 -p -
```
`--public-port` sets the port written into client configs, so they connect to 443 even though konduit listens on 8443. See [stealth-setup.md](stealth-setup.md) for the full HAProxy configuration.
`--public-port` sets the port written into client configs, so they connect to 443 even though konduit listens on 8443 — useful when running behind any TCP-passthrough reverse proxy (e.g. HAProxy, nginx `stream` module).
## 3. Add a Client
@@ -102,8 +102,8 @@ sudo apt install iptables-persistent && sudo netfilter-persistent save
## 7. Verify
```bash
# Should return your website (camouflage) not an error
curl -sk https://your-server/
# Connect a client and confirm the tunnel comes up
./konduit --config client.toml
# Check konduit logs
journalctl -u konduit-server -f

62
docs/socks-modes.md Normal file
View File

@@ -0,0 +1,62 @@
# SOCKS5 Modes
Konduit is a VPN for reaching your own resources over the public internet — it is not a censorship-circumvention tool, and it doesn't try to disguise its traffic. If you need to get through a network that's actively blocking or fingerprinting VPN traffic, that's a job for a dedicated, purpose-built tool — an SSH SOCKS proxy (`ssh -D`), Xray, sing-box, or similar — not something konduit reimplements.
What konduit does provide is standard SOCKS5 support on both sides of the connection, so you can chain it with whichever of those tools you already trust.
## Egress-dial: reaching the server through a proxy (`use_proxy`)
If you already have a SOCKS5 proxy that gets you out of a restrictive network (for example `ssh -D 1080 jumphost`), point konduit at it instead of dialing the server directly:
```toml
[client]
server_endpoint = "vpn.example.com:443"
use_proxy = "socks5://127.0.0.1:1080"
```
Or, if you are passing everything on the command line rather than via a config file:
```bash
./konduit connect \
--server vpn.example.com:443 \
--peer-id "$PEER_ID" \
--identity "$IDENTITY_HEX" \
--server-pub "$SERVER_PUB_HEX" \
--use-proxy socks5://127.0.0.1:1080
```
`--config` and `--use-proxy` cannot be combined. `--config` is a top-level flag, while `--use-proxy` belongs to the `connect` subcommand, so a single invocation has to pick one style. Mixing them fails with `error: unexpected argument '--use-proxy' found`.
Konduit dials the SOCKS5 proxy, asks it to `CONNECT` to your server, and then runs its normal protocol over that connection — the proxy is otherwise transparent to it. If the proxy is unreachable, or requires authentication konduit doesn't support, the connection attempt fails immediately; there is no silent fallback to a direct connection.
## Listener: exposing a SOCKS5 proxy after the tunnel is up (`listen_socks`)
Once connected, konduit can also expose a local SOCKS5 listener so other tools (browsers, curl, Xray outbounds) can route traffic through the tunnel without needing their own TUN-level integration:
```toml
[client]
listen_socks = "127.0.0.1:1080"
```
Or via the `connect` subcommand, as above — `--listen-socks` takes a bare address:
```bash
./konduit connect \
--server vpn.example.com:443 \
--peer-id "$PEER_ID" \
--identity "$IDENTITY_HEX" \
--server-pub "$SERVER_PUB_HEX" \
--listen-socks 127.0.0.1:1080
```
Connections accepted by the listener are plain outbound TCP connections, routed through the tunnel by the kernel's routing table — the same routes any other tunnelled traffic uses. The SOCKS code itself has no VPN-specific logic.
Notes:
- SOCKS5 only, `CONNECT` command only — no `BIND`, no `UDP ASSOCIATE`.
- No authentication — bind it to localhost (the default) unless you understand the exposure of doing otherwise.
- If no address is given, konduit falls back to `127.0.0.1:1080`. Override the fallback itself with the `KONDUIT_SOCKS_LISTEN_ADDR` environment variable.
## Using both together
`use_proxy` and `listen_socks` are independent and can be combined — for example, dial out through an SSH SOCKS proxy to reach your server, and also expose a local SOCKS5 listener once connected for other apps to use.

View File

@@ -1,105 +0,0 @@
# Stealth Mode: HAProxy + Konduit
Konduit's stealth mode makes the VPN server indistinguishable from a normal HTTPS site. It implements a fake TLS handshake — embedding the client's ephemeral key inside the TLS `SessionID` field. For this to work, **konduit must see the raw TCP stream from the client**. Any proxy that terminates TLS before konduit breaks stealth mode.
## Architecture
```
Client
│ raw TCP (looks like TLS to observers)
▼
HAProxy :443 ── TCP passthrough ──► konduit-server :8443
│
valid handshake │ handshake fails (real browser)
│ ▼
│ HAProxy :4443 (SSL termination)
│ │
│ ▼
│ real HTTPS backend
VPN tunnel
```
## HAProxy Configuration
```haproxy
# VPN ingress — raw TCP passthrough, no SSL termination
frontend vpn-ingress
bind *:443
mode tcp
option tcplog
default_backend konduit-vpn
backend konduit-vpn
mode tcp
server konduit 127.0.0.1:8443
# Camouflage backend — receives failed handshakes from konduit via PROXY protocol
# SSL is terminated here, not on the ingress
frontend camouflage
bind *:4443 ssl crt /etc/haproxy/ssl/your-domain.pem accept-proxy
mode http
http-request set-header X-Real-IP %[src]
http-request set-header X-Forwarded-Proto https
default_backend web
backend web
mode http
server web1 127.0.0.1:80 check
```
## Konduit server.toml
```toml
[server]
listen_addr = "0.0.0.0"
listen_port = 8443
public_addr = "your-domain.com"
public_port = 443 # port clients connect to (HAProxy front)
[stealth]
enabled = true
camouflage = "127.0.0.1:4443" # where to proxy non-konduit connections
```
Bootstrap with `--public-port` so generated client configs reference port 443:
```bash
echo "your mantra" | ./konduit-ctl bootstrap -l your-domain.com:8443 --public-port 443 -p -
```
## How It Works
**Konduit client (stealth handshake):**
1. Client sends fake TLS ClientHello with identity proof embedded
2. HAProxy passes raw TCP to konduit on port 8443
3. Konduit verifies identity, completes handshake, establishes VPN tunnel
4. Traffic looks like TLS Application Data to any observer
**Real browser or censor probe:**
1. Browser sends a real TLS ClientHello
2. HAProxy passes it raw to konduit
3. Konduit cannot verify identity — proxies the connection to `127.0.0.1:4443` with a PROXY protocol header preserving the real client IP
4. HAProxy at 4443 terminates TLS and serves your website
5. Observer sees a normal HTTPS site
## Common Mistakes
```
# WRONG — TLS terminated by HAProxy before konduit, fake handshake never works
Client → HAProxy SSL termination → konduit
# CORRECT — raw TCP passed through, konduit handles the fake TLS
Client → HAProxy TCP passthrough → konduit → HAProxy SSL termination (on failure)
```
The camouflage frontend uses `accept-proxy` — do not use it as the VPN ingress.
## Verify
```bash
# Browser should see your real website, not an error
curl -sk https://your-domain.com/
# Check konduit logs for stealth handshakes
journalctl -u konduit-server -f | grep -E "Stealth|Authenticated|camouflage"
```

View File

@@ -31,6 +31,31 @@ trait Resolve1Manager {
fn revert_link(&self, ifindex: i32) -> zbus::Result<()>;
}
// Per-link object (/org/freedesktop/resolve1/link/_<ifindex>), used only to
// read back the physical interface's current DNS servers before we override
// them, so they can be restored byte-for-byte on disconnect.
//
// SetLinkDefaultRoute (the "correct" API for demoting a link as a default DNS
// route) requires interactive polkit auth (auth_admin_keep) — unlike
// SetLinkDNS/SetLinkDomains, it does NOT get a CAP_NET_ADMIN bypass in
// systemd-resolved, so it silently fails for this capability-only (non-root)
// process. Reusing SetLinkDNS on the physical link's own object instead stays
// on the bypass path.
#[zbus::proxy(interface = "org.freedesktop.resolve1.Link", default_service = "org.freedesktop.resolve1")]
trait Resolve1Link {
#[zbus(property, name = "DNS")]
fn dns(&self) -> zbus::Result<Vec<(i32, Vec<u8>)>>;
}
fn link_object_path(if_index: u32) -> String {
let mut path = String::from("/org/freedesktop/resolve1/link/");
for c in if_index.to_string().chars() {
path.push_str("_3");
path.push(c);
}
path
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
@@ -38,6 +63,7 @@ trait Resolve1Manager {
pub async fn set_dns(
interface: &str,
if_index: Option<u32>,
physical_if_index: Option<u32>,
dns_servers: &[IpAddr],
_assigned_ip: IpAddr,
state: &mut DnsState,
@@ -59,6 +85,31 @@ pub async fn set_dns(
match result {
Ok(_) => {
state.configured = true;
// Both the physical link and the tunnel link end up with
// Default Route: yes in systemd-resolved (the physical link
// never loses that flag just because another link gains it).
// With two "default route" resolvers, systemd-resolved can
// race queries between the VPN's DNS server and the ISP's,
// leaking lookups outside the tunnel and returning
// inconsistent/partial answers (e.g. AAAA-only responses).
// Blank out the physical link's own DNS server list so it has
// nothing to answer with, leaving the tunnel as the sole
// practical resolver; its original servers are saved and
// restored on disconnect.
if let Some(phys_idx) = physical_if_index {
match clear_physical_link_dns(phys_idx).await {
Ok(original) => {
state.demoted_physical_if_index = Some(phys_idx);
state.physical_dns_backup = original;
}
Err(e) => {
warn!(
"Failed to clear physical interface {} DNS servers: {}",
phys_idx, e
);
}
}
}
}
Err(e) => {
warn!(
@@ -100,14 +151,28 @@ pub async fn restore(
info!("Restoring DNS settings...");
let result = match if_index {
Some(idx) => revert_link_dbus(idx).await,
None => revert_resolvectl(interface).await,
};
if let Err(e) = result {
warn!("D-Bus DNS revert failed ({}), trying resolvectl", e);
// Always attempt both paths: D-Bus RevertLink clears per-link config in
// systemd-resolved's in-memory state; resolvectl revert is a belt-and-suspenders
// pass that ensures the +DefaultRoute domain is removed even if D-Bus races
// with interface teardown.
if let Some(idx) = if_index {
if let Err(e) = revert_link_dbus(idx).await {
warn!("D-Bus RevertLink({}) failed: {}", idx, e);
}
}
// Always run resolvectl revert as a second pass.
revert_resolvectl(interface).await.ok();
// Restore the physical interface's original DNS servers we blanked out
// in set_dns, or it's left with no resolver after the VPN disconnects.
if let Some(phys_idx) = state.demoted_physical_if_index.take() {
let backup = std::mem::take(&mut state.physical_dns_backup);
if let Err(e) = restore_physical_link_dns(phys_idx, backup).await {
warn!(
"Failed to restore physical interface {} DNS servers: {}",
phys_idx, e
);
}
}
state.configured = false;
@@ -163,6 +228,62 @@ async fn set_dns_dbus(if_index: u32, dns_servers: &[IpAddr]) -> Result<()> {
Ok(())
}
/// Reads the physical link's current DNS servers, then blanks them out so it
/// stops acting as a competing resolver. Returns the original list to restore
/// later. Both the read and the clear go through the same CAP_NET_ADMIN
/// bypass path as the tunnel's own SetLinkDNS call.
async fn clear_physical_link_dns(if_index: u32) -> Result<Vec<(i32, Vec<u8>)>> {
let conn = zbus::Connection::system()
.await
.context("Failed to connect to D-Bus system bus")?;
let link_proxy = Resolve1LinkProxy::builder(&conn)
.path(link_object_path(if_index))
.context("Invalid link object path")?
.build()
.await
.context("Failed to create resolve1 link proxy")?;
let original = link_proxy
.dns()
.await
.context("Failed to read physical link DNS servers")?;
let manager_proxy = Resolve1ManagerProxy::new(&conn)
.await
.context("Failed to create resolve1 proxy")?;
manager_proxy
.set_link_dns(if_index as i32, vec![])
.await
.context("SetLinkDNS (clear) failed")?;
info!(
"Cleared physical interface {} DNS servers ({} saved for restore)",
if_index,
original.len()
);
Ok(original)
}
async fn restore_physical_link_dns(if_index: u32, original: Vec<(i32, Vec<u8>)>) -> Result<()> {
let conn = zbus::Connection::system()
.await
.context("Failed to connect to D-Bus system bus")?;
let proxy = Resolve1ManagerProxy::new(&conn)
.await
.context("Failed to create resolve1 proxy")?;
proxy
.set_link_dns(if_index as i32, original)
.await
.context("SetLinkDNS (restore) failed")?;
info!("Restored physical interface {} DNS servers", if_index);
Ok(())
}
async fn revert_link_dbus(if_index: u32) -> Result<()> {
let conn = zbus::Connection::system()
.await
@@ -172,6 +293,14 @@ async fn revert_link_dbus(if_index: u32) -> Result<()> {
.await
.context("Failed to create resolve1 proxy")?;
// Clear DNS servers and routing domains explicitly before the full revert.
// RevertLink alone can race with interface teardown in systemd-resolved;
// zeroing each setting first ensures they are gone even if RevertLink races.
let _ = proxy.set_link_dns(if_index as i32, vec![]).await;
let _ = proxy
.set_link_domains(if_index as i32, vec![])
.await;
proxy
.revert_link(if_index as i32)
.await

View File

@@ -16,6 +16,9 @@ pub struct DnsManager {
interface: String,
/// Interface index (if available/relevant)
if_index: Option<u32>,
/// Pre-VPN default-gateway interface index, demoted as DNS default route
/// while connected (Linux only)
physical_if_index: Option<u32>,
/// IP address assigned to the TUN interface
assigned_ip: IpAddr,
/// State to track if we modified DNS
@@ -26,14 +29,29 @@ pub struct DnsManager {
struct DnsState {
configured: bool,
_original_dns: Vec<IpAddr>,
// Physical interface demoted from systemd-resolved's default DNS route
// (Linux only) while the tunnel is the default; restored on disconnect.
demoted_physical_if_index: Option<u32>,
// Physical interface's original DNS servers (address_family, raw_bytes),
// saved before blanking them out; restored verbatim on disconnect.
physical_dns_backup: Vec<(i32, Vec<u8>)>,
}
impl DnsManager {
/// Create a new DNS manager for a specific interface
pub fn new(interface: String, if_index: Option<u32>, assigned_ip: IpAddr) -> Self {
/// Create a new DNS manager for a specific interface.
/// `physical_if_index` is the pre-VPN default-gateway interface (Linux
/// only) — needed so the tunnel can become the sole default DNS
/// resolver instead of racing systemd-resolved queries against it.
pub fn new(
interface: String,
if_index: Option<u32>,
physical_if_index: Option<u32>,
assigned_ip: IpAddr,
) -> Self {
Self {
interface,
if_index,
physical_if_index,
assigned_ip,
state: Arc::new(Mutex::new(DnsState::default())),
}
@@ -51,7 +69,7 @@ impl DnsManager {
// Platform specific implementation
#[cfg(target_os = "linux")]
{
linux::set_dns(&self.interface, self.if_index, dns_servers, self.assigned_ip, &mut state).await?;
linux::set_dns(&self.interface, self.if_index, self.physical_if_index, dns_servers, self.assigned_ip, &mut state).await?;
}
#[cfg(target_os = "windows")]

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,12 @@ pub struct MacOsNetManager {
server_host: String,
original_gateway: String,
primary_service: String,
// Physical interface the server host route must be pinned to via -ifscope.
// Without this, if the VPN's assigned tun subnet happens to contain the
// physical gateway IP, the OS can resolve the gateway via the tun
// interface's own directly-connected route instead of the physical NIC,
// looping the server route back into the tunnel it's meant to bypass.
wifi_iface: String,
}
impl MacOsNetManager {
@@ -42,13 +48,18 @@ impl MacOsNetManager {
server_host,
original_gateway,
primary_service,
wifi_iface: iface,
})
}
// Always adds split-default (0/1 + 128/1) for full-tunnel mode.
// Split-tunnel (per-route from ServerConfig) is not yet supported on macOS.
pub async fn setup_vpn_routes(&self) -> Result<()> {
run("route", &["add", "-host", &self.server_host, &self.original_gateway]).await;
run(
"route",
&["add", "-host", &self.server_host, &self.original_gateway, "-ifscope", &self.wifi_iface],
)
.await;
run("route", &["add", "-net", "0.0.0.0/1", "-interface", &self.tun_name]).await;
run("route", &["add", "-net", "128.0.0.0/1", "-interface", &self.tun_name]).await;
info!("macOS VPN routes added via {}", self.tun_name);
@@ -89,7 +100,11 @@ impl MacOsNetManager {
// the wrong gateway until the next reconnect that creates a new MacOsNetManager.
pub async fn ensure_server_route(&self) -> Result<()> {
run("route", &["delete", "-host", &self.server_host]).await;
run("route", &["add", "-host", &self.server_host, &self.original_gateway]).await;
run(
"route",
&["add", "-host", &self.server_host, &self.original_gateway, "-ifscope", &self.wifi_iface],
)
.await;
Ok(())
}

View File

@@ -13,6 +13,12 @@ pub struct RouteManager {
tun_routes: Vec<(Ipv4Addr, u8)>,
server_ip: Option<Ipv4Addr>,
wifi_gateway: Option<Ipv4Addr>,
// Physical interface name the server host route must be pinned to. Without
// this, if the VPN's assigned tun subnet happens to contain the physical
// gateway IP, `route add`'s gateway-only resolution can pick the tun
// interface instead of the physical NIC, looping the server route back
// into the tunnel it's meant to bypass.
wifi_iface: Option<String>,
}
impl RouteManager {
@@ -21,23 +27,30 @@ impl RouteManager {
dns_manager: DnsManager::new(
tun_interface.clone(),
None,
None,
IpAddr::V4(Ipv4Addr::UNSPECIFIED),
),
tun_interface,
tun_routes: Vec::new(),
server_ip: None,
wifi_gateway: None,
wifi_iface: None,
})
}
fn get_default_gateway() -> Result<Ipv4Addr> {
/// Returns the physical default gateway and the friendly name of the
/// adapter it's reachable on. The interface name must be used to pin any
/// route meant to bypass the tunnel — relying on gateway-only resolution
/// lets `route add` route it via the TUN adapter instead if the VPN's
/// assigned subnet happens to contain this gateway IP.
fn get_default_gateway() -> Result<(Ipv4Addr, String)> {
let adapters = ipconfig::get_adapters()
.map_err(|e| anyhow::anyhow!("Failed to enumerate adapters: {}", e))?;
for adapter in &adapters {
for gw in adapter.gateways() {
if let IpAddr::V4(gw4) = gw {
if !gw4.is_loopback() && !gw4.is_unspecified() {
return Ok(*gw4);
return Ok((*gw4, adapter.friendly_name().to_string()));
}
}
}
@@ -45,29 +58,26 @@ impl RouteManager {
anyhow::bail!("No default gateway found")
}
fn prefix_to_mask(prefix: u8) -> Ipv4Addr {
let bits = if prefix == 0 {
0u32
} else {
!0u32 << (32 - prefix)
};
let [a, b, c, d] = bits.to_be_bytes();
Ipv4Addr::new(a, b, c, d)
}
// Add/delete server host route through WiFi gateway via `route` command.
// Specifying gateway on delete ensures we only remove our entry, not any other path.
fn wifi_route_add(dest: Ipv4Addr, prefix: u8, gateway: Ipv4Addr) {
let mask = Self::prefix_to_mask(prefix);
let out = std::process::Command::new("route")
// Add/delete the server host route through the physical WiFi/Ethernet
// interface, pinned explicitly by adapter name via `netsh` rather than
// `route add`'s gateway-only resolution — same rationale and technique as
// `tun_route_add` below: without an explicit interface, an IP collision
// between the VPN's assigned tun subnet and the physical gateway can make
// this route resolve onto the TUN adapter instead, looping the server
// route back into the tunnel it's meant to bypass.
fn wifi_route_add(iface: &str, dest: Ipv4Addr, prefix: u8, gateway: Ipv4Addr) {
let prefix_str = format!("{}/{}", dest, prefix);
let out = std::process::Command::new("netsh")
.args([
"interface",
"ipv4",
"add",
&dest.to_string(),
"MASK",
&mask.to_string(),
&gateway.to_string(),
"METRIC",
"1",
"route",
&prefix_str,
iface,
&format!("nexthop={}", gateway),
"metric=1",
"store=active",
])
.creation_flags(CREATE_NO_WINDOW)
.output();
@@ -76,25 +86,19 @@ impl RouteManager {
let stdout = String::from_utf8_lossy(&o.stdout);
if !o.status.success() {
// "already exists" is acceptable — a previous run may have left it
warn!("route add {}/{} via {}: {}", dest, prefix, gateway, stdout.trim());
warn!("netsh add route {}/{} via {} on {}: {}", dest, prefix, gateway, iface, stdout.trim());
}
}
Err(e) => warn!("route add {}/{} via {}: {}", dest, prefix, gateway, e),
Err(e) => warn!("netsh add route {}/{} via {} on {}: {}", dest, prefix, gateway, iface, e),
}
}
fn wifi_route_delete(dest: Ipv4Addr, prefix: u8, gateway: Ipv4Addr) {
let mask = Self::prefix_to_mask(prefix);
let _ = std::process::Command::new("route")
.args([
"delete",
&dest.to_string(),
"MASK",
&mask.to_string(),
&gateway.to_string(),
])
fn wifi_route_delete(iface: &str, dest: Ipv4Addr, prefix: u8) {
let prefix_str = format!("{}/{}", dest, prefix);
let _ = std::process::Command::new("netsh")
.args(["interface", "ipv4", "delete", "route", &prefix_str, iface])
.creation_flags(CREATE_NO_WINDOW)
.status();
.output();
}
// Add/delete routes through the TUN interface using netsh.
@@ -169,8 +173,9 @@ impl RouteManager {
) -> Result<()> {
info!("Setting up Windows VPN routes...");
let current_gateway = Self::get_default_gateway()?;
info!("Current gateway: {}", current_gateway);
let (current_gateway, wifi_iface) = Self::get_default_gateway()?;
info!("Current gateway: {} (interface {})", current_gateway, wifi_iface);
self.wifi_iface = Some(wifi_iface.clone());
let server_ip: Ipv4Addr = {
let host = server_addr.split(':').next().unwrap_or(server_addr);
@@ -189,7 +194,7 @@ impl RouteManager {
// even if the add below fails because the route already exists.
self.server_ip = Some(server_ip);
self.wifi_gateway = Some(current_gateway);
Self::wifi_route_add(server_ip, 32, current_gateway);
Self::wifi_route_add(&wifi_iface, server_ip, 32, current_gateway);
let is_full_tunnel = routes.iter().any(|r| r == "0.0.0.0/0");
@@ -233,7 +238,7 @@ impl RouteManager {
if dns_servers.is_empty() {
return Ok(());
}
self.dns_manager = DnsManager::new(self.tun_interface.clone(), None, assigned_ip);
self.dns_manager = DnsManager::new(self.tun_interface.clone(), None, None, assigned_ip);
self.dns_manager.set_dns(dns_servers).await
}
@@ -242,8 +247,10 @@ impl RouteManager {
}
pub async fn ensure_server_route(&mut self) -> Result<()> {
if let (Some(server_ip), Some(gateway)) = (self.server_ip, self.wifi_gateway) {
Self::wifi_route_add(server_ip, 32, gateway);
if let (Some(server_ip), Some(gateway), Some(iface)) =
(self.server_ip, self.wifi_gateway, self.wifi_iface.as_deref())
{
Self::wifi_route_add(iface, server_ip, 32, gateway);
}
Ok(())
}
@@ -280,8 +287,9 @@ impl RouteManager {
}
// Always clean up the server host route — a previous unclean exit may have
// left it behind even if our add was skipped.
if let (Some(server_ip), Some(gateway)) = (self.server_ip.take(), self.wifi_gateway.take()) {
Self::wifi_route_delete(server_ip, 32, gateway);
self.wifi_gateway.take();
if let (Some(server_ip), Some(iface)) = (self.server_ip.take(), self.wifi_iface.take()) {
Self::wifi_route_delete(&iface, server_ip, 32);
}
info!("Windows VPN routes removed");
Ok(())

View File

@@ -247,35 +247,6 @@ mod tests {
assert_eq!(stats.download_bytes, 2048);
}
#[test]
fn test_speed_calculation() {
let tracker = StatsTracker::new("utilbox.eu:8443".to_string(), "test".to_string());
// Record some traffic
tracker.record_upload(1024);
tracker.record_download(2048);
// Update to calculate speed
tracker.update();
let stats = tracker.get_stats(false);
assert_eq!(stats.upload_speed, 1024);
assert_eq!(stats.download_speed, 2048);
// Record more traffic
tracker.record_upload(512);
tracker.record_download(1024);
// Update again
tracker.update();
let stats = tracker.get_stats(false);
assert_eq!(stats.upload_bytes, 1536);
assert_eq!(stats.download_bytes, 3072);
assert_eq!(stats.upload_speed, 512);
assert_eq!(stats.download_speed, 1024);
}
#[test]
fn test_ring_buffer() {
let mut buffer = RingBuffer::new();

View File

@@ -200,6 +200,35 @@ impl TunDevice {
loop {
tokio::select! {
biased;
// Server→TUN first: deliver incoming VPN traffic immediately.
//
// Deliberately NOT write_all(): a TUN character device requires each
// write() syscall to contain exactly one complete packet. write_all()
// retries a short write by sending the *remaining* bytes in a follow-up
// write() call -- correct for stream sockets, but for a TUN device that
// turns one packet into two malformed ones (a truncated first packet,
// plus a bogus "packet" made of leftover bytes with no valid IP header).
// A single write() either succeeds atomically or it doesn't; there is no
// safe way to "continue" a partial packet write, so treat anything short
// of a full write as a dropped packet, not a retry target.
Some(packet) = from_tcp_rx.recv() => {
match writer.write(&packet).await {
Ok(n) if n == packet.len() => {}
Ok(n) => {
error!(
"TUN short write: wrote {} of {} bytes -- packet dropped (TUN writes must be atomic per-packet)",
n, packet.len()
);
}
Err(e) => {
error!("TUN write error: {}", e);
}
}
}
// TUN→TCP: forward outgoing packets from the kernel.
packet = tun_pkt_rx.recv() => {
match packet {
Some(pkt) => {
@@ -213,11 +242,6 @@ impl TunDevice {
}
}
}
Some(packet) = from_tcp_rx.recv() => {
if let Err(e) = writer.write_all(&packet).await {
error!("TUN write error: {}", e);
}
}
}
}