Like 0

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.

wireguard-provision.ps1 Raw
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()]
21param(
22 [string]$OutputDirectory = (Join-Path $PWD.Path 'wireguard-profiles')
23)
24Set-StrictMode -Version Latest
25$ErrorActionPreference = 'Stop'
26
27function 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}
36function 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}
54function 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}
60function 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}
64function 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
78function 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}
105function 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}
136function 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}
157function 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}
191function 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}
212function 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
234Write-Host "`n=== WireGuard Client Provisioning (Windows) ===" -ForegroundColor Cyan
235$wg = Join-Path $env:ProgramFiles 'WireGuard\wg.exe'
236if (-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
243Ensure-QrLibrary $python
244
245$name = Read-Required 'User / device name (filename, e.g. Laptop-Alice)'
246if ($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)'
250if ($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*')
256if ($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)'
261if ($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*')
266foreach ($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)'
273if (-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"
280if ((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'
285if (-not (Test-Key $privateKey)) { throw 'wg genkey returned an invalid key.' }
286$publicKey = Invoke-Wg $wg 'pubkey' $privateKey
287if (-not (Test-Key $publicKey)) { throw 'wg pubkey returned an invalid key.' }
288$config = @"
289[Interface]
290PrivateKey = $privateKey
291Address = $address
292DNS = $dns
293
294[Peer]
295PublicKey = $serverKey
296AllowedIPs = $allowedIPs
297Endpoint = $endpoint
298PersistentKeepalive = 25
299"@
300
301try {
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 = @'
312import sys
313import qrcode
314
315with open(sys.argv[1], 'r', encoding='ascii') as f:
316 config = f.read()
317qr = qrcode.QRCode(
318 error_correction=qrcode.constants.ERROR_CORRECT_M,
319 box_size=8,
320 border=4
321)
322qr.add_data(config)
323qr.make(fit=True)
324qr.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
346Write-Host "`nSUCCESS: client configuration and QR code created." -ForegroundColor Green
347Write-Host "Config: $conf"
348Write-Host "QR PNG: $png"
349Write-Host "Client PUBLIC key: $publicKey" -ForegroundColor Yellow
350Write-Host "`nExample RouterOS command (replace <WIREGUARD_INTERFACE>; verify IP $ip is unused):"
351Write-Host ('/interface wireguard peers add interface=<WIREGUARD_INTERFACE> public-key="' + $publicKey + '" allowed-address=' + $ip + '/32 comment="' + $name + '"') -ForegroundColor Cyan
352Write-Warning 'Both files contain the client private key. Share securely. Do not upload to public Gist or online QR services.'
353Write-Warning 'This tool does not check the server for duplicate IPs or register peers automatically.'
354