421 lines
18 KiB
PowerShell
421 lines
18 KiB
PowerShell
<#
|
|
.SYNOPSIS
|
|
Sets up SSH for Git on Windows: creates a key in ~/.ssh, loads it into the
|
|
Windows ssh-agent, adds a host entry to ~/.ssh/config, points Git at
|
|
Windows' OpenSSH so pushes (and Git LFS) use the agent instead of asking
|
|
for the passphrase every time, and signs Gitea's key-verification token.
|
|
|
|
.DESCRIPTION
|
|
The script asks which SSH host to set up. The first time the answer is
|
|
blank; after that it offers the host you used last, so press Enter to keep
|
|
it or type another one to add a second server. You can type a bare host
|
|
(git.example.com), host:port, or paste an SSH clone URL.
|
|
|
|
Safe to re-run: an existing key is reused (unless -Force), a key already in
|
|
the agent is not added again, and an existing Host entry in ~/.ssh/config
|
|
is never modified.
|
|
|
|
Run it as your normal user, NOT "Run as administrator". It asks for admin
|
|
rights (UAC prompt) only if the ssh-agent service needs to be enabled.
|
|
|
|
Execution policy: start it through powershell.exe with -ExecutionPolicy
|
|
Bypass as in the examples, or double-click git-ssh-setup.cmd. Either way the
|
|
bypass applies to this one run only; the system setting is not changed.
|
|
|
|
.EXAMPLE
|
|
powershell -NoProfile -ExecutionPolicy Bypass -File .\git-ssh-setup.ps1
|
|
|
|
.EXAMPLE
|
|
# Skip the host prompt.
|
|
powershell -NoProfile -ExecutionPolicy Bypass -File .\git-ssh-setup.ps1 -HostName git.example.com -Port 2222
|
|
|
|
.EXAMPLE
|
|
# Verify the key later: click Verify next to it in Gitea and pass the token it shows.
|
|
powershell -NoProfile -ExecutionPolicy Bypass -File .\git-ssh-setup.ps1 -NoTest -Token d0d4b8e7...
|
|
|
|
.EXAMPLE
|
|
powershell -NoProfile -ExecutionPolicy Bypass -File .\git-ssh-setup.ps1 -HostName github.com -KeyName id_ed25519_github -NoVerify
|
|
#>
|
|
[CmdletBinding()]
|
|
param(
|
|
# SSH host for Git. If omitted, the script asks and remembers the answer.
|
|
# Use -HostName "" to skip the ~/.ssh/config entry and the connection test.
|
|
[string]$HostName,
|
|
|
|
# SSH user for the host (Gitea, GitHub and GitLab all use "git").
|
|
[string]$User = "git",
|
|
|
|
# SSH port. Defaults to 22, or to the port typed as host:port at the prompt.
|
|
[ValidateRange(1, 65535)]
|
|
[int]$Port = 22,
|
|
|
|
# File name of the key inside ~/.ssh.
|
|
[ValidatePattern('^[A-Za-z0-9._-]+$')]
|
|
[string]$KeyName = "id_ed25519_gitea",
|
|
|
|
# Label stored in the key; it shows up in Gitea's key list.
|
|
[ValidateNotNullOrEmpty()]
|
|
[string]$Comment = "$env:USERNAME@$env:COMPUTERNAME",
|
|
|
|
# Token from Gitea's Verify button. If omitted, the script asks for it.
|
|
[string]$Token,
|
|
|
|
# Replace an existing key with the same name instead of reusing it.
|
|
[switch]$Force,
|
|
|
|
# Skip the Gitea key-verification step.
|
|
[switch]$NoVerify,
|
|
|
|
# Skip the connection test at the end.
|
|
[switch]$NoTest
|
|
)
|
|
|
|
function Write-Step([string]$Message) { Write-Host "`n==> $Message" -ForegroundColor Cyan }
|
|
function Write-Ok([string]$Message) { Write-Host " $Message" -ForegroundColor Green }
|
|
function Write-Note([string]$Message) { Write-Host " $Message" -ForegroundColor Yellow }
|
|
function Stop-Setup([string]$Message) {
|
|
Write-Host "`nERROR: $Message" -ForegroundColor Red
|
|
exit 1
|
|
}
|
|
|
|
# Turns what the user typed into a host name and an optional port. Accepts a
|
|
# bare host, host:port, git@host, git@host:owner/repo.git, or
|
|
# ssh://git@host:port/owner/repo.git. Returns $null if it isn't usable.
|
|
function ConvertTo-SshTarget([string]$Text) {
|
|
$t = "$Text".Trim()
|
|
$t = $t -replace '^[A-Za-z][A-Za-z0-9+.-]*://', '' # ssh:// or https://
|
|
$t = $t -replace '^[^@/]*@', '' # git@
|
|
$t = ($t -split '/')[0] # any path
|
|
$p = $null
|
|
if ($t -match '^(.+):(\d+)$') {
|
|
if ($Matches[2].Length -gt 5) { return $null }
|
|
$t = $Matches[1]
|
|
$p = [int]$Matches[2]
|
|
if ($p -lt 1 -or $p -gt 65535) { return $null }
|
|
} elseif ($t.Contains(':')) {
|
|
$t = $t.Substring(0, $t.IndexOf(':')) # host:owner/repo.git
|
|
}
|
|
if ($t -notmatch '^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$') { return $null }
|
|
return [pscustomobject]@{ HostName = $t.ToLowerInvariant(); Port = $p }
|
|
}
|
|
|
|
# The last host used is kept in a small file in your user profile.
|
|
$stateDir = if ($env:APPDATA) { Join-Path $env:APPDATA 'git-ssh-setup' } else { Join-Path $HOME '.git-ssh-setup' }
|
|
$stateFile = Join-Path $stateDir 'last-host.json'
|
|
|
|
function Read-LastHost {
|
|
try {
|
|
if (Test-Path $stateFile) {
|
|
$saved = Get-Content -Path $stateFile -Raw | ConvertFrom-Json
|
|
if ($saved.HostName) {
|
|
$savedPort = 22
|
|
if ($saved.Port) { $savedPort = [int]$saved.Port }
|
|
return [pscustomobject]@{ HostName = [string]$saved.HostName; Port = $savedPort }
|
|
}
|
|
}
|
|
} catch { }
|
|
return $null
|
|
}
|
|
|
|
function Save-LastHost([string]$Name, [int]$PortNumber) {
|
|
try {
|
|
New-Item -ItemType Directory -Path $stateDir -Force | Out-Null
|
|
$json = [pscustomobject]@{ HostName = $Name; Port = $PortNumber } | ConvertTo-Json
|
|
[IO.File]::WriteAllText($stateFile, $json, (New-Object Text.UTF8Encoding($false)))
|
|
} catch {
|
|
Write-Note "Couldn't remember the host for next time: $($_.Exception.Message)"
|
|
}
|
|
}
|
|
|
|
# Signs $Text exactly as given and returns the armored signature, or $null if
|
|
# ssh-keygen failed. The text is written to a file with no trailing newline and
|
|
# no BOM: piping it into ssh-keygen from PowerShell would add a newline and
|
|
# change what gets signed.
|
|
function Get-SshSignature([string]$Text, [string]$Keygen, [string]$KeyFile, [string]$Namespace = 'gitea') {
|
|
$file = Join-Path ([IO.Path]::GetTempPath()) ("gitea-verify-" + [guid]::NewGuid().ToString('N'))
|
|
$sigFile = "$file.sig"
|
|
try {
|
|
[IO.File]::WriteAllText($file, $Text, (New-Object Text.UTF8Encoding($false)))
|
|
# A .pub key file makes ssh-keygen sign through ssh-agent (no passphrase prompt).
|
|
& $Keygen -Y sign -n $Namespace -f $KeyFile $file | Out-Host
|
|
if ($LASTEXITCODE -eq 0 -and (Test-Path $sigFile)) {
|
|
return (Get-Content -Path $sigFile -Raw).Trim()
|
|
}
|
|
return $null
|
|
} finally {
|
|
Remove-Item -LiteralPath $file, $sigFile -Force -ErrorAction SilentlyContinue
|
|
}
|
|
}
|
|
|
|
# Accept the token with stray spaces or quotes, but nothing that isn't hex.
|
|
$Token = "$Token" -replace '[\s''"]', ''
|
|
if ($Token -and $Token -notmatch '^[0-9a-fA-F]+$') {
|
|
Stop-Setup "-Token should be just the hex string from Gitea's Token box."
|
|
}
|
|
|
|
# --- 0. SSH host -------------------------------------------------------------
|
|
$target = $null
|
|
if ($PSBoundParameters.ContainsKey('HostName')) {
|
|
if ($HostName) {
|
|
$target = ConvertTo-SshTarget $HostName
|
|
if (-not $target) { Stop-Setup "'$HostName' isn't a host name the script can use." }
|
|
}
|
|
} else {
|
|
Write-Step "Choosing the SSH host"
|
|
$last = Read-LastHost
|
|
$suggested = ''
|
|
if ($last) {
|
|
$suggested = $last.HostName
|
|
if ($last.Port -ne 22) { $suggested = "$($last.HostName):$($last.Port)" }
|
|
Write-Host " Press Enter to keep the last host, or type a different one."
|
|
} else {
|
|
Write-Host " Type the host name from your SSH clone URL, e.g. git.example.com"
|
|
Write-Host " (add :port if the server doesn't use 22)."
|
|
}
|
|
while ($true) {
|
|
$label = if ($suggested) { " SSH host [$suggested]" } else { " SSH host" }
|
|
$answer = "$(Read-Host $label)".Trim()
|
|
if (-not $answer) {
|
|
if ($suggested) { $answer = $suggested } else { Write-Note "A host name is needed."; continue }
|
|
}
|
|
$target = ConvertTo-SshTarget $answer
|
|
if ($target) {
|
|
if ($answer -match '^https?://') {
|
|
Write-Note "That was a web address. Make sure '$($target.HostName)' is the host in your SSH"
|
|
Write-Note "clone URL; a site behind Cloudflare's proxy won't accept SSH."
|
|
}
|
|
break
|
|
}
|
|
Write-Note "'$answer' doesn't look like a host name. Try something like git.example.com or git.example.com:2222"
|
|
}
|
|
}
|
|
if ($target) {
|
|
$HostName = $target.HostName
|
|
if ($target.Port -and -not $PSBoundParameters.ContainsKey('Port')) { $Port = $target.Port }
|
|
Save-LastHost $HostName $Port
|
|
Write-Ok "Using $User@${HostName}, port $Port"
|
|
}
|
|
|
|
$openSshDir = Join-Path $env:WINDIR 'System32\OpenSSH'
|
|
$sshExe = Join-Path $openSshDir 'ssh.exe'
|
|
$keygenExe = Join-Path $openSshDir 'ssh-keygen.exe'
|
|
$addExe = Join-Path $openSshDir 'ssh-add.exe'
|
|
$sshDir = Join-Path $HOME '.ssh'
|
|
$keyPath = Join-Path $sshDir $KeyName
|
|
$pubPath = "$keyPath.pub"
|
|
$configPath = Join-Path $sshDir 'config'
|
|
|
|
# --- 1. ssh-agent service ----------------------------------------------------
|
|
# Windows ships it disabled. Enabling it (and installing the OpenSSH client if
|
|
# it is missing) needs admin, so only that part runs elevated.
|
|
Write-Step "Checking the ssh-agent service"
|
|
$agent = Get-Service ssh-agent -ErrorAction SilentlyContinue
|
|
if (-not $agent -or $agent.StartType -ne 'Automatic' -or $agent.Status -ne 'Running') {
|
|
$fixAgent = @'
|
|
$ErrorActionPreference = 'Stop'
|
|
if (-not (Get-Service ssh-agent -ErrorAction SilentlyContinue)) {
|
|
Write-Host 'Installing the Windows OpenSSH client (this can take a few minutes)...'
|
|
Add-WindowsCapability -Online -Name 'OpenSSH.Client~~~~0.0.1.0' | Out-Null
|
|
}
|
|
Set-Service ssh-agent -StartupType Automatic
|
|
Start-Service ssh-agent
|
|
'@
|
|
$isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole(
|
|
[Security.Principal.WindowsBuiltInRole]::Administrator)
|
|
|
|
if ($isAdmin) {
|
|
try { & ([scriptblock]::Create($fixAgent)) }
|
|
catch { Stop-Setup "Could not enable ssh-agent: $($_.Exception.Message)" }
|
|
} else {
|
|
Write-Note "Enabling ssh-agent needs admin rights - approve the UAC prompt."
|
|
$encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($fixAgent))
|
|
try {
|
|
Start-Process powershell.exe -Verb RunAs -Wait `
|
|
-ArgumentList "-NoProfile -ExecutionPolicy Bypass -EncodedCommand $encoded"
|
|
} catch {
|
|
Stop-Setup "The admin prompt was cancelled. Re-run the script and approve it."
|
|
}
|
|
}
|
|
|
|
$agent = Get-Service ssh-agent -ErrorAction SilentlyContinue
|
|
if (-not $agent -or $agent.Status -ne 'Running') {
|
|
Stop-Setup "ssh-agent still isn't running. In an admin PowerShell run:`n Set-Service ssh-agent -StartupType Automatic; Start-Service ssh-agent"
|
|
}
|
|
}
|
|
Write-Ok "ssh-agent is running (startup type: $($agent.StartType))"
|
|
|
|
foreach ($exe in @($sshExe, $keygenExe, $addExe)) {
|
|
if (-not (Test-Path $exe)) { Stop-Setup "Windows OpenSSH tool not found: $exe" }
|
|
}
|
|
|
|
# --- 2. Key ------------------------------------------------------------------
|
|
Write-Step "Preparing the key $keyPath"
|
|
New-Item -ItemType Directory -Path $sshDir -Force | Out-Null
|
|
|
|
if ((Test-Path $keyPath) -and -not $Force) {
|
|
Write-Ok "Reusing the existing key (pass -Force to replace it)."
|
|
if (-not (Test-Path $pubPath)) {
|
|
Write-Note "Public key file is missing - recreating it from the private key."
|
|
$recreated = & $keygenExe -y -f $keyPath
|
|
if ($LASTEXITCODE -ne 0 -or -not $recreated) { Stop-Setup "Could not read $keyPath." }
|
|
Set-Content -Path $pubPath -Value $recreated -Encoding Ascii
|
|
}
|
|
} else {
|
|
if (Test-Path $keyPath) {
|
|
Write-Note "-Force given: replacing the existing key."
|
|
Remove-Item -Path $keyPath, $pubPath -Force -ErrorAction SilentlyContinue
|
|
}
|
|
Write-Note "Choose a passphrase when asked (or press Enter twice for none)."
|
|
& $keygenExe -t ed25519 -C $Comment -f $keyPath
|
|
if ($LASTEXITCODE -ne 0 -or -not (Test-Path $pubPath)) { Stop-Setup "ssh-keygen failed." }
|
|
Write-Ok "Created $keyPath"
|
|
}
|
|
|
|
# --- 3. Load into the agent --------------------------------------------------
|
|
Write-Step "Adding the key to ssh-agent"
|
|
$fingerprint = ((& $keygenExe -lf $pubPath) -split '\s+')[1]
|
|
if (-not $fingerprint) { Stop-Setup "Could not read the fingerprint of $pubPath." }
|
|
|
|
$loaded = (& $addExe -l) -join "`n"
|
|
if ($loaded -match [regex]::Escape($fingerprint)) {
|
|
Write-Ok "Already loaded ($fingerprint)."
|
|
} else {
|
|
& $addExe $keyPath
|
|
if ($LASTEXITCODE -ne 0) { Stop-Setup "ssh-add failed." }
|
|
Write-Ok "Loaded $fingerprint. Windows keeps it in the agent across reboots."
|
|
}
|
|
|
|
# --- 4. ~/.ssh/config entry --------------------------------------------------
|
|
if ($HostName) {
|
|
Write-Step "Adding '$HostName' to $configPath"
|
|
$block = @(
|
|
"Host $HostName",
|
|
" HostName $HostName",
|
|
" User $User",
|
|
" Port $Port",
|
|
" IdentityFile ~/.ssh/$KeyName",
|
|
" IdentitiesOnly yes",
|
|
" AddKeysToAgent yes"
|
|
) -join "`n"
|
|
|
|
$existing = ''
|
|
if (Test-Path $configPath) {
|
|
$existing = Get-Content -Path $configPath -Raw
|
|
if ($null -eq $existing) { $existing = '' }
|
|
}
|
|
|
|
$hostPattern = '(?im)^[ \t]*Host[ \t]+([^\r\n]*[ \t])?' + [regex]::Escape($HostName) + '([ \t]|\r?$)'
|
|
if ($existing -match $hostPattern) {
|
|
Write-Note "A 'Host $HostName' entry already exists, so it was left alone."
|
|
Write-Note "Check that it matches this:"
|
|
Write-Host "`n$block"
|
|
} else {
|
|
$prefix = ''
|
|
if ($existing.Length -gt 0) {
|
|
$prefix = if ($existing.EndsWith("`n")) { "`n" } else { "`n`n" }
|
|
}
|
|
# Write UTF-8 *without* a BOM; OpenSSH can choke on a BOM in its config.
|
|
[IO.File]::AppendAllText($configPath, "$prefix$block`n", (New-Object Text.UTF8Encoding($false)))
|
|
Write-Ok "Added."
|
|
}
|
|
}
|
|
|
|
# --- 5. Git ------------------------------------------------------------------
|
|
Write-Step "Configuring Git to use Windows OpenSSH (and its agent)"
|
|
if (Get-Command git -ErrorAction SilentlyContinue) {
|
|
$sshForGit = $sshExe -replace '\\', '/'
|
|
git config --global core.sshCommand $sshForGit
|
|
# Works around a git-lfs bug that can make SSH transfers hang.
|
|
# Harmless if you never use Git LFS.
|
|
git config --global lfs.ssh.automultiplex false
|
|
Write-Ok "core.sshCommand = $sshForGit"
|
|
Write-Ok "lfs.ssh.automultiplex = false"
|
|
if ($env:GIT_SSH_COMMAND) {
|
|
Write-Note "GIT_SSH_COMMAND is set ($env:GIT_SSH_COMMAND) and overrides core.sshCommand."
|
|
Write-Note "Remove it from your environment variables if pushes still ask for a passphrase."
|
|
}
|
|
} else {
|
|
Write-Note "Git is not on PATH, so Git was not configured. Install Git for Windows and re-run."
|
|
}
|
|
|
|
# --- 6. Public key -----------------------------------------------------------
|
|
Write-Step "Your public key"
|
|
$pub = (Get-Content -Path $pubPath -Raw).Trim()
|
|
Write-Host "`n$pub`n"
|
|
try {
|
|
Set-Clipboard -Value $pub
|
|
Write-Ok "Copied to the clipboard."
|
|
} catch {
|
|
Write-Note "Couldn't copy to the clipboard - copy the line above."
|
|
}
|
|
Write-Host " Add it in Gitea: your avatar > Settings > SSH / GPG Keys > Add Key"
|
|
|
|
# --- 7. Verify the key in Gitea (optional) -----------------------------------
|
|
# Gitea's Verify button shows a token. Signing it with this key proves you own
|
|
# the key, and Gitea then marks the key as verified.
|
|
if (-not $NoVerify) {
|
|
Write-Step "Verifying the key in Gitea (optional)"
|
|
if (-not $Token) {
|
|
Write-Host " 1. Add the key in Gitea first (it's on your clipboard)."
|
|
Write-Host " 2. Click Verify next to the key and copy the token Gitea shows."
|
|
while ($true) {
|
|
$Token = "$(Read-Host ' Paste the token here, or press Enter to skip')" -replace '[\s''"]', ''
|
|
if (-not $Token -or $Token -match '^[0-9a-fA-F]+$') { break }
|
|
Write-Note "That doesn't look like a token. Paste only the hex string from the Token box."
|
|
}
|
|
}
|
|
|
|
if (-not $Token) {
|
|
Write-Note "Skipped. To verify later, click Verify in Gitea and re-run with -NoTest -Token <token>."
|
|
} else {
|
|
$signature = Get-SshSignature -Text $Token -Keygen $keygenExe -KeyFile $pubPath
|
|
|
|
if (-not $signature) {
|
|
# Windows OpenSSH older than 8.1 has no 'ssh-keygen -Y'; Git for Windows ships a newer one.
|
|
$gitKeygen = $null
|
|
$gitCmd = Get-Command git -ErrorAction SilentlyContinue
|
|
if ($gitCmd) {
|
|
$dir = Split-Path $gitCmd.Source
|
|
foreach ($i in 1..3) {
|
|
if (-not $dir) { break }
|
|
$candidate = Join-Path $dir 'usr\bin\ssh-keygen.exe'
|
|
if (Test-Path $candidate) { $gitKeygen = $candidate; break }
|
|
$dir = Split-Path $dir
|
|
}
|
|
}
|
|
if ($gitKeygen) {
|
|
Write-Note "Retrying with Git for Windows' ssh-keygen (it may ask for the key's passphrase)."
|
|
$signature = Get-SshSignature -Text $Token -Keygen $gitKeygen -KeyFile $keyPath
|
|
}
|
|
}
|
|
|
|
if ($signature) {
|
|
Write-Host "`n$signature`n"
|
|
try {
|
|
Set-Clipboard -Value $signature
|
|
Write-Ok "Signature copied to the clipboard."
|
|
} catch {
|
|
Write-Note "Couldn't copy to the clipboard - copy the block above."
|
|
}
|
|
Write-Host " Paste it into Gitea's 'Armored SSH signature' box and click Verify."
|
|
Write-Note "The token only stays valid for a short time. If Gitea rejects the signature,"
|
|
Write-Note "click Verify again for a new token and re-run with -NoTest -Token <new token>."
|
|
} else {
|
|
Write-Note "Couldn't sign the token, so the key wasn't verified. Everything else is set up."
|
|
}
|
|
}
|
|
}
|
|
|
|
# --- 8. Test -----------------------------------------------------------------
|
|
if ($HostName -and -not $NoTest) {
|
|
Write-Step "Testing the connection to $HostName"
|
|
if ($NoVerify) {
|
|
Read-Host "Press Enter once the key is added in Gitea (Ctrl+C to skip)" | Out-Null
|
|
}
|
|
& $sshExe -T -p $Port "$User@$HostName"
|
|
Write-Host ""
|
|
Write-Note "A 'Hi there, <name>! You've successfully authenticated' message means it worked."
|
|
}
|
|
|
|
Write-Step "Done"
|