#requires -version 5.1 <# .SYNOPSIS WireGuard client provisioning for Windows (offline QR generation). .DESCRIPTION Creates .conf and .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. Optional BAT launcher (save alongside this PS1 as provision.bat): @echo off powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0wireguard-provision.ps1" pause The BAT bypass applies only to this invocation; it does not change the system-wide PowerShell execution policy. Installation requires internet. Run the downloaded script only after reviewing its contents. #> [CmdletBinding()] param( [string]$OutputDirectory = (Join-Path $PWD.Path 'wireguard-profiles') ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' function Read-Required([string]$Prompt, [string]$Default = '') { while ($true) { $label = if ($Default) { "$Prompt [$Default]" } else { $Prompt } $v = Read-Host $label if ([string]::IsNullOrWhiteSpace($v)) { $v = $Default } if (-not [string]::IsNullOrWhiteSpace($v)) { return $v.Trim() } Write-Warning 'Value cannot be empty.' } } function Invoke-Wg([string]$Exe, [string]$Argument, [string]$InputText = $null) { $p = New-Object System.Diagnostics.Process $p.StartInfo.FileName = $Exe $p.StartInfo.Arguments = $Argument $p.StartInfo.UseShellExecute = $false $p.StartInfo.RedirectStandardOutput = $true $p.StartInfo.RedirectStandardError = $true $p.StartInfo.RedirectStandardInput = $true $p.StartInfo.CreateNoWindow = $true if (-not $p.Start()) { throw "Cannot start $Exe" } if ($null -ne $InputText) { $p.StandardInput.WriteLine($InputText) } $p.StandardInput.Close() $output = $p.StandardOutput.ReadToEnd().Trim() $stderr = $p.StandardError.ReadToEnd().Trim() $p.WaitForExit() if ($p.ExitCode -ne 0) { throw "wg.exe $Argument failed: $stderr" } return $output } function Test-IPv4([string]$Text) { $ip = $null return ([System.Net.IPAddress]::TryParse($Text, [ref]$ip) -and $ip.AddressFamily -eq [System.Net.Sockets.AddressFamily]::InterNetwork -and $Text -match '^(?:\d{1,3}\.){3}\d{1,3}$') } function Test-Key([string]$Text) { if ($Text -notmatch '^[A-Za-z0-9+/]{43}=$') { return $false } try { return ([Convert]::FromBase64String($Text).Length -eq 32) } catch { return $false } } function Lock-File([string]$Path) { # icacls accepts the raw SID with a leading *, avoiding localized account-name lookup. $sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value $icacls = Join-Path $env:SystemRoot 'System32\icacls.exe' if (-not (Test-Path -LiteralPath $icacls)) { throw 'icacls.exe not found; cannot secure credentials.' } $grant = ('*' + $sid + ':(F)') # Remove inherited access and explicitly grant only the provisioning account. # Do not continue if either operation fails. & $icacls $Path '/inheritance:r' | Out-Null if ($LASTEXITCODE -ne 0) { throw "Could not remove inherited ACLs from $Path" } & $icacls $Path '/grant:r' $grant | Out-Null if ($LASTEXITCODE -ne 0) { throw "Could not restrict ACLs on $Path" } } function Test-PythonExecutable([string]$Executable, [string[]]$Arguments) { # Use Process rather than PowerShell native invocation: Windows Store's # python.exe app execution alias prints an error to stderr and exits nonzero. # With $ErrorActionPreference='Stop', 2>$null is not sufficient in PS 5.1. try { $psi = New-Object System.Diagnostics.ProcessStartInfo $psi.FileName = $Executable $psi.Arguments = ($Arguments -join ' ') $psi.UseShellExecute = $false $psi.CreateNoWindow = $true $psi.RedirectStandardOutput = $true $psi.RedirectStandardError = $true $proc = [System.Diagnostics.Process]::Start($psi) if ($null -eq $proc) { return $null } $output = $proc.StandardOutput.ReadToEnd().Trim() $null = $proc.StandardError.ReadToEnd() $proc.WaitForExit() $exitCode = $proc.ExitCode $proc.Dispose() if ($exitCode -ne 0 -or -not $output) { return $null } $resolved = ($output -split "`r?`n")[0].Trim() if (-not (Test-Path -LiteralPath $resolved -PathType Leaf)) { return $null } return $resolved } catch { return $null } } function Get-WorkingPython { # Return ONLY the executable path on the success stream. Do not mix winget # installation output with this return value. foreach ($candidate in @('py.exe', 'python.exe')) { $cmd = Get-Command $candidate -ErrorAction SilentlyContinue if (-not $cmd -or -not $cmd.Source) { continue } $args = if ($candidate -eq 'py.exe') { @('-3', '-c', '"import sys; print(sys.executable)"') } else { @('-c', '"import sys; print(sys.executable)"') } $found = Test-PythonExecutable $cmd.Source $args if ($found) { return $found } } # A new install may not be on this process's PATH yet. $roots = @() if ($env:LOCALAPPDATA) { $roots += (Join-Path $env:LOCALAPPDATA 'Programs\Python') } if ($env:ProgramFiles) { $roots += (Join-Path $env:ProgramFiles 'Python') } if (${env:ProgramFiles(x86)}) { $roots += (Join-Path ${env:ProgramFiles(x86)} 'Python') } foreach ($root in $roots) { if (-not (Test-Path -LiteralPath $root)) { continue } $items = @(Get-ChildItem -LiteralPath $root -Filter python.exe -Recurse -ErrorAction SilentlyContinue | Where-Object { $_.FullName -notmatch '\\(Scripts|venv|Lib|WindowsApps)\\' } | Sort-Object FullName -Descending) foreach ($item in $items) { $found = Test-PythonExecutable $item.FullName @('-c', '"import sys; print(sys.executable)"') if ($found) { return $found } } } return $null } function Update-PythonPath([string]$PythonExe) { $pythonDir = Split-Path -Parent $PythonExe $scriptsDir = Join-Path $pythonDir 'Scripts' # Keep the user's existing PATH intact. Installers may have already added # these directories; do not duplicate them. $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') $entries = @($userPath -split ';' | Where-Object { $_ }) $missing = @(@($pythonDir, $scriptsDir) | Where-Object { $dir = $_ -not (@($entries | Where-Object { $_.TrimEnd('\') -ieq $dir.TrimEnd('\') }).Count -gt 0) }) if ($missing.Count -gt 0) { # Do not silently overwrite an overlong or unmodifiable user PATH. $newPath = (@($missing) + @($entries)) -join ';' [Environment]::SetEnvironmentVariable('Path', $newPath, 'User') } # Refresh the current process PATH from the persisted machine + user PATH. $machinePath = [Environment]::GetEnvironmentVariable('Path', 'Machine') $updatedUserPath = [Environment]::GetEnvironmentVariable('Path', 'User') $env:Path = ($machinePath, $updatedUserPath -join ';') } function Ensure-Python { $python = Get-WorkingPython if ($python) { return $python } Write-Warning 'Python 3 is not installed or cannot be run.' $answer = Read-Host 'Install Python automatically with winget? [Y]es / [M]anual (exit)' if ($answer -notmatch '^(y|yes)$') { Write-Host '' Write-Host 'Manual installation selected.' -ForegroundColor Yellow Write-Host 'Install Python 3 from https://www.python.org/downloads/windows/' Write-Host 'Then run this script again.' exit 0 } $winget = Get-Command winget.exe -ErrorAction SilentlyContinue if (-not $winget) { throw 'winget is unavailable. Install Python manually from python.org.' } Write-Host 'Searching for the latest stable Python 3 release series in winget...' -ForegroundColor Cyan # Redirect all winget output away from the function success pipeline. $results = @(& $winget.Source search --query 'Python.Python.3' --source winget --accept-source-agreements 2>&1) if ($LASTEXITCODE -ne 0) { throw ('winget search failed: ' + ($results -join ' ')) } $versions = @($results | ForEach-Object { [regex]::Matches([string]$_, 'Python\.Python\.3\.(\d+)') } | ForEach-Object { $_ } | Sort-Object { [int]$_.Groups[1].Value } -Descending) if ($versions.Count -eq 0) { throw 'No suitable Python 3 package was found in winget.' } $packageId = $versions[0].Value Write-Host "Installing $packageId with Python and pip added to the user PATH..." -ForegroundColor Cyan # This override targets the classic python.org installer packages (Python.Python.3.x). # Do not apply these arguments to unrelated Python distribution packages. & $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 if ($LASTEXITCODE -ne 0) { throw 'Python installation failed. Review winget output above.' } $python = Get-WorkingPython if (-not $python) { throw 'Python installer completed, but python.exe was not detected. Review installation and rerun.' } Update-PythonPath $python Write-Host "Python ready: $python" -ForegroundColor Green return $python } function Invoke-PythonProcess([string]$PythonExe, [string[]]$Arguments) { # Avoid PowerShell 5.1 treating Python stderr as a terminating NativeCommandError. # ArgumentList is unavailable on Windows PowerShell 5.1; quote each argument. $psi = New-Object System.Diagnostics.ProcessStartInfo $psi.FileName = $PythonExe $psi.Arguments = ($Arguments | ForEach-Object { '"' + $_.Replace('"', '\"') + '"' }) -join ' ' $psi.UseShellExecute = $false $psi.CreateNoWindow = $true $psi.RedirectStandardOutput = $true $psi.RedirectStandardError = $true $proc = [System.Diagnostics.Process]::Start($psi) if (-not $proc) { throw 'Could not start Python.' } try { $stdout = $proc.StandardOutput.ReadToEnd() $stderr = $proc.StandardError.ReadToEnd() $proc.WaitForExit() return [pscustomobject]@{ ExitCode = $proc.ExitCode; StdOut = $stdout; StdErr = $stderr } } finally { $proc.Dispose() } } function Ensure-QrLibrary([string]$PythonExe) { $check = Invoke-PythonProcess $PythonExe @('-c', 'import qrcode; from PIL import Image') if ($check.ExitCode -eq 0) { return } # Missing qrcode/Pillow is expected on a fresh Python installation. # Do not surface its traceback as a PowerShell exception. Write-Host 'QR dependencies are missing; preparing pip and qrcode[pil]...' -ForegroundColor Yellow $pip = Invoke-PythonProcess $PythonExe @('-m', 'pip', '--version') if ($pip.ExitCode -ne 0) { $bootstrap = Invoke-PythonProcess $PythonExe @('-m', 'ensurepip', '--upgrade') if ($bootstrap.ExitCode -ne 0) { throw ("pip bootstrap failed: " + $bootstrap.StdErr) } } Write-Host 'Updating pip...' -ForegroundColor Cyan $upgrade = Invoke-PythonProcess $PythonExe @('-m', 'pip', 'install', '--disable-pip-version-check', '--upgrade', 'pip') if ($upgrade.ExitCode -ne 0) { Write-Warning ('pip update failed; trying existing pip: ' + $upgrade.StdErr.Trim()) } Write-Host 'Installing/updating qrcode and Pillow...' -ForegroundColor Cyan $install = Invoke-PythonProcess $PythonExe @('-m', 'pip', 'install', '--disable-pip-version-check', '--upgrade', 'qrcode[pil]') if ($install.ExitCode -ne 0) { throw ('qrcode[pil] installation failed: ' + $install.StdErr.Trim()) } $verify = Invoke-PythonProcess $PythonExe @('-c', 'import qrcode; from PIL import Image') if ($verify.ExitCode -ne 0) { throw ('QR dependency verification failed: ' + $verify.StdErr.Trim()) } Write-Host 'QR dependencies ready.' -ForegroundColor Green } Write-Host "`n=== WireGuard Client Provisioning (Windows) ===" -ForegroundColor Cyan $wg = Join-Path $env:ProgramFiles 'WireGuard\wg.exe' if (-not (Test-Path -LiteralPath $wg)) { $found = Get-Command wg.exe -ErrorAction SilentlyContinue if ($found) { $wg = $found.Source } else { throw 'wg.exe not found. Install WireGuard for Windows first.' } } $python = Ensure-Python Ensure-QrLibrary $python $name = Read-Required 'User / device name (filename, e.g. Laptop-Alice)' if ($name -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$' -or $name -eq '.' -or $name -eq '..') { throw 'Use 1-64 characters: letters, numbers, dot, dash, underscore; start with a letter or number.' } $address = Read-Required 'Client tunnel IPv4/CIDR (e.g. 10.77.0.2/32)' if ($address -notmatch '^([^/]+)/32$' -or -not (Test-IPv4 $Matches[1])) { throw 'Client address must be a valid IPv4 /32, e.g. 10.77.0.2/32.' } $ip = $address.Split('/')[0] $dns = Read-Required 'DNS servers (comma-separated, e.g. 1.1.1.1, 9.9.9.9)' $dnsParts = @($dns -split '\s*,\s*') if ($dnsParts.Count -eq 0 -or @($dnsParts | Where-Object { -not (Test-IPv4 $_) }).Count -gt 0) { throw 'DNS must be a comma-separated list of IPv4 addresses.' } $dns = $dnsParts -join ', ' $endpoint = Read-Required 'Server endpoint (hostname:port, e.g. vpn.example.net:51820)' if ($endpoint -notmatch '^([A-Za-z0-9.-]+):(\d{1,5})$' -or [int]$Matches[2] -lt 1 -or [int]$Matches[2] -gt 65535) { throw 'Endpoint must be hostname-or-IPv4:port, e.g. vpn.example.net:51820.' } $allowedIPs = Read-Required 'AllowedIPs (comma-separated; full tunnel default)' '0.0.0.0/0' $allowedParts = @($allowedIPs -split '\s*,\s*') foreach ($route in $allowedParts) { if ($route -notmatch '^([^/]+)/(\d{1,2})$' -or -not (Test-IPv4 $Matches[1]) -or [int]$Matches[2] -gt 32) { throw 'AllowedIPs must be IPv4 CIDR entries, e.g. 0.0.0.0/0 or 10.77.0.0/24.' } } $allowedIPs = $allowedParts -join ', ' $serverKey = Read-Required 'WireGuard SERVER public key (Base64, no quotes)' if (-not (Test-Key $serverKey)) { throw 'Invalid WireGuard public key (expected 32-byte Base64 key).' } # Refuse to overwrite any existing profile. This is a provisioning tool, not an IP allocator. $fullOutput = [System.IO.Path]::GetFullPath($OutputDirectory) [void][System.IO.Directory]::CreateDirectory($fullOutput) $conf = Join-Path $fullOutput "$name.conf" $png = Join-Path $fullOutput "$name.png" if ((Test-Path -LiteralPath $conf) -or (Test-Path -LiteralPath $png)) { throw 'Profile filename already exists. Choose another name; nothing was overwritten.' } $privateKey = Invoke-Wg $wg 'genkey' if (-not (Test-Key $privateKey)) { throw 'wg genkey returned an invalid key.' } $publicKey = Invoke-Wg $wg 'pubkey' $privateKey if (-not (Test-Key $publicKey)) { throw 'wg pubkey returned an invalid key.' } $config = @" [Interface] PrivateKey = $privateKey Address = $address DNS = $dns [Peer] PublicKey = $serverKey AllowedIPs = $allowedIPs Endpoint = $endpoint PersistentKeepalive = 25 "@ try { # ASCII avoids a UTF-8 BOM, which some VPN config parsers reject. [System.IO.File]::WriteAllText($conf, ($config.Trim() + "`n"), [System.Text.Encoding]::ASCII) Lock-File $conf # Use a temporary .py file instead of python -c: Windows PowerShell 5.1 # can strip nested double quotes when passing native command arguments. # This temporary file contains CODE ONLY, never the WireGuard private key. $qrScript = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), ('wireguard-qr-' + [guid]::NewGuid().ToString('N') + '.py')) $pyCode = @' import sys import qrcode with open(sys.argv[1], 'r', encoding='ascii') as f: config = f.read() qr = qrcode.QRCode( error_correction=qrcode.constants.ERROR_CORRECT_M, box_size=8, border=4 ) qr.add_data(config) qr.make(fit=True) qr.make_image(fill_color='black', back_color='white').save(sys.argv[2]) '@ try { [System.IO.File]::WriteAllText($qrScript, $pyCode, [System.Text.Encoding]::ASCII) & $python $qrScript $conf $png $qrExitCode = $LASTEXITCODE } finally { Remove-Item -LiteralPath $qrScript -Force -ErrorAction SilentlyContinue } if ($qrExitCode -ne 0 -or -not (Test-Path -LiteralPath $png)) { throw 'QR generation failed. Verify Python qrcode[pil] installation.' } Lock-File $png } catch { # Avoid leaving a half-provisioned profile with a private key on disk. Remove-Item -LiteralPath $conf, $png -Force -ErrorAction SilentlyContinue throw } finally { $privateKey = $null $config = $null } Write-Host "`nSUCCESS: client configuration and QR code created." -ForegroundColor Green Write-Host "Config: $conf" Write-Host "QR PNG: $png" Write-Host "Client PUBLIC key: $publicKey" -ForegroundColor Yellow Write-Host "`nExample RouterOS command (replace ; verify IP $ip is unused):" Write-Host ('/interface wireguard peers add interface= public-key="' + $publicKey + '" allowed-address=' + $ip + '/32 comment="' + $name + '"') -ForegroundColor Cyan Write-Warning 'Both files contain the client private key. Share securely. Do not upload to public Gist or online QR services.' Write-Warning 'This tool does not check the server for duplicate IPs or register peers automatically.'