A rule was added so the testing gateway may also reach nodes tagged tag:orchestrator, not only tag:notplants-test-server. Both tags now appear in the skill's table, and the tool's warning fires only when a node carries neither. Verified end to end from an orchestrator box (tag:orchestrator, tag:server) rather than assumed: - the gateway can open a TCP connection to it over the tailnet - a request to https://acltest.gtest.commoninternet.net/ returns the box's own self-signed certificate, subject and issuer both CN=acltest..., which is only possible if the gateway proxied the stream instead of terminating it - the payload came back and the box's listener logged the request The troubleshooting section gains the one-liner that isolates this from the gateway itself: open a TCP connection to your backend from the gateway.
7.5 KiB
name: gateway-domain description: Give a tailnet box a real public HTTPS domain (.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 two things that silently break it: your box must carry a tailnet tag the ACL allows (tag:notplants-test-server or tag:orchestrator) or the gateway cannot reach it, and your box serves the TLS cert rather than the gateway.
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.
Your box needs a tailnet tag the ACL allows
The gateway can only open connections to nodes the tailnet ACL lets it reach. Two tags qualify:
| tag | who |
|---|---|
tag:notplants-test-server |
test servers — the usual case |
tag:orchestrator |
orchestrator boxes (added 2026-08-20, verified end to end) |
Without one of them the gateway accepts your mapping and then simply never connects — which looks like a broken gateway and is not one. Check before you start:
tailscale status --json | jq -r '.Self.Tags[]?'
If neither tag is listed, add one to that node in the Tailscale admin (a node's tags are set
when it is authenticated, so this may mean re-authenticating it), or map a backend that already
has one. gateway-domain.py warns when the node it is about to map carries neither, but it
cannot see the tags of a backend you name explicitly — that one is on you.
The gateway itself is tagged tag:testing-gateway; that is the other half of the same ACL rule.
The commands
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.
gateway-domain.py list— is the mapping actually there?getent hosts myapp.gtest.commoninternet.net— should be49.13.156.72.- Does your node carry
tag:notplants-test-serverortag:orchestrator? (tailscale status --json | jq -r '.Self.Tags[]?') This is the single most common cause. The gateway isgateway-test-1(100.91.44.90), taggedtag:testing-gateway; the ACL pairs that with the tags above, so a backend with neither is unreachable no matter how correct the mapping looks. Quick check from the gateway itself:ssh root@49.13.156.72 'timeout 5 bash -c "echo > /dev/tcp/<your-tailnet-ip>/<port>"' - 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.