Files
roa2web-service-auto/deployment/windows/scripts/Install-ROA2WEB.ps1
Claude Agent 6718c956f7 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>
2026-01-28 19:04:26 +00:00

657 lines
24 KiB
PowerShell

<#
.SYNOPSIS
ROA2WEB - Initial Installation Script for Windows Server + IIS
.DESCRIPTION
This script performs complete installation of ROA2WEB on Windows Server:
- Checks prerequisites (Admin rights, IIS)
- Installs Python 3.11+ if needed
- Installs NSSM (service manager)
- Installs IIS URL Rewrite and ARR modules
- Creates directory structure
- Installs Python dependencies
- Creates Windows Service for backend
- Configures IIS website
- Starts all services
.PARAMETER InstallPath
Installation path (default: C:\inetpub\wwwroot\roa2web)
.PARAMETER PythonVersion
Python version to install (default: 3.11.9)
.PARAMETER ServicePort
Backend service port (default: 8000)
.PARAMETER SkipPython
Skip Python installation (use existing Python)
.PARAMETER SkipIIS
Skip IIS configuration
.EXAMPLE
.\Install-ROA2WEB.ps1
Standard installation with defaults
.EXAMPLE
.\Install-ROA2WEB.ps1 -InstallPath "D:\Apps\roa2web" -ServicePort 8001
Custom installation path and port
.NOTES
Author: ROA2WEB Team
Requires: PowerShell 5.1+, Administrator privileges
#>
[CmdletBinding()]
param(
[string]$InstallPath = "C:\inetpub\wwwroot\roa2web",
[string]$PythonVersion = "3.11.9",
[int]$ServicePort = 8000,
[string]$IISSiteName = "Default Web Site",
[string]$IISAppName = "roa2web",
[switch]$CreateNewSite,
[switch]$SkipPython,
[switch]$SkipIIS
)
# Strict error handling
$ErrorActionPreference = "Stop"
$ProgressPreference = "SilentlyContinue"
# =============================================================================
# CONFIGURATION
# =============================================================================
$script:Config = @{
AppName = "ROA2WEB"
ServiceName = "ROA2WEB-Backend"
ServiceDisplayName = "ROA2WEB Unified Backend Service"
ServiceDescription = "Unified FastAPI backend for ROA2WEB ERP - includes Reports, Data Entry, and Telegram modules (Ultrathin Monolith)"
InstallPath = $InstallPath
BackendPath = Join-Path $InstallPath "backend"
FrontendPath = Join-Path $InstallPath "frontend"
LogsPath = Join-Path $InstallPath "logs"
TempPath = Join-Path $InstallPath "temp"
# IMPORTANT: venv is OUTSIDE InstallPath to survive deployments!
VenvPath = "C:\inetpub\wwwroot\roa2web-venv"
PythonVersion = $PythonVersion
ServicePort = $ServicePort
IISSiteName = $IISSiteName
IISAppName = $IISAppName
IISAppPoolName = "ROA2WEB-AppPool"
CreateNewSite = $CreateNewSite
}
# =============================================================================
# HELPER FUNCTIONS
# =============================================================================
function Write-Step {
param([string]$Message)
Write-Host "`n[*] $Message" -ForegroundColor Cyan
}
function Write-Success {
param([string]$Message)
Write-Host " [OK] $Message" -ForegroundColor Green
}
function Write-Error {
param([string]$Message)
Write-Host " [ERROR] $Message" -ForegroundColor Red
}
function Write-Warning {
param([string]$Message)
Write-Host " [WARN] $Message" -ForegroundColor Yellow
}
function Test-Administrator {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [Security.Principal.WindowsPrincipal]$identity
return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}
function Test-CommandExists {
param([string]$Command)
try {
if (Get-Command $Command -ErrorAction Stop) {
return $true
}
} catch {
return $false
}
}
function Install-Chocolatey {
Write-Step "Installing Chocolatey package manager..."
if (Test-CommandExists "choco") {
Write-Success "Chocolatey already installed"
return
}
try {
Set-ExecutionPolicy Bypass -Scope Process -Force
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072
Invoke-Expression ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1'))
# Refresh environment
$env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User")
Write-Success "Chocolatey installed successfully"
} catch {
throw "Failed to install Chocolatey: $_"
}
}
function Install-Python {
Write-Step "Checking Python installation..."
if ($SkipPython) {
Write-Warning "Skipping Python installation (as requested)"
return
}
# Check if Python is already installed
try {
$pythonCmd = Get-Command python -ErrorAction Stop
$pythonVersionOutput = & python --version 2>&1
if ($pythonVersionOutput -match "Python (\d+\.\d+\.\d+)") {
$installedVersion = $matches[1]
Write-Success "Python $installedVersion already installed at $($pythonCmd.Source)"
return
}
} catch {
Write-Warning "Python not found, will install..."
}
# Install Python via Chocolatey
Write-Step "Installing Python $PythonVersion..."
try {
choco install python --version=$PythonVersion -y --force
# Refresh environment
$env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User")
Write-Success "Python $PythonVersion installed successfully"
} catch {
throw "Failed to install Python: $_"
}
}
function Install-NSSM {
Write-Step "Installing NSSM (service manager)..."
if (Test-Path "C:\nssm\nssm.exe") {
Write-Success "NSSM already installed"
return
}
try {
choco install nssm -y
Write-Success "NSSM installed successfully"
} catch {
throw "Failed to install NSSM: $_"
}
}
function Install-IISModules {
if ($SkipIIS) {
Write-Warning "Skipping IIS configuration (as requested)"
return
}
Write-Step "Checking IIS installation..."
# Detect OS type (Server vs Desktop)
$osInfo = Get-CimInstance -ClassName Win32_OperatingSystem
$isServer = $osInfo.ProductType -eq 3 # 1=Workstation, 2=Domain Controller, 3=Server
# Check if IIS is installed (different cmdlets for Server vs Desktop)
$iisInstalled = $false
if ($isServer) {
# Windows Server - use Get-WindowsFeature
$iisFeature = Get-WindowsFeature -Name Web-Server -ErrorAction SilentlyContinue
$iisInstalled = $iisFeature -and $iisFeature.InstallState -eq "Installed"
if (-not $iisInstalled) {
Write-Error "IIS is not installed. Please install IIS first:"
Write-Host " Install-WindowsFeature -Name Web-Server -IncludeManagementTools" -ForegroundColor Yellow
throw "IIS not installed"
}
} else {
# Windows Desktop (10/11) - use Get-WindowsOptionalFeature
$iisFeature = Get-WindowsOptionalFeature -Online -FeatureName IIS-WebServer -ErrorAction SilentlyContinue
$iisInstalled = $iisFeature -and $iisFeature.State -eq "Enabled"
if (-not $iisInstalled) {
Write-Error "IIS is not installed. Please install IIS first:"
Write-Host " Enable-WindowsOptionalFeature -Online -FeatureName IIS-WebServer -All" -ForegroundColor Yellow
Write-Host " Or use: Control Panel -> Programs -> Turn Windows features on/off -> Internet Information Services" -ForegroundColor Yellow
throw "IIS not installed"
}
}
Write-Success "IIS is installed ($($osInfo.Caption))"
# Install URL Rewrite Module
Write-Step "Installing IIS URL Rewrite Module..."
$urlRewriteInstalled = Get-WebConfiguration -Filter "/system.webServer/rewrite" -PSPath "IIS:\" -ErrorAction SilentlyContinue
if (-not $urlRewriteInstalled) {
Write-Warning "URL Rewrite not found, installing..."
try {
$urlRewriteUrl = "https://download.microsoft.com/download/1/2/8/128E2E22-C1B9-44A4-BE2A-5859ED1D4592/rewrite_amd64_en-US.msi"
$urlRewritePath = "$env:TEMP\rewrite_amd64.msi"
Invoke-WebRequest -Uri $urlRewriteUrl -OutFile $urlRewritePath
Start-Process msiexec.exe -ArgumentList "/i", $urlRewritePath, "/quiet", "/norestart" -Wait
Remove-Item $urlRewritePath -Force
Write-Success "URL Rewrite Module installed"
} catch {
Write-Error "Failed to install URL Rewrite: $_"
Write-Warning "You can download it manually from: https://www.iis.net/downloads/microsoft/url-rewrite"
}
} else {
Write-Success "URL Rewrite Module already installed"
}
# Install Application Request Routing (ARR)
Write-Step "Checking Application Request Routing (ARR)..."
try {
choco install iis-arr -y
Write-Success "ARR installed successfully"
} catch {
Write-Warning "Could not install ARR via Chocolatey. Download manually from: https://www.iis.net/downloads/microsoft/application-request-routing"
}
}
function New-DirectoryStructure {
Write-Step "Creating directory structure..."
$directories = @(
$Config.InstallPath,
$Config.BackendPath,
$Config.FrontendPath,
$Config.LogsPath,
$Config.TempPath,
(Join-Path $Config.BackendPath "logs"),
(Join-Path $Config.BackendPath "temp")
)
foreach ($dir in $directories) {
if (-not (Test-Path $dir)) {
New-Item -ItemType Directory -Path $dir -Force | Out-Null
Write-Success "Created: $dir"
} else {
Write-Success "Already exists: $dir"
}
}
# Set permissions (IIS user needs read access)
try {
$acl = Get-Acl $Config.InstallPath
$accessRule = New-Object System.Security.AccessControl.FileSystemAccessRule("IIS_IUSRS", "ReadAndExecute", "ContainerInherit,ObjectInherit", "None", "Allow")
$acl.SetAccessRule($accessRule)
Set-Acl -Path $Config.InstallPath -AclObject $acl
Write-Success "Permissions set for IIS_IUSRS"
} catch {
Write-Warning "Could not set permissions: $_"
}
}
function Install-PythonDependencies {
Write-Step "Setting up Python virtual environment..."
$requirementsPath = Join-Path $Config.BackendPath "requirements.txt"
$venvPath = $Config.VenvPath
$venvPython = Join-Path $venvPath "Scripts\python.exe"
$venvPip = Join-Path $venvPath "Scripts\pip.exe"
# Create venv if it doesn't exist
if (-not (Test-Path $venvPython)) {
Write-Step "Creating virtual environment at $venvPath..."
try {
& python -m venv $venvPath
Write-Success "Virtual environment created"
} catch {
throw "Failed to create virtual environment: $_"
}
} else {
Write-Success "Virtual environment already exists"
}
# Upgrade pip in venv
Write-Step "Upgrading pip in virtual environment..."
try {
& $venvPython -m pip install --upgrade pip
Write-Success "Pip upgraded"
} catch {
Write-Warning "Could not upgrade pip: $_"
}
# Install dependencies
if (-not (Test-Path $requirementsPath)) {
Write-Warning "requirements.txt not found at $requirementsPath"
Write-Warning "Please copy backend files first, then run this script again"
return
}
Write-Step "Installing Python dependencies in virtual environment..."
try {
& $venvPip install -r $requirementsPath
Write-Success "Python dependencies installed successfully in venv"
} catch {
throw "Failed to install Python dependencies: $_"
}
}
function New-WindowsService {
Write-Step "Creating Windows Service for backend..."
# Check if service already exists using nssm (more reliable than Get-Service)
# Temporarily disable error action to check service status
$oldErrorAction = $ErrorActionPreference
$ErrorActionPreference = "SilentlyContinue"
$nssmOutput = & nssm status $Config.ServiceName 2>&1
$serviceExists = $LASTEXITCODE -eq 0
$ErrorActionPreference = $oldErrorAction
if ($serviceExists) {
Write-Warning "Service already exists, stopping and removing..."
# Check service status
$status = & nssm status $Config.ServiceName 2>&1
# Only try to stop if service is running
if ($status -match "SERVICE_RUNNING") {
Write-Step "Stopping running service..."
& nssm stop $Config.ServiceName 2>&1 | Out-Null
Start-Sleep -Seconds 2
} else {
Write-Step "Service is not running (status: $status)"
}
# Force remove service
& nssm remove $Config.ServiceName confirm 2>&1 | Out-Null
Start-Sleep -Seconds 2
Write-Success "Existing service removed"
}
# 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."
}
# 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 {
$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
& nssm set $Config.ServiceName Description $Config.ServiceDescription
& nssm set $Config.ServiceName Start SERVICE_AUTO_START
& nssm set $Config.ServiceName AppDirectory $Config.BackendPath
# Set environment variables (PYTHONPATH for shared modules)
# Point to the installation root AND backend/ so both shared/ and app/ modules can be imported
$pythonPathRoot = $Config.InstallPath
$pythonPathBackend = $Config.BackendPath
& nssm set $Config.ServiceName AppEnvironmentExtra "PYTHONPATH=$pythonPathRoot;$pythonPathBackend"
# Set logging
$stdoutLog = Join-Path $Config.LogsPath "backend-stdout.log"
$stderrLog = Join-Path $Config.LogsPath "backend-stderr.log"
& nssm set $Config.ServiceName AppStdout $stdoutLog
& nssm set $Config.ServiceName AppStderr $stderrLog
& nssm set $Config.ServiceName AppStdoutCreationDisposition 4
& nssm set $Config.ServiceName AppStderrCreationDisposition 4
# Set restart policy
& nssm set $Config.ServiceName AppExit Default Restart
& nssm set $Config.ServiceName AppRestartDelay 5000
Write-Success "Windows Service created successfully (using wrapper script)"
Write-Success " Wrapper: $wrapperScript"
} catch {
throw "Failed to create Windows Service: $_"
}
}
function Initialize-IISWebsite {
if ($SkipIIS) {
Write-Warning "Skipping IIS website configuration (as requested)"
return
}
Write-Step "Configuring IIS application..."
Import-Module WebAdministration -ErrorAction Stop
# Remove existing app pool if present
if (Test-Path "IIS:\AppPools\$($Config.IISAppPoolName)") {
Write-Warning "Removing existing app pool..."
Remove-WebAppPool -Name $Config.IISAppPoolName -ErrorAction SilentlyContinue
}
# Create Application Pool
Write-Step "Creating IIS Application Pool..."
New-WebAppPool -Name $Config.IISAppPoolName -Force | Out-Null
Set-ItemProperty -Path "IIS:\AppPools\$($Config.IISAppPoolName)" -Name "managedRuntimeVersion" -Value ""
Write-Success "Application Pool created: $($Config.IISAppPoolName)"
if ($CreateNewSite) {
# Create new website (old behavior)
Write-Step "Creating new IIS Website..."
# Stop default website if running
try {
Stop-Website -Name "Default Web Site" -ErrorAction SilentlyContinue
Write-Success "Stopped Default Web Site"
} catch {
Write-Warning "Could not stop Default Web Site: $_"
}
# Remove existing site if present
if (Get-Website -Name $Config.IISSiteName -ErrorAction SilentlyContinue) {
Write-Warning "Removing existing website..."
Remove-Website -Name $Config.IISSiteName -ErrorAction SilentlyContinue
}
New-Website -Name $Config.IISSiteName `
-PhysicalPath $Config.FrontendPath `
-ApplicationPool $Config.IISAppPoolName `
-Port 80 `
-Force | Out-Null
Write-Success "Website created: $($Config.IISSiteName)"
# Start website
Start-Website -Name $Config.IISSiteName
Write-Success "Website started: $($Config.IISSiteName)"
} else {
# Create application under existing site (default behavior)
Write-Step "Creating IIS Application under '$($Config.IISSiteName)'..."
# Verify parent site exists
$parentSite = Get-Website -Name $Config.IISSiteName -ErrorAction SilentlyContinue
if (-not $parentSite) {
throw "Parent website '$($Config.IISSiteName)' does not exist. Use -CreateNewSite to create a new site."
}
# Remove existing application if present
$existingApp = Get-WebApplication -Name $Config.IISAppName -Site $Config.IISSiteName -ErrorAction SilentlyContinue
if ($existingApp) {
Write-Warning "Removing existing application..."
Remove-WebApplication -Name $Config.IISAppName -Site $Config.IISSiteName -ErrorAction SilentlyContinue
}
# Create application
New-WebApplication -Name $Config.IISAppName `
-Site $Config.IISSiteName `
-PhysicalPath $Config.FrontendPath `
-ApplicationPool $Config.IISAppPoolName `
-Force | Out-Null
Write-Success "Application created: /$($Config.IISAppName) under $($Config.IISSiteName)"
}
# Copy web.config to frontend path
$webConfigSource = Join-Path $PSScriptRoot "..\config\web.config"
$webConfigDest = Join-Path $Config.FrontendPath "web.config"
if (Test-Path $webConfigSource) {
Copy-Item -Path $webConfigSource -Destination $webConfigDest -Force
Write-Success "web.config copied to frontend path"
} else {
Write-Warning "web.config not found at $webConfigSource"
}
}
function Start-Services {
Write-Step "Starting services..."
# Start backend service
try {
Start-Service -Name $Config.ServiceName
Start-Sleep -Seconds 3
$service = Get-Service -Name $Config.ServiceName
if ($service.Status -eq "Running") {
Write-Success "Backend service started successfully"
} else {
Write-Error "Backend service failed to start (Status: $($service.Status))"
}
} catch {
Write-Error "Failed to start backend service: $_"
}
# Test backend health
Write-Step "Testing backend health..."
Start-Sleep -Seconds 5
try {
$response = Invoke-WebRequest -Uri "http://localhost:$($Config.ServicePort)/health" -UseBasicParsing -TimeoutSec 10
if ($response.StatusCode -eq 200) {
Write-Success "Backend health check passed"
}
} catch {
Write-Warning "Backend health check failed (may need time to start): $_"
}
}
function Show-Summary {
Write-Host "`n" + ("=" * 80) -ForegroundColor Cyan
Write-Host " ROA2WEB INSTALLATION COMPLETED" -ForegroundColor Green
Write-Host ("=" * 80) -ForegroundColor Cyan
Write-Host "`nInstallation Details:" -ForegroundColor Yellow
Write-Host " Install Path: $($Config.InstallPath)"
Write-Host " Backend Path: $($Config.BackendPath)"
Write-Host " Virtual Env: $($Config.VenvPath)"
Write-Host " Frontend Path: $($Config.FrontendPath)"
Write-Host " Service Name: $($Config.ServiceName)"
Write-Host " Service Port: $($Config.ServicePort)"
Write-Host " IIS Site: $($Config.IISSiteName)"
Write-Host "`nAccess Points:" -ForegroundColor Yellow
if ($Config.CreateNewSite) {
Write-Host " Web Application: http://localhost"
} else {
Write-Host " Web Application: http://localhost/$($Config.IISAppName)"
}
Write-Host " API Backend: http://localhost:$($Config.ServicePort)"
Write-Host " API Docs: http://localhost:$($Config.ServicePort)/docs"
Write-Host " Health Check: http://localhost:$($Config.ServicePort)/health"
Write-Host "`nNext Steps:" -ForegroundColor Yellow
Write-Host " 1. Copy backend files to: $($Config.BackendPath)"
Write-Host " 2. Copy frontend files to: $($Config.FrontendPath)"
Write-Host " 3. Configure .env file at: $($Config.BackendPath)\.env"
Write-Host ""
Write-Host " IMPORTANT - Module Control Flags in .env:" -ForegroundColor Cyan
Write-Host " MODULE_REPORTS_ENABLED=true # Enable/disable Reports module"
Write-Host " MODULE_DATA_ENTRY_ENABLED=true # Enable/disable Data Entry module"
Write-Host " MODULE_TELEGRAM_ENABLED=true # Enable/disable Telegram bot module"
Write-Host ""
Write-Host " 4. Start service: Start-Service $($Config.ServiceName)"
Write-Host "`nManagement Commands:" -ForegroundColor Yellow
Write-Host " Start Service: Start-Service $($Config.ServiceName)"
Write-Host " Stop Service: Stop-Service $($Config.ServiceName)"
Write-Host " Restart Service: Restart-Service $($Config.ServiceName)"
Write-Host " View Logs: Get-Content $($Config.LogsPath)\backend-stdout.log -Tail 50"
Write-Host " Check Status: Get-Service $($Config.ServiceName)"
Write-Host "`nArchitecture:" -ForegroundColor Yellow
Write-Host " ULTRATHIN MONOLITH - Single Windows service with multiple modules"
Write-Host " All modules share Oracle pool, auth, and cache"
Write-Host " Telegram bot runs as background task (not separate service)"
Write-Host "`n" + ("=" * 80) -ForegroundColor Cyan
}
# =============================================================================
# MAIN INSTALLATION FLOW
# =============================================================================
function Main {
Write-Host @"
====================================================================
ROA2WEB - Windows Server Installation Script
Modern ERP Reports Application with FastAPI + Vue.js + IIS
====================================================================
"@ -ForegroundColor Cyan
# Check prerequisites
Write-Step "Checking prerequisites..."
if (-not (Test-Administrator)) {
Write-Error "This script must be run as Administrator"
Write-Host " Right-click PowerShell and select 'Run as Administrator'" -ForegroundColor Yellow
exit 1
}
Write-Success "Running as Administrator"
try {
# Installation steps
Install-Chocolatey
Install-Python
Install-NSSM
Install-IISModules
New-DirectoryStructure
Install-PythonDependencies
New-WindowsService
Initialize-IISWebsite
Start-Services
Show-Summary
Write-Host "`nInstallation completed successfully!" -ForegroundColor Green
} catch {
Write-Host "`n[FATAL ERROR] Installation failed: $_" -ForegroundColor Red
Write-Host $_.ScriptStackTrace -ForegroundColor Red
exit 1
}
}
# Run main installation
Main