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:
Claude (Chronicler #83 - The Compiler)
2026-04-13 20:46:27 -05:00
parent b483680bfa
commit a193523f31
4 changed files with 139 additions and 55 deletions

View File

@@ -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;

View File

@@ -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>