Einstellungen → VPN → WireGuard → Server: Einwahl anlegen (Name, Kundennetz, LAN-IP der FritzBox); ARIA erzeugt beide Schluesselpaare + PSK, ⬇ liefert die Datei fuer die Gegenseite (FritzBox-Netzwerk-Kopplung oder Standard-WireGuard). Jede Einwahl = eigener VPN-Satellit-Container mit fester IP im Netz aria-vpn (10.213.213.0/24, ausserhalb von Dockers Pools). Neuer Verteiler (satellite/vpn_relay.py, aria-vpn-relay, Host-Netz, VPN_RELAY_PORT 51820): ordnet jedes Paket per WireGuard-mac1 bzw. Sitzungsnummer dem richtigen Container zu, ohne zu entschluesseln; Fremdes wird still verworfen. Anleitung NPM-Stream / ohne Nginx / ufw im ℹ des Server-Reiters und im satellite/README. Lokal Ende-zu-Ende getestet: zwei Kunden mit demselben Netz 192.168.178.0/24 gleichzeitig ueber einen Port, jeder erreicht nur sein eigenes LAN; kein Weg Kunde → ARIA, Kunde 1 → Einwahl 2, Container → Internet; fremder Schluessel und Muell am Verteiler ohne Antwort. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
408 lines
18 KiB
JavaScript
408 lines
18 KiB
JavaScript
// ── VPN-Satelliten (Einstellungen → VPN → WireGuard) ─────────────
|
||
//
|
||
// Jede VPN-Verbindung ist ein eigener Container (Image aria-vpn-satellite,
|
||
// satellite/Dockerfile.vpn): WireGuard-Tunnel + normaler Satellit dahinter,
|
||
// Kategorie "VPN-Satellit". Eigener Container = eigener Netz-Namensraum —
|
||
// ueberlappende Kundennetze stoeren sich nicht, und lokale Satelliten sehen
|
||
// die VPN-Netze nie.
|
||
//
|
||
// Ablage (Bind-Mount /data der Diagnostic):
|
||
// /data/vpn/connections.json Liste {id, kind, name, enabled, scanNets, …}
|
||
// /data/vpn/settings.json {keepDays}
|
||
// /data/vpn/<id>/tunnel/wg.conf die WireGuard-Datei (0600)
|
||
// /data/vpn/<id>/tunnel/status.json schreibt der Container (ℹ-Fenster)
|
||
// /data/vpn/<id>/tunnel/events.jsonl verbunden/getrennt/… (Log, rotiert hier)
|
||
// /data/vpn/<id>/data/ Satelliten-Daten (Zugangsdaten, Namen, …)
|
||
//
|
||
// Server-Verbindungen (Kunde waehlt sich ein, z.B. FritzBox): ARIA erzeugt
|
||
// beide Schluesselpaare + PSK, der Container lauscht auf 51820 mit fester IP
|
||
// im Netz aria-vpn; der Verteiler (satellite/vpn_relay.py, UDP-Port fuer
|
||
// alle) ordnet die Pakete per mac1 zu und liest dafuer /data/vpn/relay.json.
|
||
//
|
||
// An = Container mit restart unless-stopped (nach Neustart wieder da).
|
||
// Aus = Container weg, Dateien bleiben — Kundennetze nur bei Bedarf.
|
||
|
||
const http = require("http");
|
||
const fs = require("fs");
|
||
const path = require("path");
|
||
const crypto = require("crypto");
|
||
|
||
const DIR = "/data/vpn";
|
||
const IMAGE = process.env.VPN_IMAGE || "aria-vpn-satellite:latest";
|
||
const NETWORK = "aria-vpn";
|
||
const SELF = process.env.DIAG_CONTAINER || "aria-diagnostic";
|
||
const PREFIX = "aria-vpn-";
|
||
// Netz der VPN-Container: dynamische IPs (Clients) aus der unteren Haelfte,
|
||
// Server-Verbindungen bekommen feste IPs aus der oberen (.128 + n), damit der
|
||
// Verteiler sie auch nach einem Neustart findet.
|
||
// Bewusst ausserhalb von Dockers eigenen Pools (172.17–31.x, 192.168.x),
|
||
// sonst kollidiert es mit automatisch angelegten Compose-Netzen.
|
||
const SUBNET = process.env.VPN_SUBNET || "10.213.213.0/24";
|
||
const SUBNET_BASE = SUBNET.split("/")[0].split(".").slice(0, 3).join(".");
|
||
const SERVER_PORT = 51820; // WireGuard im Server-Container
|
||
const TUNNEL_NET = (n) => `10.222.${n}`; // je Server-Verbindung ein /24
|
||
|
||
function docker(method, apiPath, body) {
|
||
return new Promise((resolve, reject) => {
|
||
const data = body ? JSON.stringify(body) : "";
|
||
const req = http.request({
|
||
socketPath: "/var/run/docker.sock", path: apiPath, method,
|
||
headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(data) },
|
||
}, (res) => {
|
||
const chunks = [];
|
||
res.on("data", (c) => chunks.push(c));
|
||
res.on("end", () => {
|
||
const buf = Buffer.concat(chunks);
|
||
let json = null;
|
||
try { json = JSON.parse(buf.toString() || "null"); } catch { /* Logs sind kein JSON */ }
|
||
resolve({ status: res.statusCode, json, buf });
|
||
});
|
||
});
|
||
req.on("error", reject);
|
||
req.setTimeout(30000, () => req.destroy(new Error("Docker antwortet nicht")));
|
||
if (data) req.write(data);
|
||
req.end();
|
||
});
|
||
}
|
||
|
||
// ── Ablage ──────────────────────────────────────────────────────
|
||
function readJson(p, fallback) {
|
||
try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch { return fallback; }
|
||
}
|
||
function writeJson(p, v, mode = 0o600) {
|
||
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||
fs.writeFileSync(p + ".tmp", JSON.stringify(v, null, 2), { mode });
|
||
fs.renameSync(p + ".tmp", p);
|
||
}
|
||
const connsFile = () => path.join(DIR, "connections.json");
|
||
const loadConns = () => readJson(connsFile(), []);
|
||
const saveConns = (list) => writeJson(connsFile(), list);
|
||
const tunnelDir = (id) => path.join(DIR, id, "tunnel");
|
||
function settings() {
|
||
return { keepDays: 30, ...readJson(path.join(DIR, "settings.json"), {}) };
|
||
}
|
||
|
||
// Host-Pfad von /data — die VPN-Container brauchen Bind-Mounts mit HOST-Pfaden.
|
||
let _hostData = "";
|
||
async function hostDataDir() {
|
||
if (_hostData) return _hostData;
|
||
const r = await docker("GET", `/containers/${SELF}/json`);
|
||
const m = ((r.json && r.json.Mounts) || []).find((x) => x.Destination === "/data");
|
||
if (!m) throw new Error("Host-Pfad von /data unbekannt (Diagnostic ohne Bind-Mount?)");
|
||
_hostData = m.Source;
|
||
return _hostData;
|
||
}
|
||
|
||
// ── WireGuard-Datei pruefen ─────────────────────────────────────
|
||
function parseConf(text) {
|
||
const iface = {}; const peers = []; let cur = null;
|
||
for (const raw of String(text || "").split(/\r?\n/)) {
|
||
const line = raw.split("#")[0].trim();
|
||
if (!line) continue;
|
||
const sec = line.match(/^\[(\w+)\]$/);
|
||
if (sec) {
|
||
const n = sec[1].toLowerCase();
|
||
cur = n === "interface" ? iface : n === "peer" ? (peers.push({}), peers[peers.length - 1]) : null;
|
||
continue;
|
||
}
|
||
const i = line.indexOf("=");
|
||
if (!cur || i < 0) continue;
|
||
const k = line.slice(0, i).trim().toLowerCase(); const v = line.slice(i + 1).trim();
|
||
cur[k] = cur[k] && ["address", "allowedips", "dns"].includes(k) ? cur[k] + "," + v : v;
|
||
}
|
||
return { iface, peers };
|
||
}
|
||
function checkConf(text) {
|
||
const c = parseConf(text);
|
||
if (!c.iface.privatekey) return { error: "In [Interface] fehlt PrivateKey." };
|
||
if (!c.peers.length || c.peers.some((p) => !p.publickey)) return { error: "In [Peer] fehlt PublicKey." };
|
||
if (!c.peers.some((p) => p.endpoint)) return { error: "Kein Endpoint — wohin soll sich der Tunnel verbinden?" };
|
||
const nets = [];
|
||
for (const p of c.peers) {
|
||
for (const n of String(p.allowedips || "").split(",").map((s) => s.trim()).filter(Boolean)) {
|
||
if (!n.includes(":") && !n.endsWith("/0")) nets.push(n);
|
||
}
|
||
}
|
||
const all = c.peers.some((p) => /(^|,)\s*0\.0\.0\.0\/0/.test(p.allowedips || ""));
|
||
return { ok: true, nets, all, endpoint: c.peers.map((p) => p.endpoint).filter(Boolean).join(", ") };
|
||
}
|
||
|
||
// ── Container ───────────────────────────────────────────────────
|
||
async function ensureNetwork() {
|
||
const r = await docker("GET", `/networks/${NETWORK}`);
|
||
if (r.status === 200) return;
|
||
// ICC aus: VPN-Container sehen sich gegenseitig nicht.
|
||
const c = await docker("POST", "/networks/create", {
|
||
Name: NETWORK, Driver: "bridge",
|
||
Options: { "com.docker.network.bridge.enable_icc": "false" },
|
||
IPAM: { Config: [{ Subnet: SUBNET, IPRange: `${SUBNET_BASE}.0/25`, Gateway: `${SUBNET_BASE}.1` }] },
|
||
Labels: { "aria.vpn": "1" },
|
||
});
|
||
if (c.status >= 300) throw new Error(`Netz ${NETWORK} anlegen: ${JSON.stringify(c.json).slice(0, 200)}`);
|
||
}
|
||
|
||
async function removeContainer(id) {
|
||
await docker("DELETE", `/containers/${PREFIX}${id}?force=true`);
|
||
}
|
||
|
||
async function startContainer(conn, rvs) {
|
||
const img = await docker("GET", `/images/${encodeURIComponent(IMAGE)}/json`);
|
||
if (img.status !== 200) {
|
||
throw new Error(`Image ${IMAGE} fehlt. Auf der ARIA-VM: docker compose up -d --build vpn-satellite-image`);
|
||
}
|
||
await ensureNetwork();
|
||
const host = await hostDataDir();
|
||
fs.mkdirSync(path.join(DIR, conn.id, "data"), { recursive: true });
|
||
const env = {
|
||
VPN_ID: conn.id, VPN_NAME: conn.name, SATELLITE_ID: `vpn-${conn.id}`,
|
||
SATELLITE_LOCATION: conn.name, SCAN_NETS: conn.scanNets || "",
|
||
SCAN_MAX_SEC: "180", CONTROL_ENABLED: "true",
|
||
RVS_HOST: rvs.host, RVS_PORT: String(rvs.port), RVS_TLS: String(rvs.tls),
|
||
RVS_TLS_FALLBACK: String(rvs.tlsFallback), RVS_TOKEN: rvs.token,
|
||
...(rvs.sni ? { RVS_SNI: rvs.sni } : {}),
|
||
};
|
||
await removeContainer(conn.id);
|
||
const c = await docker("POST", `/containers/create?name=${PREFIX}${conn.id}`, {
|
||
Image: IMAGE,
|
||
Env: Object.entries(env).map(([k, v]) => `${k}=${v}`),
|
||
Labels: { "aria.vpn": conn.id, "aria.vpn.name": conn.name },
|
||
HostConfig: {
|
||
Binds: [`${host}/vpn/${conn.id}/tunnel:/vpn`, `${host}/vpn/${conn.id}/data:/data`],
|
||
CapAdd: ["NET_ADMIN"],
|
||
RestartPolicy: { Name: "unless-stopped" },
|
||
NetworkMode: NETWORK,
|
||
LogConfig: { Type: "json-file", Config: { "max-size": "5m", "max-file": "2" } },
|
||
},
|
||
...(conn.kind === "server" ? { NetworkingConfig: { EndpointsConfig: {
|
||
[NETWORK]: { IPAMConfig: { IPv4Address: serverIp(conn) } } } } } : {}),
|
||
});
|
||
if (c.status >= 300) throw new Error(`Container anlegen: ${JSON.stringify(c.json).slice(0, 300)}`);
|
||
const s = await docker("POST", `/containers/${PREFIX}${conn.id}/start`);
|
||
if (s.status >= 300) throw new Error(`Container starten: ${JSON.stringify(s.json).slice(0, 300)}`);
|
||
}
|
||
|
||
async function containerState(id) {
|
||
const r = await docker("GET", `/containers/${PREFIX}${id}/json`);
|
||
if (r.status !== 200) return { exists: false };
|
||
const st = r.json.State || {};
|
||
return { exists: true, running: !!st.Running, restarting: !!st.Restarting,
|
||
startedAt: st.StartedAt, exitCode: st.ExitCode };
|
||
}
|
||
|
||
async function containerLogs(id, lines = 60) {
|
||
const r = await docker("GET", `/containers/${PREFIX}${id}/logs?stdout=true&stderr=true&tail=${lines}×tamps=true`);
|
||
if (r.status !== 200) return "";
|
||
// Docker-Multiplex-Format: 8 Byte Kopf je Block
|
||
const out = []; let i = 0; const b = r.buf;
|
||
while (i + 8 <= b.length) {
|
||
const len = b.readUInt32BE(i + 4);
|
||
out.push(b.slice(i + 8, i + 8 + len).toString());
|
||
i += 8 + len;
|
||
}
|
||
return out.join("");
|
||
}
|
||
|
||
// ── Server-Verbindungen ─────────────────────────────────────────
|
||
function wgKeypair() {
|
||
const { publicKey, privateKey } = crypto.generateKeyPairSync("x25519");
|
||
return {
|
||
priv: privateKey.export({ format: "der", type: "pkcs8" }).slice(-32).toString("base64"),
|
||
pub: publicKey.export({ format: "der", type: "spki" }).slice(-32).toString("base64"),
|
||
};
|
||
}
|
||
const serverIp = (conn) => `${SUBNET_BASE}.${128 + conn.n}`;
|
||
const keysFile = (id) => path.join(tunnelDir(id), "keys.json");
|
||
|
||
function parseLan(v) {
|
||
const nets = String(v || "").split(/[\s,]+/).filter(Boolean);
|
||
if (!nets.length) throw new Error("Kundennetz fehlt (z.B. 192.168.178.0/24).");
|
||
for (const n of nets) {
|
||
const m = n.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})\/(\d{1,2})$/);
|
||
if (!m || m.slice(1, 5).some((x) => +x > 255) || +m[5] < 8 || +m[5] > 32) {
|
||
throw new Error(`Kundennetz „${n}" ist kein Netz wie 192.168.178.0/24.`);
|
||
}
|
||
}
|
||
return nets;
|
||
}
|
||
|
||
// Server-seitige wg.conf (im Container): lauscht, Gegenseite = Kunde
|
||
function serverConf(conn, k) {
|
||
const t = TUNNEL_NET(conn.n);
|
||
return ["[Interface]", `PrivateKey = ${k.srvPriv}`, `ListenPort = ${SERVER_PORT}`, `Address = ${t}.1/24`, "",
|
||
"[Peer]", `PublicKey = ${k.peerPub}`, `PresharedKey = ${k.psk}`,
|
||
`AllowedIPs = ${[`${t}.2/32`, ...conn.lan].join(", ")}`, ""].join("\n");
|
||
}
|
||
|
||
// Datei fuer die Gegenseite. FritzBox (Netzwerk-Kopplung): Address = eigene
|
||
// LAN-Adresse der Box, AllowedIPs = unser Netz (kein 0.0.0.0/0 — lehnt die
|
||
// Box ab). Standard: eigene Tunnel-Adresse, die Gegenseite muss ihr LAN dann
|
||
// selbst routen/maskieren.
|
||
function peerProfile(conn, k, type, rvs) {
|
||
const t = TUNNEL_NET(conn.n);
|
||
const lan0 = conn.lan[0];
|
||
const prefix = lan0.split("/")[1];
|
||
const addr = type === "fritz" ? `${conn.routerIp}/${prefix}` : `${t}.2/24`;
|
||
return [`# ARIA VPN-Satellit „${conn.name}" — ${type === "fritz" ? "für FRITZ!Box (Netzwerk-Kopplung)" : "Standard-WireGuard"}`,
|
||
"[Interface]", `PrivateKey = ${k.peerPriv}`, `Address = ${addr}`, "",
|
||
"[Peer]", `PublicKey = ${k.srvPub}`, `PresharedKey = ${k.psk}`,
|
||
`AllowedIPs = ${t}.0/24`, `Endpoint = ${rvs.host}:${rvs.publicPort || 443}`,
|
||
"PersistentKeepalive = 25", ""].join("\n");
|
||
}
|
||
|
||
function writeRelayMap() {
|
||
const rows = [];
|
||
for (const c of loadConns()) {
|
||
if (c.kind !== "server" || !c.enabled) continue;
|
||
const k = readJson(keysFile(c.id), null);
|
||
if (k) rows.push({ id: c.id, ip: serverIp(c), port: SERVER_PORT, pub: k.srvPub });
|
||
}
|
||
writeJson(path.join(DIR, "relay.json"), rows, 0o644);
|
||
}
|
||
|
||
async function saveServer(msg, rvs) {
|
||
const name = String(msg.name || "").trim().slice(0, 60);
|
||
if (!name) throw new Error("Name fehlt.");
|
||
const lan = parseLan(msg.lan);
|
||
const routerIp = String(msg.routerIp || "").trim() ||
|
||
lan[0].split("/")[0].replace(/\.\d+$/, ".1");
|
||
if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(routerIp)) throw new Error("Router-IP ist keine IP-Adresse.");
|
||
const conns = loadConns();
|
||
let conn = msg.id ? conns.find((c) => c.id === msg.id) : null;
|
||
if (!conn) {
|
||
const used = new Set(conns.filter((c) => c.kind === "server").map((c) => c.n));
|
||
let n = 1;
|
||
while (used.has(n)) n++;
|
||
if (n > 126) throw new Error("Höchstens 126 Server-Verbindungen.");
|
||
conn = { id: crypto.randomBytes(4).toString("hex"), kind: "server", n, enabled: false, createdAt: Date.now() };
|
||
conns.push(conn);
|
||
}
|
||
const scanNets = String(msg.scanNets || "").split(/[\s,]+/).filter(Boolean).join(",") || lan.join(",");
|
||
Object.assign(conn, { name, lan, routerIp, scanNets, updatedAt: Date.now() });
|
||
fs.mkdirSync(tunnelDir(conn.id), { recursive: true });
|
||
let k = readJson(keysFile(conn.id), null);
|
||
if (!k) {
|
||
const s = wgKeypair(); const p = wgKeypair();
|
||
k = { srvPriv: s.priv, srvPub: s.pub, peerPriv: p.priv, peerPub: p.pub,
|
||
psk: crypto.randomBytes(32).toString("base64") };
|
||
writeJson(keysFile(conn.id), k);
|
||
}
|
||
fs.writeFileSync(path.join(tunnelDir(conn.id), "wg.conf"), serverConf(conn, k), { mode: 0o600 });
|
||
saveConns(conns);
|
||
if (conn.enabled) await startContainer(conn, rvs);
|
||
writeRelayMap();
|
||
return { id: conn.id };
|
||
}
|
||
|
||
function profile(id, type, rvs) {
|
||
const conn = loadConns().find((c) => c.id === id);
|
||
if (!conn || conn.kind !== "server") throw new Error("Server-Verbindung unbekannt.");
|
||
const k = readJson(keysFile(id), null);
|
||
if (!k) throw new Error("Schlüssel fehlen.");
|
||
return { name: conn.name, type, conf: peerProfile(conn, k, type, rvs) };
|
||
}
|
||
|
||
// ── API (vom ws-Handler in server.js) ───────────────────────────
|
||
async function list() {
|
||
const conns = loadConns();
|
||
const res = [];
|
||
for (const c of conns) {
|
||
const st = readJson(path.join(tunnelDir(c.id), "status.json"), {});
|
||
res.push({ ...c, status: st, container: await containerState(c.id) });
|
||
}
|
||
return { connections: res, settings: settings() };
|
||
}
|
||
|
||
async function save(msg, rvs) {
|
||
if ((msg.kind || "") === "server" || (msg.id && (loadConns().find((c) => c.id === msg.id) || {}).kind === "server")) {
|
||
return saveServer(msg, rvs);
|
||
}
|
||
const name = String(msg.name || "").trim().slice(0, 60);
|
||
const conf = String(msg.conf || "").replace(/\r\n/g, "\n").trim() + "\n";
|
||
if (!name) throw new Error("Name fehlt.");
|
||
const chk = checkConf(conf);
|
||
if (chk.error) throw new Error(chk.error);
|
||
const scanNets = String(msg.scanNets || "").split(/[\s,]+/).filter(Boolean).join(",");
|
||
const conns = loadConns();
|
||
let conn = msg.id ? conns.find((c) => c.id === msg.id) : null;
|
||
if (!conn) {
|
||
conn = { id: crypto.randomBytes(4).toString("hex"), kind: msg.kind || "client",
|
||
enabled: false, createdAt: Date.now() };
|
||
conns.push(conn);
|
||
}
|
||
Object.assign(conn, { name, scanNets, updatedAt: Date.now() });
|
||
fs.mkdirSync(tunnelDir(conn.id), { recursive: true });
|
||
fs.writeFileSync(path.join(tunnelDir(conn.id), "wg.conf"), conf, { mode: 0o600 });
|
||
saveConns(conns);
|
||
if (conn.enabled) await startContainer(conn, rvs); // Aenderung greift sofort
|
||
return { id: conn.id };
|
||
}
|
||
|
||
async function toggle(id, enabled, rvs) {
|
||
const conns = loadConns();
|
||
const conn = conns.find((c) => c.id === id);
|
||
if (!conn) throw new Error("Verbindung unbekannt.");
|
||
conn.enabled = !!enabled;
|
||
saveConns(conns);
|
||
try {
|
||
if (conn.enabled) await startContainer(conn, rvs);
|
||
else await removeContainer(id);
|
||
} finally {
|
||
writeRelayMap();
|
||
}
|
||
return { id, enabled: conn.enabled };
|
||
}
|
||
|
||
async function remove(id) {
|
||
const conns = loadConns();
|
||
if (!conns.some((c) => c.id === id)) throw new Error("Verbindung unbekannt.");
|
||
await removeContainer(id);
|
||
saveConns(conns.filter((c) => c.id !== id));
|
||
fs.rmSync(path.join(DIR, id), { recursive: true, force: true });
|
||
writeRelayMap();
|
||
return { id };
|
||
}
|
||
|
||
function getConf(id) {
|
||
const conn = loadConns().find((c) => c.id === id);
|
||
if (!conn) throw new Error("Verbindung unbekannt.");
|
||
if (conn.kind === "server") return { ...conn }; // Schluessel bleiben auf ARIA
|
||
return { ...conn, conf: fs.readFileSync(path.join(tunnelDir(id), "wg.conf"), "utf8") };
|
||
}
|
||
|
||
async function info(id) {
|
||
const conn = loadConns().find((c) => c.id === id);
|
||
if (!conn) throw new Error("Verbindung unbekannt.");
|
||
const dir = tunnelDir(id);
|
||
let events = [];
|
||
try {
|
||
events = fs.readFileSync(path.join(dir, "events.jsonl"), "utf8").trim().split("\n")
|
||
.filter(Boolean).slice(-300).map((l) => { try { return JSON.parse(l); } catch { return null; } })
|
||
.filter(Boolean);
|
||
} catch { /* noch keine */ }
|
||
return { ...conn, status: readJson(path.join(dir, "status.json"), {}), events,
|
||
container: await containerState(id), logs: await containerLogs(id) };
|
||
}
|
||
|
||
function setSettings(s) {
|
||
const keepDays = Math.max(1, Math.min(3650, parseInt(s.keepDays, 10) || 30));
|
||
writeJson(path.join(DIR, "settings.json"), { keepDays });
|
||
rotate();
|
||
return { keepDays };
|
||
}
|
||
|
||
// Log-Rotation: Ereignisse aelter als keepDays fliegen raus (stuendlich).
|
||
function rotate() {
|
||
const cut = Date.now() / 1000 - settings().keepDays * 86400;
|
||
for (const c of loadConns()) {
|
||
const p = path.join(tunnelDir(c.id), "events.jsonl");
|
||
try {
|
||
const lines = fs.readFileSync(p, "utf8").split("\n").filter(Boolean);
|
||
const keep = lines.filter((l) => { try { return JSON.parse(l).ts >= cut; } catch { return false; } });
|
||
if (keep.length !== lines.length) fs.writeFileSync(p, keep.join("\n") + (keep.length ? "\n" : ""));
|
||
} catch { /* keine Datei */ }
|
||
}
|
||
}
|
||
setInterval(rotate, 3600 * 1000).unref();
|
||
|
||
module.exports = { list, save, toggle, remove, getConf, info, setSettings, checkConf, profile, writeRelayMap };
|