|
| 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