diff --git a/docs/code-bridge/status/ACTIVE_CONTEXT.md b/docs/code-bridge/status/ACTIVE_CONTEXT.md index 5c9f668..f61dd0b 100644 --- a/docs/code-bridge/status/ACTIVE_CONTEXT.md +++ b/docs/code-bridge/status/ACTIVE_CONTEXT.md @@ -2,7 +2,7 @@ **Last Updated:** 2026-04-13 18:10 CDT ## Current Focus -Rules mod config bug — iterating fixes for Otherworld (NC1, 1.20.1 Forge). +Server Command Center (REQ-2026-04-14) — Arbiter feature build. ## Session Summary (2026-04-13) @@ -15,12 +15,18 @@ Rules mod config bug — iterating fixes for Otherworld (NC1, 1.20.1 Forge). - CurseForge project page copy pending from Chronicler ### Rules Mod Config Bug — FIXED ✅ -- v1.0.1: Added `ModConfigEvent.Loading` handler (symptom fix, not root cause) -- v1.0.2: Switched `ModConfig.Type.SERVER` → `COMMON` (config persists ✅) -- v1.0.3: Fixed 1.20.1 event bus registration, added section header warnings -- v1.0.4: Diagnostic build — revealed DIAG logs never fired -- v1.0.5: **Root cause found** — console `/rules` hit early return, never fetched from Discord. Fixed: console path now fetches async. DIAG logging kept for observability. -- **Status:** WORKING on Otherworld. All 6 builds at v1.0.5. Ready for CurseForge submission. +- v1.0.5: All 6 builds working. DIAG logging kept for observability. +- WORKING on Otherworld. Ready for CurseForge submission. + +### Server Command Center — IN PROGRESS 🔧 +- Migration: `139_server_config.sql` — server_config table with short_name system +- Seed: `139_seed_server_config.js` — auto-populates 17 servers from Pterodactyl API +- Services: `uptimeKuma.js` (Socket.IO direct), `pterodactyl.js` (power/commands) +- Routes: 6 new POST endpoints (set-short-name, lock, createserver, delserver, power, console) +- Views: `_server_card.ejs` rebuilt with full command center UI, `_matrix_body.ejs` refactored to use partial +- Discord detection: uses `short_name` from DB instead of broken slug derivation +- Partial channel creation: createserver only creates missing channels +- **Status:** Code complete, needs deploy testing. Chronicler needs to add `UPTIME_KUMA_USERNAME` and `UPTIME_KUMA_PASSWORD` to `.env`. ### Bridge Queue — CLEAR ✅ - All REQs have matching RES files diff --git a/services/arbiter-3.0/migrations/139_seed_server_config.js b/services/arbiter-3.0/migrations/139_seed_server_config.js new file mode 100644 index 0000000..ad004a6 --- /dev/null +++ b/services/arbiter-3.0/migrations/139_seed_server_config.js @@ -0,0 +1,93 @@ +/** + * Seed script for server_config table. + * Fetches server identifiers from Pterodactyl API and populates + * short_name mappings for all known servers. + * + * Usage: node migrations/139_seed_server_config.js + */ +require('dotenv').config({ path: require('path').resolve(__dirname, '../.env') }); +const { Pool } = require('pg'); + +const pool = new Pool({ + user: process.env.DB_USER, + host: process.env.DB_HOST, + database: process.env.DB_NAME, + password: process.env.DB_PASSWORD, + port: process.env.DB_PORT || 5432 +}); + +// Known server mappings — short_name values confirmed from Discord 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' } +]; + +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; + } + + 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) + 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, + updated_at = NOW() + `, [ptero.identifier, mapping.short_name, mapping.name, mapping.node, mapping.name]); + + console.log(` OK: "${mapping.name}" → ${mapping.short_name} (${ptero.identifier})`); + matched++; + } + + console.log(`\nDone. Matched: ${matched}, Skipped: ${skipped}`); + await pool.end(); +} + +seed().catch(err => { + console.error('Seed failed:', err); + process.exit(1); +}); diff --git a/services/arbiter-3.0/migrations/139_server_config.sql b/services/arbiter-3.0/migrations/139_server_config.sql new file mode 100644 index 0000000..b61db0d --- /dev/null +++ b/services/arbiter-3.0/migrations/139_server_config.sql @@ -0,0 +1,24 @@ +-- Migration 139: Server Command Center — server_config table +-- Creates the foundation for short_name-based Discord channel detection +-- and server management from Trinity Console. + +CREATE TABLE IF NOT EXISTS server_config ( + server_identifier VARCHAR(36) PRIMARY KEY, + short_name VARCHAR(64) UNIQUE, + short_name_locked BOOLEAN DEFAULT false, + display_name VARCHAR(128), + restart_enabled BOOLEAN DEFAULT true, + restart_offset_minutes INTEGER DEFAULT 0, + node VARCHAR(8), + pterodactyl_name VARCHAR(128), + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +-- Index for fast short_name lookups (channel detection) +CREATE INDEX IF NOT EXISTS idx_server_config_short_name + ON server_config (short_name); + +-- Index for node-based queries (matrix grouping) +CREATE INDEX IF NOT EXISTS idx_server_config_node + ON server_config (node); diff --git a/services/arbiter-3.0/package.json b/services/arbiter-3.0/package.json index 28ff538..a589702 100644 --- a/services/arbiter-3.0/package.json +++ b/services/arbiter-3.0/package.json @@ -25,6 +25,7 @@ "passport": "^0.7.0", "passport-discord": "^0.1.4", "pg": "^8.11.3", + "socket.io-client": "^4.7.5", "stripe": "^14.14.0" } } diff --git a/services/arbiter-3.0/src/routes/admin/servers.js b/services/arbiter-3.0/src/routes/admin/servers.js index 2baece1..b4df169 100644 --- a/services/arbiter-3.0/src/routes/admin/servers.js +++ b/services/arbiter-3.0/src/routes/admin/servers.js @@ -4,7 +4,9 @@ const db = require('../../database'); const { getMinecraftServers } = require('../../panel/discovery'); const { readServerProperties, writeWhitelistFile } = require('../../panel/files'); const { reloadWhitelistCommand } = require('../../panel/commands'); -const { ChannelType } = require('discord.js'); +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 }; @@ -14,6 +16,19 @@ const CACHE_TTL = 60000; // 60 seconds 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 */ @@ -38,33 +53,33 @@ async function getDiscordChannels(client) { } /** - * Check which Discord channels exist for a server - * Returns object with missing channels array + * Invalidate Discord channel cache (after create/delete) */ -function checkServerChannels(serverName, allChannels) { - // Extract the base name (before any " - " subtitle or parenthetical) - // "Homestead - A Cozy Survival Experience" -> "homestead" - // "All The Mons (Private) - TX" -> "all-the-mons" - // "Stoneblock 4" -> "stoneblock-4" - let baseName = serverName - .split(' - ')[0] // Take part before " - " subtitle - .replace(/\s*\([^)]*\)\s*/g, '') // Remove parentheticals like (Private) - .toLowerCase() - .replace(/[^a-z0-9\s]/g, '') // Remove special chars except spaces - .replace(/\s+/g, '-') // Spaces to hyphens - .replace(/-+/g, '-') // Multiple hyphens to single - .trim(); +function invalidateDiscordCache() { + discordChannelCache = { channels: null, lastFetch: 0 }; +} - // Also create a display name for voice channel matching +/** + * 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: `${baseName}-chat`, type: 'text', label: 'Chat' }, - { name: `${baseName}-in-game`, type: 'text', label: 'In-Game' }, - { name: `${baseName}-forum`, type: 'forum', label: 'Forum' }, + { 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' } ]; @@ -73,22 +88,20 @@ function checkServerChannels(serverName, allChannels) { for (const expected of expectedChannels) { let exists = false; - + if (expected.type === 'voice') { - // Voice channels match by exact name (case-insensitive) - exists = allChannels.some(ch => - ch.type === ChannelType.GuildVoice && + 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 && + exists = allChannels.some(ch => + ch.type === ChannelType.GuildForum && ch.name === expected.name ); } else { - // Text channels - exists = allChannels.some(ch => - ch.type === ChannelType.GuildText && + exists = allChannels.some(ch => + ch.type === ChannelType.GuildText && ch.name === expected.name ); } @@ -100,24 +113,34 @@ function checkServerChannels(serverName, allChannels) { } } - return { missing, found, complete: missing.length === 0 }; + 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 Matrix' }); + res.render('admin/servers/index', { title: 'Server Command Center' }); }); router.get('/matrix', async (req, res) => { const now = Date.now(); let serversData = []; - // Use cache if valid, otherwise fetch fresh if (serverCache.data && (now - serverCache.lastFetch < CACHE_TTL)) { serversData = serverCache.data; } else { const discovered = await getMinecraftServers(); - - // Fetch properties for all discovered servers sequentially to avoid rate limits for (const srv of discovered) { const props = await readServerProperties(srv.identifier); serversData.push({ ...srv, whitelistEnabled: props.whitelistEnabled, rawProps: props.raw }); @@ -125,45 +148,48 @@ router.get('/matrix', async (req, res) => { serverCache = { data: serversData, lastFetch: now }; } - // Join with Database Sync Logs (Always fetch fresh logs, no cache) + // 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; - }, {}); + 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 channelStatus = checkServerChannels(srv.name, discordChannels); + 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 }; }); - // Group by Node Location 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 + `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] @@ -180,58 +206,39 @@ router.post('/:identifier/sync', async (req, res) => { router.post('/:identifier/toggle-whitelist', async (req, res) => { const { identifier } = req.params; - // Clear cache so the UI updates on next poll - serverCache.lastFetch = 0; - + 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 { - if (props.raw.includes('white-list=false')) { - newContent = props.raw.replace('white-list=false', 'white-list=true'); - } else { - newContent = props.raw + '\nwhite-list=true'; - } + 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' - }, + headers: { 'Authorization': `Bearer ${process.env.PANEL_CLIENT_KEY}`, 'Content-Type': 'text/plain' }, body: newContent }); - res.send(`⚠️ Requires Restart`); }); -// Sync all servers on a specific node 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(`Invalid node`); - } - + if (!nodeId) return res.send(`Invalid node`); 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 + `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; - let errors = 0; - + let synced = 0, errors = 0; for (const srv of nodeServers) { try { await writeWhitelistFile(srv.identifier, players); @@ -249,11 +256,279 @@ router.post('/sync-all/:node', async (req, res) => { errors++; } } - res.send(`✅ ${synced} synced${errors > 0 ? ` (${errors} errors)` : ''}`); } catch (error) { res.send(`❌ ${error.message}`); } }); +// ─── 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(`❌ Invalid: lowercase, hyphens, numbers only`); + } + + // Check if locked + const config = await getServerConfig(identifier); + if (config && config.short_name_locked) { + return res.send(`❌ Short name is locked`); + } + + // 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(`❌ "${short_name}" already in use`); + } + + 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(`✅ Saved: ${short_name}`); +}); + +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(`❌ Set a short name first`); + } + if (config.short_name_locked) { + return res.send(`Already locked`); + } + + await db.query( + 'UPDATE server_config SET short_name_locked = true, updated_at = NOW() WHERE server_identifier = $1', + [identifier] + ); + + serverCache.lastFetch = 0; + res.send(`🔒 Locked: ${config.short_name}`); +}); + +// ─── 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(`❌ Lock short name first`); + } + + const client = req.app.locals.client; + const guild = client.guilds.cache.get(process.env.GUILD_ID); + if (!guild) return res.send(`❌ Guild not found`); + + 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(`✅ All channels exist`); + } + + // 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(`✅ Created: ${created.join(', ')}`); + } catch (error) { + console.error('[createserver]', error); + res.send(`❌ ${error.message}`); + } +}); + +router.post('/:identifier/delserver', async (req, res) => { + const { identifier } = req.params; + const config = await getServerConfig(identifier); + + if (!config) { + return res.send(`❌ No config found`); + } + + const client = req.app.locals.client; + const guild = client.guilds.cache.get(process.env.GUILD_ID); + if (!guild) return res.send(`❌ Guild not found`); + + 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(`🗑️ Deleted ${deletedCount} channels`); + } catch (error) { + console.error('[delserver]', error); + res.send(`❌ ${error.message}`); + } +}); + +// ─── 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(`${labels[signal] || signal}...`); + } catch (error) { + res.send(`❌ ${error.message}`); + } +}); + +router.post('/:identifier/console', async (req, res) => { + const { identifier } = req.params; + const { command } = req.body; + + if (!command) return res.send(`❌ No command`); + + try { + await pterodactyl.sendCommand(identifier, command); + res.send(`✅ Sent`); + } catch (error) { + res.send(`❌ ${error.message}`); + } +}); + module.exports = router; diff --git a/services/arbiter-3.0/src/services/pterodactyl.js b/services/arbiter-3.0/src/services/pterodactyl.js new file mode 100644 index 0000000..202d5af --- /dev/null +++ b/services/arbiter-3.0/src/services/pterodactyl.js @@ -0,0 +1,103 @@ +/** + * Pterodactyl Service — power actions and console commands + * + * Uses PANEL_CLIENT_KEY for client endpoints (power, commands). + * Uses PANEL_APPLICATION_KEY for application endpoints (server listing). + * Uses PANEL_ADMIN_KEY for admin-level operations. + * + * Required .env: + * PANEL_URL=https://panel.firefrostgaming.com + * PANEL_CLIENT_KEY=ptlc_... + * PANEL_APPLICATION_KEY=ptla_... + * PANEL_ADMIN_KEY=ptla_... + */ + +const PANEL_URL = process.env.PANEL_URL; +const CLIENT_KEY = process.env.PANEL_CLIENT_KEY; +const ADMIN_KEY = process.env.PANEL_ADMIN_KEY || process.env.PANEL_APPLICATION_KEY; + +/** + * Send a power action to a server. + * @param {string} identifier - Server identifier (short UUID) + * @param {string} signal - 'start' | 'stop' | 'restart' | 'kill' + */ +async function powerAction(identifier, signal) { + const valid = ['start', 'stop', 'restart', 'kill']; + if (!valid.includes(signal)) { + throw new Error(`Invalid power signal: ${signal}`); + } + + const url = `${PANEL_URL}/api/client/servers/${identifier}/power`; + const res = await fetch(url, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${CLIENT_KEY}`, + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: JSON.stringify({ signal }) + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`Power action failed (${res.status}): ${text}`); + } + + console.log(`[Pterodactyl] ${signal} sent to ${identifier}`); + return true; +} + +/** + * Send a console command to a running server. + * @param {string} identifier - Server identifier + * @param {string} command - Command string (e.g. "say Hello") + */ +async function sendCommand(identifier, command) { + const url = `${PANEL_URL}/api/client/servers/${identifier}/command`; + const res = await fetch(url, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${CLIENT_KEY}`, + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: JSON.stringify({ command }) + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`Command failed (${res.status}): ${text}`); + } + + console.log(`[Pterodactyl] Command sent to ${identifier}: ${command}`); + return true; +} + +/** + * Get resource usage for a server. + * Returns: { current_state, memory, cpu, disk } + * @param {string} identifier - Server identifier + */ +async function getServerResources(identifier) { + const url = `${PANEL_URL}/api/client/servers/${identifier}/resources`; + const res = await fetch(url, { + headers: { + 'Authorization': `Bearer ${CLIENT_KEY}`, + 'Accept': 'application/json' + } + }); + + if (!res.ok) { + throw new Error(`Resources fetch failed (${res.status})`); + } + + const data = await res.json(); + return { + state: data.attributes.current_state, + memory: data.attributes.resources.memory_bytes, + cpu: data.attributes.resources.cpu_absolute, + disk: data.attributes.resources.disk_bytes + }; +} + +module.exports = { powerAction, sendCommand, getServerResources }; diff --git a/services/arbiter-3.0/src/services/uptimeKuma.js b/services/arbiter-3.0/src/services/uptimeKuma.js new file mode 100644 index 0000000..2a8449a --- /dev/null +++ b/services/arbiter-3.0/src/services/uptimeKuma.js @@ -0,0 +1,138 @@ +/** + * Uptime Kuma Service — Socket.IO direct connection + * + * Kuma 2.x uses Socket.IO for monitor CRUD (REST only exposes status pages). + * Auth via login event with username/password. + * + * Required .env: + * UPTIME_KUMA_URL=http://localhost:3001 + * UPTIME_KUMA_USERNAME=admin + * UPTIME_KUMA_PASSWORD=your_password + */ +const { io } = require('socket.io-client'); + +const KUMA_URL = process.env.UPTIME_KUMA_URL || 'http://localhost:3001'; +const KUMA_USER = process.env.UPTIME_KUMA_USERNAME || 'admin'; +const KUMA_PASS = process.env.UPTIME_KUMA_PASSWORD || ''; + +/** + * Connect to Kuma, authenticate, run a callback, then disconnect. + * Keeps connections short-lived — no persistent socket. + */ +async function withKuma(callback) { + const socket = io(KUMA_URL, { + reconnection: false, + timeout: 10000 + }); + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + socket.disconnect(); + reject(new Error('Uptime Kuma connection timed out')); + }, 15000); + + socket.on('connect', () => { + socket.emit('login', { + username: KUMA_USER, + password: KUMA_PASS, + token: '' + }, async (res) => { + if (!res.ok) { + clearTimeout(timeout); + socket.disconnect(); + reject(new Error(`Kuma auth failed: ${res.msg}`)); + return; + } + try { + const result = await callback(socket); + clearTimeout(timeout); + socket.disconnect(); + resolve(result); + } catch (err) { + clearTimeout(timeout); + socket.disconnect(); + reject(err); + } + }); + }); + + socket.on('connect_error', (err) => { + clearTimeout(timeout); + reject(new Error(`Kuma connect failed: ${err.message}`)); + }); + }); +} + +/** + * Get all monitors. + */ +async function getMonitors() { + return withKuma((socket) => { + return new Promise((resolve) => { + socket.emit('getMonitorList', (res) => { + resolve(Object.values(res)); + }); + }); + }); +} + +/** + * Create a TCP port monitor for a Minecraft server. + * @param {string} name - Monitor name (e.g. "Stoneblock 4 - TX") + * @param {string} hostname - Server hostname/IP + * @param {number} port - Server port (usually 25565) + */ +async function createMonitor(name, hostname, port) { + return withKuma((socket) => { + return new Promise((resolve, reject) => { + socket.emit('add', { + type: 'port', + name: name, + hostname: hostname, + port: port, + interval: 60, + retryInterval: 60, + maxretries: 3, + active: true + }, (res) => { + if (res.ok) { + console.log(`[Kuma] Created monitor: ${name} (ID: ${res.monitorID})`); + resolve(res.monitorID); + } else { + reject(new Error(`Kuma create failed: ${res.msg}`)); + } + }); + }); + }); +} + +/** + * Delete a monitor by name. + * Finds the monitor first, then deletes by ID. + * @param {string} name - Monitor name to delete + */ +async function deleteMonitor(name) { + return withKuma((socket) => { + return new Promise((resolve, reject) => { + socket.emit('getMonitorList', (monitors) => { + const list = Object.values(monitors); + const monitor = list.find(m => m.name === name); + if (!monitor) { + console.log(`[Kuma] Monitor not found: ${name}`); + resolve(null); + return; + } + socket.emit('deleteMonitor', monitor.id, (res) => { + if (res.ok) { + console.log(`[Kuma] Deleted monitor: ${name} (ID: ${monitor.id})`); + resolve(monitor.id); + } else { + reject(new Error(`Kuma delete failed: ${res.msg}`)); + } + }); + }); + }); + }); +} + +module.exports = { getMonitors, createMonitor, deleteMonitor }; diff --git a/services/arbiter-3.0/src/views/admin/servers/_matrix_body.ejs b/services/arbiter-3.0/src/views/admin/servers/_matrix_body.ejs index d8f6f26..2ccd44a 100644 --- a/services/arbiter-3.0/src/views/admin/servers/_matrix_body.ejs +++ b/services/arbiter-3.0/src/views/admin/servers/_matrix_body.ejs @@ -4,70 +4,10 @@ 🔥 Dallas Node (TX1)
<%= server.identifier %>
-No servers found.
<% } %> + <% if (txServers.length === 0) { %>No servers found.
<% } %><%= server.identifier %>
-No servers found.
<% } %> + <% if (ncServers.length === 0) { %>No servers found.
<% } %><%= server.identifier %>
-⚠️ Once locked, this cannot be changed
+ <% } %> +