Skip to content

Commit c407fb8

Browse files
kerthcetCopilot
andauthored
fix: add socket to avoid network issue (#34)
* fix: add socket to avoid network issue Signed-off-by: kerthcet <kerthcet@gmail.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * format Signed-off-by: kerthcet <kerthcet@gmail.com> * fix test Signed-off-by: kerthcet <kerthcet@gmail.com> --------- Signed-off-by: kerthcet <kerthcet@gmail.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 3d6cb84 commit c407fb8

8 files changed

Lines changed: 449 additions & 15 deletions

File tree

.github/workflows/rust-ci.yaml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,29 @@ jobs:
6565

6666
- name: Run E2E tests
6767
run: make test-e2e
68+
69+
test-e2e-tunnel:
70+
name: Tunnel E2E Tests
71+
runs-on: ubuntu-latest
72+
steps:
73+
- uses: actions/checkout@v4
74+
75+
- name: Install Rust toolchain
76+
uses: actions-rust-lang/setup-rust-toolchain@v1
77+
with:
78+
toolchain: stable
79+
80+
- name: Setup Python
81+
uses: actions/setup-python@v5
82+
with:
83+
python-version: '3.12'
84+
85+
- name: Set up Docker Buildx
86+
uses: docker/setup-buildx-action@v3
87+
88+
# Regression coverage for the userspace-networking + SOCKS5 mesh dial path
89+
# (an unprivileged daemon reaching the controller only over the tailnet).
90+
# The controller container needs /dev/net/tun, which GitHub's Linux runners
91+
# provide; the daemon side is deliberately unprivileged.
92+
- name: Run tunnel-mode E2E tests
93+
run: make test-e2e-tunnel

Cargo.lock

Lines changed: 13 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Makefile

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,22 @@ MATURIN := .venv/bin/maturin
55
# Pinned so lint results don't shift when ruff changes its default rule set.
66
RUFF_VERSION := ruff==0.15.15
77

8-
.PHONY: help build install dev test clean daemon-build daemon-release test-e2e docker-build docker-down
8+
.PHONY: help build install dev test clean daemon-build daemon-release test-e2e test-e2e-tunnel docker-build docker-down
99

1010
help:
1111
@echo "SandD - Sandbox Daemon - Build Commands"
1212
@echo ""
13-
@echo " make build - Build Python package (debug mode)"
14-
@echo " make install - Install Python package locally"
15-
@echo " make dev - Install in development mode with hot reload"
16-
@echo " make test - Run unit and integration tests (fast, no Docker)"
17-
@echo " make test-e2e - Run end-to-end tests with Docker (slow)"
18-
@echo " make daemon-build - Build daemon binary (debug)"
19-
@echo " make daemon-release - Build daemon binary (release)"
20-
@echo " make docker-build - Build Docker image for daemon"
21-
@echo " make docker-down - Stop and remove Docker containers"
22-
@echo " make clean - Clean build artifacts"
13+
@echo " make build - Build Python package (debug mode)"
14+
@echo " make install - Install Python package locally"
15+
@echo " make dev - Install in development mode with hot reload"
16+
@echo " make test - Run unit and integration tests (fast, no Docker)"
17+
@echo " make test-e2e - Run direct-mode end-to-end tests with Docker (slow)"
18+
@echo " make test-e2e-tunnel - Run tunnel-mode (Tailscale mesh) e2e tests (slow)"
19+
@echo " make daemon-build - Build daemon binary (debug)"
20+
@echo " make daemon-release - Build daemon binary (release)"
21+
@echo " make docker-build - Build Docker image for daemon"
22+
@echo " make docker-down - Stop and remove Docker containers"
23+
@echo " make clean - Clean build artifacts"
2324

2425
build: $(MATURIN)
2526
$(MATURIN) build -m server/Cargo.toml
@@ -58,12 +59,26 @@ test-e2e: $(PYTEST) dev
5859
@echo "Building Docker images..."
5960
docker compose -f hack/docker/docker-compose.e2e.yml build
6061
@echo ""
61-
@echo "Running E2E tests with Docker..."
62-
$(PYTEST) python/tests/ -m e2e -v -s
62+
@echo "Running direct-mode E2E tests with Docker..."
63+
$(PYTEST) python/tests/ -m "e2e and not tunnel" -v -s
6364
@echo ""
6465
@echo "Cleaning up containers..."
6566
docker compose -f hack/docker/docker-compose.e2e.yml down
6667

68+
# Tunnel-mode e2e uses its OWN compose stack (headscale + mesh) and the test
69+
# fixture mints the auth key mid-bringup, so it runs separately from test-e2e.
70+
# The `tunnel` marker selects only these tests; the fixture handles up/down of
71+
# docker-compose.tunnel-e2e.yml, but we `down` here too as a cleanup backstop.
72+
test-e2e-tunnel: $(PYTEST) dev
73+
@echo "Building tunnel-mode Docker images..."
74+
docker compose -f hack/docker/docker-compose.tunnel-e2e.yml build
75+
@echo ""
76+
@echo "Running tunnel-mode E2E tests (Tailscale/headscale mesh)..."
77+
$(PYTEST) python/tests/ -m tunnel -v -s
78+
@echo ""
79+
@echo "Cleaning up containers..."
80+
docker compose -f hack/docker/docker-compose.tunnel-e2e.yml down -v
81+
6782
docker-build:
6883
docker compose -f hack/docker/docker-compose.e2e.yml build
6984

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
# Tunnel-mode E2E — REGRESSION coverage for the userspace-networking dial path.
2+
#
3+
# WHY THIS EXISTS (the bug it guards against):
4+
# The direct-mode e2e (docker-compose.e2e.yml) and examples/tunnel-simple both
5+
# let the daemon reach the controller over the shared Docker BRIDGE (via the
6+
# `controller` hostname / host.docker.internal). That means the tailnet is set
7+
# up but NEVER load-bearing — a plain socket always had a route. So the one path
8+
# Nebula actually uses in prod — an UNPRIVILEGED daemon in
9+
# `--tun=userspace-networking`, where the tailnet has NO kernel route and the
10+
# WebSocket must traverse tailscaled's SOCKS5 proxy — had zero coverage, and a
11+
# regression there (daemons join the mesh yet `Active daemons: 0`) shipped.
12+
#
13+
# HOW THIS TEST FORCES THE MESH PATH:
14+
# The daemon dials `ws://controller.sandd.local:8765/ws` — a MagicDNS name. The
15+
# Docker embedded DNS only knows the container name `controller`, NOT the
16+
# `.sandd.local` FQDN, so the bridge cannot resolve (let alone route) it. The
17+
# name resolves ONLY inside tailscaled (MagicDNS) to the controller's 100.64.x
18+
# mesh IP, and — because the daemon runs userspace-networking with no route to
19+
# 100.64.0.0/10 — the only way the WebSocket connects is THROUGH the SOCKS5 proxy
20+
# with remote DNS (socks5h). Without that wiring in sandd, this test hangs at
21+
# "daemon failed to connect"; with it, the daemon connects and exec works.
22+
#
23+
# BOTH SIDES RUN USERSPACE-NETWORKING (Server(connect="tunnel") and sandd --tunnel
24+
# each start `tailscaled --tun=userspace-networking`). The controller keeps
25+
# NET_ADMIN + /dev/net/tun only as a harmless fallback / to mirror the infra side
26+
# of Nebula; in userspace mode tailscaled forwards inbound mesh TCP to the local
27+
# :8765 listener, so no kernel TUN is actually required. The daemon is deliberately
28+
# UNPRIVILEGED (no NET_ADMIN/TUN) — that is the TENANT side (the GPU workload
29+
# container) and the exact constraint this test proves works.
30+
#
31+
# Orchestrated by python/tests/test_e2e_tunnel.py (mints the auth key between
32+
# `up headscale` and `up controller daemon`). Not part of the default e2e run.
33+
34+
services:
35+
# Headscale coordination server — assigns mesh IPs, runs MagicDNS for
36+
# *.sandd.local (see base_domain in the example's headscale-config.yaml).
37+
headscale:
38+
image: headscale/headscale:0.23
39+
command: serve
40+
volumes:
41+
# Reuse the example's config verbatim: magic_dns: true, base_domain:
42+
# sandd.local — that base_domain is what makes controller.sandd.local resolve.
43+
- ../../examples/tunnel-simple/headscale-config.yaml:/etc/headscale/config.yaml:ro
44+
- headscale-data:/var/lib/headscale
45+
networks:
46+
- mesh
47+
environment:
48+
- TZ=UTC
49+
50+
# Controller — the SandD server, on the mesh. Loops list_daemons()+exec and
51+
# prints distinctive markers the test greps for. NET_ADMIN/TUN: infra side.
52+
controller:
53+
hostname: controller
54+
build:
55+
context: ../..
56+
dockerfile: hack/docker/Dockerfile.server-tunnel
57+
command:
58+
- /bin/bash
59+
- -c
60+
- |
61+
set -e
62+
# Do NOT run tailscaled/`tailscale up` here: Server(connect="tunnel")
63+
# brings up its own tailscaled (--tun=userspace-networking) and runs
64+
# `tailscale up` internally (server/src/lib.rs setup_tunnel_controller).
65+
# A manual `tailscale up` first would set --hostname, and the Server's
66+
# second `up` (which omits it) then fails tailscale's "must mention all
67+
# non-default flags" guard. The tailnet hostname comes from the OS
68+
# hostname instead, which compose sets via `hostname: controller` below
69+
# -> MagicDNS name controller.sandd.local.
70+
python3 -u << 'PYEOF'
71+
import os, time
72+
from sandd import Server, TunnelConfig
73+
cfg = TunnelConfig(authkey=os.environ["SANDD_TUNNEL_AUTH_KEY"],
74+
server="http://headscale:8080")
75+
# Server joins the mesh (userspace-networking) and listens on :8765;
76+
# tailscale proxies inbound mesh connections to this local listener.
77+
server = Server(host="0.0.0.0", port=8765, connect="tunnel", tunnel_config=cfg)
78+
print("[ctrl] controller ready, waiting for daemons", flush=True)
79+
seen = set()
80+
while True:
81+
# list_daemons() returns DaemonInfo objects; exec() and the log
82+
# markers want the plain id string (d.id), not the object repr.
83+
for d in server.list_daemons():
84+
did = d.id
85+
if did not in seen:
86+
seen.add(did)
87+
print(f"[ctrl] DAEMON_CONNECTED {did}", flush=True)
88+
r = server.exec(did, "hostname")
89+
if r.success:
90+
print(f"[ctrl] EXEC_OK {did} {r.stdout.strip()}", flush=True)
91+
time.sleep(2)
92+
PYEOF
93+
environment:
94+
- SANDD_TUNNEL_AUTH_KEY=${SANDD_TUNNEL_AUTH_KEY:-}
95+
cap_add:
96+
- NET_ADMIN
97+
devices:
98+
- /dev/net/tun
99+
depends_on:
100+
- headscale
101+
networks:
102+
- mesh
103+
104+
# Daemon — the TENANT side. UNPRIVILEGED, userspace-networking. Dials the
105+
# controller by its MagicDNS name so the bridge cannot carry the connection.
106+
daemon:
107+
build:
108+
context: ../..
109+
dockerfile: hack/docker/Dockerfile.daemon-tunnel
110+
entrypoint: ["/bin/bash", "-c"]
111+
command:
112+
- |
113+
set -e
114+
echo "[daemon] starting sandd --tunnel (userspace-networking, unprivileged)"
115+
# sandd itself brings up tailscaled --tun=userspace-networking with the
116+
# SOCKS5 proxy, joins the mesh, and dials the controller THROUGH the proxy.
117+
# The MagicDNS name is resolved remotely by tailscaled (socks5h), never on
118+
# the Docker bridge — so this only succeeds over the mesh.
119+
exec sandd \
120+
--server-url=ws://controller.sandd.local:8765/ws \
121+
--daemon-id=tunnel-daemon-1 \
122+
--tunnel \
123+
--tunnel-authkey="${SANDD_TUNNEL_AUTH_KEY:-}" \
124+
--tunnel-server=http://headscale:8080
125+
environment:
126+
- SANDD_TUNNEL_AUTH_KEY=${SANDD_TUNNEL_AUTH_KEY:-}
127+
- RUST_LOG=info
128+
# NO cap_add, NO devices — this is the whole point: it must work unprivileged.
129+
depends_on:
130+
- headscale
131+
- controller
132+
networks:
133+
- mesh
134+
135+
volumes:
136+
headscale-data:
137+
138+
networks:
139+
mesh:
140+
driver: bridge

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,4 +55,5 @@ exclude = [
5555
asyncio_mode = "auto"
5656
markers = [
5757
"e2e: end-to-end tests with Docker (slow, skip by default)",
58+
"tunnel: tunnel-mode (Tailscale/headscale) e2e; needs its own compose stack",
5859
]

0 commit comments

Comments
 (0)