Files
agent-orchestrator/skills/gateway-domain/SKILL.md
T
notplants 22bd897a86 gateway-domain: give a tailnet box a real public domain
An agent on a box with no public IP frequently needs a reachable HTTPS URL —
an OAuth callback, a webhook receiver, a demo link. The testing gateway
already holds a wildcard record for *.gtest.commoninternet.net and forwards
by SNI, but nothing here knew that, so every agent had to be told by hand.

    tools/gateway-domain.py add myapp
    #   myapp.gtest.commoninternet.net  ->  100.84.190.30

The backend defaults to the running box's own tailscale IP, which is the case
that comes up almost every time.

The admin password comes from gateway.admin_password in the secret store; the
tool reads it itself, so no caller handles the value and there is no second
copy to drift or get committed.

Two things the tool refuses to do, both learned by doing them:

Backends must be a literal IPv4 address. The gateway's validate_ip accepts a
hostname, but put_domain/remove_domain only match lines whose backend is
numeric ([\d.:]+). A hostname mapping can therefore be written once and never
updated or removed through the admin UI — it becomes an orphan that only a
hand-edit of tunnel_map.conf clears. One got created while testing this.

Verification re-reads the mapping table instead of trusting the POST body.
The admin app mutates its in-memory dict and renders that, so a delete that
silently failed still renders as success. Checking the response alone
reported "removed" for an entry that was still on disk.

skills/gateway-domain/ carries the rest: that the gateway does NOT terminate
TLS (your box serves the cert for that name), how ACME still works through
it, that only 22/80/443 are open at the edge, and how to recover if an
interrupted e2e run leaves the admin password reseeded.
2026-08-20 17:10:28 +00:00

6.1 KiB

name, description
name description
gateway-domain Give a tailnet box a real public HTTPS domain (<name>.gtest.commoninternet.net) by mapping it on the shared testing gateway. Use when an agent needs a publicly reachable URL for a box with no public IP — an OAuth callback, a webhook receiver, a demo link, an ACME challenge. Covers the add/remove tool, where the admin password lives, and the traps (your box serves the TLS cert, not the gateway; hostname backends are unremovable).

Giving your box a public domain

Your machine is on the tailnet with no public IP. You need a real HTTPS URL for it. The testing gateway already owns a wildcard DNS record, so every name under *.gtest.commoninternet.net resolves to it. Map your name to your tailnet IP and it forwards matching traffic to you.

python3 engine/tools/gateway-domain.py add myapp
#   myapp.gtest.commoninternet.net  ->  100.84.190.30

That is the whole happy path. The backend defaults to this box's own tailscale IP, so run it on the machine that will serve the domain.

python3 engine/tools/gateway-domain.py list
python3 engine/tools/gateway-domain.py add myapp                  # this box, port 443
python3 engine/tools/gateway-domain.py add myapp 100.64.1.5       # another box
python3 engine/tools/gateway-domain.py add myapp 100.64.1.5:8443  # backend not on 443
python3 engine/tools/gateway-domain.py remove myapp

A bare label is expanded to <label>.gtest.commoninternet.net. Anything containing a dot is used verbatim, so you can map a domain you control elsewhere — but then you must point its DNS at the gateway (49.13.156.72); only *.gtest.commoninternet.net is pre-pointed.

The credential

The admin password is in the orchestrator secret store, not in any repo:

python3 engine/secrets.py get gateway.admin_password
store /secrets/store.yaml (sops+age, 0600, outside every git tree)
keys gateway.admin_password, gateway.fqdn, gateway.admin_user
age key ~/.config/sops/age/keys.txt
add/edit sops /secrets/store.yaml

gateway-domain.py reads it itself — you should never need to handle the value. Do not copy it into a config file, an env file, or a repo. If you need it in a subprocess, use engine/secrets.py exec-env or with-file rather than writing a second copy.

The one thing that surprises people: your box serves the certificate

The gateway does not terminate TLS. It reads the SNI name from the TLS handshake and proxies the still-encrypted bytes onward:

browser --TLS--> gateway :443 --reads SNI, proxies encrypted--> your box (tailnet)

So after mapping myapp.gtest.commoninternet.net, your box must serve a certificate valid for that exact name, on the backend port (443 unless you set one). The gateway has a cert for its own name only; it never sees your plaintext.

Getting a cert on your box works: the gateway passes HTTP-01 challenges through on :80 for mapped names, so ACME can complete normally. Add the domain here first, then request the cert — issuance needs the mapping to already exist.

If you only need plain HTTP for a quick test, that also passes through on :80.

Traps

Backends must be a literal IPv4 address (optionally IP:port). The tool refuses hostnames, and it is protecting you: the gateway's validate_ip accepts a hostname, but its put_domain/remove_domain only match lines whose backend is numeric. A hostname mapping can be written once and then never updated or removed through the admin UI — it becomes an orphan that only a hand-edit of /var/lib/tunnel-gateway/tunnel_map.conf on the box can clear.

Only ports 22/80/443 are open at the edge. Mapping IP:8443 changes which port on your box the gateway connects to; it does not open 8443 to the internet. Exposing a different gateway port is a config change in nix/hosts/hetzner-test.nix (services.tunnelGateway.openTCPPorts) and the Hetzner firewall in nix/terraform/ — not something this tool can do.

Names are shared. Anyone with the password can list, overwrite, or delete any mapping. Prefix yours with something recognisable and remove it when you are finished.

This is the test gateway. Never point this tool at the production one. The box is long-lived (it is also the e2e and CI target), but its map is not sacred.

When the password stops working

The e2e suite reseeds .htpasswd with a throwaway credential for the duration of a run and restores the previous file afterwards. If a run was killed part-way, the real one is still on the box at /var/lib/tunnel-gateway/.htpasswd.e2e-backup:

ssh -i /srv/gateway-coop/.secrets/id_admin root@49.13.156.72 \
  'mv -f /var/lib/tunnel-gateway/.htpasswd.e2e-backup /var/lib/tunnel-gateway/.htpasswd'

To reseed it from the store instead — piping so the value never lands on disk:

python3 engine/secrets.py get gateway.admin_password | \
  ssh -i /srv/gateway-coop/.secrets/id_admin root@49.13.156.72 \
    'htpasswd -ci /var/lib/tunnel-gateway/.htpasswd admin \
       && chgrp nginx /var/lib/tunnel-gateway/.htpasswd \
       && chmod 640 /var/lib/tunnel-gateway/.htpasswd'

If it still does not work

Check in this order — most failures are the last two.

  1. gateway-domain.py list — is the mapping actually there?
  2. getent hosts myapp.gtest.commoninternet.net — should be 49.13.156.72.
  3. Is your box reachable from the gateway on the tailnet? The gateway is gateway-test-1 (100.91.44.90), tagged tag:testing-gateway. If tailnet ACLs block it from reaching your node, the mapping is correct and traffic still will not flow.
  4. Is your service actually serving TLS for that name on the backend port? A backend that speaks plain HTTP on 443, or serves a cert for a different name, fails here and nowhere else.

The gateway itself

gtest.commoninternet.net / 49.13.156.72 — a Hetzner cx23 running NixOS, configured in the tunnel-gateway-server repo (nix/hosts/hetzner-test.nix), which notplants-nix pins as a submodule under external/. It also accepts reverse-SSH tunnels, for boxes not on the tailnet; that path is separate from this one and is not covered here.