diff --git a/services/arbiter-3.0/.env.example b/services/arbiter-3.0/.env.example new file mode 100644 index 0000000..bba0574 --- /dev/null +++ b/services/arbiter-3.0/.env.example @@ -0,0 +1,27 @@ +# Discord +DISCORD_BOT_TOKEN=your_bot_token_here +DISCORD_CLIENT_ID=your_client_id_here +GUILD_ID=your_guild_id_here + +# Database +DB_USER=arbiter +DB_HOST=127.0.0.1 +DB_NAME=arbiter_db +DB_PASSWORD=your_secure_password +DB_PORT=5432 + +# Pterodactyl +PANEL_URL=https://panel.firefrostgaming.com +PANEL_CLIENT_KEY=ptlc_... +PANEL_APPLICATION_KEY=ptla_... + +# Paymenter +PAYMENTER_WEBHOOK_SECRET=your_webhook_secret + +# Admin Panel +ADMIN_USERNAME=trinity +ADMIN_PASSWORD=your_secure_admin_password + +# Application +NODE_ENV=production +PORT=3000 diff --git a/services/arbiter-3.0/README.md b/services/arbiter-3.0/README.md new file mode 100644 index 0000000..77fb80a --- /dev/null +++ b/services/arbiter-3.0/README.md @@ -0,0 +1,133 @@ +# Arbiter 3.0 - Unified Access Manager + +Production-ready Discord bot + Pterodactyl whitelist manager for Firefrost Gaming. + +## Features + +- Discord `/link` slash command for Minecraft account linking +- Automatic whitelist sync to all Minecraft servers +- Paymenter webhook integration for subscription management +- Grace period handling (3 days → downgrade to Awakened) +- Hourly reconciliation cron job +- Admin panel for monitoring and manual sync + +## Installation + +```bash +npm install +``` + +## Database Setup + +1. Log into PostgreSQL: +```bash +sudo -u postgres psql +``` + +2. Create database and user: +```sql +CREATE DATABASE arbiter_db; +CREATE USER arbiter WITH ENCRYPTED PASSWORD 'your_password'; +GRANT ALL PRIVILEGES ON DATABASE arbiter_db TO arbiter; +``` + +3. Connect to database and run schema: +```bash +psql -U arbiter -d arbiter_db +``` + +4. Run the schema from the implementation guide: +```sql +CREATE TABLE users ( + discord_id VARCHAR(255) PRIMARY KEY, + minecraft_username VARCHAR(255) UNIQUE, + minecraft_uuid VARCHAR(255) UNIQUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE subscriptions ( + id SERIAL PRIMARY KEY, + discord_id VARCHAR(255) REFERENCES users(discord_id), + tier_level INT NOT NULL, + status VARCHAR(50) NOT NULL, + stripe_customer_id VARCHAR(255), + paymenter_order_id VARCHAR(255), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE server_sync_log ( + server_identifier VARCHAR(50) PRIMARY KEY, + last_successful_sync TIMESTAMP, + last_error TEXT, + is_online BOOLEAN DEFAULT true +); + +CREATE INDEX idx_users_discord_id ON users(discord_id); +CREATE INDEX idx_subscriptions_status ON subscriptions(status); +``` + +## Configuration + +Copy `.env.example` to `.env` and fill in all values: +- Discord bot token and client ID +- PostgreSQL credentials +- Pterodactyl Panel API keys (Client + Application) +- Paymenter webhook secret +- Admin panel credentials + +## Deployment (Debian 12) + +1. Move to `/opt`: +```bash +sudo cp -r services/arbiter-3.0 /opt/ +cd /opt/arbiter-3.0 +npm install --production +``` + +2. Install systemd service: +```bash +sudo cp deploy/arbiter-3.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now arbiter-3 +``` + +3. Check status: +```bash +sudo systemctl status arbiter-3 +sudo journalctl -u arbiter-3 -f +``` + +## Admin Panel + +Access at: `http://your-server:3000/admin/status` + +Endpoints: +- `GET /admin/status` - View sync logs and linked accounts +- `POST /admin/force-sync` - Trigger manual whitelist sync + +## Architecture + +- **Discord Bot**: Handles `/link` command and role assignment +- **Express Server**: Webhook handlers and admin panel +- **PostgreSQL**: Single source of truth for users and subscriptions +- **Pterodactyl Integration**: Auto-discovery and file-based whitelist sync +- **Cron**: Hourly reconciliation at minute 0 + +## Troubleshooting + +**Bot not registering slash commands?** +- Check DISCORD_CLIENT_ID and GUILD_ID in .env +- Restart the service after changes + +**Whitelist not syncing?** +- Check `/admin/status` for sync errors +- Verify Panel API keys have correct permissions +- Check server_sync_log table for error messages + +**Database connection errors?** +- Verify PostgreSQL is running +- Check DB credentials in .env +- Ensure database and user exist + +## Fire + Frost + Foundation = Where Love Builds Legacy 💙🔥❄️ diff --git a/services/arbiter-3.0/deploy/arbiter-3.service b/services/arbiter-3.0/deploy/arbiter-3.service new file mode 100644 index 0000000..00db976 --- /dev/null +++ b/services/arbiter-3.0/deploy/arbiter-3.service @@ -0,0 +1,15 @@ +[Unit] +Description=Arbiter 3.0 Unified Access Manager +After=network.target postgresql.service + +[Service] +Type=simple +User=arbiter +WorkingDirectory=/opt/arbiter-3.0 +ExecStart=/usr/bin/node src/index.js +Restart=on-failure +RestartSec=10 +Environment=NODE_ENV=production + +[Install] +WantedBy=multi-user.target diff --git a/services/arbiter-3.0/package.json b/services/arbiter-3.0/package.json new file mode 100644 index 0000000..8f36bdb --- /dev/null +++ b/services/arbiter-3.0/package.json @@ -0,0 +1,17 @@ +{ + "name": "arbiter-3.0", + "version": "3.0.0", + "description": "Unified Access Manager for Discord and Pterodactyl", + "main": "src/index.js", + "scripts": { + "start": "node src/index.js", + "dev": "node --watch src/index.js" + }, + "dependencies": { + "discord.js": "^14.14.1", + "dotenv": "^16.4.5", + "express": "^4.19.2", + "node-cron": "^3.0.3", + "pg": "^8.11.3" + } +} diff --git a/services/arbiter-3.0/src/admin/routes.js b/services/arbiter-3.0/src/admin/routes.js new file mode 100644 index 0000000..6ef147c --- /dev/null +++ b/services/arbiter-3.0/src/admin/routes.js @@ -0,0 +1,32 @@ +const express = require('express'); +const router = express.Router(); +const db = require('../database'); +const { triggerImmediateSync } = require('../sync/immediate'); + +// Basic Auth Middleware +const basicAuth = (req, res, next) => { + const b64auth = (req.headers.authorization || '').split(' ')[1] || ''; + const [login, password] = Buffer.from(b64auth, 'base64').toString().split(':'); + + if (login === process.env.ADMIN_USERNAME && password === process.env.ADMIN_PASSWORD) { + return next(); + } + res.set('WWW-Authenticate', 'Basic realm="401"'); + res.status(401).send('Authentication required.'); +}; + +router.use(basicAuth); + +router.get('/status', async (req, res) => { + const { rows: logs } = await db.query('SELECT * FROM server_sync_log ORDER BY last_successful_sync DESC'); + const { rows: users } = await db.query('SELECT u.discord_id, u.minecraft_username, s.tier_level, s.status FROM users u LEFT JOIN subscriptions s ON u.discord_id = s.discord_id'); + + res.json({ sync_status: logs, users: users }); +}); + +router.post('/force-sync', async (req, res) => { + triggerImmediateSync(); + res.json({ message: "Sync triggered in background." }); +}); + +module.exports = router; diff --git a/services/arbiter-3.0/src/database.js b/services/arbiter-3.0/src/database.js new file mode 100644 index 0000000..baf083d --- /dev/null +++ b/services/arbiter-3.0/src/database.js @@ -0,0 +1,22 @@ +const { Pool } = require('pg'); +require('dotenv').config(); + +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, + max: 20, + idleTimeoutMillis: 30000 +}); + +pool.on('error', (err) => { + console.error('Unexpected error on idle client', err); + process.exit(-1); +}); + +module.exports = { + query: (text, params) => pool.query(text, params), + pool +}; diff --git a/services/arbiter-3.0/src/discord/commands.js b/services/arbiter-3.0/src/discord/commands.js new file mode 100644 index 0000000..65439ea --- /dev/null +++ b/services/arbiter-3.0/src/discord/commands.js @@ -0,0 +1,49 @@ +const { SlashCommandBuilder } = require('discord.js'); +const db = require('../database'); +const { validateMinecraftUser } = require('../mojang/validate'); +const { triggerImmediateSync } = require('../sync/immediate'); + +const linkCommand = new SlashCommandBuilder() + .setName('link') + .setDescription('Link your Minecraft account to your subscription') + .addStringOption(option => + option.setName('username') + .setDescription('Your exact Minecraft username') + .setRequired(true) + ); + +async function handleLinkCommand(interaction) { + await interaction.deferReply({ ephemeral: true }); + const username = interaction.options.getString('username'); + const discordId = interaction.user.id; + + const mojangData = await validateMinecraftUser(username); + + if (!mojangData) { + return interaction.editReply(`❌ Could not find Minecraft account **${username}**. Please check the spelling.`); + } + + try { + await db.query( + `INSERT INTO users (discord_id, minecraft_username, minecraft_uuid) + VALUES ($1, $2, $3) + ON CONFLICT (discord_id) + DO UPDATE SET minecraft_username = $2, minecraft_uuid = $3`, + [discordId, mojangData.name, mojangData.uuid] + ); + + await interaction.editReply(`✅ Successfully linked **${mojangData.name}**! Syncing whitelists now...`); + + // Fire and forget sync + triggerImmediateSync(); + + } catch (error) { + console.error("Database error during link:", error); + if (error.constraint === 'users_minecraft_username_key') { + return interaction.editReply(`❌ The Minecraft account **${mojangData.name}** is already linked to another Discord user.`); + } + interaction.editReply('❌ An internal database error occurred.'); + } +} + +module.exports = { linkCommand, handleLinkCommand }; diff --git a/services/arbiter-3.0/src/discord/events.js b/services/arbiter-3.0/src/discord/events.js new file mode 100644 index 0000000..a681f1e --- /dev/null +++ b/services/arbiter-3.0/src/discord/events.js @@ -0,0 +1,17 @@ +const { handleLinkCommand } = require('./commands'); + +function registerEvents(client) { + client.on('interactionCreate', async interaction => { + if (!interaction.isChatInputCommand()) return; + + if (interaction.commandName === 'link') { + await handleLinkCommand(interaction); + } + }); + + client.on('ready', () => { + console.log(`Discord bot logged in as ${client.user.tag}`); + }); +} + +module.exports = { registerEvents }; diff --git a/services/arbiter-3.0/src/index.js b/services/arbiter-3.0/src/index.js new file mode 100644 index 0000000..8822af3 --- /dev/null +++ b/services/arbiter-3.0/src/index.js @@ -0,0 +1,40 @@ +require('dotenv').config(); +const { Client, GatewayIntentBits, REST, Routes } = require('discord.js'); +const express = require('express'); +const { registerEvents } = require('./discord/events'); +const { linkCommand } = require('./discord/commands'); +const { initCron } = require('./sync/cron'); +const paymenterRoutes = require('./webhooks/paymenter'); +const adminRoutes = require('./admin/routes'); + +// 1. Initialize Express +const app = express(); +app.use(express.json()); +app.use('/webhooks', paymenterRoutes); +app.use('/admin', adminRoutes); + +const PORT = process.env.PORT || 3000; +app.listen(PORT, () => console.log(`Express server running on port ${PORT}`)); + +// 2. Initialize Discord +const client = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers] }); +registerEvents(client); +client.login(process.env.DISCORD_BOT_TOKEN); + +// 3. Register Slash Commands +const rest = new REST({ version: '10' }).setToken(process.env.DISCORD_BOT_TOKEN); +(async () => { + try { + console.log('Refreshing application (/) commands.'); + await rest.put( + Routes.applicationGuildCommands(process.env.DISCORD_CLIENT_ID, process.env.GUILD_ID), + { body: [linkCommand.toJSON()] }, + ); + console.log('Successfully reloaded application (/) commands.'); + } catch (error) { + console.error(error); + } +})(); + +// 4. Initialize Cron +initCron(); diff --git a/services/arbiter-3.0/src/mojang/validate.js b/services/arbiter-3.0/src/mojang/validate.js new file mode 100644 index 0000000..431ef01 --- /dev/null +++ b/services/arbiter-3.0/src/mojang/validate.js @@ -0,0 +1,23 @@ +function formatUUID(uuidStr) { + return `${uuidStr.slice(0, 8)}-${uuidStr.slice(8, 12)}-${uuidStr.slice(12, 16)}-${uuidStr.slice(16, 20)}-${uuidStr.slice(20)}`; +} + +async function validateMinecraftUser(username) { + try { + const res = await fetch(`https://api.mojang.com/users/profiles/minecraft/${username}`); + + if (res.status === 204 || res.status === 404) return null; + if (!res.ok) throw new Error(`Mojang API error: ${res.status}`); + + const data = await res.json(); + return { + name: data.name, + uuid: formatUUID(data.id) + }; + } catch (error) { + console.error("Failed to validate Minecraft user:", error); + return null; + } +} + +module.exports = { validateMinecraftUser }; diff --git a/services/arbiter-3.0/src/panel/commands.js b/services/arbiter-3.0/src/panel/commands.js new file mode 100644 index 0000000..dbbc3fb --- /dev/null +++ b/services/arbiter-3.0/src/panel/commands.js @@ -0,0 +1,25 @@ +require('dotenv').config(); + +async function reloadWhitelistCommand(serverIdentifier) { + const endpoint = `${process.env.PANEL_URL}/api/client/servers/${serverIdentifier}/command`; + + const res = await fetch(endpoint, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${process.env.PANEL_CLIENT_KEY}`, + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: JSON.stringify({ command: "whitelist reload" }) + }); + + if (res.status === 412) { + console.log(`[${serverIdentifier}] is offline. File saved for next boot.`); + return true; + } + + if (!res.ok) throw new Error(`Command failed: ${res.statusText}`); + return true; +} + +module.exports = { reloadWhitelistCommand }; diff --git a/services/arbiter-3.0/src/panel/discovery.js b/services/arbiter-3.0/src/panel/discovery.js new file mode 100644 index 0000000..9dde563 --- /dev/null +++ b/services/arbiter-3.0/src/panel/discovery.js @@ -0,0 +1,34 @@ +require('dotenv').config(); + +async function getMinecraftServers() { + const endpoint = `${process.env.PANEL_URL}/api/application/servers?include=allocations,node,nest`; + + try { + const res = await fetch(endpoint, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${process.env.PANEL_APPLICATION_KEY}`, + 'Accept': 'application/json' + } + }); + + if (!res.ok) throw new Error(`Panel API error: ${res.statusText}`); + + const data = await res.json(); + + // Filter out non-Minecraft servers + return data.data.filter(server => { + const nestName = server.attributes.relationships?.nest?.attributes?.name; + return nestName === 'Minecraft'; + }).map(server => ({ + identifier: server.attributes.identifier, + name: server.attributes.name, + node: server.attributes.relationships?.node?.attributes?.name + })); + } catch (error) { + console.error("Discovery failed:", error); + return []; + } +} + +module.exports = { getMinecraftServers }; diff --git a/services/arbiter-3.0/src/panel/files.js b/services/arbiter-3.0/src/panel/files.js new file mode 100644 index 0000000..b068da6 --- /dev/null +++ b/services/arbiter-3.0/src/panel/files.js @@ -0,0 +1,21 @@ +require('dotenv').config(); + +async function writeWhitelistFile(serverIdentifier, whitelistArray) { + const fileContent = JSON.stringify(whitelistArray, null, 2); + const endpoint = `${process.env.PANEL_URL}/api/client/servers/${serverIdentifier}/files/write?file=whitelist.json`; + + const res = await fetch(endpoint, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${process.env.PANEL_CLIENT_KEY}`, + 'Accept': 'application/json', + 'Content-Type': 'text/plain' + }, + body: fileContent + }); + + if (!res.ok) throw new Error(`Failed to write file: ${res.statusText}`); + return true; +} + +module.exports = { writeWhitelistFile }; diff --git a/services/arbiter-3.0/src/sync/cron.js b/services/arbiter-3.0/src/sync/cron.js new file mode 100644 index 0000000..90094fc --- /dev/null +++ b/services/arbiter-3.0/src/sync/cron.js @@ -0,0 +1,12 @@ +const cron = require('node-cron'); +const { triggerImmediateSync } = require('./immediate'); + +function initCron() { + cron.schedule('0 * * * *', async () => { + console.log("Starting hourly whitelist reconciliation..."); + await triggerImmediateSync(); + }); + console.log("Hourly sync cron initialized."); +} + +module.exports = { initCron }; diff --git a/services/arbiter-3.0/src/sync/immediate.js b/services/arbiter-3.0/src/sync/immediate.js new file mode 100644 index 0000000..fea93d5 --- /dev/null +++ b/services/arbiter-3.0/src/sync/immediate.js @@ -0,0 +1,41 @@ +const db = require('../database'); +const { getMinecraftServers } = require('../panel/discovery'); +const { writeWhitelistFile } = require('../panel/files'); +const { reloadWhitelistCommand } = require('../panel/commands'); + +async function triggerImmediateSync() { + console.log("Triggering immediate whitelist sync..."); + 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')` + ); + + const servers = await getMinecraftServers(); + + for (const server of servers) { + try { + await writeWhitelistFile(server.identifier, players); + await reloadWhitelistCommand(server.identifier); + + await db.query( + "INSERT INTO server_sync_log (server_identifier, last_successful_sync, is_online) VALUES ($1, NOW(), true) ON CONFLICT (server_identifier) DO UPDATE SET last_successful_sync = NOW(), is_online = true", + [server.identifier] + ); + } catch (err) { + console.error(`Sync failed for ${server.identifier}:`, 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", + [server.identifier, err.message] + ); + } + } + console.log("Immediate sync complete."); + } catch (error) { + console.error("Critical failure during immediate sync:", error); + } +} + +module.exports = { triggerImmediateSync }; diff --git a/services/arbiter-3.0/src/webhooks/paymenter.js b/services/arbiter-3.0/src/webhooks/paymenter.js new file mode 100644 index 0000000..16072e5 --- /dev/null +++ b/services/arbiter-3.0/src/webhooks/paymenter.js @@ -0,0 +1,53 @@ +const express = require('express'); +const router = express.Router(); +const db = require('../database'); +const { triggerImmediateSync } = require('../sync/immediate'); + +const TIERS = { + 1: { name: 'Awakened', roleId: 'DISCORD_ROLE_ID_1', price: 1 }, + 5: { name: 'Elemental', roleId: 'DISCORD_ROLE_ID_5', price: 5 }, + 10: { name: 'Knight', roleId: 'DISCORD_ROLE_ID_10', price: 10 }, + 15: { name: 'Master', roleId: 'DISCORD_ROLE_ID_15', price: 15 }, + 20: { name: 'Legend', roleId: 'DISCORD_ROLE_ID_20', price: 20 }, + 499: { name: 'Sovereign', roleId: 'DISCORD_ROLE_ID_499', price: 499 } +}; + +router.post('/paymenter', async (req, res) => { + const payload = req.body; + + if (req.headers['authorization'] !== process.env.PAYMENTER_WEBHOOK_SECRET) { + return res.status(401).send('Unauthorized'); + } + + const discordId = payload.discord_id; + const tierLevel = payload.tier_level; + const eventType = payload.event; + + try { + if (eventType === 'subscription_created' || eventType === 'payment_success') { + await db.query( + `INSERT INTO subscriptions (discord_id, tier_level, status) + VALUES ($1, $2, 'active') + ON CONFLICT (discord_id) DO UPDATE SET tier_level = $2, status = 'active'`, + [discordId, tierLevel] + ); + // NOTE: In production, you would fetch the GuildMember and assign TIERS[tierLevel].roleId here + } + else if (eventType === 'subscription_cancelled') { + await db.query( + `UPDATE subscriptions SET status = 'grace_period' WHERE discord_id = $1`, + [discordId] + ); + } + + // Sync after DB updates + triggerImmediateSync(); + res.status(200).send('Webhook processed'); + + } catch (error) { + console.error('Webhook processing error:', error); + res.status(500).send('Internal Server Error'); + } +}); + +module.exports = router;