chore(infra): pre-commit validator for cloudflared-config.yml

Adds scripts/validate-cloudflared-config.mjs — a node-only validator
that lint-staged runs whenever cloudflared-config.yml is staged. The
goal is to catch the same failure modes that
`cloudflared tunnel ingress validate` would catch on the server, but
without requiring cloudflared to be installed on every dev box.

Checks:
  - YAML parses
  - tunnel: is a uuid
  - credentials-file: ends with .json and contains the tunnel id
    (warning when it doesn't — likely an out-of-sync remnant from a
    previous rebuild, exactly the failure mode that bit us in the
    first locally-managed switch)
  - ingress: is a non-empty array
  - every rule except the last has both hostname AND service
  - the LAST rule is the catch-all `service: http_status:NNN`
  - no duplicate hostnames (the most common copy-paste mistake)
  - service URLs look like http(s):// / ssh:// / http_status:NNN
    / unix:/ / hello_world
  - hostnames are lowercase dot-separated DNS labels (no spaces, no
    weird characters)

Wired into lint-staged.config.js with a single glob entry; the
existing eslint + prettier flow is unchanged.

Tested against the live cloudflared-config.yml (passes, 51 hostnames)
and a synthetic broken file (catches all 6 categories of error +
the credentials-file/tunnel id drift warning).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-09 18:02:51 +02:00
parent 77b2d1eb32
commit 6c0f88f5a2
2 changed files with 177 additions and 0 deletions

View file

@ -0,0 +1,170 @@
#!/usr/bin/env node
/**
* Validate cloudflared-config.yml without requiring cloudflared to be
* installed on the dev box. Run by lint-staged whenever the file is
* staged so a malformed ingress map can never reach main.
*
* Checks:
* - YAML parses
* - tunnel: is a UUID
* - credentials-file: is set and points at a .json under .cloudflared
* - ingress: is a non-empty array
* - every rule except the last has both `hostname` and `service`
* - the LAST rule is the catch-all `service: http_status:<code>`
* - no duplicate hostnames
* - every `service:` looks like http(s)://, ssh://, http_status:NNN, or unix:
* - no obvious typos in hostnames (must be lowercase, dot-separated, no spaces)
*
* Usage:
* node scripts/validate-cloudflared-config.mjs cloudflared-config.yml
*
* Wired into lint-staged.config.js so a `git commit` that touches
* cloudflared-config.yml automatically runs the validator first.
*/
import { readFileSync } from 'node:fs';
import { parse } from 'yaml';
const RED = '\x1b[31m';
const GREEN = '\x1b[32m';
const YELLOW = '\x1b[33m';
const NC = '\x1b[0m';
const errors = [];
const warnings = [];
function err(msg) {
errors.push(msg);
}
function warn(msg) {
warnings.push(msg);
}
const file = process.argv[2];
if (!file) {
console.error(`${RED}usage: validate-cloudflared-config.mjs <file>${NC}`);
process.exit(2);
}
let raw;
try {
raw = readFileSync(file, 'utf8');
} catch (e) {
console.error(`${RED}cannot read ${file}: ${e.message}${NC}`);
process.exit(2);
}
let doc;
try {
doc = parse(raw);
} catch (e) {
console.error(`${RED}YAML parse error: ${e.message}${NC}`);
process.exit(1);
}
if (!doc || typeof doc !== 'object') {
err('top-level document must be an object');
}
// ─── tunnel: ───────────────────────────────────────────────
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!doc.tunnel) {
err('missing required field `tunnel:`');
} else if (typeof doc.tunnel !== 'string' || !UUID.test(doc.tunnel)) {
err(`tunnel: must be a uuid, got "${doc.tunnel}"`);
}
// ─── credentials-file: ─────────────────────────────────────
if (!doc['credentials-file']) {
err('missing required field `credentials-file:`');
} else if (typeof doc['credentials-file'] !== 'string') {
err('credentials-file: must be a string path');
} else {
const cred = doc['credentials-file'];
if (!cred.endsWith('.json')) {
err(`credentials-file: should end with .json, got "${cred}"`);
}
if (doc.tunnel && !cred.includes(doc.tunnel)) {
warn(
`credentials-file does not contain the tunnel id (${doc.tunnel}) — likely an out-of-sync remnant from a previous tunnel rebuild`
);
}
}
// ─── ingress: ──────────────────────────────────────────────
const ingress = doc.ingress;
if (!Array.isArray(ingress)) {
err('`ingress:` must be an array');
} else if (ingress.length === 0) {
err('`ingress:` is empty — at least the catch-all rule must be present');
} else {
// Last rule must be the catch-all
const last = ingress[ingress.length - 1];
if (last.hostname) {
err(`last ingress rule must be the catch-all (no hostname), got hostname="${last.hostname}"`);
}
if (typeof last.service !== 'string' || !last.service.startsWith('http_status:')) {
err(`last ingress rule must be a catch-all "service: http_status:NNN", got "${last.service}"`);
}
const HOSTNAME = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/;
const SERVICE =
/^(https?:\/\/[^\s]+|ssh:\/\/[^\s]+|http_status:\d{3}|unix:\/[^\s]+|hello_world)$/;
const seen = new Map();
for (let i = 0; i < ingress.length; i++) {
const r = ingress[i];
const isLast = i === ingress.length - 1;
const where = `ingress[${i}]`;
if (!r || typeof r !== 'object') {
err(`${where}: must be an object`);
continue;
}
if (!isLast) {
if (!r.hostname) {
err(`${where}: missing hostname (only the catch-all rule may omit it)`);
} else if (typeof r.hostname !== 'string') {
err(`${where}: hostname must be a string`);
} else if (!HOSTNAME.test(r.hostname)) {
err(
`${where}: hostname "${r.hostname}" looks invalid (lowercase, dot-separated, no spaces)`
);
} else if (seen.has(r.hostname)) {
err(
`${where}: duplicate hostname "${r.hostname}" (also at ingress[${seen.get(r.hostname)}])`
);
} else {
seen.set(r.hostname, i);
}
}
if (r.service == null) {
err(`${where}: missing service`);
} else if (typeof r.service !== 'string') {
err(`${where}: service must be a string`);
} else if (!SERVICE.test(r.service)) {
err(
`${where}: service "${r.service}" doesn't look like http(s)://, ssh://, http_status:NNN, or unix:`
);
}
}
}
// ─── Report ────────────────────────────────────────────────
if (warnings.length > 0) {
for (const w of warnings) console.error(`${YELLOW}warning${NC}: ${w}`);
}
if (errors.length > 0) {
for (const e of errors) console.error(`${RED}error${NC}: ${e}`);
console.error(
`${RED}${NC} ${file}: ${errors.length} error${errors.length === 1 ? '' : 's'}, ${warnings.length} warning${warnings.length === 1 ? '' : 's'}`
);
process.exit(1);
}
const hostCount = (doc.ingress ?? []).filter((r) => r.hostname).length;
console.log(
`${GREEN}${NC} ${file}: ${hostCount} hostnames, ${warnings.length} warning${warnings.length === 1 ? '' : 's'}`
);