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 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
+ [![npm](https://img.shields.io/npm/v/pmcf.svg)](https://www.npmjs.com/package/pmcf)
2
+ [![License](https://img.shields.io/badge/License-0BSD-blue.svg)](https://spdx.org/licenses/0BSD.html)
3
+ [![bundlejs](https://deno.bundlejs.com/?q=pmcf\&badge=detailed)](https://bundlejs.com/?q=pmcf)
4
+ [![downloads](http://img.shields.io/npm/dm/pmcf.svg?style=flat-square)](https://npmjs.org/package/pmcf)
5
+ [![GitHub Issues](https://img.shields.io/github/issues/arlac77/pmcf.svg?style=flat-square)](https://github.com/arlac77/pmcf/issues)
6
+ [![Build Status](https://img.shields.io/endpoint.svg?url=https%3A%2F%2Factions-badge.atrox.dev%2Farlac77%2Fpmcf%2Fbadge\&style=flat)](https://actions-badge.atrox.dev/arlac77/pmcf/goto)
7
+ [![Styled with prettier](https://img.shields.io/badge/styled_with-prettier-ff69b4.svg)](https://github.com/prettier/prettier)
8
+ [![Commitizen friendly](https://img.shields.io/badge/commitizen-friendly-brightgreen.svg)](http://commitizen.github.io/cz-cli/)
9
+ [![Known Vulnerabilities](https://snyk.io/test/github/arlac77/pmcf/badge.svg)](https://snyk.io/test/github/arlac77/pmcf)
10
+ [![Coverage Status](https://coveralls.io/repos/arlac77/pmcf/badge.svg)](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
@@ -0,0 +1,5 @@
1
+ CHASSIS=server
2
+ DEPLOYMENT=undefined
3
+ LOCATION=L1
4
+ HARDWARE_VENDOR=undefined
5
+ HARDWARE_MODEL=m1
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
+ }