Private/SshHelpers.ps1
|
# SshHelpers.ps1 # Shared helpers for New-SshAccess / Remove-SshAccess. # Pure, testable logic (config blocks, key generation, permissions) plus a # thin remote-exec wrapper. No business logic lives in the cmdlets that is not here. # Cross-version Windows detection ($IsWindows is PS6+ only; psd1 targets 5.1+). $script:OnWindows = ($env:OS -eq 'Windows_NT') <# .SYNOPSIS Returns the absolute path to the current user's .ssh directory, creating it if missing. #> function Get-SshUserDir { [CmdletBinding()] param() $home = if ($env:HOME) { $env:HOME } else { $env:USERPROFILE } $dir = Join-Path $home '.ssh' if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null if (-not $script:OnWindows) { & chmod 700 $dir } } return $dir } <# .SYNOPSIS Builds an ~/.ssh/config Host block (pure string, no I/O). #> function New-SshConfigEntry { [CmdletBinding()] param( [Parameter(Mandatory)][string]$Alias, [Parameter(Mandatory)][string]$HostName, [Parameter(Mandatory)][string]$User, [int]$Port = 22, [Parameter(Mandatory)][string]$IdentityFile ) return @( "Host $Alias" " HostName $HostName" " User $User" " Port $Port" " IdentityFile $IdentityFile" " IdentitiesOnly yes" ) -join "`n" } <# .SYNOPSIS Adds (or replaces, with -Force) a Host block in an ssh config file. Idempotent. Returns an object with the action taken: created | appended | replaced. #> function Add-SshConfigHost { [CmdletBinding()] param( [Parameter(Mandatory)][string]$ConfigPath, [Parameter(Mandatory)][string]$Alias, [Parameter(Mandatory)][string]$HostName, [Parameter(Mandatory)][string]$User, [int]$Port = 22, [Parameter(Mandatory)][string]$IdentityFile, [switch]$Force ) $block = New-SshConfigEntry -Alias $Alias -HostName $HostName -User $User -Port $Port -IdentityFile $IdentityFile if (-not (Test-Path $ConfigPath)) { $dir = Split-Path -Parent $ConfigPath if ($dir -and -not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } Set-Content -Path $ConfigPath -Value ($block + "`n") -Encoding UTF8 return [pscustomobject]@{ Action = 'created'; Alias = $Alias } } $content = Get-Content $ConfigPath -Raw $pattern = "(?ms)^Host[ \t]+$([regex]::Escape($Alias))[ \t]*\r?$.*?(?=^Host[ \t]|\z)" if ($content -match $pattern) { if (-not $Force) { throw "Host '$Alias' already exists in $ConfigPath. Use -Force to replace it." } $content = [regex]::Replace($content, $pattern, ($block + "`n")) Set-Content -Path $ConfigPath -Value $content -Encoding UTF8 return [pscustomobject]@{ Action = 'replaced'; Alias = $Alias } } Add-Content -Path $ConfigPath -Value ("`n" + $block + "`n") return [pscustomobject]@{ Action = 'appended'; Alias = $Alias } } <# .SYNOPSIS Removes a Host block (by alias) from an ssh config file. Returns $true if removed. #> function Remove-SshConfigHost { [CmdletBinding()] param( [Parameter(Mandatory)][string]$ConfigPath, [Parameter(Mandatory)][string]$Alias ) if (-not (Test-Path $ConfigPath)) { return $false } $content = Get-Content $ConfigPath -Raw $pattern = "(?ms)^Host[ \t]+$([regex]::Escape($Alias))[ \t]*\r?$.*?(?=^Host[ \t]|\z)" if ($content -notmatch $pattern) { return $false } $content = [regex]::Replace($content, $pattern, '') Set-Content -Path $ConfigPath -Value ($content.TrimEnd() + "`n") -Encoding UTF8 return $true } <# .SYNOPSIS Restricts permissions on a private key file (chmod 600 on Linux, ACL reset on Windows). OpenSSH refuses to use a private key with loose permissions. #> function Protect-SshPrivateKey { [CmdletBinding()] param([Parameter(Mandatory)][string]$Path) if ($script:OnWindows) { $me = "$env:USERNAME" & icacls $Path /inheritance:r *> $null & icacls $Path /grant:r "${me}:F" *> $null } else { & chmod 600 $Path } } <# .SYNOPSIS Generates an SSH key pair with ssh-keygen. Returns paths, public key and fingerprint. Empty -Passphrase produces an unattended (deploy) key. #> function New-SshKeyPair { [CmdletBinding()] param( [Parameter(Mandatory)][string]$Path, [string]$Type = 'ed25519', [string]$Comment = '', [string]$Passphrase = '', [switch]$Force ) if (Test-Path $Path) { if (-not $Force) { throw "A key already exists at $Path. Use -Force to overwrite." } Remove-Item -LiteralPath $Path, "$Path.pub" -Force -ErrorAction SilentlyContinue } $dir = Split-Path -Parent $Path if ($dir -and -not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } $kgArgs = @('-t', $Type, '-f', $Path, '-C', $Comment, '-N', $Passphrase, '-q') & ssh-keygen @kgArgs if ($LASTEXITCODE -ne 0) { throw "ssh-keygen failed (exit $LASTEXITCODE)" } Protect-SshPrivateKey -Path $Path return [pscustomobject]@{ PrivateKeyPath = $Path PublicKeyPath = "$Path.pub" PublicKey = (Get-Content "$Path.pub" -Raw).Trim() Fingerprint = (Get-SshKeyFingerprint -PublicKeyPath "$Path.pub") } } <# .SYNOPSIS Returns the SHA256 fingerprint of a public key file. #> function Get-SshKeyFingerprint { [CmdletBinding()] param([Parameter(Mandatory)][string]$PublicKeyPath) $line = & ssh-keygen -lf $PublicKeyPath if ($LASTEXITCODE -ne 0) { throw "ssh-keygen -lf failed for $PublicKeyPath" } return (($line -split '\s+')[1]) } <# .SYNOPSIS Extracts the base64 blob (second field) of a public key line. Used to match keys in a server's authorized_keys regardless of the trailing comment. #> function Get-SshPublicKeyBlob { [CmdletBinding()] param([Parameter(Mandatory)][string]$PublicKey) return (($PublicKey.Trim() -split '\s+')[1]) } <# .SYNOPSIS Runs a bash script on a remote host over SSH. If -IdentityFile is given it is used (-i); otherwise SSH falls back to its default auth (password/agent) - this is how the first-time bootstrap install works before any key is authorized. Output is written live to the host (the ssh call is not piped, so an interactive -tt sudo prompt renders in real time). The ssh exit code is left in $LASTEXITCODE; the caller must read it right after the call. #> function Invoke-RemoteBash { [CmdletBinding()] param( [Parameter(Mandatory)][string]$ScriptContent, [Parameter(Mandatory)][string]$User, [Parameter(Mandatory)][string]$HostName, [int]$Port = 22, [string]$IdentityFile, [switch]$Tty, [string]$Prefix = 'macss_ssh_' ) # Normalize to LF and write a UTF-8 (no BOM) temp script. $unix = $ScriptContent -replace "`r`n", "`n" -replace "`r", "`n" $tmp = [IO.Path]::Combine([IO.Path]::GetTempPath(), ("{0}{1}.sh" -f $Prefix, ([guid]::NewGuid().ToString()))) [System.IO.File]::WriteAllText($tmp, $unix, (New-Object System.Text.UTF8Encoding($false))) $idArgs = @() if ($IdentityFile) { $idArgs = @('-i', $IdentityFile, '-o', 'IdentitiesOnly=yes') } $common = @('-o', 'StrictHostKeyChecking=accept-new', '-P', "$Port") + $idArgs $remote = "/tmp/" + [IO.Path]::GetFileName($tmp) try { Write-Host " [scp] uploading script to $HostName ..." -ForegroundColor DarkGray # scp is piped to Out-Host: its stdout is consumed (kept out of the return value) # while the password prompt, written to the local tty, still shows. & scp @common $tmp "$($User)@$($HostName):$remote" 2>&1 | Out-Host if ($LASTEXITCODE -ne 0) { throw "scp failed (exit $LASTEXITCODE)" } # ssh uses -p (lowercase) for port; rebuild without the scp-style -P. # -tt forces a pseudo-tty so an in-script `sudo` can prompt for its password # (a service-account install/revoke runs via sudo over a non-interactive ssh). $ttyArgs = @(); if ($Tty) { $ttyArgs = @('-tt') } $sshCommon = @('-o', 'StrictHostKeyChecking=accept-new', '-p', "$Port") + $ttyArgs + $idArgs Write-Host " [ssh] running remote script (enter password / sudo if prompted) ..." -ForegroundColor DarkGray # NOT piped on purpose: with -tt the forwarded sudo prompt must reach the console # live. The caller reads $LASTEXITCODE (the ssh exit code) immediately after. & ssh @sshCommon "$($User)@$($HostName)" "bash $remote; rc=`$?; rm -f $remote; exit `$rc" } finally { Remove-Item -LiteralPath $tmp -ErrorAction SilentlyContinue } } |