Add build and sign scripts

This commit is contained in:
Paul Miller
2026-02-27 08:30:02 +00:00
parent 140fa1f6d2
commit 94549a9883
3 changed files with 1261 additions and 0 deletions

771
scripts/build.ts Executable file
View File

@@ -0,0 +1,771 @@
#!/usr/bin/env node
import { CMS } from 'micro-key-producer/x509.js';
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import net from 'node:net';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
type LangData = {
code: string;
name: string;
table_columns: {
name: string;
region: string;
censorship: string;
notes: string;
install_signed: string;
install_unsigned: string;
};
yes: string;
no: string;
};
type Lang = {
code: string;
name: string;
mdFile: string;
jsonFile: string;
data: LangData;
};
type RuleParam = { DomainAction: string; Domains: string[] };
type Rule = {
Action: string;
InterfaceTypeMatch?: string;
SSIDMatch?: string[];
ActionParameters?: RuleParam[];
};
type DnsCfg = {
protocol: string;
server: string;
addresses: string[];
};
type DnsInput = {
protocol: string;
server: string;
addresses: string[];
};
type DnsValidateOpts = { requireTlsAddresses?: boolean };
type PayloadCfg = {
description?: string;
displayName: string;
identifier: string;
uuid: string;
type?: string;
version?: number;
organization?: string;
prohibitDisablement?: boolean;
};
type TopCfg = {
description: string;
displayName: string;
identifier: string;
removalDisallowed?: boolean;
scope?: string;
type?: string;
uuid: string;
version?: number;
organization?: string;
consentTextDefault?: string;
};
type CertCfg = {
fileName: string;
data: string;
displayName: string;
identifier: string;
uuid: string;
type?: string;
version?: number;
};
export type ProfileCfg = {
// Controls plist string escaping; kept for compatibility with old provider entries.
escapeXML?: boolean;
// Naming inputs used to derive PayloadDisplayName / top display fields when explicit fields are absent.
name?: string;
fullName?: string;
// Explicit top payload display name fallback when top.displayName is not set.
topName?: string;
// DNS endpoint (DoH URL or DoT hostname) and optional resolver IP hints for Apple DNSSettings payload.
ServerURLOrName?: string;
ServerAddresses?: string[];
// Inner payload fields (com.apple.dnsSettings.managed) shown in UI and used for stable ids.
PayloadDisplayName?: string;
PayloadDescription?: string;
PayloadIdentifier?: string;
PayloadUUID?: string;
PayloadType?: string;
PayloadVersion?: number;
// Apple DNS payload flag: true prevents user from toggling DNS settings off in UI.
ProhibitDisablement?: boolean;
// Optional Apple consent text block; used by some providers for privacy-policy notice.
ConsentTextDefault?: string;
// Structured variants used by CLI/tests; normalize() supports both structured and flat forms.
dns?: DnsCfg;
payload?: PayloadCfg;
// Structured top-level configuration payload; if absent, built from defaults + topName.
top?: TopCfg;
// Optional on-demand match rules (template use-case).
onDemandRules?: Rule[];
// Optional additional certificate payloads embedded into profile.
certificates?: CertCfg[];
// Compact detached signature (hex). Generator rebuilds attached CMS signed/*.mobileconfig from this.
signature?: string;
};
type Provider = {
// Provider metadata for README table + generated links.
id: string;
profile: string;
// Optional naming defaults consumed by providerFile()/normalize().
name?: string;
fullName?: string;
ServerAddresses?: string[];
// Optional output filename override (template provider).
file?: string;
// Hidden providers are excluded from README provider table.
hidden?: boolean;
website?: string;
region?: string | string[];
censorship?: boolean;
// Localized labels and notes used in README rendering.
names: Record<string, string>;
notes: Record<string, string>;
// Per-protocol profile definitions.
https?: ProfileCfg;
tls?: ProfileCfg;
formats?: {
unsigned: { https: boolean; tls: boolean };
signed: { https: boolean; tls: boolean };
};
sourceFile?: string;
};
type ProviderFileInfo = Pick<Provider, 'file' | 'name' | 'id'>;
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Moved under scripts/: resolve repo-root data paths explicitly.
const ROOT_DIR = path.join(__dirname, '..');
const CERTS_DIR = path.join(ROOT_DIR, 'certs');
const CERT_PEM_FILE = path.join(CERTS_DIR, 'cert.pem');
const CHAIN_PEM_FILE = path.join(CERTS_DIR, 'chain.pem');
// Shared CMS algorithm parameters for repo signing/building.
// We intentionally omit signingTime and S/MIME capabilities for stable, minimal signed attributes.
export const SIGN_OPTS = { extraEntropy: false } as const; // Deterministic signatures
const LANGUAGES_DIR = path.join(ROOT_DIR, 'src-languages');
const PROVIDERS_PATH = path.join(ROOT_DIR, 'src');
const DEFAULT_LANG = 'en';
const OUTPUT_DIR = ROOT_DIR;
const REPO_RAW = 'https://github.com/paulmillr/encrypted-dns/raw/master';
const outPath = (p: string) => path.join(ROOT_DIR, p);
const REGIONS: Record<string, string> = {
US: '🇺🇸',
CN: '🇨🇳',
RU: '🇷🇺',
NL: '🇳🇱',
DE: '🇩🇪',
SG: '🇸🇬',
CA: '🇨🇦',
FR: '🇫🇷',
CH: '🇨🇭',
SE: '🇸🇪',
CZ: '🇨🇿',
};
const escapeXMLText = (s: string) =>
s
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&apos;');
export const validId = (s: string) => /^[A-Za-z0-9.-]+$/.test(s);
export const validHost = (s: string) =>
/^(?=.{1,253}$)(?!-)(?:[A-Za-z0-9-]{1,63}\.)*[A-Za-z0-9-]{1,63}$/.test(s) && !s.includes('..');
export const splitCsv = (s: string) =>
s
.split(',')
.map((x) => x.trim())
.filter(Boolean);
const bad = (where: string, msg: string): never => {
throw new Error(`${where}: ${msg}`);
};
const validateDnsInputFor = (
x: DnsInput,
where: string,
protocol: 'https' | 'tls',
opts: DnsValidateOpts = {}
) => {
const requireTlsAddresses =
opts.requireTlsAddresses !== undefined ? opts.requireTlsAddresses : true;
if (!x.server.trim()) bad(where, 'server is required');
if (protocol === 'https') {
let url: URL;
try {
url = new URL(x.server);
} catch {
bad(where, `https server must be a valid URL, got: ${x.server}`);
}
if (url.protocol !== 'https:')
bad(where, `https server URL must use https://, got: ${x.server}`);
} else if (!validHost(x.server)) bad(where, `tls server must be a hostname, got: ${x.server}`);
for (const ip of x.addresses) if (!net.isIP(ip)) bad(where, `invalid IP address: ${ip}`);
if (requireTlsAddresses && protocol === 'tls' && x.addresses.length === 0)
bad(where, 'tls requires at least one IP in --addresses');
};
export const validateDnsInput = (x: DnsInput, where: string, opts: DnsValidateOpts = {}) => {
const protocol = x.protocol.toLowerCase();
if (protocol !== 'https' && protocol !== 'tls')
bad(where, `protocol: expected https|tls, got ${x.protocol}`);
validateDnsInputFor(x, where, protocol, opts);
};
export const validateProfileInput = (
x: ProfileCfg,
where: string,
expectedProtocol?: 'https' | 'tls'
) => {
const dns = x.dns || {
protocol: expectedProtocol || '',
server: x.ServerURLOrName || '',
addresses: x.ServerAddresses || [],
};
if (expectedProtocol)
validateDnsInputFor(dns, where, expectedProtocol, { requireTlsAddresses: false });
else validateDnsInput(dns, where);
const payloadId = x.payload?.identifier || x.PayloadIdentifier;
const topId = x.top?.identifier;
const scope = x.top?.scope;
if (payloadId && !validId(payloadId))
bad(where, `payload identifier must match [A-Za-z0-9.-], got: ${payloadId}`);
if (topId && !validId(topId))
bad(where, `top payload identifier must match [A-Za-z0-9.-], got: ${topId}`);
if (scope && scope !== 'System' && scope !== 'User')
bad(where, `scope: expected System|User, got ${scope}`);
};
const certData = (src: string) =>
src
.replace(/-----BEGIN CERTIFICATE-----/g, '')
.replace(/-----END CERTIFICATE-----/g, '')
.replace(/\s/g, '');
const UUID_DNS_NS = new Uint8Array([
0x6b, 0xa7, 0xb8, 0x10, 0x9d, 0xad, 0x11, 0xd1, 0x80, 0xb4, 0x00, 0xc0, 0x4f, 0xd4, 0x30, 0xc8,
]);
const hex = (b: Uint8Array) => Buffer.from(b).toString('hex');
const uuidFormat = (b: Uint8Array, upper: boolean) => {
const s = hex(b);
const out = `${s.slice(0, 8)}-${s.slice(8, 12)}-${s.slice(12, 16)}-${s.slice(16, 20)}-${s.slice(20, 32)}`;
return upper ? out.toUpperCase() : out.toLowerCase();
};
const uuidV5 = (seed: string, upper: boolean) => {
const msg = Buffer.from(seed, 'utf8');
const h = createHash('sha1').update(Buffer.from(UUID_DNS_NS)).update(msg).digest();
const out = new Uint8Array(h.subarray(0, 16));
out[6] = (out[6] & 0x0f) | 0x50;
out[8] = (out[8] & 0x3f) | 0x80;
return uuidFormat(out, upper);
};
export const deterministicUuid = (
rootIdentifier: string,
tag: 'root' | 'payload',
rel: string,
i?: number
) => {
if (tag === 'root') return uuidV5(`${rootIdentifier}::root::${rel}`, true);
return uuidV5(`${rootIdentifier}::payload::${i || 0}::${rel}`, true);
};
const deterministicPayloadIdentifier = (rootIdentifier: string, rel: string, i = 0) =>
`com.apple.dnsSettings.managed.${uuidV5(`${rootIdentifier}::payload::${i}::${rel}`, false)}`;
export const providerFile = (p: ProviderFileInfo, https: boolean, signed?: boolean) => {
if (p.file) return `${signed ? 'signed' : 'profiles'}/${p.file}`;
const postfix = (_pr: ProviderFileInfo, isHttps: boolean) => (isHttps ? 'https' : 'tls');
const name = p.name || p.id;
return `${signed ? 'signed' : 'profiles'}/${name}-${postfix(p, https)}.mobileconfig`;
};
const languages: Lang[] = fs
.readdirSync(LANGUAGES_DIR)
.filter((f: string) => f.endsWith('.json'))
.sort()
.map((f: string) => {
const p = path.join(LANGUAGES_DIR, f);
const data = JSON.parse(fs.readFileSync(p, 'utf8')) as LangData;
return {
code: data.code,
name: data.name,
mdFile: p.replace('.json', '.md'),
jsonFile: p,
data,
};
});
const providers: Provider[] = fs
.readdirSync(PROVIDERS_PATH)
.sort()
.map((name: string) => {
const sourceFile = path.join(PROVIDERS_PATH, name);
const p = JSON.parse(fs.readFileSync(sourceFile, 'utf8')) as Provider;
const unsigned = { https: !!p.https, tls: !!p.tls };
const signed = {
https: !!p.https?.signature || fs.existsSync(outPath(providerFile(p, true, true))),
tls: !!p.tls?.signature || fs.existsSync(outPath(providerFile(p, false, true))),
};
return { ...p, sourceFile, formats: { unsigned, signed } };
});
const generateSigned = () => {
for (const p of providers) {
if (!p.formats) continue;
p.formats.signed.https = fs.existsSync(outPath(providerFile(p, true, true)));
p.formats.signed.tls = fs.existsSync(outPath(providerFile(p, false, true)));
}
};
const FULLWIDTH_PATTERN =
/[\u1100-\u115F\u2329\u232A\u2E80-\u303E\u3040-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE10-\uFE19\uFE30-\uFE6F\uFF00-\uFF60\uFFE0-\uFFE6]/u;
const chrWidth = (str: string) => {
let width = 0;
for (const c of str) width += FULLWIDTH_PATTERN.test(c) || REGIONS[c] ? 2 : 1;
return width;
};
const padEnd = (s: string, len: number, chr: string) =>
`${s}${chr.repeat(Math.max(0, len - chrWidth(s)))}`;
const genTable = (rows: string[][]) => {
const widths = rows[0].map(() => 0);
for (const r of rows)
for (let i = 0; i < r.length; i++) widths[i] = Math.max(widths[i], chrWidth(r[i]));
let table = '';
rows.forEach((r, i) => {
const cells = r.map((c, j) => padEnd(c, widths[j], ' ')).join(' | ');
table += `| ${cells} |\n`;
if (i === 0) table += `| ${r.map((_, j) => padEnd('', widths[j], '-')).join(' | ')} |\n`;
});
return table;
};
const TAGS: Record<string, (lang: Lang) => string> = {
LANGUAGES: (currentLang: Lang) =>
languages
.map((lang) => {
if (lang.code === currentLang.code) return lang.name;
return `[${lang.name}](https://github.com/paulmillr/encrypted-dns/${lang.code === DEFAULT_LANG ? '' : `blob/master/README.${lang.code}.md`})`;
})
.join(' | '),
PROVIDERS_TABLE: (currentLang: Lang) => {
const rows: string[][] = [
[
currentLang.data.table_columns.name,
currentLang.data.table_columns.region,
currentLang.data.table_columns.censorship,
currentLang.data.table_columns.notes,
currentLang.data.table_columns.install_signed,
currentLang.data.table_columns.install_unsigned,
],
];
const sorted = Array.from(providers)
.filter((p) => !p.hidden)
.sort((a, b) => a.id.localeCompare(b.id));
for (const provider of sorted) {
const name = provider.names[currentLang.code] || provider.names[DEFAULT_LANG];
const note = provider.notes[currentLang.code] || provider.notes[DEFAULT_LANG];
const censorship = provider.censorship ? currentLang.data.yes : currentLang.data.no;
const regionEmoji = (
Array.isArray(provider.region) ? provider.region : [provider.region || '']
)
.map((r) => REGIONS[r] || '')
.join(' ')
.trim();
const unsignedLinks: string[] = [];
if (provider.formats?.unsigned?.https)
unsignedLinks.push(`[HTTPS][${provider.profile}-https]`);
if (provider.formats?.unsigned?.tls) unsignedLinks.push(`[TLS][${provider.profile}-tls]`);
const signedLinks: string[] = [];
if (provider.formats?.signed?.https)
signedLinks.push(`[HTTPS][${provider.profile}-https-signed]`);
if (provider.formats?.signed?.tls) signedLinks.push(`[TLS][${provider.profile}-tls-signed]`);
rows.push([
`[${name}][${provider.id}]`,
regionEmoji,
censorship,
note,
signedLinks.join(', '),
unsignedLinks.join(', '),
]);
}
return genTable(rows).trim();
},
PROVIDERS_LINKS: (_currentLang: Lang) => {
let res = '';
const addLink = (p: Provider, https: boolean, signed?: boolean) => {
const file = providerFile(p, https, signed);
if (!fs.existsSync(outPath(file))) throw new Error(`missing: ${file}`);
res += `[${p.profile}-${https ? 'https' : 'tls'}${signed ? '-signed' : ''}]: ${REPO_RAW}/${file}\n`;
};
for (const p of providers) {
if (p.hidden) continue;
if (p.website) res += `[${p.id}]: ${p.website}\n`;
if (p.formats?.unsigned?.https) addLink(p, true);
if (p.formats?.unsigned?.tls) addLink(p, false);
}
for (const p of providers) {
if (p.hidden) continue;
if (p.formats?.signed?.https) addLink(p, true, true);
if (p.formats?.signed?.tls) addLink(p, false, true);
}
return res;
},
};
const processTemplate = (templateContent: string, lang: Lang) => {
let content = templateContent;
for (const [tag, handler] of Object.entries(TAGS)) {
const tagPattern = new RegExp(`<%${tag}%>`, 'g');
if (content.match(tagPattern)) content = content.replace(tagPattern, handler(lang));
}
return content;
};
const generateReadmes = () => {
for (const lang of languages) {
if (!fs.existsSync(lang.mdFile)) throw new Error(`Template file not found: ${lang.mdFile}`);
const tpl = fs.readFileSync(lang.mdFile, 'utf8');
const processed = processTemplate(tpl, lang);
const out = lang.code === DEFAULT_LANG ? 'README.md' : `README.${lang.code}.md`;
fs.writeFileSync(path.join(OUTPUT_DIR, out), processed, 'utf8');
console.log(`Generated ${out}`);
}
};
type NormalizeOpts = {
expectedProtocol?: 'https' | 'tls';
serverAddresses?: string[];
fullName?: string;
};
const normalize = (
x: ProfileCfg,
rel: string,
opts: NormalizeOpts = {}
): Required<Pick<ProfileCfg, 'dns' | 'payload' | 'top' | 'onDemandRules' | 'certificates'>> & {
escapeXML: boolean;
signature?: string;
} => {
const escapeXML = x.escapeXML !== undefined ? x.escapeXML : true;
const protocolDefault = opts.expectedProtocol ? opts.expectedProtocol.toUpperCase() : '';
const rootIdentifier = x.top?.identifier || 'com.paulmillr.apple-dns';
const defaultPayloadDesc = (name: string) => `Configures device to use ${name}`;
const defaultTopDesc = (name: string) => `Adds the ${name} to Big Sur and iOS 14 based systems`;
const proto = (x.dns?.protocol || protocolDefault).toUpperCase();
const fullNameRaw = x.fullName || opts.fullName || '';
const fullNameWithProto = (() => {
if (!fullNameRaw) return '';
if (/ over (HTTPS|TLS)$/.test(fullNameRaw)) return fullNameRaw;
if (proto === 'HTTPS' || proto === 'TLS') return `${fullNameRaw} over ${proto}`;
return fullNameRaw;
})();
const baseName = x.PayloadDisplayName || fullNameWithProto || x.name || '';
const topName = x.top?.displayName || x.topName || baseName;
const fullName = fullNameRaw || topName || baseName;
// Mixed-shape input (e.g. CLI `new.ts`) may provide only `dns` and flat payload/top fields.
// Only treat as fully-structured mode when all three nested blocks are present.
if (x.payload && x.top && x.dns) {
const dns = x.dns || {
protocol: protocolDefault,
server: x.ServerURLOrName || '',
addresses: x.ServerAddresses !== undefined ? x.ServerAddresses : opts.serverAddresses || [],
};
return {
dns,
payload: x.payload!,
top: x.top!,
onDemandRules: x.onDemandRules || [],
certificates: x.certificates || [],
escapeXML,
signature: x.signature,
};
}
return {
dns: x.dns || {
protocol: protocolDefault,
server: x.ServerURLOrName || '',
addresses: x.ServerAddresses !== undefined ? x.ServerAddresses : opts.serverAddresses || [],
},
payload: {
description: x.PayloadDescription || defaultPayloadDesc(x.name || baseName),
displayName: baseName,
identifier: x.PayloadIdentifier || deterministicPayloadIdentifier(rootIdentifier, rel, 0),
uuid: x.PayloadUUID || deterministicUuid(rootIdentifier, 'payload', rel, 0),
type: x.PayloadType || 'com.apple.dnsSettings.managed',
version: x.PayloadVersion || 1,
organization: undefined,
prohibitDisablement: x.ProhibitDisablement !== undefined ? x.ProhibitDisablement : false,
},
top: {
description: x.top?.description || defaultTopDesc(fullName),
displayName: x.top?.displayName || topName,
identifier: rootIdentifier,
removalDisallowed: x.top?.removalDisallowed !== undefined ? x.top.removalDisallowed : false,
scope: x.top?.scope || 'System',
type: x.top?.type || 'Configuration',
uuid: x.top?.uuid || deterministicUuid(rootIdentifier, 'root', rel),
version: x.top?.version || 1,
organization: x.top?.organization,
consentTextDefault: x.top?.consentTextDefault || x.ConsentTextDefault,
},
onDemandRules: x.onDemandRules || [],
certificates: x.certificates || [],
escapeXML,
signature: x.signature,
};
};
type PlistData = { TAG: 'data'; data: string };
type PlistNode =
| string
| number
| boolean
| PlistData
| PlistNode[]
| Record<string, PlistNode | undefined>;
const plistData = (x: string): PlistData => ({ TAG: 'data', data: x });
const isPlistData = (x: PlistNode): x is PlistData =>
typeof x === 'object' && !Array.isArray(x) && (x as PlistData).TAG === 'data';
const plistNode = (x: PlistNode, level: number, esc: (s: string) => string): string => {
const pad = ' '.repeat(level);
if (typeof x === 'string') return `${pad}<string>${esc(x)}</string>\n`;
if (typeof x === 'number') return `${pad}<integer>${x}</integer>\n`;
if (typeof x === 'boolean') return `${pad}<${x ? 'true' : 'false'}/>\n`;
if (Array.isArray(x)) {
let out = `${pad}<array>\n`;
for (const i of x) out += plistNode(i, level + 1, esc);
return `${out}${pad}</array>\n`;
}
if (isPlistData(x)) return `${pad}<data>${x.data}</data>\n`;
let out = `${pad}<dict>\n`;
for (const [k, v] of Object.entries(x)) {
if (v === undefined) continue;
out += `${pad} <key>${k}</key>\n`;
out += plistNode(v, level + 1, esc);
}
return `${out}${pad}</dict>\n`;
};
const plistDoc = (root: PlistNode, rootLevel: number, esc: (s: string) => string) =>
`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
${plistNode(root, rootLevel, esc)}</plist>
`;
const dnsNode = (d: DnsCfg): Record<string, PlistNode> => ({
DNSProtocol: d.protocol,
...(d.addresses.length ? { ServerAddresses: d.addresses } : {}),
[d.server.startsWith('https://') ? 'ServerURL' : 'ServerName']: d.server,
});
const rulesNode = (rules: Rule[]): PlistNode[] =>
rules.map((r) => ({
Action: r.Action,
...(r.InterfaceTypeMatch ? { InterfaceTypeMatch: r.InterfaceTypeMatch } : {}),
...(r.SSIDMatch && r.SSIDMatch.length ? { SSIDMatch: r.SSIDMatch } : {}),
...(r.ActionParameters && r.ActionParameters.length
? {
ActionParameters: r.ActionParameters.map((p) => ({
DomainAction: p.DomainAction,
Domains: p.Domains,
})),
}
: {}),
}));
const certNodes = (certs: CertCfg[]): PlistNode[] =>
certs.map((c) => ({
PayloadCertificateFileName: c.fileName,
PayloadContent: plistData(certData(c.data)),
PayloadDisplayName: c.displayName,
PayloadIdentifier: c.identifier,
PayloadType: c.type || 'com.apple.security.pem',
PayloadUUID: c.uuid,
PayloadVersion: c.version || 1,
}));
const renderProfile = (cfg: ReturnType<typeof normalize>) => {
const p = cfg.payload;
const t = cfg.top;
const esc = cfg.escapeXML ? escapeXMLText : (s: string) => s;
const entry = (k: string, v: PlistNode | undefined): [string, PlistNode] | undefined =>
v === undefined ? undefined : [k, v];
const obj = (xs: Array<[string, PlistNode] | undefined>): Record<string, PlistNode> =>
Object.fromEntries(xs.filter(Boolean) as [string, PlistNode][]);
const payload = obj([
['DNSSettings', dnsNode(cfg.dns)],
entry('OnDemandRules', cfg.onDemandRules.length ? rulesNode(cfg.onDemandRules) : undefined),
['PayloadDescription', p.description || ''],
['PayloadDisplayName', p.displayName],
entry('PayloadOrganization', p.organization),
['PayloadIdentifier', p.identifier],
['PayloadType', p.type || 'com.apple.dnsSettings.managed'],
['PayloadUUID', p.uuid],
['PayloadVersion', p.version || 1],
entry('ProhibitDisablement', p.prohibitDisablement),
]);
const payloadContent: PlistNode = [payload, ...certNodes(cfg.certificates)];
const root = obj([
['PayloadContent', payloadContent],
['PayloadDescription', t.description],
entry('ConsentText', t.consentTextDefault ? { default: t.consentTextDefault } : undefined),
['PayloadDisplayName', t.displayName],
entry('PayloadOrganization', t.organization),
['PayloadIdentifier', t.identifier],
entry('PayloadRemovalDisallowed', t.removalDisallowed),
entry('PayloadScope', t.scope),
['PayloadType', t.type || 'Configuration'],
['PayloadUUID', t.uuid],
['PayloadVersion', t.version || 1],
]);
return plistDoc(root, 0, esc);
};
export const generateSingle = (x: ProfileCfg) => {
const cfg = normalize(x, '');
return renderProfile(cfg);
};
export const normalizeProfile = (x: ProfileCfg, rel: string, opts: NormalizeOpts = {}) =>
normalize(x, rel, opts);
export const generateForRel = (x: ProfileCfg, rel: string, opts: NormalizeOpts = {}) => {
const cfg = normalize(x, rel, opts);
return renderProfile(cfg);
};
const generateSingleRel = (x: ProfileCfg, rel: string, opts: NormalizeOpts = {}) => {
return generateForRel(x, rel, opts);
};
const withDefaults = (
cfg: ProfileCfg,
defaults: { serverAddresses?: string[]; fullName?: string } = {}
): ProfileCfg => {
const needAddrs = !!defaults.serverAddresses;
const needFullName = !!defaults.fullName;
if (!needAddrs && !needFullName) return cfg;
let out = cfg;
if (needFullName && out.fullName === undefined) out = { ...out, fullName: defaults.fullName };
if (!needAddrs) return out;
if (out.dns) {
if (out.dns.addresses !== undefined) return out;
return { ...out, dns: { ...out.dns, addresses: defaults.serverAddresses } };
}
if (out.ServerAddresses !== undefined) return out;
return { ...out, ServerAddresses: defaults.serverAddresses };
};
const toBytes = (s: string): Uint8Array => new Uint8Array(Buffer.from(s, 'utf8'));
const fromHex = (s: string): Uint8Array => new Uint8Array(Buffer.from(s, 'hex'));
const fromSignature = (s: string): Uint8Array => {
const txt = s.trim();
if (/^[0-9a-f]+$/i.test(txt) && txt.length % 2 === 0) return fromHex(txt);
throw new Error('expected compact signature in lowercase/uppercase hex');
};
let signerMaterialCache: { cert: string; chain: string } | undefined;
const signerMaterial = (): { cert: string; chain: string } => {
if (signerMaterialCache) return signerMaterialCache;
if (!fs.existsSync(CERT_PEM_FILE)) throw new Error(`missing signer cert: ${CERT_PEM_FILE}`);
if (!fs.existsSync(CHAIN_PEM_FILE)) throw new Error(`missing signer chain: ${CHAIN_PEM_FILE}`);
signerMaterialCache = {
cert: fs.readFileSync(CERT_PEM_FILE, 'utf8'),
chain: fs.readFileSync(CHAIN_PEM_FILE, 'utf8'),
};
return signerMaterialCache;
};
const verifyDetached = (
p: Provider,
protocol: 'https' | 'tls',
parsed: ProfileCfg,
content: Uint8Array
) => {
if (!parsed.signature) return;
const compactSig = fromSignature(parsed.signature);
const mat = signerMaterial();
const signed = CMS.compact.build(content, compactSig, mat.cert, mat.chain, SIGN_OPTS);
try {
CMS.verify(signed, { allowBER: true, checkSignatures: true, time: Date.now() });
} catch (e) {
throw new Error(`${p.id}/${protocol}: signature verify failed (${(e as Error).message})`);
}
};
const signedFromDetached = (
p: Provider,
protocol: 'https' | 'tls',
isHttps: boolean,
parsed: ProfileCfg,
content: Uint8Array
) => {
if (!parsed.signature) return;
const compactSig = fromSignature(parsed.signature);
const mat = signerMaterial();
const out = providerFile(p, isHttps, true);
const full = outPath(out);
fs.mkdirSync(path.dirname(full), { recursive: true });
const signed = CMS.compact.build(content, compactSig, mat.cert, mat.chain, SIGN_OPTS);
fs.writeFileSync(full, signed);
console.log(`Generated ${out}`);
};
const generateConfigs = () => {
const generate = (
file: string,
parsed?: ProfileCfg,
where?: string,
expectedProtocol?: 'https' | 'tls',
defaults: { serverAddresses?: string[]; fullName?: string } = {}
): Uint8Array | undefined => {
if (!parsed) return;
const input = withDefaults(parsed, defaults);
validateProfileInput(input, where || file, expectedProtocol);
const rel = file.startsWith('profiles/') ? file.slice('profiles/'.length) : file;
const raw = generateSingleRel(input, rel, {
expectedProtocol,
serverAddresses: defaults.serverAddresses,
fullName: defaults.fullName,
});
const out = outPath(file);
fs.mkdirSync(path.dirname(out), { recursive: true });
fs.writeFileSync(out, raw);
console.log(`Generated ${file}`);
return toBytes(raw);
};
for (const p of providers) {
if (p.formats?.unsigned?.https) {
const content = generate(
providerFile(p, true),
p.https,
`${p.sourceFile || `provider:${p.id}`}:https`,
'https',
{ serverAddresses: p.ServerAddresses, fullName: p.fullName }
);
if (content && p.https) {
verifyDetached(p, 'https', p.https, content);
signedFromDetached(p, 'https', true, p.https, content);
}
}
if (p.formats?.unsigned?.tls) {
const content = generate(
providerFile(p, false),
p.tls,
`${p.sourceFile || `provider:${p.id}`}:tls`,
'tls',
{ serverAddresses: p.ServerAddresses, fullName: p.fullName }
);
if (content && p.tls) {
verifyDetached(p, 'tls', p.tls, content);
signedFromDetached(p, 'tls', false, p.tls, content);
}
}
}
};
const main = () => {
generateConfigs();
generateSigned();
generateReadmes();
};
if (process.argv[1] && path.resolve(process.argv[1]) === __filename) main();

