From 6c0f88f5a2694fa1ff1feeb946c1ca8aa881a2b8 Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 9 Apr 2026 18:02:51 +0200 Subject: [PATCH] chore(infra): pre-commit validator for cloudflared-config.yml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- lint-staged.config.js | 7 + scripts/validate-cloudflared-config.mjs | 170 ++++++++++++++++++++++++ 2 files changed, 177 insertions(+) create mode 100755 scripts/validate-cloudflared-config.mjs diff --git a/lint-staged.config.js b/lint-staged.config.js index abbbbfbbf..9346c4d23 100644 --- a/lint-staged.config.js +++ b/lint-staged.config.js @@ -4,4 +4,11 @@ export default { 'prettier --config .prettierrc.json --write', ], '*.{json,md,svelte,astro}': ['prettier --config .prettierrc.json --write'], + // Validate the tunnel config locally so a malformed ingress map can + // never reach main. The validator runs entirely in node (no + // cloudflared CLI dependency on the dev box) and catches the same + // failure modes that `cloudflared tunnel ingress validate` would + // catch on the server: bad YAML, missing tunnel id, duplicate + // hostnames, missing catch-all, malformed service URLs. + 'cloudflared-config.yml': ['node scripts/validate-cloudflared-config.mjs'], }; diff --git a/scripts/validate-cloudflared-config.mjs b/scripts/validate-cloudflared-config.mjs new file mode 100755 index 000000000..fb293bfa6 --- /dev/null +++ b/scripts/validate-cloudflared-config.mjs @@ -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:` + * - 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 ${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'}` +);