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

@@ -205,6 +205,8 @@ create_package() {
"ROA2WEB-Console.ps1"
"Install-ROA2WEB.ps1"
"Check-And-Deploy.ps1"
"ssh-tunnel.ps1"
"start-backend-service.ps1"
)
for script in "${SCRIPTS[@]}"; do

View File

@@ -383,21 +383,24 @@ function New-WindowsService {
Write-Success "Existing service removed"
}
# Get Python path from venv
# Verify venv exists (wrapper script needs it)
$venvPython = Join-Path $Config.VenvPath "Scripts\python.exe"
if (-not (Test-Path $venvPython)) {
throw "Virtual environment Python not found at $venvPython. Run Install-PythonDependencies first."
}
$uvicornModule = "uvicorn"
$appModule = "main:app"
# NSSM service creation
# NSSM service creation using wrapper script
# The wrapper script (start-backend-service.ps1) handles:
# 1. Starting SSH tunnels before backend
# 2. Waiting for tunnel ports to be accessible
# 3. Starting uvicorn
try {
# Install service using venv Python
# NOTE: Using --workers 1 because Telegram bot requires single instance (polling conflict)
& nssm install $Config.ServiceName $venvPython "-m" $uvicornModule $appModule "--host" "127.0.0.1" "--port" $Config.ServicePort.ToString() "--workers" "1"
$wrapperScript = Join-Path $PSScriptRoot "start-backend-service.ps1"
# Install service using PowerShell wrapper
# NOTE: Using wrapper to ensure SSH tunnels start before uvicorn
& nssm install $Config.ServiceName "powershell.exe" "-ExecutionPolicy" "Bypass" "-File" "`"$wrapperScript`""
# Set service configuration
& nssm set $Config.ServiceName DisplayName $Config.ServiceDisplayName
@@ -423,7 +426,8 @@ function New-WindowsService {
& nssm set $Config.ServiceName AppExit Default Restart
& nssm set $Config.ServiceName AppRestartDelay 5000
Write-Success "Windows Service created successfully"
Write-Success "Windows Service created successfully (using wrapper script)"
Write-Success " Wrapper: $wrapperScript"
} catch {
throw "Failed to create Windows Service: $_"
}

View File

@@ -215,7 +215,15 @@ function Initialize-Venv {
function Update-ServiceToUseVenv {
<#
.SYNOPSIS
Updates NSSM service to use venv Python
Updates NSSM service to use wrapper script for SSH tunnel auto-start
.DESCRIPTION
Configures the service to use start-backend-service.ps1 wrapper which:
1. Starts SSH tunnels before backend
2. Waits for tunnel ports to be accessible
3. Starts uvicorn with correct settings
This ensures SSH tunnels are always running when the backend starts.
#>
$venvPython = Join-Path $Config.VenvPath "Scripts\python.exe"
@@ -231,24 +239,60 @@ function Update-ServiceToUseVenv {
return $false
}
# Find wrapper script
$wrapperScript = Join-Path $Config.InstallRoot "scripts\start-backend-service.ps1"
if (-not (Test-Path $wrapperScript)) {
# Fallback: try deployment location
$wrapperScript = Join-Path $PSScriptRoot "start-backend-service.ps1"
}
$useWrapper = Test-Path $wrapperScript
try {
# Get current application path
# Get current application
$currentApp = & nssm get $Config.ServiceName Application 2>&1
if ($currentApp -eq $venvPython) {
Write-Success "Service already configured to use venv Python"
return $true
if ($useWrapper) {
# Check if already using wrapper
if ($currentApp -like "*powershell*") {
$currentArgs = & nssm get $Config.ServiceName AppParameters 2>&1
if ($currentArgs -like "*start-backend-service.ps1*") {
Write-Success "Service already configured to use wrapper script"
return $true
}
}
Write-Step "Updating service to use wrapper script (SSH tunnel auto-start)..."
# Stop service first
Stop-ROAService | Out-Null
# Update service to use PowerShell wrapper
& nssm set $Config.ServiceName Application "powershell.exe"
& nssm set $Config.ServiceName AppParameters "-ExecutionPolicy Bypass -File `"$wrapperScript`""
Write-Success "Service updated to use wrapper: $wrapperScript"
Write-Info "SSH tunnels will auto-start when service starts"
} else {
# Fallback: use venv Python directly (old behavior)
if ($currentApp -eq $venvPython) {
Write-Success "Service already configured to use venv Python"
return $true
}
Write-Step "Updating service to use venv Python (no wrapper available)..."
# Stop service first
Stop-ROAService | Out-Null
# Update service application
& nssm set $Config.ServiceName Application $venvPython
& nssm set $Config.ServiceName AppParameters "-m uvicorn main:app --host 127.0.0.1 --port $($Config.ServicePort) --workers 1"
Write-Success "Service updated to use: $venvPython"
Write-Warning "Wrapper script not found - SSH tunnels must be started manually"
}
Write-Step "Updating service to use venv Python..."
# Stop service first
Stop-ROAService | Out-Null
# Update service application
& nssm set $Config.ServiceName Application $venvPython
Write-Success "Service updated to use: $venvPython"
return $true
} catch {
Write-Error "Failed to update service: $_"
@@ -777,6 +821,85 @@ function New-Backup {
# DEPLOYMENT FUNCTIONS
# =============================================================================
function Deploy-Scripts {
<#
.SYNOPSIS
Deploys PowerShell scripts to installation directory
.DESCRIPTION
Copies deployment scripts from package to install root's scripts folder.
This includes the wrapper script needed for SSH tunnel auto-start.
#>
param([string]$SourcePath)
Write-Step "Deploying scripts..."
$sourceScripts = Join-Path $SourcePath "scripts"
$destScripts = Join-Path $Config.InstallRoot "scripts"
# Scripts to deploy (essential for operation)
$requiredScripts = @(
"ssh-tunnel.ps1",
"start-backend-service.ps1",
"ROA2WEB-Console.ps1"
)
try {
# Create scripts directory if needed
if (-not (Test-Path $destScripts)) {
New-Item -ItemType Directory -Path $destScripts -Force | Out-Null
Write-Info "Created scripts directory: $destScripts"
}
$deployedCount = 0
# Copy scripts from package
if (Test-Path $sourceScripts) {
foreach ($script in $requiredScripts) {
$srcFile = Join-Path $sourceScripts $script
$destFile = Join-Path $destScripts $script
if (Test-Path $srcFile) {
Copy-Item -Path $srcFile -Destination $destFile -Force
Write-Info "Deployed: $script"
$deployedCount++
}
}
}
# Also copy from current script location (fallback)
foreach ($script in $requiredScripts) {
$srcFile = Join-Path $PSScriptRoot $script
$destFile = Join-Path $destScripts $script
if ((Test-Path $srcFile) -and (-not (Test-Path $destFile))) {
Copy-Item -Path $srcFile -Destination $destFile -Force
Write-Info "Deployed (from PSScriptRoot): $script"
$deployedCount++
}
}
if ($deployedCount -gt 0) {
Write-Success "Scripts deployed ($deployedCount files)"
} else {
Write-Warning "No scripts to deploy"
}
# Verify essential wrapper script
$wrapperPath = Join-Path $destScripts "start-backend-service.ps1"
if (Test-Path $wrapperPath) {
Write-Success "Service wrapper script ready: $wrapperPath"
} else {
Write-Warning "Service wrapper script not found - SSH tunnel auto-start may not work"
}
return $true
} catch {
Write-Error "Scripts deployment failed: $_"
return $false
}
}
function Deploy-Backend {
param([string]$SourcePath)
@@ -1019,6 +1142,9 @@ function Deploy-All {
Write-Warning "Backup failed, but continuing with deployment"
}
# Deploy scripts first (needed for service wrapper)
$scriptsOk = Deploy-Scripts -SourcePath $SourcePath
# Deploy backend (includes service restart)
$backendOk = Deploy-Backend -SourcePath $SourcePath
@@ -1028,6 +1154,12 @@ function Deploy-All {
Write-Host "`n" + ("=" * 70) -ForegroundColor Cyan
Write-Host " DEPLOYMENT SUMMARY" -ForegroundColor Cyan
Write-Host ("=" * 70) -ForegroundColor Cyan
Write-Host " Scripts: " -NoNewline
if ($scriptsOk) {
Write-Host "[OK] Success" -ForegroundColor Green
} else {
Write-Host "[X] Failed" -ForegroundColor Red
}
Write-Host " Backend: " -NoNewline
if ($backendOk) {
Write-Host "[OK] Success" -ForegroundColor Green
@@ -1042,7 +1174,7 @@ function Deploy-All {
}
Write-Host ("=" * 70) -ForegroundColor Cyan
return ($backendOk -and $frontendOk)
return ($scriptsOk -and $backendOk -and $frontendOk)
}
# =============================================================================

View File

@@ -0,0 +1,509 @@
<#
.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 }
}

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