Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
17 changes: 12 additions & 5 deletions .github/scripts/deploy.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
const fs = require("fs");
const path = require("path");
const { validateContent } = require("./validate-pr.js");

const API_URL = process.env.SKRIME_API_URL;
const API_KEY = process.env.SKRIME_API_KEY;
Expand Down Expand Up @@ -43,15 +44,21 @@ function collectRecords(domain) {
const label = file.replace(/\.json$/, "");
const name = label === "@" ? sub : `${label}.${sub}`;

const data = JSON.parse(fs.readFileSync(path.join(subDir, file), "utf8"));
const where = `${domain}: ${sub}/${file}`;
let data;
try {
data = JSON.parse(fs.readFileSync(path.join(subDir, file), "utf8"));
} catch (e) {
throw new Error(`${where} is not valid JSON: ${e.message}`);
}
const problems = validateContent(data, label === "@");
if (problems.length) throw new Error(`${where}: ${problems.join("; ")}`);

const recs = data.records || {};
for (const [type, value] of Object.entries(recs)) {
const values = Array.isArray(value) ? value : [value];
for (const v of values) {
if (typeof v !== "string" && typeof v !== "number") {
throw new Error(`${domain}: ${sub}/${file} ${type} value must be a string, got ${JSON.stringify(v)}`);
}
records.push({ name, type, data: String(v) });
records.push({ name, type, data: v });
}
}
}
Expand Down
62 changes: 58 additions & 4 deletions .github/scripts/validate-pr.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
// folder, the PR author must be the owner recorded in that folder's
// `@.json` on the base branch. If not, the PR is closed automatically.

const net = require("net");

const MARKER = "<!-- subdomain-bot -->";

const ALLOWED_RECORD_TYPES = ["A", "AAAA", "CNAME", "ALIAS", "MX", "SRV", "TXT", "CAA"];
Expand Down Expand Up @@ -250,10 +252,7 @@ function validateContent(data, isApex) {
}
}
for (const [t, value] of Object.entries(data.records)) {
const values = Array.isArray(value) ? value : [value];
if (values.length === 0 || values.some((v) => typeof v !== "string" || v.trim() === "")) {
errors.push(`\`${t}\` must be a string or a list of strings (for a record on another name like \`_verify\`, add a separate \`_verify.json\` file)`);
}
if (ALLOWED_RECORD_TYPES.includes(t)) errors.push(...validateValues(t, value));
}
if (types.includes("CNAME") && types.length > 1) {
errors.push("a `CNAME` record cannot be combined with other record types");
Expand All @@ -264,6 +263,58 @@ function validateContent(data, isApex) {
return errors;
}

const HOST = /^(?=.{1,253}\.?$)([a-z0-9_]([a-z0-9_-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}\.?$/i;

// Checks the value(s) of one record type. Returns a list of error strings.
function validateValues(type, value) {
const values = Array.isArray(value) ? value : [value];
if (values.length === 0) return [`\`${type}\` must not be an empty list`];

const errors = [];
for (const v of values) {
if (typeof v !== "string" || v.trim() === "") {
errors.push(`\`${type}\` values must be non-empty strings, got \`${JSON.stringify(v)}\` (for a record on another name like \`_verify\`, add a separate \`_verify.json\` file)`);
continue;
}
const bad = (hint) => errors.push(`\`${type}\` value \`${v}\` is invalid: ${hint}`);
switch (type) {
case "A":
if (!net.isIPv4(v)) bad("expected an IPv4 address like `185.199.108.153`");
break;
case "AAAA":
if (!net.isIPv6(v)) bad("expected an IPv6 address like `2606:50c0:8000::153`");
break;
case "CNAME":
case "ALIAS":
if (!HOST.test(v)) bad("expected a hostname like `target.example.net`");
break;
case "MX": {
const m = v.match(/^(\d{1,5}) (\S+)$/);
if (!m || Number(m[1]) > 65535 || !HOST.test(m[2])) bad("expected `<priority> <host>`, e.g. `10 mail.example.net`");
break;
}
case "SRV": {
const m = v.match(/^(\d{1,5}) (\d{1,5}) (\d{1,5}) (\S+)$/);
if (!m || m.slice(1, 4).some((n) => Number(n) > 65535) || !(m[4] === "." || HOST.test(m[4]))) {
bad("expected `<priority> <weight> <port> <target>`, e.g. `10 5 25565 mc.example.net`");
}
break;
}
case "CAA":
if (!/^\d{1,3} (issue|issuewild|iodef) \S+$/.test(v)) bad("expected `<flags> <tag> <value>`, e.g. `0 issue letsencrypt.org`");
break;
case "TXT":
if (/^".*"$/.test(v)) bad("leave out the surrounding quotes, they are added automatically");
else if (v.length > 2048) bad("longer than 2048 characters");
break;
}
}
if ((type === "CNAME" || type === "ALIAS") && values.length > 1) {
errors.push(`\`${type}\` must be a single hostname, not a list`);
}
return errors;
}

async function listDomains(github, owner, repo, ref) {
try {
const res = await github.rest.repos.getContent({ owner, repo, path: "domains", ref });
Expand Down Expand Up @@ -313,3 +364,6 @@ async function setLabels(github, owner, repo, issue_number, labels) {
} catch (e) { /* ignore */ }
await github.rest.issues.addLabels({ owner, repo, issue_number, labels }).catch(() => {});
}

// Reused by deploy.js so a broken file can never reach the DNS API.
module.exports.validateContent = validateContent;
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ Netlify, a server IP, …) at it and you're online.
| `@.json` must contain an `owner.github` | Anchors who owns the folder |
| At least one record under `records` | A subdomain has to point somewhere |
| `CNAME` cannot be combined with other record types | DNS spec |
| Every value is a plain string in the right format (`A` = IPv4, `AAAA` = IPv6, `CNAME` = one hostname, `MX` = `10 mail.example.net`, `TXT` without quotes) | Broken values would make the whole zone fail to deploy |
| A record on another name (e.g. `_railway-verify`) goes in its own file `<subdomain>/_railway-verify.json` | One file = one DNS name |
| Names like `www`, `api`, `mail`, `ns1` … are reserved | Infrastructure protection |

---
Expand Down
Loading