zlh-api/src/services/edgePublisher.js

326 lines
10 KiB
JavaScript

// src/services/edgePublisher.js
// Publishes Traefik/Velocity backend routing + DNS (Technitium + Cloudflare)
// and handles multi-game support. Minecraft uses Velocity, other games use Traefik.
//
// Relies on env:
// VELOCITY_EDGE_IP (e.g. 10.70.0.241)
// TRAEFIK_EDGE_IP (e.g. 10.60.0.242)
// CLOUDFLARE_EDGE_IP (e.g. 139.64.165.248) // public ZPACK OPNsense
// EDGE_PUBLIC_IP (legacy fallback for public IP)
// DNS_ZONE or TECHNITIUM_ZONE (e.g. zerolaghub.quest)
import proxyClient from "./proxyClient.js";
import dns from "./technitiumClient.js";
import cloudflareClient from "./cloudflareClient.js";
import velocityClient from "./velocityClient.js";
import { unpublish } from "./dePublisher.js";
/* -------------------------------------------------------------------------- */
/* Game metadata */
/* -------------------------------------------------------------------------- */
const GAME_SRV = {
minecraft: { service: "minecraft", protocol: "tcp", defaultPort: 25565 },
mc: { service: "minecraft", protocol: "tcp", defaultPort: 25565 },
rust: { service: "rust", protocol: "udp", defaultPort: 28015 },
terraria: { service: "terraria", protocol: "tcp", defaultPort: 7777 },
projectzomboid: { service: "projectzomboid", protocol: "udp", defaultPort: 16261 },
valheim: { service: "valheim", protocol: "udp", defaultPort: 2456 },
palworld: { service: "palworld", protocol: "udp", defaultPort: 8211 },
generic: { service: "game", protocol: "tcp", defaultPort: 25565 },
};
/* -------------------------------------------------------------------------- */
/* Helpers */
/* -------------------------------------------------------------------------- */
function isMinecraftGame(game) {
const g = String(game || "").toLowerCase();
return g === "mc" || g.includes("minecraft");
}
/**
* Decide which internal edge IP Technitium should point to.
* - Minecraft → Velocity (VELOCITY_EDGE_IP)
* - Other → Traefik (TRAEFIK_EDGE_IP)
*/
function pickInternalEdgeIp(game) {
if (isMinecraftGame(game)) {
return (
process.env.VELOCITY_EDGE_IP || // 10.70.0.241
process.env.TRAEFIK_EDGE_IP || // fallback if misconfigured
"10.70.0.241"
);
}
// Non-Minecraft: default to Traefik
return (
process.env.TRAEFIK_EDGE_IP || // 10.60.0.242
process.env.VELOCITY_EDGE_IP || // last-resort fallback
"10.60.0.242"
);
}
/**
* Public edge IP for Cloudflare A/SRV.
* Always the ZPACK OPNsense WAN (139.64.165.248).
*/
function pickPublicEdgeIp() {
return (
process.env.CLOUDFLARE_EDGE_IP ||
process.env.EDGE_PUBLIC_IP || // legacy name
"139.64.165.248"
);
}
/* -------------------------------------------------------------------------- */
/* Primary publisher */
/* -------------------------------------------------------------------------- */
/**
* Publish edge routing + DNS + Velocity registration.
*
* Called from postProvision:
* edgePublisher.publishEdge({
* vmid,
* ports, // external/public ports OR [25565] for MC
* ip, // container IP (ctIp)
* slotHostname, // short hostname (mc-paper-5013)
* game
* })
*/
export async function publishEdge({
vmid,
ports = [],
ip,
ctIp, // older callers may pass ctIp instead of ip
slotHostname,
game,
}) {
const backendIp = ctIp || ip;
if (!vmid) throw new Error("[edgePublisher] vmid is required");
if (!backendIp)
throw new Error(
`[edgePublisher] Missing backend IP (ctIp/ip) for vmid=${vmid}`
);
const gameKey = String(game || "").toLowerCase();
const meta = GAME_SRV[gameKey] || GAME_SRV.generic;
const isMC = isMinecraftGame(gameKey);
const ZONE =
process.env.TECHNITIUM_ZONE ||
process.env.DNS_ZONE ||
"zerolaghub.quest";
// fqdn: ensure we have <hostname>.<zone>
if (!slotHostname)
throw new Error("[edgePublisher] slotHostname is required");
const fqdn = slotHostname.includes(".")
? slotHostname
: `${slotHostname}.${ZONE}`;
const internalEdgeIp = pickInternalEdgeIp(gameKey); // Technitium A
const publicEdgeIp = pickPublicEdgeIp(); // Cloudflare A
const externalPort = ports[0] || meta.defaultPort;
console.log(
`[edgePublisher] START vmid=${vmid}, game=${gameKey}, backend=${backendIp}, internalEdgeIp=${internalEdgeIp}, publicEdgeIp=${publicEdgeIp}, ports=${ports.join(
","
)}`
);
/* ---------------------------------------------------------------------- */
/* 1) Traefik / TCP routing (non-Minecraft only) */
/* ---------------------------------------------------------------------- */
if (isMC) {
console.log(
`[edgePublisher] Skipping Traefik TCP config (Minecraft handled by Velocity)`
);
} else {
for (const port of ports) {
try {
console.log(
`[edgePublisher] Adding Traefik TCP entry for ${fqdn}:${port} -> ${backendIp}:${meta.defaultPort ||
port}`
);
await proxyClient.addProxyConfig({
vmid,
hostname: slotHostname,
externalPort: port,
ctIp: backendIp, // LXC IP
ctPort: meta.defaultPort || port, // internal game port
game: gameKey,
protocol: meta.protocol,
});
console.log(
`[edgePublisher] ✓ Traefik config applied for ${slotHostname}:${port}`
);
} catch (err) {
console.error(
`[edgePublisher] ❌ Failed to push Traefik config for ${slotHostname}:${port}:`,
err?.message || err
);
}
}
}
/* ---------------------------------------------------------------------- */
/* 2) Technitium internal DNS */
/* ---------------------------------------------------------------------- */
try {
console.log(
`[edgePublisher] Creating Technitium A record ${fqdn}${internalEdgeIp}`
);
await dns.addARecord({
hostname: fqdn,
ipAddress: internalEdgeIp,
ttl: 60,
});
console.log(
`[edgePublisher] ✓ Technitium A record created: ${fqdn}${internalEdgeIp}`
);
if (externalPort) {
console.log(
`[edgePublisher] Creating Technitium SRV _${meta.service}._${meta.protocol}.${fqdn}${fqdn}:${externalPort}`
);
await dns.addSRVRecord({
service: meta.service,
protocol: meta.protocol,
hostname: fqdn,
port: externalPort,
target: fqdn,
ttl: 60,
});
console.log(
`[edgePublisher] ✓ Technitium SRV created for ${fqdn} port ${externalPort}`
);
}
} catch (err) {
console.error(
`[edgePublisher] ❌ Technitium DNS publish failed for ${fqdn}:`,
err?.response?.data || err?.message || err
);
}
/* ---------------------------------------------------------------------- */
/* 3) Cloudflare public DNS */
/* ---------------------------------------------------------------------- */
try {
console.log(
`[edgePublisher] Creating Cloudflare A record ${fqdn}${publicEdgeIp}`
);
await cloudflareClient.createARecord({
hostname: fqdn,
ip: publicEdgeIp,
});
if (externalPort) {
await cloudflareClient.createSRVRecord({
service: meta.service,
protocol: meta.protocol,
hostname: fqdn,
port: externalPort,
target: fqdn,
});
console.log(
`[edgePublisher] ✓ Cloudflare SRV created for ${fqdn} on port ${externalPort}`
);
} else {
console.log(
`[edgePublisher] ✓ Cloudflare A record created (no SRV needed)`
);
}
} catch (err) {
console.error(
`[edgePublisher] ⚠️ Cloudflare publish failed for ${fqdn}:`,
err?.response?.data || err?.message || err
);
}
/* ---------------------------------------------------------------------- */
/* 4) Velocity registration (Minecraft only) */
/* ---------------------------------------------------------------------- */
if (isMC) {
try {
console.log(
`[edgePublisher] Registering Minecraft backend with Velocity: ${slotHostname}${backendIp}:25565`
);
const res = await velocityClient.registerServer({
name: slotHostname,
address: backendIp,
port: 25565, // internal MC port in the container
});
console.log(
`[edgePublisher] ✓ Velocity registered ${slotHostname}${backendIp}:25565 (${res})`
);
} catch (err) {
console.error(
`[edgePublisher] ⚠️ Velocity registration failed for ${slotHostname}:`,
err?.message || err
);
}
} else {
console.log(
`[edgePublisher] Skipping Velocity registration (game=${gameKey})`
);
}
console.log(
`[edgePublisher] COMPLETE vmid=${vmid}, fqdn=${fqdn}, game=${gameKey}`
);
}
/* -------------------------------------------------------------------------- */
/* Rollback helper (delegates to dePublisher) */
/* -------------------------------------------------------------------------- */
export async function rollbackEdge({ slotHostname, vmid, game, ports }) {
console.log(
`[edgePublisher] ⚠️ Edge rollback requested for ${slotHostname || vmid}`
);
try {
await unpublish({ hostname: slotHostname, vmid, game, ports });
console.log(
`[edgePublisher] ✓ Edge rollback completed for ${slotHostname || vmid}`
);
} catch (err) {
console.error(
`[edgePublisher] ❌ Edge rollback failed for ${slotHostname || vmid}:`,
err?.message || err
);
}
}
/* -------------------------------------------------------------------------- */
/* Health Check (optional) */
/* -------------------------------------------------------------------------- */
export async function edgeHealth() {
try {
// Minimal health response
return {
ok: true,
message: "edgePublisher online (dummy health check)",
};
} catch (err) {
return { ok: false, error: err?.message || String(err) };
}
}
export const unpublishEdge = rollbackEdge;
export default {
publishEdge,
rollbackEdge,
unpublishEdge,
edgeHealth,
};