$ErrorActionPreference = 'Stop' $admin = 'http://localhost:2019' $id = 'drawing-model-box-sample-deployment' $public = 'http://10.14.12.22/box-sample/' $created = $false function Test-StaticFallback($routes) { if (@($routes).Count -eq 0) { return $false } foreach ($r in $routes) { if (@($r.handle).Count -eq 0) { return $false } foreach ($h in $r.handle) { if ($h.handler -eq 'static_response') { continue } if ($h.handler -eq 'subroute' -and (Test-StaticFallback $h.routes)) { continue } return $false } } return $true } try { $response = Invoke-WebRequest -UseBasicParsing "$admin/config/" $config = $response.Content | ConvertFrom-Json if (-not $response.Headers['ETag']) { throw 'Caddy configuration ETag is required.' } $servers = @($config.apps.http.servers.PSObject.Properties | Where-Object { @($_.Value.listen | Where-Object { $_ -match '(^|:)80$' }).Count -gt 0 }) if ($servers.Count -ne 1) { throw 'Cannot uniquely identify the existing HTTP port 80 server.' } $server = $servers[0] $routes = @($server.Value.routes) $own = @($routes | Where-Object { $_.'@id' -eq $id }) $route = @{ '@id' = $id match = @(@{host=@('10.14.12.22'); path=@('/box-sample','/box-sample/*')}) handle = @(@{handler='reverse_proxy'; upstreams=@(@{dial='127.0.0.1:5092'})}) terminal = $true } if ($own.Count -gt 1) { throw 'Duplicate deployment route.' } if ($own.Count -eq 1) { if (@($own[0].handle).Count -ne 1 -or $own[0].handle[0].handler -ne 'reverse_proxy' -or @($own[0].handle[0].upstreams).Count -ne 1 -or $own[0].handle[0].upstreams[0].dial -ne '127.0.0.1:5092' -or (@($own[0].match[0].path) -join ',') -ne '/box-sample,/box-sample/*' -or (@($own[0].match[0].host) -join ',') -ne '10.14.12.22' -or -not $own[0].terminal) { throw 'Existing deployment route differs; refusing to overwrite it.' } } else { # Preserve every middleware, maintenance and service route in its original # order. Insert only before a proven static-only final fallback. $insertIndex = $routes.Count - 1 if ($insertIndex -lt 0 -or $routes[$insertIndex].match -or -not (Test-StaticFallback @($routes[$insertIndex]))) { throw 'Expected a final unmatched static-only fallback; no settings changed.' } foreach ($existing in $routes) { $json = $existing | ConvertTo-Json -Depth 100 -Compress # Do not bypass a shared authentication policy or shadow an existing mount. if ($json -match 'authentication|http_basic|forward_auth|/box-sample') { throw 'Existing route/authentication needs administrator review; nothing was replaced.' } } $backup = Join-Path $PSScriptRoot ('caddy-before-box-sample-' + (Get-Date -Format 'yyyyMMdd-HHmmss-fff') + '.json') [IO.File]::WriteAllText($backup, $response.Content) $name = [Uri]::EscapeDataString($server.Name) if ($null -eq $server.Value.routes) { throw 'HTTP routes array missing; no automatic replacement allowed.' } $body = $route | ConvertTo-Json -Depth 30 -Compress Invoke-WebRequest -UseBasicParsing -Method Put -Uri "$admin/config/apps/http/servers/$name/routes/$insertIndex" -Headers @{'If-Match'=$response.Headers['ETag']} -ContentType 'application/json' -Body $body | Out-Null $created = $true } foreach ($path in @('', 'api/status', 'src/app.js')) { $check = Invoke-WebRequest -UseBasicParsing -Uri ($public+$path) -TimeoutSec 30 if ($check.StatusCode -ne 200 -or $check.Headers['X-DMS-Deployment'] -ne 'box-sample') { throw "Public route validation failed: $path" } if ($path -eq 'api/status' -and -not ($check.Content | ConvertFrom-Json).ok) { throw 'API status failed.' } if ($path -eq 'src/app.js' -and $check.Content -notmatch '/box-sample/api/status') { throw 'Frontend API prefix missing.' } } Write-Host "VERIFIED: $public (page, API, JavaScript)." Write-Host 'Caddy API changes are autosaved; reboot persistence requires Caddy to resume that configuration.' } catch { if ($created) { # Remove only this newly inserted route; never restore over unrelated changes. try { Invoke-WebRequest -UseBasicParsing -Method Delete -Uri "$admin/id/$id" | Out-Null } catch { Write-Warning 'Could not roll back this deployment route.' } } Write-Error $_ exit 1 }