Skip to content

feat(acl): grant node_exporter egress proxies scrape access (TIN-3970) - #22

Open
Jesssullivan wants to merge 1 commit into
mainfrom
fix/tin-3970-k8s-egress-nodeexporter-grant
Open

feat(acl): grant node_exporter egress proxies scrape access (TIN-3970)#22
Jesssullivan wants to merge 1 commit into
mainfrom
fix/tin-3970-k8s-egress-nodeexporter-grant

Conversation

@Jesssullivan

@Jesssullivan Jesssullivan commented Aug 22, 2026

Copy link
Copy Markdown
Owner

Problem

The Tailscale k8s operator creates egress-proxy devices for the ExternalName Services in tinyland-staging that front node_exporter on relay-1 and petting-zoo-mini. Their flows are dropped by ACL. Verified live on the cluster, 2026-08-22:

ts-relay-node-exporter-egress-rz5zl-0
  open-conn-track: flow TCP 100.65.23.86:35180 > 100.102.229.122:9100 rejected due to acl
ts-pzm-node-exporter-egress-mn86h-0
  open-conn-track: flow TCP 100.69.142.65:51002 > 100.111.5.80:9100 rejected due to acl
ts-neo-node-exporter-egress-2vqxs-0
  (no ACL rejections)

Root cause

The operator assigns proxies its PROXY_TAGS, which is tag:k8s:

$ kubectl -n tailscale get deploy operator -o json | ... 
OPERATOR_INITIAL_TAGS = tag:k8s-operator
PROXY_TAGS            = tag:k8s

All three egress proxies therefore carry exactly ['tag:k8s']. The destinations do not:

device tailnet IP tags
relay-1 100.102.229.122 tag:dollhouse
petting-zoo-mini 100.111.5.80 tag:dev, tag:dollhouse
neo 100.67.93.34 tag:dev, tag:dollhouse, tag:k8s, tag:k8s-operator, …

No rule permits tag:k8stag:dollhouse or tag:dev. The tag:k8s-operator rule in fragments/kubernetes.dhall does reach those tags, but it applies to the operator pod itself, not to the proxies it creates.

Why neo already passes: neo holds tag:k8s itself, so the existing tag:k8s → tag:k8s:* rule covers it. It is not evidence that the policy is correct — it is a coincidence of neo's tag set.

Why not simply allow tag:k8s

tag:k8s is held by 85 devices, including 23 ephemeral github-runnervmeorf1-* CI runners, every MassageIthaca PR preview, and every MCP proxy. Granting that set scrape access to lab hosts is the broad allow this repo has repeatedly refused (cf. test_honey_does_not_receive_broad_kubernetes_acl_access).

Instead this follows the tag:mcp-proxy precedent from TIN-2940 / #18: a dedicated tag for operator-created proxies, owned by tag:k8s-operator so the operator can mint it, plus a port-scoped grant rather than an ACL rule.

Generated JSON delta

generated/policy.json, built with nix develop --command just build. This is the complete diff — no ACL rules change (41 before, 41 after):

@@ -38,6 +38,11 @@   tagOwners
 		"autogroup:admin",
 		"group:dollhouse-admins"
 	],
