Nebula replaces the retired Headscale/Tailscale control plane. A joined device gets a
192.168.100.x address and can reach every service on this box —
including the ones with no public route: Postgres, Piper TTS, Samba.
Overlay 192.168.100.0/24 ·
lighthouse jetsonhl = 192.168.100.1 ·
public endpoint 173.170.242.255:4242 (UDP)
That public IP is residential and moves. A timer on the box
watches it and pushes an ntfy alert when it changes; every other node then needs the new
address in its own static_host_map. There is also no MagicDNS — Nebula ships
no name service, so use the overlay IPs or your own /etc/hosts.
There is no self-service login the way Headscale had pre-auth keys. Enrolment is manual: the box owner picks a free overlay address and signs a cert from the CA directory:
cd /srv/stack/nebula/ca
nebula-cert sign -name "<device>" -ip "192.168.100.X/24"
That writes <device>.crt and <device>.key, which go to the
device owner along with ca.crt.
Hand them over through a secure channel and never paste one into this
page; it is public. The key is the credential — anyone holding it is a
full mesh member. ca.key never leaves the box.
nebula plus nebula-cert).Point the client at this lighthouse in its own config.yml:
pki:
ca: ca.crt
cert: <device>.crt
key: <device>.key
static_host_map:
"192.168.100.1": ["173.170.242.255:4242"]
lighthouse:
am_lighthouse: false
interval: 60
hosts:
- "192.168.100.1"
ping 192.168.100.1
ssh [email protected]
ssh [email protected]smb://192.168.100.1/files and /webappsssh -L 5432:127.0.0.1:5432 [email protected]ssh -L 3070:127.0.0.1:3070 [email protected]192.168.100.1:587, IMAP 192.168.100.1:993.
Not reachable yet — those ports were bound to the old
tailscale0 interface, which no longer exists, and nothing is listening on
them today. Stalwart must be rebound to nebula1 before this line is
true.192.168.100.0/24. The trust boundary is the CA, not a port list — only a
device holding a cert signed by this CA can put packets on that range.nebula.service, not a
reload. Validate first with nebula -test -config /srv/stack/nebula/config.yml.Kong fronts everything at api.badsyntx.com. Keys are in
/srv/stack/supabase/.env on the box (mode 600).
curl "https://api.badsyntx.com/rest/v1/<table>?select=*" \
-H "apikey: $ANON_KEY" \
-H "Authorization: Bearer $ANON_KEY"
GET /rest/v1/ with a valid anon key returns 403 — that is correct,
not a fault: the OpenAPI route is admin-only upstream. Query a real table to test.
5432 and 6543 are pinned to loopback on purpose. From a mesh device:
ssh -L 5432:127.0.0.1:5432 [email protected]
psql "postgresql://[email protected]:5432/postgres"
Endpoint https://file.badsyntx.com, SigV4, region us-east-1.
Anonymous access is denied; keys are in
/srv/stack/seaweedfs/config/s3.json.
aws --profile jetson --endpoint-url https://file.badsyntx.com s3 ls
aws --profile jetson --endpoint-url https://file.badsyntx.com \
s3 cp ./photo.jpg s3://<bucket>/
Buckets live under /buckets/. A 403 against a path that isn't a real bucket
means "no such bucket", not "auth works" — test against a real one.
The filer on :8888 is still anonymously writable; LAN/mesh only, never route it publicly.
Key in /srv/stack/vision-api/.apikey. Header
x-api-key or Authorization: Bearer.
# multipart (easiest)
curl https://vision.badsyntx.com/analyze/upload \
-H "x-api-key: $VISION_KEY" \
-F [email protected] \
-F prompt="Read all text on this label"
# JSON: {image: "<base64>", prompt, model?, format?}
curl https://vision.badsyntx.com/analyze \
-H "x-api-key: $VISION_KEY" -H "Content-Type: application/json" \
-d '{"image":"'"$B64"'","prompt":"List the foods and drinks"}'
Warm 4–10 s, cold ~30 s (model unloads after 90 s idle).
/analyze/heavy is disabled: qwen2.5vl:7b does not fit in
7.4 GB unified memory — measured, OOM-killed. Run 7B on a separate GPU box.
Binds 127.0.0.1:3070 with no public route. From the box, or from a mesh
device through a tunnel:
ssh -L 3070:127.0.0.1:3070 [email protected]
curl http://127.0.0.1:3070/speak -H "x-api-key: $TTS_KEY" \
-H "Content-Type: application/json" \
-d '{"text":"deploy finished"}' --output out.wav
GET /health lists the installed voices; pass one as voice.
A real PTY running as thomas. Log in with the
subscription account option, never the API-key option —
an API key here bills metered usage on top of the Max plan.
No ANTHROPIC_* variable exists anywhere on this box by design.
Add as a custom connector in your AI client:
https://mcp.badsyntx.com/mcp/<token>, token in
~/mcp-admin/.mcp-token. /health is the only unauthenticated
route. The public :3001 service is transport + auth only — the tool catalogue lives on
:8791, so a tool added there appears here automatically.
Portainer → container → Restart, or from the terminal
sudo docker restart <name>. Caddy is the single hostname router on this
box; after editing a Caddyfile it needs a restart to take effect.
Rotates dashboard/Caddy auth, Samba, Supabase Studio, SeaweedFS S3 keys, MCP token. Vision, TTS and Headscale keys are not in it — edit their key file and restart the unit. Samba rotation is one-way; there is no undo from the UI.
LAN only: http://192.168.68.94:3090/. Off-LAN, tunnel it:
ssh -L 3090:127.0.0.1:3090 [email protected] then open
http://localhost:3090/. Account is [email protected];
the password lives in the on-box credential store, not on this page.
cd /srv/stack/mail then sudo docker compose up -d,
sudo docker restart stalwart, or
sudo docker logs stalwart --tail 50.
Health: curl http://192.168.68.94:3090/healthz/live returns 200.
25 is bound to loopback and is never exposed — residential IP, PBL listed. Inbound arrives via Cloudflare Email Routing to a Worker, over the tunnel, into a local injector. 587 (submission) and 993 (IMAPS) bind to the headscale interface only.
The container runs as uid 2000, not root. Both
/srv/stack/mail/etc and /srv/stack/mail/store must be mounted and
owned by 2000, or config and mail silently vanish on every restart.
Full manual: mail.md
Nine monitors cover kong, db, seaweed S3 + filer, caddy, MCP, terminal, portainer, ntfy. Port-type monitors silently fail to send ntfy alerts in this version — use http-type wherever the alert actually matters.
Anonymous publish is denied; publish with the dashboard login, subscribe to the same topic in the ntfy app.
curl -u thomas:<password> -d "backup finished" https://push.badsyntx.com/jetson