mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 01:21:09 +02:00
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>
170 lines
5.6 KiB
JavaScript
Executable file
170 lines
5.6 KiB
JavaScript
Executable file
#!/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'}`
|
|
);
|