382
scripts/new.ts Executable file
View File

@@ -0,0 +1,382 @@
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import { stdin as input, stdout as output } from 'node:process';
import { createInterface } from 'node:readline/promises';
import {
deterministicUuid,
generateSingle,
splitCsv,
validId,
validateDnsInput,
type ProfileCfg,
} from './build.ts';
import { signFile } from './sign-single.ts';
type Proto = 'https' | 'tls';
type Input = {
name: string;
organizationName: string;
profileIdentifier: string;
protocol: Proto;
server: string;
addresses: string[];
certs: string[];
out: string;
description: string;
topDescription: string;
prohibitDisablement: boolean;
scope: string;
ca?: string;
priv_key?: string;
chain?: string;
};
type PartialInput = Partial<Input>;
const usage = () => {
console.error(`usage:
node scripts/new.ts --name <name> --protocol <https|tls> --server <url-or-host> --addresses <ip1,ip2,...> [--organization <name>] [--profile-identifier <id>] [--certs <path1.pem,path2.pem,...>] [--out <file.mobileconfig>] [--description <text>] [--top-description <text>] [--prohibit-disablement <true|false>] [--scope <System|User|...>] [--ca <cert.pem> --priv_key <key.pem> [--chain <chain.pem>]]
notes:
- if no args are passed, interactive mode starts
- --addresses may be empty only for https
- --prohibit-disablement: true prevents users from disabling encrypted DNS
- --scope: System applies to all users, User applies to current user
- PayloadRemovalDisallowed is fixed to false (same as dns-profile-generator UI flow)`);
};
const die = (msg: string): never => {
throw new Error(msg);
};
const parseBool = (v: string, name: string) => {
if (v === 'true') return true;
if (v === 'false') return false;
return die(`${name}: expected true|false, got ${v}`);
};
const parseYesNo = (v: string, name: string) => {
const x = v.toLowerCase();
if (x === 'yes' || x === 'y') return true;
if (x === 'no' || x === 'n') return false;
return die(`${name}: expected yes|no, got ${v}`);
};
const slug = (s: string) =>
s
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '') || 'dns-profile';
const validate = (x: Input): Input => {
if (!x.name.trim()) die('name is required');
if (!x.profileIdentifier.trim()) die('profile-identifier is required');
if (!validId(x.profileIdentifier))
die(`profile-identifier must match [A-Za-z0-9.-], got: ${x.profileIdentifier}`);
if (!x.out.trim()) die('out is required');
if (x.scope !== 'System' && x.scope !== 'User')
die(`scope: expected System|User, got ${x.scope}`);
validateDnsInput({ protocol: x.protocol, server: x.server, addresses: x.addresses }, 'cli input');
for (const f of x.certs) if (!fs.existsSync(f)) die(`missing file: ${f}`);
if ((x.ca && !x.priv_key) || (!x.ca && x.priv_key))
die('signing requires both --ca and --priv_key');
if (x.chain && (!x.ca || !x.priv_key)) die('--chain requires both --ca and --priv_key');
if (x.ca && !fs.existsSync(x.ca)) die(`missing file: ${x.ca}`);
if (x.priv_key && !fs.existsSync(x.priv_key)) die(`missing file: ${x.priv_key}`);
if (x.chain && !fs.existsSync(x.chain)) die(`missing file: ${x.chain}`);
if (!x.out.endsWith('.mobileconfig')) x.out = `${x.out}.mobileconfig`;
return x;
};
const withDefaults = (x: PartialInput): Input => {
const name = x.name || 'DNS profile';
const protocol = x.protocol || 'https';
return {
name,
protocol,
server: x.server || '',
addresses: x.addresses || [],
certs: x.certs || [],
organizationName: x.organizationName || '',
out: x.out || `${slug(name)}-${protocol}.mobileconfig`,
profileIdentifier: x.profileIdentifier || 'com.example.dns',
description: x.description || `Configures device to use ${name}`,
topDescription: x.topDescription || `Adds ${name} to Big Sur and iOS 14 based systems`,
prohibitDisablement: x.prohibitDisablement !== undefined ? x.prohibitDisablement : false,
scope: x.scope || 'System',
ca: x.ca,
priv_key: x.priv_key,
chain: x.chain,
};
};
const asProfile = (x: Input): ProfileCfg => ({
dns: {
protocol: x.protocol.toUpperCase(),
server: x.server,
addresses: x.addresses,
},
PayloadDisplayName: x.name,
PayloadDescription: x.description,
PayloadIdentifier: `${x.profileIdentifier}.dns`,
PayloadUUID: deterministicUuid(x.profileIdentifier, 'payload', 'cli', 0),
ProhibitDisablement: x.prohibitDisablement,
top: {
displayName: x.name,
description: x.topDescription,
identifier: x.profileIdentifier,
uuid: deterministicUuid(x.profileIdentifier, 'root', 'cli'),
removalDisallowed: false,
scope: x.scope,
organization: x.organizationName || undefined,
},
certificates: x.certs.map((f, i) => {
const data = fs.readFileSync(f, 'utf8');
const name = path.basename(f).replace(/\.(pem|cer|crt)$/i, '');
return {
fileName: path.basename(f),
data,
displayName: name || `Certificate ${i + 1}`,
identifier: `${x.profileIdentifier}.cert.${i}`,
uuid: deterministicUuid(x.profileIdentifier, 'payload', 'cert', i + 1),
};
}),
escapeXML: true,
});
const parseArgs = (argv: string[]): PartialInput => {
if (!argv.length) return {};
const out: Record<string, string> = {};
const allowed = new Set([
'name',
'organization',
'profile-identifier',
'protocol',
'server',
'addresses',
'certs',
'out',
'description',
'top-description',
'prohibit-disablement',
'scope',
'ca',
'priv_key',
'chain',
]);
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (!a.startsWith('--')) die(`unexpected arg: ${a}`);
const k = a.slice(2);
if (!allowed.has(k)) die(`unknown option: --${k}`);
const v = argv[i + 1];
if (!v || v.startsWith('--')) die(`missing value for --${k}`);
out[k] = v;
i++;
}
const protocol = out.protocol as Proto | undefined;
const name = out.name;
return {
name,
organizationName: out.organization,
profileIdentifier: out['profile-identifier'],
protocol,
server: out.server,
addresses: out.addresses !== undefined ? splitCsv(out.addresses) : undefined,
certs: out.certs !== undefined ? splitCsv(out.certs) : undefined,
description: out.description,
topDescription: out['top-description'],
out: out.out,
prohibitDisablement: out['prohibit-disablement']
? parseBool(out['prohibit-disablement'], 'prohibit-disablement')
: undefined,
scope: out.scope,
ca: out.ca,
priv_key: out.priv_key,
chain: out.chain,
};
};
const askRequired = async (seed: PartialInput = {}): Promise<PartialInput> => {
const rl = createInterface({ input, output });
const q = async (prompt: string, def = '') => {
const txt = await rl.question(def ? `${prompt} [${def}]: ` : `${prompt}: `);
return txt.trim() || def;
};
const retry = async <T>(prompt: string, def: string, parse: (v: string) => T): Promise<T> => {
let err = '';
while (true) {
try {
const label = err ? `${prompt} (error: ${err})` : prompt;
return parse(await q(label, def));
} catch (e) {
err = (e as Error).message;
}
}
};
try {
const name =
seed.name ||
(await retry('Display name', '', (v) => {
if (!v) throw new Error('name is required');
return v;
}));
const protocol =
seed.protocol ||
(await retry<Proto>('Protocol (https|tls)', 'https', (v) => {
const x = v.toLowerCase();
if (x !== 'https' && x !== 'tls') throw new Error(`protocol: expected https|tls, got ${v}`);
return x as Proto;
}));
const server =
seed.server ||
(await retry(
protocol === 'https' ? 'Server URL (https://...)' : 'Server hostname',
'',
(v) => {
if (!v) throw new Error('server is required');
validateDnsInput({ protocol, server: v, addresses: [] }, 'Server');
return v;
}
));
const addresses =
seed.addresses ||
(await retry<string[]>(
'Server addresses (comma-separated IPs, optional for https)',
'',
(v) => {
const arr = splitCsv(v);
validateDnsInput({ protocol, server, addresses: arr }, 'Server addresses');
return arr;
}
));
return {
...seed,
name,
protocol,
server,
addresses,
};
} finally {
rl.close();
}
};
const askFull = async (): Promise<PartialInput> => {
const rl = createInterface({ input, output });
const q = async (prompt: string, def = '') => {
const txt = await rl.question(def ? `${prompt} [${def}]: ` : `${prompt}: `);
return txt.trim() || def;
};
const retry = async <T>(prompt: string, def: string, parse: (v: string) => T): Promise<T> => {
let err = '';
while (true) {
try {
const label = err ? `${prompt} (error: ${err})` : prompt;
return parse(await q(label, def));
} catch (e) {
err = (e as Error).message;
}
}
};
try {
const name = await retry('Display name', '', (v) => {
if (!v) throw new Error('name is required');
return v;
});
const protocol = await retry<Proto>('Protocol (https|tls)', 'https', (v) => {
const x = v.toLowerCase();
if (x !== 'https' && x !== 'tls') throw new Error(`protocol: expected https|tls, got ${v}`);
return x as Proto;
});
const server = await retry(
protocol === 'https' ? 'Server URL (https://...)' : 'Server hostname',
'',
(v) => {
if (!v) throw new Error('server is required');
validateDnsInput({ protocol, server: v, addresses: [] }, 'Server');
return v;
}
);
const addresses = await retry<string[]>(
'Server addresses (comma-separated IPs, optional for https)',
'',
(v) => {
const arr = splitCsv(v);
validateDnsInput({ protocol, server, addresses: arr }, 'Server addresses');
return arr;
}
);
const sign = await retry<boolean>('Sign profile? (yes/no)', 'no', (v) => parseYesNo(v, 'sign'));
const organizationName = await q('Organization name (optional)');
const profileIdentifier = await retry('Profile identifier', 'com.example.dns', (v) => {
if (!validId(v)) throw new Error(`profile-identifier must match [A-Za-z0-9.-], got: ${v}`);
return v;
});
const out = await q('Output file', `${slug(name)}-${protocol}.mobileconfig`);
const prohibitDisablement = await retry<boolean>(
'Prohibit disabling encrypted DNS? (true|false)',
'false',
(v) => parseBool(v.toLowerCase(), 'ProhibitDisablement')
);
const scope = await retry<string>('Payload scope (System|User)', 'System', (v) => {
const x = v[0]?.toUpperCase() + v.slice(1).toLowerCase();
if (x !== 'System' && x !== 'User') throw new Error(`scope: expected System|User, got ${v}`);
return x;
});
let ca = '';
let priv_key = '';
let chain = '';
if (sign) {
ca = await retry<string>('Signer cert path', '', (v) => {
if (!v) throw new Error('signer cert path is required');
if (!fs.existsSync(v)) throw new Error(`missing file: ${v}`);
return v;
});
priv_key = await retry<string>('Signer private key path', '', (v) => {
if (!v) throw new Error('signer private key path is required');
if (!fs.existsSync(v)) throw new Error(`missing file: ${v}`);
return v;
});
chain = await retry<string>('Signer chain path (optional)', '', (v) => {
if (!v) return '';
if (!fs.existsSync(v)) throw new Error(`missing file: ${v}`);
return v;
});
}
return {
name,
protocol,
server,
addresses,
organizationName,
profileIdentifier,
out,
prohibitDisablement,
scope,
ca: ca || undefined,
priv_key: priv_key || undefined,
chain: chain || undefined,
};
} finally {
rl.close();
}
};
const main = async () => {
const argv = process.argv.slice(2);
if (argv.includes('--help') || argv.includes('-h')) {
usage();
return;
}
const parsed = parseArgs(argv);
const cfg = validate(withDefaults(argv.length ? await askRequired(parsed) : await askFull()));
const xml = generateSingle(asProfile(cfg));
const out = path.resolve(cfg.out);
fs.mkdirSync(path.dirname(out), { recursive: true });
fs.writeFileSync(out, xml);
console.log(out);
if (cfg.ca && cfg.priv_key)
console.log(signFile({ ca: cfg.ca, priv_key: cfg.priv_key, chain: cfg.chain, input: out }));
};
main().catch((e) => {
console.error((e as Error).message || e);
process.exit(1);
});

