From b73f61afb9cfd4012f85275286de77c4aaa21d92 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 31 Oct 2026 03:15:01 -0700 Subject: [PATCH 01/16] Use GitHub OIDC and OpenBao SSH certificates for secretless deploys --- bin/oidc-ssh-deploy.mjs | 106 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 bin/oidc-ssh-deploy.mjs diff --git a/bin/oidc-ssh-deploy.mjs b/bin/oidc-ssh-deploy.mjs new file mode 100644 index 0000000..a107a3c --- /dev/null +++ b/bin/oidc-ssh-deploy.mjs @@ -0,0 +1,106 @@ +#!/usr/bin/env node +/** + * Zero static deployment credentials: GitHub OIDC -> 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' + +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, body } = {}) { + const response = await fetch(url, { + method: body ? 'POST' : 'GET', + headers: { accept: 'application/json', ...(body ? { 'content-type': 'application/json' } : {}), + ...(bearer ? { authorization: 'Bearer ' + bearer } : {}) }, + ...(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 cfg = validateContext(env) + const knownHosts = resolve(env.PFDEPLOY_KNOWN_HOSTS || '') + 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, { + bearer: 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' }) + } 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 }) +} From ad0f3d0bd6708aa06759872f3ef673b8fb38485f Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 31 Oct 2026 03:15:30 -0700 Subject: [PATCH 02/16] Add centralized OIDC SSH deployment reusable workflow --- .github/workflows/ssh-deploy.yml | 62 ++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 .github/workflows/ssh-deploy.yml diff --git a/.github/workflows/ssh-deploy.yml b/.github/workflows/ssh-deploy.yml new file mode 100644 index 0000000..a71d4b7 --- /dev/null +++ b/.github/workflows/ssh-deploy.yml @@ -0,0 +1,62 @@ +# Shared job for all properties, after caller CI and security scans pass. +# GitHub OIDC -> OpenBao JWT auth -> five-minute SSH user certificate. +name: Fleet SSH deploy + +on: + workflow_call: + inputs: + host: + description: Pinned SSH host enrolled with the SSH user CA + required: true + type: string + openbao_url: + description: Public HTTPS OpenBao endpoint (not a secret) + required: true + type: string + health_url: + description: Public HTTPS health check for this property + required: true + type: string + port: + description: SSH port + required: false + type: number + default: 22 + +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 deploy tooling and pinned host keys + 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: Authenticate GitHub OIDC, sign ephemeral SSH key, deploy + run: node .pfdeploy/bin/oidc-ssh-deploy.mjs + env: + PFDEPLOY_HOST: ${{ inputs.host }} + PFDEPLOY_PORT: ${{ inputs.port }} + PFDEPLOY_OPENBAO_URL: ${{ inputs.openbao_url }} + PFDEPLOY_KNOWN_HOSTS: .pfdeploy/ssh/known_hosts + - name: Verify public service health + env: + HEALTH_URL: ${{ inputs.health_url }} + run: | + case "$HEALTH_URL" in https://*) ;; *) echo "Health URL must use HTTPS" >&2; exit 1;; esac + curl --fail --silent --show-error --retry 5 --retry-delay 3 "$HEALTH_URL" >/dev/null From 655cb80e1a52694a473fc2088ae1803a3c31f946 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 31 Oct 2026 03:16:05 -0700 Subject: [PATCH 03/16] Document central pinned SSH host keys --- ssh/known_hosts | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 ssh/known_hosts 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 From 08a44344d64ed93729e8a486c2c1d429234a4320 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 31 Oct 2026 03:16:07 -0700 Subject: [PATCH 04/16] Test OIDC deployment validation and reject unsafe contexts --- test/oidc-ssh.test.js | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 test/oidc-ssh.test.js diff --git a/test/oidc-ssh.test.js b/test/oidc-ssh.test.js new file mode 100644 index 0000000..53ae720 --- /dev/null +++ b/test/oidc-ssh.test.js @@ -0,0 +1,39 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { validateContext, 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 }))) + } +}) From ab5abbfc157e1140fb9c48b84d6425458f890fc4 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 31 Oct 2026 03:16:19 -0700 Subject: [PATCH 05/16] Add per-repo forced-command receiver with branch and SHA verification --- bin/pfdeploy-ssh-receiver | 45 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 bin/pfdeploy-ssh-receiver 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 From d8c087265c9964404601c195005bff3f4fe3817b Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 31 Oct 2026 03:16:35 -0700 Subject: [PATCH 06/16] Generate repository-scoped OpenBao OIDC authorization and SSH CA signing roles --- bin/enroll-repo-openbao.sh | 63 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 bin/enroll-repo-openbao.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" < Date: Sat, 31 Oct 2026 03:17:04 -0700 Subject: [PATCH 07/16] Make deployment caller zero-config: resolve target from central registry --- .github/workflows/ssh-deploy.yml | 37 ++++---------------------------- 1 file changed, 4 insertions(+), 33 deletions(-) diff --git a/.github/workflows/ssh-deploy.yml b/.github/workflows/ssh-deploy.yml index a71d4b7..912d934 100644 --- a/.github/workflows/ssh-deploy.yml +++ b/.github/workflows/ssh-deploy.yml @@ -1,27 +1,9 @@ -# Shared job for all properties, after caller CI and security scans pass. -# GitHub OIDC -> OpenBao JWT auth -> five-minute SSH user certificate. +# 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: - inputs: - host: - description: Pinned SSH host enrolled with the SSH user CA - required: true - type: string - openbao_url: - description: Public HTTPS OpenBao endpoint (not a secret) - required: true - type: string - health_url: - description: Public HTTPS health check for this property - required: true - type: string - port: - description: SSH port - required: false - type: number - default: 22 permissions: contents: read @@ -36,7 +18,7 @@ jobs: group: fleet-deploy-${{ github.repository_id }} cancel-in-progress: false steps: - - name: Checkout central deploy tooling and pinned host keys + - name: Checkout central deployment tools, target registry and pinned SSH hosts uses: actions/checkout@v4 with: repository: profullstack/deploy @@ -47,16 +29,5 @@ jobs: uses: actions/setup-node@v4 with: node-version: '22' - - name: Authenticate GitHub OIDC, sign ephemeral SSH key, deploy + - name: GitHub OIDC to OpenBao to ephemeral SSH certificate to deployed app run: node .pfdeploy/bin/oidc-ssh-deploy.mjs - env: - PFDEPLOY_HOST: ${{ inputs.host }} - PFDEPLOY_PORT: ${{ inputs.port }} - PFDEPLOY_OPENBAO_URL: ${{ inputs.openbao_url }} - PFDEPLOY_KNOWN_HOSTS: .pfdeploy/ssh/known_hosts - - name: Verify public service health - env: - HEALTH_URL: ${{ inputs.health_url }} - run: | - case "$HEALTH_URL" in https://*) ;; *) echo "Health URL must use HTTPS" >&2; exit 1;; esac - curl --fail --silent --show-error --retry 5 --retry-delay 3 "$HEALTH_URL" >/dev/null From 7d933d07f8fd159ce4d8d6afc8faf1173367dd67 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 31 Oct 2026 03:17:16 -0700 Subject: [PATCH 08/16] Resolve each deployment from central fleet registry and verify health; use Vault token header --- bin/oidc-ssh-deploy.mjs | 56 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 52 insertions(+), 4 deletions(-) diff --git a/bin/oidc-ssh-deploy.mjs b/bin/oidc-ssh-deploy.mjs index a107a3c..ed04ed9 100644 --- a/bin/oidc-ssh-deploy.mjs +++ b/bin/oidc-ssh-deploy.mjs @@ -15,6 +15,48 @@ 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 || '' @@ -41,11 +83,12 @@ export function validateContext(env) { return { id, sha, repository, ref, host, port, bao: bao.origin, username: 'pfdeploy-' + id } } -async function jsonRequest(url, { bearer, body } = {}) { +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 } : {}) }, + ...(bearer ? { authorization: 'Bearer ' + bearer } : {}), + ...(vaultToken ? { 'x-vault-token': vaultToken } : {}) }, ...(body ? { body: JSON.stringify(body) } : {}), signal: AbortSignal.timeout(15000), }) @@ -63,8 +106,12 @@ async function githubOidc(env) { } 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 = resolve(env.PFDEPLOY_KNOWN_HOSTS || '') + 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') @@ -82,7 +129,7 @@ export async function deploy(env = process.env) { 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, { - bearer: vaultToken, + 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')) { @@ -96,6 +143,7 @@ export async function deploy(env = process.env) { '-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 }) } From 80888352f74790a60c6be527b9438b99b77e12e0 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 31 Oct 2026 03:17:26 -0700 Subject: [PATCH 09/16] Record supplied Profullstack property inventory without guessing repos --- fleet/properties.txt | 90 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 fleet/properties.txt 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 From 784589055cfa21e7aed0895ef0371f6d55d29bc2 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 31 Oct 2026 03:17:29 -0700 Subject: [PATCH 10/16] Add central fleet control plane settings --- fleet/config.json | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 fleet/config.json 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." +} From 5c35a8e8734367845c45caf408d4bc6e8d284fc1 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 31 Oct 2026 03:17:31 -0700 Subject: [PATCH 11/16] Add fail-closed PIT pilot deployment target --- fleet/targets.json | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 fleet/targets.json 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." + } +} From 74c236e6c81b8bd76de862ccfe75b666bbd97f1b Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 31 Oct 2026 03:17:45 -0700 Subject: [PATCH 12/16] Test central repo ID enrollment and disabled deploy target gates --- test/oidc-ssh.test.js | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/test/oidc-ssh.test.js b/test/oidc-ssh.test.js index 53ae720..4177f98 100644 --- a/test/oidc-ssh.test.js +++ b/test/oidc-ssh.test.js @@ -1,6 +1,6 @@ import { describe, it } from 'node:test' import assert from 'node:assert/strict' -import { validateContext, AUDIENCE } from '../bin/oidc-ssh-deploy.mjs' +import { validateContext, resolveFleetTarget, AUDIENCE } from '../bin/oidc-ssh-deploy.mjs' const valid = { GITHUB_REPOSITORY_ID: '1423083790', @@ -37,3 +37,20 @@ describe('central fleet OIDC deployment context', () => { 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)) + }) +}) From cfb6b8f3c27f84f09ba9a716349fd4f082e62588 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 31 Oct 2026 03:18:24 -0700 Subject: [PATCH 13/16] Document centralized OpenBao SSH CA setup, single workflow and staged migration --- docs/fleet-ssh-ca.md | 128 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 docs/fleet-ssh-ca.md diff --git a/docs/fleet-ssh-ca.md b/docs/fleet-ssh-ca.md new file mode 100644 index 0000000..b621fb7 --- /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/ From f6087f544325afad55358dd66aa18c2e1acb9d15 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 31 Oct 2026 03:18:36 -0700 Subject: [PATCH 14/16] Explain fleet-wide keyless GitHub Actions deployment in README --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) 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 From bd139aa793635579716763d438c1cee354aa2d22 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 31 Oct 2026 03:19:30 -0700 Subject: [PATCH 15/16] Syntax-check central enrollment and restricted deploy receiver scripts --- test/fleet-shell.test.js | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 test/fleet-shell.test.js 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)]) + }) + } +}) From 8755d29775fe6f529c53734f0bb21183ad54ee18 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 31 Oct 2026 03:19:41 -0700 Subject: [PATCH 16/16] Fix Markdown code fences and inline code in fleet deployment guide --- docs/fleet-ssh-ca.md | 60 ++++++++++++++++++++++---------------------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/docs/fleet-ssh-ca.md b/docs/fleet-ssh-ca.md index b621fb7..3e1c7ca 100644 --- a/docs/fleet-ssh-ca.md +++ b/docs/fleet-ssh-ca.md @@ -1,15 +1,15 @@ # 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. +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\`. +- `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. @@ -17,7 +17,7 @@ This design covers **all properties** across \`profullstack\`, \`moshcoder\` and 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 +```bash bao auth enable -path=github-actions jwt bao write auth/github-actions/config \ oidc_discovery_url="https://token.actions.githubusercontent.com" \ @@ -25,19 +25,19 @@ bao write auth/github-actions/config \ 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. +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**. +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\`: +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 +```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 @@ -47,11 +47,11 @@ 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: +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 +```sshconfig TrustedUserCAKeys /etc/ssh/profullstack-user-ca.pub AuthorizedPrincipalsFile /etc/ssh/auth_principals/%u @@ -66,21 +66,21 @@ Match User pfdeploy-* 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. +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. +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. +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 +```yaml name: CI and release on: pull_request: @@ -104,23 +104,23 @@ jobs: 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. +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. +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\`. +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. +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. +**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