# =============================================================================
# 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 }
}