diff --git a/.github/workflows/ssh-deploy.yml b/.github/workflows/ssh-deploy.yml new file mode 100644 index 0000000..912d934 --- /dev/null +++ b/.github/workflows/ssh-deploy.yml @@ -0,0 +1,33 @@ +# One shared deploy job. Every caller still gates this job on tests/security scans. +# All repository, server and service mappings live in fleet/targets.json. +name: Fleet SSH deploy + +on: + workflow_call: + +permissions: + contents: read + id-token: write + +jobs: + deploy: + if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') + runs-on: ubuntu-latest + timeout-minutes: 20 + concurrency: + group: fleet-deploy-${{ github.repository_id }} + cancel-in-progress: false + steps: + - name: Checkout central deployment tools, target registry and pinned SSH hosts + uses: actions/checkout@v4 + with: + repository: profullstack/deploy + ref: main + path: .pfdeploy + persist-credentials: false + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: '22' + - name: GitHub OIDC to OpenBao to ephemeral SSH certificate to deployed app + run: node .pfdeploy/bin/oidc-ssh-deploy.mjs diff --git a/README.md b/README.md index 8fb1661..53d846c 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,12 @@ On 2026-10-19 a change rolled out to 38 repos at once on our production box. Eve Zero dependencies. Linux (`flock`, `/proc/meminfo`); the batch runner works anywhere Node 18+ does. +## Fleet-wide deployments without per-site SSH keys + +**One workflow, one centrally managed SSH certificate authority, zero permanent private deployment keys in app repositories.** On every merge to `main` or `master`, the repository's existing tests/typechecks/ThreatCrush scan run, then the same shared workflow deploys the actual commit. GitHub's signed OIDC assertion authenticates the repo to OpenBao, which returns a five-minute SSH certificate for that repo's restricted account. Target hosts and health endpoints live in `fleet/targets.json` keyed by stable GitHub repository ID; the single signing URL lives in `fleet/config.json`. `fleet/properties.txt` contains the currently supplied property inventory, pending verification of repo/host associations. + +See **[Full SSH CA rollout and provisioning guide](docs/fleet-ssh-ca.md)**. The new workflow at `.github/workflows/ssh-deploy.yml` is disabled for an app until its central target entry is explicitly `enabled` and OpenBao + the host trust have been bootstrapped. The existing deployment gate remains in place. **No live app deployment is claimed by this code alone.** + ## On the box: the gate ```sh diff --git a/bin/enroll-repo-openbao.sh b/bin/enroll-repo-openbao.sh new file mode 100644 index 0000000..04507c9 --- /dev/null +++ b/bin/enroll-repo-openbao.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Central operator enrollment. One OpenBao role and one policy per GitHub repo, +# automatically generated; NEVER one long-lived SSH key per repo. +# Run only from an authenticated OpenBao administrator workstation. +# Usage: ./bin/enroll-repo-openbao.sh OWNER/REPO GITHUB_REPO_ID main|master +set -euo pipefail + +repo="${1:-}" +id="${2:-}" +branch="${3:-}" +[[ "$repo" =~ ^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$ ]] || { echo "invalid owner/repo" >&2; exit 2; } +[[ "$id" =~ ^[1-9][0-9]{0,19}$ ]] || { echo "GitHub repo ID must be numeric" >&2; exit 2; } +[[ "$branch" = main || "$branch" = master ]] || { echo "branch must be main or master" >&2; exit 2; } +command -v bao >/dev/null || { echo "Install the OpenBao CLI first" >&2; exit 2; } + +policy="fleet-deploy-repo-$id" +tmp="$(mktemp)" +trap 'rm -f "$tmp"' EXIT +cat > "$tmp" < OpenBao JWT role -> + * short-lived SSH user certificate -> a per-repository, forced-command account. + * + * The server must already trust the OpenBao USER CA and have an enrollment for + * this repository ID. This program never uses a shared private key, doesn't + * create remote accounts, and fails closed if enrollment is incomplete. + */ +import { execFileSync } from 'node:child_process' +import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +export const AUDIENCE = 'profullstack-deploy' + +const TARGETS_FILE = new URL('../fleet/targets.json', import.meta.url) +const FLEET_FILE = new URL('../fleet/config.json', import.meta.url) +const HOSTS_FILE = new URL('../ssh/known_hosts', import.meta.url) + +/** Repository ID is from GitHub's runner, NOT a caller-controlled target. */ +export function resolveFleetTarget(env, targets, fleet) { + const id = String(env.GITHUB_REPOSITORY_ID || '') + const target = targets[id] + if (!target || !target.enabled) throw new Error('Repository is not enrolled or enabled in the central fleet registry') + if (target.repository !== env.GITHUB_REPOSITORY || target.ref !== env.GITHUB_REF) { + throw new Error('Fleet registry repository or branch mismatch') + } + if (typeof target.health_url !== 'string' || !target.health_url.startsWith('https://')) { + throw new Error('Fleet target needs an HTTPS health URL') + } + const health = new URL(target.health_url) + if (health.protocol !== 'https:' || health.username || health.password) throw new Error('Invalid health URL') + return { + env: { + ...env, + PFDEPLOY_HOST: target.host, + PFDEPLOY_PORT: String(target.port || 22), + PFDEPLOY_OPENBAO_URL: fleet.openbao_url || '', + }, + health_url: health.href, + } +} + +/** A deployed app must answer health after SSH succeeds. */ +async function verifyHealth(url) { + let lastError = null + for (let i = 0; i < 5; i++) { + try { + const response = await fetch(url, { redirect: 'error', signal: AbortSignal.timeout(12000) }) + if (response.ok) { console.log('Health check passed:', url); return } + lastError = new Error('HTTP ' + response.status) + } catch (error) { lastError = error } + if (i < 4) await new Promise(r => setTimeout(r, 3000)) + } + throw new Error('Deployment health check failed: ' + (lastError?.message || 'unknown')) +} + +export function validateContext(env) { + const id = env.GITHUB_REPOSITORY_ID || '' + const sha = env.GITHUB_SHA || '' + const repository = env.GITHUB_REPOSITORY || '' + const ref = env.GITHUB_REF || '' + const event = env.GITHUB_EVENT_NAME || '' + const host = env.PFDEPLOY_HOST || '' + const port = String(env.PFDEPLOY_PORT || '22') + if (!/^[1-9]\d{0,19}$/.test(id)) throw new Error('Missing numeric GitHub repository ID') + if (!/^[a-f0-9]{40}$/.test(sha)) throw new Error('Deployment requires a full commit SHA') + if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) throw new Error('Invalid GitHub repository') + if (ref !== 'refs/heads/main' && ref !== 'refs/heads/master') throw new Error('Only main/master are deployable') + if (event !== 'push') throw new Error('Deployments must come from a push after CI, never a PR') + if (!/^[a-zA-Z0-9][a-zA-Z0-9.-]{0,251}$/.test(host)) throw new Error('Invalid deployment host') + if (!/^\d+$/.test(port) || +port < 1 || +port > 65535) throw new Error('Invalid SSH port') + const address = env.PFDEPLOY_OPENBAO_URL || '' + const bao = new URL(address) + if (bao.protocol !== 'https:' || bao.username || bao.password || bao.search || bao.hash || bao.pathname !== '/') { + throw new Error('OpenBao URL must be an HTTPS origin with no credentials or path') + } + if (!env.ACTIONS_ID_TOKEN_REQUEST_URL || !env.ACTIONS_ID_TOKEN_REQUEST_TOKEN) { + throw new Error('GitHub Actions id-token: write permission is required') + } + return { id, sha, repository, ref, host, port, bao: bao.origin, username: 'pfdeploy-' + id } +} + +async function jsonRequest(url, { bearer, vaultToken, body } = {}) { + const response = await fetch(url, { + method: body ? 'POST' : 'GET', + headers: { accept: 'application/json', ...(body ? { 'content-type': 'application/json' } : {}), + ...(bearer ? { authorization: 'Bearer ' + bearer } : {}), + ...(vaultToken ? { 'x-vault-token': vaultToken } : {}) }, + ...(body ? { body: JSON.stringify(body) } : {}), + signal: AbortSignal.timeout(15000), + }) + if (!response.ok) throw new Error('Authentication service rejected request (HTTP ' + response.status + ')') + return response.json() +} + +async function githubOidc(env) { + const url = new URL(env.ACTIONS_ID_TOKEN_REQUEST_URL) + if (url.protocol !== 'https:') throw new Error('OIDC endpoint must use HTTPS') + url.searchParams.set('audience', AUDIENCE) + const doc = await jsonRequest(url, { bearer: env.ACTIONS_ID_TOKEN_REQUEST_TOKEN }) + if (typeof doc?.value !== 'string' || !doc.value) throw new Error('GitHub OIDC returned no token') + return doc.value +} + +export async function deploy(env = process.env) { + const targets = JSON.parse(readFileSync(TARGETS_FILE, 'utf8')) + const fleet = JSON.parse(readFileSync(FLEET_FILE, 'utf8')) + const deployment = resolveFleetTarget(env, targets, fleet) + env = deployment.env + const cfg = validateContext(env) + const knownHosts = fileURLToPath(HOSTS_FILE) + const content = readFileSync(knownHosts, 'utf8') + if (!content.trim() || !content.split(/\r?\n/).some(s => s && !s.startsWith('#'))) { + throw new Error('No pinned server host keys or host CA in known_hosts; refusing insecure SSH') + } + const temp = mkdtempSync(join(tmpdir(), 'pfdeploy-')) + try { + const key = join(temp, 'identity') + const certificate = join(temp, 'identity-cert.pub') + execFileSync('ssh-keygen', ['-t', 'ed25519', '-q', '-N', '', '-f', key]) + const publicKey = readFileSync(key + '.pub', 'utf8').trim() + const token = await githubOidc(env) + const login = await jsonRequest(cfg.bao + '/v1/auth/github-actions/login', { + body: { role: 'repo-' + cfg.id, jwt: token }, + }) + const vaultToken = login?.auth?.client_token + if (typeof vaultToken !== 'string' || !vaultToken) throw new Error('OpenBao JWT login did not yield a token') + const signed = await jsonRequest(cfg.bao + '/v1/ssh-client-signer/sign/repo-' + cfg.id, { + vaultToken, + body: { public_key: publicKey, valid_principals: cfg.username, ttl: '5m' }, + }) + if (typeof signed?.data?.signed_key !== 'string' || !signed.data.signed_key.includes('-cert-v01@openssh.com')) { + throw new Error('OpenBao did not return an SSH user certificate') + } + writeFileSync(certificate, signed.data.signed_key + '\n', { mode: 0o600 }) + console.log('Deploying', cfg.repository, cfg.sha.slice(0, 12), 'to', cfg.host, 'with a short-lived SSH certificate') + execFileSync('ssh', [ + '-o', 'BatchMode=yes', '-o', 'IdentitiesOnly=yes', '-o', 'StrictHostKeyChecking=yes', + '-o', 'UserKnownHostsFile=' + knownHosts, '-o', 'CertificateFile=' + certificate, + '-o', 'ConnectTimeout=15', '-p', cfg.port, + '-i', key, cfg.username + '@' + cfg.host, 'deploy ' + cfg.sha, + ], { stdio: 'inherit' }) + await verifyHealth(deployment.health_url) + } finally { + rmSync(temp, { recursive: true, force: true }) + } +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + deploy().catch(error => { console.error('pfdeploy:', error.message); process.exitCode = 1 }) +} diff --git a/bin/pfdeploy-ssh-receiver b/bin/pfdeploy-ssh-receiver new file mode 100644 index 0000000..2d5f397 --- /dev/null +++ b/bin/pfdeploy-ssh-receiver @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Root-owned SSHD ForceCommand, entered as a per-repository unprivileged user. +# A certificate for one repository cannot authenticate as another repo's user. +set -euo pipefail +umask 077 + +deny() { printf 'pfdeploy: %s\n' "$*" >&2; exit 1; } +account="$(id -un)" +[[ "$account" =~ ^pfdeploy-[1-9][0-9]{0,19}$ ]] || deny "unrecognized deployment identity" +id="${account#pfdeploy-}" +cfg="/etc/profullstack/deploy/$account.conf" +[[ -f "$cfg" ]] || deny "repository not enrolled" +[[ "$(stat -c %u "$cfg")" = 0 ]] || deny "enrollment must be owned by root" +mode="$(stat -c %a "$cfg")" +[[ "$mode" = 644 || "$mode" = 640 || "$mode" = 600 ]] || deny "unsafe configuration permissions" + +# Strict KEY=value reader; never evaluate shell code from config or SSH input. +setting() { sed -n "s/^$1=//p" "$cfg" | head -n 1; } +repo="$(setting REPOSITORY)" +branch="$(setting BRANCH)" +checkout="$(setting CHECKOUT)" +[[ "$repo" =~ ^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$ ]] || deny "invalid repository enrollment" +[[ "$branch" = main || "$branch" = master ]] || deny "invalid protected branch" +[[ "$checkout" = /* && -d "$checkout/.git" ]] || deny "missing dedicated checkout" +[[ "${SSH_ORIGINAL_COMMAND:-}" =~ ^deploy[[:space:]]([a-f0-9]{40})$ ]] || deny "only deploy FULL_SHA is allowed" +sha="${BASH_REMATCH[1]}" +remote="$(git -C "$checkout" remote get-url origin)" +case "$remote" in + "https://github.com/$repo"|"https://github.com/$repo.git"|"git@github.com:$repo.git"|"ssh://git@github.com/$repo.git") ;; + *) deny "checkout remote does not match enrolled repository" ;; +esac + +# Only commits on the enrolled main/master branch can be installed. +git -C "$checkout" fetch --no-tags origin "$branch" +allowed="$(git -C "$checkout" rev-parse FETCH_HEAD)" +git -C "$checkout" cat-file -e "$sha^{commit}" || deny "unknown commit" +git -C "$checkout" merge-base --is-ancestor "$sha" "$allowed" || deny "commit not on approved branch" +old="$(git -C "$checkout" rev-parse HEAD)" +git -C "$checkout" merge-base --is-ancestor "$old" "$sha" || deny "refusing rollback" +git -C "$checkout" checkout --detach --force "$sha" +cd "$checkout" +if [[ -x /usr/local/bin/deploy-gate ]]; then + exec /usr/local/bin/deploy-gate --name "$id" -- bash bin/install.sh +fi +exec bash bin/install.sh diff --git a/docs/fleet-ssh-ca.md b/docs/fleet-ssh-ca.md new file mode 100644 index 0000000..3e1c7ca --- /dev/null +++ b/docs/fleet-ssh-ca.md @@ -0,0 +1,128 @@ +# Profullstack fleet deployment: one OIDC trust, one SSH CA, one shared workflow + +This design covers **all properties** across `profullstack`, `moshcoder` and additional approved organizations. Do **not** create or copy `PIT_DEPLOY_KEY` or a different SSH private key for each app. Enroll deployments from centrally maintained repo IDs and target records. The inventory in `fleet/properties.txt` is user-supplied and incomplete: it is **not** permission to assume every domain has the same-named GitHub repository or the same deployment host. + +## What lives where + +- `profullstack/deploy/.github/workflows/ssh-deploy.yml`: one reusable GitHub Actions job. Caller must run its own tests and threat scan first. +- `fleet/targets.json`: central mapping from immutable GitHub `repository_id` to an **explicitly reviewed** owner/repository, main or master branch, host, SSH port and public HTTPS health URL. `enabled=false` blocks a deploy, including the PIT pilot until it is provisioned. +- `fleet/config.json`: the ONE public HTTPS OpenBao URL for the fleet, set once during setup. No secrets are stored here. +- `ssh/known_hosts`: verified SSH host key or a separate SSH **host** CA public key, pinned centrally. It starts deliberately empty. +- `bin/oidc-ssh-deploy.mjs`: creates a fresh Ed25519 key, requests GitHub OIDC proof, exchanges it with OpenBao, gets a five-minute certificate restricted to the repository's login principal, deploys only the GitHub push SHA and checks health. +- `bin/pfdeploy-ssh-receiver`: root-owned SSHD ForceCommand for dedicated unprivileged `pfdeploy-REPO_ID` users. The server verifies the Git remote and that the SHA belongs to the enrolled branch before checking it out and running the app's existing `bin/install.sh` through the existing `deploy-gate`. + +**One CA private key** stays in OpenBao; each GitHub job generates a throwaway, independently signed SSH key, not a shared reusable credential. Revocation is via disabling its JWT role or repo registry entry (certificates already issued expire in five minutes). One central SSH CA means rotation requires dual-trusting the old and new public CA briefly on servers; OpenBao supports CA issuers for rotation. + +## 1. One-time OpenBao control plane (operator only) + +Use a dedicated HTTPS OpenBao deployment (self-hosted, monitored, backed up, audit logs on, tightly firewalled). Never run OpenBao in dev mode in production. Run the following as an authenticated OpenBao administrator (do NOT put the admin token in GitHub): + +```bash +bao auth enable -path=github-actions jwt +bao write auth/github-actions/config \ + oidc_discovery_url="https://token.actions.githubusercontent.com" \ + bound_issuer="https://token.actions.githubusercontent.com" +bao secrets enable -path=ssh-client-signer ssh +bao write ssh-client-signer/config/ca generate_signing_key=true +bao read -field=public_key ssh-client-signer/config/ca > profullstack-user-ca.pub +``` + +The `auth/github-actions/role/repo-ID` and `ssh-client-signer/roles/repo-ID` records must be created **only by an operator** using `bin/enroll-repo-openbao.sh OWNER/REPO REPO_ID main|master`. The script generates roles and policies from identity facts; nobody must hand-manage credentials. An authenticated OpenBao operator may later automate the script from an approved registry sync. Every role binds GitHub `repository`, immutable `repository_id`, exact `ref`, `event_name=push`, audience `profullstack-deploy` and exact central `job_workflow_ref`. Each policy can sign **only** its own SSH role, and each SSH role only its account principal. + +**Do not** configure a single wildcard JWT policy that can sign arbitrary SSH principals. That would turn one compromised repo into access to the entire fleet. + +Set `fleet/config.json`'s `openbao_url` to this single TLS origin after commissioning it. This is public configuration, **not a secret**. + +## 2. Each target host, one CA and generated per-repo isolation + +On each target server, install the **public** `profullstack-user-ca.pub` and the root-owned receiver. Verify CA fingerprint through your trusted management channel first. Example PIT repo ID `1423083790`: + +```bash +sudo install -d -m 755 /etc/profullstack/deploy /etc/ssh/auth_principals +sudo install -m 644 -o root -g root profullstack-user-ca.pub /etc/ssh/profullstack-user-ca.pub +sudo install -m 755 -o root -g root bin/pfdeploy-ssh-receiver /usr/local/bin/pfdeploy-ssh-receiver +sudo useradd --create-home --shell /bin/bash pfdeploy-1423083790 +printf '%s\n' 'pfdeploy-1423083790' | sudo tee /etc/ssh/auth_principals/pfdeploy-1423083790 >/dev/null +printf 'REPOSITORY=moshcoder/pit.world\nBRANCH=main\nCHECKOUT=/opt/pit.world\n' | + sudo tee /etc/profullstack/deploy/pfdeploy-1423083790.conf >/dev/null +sudo chown root:root /etc/ssh/auth_principals/pfdeploy-1423083790 /etc/profullstack/deploy/pfdeploy-1423083790.conf +sudo chmod 644 /etc/ssh/auth_principals/pfdeploy-1423083790 /etc/profullstack/deploy/pfdeploy-1423083790.conf +``` + +Configure OpenSSH, keeping these global directives **outside** of any Match section, and the Match block last in `sshd_config`. Preserve your existing admin SSH access, do not disable it during migration: + +```sshconfig +TrustedUserCAKeys /etc/ssh/profullstack-user-ca.pub +AuthorizedPrincipalsFile /etc/ssh/auth_principals/%u + +Match User pfdeploy-* + AuthenticationMethods publickey + ForceCommand /usr/local/bin/pfdeploy-ssh-receiver + PermitTTY no + AllowTcpForwarding no + AllowAgentForwarding no + PermitTunnel no + X11Forwarding no + PermitUserRC no + PasswordAuthentication no + KbdInteractiveAuthentication no +``` + +Before reload, verify `sudo sshd -t` and `sudo sshd -T -C user=pfdeploy-1423083790,host=dev2.profullstack.com,addr=127.0.0.1`. **Do not** overwrite an existing SSHD configuration blindly. Keep an administrative shell open during the transition. Provision the checkout for the unprivileged deploy account and audit `bin/install.sh` and any necessary **narrow sudo permissions**; the current PIT installation runs as root, so **migration of its filesystem/service permissions is required** before enabling this pilot. Do not grant general passwordless root or a shell to deploy accounts. + +Each SSH certificate principal is `pfdeploy-REPO_ID`, and only the matching Unix account accepts it. After authentication, the `ForceCommand` ignores arbitrary commands and accepts only `deploy FULL_40_CHARACTER_SHA`. It checks the Git remote and that the target commit is on the approved `origin/main` or `origin/master`. This keeps repo permissions separate without separate private keys. + +## 3. Host-key trust is separate from user CA + +SSH **user certificates** authenticate GitHub to your server. SSH **host keys** authenticate the server to GitHub. Do not confuse them. Pin the servers' verified host keys centrally in `ssh/known_hosts`, or deploy a separate SSH host CA and trust it there with an `@cert-authority` entry. Verify host CA fingerprints out-of-band. Never disable SSH host-key checking or run unauthenticated `ssh-keyscan` inside CI. + +## 4. Every application has the same tiny workflow call + +Your app workflow retains its existing project-specific test and security jobs, and gates the shared deploy on them: + +```yaml +name: CI and release +on: + pull_request: + push: + branches: [main, master] +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: echo "Run this project's real test/build/typecheck here" + security: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: echo "Run the existing ThreatCrush scan here" + deploy: + if: github.event_name == 'push' + needs: [test, security] + uses: profullstack/deploy/.github/workflows/ssh-deploy.yml@main + permissions: + contents: read + id-token: write +``` + +The `echo` commands in the **example above are placeholders**. Do not replace any real CI with those placeholders. Keep your existing tests, build, type checks, and ThreatCrush scan. In production pin the reusable workflow to a reviewed release commit/tag and protect it from unreviewed changes. If deploying across organizations, grant access to the public shared workflow; its OpenBao OIDC policy independently approves the actual calling repository and workflow. + +There are **no repository secrets**, no PEM files in CI, and no target host settings in the individual application's workflow. Only `needs` and the reusable workflow reference. GitHub issues OIDC credentials that expire and OpenBao signs the generated public SSH key. + +## 5. Migrating PIT and then the fleet + +1. Confirm a backed-up, TLS-protected OpenBao and GitHub OIDC verification work. +2. Bind `moshcoder/pit.world` repo ID `1423083790` with `bin/enroll-repo-openbao.sh moshcoder/pit.world 1423083790 main`. +3. Install the one public SSH user CA on dev2, provision the dedicated PIT account, repository checkout ownership and any minimal service permissions. Check SSHD and audit logs. +4. Pin the host key in `ssh/known_hosts` and set `fleet/config.json`'s OpenBao URL. +5. Change PIT's central `fleet/targets.json` entry to `enabled=true` only **after** all checks pass. Change only PIT's deploy job to the reusable workflow; tests and ThreatCrush continue to gate the deployment. +6. Merge and verify `https://pit.world/health` and `https://pit.world/pros/industrial`. Revoke old `PIT_DEPLOY_KEY` only after new authentication works. +7. Map the other properties to **verified GitHub repository IDs and actual hosts**. Add those mappings to `fleet/targets.json`, generate OpenBao roles and server accounts from the registry (batch provisioning), migrate their existing GitHub workflows in reviewable waves (3 at a time), retain the shared `deploy-gate`, and observe failures before expanding. + +**Current repository changes are a staged implementation, not a provisioned CA or deployed fleet.** Do not flip `enabled` without real infrastructure. This is intentional fail-closed behavior. + +Reference docs: +- https://docs.github.com/en/actions/how-tos/secure-your-work/security-harden-deployments/oidc-with-reusable-workflows +- https://openbao.org/docs/auth/jwt/ +- https://openbao.org/docs/secrets/ssh/signed-ssh-certificates/ diff --git a/fleet/config.json b/fleet/config.json new file mode 100644 index 0000000..6a8b7e1 --- /dev/null +++ b/fleet/config.json @@ -0,0 +1,4 @@ +{ + "openbao_url": "", + "note": "Set your centrally managed HTTPS OpenBao origin once after deploying the CA. Never commit private keys or OpenBao tokens." +} diff --git a/fleet/properties.txt b/fleet/properties.txt new file mode 100644 index 0000000..17d3738 --- /dev/null +++ b/fleet/properties.txt @@ -0,0 +1,90 @@ +# Operator-supplied (partial) Profullstack property list. Domains are not automatically assumed to match repo names. +# Enrollment requires a verified GitHub repo ID, default branch and actual deployment host. +b1dz.com +bl0ggers.com +brisk.news +c0mpute.com +c0ncerts.com +c0upons.com +coinpayportal.com +crawlproof.com +d0rz.com +logicsrc.com +marksyncr.com +bittorrented.com +naallo.com +pairux.com +phonenumbers.bot +postammo.com +profullstack.com +qaaas.dev +qrypt.chat +recipepdfs.com +saasrow.com +sh1pt.com +smshub.dev +threatcrush.com +ugig.net +vu1nz.com +weedforcrypto.com +tronbrowser.dev +goingbroke.now +infernetprotocol.com +moshcode.sh +moshcoding.com +aiornot.vote +advis0r.com +spl00f.com +bbs.profullstack.com +outreachgraph.com +reeleel.com +rssamplifier.com +bufferoverride.com +tipoffwatch.com +genrewatch.com +p0dcasters.com +d3vices.com +diskpush.com +hqtui.com +chovy.com +nichedb.dev +readm3.com +watchnews.now +nixamp.com +agenticjobs.work +nightcell7.com +r4ck.dev +mediaanalyzer.pro +demo.movie +bg0ne.com +meshhook.com +vipvap.xyz +complymist.com +typeheard.com +pwamart.com +tleehealth.com +hqterm.sh +x93.me +wx93.me +sysops.ceo +datamart.help +hdtilt.com +ringhuman.com +airdpx.com +submitfleet.com +ch4nnels.com +pit.world +ad.network.pit.world +built.indie.pit.world +pay.agent.pit.world +family.ancestry.pit.world +seo.rank.pit.world +cigarunderground.org +search.peptides.pit.world +pics.collage.pit.world +resident.alerts.pit.world +watch.wallet.pit.world +deadman.switch.pit.world +group.call.pit.world +auto.hacker.pit.world +fineprint.agent.pit.world diff --git a/fleet/targets.json b/fleet/targets.json new file mode 100644 index 0000000..4b2049d --- /dev/null +++ b/fleet/targets.json @@ -0,0 +1,11 @@ +{ + "1423083790": { + "repository": "moshcoder/pit.world", + "ref": "refs/heads/main", + "host": "dev2.profullstack.com", + "port": 22, + "health_url": "https://pit.world/health", + "enabled": false, + "note": "PILOT: Do not enable until SSH CA, unprivileged receiver account, repo checkout and OpenBao role are provisioned. Existing PIT deployment still uses root and static GitHub secret." + } +} diff --git a/ssh/known_hosts b/ssh/known_hosts new file mode 100644 index 0000000..0214d2c --- /dev/null +++ b/ssh/known_hosts @@ -0,0 +1,4 @@ +# Operator-pinned SSH host public keys OR SSH host CA entries. +# Populate from trusted console after verifying fingerprints out of band. +# Never ssh-keyscan blindly in CI; never disable StrictHostKeyChecking. +# Example: @cert-authority *.profullstack.com ssh-ed25519 AAAA... verified-host-CA diff --git a/test/fleet-shell.test.js b/test/fleet-shell.test.js new file mode 100644 index 0000000..ab1cbb4 --- /dev/null +++ b/test/fleet-shell.test.js @@ -0,0 +1,11 @@ +import { describe, it } from 'node:test' +import { execFileSync } from 'node:child_process' +import { resolve } from 'node:path' + +describe('shared fleet deployment scripts', () => { + for (const name of ['bin/enroll-repo-openbao.sh', 'bin/pfdeploy-ssh-receiver']) { + it('parses safely in bash: ' + name, () => { + execFileSync('bash', ['-n', resolve(name)]) + }) + } +}) diff --git a/test/oidc-ssh.test.js b/test/oidc-ssh.test.js new file mode 100644 index 0000000..4177f98 --- /dev/null +++ b/test/oidc-ssh.test.js @@ -0,0 +1,56 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { validateContext, resolveFleetTarget, AUDIENCE } from '../bin/oidc-ssh-deploy.mjs' + +const valid = { + GITHUB_REPOSITORY_ID: '1423083790', + GITHUB_SHA: 'a'.repeat(40), + GITHUB_REPOSITORY: 'moshcoder/pit.world', + GITHUB_REF: 'refs/heads/main', + GITHUB_EVENT_NAME: 'push', + PFDEPLOY_HOST: 'dev2.profullstack.com', + PFDEPLOY_PORT: '22', + PFDEPLOY_OPENBAO_URL: 'https://openbao.example.net', + ACTIONS_ID_TOKEN_REQUEST_URL: 'https://example.net/oidc', + ACTIONS_ID_TOKEN_REQUEST_TOKEN: 'runner-temporary-secret', +} +describe('central fleet OIDC deployment context', () => { + it('uses stable repo ID for short-lived certificate role and Unix principal', () => { + const ctx = validateContext(valid) + assert.equal(ctx.username, 'pfdeploy-1423083790') + assert.equal(ctx.bao, 'https://openbao.example.net') + assert.equal(AUDIENCE, 'profullstack-deploy') + }) + it('supports the master branch', () => { + assert.equal(validateContext({ ...valid, GITHUB_REF: 'refs/heads/master' }).ref, 'refs/heads/master') + }) + for (const [name, change] of Object.entries({ + 'reject pull requests': { GITHUB_EVENT_NAME: 'pull_request' }, + 'reject feature branches': { GITHUB_REF: 'refs/heads/feature' }, + 'reject abbreviated SHA': { GITHUB_SHA: 'a'.repeat(12) }, + 'reject missing repo ID': { GITHUB_REPOSITORY_ID: '' }, + 'reject host argument injection': { PFDEPLOY_HOST: 'host; rm -rf /' }, + 'reject plaintext signing service': { PFDEPLOY_OPENBAO_URL: 'http://example.net' }, + 'reject URL credentials': { PFDEPLOY_OPENBAO_URL: 'https://token@example.net' }, + 'reject missing OIDC': { ACTIONS_ID_TOKEN_REQUEST_TOKEN: '' }, + })) { + it(name, () => assert.throws(() => validateContext({ ...valid, ...change }))) + } +}) + +describe('central repository-to-deployment mapping', () => { + const targets = { '1423083790': { repository: 'moshcoder/pit.world', ref: 'refs/heads/main', host: 'dev2.profullstack.com', port: 22, health_url: 'https://pit.world/health', enabled: true } } + const fleet = { openbao_url: 'https://openbao.example.net' } + it('resolves the target by immutable GitHub repository ID', () => { + const result = resolveFleetTarget(valid, targets, fleet) + assert.equal(result.env.PFDEPLOY_HOST, 'dev2.profullstack.com') + assert.equal(result.env.PFDEPLOY_OPENBAO_URL, fleet.openbao_url) + assert.equal(result.health_url, 'https://pit.world/health') + }) + it('refuses unknown, disabled and wrong repository/branch mappings', () => { + assert.throws(() => resolveFleetTarget(valid, {}, fleet)) + assert.throws(() => resolveFleetTarget(valid, { '1423083790': { ...targets['1423083790'], enabled: false } }, fleet)) + assert.throws(() => resolveFleetTarget({ ...valid, GITHUB_REPOSITORY: 'other/repo' }, targets, fleet)) + assert.throws(() => resolveFleetTarget({ ...valid, GITHUB_REF: 'refs/heads/master' }, targets, fleet)) + }) +})