badSyntx

JetsonHL · self-hosted

Mesh

Nebula Lighthouse
jetsonhl = 192.168.100.1 · UDP :4242 · no web UI to open
Mesh CA
Devices enrolled by hand · nebula-cert on the box
How to join a device to the mesh

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.

1 · Get a certificate

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.

2 · Install the client

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"

3 · Verify

ping 192.168.100.1
ssh [email protected]

What you get once joined

Housekeeping

Data

Supabase Studio
Postgres tables, SQL editor, auth users
Supabase API
REST, realtime, storage, edge functions
SeaweedFS
S3 object storage
How to use the data services

Supabase REST

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.

Postgres direct

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"

SeaweedFS S3

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.

AI

Vision API
qwen2.5vl:3b · image → text · API key
Piper TTS
Text → wav · loopback only, reach it over the mesh
How to call the AI models

Vision — POST /analyze

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.

TTS — POST /speak

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.

Control

[syntxStudio] Terminal
Coding agent CLI in the browser · basic auth
Portainer
Containers, logs, images, volumes
MCP Endpoint
23 admin tools · bearer auth
Passwords
Rotate service credentials · basic auth
How to drive the box

[syntxStudio] Terminal

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.

MCP

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.

Restarting things

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.

Password UI

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.

Operations

Uptime Kuma
Service health, history, alerting
ntfy
Push notifications to phone
Mail server
Stalwart operations manual — paths, ports, admin, testing
How to administer the mail server

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

Everyday commands

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.

Ports

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.

Persistence

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

How to watch and get paged

Uptime Kuma

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.

ntfy

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