Every userspace-networking Tailscale sidecar on telep-mainframe was forwarding its backend loopback port onto the node’s own tailnet IP as plain HTTP, sitting beside the tailscale serve TLS front door rather than behind it. This exposed an unauthenticated code-server shell and an SSO-bypassing raw Nextcloud to the whole tailnet in cleartext. The fix is to bind each backend to 127.0.0.2 instead of 127.0.0.1. All sidecars were swept; one remote-code surface (cad:9875) is left open and flagged for follow-up.
The systemic finding —
tailscale servedoes NOT close the raw forwarder
tailscaled --tun=userspace-networkingunconditionally forwards inbound TCP on the node’s tailnet IP to127.0.0.1on the same port. Adding atailscale serve --https=<p> http://127.0.0.1:<backend>mount puts a TLS door in front of the backend, but it does not disable that raw forwarder. So every sidecar answered on two doors:
https://<svc>.taild4189d.ts.net(:443) — the intended TLS front door.http://<node-tailnet-IP>:<backend>— plain HTTP, no TLS, no identity, returning the same app.The second door bypassed everything the first one added. For
drivethat meant raw Nextcloud on:11000with the SSO/identity layer skipped entirely; forcodeit meant an unauthenticated browser VS Code + shell (--auth none) in cleartext.
The fix — bind the backend to 127.0.0.2
The forwarder only ever targets 127.0.0.1. Move the backend’s listener to 127.0.0.2 (still loopback, still host-only) and re-point tailscale serve at 127.0.0.2 too. The raw forwarder then finds nothing on 127.0.0.1:<port> and the plaintext door goes dead, while the TLS front door keeps working because serve was moved with it.
BEFORE: serve :443 ──► 127.0.0.1:P ◄── raw forwarder (tailnet IP:P, plaintext) ← LEAK
AFTER: serve :443 ──► 127.0.0.2:P raw forwarder (tailnet IP:P) ──► 127.0.0.1:P = nothing ← dead
Per service the change is one of:
- Container — flip the compose port map
127.0.0.1:P:...→127.0.0.2:P:...anddocker compose up -d <svc>. - Host process — change the app’s bind (systemd unit env /
ExecStartflag, or a source bind line) and restart the unit. - Then
tailscale --socket=/run/tailscale-<svc>/tailscaled.sock serve --bg --https=<p> http://127.0.0.2:P.
Test closure by IPv4, NOT by hostname
A MagicDNS hostname resolves IPv6 first, and the userspace forwarder does not forward IPv6 — so
curl http://<svc>.taild4189d.ts.net:Preturns a false “connection refused” and makes an open port look closed. Always probe the bare100.xIPv4 of the node; that is the authoritative test. Every before/after below was verified by IPv4 from an owner tailnet peer.
House rule — ANY future sidecar must bind
127.0.0.2from the startThis is a property of the whole per-service sidecar pattern (see tailnet-service-exposure-convention), not of any one service. A new sidecar that binds its backend to
127.0.0.1reintroduces the plaintext door the moment it comes up. Bind127.0.0.2, serve127.0.0.2, and point the homepagesiteMonitorat127.0.0.2too.
What was swept (IPv4-verified before → after)
All moved cleanly; every backend now REFUSED on the node’s tailnet IPv4, every TLS front door still serves.
| Service | Node:port | Before | After | How moved |
|---|---|---|---|---|
drive Nextcloud | 100.110.208.117:11000 | 200 (SSO-bypass!) | refused | nextcloud compose port map |
drive tsauth-proxy | :11001 | 302 | refused | hardcoded Go const in main.go → 127.0.0.2, rebuilt, atomic-swapped binary |
drive OnlyOffice DS | :11002 | 302 | refused | nextcloud compose |
drive drive-mcp | :9100 | 404 | refused | unit env DRIVE_MCP_HOST |
code code-server | 100.96.41.118:8888 | 302 (no-auth shell!) | refused | compose port map |
monitor netdata | 100.97.52.21:19999 | 200 | refused | netdata.conf [web] bind to |
cad exports-http | 100.120.203.1:8085 | 200 | refused | freecad compose (nginx) |
cad freecad GUI (noVNC) | :3080 | 200 | refused | freecad compose |
cad freecad-mcp | :9876 | 200 | refused | freecad compose |
cad o3dv (serves Drive data) | :8087 | 200 | refused | freecad compose (Drive mount preserved) |
cad freecad-open shim | :8094 | 400 | refused | server.py LISTEN_HOST |
transmute | 100.73.152.121:3313 | 200 | refused | compose |
pdf/stirling | 100.101.222.61:8080 | 200 | refused | compose |
dnsmon | 100.69.80.116:8099 | 200 | refused | unit --host |
knowledgebase kb-mcp | 100.116.0.83:9099 | 404 | refused | unit env TELEP_KB_MCP_HOST |
knowledgebase portal backend | :8092 | 200 | refused | app.py serve(host=) |
bambuddy | 100.84.98.18:8000 | 200 (was 0.0.0.0!) | refused | compose env HOST |
home/homepage | 100.93.133.62:3010 | 400 (responds) | refused | homepage compose HOSTNAME |
kb:8092was a false negative in round 1The first pass mislabeled the knowledgebase portal backend
:8092as “refused/safe”. It was actually open — a false negative from an IPv6-first hostname probe. Round 2 caught and closed it. This is exactly why the IPv4 rule above exists.
Two regressions the sweep created and fixed
- drive-mcp lost its Nextcloud backend.
DRIVE_MCP_NC_BASEstill pointed at127.0.0.1:11000, which went dead when Nextcloud moved to127.0.0.2in round 1 — drive-mcp’s WebDAV had been silently broken. Repointed to127.0.0.2:11000. - Healthchecks went
unhealthy.netdata,homepageandbambuddyimage healthchecks probe127.0.0.1/localhost, which is now empty. Added127.0.0.2healthcheck overrides (and127.0.0.2:3010toHOMEPAGE_ALLOWED_HOSTS). All back to healthy. This is a standard side-effect of the move: anything that self-probes on127.0.0.1must be repointed to127.0.0.2.
Still open — flagged for follow-up
cad:9875is FreeCAD's XML-RPC — a remote-code surface, still plaintext on the tailnet
cad:9875(OPEN, HTTP 501) is FreeCAD’s XML-RPC server — the upstream that thefreecad-openshim drives viaexecute_code. Left plaintext-open on the tailnet because closing it needs another FreeCAD-GUI container recreate plus a shimRPC_URLchange, which could not be done safely under active-agent contention.cad:3081(400) is the FreeCAD container’s other secondary port, also not serve-fronted. These are the one remaining exposure — remote FreeCAD scripting reachable in cleartext by any tailnet peer, including anytag:telep-usercollaborator. Close them the same way (move the bind to127.0.0.2, re-point the shim’sRPC_URL) when the box is stable.
Everything else visible on the tailnet is closed. No Funnel anywhere (verified — every mount is (tailnet only)).
Backups
Every edited file has a .bak-portclose (round 1) or .bak-portclose2 (round 2) sibling on the host: the netdata / code-server / nextcloud / freecad / transmute / stirling-pdf / bambuddy / homepage composes, the dnsmon / drive-mcp / telep-kb-mcp units, tsauth-proxy/main.go, freecad-open/server.py, and knowledgebase/app.py.
The host crash-looped during the sweep
The box rebooted at 06:20 mid-run — the known CPU fault, not the sweep. Because every change is on-disk (compose / unit / persisted serve state), all containers auto-restarted on
127.0.0.2and every post-reboot verification passed.
Related
- tailnet-service-exposure-convention — the per-service sidecar pattern that has this property house-wide; the
127.0.0.2rule now belongs to it - 2026-08-31-nextcloud-drive-code-server-runbook — the
drive/codesidecars; its port map and identity chain now target127.0.0.2 - 2026-08-31-telep-monitoring-and-file-services — the
monitor(netdata) andtransmutenodes swept here - 2026-08-31-telep-mainframe-mce-hardware-fault — the crash that hit mid-sweep
- telep-mainframe · telep-mainframe-handover
- LOG · TOPICS