Files
roa2web-service-auto/deployment/windows/scripts/ssh-tunnel.ps1
Claude Agent 6718c956f7 feat: [US-004] Add SSH tunnel auto-start for Windows services
- 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>
2026-01-28 19:04:26 +00:00

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