# ============================================================================= # intenv-vpn.ps1 - Unified VPN client for Windows # ============================================================================= # Single script for complete VPN lifecycle: setup, connect, renew, uninstall. # Uses native PowerShell API calls to OpenBao (no external CLI required). # # Usage: # .\intenv-vpn.ps1 setup Full setup: deps -> bootstrap -> cert -> auto-renewal # .\intenv-vpn.ps1 connect Connect to VPN # .\intenv-vpn.ps1 disconnect Disconnect # .\intenv-vpn.ps1 renew Renew certificate # .\intenv-vpn.ps1 status Show status # .\intenv-vpn.ps1 uninstall Remove everything # .\intenv-vpn.ps1 test Run diagnostics # ============================================================================= [CmdletBinding()] param( [Parameter(Position=0)] [ValidateSet('setup','connect','disconnect','renew','status','uninstall','test','help')] [string]$Command = 'help', [switch]$Quiet, [switch]$Force ) # ============================================================================= # AUTO-ELEVATION (hidden elevated window with output relay to parent console) # ============================================================================= if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole( [Security.Principal.WindowsBuiltInRole]::Administrator)) { # --- Collect interactive input BEFORE elevation (in user's visible console) --- $preUser = $null if ($Command -in @('setup','connect')) { $cfgPath = "$env:USERPROFILE\.config\intenv-vpn\config.json" $savedUser = $null if (Test-Path $cfgPath) { try { $savedUser = (Get-Content $cfgPath -Raw | ConvertFrom-Json).username } catch {} } if (-not $savedUser) { Write-Host "" $preUser = Read-Host "Enter your FreeIPA username (without @intenv.ru)" if (-not $preUser) { Write-Host "[ERROR] Username is required" -ForegroundColor Red; exit 1 } $preUser = $preUser.Trim() } } if ($Command -eq 'uninstall') { Write-Host "`nINTENV VPN Uninstall`n================================" $c = Read-Host "Remove all VPN configuration and certificates? [y/N]" if ($c -notmatch '^[Yy]') { Write-Host "[INFO] Cancelled" -ForegroundColor Green; exit 0 } } # --- Start hidden elevated process --- $logFile = Join-Path $env:TEMP "intenv-vpn-$PID.log" [IO.File]::WriteAllText($logFile, '') $esc = $PSCommandPath -replace "'", "''" $envCmd = "`$env:_INTENV_LOG='$logFile'" if ($preUser) { $envCmd += "; `$env:_INTENV_USER='$($preUser -replace "'","''")'" } if ($Command -eq 'uninstall') { $envCmd += "; `$env:_INTENV_UNINSTALL_OK='1'" } $innerArgs = "$Command" + $(if ($Quiet) { " -Quiet" } else { "" }) + $(if ($Force) { " -Force" } else { "" }) $fullCmd = "$envCmd; & '$esc' $innerArgs" try { Write-Host "[INFO] Requesting administrator privileges..." -ForegroundColor Cyan $proc = Start-Process powershell -Verb RunAs -WindowStyle Hidden -PassThru -ArgumentList @( "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", $fullCmd ) } catch { Write-Host "[ERROR] Administrator privileges required. Right-click PowerShell -> Run as Administrator" -ForegroundColor Red exit 1 } # --- Relay output from hidden elevated process to this console --- # Child encodes colors as @@Color@@ prefix per line; we decode and apply function Write-ColorLine([string]$line) { if ($line -match '^@@(\w+)@@(.*)$') { Write-Host $Matches[2] -ForegroundColor $Matches[1] } else { Write-Host $line } } Start-Sleep -Milliseconds 500 try { $fs = [IO.FileStream]::new($logFile, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::ReadWrite -bor [IO.FileShare]::Delete) $sr = [IO.StreamReader]::new($fs, [Text.Encoding]::UTF8) $buffer = "" while (-not $proc.HasExited) { $text = $sr.ReadToEnd() if ($text) { $buffer += $text while ($buffer -match '^(.*?)\r?\n') { Write-ColorLine $Matches[1] $buffer = $buffer.Substring($Matches[0].Length) } } Start-Sleep -Milliseconds 200 } Start-Sleep -Milliseconds 500 # grace period for final writes $text = $sr.ReadToEnd() if ($text) { $buffer += $text } if ($buffer) { Write-ColorLine $buffer } $sr.Close(); $fs.Close() } catch { $proc | Wait-Process if (Test-Path $logFile) { Get-Content $logFile | ForEach-Object { Write-ColorLine $_ } } } Remove-Item $logFile -Force -ErrorAction SilentlyContinue exit $proc.ExitCode } # ============================================================================= # OUTPUT RELAY (elevated child process -- hide window, write to log file) # ============================================================================= if ($env:_INTENV_LOG) { # Hide the elevated console window (may flash briefly after UAC) Add-Type -ErrorAction SilentlyContinue @" using System; using System.Runtime.InteropServices; public class WinHide { [DllImport("kernel32.dll")] public static extern IntPtr GetConsoleWindow(); [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); } "@ try { [WinHide]::ShowWindow([WinHide]::GetConsoleWindow(), 0) | Out-Null } catch {} # Override Write-Host to append to log file (parent tails it in real-time) # Use $global: scope -- $script: in a global function may not resolve to the script's scope $global:_IntenvLogFile = $env:_INTENV_LOG function global:Write-Host { param( [Parameter(Position=0, ValueFromRemainingArguments)]$Object, [ConsoleColor]$ForegroundColor, [ConsoleColor]$BackgroundColor, [switch]$NoNewline, $Separator ) $text = if ($Object -is [array]) { $Object -join ' ' } else { "$Object" } $colorTag = if ($ForegroundColor) { "@@$ForegroundColor@@" } else { "" } if ($NoNewline) { [IO.File]::AppendAllText($global:_IntenvLogFile, "$colorTag$text") } else { [IO.File]::AppendAllText($global:_IntenvLogFile, "$colorTag$text`r`n") } } } # Re-hide elevated console (call after browser/rasphone steal focus) function Hide-ElevatedWindow { if ($env:_INTENV_LOG) { try { [WinHide]::ShowWindow([WinHide]::GetConsoleWindow(), 0) | Out-Null } catch {} } } # ============================================================================= # CONFIGURATION # ============================================================================= $VaultAddr = if ($env:VAULT_ADDR) { $env:VAULT_ADDR } else { "https://vault.intenv.ru" } $VpnEndpoint = "vpn.intenv.ru" $VpnRemoteId = "vpn.intenv.ru" $VpnConnectionName = "INTENV VPN" $VpnBootstrapName = "INTENV Bootstrap" $CaDownloadUrl = "https://onboard.intenv.ru/intenv-ca.crt" $CertTTL = "336h" $RenewDays = 7 $ConfigDir = "$env:USERPROFILE\.config\intenv-vpn" $ScriptVersion = "3.13.0" $VpnDomain = "intenv.ru" $InternalDns = @("10.130.2.3", "10.130.9.3") # Unbound resolvers (MSK, SPB) $InternalServiceIps = @("10.130.1.1", "10.130.2.1", "10.130.7.1", "10.130.9.1") # OPNsense HAProxy (Vault, Auth, Keycloak) # Split tunneling routes (only these networks go through VPN) $VpnRoutes = @( "10.110.0.0/16", "10.120.0.0/16", "10.130.0.0/16", "10.200.0.0/13", # 10.200-207 "10.208.0.0/12", # 10.208-223 "10.224.0.0/12", # 10.224-239 "10.240.0.0/14", # 10.240-243 "10.244.0.0/16" ) [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 Add-Type -AssemblyName System.Web # Bypass HTTP proxy for all intenv.ru traffic (VPN talks directly to internal network) [System.Net.WebRequest]::DefaultWebProxy = New-Object System.Net.WebProxy $env:NO_PROXY = "*.intenv.ru,intenv.ru,10.0.0.0/8,$($env:NO_PROXY)" $env:no_proxy = $env:NO_PROXY # curl.exe reads lowercase # Clear http(s)_proxy for the current process -- prevents curl.exe and child processes # from routing intenv.ru traffic through a dead/irrelevant corporate proxy foreach ($proxyVar in @('http_proxy','https_proxy','HTTP_PROXY','HTTPS_PROXY')) { if ([Environment]::GetEnvironmentVariable($proxyVar)) { [Environment]::SetEnvironmentVariable($proxyVar, $null) } } # Add *.intenv.ru to Windows system proxy bypass (for browser OIDC flow) try { $regPath = "HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings" $current = (Get-ItemProperty -Path $regPath -Name ProxyOverride -ErrorAction SilentlyContinue).ProxyOverride if ($current -and $current -notmatch 'intenv\.ru') { Set-ItemProperty -Path $regPath -Name ProxyOverride -Value "$current;*.intenv.ru" } elseif (-not $current) { Set-ItemProperty -Path $regPath -Name ProxyOverride -Value "*.intenv.ru" } } catch {} $script:VaultToken = $null $script:Username = $null # ============================================================================= # WINDOW FOCUS HELPER # ============================================================================= Add-Type -ErrorAction SilentlyContinue @" using System; using System.Runtime.InteropServices; public class WinFocus { [DllImport("kernel32.dll")] public static extern IntPtr GetConsoleWindow(); [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd); [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); } "@ function Restore-ConsoleFocus { # In hidden-window mode, re-hide instead of restoring if ($env:_INTENV_LOG) { Hide-ElevatedWindow return } try { $hwnd = [WinFocus]::GetConsoleWindow() if ($hwnd -ne [IntPtr]::Zero) { [WinFocus]::ShowWindow($hwnd, 9) | Out-Null # SW_RESTORE [WinFocus]::SetForegroundWindow($hwnd) | Out-Null } } catch {} } # ============================================================================= # USER CONFIGURATION # ============================================================================= function Get-SavedConfig { $configFile = "$ConfigDir\config.json" if (Test-Path $configFile) { try { return Get-Content $configFile | ConvertFrom-Json } catch { return $null } } return $null } function Save-Config { param([string]$Username) if (-not (Test-Path $ConfigDir)) { New-Item -ItemType Directory -Path $ConfigDir -Force | Out-Null } @{ username = $Username; updated = (Get-Date -Format "yyyy-MM-dd HH:mm:ss") } | ConvertTo-Json | Out-File -FilePath "$ConfigDir\config.json" -Encoding utf8 } function Get-VpnUsername { if ($script:Username) { return $script:Username } # From parent process (collected before elevation) if ($env:_INTENV_USER) { $script:Username = $env:_INTENV_USER Save-Config -Username $script:Username return $script:Username } # Check saved config $config = Get-SavedConfig if ($config -and $config.username) { $script:Username = $config.username return $config.username } # Ask user (only works if console is visible) Write-Host "" $login = Read-Host "Enter your FreeIPA username (without @$VpnDomain)" if (-not $login) { Write-Err "Username is required" } $login = $login.Trim() Save-Config -Username $login $script:Username = $login return $login } # ============================================================================= # VPN ROUTES (split tunneling) # ============================================================================= function Add-VpnRoutes { param([string]$ConnectionName) foreach ($route in $VpnRoutes) { $parts = $route -split "/" Add-VpnConnectionRoute -ConnectionName $ConnectionName ` -DestinationPrefix $route -PassThru -ErrorAction SilentlyContinue | Out-Null } Write-Info "Split tunneling routes added ($($VpnRoutes.Count) networks)" } function Get-VpnInterfaceIndex { # Find the INTENV VPN adapter by its IP in the VPN pool range (10.130.10x.x) $vpnIp = Get-NetIPAddress -AddressFamily IPv4 -ErrorAction SilentlyContinue | Where-Object { $_.IPAddress -match '^10\.130\.10\d\.' } if ($vpnIp) { return $vpnIp.InterfaceIndex } # Fallback: find by connection name $adapter = Get-NetAdapter -ErrorAction SilentlyContinue | Where-Object { $_.Name -in @($VpnConnectionName, $VpnBootstrapName) -and $_.Status -eq 'Up' } if ($adapter) { return $adapter.ifIndex } return $null } function Get-LocalGatewayIp { # Derive local OPNsense HAProxy IP from VPN pool IP # VPN pool 10.130.10X.Y -> OPNsense gateway 10.130.X.1 $vpnIp = Get-NetIPAddress -AddressFamily IPv4 -ErrorAction SilentlyContinue | Where-Object { $_.IPAddress -match '^10\.130\.10(\d)\.' } if ($vpnIp) { return "10.130.$($Matches[1]).1" } return $null } function Wait-VpnReady { # Wait for VPN adapter to get an IP address (10.130.10x.x) # rasdial returns before the tunnel is fully established for ($i = 0; $i -lt 20; $i++) { $vpnIp = Get-NetIPAddress -AddressFamily IPv4 -ErrorAction SilentlyContinue | Where-Object { $_.IPAddress -match '^10\.130\.10\d\.' } if ($vpnIp) { return $true } Start-Sleep -Milliseconds 500 } return $false } function Test-VpnConnectivity { # Verify actual network reachability through VPN (not just "Connected" status) $targets = $InternalServiceIps + $InternalDns foreach ($ip in $targets) { if (Test-Connection -ComputerName $ip -Count 1 -Quiet -ErrorAction SilentlyContinue) { return $true } } return $false } function Add-VpnDnsConfig { # Add /32 host routes for service IPs via VPN adapter # Ensures traffic goes through INTENV VPN even if another VPN (e.g. OpenVPN) # has overlapping routes for 10.0.0.0/8 or 10.130.0.0/16 $ifIdx = Get-VpnInterfaceIndex if ($ifIdx) { $hostRoutes = $InternalDns + $InternalServiceIps foreach ($ip in $hostRoutes) { Remove-NetRoute -DestinationPrefix "$ip/32" -InterfaceIndex $ifIdx ` -Confirm:$false -ErrorAction SilentlyContinue New-NetRoute -DestinationPrefix "$ip/32" -InterfaceIndex $ifIdx ` -RouteMetric 1 -ErrorAction SilentlyContinue | Out-Null } } # Try NRPT-based DNS first Get-DnsClientNrptRule | Where-Object { $_.Namespace -eq ".intenv.ru" } | ForEach-Object { Remove-DnsClientNrptRule -Name $_.Name -Force } Add-DnsClientNrptRule -Namespace ".intenv.ru" -NameServers $InternalDns | Out-Null Clear-DnsClientCache # Test if DNS actually works (OPNsense may block UDP 53 from VPN pools) Start-Sleep -Milliseconds 500 $dnsWorks = $false try { $r = Resolve-DnsName -Name "vault.$VpnDomain" -DnsOnly -ErrorAction Stop 2>$null if ($r) { $dnsWorks = $true } } catch {} if ($dnsWorks) { Write-Info "DNS configured for .$VpnDomain (NRPT)" } else { Write-Warn "DNS queries to Unbound blocked from VPN pool, using hosts file fallback" # Remove NRPT rule -- it takes priority over hosts file and blocks resolution Get-DnsClientNrptRule | Where-Object { $_.Namespace -eq ".intenv.ru" } | ForEach-Object { Remove-DnsClientNrptRule -Name $_.Name -Force } Clear-DnsClientCache # Force .NET to re-resolve DNS (drop cached negative results and pooled connections) [System.Net.ServicePointManager]::FindServicePoint("$VaultAddr") | ForEach-Object { $_.CloseConnectionGroup("") } 2>$null # Derive local OPNsense HAProxy IP from VPN pool $gwIp = Get-LocalGatewayIp if (-not $gwIp) { # Use first reachable service IP $gwIp = $InternalServiceIps | Where-Object { Test-Connection -ComputerName $_ -Count 1 -Quiet -ErrorAction SilentlyContinue } | Select-Object -First 1 } if ($gwIp) { Add-VpnHostsEntries $gwIp # Bypass .NET DNS cache: store resolved IP for direct use in Invoke-VaultApi $script:VaultResolvedIp = $gwIp Write-Info "Hosts file entries added for .$VpnDomain -> $gwIp" } else { Write-Warn "No reachable gateway IP found for hosts fallback" } } } function Add-VpnHostsEntries([string]$GatewayIp) { $hostsPath = "$env:SystemRoot\System32\drivers\etc\hosts" $marker = "# INTENV-VPN" $entries = @( "$GatewayIp vault.$VpnDomain $marker", "$GatewayIp auth.$VpnDomain $marker", "$GatewayIp keycloak.$VpnDomain $marker", "$GatewayIp sso.$VpnDomain $marker" ) # Remove old entries, add new $content = @(Get-Content $hostsPath -ErrorAction SilentlyContinue | Where-Object { $_ -notmatch $marker }) $content += $entries Set-Content -Path $hostsPath -Value $content -Force Clear-DnsClientCache } function Remove-VpnHostsEntries { $hostsPath = "$env:SystemRoot\System32\drivers\etc\hosts" $marker = "# INTENV-VPN" $content = @(Get-Content $hostsPath -ErrorAction SilentlyContinue | Where-Object { $_ -notmatch $marker }) Set-Content -Path $hostsPath -Value $content -Force } function Remove-VpnDnsConfig { Get-DnsClientNrptRule | Where-Object { $_.Namespace -eq ".intenv.ru" } | ForEach-Object { Remove-DnsClientNrptRule -Name $_.Name -Force -ErrorAction SilentlyContinue } Remove-VpnHostsEntries # Remove /32 host routes for DNS and service IPs $hostRoutes = $InternalDns + $InternalServiceIps foreach ($ip in $hostRoutes) { Remove-NetRoute -DestinationPrefix "$ip/32" -Confirm:$false -ErrorAction SilentlyContinue } $script:VaultResolvedIp = $null Clear-DnsClientCache } # ============================================================================= # OUTPUT HELPERS # ============================================================================= function Write-Info($msg) { if (-not $Quiet) { Write-Host "[INFO] $msg" -ForegroundColor Green } } function Write-Warn($msg) { Write-Host "[WARN] $msg" -ForegroundColor Yellow } function Write-Err($msg) { Write-Host "[ERROR] $msg" -ForegroundColor Red Invoke-ScriptCleanup exit 1 } function Invoke-ScriptCleanup { # Always clean up NRPT/hosts on error exit. # Even if VPN is connected now, it will disconnect later leaving stale rules. Remove-VpnDnsConfig 2>$null # Disconnect and remove bootstrap VPN if it was created for renewal $b = Get-VpnConnection -Name $VpnBootstrapName -ErrorAction SilentlyContinue if ($b) { rasdial $VpnBootstrapName /DISCONNECT 2>$null | Out-Null Remove-VpnConnection -Name $VpnBootstrapName -Force -ErrorAction SilentlyContinue } } function Write-Step($msg) { if (-not $Quiet) { Write-Host "`n==> $msg" -ForegroundColor Cyan } } # ============================================================================= # TLS BYPASS (self-signed OpenBao certificate) # ============================================================================= # PowerShell 7+: use -SkipCertificateCheck per-request # PowerShell 5.x: global callback if ($PSVersionTable.PSVersion.Major -lt 7) { if (-not ([System.Management.Automation.PSTypeName]'TrustAllCertsPolicy').Type) { Add-Type @" using System.Net; using System.Security.Cryptography.X509Certificates; public class TrustAllCertsPolicy : ICertificatePolicy { public bool CheckValidationResult( ServicePoint srvPoint, X509Certificate certificate, WebRequest request, int certificateProblem) { return true; } } "@ } [System.Net.ServicePointManager]::CertificatePolicy = New-Object TrustAllCertsPolicy } # .NET caches DNS results for 120s independently of Windows DNS cache. # When we fall back from NRPT to hosts file, stale negative cache prevents resolution. [System.Net.ServicePointManager]::DnsRefreshTimeout = 0 # ============================================================================= # VAULT API HELPER # ============================================================================= function Invoke-VaultApi { param( [string]$Method = "GET", [string]$Path, [hashtable]$Body, [string]$Token, [System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate ) $uri = "$VaultAddr/v1/$Path" $headers = @{} if ($Token) { $headers["X-Vault-Token"] = $Token } # Bypass .NET DNS cache: replace hostname with resolved IP when hosts fallback is active if ($script:VaultResolvedIp) { $parsed = [uri]$uri $uri = $uri.Replace("://$($parsed.Host)", "://$($script:VaultResolvedIp)") $headers["Host"] = $parsed.Host } $params = @{ Uri = $uri Method = $Method Headers = $headers ContentType = "application/json" } if ($Body) { $params["Body"] = ($Body | ConvertTo-Json -Depth 10) } if ($Certificate) { $params["Certificate"] = $Certificate } # PowerShell 7+: add -SkipCertificateCheck and -NoProxy if ($PSVersionTable.PSVersion.Major -ge 7) { $params["SkipCertificateCheck"] = $true $params["NoProxy"] = $true } try { return Invoke-RestMethod @params } catch { $resp = $_.Exception.Response if ($resp) { $status = $resp.StatusCode.value__ $detail = $_.ErrorDetails.Message if ($detail) { try { $detail = ($detail | ConvertFrom-Json).errors -join "; " } catch {} } throw "Vault API error ($status) on $Method $Path`: $detail" } else { throw "Vault connection failed on $Method $Path`: $($_.Exception.Message)" } } } # ============================================================================= # DEPENDENCY MANAGEMENT # ============================================================================= function Test-Admin { ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole( [Security.Principal.WindowsBuiltInRole]::Administrator ) } function Install-Dependencies { Write-Step "Checking dependencies" # Check for admin (needed for cert store and VPN) if (-not (Test-Admin)) { Write-Warn "Not running as Administrator. Some operations may fail." Write-Warn "Re-run: Start-Process powershell -Verb RunAs -ArgumentList '-File', '$PSCommandPath', '$Command'" } # Fix IKEv2 behind NAT (Error 809): enable NAT-T for server behind NAT $regPath = "HKLM:\SYSTEM\CurrentControlSet\Services\PolicyAgent" $regName = "AssumeUDPEncapsulationContextOnSendRule" $current = Get-ItemProperty -Path $regPath -Name $regName -ErrorAction SilentlyContinue if (-not $current -or $current.$regName -ne 2) { try { Set-ItemProperty -Path $regPath -Name $regName -Value 2 -Type DWord Write-Info "NAT-T registry fix applied (requires reboot if first time)" $script:NeedReboot = $true } catch { Write-Warn "Cannot set NAT-T registry key. Run as Administrator." } } # Import INTENV Root CA (required for IKEv2 server cert validation) $caPath = "$ConfigDir\intenv-ca.crt" if (-not (Test-Path $ConfigDir)) { New-Item -ItemType Directory -Path $ConfigDir -Force | Out-Null } try { Write-Info "Downloading INTENV Root CA..." Invoke-WebRequest -Uri $CaDownloadUrl -OutFile $caPath -UseBasicParsing $caCert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($caPath) $caStore = New-Object System.Security.Cryptography.X509Certificates.X509Store("Root", "LocalMachine") $caStore.Open("ReadWrite") $existing = $caStore.Certificates | Where-Object { $_.Thumbprint -eq $caCert.Thumbprint } if (-not $existing) { $caStore.Add($caCert) Write-Info "Root CA imported to Trusted Root Certification Authorities" } else { Write-Info "Root CA already trusted" } $caStore.Close() } catch { Write-Warn "Cannot import Root CA: $_" Write-Warn "Run as Administrator for certificate import" } Write-Info "All dependencies available" } # ============================================================================= # AUTHENTICATION # ============================================================================= function Invoke-CertAuth { $cert = "$ConfigDir\client.crt" $key = "$ConfigDir\client.key" if (-not (Test-Path $cert) -or -not (Test-Path $key)) { return $null } # Check cert not expired try { $x509 = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($cert) if ($x509.NotAfter -lt (Get-Date)) { return $null } } catch { return $null } # Build PFX for TLS client auth $pfxPath = "$ConfigDir\client.pfx" if (-not (Test-Path $pfxPath)) { $tmpPem = "$ConfigDir\_combined.pem" ((Get-Content $cert -Raw) + "`n" + (Get-Content $key -Raw)) | Out-File $tmpPem -Encoding ascii -NoNewline $null = certutil -p "intenv-vpn" -MergePFX $tmpPem $pfxPath 2>&1 Remove-Item $tmpPem -ErrorAction SilentlyContinue if (-not (Test-Path $pfxPath)) { return $null } } try { $pfxPassword = ConvertTo-SecureString -String "intenv-vpn" -Force -AsPlainText $clientCert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2( $pfxPath, $pfxPassword, [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::Exportable ) $result = Invoke-VaultApi -Method POST -Path "auth/cert/login" -Body @{ name = "vpn-renew" } -Certificate $clientCert return $result.auth.client_token } catch { return $null } } function Invoke-OidcAuth { Write-Info "Authenticating via Keycloak SSO..." Write-Info "A browser window will open for authentication" $listenPort = 8250 $redirectUri = "http://localhost:$listenPort/oidc/callback" # Step 1: Get auth URL from Vault try { $authUrlResult = Invoke-VaultApi -Method PUT -Path "auth/oidc/oidc/auth_url" -Body @{ role = "vpn-user" redirect_uri = $redirectUri } $authUrl = $authUrlResult.data.auth_url if (-not $authUrl) { Write-Err "Failed to get OIDC auth URL from Vault" } # Add login_hint so Keycloak pre-fills the username if ($script:Username) { $hint = [System.Web.HttpUtility]::UrlEncode($script:Username) $authUrl += "&login_hint=$hint" } } catch { Write-Err "Failed to initiate OIDC flow: $_" } # Step 2: Start TCP listener for callback (more reliable than HttpListener) $tcpListener = $null try { $tcpListener = New-Object System.Net.Sockets.TcpListener( [System.Net.IPAddress]::Loopback, $listenPort) $tcpListener.Start() Write-Info "Callback listener started on 127.0.0.1:$listenPort" } catch { Write-Err "Cannot start listener on port $listenPort`: $_" } # Step 3: Open browser Start-Process $authUrl Start-Sleep -Milliseconds 500 Hide-ElevatedWindow # browser steals focus, re-hide our console # Step 4: Wait for callback (timeout 300s) Write-Info "Waiting for browser authentication (timeout 5 min)..." $deadline = (Get-Date).AddSeconds(300) while (-not $tcpListener.Pending() -and (Get-Date) -lt $deadline) { Start-Sleep -Milliseconds 200 } if (-not $tcpListener.Pending()) { $tcpListener.Stop() Write-Err "Authentication timed out (300s). Try again." } $client = $tcpListener.AcceptTcpClient() $stream = $client.GetStream() $reader = New-Object System.IO.StreamReader($stream) # Read full HTTP request (request line + all headers) $requestLine = $reader.ReadLine() while ($true) { $header = $reader.ReadLine() if ([string]::IsNullOrEmpty($header)) { break } } # Send proper HTTP response to browser $html = @" INTENV VPN

