<# .SYNOPSIS ROA2WEB SSH Tunnel Manager for Windows (equivalent to ssh-tunnel.sh) .DESCRIPTION Manages SSH tunnels to Oracle servers on Windows. Reads configuration from backend/ssh-tunnels.json SSH keys should be in backend/secrets/{id}.ssh_key Requirements: - Windows 10/11 with OpenSSH Client (installed by default) - SSH private keys in backend/secrets/ .PARAMETER Action start - Start all configured SSH tunnels stop - Stop all SSH tunnels status - Show status of all tunnels restart - Restart all tunnels help - Show this help .EXAMPLE .\ssh-tunnel.ps1 start Start all configured tunnels .EXAMPLE .\ssh-tunnel.ps1 status Check tunnel status .NOTES Author: ROA2WEB Team Version: 1.0 #> param( [Parameter(Position=0)] [ValidateSet("start", "stop", "status", "restart", "help", "")] [string]$Action = "help" ) $ErrorActionPreference = "Stop" # ============================================================================= # CONFIGURATION # ============================================================================= # Detect paths - script can run from deployment/windows/scripts or project root $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path # Try to find backend relative to script location $PossiblePaths = @( (Join-Path $ScriptDir "..\..\..\backend"), # From deployment/windows/scripts (Join-Path $ScriptDir "backend"), # From project root "C:\inetpub\wwwroot\roa2web\backend" # Production path ) $BackendPath = $null foreach ($path in $PossiblePaths) { $resolved = [System.IO.Path]::GetFullPath($path) if (Test-Path $resolved) { $BackendPath = $resolved break } } if (-not $BackendPath) { Write-Host "[ERROR] Cannot find backend directory" -ForegroundColor Red Write-Host "Tried: $($PossiblePaths -join ', ')" -ForegroundColor Yellow exit 1 } $Config = @{ BackendPath = $BackendPath TunnelsFile = Join-Path $BackendPath "ssh-tunnels.json" SecretsPath = Join-Path $BackendPath "secrets" PidDir = Join-Path $env:TEMP "roa_tunnels" } # Create PID directory if (-not (Test-Path $Config.PidDir)) { New-Item -ItemType Directory -Path $Config.PidDir -Force | Out-Null } # ============================================================================= # HELPER FUNCTIONS # ============================================================================= function Write-ColorLine { param([string]$Text, [string]$Color = "White") Write-Host $Text -ForegroundColor $Color } function Get-PidFile { param([string]$ServerId) return Join-Path $Config.PidDir "tunnel_$ServerId.pid" } function Test-TunnelRunning { param([string]$ServerId) $pidFile = Get-PidFile $ServerId if (Test-Path $pidFile) { $tunnelPid = Get-Content $pidFile -ErrorAction SilentlyContinue if ($tunnelPid) { $process = Get-Process -Id $tunnelPid -ErrorAction SilentlyContinue if ($process -and $process.ProcessName -eq "ssh") { return $true } } # Clean up stale PID file Remove-Item $pidFile -Force -ErrorAction SilentlyContinue } return $false } function Test-PortOpen { param([int]$Port) try { $tcpClient = New-Object System.Net.Sockets.TcpClient $tcpClient.Connect("127.0.0.1", $Port) $tcpClient.Close() return $true } catch { return $false } } function Read-TunnelConfig { if (-not (Test-Path $Config.TunnelsFile)) { Write-ColorLine "[WARNING] ssh-tunnels.json not found at: $($Config.TunnelsFile)" "Yellow" return @() } try { $json = Get-Content $Config.TunnelsFile -Raw | ConvertFrom-Json return $json } catch { Write-ColorLine "[ERROR] Failed to parse ssh-tunnels.json: $_" "Red" return @() } } # ============================================================================= # TUNNEL MANAGEMENT # ============================================================================= function Start-SingleTunnel { param([PSCustomObject]$Tunnel) $id = $Tunnel.id $name = $Tunnel.name $localPort = $Tunnel.local_port $sshHost = $Tunnel.ssh_host $sshPort = if ($Tunnel.ssh_port) { $Tunnel.ssh_port } else { 22 } $sshUser = if ($Tunnel.ssh_user) { $Tunnel.ssh_user } else { "root" } $oracleHost = if ($Tunnel.oracle_host) { $Tunnel.oracle_host } else { "127.0.0.1" } $oraclePort = if ($Tunnel.oracle_port) { $Tunnel.oracle_port } else { 1521 } Write-ColorLine "[$id] $name" "Cyan" Write-Host " Tunnel: localhost:$localPort -> ${oracleHost}:$oraclePort" Write-Host " Via: ${sshUser}@${sshHost}:$sshPort" # Skip if no SSH host configured if (-not $sshHost) { Write-ColorLine " [SKIP] No ssh_host configured (direct connection)" "Yellow" return $true } # Check if already running if (Test-TunnelRunning $id) { $pidFile = Get-PidFile $id $tunnelPid = Get-Content $pidFile Write-ColorLine " [RUNNING] Already running (PID: $tunnelPid)" "Yellow" return $true } # Find SSH key $sshKeyPath = $null $possibleKeys = @( (Join-Path $Config.SecretsPath "$id.ssh_key"), (Join-Path $Config.SecretsPath "${id}_rsa"), (Join-Path $Config.SecretsPath "id_rsa") ) # Also check if key path is specified in config if ($Tunnel.ssh_key) { $configKey = $Tunnel.ssh_key if (-not [System.IO.Path]::IsPathRooted($configKey)) { $configKey = Join-Path $Config.BackendPath $configKey } $possibleKeys = @($configKey) + $possibleKeys } foreach ($keyPath in $possibleKeys) { if (Test-Path $keyPath) { $sshKeyPath = $keyPath break } } # Check for password file $sshPassPath = Join-Path $Config.SecretsPath "$id.ssh_pass" $hasPassword = Test-Path $sshPassPath $sshPassword = $null if ($hasPassword) { $sshPassword = (Get-Content $sshPassPath -Raw).Trim() } if (-not $sshKeyPath -and -not $hasPassword) { Write-ColorLine " [ERROR] No SSH key or password found" "Red" Write-ColorLine " Expected: $($Config.SecretsPath)\$id.ssh_key" "Yellow" Write-ColorLine " or: $($Config.SecretsPath)\$id.ssh_pass" "Yellow" return $false } # Build SSH command based on auth method # Note: Windows ssh.exe doesn't support -f (background), so we use Start-Process $sshArgs = @( "-N", # No remote command "-L", "${localPort}:${oracleHost}:${oraclePort}", # Port forwarding "-p", $sshPort, # SSH port "-o", "StrictHostKeyChecking=no", # Skip host key check "-o", "ServerAliveInterval=60", # Keep-alive "-o", "ServerAliveCountMax=3", # Retry count "-o", "ExitOnForwardFailure=yes" # Fail if port forward fails ) if ($sshKeyPath) { $sshArgs += @("-i", "`"$sshKeyPath`"") Write-ColorLine " Using SSH key authentication" "Gray" } $sshArgs += @("${sshUser}@${sshHost}") try { $process = $null if ($hasPassword -and -not $sshKeyPath) { # Password authentication - use plink if available, otherwise show instructions $plinkPath = Get-Command plink.exe -ErrorAction SilentlyContinue if ($plinkPath) { # Use plink for password auth Write-ColorLine " Using password authentication (plink)" "Gray" $plinkArgs = @( "-ssh", "-batch", # Non-interactive mode (skip "Press Return" prompt) "-N", "-L", "${localPort}:${oracleHost}:${oraclePort}", "-P", $sshPort, "-pw", $sshPassword ) # Add hostkey if specified (required for non-interactive/batch mode) if ($Tunnel.ssh_hostkey) { $plinkArgs += @("-hostkey", $Tunnel.ssh_hostkey) } $plinkArgs += "${sshUser}@${sshHost}" $process = Start-Process -FilePath "plink.exe" ` -ArgumentList $plinkArgs ` -WindowStyle Hidden ` -PassThru } else { # Try with sshpass if available (e.g., via Git Bash or WSL) $sshpassPath = Get-Command sshpass -ErrorAction SilentlyContinue if ($sshpassPath) { Write-ColorLine " Using password authentication (sshpass)" "Gray" $process = Start-Process -FilePath "sshpass" ` -ArgumentList @("-p", $sshPassword, "ssh") + $sshArgs ` -WindowStyle Hidden ` -PassThru } else { Write-ColorLine " [ERROR] Password auth requires plink.exe (PuTTY) or sshpass" "Red" Write-ColorLine " Install PuTTY: choco install putty" "Yellow" Write-ColorLine " Or create SSH key: ssh-keygen -t rsa -f secrets\$id.ssh_key" "Yellow" Write-ColorLine " Then copy public key to server: ssh-copy-id -p $sshPort ${sshUser}@${sshHost}" "Yellow" return $false } } } else { # SSH key authentication $process = Start-Process -FilePath "ssh.exe" ` -ArgumentList $sshArgs ` -WindowStyle Hidden ` -PassThru } # Wait a moment for connection Start-Sleep -Seconds 2 # Check if process is still running $process.Refresh() if ($process.HasExited) { Write-ColorLine " [ERROR] SSH tunnel exited immediately (code: $($process.ExitCode))" "Red" return $false } # Save PID $pidFile = Get-PidFile $id $process.Id | Out-File -FilePath $pidFile -NoNewline Write-ColorLine " [OK] Started (PID: $($process.Id))" "Green" # Test port connectivity if (Test-PortOpen $localPort) { Write-ColorLine " [OK] Port $localPort accessible" "Green" } else { Write-ColorLine " [WARN] Port $localPort not responding yet" "Yellow" } return $true } catch { Write-ColorLine " [ERROR] Failed to start tunnel: $_" "Red" return $false } } function Stop-SingleTunnel { param([PSCustomObject]$Tunnel) $id = $Tunnel.id $name = $Tunnel.name Write-ColorLine "[$id] $name" "Cyan" $pidFile = Get-PidFile $id if (Test-Path $pidFile) { $tunnelPid = Get-Content $pidFile -ErrorAction SilentlyContinue if ($tunnelPid) { $process = Get-Process -Id $tunnelPid -ErrorAction SilentlyContinue if ($process) { Stop-Process -Id $tunnelPid -Force -ErrorAction SilentlyContinue Write-ColorLine " [OK] Stopped (was PID: $tunnelPid)" "Green" } else { Write-ColorLine " [WARN] Was not running" "Yellow" } } Remove-Item $pidFile -Force -ErrorAction SilentlyContinue } else { Write-ColorLine " [WARN] Was not running" "Yellow" } } function Get-SingleTunnelStatus { param([PSCustomObject]$Tunnel) $id = $Tunnel.id $name = $Tunnel.name $localPort = $Tunnel.local_port $sshHost = $Tunnel.ssh_host # Skip tunnels without SSH host (direct connection) if (-not $sshHost) { Write-Host " " -NoNewline Write-ColorLine "[$id] $name" "Cyan" Write-ColorLine " Direct connection (no tunnel needed)" "Gray" return } if (Test-TunnelRunning $id) { $pidFile = Get-PidFile $id $tunnelPid = Get-Content $pidFile Write-Host " " -NoNewline Write-Host "[OK]" -ForegroundColor Green -NoNewline Write-ColorLine " [$id] $name" "Cyan" Write-Host " localhost:$localPort (PID: $tunnelPid)" if (Test-PortOpen $localPort) { Write-ColorLine " Port accessible" "Green" } else { Write-ColorLine " Port not responding" "Yellow" } } else { Write-Host " " -NoNewline Write-Host "[--]" -ForegroundColor Red -NoNewline Write-ColorLine " [$id] $name" "Cyan" Write-Host " localhost:$localPort (stopped)" } } # ============================================================================= # MAIN COMMANDS # ============================================================================= function Show-Header { Write-Host "" Write-ColorLine "============================================" "Blue" Write-ColorLine " ROA2WEB SSH Tunnel Manager (Windows)" "Blue" Write-ColorLine "============================================" "Blue" Write-Host "" } function Invoke-Start { Show-Header $tunnels = Read-TunnelConfig if ($tunnels.Count -eq 0) { Write-ColorLine "[WARNING] No tunnels configured" "Yellow" return } Write-ColorLine "Starting $($tunnels.Count) tunnel(s)..." "Blue" Write-Host "" $failed = 0 foreach ($tunnel in $tunnels) { if (-not (Start-SingleTunnel $tunnel)) { $failed++ } Write-Host "" } if ($failed -gt 0) { Write-ColorLine "[WARNING] $failed tunnel(s) failed to start" "Yellow" } else { Write-ColorLine "[OK] All tunnels started successfully" "Green" } } function Invoke-Stop { Show-Header $tunnels = Read-TunnelConfig Write-ColorLine "Stopping tunnels..." "Blue" Write-Host "" foreach ($tunnel in $tunnels) { Stop-SingleTunnel $tunnel } Write-Host "" Write-ColorLine "[OK] All tunnels stopped" "Green" } function Invoke-Status { Show-Header $tunnels = Read-TunnelConfig Write-ColorLine "Tunnel Status:" "Blue" Write-Host "--------------------------------------------" foreach ($tunnel in $tunnels) { Get-SingleTunnelStatus $tunnel Write-Host "" } } function Invoke-Restart { Invoke-Stop Start-Sleep -Seconds 2 Invoke-Start } function Show-Help { Show-Header Write-Host "Usage: .\ssh-tunnel.ps1 " Write-Host "" Write-ColorLine "Actions:" "Blue" Write-Host " start - Start all configured SSH tunnels" Write-Host " stop - Stop all SSH tunnels" Write-Host " status - Show status of all tunnels" Write-Host " restart - Restart all tunnels" Write-Host " help - Show this help" Write-Host "" Write-ColorLine "Configuration:" "Blue" Write-Host " Tunnels file: $($Config.TunnelsFile)" Write-Host " Secrets dir: $($Config.SecretsPath)" Write-Host "" Write-ColorLine "SSH Key Setup:" "Blue" Write-Host " 1. Copy your SSH private key to: backend\secrets\{server_id}.ssh_key" Write-Host " 2. Ensure the key has correct permissions (readable only by you)" Write-Host "" Write-ColorLine "Example ssh-tunnels.json:" "Blue" Write-Host ' [{"id": "vending", "name": "Vending Master", "local_port": 1521,' Write-Host ' "ssh_host": "79.119.86.134", "ssh_port": 22122, "ssh_user": "romfast",' Write-Host ' "oracle_host": "127.0.0.1", "oracle_port": 1521}]' Write-Host "" } # ============================================================================= # MAIN # ============================================================================= # Check OpenSSH availability $sshPath = Get-Command ssh.exe -ErrorAction SilentlyContinue if (-not $sshPath) { Write-ColorLine "[ERROR] OpenSSH not found!" "Red" Write-Host "" Write-Host "OpenSSH Client is required. Install it via:" Write-Host " Settings -> Apps -> Optional Features -> Add a feature -> OpenSSH Client" Write-Host "" Write-Host "Or via PowerShell (Admin):" Write-Host " Add-WindowsCapability -Online -Name OpenSSH.Client~~~~0.0.1.0" exit 1 } switch ($Action) { "start" { Invoke-Start } "stop" { Invoke-Stop } "status" { Invoke-Status } "restart" { Invoke-Restart } "help" { Show-Help } default { Show-Help } }