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:
@@ -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
|
||||
|
||||
@@ -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: $_"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
|
||||
509
deployment/windows/scripts/ssh-tunnel.ps1
Normal file
509
deployment/windows/scripts/ssh-tunnel.ps1
Normal 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 }
|
||||
}
|
||||
225
deployment/windows/scripts/start-backend-service.ps1
Normal file
225
deployment/windows/scripts/start-backend-service.ps1
Normal 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
|
||||
Reference in New Issue
Block a user