Skip to content

Commit 0663eed

Browse files
authored
Merge pull request #244 from compscidr/feature/linux-local-demo
Add a runnable local Linux demo (server + client + curl)
2 parents 98a7818 + 2377707 commit 0663eed

6 files changed

Lines changed: 258 additions & 13 deletions

File tree

README.md

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,116 @@ val response = kanonProxy.takeResponse()
9494

9595
There are more examples of usage in the [tests](core/src/test/kotlin/com/jasonernst/kanonproxy).
9696

97+
## Local Linux demo
98+
99+
An end-to-end demo on a single Linux host: a kanonproxy server, a kanonproxy
100+
client tunneling a TUN device to that server, and a `curl` issued through the
101+
proxy.
102+
103+
[![Local Linux demo](https://img.youtube.com/vi/_ypo_3PYqTM/maxresdefault.jpg)](https://youtu.be/_ypo_3PYqTM)
104+
105+
(Click to watch on YouTube — the steps below produce what the video shows.)
106+
107+
Prerequisites:
108+
- Linux with `iproute2` and `sudo` (TUN setup and `--interface` both need root)
109+
- JDK 21 (the Gradle wrapper handles Gradle itself)
110+
- `./gradlew :server:assemble :client:assemble` succeeds
111+
112+
### Path A — scripted (one-shot)
113+
114+
```bash
115+
bash client/scripts/demo.sh # uses 1.1.1.1 as the curl target
116+
bash client/scripts/demo.sh 9.9.9.9 # or pick your own HTTP-reachable IP
117+
```
118+
119+
The script will:
120+
1. Run `client/scripts/tuntap.sh` to create the persistent `kanon` TUN device
121+
(`10.0.1.1/24`, MTU 1024).
122+
2. Start the proxy server with `./gradlew :server:run --args="8080"` (UDP
123+
listener on port 8080) — logs to `build/demo-logs/server.log`.
124+
3. Start the proxy client with `./gradlew :client:run --args="127.0.0.1 8080"`
125+
logs to `build/demo-logs/client.log`.
126+
4. Run `sudo curl -v --interface kanon http://<target>/`. `--interface kanon`
127+
uses `SO_BINDTODEVICE` to pin curl's socket to the TUN, so curl's packets
128+
go into the proxy without touching the kernel's main route table. The
129+
server's own outbound TCP socket stays unbound and follows the normal
130+
default route — that's what prevents the server from looping back into
131+
its own VPN.
132+
133+
The server and client stay running after the script finishes, so you can fire
134+
more requests against the same proxy:
135+
```bash
136+
sudo curl -v --interface kanon http://example.com/
137+
sudo curl -v --interface kanon http://1.1.1.1/
138+
```
139+
Each call creates a new session — look for `New session: ...` lines in
140+
`tail -f build/demo-logs/server.log`.
141+
142+
Tear-down:
143+
```bash
144+
bash client/scripts/cleanup.sh
145+
```
146+
This SIGTERMs the server/client/Gradle workers (then SIGKILLs anything left),
147+
retries `ip tuntap del` to handle any fd-release race, and removes the TUN
148+
interface. It's idempotent — safe to rerun.
149+
150+
### Path B — manual (4 terminals)
151+
152+
Use this if you want to see each piece's output live, or to debug a failure
153+
from path A.
154+
155+
**Terminal 1 — TUN device:**
156+
```bash
157+
bash client/scripts/tuntap.sh "$USER"
158+
ip addr show kanon # expect: kanon, inet 10.0.1.1/24, MTU 1024
159+
```
160+
161+
**Terminal 2 — server:**
162+
```bash
163+
./gradlew :server:run --args="8080"
164+
# expect log: "Server listening on default port: 8080"
165+
# verify in another shell: ss -lun | grep 8080
166+
```
167+
168+
**Terminal 3 — client:**
169+
```bash
170+
./gradlew :client:run --args="127.0.0.1 8080"
171+
# expect logs: "Opened TUN/TAP device" and "Created TUN/TAP device"
172+
```
173+
174+
**Terminal 4 — curl through the proxy:**
175+
```bash
176+
sudo curl -v --max-time 15 --interface kanon http://1.1.1.1/
177+
```
178+
Success looks like a real HTTP response from `1.1.1.1` (almost certainly a
179+
`301 Moved Permanently` to HTTPS — that proves the round trip).
180+
181+
Tear-down:
182+
```bash
183+
# Ctrl-C terminal 3 (client), then terminal 2 (server)
184+
bash client/scripts/cleanup.sh
185+
ip link show kanon || echo "kanon gone"
186+
```
187+
If cleanup ever reports `kanon interface is still present`, run
188+
`sudo lsof /dev/net/tun` to find what's still holding the fd.
189+
190+
### Watching packets while the demo runs
191+
192+
`kanon` is a regular kernel interface, so on Linux the easiest capture is
193+
plain libpcap on the device itself. The in-process pcap-ng dumpers
194+
([packetdumper](https://github.com/compscidr/packetdumper)) are also exposed
195+
in case you want to see the proxy's egress side (and they're what the Android
196+
app uses where you can't `tcpdump` the VPN interface from your laptop):
197+
198+
```bash
199+
sudo wireshark -k -i kanon # client/TUN leg, native libpcap
200+
wireshark -k -i TCP@127.0.0.1:19000 # client-side in-process dumper (same packets)
201+
wireshark -k -i TCP@127.0.0.1:19001 # server-side dumper (proxy's outbound to public Internet)
202+
```
203+
204+
See [Debugging with Wireshark](#debugging-with-wireshark) below for more on
205+
the in-process dumpers.
206+
97207
## Debugging with Wireshark
98208

99209
Both the reference server/client and the Android sample app embed a

client/build.gradle.kts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,17 @@ plugins {
22
alias(libs.plugins.jetbrains.kotlin.jvm)
33
alias(libs.plugins.kotlinter)
44
id("java-library")
5+
id("application")
56
id("jacoco")
67
alias(libs.plugins.git.version)
78
alias(libs.plugins.sonatype.maven.central)
89
alias(libs.plugins.gradleup.nmcp.aggregation)
910
}
1011

12+
application {
13+
mainClass.set("com.jasonernst.kanonproxy.LinuxProxyClient")
14+
}
15+
1116
tasks.withType<Test>().configureEach {
1217
finalizedBy("jacocoTestReport")
1318
testLogging {

client/scripts/cleanup.sh

Lines changed: 60 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,60 @@
1-
#!/bin/bash
2-
# Kill and java -jar invoked processes, delete kanon namespace, delete kanon interface
3-
sudo pkill -f "java -jar"
4-
sudo pkill -f "java -Djava.library.path"
5-
{
6-
# try to cleanup old interfaces first
7-
# hide output of these - we'll get a failure message if the bump interface doesn't exist
8-
# and we don't care if it doesn't
9-
sudo ip link set dev kanon down
10-
sudo ip tuntap del dev kanon mode tun
11-
rm *.jar *.so
12-
} &> /dev/null
1+
#!/usr/bin/env bash
2+
# Kill kanonproxy processes, drop any routes pointed at the kanon device,
3+
# and delete the kanon TUN interface. Idempotent: safe to run when nothing
4+
# is up.
5+
6+
# Kill the JVM processes that hold the TUN fd. Scope by current user so we
7+
# don't accidentally take out unrelated JVMs on the host that happen to have
8+
# matching strings on their command line. Use SIGTERM first so the JVM has
9+
# a chance to release the TUN fd cleanly, then SIGKILL anything that didn't
10+
# exit.
11+
CURRENT_UID="$(id -u)"
12+
PATTERNS=("ProxyServer" "LinuxProxyClient" "GradleWorkerMain" "java -jar" "java -Djava.library.path")
13+
14+
kill_matching() {
15+
local signal="$1"
16+
local pattern="$2"
17+
local pids
18+
pids="$(pgrep -u "$CURRENT_UID" -f -- "$pattern" 2>/dev/null || true)"
19+
[ -n "$pids" ] || return 0
20+
sudo kill "$signal" $pids 2>/dev/null || true
21+
}
22+
23+
for pat in "${PATTERNS[@]}"; do
24+
kill_matching -TERM "$pat"
25+
done
26+
# Give the JVMs a moment to actually release the TUN fd. Without this,
27+
# `ip tuntap del` can race the kernel cleanup and leave the interface
28+
# stuck in a DOWN state.
29+
sleep 1
30+
for pat in "${PATTERNS[@]}"; do
31+
kill_matching -KILL "$pat"
32+
done
33+
34+
if ip link show kanon &>/dev/null; then
35+
# drop any host routes still pointed at kanon (added by older demo.sh
36+
# versions; the current demo doesn't add routes)
37+
ip route show | awk '/dev kanon/ {print $1}' | while read -r net; do
38+
sudo ip route del "$net" dev kanon 2>/dev/null || true
39+
done
40+
41+
sudo ip link set dev kanon down 2>/dev/null || true
42+
43+
# Retry the delete a few times — even after pkill -9, the kernel may
44+
# need a tick before all references to the tun fd drop.
45+
for _ in 1 2 3 4 5; do
46+
if sudo ip tuntap del dev kanon mode tun 2>/dev/null; then
47+
break
48+
fi
49+
sleep 1
50+
done
51+
52+
if ip link show kanon &>/dev/null; then
53+
echo "warning: kanon interface is still present; something may still hold its fd" >&2
54+
echo " try: sudo lsof /dev/net/tun" >&2
55+
else
56+
echo "kanon interface removed"
57+
fi
58+
else
59+
echo "kanon interface not present, nothing to remove"
60+
fi

client/scripts/demo.sh

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
#!/usr/bin/env bash
2+
# Bring up a local Linux kanonproxy demo end-to-end:
3+
# 1. Create the kanon TUN device
4+
# 2. Start the proxy server (UDP :8080)
5+
# 3. Start the proxy client (tunnels TUN traffic to 127.0.0.1:8080)
6+
# 4. Issue a curl pinned to the kanon device so its outbound packets
7+
# go through the proxy, while the server's own outbound TCP socket
8+
# uses the normal default route (no loop).
9+
#
10+
# Usage: bash client/scripts/demo.sh [target-ip]
11+
# target-ip defaults to 1.1.1.1 (Cloudflare). Whatever you pick must
12+
# speak HTTP on port 80 since this demo issues a plain HTTP curl.
13+
#
14+
# Run client/scripts/cleanup.sh afterwards to tear everything down.
15+
16+
set -euo pipefail
17+
18+
TARGET="${1:-1.1.1.1}"
19+
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
20+
LOG_DIR="${REPO_ROOT}/build/demo-logs"
21+
mkdir -p "$LOG_DIR"
22+
23+
cd "$REPO_ROOT"
24+
25+
echo "[1/4] Creating kanon TUN device (sudo will be required)..."
26+
bash client/scripts/tuntap.sh "$USER"
27+
28+
echo "[2/4] Starting proxy server on UDP :8080 (logs: $LOG_DIR/server.log)..."
29+
./gradlew --no-daemon -q :server:run --args="8080" \
30+
> "$LOG_DIR/server.log" 2>&1 &
31+
SERVER_PID=$!
32+
echo " server pid=$SERVER_PID"
33+
34+
echo "[3/4] Waiting for server to start listening..."
35+
SERVER_READY=0
36+
for i in $(seq 1 30); do
37+
if ! kill -0 "$SERVER_PID" 2>/dev/null; then
38+
echo " server process exited before binding UDP :8080; see $LOG_DIR/server.log" >&2
39+
exit 1
40+
fi
41+
if ss -lun | grep -q ":8080"; then
42+
echo " server is listening"
43+
SERVER_READY=1
44+
break
45+
fi
46+
sleep 1
47+
done
48+
49+
if [ "$SERVER_READY" -ne 1 ]; then
50+
echo " timed out waiting for server to bind UDP :8080; see $LOG_DIR/server.log" >&2
51+
exit 1
52+
fi
53+
54+
echo "[3.5/4] Starting proxy client (logs: $LOG_DIR/client.log)..."
55+
./gradlew --no-daemon -q :client:run --args="127.0.0.1 8080" \
56+
> "$LOG_DIR/client.log" 2>&1 &
57+
CLIENT_PID=$!
58+
echo " client pid=$CLIENT_PID"
59+
sleep 3
60+
61+
echo "[4/4] curl --interface kanon http://$TARGET/ (sudo for SO_BINDTODEVICE)..."
62+
echo "---- curl -v --interface kanon http://$TARGET/ ----"
63+
sudo curl -v --max-time 15 --interface kanon "http://$TARGET/" || true
64+
echo "---- end curl ----"
65+
66+
cat <<EOF
67+
68+
Demo finished. Server (pid=$SERVER_PID) and client (pid=$CLIENT_PID) are still
69+
running so you can attach Wireshark:
70+
wireshark -k -i TCP@127.0.0.1:19000 # client-side dumper
71+
wireshark -k -i TCP@127.0.0.1:19001 # server-side dumper
72+
73+
To tear everything down:
74+
bash client/scripts/cleanup.sh
75+
76+
Logs: $LOG_DIR/{server,client}.log
77+
EOF

client/src/main/kotlin/com/jasonernst/kanonproxy/LinuxProxyClient.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ class LinuxProxyClient(
3434
staticLogger.debug("Using default server: 127.0.0.1 $DEFAULT_PORT")
3535
val datagramChannel = DatagramChannel.open()
3636
datagramChannel.configureBlocking(false)
37-
datagramChannel.connect(InetSocketAddress("10.0.0.114", DEFAULT_PORT))
37+
datagramChannel.connect(InetSocketAddress("127.0.0.1", DEFAULT_PORT))
3838
LinuxProxyClient(datagramChannel = datagramChannel, packetDumper = packetDumper)
3939
} else {
4040
if (args.size != 2) {

server/build.gradle.kts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,17 @@ plugins {
22
alias(libs.plugins.jetbrains.kotlin.jvm)
33
alias(libs.plugins.kotlinter)
44
id("java-library")
5+
id("application")
56
id("jacoco")
67
alias(libs.plugins.git.version)
78
alias(libs.plugins.sonatype.maven.central)
89
alias(libs.plugins.gradleup.nmcp.aggregation)
910
}
1011

12+
application {
13+
mainClass.set("com.jasonernst.kanonproxy.ProxyServer")
14+
}
15+
1116
java {
1217
sourceCompatibility = JavaVersion.VERSION_21
1318
targetCompatibility = JavaVersion.VERSION_21

0 commit comments

Comments
 (0)