Compare commits

...
+62
View File
@@ -0,0 +1,62 @@
name: cf-mint-tunnel
# Sibling of cf-mint-token: mints a narrowly scoped Cloudflare API token using the broad CROWD_FAIR
# secret — Cloudflare Tunnel write on the account, DNS write on one zone — then seals it to the
# caller's RSA public key so only the caller can read it. The plaintext token never appears in logs.
on:
workflow_dispatch:
inputs:
pubkey_b64:
description: base64 of a PEM RSA public key (>=2048 bits)
required: true
name:
description: token name
required: true
default: hb5-tunnels
zone:
description: zone name the token is scoped to
required: true
default: teahub.xyz
jobs:
mint:
runs-on: ubuntu-latest
steps:
- name: mint and seal
env:
CF: ${{ secrets.CROWD_FAIR }}
PUB: ${{ inputs.pubkey_b64 }}
TNAME: ${{ inputs.name }}
ZNAME: ${{ inputs.zone }}
run: |
cat > /tmp/mint.mjs <<'EOF'
import crypto from 'node:crypto';
const H = { Authorization: `Bearer ${process.env.CF}`, 'Content-Type': 'application/json' };
const api = async (p, o = {}) => { const r = await fetch('https://api.cloudflare.com/client/v4/' + p, { headers: H, ...o }); return r.json(); };
const z = (await api('zones?name=' + process.env.ZNAME)).result?.[0];
if (!z) throw new Error('zone not visible to CROWD_FAIR');
const A = z.account.id, Z = z.id;
let base = 'user/tokens';
let pg = await api(base + '/permission_groups');
if (!pg.success) { base = `accounts/${A}/tokens`; pg = await api(base + '/permission_groups'); }
if (!pg.success) throw new Error('cannot list permission groups: ' + JSON.stringify(pg.errors));
const want = {
acct: ['Cloudflare Tunnel Write', 'Account Settings Read'],
zone: ['DNS Write', 'Zone Read'],
};
const pick = (names) => names.map((n) => { const g = pg.result.find((x) => x.name === n); if (!g) console.log('missing group:', n); return g && { id: g.id }; }).filter(Boolean);
const acct = pick(want.acct);
if (!acct.length) { console.log('tunnel-ish groups:', pg.result.filter((x) => /tunnel/i.test(x.name)).map((x) => x.name).join(' | ')); throw new Error('no tunnel permission group found'); }
const body = {
name: process.env.TNAME,
policies: [
{ effect: 'allow', resources: { [`com.cloudflare.api.account.${A}`]: '*' }, permission_groups: acct },
{ effect: 'allow', resources: { [`com.cloudflare.api.account.zone.${Z}`]: '*' }, permission_groups: pick(want.zone) },
],
};
const t = await api(base, { method: 'POST', body: JSON.stringify(body) });
if (!t.success) throw new Error('create failed: ' + JSON.stringify(t.errors));
const pub = Buffer.from(process.env.PUB, 'base64').toString();
const sealed = crypto.publicEncrypt({ key: pub, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256' }, Buffer.from(t.result.value));
console.log('account', A, 'zone', Z, 'token id', t.result.id, 'via', base);
console.log('SEALED=' + sealed.toString('base64'));
EOF
node /tmp/mint.mjs