| 1 | #requires -version 5.1 |
| 2 | <# |
| 3 | .SYNOPSIS |
| 4 | WireGuard client provisioning for Windows (offline QR generation). |
| 5 | .DESCRIPTION |
| 6 | Creates <device>.conf and <device>.png, prints the client public key and |
| 7 | RouterOS peer command. Never sends keys to a web service. |
| 8 | Requires WireGuard for Windows (wg.exe). Uses Python qrcode[pil] exclusively. |
| 9 | Can offer automatic Python installation through winget when absent. |
| 10 | |
| 11 | Optional BAT launcher (save alongside this PS1 as provision.bat): |
| 12 | @echo off |
| 13 | powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0wireguard-provision.ps1" |
| 14 | pause |
| 15 | |
| 16 | The BAT bypass applies only to this invocation; it does not change the |
| 17 | system-wide PowerShell execution policy. Installation requires internet. |
| 18 | Run the downloaded script only after reviewing its contents. |
| 19 | #> |
| 20 | [CmdletBinding()] |
| 21 | param( |
| 22 | [string]$OutputDirectory = (Join-Path $PWD.Path 'wireguard-profiles') |
| 23 | ) |
| 24 | Set-StrictMode -Version Latest |
| 25 | $ErrorActionPreference = 'Stop' |
| 26 | |
| 27 | function Read-Required([string]$Prompt, [string]$Default = '') { |
| 28 | while ($true) { |
| 29 | $label = if ($Default) { "$Prompt [$Default]" } else { $Prompt } |
| 30 | $v = Read-Host $label |
| 31 | if ([string]::IsNullOrWhiteSpace($v)) { $v = $Default } |
| 32 | if (-not [string]::IsNullOrWhiteSpace($v)) { return $v.Trim() } |
| 33 | Write-Warning 'Value cannot be empty.' |
| 34 | } |
| 35 | } |
| 36 | function Invoke-Wg([string]$Exe, [string]$Argument, [string]$InputText = $null) { |
| 37 | $p = New-Object System.Diagnostics.Process |
| 38 | $p.StartInfo.FileName = $Exe |
| 39 | $p.StartInfo.Arguments = $Argument |
| 40 | $p.StartInfo.UseShellExecute = $false |
| 41 | $p.StartInfo.RedirectStandardOutput = $true |
| 42 | $p.StartInfo.RedirectStandardError = $true |
| 43 | $p.StartInfo.RedirectStandardInput = $true |
| 44 | $p.StartInfo.CreateNoWindow = $true |
| 45 | if (-not $p.Start()) { throw "Cannot start $Exe" } |
| 46 | if ($null -ne $InputText) { $p.StandardInput.WriteLine($InputText) } |
| 47 | $p.StandardInput.Close() |
| 48 | $output = $p.StandardOutput.ReadToEnd().Trim() |
| 49 | $stderr = $p.StandardError.ReadToEnd().Trim() |
| 50 | $p.WaitForExit() |
| 51 | if ($p.ExitCode -ne 0) { throw "wg.exe $Argument failed: $stderr" } |
| 52 | return $output |
| 53 | } |
| 54 | function Test-IPv4([string]$Text) { |
| 55 | $ip = $null |
| 56 | return ([System.Net.IPAddress]::TryParse($Text, [ref]$ip) -and |
| 57 | $ip.AddressFamily -eq [System.Net.Sockets.AddressFamily]::InterNetwork -and |
| 58 | $Text -match '^(?:\d{1,3}\.){3}\d{1,3}$') |
| 59 | } |
| 60 | function Test-Key([string]$Text) { |
| 61 | if ($Text -notmatch '^[A-Za-z0-9+/]{43}=$') { return $false } |
| 62 | try { return ([Convert]::FromBase64String($Text).Length -eq 32) } catch { return $false } |
| 63 | } |
| 64 | function Lock-File([string]$Path) { |
| 65 | # icacls accepts the raw SID with a leading *, avoiding localized account-name lookup. |
| 66 | $sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value |
| 67 | $icacls = Join-Path $env:SystemRoot 'System32\icacls.exe' |
| 68 | if (-not (Test-Path -LiteralPath $icacls)) { throw 'icacls.exe not found; cannot secure credentials.' } |
| 69 | $grant = ('*' + $sid + ':(F)') |
| 70 | # Remove inherited access and explicitly grant only the provisioning account. |
| 71 | # Do not continue if either operation fails. |
| 72 | & $icacls $Path '/inheritance:r' | Out-Null |
| 73 | if ($LASTEXITCODE -ne 0) { throw "Could not remove inherited ACLs from $Path" } |
| 74 | & $icacls $Path '/grant:r' $grant | Out-Null |
| 75 | if ($LASTEXITCODE -ne 0) { throw "Could not restrict ACLs on $Path" } |
| 76 | } |
| 77 | |
| 78 | function Test-PythonExecutable([string]$Executable, [string[]]$Arguments) { |
| 79 | # Use Process rather than PowerShell native invocation: Windows Store's |
| 80 | # python.exe app execution alias prints an error to stderr and exits nonzero. |
| 81 | # With $ErrorActionPreference='Stop', 2>$null is not sufficient in PS 5.1. |
| 82 | try { |
| 83 | $psi = New-Object System.Diagnostics.ProcessStartInfo |
| 84 | $psi.FileName = $Executable |
| 85 | $psi.Arguments = ($Arguments -join ' ') |
| 86 | $psi.UseShellExecute = $false |
| 87 | $psi.CreateNoWindow = $true |
| 88 | $psi.RedirectStandardOutput = $true |
| 89 | $psi.RedirectStandardError = $true |
| 90 | $proc = [System.Diagnostics.Process]::Start($psi) |
| 91 | if ($null -eq $proc) { return $null } |
| 92 | $output = $proc.StandardOutput.ReadToEnd().Trim() |
| 93 | $null = $proc.StandardError.ReadToEnd() |
| 94 | $proc.WaitForExit() |
| 95 | $exitCode = $proc.ExitCode |
| 96 | $proc.Dispose() |
| 97 | if ($exitCode -ne 0 -or -not $output) { return $null } |
| 98 | $resolved = ($output -split "`r?`n")[0].Trim() |
| 99 | if (-not (Test-Path -LiteralPath $resolved -PathType Leaf)) { return $null } |
| 100 | return $resolved |
| 101 | } catch { |
| 102 | return $null |
| 103 | } |
| 104 | } |
| 105 | function Get-WorkingPython { |
| 106 | # Return ONLY the executable path on the success stream. Do not mix winget |
| 107 | # installation output with this return value. |
| 108 | foreach ($candidate in @('py.exe', 'python.exe')) { |
| 109 | $cmd = Get-Command $candidate -ErrorAction SilentlyContinue |
| 110 | if (-not $cmd -or -not $cmd.Source) { continue } |
| 111 | $args = if ($candidate -eq 'py.exe') { |
| 112 | @('-3', '-c', '"import sys; print(sys.executable)"') |
| 113 | } else { |
| 114 | @('-c', '"import sys; print(sys.executable)"') |
| 115 | } |
| 116 | $found = Test-PythonExecutable $cmd.Source $args |
| 117 | if ($found) { return $found } |
| 118 | } |
| 119 | # A new install may not be on this process's PATH yet. |
| 120 | $roots = @() |
| 121 | if ($env:LOCALAPPDATA) { $roots += (Join-Path $env:LOCALAPPDATA 'Programs\Python') } |
| 122 | if ($env:ProgramFiles) { $roots += (Join-Path $env:ProgramFiles 'Python') } |
| 123 | if (${env:ProgramFiles(x86)}) { $roots += (Join-Path ${env:ProgramFiles(x86)} 'Python') } |
| 124 | foreach ($root in $roots) { |
| 125 | if (-not (Test-Path -LiteralPath $root)) { continue } |
| 126 | $items = @(Get-ChildItem -LiteralPath $root -Filter python.exe -Recurse -ErrorAction SilentlyContinue | |
| 127 | Where-Object { $_.FullName -notmatch '\\(Scripts|venv|Lib|WindowsApps)\\' } | |
| 128 | Sort-Object FullName -Descending) |
| 129 | foreach ($item in $items) { |
| 130 | $found = Test-PythonExecutable $item.FullName @('-c', '"import sys; print(sys.executable)"') |
| 131 | if ($found) { return $found } |
| 132 | } |
| 133 | } |
| 134 | return $null |
| 135 | } |
| 136 | function Update-PythonPath([string]$PythonExe) { |
| 137 | $pythonDir = Split-Path -Parent $PythonExe |
| 138 | $scriptsDir = Join-Path $pythonDir 'Scripts' |
| 139 | # Keep the user's existing PATH intact. Installers may have already added |
| 140 | # these directories; do not duplicate them. |
| 141 | $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') |
| 142 | $entries = @($userPath -split ';' | Where-Object { $_ }) |
| 143 | $missing = @(@($pythonDir, $scriptsDir) | Where-Object { |
| 144 | $dir = $_ |
| 145 | -not (@($entries | Where-Object { $_.TrimEnd('\') -ieq $dir.TrimEnd('\') }).Count -gt 0) |
| 146 | }) |
| 147 | if ($missing.Count -gt 0) { |
| 148 | # Do not silently overwrite an overlong or unmodifiable user PATH. |
| 149 | $newPath = (@($missing) + @($entries)) -join ';' |
| 150 | [Environment]::SetEnvironmentVariable('Path', $newPath, 'User') |
| 151 | } |
| 152 | # Refresh the current process PATH from the persisted machine + user PATH. |
| 153 | $machinePath = [Environment]::GetEnvironmentVariable('Path', 'Machine') |
| 154 | $updatedUserPath = [Environment]::GetEnvironmentVariable('Path', 'User') |
| 155 | $env:Path = ($machinePath, $updatedUserPath -join ';') |
| 156 | } |
| 157 | function Ensure-Python { |
| 158 | $python = Get-WorkingPython |
| 159 | if ($python) { return $python } |
| 160 | Write-Warning 'Python 3 is not installed or cannot be run.' |
| 161 | $answer = Read-Host 'Install Python automatically with winget? [Y]es / [M]anual (exit)' |
| 162 | if ($answer -notmatch '^(y|yes)$') { |
| 163 | Write-Host '' |
| 164 | Write-Host 'Manual installation selected.' -ForegroundColor Yellow |
| 165 | Write-Host 'Install Python 3 from https://www.python.org/downloads/windows/' |
| 166 | Write-Host 'Then run this script again.' |
| 167 | exit 0 |
| 168 | } |
| 169 | $winget = Get-Command winget.exe -ErrorAction SilentlyContinue |
| 170 | if (-not $winget) { throw 'winget is unavailable. Install Python manually from python.org.' } |
| 171 | Write-Host 'Searching for the latest stable Python 3 release series in winget...' -ForegroundColor Cyan |
| 172 | # Redirect all winget output away from the function success pipeline. |
| 173 | $results = @(& $winget.Source search --query 'Python.Python.3' --source winget --accept-source-agreements 2>&1) |
| 174 | if ($LASTEXITCODE -ne 0) { throw ('winget search failed: ' + ($results -join ' ')) } |
| 175 | $versions = @($results | ForEach-Object { |
| 176 | [regex]::Matches([string]$_, 'Python\.Python\.3\.(\d+)') |
| 177 | } | ForEach-Object { $_ } | Sort-Object { [int]$_.Groups[1].Value } -Descending) |
| 178 | if ($versions.Count -eq 0) { throw 'No suitable Python 3 package was found in winget.' } |
| 179 | $packageId = $versions[0].Value |
| 180 | Write-Host "Installing $packageId with Python and pip added to the user PATH..." -ForegroundColor Cyan |
| 181 | # This override targets the classic python.org installer packages (Python.Python.3.x). |
| 182 | # Do not apply these arguments to unrelated Python distribution packages. |
| 183 | & $winget.Source install --id $packageId --exact --source winget --accept-source-agreements --accept-package-agreements --override '/quiet /norestart InstallAllUsers=0 PrependPath=1 Include_pip=1' | Out-Host |
| 184 | if ($LASTEXITCODE -ne 0) { throw 'Python installation failed. Review winget output above.' } |
| 185 | $python = Get-WorkingPython |
| 186 | if (-not $python) { throw 'Python installer completed, but python.exe was not detected. Review installation and rerun.' } |
| 187 | Update-PythonPath $python |
| 188 | Write-Host "Python ready: $python" -ForegroundColor Green |
| 189 | return $python |
| 190 | } |
| 191 | function Invoke-PythonProcess([string]$PythonExe, [string[]]$Arguments) { |
| 192 | # Avoid PowerShell 5.1 treating Python stderr as a terminating NativeCommandError. |
| 193 | # ArgumentList is unavailable on Windows PowerShell 5.1; quote each argument. |
| 194 | $psi = New-Object System.Diagnostics.ProcessStartInfo |
| 195 | $psi.FileName = $PythonExe |
| 196 | $psi.Arguments = ($Arguments | ForEach-Object { |
| 197 | '"' + $_.Replace('"', '\"') + '"' |
| 198 | }) -join ' ' |
| 199 | $psi.UseShellExecute = $false |
| 200 | $psi.CreateNoWindow = $true |
| 201 | $psi.RedirectStandardOutput = $true |
| 202 | $psi.RedirectStandardError = $true |
| 203 | $proc = [System.Diagnostics.Process]::Start($psi) |
| 204 | if (-not $proc) { throw 'Could not start Python.' } |
| 205 | try { |
| 206 | $stdout = $proc.StandardOutput.ReadToEnd() |
| 207 | $stderr = $proc.StandardError.ReadToEnd() |
| 208 | $proc.WaitForExit() |
| 209 | return [pscustomobject]@{ ExitCode = $proc.ExitCode; StdOut = $stdout; StdErr = $stderr } |
| 210 | } finally { $proc.Dispose() } |
| 211 | } |
| 212 | function Ensure-QrLibrary([string]$PythonExe) { |
| 213 | $check = Invoke-PythonProcess $PythonExe @('-c', 'import qrcode; from PIL import Image') |
| 214 | if ($check.ExitCode -eq 0) { return } |
| 215 | # Missing qrcode/Pillow is expected on a fresh Python installation. |
| 216 | # Do not surface its traceback as a PowerShell exception. |
| 217 | Write-Host 'QR dependencies are missing; preparing pip and qrcode[pil]...' -ForegroundColor Yellow |
| 218 | $pip = Invoke-PythonProcess $PythonExe @('-m', 'pip', '--version') |
| 219 | if ($pip.ExitCode -ne 0) { |
| 220 | $bootstrap = Invoke-PythonProcess $PythonExe @('-m', 'ensurepip', '--upgrade') |
| 221 | if ($bootstrap.ExitCode -ne 0) { throw ("pip bootstrap failed: " + $bootstrap.StdErr) } |
| 222 | } |
| 223 | Write-Host 'Updating pip...' -ForegroundColor Cyan |
| 224 | $upgrade = Invoke-PythonProcess $PythonExe @('-m', 'pip', 'install', '--disable-pip-version-check', '--upgrade', 'pip') |
| 225 | if ($upgrade.ExitCode -ne 0) { Write-Warning ('pip update failed; trying existing pip: ' + $upgrade.StdErr.Trim()) } |
| 226 | Write-Host 'Installing/updating qrcode and Pillow...' -ForegroundColor Cyan |
| 227 | $install = Invoke-PythonProcess $PythonExe @('-m', 'pip', 'install', '--disable-pip-version-check', '--upgrade', 'qrcode[pil]') |
| 228 | if ($install.ExitCode -ne 0) { throw ('qrcode[pil] installation failed: ' + $install.StdErr.Trim()) } |
| 229 | $verify = Invoke-PythonProcess $PythonExe @('-c', 'import qrcode; from PIL import Image') |
| 230 | if ($verify.ExitCode -ne 0) { throw ('QR dependency verification failed: ' + $verify.StdErr.Trim()) } |
| 231 | Write-Host 'QR dependencies ready.' -ForegroundColor Green |
| 232 | } |
| 233 | |
| 234 | Write-Host "`n=== WireGuard Client Provisioning (Windows) ===" -ForegroundColor Cyan |
| 235 | $wg = Join-Path $env:ProgramFiles 'WireGuard\wg.exe' |
| 236 | if (-not (Test-Path -LiteralPath $wg)) { |
| 237 | $found = Get-Command wg.exe -ErrorAction SilentlyContinue |
| 238 | if ($found) { $wg = $found.Source } |
| 239 | else { throw 'wg.exe not found. Install WireGuard for Windows first.' } |
| 240 | } |
| 241 | |
| 242 | $python = Ensure-Python |
| 243 | Ensure-QrLibrary $python |
| 244 | |
| 245 | $name = Read-Required 'User / device name (filename, e.g. Laptop-Alice)' |
| 246 | if ($name -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$' -or $name -eq '.' -or $name -eq '..') { |
| 247 | throw 'Use 1-64 characters: letters, numbers, dot, dash, underscore; start with a letter or number.' |
| 248 | } |
| 249 | $address = Read-Required 'Client tunnel IPv4/CIDR (e.g. 10.77.0.2/32)' |
| 250 | if ($address -notmatch '^([^/]+)/32$' -or -not (Test-IPv4 $Matches[1])) { |
| 251 | throw 'Client address must be a valid IPv4 /32, e.g. 10.77.0.2/32.' |
| 252 | } |
| 253 | $ip = $address.Split('/')[0] |
| 254 | $dns = Read-Required 'DNS servers (comma-separated, e.g. 1.1.1.1, 9.9.9.9)' |
| 255 | $dnsParts = @($dns -split '\s*,\s*') |
| 256 | if ($dnsParts.Count -eq 0 -or @($dnsParts | Where-Object { -not (Test-IPv4 $_) }).Count -gt 0) { |
| 257 | throw 'DNS must be a comma-separated list of IPv4 addresses.' |
| 258 | } |
| 259 | $dns = $dnsParts -join ', ' |
| 260 | $endpoint = Read-Required 'Server endpoint (hostname:port, e.g. vpn.example.net:51820)' |
| 261 | if ($endpoint -notmatch '^([A-Za-z0-9.-]+):(\d{1,5})$' -or [int]$Matches[2] -lt 1 -or [int]$Matches[2] -gt 65535) { |
| 262 | throw 'Endpoint must be hostname-or-IPv4:port, e.g. vpn.example.net:51820.' |
| 263 | } |
| 264 | $allowedIPs = Read-Required 'AllowedIPs (comma-separated; full tunnel default)' '0.0.0.0/0' |
| 265 | $allowedParts = @($allowedIPs -split '\s*,\s*') |
| 266 | foreach ($route in $allowedParts) { |
| 267 | if ($route -notmatch '^([^/]+)/(\d{1,2})$' -or -not (Test-IPv4 $Matches[1]) -or [int]$Matches[2] -gt 32) { |
| 268 | throw 'AllowedIPs must be IPv4 CIDR entries, e.g. 0.0.0.0/0 or 10.77.0.0/24.' |
| 269 | } |
| 270 | } |
| 271 | $allowedIPs = $allowedParts -join ', ' |
| 272 | $serverKey = Read-Required 'WireGuard SERVER public key (Base64, no quotes)' |
| 273 | if (-not (Test-Key $serverKey)) { throw 'Invalid WireGuard public key (expected 32-byte Base64 key).' } |
| 274 | |
| 275 | # Refuse to overwrite any existing profile. This is a provisioning tool, not an IP allocator. |
| 276 | $fullOutput = [System.IO.Path]::GetFullPath($OutputDirectory) |
| 277 | [void][System.IO.Directory]::CreateDirectory($fullOutput) |
| 278 | $conf = Join-Path $fullOutput "$name.conf" |
| 279 | $png = Join-Path $fullOutput "$name.png" |
| 280 | if ((Test-Path -LiteralPath $conf) -or (Test-Path -LiteralPath $png)) { |
| 281 | throw 'Profile filename already exists. Choose another name; nothing was overwritten.' |
| 282 | } |
| 283 | |
| 284 | $privateKey = Invoke-Wg $wg 'genkey' |
| 285 | if (-not (Test-Key $privateKey)) { throw 'wg genkey returned an invalid key.' } |
| 286 | $publicKey = Invoke-Wg $wg 'pubkey' $privateKey |
| 287 | if (-not (Test-Key $publicKey)) { throw 'wg pubkey returned an invalid key.' } |
| 288 | $config = @" |
| 289 | [Interface] |
| 290 | PrivateKey = $privateKey |
| 291 | Address = $address |
| 292 | DNS = $dns |
| 293 | |
| 294 | [Peer] |
| 295 | PublicKey = $serverKey |
| 296 | AllowedIPs = $allowedIPs |
| 297 | Endpoint = $endpoint |
| 298 | PersistentKeepalive = 25 |
| 299 | "@ |
| 300 | |
| 301 | try { |
| 302 | # ASCII avoids a UTF-8 BOM, which some VPN config parsers reject. |
| 303 | [System.IO.File]::WriteAllText($conf, ($config.Trim() + "`n"), [System.Text.Encoding]::ASCII) |
| 304 | Lock-File $conf |
| 305 | |
| 306 | # Use a temporary .py file instead of python -c: Windows PowerShell 5.1 |
| 307 | # can strip nested double quotes when passing native command arguments. |
| 308 | # This temporary file contains CODE ONLY, never the WireGuard private key. |
| 309 | $qrScript = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), |
| 310 | ('wireguard-qr-' + [guid]::NewGuid().ToString('N') + '.py')) |
| 311 | $pyCode = @' |
| 312 | import sys |
| 313 | import qrcode |
| 314 | |
| 315 | with open(sys.argv[1], 'r', encoding='ascii') as f: |
| 316 | config = f.read() |
| 317 | qr = qrcode.QRCode( |
| 318 | error_correction=qrcode.constants.ERROR_CORRECT_M, |
| 319 | box_size=8, |
| 320 | border=4 |
| 321 | ) |
| 322 | qr.add_data(config) |
| 323 | qr.make(fit=True) |
| 324 | qr.make_image(fill_color='black', back_color='white').save(sys.argv[2]) |
| 325 | '@ |
| 326 | try { |
| 327 | [System.IO.File]::WriteAllText($qrScript, $pyCode, [System.Text.Encoding]::ASCII) |
| 328 | & $python $qrScript $conf $png |
| 329 | $qrExitCode = $LASTEXITCODE |
| 330 | } finally { |
| 331 | Remove-Item -LiteralPath $qrScript -Force -ErrorAction SilentlyContinue |
| 332 | } |
| 333 | if ($qrExitCode -ne 0 -or -not (Test-Path -LiteralPath $png)) { |
| 334 | throw 'QR generation failed. Verify Python qrcode[pil] installation.' |
| 335 | } |
| 336 | Lock-File $png |
| 337 | } catch { |
| 338 | # Avoid leaving a half-provisioned profile with a private key on disk. |
| 339 | Remove-Item -LiteralPath $conf, $png -Force -ErrorAction SilentlyContinue |
| 340 | throw |
| 341 | } finally { |
| 342 | $privateKey = $null |
| 343 | $config = $null |
| 344 | } |
| 345 | |
| 346 | Write-Host "`nSUCCESS: client configuration and QR code created." -ForegroundColor Green |
| 347 | Write-Host "Config: $conf" |
| 348 | Write-Host "QR PNG: $png" |
| 349 | Write-Host "Client PUBLIC key: $publicKey" -ForegroundColor Yellow |
| 350 | Write-Host "`nExample RouterOS command (replace <WIREGUARD_INTERFACE>; verify IP $ip is unused):" |
| 351 | Write-Host ('/interface wireguard peers add interface=<WIREGUARD_INTERFACE> public-key="' + $publicKey + '" allowed-address=' + $ip + '/32 comment="' + $name + '"') -ForegroundColor Cyan |
| 352 | Write-Warning 'Both files contain the client private key. Share securely. Do not upload to public Gist or online QR services.' |
| 353 | Write-Warning 'This tool does not check the server for duplicate IPs or register peers automatically.' |
| 354 |
dartokloning / WireGuard client provisioning for Windows (offline QR generation)
Last active 22 hours ago
Creates <device>.conf and <device>.png, prints the client public key and RouterOS peer command. Never sends keys to a web service. Requires WireGuard for Windows (wg.exe). Uses Python qrcode[pil] exclusively. Can offer automatic Python installation through winget when absent.