private/Get-WtwNetworkPeers.ps1
|
function Get-WtwTailscaleCommand { <# .SYNOPSIS Locate the tailscale CLI. .DESCRIPTION The macOS App Store build ships its CLI inside the bundle rather than on PATH, so a plain Get-Command misses it on exactly the machines most likely to have Tailscale installed. .OUTPUTS Path to the CLI, or $null. #> [CmdletBinding()] param() $onPath = Get-Command tailscale -ErrorAction SilentlyContinue if ($onPath -and $onPath.Source) { return $onPath.Source } $candidates = @( '/Applications/Tailscale.app/Contents/MacOS/Tailscale' '/usr/local/bin/tailscale' '/opt/homebrew/bin/tailscale' '/usr/bin/tailscale' "$env:ProgramFiles\Tailscale\tailscale.exe" ) foreach ($candidate in $candidates) { if ($candidate -and (Test-Path $candidate)) { return $candidate } } return $null } function ConvertFrom-WtwTailscaleOs { <# .SYNOPSIS Map a Tailscale OS string to a wtw platform, or $null if not hostable. .DESCRIPTION Tailscale reports the OS authoritatively, which is exactly what `--platform` needs — it drives remote path translation. Phones and tablets are in the tailnet too but cannot host a worktree or run pwsh, so they map to $null and get skipped rather than added and then failing. #> [CmdletBinding()] param([AllowNull()] [string] $Os) switch -Regex ($Os) { '^macOS$' { return 'macos' } '^windows$' { return 'windows' } '^linux$' { return 'linux' } default { return $null } } } function Get-WtwTailscalePeers { <# .SYNOPSIS Machines in the tailnet, shaped for wtw host entries. .DESCRIPTION Reads `tailscale status --json`. The name comes from DNSName rather than HostName because mobile devices report HostName as "localhost" — DNSName is always the tailnet-unique name. Addresses are the MagicDNS FQDN first, then the tailnet IPv4. The FQDN is preferred over the bare short name because the short form only resolves when the tailnet search domain is configured, while the FQDN always does. .PARAMETER Raw Pre-parsed status object, for tests. .OUTPUTS @{ Name; DnsName; Platform; Addresses; Online; IsSelf; Os } #> [CmdletBinding()] param([AllowNull()] [object] $Raw) if ($null -eq $Raw) { $cli = Get-WtwTailscaleCommand if (-not $cli) { return , @() } $json = & $cli status --json 2>$null if (-not $json) { return , @() } try { $Raw = ($json -join "`n") | ConvertFrom-Json } catch { return , @() } } $nodes = @() $self = Get-WtwPropertyValue -Object $Raw -Name 'Self' if ($self) { $nodes += , @{ Node = $self; IsSelf = $true } } $peers = Get-WtwPropertyValue -Object $Raw -Name 'Peer' foreach ($key in (Get-WtwPropertyNames -Object $peers)) { $nodes += , @{ Node = (Get-WtwPropertyValue -Object $peers -Name $key); IsSelf = $false } } $result = @() foreach ($wrapped in $nodes) { $node = $wrapped.Node if (-not $node) { continue } $dns = [string](Get-WtwPropertyValue -Object $node -Name 'DNSName') $dns = $dns.TrimEnd('.') $name = if ($dns) { ($dns -split '\.')[0] } else { [string](Get-WtwPropertyValue -Object $node -Name 'HostName') } if (-not $name) { continue } $ips = @(Get-WtwPropertyValue -Object $node -Name 'TailscaleIPs' -DefaultValue @()) $ipv4 = @($ips | Where-Object { $_ -and $_ -notmatch ':' }) | Select-Object -First 1 $addresses = @() if ($dns) { $addresses += $dns } if ($ipv4) { $addresses += $ipv4 } $os = [string](Get-WtwPropertyValue -Object $node -Name 'OS') $result += @{ Name = $name.ToLowerInvariant() DnsName = $dns Os = $os Platform = (ConvertFrom-WtwTailscaleOs -Os $os) Addresses = $addresses Online = [bool](Get-WtwPropertyValue -Object $node -Name 'Online' -DefaultValue $false) IsSelf = $wrapped.IsSelf } } return , @($result) } function Show-WtwZeroTierHint { <# .SYNOPSIS Explain the ZeroTier path, when ZeroTier is actually present. .DESCRIPTION Silent when ZeroTier is not installed — there is no point advertising a manual workaround for software the user does not run. #> [CmdletBinding()] param() # No @() wrapper: the function already returns `,@(...)`, and re-wrapping # nests the array so every element access reaches the wrapper instead. $networks = Get-WtwZeroTierNetworks if ($networks.Count -eq 0) { return } Write-Host ' ZeroTier detected.' -ForegroundColor Cyan foreach ($net in $networks) { $label = if ($net.Name) { "$($net.Name) ($($net.Id))" } else { $net.Id } Write-Host " $label this machine: $((@($net.Addresses)) -join ', ')" -ForegroundColor DarkGray } Write-Host ' ZeroTier cannot be auto-discovered: its local client exposes only your own' -ForegroundColor DarkGray Write-Host ' address and peer node IDs — not member names or their managed IPs.' -ForegroundColor DarkGray Write-Host ' Add a ZeroTier peer by its managed IP, which works like any other address:' -ForegroundColor DarkGray Write-Host ' wtw host add <name> --user <u> --address 10.147.20.42 --platform windows' -ForegroundColor DarkGray Write-Host '' } function Resolve-WtwPeerPlan { <# .SYNOPSIS Work out what `wtw host discover` would do to the current config. .DESCRIPTION Pure function so the plan can be shown before anything is written, and tested without a tailnet. Matching against existing hosts is by exact name or alias only — never by prefix. A prefix match here would silently merge tailnet addresses into the wrong machine's entry, which is far worse than adding a duplicate the user can see and remove. Tailnet addresses go at the FRONT of the candidate list. `wtw host sync` writes the first one that answers, and a tailnet name resolves both on the LAN and away from it, so leading with it means the ssh config keeps working when you move networks instead of needing a re-sync. The LAN names stay behind it as fallback for when Tailscale is down. .PARAMETER Peers Output of Get-WtwTailscalePeers. .PARAMETER Hosts Existing hosts from Get-WtwHosts. .OUTPUTS @{ Name; Action; Platform; Online; AddAddresses; Addresses; Reason } Action is one of add | update | ok | skip. #> [CmdletBinding()] param( [AllowNull()] [object[]] $Peers, [AllowNull()] [object[]] $Hosts, [AllowNull()] [string[]] $Ignore ) $plan = @() foreach ($peer in @($Peers)) { if (@($Ignore) -contains $peer.Name) { $plan += @{ Name = $peer.Name; Action = 'skip'; Reason = 'excluded'; Online = $peer.Online; Platform = $peer.Platform; AddAddresses = @(); Addresses = @() } continue } if ($peer.IsSelf) { $plan += @{ Name = $peer.Name; Action = 'skip'; Reason = 'this machine'; Online = $peer.Online; Platform = $peer.Platform; AddAddresses = @(); Addresses = @() } continue } if (-not $peer.Platform) { $plan += @{ Name = $peer.Name; Action = 'skip'; Reason = "$($peer.Os) cannot host a worktree"; Online = $peer.Online; Platform = $null; AddAddresses = @(); Addresses = @() } continue } $existing = @($Hosts) | Where-Object { $_.Name -ieq $peer.Name -or (@($_.Aliases) -contains $peer.Name) } | Select-Object -First 1 if (-not $existing) { $plan += @{ Name = $peer.Name; Action = 'add'; Reason = 'new host' Online = $peer.Online; Platform = $peer.Platform AddAddresses = @($peer.Addresses); Addresses = @($peer.Addresses) } continue } $missing = @($peer.Addresses | Where-Object { @($existing.HostNames) -notcontains $_ }) if ($missing.Count -eq 0) { $plan += @{ Name = $existing.Name; Action = 'ok'; Reason = 'already has its tailnet addresses' Online = $peer.Online; Platform = $existing.Platform AddAddresses = @(); Addresses = @($existing.HostNames) } continue } $plan += @{ Name = $existing.Name; Action = 'update'; Reason = 'add tailnet addresses' Online = $peer.Online; Platform = $existing.Platform AddAddresses = $missing Addresses = @($missing) + @($existing.HostNames) } } return , @($plan) } function Get-WtwZeroTierNetworks { <# .SYNOPSIS ZeroTier networks this machine has joined, with its own managed IPs. .DESCRIPTION Deliberately limited, because the local ZeroTier client is. `listnetworks` returns only *this* node's assigned addresses, and `peers` returns node IDs with physical endpoints — no member names and no managed IPs. There is therefore no way to enumerate the other members of a ZeroTier network locally the way `tailscale status` does; that needs the ZeroTier Central API and an account token. So this reports your own address per network, which is what you need to work out a peer's address by hand, and wtw takes it from there as an ordinary `--address` value. .OUTPUTS @{ Id; Name; Addresses } #> [CmdletBinding()] param([AllowNull()] [object] $Raw) if ($null -eq $Raw) { if (-not (Get-Command zerotier-cli -ErrorAction SilentlyContinue)) { return , @() } $json = & zerotier-cli -j listnetworks 2>$null if (-not $json) { return , @() } try { $Raw = ($json -join "`n") | ConvertFrom-Json } catch { return , @() } } $result = @() foreach ($net in @($Raw)) { $result += @{ Id = [string](Get-WtwPropertyValue -Object $net -Name 'id') Name = [string](Get-WtwPropertyValue -Object $net -Name 'name') Addresses = @(Get-WtwPropertyValue -Object $net -Name 'assignedAddresses' -DefaultValue @()) } } return , @($result) } |