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

@@ -265,6 +265,76 @@ All endpoints prefixed with `/api`:
--- ---
## SSH Tunnel Configuration
ROA2WEB uses SSH tunnels to connect to Oracle servers. Configuration is in `backend/ssh-tunnels.json`.
### Setup (one-time)
**Linux:**
```bash
# Copy SSH key to secrets folder
cp ~/.ssh/your_key backend/secrets/vending.ssh_key
chmod 600 backend/secrets/vending.ssh_key
# Or use password (requires sshpass)
echo "your_password" > backend/secrets/vending.ssh_pass
sudo apt install sshpass
```
**Windows:**
```powershell
# Option 1: SSH Key (recommended)
ssh-keygen -t rsa -b 4096 -f C:\inetpub\wwwroot\roa2web\backend\secrets\vending.ssh_key -N ""
# Then add public key to remote server's ~/.ssh/authorized_keys
# Option 2: Password (requires PuTTY)
choco install putty -y
echo "your_password" > C:\inetpub\wwwroot\roa2web\backend\secrets\vending.ssh_pass
```
### Configuration File
`backend/ssh-tunnels.json`:
```json
[
{
"id": "vending",
"name": "Vending Master",
"local_port": 1522,
"ssh_host": "79.119.86.134",
"ssh_port": 22122,
"ssh_user": "romfast",
"ssh_hostkey": "SHA256:xxxxx",
"oracle_host": "127.0.0.1",
"oracle_port": 1521
}
]
```
**Important:**
- `local_port` must match the port in `ORACLE_SERVERS` (.env) for this server
- `ssh_hostkey` is **required on Windows** (plink batch mode). Get it with:
```powershell
plink.exe -ssh user@host -P port "exit"
# Accept the key, then copy SHA256 fingerprint from output
```
### Commands
| Platform | Start | Stop | Status |
|----------|-------|------|--------|
| Linux | `./ssh-tunnel.sh start` | `./ssh-tunnel.sh stop` | `./ssh-tunnel.sh status` |
| Windows | `.\scripts\ssh-tunnel.ps1 start` | `.\scripts\ssh-tunnel.ps1 stop` | `.\scripts\ssh-tunnel.ps1 status` |
### Auto-Start (Production)
- **Linux**: `start.sh` automatically starts tunnels before backend
- **Windows Service**: `start-backend-service.ps1` wrapper starts tunnels before uvicorn
- **Auto-Reconnect**: Backend monitors tunnels and restarts them if they drop (every 30s check)
---
## Environment Configuration ## Environment Configuration
Copy `.env.example` to `.env` in each microservice and configure: Copy `.env.example` to `.env` in each microservice and configure:

View File

