Files

321 lines
8.9 KiB
JavaScript

const { fork } = require("node:child_process");
const fs = require("fs");
const yaml = require("js-yaml");
const redis = require("redis");
const { nsupdate } = require("./nsupdate");
const { dig } = require("./dig");
const { elevenLogJSON } = require("/labels/lib/util.js");
process
.on("unhandledRejection", (e, p) => {
elevenLogJSON("error", { unhandledRejection: e.toString() });
})
.on("uncaughtException", (e) => {
elevenLogJSON("error", { uncaughtException: e.toString() });
});
class Labels {
#config = { webhook: { headers: { "Content-Type": "application/json" } } };
#workers = {};
#interval = { run: false, fok: false };
#redis;
constructor() {
const config = yaml.load(
fs.readFileSync(`${process.env.APP_ROOT}/etc/config.yaml`, "utf8"),
)?.labels;
this.#config.redis = {
url: config?.redis?.url || "rediss://localhost:6379/0",
password: process.env.REDIS_PASSWORD,
};
this.#config.webhook.url = config?.webhook?.url || null;
if (this.#config.webhook.url && config?.webhook?.auth?.basic) {
this.#config.webhook.headers["Authorization"] =
"Basic " + Buffer.from(config.webhook.auth.basic).toString("base64");
elevenLogJSON(
"info",
`using webhook ${this.#config.webhook.url} with basic authentication`,
);
}
this.#config.rfc2136 = {
verify: config?.rfc2136?.verify || false,
remove: config?.rfc2136?.remove || false,
};
this.#config.poll = { interval: config?.poll?.interval || 300 };
this.#config.ping = { interval: config?.ping?.interval || 2.5 };
this.#config.port = config?.port || 2376;
this.#config.timeout = config?.timeout || 5;
this.#config.interval = config?.interval || 0;
this.#config.tls = {
ca: config?.tls?.ca || `${process.env.APP_ROOT}/ssl/ca.crt`,
crt: config?.tls?.crt || `${process.env.APP_ROOT}/ssl/labels.crt`,
key: config?.tls?.key || `${process.env.APP_ROOT}/ssl/labels.key`,
};
if (this.#config.interval > 0) {
elevenLogJSON(
"info",
`${process.env.APP_ROOT}/etc/config.yaml will reload labels.nodes every ${this.#config.interval}s`,
);
}
}
async run() {
this.#redis = await redis.createClient({
url: this.#config.redis.url,
pingInterval: 30000,
socket: {
rejectUnauthorized: false,
},
password: this.#config.redis.url,
disableOfflineQueue: false,
commandsQueueMaxLength: 1024,
});
this.#redis.on("ready", async () => {
elevenLogJSON("info", `connected to redis`);
this.#run();
if (this.#config.interval > 0) {
setInterval(async () => {
if (!this.#interval.run) {
this.#interval.run = true;
try {
await this.#run();
} catch (e) {
elevenLogJSON("error", { error: e });
} finally {
this.#interval.run = false;
}
}
}, this.#config.interval * 1000);
}
setInterval(async () => {
if (!this.#interval.fork) {
this.#interval.fork = true;
try {
await this.#fork();
} catch (e) {
elevenLogJSON("error", { error: e });
} finally {
this.#interval.fork = false;
}
}
}, this.#config.ping.interval * 1000);
});
this.#redis.on("error", (error) => {
elevenLogJSON("error", { redis: error.toString() });
});
this.#redis.connect();
}
#run() {
const nodes = yaml.load(
fs.readFileSync(`${process.env.APP_ROOT}/etc/config.yaml`, "utf8"),
)?.labels?.nodes;
for (const node of nodes) {
if (!this.#workers[node]) {
this.#workers[node] = new Worker(this.#config, node, this);
this.#workers[node].fork();
elevenLogJSON("info", `created new worker for node [${node}]`);
}
}
}
async #fork() {
for (const node in this.#workers) {
if (!this.#workers[node].run) {
await this.#workers[node].fork();
if (!this.#workers[node].log.disconnect) {
this.#workers[node].log.disconnect = true;
elevenLogJSON(
"info",
`trying to fork existing worker for node [${node}] after disconnect`,
);
}
}
}
}
async inspect(container) {
try {
const counter = {
add: 0,
del: 0,
};
const rfc2136 = {
WAN: { server: "", key: "", commands: [] },
LAN: { server: "", key: "", commands: [] },
};
for (const label in container.labels) {
switch (true) {
case /traefik\//i.test(label):
if (container.start) {
counter.add++;
await this.#redis.set(label, container.labels[label], {
EX: this.#config.poll.interval + 30,
});
} else {
counter.del++;
await this.#redis.del(label);
}
break;
case /rfc2136\//i.test(label):
const type = label.match(/rfc2136\/WAN\//i) ? "WAN" : "LAN";
switch (true) {
case /rfc2136\/\S+\/server/i.test(label):
rfc2136[type].server = container.labels[label];
break;
case /rfc2136\/\S+\/key/i.test(label):
rfc2136[type].key = container.labels[label];
break;
default:
if (!container.start && this.#config.rfc2136.remove) {
container.labels[label] = container.labels[label].replace(
/update add/i,
"update delete",
);
}
rfc2136[type].commands.push(container.labels[label]);
}
break;
}
}
if (rfc2136.LAN.commands.length > 0 || rfc2136.WAN.commands.length) {
await this.#rfc2136(rfc2136);
}
if (this.#config?.webhook?.url) {
await this.#webhook(container);
}
} catch (e) {
elevenLogJSON("error", { inspect: e.toString(), exception: e });
}
}
async #rfc2136(rfc2136) {
for (const type in rfc2136) {
if (
rfc2136[type].commands.length > 0 &&
rfc2136[type].server &&
rfc2136[type].key
) {
if (this.#config.rfc2136.verify) {
for (let i = 0; i < rfc2136[type].commands.length; i++) {
if (
await this.#rfc2136knownRecord(
rfc2136[type].server,
rfc2136[type].commands[i],
)
) {
rfc2136[type].commands.splice(i, 1);
}
}
}
try {
if (rfc2136[type].commands.length > 0) {
await nsupdate(
rfc2136[type].server,
rfc2136[type].key,
rfc2136[type].commands,
);
}
} catch (e) {
elevenLogJSON("error", { nsupdate: { exception: e.toString() } });
}
}
}
}
async #rfc2136knownRecord(server, nsupdate) {
const matches = nsupdate.match(/update add (\S+) \d+ (\S+) (\S+)/i);
if (matches && matches.length >= 4) {
try {
const record = await dig(server, matches[2], matches[1]);
return record.match(new RegExp(matches[3], "ig"));
} catch (e) {
return false;
}
}
}
async #webhook(container) {
try {
await fetch(this.#config.webhook.url, {
method: container.start ? "PUT" : "DELETE",
body: JSON.stringify(container),
headers: this.#config.webhook.headers,
signal: AbortSignal.timeout(2500),
});
} catch (e) {
elevenLogJSON("error", { webhook: { exception: e.toString() } });
}
}
}
class Worker {
run = false;
#fork;
#config;
#parent;
log = {
disconnect: false,
};
constructor(config, node, parent) {
this.#config = {
tls: config.tls,
poll: config.poll.interval,
ping: config.ping.interval,
port: config.port,
node: node,
};
this.#parent = parent;
}
async fork() {
return new Promise((resolve, reject) => {
this.#fork = fork(
`${process.env.APP_ROOT}/worker.js`,
[JSON.stringify(this.#config)],
{ stdio: "inherit" },
);
this.#fork.on("spawn", () => {
this.run = true;
resolve();
});
this.#fork.on("error", () => {
this.run = false;
reject();
});
this.#fork.on("close", (code) => {
this.run = false;
});
this.#fork.on("message", (message) => {
if (message.error) {
if (!this.log.disconnect) {
elevenLogJSON("error", { fork: message.error });
}
} else {
this.run = true;
this.log.disconnect = false;
if (message?.labels) {
this.#parent.inspect.apply(this.#parent, [message]);
}
}
});
});
}
}
new Labels().run();