diff --git a/wiki/index.md b/wiki/index.md index 058851c..ede65c4 100644 --- a/wiki/index.md +++ b/wiki/index.md @@ -76,7 +76,7 @@ Run `/wiki-lint` to check for stale entries and broken backlinks. | Article | Summary | Last Compiled | |---|---|---| -| *(none yet — patterns will be extracted during system/project compilation passes)* | | | +| [Tailscale client management](patterns/tailscale-client-management.md) | One tailnet per client (never merge into yours); you hold Admin; enroll devices as tagged nodes via pre-auth keys pushed from GuruRMM ([enroll script](patterns/tailscale-client-enroll.ps1)). Windows subnet-routing reality + "see each other" ACL. | 2026-06-06 | --- diff --git a/wiki/patterns/tailscale-client-enroll.ps1 b/wiki/patterns/tailscale-client-enroll.ps1 new file mode 100644 index 0000000..a0323bc --- /dev/null +++ b/wiki/patterns/tailscale-client-enroll.ps1 @@ -0,0 +1,93 @@ +<# +.SYNOPSIS + GuruRMM enrollment: install Tailscale on a Windows client machine and join the + CLIENT's tailnet unattended, using a pre-authorized tagged auth key. + +.DESCRIPTION + Idempotent and safe to re-run. Designed to run as SYSTEM from GuruRMM with ZERO + end-user interaction: + 1. Installs the Tailscale MSI silently in unattended mode (stays connected even + when no user is logged in - TS_UNATTENDEDMODE=always). + 2. Authenticates with a pre-auth key. The device's tag + identity come from the + key, so there is no interactive IdP login for the (tech-inept) user to do. + For a "the machines just need to see each other" setup it advertises NO routes and + is NOT an exit node - each box is simply a node on the tailnet. + +.PARAMETER AuthKey + Pre-auth key from the CLIENT's tailnet (Admin console -> Settings -> Keys -> + Generate auth key). Generate it as: + [x] Reusable - enroll multiple machines / tolerate re-runs + [x] Pre-approved - skip manual device approval + [x] Tags: tag: - node inherits the tag; not tied to a user login + Expiry: ~90 days is fine for an onboarding window; rotate afterward. + Pass this into GuruRMM as a SECRET/masked parameter. NEVER hardcode it here. + +.PARAMETER Hostname + Name the device shows as on the tailnet. Defaults to the Windows computer name. + +.PARAMETER MsiUrl + Tailscale stable MSI. Defaults to the rolling "latest" stable. Pin a version if + you want deterministic installs (see https://pkgs.tailscale.com/stable/#windows). + +.PARAMETER LoginServer + Custom coordination server. Leave blank for Tailscale's default (SaaS). + +.NOTES + tailscale.exe lives at C:\Program Files\Tailscale\tailscale.exe after install. + Exit 0 = node is up on the tailnet. Exit 1 = failed (check the key + connectivity). +#> +param( + [Parameter(Mandatory = $true)][string]$AuthKey, + [string]$Hostname = $env:COMPUTERNAME, + [string]$MsiUrl = 'https://pkgs.tailscale.com/stable/tailscale-setup-latest-amd64.msi', + [string]$LoginServer = '' +) + +$ErrorActionPreference = 'Stop' +$ts = "$env:ProgramFiles\Tailscale\tailscale.exe" +function Log($m) { Write-Output "[tailscale-enroll] $m" } + +# 1. Install Tailscale if absent (silent, unattended, no GUI popup) ---------------- +if (-not (Test-Path $ts)) { + Log "Tailscale not present - downloading MSI from $MsiUrl" + $msi = Join-Path $env:TEMP 'tailscale-setup.msi' + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + Invoke-WebRequest -Uri $MsiUrl -OutFile $msi -UseBasicParsing + Log "Installing MSI (TS_UNATTENDEDMODE=always, no GUI launch)" + $msiArgs = "/i `"$msi`" /quiet /norestart TS_UNATTENDEDMODE=always TS_NOLAUNCH=true" + $p = Start-Process msiexec.exe -ArgumentList $msiArgs -Wait -PassThru + if ($p.ExitCode -ne 0 -and $p.ExitCode -ne 3010) { throw "MSI install failed (msiexec exit $($p.ExitCode))" } + Remove-Item $msi -ErrorAction SilentlyContinue + Start-Sleep -Seconds 5 +} else { + Log "Tailscale already installed: $(((& $ts version) -split "`n")[0])" +} +if (-not (Test-Path $ts)) { throw "tailscale.exe not found after install" } + +# 2. Already connected? (idempotent - re-runs are a no-op) ------------------------- +try { $cur = (& $ts status --json 2>$null | ConvertFrom-Json) } catch { $cur = $null } +if ($cur -and $cur.BackendState -eq 'Running' -and $cur.Self.TailscaleIPs) { + Log "Already connected as $($cur.Self.HostName) ($($cur.Self.TailscaleIPs -join ', ')) - nothing to do" + & $ts status + exit 0 +} + +# 3. Bring it up with the pre-auth key (tag + identity come FROM the key) ---------- +# No --advertise-routes / --advertise-exit-node: peer-to-peer only. +$up = @('up', '--authkey', $AuthKey, '--hostname', $Hostname, '--accept-dns=true') +if ($LoginServer) { $up += "--login-server=$LoginServer" } +Log "Joining tailnet as '$Hostname'" +& $ts @up +Start-Sleep -Seconds 3 + +# 4. Verify + report the 100.x address for the RMM job output ---------------------- +try { $st = (& $ts status --json 2>$null | ConvertFrom-Json) } catch { $st = $null } +if ($st -and $st.BackendState -eq 'Running' -and $st.Self.TailscaleIPs) { + Log "SUCCESS - $($st.Self.HostName) is on the tailnet at $($st.Self.TailscaleIPs -join ', ')" + & $ts status + exit 0 +} else { + Log "FAILED - backend state: $(if($st){$st.BackendState}else{'unknown'})." + Log "Check the auth key (expired / not reusable / not pre-approved) and outbound connectivity to *.tailscale.com:443 + UDP 41641." + exit 1 +} diff --git a/wiki/patterns/tailscale-client-management.md b/wiki/patterns/tailscale-client-management.md new file mode 100644 index 0000000..c158b63 --- /dev/null +++ b/wiki/patterns/tailscale-client-management.md @@ -0,0 +1,141 @@ +# Pattern: Tailscale for Client Networks (MSP-managed) + +**Type:** pattern +**Applies to:** any ACG client that needs Tailscale (small offices especially) +**Companion script:** [`tailscale-client-enroll.ps1`](tailscale-client-enroll.ps1) +**Last updated:** 2026-06-06 + +--- + +## Decision (TL;DR) + +**One tailnet PER client. Never merge a client into your own tailnet, and never share +one tailnet across multiple clients.** You hold an Admin/Owner seat on each client's +tailnet, enroll their devices as **tagged nodes via pre-auth keys pushed from GuruRMM**, +and bill on the client's own plan. + +This gives clean isolation, clean offboarding, and zero reliance on a non-technical user +doing anything. + +--- + +## Why per-client, not a shared tailnet + +- **Isolation / liability.** Your tailnet carries ACG infra (the 100.x fleet, the vault + host, coord API, GuruRMM server). One ACL slip and a client box - or malware on it - + can reach your stuff or another client. Hard no. +- **Billing.** Client devices/users on your tailnet inflate and mix your bill. You want + their cost on their ledger (or cleanly bundled into their managed-services fee). +- **Offboarding.** Ending an engagement = delete the tailnet / revoke your admin: one + click. Untangling one client's nodes, ACLs, and keys out of a shared tailnet is + error-prone and risky. +- **IdP blast radius.** Their Microsoft/Google identity changes shouldn't touch a tailnet + that anyone else lives on. + +--- + +## Ownership and roles + +1. **Tie the tailnet to an identity that belongs to the client** - their Microsoft 365 or + Google Workspace (most ACG clients have one) as the login identity provider, or a + dedicated admin account you hold on their behalf. +2. **Add yourself as Owner/Admin.** Tailscale roles: Owner, Admin, IT admin, Network + admin, Auditor, Member. Admin is enough to manage devices/keys/ACLs. +3. **Billing on the client's subscription.** Two users fits the free tier *functionally*, + but for a commercial client the **Starter plan (~$6/user/mo)** is the correct, + supported footing - call it ~$12/mo, trivially bundled into their MSP fee. + +--- + +## Device enrollment (built for tech-inept users) + +The only step in Tailscale that is hard for a non-technical user is the initial IdP login. +**Eliminate it entirely** with tagged pre-auth keys - a tagged node is owned by a tag, not +a user, so there is nothing to sign into and nothing that breaks when a password changes. + +1. In the client's admin console: **Settings -> Keys -> Generate auth key** + - [x] **Reusable** (enroll both machines / tolerate RMM re-runs) + - [x] **Pre-approved** (skip manual device approval) + - [x] **Tag:** `tag:` (e.g. `tag:roberts`) + - Expiry ~90 days for the onboarding window; rotate after. +2. In GuruRMM, store the key as a **secret/masked parameter** and run + [`tailscale-client-enroll.ps1`](tailscale-client-enroll.ps1) on each machine. + It installs the MSI silently with `TS_UNATTENDEDMODE=always` (stays connected with no + user logged in) and authenticates with the key. Zero user interaction; survives reboots. + +You already have the GuruRMM agent on their boxes, so this is the whole job - no site visit, +no walking a confused user through a login. + +--- + +## "Just see each other" ACL + +Tailscale's default ACL is allow-all; the moment you set an ACL it becomes default-deny. +This tag-scoped ACL lets the client's tagged machines reach each other and nothing else: + +```json +{ + "tagOwners": { + "tag:roberts": ["autogroup:admin"] + }, + "acls": [ + { "action": "accept", "src": ["tag:roberts"], "dst": ["tag:roberts:*"] } + ] +} +``` + +With **MagicDNS** on, the two boxes reach each other by name (e.g. `front-desk`, +`back-office`) with no IP juggling. + +--- + +## Subnet routing on Windows - the real story + +- **For two peers you do not need it.** Each machine is a node with its own 100.x address + and talks to the other directly. Skip subnet routing entirely. +- **If you ever need to expose a whole LAN behind one box:** modern Tailscale runs Windows + subnet routers in **userspace / netstack mode** - no kernel IP-forwarding, no + `IPEnableRouter` registry hack, and Windows automatically picks up the advertised routes. + So it is *not* "hard" the way it used to be. The only real caveat is **throughput**: + userspace routing is slower than Linux kernel-mode, so for a heavily used router drop in a + cheap Linux box/appliance instead. Summary: easier than its reputation, just not the fastest. + +--- + +## Offboarding + +Delete the client's tailnet, or revoke your admin seat and rotate the auth keys. Because the +client is isolated in their own tailnet, the blast radius is exactly one client. + +--- + +## Multi-client management reality + +There is **no native single pane of glass across tailnets** - each tailnet is its own admin +console, so per-client tailnets means a separate login per client. For a handful of small +clients that is fine. Record each client's **tailnet name + admin identity + key rotation +date** in their `wiki/clients/.md`. (Re-check Tailscale's MSP/partner program +periodically for centralized multi-tenant tooling.) + +--- + +## Runbook - Robert's office (2 machines, "see each other") + +1. Stand up the tailnet on Robert's identity; add yourself as **Admin**. +2. Turn on **MagicDNS**; set the **ACL** above (`tag:roberts`). +3. Generate a **reusable + pre-approved** auth key tagged `tag:roberts`. +4. In GuruRMM, add the key as a secret var and run + [`tailscale-client-enroll.ps1`](tailscale-client-enroll.ps1) on both machines. +5. Confirm both show a 100.x IP in `tailscale status`; test reachability by MagicDNS name. +6. Record the tailnet name, your admin identity, and the key rotation date in Robert's + client wiki article. + +--- + +## Sources + +- [Subnet routers - Tailscale Docs](https://tailscale.com/docs/features/subnet-routers) +- [Kernel vs. netstack subnet routing & exit nodes - Tailscale Docs](https://tailscale.com/docs/reference/kernel-vs-userspace-routers) +- [Install Tailscale on Windows with MSI - Tailscale Docs](https://tailscale.com/docs/install/windows/msi) +- [Keep Tailscale running when I'm not logged in - Tailscale Docs](https://tailscale.com/docs/how-to/run-unattended) +- [Auth keys - Tailscale Docs](https://tailscale.com/kb/1085/auth-keys)