Skip to content

Commit 39b4047

Browse files
myleshortonclaude
andcommitted
Add E2E workflow testing ALGeneva, Samizdat, and WATER protocols
Spins up a DigitalOcean droplet, deploys lantern-box server instances for each protocol, and verifies real HTTP traffic proxied through each. Triggered manually via workflow_dispatch. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent de0558a commit 39b4047

File tree

1 file changed

+390
-0
lines changed

1 file changed

+390
-0
lines changed

.github/workflows/e2e.yaml

Lines changed: 390 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,390 @@
1+
name: E2E Protocol Tests
2+
3+
on:
4+
workflow_dispatch:
5+
6+
env:
7+
DROPLET_NAME: "e2e-lantern-box-${{ github.run_id }}"
8+
SSH_KEY_NAME: "e2e-lantern-box-${{ github.run_id }}"
9+
10+
jobs:
11+
e2e:
12+
runs-on: ubuntu-latest
13+
steps:
14+
- uses: actions/checkout@v4
15+
16+
- name: Set up Go
17+
uses: actions/setup-go@v4
18+
with:
19+
go-version-file: "go.mod"
20+
21+
- name: Grant private modules access
22+
run: |
23+
git config --global url."https://${{ secrets.CI_PRIVATE_REPOS_GH_TOKEN }}:x-oauth-basic@github.com/".insteadOf "https://github.com/"
24+
25+
- name: Build lantern-box (linux/amd64)
26+
run: |
27+
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
28+
-tags "with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api" \
29+
-o lantern-box \
30+
./cmd
31+
32+
- name: Generate test credentials
33+
id: creds
34+
run: |
35+
# Samizdat X25519 keypair
36+
openssl genpkey -algorithm X25519 -out samizdat_priv.pem
37+
SAMIZDAT_PRIV=$(openssl pkey -in samizdat_priv.pem -text 2>/dev/null \
38+
| grep -A2 'priv:' | tail -n+2 | tr -d ' :\n')
39+
SAMIZDAT_PUB=$(openssl pkey -in samizdat_priv.pem -pubout -text 2>/dev/null \
40+
| grep -A2 'pub:' | tail -n+2 | tr -d ' :\n')
41+
SAMIZDAT_SHORT_ID=$(openssl rand -hex 8)
42+
43+
# Self-signed TLS cert for Samizdat
44+
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \
45+
-keyout key.pem -out cert.pem -days 1 -nodes \
46+
-subj "/CN=example.com"
47+
48+
# Locate plain.wasm from Go module cache
49+
WATER_MOD_DIR="$(go env GOPATH)/pkg/mod/github.com/refraction-networking/water@v0.7.1-alpha"
50+
WASM_PATH="${WATER_MOD_DIR}/transport/v0/testdata/plain.wasm"
51+
if [ ! -f "$WASM_PATH" ]; then
52+
echo "plain.wasm not found at $WASM_PATH, downloading module..."
53+
go mod download github.com/refraction-networking/water@v0.7.1-alpha
54+
WATER_MOD_DIR="$(go env GOPATH)/pkg/mod/github.com/refraction-networking/water@v0.7.1-alpha"
55+
WASM_PATH="${WATER_MOD_DIR}/transport/v0/testdata/plain.wasm"
56+
fi
57+
WASM_HASH=$(sha256sum "$WASM_PATH" | awk '{print $1}')
58+
cp "$WASM_PATH" plain.wasm
59+
60+
echo "samizdat_priv=$SAMIZDAT_PRIV" >> "$GITHUB_OUTPUT"
61+
echo "samizdat_pub=$SAMIZDAT_PUB" >> "$GITHUB_OUTPUT"
62+
echo "samizdat_short_id=$SAMIZDAT_SHORT_ID" >> "$GITHUB_OUTPUT"
63+
echo "wasm_hash=$WASM_HASH" >> "$GITHUB_OUTPUT"
64+
65+
- name: Install doctl
66+
uses: digitalocean/action-doctl@v2
67+
with:
68+
token: ${{ secrets.DO_API_TOKEN }}
69+
70+
- name: Create DO droplet
71+
id: droplet
72+
run: |
73+
# Generate ephemeral SSH key
74+
ssh-keygen -t ed25519 -f e2e_key -N "" -q
75+
SSH_PUB=$(cat e2e_key.pub)
76+
77+
# Upload SSH key to DO
78+
SSH_KEY_ID=$(doctl compute ssh-key create "$SSH_KEY_NAME" \
79+
--public-key "$SSH_PUB" --format ID --no-header)
80+
echo "ssh_key_id=$SSH_KEY_ID" >> "$GITHUB_OUTPUT"
81+
82+
# Create droplet
83+
DROPLET_ID=$(doctl compute droplet create "$DROPLET_NAME" \
84+
--image ubuntu-24-04-x64 \
85+
--size s-1vcpu-1gb \
86+
--region nyc3 \
87+
--ssh-keys "$SSH_KEY_ID" \
88+
--wait \
89+
--format ID --no-header)
90+
echo "droplet_id=$DROPLET_ID" >> "$GITHUB_OUTPUT"
91+
92+
# Get droplet IP
93+
DROPLET_IP=$(doctl compute droplet get "$DROPLET_ID" \
94+
--format PublicIPv4 --no-header)
95+
echo "droplet_ip=$DROPLET_IP" >> "$GITHUB_OUTPUT"
96+
97+
echo "Droplet $DROPLET_ID created at $DROPLET_IP"
98+
99+
# Wait for SSH readiness
100+
for i in $(seq 1 30); do
101+
if ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 \
102+
-i e2e_key root@"$DROPLET_IP" echo ready 2>/dev/null; then
103+
echo "SSH is ready"
104+
break
105+
fi
106+
echo "Waiting for SSH... attempt $i/30"
107+
sleep 10
108+
done
109+
110+
- name: Deploy to droplet
111+
env:
112+
DROPLET_IP: ${{ steps.droplet.outputs.droplet_ip }}
113+
SAMIZDAT_PRIV: ${{ steps.creds.outputs.samizdat_priv }}
114+
SAMIZDAT_SHORT_ID: ${{ steps.creds.outputs.samizdat_short_id }}
115+
WASM_HASH: ${{ steps.creds.outputs.wasm_hash }}
116+
run: |
117+
SSH_OPTS="-o StrictHostKeyChecking=no -i e2e_key"
118+
119+
# Upload files
120+
scp $SSH_OPTS lantern-box cert.pem key.pem plain.wasm root@"$DROPLET_IP":/root/
121+
122+
# Make binary executable
123+
ssh $SSH_OPTS root@"$DROPLET_IP" chmod +x /root/lantern-box
124+
125+
# Start Python HTTP server to serve plain.wasm (for WATER)
126+
ssh $SSH_OPTS root@"$DROPLET_IP" \
127+
"cd /root && nohup python3 -m http.server 8888 > /root/wasm-server.log 2>&1 &"
128+
129+
# --- ALGeneva server config ---
130+
cat > /tmp/algeneva-server.json << 'JSONEOF'
131+
{
132+
"log": {"level": "debug"},
133+
"inbounds": [{
134+
"type": "algeneva",
135+
"tag": "algeneva-in",
136+
"listen": "::",
137+
"listen_port": 9001
138+
}],
139+
"outbounds": [{
140+
"type": "direct",
141+
"tag": "direct"
142+
}]
143+
}
144+
JSONEOF
145+
scp $SSH_OPTS /tmp/algeneva-server.json root@"$DROPLET_IP":/root/algeneva-server.json
146+
147+
# --- Samizdat server config ---
148+
cat > /tmp/samizdat-server.json << JSONEOF
149+
{
150+
"log": {"level": "debug"},
151+
"inbounds": [{
152+
"type": "samizdat",
153+
"tag": "samizdat-in",
154+
"listen": "::",
155+
"listen_port": 9002,
156+
"private_key": "${SAMIZDAT_PRIV}",
157+
"short_ids": ["${SAMIZDAT_SHORT_ID}"],
158+
"cert_path": "/root/cert.pem",
159+
"key_path": "/root/key.pem",
160+
"masquerade_domain": "example.com"
161+
}],
162+
"outbounds": [{
163+
"type": "direct",
164+
"tag": "direct"
165+
}]
166+
}
167+
JSONEOF
168+
scp $SSH_OPTS /tmp/samizdat-server.json root@"$DROPLET_IP":/root/samizdat-server.json
169+
170+
# --- WATER server config ---
171+
cat > /tmp/water-server.json << JSONEOF
172+
{
173+
"log": {"level": "debug"},
174+
"inbounds": [{
175+
"type": "water",
176+
"tag": "water-in",
177+
"listen": "::",
178+
"listen_port": 9003,
179+
"transport": "plain",
180+
"hashsum": "${WASM_HASH}",
181+
"wasm_available_at": ["http://127.0.0.1:8888/plain.wasm"]
182+
}],
183+
"outbounds": [{
184+
"type": "direct",
185+
"tag": "direct"
186+
}]
187+
}
188+
JSONEOF
189+
scp $SSH_OPTS /tmp/water-server.json root@"$DROPLET_IP":/root/water-server.json
190+
191+
# Start all 3 server processes
192+
ssh $SSH_OPTS root@"$DROPLET_IP" \
193+
"nohup /root/lantern-box run --config /root/algeneva-server.json > /root/algeneva-server.log 2>&1 &
194+
nohup /root/lantern-box run --config /root/samizdat-server.json > /root/samizdat-server.log 2>&1 &
195+
nohup /root/lantern-box run --config /root/water-server.json > /root/water-server.log 2>&1 &"
196+
197+
# Wait for servers to start
198+
sleep 5
199+
200+
# Verify server processes are running
201+
ssh $SSH_OPTS root@"$DROPLET_IP" \
202+
"pgrep -f 'lantern-box run' || (echo 'No lantern-box processes found!' && cat /root/*.log && exit 1)"
203+
204+
- name: Test ALGeneva
205+
env:
206+
DROPLET_IP: ${{ steps.droplet.outputs.droplet_ip }}
207+
run: |
208+
# Generate client config
209+
cat > /tmp/algeneva-client.json << JSONEOF
210+
{
211+
"log": {"level": "debug"},
212+
"inbounds": [{
213+
"type": "mixed",
214+
"tag": "mixed-in",
215+
"listen": "127.0.0.1",
216+
"listen_port": 1081
217+
}],
218+
"outbounds": [{
219+
"type": "algeneva",
220+
"tag": "algeneva-out",
221+
"server": "${DROPLET_IP}",
222+
"server_port": 9001,
223+
"strategy": "[HTTP:method:*]-insert{%0A:end:value:2}-|"
224+
}]
225+
}
226+
JSONEOF
227+
228+
# Start client
229+
./lantern-box run --config /tmp/algeneva-client.json > /tmp/algeneva-client.log 2>&1 &
230+
CLIENT_PID=$!
231+
sleep 3
232+
233+
# Verify client is running
234+
if ! kill -0 $CLIENT_PID 2>/dev/null; then
235+
echo "ALGeneva client failed to start"
236+
cat /tmp/algeneva-client.log
237+
exit 1
238+
fi
239+
240+
# Test
241+
RESPONSE=$(curl -sf -x socks5h://127.0.0.1:1081 -m 30 http://example.com)
242+
if echo "$RESPONSE" | grep -q "Example Domain"; then
243+
echo "ALGeneva test PASSED"
244+
else
245+
echo "ALGeneva test FAILED"
246+
echo "Response: $RESPONSE"
247+
cat /tmp/algeneva-client.log
248+
kill $CLIENT_PID 2>/dev/null || true
249+
exit 1
250+
fi
251+
252+
kill $CLIENT_PID 2>/dev/null || true
253+
254+
- name: Test Samizdat
255+
env:
256+
DROPLET_IP: ${{ steps.droplet.outputs.droplet_ip }}
257+
SAMIZDAT_PUB: ${{ steps.creds.outputs.samizdat_pub }}
258+
SAMIZDAT_SHORT_ID: ${{ steps.creds.outputs.samizdat_short_id }}
259+
run: |
260+
cat > /tmp/samizdat-client.json << JSONEOF
261+
{
262+
"log": {"level": "debug"},
263+
"inbounds": [{
264+
"type": "mixed",
265+
"tag": "mixed-in",
266+
"listen": "127.0.0.1",
267+
"listen_port": 1082
268+
}],
269+
"outbounds": [{
270+
"type": "samizdat",
271+
"tag": "samizdat-out",
272+
"server": "${DROPLET_IP}",
273+
"server_port": 9002,
274+
"public_key": "${SAMIZDAT_PUB}",
275+
"short_id": "${SAMIZDAT_SHORT_ID}",
276+
"server_name": "example.com"
277+
}]
278+
}
279+
JSONEOF
280+
281+
./lantern-box run --config /tmp/samizdat-client.json > /tmp/samizdat-client.log 2>&1 &
282+
CLIENT_PID=$!
283+
sleep 3
284+
285+
if ! kill -0 $CLIENT_PID 2>/dev/null; then
286+
echo "Samizdat client failed to start"
287+
cat /tmp/samizdat-client.log
288+
exit 1
289+
fi
290+
291+
RESPONSE=$(curl -sf -x socks5h://127.0.0.1:1082 -m 30 http://example.com)
292+
if echo "$RESPONSE" | grep -q "Example Domain"; then
293+
echo "Samizdat test PASSED"
294+
else
295+
echo "Samizdat test FAILED"
296+
echo "Response: $RESPONSE"
297+
cat /tmp/samizdat-client.log
298+
kill $CLIENT_PID 2>/dev/null || true
299+
exit 1
300+
fi
301+
302+
kill $CLIENT_PID 2>/dev/null || true
303+
304+
- name: Test WATER
305+
env:
306+
DROPLET_IP: ${{ steps.droplet.outputs.droplet_ip }}
307+
WASM_HASH: ${{ steps.creds.outputs.wasm_hash }}
308+
run: |
309+
cat > /tmp/water-client.json << JSONEOF
310+
{
311+
"log": {"level": "debug"},
312+
"inbounds": [{
313+
"type": "mixed",
314+
"tag": "mixed-in",
315+
"listen": "127.0.0.1",
316+
"listen_port": 1083
317+
}],
318+
"outbounds": [{
319+
"type": "water",
320+
"tag": "water-out",
321+
"server": "${DROPLET_IP}",
322+
"server_port": 9003,
323+
"transport": "plain",
324+
"hashsum": "${WASM_HASH}",
325+
"wasm_available_at": ["http://${DROPLET_IP}:8888/plain.wasm"],
326+
"download_timeout": "60s",
327+
"water_dir": "/tmp/water"
328+
}]
329+
}
330+
JSONEOF
331+
332+
./lantern-box run --config /tmp/water-client.json > /tmp/water-client.log 2>&1 &
333+
CLIENT_PID=$!
334+
sleep 5
335+
336+
if ! kill -0 $CLIENT_PID 2>/dev/null; then
337+
echo "WATER client failed to start"
338+
cat /tmp/water-client.log
339+
exit 1
340+
fi
341+
342+
RESPONSE=$(curl -sf -x socks5h://127.0.0.1:1083 -m 30 http://example.com)
343+
if echo "$RESPONSE" | grep -q "Example Domain"; then
344+
echo "WATER test PASSED"
345+
else
346+
echo "WATER test FAILED"
347+
echo "Response: $RESPONSE"
348+
cat /tmp/water-client.log
349+
kill $CLIENT_PID 2>/dev/null || true
350+
exit 1
351+
fi
352+
353+
kill $CLIENT_PID 2>/dev/null || true
354+
355+
- name: Collect server logs
356+
if: always()
357+
env:
358+
DROPLET_IP: ${{ steps.droplet.outputs.droplet_ip }}
359+
run: |
360+
SSH_OPTS="-o StrictHostKeyChecking=no -o ConnectTimeout=10 -i e2e_key"
361+
mkdir -p /tmp/server-logs
362+
363+
scp $SSH_OPTS root@"$DROPLET_IP":/root/*.log /tmp/server-logs/ 2>/dev/null || true
364+
365+
# Also grab client logs
366+
cp /tmp/*-client.log /tmp/server-logs/ 2>/dev/null || true
367+
368+
- name: Upload logs
369+
if: always()
370+
uses: actions/upload-artifact@v4
371+
with:
372+
name: e2e-logs
373+
path: /tmp/server-logs/
374+
retention-days: 7
375+
376+
- name: Cleanup DO resources
377+
if: always()
378+
run: |
379+
DROPLET_ID="${{ steps.droplet.outputs.droplet_id }}"
380+
SSH_KEY_ID="${{ steps.droplet.outputs.ssh_key_id }}"
381+
382+
if [ -n "$DROPLET_ID" ]; then
383+
echo "Destroying droplet $DROPLET_ID..."
384+
doctl compute droplet delete "$DROPLET_ID" --force || true
385+
fi
386+
387+
if [ -n "$SSH_KEY_ID" ]; then
388+
echo "Deleting SSH key $SSH_KEY_ID..."
389+
doctl compute ssh-key delete "$SSH_KEY_ID" --force || true
390+
fi

0 commit comments

Comments
 (0)