feat: Implémentation RCON, gestion historique et corrections Docker

- Fix protocole RCON (Int32LE, Map-based response handling)
- Ajout historique des commandes RCON avec persistance
- Correction chemins Docker (SERVER_DIR, RCON_HOST, volumes)
- Fix récupération données joueurs (world/players)
- Amélioration UX login/register
- Nettoyage logs de debug
This commit is contained in:
y.campiontrebouta@innotexnas.ovh
2026-02-04 21:58:42 +01:00
parent 4e12afe105
commit ce25f7c93a
8 changed files with 484 additions and 162 deletions

View File

@@ -27,10 +27,11 @@ async function getServerOps() {
try {
if (await fs.pathExists(opsFile)) {
const content = await fs.readFile(opsFile, 'utf-8');
return content
const ops = content
.split('\n')
.map(line => line.trim())
.filter(line => line && !line.startsWith('#'));
.filter(line => line && line !== '' && !line.startsWith('#'));
return ops;
}
} catch (e) {
console.error('Erreur lecture ops.txt:', e);
@@ -66,14 +67,22 @@ router.post('/register', async (req, res) => {
return res.status(400).json({ error: 'Données manquantes' });
}
console.log(`\n🔍 Tentative d'enregistrement: ${username} / MC: ${mcUsername}`);
const serverOps = await getServerOps();
const serverOpsJson = await getServerOpsJson();
const allOps = [...new Set([...serverOps, ...serverOpsJson])];
console.log(`📋 OPs disponibles:`, allOps);
console.log(`🔎 Cherche: "${mcUsername}"`);
console.log(`✓ Trouvé: ${allOps.includes(mcUsername)}`);
if (!allOps.includes(mcUsername)) {
console.log(`❌ OP non trouvé pour ${mcUsername}`);
return res.status(403).json({
error: 'Le joueur n\'est pas OP sur le serveur',
availableOps: allOps
availableOps: allOps,
checkedUsername: mcUsername
});
}

View File

@@ -6,7 +6,7 @@ const router = express.Router();
const SERVER_DIR = process.env.SERVER_DIR || '/home/innotex/Documents/Projet/Serveur NationsGlory/NationsGlory_ServeurBuild_Red';
function isAuthenticated(req, res, next) {
if (req.session.user) {
if (req.session && req.session.user) {
next();
} else {
res.status(401).json({ error: 'Non authentifié' });
@@ -15,31 +15,58 @@ function isAuthenticated(req, res, next) {
router.get('/', isAuthenticated, async (req, res) => {
try {
const playersDir = path.join(SERVER_DIR, 'playerdata');
const statsDir = path.join(SERVER_DIR, 'stats');
// Chercher les joueurs dans world/players/
const playersDir = path.join(SERVER_DIR, 'world', 'players');
if (!await fs.pathExists(playersDir)) {
console.warn('Répertoire joueurs non trouvé:', playersDir);
return res.json({ players: [] });
}
const files = await fs.readdir(playersDir);
const uuidRegex = /^[0-9a-f-]{36}\.dat$/i;
const playerFiles = files.filter(f => uuidRegex.test(f));
const playerFiles = files.filter(f => f.endsWith('.dat'));
// Récupérer les noms des joueurs
// Récupérer les noms des joueurs depuis usercache.json
const usercacheFile = path.join(SERVER_DIR, 'usercache.json');
let usercache = {};
if (await fs.pathExists(usercacheFile)) {
try {
const cache = await fs.readJson(usercacheFile);
usercache = cache.reduce((acc, entry) => {
acc[entry.uuid] = entry.name;
return acc;
}, {});
if (Array.isArray(cache)) {
usercache = cache.reduce((acc, entry) => {
acc[entry.uuid] = entry.name;
return acc;
}, {});
}
} catch (e) {
console.error('Erreur lecture usercache:', e);
}
} else {
console.warn('usercache.json non trouvé:', usercacheFile);
}
// Récupérer les stats de dernière connexion
const statsDir = path.join(SERVER_DIR, 'world', 'stats');
let statsByUuid = {};
if (await fs.pathExists(statsDir)) {
try {
const statFiles = await fs.readdir(statsDir);
for (const file of statFiles) {
if (file.endsWith('.json')) {
try {
const uuid = file.replace('.json', '');
const stat = await fs.readJson(path.join(statsDir, file));
statsByUuid[uuid] = stat;
} catch (e) {
// Ignorer les fichiers corrompus
}
}
}
} catch (e) {
console.warn('Erreur lecture stats:', e);
}
}
const players = playerFiles.map(file => {
@@ -47,7 +74,7 @@ router.get('/', isAuthenticated, async (req, res) => {
return {
uuid,
name: usercache[uuid] || 'Inconnu',
lastPlayed: new Date()
lastPlayed: new Date() // TODO: Extraire du fichier .dat si possible
};
});

View File

@@ -7,7 +7,7 @@ const router = express.Router();
const SERVER_DIR = process.env.SERVER_DIR || '/home/innotex/Documents/Projet/Serveur NationsGlory/NationsGlory_ServeurBuild_Red';
function isAuthenticated(req, res, next) {
if (req.session.user) {
if (req.session && req.session.user) {
next();
} else {
res.status(401).json({ error: 'Non authentifié' });
@@ -19,7 +19,13 @@ async function getRconConfig() {
try {
const content = await fs.readFile(serverPropsFile, 'utf-8');
const lines = content.split('\n');
let config = { host: 'localhost', port: 25575, password: '' };
// Utiliser RCON_HOST depuis les variables d'environnement ou localhost
let config = {
host: process.env.RCON_HOST || 'localhost',
port: process.env.RCON_PORT || 25575,
password: ''
};
for (let line of lines) {
if (line.startsWith('rcon.port=')) {
@@ -36,7 +42,7 @@ async function getRconConfig() {
}
}
async function addToHistory(command, response) {
async function addToHistory(command, response, success = true, error = null) {
try {
const historyFile = path.join(SERVER_DIR, '.web-admin/rcon-history.json');
await fs.ensureDir(path.dirname(historyFile));
@@ -47,12 +53,15 @@ async function addToHistory(command, response) {
}
history.push({
timestamp: new Date(),
timestamp: new Date().toISOString(),
command,
response: response.substring(0, 500)
response: response ? response.substring(0, 1000) : null,
success,
error: error ? error.substring(0, 500) : null
});
await fs.writeJson(historyFile, history.slice(-100), { spaces: 2 });
// Garder les 200 dernières commandes
await fs.writeJson(historyFile, history.slice(-200), { spaces: 2 });
} catch (e) {
console.error('Erreur sauvegarde historique:', e);
}
@@ -78,12 +87,13 @@ router.post('/command', isAuthenticated, async (req, res) => {
const response = await rcon.sendCommand(command);
rcon.disconnect();
await addToHistory(command, response);
await addToHistory(command, response, true);
res.json({ response, command });
} catch (e) {
console.error('Erreur RCON:', e);
rcon.disconnect();
await addToHistory(command, null, false, e.message);
res.status(500).json({ error: `Erreur RCON: ${e.message}` });
}
@@ -96,19 +106,49 @@ router.post('/command', isAuthenticated, async (req, res) => {
router.get('/history', isAuthenticated, async (req, res) => {
try {
const historyFile = path.join(SERVER_DIR, '.web-admin/rcon-history.json');
const limit = parseInt(req.query.limit) || 100;
const search = req.query.search || '';
let history = [];
if (await fs.pathExists(historyFile)) {
const history = await fs.readJson(historyFile);
res.json(history);
} else {
res.json([]);
history = await fs.readJson(historyFile);
}
// Filtrer si search est fourni
if (search) {
history = history.filter(h =>
h.command.toLowerCase().includes(search.toLowerCase()) ||
(h.response && h.response.toLowerCase().includes(search.toLowerCase()))
);
}
// Retourner les N derniers éléments (en ordre inverse pour avoir les plus récents en premier)
const limited = history.reverse().slice(0, limit);
console.log(`✓ Historique RCON récupéré: ${limited.length} entrées`);
res.json({
total: history.length,
count: limited.length,
history: limited
});
} catch (e) {
console.error('Erreur historique:', e);
res.status(500).json({ error: 'Erreur serveur' });
}
});
router.delete('/history', isAuthenticated, async (req, res) => {
try {
const historyFile = path.join(SERVER_DIR, '.web-admin/rcon-history.json');
await fs.remove(historyFile);
console.log('✓ Historique RCON supprimé');
res.json({ message: 'Historique supprimé' });
} catch (e) {
console.error('Erreur suppression historique:', e);
res.status(500).json({ error: 'Erreur serveur' });
}
});
router.post('/restart', isAuthenticated, async (req, res) => {
try {
const config = await getRconConfig();

View File

@@ -11,11 +11,25 @@ const app = express();
const PORT = process.env.PORT || 4001;
const SERVER_DIR = process.env.SERVER_DIR || '/home/innotex/Documents/Projet/Serveur NationsGlory/NationsGlory_ServeurBuild_Red';
// Déterminer le chemin du frontend (support Docker et local)
const frontendPath = (() => {
// En Docker: /app/frontend
if (fs.existsSync(path.join(__dirname, '../frontend'))) {
return path.join(__dirname, '../frontend/public');
}
// En local: ../../frontend
if (fs.existsSync(path.join(__dirname, '../../frontend/public'))) {
return path.join(__dirname, '../../frontend/public');
}
// Fallback
return path.join(__dirname, '../frontend/public');
})();
// Middlewares
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(cors({
origin: process.env.NODE_ENV === 'production' ? false : true,
origin: true,
credentials: true
}));
@@ -25,8 +39,9 @@ app.use(session({
resave: false,
saveUninitialized: true,
cookie: {
secure: process.env.NODE_ENV === 'production',
secure: false, // localhost n'utilise pas HTTPS
httpOnly: true,
sameSite: 'lax',
maxAge: 24 * 60 * 60 * 1000 // 24 heures
}
}));
@@ -55,11 +70,11 @@ app.get('/api/health', (req, res) => {
});
// Servir les fichiers statiques du frontend
app.use(express.static(path.join(__dirname, '../../frontend/public')));
app.use(express.static(frontendPath));
// Route pour l'index
app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, '../../frontend/public/index.html'));
res.sendFile(path.join(frontendPath, 'index.html'));
});
// Erreur 404 pour les routes API non trouvées
@@ -69,18 +84,20 @@ app.use('/api/*', (req, res) => {
// Pour toutes les autres routes, servir index.html (SPA support)
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, '../../frontend/public/index.html'));
res.sendFile(path.join(frontendPath, 'index.html'));
});
// Error handler
app.use((err, req, res, next) => {
console.error(err);
res.status(500).json({ error: 'Erreur serveur interne' });
console.error('Erreur:', err);
console.error('Path:', err.path);
console.error('Status:', err.status);
res.status(err.status || 500).json({ error: 'Erreur serveur interne', details: err.message });
});
app.listen(PORT, () => {
console.log(`\n🚀 Backend Admin NationsGlory démarré sur http://localhost:${PORT}`);
console.log(`📁 Répertoire du serveur: ${SERVER_DIR}\n`);
console.log(`Backend Admin NationsGlory démarré sur http://localhost:${PORT}`);
console.log(`Répertoire du serveur: ${SERVER_DIR}`);
});
module.exports = { app, SERVER_DIR };

View File

@@ -7,41 +7,29 @@ class RconClient {
this.password = password;
this.socket = null;
this.authenticated = false;
this.packetId = 0;
this.responseBuffer = Buffer.alloc(0);
this.requestId = 0;
this.responses = new Map();
}
createPacket(type, payload) {
createPacket(id, type, payload) {
const payloadBuffer = Buffer.from(payload, 'utf8');
const body = Buffer.alloc(4 + payloadBuffer.length + 1);
body.writeInt32BE(type, 0);
payloadBuffer.copy(body, 4);
body[4 + payloadBuffer.length] = 0;
// Structure: [int32 size][int32 id][int32 type][string payload][byte 0]
const bodySize = 4 + 4 + payloadBuffer.length + 1;
const body = Buffer.alloc(4 + bodySize);
const size = body.length;
const packet = Buffer.alloc(4 + size);
packet.writeInt32LE(size, 0);
body.copy(packet, 4);
// Taille
body.writeInt32LE(bodySize, 0);
// ID
body.writeInt32LE(id, 4);
// Type
body.writeInt32LE(type, 8);
// Payload
payloadBuffer.copy(body, 12);
// Null terminator
body[12 + payloadBuffer.length] = 0;
return packet;
}
parsePacket(buffer) {
if (buffer.length < 12) return null;
const size = buffer.readInt32LE(0);
if (buffer.length < 4 + size) return null;
const id = buffer.readInt32LE(4);
const type = buffer.readInt32LE(8);
let payload = '';
if (size > 8) {
payload = buffer.slice(12, 4 + size - 1).toString('utf8');
}
return { id, type, payload, totalSize: 4 + size };
return body;
}
async connect() {
@@ -51,81 +39,128 @@ class RconClient {
port: this.port
});
const timeout = setTimeout(() => {
this.socket?.destroy();
reject(new Error(`Timeout de connexion RCON (${this.host}:${this.port})`));
}, 5000);
this.socket.on('connect', () => {
console.log('✓ Connecté au serveur RCON');
clearTimeout(timeout);
this.setupSocket();
this.authenticate().then(resolve).catch(reject);
});
this.socket.on('error', reject);
setTimeout(() => {
reject(new Error('Timeout de connexion'));
}, 5000);
this.socket.on('error', (err) => {
clearTimeout(timeout);
reject(new Error(`Erreur RCON: ${err.message}`));
});
});
}
setupSocket() {
let buffer = Buffer.alloc(0);
this.socket.on('data', (data) => {
buffer = Buffer.concat([buffer, data]);
this.processBuffer(buffer);
buffer = this.getRemainingBuffer(buffer);
});
}
processBuffer(buffer) {
while (buffer.length >= 10) {
const size = buffer.readInt32LE(0);
if (buffer.length < size + 4) {
return;
}
const id = buffer.readInt32LE(4);
const type = buffer.readInt32LE(8);
let payload = '';
if (size > 10) {
payload = buffer.slice(12, 4 + size - 1).toString('utf8');
}
const response = { id, type, payload };
if (this.responses.has(id)) {
this.responses.get(id)(response);
this.responses.delete(id);
}
buffer = buffer.slice(4 + size);
}
}
getRemainingBuffer(buffer) {
if (buffer.length >= 10) {
const size = buffer.readInt32LE(0);
if (buffer.length >= size + 4) {
return buffer.slice(size + 4);
}
}
return buffer;
}
async authenticate() {
return new Promise((resolve, reject) => {
this.packetId++;
const packet = this.createPacket(3, this.password);
this.requestId++;
const id = this.requestId;
this.socket.write(packet);
const handleData = (data) => {
this.responseBuffer = Buffer.concat([this.responseBuffer, data]);
const response = this.parsePacket(this.responseBuffer);
if (response && response.id === this.packetId) {
this.responseBuffer = this.responseBuffer.slice(response.totalSize);
this.socket.removeListener('data', handleData);
if (response.id !== -1) {
this.authenticated = true;
console.log('✓ Authentifié RCON');
resolve();
} else {
reject(new Error('Mot de passe RCON incorrect'));
}
}
};
this.socket.on('data', handleData);
setTimeout(() => {
reject(new Error('Timeout d\'authentification'));
const packet = this.createPacket(id, 3, this.password);
const timeout = setTimeout(() => {
this.responses.delete(id);
reject(new Error('Timeout d\'authentification RCON'));
}, 5000);
this.responses.set(id, (response) => {
clearTimeout(timeout);
if (response.type === 0 || response.id === id) {
this.authenticated = true;
resolve();
} else if (response.id === -1) {
reject(new Error('Mot de passe RCON incorrect'));
} else {
reject(new Error('Authentification RCON échouée'));
}
});
this.socket.write(packet);
});
}
async sendCommand(command) {
if (!this.authenticated) {
throw new Error('Non authentifié au serveur RCON');
}
return new Promise((resolve, reject) => {
if (!this.authenticated) {
reject(new Error('Non authentifié'));
return;
}
this.packetId++;
const id = this.packetId;
const packet = this.createPacket(2, command);
this.requestId++;
const id = this.requestId;
this.socket.write(packet);
const handleData = (data) => {
this.responseBuffer = Buffer.concat([this.responseBuffer, data]);
const response = this.parsePacket(this.responseBuffer);
if (response && response.id === id) {
this.responseBuffer = this.responseBuffer.slice(response.totalSize);
this.socket.removeListener('data', handleData);
resolve(response.payload);
}
};
this.socket.on('data', handleData);
setTimeout(() => {
reject(new Error('Timeout de commande'));
const packet = this.createPacket(id, 2, command);
const timeout = setTimeout(() => {
this.responses.delete(id);
reject(new Error('Timeout de commande RCON'));
}, 10000);
this.responses.set(id, (response) => {
clearTimeout(timeout);
resolve(response.payload);
});
try {
this.socket.write(packet);
} catch (e) {
this.responses.delete(id);
clearTimeout(timeout);
reject(e);
}
});
}