From 35a254a86e228e5b4466351d5ac869a3bd975cd6 Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Sun, 9 Aug 2026 15:59:42 +0700 Subject: [PATCH 1/2] Give the Tailscale ACL step a working policy file (#46) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DEPLOY §7 snippet was a bare `acls` fragment: it named tags without declaring tagOwners, without saying how tags get applied, and without the rule that keeps the operator's own SSH access once the blanket accept is gone. Following it literally locks you out of the browser machine. Also records why tagging is load-bearing rather than stylistic - Tailscale has no deny, so excluding CDP from a selector that still covers your own devices requires the destination to fall outside autogroup:member/self, which is exactly what a tag does - and separates the bind proof from the ACL proof, which the old text conflated. --- DEPLOY.md | 83 +++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 75 insertions(+), 8 deletions(-) diff --git a/DEPLOY.md b/DEPLOY.md index 892f5b5..2fbe9ab 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -309,18 +309,74 @@ refuses to start rather than guess. **Narrow it to the one device that needs it.** The bind address keeps CDP off your LAN; it still leaves port 9222 open to every device on the tailnet, and -CDP has no login. Add a rule in the Tailscale admin console's access controls -so only the VPS can reach it — tag the two machines, then: +CDP has no login — a compromised phone is enough to drive this host. A new +tailnet's policy is allow-all, so this is the step that makes "Tailscale +identity is the access control" true rather than aspirational. + +Tailscale has no `deny`, so a restriction is expressed by removing the blanket +`accept` and enumerating what is left. That only works if the browser machine +can be *excluded* from a selector that still covers your own devices — which is +what tagging buys: a tagged device has no user, so `autogroup:member` and +`autogroup:self` stop matching it. Tagging is the mechanism, not decoration. + +In the admin console, under **Access controls**, replace the default rule: ```jsonc -// tailnet policy file -"acls": [ - { "action": "accept", "src": ["tag:bookmark-api"], "dst": ["tag:bookmark-browser:9222"] }, -] +{ + "tagOwners": { + // Empty list: implicitly owned by the tailnet Owner/Admins, which is you. + "tag:bookmark-api": [], + "tag:bookmark-browser": [], + }, + + "acls": [ + // The only thing on the tailnet that may drive the browser. + { + "action": "accept", + "src": ["tag:bookmark-api"], + "proto": "tcp", + "dst": ["tag:bookmark-browser:9222"], + }, + // Your own devices: everything of yours, both servers — but not CDP. + // Drop the `:22` and you have locked yourself out of the browser machine. + { + "action": "accept", + "src": ["autogroup:member"], + "dst": ["autogroup:self:*", "tag:bookmark-api:*", "tag:bookmark-browser:22"], + }, + ], + + // Saved policies are rejected if these fail, so the rule cannot rot silently. + "tests": [ + { "src": "tag:bookmark-api", "accept": ["tag:bookmark-browser:9222"] }, + { + "src": "you@example.com", + "accept": ["tag:bookmark-browser:22"], + "deny": ["tag:bookmark-browser:9222"], + }, + ], +} ``` -Without a rule the tailnet default is allow-all, so this step is what makes -"Tailscale identity is the access control" true rather than aspirational. +Then apply the tags — on the VPS and the home machine respectively: + +```bash +sudo tailscale up --advertise-tags=tag:bookmark-api +sudo tailscale up --advertise-tags=tag:bookmark-browser +``` + +Each re-authenticates in a browser and issues a new node key; the tailnet IP is +unchanged, so `BROWSER_WS_URL` and `BROWSER_BIND_ADDR` still hold. Key expiry is +disabled once a device is tagged, which is what you want for a server — an +expired key would otherwise take the poller down every few months. + +**Tagging replaces the device's user identity**, so do this only to machines +that exist to run these services. If your "home machine" is also your daily +driver, tag it anyway and reach it through the `:22` rule above, or skip the +tag and accept that any device of yours can reach CDP. + +Enforcement is by the destination's packet filter, so the check below is real, +not advisory. Prove the bind is tight, from the home machine itself: @@ -334,6 +390,17 @@ The first call is also what wakes Chrome: it is not running until something connects, and it is reaped again after five idle minutes. A cold first response takes a few seconds; that is the browser starting, not a fault. +That check proves the *bind*, not the ACL — traffic that starts on the node is +not filtered. Prove the ACL from somewhere else: on your laptop or phone the +same URL must now time out, and from the VPS it must answer. + +```bash +# on any other device of yours -> hangs until timeout +curl -s -m 5 http://:9222/json/version +# on the VPS -> JSON +curl -s -m 20 http://:9222/json/version +``` + **On the VPS:** ```bash -- 2.52.0 From 240edfb6778039e5a7b5a6ac47a813f6d9ba8654 Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Sun, 9 Aug 2026 16:05:13 +0700 Subject: [PATCH 2/2] Write the Tailscale policy in grants, not acls (#46) The console ships a grants-based default policy, so an acls example forces the operator to translate before they can use it - and the two differ in exactly the place that matters: grants carry ports in `ip` (`tcp:9222`) rather than suffixed onto `dst`. Adds the three things dropping the blanket grant silently takes away: an ssh block, since tagged devices leave `autogroup:self` and Tailscale SSH stops resolving them; a commented `autogroup:internet` grant for exit-node users; and `tcp:22` to the browser machine, without which tagging locks you out. --- DEPLOY.md | 47 ++++++++++++++++++++++++++++++++++------------- 1 file changed, 34 insertions(+), 13 deletions(-) diff --git a/DEPLOY.md b/DEPLOY.md index 2fbe9ab..6cc6daf 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -314,12 +314,13 @@ tailnet's policy is allow-all, so this is the step that makes "Tailscale identity is the access control" true rather than aspirational. Tailscale has no `deny`, so a restriction is expressed by removing the blanket -`accept` and enumerating what is left. That only works if the browser machine -can be *excluded* from a selector that still covers your own devices — which is +grant and enumerating what is left. That only works if the browser machine can +be *excluded* from a selector that still covers your own devices — which is what tagging buys: a tagged device has no user, so `autogroup:member` and `autogroup:self` stop matching it. Tagging is the mechanism, not decoration. -In the admin console, under **Access controls**, replace the default rule: +In the admin console, under **Access controls**, the shipped policy grants +`{"src": ["*"], "dst": ["*"], "ip": ["*"]}`. Replace it: ```jsonc { @@ -329,24 +330,44 @@ In the admin console, under **Access controls**, replace the default rule: "tag:bookmark-browser": [], }, - "acls": [ + "grants": [ // The only thing on the tailnet that may drive the browser. { - "action": "accept", - "src": ["tag:bookmark-api"], - "proto": "tcp", - "dst": ["tag:bookmark-browser:9222"], + "src": ["tag:bookmark-api"], + "dst": ["tag:bookmark-browser"], + "ip": ["tcp:9222"], }, - // Your own devices: everything of yours, both servers — but not CDP. - // Drop the `:22` and you have locked yourself out of the browser machine. + // Your own devices reach your own devices, and the VPS, in full. { - "action": "accept", + "src": ["autogroup:member"], + "dst": ["autogroup:self", "tag:bookmark-api"], + "ip": ["*"], + }, + // On the browser machine you get SSH and nothing else. Widen this to `*` + // and the restriction above is void; delete it and you are locked out. + { + "src": ["autogroup:member"], + "dst": ["tag:bookmark-browser"], + "ip": ["tcp:22"], + }, + // Uncomment if you route traffic through an exit node — dropping the + // blanket grant takes exit-node access with it. + // {"src": ["autogroup:member"], "dst": ["autogroup:internet"], "ip": ["*"]}, + ], + + // Tagged devices left `autogroup:self`, so Tailscale SSH needs them named. + // Irrelevant if you reach these boxes with ordinary sshd over the tailnet — + // that is the `tcp:22` grant above. + "ssh": [ + { + "action": "check", "src": ["autogroup:member"], - "dst": ["autogroup:self:*", "tag:bookmark-api:*", "tag:bookmark-browser:22"], + "dst": ["autogroup:self", "tag:bookmark-api", "tag:bookmark-browser"], + "users": ["autogroup:nonroot", "root"], }, ], - // Saved policies are rejected if these fail, so the rule cannot rot silently. + // Run on every save, so a later edit that reopens 9222 is rejected outright. "tests": [ { "src": "tag:bookmark-api", "accept": ["tag:bookmark-browser:9222"] }, { -- 2.52.0