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
+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(