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