gateway-domain: the backend needs tag:notplants-test-server

The tailnet ACL only permits the gateway to open connections to nodes tagged
tag:notplants-test-server. Map an untagged node and the gateway accepts the
mapping and then never connects — no error anywhere, and it presents as a
broken gateway rather than as a missing tag on your own box.

Nothing said so, and it is not discoverable from the failure. The skill now
leads with the requirement and the one-liner to check it, and names it as the
first thing to look at when traffic does not flow.

The tool also warns when the node it is about to map does not carry the tag.
It cannot check a backend given explicitly on the command line — it only sees
its own tags — so that case stays documented rather than enforced.

Found by mapping this orchestrator box (tag:orchestrator, tag:server) as a
smoke test: the mapping was written and looked entirely healthy.
This commit is contained in:
2026-08-20 18:01:41 +00:00
parent 22bd897a86
commit c7cbac6fb2
2 changed files with 54 additions and 4 deletions
+24 -4
View File
@@ -1,6 +1,6 @@
---
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 traps (your box serves the TLS cert, not the gateway; hostname backends are unremovable).
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 the tag:notplants-test-server tailnet tag or the ACL blocks the gateway from reaching it, and your box serves the TLS cert rather than the gateway.
---
# Giving your box a public domain
@@ -18,6 +18,25 @@ python3 engine/tools/gateway-domain.py add myapp
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 must carry the `notplants-test-server` tag
The tailnet ACL only permits the gateway to open connections to nodes tagged
**`tag:notplants-test-server`**. Without it 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 `tag:notplants-test-server` is not listed, add it 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 the tag. `gateway-domain.py` warns when the node it is about to map
lacks the tag, 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
@@ -115,9 +134,10 @@ 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.
3. **Is your node tagged `tag:notplants-test-server`?** (`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 those two tags, so an
untagged backend is unreachable no matter how correct the mapping looks.
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.
+30
View File
@@ -27,6 +27,7 @@ This script reads it from there itself. You should not need to handle the value.
"""
import argparse
import base64
import json
import os
import re
import shutil
@@ -96,6 +97,11 @@ _ERR = re.compile(r'<p style="color:red">(.*?)</p>', re.S)
# /var/lib/tunnel-gateway/tunnel_map.conf can clear. Refuse to create one.
_BACKEND = re.compile(r"^(\d{1,3}(?:\.\d{1,3}){3})(?::(\d{1,5}))?$")
# The tailnet ACL only lets the gateway open connections to nodes carrying this tag.
# A mapping to an untagged node is accepted by the gateway and then simply never
# connects, which looks like a gateway fault and is not one.
REQUIRED_TAG = "tag:notplants-test-server"
def _validate_backend(backend):
m = _BACKEND.match(backend)
@@ -125,12 +131,36 @@ def _parse_domains(body):
return re.findall(r"<td>\s*(.*?)\s*</td>\s*<td>\s*(.*?)\s*</td>", section, re.S)
def _self_tags(exe):
"""This node's tailnet tags, or None if they cannot be determined."""
try:
out = subprocess.run([exe, "status", "--json"], capture_output=True, text=True, timeout=15)
return json.loads(out.stdout).get("Self", {}).get("Tags") or []
except Exception:
return None
def _warn_untagged(exe):
tags = _self_tags(exe)
if tags is None:
return
if REQUIRED_TAG not in tags:
print(
f"warning: this node is not tagged {REQUIRED_TAG} (tags: {', '.join(tags) or 'none'}).\n"
" The gateway will accept the mapping but the tailnet ACL will not let it\n"
" reach this box, so no traffic will flow. Add the tag in the Tailscale\n"
" admin, or map a backend that already has it.",
file=sys.stderr,
)
def _tailscale_ip():
exe = shutil.which("tailscale") or "/run/current-system/sw/bin/tailscale"
try:
out = subprocess.run([exe, "ip", "-4"], capture_output=True, text=True, timeout=15)
except (OSError, subprocess.SubprocessError) as e:
sys.exit(f"could not run tailscale to detect this box's IP ({e}); pass the backend explicitly")
_warn_untagged(exe)
ip = out.stdout.strip().splitlines()[0].strip() if out.stdout.strip() else ""
if not ip:
sys.exit(