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>
This commit is contained in:
Claude Agent
2026-01-28 19:04:26 +00:00
parent dc1711acd0
commit 6718c956f7
9 changed files with 1766 additions and 26 deletions

View File

@@ -0,0 +1,225 @@
<#
.SYNOPSIS
ROA2WEB Backend Service Wrapper for NSSM
.DESCRIPTION
This script is called by NSSM (Windows Service Manager) to start the backend.
It ensures SSH tunnels are running before starting uvicorn.
Flow:
1. Start SSH tunnels via ssh-tunnel.ps1
2. Wait for tunnel ports to be accessible (timeout 30s)
3. Start uvicorn (blocking - NSSM monitors this process)
This wrapper ensures the database connection is available before
the FastAPI application tries to initialize the Oracle pool.
.NOTES
Author: ROA2WEB Team
Version: 1.0
NSSM Configuration:
- Application: powershell.exe
- Arguments: -ExecutionPolicy Bypass -File "C:\path\to\start-backend-service.ps1"
- AppDirectory: C:\inetpub\wwwroot\roa2web\backend
#>
$ErrorActionPreference = "Stop"
# =============================================================================
# CONFIGURATION
# =============================================================================
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
# Detect paths (can run from scripts dir or project root)
$PossibleRoots = @(
(Join-Path $ScriptDir "..\..\.."), # From deployment/windows/scripts
$ScriptDir, # From project root
"C:\inetpub\wwwroot\roa2web" # Production path
)
$ProjectRoot = $null
foreach ($path in $PossibleRoots) {
$resolved = [System.IO.Path]::GetFullPath($path)
if (Test-Path (Join-Path $resolved "backend\main.py")) {
$ProjectRoot = $resolved
break
}
}
if (-not $ProjectRoot) {
Write-Host "[ERROR] Cannot find project root (looking for backend/main.py)" -ForegroundColor Red
exit 1
}
$BackendDir = Join-Path $ProjectRoot "backend"
# SSH tunnel script - check production location first, then development
$TunnelsScript = Join-Path $ProjectRoot "scripts\ssh-tunnel.ps1"
if (-not (Test-Path $TunnelsScript)) {
$TunnelsScript = Join-Path $ProjectRoot "deployment\windows\scripts\ssh-tunnel.ps1"
}
$TunnelsConfig = Join-Path $BackendDir "ssh-tunnels.json"
$VenvPath = "C:\inetpub\wwwroot\roa2web-venv"
$VenvPython = Join-Path $VenvPath "Scripts\python.exe"
$LogDir = Join-Path $ProjectRoot "logs"
# Fallback to local venv if production venv doesn't exist
if (-not (Test-Path $VenvPython)) {
$VenvPath = Join-Path $BackendDir "venv"
$VenvPython = Join-Path $VenvPath "Scripts\python.exe"
}
# =============================================================================
# HELPER FUNCTIONS
# =============================================================================
function Write-Log {
param([string]$Message, [string]$Level = "INFO")
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
Write-Host "[$timestamp] [$Level] $Message"
}
function Test-PortOpen {
param([int]$Port, [int]$Timeout = 3)
try {
$tcpClient = New-Object System.Net.Sockets.TcpClient
$result = $tcpClient.BeginConnect("127.0.0.1", $Port, $null, $null)
$success = $result.AsyncWaitHandle.WaitOne($Timeout * 1000)
if ($success) {
$tcpClient.EndConnect($result)
$tcpClient.Close()
return $true
}
return $false
} catch {
return $false
}
}
function Get-TunnelPorts {
# Read ports from ssh-tunnels.json
if (-not (Test-Path $TunnelsConfig)) {
Write-Log "No ssh-tunnels.json found, assuming no tunnels needed" "WARN"
return @()
}
try {
$tunnels = Get-Content $TunnelsConfig -Raw | ConvertFrom-Json
$ports = @()
foreach ($tunnel in $tunnels) {
# Only include tunnels that have ssh_host configured
if ($tunnel.ssh_host) {
$ports += [int]$tunnel.local_port
}
}
return $ports
} catch {
Write-Log "Failed to parse ssh-tunnels.json: $_" "ERROR"
return @()
}
}
# =============================================================================
# MAIN STARTUP SEQUENCE
# =============================================================================
Write-Log "=========================================="
Write-Log "ROA2WEB Backend Service Wrapper Starting"
Write-Log "=========================================="
Write-Log "Project Root: $ProjectRoot"
Write-Log "Backend Dir: $BackendDir"
Write-Log "Venv Python: $VenvPython"
# Ensure log directory exists
if (-not (Test-Path $LogDir)) {
New-Item -ItemType Directory -Path $LogDir -Force | Out-Null
}
# -------------------------------------------------------------------------
# Step 1: Start SSH Tunnels
# -------------------------------------------------------------------------
Write-Log "Step 1: Starting SSH Tunnels..."
if (Test-Path $TunnelsScript) {
try {
& $TunnelsScript start
Write-Log "SSH tunnel script executed"
} catch {
Write-Log "SSH tunnel script failed: $_" "ERROR"
# Continue anyway - tunnels might already be running
}
} else {
Write-Log "SSH tunnel script not found at $TunnelsScript" "WARN"
}
# -------------------------------------------------------------------------
# Step 2: Wait for Tunnel Ports
# -------------------------------------------------------------------------
$tunnelPorts = Get-TunnelPorts
if ($tunnelPorts.Count -gt 0) {
Write-Log "Step 2: Waiting for tunnel ports: $($tunnelPorts -join ', ')..."
$timeout = 30
$waited = 0
$allReady = $false
while ($waited -lt $timeout) {
$allReady = $true
foreach ($port in $tunnelPorts) {
if (-not (Test-PortOpen $port)) {
$allReady = $false
break
}
}
if ($allReady) {
Write-Log "All tunnel ports are accessible"
break
}
Start-Sleep -Seconds 1
$waited++
if ($waited % 5 -eq 0) {
Write-Log "Still waiting for tunnel ports... ($waited/${timeout}s)"
}
}
if (-not $allReady) {
Write-Log "Tunnel ports not ready after ${timeout}s - continuing anyway" "WARN"
}
} else {
Write-Log "Step 2: No tunnel ports configured, skipping wait"
}
# -------------------------------------------------------------------------
# Step 3: Start uvicorn (blocking)
# -------------------------------------------------------------------------
Write-Log "Step 3: Starting uvicorn..."
Write-Log "Working Directory: $BackendDir"
Write-Log "Python: $VenvPython"
# Verify Python exists
if (-not (Test-Path $VenvPython)) {
Write-Log "Python not found at $VenvPython" "ERROR"
exit 1
}
# Set working directory
Set-Location $BackendDir
# Set PYTHONPATH for shared modules
$env:PYTHONPATH = "$ProjectRoot;$BackendDir"
# Start uvicorn (this blocks - NSSM monitors this process)
# NOTE: --workers 1 is required because Telegram bot uses polling (single instance only)
Write-Log "Executing: $VenvPython -m uvicorn main:app --host 127.0.0.1 --port 8000 --workers 1"
& $VenvPython -m uvicorn main:app --host 127.0.0.1 --port 8000 --workers 1
# If we get here, uvicorn has exited
$exitCode = $LASTEXITCODE
Write-Log "uvicorn exited with code: $exitCode" "WARN"
exit $exitCode