is-a.bot

Grab your own sweet-looking .i...
Log | Files | Refs | Activity | README | LICENSE

commit 9eeef28e965f7ebe26dc6ba4f018d575425b069c
parent 5f502b3966d92e1e72815c3a37f712d3fa52394a
Author: William Harrison <87287585+wdhdev@users.noreply.github.com>
Date:   Wed,  5 Aug 2026 20:03:41 +0800

feat: ci

Diffstat:
A.github/CODEOWNERS | 1+
A.github/workflows/ci.yml | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
A.github/workflows/dnscontrol.yml | 27+++++++++++++++++++++++++++
A.github/workflows/publish.yml | 34++++++++++++++++++++++++++++++++++
Adnsconfig.js | 160+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/domains.test.js | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/json.test.js | 180+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/pr.test.js | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/proxy.test.js | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/records.test.js | 291++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Autil/disallowed-cnames.json | 17+++++++++++++++++
Autil/internal.json | 3+++
Autil/reserved.json | 150+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Autil/trusted.json | 7+++++++
14 files changed, 1156 insertions(+), 0 deletions(-)

diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @wdhdev diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml @@ -0,0 +1,55 @@ +name: CI + +on: + pull_request: + + push: + branches: [main] + paths: + - "domains/*" + - "tests/*" + - "util/*" + - ".github/workflows/ci.yml" + + workflow_dispatch: + +concurrency: + group: ${{ github.ref }}-ci + +jobs: + tests: + name: Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - run: npm ci + + - name: Fetch PR information + if: github.event_name == 'pull_request' + run: | + echo "PR_AUTHOR=${{ github.event.pull_request.user.login }}" >> $GITHUB_ENV + echo "PR_AUTHOR_ID=${{ github.event.pull_request.user.id }}" >> $GITHUB_ENV + + LABELS=$(gh api --jq '[.labels[].name]' /repos/{owner}/{repo}/pulls/${{ github.event.number }}) + echo "PR_LABELS=$LABELS" >> $GITHUB_ENV + + FILES=$(gh api --jq '[.[] | select(.status != "removed") | .filename]' /repos/{owner}/{repo}/pulls/${{ github.event.number }}/files) + echo "CHANGED_FILES=$FILES" >> $GITHUB_ENV + + REMOVED_FILE_DATA=$(gh api --jq '[.[] | select(.status == "removed") | {name: .filename, data: .patch}]' /repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files) + echo "DELETED_FILES=$REMOVED_FILE_DATA" >> $GITHUB_ENV + env: + GH_TOKEN: ${{ github.token }} + + - uses: gacts/install-dnscontrol@890ba3249712c51203ccb7aba63307c0091e921b # v1.3.4 + if: github.event_name == 'pull_request' && contains(fromJson(env.CHANGED_FILES), 'dnsconfig.js') + with: + version: 4.14.0 + + - name: Run DNSControl checks + run: dnscontrol check + if: github.event_name == 'pull_request' && contains(fromJson(env.CHANGED_FILES), 'dnsconfig.js') + + - name: Run tests + run: npx ava tests/*.test.js --timeout=1m diff --git a/.github/workflows/dnscontrol.yml b/.github/workflows/dnscontrol.yml @@ -0,0 +1,27 @@ +name: DNSControl + +on: + push: + branches: [main] + paths: + - ".github/workflows/dnscontrol.yml" + - "dnsconfig.js" + + workflow_dispatch: + +concurrency: + group: ${{ github.ref }}-dnscontrol + +jobs: + tests: + name: Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - uses: gacts/install-dnscontrol@890ba3249712c51203ccb7aba63307c0091e921b # v1.3.4 + with: + version: 4.14.0 + + - name: Run DNSControl checks + run: dnscontrol check diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml @@ -0,0 +1,34 @@ +name: Publish + +on: + push: + branches: [main] + paths: + - "domains/*" + - ".github/workflows/publish.yml" + - "util/reserved.json" + - "dnsconfig.js" + + workflow_dispatch: + +concurrency: + group: ${{ github.ref }}-publish + +jobs: + dns: + name: DNS + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - uses: gacts/install-dnscontrol@890ba3249712c51203ccb7aba63307c0091e921b # v1.3.4 + with: + version: 4.14.0 + + - name: Generate creds.json + run: echo '{"cloudflare":{"TYPE":"CLOUDFLAREAPI","apitoken":"$CLOUDFLARE_API_TOKEN"}}' > ./creds.json + + - name: Push DNS records + run: dnscontrol push + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} diff --git a/dnsconfig.js b/dnsconfig.js @@ -0,0 +1,160 @@ +var domainName = "is-a.bot"; +var registrar = NewRegistrar("none"); +var dnsProvider = DnsProvider(NewDnsProvider("cloudflare")); + +function getDomainsList(filesPath) { + var result = []; + var files = glob.apply(null, [filesPath, true, ".json"]); + + for (var i = 0; i < files.length; i++) { + var name = files[i] + .split("/") + .pop() + .replace(/\.json$/, ""); + + result.push({ name: name, data: require(files[i]) }); + } + + return result; +} + +var domains = getDomainsList("./domains"); +var records = []; + +for (var subdomain in domains) { + var subdomainName = domains[subdomain].name; + var data = domains[subdomain].data; + var proxyState = data.proxied ? CF_PROXY_ON : CF_PROXY_OFF; + + // Handle A records + if (data.records.A) { + for (var a in data.records.A) { + records.push(A(subdomainName, IP(data.records.A[a]), proxyState)); + } + } + + // Handle AAAA records + if (data.records.AAAA) { + for (var aaaa in data.records.AAAA) { + records.push(AAAA(subdomainName, data.records.AAAA[aaaa], proxyState)); + } + } + + // Handle CAA records + if (data.records.CAA) { + for (var caa in data.records.CAA) { + var caaRecord = data.records.CAA[caa]; + records.push(CAA(subdomainName, caaRecord.tag, caaRecord.value)); + } + } + + // Handle CNAME records + if (data.records.CNAME) { + records.push(ALIAS(subdomainName, data.records.CNAME + ".", proxyState)); + } + + // Handle DS records + if (data.records.DS) { + for (var ds in data.records.DS) { + var dsRecord = data.records.DS[ds]; + records.push( + DS(subdomainName, dsRecord.key_tag, dsRecord.algorithm, dsRecord.digest_type, dsRecord.digest) + ); + } + } + + // Handle MX records + if (data.records.MX) { + for (var mx in data.records.MX) { + var mxRecord = data.records.MX[mx]; + + if (typeof mxRecord === "string") { + records.push( + MX(subdomainName, 10 + parseInt(mx), data.records.MX[mx] + ".") + ); + } else { + records.push( + MX( + subdomainName, + parseInt(mxRecord.priority), + mxRecord.target + "." + ) + ); + } + } + } + + // Handle NS records + if (data.records.NS) { + for (var ns in data.records.NS) { + records.push(NS(subdomainName, data.records.NS[ns] + ".")); + } + } + + // Handle SRV records + if (data.records.SRV) { + for (var srv in data.records.SRV) { + var srvRecord = data.records.SRV[srv]; + records.push( + SRV(subdomainName, srvRecord.priority, srvRecord.weight, srvRecord.port, srvRecord.target + ".") + ); + } + } + + // Handle TLSA records + if (data.records.TLSA) { + for (var tlsa in data.records.TLSA) { + var tlsaRecord = data.records.TLSA[tlsa]; + + records.push( + TLSA( + subdomainName, + tlsaRecord.usage, + tlsaRecord.selector, + tlsaRecord.matching_type, + tlsaRecord.certificate + ) + ); + } + } + + // Handle TXT records + if (data.records.TXT) { + if (Array.isArray(data.records.TXT)) { + for (var txt in data.records.TXT) { + records.push(TXT(subdomainName, data.records.TXT[txt].length <= 255 ? "\"" + data.records.TXT[txt] + "\"" : data.records.TXT[txt])); + } + } else { + records.push(TXT(subdomainName, data.records.TXT.length <= 255 ? "\"" + data.records.TXT + "\"" : data.records.TXT)); + } + } + + // Handle URL records + if (data.records.URL) { + records.push(A(subdomainName, IP("192.0.2.1"), CF_PROXY_ON)); + } +} + +// Zone last updated TXT record +records.push(TXT("_zone-updated", "\"" + Date.now().toString() + "\"")); + +var ignored = [ + IGNORE("\\*", "A"), + IGNORE("*._domainkey", "TXT"), + IGNORE("@", "*"), + IGNORE("_acme-challenge", "TXT"), + IGNORE("_dmarc", "TXT"), + IGNORE("_gh-free-domains-o", "TXT"), + IGNORE("_gh-free-domains-o.**", "TXT"), + IGNORE("_github-pages-challenge-free-domains", "TXT"), + IGNORE("_github-pages-challenge-free-domains.**", "TXT"), + IGNORE("_psl", "TXT") +]; + +var internal = require("./util/internal.json"); + +internal.forEach(function(subdomain) { + ignored.push(IGNORE(subdomain, "*")); +}); + +D(domainName, registrar, dnsProvider, records, ignored); diff --git a/tests/domains.test.js b/tests/domains.test.js @@ -0,0 +1,68 @@ +import t from "ava"; +import fs from "fs-extra"; +import path from "path"; + +const domainsPath = path.resolve("domains"); +const files = fs.readdirSync(domainsPath).filter((file) => file.endsWith(".json")); + +const domainCache = {}; + +function getDomainData(subdomain) { + if (domainCache[subdomain]) { + return domainCache[subdomain]; + } + + try { + const data = fs.readJsonSync(path.join(domainsPath, `${subdomain}.json`)); + domainCache[subdomain] = data; // Cache the domain data + return data; + } catch (error) { + throw new Error(`Failed to read JSON for ${subdomain}: ${error.message}`); + } +} + +t("Nested subdomains should not exist without a parent subdomain", (t) => { + files.forEach((file) => { + const subdomain = file.replace(/\.json$/, ""); + const parts = subdomain.split("."); + + for (let i = 1; i < parts.length; i++) { + const parent = parts.slice(i).join("."); + if (parent.startsWith("_")) continue; + + t.true(files.includes(`${parent}.json`), `${file}: Parent subdomain "${parent}" does not exist`); + } + }); +}); + +t("Nested subdomains should not exist if any parent subdomain has NS records", (t) => { + files.forEach((file) => { + const subdomain = file.replace(/\.json$/, ""); + const parts = subdomain.split("."); + + for (let i = 1; i < parts.length; i++) { + const parent = parts.slice(i).join("."); + if (parent.startsWith("_") || !files.includes(`${parent}.json`)) continue; + const parentData = getDomainData(parent); + + t.true(!parentData.records.NS, `${file}: Parent subdomain "${parent}" has NS records`); + } + }); +}); + +t("Nested subdomains should be owned by the parent subdomain's owner", (t) => { + files.forEach((file) => { + const subdomain = file.replace(/\.json$/, ""); + const parentDomain = subdomain.split(".").reverse()[0]; + + if (parentDomain !== subdomain) { + const data = getDomainData(subdomain); + const parentData = getDomainData(parentDomain); + + t.true( + data.owner.username.toLowerCase() === parentData.owner.username.toLowerCase(), + `${file}: Owner does not match the parent subdomain` + ); + } + }); +}); diff --git a/tests/json.test.js b/tests/json.test.js @@ -0,0 +1,180 @@ +import t from "ava"; +import fs from "fs-extra"; +import path from "path"; + +const internalDomains = fs.readJsonSync(path.resolve("util/internal.json")); +const reservedDomains = fs.readJsonSync(path.resolve("util/reserved.json")); + +const ignoredRootJSONFiles = ["package-lock.json", "package.json"]; + +const requiredFields = { + owner: "object", + records: "object" +}; + +const optionalFields = { + proxied: "boolean" +}; + +const requiredOwnerFields = { + username: "string" +}; + +const optionalOwnerFields = { + email: "string" +}; + +const blockedFields = [ + "domain", + "internal", + "proxy", + "reserved", + "services", + "subdomain", + "nested", + "record", + "redirect_config", + "redirects", + "redirects_config" +]; + +const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; +const hostnameRegex = /^(?=.{1,253}$)(?:(?:[_a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)\.)+[a-zA-Z]{2,63}$/; + +const domainsPath = path.resolve("domains"); +const files = fs.readdirSync(domainsPath); + +function findDuplicateKeys(jsonString) { + const duplicateKeys = new Set(); + const keyStack = []; + + const keyRegex = /"(.*?)"\s*:/g; + + let i = 0; + while (i < jsonString.length) { + const char = jsonString[i]; + + if (char === "{") { + keyStack.push({}); + i++; + continue; + } + + if (char === "}") { + keyStack.pop(); + i++; + continue; + } + + keyRegex.lastIndex = i; + const match = keyRegex.exec(jsonString); + if (match && match.index === i && keyStack.length > 0) { + const key = match[1]; + const currentScope = keyStack[keyStack.length - 1]; + + if (currentScope[key]) { + duplicateKeys.add(key); + } else { + currentScope[key] = true; + } + + i = keyRegex.lastIndex; + } else { + i++; + } + } + + return [...duplicateKeys]; +} + +async function validateFields(t, obj, fields, file, prefix = "") { + for (const key of Object.keys(fields)) { + const fieldPath = prefix ? `${prefix}.${key}` : key; + + if (obj.hasOwnProperty(key)) { + t.is(typeof obj[key], fields[key], `${file}: Field ${fieldPath} should be of type ${fields[key]}`); + } else if (fields === requiredFields || fields === requiredOwnerFields) { + t.true(false, `${file}: Missing required field: ${fieldPath}`); + } + } +} + +async function validateFileName(t, file) { + t.true(file.endsWith(".json"), `${file}: File does not have .json extension`); + t.false(file.includes(".is-a.bot"), `${file}: File name should not contain .is-a.bot`); + t.true(file === file.toLowerCase(), `${file}: File name should be all lowercase`); + t.false(file.includes("--"), `${file}: File name should not contain consecutive hyphens`); + + const subdomain = file.replace(/\.json$/, ""); + + t.regex( + subdomain + ".is-a.bot", + hostnameRegex, + `${file}: FQDN must be 1-253 characters, can use letters, numbers, dots, and non-consecutive hyphens.` + ); + t.false(internalDomains.includes(subdomain), `${file}: Subdomain name is registered internally`); + t.false(reservedDomains.includes(subdomain), `${file}: Subdomain name is reserved`); + t.true( + !internalDomains.some((i) => subdomain.endsWith(`.${i}`)), + `${file}: Subdomain name is registered internally` + ); + t.true(!reservedDomains.some((r) => subdomain.endsWith(`.${r}`)), `${file}: Subdomain name is reserved`); + + const rootSubdomain = subdomain.split(".").pop(); + t.false(rootSubdomain.startsWith("_"), `${file}: Root subdomains should not start with an underscore`); +} + +async function processFile(file, t) { + const filePath = path.join(domainsPath, file); + const data = await fs.readJson(filePath); + + validateFileName(t, file); + + // Check for duplicate keys + const rawData = await fs.readFile(filePath, "utf8"); + const duplicateKeys = findDuplicateKeys(rawData); + t.true(!duplicateKeys.length, `${file}: Duplicate keys found: ${duplicateKeys.join(", ")}`); + + // Validate fields + validateFields(t, data, requiredFields, file); + validateFields(t, data.owner, requiredOwnerFields, file, "owner"); + validateFields(t, data.owner, optionalOwnerFields, file, "owner"); + validateFields(t, data, optionalFields, file); + + if (data.owner.email) { + t.regex(data.owner.email, emailRegex, `${file}: Owner email should be a valid email address`); + t.false( + data.owner.email.endsWith("@users.noreply.github.com"), + `${file}: Owner email should not be a GitHub no-reply email` + ); + } + + t.true(Object.keys(data.records).length > 0, `${file}: Missing DNS records`); + + for (const field of blockedFields) { + t.true(!data.hasOwnProperty(field), `${file}: Disallowed field: ${field}`); + } +} + +t("JSON files should not be in the root directory", (t) => { + const rootFiles = fs + .readdirSync(path.resolve()) + .filter((file) => file.endsWith(".json") && !ignoredRootJSONFiles.includes(file)); + t.is(rootFiles.length, 0, "JSON files should not be in the root directory"); +}); + +t("All files should be valid JSON", async (t) => { + await Promise.all( + files.map((file) => { + return t.notThrows(() => fs.readJson(path.join(domainsPath, file)), `${file}: Invalid JSON file`); + }) + ); +}); + +t("All files should have valid file names", async (t) => { + await Promise.all(files.map((file) => validateFileName(t, file))); +}); + +t("All files should have valid required and optional fields", async (t) => { + await Promise.all(files.map((file) => processFile(file, t))); +}); diff --git a/tests/pr.test.js b/tests/pr.test.js @@ -0,0 +1,81 @@ +import t from "ava"; +import fs from "fs-extra"; +import path from "path"; + +import trustedUsers from "../util/trusted.json" with { type: "json" }; + +const requiredEnvVars = ["PR_AUTHOR", "PR_AUTHOR_ID"]; + +const trusted = trustedUsers.map((u) => u.id.toString()); +const admins = trustedUsers.filter((u) => u.admin).map((u) => u.id.toString()); + +function getDomainData(subdomain) { + try { + const data = fs.readJsonSync(path.join(path.resolve("domains"), `${subdomain}.json`)); + return data; + } catch (error) { + throw new Error(`Failed to read JSON for ${subdomain}: ${error.message}`); + } +} + +t("Users can only update their own subdomains", (t) => { + if (requiredEnvVars.every((v) => process.env[v])) { + const changedFiles = JSON.parse(process.env.CHANGED_FILES); + const deletedFiles = JSON.parse(process.env.DELETED_FILES); + const prAuthor = process.env.PR_AUTHOR.toLowerCase(); + const prAuthorId = process.env.PR_AUTHOR_ID; + + const changedJSONFiles = changedFiles + .filter((file) => file.startsWith("domains/")) + .map((file) => path.basename(file)); + const deletedJSONFiles = deletedFiles + .filter((file) => file.name.startsWith("domains/")) + .map((file) => path.basename(file.name)); + + if (!changedJSONFiles && !deletedFiles) return t.pass(); + if (process.env.PR_LABELS && process.env.PR_LABELS.includes("ci: bypass-owner-check")) return t.pass(); + + changedJSONFiles.forEach((file) => { + const subdomain = file.replace(/\.json$/, ""); + const data = getDomainData(subdomain); + + if (data.owner.username === "free-domains") { + t.true( + admins.includes(prAuthorId), + `${subdomain}: ${prAuthor} is not authorized to update ${subdomain}.is-a.bot` + ); + } else { + t.true( + data.owner.username.toLowerCase() === prAuthor || trusted.includes(prAuthorId), + `${file}: ${prAuthor} is not authorized to update ${subdomain}.is-a.bot` + ); + } + }); + + deletedJSONFiles.forEach((file) => { + const subdomain = file.replace(/\.json$/, ""); + const data = JSON.parse( + deletedFiles + .find((f) => f.name === `domains/${file}`) + .data.split("\n") + .filter((line) => line.startsWith("-") && !line.startsWith("---")) + .map((line) => line.substring(1)) + .join("\n") + ); + + if (data.owner.username === "free-domains") { + t.true( + admins.includes(prAuthorId), + `${subdomain}: ${prAuthor} is not authorized to delete ${subdomain}.is-a.bot` + ); + } else { + t.true( + data.owner.username.toLowerCase() === prAuthor || trusted.includes(prAuthorId), + `${file}: ${prAuthor} is not authorized to delete ${subdomain}.is-a.bot` + ); + } + }); + } + + t.pass(); +}); diff --git a/tests/proxy.test.js b/tests/proxy.test.js @@ -0,0 +1,82 @@ +import t from "ava"; +import fs from "fs-extra"; +import path from "path"; + +const requiredRecordsToProxy = new Set(["A", "AAAA", "CNAME"]); + +const domainCache = {}; + +function getDomainData(file) { + if (domainCache[file]) { + return domainCache[file]; + } + + try { + const data = fs.readJsonSync(path.join(domainsPath, file)); + domainCache[file] = data; + return data; + } catch (error) { + throw new Error(`Failed to read JSON for ${file}: ${error.message}`); + } +} + +function validateProxiedRecords(t, data, file) { + const recordTypes = Array.from(requiredRecordsToProxy).join(", "); + + if (data.proxied) { + const hasProxiedRecord = Object.keys(data.records).some((key) => requiredRecordsToProxy.has(key)); + + t.true( + hasProxiedRecord, + `${file}: Proxied is true but there are no records that can be proxied (${recordTypes} expected)` + ); + } +} + +const domainsPath = path.resolve("domains"); +const files = fs.readdirSync(domainsPath).filter((file) => file.endsWith(".json")); + +t("Domains with proxy enabled must have at least one proxy-able record", (t) => { + files.forEach((file) => { + const data = getDomainData(file); + + validateProxiedRecords(t, data, file); + }); +}); + +const unproxyable = [ + { + type: "CNAME", + value: "*.onrender.com" + } +]; + +t("Domains with specific records must not have proxy enabled", (t) => { + files.forEach((file) => { + const data = getDomainData(file); + + if (data.proxied) { + unproxyable.forEach((record) => { + let recordExists = false; + + if (!data.records[record.type]) { + t.pass(); + return; + } else if (Array.isArray(data.records[record.type])) { + recordExists = data.records[record.type].some((r) => + record.value.startsWith("*.") ? r.endsWith(record.value.slice(2)) : r === record.value + ); + } else { + recordExists = record.value.startsWith("*.") + ? data.records[record.type].endsWith(record.value.slice(2)) + : data.records[record.type] === record.value; + } + + t.false( + recordExists, + `${file}: Records matching \`${record.type}: "${record.value}"\` cannot be proxied` + ); + }); + } + }); +}); diff --git a/tests/records.test.js b/tests/records.test.js @@ -0,0 +1,291 @@ +import t from "ava"; +import fs from "fs-extra"; +import path from "path"; + +import disallowedCNAMEs from "../util/disallowed-cnames.json" with { type: "json" }; + +const validRecordTypes = new Set(["A", "AAAA", "CAA", "CNAME", "DS", "MX", "NS", "SRV", "TLSA", "TXT"]); +const hostnameRegex = /^(?=.{1,253}$)(?:(?:[_a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)\.)+[a-zA-Z]{2,63}$/; +const ipv4Regex = /^(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(\.(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3}$/; +const ipv6Regex = + /^(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$|^::(?:[0-9a-fA-F]{1,4}:){0,6}[0-9a-fA-F]{1,4}$|^(?:[0-9a-fA-F]{1,4}:){1,7}:$|^(?:[0-9a-fA-F]{1,4}:){0,6}::(?:[0-9a-fA-F]{1,4}:){0,5}[0-9a-fA-F]{1,4}$/; + +const domainsPath = path.resolve("domains"); +const files = fs.readdirSync(domainsPath).filter((file) => file.endsWith(".json")); + +const domainCache = {}; + +function getDomainData(file) { + if (domainCache[file]) { + return domainCache[file]; + } + + try { + const data = fs.readJsonSync(path.join(domainsPath, file)); + domainCache[file] = data; + return data; + } catch (error) { + throw new Error(`Failed to read JSON for ${file}: ${error.message}`); + } +} + +function expandIPv6(ip) { + let segments = ip.split(":"); + const emptyIndex = segments.indexOf(""); + + if (emptyIndex !== -1) { + const nonEmptySegments = segments.filter((seg) => seg !== ""); + const missingSegments = 8 - nonEmptySegments.length; + + segments = [ + ...nonEmptySegments.slice(0, emptyIndex), + ...Array(missingSegments).fill("0000"), + ...nonEmptySegments.slice(emptyIndex) + ]; + } + + return segments.map((segment) => segment.padStart(4, "0")).join(":"); +} + +function validateIPv4(ip, proxied) { + const parts = ip.split(".").map(Number); + + if (parts.length !== 4 || parts.some((part) => isNaN(part) || part < 0 || part > 255)) return false; + if (ip === "192.0.2.1" && proxied) return true; + + return !( + parts[0] === 10 || + (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) || + (parts[0] === 192 && parts[1] === 168) || + (parts[0] === 100 && parts[1] >= 64 && parts[1] <= 127) || + (parts[0] === 169 && parts[1] === 254) || + (parts[0] === 192 && parts[1] === 0 && parts[2] === 0) || + (parts[0] === 192 && parts[1] === 0 && parts[2] === 2) || + (parts[0] === 198 && parts[1] === 18) || + (parts[0] === 198 && parts[1] === 51 && parts[2] === 100) || + (parts[0] === 203 && parts[1] === 0 && parts[2] === 113) || + parts[0] >= 224 + ); +} + +function validateIPv6(ip) { + return !( + ip.toLowerCase().startsWith("fc") || + ip.toLowerCase().startsWith("fd") || + ip.toLowerCase().startsWith("fe80") || + ip.toLowerCase().startsWith("::1") || + ip.toLowerCase().startsWith("2001:db8") + ); +} + +function validateRecordType(recordType) { + return validRecordTypes.has(recordType); +} + +function isValidHostname(hostname) { + return hostnameRegex.test(hostname); +} + +function isValidHexadecimal(value) { + return /^[0-9a-fA-F]+$/.test(value); +} + +function validateRecordValues(t, data, file) { + const subdomain = file.replace(/\.json$/, ""); + + Object.entries(data.records).forEach(([key, value]) => { + // General validation for arrays + if (["A", "AAAA", "MX", "NS"].includes(key)) { + t.true(Array.isArray(value), `${file}: Record value for ${key} should be an array`); + + value.forEach((record, idx) => { + t.true( + typeof record === "string" || typeof record === "object", + `${file}: Record value for ${key} should be a string or an object at index ${idx}` + ); + + if (key === "A") { + t.true(ipv4Regex.test(record), `${file}: Invalid IPv4 address for ${key} at index ${idx}`); + t.true( + validateIPv4(record, data.proxied), + `${file}: Invalid IPv4 address for ${key} at index ${idx}` + ); + } else if (key === "AAAA") { + const expandedIPv6 = expandIPv6(record); + t.true(ipv6Regex.test(expandedIPv6), `${file}: Invalid IPv6 address for ${key} at index ${idx}`); + t.true(validateIPv6(expandedIPv6), `${file}: Invalid IPv6 address for ${key} at index ${idx}`); + } else if (key === "MX") { + t.true( + typeof record === "object" || typeof record === "string", + `${file}: Record value for ${key} should be an object or a string at index ${idx}` + ); + + if (typeof record === "string") { + t.true(isValidHostname(record), `${file}: Invalid hostname for ${key} at index ${idx}`); + } else { + t.true(isValidHostname(record.target), `${file}: Invalid target for ${key} at index ${idx}`); + t.true( + Number.isInteger(record.priority) && record.priority >= 0 && record.priority <= 65535, + `${file}: Invalid priority for ${key} at index ${idx}` + ); + } + } else if (key === "NS") { + t.true(isValidHostname(record), `${file}: Invalid hostname for ${key} at index ${idx}`); + } + }); + } + + // CNAME validation + if (key === "CNAME") { + t.true(typeof value === "string", `${file}: Record value for ${key} should be a string`); + + t.true(isValidHostname(value), `${file}: Invalid hostname for ${key}`); + t.true(value !== `${subdomain}.is-a.dev`, `${file}: ${key} cannot point to itself`); + t.true(value !== "is-a.dev", `${file}: ${key} cannot point to is-a.dev`); + + for (const disallowed of disallowedCNAMEs) { + if (disallowed.startsWith(".")) { + t.false(value.endsWith(disallowed), `${file}: ${key} cannot end with ${disallowed}`); + } else { + t.false(value === disallowed, `${file}: ${key} cannot be ${disallowed}`); + } + } + } + + // CAA, DS, SRV, TLSA validations + if (["CAA", "DS", "SRV", "TLSA"].includes(key)) { + t.true(Array.isArray(value), `${file}: Record value for ${key} should be an array`); + + value.forEach((record, idx) => { + t.true( + typeof record === "object", + `${file}: Record value for ${key} should be an object at index ${idx}` + ); + + if (key === "CAA") { + t.true( + ["issue", "issuewild", "iodef"].includes(record.tag), + `${file}: Invalid tag for ${key} at index ${idx}` + ); + t.true(typeof record.value === "string", `${file}: Invalid value for ${key} at index ${idx}`); + t.true( + isValidHostname(record.value) || record.value === ";", + `${file}: Value must be a hostname or semicolon for ${key} at index ${idx}` + ); + } else if (key === "DS") { + t.true( + Number.isInteger(record.key_tag) && record.key_tag >= 0 && record.key_tag <= 65535, + `${file}: Invalid key_tag for ${key} at index ${idx}` + ); + t.true( + Number.isInteger(record.algorithm) && record.algorithm >= 0 && record.algorithm <= 255, + `${file}: Invalid algorithm for ${key} at index ${idx}` + ); + t.true( + Number.isInteger(record.digest_type) && record.digest_type >= 0 && record.digest_type <= 255, + `${file}: Invalid digest_type for ${key} at index ${idx}` + ); + t.true(isValidHexadecimal(record.digest), `${file}: Invalid digest for ${key} at index ${idx}`); + } else if (key === "SRV") { + t.true( + Number.isInteger(record.priority) && record.priority >= 0 && record.priority <= 65535, + `${file}: Invalid priority for ${key} at index ${idx}` + ); + t.true( + Number.isInteger(record.weight) && record.weight >= 0 && record.weight <= 65535, + `${file}: Invalid weight for ${key} at index ${idx}` + ); + t.true( + Number.isInteger(record.port) && record.port >= 0 && record.port <= 65535, + `${file}: Invalid port for ${key} at index ${idx}` + ); + t.true(isValidHostname(record.target), `${file}: Invalid target for ${key} at index ${idx}`); + } else if (key === "TLSA") { + t.true( + Number.isInteger(record.usage) && record.usage >= 0 && record.usage <= 255, + `${file}: Invalid usage for ${key} at index ${idx}` + ); + t.true( + Number.isInteger(record.selector) && record.selector >= 0 && record.selector <= 255, + `${file}: Invalid selector for ${key} at index ${idx}` + ); + t.true( + Number.isInteger(record.matching_type) && + record.matching_type >= 0 && + record.matching_type <= 255, + `${file}: Invalid matching_type for ${key} at index ${idx}` + ); + t.true( + isValidHexadecimal(record.certificate), + `${file}: Invalid certificate for ${key} at index ${idx}` + ); + } + }); + } + + // TXT validation + if (key === "TXT") { + const values = Array.isArray(value) ? value : [value]; + values.forEach((record, idx) => { + t.true(typeof record === "string", `${file}: TXT record value should be a string at index ${idx}`); + }); + } + }); +} + +t("All files should have valid records", (t) => { + files.forEach((file) => { + const data = getDomainData(file); + const recordKeys = Object.keys(data.records); + + recordKeys.forEach((key) => { + t.true(validateRecordType(key), `${file}: Invalid record type: ${key}`); + }); + + // Record type combinations validation + if (recordKeys.includes("CNAME")) { + if (!data.proxied) { + t.is( + recordKeys.length, + 1, + `${file}: CNAME records cannot be combined with other records unless proxied` + ); + } else { + t.true( + !recordKeys.includes("A") && !recordKeys.includes("AAAA"), + `${file}: CNAME records cannot be combined with A or AAAA records` + ); + } + } + if (recordKeys.includes("NS")) { + t.true( + recordKeys.length === 1 || (recordKeys.length === 2 && recordKeys.includes("DS")), + `${file}: NS records cannot be combined with other records, except for DS records` + ); + } + if (recordKeys.includes("DS")) { + t.true(recordKeys.includes("NS"), `${file}: DS records must be combined with NS records`); + } + + validateRecordValues(t, data, file); + }); + + t.pass(); +}); + +t("Root subdomains should have at least one usable record", (t) => { + const usableRecordTypes = ["A", "AAAA", "CNAME", "MX", "NS"]; + + files.forEach((file) => { + const subdomain = file.replace(/\.json$/, ""); + if (subdomain.includes(".") || subdomain.startsWith("_")) return; + + const data = getDomainData(file); + const recordKeys = Object.keys(data.records); + + t.true( + usableRecordTypes.some((record) => recordKeys.includes(record)), + `${file}: Root subdomains must have at least one A, AAAA, CNAME, MX, or NS record` + ); + }); +}); diff --git a/util/disallowed-cnames.json b/util/disallowed-cnames.json @@ -0,0 +1,17 @@ +[ + ".adult", + ".blob.core.windows.net", + ".cf", + ".cfargotunnel.com", + ".ga", + ".gq", + ".ml", + ".porn", + ".r2.dev", + ".sex", + ".sexy", + ".trycloudflare.com", + ".tk", + ".workers.dev", + ".xxx" +] diff --git a/util/internal.json b/util/internal.json @@ -0,0 +1,3 @@ +[ + "www" +] diff --git a/util/reserved.json b/util/reserved.json @@ -0,0 +1,150 @@ +[ + "about", + "abuse", + "access", + "account", + "accounts", + "acme", + "admin", + "api", + "app", + "apps", + "assets", + "auth", + "autoconfig", + "autodiscover", + "billing", + "blog", + "bot", + "cdn", + "chat", + "checkout", + "cname", + "co", + "com", + "community", + "dash", + "dashboard", + "dev", + "devops", + "discord", + "dns", + "doc", + "documentation", + "domain", + "domains", + "edu", + "email", + "example", + "file", + "files", + "get", + "git", + "github", + "gitlab", + "go", + "help", + "home", + "host", + "hosting", + "hosts", + "http", + "https", + "iad", + "info", + "int", + "internal", + "is-a", + "is-a-dev", + "isa", + "isadev", + "link", + "login", + "m", + "mail", + "mailbox", + "maintainer", + "maintainers", + "manage", + "metrics", + "mc", + "media", + "mobile", + "net", + "network", + "news", + "nic", + "noc", + "notification", + "notifications", + "ns", + "ns0", + "ns7", + "ns8", + "ns9", + "oauth", + "org", + "organisation", + "organisations", + "organization", + "organizations", + "open", + "panel", + "pay", + "payment", + "payments", + "portal", + "preview", + "public", + "private", + "prod", + "production", + "proxied", + "proxy", + "rdap", + "redirect", + "register", + "registrar", + "registry", + "reserved", + "root", + "secure", + "security", + "service", + "services", + "shop", + "sso", + "staff", + "staging", + "static", + "stats", + "status", + "server", + "srv", + "store", + "subdomain", + "subdomains", + "support", + "sys", + "system", + "team", + "test", + "testing", + "tools", + "uptime", + "url", + "util", + "vpn", + "web", + "webmail", + "website", + "websocket", + "whois", + "wss", + "ww", + "wwww", + "your-domain", + "your-domain-name", + "your-subdomain", + "zone" +] diff --git a/util/trusted.json b/util/trusted.json @@ -0,0 +1,7 @@ +[ + { + "username": "wdhdev", + "id": 87287585, + "admin": true + } +]
© notamitgamer • Site Built: 2026-09-05 01:53:16 UTC • git-mirror commit: c170d72 [view raw info]
Originally created with stagit • modified by notamitgamer
Forked from github.com/notamitgamer/git-mirror