private/Sync-WtwSshConfig.ps1
|
function Get-WtwAddressKind { <# .SYNOPSIS Classify a candidate address by the transport it implies. .DESCRIPTION The candidate list mixes networks — a tailnet name, a mDNS name, a LAN IP — and which one is in use decides whether the connection survives you leaving the house. Labelling them makes `wtw host list/show` legible and gives `--via` something to select on. Tailscale is identified by its MagicDNS suffix or by the 100.64.0.0/10 CGNAT range it assigns. ZeroTier cannot be identified by shape (it hands out ordinary RFC1918 addresses), so it is matched against the subnets this machine has actually joined — which is the one thing the local ZeroTier client does tell us. .PARAMETER Address Hostname or IP. .PARAMETER ZeroTierPrefixes Assigned subnets from Get-WtwZeroTierNetworks, e.g. @('10.147.20.'). .OUTPUTS tailscale | zerotier | mdns | lan | other #> [CmdletBinding()] param( [Parameter(Mandatory)] [string] $Address, [AllowNull()] [string[]] $ZeroTierPrefixes ) if ($Address -match '\.ts\.net$') { return 'tailscale' } # 100.64.0.0/10 → second octet 64-127. if ($Address -match '^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.') { return 'tailscale' } foreach ($prefix in @($ZeroTierPrefixes)) { if ($prefix -and $Address.StartsWith($prefix)) { return 'zerotier' } } if ($Address -match '\.local$') { return 'mdns' } if ($Address -match '^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)') { return 'lan' } return 'other' } function Get-WtwZeroTierPrefixes { <# .SYNOPSIS Network prefixes for the ZeroTier networks this machine has joined. .DESCRIPTION Turns "10.147.20.128/24" into "10.147.20." so peer addresses on the same network can be recognised. Only /24 and larger-octet-aligned masks are handled, which covers ZeroTier's defaults; anything else falls back to being classified as a plain LAN address, which is cosmetic only. #> [CmdletBinding()] param([AllowNull()] [object[]] $Networks) if ($null -eq $Networks) { $Networks = Get-WtwZeroTierNetworks } $prefixes = @() foreach ($net in @($Networks)) { foreach ($assigned in @($net.Addresses)) { $ip = ($assigned -split '/')[0] $octets = $ip -split '\.' if ($octets.Count -eq 4) { $prefixes += "$($octets[0]).$($octets[1]).$($octets[2])." } } } return , @($prefixes | Select-Object -Unique) } function Test-WtwAddressReachable { <# .SYNOPSIS Can we open a TCP connection to this address:port? .DESCRIPTION A plain TcpClient probe with an explicit timeout. `Test-Connection` uses ICMP, which Windows blocks by default — a box that happily accepts ssh would look unreachable. .PARAMETER Address Hostname or IP. .PARAMETER Port TCP port (default 22). .PARAMETER TimeoutMs Per-candidate budget. Kept short: this runs once per candidate on sync, and an unreachable name should not stall the command. #> [CmdletBinding()] param( [Parameter(Mandatory)] [string] $Address, [int] $Port = 22, [int] $TimeoutMs = 1200 ) $client = [System.Net.Sockets.TcpClient]::new() try { $async = $client.BeginConnect($Address, $Port, $null, $null) if (-not $async.AsyncWaitHandle.WaitOne($TimeoutMs)) { return $false } $client.EndConnect($async) return $true } catch { return $false } finally { $client.Dispose() } } function Resolve-WtwHostAddress { <# .SYNOPSIS Pick the first candidate address that actually answers on the ssh port. .DESCRIPTION Falls back to the first candidate when none answers, so a sync run while the other machine is asleep still writes a usable config rather than emptying it. .OUTPUTS @{ Address; Reachable } #> [CmdletBinding()] param([Parameter(Mandatory)] $HostEntry) # Re-wrap the filter: with a single candidate Where-Object yields a bare # string, and `.Count` on a string throws under strict mode — which is every # host configured with one address. $candidates = @(@($HostEntry.HostNames) | Where-Object { $_ }) if ($candidates.Count -eq 0) { return @{ Address = $null; Reachable = $false; Kind = $null; Preferred = $true } } $port = if ($HostEntry.Port) { [int]$HostEntry.Port } else { 22 } $ztPrefixes = Get-WtwZeroTierPrefixes $via = Get-WtwPropertyValue -Object $HostEntry -Name 'Via' # A `via` preference reorders rather than filters. Pinning a transport should # not make the host unreachable when that transport is down — it should just # be tried first, and the caller is told when the fallback kicked in. # @() around the whole if-expression: assigning from `if` sends the value # through the pipeline, which unrolls a one-element array to a bare string — # and then $ordered[0] below yields its first CHARACTER, so a single-address # host that is momentarily unreachable got "HostName 1" written into ssh config. $ordered = @( if ($via -and $via -ne 'any') { @($candidates | Where-Object { (Get-WtwAddressKind -Address $_ -ZeroTierPrefixes $ztPrefixes) -eq $via }) + @($candidates | Where-Object { (Get-WtwAddressKind -Address $_ -ZeroTierPrefixes $ztPrefixes) -ne $via }) } else { $candidates } ) foreach ($candidate in $ordered) { if (Test-WtwAddressReachable -Address $candidate -Port $port) { $kind = Get-WtwAddressKind -Address $candidate -ZeroTierPrefixes $ztPrefixes return @{ Address = $candidate Reachable = $true Kind = $kind Preferred = (-not $via -or $via -eq 'any' -or $kind -eq $via) } } } return @{ Address = $ordered[0] Reachable = $false Kind = (Get-WtwAddressKind -Address $ordered[0] -ZeroTierPrefixes $ztPrefixes) Preferred = $true } } function New-WtwSshConfigBlock { <# .SYNOPSIS Render configured wtw hosts as an OpenSSH config fragment. .DESCRIPTION Each host becomes one `Host` line carrying its name *and* its aliases, so `ssh at` and `ssh-remote+at` both work without a second block. This fragment exists because the Remote-SSH extension resolves `ssh-remote+<host>` through the ssh client, not through wtw. Whatever wtw knows about a host is invisible to the editor unless it lands in ssh's own config. .PARAMETER Hosts Host entries from Get-WtwHosts. #> [CmdletBinding()] param([AllowNull()] [object[]] $Hosts) if ($null -eq $Hosts) { $Hosts = Get-WtwHosts } $lines = @( '# Managed by wtw — do not edit by hand.' '# Regenerate with: wtw host sync' '' ) foreach ($h in ($Hosts | Sort-Object { $_.Name })) { $patterns = @($h.Name) + @($h.Aliases | Where-Object { $_ -and $_ -ne $h.Name }) $lines += "Host $((@($patterns) | Select-Object -Unique) -join ' ')" # ssh config takes exactly one HostName, so a multi-candidate host is # resolved here — first one answering on the ssh port wins. $resolved = Resolve-WtwHostAddress -HostEntry $h if (@($h.HostNames).Count -gt 1) { $note = if ($resolved.Reachable) { 'reachable' } else { 'no candidate answered; using the first' } $lines += " # candidates: $((@($h.HostNames)) -join ', ') ($note)" } if ($resolved.Address) { $lines += " HostName $($resolved.Address)" } if ($h.User) { $lines += " User $($h.User)" } if ($h.Port) { $lines += " Port $($h.Port)" } if ($h.IdentityFile) { $lines += " IdentityFile $($h.IdentityFile)" } if ($h.IdentitiesOnly) { $lines += ' IdentitiesOnly yes' } $lines += '' # Second block: every candidate address as a Host pattern, with the same # credentials but deliberately NO HostName — so ssh uses the pattern # itself as the address. # # This is what makes a per-invocation `--via` possible. Overriding the # transport for one command means targeting a specific address rather # than the host name, and the editor's `ssh-remote+<authority>` resolves # through the ssh client too — so the address has to carry User and # IdentityFile on its own, not just inside the named block. $alternates = @($h.HostNames) | Where-Object { $_ -and $_ -ne $resolved.Address } if (@($alternates).Count -gt 0 -or $h.HostNames.Count -gt 0) { $lines += "Host $((@($h.HostNames) | Select-Object -Unique) -join ' ')" if ($h.User) { $lines += " User $($h.User)" } if ($h.Port) { $lines += " Port $($h.Port)" } if ($h.IdentityFile) { $lines += " IdentityFile $($h.IdentityFile)" } if ($h.IdentitiesOnly) { $lines += ' IdentitiesOnly yes' } $lines += '' } } return ($lines -join "`n") } function Sync-WtwSshConfig { <# .SYNOPSIS Write ~/.ssh/config.d/wtw and make sure ~/.ssh/config includes it. .DESCRIPTION The Include line is *prepended*, never appended. OpenSSH applies the first obtained value for each keyword, so an Include placed after an existing `Host` block would be parsed inside that block's scope and the wtw hosts would only apply to it. Prepending keeps the fragment global. wtw only ever owns its own fragment file; the user's ~/.ssh/config is touched exactly once, to add the Include. .PARAMETER Hosts Host entries to write. Defaults to the configured hosts. .PARAMETER Quiet Suppress progress output. .OUTPUTS Path to the managed fragment. #> [CmdletBinding()] param( [AllowNull()] [object[]] $Hosts, [switch] $Quiet ) if ($null -eq $Hosts) { $Hosts = Get-WtwHosts } $managedPath = Join-Path $script:WtwSshConfigDir 'config.d' 'wtw' $managedDir = Split-Path $managedPath -Parent if (-not (Test-Path $managedDir)) { New-Item -ItemType Directory -Path $managedDir -Force | Out-Null } Set-Content -Path $managedPath -Value (New-WtwSshConfigBlock -Hosts $Hosts) -Encoding utf8 if (-not $IsWindows) { # ssh refuses to read a config file that is group/world readable. & chmod 600 $managedPath 2>$null } $includeLine = "Include $script:WtwSshManagedRelative" if (-not (Test-Path $script:WtwSshConfigPath)) { Set-Content -Path $script:WtwSshConfigPath -Value "$includeLine`n" -Encoding utf8 if (-not $IsWindows) { & chmod 600 $script:WtwSshConfigPath 2>$null } if (-not $Quiet) { Write-Host " Created $script:WtwSshConfigPath with the wtw include." -ForegroundColor Green } return $managedPath } $existing = Get-Content -Path $script:WtwSshConfigPath -Raw if ($existing -match [regex]::Escape($script:WtwSshManagedRelative)) { if (-not $Quiet) { Write-Host " Wrote $managedPath (already included)." -ForegroundColor Green } return $managedPath } Set-Content -Path $script:WtwSshConfigPath -Value "$includeLine`n`n$existing" -Encoding utf8 if (-not $Quiet) { Write-Host " Wrote $managedPath" -ForegroundColor Green Write-Host " Prepended '$includeLine' to $script:WtwSshConfigPath" -ForegroundColor Green } return $managedPath } function Get-WtwSshHostConflicts { <# .SYNOPSIS Other `Host` blocks in the user's ssh config that match a wtw host. .DESCRIPTION wtw's fragment is Include'd at the very top, and OpenSSH takes the FIRST obtained value for each keyword — so a hand-written or chezmoi-managed `Host workstation` further down still parses, but its HostName, User and IdentityFile are silently overridden by wtw's. That is usually harmless (same machine, same key) but it is invisible, and it means anything else relying on that name — an editor's remote project, a chezmoi-managed entry — quietly follows whichever address wtw last synced. Surfacing it lets the user decide. Only the *other* files are scanned; wtw's own fragment is the baseline, not a conflict. .PARAMETER Name Host name to look for. .PARAMETER Aliases Additional patterns that address the same host. .OUTPUTS @{ File; Line; Text } #> [CmdletBinding()] param( [Parameter(Mandatory)] [string] $Name, [AllowNull()] [string[]] $Aliases ) $patterns = @($Name) + @($Aliases) | Where-Object { $_ } | Select-Object -Unique $managed = Join-Path $script:WtwSshConfigDir 'config.d' 'wtw' $files = @() if (Test-Path $script:WtwSshConfigPath) { $files += $script:WtwSshConfigPath } $configD = Join-Path $script:WtwSshConfigDir 'config.d' if (Test-Path $configD) { $files += @(Get-ChildItem -Path $configD -File -ErrorAction SilentlyContinue | ForEach-Object { $_.FullName }) } $conflicts = @() foreach ($file in ($files | Select-Object -Unique)) { if ([System.IO.Path]::GetFullPath($file) -eq [System.IO.Path]::GetFullPath($managed)) { continue } $lineNumber = 0 foreach ($line in (Get-Content -Path $file -ErrorAction SilentlyContinue)) { $lineNumber++ if ($line -notmatch '^\s*Host\s+(.+?)\s*$') { continue } # Split the pattern list and compare whole tokens, so `Host workstation2` # is not reported as a conflict with `workstation`. $tokens = @($Matches[1] -split '\s+') if (@($tokens | Where-Object { $patterns -contains $_ }).Count -gt 0) { $conflicts += @{ File = $file; Line = $lineNumber; Text = $line.Trim() } } } } return , @($conflicts) } function Get-WtwSshEffectiveHostName { <# .SYNOPSIS The HostName the ssh client will actually use for a name. .DESCRIPTION `ssh -G` prints the effective config after all Host blocks and Includes are merged, so this is the ground truth — not what wtw last intended to write. Returns $null when ssh only echoes the name back, which is how an unconfigured host presents. #> [CmdletBinding()] param([Parameter(Mandatory)] [string] $Name) if (-not (Get-Command ssh -ErrorAction SilentlyContinue)) { return $null } $output = & ssh -G $Name 2>$null if (-not $output) { return $null } $line = $output | Where-Object { $_ -match '^hostname\s+(.+)$' } | Select-Object -First 1 if (-not $line) { return $null } $resolved = ($line -replace '^hostname\s+', '').Trim() if (-not $resolved -or $resolved -eq $Name) { return $null } return $resolved } function Test-WtwSshHostKnown { <# .SYNOPSIS Does the ssh client already resolve this host name? .DESCRIPTION `ssh -G <host>` prints the effective config. When the host is unknown, `hostname` echoes the literal name back — which is how we tell a real config entry from ssh's default passthrough. Used to warn *before* handing a `ssh-remote+<host>` authority to an editor that would otherwise fail with an opaque connection error. #> [CmdletBinding()] param([Parameter(Mandatory)] [string] $Name) if (-not (Get-Command ssh -ErrorAction SilentlyContinue)) { return $false } $output = & ssh -G $Name 2>$null if (-not $output) { return $false } $hostLine = $output | Where-Object { $_ -match '^hostname\s+(.+)$' } | Select-Object -First 1 if (-not $hostLine) { return $false } $resolved = ($hostLine -replace '^hostname\s+', '').Trim() return ($resolved -and $resolved -ne $Name) } |