Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .github/workflows/ssh-deploy.yml
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
63 changes: 63 additions & 0 deletions bin/enroll-repo-openbao.sh
Original file line number Diff line number Diff line change
@@ -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" <<EOF
path "ssh-client-signer/sign/repo-$id" {
capabilities = ["update"]
}
EOF
bao policy write "$policy" "$tmp"

# Bound to THIS repo identity, this protected branch, GitHub push event,
# and only the centrally maintained reusable workflow (not an arbitrary Action).
bao write "auth/github-actions/role/repo-$id" - <<EOF
{
"role_type": "jwt",
"user_claim": "repository_id",
"bound_audiences": ["profullstack-deploy"],
"bound_claims_type": "string",
"bound_claims": {
"repository": ["$repo"],
"repository_id": ["$id"],
"ref": ["refs/heads/$branch"],
"event_name": ["push"],
"job_workflow_ref": ["profullstack/deploy/.github/workflows/ssh-deploy.yml@refs/heads/main"]
},
"token_policies": ["$policy"],
"token_no_default_policy": true,
"token_ttl": "5m",
"token_max_ttl": "5m"
}
EOF

# The signer is not allowed to choose another repository's login principal.
bao write "ssh-client-signer/roles/repo-$id" - <<EOF
{
"key_type": "ca",
"allow_user_certificates": true,
"allowed_users": "pfdeploy-$id",
"default_user": "pfdeploy-$id",
"ttl": "5m",
"max_ttl": "5m",
"allow_user_key_ids": false,
"allowed_extensions": "",
"allowed_critical_options": ""
}
EOF
echo "Enrolled $repo@$branch: OpenBao roles repo-$id, principal pfdeploy-$id"
echo "Next: enroll pfdeploy-$id on its target host and trust the central USER CA public key."
154 changes: 154 additions & 0 deletions bin/oidc-ssh-deploy.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
#!/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'

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 })
}
45 changes: 45 additions & 0 deletions bin/pfdeploy-ssh-receiver
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading