diff --git a/skills/gateway-domain/SKILL.md b/skills/gateway-domain/SKILL.md index e58eb52..1e1e5d7 100644 --- a/skills/gateway-domain/SKILL.md +++ b/skills/gateway-domain/SKILL.md @@ -1,6 +1,6 @@ --- 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 traps (your box serves the TLS cert, not the gateway; hostname backends are unremovable). +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 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. diff --git a/tools/gateway-domain.py b/tools/gateway-domain.py index e350d35..65adeeb 100755 --- a/tools/gateway-domain.py +++ b/tools/gateway-domain.py @@ -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'

(.*?)

', 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"\s*(.*?)\s*\s*\s*(.*?)\s*", 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(