Private/WgInStallerGenerator.psm1
|
#!/usr/bin/env pwsh using namespace System using namespace System.IO using namespace System.Web using namespace System.Text using namespace System.Net.Http using namespace System.Text.RegularExpressions using namespace System.Collections.Specialized #Requires -PSEdition Core #Requires -Modules PsModuleBase, argparser #region enums enum GenerationMode { Interactive Direct ConfigFile } enum InstallStatus { NotInstalled Downloading Installing Installed Failed } #endregion #region Exceptions class WgInstallException : Exception { WgInstallException() {} WgInstallException([string]$message) : base($message) {} WgInstallException([string]$message, [Exception]$innerException) : base($message, $innerException) {} } class WgValidationException : Exception { WgValidationException() {} WgValidationException([string]$message) : base($message) {} WgValidationException([string]$message, [Exception]$innerException) : base($message, $innerException) {} } #endregion #region WgInstallerValidator class WgInstallerValidator { static [bool] IsValidIPv4([string]$ip) { if ([string]::IsNullOrWhiteSpace($ip)) { return $false } $clean = $ip.Split('/')[0] $cidr = $null if ($ip.Contains('/')) { $parts = $ip.Split('/') if ($parts.Length -ne 2) { return $false } if (![int]::TryParse($parts[1], [ref]$cidr)) { return $false } if ($cidr -lt 0 -or $cidr -gt 32) { return $false } } return [IPAddress]::TryParse($clean, [ref]$null) } static [bool] IsValidDNS([string]$dns) { if ([string]::IsNullOrWhiteSpace($dns)) { return $false } $servers = $dns.Split(',').ForEach({ $_.Trim() }) foreach ($server in $servers) { if (![IPAddress]::TryParse($server, [ref]$null)) { if (![Uri]::CheckHostName($server)) { return $false } } } return $true } static [bool] IsValidPublicKey([string]$key) { if ([string]::IsNullOrWhiteSpace($key)) { return $false } $key = $key.Trim() if ($key.Length -ne 44) { return $false } try { [Convert]::FromBase64String($key) | Out-Null return $true } catch { return $false } } static [bool] IsValidEndpoint([string]$endpoint) { if ([string]::IsNullOrWhiteSpace($endpoint)) { return $false } $endpoint = $endpoint.Trim() if ($endpoint -notmatch '^.+:\d+$') { return $false } $port = [int]($endpoint.Split(':')[-1]) return $port -gt 0 -and $port -le 65535 } static [bool] IsValidKeepAlive([string]$keepalive) { if ([string]::IsNullOrWhiteSpace($keepalive)) { return $false } $val = 0 if (![int]::TryParse($keepalive.Trim(), [ref]$val)) { return $false } return $val -ge 0 -and $val -le 65535 } static [void] ValidateConfig([WgInstallerConfig]$config) { $errors = [Ordered]@{} if (![WgInstallerValidator]::IsValidInterfaceName($config.InterfaceName)) { $errors['InterfaceName'] = 'Invalid interface name. Use alphanumeric characters and hyphens only.' } if (![WgInstallerValidator]::IsValidIPv4($config.ClientIP)) { $errors['ClientIP'] = 'Invalid IPv4 address. Use format like 10.0.0.2/24' } if (![WgInstallerValidator]::IsValidDNS($config.DNS)) { $errors['DNS'] = 'Invalid DNS. Use comma-separated IPs or hostnames.' } if (![WgInstallerValidator]::IsValidPublicKey($config.ServerPublicKey)) { $errors['ServerPublicKey'] = 'Invalid public key. Must be 44-character base64 string.' } if (![WgInstallerValidator]::IsValidIPv4OrWildcard($config.AllowedIPs)) { $errors['AllowedIPs'] = 'Invalid AllowedIPs. Use comma-separated CIDRs or 0.0.0.0/0' } if (![WgInstallerValidator]::IsValidEndpoint($config.Endpoint)) { $errors['Endpoint'] = 'Invalid endpoint. Use format host:port' } if (![WgInstallerValidator]::IsValidKeepAlive([string]$config.KeepAlive)) { $errors['KeepAlive'] = 'Invalid keepalive. Must be 0-65535.' } if ($errors.Count -gt 0) { $msg = $errors.GetEnumerator().ForEach({ "$($_.Key): $($_.Value)" }) -join "`n" throw [WgValidationException]::new("Configuration validation failed:`n$msg") } } static [bool] IsValidInterfaceName([string]$name) { if ([string]::IsNullOrWhiteSpace($name)) { return $false } return $name -match '^[a-zA-Z0-9_-]+$' } static [bool] IsValidIPv4OrWildcard([string]$ips) { if ([string]::IsNullOrWhiteSpace($ips)) { return $false } $items = $ips.Split(',').ForEach({ $_.Trim() }) foreach ($item in $items) { if ($item -eq '0.0.0.0/0' -or $item -eq '::/0') { continue } if (![WgInstallerValidator]::IsValidIPv4($item)) { return $false } } return $true } } #endregion #region WgInstallerConfig class WgInstallerConfig { [string]$InterfaceName = 'wg0' [string]$ClientIP = '10.0.0.2/24' [string]$DNS = '1.1.1.1, 8.8.8.8' [string]$ServerPublicKey = '' [string]$AllowedIPs = '0.0.0.0/0' [string]$Endpoint = '' [int]$KeepAlive = 15 WgInstallerConfig() {} WgInstallerConfig([string]$interfaceName, [string]$clientIP, [string]$dns, [string]$serverPublicKey, [string]$allowedIPs, [string]$endpoint, [int]$keepAlive) { $this.InterfaceName = $interfaceName $this.ClientIP = $clientIP $this.DNS = $dns $this.ServerPublicKey = $serverPublicKey $this.AllowedIPs = $allowedIPs $this.Endpoint = $endpoint $this.KeepAlive = $keepAlive } [string] ToString() { return @" === WireGuard Client Configuration === Interface Name : $($this.InterfaceName) Client IP : $($this.ClientIP) DNS : $($this.DNS) Server PubKey : $($this.ServerPublicKey) Allowed IPs : $($this.AllowedIPs) Endpoint : $($this.Endpoint) KeepAlive : $($this.KeepAlive) ====================================== "@ } [string] ToJson() { return $this | ConvertTo-Json -Compress } static [WgInstallerConfig] FromJson([string]$json) { $obj = $json | ConvertFrom-Json $config = [WgInstallerConfig]::new() $config.InterfaceName = $obj.InterfaceName $config.ClientIP = $obj.ClientIP $config.DNS = $obj.DNS $config.ServerPublicKey = $obj.ServerPublicKey $config.AllowedIPs = $obj.AllowedIPs $config.Endpoint = $obj.Endpoint $config.KeepAlive = $obj.KeepAlive return $config } static [WgInstallerConfig] FromFile([string]$path) { $json = [File]::ReadAllText($path) return [WgInstallerConfig]::FromJson($json) } [void] SaveToFile([string]$path) { $dir = [Path]::GetDirectoryName($path) if (![string]::IsNullOrWhiteSpace($dir) -and !(Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } $utf8NoBom = [UTF8Encoding]::new($false) [File]::WriteAllText($path, $this.ToJson(), $utf8NoBom) } [string] ToInstallerScript() { $script = @' # ========================================================== # WireGuard - Client Installer (Windows) # Auto-generated by WgInStallerGenerator # ========================================================== #Requires -RunAsAdministrator # ---------------- VARIABLES ---------------- $InterfaceName = "{{INTERFACE_NAME}}" $ClientIP = "{{CLIENT_IP}}" $DNS = "{{DNS}}" $PeerPublicKey = "{{SERVER_PUBLIC_KEY}}" $AllowedIPs = "{{ALLOWED_IPS}}" $Endpoint = "{{ENDPOINT}}" $KeepAlive = {{KEEPALIVE}} # ---------------- PATHS ---------------- $WGBase = [IO.Path]::Combine($env:ProgramFiles, 'WireGuard') $WGExe = "$WGBase\wireguard.exe" $WGCmd = "$WGBase\wg.exe" $WorkDir = "C:\ConfWireGuard" $WGConfDir = "$WGBase\Data\Configurations" $WorkConf = "$WorkDir\$InterfaceName.conf" $FinalConf = "$WGConfDir\$InterfaceName.conf" # ---------------- ADMIN ---------------- $principal = New-Object Security.Principal.WindowsPrincipal( [Security.Principal.WindowsIdentity]::GetCurrent() ) if (!$principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { Write-Host "Run as Administrator." -ForegroundColor Red exit 1 } # ---------------- FUNCTION ---------------- function Write-FileNoBOM { param ($Path, $Content) $utf8NoBom = New-Object System.Text.UTF8Encoding($false) [System.IO.File]::WriteAllText($Path, $Content, $utf8NoBom) } # ---------------- DIRECTORIES ---------------- foreach ($dir in @($WorkDir, $WGConfDir)) { if (!(Test-Path $dir)) { New-Item -ItemType Directory -Path $dir | Out-Null } } # ---------------- INSTALL WG ---------------- if (!(Test-Path $WGExe)) { Write-Host "Installing WireGuard..." -ForegroundColor Yellow $Installer = "$env:TEMP\wireguard-installer.exe" Invoke-WebRequest ` -Uri "https://download.wireguard.com/windows-client/wireguard-installer.exe" ` -OutFile $Installer Start-Process $Installer -ArgumentList "/install /quiet" -Wait Start-Sleep -Seconds 5 } # ---------------- KEYS ---------------- $PrivateKey = & $WGCmd genkey $PublicKey = $PrivateKey | & $WGCmd pubkey # ---------------- CONF ---------------- $Config = @" [Interface] PrivateKey = $PrivateKey Address = $ClientIP DNS = $DNS [Peer] PublicKey = $PeerPublicKey AllowedIPs = $AllowedIPs Endpoint = $Endpoint PersistentKeepalive = $KeepAlive "@ Write-FileNoBOM $WorkConf $Config Copy-Item $WorkConf $FinalConf -Force # ---------------- IMPORT ---------------- & $WGExe /uninstalltunnelservice $InterfaceName 2>$null & $WGExe /installtunnelservice $FinalConf Write-Host "WireGuard installed successfully!" -ForegroundColor Green Write-Host "Client Public Key: $PublicKey" '@ $script = $script -replace '{{INTERFACE_NAME}}', [Regex]::Escape($this.InterfaceName) $script = $script -replace '{{CLIENT_IP}}', [Regex]::Escape($this.ClientIP) $script = $script -replace '{{DNS}}', [Regex]::Escape($this.DNS) $script = $script -replace '{{SERVER_PUBLIC_KEY}}', [Regex]::Escape($this.ServerPublicKey) $script = $script -replace '{{ALLOWED_IPS}}', [Regex]::Escape($this.AllowedIPs) $script = $script -replace '{{ENDPOINT}}', [Regex]::Escape($this.Endpoint) $script = $script -replace '\{\{KEEPALIVE\}\}', $this.KeepAlive return $script } } #endregion #region WgInstallerPaths class WgInstallerPaths { [string]$WireGuardBase = [IO.Path]::Combine($env:ProgramFiles, 'WireGuard') [string]$WireGuardExe [string]$WgExe [string]$WorkDir = 'C:\ConfWireGuard' [string]$WgConfigDir [string]$WorkConf [string]$FinalConf WgInstallerPaths([string]$interfaceName) { $this.WireGuardExe = Join-Path $this.WireGuardBase 'wireguard.exe' $this.WgExe = Join-Path $this.WireGuardBase 'wg.exe' $this.WgConfigDir = Join-Path $this.WireGuardBase 'Data\Configurations' $this.WorkConf = Join-Path $this.WorkDir "$interfaceName.conf" $this.FinalConf = Join-Path $this.WgConfigDir "$interfaceName.conf" } [void] EnsureDirectories() { foreach ($dir in @($this.WorkDir, $this.WgConfigDir)) { if (!(Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } } } } #endregion #region WgInstallerRunner class WgInstallerRunner { static [void] CheckAdmin() { $principal = [Security.Principal.WindowsPrincipal]::new( [Security.Principal.WindowsIdentity]::GetCurrent() ) if (!$principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw [WgInstallException]::new('This operation requires Administrator privileges.') } } static [InstallStatus] CheckWireGuardInstalled() { $wgExe = [IO.Path]::Combine($env:ProgramFiles, 'WireGuard', 'wireguard.exe') if (Test-Path $wgExe -PathType Leaf -ea Ignore) { return 'Installed' } return 'NotInstalled' } static [void] SilentInstallWireGuard() { $installer = Join-Path $env:TEMP 'wireguard-installer.exe' Write-Host 'Downloading WireGuard installer...' -ForegroundColor Yellow Invoke-WebRequest ` -Uri 'https://download.wireguard.com/windows-client/wireguard-installer.exe' ` -OutFile $installer Write-Host 'Installing WireGuard silently...' -ForegroundColor Yellow Start-Process $installer -ArgumentList '/install /quiet' -Wait Start-Sleep -Seconds 5 if ([WgInstallerRunner]::CheckWireGuardInstalled() -ne [InstallStatus]::Installed) { throw [WgInstallException]::new('WireGuard installation failed.') } } static [string[]] GenerateKeys([string]$wgCmdPath) { if (!(Test-Path $wgCmdPath)) { throw [WgInstallException]::new("wg.exe not found at $wgCmdPath") } $privateKey = & $wgCmdPath genkey $publicKey = $privateKey | & $wgCmdPath pubkey return @($privateKey, $publicKey) } static [void] WriteConfigFile([string]$path, [string]$content) { $dir = [Path]::GetDirectoryName($path) if (!(Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } $utf8NoBom = [UTF8Encoding]::new($false) [File]::WriteAllText($path, $content, $utf8NoBom) } static [void] ImportTunnel([string]$wgExePath, [string]$configPath, [string]$interfaceName) { if (!(Test-Path $wgExePath)) { throw [WgInstallException]::new("wireguard.exe not found at $wgExePath") } & $wgExePath /uninstalltunnelservice $interfaceName 2>$null & $wgExePath /installtunnelservice $configPath } } #endregion #region WgInStallerGenerator class WgInStallerGenerator : PsModuleBase { <# .SYNOPSIS A Hlper class to interactively help the user Generate a windows installer ps1 script to silently install and configure WireGuard vpn .EXAMPLE # Interactive mode [WgInStallerGenerator]::Generate('--output installer.ps1') .EXAMPLE # Use config file [WgInStallerGenerator]::Generate('--config wg-config.json --output installer.ps1') .EXAMPLE # Save config after interactive generation [WgInStallerGenerator]::Generate('--output installer.ps1 --save-config wg-config.json') #> static [WgInstallerConfig] GetDefaultConfig() { return [WgInstallerConfig]::new() } static [string] GetDefaultConfigPath() { return [IO.Path]::Combine($env:USERPROFILE, '.wg-installer-config.json') } static [IO.FileInfo] Generate([string]$argline) { if ([string]::IsNullOrWhiteSpace($argline)) { throw [ArgumentNullException]::new() } return [WgInStallerGenerator]::Generate($argline.Split(' ')) } static [WgInstallerConfig] GetConfigFromArgs([string[]]$argslist) { $schema = @{ help = [switch], $false h = [switch], $false interactive = [switch], $false i = [switch], $false config = [string], $null c = [string], $null output = [string], $null o = [string], $null saveConfig = [string], $null } if ($null -eq $argslist -or $argslist.Count -eq 0) { return [WgInStallerGenerator]::PromptInteractive() } $parsed = ArgParser\ConvertTo-Params $argslist -schema $schema $showHelp = [bool]($parsed['help'].Value -or $parsed['h'].Value) if ($showHelp) { [WgInStallerGenerator]::ShowHelp() return $null } $configPath = if ($parsed['config'].Value) { $parsed['config'].Value } elseif ($parsed['c'].Value) { $parsed['c'].Value } else { $null } $config = $null if ($parsed['interactive'].Value -or $parsed['i'].Value) { $config = [WgInStallerGenerator]::PromptInteractive() } elseif (![string]::IsNullOrWhiteSpace($configPath)) { $config = [WgInStallerGenerator]::LoadFromConfigFile($configPath) } elseif ([IO.File]::Exists([WgInStallerGenerator]::GetDefaultConfigPath())) { $config = [WgInStallerGenerator]::LoadFromConfigFile([WgInStallerGenerator]::GetDefaultConfigPath()) } else { $config = [WgInStallerGenerator]::PromptInteractive() } $saveConfigPath = $parsed['saveConfig'].Value if (![string]::IsNullOrWhiteSpace($saveConfigPath) -and $null -ne $config) { [WgInStallerGenerator]::SaveConfig($config, $saveConfigPath) } return $config } static [IO.FileInfo] Generate([string[]]$argslist) { $config = [WgInStallerGenerator]::GetConfigFromArgs($argslist) if ($null -eq $config) { return $null } $schema = @{ help = [switch], $false h = [switch], $false interactive = [switch], $false i = [switch], $false config = [string], $null c = [string], $null output = [string], $null o = [string], $null saveConfig = [string], $null } $parsed = ArgParser\ConvertTo-Params $argslist -schema $schema $outputPath = if ($parsed['output'].Value) { $parsed['output'].Value } elseif ($parsed['o'].Value) { $parsed['o'].Value } else { $null } if ([string]::IsNullOrWhiteSpace($outputPath)) { $outputPath = Join-Path (Get-Location).Path 'installer.ps1' } return [WgInStallerGenerator]::Generate([WgInstallerConfig]$config, $outputPath) } static [IO.FileInfo] Generate([DirectoryInfo]$outputPath) { $config = [WgInStallerGenerator]::PromptInteractive() return [WgInStallerGenerator]::Generate($config, $outputPath.FullName) } static [IO.FileInfo] Generate([WgInstallerConfig]$config, [string]$outputPath) { [WgInstallerValidator]::ValidateConfig($config) $scriptContent = $config.ToInstallerScript() $dir = [Path]::GetDirectoryName($outputPath) if (![string]::IsNullOrWhiteSpace($dir) -and !(Test-Path $dir -PathType Container -ea Ignore)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } $utf8NoBom = [UTF8Encoding]::new($false) [File]::WriteAllText($outputPath, $scriptContent, $utf8NoBom) return [IO.FileInfo]::new($outputPath) } static [WgInstallerConfig] PromptInteractive() { Write-Host "`n=== WireGuard Client Installer ===" -ForegroundColor Cyan Write-Host "Enter configuration values (press Enter for defaults)`n" -ForegroundColor DarkCyan $config = [WgInstallerConfig]::new() $config.InterfaceName = [WgInStallerGenerator]::ReadInput('Interface Name', $config.InterfaceName, { param($val) return [WgInstallerValidator]::IsValidInterfaceName($val) }, 'Use alphanumeric characters and hyphens only.') $config.ClientIP = [WgInStallerGenerator]::ReadInput('Client IP (with CIDR)', $config.ClientIP, { param($val) return [WgInstallerValidator]::IsValidIPv4($val) }, 'Example: 10.0.0.2/24') $config.DNS = [WgInStallerGenerator]::ReadInput('DNS servers (comma-separated)', $config.DNS, { param($val) return [WgInstallerValidator]::IsValidDNS($val) }, 'Example: 1.1.1.1, 8.8.8.8') $config.ServerPublicKey = [WgInStallerGenerator]::ReadInput('Server Public Key', '', { param($val) return [WgInstallerValidator]::IsValidPublicKey($val) }, '44-character base64 string') $config.AllowedIPs = [WgInStallerGenerator]::ReadInput('Allowed IPs', $config.AllowedIPs, { param($val) return [WgInstallerValidator]::IsValidIPv4OrWildcard($val) }, 'Example: 0.0.0.0/0 or 10.0.0.0/8,192.168.1.0/24') $config.Endpoint = [WgInStallerGenerator]::ReadInput('Endpoint (host:port)', '', { param($val) return [WgInstallerValidator]::IsValidEndpoint($val) }, 'Example: vpn.example.com:51820') $keepAliveStr = [WgInStallerGenerator]::ReadInput('Persistent Keepalive (seconds)', [string]$config.KeepAlive, { param($val) return [WgInstallerValidator]::IsValidKeepAlive($val) }, '0-65535 (15 recommended)') $config.KeepAlive = [int]$keepAliveStr Write-Host "`n$config" -ForegroundColor Green $confirm = Read-Host 'Save this installer script? (Y/n)' if ($confirm -match '^[nN]') { throw [WgInstallException]::new('Generation cancelled by user.') } return $config } static [string] ReadInput([string]$prompt, [string]$default, [scriptblock]$validator, [string]$helpText) { $usrinput = [string]::Empty while ($true) { $displayDefault = if (![string]::IsNullOrWhiteSpace($default)) { " [$default]" } else { '' } $value = Read-Host "$prompt$displayDefault" if ([string]::IsNullOrWhiteSpace($value)) { if (![string]::IsNullOrWhiteSpace($default)) { $usrinput = $default break } Write-Host " This field is required. $helpText" -ForegroundColor Red continue } if ($validator.InvokeReturnAsIs($value)) { $usrinput = $value.Trim() break } Write-Host " Invalid input. $helpText" -ForegroundColor Red } return $usrinput } static [WgInstallerConfig] LoadFromConfigFile([string]$path) { if (![IO.File]::Exists($path)) { throw [WgInstallException]::new("Config file path not found") } return [WgInstallerConfig]::FromFile($path) } static [void] SaveConfig([WgInstallerConfig]$config, [string]$path) { $config.SaveToFile($path) Write-Host "Configuration saved to $path" -ForegroundColor Green } static [IO.FileInfo] GenerateFromConfigFile([string]$configPath, [string]$outputPath) { $config = [WgInStallerGenerator]::LoadFromConfigFile($configPath) return [WgInStallerGenerator]::Generate($config, $outputPath) } static [void] ShowHelp() { $help = @" WireGuard Client Installer USAGE: Import-Module WireGuardCtl -Scope Local [WgInStallerGenerator]::Generate(@('--interactive', '--output', 'installer.ps1')) OPTIONS: --interactive, -i Interactive mode with prompts (default if no config specified) --config <path>, -c Load configuration from JSON file --output <path>, -o Output path for generated installer script --save-config <path> Save configuration to JSON file after generation --help, -h Show this help message EXAMPLES: # Interactive mode [WgInStallerGenerator]::Generate('--output installer.ps1') # From config file [WgInStallerGenerator]::Generate(@('--config', 'wg-config.json', '--output', 'installer.ps1')) # Save config after interactive generation [WgInStallerGenerator]::Generate('--output installer.ps1 --save-config wg-config.json') "@ Write-Host $help -f Green } } #endregion # Types that will be available to users when they import the module. $typestoExport = @( [GenerationMode], [InstallStatus], [WgInstallException], [WgValidationException], [WgInstallerValidator], [WgInstallerConfig], [WgInstallerPaths], [WgInstallerRunner], [WgInStallerGenerator] ) $TypeAcceleratorsClass = [PsObject].Assembly.GetType('System.Management.Automation.TypeAccelerators') foreach ($Type in $typestoExport) { if ($Type.FullName -in $TypeAcceleratorsClass::Get.Keys) { $Message = @( "Unable to register type accelerator '$($Type.FullName)'" 'Accelerator already exists.' ) -join ' - ' "TypeAcceleratorAlreadyExists $Message" | Write-Debug } } # Add type accelerators for every exportable type. foreach ($Type in $typestoExport) { $TypeAcceleratorsClass::Add($Type.FullName, $Type) } # Remove type accelerators when the module is removed. $MyInvocation.MyCommand.ScriptBlock.Module.OnRemove = { foreach ($Type in $typestoExport) { $TypeAcceleratorsClass::Remove($Type.FullName) } }.GetNewClosure(); |