mirror of
https://github.com/paulmillr/encrypted-dns.git
synced 2026-03-31 01:40:26 +02:00
Add build and sign scripts
This commit is contained in:
771
scripts/build.ts
Executable file
771
scripts/build.ts
Executable 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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
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
382
scripts/new.ts
Executable 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
108
scripts/sign.ts
Executable 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();
|
||||
Reference in New Issue
Block a user