pmcf
Version:
Poor mans configuration management
647 lines (556 loc) • 16.7 kB
JavaScript
import { join } from "node:path";
import { createHmac } from "node:crypto";
import { FileContentProvider } from "npm-pkgbuild";
import { isLinkLocal, reverseArpa } from "ip-utilties";
import {
oneOfType,
default_attribute_writable,
string_attribute_writable,
boolean_attribute_writable_true,
boolean_attribute_writable_false,
number_attribute_writable,
string_collection_attribute_writable,
name_attribute_writable
} from "pacc";
import { writeLines, asArray } from "../utils.mjs";
import {
DNSRecord,
dnsFullName,
dnsRecordTypeForAddressFamily,
sortZoneRecords
} from "../dns-utils.mjs";
import {
ExtraSourceService,
serviceEndpoints,
addresses,
networkAddressType
} from "pmcf";
import { addType } from "../types.mjs";
import { ServiceTypeDefinition } from "../service.mjs";
import { ExtraSourceServiceTypeDefinition } from "../extra-source-service.mjs";
import { addHook } from "../hooks.mjs";
const BindServiceViewTypeDefinition = {
name: "bind-view",
key: "name",
attributes: {
name: { ...name_attribute_writable },
access: {
type: networkAddressType,
collection: true,
writable: true
}
}
};
const BindServiceTypeDefinition = {
name: "bind",
extends: ExtraSourceServiceTypeDefinition,
specializationOf: ServiceTypeDefinition,
owners: ServiceTypeDefinition.owners,
key: "name",
attributes: {
/*views: {
type: "object", //BindServiceViewTypeDefinition,
collection: true,
writable: true
},*/
zones: {
type: oneOfType([networkAddressType, "location", "owner"]),
collection: true,
writable: true
},
trusted: {
type: networkAddressType,
collection: true,
writable: true
},
protected: {
...default_attribute_writable,
type: networkAddressType,
collection: true
},
internal: {
...default_attribute_writable,
type: networkAddressType,
collection: true
},
hasSVRRecords: boolean_attribute_writable_false,
hasCatalog: boolean_attribute_writable_true,
hasLinkLocalAdresses: boolean_attribute_writable_false,
hasLocationRecord: boolean_attribute_writable_true,
excludeInterfaceKinds: string_collection_attribute_writable,
exclude: {
...default_attribute_writable,
type: networkAddressType,
collection: true
},
notify: boolean_attribute_writable_false,
recordTTL: string_attribute_writable,
serial: number_attribute_writable,
refresh: { ...string_attribute_writable, default: 36000 },
retry: { ...string_attribute_writable, default: 72000 },
expire: { ...string_attribute_writable, default: 600000 },
minimum: { ...string_attribute_writable, default: 60000 },
allowedUpdates: string_collection_attribute_writable
},
service: {
extends: ["dns"],
services: {
"bind-statistics": {
endpoints: [
{
family: "IPv4",
port: 19521,
protocol: "tcp",
tls: false,
kind: "loopback"
}
]
},
"bind-rdnc": {
endpoints: [
{
family: "IPv4",
port: 953,
protocol: "tcp",
tls: false,
kind: "loopback"
}
]
}
}
}
};
function addressesStatement(prefix, objects, generateEmpty = false) {
const body = asArray(objects).map(name => ` ${name};`);
if (body.length || generateEmpty) {
return [`${prefix} {`, body, "};"];
}
return [];
}
export class BindService extends ExtraSourceService {
allowedUpdates = [];
recordTTL = "1W";
hasSVRRecords = true;
hasCatalog = true;
hasLinkLocalAdresses =
BindServiceTypeDefinition.attributes.hasLinkLocalAdresses.default;
hasLocationRecord = true;
notify = true;
_zones = [];
_trusted = [];
_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 BindServiceTypeDefinition;
}
constructor(owner, data) {
super(owner, data);
this._systemd = "bind.service";
this.views = {};
for (const name of ["internal", "protected"]) {
this.views[name] = {
name,
access: []
};
}
this.views.protected.inView = this.views.internal;
this.views.protected.access = ["!internal"];
}
get type() {
return BindServiceTypeDefinition.name;
}
get soaUpdates() {
return [this.serial, this.refresh, this.retry, this.expire, this.minimum];
}
set zones(value) {
this._zones.push(value);
}
get zones() {
return this._zones;
}
set protected(value) {
this.views.protected.access.push(value);
}
get protected() {
return this.views.protected.access;
}
set internal(value) {
this.views.internal.access.push(value);
}
get internal() {
return this.views.internal.access;
}
set trusted(value) {
this._trusted.push(value);
}
get trusted() {
return this._trusted;
}
set exclude(value) {
this._exclude.add(value);
}
get exclude() {
return this._exclude;
}
set excludeInterfaceKinds(value) {
this._excludeInterfaceKinds.add(value);
}
get excludeInterfaceKinds() {
return this._excludeInterfaceKinds;
}
async *preparePackages(dir) {
const zoneSources = this.zones.length ? this.zones : [this.owner];
const names = zoneSources.map(a => a.fullName).join(" ");
const name = this.owner.owner.name || this.owner.name;
const configPackageDir = join(dir, "config") + "/";
const packageData = {
dir: configPackageDir,
sources: [new FileContentProvider(configPackageDir)],
outputs: this.outputs,
properties: {
name: `named-${name}`,
description: `named definitions for ${names}`,
access: "private"
}
};
const forwarders = serviceEndpoints(this.source, {
services: 'type="dns" && priority>=100 && priority<200',
endpoints: endpoint => endpoint.family !== "dns",
select: e => e.address,
limit: 5
});
if (forwarders.length) {
await writeLines(
join(configPackageDir, "etc/named/options"),
`forwarders.conf`,
addressesStatement("forwarders", forwarders)
);
}
const acls = addressesStatement(
"acl trusted",
addresses(this.trusted, { aggregate: true })
);
for (const view of Object.values(this.views)) {
acls.push(
...addressesStatement(
`acl ${view.name}`,
addresses(view.access, { aggregate: true }),
true
)
);
}
if (this.internal?.length) {
await writeLines(
join(configPackageDir, "etc/named"),
`0-acl-${name}.conf`,
acls
);
}
if (forwarders.length || this.internal?.length) {
yield packageData;
}
const zonesPackageDir = join(dir, "zones") + "/";
packageData.dir = zonesPackageDir;
packageData.properties = {
name: `named-zones-${name}`,
description: `zone definitions for ${names}`,
dependencies: ["mf-named"],
access: "private",
hooks: {}
};
const filePermissions = [
{
mode: 0o644,
owner: "named",
group: "named"
},
{
mode: 0o755,
owner: "named",
group: "named"
}
];
packageData.sources = [
new FileContentProvider(zonesPackageDir, ...filePermissions)
];
yield this.generateZoneDefs(zoneSources, packageData);
const outfacingZonesPackageDir = join(dir, "outfacingZones") + "/";
packageData.dir = outfacingZonesPackageDir;
packageData.properties = {
name: `named-zones-${name}-outfacing`,
description: `outfacing zone definitions for ${names}`,
access: "private",
replaces: [`named-zones-${name}-OUTFACING`],
hooks: {}
};
packageData.sources = [
new FileContentProvider(outfacingZonesPackageDir, ...filePermissions)
];
yield* this.generateOutfacingDefs(zoneSources, packageData);
}
async *generateOutfacingDefs(sources, packageData) {
const configs = [];
for (const source of sources) {
for (const host of source.hosts()) {
configs.push(
...this.outfacingZones(host, this.views.internal, this.defaultRecords)
);
}
}
const outfacingZones = configs.map(c => c.zones).flat();
if (outfacingZones.length) {
addHook(
packageData.properties.hooks,
"post_upgrade",
`/usr/bin/named-hostname-info ${outfacingZones
.map(zone => zone.id)
.join(" ")}|/usr/bin/named-hostname-update`
);
await this.writeZones(packageData, configs);
yield packageData;
}
}
async generateZoneDefs(zoneSources, packageData) {
const configs = [];
for (const zoneSource of zoneSources) {
console.log(
"SOURCE",
zoneSource.toString(),
[...zoneSource.localDomains].join(" ")
);
for (const domain of zoneSource.localDomains) {
const locationName = zoneSource.location.name;
const reverseZones = new Map();
const config = {
view: this.views.internal,
name: `${domain}.zone.conf`,
type: "master",
zones: []
};
configs.push(config);
const zone = {
id: domain,
file: `${locationName}/${domain}.zone`,
records: new Set(this.defaultRecords)
};
if (this.hasLocationRecord) {
zone.records.add(DNSRecord("location", "TXT", locationName));
}
config.zones.push(zone);
if (this.hasCatalog) {
const catalogConfig = {
view: this.views.internal,
name: `catalog.${domain}.zone.conf`,
type: "master",
zones: []
};
configs.push(catalogConfig);
zone.catalogZone = {
catalog: true,
id: `catalog.${domain}`,
file: `${locationName}/catalog.${domain}.zone`,
records: new Set([
...this.defaultRecords,
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 zoneSource.networkAddresses()) {
if (
!this.exclude.has(networkInterface.network) &&
!this.excludeInterfaceKinds.has(networkInterface.kind)
) {
if (
!addresses.has(address) &&
(this.hasLinkLocalAdresses || !isLinkLocal(address))
) {
addresses.add(address);
let reverseZone = reverseZones.get(subnet);
// is there already a matching subnet ?
if (!reverseZone) {
for (const [presentSubnet, zone] of reverseZones) {
if (presentSubnet.matchesAddress(subnet.address)) {
reverseZone = zone;
}
}
}
if (!reverseZone) {
const id = reverseArpa(subnet.prefix);
reverseZone = {
id,
type: "plain",
file: `${locationName}/${id}.zone`,
records: new Set(this.defaultRecords)
};
config.zones.push(reverseZone);
reverseZones.set(subnet, reverseZone);
}
for (const domainName of domainNames) {
zone.records.add(
DNSRecord(
dnsFullName(domainName),
dnsRecordTypeForAddressFamily(family),
address
)
);
reverseZone.records.add(
DNSRecord(
dnsFullName(reverseArpa(address)),
"PTR",
dnsFullName(domainName)
)
);
}
}
const host = networkInterface.host;
if (host && !hosts.has(host)) {
hosts.add(host);
for (const foreignDomainName of host.foreignDomainNames) {
zone.records.add(
DNSRecord("outfacing", "PTR", dnsFullName(foreignDomainName))
);
}
const sm = new Map();
for (const service of host._services) {
for (const record of service.dnsRecordsForDomainName(
host.domainName,
this.hasSVRRecords
)) {
sm.set(record.toString(), record);
}
}
for (const r of sm.values()) {
zone.records.add(r);
}
}
}
}
configs.push({
view: this.views.protected,
inView: this.views.protected.inView,
name: config.name,
zones: config.zones
});
}
}
await this.writeZones(packageData, configs);
return packageData;
}
outfacingZones(host, view, records) {
return host.foreignDomainNames.map(domain => {
const wildcard = domain.startsWith("*.");
if (wildcard) {
domain = domain.substring(2);
}
const zone = {
id: domain,
file: `outfacing/${domain}.zone`,
records: new Set(records)
};
const config = {
view,
name: `${domain}.zone.conf`,
type: "master",
zones: [zone]
};
if (this.hasLocationRecord) {
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)
);
if (wildcard) {
zone.records.add(
DNSRecord("*", dnsRecordTypeForAddressFamily(na.family), na.address)
);
}
}
return config;
});
}
get defaultRecords() {
const nameService = this.findService('in("dns",types) && priority>=300');
const SOARecord = DNSRecord(
"@",
"SOA",
dnsFullName(nameService.domainName),
dnsFullName(this.administratorEmail.replace(/@/, ".")),
`(${[...this.soaUpdates].join(" ")})`
);
const NSRecord = DNSRecord("@", "NS", dnsFullName(nameService.address()));
return [SOARecord, NSRecord];
}
async writeZones(packageData, configs) {
for (const config of configs) {
console.log(`config: ${config.view.name}/${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}\" {`);
if (config.inView) {
content.push(` in-view ${config.inView.name};`);
} else {
content.push(` type ${config.type};`);
content.push(` file \"${zone.file}\";`);
content.push(
` allow-update { ${
this.allowedUpdates.length
? this.allowedUpdates.join(";")
: "none"
}; };`
);
content.push(` notify ${this.notify ? "yes" : "no"};`);
}
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, this.recordTTL))
);
}
await writeLines(
join(packageData.dir, `etc/named/${config.view.name}`),
config.name,
content
);
}
}
}