108
scripts/sign.ts Executable file
View File

@@ -0,0 +1,108 @@
#!/usr/bin/env node
import { CMS } from 'micro-key-producer/x509.js';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
generateForRel,
providerFile,
SIGN_OPTS,
validateProfileInput,
type ProfileCfg,
} from './build.ts';
type Provider = {
id: string;
name?: string;
file?: string;
fullName?: string;
ServerAddresses?: string[];
https?: ProfileCfg;
tls?: ProfileCfg;
};
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const ROOT = path.join(__dirname, '..');
const PROVIDERS = path.join(ROOT, 'src');
const CERT_PEM = path.join(ROOT, 'certs', 'cert.pem');
const CHAIN_PEM = path.join(ROOT, 'certs', 'chain.pem');
const PRIVKEY_PEM = path.join(ROOT, 'certs', 'privkey.pem');
const USAGE = `node sign.ts expects following files to exist:
* ${path.relative(ROOT, CERT_PEM)}: pubkey certificate
* ${path.relative(ROOT, CHAIN_PEM)}: pubkey certificate chain
* ${path.relative(ROOT, PRIVKEY_PEM)}: PRIVATE key used to sign requests (never share this)
`;
const withDefaults = (
cfg: ProfileCfg,
defaults: { serverAddresses?: string[]; fullName?: string } = {}
): ProfileCfg => {
const needAddrs = !!defaults.serverAddresses;
const needFullName = !!defaults.fullName;
if (!needAddrs && !needFullName) return cfg;
let out = cfg;
if (needFullName && out.fullName === undefined) out = { ...out, fullName: defaults.fullName };
if (!needAddrs) return out;
if (out.dns) {
if (out.dns.addresses !== undefined) return out;
return { ...out, dns: { ...out.dns, addresses: defaults.serverAddresses } };
}
if (out.ServerAddresses !== undefined) return out;
return { ...out, ServerAddresses: defaults.serverAddresses };
};
const main = () => {
[PRIVKEY_PEM, CERT_PEM, CHAIN_PEM].forEach(filepath => {
if (!fs.existsSync(filepath)) throw new Error(USAGE);
});
const key = fs.readFileSync(PRIVKEY_PEM, 'utf8');
const cert = fs.readFileSync(CERT_PEM, 'utf8');
const chain = fs.readFileSync(CHAIN_PEM, 'utf8');
const files = fs
.readdirSync(PROVIDERS)
.filter((f) => f.endsWith('.json'))
.sort();
const enc = new TextEncoder();
let updated = 0;
for (const fileName of files) {
const full = path.join(PROVIDERS, fileName);
const provider = JSON.parse(fs.readFileSync(full, 'utf8')) as Provider;
let changed = false;
for (const protocol of ['https', 'tls'] as const) {
const src = provider[protocol];
if (!src) continue;
const input = withDefaults(src, {
serverAddresses: provider.ServerAddresses,
fullName: provider.fullName,
});
validateProfileInput(input, `${fileName}:${protocol}`, protocol);
const relPath = providerFile(provider, protocol === 'https').replace(/^profiles\//, '');
const raw = generateForRel(input, relPath, {
expectedProtocol: protocol,
serverAddresses: provider.ServerAddresses,
fullName: provider.fullName,
});
const content = enc.encode(raw);
const compact = CMS.compact.sign(content, cert, key, SIGN_OPTS);
const signed = CMS.compact.build(content, compact, cert, chain, SIGN_OPTS);
CMS.verify(signed, { allowBER: true, checkSignatures: true, time: Date.now() });
const sigHex = Buffer.from(compact).toString('hex');
if (src.signature !== sigHex) {
src.signature = sigHex;
changed = true;
}
}
if (!changed) continue;
fs.writeFileSync(full, `${JSON.stringify(provider, undefined, 4)}\n`);
updated++;
console.log(`Updated ${fileName}`);
}
console.log(`${updated} mobileconfig files updated`);
console.log(`signing done`);
};
main();