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

391
start.ps1 Normal file
View File

@@ -0,0 +1,391 @@
<#
.SYNOPSIS
ROA2WEB - Unified Starter Script for Windows (equivalent to start.sh)
.DESCRIPTION
Orchestrates startup of all ROA2WEB services on Windows:
1. SSH Tunnels (if needed for environment)
2. Unified Backend (port 8000)
3. Unified Frontend (port 3000) - optional for development
This is the Windows equivalent of the Linux start.sh script.
.PARAMETER Environment
Target environment: prod or test
- prod: Uses SSH tunnel to Oracle
- test: Direct connection (no tunnel needed)
.PARAMETER Action
Action to perform: start or stop
.PARAMETER SkipFrontend
Skip starting the frontend (useful for production where IIS serves static files)
.EXAMPLE
.\start.ps1 prod
Start all services in production mode
.EXAMPLE
.\start.ps1 test
Start all services in test mode
.EXAMPLE
.\start.ps1 prod stop
Stop all services
.NOTES
Author: ROA2WEB Team
Version: 1.0
#>
param(
[Parameter(Position=0)]
[ValidateSet("prod", "production", "test")]
[string]$Environment = "prod",
[Parameter(Position=1)]
[ValidateSet("start", "stop")]
[string]$Action = "start",
[switch]$SkipFrontend
)
$ErrorActionPreference = "Stop"
# =============================================================================
# CONFIGURATION
# =============================================================================
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
# Normalize environment name
if ($Environment -eq "production") { $Environment = "prod" }
$Config = @{
prod = @{
Name = "PROD"
EnvFile = ".env.prod"
NeedsTunnel = $true
LogDir = Join-Path $ScriptDir "logs"
}
test = @{
Name = "TEST"
EnvFile = ".env.test"
NeedsTunnel = $false
LogDir = $env:TEMP
}
}
$Env = $Config[$Environment]
$BackendDir = Join-Path $ScriptDir "backend"
$BackendLog = Join-Path $Env.LogDir "backend-stderr.log"
$FrontendLog = Join-Path $Env.LogDir "frontend.log"
$VenvPath = Join-Path $BackendDir "venv"
$VenvPython = Join-Path $VenvPath "Scripts\python.exe"
$TunnelScript = Join-Path $ScriptDir "deployment\windows\scripts\ssh-tunnel.ps1"
# PID tracking files
$PidDir = Join-Path $env:TEMP "roa2web_pids"
if (-not (Test-Path $PidDir)) {
New-Item -ItemType Directory -Path $PidDir -Force | Out-Null
}
$BackendPidFile = Join-Path $PidDir "backend.pid"
$FrontendPidFile = Join-Path $PidDir "frontend.pid"
# =============================================================================
# HELPER FUNCTIONS
# =============================================================================
function Write-Header {
Write-Host ""
Write-Host "============================================" -ForegroundColor Blue
Write-Host " ROA2WEB Unified Starter (Windows)" -ForegroundColor Blue
Write-Host " Environment: $($Env.Name)" -ForegroundColor Cyan
Write-Host "============================================" -ForegroundColor Blue
Write-Host ""
}
function Write-Step {
param([string]$Message)
Write-Host "[UNIFIED-$($Env.Name)] $Message" -ForegroundColor Cyan
}
function Write-Success {
param([string]$Message)
Write-Host "[SUCCESS] $Message" -ForegroundColor Green
}
function Write-Warning {
param([string]$Message)
Write-Host "[WARNING] $Message" -ForegroundColor Yellow
}
function Write-Error {
param([string]$Message)
Write-Host "[ERROR] $Message" -ForegroundColor Red
}
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 Stop-ProcessByPidFile {
param([string]$PidFile, [string]$Name)
if (Test-Path $PidFile) {
$pid = Get-Content $PidFile -ErrorAction SilentlyContinue
if ($pid) {
$process = Get-Process -Id $pid -ErrorAction SilentlyContinue
if ($process) {
Write-Step "Stopping $Name (PID: $pid)..."
Stop-Process -Id $pid -Force -ErrorAction SilentlyContinue
Start-Sleep -Seconds 1
}
}
Remove-Item $PidFile -Force -ErrorAction SilentlyContinue
}
}
# =============================================================================
# STOP SERVICES
# =============================================================================
function Stop-AllServices {
Write-Header
Write-Step "Stopping all services..."
Write-Host ""
# Stop Backend
Write-Step "1. Stopping Backend..."
Stop-ProcessByPidFile $BackendPidFile "Backend"
# Also kill any uvicorn processes
Get-Process -Name "python" -ErrorAction SilentlyContinue |
Where-Object { $_.CommandLine -like "*uvicorn*main:app*" } |
ForEach-Object {
Write-Step " Killing uvicorn process (PID: $($_.Id))..."
Stop-Process -Id $_.Id -Force -ErrorAction SilentlyContinue
}
if (Test-PortOpen 8000) {
Write-Warning "Port 8000 still in use, force killing..."
$netstat = netstat -ano | Select-String ":8000" | Select-String "LISTENING"
if ($netstat) {
$pid = ($netstat -split '\s+')[-1]
Stop-Process -Id $pid -Force -ErrorAction SilentlyContinue
}
}
# Stop Frontend
Write-Step "2. Stopping Frontend..."
Stop-ProcessByPidFile $FrontendPidFile "Frontend"
Get-Process -Name "node" -ErrorAction SilentlyContinue |
Where-Object { $_.CommandLine -like "*vite*" } |
ForEach-Object {
Write-Step " Killing Vite process (PID: $($_.Id))..."
Stop-Process -Id $_.Id -Force -ErrorAction SilentlyContinue
}
# Stop SSH Tunnels
Write-Step "3. Stopping SSH Tunnels..."
if (Test-Path $TunnelScript) {
& $TunnelScript stop
}
Write-Host ""
Write-Success "All services stopped."
exit 0
}
# =============================================================================
# START SERVICES
# =============================================================================
function Start-AllServices {
Write-Header
Write-Step "Starting ROA2WEB Ultrathin Monolith..."
Write-Host ""
# Ensure log directory exists
if (-not (Test-Path $Env.LogDir)) {
New-Item -ItemType Directory -Path $Env.LogDir -Force | Out-Null
}
# -------------------------------------------------------------------------
# Step 1: SSH Tunnels
# -------------------------------------------------------------------------
Write-Step "1. Checking SSH Tunnels..."
if (Test-Path $TunnelScript) {
if ($Env.NeedsTunnel) {
& $TunnelScript start
Write-Success "SSH Tunnels started"
Start-Sleep -Seconds 2
} else {
Write-Success "$($Env.Name) uses direct connection - no tunnel needed"
}
} else {
Write-Warning "SSH tunnel script not found: $TunnelScript"
}
# -------------------------------------------------------------------------
# Step 2: Backend
# -------------------------------------------------------------------------
Write-Step "2. Starting Unified Backend on port 8000..."
if (Test-PortOpen 8000) {
Write-Warning "Port 8000 already in use - Backend may be running"
} else {
# Check venv exists
if (-not (Test-Path $VenvPython)) {
Write-Step "Creating Python virtual environment..."
& python -m venv $VenvPath
}
# Check env file
$envFilePath = Join-Path $BackendDir $Env.EnvFile
if (-not (Test-Path $envFilePath)) {
Write-Error "$($Env.EnvFile) not found at $envFilePath"
exit 1
}
# Copy env file to active .env
Write-Step "Using $($Env.Name) environment ($($Env.EnvFile))..."
Copy-Item -Path $envFilePath -Destination (Join-Path $BackendDir ".env") -Force
# Start backend
Write-Step "Starting unified backend..."
$backendProcess = Start-Process -FilePath $VenvPython `
-ArgumentList "-m", "uvicorn", "main:app", "--host", "127.0.0.1", "--port", "8000", "--workers", "1" `
-WorkingDirectory $BackendDir `
-WindowStyle Hidden `
-RedirectStandardError $BackendLog `
-PassThru
$backendProcess.Id | Out-File -FilePath $BackendPidFile -NoNewline
# Wait for backend to start
Write-Step "Waiting for Backend to initialize..."
$maxWait = 45
$elapsed = 0
while ($elapsed -lt $maxWait) {
if (Test-PortOpen 8000) {
Write-Success "Unified Backend started on http://localhost:8000"
break
}
Start-Sleep -Seconds 1
$elapsed++
if ($elapsed % 5 -eq 0) {
Write-Step " Still initializing... ($elapsed/${maxWait}s)"
}
}
if (-not (Test-PortOpen 8000)) {
Write-Error "Backend failed to start - check $BackendLog"
Get-Content $BackendLog -Tail 30
Stop-AllServices
}
}
# -------------------------------------------------------------------------
# Step 3: Frontend (optional)
# -------------------------------------------------------------------------
if (-not $SkipFrontend) {
Write-Step "3. Starting Unified Frontend on port 3000..."
if (Test-PortOpen 3000) {
Write-Warning "Port 3000 already in use - Frontend may be running"
} else {
# Check node_modules
$nodeModules = Join-Path $ScriptDir "node_modules"
if (-not (Test-Path $nodeModules)) {
Write-Step "Installing Frontend dependencies..."
Push-Location $ScriptDir
& npm install
Pop-Location
}
# Start frontend
Write-Step "Starting Vite development server..."
$frontendProcess = Start-Process -FilePath "cmd.exe" `
-ArgumentList "/c", "npm", "run", "dev" `
-WorkingDirectory $ScriptDir `
-WindowStyle Hidden `
-RedirectStandardOutput $FrontendLog `
-PassThru
$frontendProcess.Id | Out-File -FilePath $FrontendPidFile -NoNewline
# Wait for frontend
Write-Step "Waiting for Vite to initialize..."
$maxWait = 15
$elapsed = 0
while ($elapsed -lt $maxWait) {
if (Test-PortOpen 3000) {
Write-Success "Unified Frontend started on http://localhost:3000"
break
}
Start-Sleep -Seconds 2
$elapsed += 2
}
if (-not (Test-PortOpen 3000)) {
Write-Error "Frontend failed to start - check $FrontendLog"
Get-Content $FrontendLog
}
}
} else {
Write-Step "3. Skipping Frontend (use IIS for static files)"
}
# -------------------------------------------------------------------------
# Summary
# -------------------------------------------------------------------------
Write-Host ""
Write-Success "ROA2WEB Ultrathin Monolith ($($Env.Name)) is now running!"
Write-Host ""
Write-Host "Services:" -ForegroundColor Blue
if ($Env.NeedsTunnel) {
Write-Host " * SSH Tunnel: Active (Oracle DB connection)"
} else {
Write-Host " * Oracle Connection: Direct (no SSH tunnel needed)"
}
Write-Host " * Unified Backend: http://localhost:8000"
Write-Host " +-- Reports API: http://localhost:8000/api/reports/*"
Write-Host " +-- Data Entry: http://localhost:8000/api/data-entry/*"
Write-Host " +-- Telegram: http://localhost:8000/api/telegram/*"
if (-not $SkipFrontend) {
Write-Host " * Unified Frontend: http://localhost:3000"
}
Write-Host ""
Write-Host "API Documentation:" -ForegroundColor Blue
Write-Host " * API Docs: http://localhost:8000/docs"
Write-Host " * Health Check: http://localhost:8000/health"
Write-Host ""
Write-Host "Log Files:" -ForegroundColor Blue
Write-Host " * Backend: $BackendLog"
if (-not $SkipFrontend) {
Write-Host " * Frontend: $FrontendLog"
}
Write-Host ""
Write-Host "To stop services: .\start.ps1 $Environment stop" -ForegroundColor Yellow
Write-Host ""
}
# =============================================================================
# MAIN
# =============================================================================
switch ($Action) {
"start" { Start-AllServices }
"stop" { Stop-AllServices }
}