@@ -73,6 +73,7 @@ telegram_bot_task = None
ocr_job_worker_running = False ocr_job_worker_running = False
cleanup_task_running = False cleanup_task_running = False
email_cache_running = False email_cache_running = False
ssh_tunnel_monitoring = False
# ============================================================================ # ============================================================================
@@ -265,6 +266,40 @@ async def init_email_server_cache():
email_cache_running = False email_cache_running = False
async def init_ssh_tunnel_monitoring():
"""Initialize SSH tunnel monitoring with auto-reconnect.
This does NOT start tunnels - they should already be running
(started by start.sh / start.ps1 / start-backend-service.ps1).
Responsibilities:
- Monitor tunnel health via port checks (every 30s)
- Auto-restart tunnels if they go down
- Expose status for /health endpoint
"""
global ssh_tunnel_monitoring
logger.info("[SSH-MONITOR] Initializing tunnel monitoring...")
try:
from backend.shared.ssh_tunnel_manager import ssh_tunnel_manager
success = await ssh_tunnel_manager.start_monitoring()
ssh_tunnel_monitoring = success
if success:
status = ssh_tunnel_manager.get_status()
if status["status"] == "not_configured":
logger.info("[SSH-MONITOR] No tunnels configured (direct connection mode)")
else:
logger.info(f"[SSH-MONITOR] ✅ Monitoring active: {status['status']}")
else:
logger.warning("[SSH-MONITOR] ⚠️ Failed to start monitoring")
except Exception as e:
logger.warning(f"[SSH-MONITOR] ⚠️ Init failed: {e}")
ssh_tunnel_monitoring = False
async def run_telegram_bot(): async def run_telegram_bot():
"""Run Telegram bot as background task.""" """Run Telegram bot as background task."""
logger.info("[TELEGRAM] Starting bot...") logger.info("[TELEGRAM] Starting bot...")
@@ -381,7 +416,10 @@ async def startup_event():
# Step 5: Initialize email-server cache for multi-Oracle (US-003) # Step 5: Initialize email-server cache for multi-Oracle (US-003)
await init_email_server_cache() await init_email_server_cache()
# Step 6: Start Telegram bot as background task # Step 6: Initialize SSH tunnel monitoring (auto-reconnect)
await init_ssh_tunnel_monitoring()
# Step 7: Start Telegram bot as background task
if settings.telegram_bot_token: if settings.telegram_bot_token:
telegram_bot_task = asyncio.create_task(run_telegram_bot()) telegram_bot_task = asyncio.create_task(run_telegram_bot())
logger.info("[STARTUP] ✅ Telegram bot task created") logger.info("[STARTUP] ✅ Telegram bot task created")
@@ -401,13 +439,24 @@ async def startup_event():
@app.on_event("shutdown") @app.on_event("shutdown")
async def shutdown_event(): async def shutdown_event():
"""Application shutdown - Cleanup resources.""" """Application shutdown - Cleanup resources."""
global telegram_bot_task, ocr_job_worker_running, cleanup_task_running, email_cache_running global telegram_bot_task, ocr_job_worker_running, cleanup_task_running, email_cache_running, ssh_tunnel_monitoring
logger.info("=" * 80) logger.info("=" * 80)
logger.info("[SHUTDOWN] Stopping ROA2WEB Unified Backend...") logger.info("[SHUTDOWN] Stopping ROA2WEB Unified Backend...")
logger.info("=" * 80) logger.info("=" * 80)
try: try:
# Stop SSH tunnel monitoring
if ssh_tunnel_monitoring:
logger.info("[SHUTDOWN] Stopping SSH tunnel monitoring...")
try:
from backend.shared.ssh_tunnel_manager import ssh_tunnel_manager
await ssh_tunnel_manager.stop_monitoring()
ssh_tunnel_monitoring = False
logger.info("[SHUTDOWN] SSH tunnel monitoring stopped")
except Exception as e:
logger.error(f"[SHUTDOWN] SSH tunnel monitoring error: {e}")
# Stop email cache auto-refresh (US-003) # Stop email cache auto-refresh (US-003)
if email_cache_running: if email_cache_running:
logger.info("[SHUTDOWN] Stopping email cache auto-refresh...") logger.info("[SHUTDOWN] Stopping email cache auto-refresh...")
@@ -609,6 +658,14 @@ async def health_check():
except Exception as e: except Exception as e:
health_status["modules"]["ocr_worker"] = f"error: {str(e)}" health_status["modules"]["ocr_worker"] = f"error: {str(e)}"
# Check SSH tunnels
global ssh_tunnel_monitoring
try:
from backend.shared.ssh_tunnel_manager import ssh_tunnel_manager
health_status["modules"]["ssh_tunnels"] = ssh_tunnel_manager.get_status()
except Exception as e:
health_status["modules"]["ssh_tunnels"] = f"error: {str(e)}"
return health_status return health_status

View File

