Server Command Center: add subdomain + Cloudflare provisioning
- 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>
This commit is contained in:
parent
b483680bfa
commit
a193523f31
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Seed script for server_config table.
|
||||
* Fetches server identifiers from Pterodactyl API and populates
|
||||
* short_name mappings for all known servers.
|
||||
* Uses known server identifiers from Chronicler #87 audit.
|
||||
* No Pterodactyl API call needed — identifiers are hardcoded.
|
||||
*
|
||||
* Usage: node migrations/139_seed_server_config.js
|
||||
*/
|
||||
@@ -16,74 +16,54 @@ const pool = new Pool({
|
||||
port: process.env.DB_PORT || 5432
|
||||
});
|
||||
|
||||
// Known server mappings — short_name values confirmed from Discord audit
|
||||
// Full server data — identifiers, subdomains, IPs, ports from Chronicler #87 audit
|
||||
const SERVER_MAP = [
|
||||
{ name: 'All the Mods 10: To the Sky', short_name: 'atm10-tts', node: 'NC1' },
|
||||
{ name: 'All the Mons', short_name: 'all-the-mons', node: 'NC1' },
|
||||
{ name: 'Mythcraft 5', short_name: 'mythcraft-5', node: 'NC1' },
|
||||
{ name: 'All of Create (Creative)', short_name: 'all-of-create', node: 'NC1' },
|
||||
{ name: 'DeceasedCraft', short_name: 'deceasedcraft', node: 'NC1' },
|
||||
{ name: "Sneak's Pirate Pack", short_name: 'sneaks-pirate-pack', node: 'NC1' },
|
||||
{ name: 'Otherworld [Dungeons & Dragons]', short_name: 'otherworld', node: 'NC1' },
|
||||
{ name: 'Farm Crossing 6', short_name: 'farm-crossing-6', node: 'NC1' },
|
||||
{ name: 'Homestead - A Cozy Survival Experience', short_name: 'homestead', node: 'NC1' },
|
||||
{ name: 'Stoneblock 4', short_name: 'stoneblock-4', node: 'TX1' },
|
||||
{ name: 'Society: Sunlit Valley', short_name: 'society-sunlit-valley', node: 'TX1' },
|
||||
{ name: 'Submerged 2', short_name: 'submerged-2', node: 'TX1' },
|
||||
{ name: 'Beyond Depth', short_name: 'beyond-depth', node: 'TX1' },
|
||||
{ name: 'Beyond Ascension', short_name: 'beyond-ascension', node: 'TX1' },
|
||||
{ name: 'Cottage Witch', short_name: 'cottage-witch', node: 'TX1' },
|
||||
{ name: 'All The Mons (Private) - TX', short_name: 'all-the-mons-private', node: 'TX1' },
|
||||
{ name: "Wold's Vaults", short_name: 'wolds-vaults', node: 'TX1' }
|
||||
{ id: 'f408e832', name: 'All the Mods 10: To the Sky', short_name: 'atm10-tts', subdomain: 'atm10tts', ip: '216.239.104.130', port: 25565, node: 'NC1' },
|
||||
{ id: 'c4bc5892', name: 'All the Mons', short_name: 'all-the-mons', subdomain: 'atmons', ip: '216.239.104.130', port: 25566, node: 'NC1' },
|
||||
{ id: 'b90ced3c', name: 'Mythcraft 5', short_name: 'mythcraft-5', subdomain: 'mythcraft5', ip: '216.239.104.130', port: 25567, node: 'NC1' },
|
||||
{ id: 'e1c6ff8d', name: 'All of Create (Creative)', short_name: 'all-of-create', subdomain: 'aocc', ip: '216.239.104.130', port: 25568, node: 'NC1' },
|
||||
{ id: '82e63949', name: 'All The Mods 10', short_name: 'atm10', subdomain: 'atm10', ip: '216.239.104.130', port: 25569, node: 'NC1' },
|
||||
{ id: 'd4790f45', name: 'Otherworld [Dungeons & Dragons]', short_name: 'otherworld', subdomain: 'otherworld', ip: '216.239.104.130', port: 25570, node: 'NC1' },
|
||||
{ id: '8950fa1e', name: 'DeceasedCraft', short_name: 'deceasedcraft', subdomain: 'deceasedcraft', ip: '216.239.104.130', port: 25571, node: 'NC1' },
|
||||
{ id: '7c9c2dc0', name: "Sneak's Pirate Pack", short_name: 'sneaks-pirate-pack', subdomain: 'sneakspiratpack', ip: '216.239.104.130', port: 25572, node: 'NC1' },
|
||||
{ id: '25b23f6e', name: 'Farm Crossing 6', short_name: 'farm-crossing-6', subdomain: 'farmcrossing6', ip: '216.239.104.130', port: 25573, node: 'NC1' },
|
||||
{ id: 'f5befeab', name: 'Homestead - A Cozy Survival Experience', short_name: 'homestead', subdomain: 'homestead', ip: '216.239.104.130', port: 25574, node: 'NC1' },
|
||||
{ id: 'a0efbfe8', name: 'Stoneblock 4', short_name: 'stoneblock-4', subdomain: 'stoneblock4', ip: '38.68.14.26', port: 25565, node: 'TX1' },
|
||||
{ id: '9310d0a6', name: 'Society: Sunlit Valley', short_name: 'society-sunlit-valley', subdomain: 'society', ip: '38.68.14.28', port: 25565, node: 'TX1' },
|
||||
{ id: '576342b8', name: 'Submerged 2', short_name: 'submerged-2', subdomain: 'submerged2', ip: '38.68.14.26', port: 25571, node: 'TX1' },
|
||||
{ id: 'e95ed4a8', name: 'Beyond Depth', short_name: 'beyond-depth', subdomain: 'beyonddepth', ip: '38.68.14.26', port: 25568, node: 'TX1' },
|
||||
{ id: '3f842757', name: 'Beyond Ascension', short_name: 'beyond-ascension', subdomain: 'beyondascension', ip: '38.68.14.26', port: 25569, node: 'TX1' },
|
||||
{ id: '7a9754ad', name: 'Cottage Witch', short_name: 'cottage-witch', subdomain: 'cottagewitch', ip: '38.68.14.26', port: 25572, node: 'TX1' },
|
||||
{ id: '668a5220', name: 'All The Mons (Private) - TX', short_name: 'all-the-mons-private', subdomain: 'allthemons', ip: '38.68.14.30', port: 25565, node: 'TX1' },
|
||||
{ id: 'fcbe0a1d', name: "Wold's Vaults", short_name: 'wolds-vaults', subdomain: 'vaults', ip: '38.68.14.26', port: 25570, node: 'TX1' }
|
||||
];
|
||||
|
||||
async function seed() {
|
||||
console.log('Fetching servers from Pterodactyl API...');
|
||||
|
||||
const endpoint = `${process.env.PANEL_URL}/api/application/servers`;
|
||||
const res = await fetch(endpoint, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${process.env.PANEL_APPLICATION_KEY}`,
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
console.error(`Panel API error: ${res.status} ${res.statusText}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const servers = data.data.map(s => s.attributes);
|
||||
|
||||
let matched = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const mapping of SERVER_MAP) {
|
||||
const ptero = servers.find(s => s.name === mapping.name);
|
||||
if (!ptero) {
|
||||
console.warn(` SKIP: "${mapping.name}" — not found in Pterodactyl`);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
console.log('Seeding server_config with %d servers...', SERVER_MAP.length);
|
||||
|
||||
let count = 0;
|
||||
for (const s of SERVER_MAP) {
|
||||
await pool.query(`
|
||||
INSERT INTO server_config (server_identifier, short_name, short_name_locked, display_name, node, pterodactyl_name)
|
||||
VALUES ($1, $2, true, $3, $4, $5)
|
||||
INSERT INTO server_config
|
||||
(server_identifier, short_name, short_name_locked, display_name, pterodactyl_name, subdomain, server_ip, server_port, node)
|
||||
VALUES ($1, $2, true, $3, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (server_identifier) DO UPDATE SET
|
||||
short_name = EXCLUDED.short_name,
|
||||
short_name_locked = true,
|
||||
display_name = EXCLUDED.display_name,
|
||||
node = EXCLUDED.node,
|
||||
pterodactyl_name = EXCLUDED.pterodactyl_name,
|
||||
subdomain = EXCLUDED.subdomain,
|
||||
server_ip = EXCLUDED.server_ip,
|
||||
server_port = EXCLUDED.server_port,
|
||||
node = EXCLUDED.node,
|
||||
updated_at = NOW()
|
||||
`, [ptero.identifier, mapping.short_name, mapping.name, mapping.node, mapping.name]);
|
||||
`, [s.id, s.short_name, s.name, s.subdomain, s.ip, s.port, s.node]);
|
||||
|
||||
console.log(` OK: "${mapping.name}" → ${mapping.short_name} (${ptero.identifier})`);
|
||||
matched++;
|
||||
console.log(' OK: %s → %s (%s.firefrostgaming.com)', s.name, s.short_name, s.subdomain);
|
||||
count++;
|
||||
}
|
||||
|
||||
console.log(`\nDone. Matched: ${matched}, Skipped: ${skipped}`);
|
||||
console.log('\nDone. Seeded %d servers.', count);
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,9 @@ CREATE TABLE IF NOT EXISTS server_config (
|
||||
short_name VARCHAR(64) UNIQUE,
|
||||
short_name_locked BOOLEAN DEFAULT false,
|
||||
display_name VARCHAR(128),
|
||||
subdomain VARCHAR(64) UNIQUE,
|
||||
server_ip VARCHAR(45),
|
||||
server_port INTEGER DEFAULT 25565,
|
||||
restart_enabled BOOLEAN DEFAULT true,
|
||||
restart_offset_minutes INTEGER DEFAULT 0,
|
||||
node VARCHAR(8),
|
||||
|
||||
@@ -531,4 +531,76 @@ router.post('/:identifier/console', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ─── 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;
|
||||
|
||||
@@ -74,6 +74,35 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Subdomain -->
|
||||
<div class="mb-3" id="subdomain-<%= server.identifier %>">
|
||||
<span class="text-gray-500 dark:text-gray-400 block text-xs mb-1">Subdomain</span>
|
||||
<% if (config && config.subdomain) { %>
|
||||
<div class="text-xs">
|
||||
<span class="text-green-600 dark:text-green-400 font-mono">🌐 <%= config.subdomain %>.firefrostgaming.com</span>
|
||||
<span class="text-gray-400 ml-2"><%= config.server_ip || '' %>:<%= config.server_port || 25565 %></span>
|
||||
</div>
|
||||
<% } else if (config && config.server_ip) { %>
|
||||
<div class="text-xs">
|
||||
<span class="text-gray-400">🌐 No subdomain</span>
|
||||
<span class="text-gray-400 ml-2"><%= config.server_ip %>:<%= config.server_port || 25565 %></span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<input type="text" name="subdomain" placeholder="e.g. stoneblock4"
|
||||
class="text-xs font-mono border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white rounded px-2 py-1 w-32"
|
||||
id="sd-input-<%= server.identifier %>">
|
||||
<button hx-post="/admin/servers/<%= server.identifier %>/provision-subdomain"
|
||||
hx-include="#sd-input-<%= server.identifier %>"
|
||||
hx-target="#subdomain-<%= server.identifier %>"
|
||||
class="text-xs bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400 px-2 py-1 rounded hover:bg-purple-200 dark:hover:bg-purple-900/50">
|
||||
+ Provision
|
||||
</button>
|
||||
</div>
|
||||
<% } else { %>
|
||||
<span class="text-gray-400 dark:text-gray-500 text-xs italic">No server config</span>
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
<!-- Discord Channel Status -->
|
||||
<div class="mb-3">
|
||||
<span class="text-gray-500 dark:text-gray-400 block text-xs mb-1">Discord Channels</span>
|
||||
|
||||
Reference in New Issue
Block a user