Files
agent-orchestrator/skills/gateway-domain/SKILL.md
T
notplants 23391cef2b gateway-domain: tag:orchestrator is now allowed by the ACL too
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.
2026-08-20 18:22:58 +00:00

158 lines
7.5 KiB
Markdown

---
name: gateway-domain
description: 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 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.
```bash
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:
```bash
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
```bash
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:
```bash
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`:
```bash
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:
```bash
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. **Does your node carry `tag:notplants-test-server` or `tag:orchestrator`?**
(`tailscale status --json | jq -r '.Self.Tags[]?'`) This is the single most common cause.
The gateway is `gateway-test-1` (`100.91.44.90`), tagged `tag: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>"'`
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.