@@ -0,0 +1,350 @@
"""
SSH Tunnel Manager - Cross-Platform Monitoring and Auto-Reconnect
This module provides MONITORING and AUTO-RECONNECT for SSH tunnels.
It does NOT start tunnels - that's the responsibility of:
- Linux: start.sh → ssh-tunnel.sh
- Windows: Start-ROA2WEB.ps1 → SSH-Tunnels.ps1
- Windows Service: Start-Backend-Service.ps1 → SSH-Tunnels.ps1
Responsibilities:
✅ Monitor tunnel health via port checks (background asyncio task)
✅ Auto-restart tunnels if they go down (calls platform-specific scripts)
✅ Expose status for /health endpoint
NOT responsible for:
❌ Initial tunnel startup (done by wrapper scripts before backend starts)
Usage in main.py:
from backend.shared.ssh_tunnel_manager import ssh_tunnel_manager
@app.on_event("startup")
async def startup():
await ssh_tunnel_manager.start_monitoring()
@app.on_event("shutdown")
async def shutdown():
await ssh_tunnel_manager.stop_monitoring()
@app.get("/health")
async def health():
return {
"ssh_tunnels": ssh_tunnel_manager.get_status()
}
"""
import asyncio
import json
import logging
import platform
import subprocess
from pathlib import Path
from typing import Dict, List, Optional, Any
logger = logging.getLogger(__name__)
class SSHTunnelManager:
"""
Cross-platform SSH tunnel MONITOR (not starter).
Timeline:
T=0 start.sh / Wrapper starts
T=1s ssh-tunnel.sh / SSH-Tunnels.ps1 START
T=3s Tunnels active ✅
T=5s uvicorn backend starts
T=7s Backend startup_event()
T=8s ssh_tunnel_manager.start_monitoring()
└─ Detects tunnels already active (just monitors, doesn't start)
T=38s Monitor check #1 - OK ✅
...
T=XXs [Tunnel drops]
T=XX+30 Monitor detects FAIL (1/2)
T=XX+60 Monitor detects FAIL (2/2) → RESTART via script
"""
def __init__(self):
# Configuration
self.check_interval: int = 30 # seconds between health checks
self.max_failures_before_restart: int = 2 # restart after N consecutive failures
self.restart_cooldown: int = 60 # minimum seconds between restarts
# State
self.tunnel_configs: List[Dict] = []
self.tunnel_status: Dict[str, bool] = {}
self.consecutive_failures: Dict[str, int] = {}
self.last_restart_time: float = 0
self.monitor_task: Optional[asyncio.Task] = None
self._is_monitoring: bool = False
# Paths (detected at runtime)
self._project_root: Optional[Path] = None
self._config_file: Optional[Path] = None
def _detect_paths(self) -> bool:
"""Detect project paths based on current file location."""
# This file is at: backend/shared/ssh_tunnel_manager.py
# Project root is 2 levels up
current_file = Path(__file__)
self._project_root = current_file.parent.parent.parent
# Config file location
self._config_file = self._project_root / "backend" / "ssh-tunnels.json"
return self._config_file.exists()
def _load_config(self) -> List[Dict]:
"""Load tunnel configuration from ssh-tunnels.json."""
if not self._config_file or not self._config_file.exists():
return []
try:
with open(self._config_file, 'r') as f:
tunnels = json.load(f)
# Filter to only tunnels with ssh_host (excludes direct connections)
return [t for t in tunnels if t.get("ssh_host")]
except Exception as e:
logger.error(f"[SSH-MONITOR] Failed to load config: {e}")
return []
async def start_monitoring(self) -> bool:
"""
Start monitoring EXISTING tunnels.
Does NOT start tunnels - assumes they're already running
(started by start.sh / Start-ROA2WEB.ps1 / Start-Backend-Service.ps1).
"""
if self._is_monitoring:
logger.warning("[SSH-MONITOR] Already monitoring")
return True
# Detect paths and load config
if not self._detect_paths():
logger.info("[SSH-MONITOR] No ssh-tunnels.json found, skipping")
return True
self.tunnel_configs = self._load_config()
if not self.tunnel_configs:
logger.info("[SSH-MONITOR] No SSH tunnels configured (or all are direct connections)")
return True
# Check initial status (tunnels should already be running)
logger.info(f"[SSH-MONITOR] Checking {len(self.tunnel_configs)} tunnel(s)...")
for config in self.tunnel_configs:
tunnel_id = config.get("id", "default")
port = config.get("local_port", 1521)
name = config.get("name", tunnel_id)
is_active = await self._check_port("127.0.0.1", port)
self.tunnel_status[tunnel_id] = is_active
self.consecutive_failures[tunnel_id] = 0
status = "✅ active" if is_active else "❌ NOT active"
logger.info(f"[SSH-MONITOR] [{tunnel_id}] {name} - localhost:{port} - {status}")
# Start background monitor loop
self._is_monitoring = True
self.monitor_task = asyncio.create_task(self._monitor_loop())
logger.info(f"[SSH-MONITOR] ✅ Monitoring started (check every {self.check_interval}s)")
return True
async def stop_monitoring(self) -> None:
"""Stop the monitoring background task."""
if not self._is_monitoring:
return
self._is_monitoring = False
if self.monitor_task and not self.monitor_task.done():
self.monitor_task.cancel()
try:
await self.monitor_task
except asyncio.CancelledError:
pass
logger.info("[SSH-MONITOR] ✅ Monitoring stopped")
async def _monitor_loop(self) -> None:
"""Background loop: check tunnel health every N seconds, restart if needed."""
while self._is_monitoring:
try:
await asyncio.sleep(self.check_interval)
if not self._is_monitoring:
break
needs_restart = False
for config in self.tunnel_configs:
tunnel_id = config.get("id", "default")
port = config.get("local_port", 1521)
is_healthy = await self._check_port("127.0.0.1", port)
self.tunnel_status[tunnel_id] = is_healthy
if is_healthy:
# Reset failure count on success
if self.consecutive_failures.get(tunnel_id, 0) > 0:
logger.info(f"[SSH-MONITOR] [{tunnel_id}] Recovered ✅")
self.consecutive_failures[tunnel_id] = 0
else:
# Increment failure count
self.consecutive_failures[tunnel_id] = \
self.consecutive_failures.get(tunnel_id, 0) + 1
failures = self.consecutive_failures[tunnel_id]
logger.warning(
f"[SSH-MONITOR] [{tunnel_id}] FAIL "
f"({failures}/{self.max_failures_before_restart})"
)
if failures >= self.max_failures_before_restart:
needs_restart = True
# Restart all tunnels if any failed enough times
if needs_restart:
await self._restart_tunnels()
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"[SSH-MONITOR] Monitor loop error: {e}")
await asyncio.sleep(5) # Brief pause before retrying
async def _check_port(self, host: str, port: int, timeout: float = 3.0) -> bool:
"""Check if a port is accessible (tunnel is working)."""
try:
# Use asyncio.open_connection for non-blocking port check
reader, writer = await asyncio.wait_for(
asyncio.open_connection(host, port),
timeout=timeout
)
writer.close()
await writer.wait_closed()
return True
except (asyncio.TimeoutError, ConnectionRefusedError, OSError):
return False
except Exception as e:
logger.debug(f"[SSH-MONITOR] Port check error {host}:{port}: {e}")
return False
async def _restart_tunnels(self) -> bool:
"""Restart tunnels via platform-specific script."""
import time
# Check cooldown
now = time.time()
if now - self.last_restart_time < self.restart_cooldown:
remaining = int(self.restart_cooldown - (now - self.last_restart_time))
logger.warning(f"[SSH-MONITOR] Restart cooldown active ({remaining}s remaining)")
return False
self.last_restart_time = now
logger.warning("[SSH-MONITOR] 🔄 Restarting tunnels...")
# Build platform-specific command
if platform.system() == "Windows":
# On Windows, scripts are deployed to scripts/ folder
script_path = self._project_root / "scripts" / "ssh-tunnel.ps1"
# Fallback to development path if not found
if not script_path.exists():
script_path = self._project_root / "deployment" / "windows" / "scripts" / "ssh-tunnel.ps1"
if not script_path.exists():
logger.error(f"[SSH-MONITOR] Script not found in scripts/ or deployment/windows/scripts/")
return False
cmd = [
"powershell.exe",
"-ExecutionPolicy", "Bypass",
"-File", str(script_path),
"restart"
]
else:
script_path = self._project_root / "ssh-tunnel.sh"
if not script_path.exists():
logger.error(f"[SSH-MONITOR] Script not found: {script_path}")
return False
cmd = [str(script_path), "restart"]
try:
# Run restart command in subprocess
result = await asyncio.get_event_loop().run_in_executor(
None,
lambda: subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=60,
cwd=str(self._project_root)
)
)
if result.returncode == 0:
logger.info("[SSH-MONITOR] ✅ Tunnels restarted successfully")
# Reset failure counts
for tunnel_id in self.consecutive_failures:
self.consecutive_failures[tunnel_id] = 0
return True
else:
logger.error(f"[SSH-MONITOR] Restart failed (code {result.returncode})")
if result.stderr:
logger.error(f"[SSH-MONITOR] stderr: {result.stderr[:500]}")
return False
except subprocess.TimeoutExpired:
logger.error("[SSH-MONITOR] Restart command timed out")
return False
except Exception as e:
logger.error(f"[SSH-MONITOR] Restart error: {e}")
return False
def get_status(self) -> Dict[str, Any]:
"""
Get current tunnel status for /health endpoint.
Returns:
{
"status": "connected" | "degraded" | "disconnected" | "not_configured",
"tunnels": {
"tunnel_id": true/false,
...
},
"monitoring": true/false
}
"""
if not self.tunnel_configs:
return {
"status": "not_configured",
"tunnels": {},
"monitoring": False
}
# Determine overall status
all_connected = all(self.tunnel_status.values()) if self.tunnel_status else False
any_connected = any(self.tunnel_status.values()) if self.tunnel_status else False
if all_connected:
status = "connected"
elif any_connected:
status = "degraded"
else:
status = "disconnected"
return {
"status": status,
"tunnels": dict(self.tunnel_status),
"monitoring": self._is_monitoring
}
def is_healthy(self) -> bool:
"""Quick check if all tunnels are healthy."""
if not self.tunnel_configs:
return True # No tunnels configured = healthy (direct connection)
return all(self.tunnel_status.values()) if self.tunnel_status else False
# Global singleton instance
ssh_tunnel_manager = SSHTunnelManager()

