Files
chimera-Public/server.js
T

193 lines
11 KiB
JavaScript

'use strict';
const fs = require('fs');
const net = require('net');
const path = require('path');
const pkg = require('./package.json');
const db = require('./src/database');
const ftp = require('./src/ftp');
const { initializeDataPaths } = require('./src/platform/data-paths');
const { readBuildInfo } = require('./src/platform/build-info');
const { createLogger } = require('./src/platform/logger');
const { SettingsStore } = require('./src/persistence/settings-store');
const { SecretsStore } = require('./src/security/secrets-store');
const { createRegistry } = require('./src/server/create-registry');
const { createAppServer } = require('./src/server/app-server');
const appRoot = __dirname;
const paths = initializeDataPaths();
const logger = createLogger(paths.logs);
const settings = new SettingsStore(path.join(paths.config, 'settings.json'));
const keyFile = process.env.APP_SECRET_KEY_FILE || path.join(paths.secrets, 'master.key');
const secrets = new SecretsStore({ filePath: path.join(paths.secrets, 'secrets.enc.json'), keyFile });
const capabilities = {
platform: 'linux-server', language: { default: 'en', fallback: 'en' },
features: {
browserUi: { available: true, mode: 'native-web' }, ftp: { available: true },
https: { available: true, mode: 'reverse-proxy', configured: Boolean(process.env.CHIMERA_PUBLIC_ORIGIN || process.env.PORKFOLIO_PUBLIC_ORIGIN) },
gamesDatabase: { available: true }, transferEvents: { available: true, transport: 'websocket' },
systemView: { available: true, mode: 'browser-media-devices', secureContextRequired: true },
ffpkgConversion: { available: false, reason: 'Windows worker required.' },
ffpfscConversion: { available: true, mode: 'native-worker' },
exfatConversion: { available: false, reason: 'Windows worker required.' },
arbitrarySqlWrite: { available: false, reason: 'SQL Explorer is read-only in Docker.' },
shellAccess: { available: false, reason: 'General shell access is intentionally disabled.' },
localGameLibraries: { available: true, roots: ['/games/ps4', '/games/ps5'], readOnly: true },
elfArsenal: { available: true, requiresPayload: true, port: 6969 },
payloadManager: { available: true, requiresPayload: true, port: 8084 },
cheats: { available: true, mode: 'server-cache-and-ftp' },
localSaveManager: { available: true, requiresPayload: true, port: 8082 },
media: { available: true, mode: 'ftp-cache' },
localBackups: { available: true, roots: ['/games/ps4', '/games/ps5', '/game-backups'] },
backporkGenerator: { available: true, mode: 'native-worker', pupUnpacker: '/usr/local/bin/ps5-pup-unpacker' },
bdjbGenerator: { available: true, mode: 'bundled-linux-sdk' },
y2jbGenerator: { available: true, mode: 'server-download' },
ecosystemHub: { available: true, mode: 'official-upstream-connectors' },
compatibilityEngine: { available: true, automaticExecution: false },
consoleHealth: { available: true, scheduled: true, notifications: 'optional-discord' },
safeUpdatePlanner: { available: true, dependencyOrder: true, automaticExecution: false },
payloadManagerRepository: { available: true, path: '/payloads.json', cors: true, checksums: true },
websrvDiscovery: { available: true, endpoint: '/mdns' },
homebrewHub: { available: true, requiresPayload: true, backend: 'ps5-payload-dev/websrv', port: 8080 },
remoteSources: { available: true, protocols: ['smb', 'nfs', 'sftp', 'ftp', 'webdav', 'http', 'https'], credentialsEncrypted: true },
etaHenProfiles: { available: true, snapshots: true, uploadVerification: true },
etaHenPlugins: { available: true, usbPriorityAware: true },
cheatsV2: { available: true, sources: ['etaHEN/PS5_Cheats', 'GoldHEN/GoldHEN_Cheat_Repository', 'illusionyy/PS-Game-Patch'] },
saveVault: { available: true, backend: 'earthonion/garlic-savemgr', port: 8082 },
consoleTimeline: { available: true, ports: [9081, 3232], expertShellProxy: false },
gameUpdateCenter: { available: true, officialManifestHostsOnly: true, pieceAndPackageHashes: true, automaticInstall: false },
payloadSupplyChain: { available: true, localRehash: true, officialSources: true, automaticExecution: false },
liveSession: { available: true, backend: 'ps5debug-NG', readOnlyCommands: 5, memoryAccess: false, kernelAccess: false },
websrvFileExplorer: { available: true, readOnly: true, fixedRoots: true, smbProfilesOnly: true },
remotePlayWake: { available: true, backend: 'chiaki-ng-compatible', registrationKeyEncrypted: true },
remotePlay: { available: true, mode: 'paired-native-client-worker', discovery: true, wake: true, sessionOrchestration: true, embeddedVideo: false, hmacWorker: true },
itemzflowJobs: { available: true, mode: 'read-only-log-monitor', undocumentedCommands: false },
backupReplication: { available: true, target: '/replication', sourceDeletion: false, sha256Verification: true, scheduledOptIn: true },
prometheusMetrics: { available: true, path: '/metrics', bearerTokenRequired: true },
sessionManagement: { available: true, revoke: true, passwordRotation: true },
pacbrewCatalog: { available: true, mode: 'read-only-recipes', install: false },
ps5LinuxLab: { available: true, mode: 'read-only-preflight', payloadSend: false, blockDeviceWrite: false },
auditCenter: { available: true, filters: true, boundedRetention: true, authenticatedExport: true },
recoveryVault: { available: true, schema: 2, sha256: true, externalImport: true, automaticRestore: false },
shadowMountPreflight: { available: true, invalidConfigBlocked: true, criticalConfirmation: true },
passiveKlog: { available: true, port: 3232, zeroWrite: true, payloadLaunch: false, explicitStart: true },
},
};
function backupDatabase() {
const chimeraSource = path.join(paths.database, 'chimera.db');
const legacySource = path.join(paths.database, 'porkfolio.db');
const source = fs.existsSync(chimeraSource) ? chimeraSource : legacySource;
if (!fs.existsSync(source)) return;
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
fs.copyFileSync(source, path.join(paths.backups, `chimera-${stamp}.db`));
const retention = Math.max(1, Number(settings.get('backup.retention', 14)) || 14);
const backups = fs.readdirSync(paths.backups).filter(name => /^chimera-.*\.db$/.test(name)).sort().reverse();
for (const stale of backups.slice(retention)) fs.unlinkSync(path.join(paths.backups, stale));
}
let appServer;
let registryInstance;
let shuttingDown = false;
function portReachable(host, port, timeoutMs = 1_500) {
return new Promise(resolve => {
const socket = net.createConnection({ host, port });
let settled = false;
const finish = reachable => {
if (settled) return;
settled = true;
socket.destroy();
resolve(reachable);
};
socket.setTimeout(timeoutMs);
socket.once('connect', () => finish(true));
socket.once('timeout', () => finish(false));
socket.once('error', () => finish(false));
});
}
async function autoConnectFtp() {
if (!settings.get('autoConnect', true)) {
logger.info('[FTP] Automatisch verbinden is uitgeschakeld');
return { connected: false, reason: 'disabled' };
}
const host = String(settings.get('ftp.host', '') || '').trim();
if (!host) {
logger.warn('[FTP] Automatisch verbinden overgeslagen: PS5-host ontbreekt');
return { connected: false, reason: 'host-missing' };
}
const configured = settings.get('ftp.ports', ['1337', '2121', '21']);
const preferred = settings.get('ftp.port', '1337');
const ports = [...new Set([preferred, ...(Array.isArray(configured) ? configured : [configured])]
.map(Number)
.filter(port => Number.isInteger(port) && port >= 1 && port <= 65535))];
const errors = [];
const reachability = new Map(await Promise.all(ports.map(async port => [port, await portReachable(host, port)])));
for (const port of ports) {
if (!reachability.get(port)) {
errors.push({ port, error: 'TCP-poort niet bereikbaar' });
continue;
}
try {
await ftp.connect({
host,
port,
user: String(settings.get('ftp.user', '') || ''),
password: String(secrets.get('ftp.password') || ''),
});
settings.set('ftp.port', String(port));
logger.info('[FTP] PS5 automatisch verbonden', { host, port });
return { connected: true, host, port };
} catch (error) {
errors.push({ port, error: error.message });
}
}
logger.warn('[FTP] Automatisch verbinden met de PS5 mislukt', { host, attempts: errors });
return { connected: false, reason: 'unreachable', host, attempts: errors };
}
async function shutdown(signal) {
if (shuttingDown) return;
shuttingDown = true;
logger.info('Graceful shutdown gestart', { signal });
try { registryInstance?.maintenance?.stopOperations?.(); } catch (_) {}
try { await registryInstance?.ps5P15A10Runtime?.stopEngine?.(); } catch (error) { logger.warn('[PS5Upload] diagnostische engine stoppen mislukt', { error: error.message }); }
try { await registryInstance?.ps5ProfileRuntime?.stopEngine?.(); } catch (error) { logger.warn('[PS5Upload] productie-engine stoppen mislukt', { error: error.message }); }
try { await ftp.disconnect(); } catch (_) {}
try { db.close(); } catch (error) { logger.error('Database sluiten mislukt', error); }
try { if (appServer) await appServer.close(); } catch (error) { logger.error('HTTP-server sluiten mislukt', error); }
logger.info('Graceful shutdown voltooid');
}
async function main() {
backupDatabase();
db.configure({ dataDir: paths.database, logger });
await db.initialize();
const registry = createRegistry({ db, ftp, settings, secrets, paths, logger, capabilities, appRoot, broadcast: (channel, ...args) => appServer?.broadcast(channel, ...args) });
registryInstance = registry;
appServer = createAppServer({ appRoot, paths, settings, secrets, registry, logger, ftp, capabilities, version: pkg.version });
const port = Number(process.env.PORT || 6967);
const host = process.env.HOST || '0.0.0.0';
await appServer.listen(port, host);
logger.info('Chimera server gestart', { host, port, dataRoot: paths.root, version: pkg.version, build: readBuildInfo() });
try { await registry.ps5ProfileRuntime.startEngine(); }
catch (error) { logger.warn('[PS5Upload] engine autostart geblokkeerd', { code: error.code, error: error.message }); }
registry.maintenance.seedPayloadSources();
if (settings.get('admin.passwordHash', '')) {
await autoConnectFtp();
registry.maintenance.startOperations();
} else {
logger.info('[Operations] Taakplanner wacht tot de eerste configuratie is voltooid');
}
}
process.on('SIGTERM', () => shutdown('SIGTERM').finally(() => process.exit(0)));
process.on('SIGINT', () => shutdown('SIGINT').finally(() => process.exit(0)));
process.on('uncaughtException', error => { logger.error('Uncaught exception', error); shutdown('uncaughtException').finally(() => process.exit(1)); });
process.on('unhandledRejection', error => logger.error('Unhandled rejection', error));
if (require.main === module) main().catch(error => { logger.error('Serverstart mislukt', error); process.exitCode = 1; });
module.exports = { main, shutdown, capabilities, autoConnectFtp, portReachable };