193 lines
11 KiB
JavaScript
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 };
|