feat: refactor codebase for separation of concerns
- Implement modular backend architecture with services, middleware, routes, and utils - Restructure frontend with centralized state management and component organization - Create dedicated services for auth, Docker, status monitoring, and API communication - Add WebSocket message handlers and connection management - Update README.md with comprehensive architecture documentation - Establish clear boundaries between functional areas for improved maintainability BREAKING CHANGE: Major architectural overhaul with new module structure
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
import fetch from 'node-fetch';
|
||||
import { API_URL } from '../../config/environment.js';
|
||||
|
||||
/**
|
||||
* API client for making requests to the external API
|
||||
*/
|
||||
|
||||
/**
|
||||
* Make an API request to the external service
|
||||
* @param {string} endpoint - API endpoint (without base URL)
|
||||
* @param {string} apiKey - Authentication API key
|
||||
* @param {string} method - HTTP method (default: 'GET')
|
||||
* @param {object} body - Request body for POST/PUT requests
|
||||
* @returns {Promise<object>} API response or error object
|
||||
*/
|
||||
export async function apiRequest(endpoint, apiKey, method = 'GET', body = null) {
|
||||
const headers = {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'x-my-mc-auth': apiKey
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}${endpoint}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : null
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
return { error: data.message || `HTTP ${response.status}` };
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
return { error: `Network error: ${error.message}` };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { linkStorage } from './linkStorage.js';
|
||||
import { generateApiKey, generateLoginLinkId, createLoginLink } from './tokenService.js';
|
||||
import { validateSecretKey, validateUsername } from '../../middleware/validation.js';
|
||||
import { getRealIp } from '../../utils/ipUtils.js';
|
||||
import {
|
||||
ADMIN_SECRET_KEY,
|
||||
STRICT_USER_AGENT_CHECK
|
||||
} from '../../config/environment.js';
|
||||
|
||||
/**
|
||||
* Login link generation service
|
||||
*/
|
||||
|
||||
export class LinkGenerator {
|
||||
/**
|
||||
* Generate a login link for a user
|
||||
* @param {string} secretKey - Admin secret key
|
||||
* @param {string} username - Username to generate link for
|
||||
* @param {object} req - Express request object
|
||||
* @returns {Promise<{success: boolean, link?: string, error?: string}>} Result object
|
||||
*/
|
||||
static async generateLoginLink(secretKey, username, req) {
|
||||
try {
|
||||
// Validate secret key
|
||||
if (!validateSecretKey(secretKey, ADMIN_SECRET_KEY)) {
|
||||
console.log(`Invalid secret key attempt from IP: ${getRealIp(req)}`);
|
||||
return { success: false, error: 'Unauthorized' };
|
||||
}
|
||||
|
||||
// Validate username
|
||||
const sanitizedUsername = validateUsername(username);
|
||||
if (!sanitizedUsername) {
|
||||
console.log(`Invalid username attempt from IP: ${getRealIp(req)}, username: ${username}`);
|
||||
return { success: false, error: 'Invalid username' };
|
||||
}
|
||||
|
||||
// Generate API key
|
||||
const apiKey = await generateApiKey(sanitizedUsername);
|
||||
if (!apiKey) {
|
||||
return { success: false, error: 'Authentication service error' };
|
||||
}
|
||||
|
||||
// Generate link ID and create link
|
||||
const linkId = generateLoginLinkId();
|
||||
const loginLink = createLoginLink(linkId);
|
||||
|
||||
// Store link data
|
||||
linkStorage.storeLink(linkId, {
|
||||
apiKey,
|
||||
username: sanitizedUsername,
|
||||
ip: getRealIp(req),
|
||||
userAgent: req.get('User-Agent') || 'Discord-Bot-Request'
|
||||
});
|
||||
|
||||
console.log(`Generated login link for username: ${sanitizedUsername} from IP: ${getRealIp(req)}, userAgent: ${req.get('User-Agent') || 'Discord-Bot-Request'}`);
|
||||
|
||||
return { success: true, link: loginLink };
|
||||
} catch (error) {
|
||||
console.log(`Error generating login link: ${error.message}`);
|
||||
return { success: false, error: 'Server error' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and retrieve login link data
|
||||
* @param {string} linkId - Link ID to validate
|
||||
* @param {object} req - Express request object
|
||||
* @returns {{success: boolean, data?: object, error?: string}} Validation result
|
||||
*/
|
||||
static validateLoginLink(linkId, req) {
|
||||
const linkData = linkStorage.getLink(linkId);
|
||||
|
||||
if (!linkData) {
|
||||
console.log(`Expired or invalid login attempt for link: ${linkId} from IP: ${getRealIp(req)}`);
|
||||
return { success: false, error: 'Link expired or invalid' };
|
||||
}
|
||||
|
||||
// Verify client consistency
|
||||
const isIpMatch = linkData.ip === getRealIp(req);
|
||||
const isUserAgentMatch = linkData.userAgent === (req.get('User-Agent') || 'Discord-Bot-Request');
|
||||
const isLocal = this.isLocalIp(getRealIp(req)) && this.isLocalIp(linkData.ip);
|
||||
|
||||
if (STRICT_USER_AGENT_CHECK && !isUserAgentMatch && !isLocal) {
|
||||
linkStorage.deleteLink(linkId);
|
||||
console.log(
|
||||
`Suspicious login attempt for link: ${linkId} from IP: ${getRealIp(req)}, ` +
|
||||
`expected IP: ${linkData.ip}, isLocal: ${isLocal}, ` +
|
||||
`userAgentMatch: ${isUserAgentMatch}, ` +
|
||||
`expectedUserAgent: ${linkData.userAgent}, ` +
|
||||
`actualUserAgent: ${req.get('User-Agent') || 'Discord-Bot-Request'}`
|
||||
);
|
||||
return { success: false, error: 'Invalid session' };
|
||||
}
|
||||
|
||||
if (!isUserAgentMatch) {
|
||||
console.log(
|
||||
`Non-critical user-agent mismatch for link: ${linkId} from IP: ${getRealIp(req)}, ` +
|
||||
`expectedUserAgent: ${linkData.userAgent}, ` +
|
||||
`actualUserAgent: ${req.get('User-Agent') || 'Discord-Bot-Request'}`
|
||||
);
|
||||
}
|
||||
|
||||
return { success: true, data: linkData };
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume (delete) a login link after successful use
|
||||
* @param {string} linkId - Link ID to consume
|
||||
*/
|
||||
static consumeLoginLink(linkId) {
|
||||
linkStorage.deleteLink(linkId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an IP address is considered local
|
||||
* @param {string} ip - IP address to check
|
||||
* @returns {boolean} True if local
|
||||
*/
|
||||
static isLocalIp(ip) {
|
||||
return (
|
||||
ip === '127.0.0.1' ||
|
||||
ip === '::1' ||
|
||||
ip.startsWith('192.168.') ||
|
||||
ip.startsWith('10.')
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import {
|
||||
TEMP_LINKS_CLEANUP_INTERVAL_MS,
|
||||
LINK_EXPIRY_SECONDS
|
||||
} from '../../config/environment.js';
|
||||
|
||||
/**
|
||||
* Temporary login link storage service
|
||||
*/
|
||||
|
||||
class LinkStorage {
|
||||
constructor() {
|
||||
this.temporaryLinks = new Map();
|
||||
this.initializeCleanup();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the cleanup interval for expired links
|
||||
*/
|
||||
initializeCleanup() {
|
||||
const cleanupInterval = Math.max(60000, parseInt(TEMP_LINKS_CLEANUP_INTERVAL_MS, 10));
|
||||
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [linkId, linkData] of this.temporaryLinks.entries()) {
|
||||
if (linkData.expiresAt < now) {
|
||||
this.temporaryLinks.delete(linkId);
|
||||
console.log(`Cleaned up expired link: ${linkId}`);
|
||||
}
|
||||
}
|
||||
}, cleanupInterval);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a temporary login link
|
||||
* @param {string} linkId - Unique link identifier
|
||||
* @param {object} linkData - Link data containing apiKey, username, expiresAt, ip, userAgent
|
||||
*/
|
||||
storeLink(linkId, linkData) {
|
||||
// Set expiry time with maximum of 1 hour
|
||||
const expiresAt = Date.now() + Math.min(3600000, parseInt(LINK_EXPIRY_SECONDS, 10) * 1000);
|
||||
linkData.expiresAt = expiresAt;
|
||||
|
||||
this.temporaryLinks.set(linkId, linkData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a temporary login link
|
||||
* @param {string} linkId - Link identifier
|
||||
* @returns {object|null} Link data or null if not found/expired
|
||||
*/
|
||||
getLink(linkId) {
|
||||
const linkData = this.temporaryLinks.get(linkId);
|
||||
|
||||
if (!linkData || linkData.expiresAt < Date.now()) {
|
||||
if (linkData) {
|
||||
this.temporaryLinks.delete(linkId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return linkData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a temporary login link
|
||||
* @param {string} linkId - Link identifier
|
||||
*/
|
||||
deleteLink(linkId) {
|
||||
this.temporaryLinks.delete(linkId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a link exists and is valid
|
||||
* @param {string} linkId - Link identifier
|
||||
* @returns {boolean} True if link exists and is valid
|
||||
*/
|
||||
hasValidLink(linkId) {
|
||||
return this.getLink(linkId) !== null;
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const linkStorage = new LinkStorage();
|
||||
@@ -0,0 +1,58 @@
|
||||
import unirest from 'unirest';
|
||||
import { generateSecureToken } from '../../utils/cryptoUtils.js';
|
||||
import {
|
||||
AUTH_ENDPOINT,
|
||||
AUTH_PASSWORD,
|
||||
LINK_ID_BYTES,
|
||||
AUTO_LOGIN_LINK_PREFIX
|
||||
} from '../../config/environment.js';
|
||||
|
||||
/**
|
||||
* Token service for authentication operations
|
||||
*/
|
||||
|
||||
/**
|
||||
* Generate an API key from the authentication service
|
||||
* @param {string} username - Username to authenticate
|
||||
* @returns {Promise<string|null>} API key or null if failed
|
||||
*/
|
||||
export async function generateApiKey(username) {
|
||||
try {
|
||||
const tokenResponse = await unirest
|
||||
.post(AUTH_ENDPOINT)
|
||||
.headers({
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-Request-ID': generateSecureToken(16)
|
||||
})
|
||||
.send({ username, password: AUTH_PASSWORD })
|
||||
.timeout(5000);
|
||||
|
||||
if (!tokenResponse.body.token) {
|
||||
console.log(`Failed to generate API key for username: ${username}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return tokenResponse.body.token;
|
||||
} catch (error) {
|
||||
console.log(`Error generating API key for username ${username}:`, error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a secure login link ID
|
||||
* @returns {string} Secure link ID
|
||||
*/
|
||||
export function generateLoginLinkId() {
|
||||
return generateSecureToken(LINK_ID_BYTES);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a complete login link from a link ID
|
||||
* @param {string} linkId - Link ID
|
||||
* @returns {string} Complete login link
|
||||
*/
|
||||
export function createLoginLink(linkId) {
|
||||
return `${AUTO_LOGIN_LINK_PREFIX}${linkId}`;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import Docker from 'dockerode';
|
||||
import { DOCKER_SOCKET_PATH } from '../../config/environment.js';
|
||||
|
||||
/**
|
||||
* Docker container service for general container operations
|
||||
*/
|
||||
|
||||
/**
|
||||
* Set up Docker client instance
|
||||
* @returns {Docker} Docker client instance
|
||||
*/
|
||||
export function setupDocker() {
|
||||
return new Docker({ socketPath: DOCKER_SOCKET_PATH });
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a container exists
|
||||
* @param {Docker} docker - Docker instance
|
||||
* @param {string} containerName - Name of the container
|
||||
* @returns {Promise<boolean>} True if container exists
|
||||
*/
|
||||
export async function containerExists(docker, containerName) {
|
||||
try {
|
||||
const containers = await docker.listContainers({ all: true });
|
||||
return containers.some(c => c.Names.includes(`/${containerName}`));
|
||||
} catch (error) {
|
||||
console.error(`Error checking if container ${containerName} exists:`, error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get container inspection data
|
||||
* @param {Docker} docker - Docker instance
|
||||
* @param {string} containerName - Name of the container
|
||||
* @returns {Promise<object|null>} Container inspection data or null if error
|
||||
*/
|
||||
export async function inspectContainer(docker, containerName) {
|
||||
try {
|
||||
const container = docker.getContainer(containerName);
|
||||
return await container.inspect();
|
||||
} catch (error) {
|
||||
console.error(`Error inspecting container ${containerName}:`, error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if container is running
|
||||
* @param {Docker} docker - Docker instance
|
||||
* @param {string} containerName - Name of the container
|
||||
* @returns {Promise<boolean>} True if container is running
|
||||
*/
|
||||
export async function isContainerRunning(docker, containerName) {
|
||||
const inspect = await inspectContainer(docker, containerName);
|
||||
return inspect ? inspect.State.Status === 'running' : false;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { LOG_STREAM_TAIL_LINES, LOG_STREAM_MONITOR_INTERVAL_MS } from '../../config/environment.js';
|
||||
|
||||
/**
|
||||
* Docker container logs streaming service
|
||||
*/
|
||||
|
||||
/**
|
||||
* Stream container logs to a WebSocket client
|
||||
* @param {Docker} docker - Docker instance
|
||||
* @param {WebSocket} ws - WebSocket connection
|
||||
* @param {string} containerName - Name of the container
|
||||
* @param {object} client - Client object to store stream reference
|
||||
*/
|
||||
export async function streamContainerLogs(docker, ws, containerName, client) {
|
||||
let isStreaming = true;
|
||||
let isStartingStream = false;
|
||||
|
||||
const startLogStream = async () => {
|
||||
if (isStartingStream) return false;
|
||||
isStartingStream = true;
|
||||
|
||||
try {
|
||||
const container = docker.getContainer(containerName);
|
||||
const [containers, inspect] = await Promise.all([
|
||||
docker.listContainers({ all: true }),
|
||||
container.inspect()
|
||||
]);
|
||||
|
||||
if (!containers.some(c => c.Names.includes(`/${containerName}`))) {
|
||||
if (isStreaming) ws.send(JSON.stringify({ type: 'docker-logs', error: `Container ${containerName} not found` }));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (inspect.State.Status !== 'running') {
|
||||
if (isStreaming) ws.send(JSON.stringify({ type: 'docker-logs', error: `Container ${containerName} is not running` }));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (client.logStream) {
|
||||
client.logStream.removeAllListeners();
|
||||
client.logStream.destroy();
|
||||
client.logStream = null;
|
||||
}
|
||||
|
||||
const logStream = await container.logs({
|
||||
follow: true,
|
||||
stdout: true,
|
||||
stderr: true,
|
||||
tail: parseInt(LOG_STREAM_TAIL_LINES, 10),
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
logStream.on('data', (chunk) => {
|
||||
if (isStreaming && client.logStream === logStream) {
|
||||
ws.send(JSON.stringify({ type: 'docker-logs', data: { log: chunk.toString('utf8') } }));
|
||||
}
|
||||
});
|
||||
|
||||
logStream.on('error', (error) => {
|
||||
if (isStreaming) ws.send(JSON.stringify({ type: 'docker-logs', error: `Log stream error: ${error.message}` }));
|
||||
});
|
||||
|
||||
client.logStream = logStream;
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isStreaming) ws.send(JSON.stringify({ type: 'docker-logs', error: `Failed to stream logs: ${error.message}` }));
|
||||
return false;
|
||||
} finally {
|
||||
isStartingStream = false;
|
||||
}
|
||||
};
|
||||
|
||||
const monitorContainer = async () => {
|
||||
try {
|
||||
const container = docker.getContainer(containerName);
|
||||
const inspect = await container.inspect();
|
||||
if (inspect.State.Status !== 'running') {
|
||||
if (client.logStream) {
|
||||
client.logStream.removeAllListeners();
|
||||
client.logStream.destroy();
|
||||
client.logStream = null;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Try to start the log stream initially
|
||||
await startLogStream();
|
||||
|
||||
// Set up a single monitor interval to restart the stream if it stops
|
||||
const monitorInterval = setInterval(async () => {
|
||||
if (!isStreaming) {
|
||||
clearInterval(monitorInterval);
|
||||
return;
|
||||
}
|
||||
if (await monitorContainer() && !client.logStream && !isStartingStream) {
|
||||
await startLogStream();
|
||||
}
|
||||
}, parseInt(LOG_STREAM_MONITOR_INTERVAL_MS, 10));
|
||||
|
||||
// Set up close handler to clean up resources
|
||||
ws.on('close', () => {
|
||||
isStreaming = false;
|
||||
clearInterval(monitorInterval);
|
||||
if (client.logStream) {
|
||||
client.logStream.removeAllListeners();
|
||||
client.logStream.destroy();
|
||||
client.logStream = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { promisify } from 'util';
|
||||
import { exec } from 'child_process';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import {
|
||||
SERVER_PROPERTIES_PATH,
|
||||
TEMP_DIR,
|
||||
TEMP_FILE_RANDOM_ID_BYTES,
|
||||
CONTAINER_TEMP_FILE_PREFIX
|
||||
} from '../../config/environment.js';
|
||||
|
||||
const execPromise = promisify(exec);
|
||||
|
||||
/**
|
||||
* Docker server properties management service
|
||||
*/
|
||||
|
||||
/**
|
||||
* Read server.properties file from container
|
||||
* @param {Docker} docker - Docker instance
|
||||
* @param {string} containerName - Name of the container
|
||||
* @returns {Promise<object>} Object with content or error
|
||||
*/
|
||||
export async function readServerProperties(docker, containerName) {
|
||||
try {
|
||||
const container = docker.getContainer(containerName);
|
||||
const inspect = await container.inspect();
|
||||
if (inspect.State.Status !== 'running') {
|
||||
return { error: `Container ${containerName} is not running` };
|
||||
}
|
||||
const { stdout, stderr } = await execPromise(`docker exec ${containerName} bash -c "cat ${SERVER_PROPERTIES_PATH}"`);
|
||||
if (stderr) return { error: 'Failed to read server.properties' };
|
||||
return { content: stdout };
|
||||
} catch (error) {
|
||||
return { error: `Failed to read server.properties: ${error.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write server.properties file to container
|
||||
* @param {Docker} docker - Docker instance
|
||||
* @param {string} containerName - Name of the container
|
||||
* @param {string} content - Content to write
|
||||
* @returns {Promise<object>} Success message or error
|
||||
*/
|
||||
export async function writeServerProperties(docker, containerName, content) {
|
||||
try {
|
||||
const randomId = randomBytes(parseInt(TEMP_FILE_RANDOM_ID_BYTES, 10)).toString('hex');
|
||||
const tmpFile = path.join(TEMP_DIR, `server_properties_${randomId}.tmp`);
|
||||
const containerFilePath = `${CONTAINER_TEMP_FILE_PREFIX}${randomId}.tmp`;
|
||||
|
||||
await fs.writeFile(tmpFile, content);
|
||||
await execPromise(`docker cp ${tmpFile} ${containerName}:${containerFilePath}`);
|
||||
await execPromise(`docker exec ${containerName} bash -c "mv ${containerFilePath} ${SERVER_PROPERTIES_PATH} && chown mc:mc ${SERVER_PROPERTIES_PATH}"`);
|
||||
await fs.unlink(tmpFile).catch(err => console.error(`Error deleting temp file: ${err.message}`));
|
||||
return { message: 'Server properties updated' };
|
||||
} catch (error) {
|
||||
return { error: `Failed to write server.properties: ${error.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update mods in container
|
||||
* @param {Docker} docker - Docker instance
|
||||
* @param {string} containerName - Name of the container
|
||||
* @returns {Promise<object>} Output or error
|
||||
*/
|
||||
export async function updateMods(docker, containerName) {
|
||||
try {
|
||||
const container = docker.getContainer(containerName);
|
||||
const inspect = await container.inspect();
|
||||
if (inspect.State.Status !== 'running') {
|
||||
return { error: `Container ${containerName} is not running` };
|
||||
}
|
||||
const { stdout, stderr } = await execPromise(`docker exec ${containerName} bash -c 'cd /home/mc/minecraft && mod-manager update'`);
|
||||
if (stderr) return { output: stderr };
|
||||
return { output: stdout || 'Mod update completed successfully.' };
|
||||
} catch (error) {
|
||||
return { error: `Failed to update mods: ${error.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a backup of the container
|
||||
* @param {Docker} docker - Docker instance
|
||||
* @param {string} containerName - Name of the container
|
||||
* @returns {Promise<object>} Backup result with download URL or error
|
||||
*/
|
||||
export async function createBackup(docker, containerName) {
|
||||
try {
|
||||
const container = docker.getContainer(containerName);
|
||||
const inspect = await container.inspect();
|
||||
if (inspect.State.Status !== 'running') {
|
||||
return { error: `Container ${containerName} is not running` };
|
||||
}
|
||||
const command = `docker exec -t ${containerName} bash -c "/home/backup.sh | grep export"`;
|
||||
const { stdout, stderr } = await execPromise(command);
|
||||
if (stderr) return { error: stderr };
|
||||
|
||||
// Extract the URL using a regular expression
|
||||
const urlRegex = /(https:\/\/[^\s]+)/;
|
||||
const match = stdout.match(urlRegex);
|
||||
if (!match) return { error: 'No download URL found in backup output' };
|
||||
|
||||
const downloadURL = match[0];
|
||||
return { output: 'Backup completed successfully', downloadURL };
|
||||
} catch (error) {
|
||||
return { error: `Failed to create backup: ${error.message}` };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { promisify } from 'util';
|
||||
import { exec } from 'child_process';
|
||||
const execPromise = promisify(exec);
|
||||
|
||||
/**
|
||||
* Docker container statistics service
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get comprehensive container statistics
|
||||
* @param {Docker} docker - Docker instance
|
||||
* @param {string} containerName - Name of the container
|
||||
* @returns {Promise<object>} Container statistics or error object
|
||||
*/
|
||||
export async function getContainerStats(docker, containerName) {
|
||||
try {
|
||||
const container = docker.getContainer(containerName);
|
||||
const [containers, info, stats] = await Promise.all([
|
||||
docker.listContainers({ all: true }),
|
||||
container.inspect(),
|
||||
container.stats({ stream: false })
|
||||
]);
|
||||
|
||||
if (!containers.some(c => c.Names.includes(`/${containerName}`))) {
|
||||
return { error: `Container ${containerName} not found` };
|
||||
}
|
||||
|
||||
// Calculate memory statistics
|
||||
const memoryUsage = stats.memory_stats.usage / 1024 / 1024;
|
||||
const memoryLimit = stats.memory_stats.limit / 1024 / 1024 / 1024;
|
||||
const memoryPercent = ((memoryUsage / (memoryLimit * 1024)) * 100).toFixed(2);
|
||||
|
||||
// Calculate CPU statistics
|
||||
const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - (stats.precpu_stats.cpu_usage?.total_usage || 0);
|
||||
const systemDelta = stats.cpu_stats.system_cpu_usage - (stats.precpu_stats.system_cpu_usage || 0);
|
||||
const cpuPercent = systemDelta > 0 ? ((cpuDelta / systemDelta) * stats.cpu_stats.online_cpus * 100).toFixed(2) : 0;
|
||||
|
||||
// Collect disk space data
|
||||
let diskData = null;
|
||||
try {
|
||||
const { stdout: dfOutput } = await execPromise(`docker exec ${containerName} df -h /`);
|
||||
const lines = dfOutput.trim().split('\n');
|
||||
if (lines.length >= 2) {
|
||||
const dataLine = lines[1];
|
||||
const columns = dataLine.split(/\s+/);
|
||||
if (columns.length >= 6) {
|
||||
const totalSize = columns[1];
|
||||
const usedSpace = columns[2];
|
||||
const availableSpace = columns[3];
|
||||
const usagePercent = columns[4].replace('%', '');
|
||||
|
||||
// Check if total size is exactly 25GB (handle both "25G" and "25Gi" formats)
|
||||
const normalizedTotal = totalSize.replace('i', ''); // Remove 'i' from GiB if present
|
||||
if (normalizedTotal === '25G') {
|
||||
diskData = {
|
||||
raw: `${usedSpace} / ${totalSize}`,
|
||||
percent: usagePercent,
|
||||
used: usedSpace,
|
||||
available: availableSpace,
|
||||
total: totalSize
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (diskError) {
|
||||
console.warn(`Failed to get disk stats for ${containerName}:`, diskError.message);
|
||||
// Don't fail the entire stats collection if disk data is unavailable
|
||||
}
|
||||
|
||||
const result = {
|
||||
status: info.State.Status,
|
||||
memory: { raw: `${memoryUsage.toFixed(2)}MiB / ${memoryLimit.toFixed(2)}GiB`, percent: memoryPercent },
|
||||
cpu: cpuPercent
|
||||
};
|
||||
|
||||
// Only include disk data if it's available and the container has 25GB quota
|
||||
if (diskData) {
|
||||
result.disk = diskData;
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`Docker stats error for ${containerName}:`, error.message);
|
||||
return { error: `Failed to fetch stats for ${containerName}: ${error.message}` };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { promisify } from 'util';
|
||||
import { exec } from 'child_process';
|
||||
import { Socket } from 'net';
|
||||
import {
|
||||
STATUS_CHECK_PATH,
|
||||
GEYSER_STATUS_CHECK_PATH,
|
||||
SFTP_CONNECTION_TIMEOUT_MS,
|
||||
SFTP_HOSTNAME
|
||||
} from '../../config/environment.js';
|
||||
|
||||
const execPromise = promisify(exec);
|
||||
|
||||
/**
|
||||
* Connection status checking services
|
||||
*/
|
||||
|
||||
/**
|
||||
* Check Minecraft server connection status
|
||||
* @param {string} hostname - Server hostname
|
||||
* @param {number} port - Server port
|
||||
* @returns {Promise<object>} Status object with isOnline and optional data/error
|
||||
*/
|
||||
export async function checkConnectionStatus(hostname, port) {
|
||||
try {
|
||||
const { stdout, stderr } = await execPromise(`${STATUS_CHECK_PATH} -host ${hostname} -port ${port}`);
|
||||
if (stderr) return { isOnline: false, error: stderr };
|
||||
return { isOnline: true, data: JSON.parse(stdout) };
|
||||
} catch (error) {
|
||||
return { isOnline: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check Geyser (Bedrock) server connection status
|
||||
* @param {string} hostname - Server hostname
|
||||
* @param {number} port - Server port
|
||||
* @returns {Promise<object>} Status object with isOnline and optional data/error
|
||||
*/
|
||||
export async function checkGeyserStatus(hostname, port) {
|
||||
try {
|
||||
const { stdout, stderr } = await execPromise(`${GEYSER_STATUS_CHECK_PATH} -host ${hostname} -port ${port}`);
|
||||
if (stderr) return { isOnline: false, error: stderr };
|
||||
return { isOnline: true, data: JSON.parse(stdout) };
|
||||
} catch (error) {
|
||||
return { isOnline: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check SFTP server connection status
|
||||
* @param {string} hostname - Server hostname
|
||||
* @param {number} port - Server port
|
||||
* @returns {Promise<object>} Status object with isOnline and optional error
|
||||
*/
|
||||
export async function checkSftpStatus(hostname, port) {
|
||||
return new Promise((resolve) => {
|
||||
const socket = new Socket();
|
||||
const timeout = parseInt(SFTP_CONNECTION_TIMEOUT_MS, 10);
|
||||
socket.setTimeout(timeout);
|
||||
|
||||
socket.on('connect', () => {
|
||||
socket.destroy();
|
||||
resolve({ isOnline: true });
|
||||
});
|
||||
|
||||
socket.on('timeout', () => {
|
||||
socket.destroy();
|
||||
resolve({ isOnline: false, error: 'Connection timed out' });
|
||||
});
|
||||
|
||||
socket.on('error', (error) => {
|
||||
socket.destroy();
|
||||
resolve({ isOnline: false, error: error.message });
|
||||
});
|
||||
|
||||
socket.connect(port, SFTP_HOSTNAME);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Status monitoring service
|
||||
* Handles periodic status checks and monitoring for various services
|
||||
*/
|
||||
|
||||
export class StatusMonitor {
|
||||
constructor() {
|
||||
this.monitors = new Map();
|
||||
this.intervals = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start monitoring a service
|
||||
* @param {string} id - Monitor identifier
|
||||
* @param {Function} checkFunction - Function to call for status check
|
||||
* @param {number} intervalMs - Interval in milliseconds
|
||||
* @param {Function} callback - Callback function to handle results
|
||||
*/
|
||||
startMonitoring(id, checkFunction, intervalMs, callback) {
|
||||
// Clear existing monitor if it exists
|
||||
this.stopMonitoring(id);
|
||||
|
||||
const intervalId = setInterval(async () => {
|
||||
try {
|
||||
const result = await checkFunction();
|
||||
callback(result);
|
||||
} catch (error) {
|
||||
console.error(`Status monitor error for ${id}:`, error.message);
|
||||
callback({ isOnline: false, error: error.message });
|
||||
}
|
||||
}, intervalMs);
|
||||
|
||||
this.intervals.set(id, intervalId);
|
||||
this.monitors.set(id, { checkFunction, callback });
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop monitoring a service
|
||||
* @param {string} id - Monitor identifier
|
||||
*/
|
||||
stopMonitoring(id) {
|
||||
const intervalId = this.intervals.get(id);
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
this.intervals.delete(id);
|
||||
this.monitors.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all monitoring
|
||||
*/
|
||||
stopAllMonitoring() {
|
||||
for (const id of this.intervals.keys()) {
|
||||
this.stopMonitoring(id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active monitors
|
||||
* @returns {Array<string>} Array of monitor IDs
|
||||
*/
|
||||
getActiveMonitors() {
|
||||
return Array.from(this.monitors.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a one-time status check
|
||||
* @param {Function} checkFunction - Function to call for status check
|
||||
* @returns {Promise<object>} Status result
|
||||
*/
|
||||
static async performCheck(checkFunction) {
|
||||
try {
|
||||
return await checkFunction();
|
||||
} catch (error) {
|
||||
return { isOnline: false, error: error.message };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const statusMonitor = new StatusMonitor();
|
||||
Reference in New Issue
Block a user