Authentication successful

Return to PowerShell to continue setup.

You can close this tab.

"@ $htmlBytes = [System.Text.Encoding]::UTF8.GetBytes($html) $responseHeader = "HTTP/1.1 200 OK`r`nContent-Type: text/html; charset=utf-8`r`nContent-Length: $($htmlBytes.Length)`r`nConnection: close`r`n`r`n" $headerBytes = [System.Text.Encoding]::UTF8.GetBytes($responseHeader) $stream.Write($headerBytes, 0, $headerBytes.Length) $stream.Write($htmlBytes, 0, $htmlBytes.Length) $stream.Flush() Start-Sleep -Milliseconds 500 $client.Close() $tcpListener.Stop() Restore-ConsoleFocus # Step 5: Extract state and code from request if ($requestLine -notmatch 'GET\s+(/[^\s]+)') { Write-Err "Invalid callback request: $requestLine" } $callbackPath = $Matches[1] $queryString = ($callbackPath -split '\?', 2)[1] $query = [System.Web.HttpUtility]::ParseQueryString($queryString) $state = $query["state"] $code = $query["code"] if (-not $state -or -not $code) { Write-Err "Invalid callback: missing state or code" } # Step 6: Exchange code for Vault token try { $callbackResult = Invoke-VaultApi -Method GET -Path "auth/oidc/oidc/callback?state=$state&code=$code&nonce=" $token = $callbackResult.auth.client_token if (-not $token) { Write-Err "Failed to get Vault token from OIDC callback" } Write-Info "Authentication successful" return $token } catch { Write-Err "OIDC token exchange failed: $_" } } # ============================================================================= # CERTIFICATE MANAGEMENT # ============================================================================= function Request-Certificate($Token) { Write-Info "Requesting VPN certificate (TTL: $CertTTL)..." if (-not (Test-Path $ConfigDir)) { New-Item -ItemType Directory -Path $ConfigDir -Force | Out-Null } # Use saved FreeIPA username, fall back to Windows username $config = Get-SavedConfig $Username = if ($config -and $config.username) { $config.username } else { $env:USERNAME } try { $result = Invoke-VaultApi -Method POST -Path "pki-vpn/issue/vpn-client" -Token $Token -Body @{ common_name = "$Username@vpn.intenv.ru" ttl = $CertTTL } if (-not $result.data.certificate) { Write-Err "Failed to get certificate from Vault" } $result.data.certificate | Out-File -FilePath "$ConfigDir\client.crt" -Encoding ascii -NoNewline $result.data.private_key | Out-File -FilePath "$ConfigDir\client.key" -Encoding ascii -NoNewline $result.data.issuing_ca | Out-File -FilePath "$ConfigDir\ca.crt" -Encoding ascii -NoNewline if ($result.data.ca_chain) { $result.data.ca_chain -join "`n" | Out-File -FilePath "$ConfigDir\ca.crt" -Append -Encoding ascii -NoNewline } # Import EC private key + certificate to Windows cert store # .NET Framework can't create PFX from EC keys (CopyWithPrivateKey is .NET Core only) # Strategy: parse EC key -> CNG named key -> import cert -> certutil -repairstore links them Import-EcCertToStore "$ConfigDir\client.key" "$ConfigDir\client.crt" "$ConfigDir\ca.crt" Write-Info "Certificate obtained successfully" } catch { Write-Err "Failed to request certificate: $_" } } function Get-CertDaysRemaining { $certFile = "$ConfigDir\client.crt" if (-not (Test-Path $certFile)) { return -1 } try { $cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($certFile) return ($cert.NotAfter - (Get-Date)).Days } catch { return -1 } } function Import-EcCertToStore([string]$KeyFile, [string]$CertFile, [string]$CaFile) { # Import EC P-256 cert + key to Windows cert store without PFX/openssl. # .NET Framework 4.x lacks ECDsaCertificateExtensions.CopyWithPrivateKey (.NET Core 3+), # and certutil -MergePFX fails with EC keys. Use NCrypt P/Invoke instead: # 1. Parse SEC 1 EC key -> extract d, Qx, Qy # 2. Build BCRYPT_ECCKEY_BLOB -> NCryptImportKey (persistent machine key) # 3. Import cert to LocalMachine\My # 4. CertSetCertificateContextProperty links cert -> CNG key Add-Type -ErrorAction Stop @" using System; using System.Text; using System.Runtime.InteropServices; using System.Security.Cryptography.X509Certificates; public class NcryptHelper { const string MS_KSP = "Microsoft Software Key Storage Provider"; const uint NCRYPT_MACHINE_KEY_FLAG = 0x20; const uint CERT_KEY_PROV_INFO_PROP_ID = 2; const uint CRYPT_MACHINE_KEYSET = 0x20; [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] struct CRYPT_KEY_PROV_INFO { public string pwszContainerName; public string pwszProvName; public uint dwProvType; public uint dwFlags; public uint cProvParam; public IntPtr rgProvParam; public uint dwKeySpec; } [DllImport("ncrypt.dll", CharSet = CharSet.Unicode)] static extern int NCryptOpenStorageProvider(out IntPtr phProv, string pszProvName, uint dwFlags); [DllImport("ncrypt.dll", CharSet = CharSet.Unicode)] static extern int NCryptImportKey(IntPtr hProv, IntPtr hImpKey, string pszBlobType, IntPtr pList, out IntPtr phKey, byte[] pbData, int cbData, uint dwFlags); [DllImport("ncrypt.dll", CharSet = CharSet.Unicode)] static extern int NCryptGetProperty(IntPtr hObj, string pszProp, byte[] pbOut, int cbOut, out int pcbResult, uint dwFlags); [DllImport("ncrypt.dll")] static extern int NCryptFreeObject(IntPtr h); [DllImport("crypt32.dll", SetLastError = true, CharSet = CharSet.Unicode)] static extern bool CertSetCertificateContextProperty(IntPtr pCert, uint dwPropId, uint dwFlags, ref CRYPT_KEY_PROV_INFO pvData); public static void ImportKeyAndLinkCert(byte[] eccBlob, X509Certificate2 cert) { IntPtr hProv, hKey; int hr = NCryptOpenStorageProvider(out hProv, MS_KSP, 0); if (hr != 0) throw new Exception("NCryptOpenStorageProvider: 0x" + hr.ToString("X8")); try { // Import as persistent machine key (auto-finalized, auto-named) hr = NCryptImportKey(hProv, IntPtr.Zero, "ECCPRIVATEBLOB", IntPtr.Zero, out hKey, eccBlob, eccBlob.Length, NCRYPT_MACHINE_KEY_FLAG); if (hr != 0) throw new Exception("NCryptImportKey: 0x" + hr.ToString("X8")); try { // Read the auto-generated key container name byte[] nameBuf = new byte[512]; int nameSize; hr = NCryptGetProperty(hKey, "Name", nameBuf, nameBuf.Length, out nameSize, 0); if (hr != 0) throw new Exception("NCryptGetProperty(Name): 0x" + hr.ToString("X8")); string containerName = Encoding.Unicode.GetString(nameBuf, 0, nameSize).TrimEnd('\0'); // Set persistent key provider info on the cert context var provInfo = new CRYPT_KEY_PROV_INFO { pwszContainerName = containerName, pwszProvName = MS_KSP, dwProvType = 0, dwFlags = CRYPT_MACHINE_KEYSET, cProvParam = 0, rgProvParam = IntPtr.Zero, dwKeySpec = 0xFFFFFFFF // CERT_NCRYPT_KEY_SPEC }; if (!CertSetCertificateContextProperty(cert.Handle, CERT_KEY_PROV_INFO_PROP_ID, 0, ref provInfo)) throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error()); } finally { NCryptFreeObject(hKey); } } finally { NCryptFreeObject(hProv); } } } "@ Write-Info "Importing certificates to Windows Certificate Store..." # Import CA to Trusted Root if (Test-Path $CaFile) { try { Import-Certificate -FilePath $CaFile -CertStoreLocation Cert:\LocalMachine\Root | Out-Null Write-Info "CA certificate imported" } catch { Write-Warn "CA import failed: $_" } } # Load cert first (needed by all import paths) $certDer = [Convert]::FromBase64String(((Get-Content $CertFile -Raw) -replace '-----[^-]+-----' -replace '\s')) $cert = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($certDer) $thumbprint = $cert.Thumbprint Write-Info "Client cert: $($cert.Subject), expires $($cert.NotAfter.ToString('yyyy-MM-dd'))" $keyLinked = $false # --- Strategy 1: openssl PFX (most reliable if available) --- $opensslPath = $null foreach ($candidate in @( "$env:ProgramFiles\Git\usr\bin\openssl.exe", "${env:ProgramFiles(x86)}\Git\usr\bin\openssl.exe", "$env:LOCALAPPDATA\Programs\Git\usr\bin\openssl.exe" )) { if (Test-Path $candidate) { $opensslPath = $candidate; break } } # Also check PATH if (-not $opensslPath) { $opensslPath = Get-Command openssl.exe -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source } if ($opensslPath) { try { $pfxFile = "$ConfigDir\client-bundle.pfx" $pfxPass = [guid]::NewGuid().ToString("N").Substring(0, 16) & $opensslPath pkcs12 -export -inkey $KeyFile -in $CertFile -certfile $CaFile ` -out $pfxFile -passout "pass:$pfxPass" -legacy 2>$null if ($LASTEXITCODE -ne 0) { # Retry without -legacy (older openssl) & $opensslPath pkcs12 -export -inkey $KeyFile -in $CertFile -certfile $CaFile ` -out $pfxFile -passout "pass:$pfxPass" 2>$null } if ($LASTEXITCODE -eq 0 -and (Test-Path $pfxFile)) { $secPass = ConvertTo-SecureString $pfxPass -AsPlainText -Force $imported = Import-PfxCertificate -FilePath $pfxFile ` -CertStoreLocation Cert:\LocalMachine\My -Password $secPass -Exportable $thumbprint = $imported.Thumbprint $keyLinked = $true Remove-Item $pfxFile -Force -ErrorAction SilentlyContinue Write-Info "EC key imported via openssl PFX" } } catch { Write-Warn "openssl PFX import failed: $_" } } # --- Strategy 2: CNG NCrypt P/Invoke (no external deps) --- if (-not $keyLinked) { try { # Parse SEC 1 EC private key (RFC 5915) $keyPem = Get-Content $KeyFile -Raw $keyBase64 = ($keyPem -replace '-----[^-]+-----' -replace '\s') $sec1 = [Convert]::FromBase64String($keyBase64) # ASN.1: SEQUENCE { INTEGER 1, OCTET STRING d, [0] OID, [1] BIT STRING 04||Qx||Qy } # Skip SEQUENCE tag + length $p = 1 if ($sec1[1] -band 0x80) { $lenBytes = $sec1[1] -band 0x7F $p = 2 + $lenBytes } else { $p = 2 } # Skip INTEGER 1 (02 01 01) $p += 3 # Read OCTET STRING containing private key d if ($sec1[$p] -ne 0x04) { throw "Expected OCTET STRING (0x04) at offset $p, got 0x$($sec1[$p].ToString('X2'))" } $p++ $dLen = [int]$sec1[$p]; $p++ $d = [byte[]]$sec1[$p..($p + $dLen - 1)]; $p += $dLen # Skip [0] curve OID (optional) if ($p -lt $sec1.Length -and $sec1[$p] -eq 0xA0) { $p++ $tagLen = [int]$sec1[$p]; $p++ $p += $tagLen } # Read [1] public key (optional) $Qx = [byte[]]::new(32); $Qy = [byte[]]::new(32) if ($p -lt $sec1.Length -and $sec1[$p] -eq 0xA1) { $p++ # [1] tag $p++ # [1] length $p++ # BIT STRING tag (0x03) $p++ # BIT STRING length $p++ # unused bits (0x00) if ($sec1[$p] -ne 0x04) { throw "Expected uncompressed point (0x04), got 0x$($sec1[$p].ToString('X2'))" } $p++ # uncompressed marker (0x04) $Qx = [byte[]]$sec1[$p..($p + 31)]; $p += 32 $Qy = [byte[]]$sec1[$p..($p + 31)] } # Pad/trim d to exactly 32 bytes (P-256) if ($d.Length -lt 32) { $pad = [byte[]]::new(32); [Array]::Copy($d, 0, $pad, 32 - $d.Length, $d.Length); $d = $pad } elseif ($d.Length -gt 32) { $d = $d[($d.Length - 32)..($d.Length - 1)] } # Build BCRYPT_ECCKEY_BLOB: magic(4) + cbKey(4) + Qx(32) + Qy(32) + d(32) = 104 bytes $blob = [byte[]]::new(104) [Array]::Copy([BitConverter]::GetBytes([int32]0x32534345), 0, $blob, 0, 4) # ECDSA_PRIVATE_P256_MAGIC [Array]::Copy([BitConverter]::GetBytes([int32]32), 0, $blob, 4, 4) [Array]::Copy($Qx, 0, $blob, 8, 32) [Array]::Copy($Qy, 0, $blob, 40, 32) [Array]::Copy($d, 0, $blob, 72, 32) [NcryptHelper]::ImportKeyAndLinkCert($blob, $cert) $keyLinked = $true Write-Info "EC key imported via CNG" } catch { Write-Warn "CNG key import failed: $_" } } if (-not $keyLinked) { Write-Warn "Private key could not be linked to certificate -- VPN auth may fail" } # Add cert to store (if not already added by PFX import) $store = [System.Security.Cryptography.X509Certificates.X509Store]::new( [System.Security.Cryptography.X509Certificates.StoreName]::My, [System.Security.Cryptography.X509Certificates.StoreLocation]::LocalMachine) $store.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite) # Remove old cert with same subject $old = $store.Certificates | Where-Object { $_.Subject -eq $cert.Subject -and $_.Thumbprint -ne $thumbprint } foreach ($o in $old) { $store.Remove($o) } # Only add if not already in store (PFX import adds it directly) if (-not ($store.Certificates | Where-Object { $_.Thumbprint -eq $thumbprint })) { $store.Add($cert) } $store.Close() Write-Info "Client cert imported ($thumbprint)" $script:ClientCertThumbprint = $thumbprint } function Import-CertificatesToStore { # Legacy wrapper -- now handled by Import-EcCertToStore called from Request-Certificate # Only import CA if not already done if (Test-Path "$ConfigDir\ca.crt") { try { Import-Certificate -FilePath "$ConfigDir\ca.crt" -CertStoreLocation Cert:\LocalMachine\Root -ErrorAction SilentlyContinue | Out-Null } catch {} } } # ============================================================================= # BOOTSTRAP: PASSWORD VPN # ============================================================================= function New-BootstrapVpn { Write-Step "Creating bootstrap VPN (password-based)" rasdial $VpnBootstrapName /DISCONNECT 2>$null | Out-Null Remove-VpnConnection -Name $VpnBootstrapName -Force -ErrorAction SilentlyContinue Add-VpnConnection ` -Name $VpnBootstrapName ` -ServerAddress $VpnEndpoint ` -TunnelType "Ikev2" ` -AuthenticationMethod "EAP" ` -EncryptionLevel "Required" ` -RememberCredential $false # Enable split tunneling (must be set separately from Add-VpnConnection) Set-VpnConnection -Name $VpnBootstrapName -SplitTunneling $true Set-VpnConnectionIPsecConfiguration ` -ConnectionName $VpnBootstrapName ` -AuthenticationTransformConstants SHA256128 ` -CipherTransformConstants AES256 ` -DHGroup ECP256 ` -EncryptionMethod AES256 ` -IntegrityCheckMethod SHA256 ` -PfsGroup ECP256 ` -Force # Add split tunneling routes Add-VpnRoutes -ConnectionName $VpnBootstrapName # Pre-fill username and domain in phonebook $username = Get-VpnUsername $fullUser = "$username@$VpnDomain" $pbkPath = "$env:APPDATA\Microsoft\Network\Connections\Pbk\rasphone.pbk" if (Test-Path $pbkPath) { $lines = Get-Content $pbkPath $inSection = $false for ($i = 0; $i -lt $lines.Count; $i++) { if ($lines[$i] -eq "[$VpnBootstrapName]") { $inSection = $true } elseif ($lines[$i] -match '^\[') { $inSection = $false } if ($inSection) { if ($lines[$i] -match '^Domain=') { $lines[$i] = "Domain=$VpnDomain" } if ($lines[$i] -match '^User=') { $lines[$i] = "User=$fullUser" } } } Set-Content $pbkPath -Value $lines } # Pre-store username in Windows Credential Manager (rasphone reads it) # Store under both server address and connection name -- Windows may look up either cmdkey /add:$VpnEndpoint /user:$fullUser 2>$null | Out-Null cmdkey /add:"$VpnBootstrapName" /user:$fullUser 2>$null | Out-Null Write-Info "Bootstrap VPN '$VpnBootstrapName' created (user: $fullUser)" } function Start-RasphoneWithAutoFill { param([string]$ConnectionName, [string]$Username) $proc = Start-Process -FilePath "rasphone" -ArgumentList "-d", "`"$ConnectionName`"" -PassThru # Auto-fill username via WScript.Shell SendKeys # rasphone dialog title: "Connect " (EN) / "Connect " (other locales) (RU) # AppActivate matches by substring, so $ConnectionName is enough if ($Username -and -not $proc.HasExited) { try { $wsh = New-Object -ComObject WScript.Shell $activated = $false # Wait for rasphone dialog to appear (up to 10 seconds) for ($i = 0; $i -lt 20 -and -not $proc.HasExited; $i++) { Start-Sleep -Milliseconds 500 if ($wsh.AppActivate($ConnectionName)) { $activated = $true break } } if ($activated) { Start-Sleep -Milliseconds 300 # Escape SendKeys special characters $safe = -join ($Username.ToCharArray() | ForEach-Object { if ($_ -in [char[]]@('+','^','%','~','{','}','(',')')) { "{$_}" } else { "$_" } }) $wsh.SendKeys("^a") # Select all in username field $wsh.SendKeys("{DEL}") # Clear $wsh.SendKeys($safe) # Type username $wsh.SendKeys("{TAB}") # Tab to password field Write-Info "Username filled: $Username" } else { Write-Warn "Could not activate rasphone dialog for auto-fill" } } catch { Write-Warn "Could not auto-fill username: $_" } } Write-Info "Enter password and click Connect" $proc | Wait-Process Restore-ConsoleFocus return $proc.ExitCode } function Connect-BootstrapVpn { Write-Step "Connecting via bootstrap VPN" $username = Get-VpnUsername $fullUser = "$username@$VpnDomain" Write-Info "Connecting as: $fullUser" Write-Host "" # rasphone opens a dialog with username auto-filled via SendKeys # (rasdial doesn't work for EAP connections -- error 703) $exitCode = Start-RasphoneWithAutoFill -ConnectionName $VpnBootstrapName -Username $fullUser if ($exitCode -ne 0) { Write-Err "Bootstrap VPN connection failed. Check credentials." } # Verify connectivity Write-Info "Verifying connectivity..." Start-Sleep -Seconds 2 if (Test-Connection -ComputerName 10.130.2.3 -Count 1 -Quiet -ErrorAction SilentlyContinue) { Write-Info "Internal network reachable" } else { Write-Warn "Cannot reach internal network yet. Continuing..." } # Enable NRPT now that VPN is up (route .intenv.ru to internal DNS) Add-VpnDnsConfig } function Remove-BootstrapVpn { Remove-VpnDnsConfig rasdial $VpnBootstrapName /DISCONNECT 2>$null | Out-Null Remove-VpnConnection -Name $VpnBootstrapName -Force -ErrorAction SilentlyContinue } # ============================================================================= # VPN ROUTE MTU FIX # ============================================================================= # ESP encapsulation adds ~60-80 bytes overhead. If the original packet is close # to interface MTU, the encapsulated packet exceeds the path MTU and gets # silently dropped -- SSH and other TCP sessions stall. function Fix-VpnMtu { Start-Sleep -Seconds 3 try { Set-NetIPInterface -InterfaceAlias $VpnConnectionName -NlMtuBytes 1350 -ErrorAction Stop Write-Info "VPN interface MTU set to 1350 (ESP overhead compensation)" } catch { try { $null = netsh interface ipv4 set subinterface "$VpnConnectionName" mtu=1350 store=active 2>&1 if ($LASTEXITCODE -eq 0) { Write-Info "VPN interface MTU set to 1350 via netsh" } } catch { Write-Warn "Could not set VPN MTU. If SSH stalls, run as Admin:" Write-Warn " netsh interface ipv4 set subinterface `"$VpnConnectionName`" mtu=1350 store=persistent" } } } # ============================================================================= # VPN CONNECTION # ============================================================================= function New-CertVpnConnection { Write-Step "Configuring certificate-based VPN" rasdial $VpnConnectionName /DISCONNECT 2>$null | Out-Null Remove-VpnConnection -Name $VpnConnectionName -Force -ErrorAction SilentlyContinue Add-VpnConnection ` -Name $VpnConnectionName ` -ServerAddress $VpnEndpoint ` -TunnelType "Ikev2" ` -AuthenticationMethod "MachineCertificate" ` -EncryptionLevel "Required" ` -RememberCredential $true # Enable split tunneling (must be set separately from Add-VpnConnection) Set-VpnConnection -Name $VpnConnectionName -SplitTunneling $true Set-VpnConnectionIPsecConfiguration ` -ConnectionName $VpnConnectionName ` -AuthenticationTransformConstants SHA256128 ` -CipherTransformConstants AES256 ` -DHGroup ECP256 ` -EncryptionMethod AES256 ` -IntegrityCheckMethod SHA256 ` -PfsGroup ECP256 ` -Force # Add split tunneling routes Add-VpnRoutes -ConnectionName $VpnConnectionName Write-Info "VPN connection '$VpnConnectionName' configured (split tunneling)" } # ============================================================================= # AUTO-RENEWAL (Task Scheduler) # ============================================================================= function Install-AutoRenewal { Write-Step "Configuring auto-renewal (Task Scheduler)" $scriptPath = $PSCommandPath $taskName = "IntenvVPN-Renew" # Remove existing task Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue $action = New-ScheduledTaskAction ` -Execute "powershell.exe" ` -Argument "-NoProfile -ExecutionPolicy Bypass -File `"$scriptPath`" renew -Quiet" $trigger = New-ScheduledTaskTrigger -Daily -At "09:00" $settings = New-ScheduledTaskSettingsSet ` -AllowStartIfOnBatteries ` -DontStopIfGoingOnBatteries ` -StartWhenAvailable ` -RunOnlyIfNetworkAvailable $principal = New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType S4U -RunLevel Limited Register-ScheduledTask ` -TaskName $taskName ` -Action $action ` -Trigger $trigger ` -Settings $settings ` -Principal $principal ` -Description "INTENV VPN certificate daily renewal check" | Out-Null Write-Info "Task Scheduler job created (daily at 09:00)" Write-Info "Check: Get-ScheduledTask -TaskName '$taskName'" } # ============================================================================= # COMMANDS # ============================================================================= function Invoke-Setup { Write-Host "" Write-Host "INTENV VPN Setup (v$ScriptVersion)" -ForegroundColor White Write-Host "================================" Write-Host "" # Phase 0: Get username if (-not (Test-Path $ConfigDir)) { New-Item -ItemType Directory -Path $ConfigDir -Force | Out-Null } $username = Get-VpnUsername Write-Info "User: $username@$VpnDomain" # Phase 1: Dependencies Install-Dependencies # Phase 2: Bootstrap Write-Step "Bootstrap VPN connection" Write-Info "Temporary password-based VPN for reaching the certificate server." if ($script:NeedReboot) { Write-Warn "NAT-T fix was applied for the first time." Write-Warn "You MUST reboot Windows before VPN will work." Write-Warn "After reboot, run: .\intenv-vpn.ps1 setup" exit 0 } $canReach = Test-Connection -ComputerName 10.130.2.3 -Count 1 -Quiet -ErrorAction SilentlyContinue if ($canReach) { Write-Info "Internal network already reachable - skipping bootstrap" } else { New-BootstrapVpn Connect-BootstrapVpn } # Phase 3: Get certificate Write-Step "Obtaining VPN certificate" $token = Invoke-OidcAuth Request-Certificate $token Import-CertificatesToStore # Phase 4: Configure cert VPN New-CertVpnConnection # Phase 5: Switch Write-Step "Switching to certificate VPN" Remove-BootstrapVpn Write-Info "Connecting via certificate..." try { rasdial $VpnConnectionName 2>$null | Out-Null Write-Info "Connected!" Fix-VpnMtu } catch { Write-Warn "Auto-connect failed. Connect manually via Settings -> VPN" } # Phase 6: Auto-renewal Install-AutoRenewal # Done Write-Host "" Write-Host "========================================" Write-Host " Setup Complete" -ForegroundColor Green Write-Host "========================================" Show-CertInfo Write-Host "" Write-Host "Commands:" Write-Host " .\intenv-vpn.ps1 connect Connect to VPN" Write-Host " .\intenv-vpn.ps1 disconnect Disconnect" Write-Host " .\intenv-vpn.ps1 status Show status" Write-Host " .\intenv-vpn.ps1 renew Renew certificate" Write-Host " .\intenv-vpn.ps1 uninstall Remove everything" Write-Host "========================================" } function Invoke-Connect { if (-not (Test-Path "$ConfigDir\client.crt")) { Write-Err "No certificate found. Run: .\intenv-vpn.ps1 setup" } $days = Get-CertDaysRemaining if ($days -lt 0) { Write-Warn "Certificate expired. Renewing..." $script:Force = $true Invoke-Renew } elseif ($days -lt $RenewDays) { Write-Warn "Certificate expires in $days days. Renewing..." Invoke-Renew } rasdial $VpnConnectionName 2>$null | Out-Null if ($LASTEXITCODE -ne 0) { $errCode = $LASTEXITCODE Write-Warn "Cert VPN failed (error $errCode), renewing certificate via bootstrap..." $script:Force = $true Invoke-Renew # Invoke-Renew cleans up bootstrap, but NRPT may remain -- clean for vpn.intenv.ru Remove-VpnDnsConfig # Restart IKE service to pick up newly imported certificate Restart-Service IKEEXT -Force -ErrorAction SilentlyContinue Start-Sleep -Seconds 2 # Retry with renewed cert rasdial $VpnConnectionName 2>$null | Out-Null if ($LASTEXITCODE -ne 0) { Write-Err "Connection still failed after cert renewal (error $LASTEXITCODE)" } } if (-not (Wait-VpnReady)) { Write-Warn "VPN connected but IP not assigned yet, continuing..." } Write-Info "Connected to $VpnConnectionName" Add-VpnDnsConfig Fix-VpnMtu } function Invoke-Disconnect { try { Remove-VpnDnsConfig rasdial $VpnConnectionName /DISCONNECT 2>$null | Out-Null Write-Info "Disconnected" } catch { Write-Warn "Not connected" } } function Invoke-Renew { $days = Get-CertDaysRemaining if ($days -ge $RenewDays -and -not $Force) { Write-Info "Certificate valid for $days more days (threshold: $RenewDays)" return } Write-Info "Certificate has $days days remaining, renewing..." # Ensure VPN is connected and DNS/routes are configured $vpn = Get-VpnConnection -Name $VpnConnectionName -ErrorAction SilentlyContinue $bootstrap = Get-VpnConnection -Name $VpnBootstrapName -ErrorAction SilentlyContinue $connected = ($vpn -and $vpn.ConnectionStatus -eq 'Connected') -or ($bootstrap -and $bootstrap.ConnectionStatus -eq 'Connected') if (-not $connected) { # Try cert VPN first, fall back to bootstrap if ($vpn) { Write-Info "Connecting to $VpnConnectionName..." rasdial $VpnConnectionName 2>$null | Out-Null if ($LASTEXITCODE -eq 0) { Wait-VpnReady | Out-Null $connected = $true } } if (-not $connected) { # Create bootstrap VPN if it doesn't exist (setup removes it after cert setup) $bootstrap = Get-VpnConnection -Name $VpnBootstrapName -ErrorAction SilentlyContinue if (-not $bootstrap) { Write-Info "Creating bootstrap VPN for renewal..." New-BootstrapVpn } Connect-BootstrapVpn Wait-VpnReady | Out-Null $connected = $true $script:_renewUsedBootstrap = $true } } # Verify actual network connectivity (not just VPN "Connected" status) if (-not (Test-VpnConnectivity)) { Write-Err "VPN connected but internal network unreachable. Check VPN tunnel." } Add-VpnDnsConfig # Try cert auth first (non-interactive) $token = Invoke-CertAuth if ($token) { Write-Info "Authenticated via certificate (non-interactive)" } else { Write-Info "Cert auth unavailable, using Keycloak SSO..." $token = Invoke-OidcAuth } Request-Certificate $token Import-CertificatesToStore # Clean up bootstrap VPN if we created it for renewal if ($script:_renewUsedBootstrap) { Remove-BootstrapVpn $script:_renewUsedBootstrap = $false } Write-Info "Certificate renewed successfully" } function Invoke-Status { Write-Host "" Write-Host "INTENV VPN Status" -ForegroundColor White Write-Host "================================" # Connection Write-Host "" Write-Host -NoNewline "Connection: " $vpn = Get-VpnConnection -Name $VpnConnectionName -ErrorAction SilentlyContinue if ($vpn -and $vpn.ConnectionStatus -eq 'Connected') { Write-Host "Connected" -ForegroundColor Green } elseif ($vpn) { Write-Host "Disconnected" -ForegroundColor Yellow } else { Write-Host "Not configured" -ForegroundColor Red } # Certificate Write-Host "" if (Test-Path "$ConfigDir\client.crt") { Show-CertInfo } else { Write-Host "Certificate: " -NoNewline Write-Host "Not found" -ForegroundColor Red } # Vault connectivity Write-Host "" Write-Host -NoNewline "Vault ($VaultAddr): " try { $health = Invoke-VaultApi -Method GET -Path "sys/health" Write-Host "Reachable (sealed: $($health.sealed))" -ForegroundColor Green } catch { Write-Host "Unreachable" -ForegroundColor Yellow } # Timer Write-Host "" Write-Host -NoNewline "Auto-renewal: " $task = Get-ScheduledTask -TaskName "IntenvVPN-Renew" -ErrorAction SilentlyContinue if ($task) { $nextRun = ($task | Get-ScheduledTaskInfo).NextRunTime Write-Host "Active (next: $nextRun)" -ForegroundColor Green } else { Write-Host "Inactive" -ForegroundColor Yellow } Write-Host "================================" } function Invoke-Uninstall { Write-Host "" Write-Host "INTENV VPN Uninstall" -ForegroundColor White Write-Host "================================" if (-not $env:_INTENV_UNINSTALL_OK) { $confirm = Read-Host "Remove all VPN configuration and certificates? [y/N]" if ($confirm -notmatch '^[Yy]') { Write-Info "Cancelled" return } } # Disconnect rasdial $VpnConnectionName /DISCONNECT 2>$null | Out-Null rasdial $VpnBootstrapName /DISCONNECT 2>$null | Out-Null # Remove scheduled task Unregister-ScheduledTask -TaskName "IntenvVPN-Renew" -Confirm:$false -ErrorAction SilentlyContinue # Remove DNS and VPN connections Remove-VpnDnsConfig Remove-VpnConnection -Name $VpnConnectionName -Force -ErrorAction SilentlyContinue Remove-VpnConnection -Name $VpnBootstrapName -Force -ErrorAction SilentlyContinue # Remove certs from store (LocalMachine where IKEv2 certs live) try { $myStore = New-Object System.Security.Cryptography.X509Certificates.X509Store("My", "LocalMachine") $myStore.Open("ReadWrite") $vpnCerts = $myStore.Certificates | Where-Object { $_.Subject -match "vpn\.intenv\.ru" } foreach ($c in $vpnCerts) { $myStore.Remove($c) } $myStore.Close() } catch {} # Remove config if (Test-Path $ConfigDir) { Remove-Item -Recurse -Force $ConfigDir } Write-Info "VPN configuration removed" } function Invoke-Test { Write-Host "" Write-Host "INTENV VPN Diagnostics" -ForegroundColor White Write-Host "========================================" $passed = 0; $failed = 0 # DNS Write-Host -NoNewline "[TEST] DNS Resolution ($VpnEndpoint)... " try { $dns = Resolve-DnsName -Name $VpnEndpoint -Type A -ErrorAction Stop $ips = ($dns | Where-Object { $_.QueryType -eq 'A' }).IPAddress if ($ips) { Write-Host "PASS ($($ips -join ', '))" -ForegroundColor Green; $passed++ } else { Write-Host "FAIL (no A records)" -ForegroundColor Red; $failed++ } } catch { Write-Host "FAIL ($_)" -ForegroundColor Red; $failed++; $ips = @() } # Ports foreach ($ip in $ips) { foreach ($port in @(500, 4500)) { $portName = if ($port -eq 500) { "IKE" } else { "NAT-T" } Write-Host -NoNewline "[TEST] UDP $port ($portName) on $ip... " try { $udp = New-Object System.Net.Sockets.UdpClient $udp.Client.ReceiveTimeout = 3000 $udp.Connect($ip, $port) $bytes = [Text.Encoding]::ASCII.GetBytes("test") $udp.Send($bytes, $bytes.Length) | Out-Null Write-Host "PASS" -ForegroundColor Green; $passed++ $udp.Close() } catch { Write-Host "FAIL" -ForegroundColor Red; $failed++ } } } # VPN Connection Write-Host -NoNewline "[TEST] VPN Connection '$VpnConnectionName'... " $vpn = Get-VpnConnection -Name $VpnConnectionName -ErrorAction SilentlyContinue if ($vpn) { Write-Host "PASS ($($vpn.ConnectionStatus))" -ForegroundColor Green; $passed++ } else { Write-Host "FAIL (not configured)" -ForegroundColor Red; $failed++ } # Certificate Write-Host -NoNewline "[TEST] Client VPN Certificate... " $days = Get-CertDaysRemaining if ($days -gt 0) { Write-Host "PASS ($days days remaining)" -ForegroundColor Green; $passed++ } elseif ($days -eq -1) { Write-Host "SKIP (no cert)" -ForegroundColor Yellow } else { Write-Host "FAIL (expired)" -ForegroundColor Red; $failed++ } # Vault API Write-Host -NoNewline "[TEST] Vault API ($VaultAddr)... " try { $health = Invoke-VaultApi -Method GET -Path "sys/health" Write-Host "PASS (initialized: $($health.initialized), sealed: $($health.sealed))" -ForegroundColor Green; $passed++ } catch { Write-Host "FAIL ($_)" -ForegroundColor Red; $failed++ } # Task Scheduler Write-Host -NoNewline "[TEST] Auto-renewal task... " $task = Get-ScheduledTask -TaskName "IntenvVPN-Renew" -ErrorAction SilentlyContinue if ($task) { Write-Host "PASS" -ForegroundColor Green; $passed++ } else { Write-Host "FAIL (not found)" -ForegroundColor Red; $failed++ } Write-Host "" Write-Host "========================================" Write-Host " Results: $passed passed, $failed failed" Write-Host "========================================" } function Show-Help { Write-Host @" intenv-vpn.ps1 - INTENV Corporate VPN Client (Windows) Usage: .\intenv-vpn.ps1 [-Quiet] [-Force] Commands: setup Full setup (install deps, bootstrap, get cert, configure VPN) connect Connect to VPN disconnect Disconnect from VPN renew Renew certificate (auto: cert-auth, fallback: Keycloak SSO) status Show connection status and certificate expiry uninstall Remove all VPN configuration test Run connectivity diagnostics Options: -Quiet Suppress non-error output (for scheduled tasks) -Force Force renewal even if certificate is still valid Certificate renewal: Auto-renewal runs daily via Task Scheduler. Renews when <7 days remaining. Non-interactive renewal uses Vault cert auth (no browser). If cert expired, falls back to Keycloak SSO (browser). "@ } function Show-CertInfo { $certFile = "$ConfigDir\client.crt" if (-not (Test-Path $certFile)) { return } try { $cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($certFile) $days = ($cert.NotAfter - (Get-Date)).Days Write-Host "Certificate:" Write-Host " Subject: $($cert.Subject)" Write-Host " Expires: $($cert.NotAfter)" if ($days -lt 0) { Write-Host " Status: Expired" -ForegroundColor Red } elseif ($days -lt $RenewDays) { Write-Host " Status: $days days remaining (renewal pending)" -ForegroundColor Yellow } else { Write-Host " Status: $days days remaining" -ForegroundColor Green } Write-Host " Files: $ConfigDir\" } catch {} } # ============================================================================= # MAIN # ============================================================================= # Clean stale NRPT/hosts rules if VPN is not connected. # If script crashed before Remove-VpnDnsConfig, NRPT for .intenv.ru stays, # routing vpn.intenv.ru to unreachable internal DNS -> rasdial error 868. $_vpnConn = Get-VpnConnection -Name $VpnConnectionName -ErrorAction SilentlyContinue $_bsConn = Get-VpnConnection -Name $VpnBootstrapName -ErrorAction SilentlyContinue $_anyConnected = ($_vpnConn -and $_vpnConn.ConnectionStatus -eq 'Connected') -or ($_bsConn -and $_bsConn.ConnectionStatus -eq 'Connected') if (-not $_anyConnected -and $Command -notin @('help','disconnect','uninstall')) { Remove-VpnDnsConfig } trap { Write-Host "[ERROR] $($_.Exception.Message)" -ForegroundColor Red Invoke-ScriptCleanup break } switch ($Command) { 'setup' { Invoke-Setup } 'connect' { Invoke-Connect } 'disconnect' { Invoke-Disconnect } 'renew' { Invoke-Renew } 'status' { Invoke-Status } 'uninstall' { Invoke-Uninstall } 'test' { Invoke-Test } 'help' { Show-Help } default { Show-Help } }