From 6718c956f70712714e14017c83f4e54bd3ed7cfd Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Wed, 28 Jan 2026 19:04:26 +0000 Subject: [PATCH] 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 --- README.md | 70 +++ backend/main.py | 61 ++- backend/shared/ssh_tunnel_manager.py | 350 ++++++++++++ deployment/linux/deploy.sh | 2 + .../windows/scripts/Install-ROA2WEB.ps1 | 22 +- .../windows/scripts/ROA2WEB-Console.ps1 | 162 +++++- deployment/windows/scripts/ssh-tunnel.ps1 | 509 ++++++++++++++++++ .../windows/scripts/start-backend-service.ps1 | 225 ++++++++ start.ps1 | 391 ++++++++++++++ 9 files changed, 1766 insertions(+), 26 deletions(-) create mode 100644 backend/shared/ssh_tunnel_manager.py create mode 100644 deployment/windows/scripts/ssh-tunnel.ps1 create mode 100644 deployment/windows/scripts/start-backend-service.ps1 create mode 100644 start.ps1 diff --git a/README.md b/README.md index b1dd713..04d7a2a 100644 --- a/README.md +++ b/README.md @@ -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 Copy `.env.example` to `.env` in each microservice and configure: diff --git a/backend/main.py b/backend/main.py index 812fbb3..51e5f3c 100644 --- a/backend/main.py +++ b/backend/main.py @@ -73,6 +73,7 @@ telegram_bot_task = None ocr_job_worker_running = False cleanup_task_running = False email_cache_running = False +ssh_tunnel_monitoring = False # ============================================================================ @@ -265,6 +266,40 @@ async def init_email_server_cache(): 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(): """Run Telegram bot as background task.""" logger.info("[TELEGRAM] Starting bot...") @@ -381,7 +416,10 @@ async def startup_event(): # Step 5: Initialize email-server cache for multi-Oracle (US-003) 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: telegram_bot_task = asyncio.create_task(run_telegram_bot()) logger.info("[STARTUP] ✅ Telegram bot task created") @@ -401,13 +439,24 @@ async def startup_event(): @app.on_event("shutdown") async def shutdown_event(): """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("[SHUTDOWN] Stopping ROA2WEB Unified Backend...") logger.info("=" * 80) 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) if email_cache_running: logger.info("[SHUTDOWN] Stopping email cache auto-refresh...") @@ -609,6 +658,14 @@ async def health_check(): except Exception as 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 diff --git a/backend/shared/ssh_tunnel_manager.py b/backend/shared/ssh_tunnel_manager.py new file mode 100644 index 0000000..b1bc293 --- /dev/null +++ b/backend/shared/ssh_tunnel_manager.py @@ -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() diff --git a/deployment/linux/deploy.sh b/deployment/linux/deploy.sh index ea2b073..3755175 100755 --- a/deployment/linux/deploy.sh +++ b/deployment/linux/deploy.sh @@ -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 diff --git a/deployment/windows/scripts/Install-ROA2WEB.ps1 b/deployment/windows/scripts/Install-ROA2WEB.ps1 index 8fa0d1a..0af621d 100644 --- a/deployment/windows/scripts/Install-ROA2WEB.ps1 +++ b/deployment/windows/scripts/Install-ROA2WEB.ps1 @@ -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: $_" } diff --git a/deployment/windows/scripts/ROA2WEB-Console.ps1 b/deployment/windows/scripts/ROA2WEB-Console.ps1 index 014ddbd..d14c39a 100644 --- a/deployment/windows/scripts/ROA2WEB-Console.ps1 +++ b/deployment/windows/scripts/ROA2WEB-Console.ps1 @@ -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) } # ============================================================================= diff --git a/deployment/windows/scripts/ssh-tunnel.ps1 b/deployment/windows/scripts/ssh-tunnel.ps1 new file mode 100644 index 0000000..fa63726 --- /dev/null +++ b/deployment/windows/scripts/ssh-tunnel.ps1 @@ -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 " + 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 } +} diff --git a/deployment/windows/scripts/start-backend-service.ps1 b/deployment/windows/scripts/start-backend-service.ps1 new file mode 100644 index 0000000..10028b9 --- /dev/null +++ b/deployment/windows/scripts/start-backend-service.ps1 @@ -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 diff --git a/start.ps1 b/start.ps1 new file mode 100644 index 0000000..ca9f47b --- /dev/null +++ b/start.ps1 @@ -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 } +}