Skip to content

Allow tailnet members to route to internet via exit nodes - #10

Merged
nickvigilante merged 1 commit into
mainfrom
tailscale-acl-internet-via-exit
May 17, 2026
Merged

Allow tailnet members to route to internet via exit nodes#10
nickvigilante merged 1 commit into
mainfrom
tailscale-acl-internet-via-exit

Conversation

@nickvigilante

@nickvigilante nickvigilante commented May 17, 2026

Copy link
Copy Markdown
Owner

Summary

Adds an ACL rule permitting autogroup:member → autogroup:internet:*. Without it, Tailscale's control plane strips Hostinfo.RoutableIPs from peer netmaps (i.e., hides exit-node advertisements) as a "this peer can't use these routes anyway per ACL" optimization — even when the device is correctly advertising and routes are approved.

Diagnosis chain (why this matters)

  1. pi-zero-exit re-advertised with --advertise-exit-node.
  2. tailscale exit-node list from gandalf / iPhone / MacBook → "no exit nodes found."
  3. AutoApprovers (Auto-approve Tailscale exit-node routes from tag:homelab devices #9) was applied and verified live via the Tailscale ACL API.
  4. Manual UI toggle of "Use as exit node" attempted multiple times.
  5. Tailscale API GET /api/v2/device/{id}/routes confirmed enabledRoutes: ["0.0.0.0/0", "::/0"]. Approval is in.
  6. Gandalf's netmap view of pi-zero-exit's Hostinfo: no RoutableIPs field at all.
  7. pi-zero-exit's local SelfNode: RoutableIPs: ["0.0.0.0/0","::/0"] present, prefs / kernel forwarding / etc all clean.
  8. Concluded: control plane has the approval but is filtering the advertisement out of peer netmaps based on the ACL not authorizing the egress destinations.

Validation

The rule was first added via the Tailscale admin UI during diagnosis; this PR codifies that change in HCL so IaC is source-of-truth-consistent. tofu apply from this branch is a no-op.

  • tofu validate green.
  • tofu plan shows zero changes (state already matches after manual UI edit).
  • tofu apply clean: 0 added, 0 changed, 0 destroyed.
  • pi-zero-exit retired (hardware EOL); gandalf now advertises exit-node and is visible in the MBP's exit-node selector — proving the rule unblocks the netmap-advertisement path.
  • Existing tag:homelab → tag:homelab:* traffic still works (k3s cluster reachable; 3 nodes Ready).

Security note

Scoped to autogroup:member (operator's personal devices). Homelab-tagged servers can still only reach other homelab-tagged servers — they don't get internet egress through other tailnet exit nodes.

Adds an ACL rule permitting `autogroup:member → autogroup:internet:*`.
Without it, Tailscale's control plane strips `Hostinfo.RoutableIPs`
from peer netmaps (i.e., hides the exit-node advertisement) as a
"this peer can't actually use these routes anyway per ACL"
optimization — even when:
  - the device advertises with `--advertise-exit-node`,
  - autoApprovers in this policy auto-approves the routes,
  - per-machine `enabledRoutes` confirms the routes are approved.

Diagnosed today: pi-zero-exit's exit-node advertisement was correctly
landing in the control plane (verified via the Tailscale API:
`enabledRoutes` showed both 0.0.0.0/0 and ::/0) but `tailscale
exit-node list` from gandalf/iPhone/MacBook reported "no exit nodes
found." Tracing the netmap difference between pi-zero-exit's SelfNode
(routes present) and gandalf's view of pi-zero-exit as a peer (Hostinfo
sans RoutableIPs) pointed at this ACL filter behavior.

Scoped tightly: only `autogroup:member` gets the internet egress
permission — homelab-tagged servers can still only reach other
homelab-tagged servers. Members are personal devices the operator
controls (per the existing tagOwners model).
@github-actions

Copy link
Copy Markdown

homelab tofu plan

Plan output
tailscale_dns_nameservers.global: Refreshing state... [id=34619e51-87ec-b5df-c078-fd2e3181d9c5]
tailscale_dns_preferences.main: Refreshing state... [id=ad9f64d5-f380-ae42-4811-7604bfdb5bcd]
tailscale_acl.main: Refreshing state... [id=acl]
data.github_user.self: Reading...
github_repository.managed["infrastructure"]: Refreshing state... [id=infrastructure]
github_repository.managed["passgen"]: Refreshing state... [id=passgen]
github_repository.managed["tuile"]: Refreshing state... [id=tuile]
github_repository.managed["homelab"]: Refreshing state... [id=homelab]
github_repository.managed["dotfiles"]: Refreshing state... [id=dotfiles]
github_repository.managed["vale-languagetool"]: Refreshing state... [id=vale-languagetool]
github_repository.managed["puzzles"]: Refreshing state... [id=puzzles]
github_repository.managed["styleguide"]: Refreshing state... [id=styleguide]
github_repository.managed["tools"]: Refreshing state... [id=tools]
github_repository.managed["docs"]: Refreshing state... [id=docs]
data.github_user.self: Read complete after 3s [id=13631471]
github_actions_secret.tailscale_oauth_client_id: Refreshing state... [id=infrastructure:TAILSCALE_OAUTH_CLIENT_ID]
github_actions_secret.tailscale_oauth_client_secret: Refreshing state... [id=infrastructure:TAILSCALE_OAUTH_CLIENT_SECRET]
github_actions_secret.github_app_installation_id: Refreshing state... [id=infrastructure:TF_GITHUB_APP_INSTALLATION_ID]
github_actions_secret.github_app_private_key: Refreshing state... [id=infrastructure:TF_GITHUB_APP_PRIVATE_KEY]
github_actions_secret.github_app_id: Refreshing state... [id=infrastructure:TF_GITHUB_APP_ID]
github_repository_environment.homelab_apply: Refreshing state... [id=infrastructure:homelab-apply]
github_branch_protection.main["tools"]: Refreshing state... [id=BPR_kwDOCpIRFs4CjUwo]
github_branch_protection.main["homelab"]: Refreshing state... [id=BPR_kwDOSZ4rVc4Ek1bm]
github_branch_protection.main["tuile"]: Refreshing state... [id=BPR_kwDOSB7YJc4Ek1LN]
github_actions_secret.aws_access_key_id: Refreshing state... [id=infrastructure:AWS_ACCESS_KEY_ID]
github_actions_secret.aws_secret_access_key: Refreshing state... [id=infrastructure:AWS_SECRET_ACCESS_KEY]
github_branch_protection.main["docs"]: Refreshing state... [id=BPR_kwDOSFgwGs4Ek1Ll]
github_branch_protection.main["vale-languagetool"]: Refreshing state... [id=BPR_kwDONCBpAs4Ek1LO]
github_branch_protection.main["infrastructure"]: Refreshing state... [id=BPR_kwDOSZwzas4Ek1LQ]
github_branch_protection.main["passgen"]: Refreshing state... [id=BPR_kwDOPAgUIM4Ek1Lp]
github_branch_protection.main["puzzles"]: Refreshing state... [id=BPR_kwDOSMxZ4c4Ek1Lo]
github_branch_protection.main["styleguide"]: Refreshing state... [id=BPR_kwDONEbTDs4Ek1Lj]
github_branch_protection.main["dotfiles"]: Refreshing state... [id=BPR_kwDOKUP2984EclBF]
github_repository_vulnerability_alerts.alerts["vale-languagetool"]: Refreshing state... [id=874539266]
github_repository_vulnerability_alerts.alerts["styleguide"]: Refreshing state... [id=877056782]
github_repository_vulnerability_alerts.alerts["tuile"]: Refreshing state... [id=1209980965]
github_repository_vulnerability_alerts.alerts["tools"]: Refreshing state... [id=177344790]
github_repository_vulnerability_alerts.alerts["passgen"]: Refreshing state... [id=1007162400]
github_repository_vulnerability_alerts.alerts["docs"]: Refreshing state... [id=1213739034]
github_repository_vulnerability_alerts.alerts["homelab"]: Refreshing state... [id=1235102549]
github_repository_vulnerability_alerts.alerts["dotfiles"]: Refreshing state... [id=692319991]
github_repository_vulnerability_alerts.alerts["infrastructure"]: Refreshing state... [id=1234973546]
github_repository_vulnerability_alerts.alerts["puzzles"]: Refreshing state... [id=1221351905]

OpenTofu used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  ~ update in-place

OpenTofu will perform the following actions:

  # tailscale_acl.main will be updated in-place
  ~ resource "tailscale_acl" "main" {
      ~ acl = jsonencode(
          ~ {
              ~ acls          = [
                    # (1 unchanged element hidden)
                    {
                        action = "accept"
                        dst    = [
                            "tag:homelab:*",
                        ]
                        src    = [
                            "tag:homelab",
                        ]
                    },
                  + {
                      + action = "accept"
                      + dst    = [
                          + "autogroup:internet:*",
                        ]
                      + src    = [
                          + "autogroup:member",
                        ]
                    },
                ]
                # (4 unchanged attributes hidden)
            }
        )
        id  = "acl"
    }

Plan: 0 to add, 1 to change, 0 to destroy.

Warning: Deprecated attribute

  on github.tf line 32, in resource "github_repository" "managed":
  32:     ignore_changes = [vulnerability_alerts]

The attribute "vulnerability_alerts" is deprecated. Refer to the provider
documentation for details.

Warning: Argument is deprecated

  with github_actions_secret.tailscale_oauth_client_id,
  on github.tf line 85, in resource "github_actions_secret" "tailscale_oauth_client_id":
  85:   plaintext_value = var.tailscale_oauth_client_id

Use value.

(and 6 more similar warnings elsewhere)

─────────────────────────────────────────────────────────────────────────────

Saved the plan to: tfplan

To perform exactly these actions, run the following command to apply:
    tofu apply "tfplan"

@nickvigilante
nickvigilante merged commit db3403a into main May 17, 2026
1 check passed
@nickvigilante
nickvigilante deleted the tailscale-acl-internet-via-exit branch May 17, 2026 23:04
@nickvigilante
nickvigilante restored the tailscale-acl-internet-via-exit branch May 26, 2026 03:02
@nickvigilante
nickvigilante deleted the tailscale-acl-internet-via-exit branch May 26, 2026 03:05
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