pmcf
Version:
Poor mans configuration management
541 lines (465 loc) • 13.4 kB
JavaScript
import { join } from "node:path";
import { createHmac } from "node:crypto";
import { FileContentProvider } from "npm-pkgbuild";
import { isLinkLocal, reverseArpa, decodeIP } from "ip-utilties";
import { writeLines } from "../utils.mjs";
import {
DNSRecord,
dnsFullName,
dnsRecordTypeForAddressFamily,
sortZoneRecords
} from "../dns-utils.mjs";
import { Endpoint, serviceEndpoints } from "pmcf";
import { addType } from "../types.mjs";
import { ServiceTypeDefinition, serviceAddresses } from "../service.mjs";
import {
ExtraSourceService,
ExtraSourceServiceTypeDefinition
} from "../extra-source-service.mjs";
import { addresses } from "../network-support.mjs";
import { addHook } from "../hooks.mjs";
const address_types = ["network", "host", "network_interface"];
const DNSServiceTypeDefinition = {
name: "dns",
specializationOf: ServiceTypeDefinition,
owners: ServiceTypeDefinition.owners,
extends: ExtraSourceServiceTypeDefinition,
priority: 0.1,
properties: {
trusted: {
type: address_types,
collection: true,
writeable: true
},
protected: { type: address_types, collection: true, writeable: true },
open: { type: address_types, collection: true, writeable: true },
hasSVRRecords: {
type: "boolean",
collection: false,
writeable: true,
default: false
},
hasCatalog: {
type: "boolean",
collection: false,
writeable: true,
default: false
},
hasLinkLocalAdresses: {
type: "boolean",
collection: false,
writeable: true,
default: false
},
excludeInterfaceKinds: {
type: "string",
collection: true,
writeable: true
},
exclude: { type: address_types, collection: true, writeable: true },
notify: {
type: "boolean",
collection: false,
writeable: true,
default: false
},
recordTTL: { type: "string", collection: false, writeable: true },
serial: { type: "number", collection: false, writeable: true },
refresh: { type: "string", collection: false, writeable: true },
retry: { type: "string", collection: false, writeable: true },
expire: { type: "string", collection: false, writeable: true },
minimum: { type: "string", collection: false, writeable: true },
allowedUpdates: { type: "string", collection: true, writeable: true }
}
};
const rdncEndpoint = {
type: "rdnc",
port: 953,
protocol: "tcp",
tls: false
};
const statisticsEndpoint = {
type: "bind-statistics",
port: 19521,
protocol: "tcp",
tls: false
};
const DNS_SERVICE_FILTER = { type: DNSServiceTypeDefinition.name };
function addressList(objects) {
return Array.from(objects).map(object => {
switch (typeof object) {
case "string":
return object;
case "object":
if (object.name) {
return object.name;
}
return decodeIP(object);
}
});
}
function addressesStatement(prefix, objects, generateEmpty = false) {
const body = addressList(objects).map(name => ` ${name};`);
if (body.length || generateEmpty) {
return [`${prefix} {`, body, "};"];
}
return [];
}
export class DNSService extends ExtraSourceService {
allowedUpdates = [];
recordTTL = "1W";
hasSVRRecords = true;
hasCatalog = true;
hasLinkLocalAdresses = true;
notify = true;
_trusted = [];
_protected = [];
_open = [];
_exclude = new Set([]);
_excludeInterfaceKinds = new Set();
serial = Math.ceil(Date.now() / 1000);
refresh = 36000;
retry = 72000;
expire = 600000;
minimum = 60000;
static {
addType(this);
}
static get typeDefinition() {
return DNSServiceTypeDefinition;
}
constructor(owner, data) {
super(owner, data);
this.read(data, DNSServiceTypeDefinition);
}
get type() {
return DNSServiceTypeDefinition.name;
}
endpoints(filter) {
const endpoints = super.endpoints(filter);
for (const na of this.owner.networkAddresses(
na => na.networkInterface.kind === "localhost"
)) {
endpoints.push(new Endpoint(this, na, rdncEndpoint));
endpoints.push(new Endpoint(this, na, statisticsEndpoint));
}
return endpoints;
}
get soaUpdates() {
return [this.serial, this.refresh, this.retry, this.expire, this.minimum];
}
set protected(value) {
this._protected.push(value);
}
get protected() {
return this._protected;
}
set trusted(value) {
this._trusted.push(value);
}
get trusted() {
return this._trusted;
}
set open(value) {
this._open.push(value);
}
get open() {
return this._open;
}
set exclude(value) {
this._exclude.add(value);
}
get exclude() {
return this._exclude;
}
set excludeInterfaceKinds(value) {
this._excludeInterfaceKinds.add(value);
}
get excludeInterfaceKinds() {
return this._excludeInterfaceKinds;
}
get systemdConfig() {
return [
"Resolve",
{
DNS: serviceAddresses(this, {
...DNS_SERVICE_FILTER,
priority: "<10"
}).join(" "),
FallbackDNS: serviceAddresses(this, {
...DNS_SERVICE_FILTER,
priority: ">=10"
}).join(" "),
Domains: [...this.localDomains].join(" "),
DNSSEC: "no",
MulticastDNS: this.network.multicastDNS ? "yes" : "no",
LLMNR: "no"
}
];
}
async *preparePackages(dir) {
const location = this.owner.owner;
const name = location.name;
const p1 = join(dir, "p1") + "/";
const packageData = {
dir: p1,
sources: [new FileContentProvider(p1)],
outputs: this.outputs,
properties: {
name: `named-${name}`,
description: `named definitions for ${location.fullName}`,
access: "private"
}
};
const forwarders = new Set(
serviceEndpoints(this.source, DNS_SERVICE_FILTER).map(e => e.address)
);
if (forwarders.size) {
await writeLines(
join(p1, "etc/named/options"),
`forwarders.conf`,
addressesStatement("forwarders", forwarders)
);
}
const acls = [
addressesStatement("acl trusted", addresses(this.trusted)),
addressesStatement("acl protected", addresses(this.protected)),
addressesStatement("acl open", addresses(this.open), true)
].flat();
if (acls.length) {
await writeLines(join(p1, "etc/named"), `0-acl-${name}.conf`, acls);
}
if (forwarders.size || acls.length) {
yield packageData;
}
const p2 = join(dir, "p2") + "/";
packageData.dir = p2;
packageData.properties = {
name: `named-zones-${name}`,
description: `zone definitions for ${location.fullName}`,
dependencies: ["mf-named"],
replaces: ["mf-named-zones"],
access: "private",
hooks: {}
};
packageData.sources = [
new FileContentProvider(
p2,
{
mode: 0o644,
owner: "named",
group: "named"
},
{
mode: 0o755,
owner: "named",
group: "named"
}
)
];
await generateZoneDefs(this, location, packageData);
yield packageData;
}
}
async function generateZoneDefs(dns, location, packageData) {
const ttl = dns.recordTTL;
const nameService = dns.findService(DNS_SERVICE_FILTER);
const rname = dns.administratorEmail.replace(/@/, ".");
const SOARecord = DNSRecord(
"@",
"SOA",
dnsFullName(nameService.domainName),
dnsFullName(rname),
`(${[...dns.soaUpdates].join(" ")})`
);
const NSRecord = DNSRecord(
"@",
"NS",
dnsFullName(nameService.ipAddressOrDomainName)
);
console.log(`${nameService}`, nameService.ipAddressOrDomainName);
const configs = [];
for (const host of location.hosts()) {
for (const domain of host.foreignDomainNames) {
const zone = {
id: domain,
file: `FOREIGN/${domain}.zone`,
records: new Set([SOARecord, NSRecord])
};
const config = {
name: `${domain}.zone.conf`,
zones: [zone]
};
configs.push(config);
zone.records.add(DNSRecord("location", "TXT", host.location.name));
for (const na of host.networkAddresses(
na => na.networkInterface.kind != "loopback"
)) {
zone.records.add(
DNSRecord("@", dnsRecordTypeForAddressFamily(na.family), na.address)
);
}
}
}
const foreignZones = configs.map(c => c.zones).flat();
if (foreignZones.length) {
addHook(
packageData.properties.hooks,
"post_upgrade",
// `rm -f ${foreignZones.map(zone => `/var/lib/named/${zone.file}.jnl`)}\n` +
// "systemctl try-reload-or-restart named\n" +
`/usr/bin/named-hostname-info ${foreignZones
.map(zone => zone.id)
.join(" ")}|/usr/bin/named-hostname-update`
);
}
for (const domain of dns.localDomains) {
const locationName = location.name;
const reverseZones = new Map();
const config = {
name: `${domain}.zone.conf`,
zones: []
};
configs.push(config);
const locationRecord = DNSRecord("location", "TXT", locationName);
const zone = {
id: domain,
file: `${locationName}/${domain}.zone`,
records: new Set([SOARecord, NSRecord, locationRecord])
};
config.zones.push(zone);
if (dns.hasCatalog) {
const catalogConfig = {
name: `catalog.${domain}.zone.conf`,
zones: []
};
configs.push(catalogConfig);
zone.catalogZone = {
id: `catalog.${domain}`,
file: `${locationName}/catalog.${domain}.zone`,
records: new Set([
SOARecord,
NSRecord,
DNSRecord(dnsFullName(`version.catalog.${domain}`), "TXT", '"1"')
])
};
catalogConfig.zones.push(zone.catalogZone);
}
const hosts = new Set();
const addresses = new Set();
for await (const {
address,
subnet,
networkInterface,
domainNames,
family
} of location.networkAddresses()) {
if (
!dns.exclude.has(networkInterface.network) &&
!dns.excludeInterfaceKinds.has(networkInterface.kind)
) {
const host = networkInterface.host;
if (
!addresses.has(address) &&
(dns.hasLinkLocalAdresses || !isLinkLocal(address))
) {
addresses.add(address);
for (const domainName of domainNames) {
zone.records.add(
DNSRecord(
dnsFullName(domainName),
dnsRecordTypeForAddressFamily(family),
address
)
);
}
if (subnet && host.domain === domain) {
let reverseZone = reverseZones.get(subnet.address);
if (!reverseZone) {
const id = reverseArpa(subnet.prefix);
reverseZone = {
id,
type: "plain",
file: `${locationName}/${id}.zone`,
records: new Set([SOARecord, NSRecord])
};
config.zones.push(reverseZone);
reverseZones.set(subnet.address, reverseZone);
}
for (const domainName of host.domainNames) {
reverseZone.records.add(
DNSRecord(
dnsFullName(reverseArpa(address)),
"PTR",
dnsFullName(domainName)
)
);
}
}
}
if (!hosts.has(host)) {
hosts.add(host);
for (const foreignDomainName of host.foreignDomainNames) {
zone.records.add(
DNSRecord("outfacing", "PTR", dnsFullName(foreignDomainName))
);
}
for (const service of host.findServices()) {
for (const record of service.dnsRecordsForDomainName(
host.domainName,
dns.hasSVRRecords
)) {
zone.records.add(record);
}
}
}
}
}
}
for (const config of configs) {
console.log(`config: ${config.name}`);
const content = [];
for (const zone of config.zones) {
console.log(` file: ${zone.file}`);
if (zone.catalogZone) {
const hash = createHmac("md5", zone.id).digest("hex");
zone.catalogZone.records.add(
DNSRecord(
`${hash}.zones.catalog.${zone.id}.`,
"PTR",
dnsFullName(zone.id)
)
);
}
content.push(`zone \"${zone.id}\" {`);
content.push(` type master;`);
content.push(` file \"${zone.file}\";`);
content.push(
` allow-update { ${
dns.allowedUpdates.length ? dns.allowedUpdates.join(";") : "none"
}; };`
);
content.push(` notify ${dns.notify ? "yes" : "no"};`);
content.push(`};`);
content.push("");
let maxKeyLength = 0;
for (const r of zone.records) {
if (r.key.length > maxKeyLength) {
maxKeyLength = r.key.length;
}
}
await writeLines(
join(packageData.dir, "var/lib/named"),
zone.file,
[...zone.records]
.sort(sortZoneRecords)
.map(r => r.toString(maxKeyLength, ttl))
);
}
await writeLines(
join(packageData.dir, "etc/named/zones"),
config.name,
content
);
}
}