View File

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

View File

@@ -383,21 +383,24 @@ function New-WindowsService {
Write-Success "Existing service removed" 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" $venvPython = Join-Path $Config.VenvPath "Scripts\python.exe"
if (-not (Test-Path $venvPython)) { if (-not (Test-Path $venvPython)) {
throw "Virtual environment Python not found at $venvPython. Run Install-PythonDependencies first." throw "Virtual environment Python not found at $venvPython. Run Install-PythonDependencies first."
} }
$uvicornModule = "uvicorn" # NSSM service creation using wrapper script
$appModule = "main:app" # The wrapper script (start-backend-service.ps1) handles:
# 1. Starting SSH tunnels before backend
# NSSM service creation # 2. Waiting for tunnel ports to be accessible
# 3. Starting uvicorn
try { try {
# Install service using venv Python $wrapperScript = Join-Path $PSScriptRoot "start-backend-service.ps1"
# 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" # 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 # Set service configuration
& nssm set $Config.ServiceName DisplayName $Config.ServiceDisplayName & 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 AppExit Default Restart
& nssm set $Config.ServiceName AppRestartDelay 5000 & 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 { } catch {
throw "Failed to create Windows Service: $_" throw "Failed to create Windows Service: $_"
} }

View File

@@ -215,7 +215,15 @@ function Initialize-Venv {
function Update-ServiceToUseVenv { function Update-ServiceToUseVenv {
<# <#
.SYNOPSIS .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" $venvPython = Join-Path $Config.VenvPath "Scripts\python.exe"
@@ -231,24 +239,60 @@ function Update-ServiceToUseVenv {
return $false 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 { try {
# Get current application path # Get current application
$currentApp = & nssm get $Config.ServiceName Application 2>&1 $currentApp = & nssm get $Config.ServiceName Application 2>&1
if ($currentApp -eq $venvPython) { if ($useWrapper) {
Write-Success "Service already configured to use venv Python" # Check if already using wrapper
return $true 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 return $true
} catch { } catch {
Write-Error "Failed to update service: $_" Write-Error "Failed to update service: $_"
@@ -777,6 +821,85 @@ function New-Backup {
# DEPLOYMENT FUNCTIONS # 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 { function Deploy-Backend {
param([string]$SourcePath) param([string]$SourcePath)
@@ -1019,6 +1142,9 @@ function Deploy-All {
Write-Warning "Backup failed, but continuing with deployment" 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) # Deploy backend (includes service restart)
$backendOk = Deploy-Backend -SourcePath $SourcePath $backendOk = Deploy-Backend -SourcePath $SourcePath
@@ -1028,6 +1154,12 @@ function Deploy-All {
Write-Host "`n" + ("=" * 70) -ForegroundColor Cyan Write-Host "`n" + ("=" * 70) -ForegroundColor Cyan
Write-Host " DEPLOYMENT SUMMARY" -ForegroundColor Cyan Write-Host " DEPLOYMENT SUMMARY" -ForegroundColor Cyan
Write-Host ("=" * 70) -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 Write-Host " Backend: " -NoNewline
if ($backendOk) { if ($backendOk) {
Write-Host "[OK] Success" -ForegroundColor Green Write-Host "[OK] Success" -ForegroundColor Green
@@ -1042,7 +1174,7 @@ function Deploy-All {
} }
Write-Host ("=" * 70) -ForegroundColor Cyan 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

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