docs(wiki): add Tailscale client-management pattern + GuruRMM enroll script

One tailnet per client (never merge into ACG own tailnet), MSP holds Admin,
devices enrolled as tagged nodes via pre-auth keys pushed from GuruRMM.
Includes tailscale-client-enroll.ps1 (idempotent unattended Windows MSI
install + tagged auth-key join), a see-each-other tag ACL, the Windows
subnet-routing reality (userspace/netstack, not the old IP-forward hack),
and a runbook. Indexed under wiki Patterns.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-06 15:26:15 -07:00
parent fd30af6aba
commit 8d7e3805c7
3 changed files with 235 additions and 1 deletions

View File

@@ -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:<client> - 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
}