Start-AzureLocalServer.ps1
|
#!/usr/bin/env pwsh <#PSScriptInfo .VERSION 1.2.0 .GUID b4e8c3d2-5f9a-4e7b-8c1d-2f3e4a5b6c7d .AUTHOR Alex ter Neuzen .COMPANYNAME GetToTheCloud .COPYRIGHT (c) 2025 Alex ter Neuzen. All rights reserved. .TAGS Azure AzureLocal AzureStackHCI WebServer Dashboard .LICENSEURI https://github.com/GetToThe-Cloud/documenter-azure-local/blob/main/LICENSE .PROJECTURI https://github.com/GetToThe-Cloud/documenter-azure-local .ICONURI .EXTERNALMODULEDEPENDENCIES Az.Accounts .REQUIREDSCRIPTS Get-AzureLocalInventory.ps1 .EXTERNALSCRIPTDEPENDENCIES .RELEASENOTES v1.2.0 - Security hardening: removed wildcard CORS, Host/Origin validation, consent-based module install, POST-only login endpoint v1.1.135 - Changed filtering for OSSku to look for "Azure Stack HCI" v1.1.124 - Cross-subscription scanning, executive PDF export support v1.0.0 - Initial release .PRIVATEDATA #> <# .SYNOPSIS Azure Local Inventory Web Server .DESCRIPTION Starts a web server that provides a live view of Azure Local (Azure Stack HCI) inventory with authentication, navigation, and PDF export capabilities. .PARAMETER Port Port number for the web server (default: 8081) #> param( [int]$Port = 8081 ) # Check PowerShell version requirement $minimumVersion = [version]"7.0.0" $currentVersion = $PSVersionTable.PSVersion if ($currentVersion -lt $minimumVersion) { Write-Error "PowerShell 7.0 or higher is required. Current version: $currentVersion" Write-Host "Please install PowerShell 7 from: https://aka.ms/powershell" -ForegroundColor Yellow exit 1 } # Import required modules $ErrorActionPreference = "Stop" Write-Host "🚀 Starting Azure Local Inventory Server..." -ForegroundColor Cyan Write-Host "✓ PowerShell Version: $currentVersion" -ForegroundColor Green # Check and import Azure modules $requiredModules = @( 'Az.Accounts', 'Az.Resources', 'Az.StackHCI', 'Az.ConnectedMachine' ) foreach ($module in $requiredModules) { if (-not (Get-Module -ListAvailable -Name $module)) { Write-Host "⚠️ Module $module not found." -ForegroundColor Yellow $consent = Read-Host "Install $module from the PowerShell Gallery (CurrentUser scope)? [y/N]" if ($consent -notmatch '^[Yy]') { Write-Error "Required module $module is not installed. Install it manually with: Install-Module -Name $module -Scope CurrentUser" exit 1 } Install-Module -Name $module -Repository PSGallery -Scope CurrentUser -Force } Import-Module $module -ErrorAction SilentlyContinue } # Import inventory collection module $inventoryModulePath = Join-Path $PSScriptRoot "Get-AzureLocalInventory.ps1" . $inventoryModulePath # Check Azure connection function Test-AzureConnection { try { $context = Get-AzContext if ($null -eq $context) { return $false } return $true } catch { return $false } } # Global state $script:IsAuthenticated = Test-AzureConnection $script:InventoryData = @{} $script:LastUpdate = $null $script:WafConfig = $null # Load WAF Configuration $wafConfigPath = Join-Path $PSScriptRoot "waf-config.json" if (Test-Path $wafConfigPath) { try { $script:WafConfig = Get-Content $wafConfigPath -Raw | ConvertFrom-Json Write-Host "✓ WAF Configuration loaded (Version: $($script:WafConfig.version))" -ForegroundColor Green } catch { Write-Host "⚠️ Failed to load WAF configuration: $($_.Exception.Message)" -ForegroundColor Yellow } } else { Write-Host "⚠️ WAF configuration file not found at: $wafConfigPath" -ForegroundColor Yellow } Write-Host "🔐 Azure Authentication Status: $(if ($script:IsAuthenticated) { 'Connected ✓' } else { 'Not Connected ✗' })" -ForegroundColor $(if ($script:IsAuthenticated) { 'Green' } else { 'Yellow' }) # HTTP Listener $listener = New-Object System.Net.HttpListener $listener.Prefixes.Add("http://localhost:$Port/") $listener.Start() Write-Host "🌐 Server started at http://localhost:$Port" -ForegroundColor Green Write-Host "📊 Access the Azure Local Inventory Dashboard in your browser" -ForegroundColor Cyan Write-Host "Press Ctrl+C to stop the server" -ForegroundColor Gray Write-Host "" try { while ($listener.IsListening) { $context = $listener.GetContext() $request = $context.Request $response = $context.Response $path = $request.Url.AbsolutePath $method = $request.HttpMethod Write-Host "$(Get-Date -Format 'HH:mm:ss') $method $path" -ForegroundColor Gray # Reject requests not addressed to localhost (DNS-rebinding defense) # and cross-origin requests from other websites (CSRF defense). $allowedHosts = @('localhost', '127.0.0.1', '::1', '[::1]') $originHeader = $request.Headers['Origin'] $originAllowed = $true if ($originHeader -and $originHeader -ne 'null') { try { $originUri = [Uri]$originHeader $originAllowed = ($allowedHosts -contains $originUri.Host) -and ($originUri.Port -eq $Port) } catch { $originAllowed = $false } } if (($allowedHosts -notcontains $request.Url.Host) -or (-not $originAllowed)) { Write-Host " ⛔ Rejected request (host: $($request.Url.Host), origin: $originHeader)" -ForegroundColor Red $response.StatusCode = 403 $forbidden = [System.Text.Encoding]::UTF8.GetBytes('{"error":"Forbidden"}') $response.ContentType = 'application/json' $response.ContentLength64 = $forbidden.Length $response.OutputStream.Write($forbidden, 0, $forbidden.Length) $response.Close() continue } # Content type and response $content = "" $contentType = "text/html; charset=utf-8" # Route handling switch -Regex ($path) { '^/$' { # Serve main page $indexPath = Join-Path $PSScriptRoot "index.html" if (Test-Path $indexPath) { $content = Get-Content $indexPath -Raw } else { $content = "<html><body><h1>Azure Local Inventory Server</h1><p>index.html not found</p></body></html>" } } '^/styles\.css$' { $cssPath = Join-Path $PSScriptRoot "styles.css" if (Test-Path $cssPath) { $content = Get-Content $cssPath -Raw $contentType = "text/css" } } '^/app\.js$' { $jsPath = Join-Path $PSScriptRoot "app.js" if (Test-Path $jsPath) { $content = Get-Content $jsPath -Raw $contentType = "application/javascript" } } '^/api/auth/status$' { $script:IsAuthenticated = Test-AzureConnection $authStatus = @{ authenticated = $script:IsAuthenticated context = if ($script:IsAuthenticated) { $ctx = Get-AzContext @{ account = $ctx.Account.Id subscription = $ctx.Subscription.Name tenant = $ctx.Tenant.Id } } else { $null } } $content = $authStatus | ConvertTo-Json $contentType = "application/json" } '^/api/auth/login$' { if ($method -ne 'POST') { $response.StatusCode = 405 $content = @{ success = $false; message = "Method not allowed" } | ConvertTo-Json } else { try { Connect-AzAccount -UseDeviceAuthentication | Out-Null $script:IsAuthenticated = $true $content = @{ success = $true; message = "Authentication successful" } | ConvertTo-Json } catch { $content = @{ success = $false; message = $_.Exception.Message } | ConvertTo-Json } } $contentType = "application/json" } '^/api/waf/config$' { # Serve WAF configuration try { if ($null -ne $script:WafConfig) { Write-Host " 📋 Serving WAF configuration" -ForegroundColor Cyan $content = $script:WafConfig | ConvertTo-Json -Depth 20 -Compress:$false Write-Host " ✅ WAF config sent ($(($content.Length / 1KB).ToString('N2')) KB)" -ForegroundColor Green } else { Write-Host " ⚠️ WAF configuration not available" -ForegroundColor Yellow $response.StatusCode = 503 $content = @{ error = "WAF configuration not loaded" } | ConvertTo-Json } } catch { Write-Host " ❌ Error serving WAF config: $($_.Exception.Message)" -ForegroundColor Red $response.StatusCode = 500 $content = @{ error = $_.Exception.Message } | ConvertTo-Json } $contentType = "application/json" } '^/api/inventory/progress$' { # Return current inventory collection progress try { $progress = Get-CollectionProgress $content = $progress | ConvertTo-Json } catch { $response.StatusCode = 500 $content = @{ message = "Initializing..." percent = 0 lastUpdate = (Get-Date).ToString('o') error = $_.Exception.Message } | ConvertTo-Json } $contentType = "application/json" } '^/api/inventory/data$' { if ($script:IsAuthenticated) { try { Write-Host " 📊 Collecting Azure Local inventory..." -ForegroundColor Cyan # Reset progress before starting Update-CollectionProgress "Starting inventory collection..." 0 $script:InventoryData = Get-AzureLocalInventory $script:LastUpdate = Get-Date $content = $script:InventoryData | ConvertTo-Json -Depth 10 } catch { $content = @{ error = $_.Exception.Message } | ConvertTo-Json } } else { $response.StatusCode = 401 $content = @{ error = "Not authenticated" } | ConvertTo-Json } $contentType = "application/json" } '^/api/inventory/refresh$' { if ($script:IsAuthenticated) { try { Write-Host " 🔄 Refreshing inventory..." -ForegroundColor Cyan # Reset progress before starting Update-CollectionProgress "Starting inventory refresh..." 0 $script:InventoryData = Get-AzureLocalInventory $script:LastUpdate = Get-Date $content = @{ success = $true lastUpdate = $script:LastUpdate.ToString('o') } | ConvertTo-Json } catch { $content = @{ success = $false; error = $_.Exception.Message } | ConvertTo-Json } } else { $response.StatusCode = 401 $content = @{ success = $false; error = "Not authenticated" } | ConvertTo-Json } $contentType = "application/json" } default { $response.StatusCode = 404 $content = "<html><body><h1>404 - Not Found</h1></body></html>" } } # Send response (same-origin only; no CORS header on purpose) $buffer = [System.Text.Encoding]::UTF8.GetBytes($content) $response.ContentLength64 = $buffer.Length $response.ContentType = $contentType $response.OutputStream.Write($buffer, 0, $buffer.Length) $response.Close() } } finally { Write-Host "`n🛑 Stopping server..." -ForegroundColor Yellow $listener.Stop() $listener.Close() Write-Host "✓ Server stopped" -ForegroundColor Green } |