- Migration: added subdomain, server_ip, server_port columns - Seed: rewritten with hardcoded identifiers + full subdomain/IP/port data from Chronicler #87 audit (no more Pterodactyl API dependency) - Route: POST /:id/provision-subdomain creates A + SRV records via Cloudflare API, saves subdomain to server_config - Card: subdomain section shows FQDN if provisioned, provision button with inline input if not Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
607 lines
24 KiB
JavaScript
607 lines
24 KiB
JavaScript
const express = require('express');
|
|
const router = express.Router();
|
|
const db = require('../../database');
|
|
const { getMinecraftServers } = require('../../panel/discovery');
|
|
const { readServerProperties, writeWhitelistFile } = require('../../panel/files');
|
|
const { reloadWhitelistCommand } = require('../../panel/commands');
|
|
const pterodactyl = require('../../services/pterodactyl');
|
|
const uptimeKuma = require('../../services/uptimeKuma');
|
|
const { ChannelType, PermissionFlagsBits } = require('discord.js');
|
|
|
|
// In-memory cache for RV low-bandwidth operations
|
|
let serverCache = { data: null, lastFetch: 0 };
|
|
const CACHE_TTL = 60000; // 60 seconds
|
|
|
|
// Cache for Discord channels (refresh less frequently)
|
|
let discordChannelCache = { channels: null, lastFetch: 0 };
|
|
const DISCORD_CACHE_TTL = 300000; // 5 minutes
|
|
|
|
// Staff/admin role names for channel permissions
|
|
const ADMIN_ROLES = ['Staff', '🛡️ Moderator', '👑 The Wizard', '💎 The Emissary', '✨ The Catalyst'];
|
|
|
|
// Forum tags for new server forums
|
|
const STANDARD_FORUM_TAGS = [
|
|
{ name: 'Builds', emoji: '🏗️' },
|
|
{ name: 'Help', emoji: '❓' },
|
|
{ name: 'Suggestion', emoji: '💡' },
|
|
{ name: 'Bug Report', emoji: '🐛' },
|
|
{ name: 'Achievement', emoji: '🎉' },
|
|
{ name: 'Guide', emoji: '📖' }
|
|
];
|
|
|
|
/**
|
|
* Get Discord channels from cache or fetch fresh
|
|
*/
|
|
async function getDiscordChannels(client) {
|
|
const now = Date.now();
|
|
if (discordChannelCache.channels && (now - discordChannelCache.lastFetch < DISCORD_CACHE_TTL)) {
|
|
return discordChannelCache.channels;
|
|
}
|
|
|
|
const guild = client.guilds.cache.get(process.env.GUILD_ID);
|
|
if (!guild) return [];
|
|
|
|
const channels = guild.channels.cache.map(ch => ({
|
|
id: ch.id,
|
|
name: ch.name,
|
|
type: ch.type,
|
|
parentId: ch.parentId
|
|
}));
|
|
|
|
discordChannelCache = { channels, lastFetch: now };
|
|
return channels;
|
|
}
|
|
|
|
/**
|
|
* Invalidate Discord channel cache (after create/delete)
|
|
*/
|
|
function invalidateDiscordCache() {
|
|
discordChannelCache = { channels: null, lastFetch: 0 };
|
|
}
|
|
|
|
/**
|
|
* Check which Discord channels exist for a server.
|
|
* Uses short_name from server_config instead of deriving from full name.
|
|
* Returns { missing, found, complete, unconfigured }
|
|
*/
|
|
function checkServerChannels(shortName, serverName, allChannels) {
|
|
if (!shortName) {
|
|
return { missing: [], found: [], complete: false, unconfigured: true };
|
|
}
|
|
|
|
// Voice channel uses the display name (before " - " subtitle)
|
|
const voiceDisplayName = serverName
|
|
.split(' - ')[0]
|
|
.replace(/\s*\([^)]*\)\s*/g, '')
|
|
.trim();
|
|
|
|
const expectedChannels = [
|
|
{ name: `${shortName}-chat`, type: 'text', label: 'Chat' },
|
|
{ name: `${shortName}-in-game`, type: 'text', label: 'In-Game' },
|
|
{ name: `${shortName}-forum`, type: 'forum', label: 'Forum' },
|
|
{ name: `${shortName}-status`, type: 'text', label: 'Status' },
|
|
{ name: voiceDisplayName, type: 'voice', label: 'Voice' }
|
|
];
|
|
|
|
const missing = [];
|
|
const found = [];
|
|
|
|
for (const expected of expectedChannels) {
|
|
let exists = false;
|
|
|
|
if (expected.type === 'voice') {
|
|
exists = allChannels.some(ch =>
|
|
ch.type === ChannelType.GuildVoice &&
|
|
ch.name.toLowerCase() === expected.name.toLowerCase()
|
|
);
|
|
} else if (expected.type === 'forum') {
|
|
exists = allChannels.some(ch =>
|
|
ch.type === ChannelType.GuildForum &&
|
|
ch.name === expected.name
|
|
);
|
|
} else {
|
|
exists = allChannels.some(ch =>
|
|
ch.type === ChannelType.GuildText &&
|
|
ch.name === expected.name
|
|
);
|
|
}
|
|
|
|
if (exists) {
|
|
found.push(expected.label);
|
|
} else {
|
|
missing.push(expected.label);
|
|
}
|
|
}
|
|
|
|
return { missing, found, complete: missing.length === 0, unconfigured: false };
|
|
}
|
|
|
|
/**
|
|
* Get server_config row from DB, or null
|
|
*/
|
|
async function getServerConfig(identifier) {
|
|
const { rows } = await db.query(
|
|
'SELECT * FROM server_config WHERE server_identifier = $1',
|
|
[identifier]
|
|
);
|
|
return rows[0] || null;
|
|
}
|
|
|
|
// ─── PAGE ROUTES ────────────────────────────────────────────
|
|
|
|
router.get('/', (req, res) => {
|
|
res.render('admin/servers/index', { title: 'Server Command Center' });
|
|
});
|
|
|
|
router.get('/matrix', async (req, res) => {
|
|
const now = Date.now();
|
|
let serversData = [];
|
|
|
|
if (serverCache.data && (now - serverCache.lastFetch < CACHE_TTL)) {
|
|
serversData = serverCache.data;
|
|
} else {
|
|
const discovered = await getMinecraftServers();
|
|
for (const srv of discovered) {
|
|
const props = await readServerProperties(srv.identifier);
|
|
serversData.push({ ...srv, whitelistEnabled: props.whitelistEnabled, rawProps: props.raw });
|
|
}
|
|
serverCache = { data: serversData, lastFetch: now };
|
|
}
|
|
|
|
// Join with sync logs
|
|
const { rows: logs } = await db.query('SELECT * FROM server_sync_log');
|
|
const logMap = logs.reduce((acc, log) => { acc[log.server_identifier] = log; return acc; }, {});
|
|
|
|
// Join with server_config
|
|
const { rows: configs } = await db.query('SELECT * FROM server_config');
|
|
const configMap = configs.reduce((acc, cfg) => { acc[cfg.server_identifier] = cfg; return acc; }, {});
|
|
|
|
// Get Discord channels
|
|
const client = req.app.locals.client;
|
|
const discordChannels = await getDiscordChannels(client);
|
|
|
|
const enrichedServers = serversData.map(srv => {
|
|
const config = configMap[srv.identifier] || null;
|
|
const shortName = config ? config.short_name : null;
|
|
const channelStatus = checkServerChannels(shortName, srv.name, discordChannels);
|
|
return {
|
|
...srv,
|
|
config,
|
|
log: logMap[srv.identifier] || { is_online: false, last_error: 'Never synced' },
|
|
discord: channelStatus
|
|
};
|
|
});
|
|
|
|
const txServers = enrichedServers.filter(s => s.node === 'TX1');
|
|
const ncServers = enrichedServers.filter(s => s.node === 'NC1');
|
|
|
|
res.render('admin/servers/_matrix_body', { txServers, ncServers, layout: false });
|
|
});
|
|
|
|
// ─── EXISTING ROUTES ────────────────────────────────────────
|
|
|
|
router.post('/:identifier/sync', async (req, res) => {
|
|
const { identifier } = req.params;
|
|
try {
|
|
const { rows: players } = await db.query(
|
|
`SELECT minecraft_username as name, minecraft_uuid as uuid FROM users
|
|
JOIN subscriptions ON users.discord_id = subscriptions.discord_id
|
|
WHERE subscriptions.status IN ('active', 'grace_period', 'lifetime')`
|
|
);
|
|
await writeWhitelistFile(identifier, players);
|
|
await reloadWhitelistCommand(identifier);
|
|
await db.query(
|
|
"INSERT INTO server_sync_log (server_identifier, last_successful_sync, is_online, last_error) VALUES ($1, NOW(), true, NULL) ON CONFLICT (server_identifier) DO UPDATE SET last_successful_sync = NOW(), is_online = true, last_error = NULL",
|
|
[identifier]
|
|
);
|
|
res.send(`<span class="text-green-500 font-bold text-sm">✅ Synced!</span>`);
|
|
} catch (error) {
|
|
await db.query(
|
|
"INSERT INTO server_sync_log (server_identifier, last_error, is_online) VALUES ($1, $2, false) ON CONFLICT (server_identifier) DO UPDATE SET last_error = $2, is_online = false",
|
|
[identifier, error.message]
|
|
);
|
|
res.send(`<span class="text-red-500 font-bold text-sm">❌ Error</span>`);
|
|
}
|
|
});
|
|
|
|
router.post('/:identifier/toggle-whitelist', async (req, res) => {
|
|
const { identifier } = req.params;
|
|
serverCache.lastFetch = 0;
|
|
const props = await readServerProperties(identifier);
|
|
if (!props.exists) return res.send('File not found');
|
|
let newContent;
|
|
if (props.whitelistEnabled) {
|
|
newContent = props.raw.replace('white-list=true', 'white-list=false');
|
|
} else {
|
|
newContent = props.raw.includes('white-list=false')
|
|
? props.raw.replace('white-list=false', 'white-list=true')
|
|
: props.raw + '\nwhite-list=true';
|
|
}
|
|
const endpoint = `${process.env.PANEL_URL}/api/client/servers/${identifier}/files/write?file=server.properties`;
|
|
await fetch(endpoint, {
|
|
method: 'POST',
|
|
headers: { 'Authorization': `Bearer ${process.env.PANEL_CLIENT_KEY}`, 'Content-Type': 'text/plain' },
|
|
body: newContent
|
|
});
|
|
res.send(`<span class="text-yellow-500 font-bold text-sm">⚠️ Requires Restart</span>`);
|
|
});
|
|
|
|
router.post('/sync-all/:node', async (req, res) => {
|
|
const { node } = req.params;
|
|
const nodeId = node === 'tx1' ? 3 : node === 'nc1' ? 2 : null;
|
|
if (!nodeId) return res.send(`<span class="text-red-500">Invalid node</span>`);
|
|
try {
|
|
const discovered = await getMinecraftServers();
|
|
const nodeServers = discovered.filter(s => s.nodeId === nodeId);
|
|
const { rows: players } = await db.query(
|
|
`SELECT minecraft_username as name, minecraft_uuid as uuid FROM users
|
|
JOIN subscriptions ON users.discord_id = subscriptions.discord_id
|
|
WHERE subscriptions.status IN ('active', 'grace_period', 'lifetime')`
|
|
);
|
|
let synced = 0, errors = 0;
|
|
for (const srv of nodeServers) {
|
|
try {
|
|
await writeWhitelistFile(srv.identifier, players);
|
|
await reloadWhitelistCommand(srv.identifier);
|
|
await db.query(
|
|
"INSERT INTO server_sync_log (server_identifier, last_successful_sync, is_online, last_error) VALUES ($1, NOW(), true, NULL) ON CONFLICT (server_identifier) DO UPDATE SET last_successful_sync = NOW(), is_online = true, last_error = NULL",
|
|
[srv.identifier]
|
|
);
|
|
synced++;
|
|
} catch (err) {
|
|
await db.query(
|
|
"INSERT INTO server_sync_log (server_identifier, last_error, is_online) VALUES ($1, $2, false) ON CONFLICT (server_identifier) DO UPDATE SET last_error = $2, is_online = false",
|
|
[srv.identifier, err.message]
|
|
);
|
|
errors++;
|
|
}
|
|
}
|
|
res.send(`<span class="text-green-500 font-bold">✅ ${synced} synced</span>${errors > 0 ? ` <span class="text-red-500">(${errors} errors)</span>` : ''}`);
|
|
} catch (error) {
|
|
res.send(`<span class="text-red-500">❌ ${error.message}</span>`);
|
|
}
|
|
});
|
|
|
|
// ─── NEW: SHORT NAME MANAGEMENT ─────────────────────────────
|
|
|
|
router.post('/:identifier/set-short-name', async (req, res) => {
|
|
const { identifier } = req.params;
|
|
const { short_name } = req.body;
|
|
|
|
// Validate: lowercase, hyphens, numbers only
|
|
if (!short_name || !/^[a-z0-9-]+$/.test(short_name)) {
|
|
return res.send(`<span class="text-red-500 text-sm">❌ Invalid: lowercase, hyphens, numbers only</span>`);
|
|
}
|
|
|
|
// Check if locked
|
|
const config = await getServerConfig(identifier);
|
|
if (config && config.short_name_locked) {
|
|
return res.send(`<span class="text-red-500 text-sm">❌ Short name is locked</span>`);
|
|
}
|
|
|
|
// Check uniqueness
|
|
const { rows: existing } = await db.query(
|
|
'SELECT server_identifier FROM server_config WHERE short_name = $1 AND server_identifier != $2',
|
|
[short_name, identifier]
|
|
);
|
|
if (existing.length > 0) {
|
|
return res.send(`<span class="text-red-500 text-sm">❌ "${short_name}" already in use</span>`);
|
|
}
|
|
|
|
await db.query(`
|
|
INSERT INTO server_config (server_identifier, short_name, updated_at)
|
|
VALUES ($1, $2, NOW())
|
|
ON CONFLICT (server_identifier) DO UPDATE SET
|
|
short_name = $2, updated_at = NOW()
|
|
`, [identifier, short_name]);
|
|
|
|
// Invalidate caches
|
|
serverCache.lastFetch = 0;
|
|
invalidateDiscordCache();
|
|
|
|
res.send(`<span class="text-green-500 text-sm">✅ Saved: ${short_name}</span>`);
|
|
});
|
|
|
|
router.post('/:identifier/lock-short-name', async (req, res) => {
|
|
const { identifier } = req.params;
|
|
const config = await getServerConfig(identifier);
|
|
|
|
if (!config || !config.short_name) {
|
|
return res.send(`<span class="text-red-500 text-sm">❌ Set a short name first</span>`);
|
|
}
|
|
if (config.short_name_locked) {
|
|
return res.send(`<span class="text-yellow-500 text-sm">Already locked</span>`);
|
|
}
|
|
|
|
await db.query(
|
|
'UPDATE server_config SET short_name_locked = true, updated_at = NOW() WHERE server_identifier = $1',
|
|
[identifier]
|
|
);
|
|
|
|
serverCache.lastFetch = 0;
|
|
res.send(`<span class="text-green-500 text-sm">🔒 Locked: ${config.short_name}</span>`);
|
|
});
|
|
|
|
// ─── NEW: CREATE/DELETE SERVER (DISCORD CHANNELS) ───────────
|
|
|
|
router.post('/:identifier/createserver', async (req, res) => {
|
|
const { identifier } = req.params;
|
|
const config = await getServerConfig(identifier);
|
|
|
|
if (!config || !config.short_name_locked) {
|
|
return res.send(`<span class="text-red-500 text-sm">❌ Lock short name first</span>`);
|
|
}
|
|
|
|
const client = req.app.locals.client;
|
|
const guild = client.guilds.cache.get(process.env.GUILD_ID);
|
|
if (!guild) return res.send(`<span class="text-red-500 text-sm">❌ Guild not found</span>`);
|
|
|
|
const shortName = config.short_name;
|
|
const serverName = config.pterodactyl_name || config.display_name || shortName;
|
|
|
|
try {
|
|
await guild.channels.fetch();
|
|
await guild.roles.fetch();
|
|
|
|
const allChannels = guild.channels.cache;
|
|
|
|
// Voice channel uses display name
|
|
const voiceDisplayName = serverName
|
|
.split(' - ')[0]
|
|
.replace(/\s*\([^)]*\)\s*/g, '')
|
|
.trim();
|
|
|
|
// Define all 5 expected channels
|
|
const expectedChannels = [
|
|
{ name: `${shortName}-chat`, type: ChannelType.GuildText, label: 'Chat' },
|
|
{ name: `${shortName}-in-game`, type: ChannelType.GuildText, label: 'In-Game' },
|
|
{ name: `${shortName}-forum`, type: ChannelType.GuildForum, label: 'Forum' },
|
|
{ name: `${shortName}-status`, type: ChannelType.GuildText, label: 'Status' },
|
|
{ name: voiceDisplayName, type: ChannelType.GuildVoice, label: 'Voice' }
|
|
];
|
|
|
|
// Check which already exist
|
|
const missing = expectedChannels.filter(exp => {
|
|
return !allChannels.some(ch => ch.type === exp.type && ch.name.toLowerCase() === exp.name.toLowerCase());
|
|
});
|
|
|
|
if (missing.length === 0) {
|
|
return res.send(`<span class="text-green-500 text-sm">✅ All channels exist</span>`);
|
|
}
|
|
|
|
// Find or create category
|
|
let category = allChannels.find(
|
|
ch => ch.type === ChannelType.GuildCategory &&
|
|
(ch.name === `🎮 ${serverName}` || ch.name === serverName)
|
|
);
|
|
|
|
if (!category) {
|
|
// Build permission overwrites
|
|
const everyoneRole = guild.roles.everyone;
|
|
const wandererRole = guild.roles.cache.find(r => r.name === 'Wanderer');
|
|
const serverRole = guild.roles.cache.find(r => r.name.toLowerCase() === serverName.toLowerCase());
|
|
const adminRoleIds = ADMIN_ROLES
|
|
.map(name => guild.roles.cache.find(r => r.name === name)?.id)
|
|
.filter(Boolean);
|
|
|
|
const permissionOverwrites = [
|
|
{ id: everyoneRole.id, deny: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.Connect] }
|
|
];
|
|
if (wandererRole) {
|
|
permissionOverwrites.push({ id: wandererRole.id, allow: [PermissionFlagsBits.ViewChannel], deny: [PermissionFlagsBits.SendMessages, PermissionFlagsBits.Connect] });
|
|
}
|
|
if (serverRole) {
|
|
permissionOverwrites.push({ id: serverRole.id, allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.Connect, PermissionFlagsBits.ReadMessageHistory] });
|
|
}
|
|
adminRoleIds.forEach(roleId => {
|
|
permissionOverwrites.push({ id: roleId, allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.Connect, PermissionFlagsBits.ReadMessageHistory] });
|
|
});
|
|
|
|
category = await guild.channels.create({
|
|
name: `🎮 ${serverName}`,
|
|
type: ChannelType.GuildCategory,
|
|
permissionOverwrites,
|
|
reason: `Server Command Center: createserver ${serverName}`
|
|
});
|
|
}
|
|
|
|
// Create only missing channels
|
|
const created = [];
|
|
for (const ch of missing) {
|
|
const opts = {
|
|
name: ch.name,
|
|
type: ch.type,
|
|
parent: category.id,
|
|
reason: `Server Command Center: createserver ${serverName}`
|
|
};
|
|
if (ch.type === ChannelType.GuildText) {
|
|
opts.topic = ch.label === 'Status' ? `Uptime status for ${serverName}` : `${ch.label} for ${serverName}`;
|
|
}
|
|
if (ch.type === ChannelType.GuildForum) {
|
|
opts.topic = `Discussion forum for ${serverName}`;
|
|
opts.availableTags = STANDARD_FORUM_TAGS.map(tag => ({ name: tag.name, emoji: { name: tag.emoji } }));
|
|
}
|
|
await guild.channels.create(opts);
|
|
created.push(ch.label);
|
|
}
|
|
|
|
// Try creating Uptime Kuma monitor (non-blocking)
|
|
try {
|
|
const monitorName = `${serverName} - ${config.node || 'Unknown'}`;
|
|
await uptimeKuma.createMonitor(monitorName, 'localhost', 25565);
|
|
} catch (kumaErr) {
|
|
console.warn('[Kuma] Monitor creation skipped:', kumaErr.message);
|
|
}
|
|
|
|
invalidateDiscordCache();
|
|
serverCache.lastFetch = 0;
|
|
|
|
res.send(`<span class="text-green-500 text-sm">✅ Created: ${created.join(', ')}</span>`);
|
|
} catch (error) {
|
|
console.error('[createserver]', error);
|
|
res.send(`<span class="text-red-500 text-sm">❌ ${error.message}</span>`);
|
|
}
|
|
});
|
|
|
|
router.post('/:identifier/delserver', async (req, res) => {
|
|
const { identifier } = req.params;
|
|
const config = await getServerConfig(identifier);
|
|
|
|
if (!config) {
|
|
return res.send(`<span class="text-red-500 text-sm">❌ No config found</span>`);
|
|
}
|
|
|
|
const client = req.app.locals.client;
|
|
const guild = client.guilds.cache.get(process.env.GUILD_ID);
|
|
if (!guild) return res.send(`<span class="text-red-500 text-sm">❌ Guild not found</span>`);
|
|
|
|
const serverName = config.pterodactyl_name || config.display_name || config.short_name;
|
|
|
|
try {
|
|
await guild.channels.fetch();
|
|
|
|
// Find category
|
|
const category = guild.channels.cache.find(
|
|
ch => ch.type === ChannelType.GuildCategory &&
|
|
(ch.name === `🎮 ${serverName}` || ch.name === serverName)
|
|
);
|
|
|
|
let deletedCount = 0;
|
|
|
|
if (category) {
|
|
// Delete all channels in category
|
|
const children = guild.channels.cache.filter(ch => ch.parentId === category.id);
|
|
for (const [, channel] of children) {
|
|
try {
|
|
await channel.delete(`Server Command Center: delserver ${serverName}`);
|
|
deletedCount++;
|
|
} catch (err) {
|
|
console.error(`Failed to delete channel ${channel.name}:`, err.message);
|
|
}
|
|
}
|
|
// Delete category
|
|
await category.delete(`Server Command Center: delserver ${serverName}`);
|
|
deletedCount++;
|
|
}
|
|
|
|
// Try deleting Uptime Kuma monitor (non-blocking)
|
|
try {
|
|
const monitorName = `${serverName} - ${config.node || 'Unknown'}`;
|
|
await uptimeKuma.deleteMonitor(monitorName);
|
|
} catch (kumaErr) {
|
|
console.warn('[Kuma] Monitor deletion skipped:', kumaErr.message);
|
|
}
|
|
|
|
invalidateDiscordCache();
|
|
serverCache.lastFetch = 0;
|
|
|
|
res.send(`<span class="text-green-500 text-sm">🗑️ Deleted ${deletedCount} channels</span>`);
|
|
} catch (error) {
|
|
console.error('[delserver]', error);
|
|
res.send(`<span class="text-red-500 text-sm">❌ ${error.message}</span>`);
|
|
}
|
|
});
|
|
|
|
// ─── NEW: POWER CONTROLS ────────────────────────────────────
|
|
|
|
router.post('/:identifier/power', async (req, res) => {
|
|
const { identifier } = req.params;
|
|
const { signal } = req.body;
|
|
|
|
try {
|
|
await pterodactyl.powerAction(identifier, signal);
|
|
const labels = { start: '▶️ Starting', stop: '⏹ Stopping', restart: '🔄 Restarting', kill: '💀 Killing' };
|
|
res.send(`<span class="text-yellow-500 text-sm">${labels[signal] || signal}...</span>`);
|
|
} catch (error) {
|
|
res.send(`<span class="text-red-500 text-sm">❌ ${error.message}</span>`);
|
|
}
|
|
});
|
|
|
|
router.post('/:identifier/console', async (req, res) => {
|
|
const { identifier } = req.params;
|
|
const { command } = req.body;
|
|
|
|
if (!command) return res.send(`<span class="text-red-500 text-sm">❌ No command</span>`);
|
|
|
|
try {
|
|
await pterodactyl.sendCommand(identifier, command);
|
|
res.send(`<span class="text-green-500 text-sm">✅ Sent</span>`);
|
|
} catch (error) {
|
|
res.send(`<span class="text-red-500 text-sm">❌ ${error.message}</span>`);
|
|
}
|
|
});
|
|
|
|
// ─── NEW: SUBDOMAIN PROVISIONING ────────────────────────────
|
|
|
|
const CF_API = 'https://api.cloudflare.com/client/v4';
|
|
const CF_ZONE_ID = '7604c173d802f154035f7e998018c1a9';
|
|
|
|
router.post('/:identifier/provision-subdomain', async (req, res) => {
|
|
const { identifier } = req.params;
|
|
const { subdomain } = req.body;
|
|
|
|
if (!subdomain || !/^[a-z0-9]+$/.test(subdomain)) {
|
|
return res.send(`<span class="text-red-500 text-sm">❌ Invalid: lowercase letters and numbers only, no hyphens</span>`);
|
|
}
|
|
|
|
const config = await getServerConfig(identifier);
|
|
if (!config || !config.server_ip) {
|
|
return res.send(`<span class="text-red-500 text-sm">❌ No server config or IP</span>`);
|
|
}
|
|
if (config.subdomain) {
|
|
return res.send(`<span class="text-yellow-500 text-sm">Already provisioned: ${config.subdomain}.firefrostgaming.com</span>`);
|
|
}
|
|
|
|
const cfToken = process.env.CLOUDFLARE_API_TOKEN;
|
|
if (!cfToken) {
|
|
return res.send(`<span class="text-red-500 text-sm">❌ CLOUDFLARE_API_TOKEN not set</span>`);
|
|
}
|
|
|
|
const fqdn = `${subdomain}.firefrostgaming.com`;
|
|
|
|
try {
|
|
// Create A record
|
|
const aRes = await fetch(`${CF_API}/zones/${CF_ZONE_ID}/dns_records`, {
|
|
method: 'POST',
|
|
headers: { 'Authorization': `Bearer ${cfToken}`, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ type: 'A', name: fqdn, content: config.server_ip, ttl: 1, proxied: false })
|
|
});
|
|
const aData = await aRes.json();
|
|
if (!aData.success) {
|
|
return res.send(`<span class="text-red-500 text-sm">❌ A record: ${aData.errors?.[0]?.message || 'failed'}</span>`);
|
|
}
|
|
|
|
// Create SRV record
|
|
const srvRes = await fetch(`${CF_API}/zones/${CF_ZONE_ID}/dns_records`, {
|
|
method: 'POST',
|
|
headers: { 'Authorization': `Bearer ${cfToken}`, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
type: 'SRV',
|
|
name: `_minecraft._tcp.${fqdn}`,
|
|
data: { priority: 0, weight: 0, port: config.server_port || 25565, target: fqdn },
|
|
ttl: 1
|
|
})
|
|
});
|
|
const srvData = await srvRes.json();
|
|
if (!srvData.success) {
|
|
console.warn('[Cloudflare] SRV failed (A record created):', srvData.errors);
|
|
}
|
|
|
|
// Save to DB
|
|
await db.query(
|
|
'UPDATE server_config SET subdomain = $1, updated_at = NOW() WHERE server_identifier = $2',
|
|
[subdomain, identifier]
|
|
);
|
|
|
|
serverCache.lastFetch = 0;
|
|
console.log(`[DNS] Provisioned ${fqdn} → ${config.server_ip}:${config.server_port}`);
|
|
|
|
res.send(`<span class="text-green-500 text-sm">✅ ${fqdn} provisioned</span>`);
|
|
} catch (error) {
|
|
console.error('[provision-subdomain]', error);
|
|
res.send(`<span class="text-red-500 text-sm">❌ ${error.message}</span>`);
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|