From 19ec79781108e2b945223cdd0f48b6fdc0fd8b5d Mon Sep 17 00:00:00 2001 From: CoreweaverLabs <2.18304646e+08+CoreweaverLabs@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:39:04 +0000 Subject: [PATCH 1/4] ops: add guarded VPS page factory release kit --- .../workflows/vps-page-factory-release.yml | 54 +++++++++++++++++++ ops/vps/README.md | 40 ++++++++++++++ ops/vps/cutover-manifest.example.yml | 44 +++++++++++++++ ops/vps/scripts/preflight.mjs | 36 +++++++++++++ ops/vps/scripts/release.sh | 50 +++++++++++++++++ ops/vps/templates/Caddyfile | 17 ++++++ ops/vps/templates/arm-page-factory.service | 20 +++++++ 7 files changed, 261 insertions(+) create mode 100644 .github/workflows/vps-page-factory-release.yml create mode 100644 ops/vps/README.md create mode 100644 ops/vps/cutover-manifest.example.yml create mode 100644 ops/vps/scripts/preflight.mjs create mode 100755 ops/vps/scripts/release.sh create mode 100644 ops/vps/templates/Caddyfile create mode 100644 ops/vps/templates/arm-page-factory.service diff --git a/.github/workflows/vps-page-factory-release.yml b/.github/workflows/vps-page-factory-release.yml new file mode 100644 index 0000000..9813196 --- /dev/null +++ b/.github/workflows/vps-page-factory-release.yml @@ -0,0 +1,54 @@ +name: VPS page factory release + +on: + push: + branches: [production] + paths: + - "index.html" + - "robots.txt" + - "sitemap.xml" + - "llms.txt" + - "schema.json" + - "aure/**" + - "coreweaver-labs/**" + - "ops/vps/**" + - ".github/workflows/vps-page-factory-release.yml" + workflow_dispatch: + +permissions: + contents: read + +jobs: + release: + # Keep automatic deployment dormant until a named owner configures and approves + # an existing VPS target. The workflow does not create a server or edit DNS. + if: vars.VPS_RELEASES_ENABLED == 'true' + runs-on: ubuntu-latest + environment: vps-production + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - name: Validate static release artifact + run: node ops/vps/scripts/preflight.mjs + - name: Configure restricted deployment key + env: + ARM_VPS_DEPLOY_KEY: ${{ secrets.ARM_VPS_DEPLOY_KEY }} + VPS_HOST: ${{ vars.VPS_HOST }} + run: | + test -n "$ARM_VPS_DEPLOY_KEY" + test -n "$VPS_HOST" + install -d -m 700 ~/.ssh + printf '%s' "$ARM_VPS_DEPLOY_KEY" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + ssh-keyscan -H "$VPS_HOST" >> ~/.ssh/known_hosts + - name: Create immutable release + env: + CONFIRM_VPS_RELEASE: "yes" + VPS_HOST: ${{ vars.VPS_HOST }} + VPS_DEPLOY_USER: ${{ vars.VPS_DEPLOY_USER }} + VPS_PORT: ${{ vars.VPS_PORT }} + VPS_ROOT: ${{ vars.VPS_ROOT }} + RELEASE_ID: ${{ github.sha }} + run: bash ops/vps/scripts/release.sh diff --git a/ops/vps/README.md b/ops/vps/README.md new file mode 100644 index 0000000..0f0fac1 --- /dev/null +++ b/ops/vps/README.md @@ -0,0 +1,40 @@ +# VPS Page Factory + +This directory makes the static ARM repository releasable to an already-approved VPS without a dashboard login for each page change. It deliberately contains no provider credential, hostname, IP address, or live DNS record. + +The first server and public hostname remain an explicit infrastructure decision. After the server, deployment user, repository branch, DNS route, TLS issuance, rollback root, and GitHub deployment secrets are approved, ordinary source releases run the same preflight and immutable-release process. + +## Required first-host baseline + +| Area | Required before the first cutover | +| --- | --- | +| Server | Supported Linux host, non-root deployment user, SSH public-key access, provider snapshot, and owner for patching/incident response. | +| Network | Provider firewall and host firewall allow public TCP 80/443. Restrict SSH to known administrative networks; do not open database or administration ports publicly. | +| TLS | Install Caddy, associate the final hostname in `Caddyfile`, check the domain’s CAA policy, and verify certificate issuance before canonical traffic changes. | +| Storage | Create `${VPS_ROOT}/releases`, `${VPS_ROOT}/shared`, and the `current` symlink owned by the deploy user. Keep a retained release set plus a provider-level snapshot. | +| Observability | Keep Caddy access/error logs, configure an anonymous HTTPS health check, and name the person who receives a failed-check alert. | +| DNS | Preserve the current origin, record the exact existing DNS values, and obtain explicit approval of the proposed record diff and rollback step. | + +## Routine release + +The release script validates the static content, uploads a uniquely named artifact, switches the `current` symlink only after extraction succeeds, and retains the newest five releases. It exits without network action unless `CONFIRM_VPS_RELEASE=yes` is set by an approved deployment workflow. + +```bash +node ops/vps/scripts/preflight.mjs +CONFIRM_VPS_RELEASE=yes VPS_HOST=... VPS_DEPLOY_USER=... \ + VPS_ROOT=/var/www/arm-page-factory bash ops/vps/scripts/release.sh +``` + +Set the deployment workflow’s variables and deploy key only after the initial server ownership and hostname decisions are confirmed. Do not set `VPS_RELEASES_ENABLED=true` until the first manually verified release has passed. + +## Rollback + +List releases on the server, point `current` to the prior verified release, reload Caddy only if its configuration changed, then run the anonymous HTTPS and mobile checks. For a failed DNS cutover, restore the recorded prior DNS target rather than changing unrelated nameserver, MX, or verification records. + +## Resources + +- `scripts/preflight.mjs` validates the repository artifact before upload. +- `scripts/release.sh` creates a guarded immutable release and switches `current`. +- `templates/Caddyfile` serves the static site with compression and explicit cache behavior. +- `templates/arm-page-factory.service` is an optional server-side one-shot release service. +- `cutover-manifest.example.yml` captures the exact first-host approval and rollback record. diff --git a/ops/vps/cutover-manifest.example.yml b/ops/vps/cutover-manifest.example.yml new file mode 100644 index 0000000..4dc1c20 --- /dev/null +++ b/ops/vps/cutover-manifest.example.yml @@ -0,0 +1,44 @@ +# This file is a non-executable approval record. Do not replace placeholders or +# apply infrastructure changes until the owner approves the exact values. +target: + provider: __APPROVED_VPS_PROVIDER__ + server_id: __APPROVED_SERVER_ID__ + ipv4: __APPROVED_IPV4__ + hostname: __APPROVED_PUBLIC_HOSTNAME__ + canonical: false +access: + server_owner: __NAME__ + deployment_owner: __NAME__ + incident_owner: __NAME__ + ssh_admin_source_ranges: [] +release: + repository: CoreweaverLabs/autonomousresourcemanagement + source_branch: production + deployment_user: armdeploy + vps_root: /var/www/arm-page-factory + retained_releases: 5 +security: + public_ports: [80, 443] + tls_terminator: Caddy + provider_snapshot_verified: false + host_firewall_verified: false + certificate_verified: false +observability: + healthcheck_url: https://__APPROVED_PUBLIC_HOSTNAME__/ + alert_owner: __NAME__ + access_log_path: /var/log/caddy/arm-page-factory-access.log +dns_cutover: + previous_records: [] + proposed_records: [] + caa_records_checked: false + explicit_confirmation_received: false +rollback: + previous_origin: __CURRENT_PUBLIC_ORIGIN__ + previous_dns_records: [] + prior_release_id: __RELEASE_ID__ + validation_steps: + - anonymous HTTPS response + - expected canonical redirect + - certificate chain + - homepage mobile check + - sitemap and robots response diff --git a/ops/vps/scripts/preflight.mjs b/ops/vps/scripts/preflight.mjs new file mode 100644 index 0000000..4e60b00 --- /dev/null +++ b/ops/vps/scripts/preflight.mjs @@ -0,0 +1,36 @@ +import { existsSync, readFileSync, statSync } from "node:fs"; +import { resolve } from "node:path"; + +const root = resolve(process.cwd()); +const requiredFiles = ["index.html", "robots.txt", "sitemap.xml", "llms.txt", "schema.json"]; +const errors = []; + +for (const file of requiredFiles) { + const path = resolve(root, file); + if (!existsSync(path) || !statSync(path).isFile()) { + errors.push(`Missing required static artifact: ${file}`); + } +} + +if (errors.length === 0) { + const index = readFileSync(resolve(root, "index.html"), "utf8"); + const robots = readFileSync(resolve(root, "robots.txt"), "utf8"); + const sitemap = readFileSync(resolve(root, "sitemap.xml"), "utf8"); + const schema = readFileSync(resolve(root, "schema.json"), "utf8"); + + if (!index.includes('application/ld+json')) errors.push("index.html has no inline JSON-LD block"); + if (!robots.match(/^Sitemap:\s*https?:\/\//im)) errors.push("robots.txt has no absolute Sitemap directive"); + if (!sitemap.includes(" 0) { + console.error("VPS page-factory preflight failed:"); + for (const error of errors) console.error(`- ${error}`); + process.exit(1); +} + +console.log(`VPS page-factory preflight passed for ${root}`); diff --git a/ops/vps/scripts/release.sh b/ops/vps/scripts/release.sh new file mode 100755 index 0000000..e8945dc --- /dev/null +++ b/ops/vps/scripts/release.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ "${CONFIRM_VPS_RELEASE:-}" != "yes" ]]; then + echo "Refusing VPS release: set CONFIRM_VPS_RELEASE=yes in an approved release workflow." >&2 + exit 64 +fi + +: "${VPS_HOST:?VPS_HOST is required}" +: "${VPS_DEPLOY_USER:?VPS_DEPLOY_USER is required}" +: "${VPS_ROOT:?VPS_ROOT is required}" + +VPS_PORT="${VPS_PORT:-22}" +RELEASE_ID="${RELEASE_ID:-$(date -u +%Y%m%dT%H%M%SZ)}" +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +REMOTE="${VPS_DEPLOY_USER}@${VPS_HOST}" + +if [[ ! "$VPS_PORT" =~ ^[0-9]+$ ]]; then + echo "VPS_PORT must be numeric." >&2 + exit 64 +fi +if [[ ! "$VPS_ROOT" =~ ^/[A-Za-z0-9._/-]+$ ]]; then + echo "VPS_ROOT must be an absolute, space-free path." >&2 + exit 64 +fi +if [[ ! "$RELEASE_ID" =~ ^[A-Za-z0-9._-]+$ ]]; then + echo "RELEASE_ID contains unsupported characters." >&2 + exit 64 +fi + +cd "$REPO_ROOT" +node ops/vps/scripts/preflight.mjs + +ssh -p "$VPS_PORT" "$REMOTE" "install -d -m 0755 '${VPS_ROOT}/releases/${RELEASE_ID}' '${VPS_ROOT}/shared'" + +tar \ + --exclude='.git' \ + --exclude='ops/vps' \ + --exclude='.github' \ + -czf - \ + CNAME README.md aure coreweaver-labs googlef40b1c9053cf9205.html index.html llms.txt robots.txt schema.json sitemap.xml \ + | ssh -p "$VPS_PORT" "$REMOTE" "tar -xzf - -C '${VPS_ROOT}/releases/${RELEASE_ID}'" + +ssh -p "$VPS_PORT" "$REMOTE" " + test -f '${VPS_ROOT}/releases/${RELEASE_ID}/index.html' && + ln -sfn '${VPS_ROOT}/releases/${RELEASE_ID}' '${VPS_ROOT}/current' && + find '${VPS_ROOT}/releases' -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort -r | tail -n +6 | xargs -r -I{} rm -rf '${VPS_ROOT}/releases/{}' +" + +echo "Released ${RELEASE_ID} to ${REMOTE}:${VPS_ROOT}/current" diff --git a/ops/vps/templates/Caddyfile b/ops/vps/templates/Caddyfile new file mode 100644 index 0000000..dfbcc3a --- /dev/null +++ b/ops/vps/templates/Caddyfile @@ -0,0 +1,17 @@ +# Replace __ARM_HOSTNAME__ only in the approved server configuration. +# Caddy obtains and renews TLS after the hostname resolves to this server. +__ARM_HOSTNAME__ { + root * /var/www/arm-page-factory/current + encode zstd gzip + file_server + + @html path / /index.html /robots.txt /sitemap.xml /llms.txt /schema.json + header @html Cache-Control "public, max-age=300, stale-while-revalidate=60" + + @static path *.css *.js *.svg *.png *.jpg *.jpeg *.webp *.ico *.woff *.woff2 + header @static Cache-Control "public, max-age=31536000, immutable" + + log { + output file /var/log/caddy/arm-page-factory-access.log + } +} diff --git a/ops/vps/templates/arm-page-factory.service b/ops/vps/templates/arm-page-factory.service new file mode 100644 index 0000000..9341972 --- /dev/null +++ b/ops/vps/templates/arm-page-factory.service @@ -0,0 +1,20 @@ +[Unit] +Description=ARM Page Factory local release runner +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +User=armdeploy +Group=armdeploy +WorkingDirectory=/srv/arm-page-factory +EnvironmentFile=/etc/arm-page-factory/release.env +ExecStart=/srv/arm-page-factory/ops/vps/scripts/release.sh +NoNewPrivileges=yes +PrivateTmp=yes +ProtectSystem=strict +ProtectHome=yes +ReadWritePaths=/var/www/arm-page-factory + +# This is an optional one-shot service. Do not enable a timer until its source, +# release authority, host ownership, and rollback procedure are approved. From 43bac8d0f22d965e32f163a09d54fb23e68da40c Mon Sep 17 00:00:00 2001 From: CoreweaverLabs <2.18304646e+08+CoreweaverLabs@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:52:30 +0000 Subject: [PATCH 2/4] ops: add VPS production acceptance controls --- ops/vps/README.md | 4 +++ ops/vps/production-acceptance.md | 26 +++++++++++++++++++ ops/vps/scripts/healthcheck.sh | 21 +++++++++++++++ ops/vps/templates/Caddyfile | 12 +++++++++ .../arm-page-factory-healthcheck.service | 15 +++++++++++ .../arm-page-factory-healthcheck.timer | 11 ++++++++ 6 files changed, 89 insertions(+) create mode 100644 ops/vps/production-acceptance.md create mode 100755 ops/vps/scripts/healthcheck.sh create mode 100644 ops/vps/templates/arm-page-factory-healthcheck.service create mode 100644 ops/vps/templates/arm-page-factory-healthcheck.timer diff --git a/ops/vps/README.md b/ops/vps/README.md index 0f0fac1..6a6c107 100644 --- a/ops/vps/README.md +++ b/ops/vps/README.md @@ -31,6 +31,10 @@ Set the deployment workflow’s variables and deploy key only after the initial List releases on the server, point `current` to the prior verified release, reload Caddy only if its configuration changed, then run the anonymous HTTPS and mobile checks. For a failed DNS cutover, restore the recorded prior DNS target rather than changing unrelated nameserver, MX, or verification records. +## Production acceptance + +Read [`production-acceptance.md`](production-acceptance.md) before moving any hostname from preview to canonical production. A production release needs an approved release manifest, an anonymous HTTPS check, a desktop and mobile render check, a working health-check timer, a verified retained release, and a named rollback owner. Passing source preflight alone is not production readiness. + ## Resources - `scripts/preflight.mjs` validates the repository artifact before upload. diff --git a/ops/vps/production-acceptance.md b/ops/vps/production-acceptance.md new file mode 100644 index 0000000..21c169e --- /dev/null +++ b/ops/vps/production-acceptance.md @@ -0,0 +1,26 @@ +# VPS Page Factory — Production Acceptance + +## Release candidate checklist + +| Area | Acceptance check | Evidence to record | Owner | +| --- | --- | --- | --- | +| Content and route | Page carries a specific audience decision, a claim map, source links, metadata, canonical route, and an appropriate ARM/context link. | Release packet and committed source revision. | Content owner. | +| Static artifact | `node ops/vps/scripts/preflight.mjs` passes and the deployment archive contains only the intended public files. | CI log and release identifier. | Release owner. | +| Access and TLS | Anonymous HTTPS returns a valid success response for root and each intended route; the certificate name, redirect policy, and CAA conditions are checked. | Timestamped response and certificate check. | Infrastructure owner. | +| Security | Provider and host firewalls expose only approved public ports; SSH access is key-based and restricted; Caddy headers and served-path exclusions are in place. | Firewall rule record and configuration revision. | Infrastructure owner. | +| Visual and mobile | The page is inspected at desktop and a 375px mobile viewport; no primary control, text block, or route is hidden or horizontally clipped. | Screenshots and issue disposition. | Release owner. | +| Observability | The HTTPS health-check service and timer are active, logs are retained, and a named person receives failed-check notification. | Timer status and alert test record. | Operations owner. | +| Rollback | A prior release is retained; the current symlink target and prior DNS/origin record are documented; the rollback command is rehearsed in preview. | Release list, manifest, and rehearsal record. | Release and infrastructure owners. | + +## Cutover order + +1. Verify the candidate on the VPS using a noncanonical preview hostname. +2. Capture the current DNS and origin state in the approved cutover manifest. +3. Confirm the exact record change, TTL, canonical redirect behavior, certificate readiness, and rollback owner. +4. Apply the approved DNS change once; do not alter nameservers or unrelated records. +5. Check anonymous HTTPS, route responses, certificate chain, desktop/mobile rendering, health-check timer, logs, and canonical behavior. +6. If any required check fails, restore the recorded prior DNS/origin before debugging further. + +## Routine release condition + +After a successful first release and rollback rehearsal, routine releases can be automated only for an approved branch and hostname. The workflow remains prohibited from purchasing infrastructure, changing DNS, changing a canonical hostname, modifying the firewall, publishing new claims outside the reviewed source, or initiating external communication. diff --git a/ops/vps/scripts/healthcheck.sh b/ops/vps/scripts/healthcheck.sh new file mode 100755 index 0000000..7543d14 --- /dev/null +++ b/ops/vps/scripts/healthcheck.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${PUBLIC_URL:?PUBLIC_URL is required, e.g. https://field-systems.example.com}" +HEALTHCHECK_PATHS="${HEALTHCHECK_PATHS:-/}" +MAX_TIME_SECONDS="${MAX_TIME_SECONDS:-20}" + +if [[ ! "$PUBLIC_URL" =~ ^https:// ]]; then + echo "PUBLIC_URL must use HTTPS." >&2 + exit 64 +fi +if [[ ! "$MAX_TIME_SECONDS" =~ ^[0-9]+$ ]]; then + echo "MAX_TIME_SECONDS must be numeric." >&2 + exit 64 +fi + +base="${PUBLIC_URL%/}" +for path in $HEALTHCHECK_PATHS; do + response="$(curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 --max-time "$MAX_TIME_SECONDS" --write-out ' status=%{http_code} total=%{time_total}s' --output /dev/null "${base}${path}")" + echo "${base}${path}${response}" +done diff --git a/ops/vps/templates/Caddyfile b/ops/vps/templates/Caddyfile index dfbcc3a..97c5888 100644 --- a/ops/vps/templates/Caddyfile +++ b/ops/vps/templates/Caddyfile @@ -3,6 +3,18 @@ __ARM_HOSTNAME__ { root * /var/www/arm-page-factory/current encode zstd gzip + + header { + -Server + X-Content-Type-Options "nosniff" + Referrer-Policy "strict-origin-when-cross-origin" + Permissions-Policy "geolocation=(), microphone=(), camera=()" + Strict-Transport-Security "max-age=31536000" + } + + @restricted path /.git/* /.github/* /ops/* /README.md + respond @restricted 404 + file_server @html path / /index.html /robots.txt /sitemap.xml /llms.txt /schema.json diff --git a/ops/vps/templates/arm-page-factory-healthcheck.service b/ops/vps/templates/arm-page-factory-healthcheck.service new file mode 100644 index 0000000..31707f3 --- /dev/null +++ b/ops/vps/templates/arm-page-factory-healthcheck.service @@ -0,0 +1,15 @@ +[Unit] +Description=ARM Page Factory anonymous HTTPS health check +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +User=armdeploy +Group=armdeploy +EnvironmentFile=/etc/arm-page-factory/healthcheck.env +ExecStart=/srv/arm-page-factory/ops/vps/scripts/healthcheck.sh +NoNewPrivileges=yes +PrivateTmp=yes +ProtectSystem=strict +ProtectHome=yes diff --git a/ops/vps/templates/arm-page-factory-healthcheck.timer b/ops/vps/templates/arm-page-factory-healthcheck.timer new file mode 100644 index 0000000..0e5e589 --- /dev/null +++ b/ops/vps/templates/arm-page-factory-healthcheck.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Run ARM Page Factory anonymous HTTPS health check + +[Timer] +OnBootSec=3m +OnUnitActiveSec=5m +RandomizedDelaySec=30s +Persistent=true + +[Install] +WantedBy=timers.target From d2dc0499f2afdae4cfb07871e5704f9670441344 Mon Sep 17 00:00:00 2001 From: CoreweaverLabs <2.18304646e+08+CoreweaverLabs@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:54:33 +0000 Subject: [PATCH 3/4] docs: add Hostinger VPS baseline runbook --- ops/vps/README.md | 1 + ops/vps/hostinger-baseline.md | 82 +++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 ops/vps/hostinger-baseline.md diff --git a/ops/vps/README.md b/ops/vps/README.md index 6a6c107..af41318 100644 --- a/ops/vps/README.md +++ b/ops/vps/README.md @@ -42,3 +42,4 @@ Read [`production-acceptance.md`](production-acceptance.md) before moving any ho - `templates/Caddyfile` serves the static site with compression and explicit cache behavior. - `templates/arm-page-factory.service` is an optional server-side one-shot release service. - `cutover-manifest.example.yml` captures the exact first-host approval and rollback record. +- `hostinger-baseline.md` maps the kit to a Hostinger VPS without embedding a server ID, IP, credential, or public hostname. diff --git a/ops/vps/hostinger-baseline.md b/ops/vps/hostinger-baseline.md new file mode 100644 index 0000000..5b83790 --- /dev/null +++ b/ops/vps/hostinger-baseline.md @@ -0,0 +1,82 @@ +# Hostinger VPS Baseline for the ARM Page Factory + +## Scope and decision boundary + +This baseline applies the provider-neutral page-factory kit to a **Hostinger VPS that already exists**. The connected account currently has no virtual machine, so the server identifier, IPv4 address, and public hostname remain intentionally blank. Do not execute any step that creates a server, changes DNS, changes nameservers, changes a firewall, attaches a public key, or issues a certificate without an approved cutover manifest. + +Hostinger exposes virtual-machine, backup, snapshot, public-key, firewall, and post-install-script functions in its VPS API; this guide uses those as the responsibility map, not as an instruction to invoke them automatically.[1] + +## Required values after provisioning approval + +| Value | Example format | Owner | Where it belongs | +| --- | --- | --- | --- | +| VPS identifier | Provider-generated resource ID | Infrastructure owner | Approved cutover manifest only. | +| IPv4 address | `203.0.113.10` | Infrastructure owner | Approved cutover manifest and GitHub environment variable. | +| Public hostname | `field-systems.example.com` | Domain owner | Caddy configuration, health-check env, and cutover manifest. | +| Deployment user | `armdeploy` | Release owner | VPS and GitHub variable. | +| Deployment SSH public key | Ed25519 public key | Release owner | VPS public-key attachment. | +| GitHub deploy private key | Ed25519 private key | Release owner | `ARM_VPS_DEPLOY_KEY` GitHub environment secret only. | +| SSH administrative ranges | Named office/VPN/CIDR sources | Infrastructure owner | Provider and host firewall record. | +| Monitoring alert recipient | Named person or approved alert target | Operations owner | Monitoring record; never committed to source. | + +## First-host implementation sequence + +| Order | Hostinger-side action | Acceptance evidence | Does this change public traffic? | +| --- | --- | --- | --- | +| 1 | Provision a supported Linux VPS and record its immutable server ID, location, and IPv4 in the approved manifest. | Provider resource record and a recovery/snapshot decision. | No. | +| 2 | Add the release owner’s SSH public key and disable password-based routine access where the chosen image supports it. | Successful key-based, non-root SSH login. | No. | +| 3 | Create `armdeploy`, grant ownership of `/var/www/arm-page-factory`, and keep the deployment process non-root. | `id armdeploy` and ownership record. | No. | +| 4 | Configure the Hostinger firewall to allow TCP 80 and 443 publicly and SSH only from approved administrative ranges; reinforce the same policy with the host firewall. | Provider-rule and host-rule records. | No, until DNS points to the host. | +| 5 | Install Caddy and deploy the reviewed `Caddyfile`; create Caddy’s log directory with appropriate service ownership. | `caddy validate` and service status. | No, until DNS points to the host. | +| 6 | Create `/etc/arm-page-factory/healthcheck.env` containing only the approved public URL and intended route paths. Install the reviewed health-check service and timer. | `systemctl status` for service and timer. | No. | +| 7 | Configure GitHub environment `vps-production` with approval protection, deployment variables, and the restricted release key secret. Keep `VPS_RELEASES_ENABLED` false while the host is still unverified. | Environment configuration review. | No. | +| 8 | Perform one manual immutable release to a noncanonical host. Confirm retained release, logs, anonymous HTTPS, mobile render, and rollback. | Acceptance checklist and release ID. | Only on the noncanonical host. | +| 9 | Enable routine branch deployment after a successful release and rollback rehearsal. | Approved production acceptance record. | No new DNS change. | + +## Firewall baseline + +Hostinger’s managed firewall operates before server traffic reaches the instance and uses explicit rules. An empty active rule set blocks traffic, so record the intended allow-list before attaching it to the VPS.[2] + +| Layer | Rule | Source | Reason | +| --- | --- | --- | --- | +| Hostinger firewall | Allow TCP 80 | Anywhere | HTTP-to-HTTPS handling and certificate validation. | +| Hostinger firewall | Allow TCP 443 | Anywhere | Public HTTPS delivery. | +| Hostinger firewall | Allow TCP 22 | Approved administrative CIDRs only | Break-glass and administration access. | +| Hostinger firewall | Reject or drop all other inbound traffic | Anywhere | Prevent unneeded public services. | +| Host firewall | Match the provider allow-list | Same as above | Defense in depth and finer host control. | + +Do not expose a database, Docker daemon, Caddy admin interface, monitoring dashboard, deployment webhook receiver, or SSH to the open internet without a separately approved design. + +## Caddy and health-check installation record + +The server’s actual Caddy configuration uses the reviewed repository template, replacing only the public hostname and the approved release root. Caddy should be validated before reload. The health-check environment should contain a single `PUBLIC_URL` for the noncanonical preview first, then the approved production hostname after cutover. The timer’s five-minute interval is an availability check, not a publishing trigger; its service only performs an anonymous HTTPS response check and logs a failure. + +Store deploy-time values outside the repository: + +| Location | Contains | Must not contain | +| --- | --- | --- | +| GitHub `vps-production` environment variables | Host, user, port, release root, `VPS_RELEASES_ENABLED`. | Private keys, API tokens, hostname changes not recorded in the manifest. | +| GitHub `vps-production` environment secrets | `ARM_VPS_DEPLOY_KEY`. | DNS/provider credentials unless a separately approved automation design needs them. | +| `/etc/arm-page-factory/healthcheck.env` | `PUBLIC_URL`, health-check paths, timeout. | Provider API key, GitHub private key, or public-site content. | +| `/etc/caddy/Caddyfile` | Public hostname, site root, headers, log path. | SSH keys, API credentials, or private operational contacts. | + +## Backup and rollback + +Take a Hostinger snapshot or confirm the provider backup posture before the first host configuration. Preserve the five immutable application releases defined by the release kit. Provider recovery is for a server-level failure; the `current` symlink is for a bad static release; the DNS rollback record is for a bad routing change. Each has a different owner and a different recovery path. + +## First cutover hold points + +The final cutover manifest remains blocked until all fields below are named: + +1. Provider and VPS identifier. +2. Public hostname and the exact pre-existing DNS records. +3. The authoritative DNS operator and the permitted record change. +4. CAA and certificate readiness. +5. Operations, release, and rollback owners. +6. The preview release ID and its acceptance record. + +## References + +[1] [Hostinger API Reference — VPS operations](https://developers.hostinger.com/) + +[2] [Hostinger, “How to use a managed VPS firewall.”](https://www.hostinger.com/support/8172641-how-to-use-a-managed-vps-firewall-at-hostinger/) From 58465012cf6bd4c0966f15adbe80c1e5c348c1e9 Mon Sep 17 00:00:00 2001 From: CoreweaverLabs <2.18304646e+08+CoreweaverLabs@users.noreply.github.com> Date: Thu, 27 Aug 2026 19:04:50 +0000 Subject: [PATCH 4/4] fix: harden VPS release target controls --- .github/workflows/vps-page-factory-release.yml | 12 +++++++----- ops/vps/README.md | 6 +++--- ops/vps/cutover-manifest.example.yml | 4 +++- ops/vps/templates/Caddyfile | 4 ++-- 4 files changed, 15 insertions(+), 11 deletions(-) diff --git a/.github/workflows/vps-page-factory-release.yml b/.github/workflows/vps-page-factory-release.yml index 9813196..a38bcb5 100644 --- a/.github/workflows/vps-page-factory-release.yml +++ b/.github/workflows/vps-page-factory-release.yml @@ -2,7 +2,7 @@ name: VPS page factory release on: push: - branches: [production] + branches: [main] paths: - "index.html" - "robots.txt" @@ -11,6 +11,7 @@ on: - "schema.json" - "aure/**" - "coreweaver-labs/**" + - "field-systems/**" - "ops/vps/**" - ".github/workflows/vps-page-factory-release.yml" workflow_dispatch: @@ -32,17 +33,18 @@ jobs: node-version: 22 - name: Validate static release artifact run: node ops/vps/scripts/preflight.mjs - - name: Configure restricted deployment key + - name: Configure restricted deployment key and pinned server identity env: ARM_VPS_DEPLOY_KEY: ${{ secrets.ARM_VPS_DEPLOY_KEY }} - VPS_HOST: ${{ vars.VPS_HOST }} + ARM_VPS_KNOWN_HOSTS: ${{ secrets.ARM_VPS_KNOWN_HOSTS }} run: | test -n "$ARM_VPS_DEPLOY_KEY" - test -n "$VPS_HOST" + test -n "$ARM_VPS_KNOWN_HOSTS" install -d -m 700 ~/.ssh printf '%s' "$ARM_VPS_DEPLOY_KEY" > ~/.ssh/id_ed25519 chmod 600 ~/.ssh/id_ed25519 - ssh-keyscan -H "$VPS_HOST" >> ~/.ssh/known_hosts + printf '%s\n' "$ARM_VPS_KNOWN_HOSTS" > ~/.ssh/known_hosts + chmod 600 ~/.ssh/known_hosts - name: Create immutable release env: CONFIRM_VPS_RELEASE: "yes" diff --git a/ops/vps/README.md b/ops/vps/README.md index af41318..55f07f6 100644 --- a/ops/vps/README.md +++ b/ops/vps/README.md @@ -8,9 +8,9 @@ The first server and public hostname remain an explicit infrastructure decision. | Area | Required before the first cutover | | --- | --- | -| Server | Supported Linux host, non-root deployment user, SSH public-key access, provider snapshot, and owner for patching/incident response. | +| Server | Supported Linux host, non-root deployment user, SSH public-key access, a host key verified out-of-band and stored as `ARM_VPS_KNOWN_HOSTS`, provider snapshot, and owner for patching/incident response. | | Network | Provider firewall and host firewall allow public TCP 80/443. Restrict SSH to known administrative networks; do not open database or administration ports publicly. | -| TLS | Install Caddy, associate the final hostname in `Caddyfile`, check the domain’s CAA policy, and verify certificate issuance before canonical traffic changes. | +| TLS | Install Caddy, associate the final hostname and page-specific document root in `Caddyfile`, check the domain’s CAA policy, and verify certificate issuance before canonical traffic changes. | | Storage | Create `${VPS_ROOT}/releases`, `${VPS_ROOT}/shared`, and the `current` symlink owned by the deploy user. Keep a retained release set plus a provider-level snapshot. | | Observability | Keep Caddy access/error logs, configure an anonymous HTTPS health check, and name the person who receives a failed-check alert. | | DNS | Preserve the current origin, record the exact existing DNS values, and obtain explicit approval of the proposed record diff and rollback step. | @@ -25,7 +25,7 @@ CONFIRM_VPS_RELEASE=yes VPS_HOST=... VPS_DEPLOY_USER=... \ VPS_ROOT=/var/www/arm-page-factory bash ops/vps/scripts/release.sh ``` -Set the deployment workflow’s variables and deploy key only after the initial server ownership and hostname decisions are confirmed. Do not set `VPS_RELEASES_ENABLED=true` until the first manually verified release has passed. +Set the deployment workflow’s variables, deploy key, and verified `ARM_VPS_KNOWN_HOSTS` secret only after the initial server ownership and hostname decisions are confirmed. `ARM_VPS_KNOWN_HOSTS` must be captured from a verified server/provider record, not generated during the deployment run. Do not set `VPS_RELEASES_ENABLED=true` until the first manually verified release has passed. ## Rollback diff --git a/ops/vps/cutover-manifest.example.yml b/ops/vps/cutover-manifest.example.yml index 4dc1c20..4126e3b 100644 --- a/ops/vps/cutover-manifest.example.yml +++ b/ops/vps/cutover-manifest.example.yml @@ -13,13 +13,15 @@ access: ssh_admin_source_ranges: [] release: repository: CoreweaverLabs/autonomousresourcemanagement - source_branch: production + source_branch: main deployment_user: armdeploy vps_root: /var/www/arm-page-factory + site_root: /var/www/arm-page-factory/current/field-systems retained_releases: 5 security: public_ports: [80, 443] tls_terminator: Caddy + ssh_host_key_verified: false provider_snapshot_verified: false host_firewall_verified: false certificate_verified: false diff --git a/ops/vps/templates/Caddyfile b/ops/vps/templates/Caddyfile index 97c5888..ce32e57 100644 --- a/ops/vps/templates/Caddyfile +++ b/ops/vps/templates/Caddyfile @@ -1,7 +1,7 @@ -# Replace __ARM_HOSTNAME__ only in the approved server configuration. +# Replace __ARM_HOSTNAME__ and __ARM_SITE_ROOT__ only in the approved server configuration. # Caddy obtains and renews TLS after the hostname resolves to this server. __ARM_HOSTNAME__ { - root * /var/www/arm-page-factory/current + root * __ARM_SITE_ROOT__ encode zstd gzip header {