- Add ssh-tunnel.ps1: Windows SSH tunnel manager (equivalent to ssh-tunnel.sh) - Supports password auth via plink.exe (PuTTY) - Supports ssh_hostkey for non-interactive batch mode - Commands: start, stop, restart, status - Add start-backend-service.ps1: NSSM service wrapper - Starts SSH tunnels before uvicorn - Waits for tunnel ports to be accessible (30s timeout) - Configured by Install-ROA2WEB.ps1 - Add start.ps1: Windows equivalent of start.sh - Orchestrates SSH tunnel + backend + frontend startup - Add backend/shared/ssh_tunnel_manager.py: Python monitoring - Background asyncio task monitors tunnel health every 30s - Auto-restarts tunnels after 2 consecutive failures - Exposes status to /health endpoint - Update ROA2WEB-Console.ps1: - Add Deploy-Scripts function - Update Update-ServiceToUseVenv to use wrapper script - Fix PowerShell reserved variable ($PID -> $tunnelPid) - Fix script path detection (scripts/ vs deployment/windows/scripts/) - Update README.md with ssh_hostkey documentation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
510 lines
16 KiB
PowerShell
510 lines
16 KiB
PowerShell
<#
|
|
.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 <action>"
|
|
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 }
|
|
}
|