+	"tag:k8s-egress-nodeexporter": [
+		"tag:k8s-operator",
+		"autogroup:admin",
+		"group:dollhouse-admins"
+	],
 	"tag:k8s-operator": [

@@ -718,6 +723,19 @@   grants
 	{
 		"src": [
+			"tag:k8s-egress-nodeexporter"
+		],
+		"dst": [
+			"tinyland-relay-1",
+			"tinyland-petting-zoo-mini",
+			"tinyland-neo"
+		],
+		"ip": [
+			"tcp:9100"
+		]
+	},
+	{
+		"src": [
 			"tag:dollhouse"

@@ -895,6 +913,9 @@   hosts
 	"tinyland-honey": "100.113.89.12",
 	"tinyland-loki-observability": "100.64.15.87",
+	"tinyland-neo": "100.67.93.34",
+	"tinyland-petting-zoo-mini": "100.111.5.80",
+	"tinyland-relay-1": "100.102.229.122",
 	"tinyland-sting": "100.85.46.118"

Counts: tagOwners 25 → 26, grants 9 → 10, hosts 6 → 9, acls 41 → 41, ssh 8 → 8.

Scope rationale

  • ip: ["tcp:9100"] — TCP only, single port. node_exporter is read-only metrics.
  • Pinned host aliases, not tags — the grant names three specific IPs. It does not reach any other tag:dollhouse or tag:dev device.
  • Dedicated src tag — narrows the source from 85 devices to exactly the proxies the operator labels.
  • Expressed as a grant, never an ACL src — an ACL rule takes dst as host:port and is easy to widen to :* in a later edit. A grant carries the port in a separate ip field that a contract test pins. test_node_exporter_egress_tag_gets_no_broad_acl_access fails if the tag ever appears as an ACL src.

Why tinyland-neo is in the grant

Not a new capability — the neo proxy can already reach neo:9100 today via tag:k8s → tag:k8s. It is included to prevent a regression: once the blahaj follow-up moves that proxy onto tag:k8s-egress-nodeexporter, it loses tag:k8s and would lose the path. Omitting it would break a currently-working (if unproductive — see below) route.

Required follow-up — this PR alone does not flip the metrics

The proxies do not carry the new tag yet. The tailnet side must land first so the tag exists and is owned by tag:k8s-operator; only then can the operator mint it.

The egress Services are not in blahaj. They live in the tinyland.dev repo:

infra/staging/observability-tailnet-services.yaml — three Services, each needing one added annotation:

Service annotation block
relay-node-exporter-egress ~L72
neo-node-exporter-egress ~L89
pzm-node-exporter-egress ~L106
  annotations:
    tailscale.com/tailnet-fqdn: relay-1.taila4c78d.ts.net
    tailscale.com/proxy-class: honey-sting-tailnet
+   tailscale.com/tags: "tag:k8s-egress-nodeexporter"

The operator then recreates the proxy devices with the new tag and the grant takes effect.

Note this is a Service annotation, not a ProxyClass field — the honey-sting-tailnet ProxyClass these Services reference carries only scheduling config (statefulSet.pod affinity/tolerations/spread), and the ProxyClass CRD has no tags field. Tagging is per-Service.

Apply all three together: neo-node-exporter-egress must be annotated in the same change, or it keeps tag:k8s while the others move, leaving the fleet inconsistent.

Verification plan

Current baseline, from inside the prometheus pod:

node-relay  relay-node-exporter-egress...:9100  up = 0
node-neo    neo-node-exporter-egress...:9100    up = 0
node-pzm    pzm-node-exporter-egress...:9100    up = 0

After this PR and the blahaj annotation:

  • up{job="node-relay"} → 1. High confidence. From an ACL-permitted device, relay-1:9100 is open and serving genuine node_exporter — 450 node_* metric lines. Only the ACL is in the way.
  • up{job="node-pzm"} → exporter state becomes verifiable. The ACL block is removed, but a probe from an ACL-permitted device gets connection refused on 100.111.5.80:9100, which indicates no listener. Expect this job to stay at 0 and need a pzm-side node_exporter deployment. That is a host-config gap, not an ACL gap.
  • up{job="node-neo"} → unchanged at 0, and this PR does not claim to fix it. neo's 9100 is held by tcfsd bound to 127.0.0.1 only, so tailnet peers get RST. It is also not node_exporter. The node-neo scrape job is misconfigured independently of the tailnet policy and needs a separate fix in the owning repo.

Post-merge the CD workflow pushes the policy; confirm the live diff matches the delta above, then re-check the proxy logs for absence of rejected due to acl.

Validation evidence

All run locally against the repo's own toolchain via nix develop:

check result
dhall type --file policy.dhall pass
find . -name '*.dhall' -exec dhall format --check {} + exit 0
just build wrote generated/policy.json, delta as quoted
python3 -m unittest discover -s tests 7 tests, OK

The four new contract tests were mutation-tested — each was re-run against a deliberately corrupted generated/policy.json to prove it can fail rather than passing vacuously:

mutation test result
drop tag:k8s-operator from the tag's owners FAIL
widen grant ip to tcp:* FAIL
repoint tinyland-relay-1 to 10.0.0.1 FAIL
add an ACL rule with the tag as src FAIL

Restored, all 7 pass.

One authoring note for future edits: dhall 1.42.3's formatter discards every comment except the file header, so the provenance comments live in the core.dhall / kubernetes.dhall headers. Inline comments inside list literals are silently dropped and then fail CI's dhall format --check.

Dirty-tree disposition — set aside, not incorporated

The worktree was dirty on arrival, but not with a half-finished version of this grant, so per the operator ruling it was preserved untouched rather than folded in.

It was 10 modified files (not 5) on branch fix/emergency-ssh-admin-access, 7 commits behind origin/main, plus untracked generated/ and tofu/. The edits would have reverted merged work: gutting fragments/kubernetes.dhall and fragments/core.dhall, deleting scripts/ts_auth.py and tests/, and re-adding the tofu/ path that #9 deliberately removed.

Preserved verbatim as a signed commit on wip/dirty-worktree-20260822 (b22420e), pushed to origin. Nothing was stashed or discarded; this branch is available for operator triage. This PR then branched from a clean origin/main (a44dc90).

Not merging

Held for the ratified adversarial-review gate: agent review, then operator review.

Refs: TIN-2626, TIN-3970

The Tailscale k8s operator creates egress proxy devices for the
ExternalName Services in tinyland-staging that front node_exporter on
relay-1 and petting-zoo-mini. Those proxies inherit the operator's
PROXY_TAGS (tag:k8s), and no ACL permits tag:k8s to reach tag:dollhouse
or tag:dev, so their flows are dropped. Verified live 2026-08-22:

  ts-relay-node-exporter-egress: flow TCP 100.65.23.86:35180 >
    100.102.229.122:9100 rejected due to acl
  ts-pzm-node-exporter-egress:   flow TCP 100.69.142.65:51002 >
    100.111.5.80:9100 rejected due to acl

Rather than widen tag:k8s, which 85 devices hold including 23 ephemeral
GitHub runner VMs, introduce a dedicated identity for these proxies,
mirroring the tag:mcp-proxy precedent from TIN-2940:

  * tag:k8s-egress-nodeexporter, owned by tag:k8s-operator so the
    operator can mint it for the proxies it creates
  * a grant scoped to tcp:9100 on three pinned host aliases

tinyland-neo is included to prevent a regression. The neo egress proxy
reaches neo today only because neo itself holds tag:k8s and the
tag:k8s to tag:k8s rule covers it; once that proxy moves to the
dedicated tag it would lose the path without this grant.

Reachability is expressed only as a port-scoped grant, never as an ACL
src, so it cannot silently widen past tcp:9100. Contract tests pin the
owner, the port, the host IPs, and the absence of a broad ACL rule; all
four were mutation-tested to confirm they can fail.

This is the tailnet half. The proxies do not carry the new tag yet;
the blahaj-side Service annotations are a required follow-up.

Refs: TIN-2626, TIN-3970
@Jesssullivan

Copy link
Copy Markdown
Owner Author

Reviewer note: residual risk — no server-side policy validation was possible locally

Flagging this explicitly rather than letting it be discovered at deploy time.

The gap. scripts/push.py --dry-run — what both the PR validate job and the CD Show diff step run — only fetches the live ACL and diffs it locally. It never calls Tailscale's POST /acl/validate. So nothing in the PR pipeline checks the policy's grammar server-side; the first real server-side parse happens in CD's Push ACL to Tailscale step, on main, after merge.

What I tried. Both local credential paths failed:

credential result
TAILSCALE_OAUTH_CLIENT_SECRET_FILE (OAuth exchange succeeded) 403 calling actor does not have enough permissions to perform this function
TAILSCALE_API_KEY_FILE 401 API token invalid — expired (admin API keys cap at 90 days)

The specific construct at risk. This grant is the repo's first use of a host alias in a grant dst:

"dst": ["tinyland-relay-1", "tinyland-petting-zoo-mini", "tinyland-neo"]

The existing tag:mcp-proxy grant uses a host alias in src and a tag in dst, so the dst form here has no in-repo precedent.

Why I still believe it is correct. Grants and ACL rules share one destination grammar — grants just move the port out of dst and into a separate ip field. fragments/kubernetes.dhall already ships host aliases as ACL destinations ("tinyland-loki-observability:3100", "tinyland-grafana-observability:3000") and those are live. The three aliases are defined in the hosts section of the same generated policy.

Suggested gate before merge — run once with a credential that has ACL read/write:

nix develop --command just build
python3 - <<'PY'
import os, sys, json, urllib.request, urllib.error
sys.path.insert(0, "scripts")
from ts_auth import resolve_bearer
tok = resolve_bearer(os.environ["TAILSCALE_API_KEY"])
req = urllib.request.Request(
    "https://api.tailscale.com/api/v2/tailnet/taila4c78d.ts.net/acl/validate",
    data=json.dumps(json.load(open("generated/policy.json"))).encode(),
    method="POST")
req.add_header("Authorization", f"Bearer {tok}")
req.add_header("Content-Type", "application/json")
try:
    with urllib.request.urlopen(req) as r:
        print("HTTP", r.status, "->", r.read().decode() or "(empty = VALID)")
except urllib.error.HTTPError as e:
    print("HTTP", e.code, "->", e.read().decode()[:1000])
PY

An empty 200 body means the policy parses. If it rejects host aliases in a grant dst, the fix is mechanical — swap the three aliases for their literal IPs (100.102.229.122, 100.111.5.80, 100.67.93.34), which the hosts entries already pin and test_node_exporter_egress_targets_resolve_to_pinned_hosts already asserts.

Worth fixing separately (out of scope here): that this repo auto-deploys on merge while its only pre-merge check is a local diff is a structural gap, not specific to this PR. Adding an acl/validate call to the CI validate job would close it for every future change.

@github-actions

Copy link
Copy Markdown

⚠️ ACL Validation

Validation

tailnet-acl dev shell
  dhall:         1.42.3
  dhall-to-json: 1.7.12
  just:          just 1.46.0
Loading local policy from /home/runner/work/tailnet-acl/tailnet-acl/generated/policy.json ...
Fetching live ACL from taila4c78d.ts.net ...
API error 401: {"message":"API token invalid"}

Diff against live

tailnet-acl dev shell
  dhall:         1.42.3
  dhall-to-json: 1.7.12
  just:          just 1.46.0
Fetching live ACL ...
API error 401: {"message":"API token invalid"}

@Jesssullivan

Copy link
Copy Markdown
Owner Author

Two findings from verification, both pre-existing and neither caused by this PR

Raising these here because the first one blocks this PR from actually deploying, and the second is direct evidence that the follow-up half of this change is the step that historically gets dropped.


1. BLOCKING — the ACL deploy credential looks expired; merging will not deploy

The Validate against live ACL check on this PR failed:

API error 401: {"message":"API token invalid"}
##[error]Process completed with exit code 1

This is not the advisory "local policy differs from live" path — the workflow catches that string and downgrades it to a warning. This is a hard auth failure before any diff happens.

It is new, and not mine. The same check passed on the previous two PRs:

PR merged Validate against live ACL
#18 2026-07-16 success
#19 2026-07-27 success
#22 (this) failure (401)

Last successful CD run was 2026-07-27. Admin API keys cap at 90 days, so an expiry in the intervening ~4 weeks fits exactly.

Why this blocks the merge. cd.yml's Push ACL to Tailscale step uses the same secrets.TAILSCALE_API_KEY, and unlike the validate and diff steps it has no continue-on-error / || true. If the credential is dead, merging produces a failed CD run and no policy change — the grant would look landed while the proxies stay blocked.

Evidence the secret is a direct key, not the OAuth client secret. ci.yml's validate step passes only TAILSCALE_API_KEY; it never maps TS_OAUTH_CLIENT_ID (only cd.yml does). If the secret began with tskey-client-, ts_auth.resolve_bearer would have raised its explicit RuntimeError about the missing client id. We got a Tailscale-side 401 instead, which only happens on the pass-through path — so the stored value is a tskey-api-… key that the API now rejects. My local tskey-api-… credential returns the identical 401.

Suggested fix, in order:

  1. Rotate secrets.TAILSCALE_API_KEY to the non-expiring OAuth client secret that feat(cd): accept a non-expiring OAuth client secret for ACL push #15/fix(cd): OAuth token exchange requires client_id — read TS_OAUTH_CLIENT_ID #16 introduced support for, and confirm the TS_OAUTH_CLIENT_ID repo variable is set.
  2. Map TS_OAUTH_CLIENT_ID into ci.yml's validate step too, so PR validation stops silently depending on a credential shape that CD does not use. Right now the two workflows authenticate differently, which is why this rotted without anyone noticing.
  3. Re-run this PR's checks; only then merge.

2. The tag:mcp-proxy precedent this PR mirrors was never completed — its grant is inert today

I cited TIN-2940 / #18 as precedent. Verifying it, the tailnet half landed and the cluster half never did:

devices carrying tag:mcp-proxy: 0

The MCP Services were never annotated, so their proxies took the operator default instead:

$ kubectl -n mcp-services get svc -o json | ...
mcp-arxiv        | tailscale.com/tags = <ABSENT>
mcp-duckduckgo   | tailscale.com/tags = <ABSENT>
mcp-fetch        | tailscale.com/tags = <ABSENT>
mcp-paper-search | tailscale.com/tags = <ABSENT>
mcp-wikipedia    | tailscale.com/tags = <ABSENT>

So the grant shipped by #18

{"src": ["tinyland-honey"], "dst": ["tag:mcp-proxy"], "ip": ["tcp:8080"]}

— matches zero devices. And honey does not reach them by any other route: honey is ['tag:dollhouse','tag:subnet-router','tag:switch'], and no rule carries those tags to tag:k8s:8080 (the tag:dollhouse → tag:k8s rules cover only 6443/9345/30443/10250/2379-2380 and 4222/8333/8888/9333). Probed live from honey:

100.92.195.95:8080 (mcp-fetch-1)  blocked/closed
100.70.21.43:8080  (mcp-arxiv-1)  blocked/closed

Implication for this PR. The structure is sound — this is exactly how a dedicated proxy identity should be modelled — but TIN-2940 shows the two-part change has a real failure mode in this repo: part one merges, part two is forgotten, and the policy reads as if the access exists when it does not. That is why the tinyland.dev annotation is called out as required, not optional, and why the verification plan is written in terms of up{job=...} flipping rather than "the grant merged."

Worth its own issue: either finish TIN-2940 by annotating the five MCP Services with tailscale.com/tags: "tag:mcp-proxy", or drop the inert grant and tag:mcp-proxy from the policy. Leaving it as-is means the ACL claims an access path that does not exist.


Neither finding changes the diff in this PR. Finding 1 is a merge prerequisite; finding 2 is a follow-up and a caution.

@Jesssullivan Jesssullivan left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adversarial review — CHANGES NEEDED

Posted as a review comment: GitHub refuses REQUEST_CHANGES from the PR author’s own token. Treat this as a blocking review — do not merge until items 1–4 below are cleared.

Reviewed from an independent detached worktree at 34271d7, rebuilt with the repo's own tooling, and cross-checked against the live tailnet and the live cluster. Not an approval — the operator approves.

Verdict: the diff is clean and correctly scoped, but the change is a no-op on merge, and the only path that makes it work is undocumented here and fleet-breaking if done the obvious way. Two of the PR's stated findings are also mis-stated in a way that would cause harm if acted on.


1. Reproduction — delta confirmed, no smuggled changes

Rebuilt generated/policy.json at a44dc90 (main) and at 34271d7 (head) with just build, using the flake toolchain (dhall 1.42.3, dhall-json 1.7.12). The diff is exactly what the PR claims and nothing more:

tagOwners 25 -> 26   + tag:k8s-egress-nodeexporter: [tag:k8s-operator, autogroup:admin, group:dollhouse-admins]
grants     9 -> 10   + {src:[tag:k8s-egress-nodeexporter], dst:[tinyland-relay-1, tinyland-petting-zoo-mini, tinyland-neo], ip:[tcp:9100]}
hosts      6 ->  9   + tinyland-neo / tinyland-petting-zoo-mini / tinyland-relay-1
acls      41 -> 41   (unchanged)
ssh        8 ->  8   (unchanged)
groups/nodeAttrs/autoApprovers  unchanged

dhall type OK · dhall format --check exit 0 · 7/7 contract tests pass · structural validation passes. Independently confirmed.

Host pinning is correct. I resolved all three against live MagicDNS:

alias pinned live device tags
tinyland-relay-1 100.102.229.122 relay-1.taila4c78d.ts.net tag:dollhouse
tinyland-petting-zoo-mini 100.111.5.80 tag:dev, tag:dollhouse
tinyland-neo 100.67.93.34 includes tag:k8s

Root cause in the PR body is correct. Verified live: all three egress proxies (tinyland-staging-{neo,pzm,relay}-node-exporter-egress) carry exactly ['tag:k8s']; relay-1 and pzm do not hold tag:k8s; ACL rule #8 is tag:k8s -> tag:k8s:*, which is precisely why neo scrapes and the other two don't. Rejecting src=[tag:k8s] was right — census says 85 devices hold it.

Including tinyland-neo in dst is not speculative — it is required. Once a proxy is retagged it loses tag:k8s and therefore loses rule #8, so without neo in dst the retag would break the one target that works today. Correct call.

Public-repo hygiene: clean. Only 100.64/10 CGNAT addresses (9) plus 10.0.0.0 / 192.168.0.0 network placeholders. No key material, no credentials, no external hostnames. gitleaks green.

Dirty-tree disposition: verified, claim holds. wip/dirty-worktree-20260822 = b22420e, GPG-signed (G), on origin, parented at 15d37c9 on fix/emergency-ssh-admin-access. 13 paths (10 tracked-modified + generated/policy.json + tofu/main.tf + tofu/variables.tf) — matches the report. Nothing was lost.


2. BLOCKING — on merge this changes nothing, and the follow-up is a fleet hazard

Live tag census across the whole tailnet:

tag:k8s-egress-nodeexporter   0 devices
tag:mcp-proxy                 0 devices

Merging this alters zero reachability. That alone would just be "part one of two". The problem is what part two actually requires:

  • Operator is tailscale/k8s-operator:v1.94.2 with PROXY_TAGS = tag:k8s — a single, global env var on the operator Deployment.
  • All 27 tailscale-managed Services in the cluster share the one ProxyClass honey-sting-tailnet: ingress-nginx-controller, massageithaca-prod, nix-cache/bazel-cache-grpc, gf-rbe/gf-reapi-cell, the entire LGTM stack (loki/grafana/mimir/tempo/pyroscope/otlp), all five mcp-services, and the three node_exporter egress proxies.
  • I checked the live proxyclasses.tailscale.com CRD: ProxyClass v1alpha1 has no tags field. There is no per-class lever. The only per-proxy lever is the tailscale.com/tags Service annotation.

So the two available paths are:

  1. Change PROXY_TAGS — the obvious move, and it is catastrophic. 85 devices lose tag:k8s, ACL rule #8 (tag:k8s -> tag:k8s:*) collapses fleet-wide, and every ingress above goes dark.
  2. Annotate only the three egress Services with tailscale.com/tags: tag:k8s-egress-nodeexporter — the correct path, but this PR never states it, and its behavior on egress Services (type: ExternalName + tailscale.com/tailnet-fqdn, no tailscale.com/expose) is not demonstrated anywhere in this PR. Those Services also live in a different repo, so nothing links the two halves.

The PR contains no warning about path 1. Given the PR's own evidence that part two gets dropped here — tag:mcp-proxy has been inert since TIN-2940 landed 2026-07-16, confirmed 0 devices — shipping the ACL half with an unstated, fleet-breaking obvious-path is the thing to fix before merge.

To clear this: state the annotation explicitly in the PR body (or a repo note), prove tailscale.com/tags is honored on one egress Service on v1.94.2 before relying on it, add an explicit "do not touch PROXY_TAGS" warning, and link the manifest change so part two is tracked rather than remembered.


3. Finding #1 in the PR body is misdiagnosed — and acting on it as written would break CD

The PR concludes from the CI 401 that "the deploy credential appears expired… merging yields a failed CD run". CI and CD do not read the same secret.

TAILSCALE_API_KEY is defined twice:

scope created updated
repo-level 2026-02-24T18:43:17Z 2026-06-24T21:39:43Z
production environment 2026-02-24T18:43:31Z 2026-06-24T21:39:41Z

ci.yml's validate job has no environment:, so it reads the repo-level secret. cd.yml's deploy job has environment: production, so the environment secret overrides it. The 401 you observed is evidence about the repo-level secret only. It is not evidence about the credential CD uses. (They were co-rotated 2s apart and are plausibly the same value — but that was never established, and the PR reasons as if the two scopes were one.)

And the implied remediation is a live landmine. cd.yml passes TS_OAUTH_CLIENT_ID: ${{ vars.TS_OAUTH_CLIENT_ID }}. I checked both scopes:

repo variables:                {"variables": [], "total_count": 0}
production env variables:      {"variables": [], "total_count": 0}

TS_OAUTH_CLIENT_ID is set nowhere. So the non-expiring-OAuth path built by #15/#16 has never actually been exercised — every CD run since has succeeded because the secret is a direct tskey-api- key that resolve_bearer passes through untouched. If someone reads "rotate before merging" and drops in a tskey-client- OAuth secret, ts_auth.resolve_bearer raises RuntimeError, push.py returns 1, and CD hard-fails — the exact silent non-deploy the PR is warning about, caused by the fix.

Do this instead: read the actual expiry off the Tailscale admin keys page rather than inferring it from a 401 in a different secret scope. If rotating, either mint a fresh tskey-api- key, or set the TS_OAUTH_CLIENT_ID repo variable first and only then swap in the client secret.

Separately, ci.yml is structurally wrong and will stay red even after a correct rotation. Its validate job never maps TS_OAUTH_CLIENT_ID at all. Post-OAuth-rotation it would raise RuntimeError, which does not emit the Validation FAILED: local policy differs from live. string the workflow greps for, so it falls through to exit "$EXIT_CODE" = 1. Fix ci.yml to mirror cd.yml (environment: production + TS_OAUTH_CLIENT_ID) so the two stop diverging.


4. Scope analysis of the grant — tight, with two caveats

Attacked for over-breadth; it holds up.

  • src breadth. tag:k8s-egress-nodeexporter is mintable only by tag:k8s-operator, autogroup:admin, group:dollhouse-admins — same owner set as tag:mcp-proxy. Tailscale tags do not inherit, so no other tag picks this up transitively. Bounded and correct.
  • dst breadth. Three host aliases, each a bare /32 (no CIDR, no wildcard, no autogroup:). Grants match dst by IP, not by identity — if a device is deleted and its 100.x address later recycled, the grant silently follows the address. Low probability; worth knowing.
  • ip breadth. tcp:9100 only. No UDP, no ranges, no *. Grants are independent of acls, so this cannot be widened by an ACL rule elsewhere.
  • Net effect is a narrowing. Today the neo path runs through tag:k8s -> tag:k8s:*all ports to 85 devices. After retag it becomes tcp:9100 to three hosts. Strictly better.
  • No future-device surprise beyond the intended semantic: anything the operator tags with this gets tcp:9100 to those three, nothing else.

Grant-dst grammar — better evidence than "unproven". The live policy already carries {"src":["tinyland-honey"], "dst":["tag:mcp-proxy"], "ip":["tcp:8080"]}, in which a host alias is used in grant src and was accepted by a real POST /acl (CD run for #18, 2026-07-16, success). Host aliases are therefore proven valid in grant selectors on this tailnet; dst symmetry is near-certain. Downgrade that residual-risk note.

MagicDNS drift trap (pre-existing, worth an issue). relay-1 is a collision-suffixed name: there is a stale device relay at 100.114.248.77, untagged, offline since 2026-04-10. If that stale device is deleted, Tailscale frees the base name and today's relay-1 can become relay. The ACL is safe (it pins the IP), but the egress Service is annotated tailnet-fqdn: relay-1.taila4c78d.ts.netthat name is unstable, so the proxy would silently repoint while the grant kept pointing at 100.102.229.122. Reaping the dead relay device is the cheap fix.


5. CD safety on merge

Answering "could a bad policy lock the operator out": not from this diff. It touches no ssh rule, no admin ACL, and is purely additive; Tailscale rejects unparseable policies server-side before applying. The systemic risks are real but pre-existing:

  • push.py does a full-policy POST with no If-Match/ETag — a blind overwrite of live.
  • CD's Validate against live ACL is continue-on-error: true and Show diff ends in || true, so drift is never a gate anywhere in the pipeline. If live has been hand-edited in the admin console since the last CD success (2026-07-27, a44dc903), merging silently reverts it — and right now nobody can tell, because validation can't authenticate.
  • CI's validate is advisory by construction, not a gate: it catches the Validation FAILED string and downgrades to a warning. The only thing that reddens it is a hard error — which is why the 401 is visible at all.

Adding If-Match and making CD's validate blocking on drift (while staying advisory on expected diff) is the durable fix. Out of scope for this PR; worth an issue.


6. Test quality — one weak assertion

test_node_exporter_egress_tag_gets_no_broad_acl_access compares whole src lists:

srcs = [r["src"] for r in self.policy["acls"]]
self.assertNotIn(["tag:k8s-egress-nodeexporter"], srcs)

This only catches a rule whose src is exactly that one-element list. An ACL rule with src: ["tag:dollhouse", "tag:k8s-egress-nodeexporter"] grants the broad access the test exists to forbid and the test still passes. Make it a membership check:

self.assertFalse(any("tag:k8s-egress-nodeexporter" in r["src"] for r in self.policy["acls"]))

The other three new tests are sound regression guards and I confirmed they fail under mutation. Note they pin repo state against literals — they cannot detect the tailnet drifting out from under the pinned IPs; only a live check can, which is the gate that's currently broken.


Summary — what to change before merge

  1. State the part-two mechanism in the PR: the per-Service tailscale.com/tags annotation on the three egress Services, plus an explicit "do not change PROXY_TAGS" warning (85 devices, all 27 proxies, one shared ProxyClass, no tags field on ProxyClass v1alpha1).
  2. Prove tailscale.com/tags is honored on an egress Service on operator v1.94.2 before depending on it.
  3. Link part two so it can't go the way of tag:mcp-proxy.
  4. Correct finding #1: CI reads the repo-level secret, CD reads the production environment secret. Check the real expiry in the Tailscale admin console. If rotating to OAuth, set TS_OAUTH_CLIENT_ID first — it is currently unset in both scopes and CD will hard-fail without it.
  5. Fix ci.yml's validate job to mirror cd.yml so CI and CD stop authenticating differently.
  6. Tighten test_node_exporter_egress_tag_gets_no_broad_acl_access to a membership check.

Items 1–4 are pre-merge. 5, 6 and the If-Match / dead-relay-device items can be follow-ups.

The Dhall, the generated delta, the host pinning, the grant scope, and the WIP preservation are all correct and independently verified. The objection is to landing this as a completed fix while the half that does the work is unstated, unproven, and one obvious wrong move away from taking down every ingress on the cluster.

@Jesssullivan

Copy link
Copy Markdown
Owner Author

Part 2 is up: tinyland-inc/tinyland.dev#821

The review here flagged that this grant matches zero devices as written, because nothing tags the egress proxies. That half now exists: https://github.com/tinyland-inc/tinyland.dev/pull/821 adds tailscale.com/tags: "tag:k8s-egress-nodeexporter" to the three ExternalName Services in infra/staging/observability-tailnet-services.yaml.

Both PRs are held. This one merges first — see ordering below.

The annotation mechanism is now proven, not assumed

The open question was whether tailscale.com/tags does anything on egress (ExternalName + tailnet-fqdn) Services, since the docs only describe it for ingress. Verdict from reading the source at the version actually running in-cluster (tailscale/k8s-operator:v1.94.2):

  • Yes, it applies to egress. svc.go#L261-L275 reads the annotation into tailscaleSTSConfig.Tags before the ingress/egress branch; the egress branch populates the same struct. Nothing about it is ingress-specific.
  • Tags land via the auth key. The single consumer of stsC.Tags is newAuthKeycaps.Devices.Create.Tags, i.e. baked into device identity at registration. There is no SetDeviceTags-style call anywhere in the operator.
  • ProxyClass really has no tags field in v1.94.2 — confirmed upstream and against the live CRD. So the earlier conclusion holds: the per-Service annotation is the only per-proxy lever, and PROXY_TAGS remains untouchable (85 devices).

Two things the review should know

1. Ordering is load-bearing, and this PR is the hard prerequisite. The operator applies tags by minting a pre-authorized auth key naming them. The Tailscale API rejects a key requesting a tag that does not exist or is not owned by tag:k8s-operator. So if #821 lands before this ACL is live, proxy provisioning does not silently no-op — it fails closed, and the three Services sit unprovisioned with ProxyReady=False. The tag:k8s-operator ownership added here is exactly what makes the mint legal.

2. Tagging is create-time only — neither PR is self-executing. provisionSecrets mints a key only if orig == nil, i.e. only when the proxy's state Secret is absent. All three proxies already have state Secrets, and the headless-Service name (hence the Secret name) is stable across reconciles, so every future reconcile reuses the old key. Live check confirms it:

ts-relay-node-exporter-egress-rz5zl-0   tags: ['tag:k8s']
ts-neo-node-exporter-egress-2vqxs-0     tags: ['tag:k8s']

Merging both PRs and applying still leaves the devices on tag:k8s. A one-time reprovision is required — deleting the three Services and letting the apply reconverge them, which also runs the operator finalizer → DeleteDevice so the old tag:k8s devices are cleaned off the tailnet rather than orphaned. Full ceremony is in the #821 body.

Sequence

  1. Merge + apply this ACL (tag exists and is owned).
  2. Merge + apply tinyland.dev#821 (Services carry the annotation).
  3. Run the reprovision ceremony (devices re-register tagged).
  4. Expect up{job="node-relay"} and up{job="node-pzm"} to flip 0 → 1.

Nothing has been merged or applied. Refs TIN-2626, TIN-3970.

@Jesssullivan

Jesssullivan commented Aug 22, 2026

Copy link
Copy Markdown
Owner Author

Blocked on a credential-wiring problem, not on this PR — see #23

This PR's validate job is red for a reason that has nothing to do with the
grant. Run 32587939240:

API error 401: {"message":"API token invalid"}

Investigating that turned up a wiring trap worth fixing before this merges:

  • TAILSCALE_API_KEY exists in two scopes under one name — a repository
    secret (what validate here reads) and a production environment secret
    (what cd.yml's deploy reads, since that job declares
    environment: production). So this PR's red validate was never evidence
    about the deploy path, and rotating one scope would leave the other dead with
    no signal.
  • Update: fix(ci): read Tailscale credentials from one scope, fail fast, validate server-side #23's own CI, which is the first thing to exercise the
    production scope since 2026-07-27, shows that key returns 401 as well.
    Both scopes are dead. Merging this PR today would push the grant into a
    CD job that cannot authenticate — the current wiring just has no way to tell
    you until after the merge. Rotation is required regardless of fix(ci): read Tailscale credentials from one scope, fail fast, validate server-side #23.
  • cd.yml references vars.TS_OAUTH_CLIENT_ID, but repo variables and
    production environment variables are both total_count = 0 — it is set
    nowhere. Swapping the secret to the non-expiring OAuth form
    (tskey-client-) would then fail the push with a RuntimeError from
    ts_auth.resolve_bearer. ci.yml never mapped it at all.

#23 puts both jobs on one scope and one variable set, adds a fail-fast
preflight that names the exact secret/variable and the scope it must live in,
and adds the pre-merge server-side grammar check this repo has never had
(POST /acl/validatepush.py --dry-run only diffs locally).

Suggested order: merge #23 → rotate the credential into the production
environment (kind and location are in #23's body) → re-run CI here → merge this
once validate is green, which will then actually mean the deploy credential
works.

Nothing in #23 touches .dhall or grants.json, so it has no interaction with
the grant proposed here beyond the ordering.

@Jesssullivan

Copy link
Copy Markdown
Owner Author

Status update: #23 merged, credential rotation still pending — no code change needed here

#23 merged 2026-08-22 20:31 UTC (unifies both jobs onto the production
environment scope + fail-fast preflight, per the ordering suggested above).

Checked whether the operator has rotated TAILSCALE_API_KEY since:

GET /repos/Jesssullivan/tailnet-acl/environments/production/secrets
"updated_at": "2026-06-24T21:39:41Z"

Unchanged — still the same value that returned 401 in both this PR's run
(32587939240, 17:33Z) and #23's own CI. So this PR's red validate check is
stale evidence of a still-live problem, not evidence of anything wrong with
this PR or with #23's fix.

Nothing pending here is a code change. The remaining step is purely
operator-side: rotate TAILSCALE_API_KEY in the repo's production
environment (Settings -> Environments -> production), per the location/kind
#23 documents. Once that's done, re-run validate on this PR (or push an
empty commit) and it should go green on the credential the CD path will
actually use.

@Jesssullivan

Copy link
Copy Markdown
Owner Author

Hygiene-sweep check on the #23 follow-through: rotation is still pending.

production environment secret TAILSCALE_API_KEYupdated_at: 2026-06-24T21:39:41Z, unchanged since before #23 merged (2026-08-22T20:31Z). The repo-scoped duplicate TAILSCALE_API_KEY also still exists (updated_at: 2026-06-24T21:39:43Z) — #23 asked for it to be deleted once the environment copy is confirmed good, and it hasn't been.

Per #23's own rotation table, both are still the dead tskey-api-… key from the incident. This PR's Validate against live ACL failure (run 32587939240) is consistent with that — it isn't a defect in this PR, it's the same dead credential #23 diagnosed.

Precisely what's pending before this can go green and merge, per #23:

  1. Rotate TAILSCALE_API_KEY in Settings → Environments → production (OAuth client-secret preferred, or a fresh tskey-api-… as the interim).
  2. If using OAuth, also set the TS_OAUTH_CLIENT_ID environment variable (currently 0 variables in production).
  3. Delete the repository-scoped TAILSCALE_API_KEY so the two-copy trap fix(ci): read Tailscale credentials from one scope, fail fast, validate server-side #23 fixed can't recur.
  4. Re-run validate on this PR — a green run is now evidence the deploy credential itself works.

Not rotating this myself (operator credential action). No merge action taken here — holding per #23's stated order (#23 → rotate → green #22 → merge #22).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant