pmcf 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +12 -0
- package/README.md +24 -0
- package/bin/gen-host-defs +181 -0
- package/bin/gen-location-defs +63 -0
- package/bin/gen-named-defs +211 -0
- package/bin/host-info +16 -0
- package/bin/location-info +18 -0
- package/bin/network +40 -0
- package/package.json +58 -0
- package/pkg/host-host1/etc/hostname +1 -0
- package/pkg/host-host1/etc/machine-id +0 -0
- package/pkg/host-host1/etc/machine-info +5 -0
- package/pkg/host-host1/root/.ssh/known_hosts +0 -0
- package/src/model.mjs +757 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Copyright (C) 2025 by arlac77
|
|
2
|
+
|
|
3
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
4
|
+
purpose with or without fee is hereby granted.
|
|
5
|
+
|
|
6
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
|
7
|
+
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
|
8
|
+
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
|
9
|
+
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
|
10
|
+
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
|
11
|
+
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
|
12
|
+
PERFORMANCE OF THIS SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[](https://www.npmjs.com/package/pmcf)
|
|
2
|
+
[](https://spdx.org/licenses/0BSD.html)
|
|
3
|
+
[](https://bundlejs.com/?q=pmcf)
|
|
4
|
+
[](https://npmjs.org/package/pmcf)
|
|
5
|
+
[](https://github.com/arlac77/pmcf/issues)
|
|
6
|
+
[](https://actions-badge.atrox.dev/arlac77/pmcf/goto)
|
|
7
|
+
[](https://github.com/prettier/prettier)
|
|
8
|
+
[](http://commitizen.github.io/cz-cli/)
|
|
9
|
+
[](https://snyk.io/test/github/arlac77/pmcf)
|
|
10
|
+
[](https://coveralls.io/github/arlac77/pmcf)
|
|
11
|
+
|
|
12
|
+
# pmcf
|
|
13
|
+
|
|
14
|
+
## Poor mans configuration management
|
|
15
|
+
|
|
16
|
+
# API
|
|
17
|
+
|
|
18
|
+
# install
|
|
19
|
+
|
|
20
|
+
With [npm](http://npmjs.org) do:
|
|
21
|
+
|
|
22
|
+
```shell
|
|
23
|
+
npm install pmcf
|
|
24
|
+
```
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { writeFile, mkdir, copyFile, glob } from "node:fs/promises";
|
|
4
|
+
import { cwd, argv } from "node:process";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { World, writeLines, sectionLines } from "../src/model.mjs";
|
|
7
|
+
|
|
8
|
+
const world = new World(argv[2] || cwd());
|
|
9
|
+
|
|
10
|
+
const hostName = argv[3];
|
|
11
|
+
|
|
12
|
+
const host = await world.host(hostName);
|
|
13
|
+
|
|
14
|
+
const targetDir = argv[4] || `pkg/host-${host.hostName}`;
|
|
15
|
+
|
|
16
|
+
await generateNetworkDefs(host, targetDir);
|
|
17
|
+
await generateMachineInfo(host, targetDir);
|
|
18
|
+
await copySshKeys(host, targetDir);
|
|
19
|
+
await generateKnownHosts(world.hosts(), join(targetDir, "root", ".ssh"));
|
|
20
|
+
|
|
21
|
+
console.log("provides", "host", ...host.provides);
|
|
22
|
+
console.log("depends", `location-${host.location.name}`, ...host.depends);
|
|
23
|
+
console.log("replaces", `mf-${host.hostName}`, ...host.replaces);
|
|
24
|
+
console.log("description", `host definitions for ${host.domainName}`);
|
|
25
|
+
console.log("backup", "root/.ssh/known_hosts");
|
|
26
|
+
|
|
27
|
+
async function generateMachineInfo(host, dir) {
|
|
28
|
+
const etcDir = join(dir, "etc");
|
|
29
|
+
await writeLines(
|
|
30
|
+
etcDir,
|
|
31
|
+
"machine-info",
|
|
32
|
+
Object.entries({
|
|
33
|
+
CHASSIS: host.model.chassis,
|
|
34
|
+
DEPLOYMENT: host.deployment,
|
|
35
|
+
LOCATION: host.location.name,
|
|
36
|
+
HARDWARE_VENDOR: host.model.vendor,
|
|
37
|
+
HARDWARE_MODEL: host.modelName
|
|
38
|
+
}).map(([k, v]) => `${k}=${v}`)
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
await writeLines(etcDir, "machine-id", [host["machine-id"]]);
|
|
42
|
+
await writeLines(etcDir, "hostname", [host.hostName]);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function generateNetworkDefs(host, dir) {
|
|
46
|
+
const networkDir = join(dir, "etc/systemd/network");
|
|
47
|
+
|
|
48
|
+
for (const [name, network] of Object.entries(
|
|
49
|
+
host.networkInterfaces || { [host.interface || "eth0"]: host }
|
|
50
|
+
)) {
|
|
51
|
+
if (name !== "eth0" && network.hwaddr) {
|
|
52
|
+
await writeLines(networkDir, `${name}.link`, [
|
|
53
|
+
sectionLines("Match", { MACAddress: network.hwaddr }),
|
|
54
|
+
"",
|
|
55
|
+
sectionLines("Link", { Name: name })
|
|
56
|
+
]);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const networkSections = [
|
|
60
|
+
sectionLines("Match", { Name: name }),
|
|
61
|
+
"",
|
|
62
|
+
sectionLines("Address", {
|
|
63
|
+
Address: network.ipv4 + "/" + network.network.ipv4_netmask
|
|
64
|
+
})
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
if (network["link-local-ipv6"]) {
|
|
68
|
+
networkSections.push(
|
|
69
|
+
"",
|
|
70
|
+
sectionLines("Address", {
|
|
71
|
+
Address: network["link-local-ipv6"]
|
|
72
|
+
})
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
switch (network?.network?.kind) {
|
|
77
|
+
case "ethernet":
|
|
78
|
+
case "wifi":
|
|
79
|
+
const routeSectionExtra = network?.destination
|
|
80
|
+
? { Destination: network.destination }
|
|
81
|
+
: { Gateway: host.location.gateway_ipv4 };
|
|
82
|
+
|
|
83
|
+
const networkSectionExtra = network.arpbridge
|
|
84
|
+
? {
|
|
85
|
+
IPForward: "yes",
|
|
86
|
+
IPv4ProxyARP: "yes"
|
|
87
|
+
}
|
|
88
|
+
: {};
|
|
89
|
+
|
|
90
|
+
networkSections.push(
|
|
91
|
+
"",
|
|
92
|
+
sectionLines("Network", {
|
|
93
|
+
...networkSectionExtra,
|
|
94
|
+
DHCP: "no",
|
|
95
|
+
DHCPServer: "no",
|
|
96
|
+
MulticastDNS: "yes",
|
|
97
|
+
LinkLocalAddressing: "ipv6",
|
|
98
|
+
IPv6LinkLocalAddressGenerationMode: "stable-privacy"
|
|
99
|
+
}),
|
|
100
|
+
"",
|
|
101
|
+
sectionLines("Route", {
|
|
102
|
+
...routeSectionExtra,
|
|
103
|
+
Scope: network?.network.scope || "global",
|
|
104
|
+
Metric: network?.network.metric || 1004,
|
|
105
|
+
InitialCongestionWindow: 20,
|
|
106
|
+
InitialAdvertisedReceiveWindow: 20
|
|
107
|
+
}),
|
|
108
|
+
"",
|
|
109
|
+
sectionLines("IPv6AcceptRA", {
|
|
110
|
+
UseAutonomousPrefix: "true",
|
|
111
|
+
UseOnLinkPrefix: "true",
|
|
112
|
+
DHCPv6Client: "false",
|
|
113
|
+
Token: "eui64"
|
|
114
|
+
})
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
if (network.arpbridge) {
|
|
118
|
+
networkSections.push(
|
|
119
|
+
"",
|
|
120
|
+
sectionLines("Link", { Promiscuous: "yes" })
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
await writeLines(networkDir, `${name}.network`, networkSections);
|
|
126
|
+
|
|
127
|
+
switch (network?.network?.kind) {
|
|
128
|
+
case "wireguard":
|
|
129
|
+
{
|
|
130
|
+
}
|
|
131
|
+
break;
|
|
132
|
+
case "wifi": {
|
|
133
|
+
const d = join(dir, "etc/wpa_supplicant");
|
|
134
|
+
await mkdir(d, { recursive: true });
|
|
135
|
+
writeFile(
|
|
136
|
+
join(d, `wpa_supplicant-${name}.conf`),
|
|
137
|
+
`country=${host.location.country}
|
|
138
|
+
ctrl_interface=DIR=/run/wpa_supplicant GROUP=netdev
|
|
139
|
+
update_config=1
|
|
140
|
+
p2p_disabled=1
|
|
141
|
+
network={
|
|
142
|
+
ssid="${network.ssid || network.network?.ssid}"
|
|
143
|
+
psk=${network.psk || network.network?.psk}
|
|
144
|
+
scan_ssid=1
|
|
145
|
+
}`,
|
|
146
|
+
"utf8"
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
host.postinstall.push(
|
|
150
|
+
`systemctl enable wpa_supplicant@${name}.service`
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function copySshKeys(host, dir) {
|
|
158
|
+
const sshDir = join(dir, "etc", "ssh");
|
|
159
|
+
|
|
160
|
+
await mkdir(sshDir, { recursive: true });
|
|
161
|
+
|
|
162
|
+
for await (const file of glob("ssh_host_*", { cwd: host.directory })) {
|
|
163
|
+
copyFile(join(host.directory, file), join(sshDir, file));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function generateKnownHosts(hosts, dir) {
|
|
168
|
+
const keys = [];
|
|
169
|
+
for await (const host of hosts) {
|
|
170
|
+
try {
|
|
171
|
+
const [alg, key, desc] = (await host.publicKey("ed25519")).split(/\s+/);
|
|
172
|
+
keys.push(`${host.domainName} ${alg} ${key}`);
|
|
173
|
+
|
|
174
|
+
for await (const addr of host.networkAddresses()) {
|
|
175
|
+
keys.push(`${addr.address} ${alg} ${key}`);
|
|
176
|
+
}
|
|
177
|
+
} catch {}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
await writeLines(dir, "known_hosts", keys);
|
|
181
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { cwd, argv } from "node:process";
|
|
4
|
+
import { mkdir, copyFile } from "node:fs/promises";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { World, writeLines, sectionLines } from "../src/model.mjs";
|
|
7
|
+
|
|
8
|
+
const world = new World(argv[2] || cwd());
|
|
9
|
+
const location = await world.location(argv[3] || "SW");
|
|
10
|
+
const targetDir = argv[4];
|
|
11
|
+
|
|
12
|
+
await generateLocationDefs(location, targetDir);
|
|
13
|
+
|
|
14
|
+
console.log("provides", "location", "mf-location", `mf-location-${location.name}`);
|
|
15
|
+
console.log("replaces", `mf-location-${location.name}`);
|
|
16
|
+
console.log("description", `location definitions for ${location.name}`);
|
|
17
|
+
|
|
18
|
+
async function generateLocationDefs(location, dir) {
|
|
19
|
+
const sl = location.dns_servers;
|
|
20
|
+
const s1 = sl.shift();
|
|
21
|
+
|
|
22
|
+
await writeLines(
|
|
23
|
+
join(dir, "etc/systemd/resolved.conf.d"),
|
|
24
|
+
`${location.name}.conf`,
|
|
25
|
+
sectionLines("Resolve", {
|
|
26
|
+
DNS: s1,
|
|
27
|
+
FallbackDNS: sl.join(","),
|
|
28
|
+
Domains: location.domain,
|
|
29
|
+
DNSSEC: "no",
|
|
30
|
+
MulticastDNS: "yes",
|
|
31
|
+
LLMNR: "no"
|
|
32
|
+
})
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
await writeLines(
|
|
36
|
+
join(dir, "etc/systemd/journald.conf.d"),
|
|
37
|
+
`${location.name}.conf`,
|
|
38
|
+
sectionLines("Journal", {
|
|
39
|
+
Compress: "yes",
|
|
40
|
+
SystemMaxUse: "500M",
|
|
41
|
+
SyncIntervalSec: "15m"
|
|
42
|
+
})
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
await writeLines(
|
|
46
|
+
join(dir, "etc/systemd/timesyncd.conf.d"),
|
|
47
|
+
`${location.name}.conf`,
|
|
48
|
+
sectionLines("Time", {
|
|
49
|
+
NTP: location.ntp_servers.join(" "),
|
|
50
|
+
PollIntervalMinSec: 60,
|
|
51
|
+
SaveIntervalSec: 3600
|
|
52
|
+
})
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
const locationDir = join(dir, "etc", "location");
|
|
56
|
+
|
|
57
|
+
await mkdir(locationDir, { recursive: true });
|
|
58
|
+
|
|
59
|
+
copyFile(
|
|
60
|
+
join(location.directory, "location.json"),
|
|
61
|
+
join(locationDir, "location.json")
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { cwd, argv } from "node:process";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { createHmac } from "node:crypto";
|
|
6
|
+
|
|
7
|
+
import { World, writeLines } from "../src/model.mjs";
|
|
8
|
+
|
|
9
|
+
const world = new World(argv[2] || cwd());
|
|
10
|
+
const location = await world.location(argv[3] || "SW");
|
|
11
|
+
const targetDir = argv[4] || `pkg/mf-named-${location.name}`;
|
|
12
|
+
const ttl = location.dnsRecordTTL;
|
|
13
|
+
const updates = [
|
|
14
|
+
Math.ceil(Date.now() / 1000),
|
|
15
|
+
36000,
|
|
16
|
+
72000,
|
|
17
|
+
600000,
|
|
18
|
+
60000
|
|
19
|
+
].join(" ");
|
|
20
|
+
|
|
21
|
+
const NAME_LEN = 35;
|
|
22
|
+
|
|
23
|
+
await generateNamedDefs(location, targetDir);
|
|
24
|
+
|
|
25
|
+
console.log("depends", "mf-named");
|
|
26
|
+
console.log("replaces", "mf-named-zones");
|
|
27
|
+
console.log("description", `named defintions for ${location.name}`);
|
|
28
|
+
|
|
29
|
+
/*for await (const location of world.locations()) {
|
|
30
|
+
await generateNamedDefs(location, targetDir);
|
|
31
|
+
}*/
|
|
32
|
+
|
|
33
|
+
async function generateNamedDefs(location, targetDir) {
|
|
34
|
+
const domain = location.domain;
|
|
35
|
+
|
|
36
|
+
if (domain) {
|
|
37
|
+
const zones = [];
|
|
38
|
+
const records = new Set();
|
|
39
|
+
|
|
40
|
+
const nameserver = (await location.service({ type: "dns" }))?.owner;
|
|
41
|
+
const rname = location.administratorEmail.replace(/@/,'.');
|
|
42
|
+
|
|
43
|
+
for await (const mail of location.services({ type: "smtp" })) {
|
|
44
|
+
records.add(
|
|
45
|
+
`${"@".padEnd(NAME_LEN, " ")} ${ttl} IN MX ${mail.priority} ${
|
|
46
|
+
mail.owner.domainName
|
|
47
|
+
}.`
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
console.log(
|
|
52
|
+
location.name,
|
|
53
|
+
location.domain,
|
|
54
|
+
nameserver?.hostName,
|
|
55
|
+
rname
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const catalogZone = {
|
|
59
|
+
id: `catalog.${domain}`,
|
|
60
|
+
file: `catalog.${domain}.zone`,
|
|
61
|
+
records: new Set([
|
|
62
|
+
`${"@".padEnd(NAME_LEN, " ")} ${ttl} IN SOA ${
|
|
63
|
+
nameserver.domainName
|
|
64
|
+
}. ${rname}. (${updates})`,
|
|
65
|
+
`${"@".padEnd(NAME_LEN, " ")} ${ttl} IN NS ${nameserver.ipAddress}.`,
|
|
66
|
+
`${("version."+domain+'.').padEnd(NAME_LEN, " ")} IN TXT "2"`
|
|
67
|
+
])
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const zone = {
|
|
71
|
+
id: domain,
|
|
72
|
+
file: `${domain}.zone`,
|
|
73
|
+
records: new Set([
|
|
74
|
+
`${"@".padEnd(NAME_LEN, " ")} ${ttl} IN SOA ${
|
|
75
|
+
nameserver.domainName
|
|
76
|
+
}. ${rname}. (${updates})`,
|
|
77
|
+
`${"@".padEnd(NAME_LEN, " ")} ${ttl} IN NS ${nameserver.ipAddress}.`,
|
|
78
|
+
...records
|
|
79
|
+
])
|
|
80
|
+
};
|
|
81
|
+
zones.push(zone);
|
|
82
|
+
|
|
83
|
+
for await (const subnet of location.subnets()) {
|
|
84
|
+
if (subnet.address) {
|
|
85
|
+
const reverse = reverseAddress(subnet.address);
|
|
86
|
+
const reverseArpa = reverseArpaAddress(subnet.address);
|
|
87
|
+
const zone = {
|
|
88
|
+
id: reverseArpa,
|
|
89
|
+
file: `${reverse}.zone`,
|
|
90
|
+
records: new Set([
|
|
91
|
+
`${"@".padEnd(NAME_LEN, " ")} ${ttl} IN SOA ${
|
|
92
|
+
nameserver.domainName
|
|
93
|
+
}. ${rname}. (${updates})`,
|
|
94
|
+
`${(reverseArpa + ".").padEnd(NAME_LEN, " ")} ${ttl} IN NS ${
|
|
95
|
+
nameserver.domainName
|
|
96
|
+
}.`
|
|
97
|
+
])
|
|
98
|
+
};
|
|
99
|
+
zones.push(zone);
|
|
100
|
+
subnet.reverseZone = zone;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
for await (const {
|
|
105
|
+
address,
|
|
106
|
+
networkInterface
|
|
107
|
+
} of location.networkAddresses()) {
|
|
108
|
+
const host = networkInterface.host;
|
|
109
|
+
zone.records.add(
|
|
110
|
+
`${host.hostName.padEnd(NAME_LEN, " ")} ${ttl} IN ${
|
|
111
|
+
address.indexOf(".") >= 0 ? "A " : "AAAA"
|
|
112
|
+
} ${normalizeIPAddress(address)}`
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
for (const service of Object.values(host.services)) {
|
|
116
|
+
if (service.master && service.alias) {
|
|
117
|
+
zone.records.add(
|
|
118
|
+
`${service.alias.padEnd(NAME_LEN, " ")} ${ttl} IN CNAME ${
|
|
119
|
+
host.domainName
|
|
120
|
+
}.`
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (service.prefix) {
|
|
125
|
+
zone.records.add(
|
|
126
|
+
`${`${service.prefix}.${host.domainName}.`.padEnd(
|
|
127
|
+
NAME_LEN,
|
|
128
|
+
" "
|
|
129
|
+
)} ${ttl} IN SRV ${String(service.priority).padStart(4)} ${String(
|
|
130
|
+
service.weight
|
|
131
|
+
).padStart(3)} ${String(service.port).padStart(5)} ${
|
|
132
|
+
host.domainName
|
|
133
|
+
}.`
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const reverseZone = networkInterface.network.subnet?.reverseZone;
|
|
139
|
+
|
|
140
|
+
if (reverseZone && address.indexOf(".") >= 0) {
|
|
141
|
+
reverseZone.records.add(
|
|
142
|
+
`${(reverseArpaAddress(address) + ".").padEnd(
|
|
143
|
+
NAME_LEN,
|
|
144
|
+
" "
|
|
145
|
+
)} ${ttl} IN PTR ${networkInterface.host.domainName}.`
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const zoneConfig = [];
|
|
151
|
+
|
|
152
|
+
zones.push(catalogZone);
|
|
153
|
+
|
|
154
|
+
for (const zone of zones) {
|
|
155
|
+
if(zone !== catalogZone) {
|
|
156
|
+
const hash = createHmac("md5", zone.id).digest("hex");
|
|
157
|
+
catalogZone.records.add(`${hash}.zones.${domain}. IN PTR ${zone.id}.`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
zoneConfig.push(`zone \"${zone.id}\" {`);
|
|
161
|
+
zoneConfig.push(` type master;`);
|
|
162
|
+
zoneConfig.push(` file \"${zone.file}\";`);
|
|
163
|
+
|
|
164
|
+
const u = location.dnsAllowedUpdates;
|
|
165
|
+
zoneConfig.push(` allow-update { ${u.length ? u.join(';') : "none" }; };`);
|
|
166
|
+
zoneConfig.push(` notify yes;`);
|
|
167
|
+
zoneConfig.push(`};`);
|
|
168
|
+
zoneConfig.push("");
|
|
169
|
+
|
|
170
|
+
writeLines(join(targetDir, "var/lib/named"), zone.file, zone.records);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
writeLines(
|
|
174
|
+
join(targetDir, "etc/named.d/zones"),
|
|
175
|
+
`${domain}.zone.conf`,
|
|
176
|
+
zoneConfig
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function reverseAddress(address) {
|
|
182
|
+
if (address.indexOf(".") >= 0) {
|
|
183
|
+
return address.split(".").reverse().join(".");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return normalizeIPAddress(address)
|
|
187
|
+
.replaceAll(":", "")
|
|
188
|
+
.split("")
|
|
189
|
+
.reverse()
|
|
190
|
+
.join(".");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function reverseArpaAddress(address) {
|
|
194
|
+
return (
|
|
195
|
+
reverseAddress(address) +
|
|
196
|
+
(address.indexOf(".") >= 0 ? ".in-addr.arpa" : ".ip6.arpa")
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function normalizeIPAddress(address) {
|
|
201
|
+
if (address.indexOf(".") >= 0) {
|
|
202
|
+
return address;
|
|
203
|
+
}
|
|
204
|
+
address = address.replace(/\/\d+$/, "");
|
|
205
|
+
const parts = address.split(":");
|
|
206
|
+
const i = parts.indexOf("");
|
|
207
|
+
if (i >= 0) {
|
|
208
|
+
parts.splice(i, 1, ..."0".repeat(9 - parts.length));
|
|
209
|
+
}
|
|
210
|
+
return parts.map(s => s.padStart(4, "0")).join(":");
|
|
211
|
+
}
|
package/bin/host-info
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { cwd, argv } from "node:process";
|
|
3
|
+
import { World } from "../src/model.mjs";
|
|
4
|
+
|
|
5
|
+
const world = new World(argv[2] || cwd());
|
|
6
|
+
|
|
7
|
+
const hostName = argv[3];
|
|
8
|
+
|
|
9
|
+
if (hostName) {
|
|
10
|
+
const host = await world.host(hostName);
|
|
11
|
+
console.log(host.toJSON());
|
|
12
|
+
} else {
|
|
13
|
+
for await (const host of world.hosts()) {
|
|
14
|
+
console.log(host.name);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { cwd, argv } from "node:process";
|
|
3
|
+
import { World } from "../src/model.mjs";
|
|
4
|
+
|
|
5
|
+
const world = new World(argv[2] || cwd());
|
|
6
|
+
|
|
7
|
+
const locationName = argv[3];
|
|
8
|
+
|
|
9
|
+
if (locationName) {
|
|
10
|
+
const location = await world.location(locationName);
|
|
11
|
+
await location.load();
|
|
12
|
+
console.log(location.toJSON());
|
|
13
|
+
} else {
|
|
14
|
+
for await (const location of world.locations()) {
|
|
15
|
+
console.log(location.name);
|
|
16
|
+
console.log(" ", (await location.service({ type: "dns"}))?.toString());
|
|
17
|
+
}
|
|
18
|
+
}
|
package/bin/network
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { cwd, argv } from "node:process";
|
|
4
|
+
import { World } from "../src/model.mjs";
|
|
5
|
+
|
|
6
|
+
const world = new World(argv[2] || cwd());
|
|
7
|
+
const location = await world.location(argv[3] || "SW");
|
|
8
|
+
|
|
9
|
+
function q(str) {
|
|
10
|
+
return str.match(/^\w+$/) ? str : `"${str}"`;
|
|
11
|
+
}
|
|
12
|
+
function id(str) {
|
|
13
|
+
return str.replaceAll(/-/g,'');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
console.log("graph G {");
|
|
17
|
+
console.log(" node [shape=record];");
|
|
18
|
+
for await (const host of location.hosts()) {
|
|
19
|
+
console.log(
|
|
20
|
+
` ${id(host.name)} [label="${host.name}|{${Object.entries(
|
|
21
|
+
host.networkInterfaces
|
|
22
|
+
)
|
|
23
|
+
.map(([n, i]) => `<${id(n)}> ${n}`)
|
|
24
|
+
.join("|")}}"];`
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
for await (const network of location.networks()) {
|
|
29
|
+
console.log(` ${id(network.name)} [label="${network.name}\\n${network.ipv4}" shape=circle];`);
|
|
30
|
+
|
|
31
|
+
for await (const host of network.hosts()) {
|
|
32
|
+
for (const [n, i] of Object.entries(host.networkInterfaces)) {
|
|
33
|
+
if (i.network === network) {
|
|
34
|
+
console.log(` ${id(network.name)} -- ${id(host.name)}:${id(n)};`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
console.log("}");
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pmcf",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"publishConfig": {
|
|
5
|
+
"access": "public"
|
|
6
|
+
},
|
|
7
|
+
"description": "Poor mans configuration management",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"config management",
|
|
10
|
+
"config"
|
|
11
|
+
],
|
|
12
|
+
"contributors": [
|
|
13
|
+
{
|
|
14
|
+
"name": "Markus Felten",
|
|
15
|
+
"email": "markus.felten@gmx.de"
|
|
16
|
+
}
|
|
17
|
+
],
|
|
18
|
+
"license": "0BSD",
|
|
19
|
+
"bin": {
|
|
20
|
+
"gen-host-defs": "./bin/gen-host-defs",
|
|
21
|
+
"gen-location-defs": "./bin/gen-location-defs",
|
|
22
|
+
"gen-named-defs": "./bin/gen-named-defs",
|
|
23
|
+
"host-info": "./bin/host-info",
|
|
24
|
+
"location-info": "./bin/location-info",
|
|
25
|
+
"network": "./bin/network"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"test": "node --run test:ava",
|
|
29
|
+
"test:ava": "ava --timeout 4m tests/*-ava.mjs tests/*-ava-node.mjs",
|
|
30
|
+
"cover": "c8 -x 'tests/**/*' --temp-directory build/tmp ava --timeout 4m tests/*-ava.mjs tests/*-ava-node.mjs && c8 report -r lcov -o build/coverage --temp-directory build/tmp",
|
|
31
|
+
"docs": "documentation readme --section=API ./src**/*.mjs",
|
|
32
|
+
"lint": "node --run lint:docs",
|
|
33
|
+
"lint:docs": "documentation lint ./src**/*.mjs"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"ava": "^6.2.0",
|
|
37
|
+
"c8": "^10.1.3",
|
|
38
|
+
"documentation": "^14.0.3",
|
|
39
|
+
"semantic-release": "^24.2.1"
|
|
40
|
+
},
|
|
41
|
+
"engines": {
|
|
42
|
+
"node": ">=22.13.0"
|
|
43
|
+
},
|
|
44
|
+
"repository": {
|
|
45
|
+
"type": "git",
|
|
46
|
+
"url": "git+https://github.com/arlac77/pmcf.git"
|
|
47
|
+
},
|
|
48
|
+
"bugs": {
|
|
49
|
+
"url": "https://github.com/arlac77/pmcf/issues"
|
|
50
|
+
},
|
|
51
|
+
"homepage": "https://github.com/arlac77/pmcf#readme",
|
|
52
|
+
"template": {
|
|
53
|
+
"inheritFrom": [
|
|
54
|
+
"arlac77/template-arlac77-github",
|
|
55
|
+
"arlac77/template-node-app"
|
|
56
|
+
]
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
host1
|
|
File without changes
|
|
File without changes
|
package/src/model.mjs
ADDED
|
@@ -0,0 +1,757 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir, glob } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
class Base {
|
|
5
|
+
owner;
|
|
6
|
+
name;
|
|
7
|
+
|
|
8
|
+
static get typeName() {
|
|
9
|
+
return "base";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
static get typeFileName() {
|
|
13
|
+
return this.typeName + ".json";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
static get fileNameGlob() {
|
|
17
|
+
return "**/" + this.typeFileName;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
static baseName(name) {
|
|
21
|
+
if (!name) {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (name.endsWith("/" + this.typeFileName)) {
|
|
26
|
+
return name.substring(0, name.length - this.typeFileName.length - 1);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return name;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
constructor(owner, data) {
|
|
33
|
+
this.owner = owner;
|
|
34
|
+
if (data?.name) {
|
|
35
|
+
this.name = data.name;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
get typeName()
|
|
40
|
+
{
|
|
41
|
+
return this.constructor.typeName;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
get host()
|
|
45
|
+
{
|
|
46
|
+
if(this instanceof Host) { return this; }
|
|
47
|
+
return this.owner.host;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
get network()
|
|
51
|
+
{
|
|
52
|
+
if(this instanceof Network) { return this; }
|
|
53
|
+
return this.owner.network;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
expand(object) {
|
|
57
|
+
if (typeof object === "string") {
|
|
58
|
+
return object.replaceAll(/\$\{([^\}]*)\}/g, (match, m1) => {
|
|
59
|
+
return this[m1] || "${" + m1 + "}";
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (Array.isArray(object)) {
|
|
64
|
+
return object.map(e => this.expand(e));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (object instanceof Set) {
|
|
68
|
+
return new Set([...object].map(e => this.expand(e)));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return object;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
toString() {
|
|
75
|
+
return this.typeName + ":" + this.owner.name + "/" + this.name;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
toJSON() {
|
|
79
|
+
return {
|
|
80
|
+
name: this.name,
|
|
81
|
+
owner: this.owner.name
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export class World {
|
|
87
|
+
baseDir;
|
|
88
|
+
/** @typedef {Map<string,Location>} */ #locations = new Map();
|
|
89
|
+
/** @typedef {Map<string,Host>} */ #hosts = new Map();
|
|
90
|
+
|
|
91
|
+
constructor(baseDir) {
|
|
92
|
+
this.baseDir = baseDir;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
get name() {
|
|
96
|
+
return "";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async *locations() {
|
|
100
|
+
if (this.#locations.size > 0) {
|
|
101
|
+
for (const location of this.#locations.values()) {
|
|
102
|
+
yield location;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
for await (const name of glob(Location.fileNameGlob, {
|
|
107
|
+
cwd: this.baseDir
|
|
108
|
+
})) {
|
|
109
|
+
yield this.location(name);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async *hosts() {
|
|
114
|
+
if (this.#hosts.size > 0) {
|
|
115
|
+
for (const host of this.#hosts.values()) {
|
|
116
|
+
yield host;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
for await (const name of glob(Host.fileNameGlob, {
|
|
121
|
+
cwd: this.baseDir
|
|
122
|
+
})) {
|
|
123
|
+
yield this.host(name);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async *domains() {
|
|
128
|
+
for await (const location of this.locations()) {
|
|
129
|
+
yield location.domain;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async location(name) {
|
|
134
|
+
name = Location.baseName(name);
|
|
135
|
+
if (name === undefined) {
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
let location = this.#locations.get(name);
|
|
140
|
+
if (location) {
|
|
141
|
+
return location;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const directory = join(this.baseDir, name);
|
|
145
|
+
try {
|
|
146
|
+
const data = JSON.parse(
|
|
147
|
+
await readFile(join(directory, Location.typeFileName), "utf8")
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
data.directory = directory;
|
|
151
|
+
data.name = name;
|
|
152
|
+
|
|
153
|
+
location = new Location(this, data);
|
|
154
|
+
} catch {} // TODO
|
|
155
|
+
this.#locations.set(name, location);
|
|
156
|
+
return location;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async host(name) {
|
|
160
|
+
name = Host.baseName(name);
|
|
161
|
+
|
|
162
|
+
if (name === undefined) {
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
let host = this.#hosts.get(name);
|
|
167
|
+
if (host) {
|
|
168
|
+
return host;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const directory = join(this.baseDir, name);
|
|
172
|
+
const data = JSON.parse(
|
|
173
|
+
await readFile(join(directory, Host.typeFileName), "utf8")
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
data.directory = directory;
|
|
177
|
+
|
|
178
|
+
if (!data.name) {
|
|
179
|
+
data.name = name;
|
|
180
|
+
} else {
|
|
181
|
+
name = data.name;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (!data.location) {
|
|
185
|
+
const parts = name.split(/\//);
|
|
186
|
+
|
|
187
|
+
if (parts.length > 1 && parts[0] !== "services" && parts[0] !== "model") {
|
|
188
|
+
data.location = parts[0];
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
data.location = await this.location(data.location);
|
|
193
|
+
|
|
194
|
+
if (data.extends) {
|
|
195
|
+
data.extends = await Promise.all(data.extends.map(e => this.host(e)));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
host = new (data.name.indexOf("model/") >= 0 ? Model : Host)(this, data);
|
|
199
|
+
|
|
200
|
+
this.#hosts.set(name, host);
|
|
201
|
+
return host;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async *subnets() {
|
|
205
|
+
for await (const location of this.locations()) {
|
|
206
|
+
yield* location.subnets();
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async *networkAddresses() {
|
|
211
|
+
for await (const host of this.hosts()) {
|
|
212
|
+
for (const networkAddresses of host.networkAddresses()) {
|
|
213
|
+
yield networkAddresses;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
class Host extends Base {
|
|
220
|
+
directory;
|
|
221
|
+
networkInterfaces = {};
|
|
222
|
+
services = {};
|
|
223
|
+
postinstall = [];
|
|
224
|
+
location;
|
|
225
|
+
#extends = [];
|
|
226
|
+
#provides = new Set();
|
|
227
|
+
#replaces = new Set();
|
|
228
|
+
#depends = new Set();
|
|
229
|
+
#master = false;
|
|
230
|
+
#os;
|
|
231
|
+
#distribution;
|
|
232
|
+
#deployment;
|
|
233
|
+
|
|
234
|
+
static get typeName() {
|
|
235
|
+
return "host";
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
constructor(owner, data) {
|
|
239
|
+
super(owner, data);
|
|
240
|
+
|
|
241
|
+
if (data.deployment !== undefined) {
|
|
242
|
+
this.#deployment = data.deployment;
|
|
243
|
+
delete data.deployment;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (data.extends !== undefined) {
|
|
247
|
+
this.#extends = data.extends;
|
|
248
|
+
delete data.extends;
|
|
249
|
+
}
|
|
250
|
+
if (data.os !== undefined) {
|
|
251
|
+
this.#os = data.os;
|
|
252
|
+
delete data.os;
|
|
253
|
+
}
|
|
254
|
+
if (data.distribution !== undefined) {
|
|
255
|
+
this.#distribution = data.distribution;
|
|
256
|
+
delete data.distribution;
|
|
257
|
+
}
|
|
258
|
+
if (data.master !== undefined) {
|
|
259
|
+
this.#master = data.master;
|
|
260
|
+
delete data.master;
|
|
261
|
+
}
|
|
262
|
+
if (data.depends !== undefined) {
|
|
263
|
+
this.#depends = new Set(data.depends);
|
|
264
|
+
delete data.depends;
|
|
265
|
+
}
|
|
266
|
+
if (data.replaces !== undefined) {
|
|
267
|
+
this.#replaces = new Set(data.replaces);
|
|
268
|
+
delete data.replaces;
|
|
269
|
+
}
|
|
270
|
+
if (data.provides !== undefined) {
|
|
271
|
+
this.#provides = new Set(data.provides);
|
|
272
|
+
delete data.provides;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
Object.assign(this, { services: {}, networkInterfaces: {} }, data);
|
|
276
|
+
|
|
277
|
+
this.location?.addHost(this);
|
|
278
|
+
|
|
279
|
+
for (const [name, iface] of Object.entries(this.networkInterfaces)) {
|
|
280
|
+
iface.host = this;
|
|
281
|
+
iface.name = name;
|
|
282
|
+
if (iface.network) {
|
|
283
|
+
iface.network = this.location?.network(iface.network);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
for (const [name, data] of Object.entries(
|
|
288
|
+
Object.assign({}, ...this.extends.map(e => e.services), this.services)
|
|
289
|
+
)) {
|
|
290
|
+
data.name = name;
|
|
291
|
+
this.services[name] = new Service(this, data);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
get deployment() {
|
|
296
|
+
return this.#deployment || this.extends.find(e => e.deployment);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
get extends() {
|
|
300
|
+
return this.#extends.map(e => this.expand(e));
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
get provides() {
|
|
304
|
+
let provides = new Set(this.#provides);
|
|
305
|
+
this.extends.forEach(h => (provides = provides.union(h.provides)));
|
|
306
|
+
return provides;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
get replaces() {
|
|
310
|
+
let replaces = new Set(this.#replaces);
|
|
311
|
+
this.extends.forEach(h => (replaces = replaces.union(h.replaces)));
|
|
312
|
+
return replaces;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
get _depends() {
|
|
316
|
+
let depends = this.#depends;
|
|
317
|
+
this.extends.forEach(h => (depends = depends.union(h._depends)));
|
|
318
|
+
return depends;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
get depends() {
|
|
322
|
+
return this.expand(this._depends);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
get master() {
|
|
326
|
+
return this.#master || this.extends.find(e => e.master) ? true : false;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
get os() {
|
|
330
|
+
return this.#os || this.extends.find(e => e.os);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
get distribution() {
|
|
334
|
+
return this.#distribution || this.extends.find(e => e.distribution);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
get model() {
|
|
338
|
+
return this.extends.find(h => h instanceof Model);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
get domain() {
|
|
342
|
+
return this.location?.domain;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
get modelName() {
|
|
346
|
+
return this.model?.hostName;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
get hostName() {
|
|
350
|
+
const parts = this.name.split(/\//);
|
|
351
|
+
return parts[parts.length - 1];
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
get domainName() {
|
|
355
|
+
return this.hostName + "." + this.domain;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
*networkAddresses() {
|
|
359
|
+
for (const [name, networkInterface] of Object.entries(
|
|
360
|
+
this.networkInterfaces
|
|
361
|
+
)) {
|
|
362
|
+
for (const attribute of ["ipv4", "ipv6", "link-local-ipv6"]) {
|
|
363
|
+
if (networkInterface[attribute]) {
|
|
364
|
+
yield { address: networkInterface[attribute], networkInterface };
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
get ipAddress() {
|
|
371
|
+
for (const a of this.networkAddresses()) {
|
|
372
|
+
return a.address;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async publicKey(type = "ed25519") {
|
|
377
|
+
return readFile(join(this.directory, `ssh_host_${type}_key.pub`), "utf8");
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
toJSON() {
|
|
381
|
+
return {
|
|
382
|
+
...super.toJSON(),
|
|
383
|
+
...Object.fromEntries(
|
|
384
|
+
[
|
|
385
|
+
"directory",
|
|
386
|
+
"location",
|
|
387
|
+
"model",
|
|
388
|
+
"os",
|
|
389
|
+
"distribution",
|
|
390
|
+
"deployment",
|
|
391
|
+
"replaces",
|
|
392
|
+
"depends",
|
|
393
|
+
"master",
|
|
394
|
+
"networkInterfaces"
|
|
395
|
+
]
|
|
396
|
+
.filter(p => this[p])
|
|
397
|
+
.map(p => [p, this[p]])
|
|
398
|
+
),
|
|
399
|
+
extends: this.extends.map(host => host.name),
|
|
400
|
+
services: Object.fromEntries(
|
|
401
|
+
Object.values(this.services).map(s => [s.name, s.toJSON()])
|
|
402
|
+
)
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
class Model extends Host {}
|
|
408
|
+
|
|
409
|
+
class Location extends Base {
|
|
410
|
+
directory;
|
|
411
|
+
domain;
|
|
412
|
+
dns;
|
|
413
|
+
#administratorEmail;
|
|
414
|
+
#hosts = new Map();
|
|
415
|
+
#networks = new Map();
|
|
416
|
+
#subnets = new Map();
|
|
417
|
+
|
|
418
|
+
static get typeName() {
|
|
419
|
+
return "location";
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
constructor(owner, data) {
|
|
423
|
+
super(owner, data);
|
|
424
|
+
|
|
425
|
+
const networks = data.networks;
|
|
426
|
+
delete data.networks;
|
|
427
|
+
Object.assign(this, data);
|
|
428
|
+
|
|
429
|
+
if (networks) {
|
|
430
|
+
for (const [name, network] of Object.entries(networks)) {
|
|
431
|
+
network.name = name;
|
|
432
|
+
this.addNetwork(network);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
for (const network of this.#networks.values()) {
|
|
436
|
+
if (network.bridges) {
|
|
437
|
+
network.bridges = new Set(
|
|
438
|
+
network.bridges.map(b => {
|
|
439
|
+
const n = this.network(b);
|
|
440
|
+
if (!n) {
|
|
441
|
+
console.error(`No network named ${b}`);
|
|
442
|
+
}
|
|
443
|
+
return n;
|
|
444
|
+
})
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
async load() {
|
|
452
|
+
for await (const host of this.owner.hosts());
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
async *hosts() {
|
|
456
|
+
for await (const host of this.owner.hosts()) {
|
|
457
|
+
if (host.location === this) {
|
|
458
|
+
yield host;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
async service(filter) {
|
|
464
|
+
let best;
|
|
465
|
+
for await (const service of this.services(filter)) {
|
|
466
|
+
if (!best || service.priority < best.priority) {
|
|
467
|
+
best = service;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
return best;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
async *services(filter) {
|
|
475
|
+
for await (const host of this.hosts()) {
|
|
476
|
+
for (const service of Object.values(host.services)) {
|
|
477
|
+
if (
|
|
478
|
+
!filter ||
|
|
479
|
+
filter.type === "*" ||
|
|
480
|
+
filter.type === service.type ||
|
|
481
|
+
filter.name === service.name
|
|
482
|
+
) {
|
|
483
|
+
yield service;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
network(name) {
|
|
490
|
+
return this.#networks.get(name);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
async *networkAddresses() {
|
|
494
|
+
await this.load();
|
|
495
|
+
for await (const host of this.hosts()) {
|
|
496
|
+
for (const networkAddresses of host.networkAddresses()) {
|
|
497
|
+
yield networkAddresses;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
async *networks() {
|
|
503
|
+
await this.load();
|
|
504
|
+
for (const network of this.#networks.values()) {
|
|
505
|
+
yield network;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
async *subnets() {
|
|
510
|
+
await this.load();
|
|
511
|
+
for (const subnet of this.#subnets.values()) {
|
|
512
|
+
yield subnet;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
addNetwork(data) {
|
|
517
|
+
if (!data?.name) {
|
|
518
|
+
return undefined;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
let network = this.#networks.get(data.name);
|
|
522
|
+
if (network) {
|
|
523
|
+
return network;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
network = new Network(this, data);
|
|
527
|
+
this.#networks.set(data.name, network);
|
|
528
|
+
|
|
529
|
+
const subnetAddress = network.subnetAddress;
|
|
530
|
+
|
|
531
|
+
if (subnetAddress) {
|
|
532
|
+
let subnet = this.#subnets.get(subnetAddress);
|
|
533
|
+
if (!subnet) {
|
|
534
|
+
subnet = new Subnet(this, subnetAddress);
|
|
535
|
+
this.#subnets.set(subnetAddress, subnet);
|
|
536
|
+
}
|
|
537
|
+
network.subnet = subnet;
|
|
538
|
+
subnet.networks.add(network);
|
|
539
|
+
}
|
|
540
|
+
return network;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
addHost(host) {
|
|
544
|
+
this.#hosts.set(host.name, host);
|
|
545
|
+
|
|
546
|
+
for (const [name, iface] of Object.entries(host.networkInterfaces)) {
|
|
547
|
+
const network = this.addNetwork({ name: iface.network });
|
|
548
|
+
if (!network) {
|
|
549
|
+
console.error("Missing network", host.name, name);
|
|
550
|
+
} else {
|
|
551
|
+
network.addHost(host);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
get dnsAllowedUpdates() {
|
|
557
|
+
return this.dns?.allowedUpdates || [];
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
get dnsRecordTTL() {
|
|
561
|
+
return this.dns?.recordTTL || "1W";
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
get administratorEmail() {
|
|
565
|
+
return this.#administratorEmail || "admin@" + this.domain;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
toJSON() {
|
|
569
|
+
return {
|
|
570
|
+
...super.toJSON(),
|
|
571
|
+
domain: this.domain,
|
|
572
|
+
hosts: [...this.#hosts.keys()].sort()
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
class Network extends Base {
|
|
578
|
+
#hosts = new Map();
|
|
579
|
+
kind;
|
|
580
|
+
ipv4;
|
|
581
|
+
ipv4_netmask;
|
|
582
|
+
subnet;
|
|
583
|
+
|
|
584
|
+
static get typeName() {
|
|
585
|
+
return "network";
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
constructor(owner, data) {
|
|
589
|
+
super(owner, data);
|
|
590
|
+
|
|
591
|
+
Object.assign(this, data);
|
|
592
|
+
|
|
593
|
+
if (this.ipv4) {
|
|
594
|
+
const m = this.ipv4.match(/\/(\d+)$/);
|
|
595
|
+
if (m) {
|
|
596
|
+
this.ipv4_netmask = m[1];
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
get subnetAddress() {
|
|
602
|
+
if (this.ipv4) {
|
|
603
|
+
const [addr, bits] = this.ipv4.split(/\//);
|
|
604
|
+
const parts = addr.split(/\./);
|
|
605
|
+
return parts.slice(0, bits / 8).join(".");
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
async *hosts() {
|
|
610
|
+
for (const host of this.#hosts.values()) {
|
|
611
|
+
yield host;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
addHost(host) {
|
|
616
|
+
this.#hosts.set(host.name, host);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
toJSON() {
|
|
620
|
+
return {
|
|
621
|
+
...super.toJSON(),
|
|
622
|
+
kind: this.kind
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
class Subnet extends Base {
|
|
628
|
+
networks = new Set();
|
|
629
|
+
|
|
630
|
+
static get typeName() {
|
|
631
|
+
return "subnet";
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
constructor(owner, address) {
|
|
635
|
+
super(owner, { name: address });
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
get address() {
|
|
639
|
+
return this.name;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
const ServiceTypes = {
|
|
644
|
+
dns: { prefix: "_dns._udp", port: 53 },
|
|
645
|
+
ldap: { prefix: "_ldap._tcp", port: 389 },
|
|
646
|
+
http: { prefix: "_http._tcp", port: 80 },
|
|
647
|
+
https: { prefix: "_http._tcp", port: 443 },
|
|
648
|
+
rtsp: { prefix: "_rtsp._tcp", port: 554 },
|
|
649
|
+
smtp: { prefix: "_smtp._tcp", port: 25 },
|
|
650
|
+
ssh: { prefix: "_ssh._tcp", port: 22 },
|
|
651
|
+
imap: { prefix: "_imap._tcp", port: 143 },
|
|
652
|
+
imaps: { prefix: "_imaps._tcp", port: 993 },
|
|
653
|
+
dhcp: {}
|
|
654
|
+
};
|
|
655
|
+
|
|
656
|
+
class Service extends Base {
|
|
657
|
+
alias;
|
|
658
|
+
#weight;
|
|
659
|
+
#priority;
|
|
660
|
+
#type;
|
|
661
|
+
#port;
|
|
662
|
+
#ipAddress;
|
|
663
|
+
|
|
664
|
+
static get typeName() {
|
|
665
|
+
return "service";
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
constructor(owner, data) {
|
|
669
|
+
super(owner, data);
|
|
670
|
+
if (data.weight !== undefined) {
|
|
671
|
+
this.#weight = data.weight;
|
|
672
|
+
delete data.weight;
|
|
673
|
+
}
|
|
674
|
+
if (data.priority !== undefined) {
|
|
675
|
+
this.#priority = data.priority;
|
|
676
|
+
delete data.priority;
|
|
677
|
+
}
|
|
678
|
+
if (data.type) {
|
|
679
|
+
this.#type = data.type;
|
|
680
|
+
delete data.type;
|
|
681
|
+
}
|
|
682
|
+
if (data.port !== undefined) {
|
|
683
|
+
this.#port = data.port;
|
|
684
|
+
delete data.port;
|
|
685
|
+
}
|
|
686
|
+
if (data.ipAddress) {
|
|
687
|
+
this.#ipAddress = data.ipAddress;
|
|
688
|
+
delete data.ipAddress;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
Object.assign(this, data);
|
|
692
|
+
this.owner = owner;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
get prefix() {
|
|
696
|
+
return ServiceTypes[this.type]?.prefix;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
get ipAddress() {
|
|
700
|
+
return this.#ipAddress || this.owner.ipAddress;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
get port() {
|
|
704
|
+
return this.#port || ServiceTypes[this.type]?.port;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
get priority() {
|
|
708
|
+
return /*this.#priority || */ this.owner.priority || 99;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
get weight() {
|
|
712
|
+
return this.#weight || this.owner.weight || 0;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
get master() {
|
|
716
|
+
return this.owner.master;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
get type() {
|
|
720
|
+
return this.#type || this.name;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
toJSON() {
|
|
724
|
+
return {
|
|
725
|
+
...super.toJSON(),
|
|
726
|
+
ipAddress: this.ipAddress,
|
|
727
|
+
alias: this.alias,
|
|
728
|
+
type: this.type,
|
|
729
|
+
master: this.master,
|
|
730
|
+
priority: this.priority,
|
|
731
|
+
weight: this.weight
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
export async function writeLines(dir, name, lines) {
|
|
737
|
+
await mkdir(dir, { recursive: true });
|
|
738
|
+
return writeFile(
|
|
739
|
+
join(dir, name),
|
|
740
|
+
[...lines]
|
|
741
|
+
.flat()
|
|
742
|
+
.filter(line => line !== undefined)
|
|
743
|
+
.map(l => l + "\n")
|
|
744
|
+
.join(""),
|
|
745
|
+
"utf8"
|
|
746
|
+
);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
export function sectionLines(sectionName, values) {
|
|
750
|
+
const lines = [`[${sectionName}]`];
|
|
751
|
+
|
|
752
|
+
for (const [name, value] of Object.entries(values)) {
|
|
753
|
+
lines.push(`${name}=${value}`);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
return lines;
|
|
757
|
+
}
|