diff --git a/index.js b/index.js index f005de4..265215f 100644 --- a/index.js +++ b/index.js @@ -496,7 +496,7 @@ function appendBonusAdminLog(actorId, action, details = '') { fs.mkdirSync(path.dirname(bonusAdminLogFile), { recursive: true }); const line = `[${new Date().toISOString()}] actor=${actorId || 'system'} action=${action}${details ? ` details=${details}` : ''} `; - fs.appendFileSync(bonusAdminLogFile, line, 'utf8'); + fs.appendFileSync(bonusAdminLogFile, line, { encoding: 'utf8', mode: 0o640 }); } catch (_) { /* ignore log write errors */ } } const backupDir = path.join(dataDir, 'backups'); @@ -4538,7 +4538,7 @@ function adminLog(event, payload) { // Also append to persistent NDJSON log file (best-effort) try { ensureDataDir(); - fs.appendFileSync(adminEventsLogFile, JSON.stringify(entry) + '\n'); + fs.appendFileSync(adminEventsLogFile, JSON.stringify(entry) + '\n', { mode: 0o640 }); } catch (_) { /* non-fatal — in-memory log is the source of truth */ } } @@ -4800,7 +4800,7 @@ function writeFileAtomic(filePath, content) { const dirPath = path.dirname(safeFilePath); if (!fs.existsSync(dirPath)) fs.mkdirSync(dirPath, { recursive: true }); const tempFile = `${safeFilePath}.tmp.${process.pid}.${Date.now()}`; - fs.writeFileSync(tempFile, content); + fs.writeFileSync(tempFile, content, { mode: 0o600 }); fs.renameSync(tempFile, safeFilePath); } @@ -4829,16 +4829,25 @@ function saveJson(filePath, data) { writeFileAtomic(filePath, JSON.stringify(data, null, 2)); } -// Expected error codes when the service user cannot write to qa/ (created as root). -const QA_FS_SUPPRESS = new Set(['EACCES', 'EPERM', 'EROFS', 'EEXIST']); +/** + * QA_FS_SUPPRESS — single authoritative set of errno codes that are silently + * swallowed across ALL QA filesystem operations (ensureQaDirs, ensureQaArtifacts, + * persistQaProviderState). Add codes here once and every helper inherits the + * suppression automatically. + * + * EEXIST is intentionally excluded: mkdirSync({ recursive: true }) never throws + * EEXIST for an existing directory, so seeing it here means a *file* sits where a + * directory is expected — a misconfiguration we want surfaced, not silenced. + */ +const QA_FS_SUPPRESS = new Set(['EACCES', 'EPERM', 'EROFS']); function ensureQaDirs() { for (const dir of [qaContextDir, qaStateDir, qaLogsDir]) { try { fs.mkdirSync(dir, { recursive: true }); } catch (e) { - if (!QA_FS_SUPPRESS.has(e.code)) { - logEvent('warn', 'ensureQaDirs: unexpected error creating QA directory', { dir, code: e.code, error: e.message }); + if (!QA_FS_SUPPRESS.has(e?.code)) { + logEvent('warn', 'ensureQaDirs: unexpected error creating QA directory', { dir, code: e?.code, error: e?.message ?? String(e), ...(process.env.LOG_LEVEL === 'debug' && { stack: e?.stack }) }); } } } @@ -4854,7 +4863,7 @@ function getQaLogDirForToday() { function writeQaLog(fileName, payload) { try { const line = `${JSON.stringify({ ts: new Date().toISOString(), ...payload })}\n`; - fs.appendFileSync(path.join(getQaLogDirForToday(), fileName), line, 'utf8'); + fs.appendFileSync(path.join(getQaLogDirForToday(), fileName), line, { encoding: 'utf8', mode: 0o640 }); } catch (_) { /* ignore QA log write errors */ } } @@ -4893,14 +4902,14 @@ function generateQaCapabilities() { function persistQaProviderState() { try { ensureQaDirs(); - fs.writeFileSync(qaProviderStateFile, JSON.stringify(qaRuntime.providerStatus, null, 2)); + fs.writeFileSync(qaProviderStateFile, JSON.stringify(qaRuntime.providerStatus, null, 2), { mode: 0o600 }); } catch (e) { - if (QA_FS_SUPPRESS.has(e.code)) { + if (QA_FS_SUPPRESS.has(e?.code)) { // Expected: service user lacks write permission on qa/ (created as root). Non-fatal. return; } // Unexpected (e.g. JSON serialization failure, bad path) — log so it stays visible. - logEvent('warn', 'persistQaProviderState: unexpected error', { code: e.code, error: e.message }); + logEvent('warn', 'persistQaProviderState: unexpected error', { code: e?.code, error: e?.message ?? String(e), ...(process.env.LOG_LEVEL === 'debug' && { stack: e?.stack }) }); } } @@ -4916,32 +4925,38 @@ function refreshQaProviderCooldowns() { persistQaProviderState(); } -function ensureQaArtifacts() { - // QA artifacts are observability helpers — never fatal. - // The service user may lack write access to qa/ when the directory was - // created as root; that is expected and silently skipped. - // Other errors (bad path, JSON serialization failure) are logged as warnings - // so they remain visible without crashing the bot. +/** + * tryWriteQaJson — write a single QA artifact file, applying QA_FS_SUPPRESS + * suppression semantics. Shared by ensureQaArtifacts so each artifact write + * is guarded independently: one serialization failure cannot block the rest. + */ +function tryWriteQaJson(filePath, payload, label) { try { - ensureQaDirs(); - fs.writeFileSync(qaRepoInfoFile, JSON.stringify({ - repoPath: '/var/www/html/Runewager', - branch: process.env.GIT_BRANCH || 'main', - systemdService: 'runewager.service', - entryFile: 'index.js', - telegramDefault: true, - }, null, 2)); - fs.writeFileSync(qaCapabilitiesFile, JSON.stringify(generateQaCapabilities(), null, 2)); - persistQaProviderState(); + fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), { mode: 0o600 }); } catch (e) { - if (!QA_FS_SUPPRESS.has(e.code)) { - logEvent('warn', 'ensureQaArtifacts: unexpected error writing QA artifacts', { code: e.code, error: e.message }); + if (!QA_FS_SUPPRESS.has(e?.code)) { + logEvent('warn', `${label}: unexpected error`, { code: e?.code, error: e?.message ?? String(e), ...(process.env.LOG_LEVEL === 'debug' && { stack: e?.stack }) }); } // Permission errors (EACCES/EPERM/EROFS) are intentionally silent — // the bot runs fine without QA artifacts. } } +function ensureQaArtifacts() { + // QA artifacts are observability helpers — never fatal. + // Each write is guarded independently so one failure does not block others. + ensureQaDirs(); + tryWriteQaJson(qaRepoInfoFile, { + repoPath: '/var/www/html/Runewager', + branch: process.env.GIT_BRANCH || 'main', + systemdService: 'runewager.service', + entryFile: 'index.js', + telegramDefault: true, + }, 'ensureQaArtifacts: repo_info'); + tryWriteQaJson(qaCapabilitiesFile, generateQaCapabilities(), 'ensureQaArtifacts: capabilities'); + persistQaProviderState(); +} + setInterval(refreshQaProviderCooldowns, qaRuntime.providerStatus.resetIntervalMs).unref(); /** @@ -9270,7 +9285,7 @@ bot.action('sshv_editor_save', async (ctx) => { return; } try { - fs.writeFileSync(session.editorMode.filePath, draft, 'utf8'); + fs.writeFileSync(session.editorMode.filePath, draft, { encoding: 'utf8', mode: 0o600 }); adminLog('sshv_editor_save', { adminId: user.id, filePath: session.editorMode.filePath, size: draft.length }); session.buffer = `Saved ${session.editorMode.filePath}`; session.editorMode = null; diff --git a/runewager.service b/runewager.service index 9d53522..f03339b 100644 --- a/runewager.service +++ b/runewager.service @@ -51,28 +51,38 @@ StandardError=append:/var/www/html/Runewager/logs/runewager-error.log # (strict would make ALL paths read-only, requiring explicit ReadWritePaths # for every directory Node touches — too brittle for a Node.js app.) # -# ProtectHome=false — the service runs as root, whose home is /root. -# Node.js and npm use /root for caches and internal temp files; blocking -# /root causes subtle startup failures under strict sandboxing. +# ProtectHome=true — blocks blanket /root access; only the specific npm/ +# cache paths listed in ReadWritePaths below are permitted. This prevents +# the process from writing to the rest of /root while still allowing Node.js +# and npm to reach their cache directories. # # PrivateTmp=true — the process gets its own /tmp namespace. # # NoNewPrivileges=true — process and children cannot gain new privileges. # -# UMask=0022 — created files are world-readable (644) and -# directories are world-executable (755). Required so the log files written -# by root are readable by non-root log-tailing utilities. +# UMask=0077 — files default to mode 600 (owner rw only), directories +# to 700. All write calls in index.js pass an explicit mode where broader +# access is intentional: +# 0o640 — append-only log files (adminEventsLogFile, bonusAdminLogFile, +# writeQaLog daily logs) — readable by group log-tailing utilities +# 0o600 — sensitive data files (runtime-state.json via writeFileAtomic, +# qaProviderStateFile, QA artifact files, editor session drafts) +# Note: StandardOutput/StandardError log files (runewager.log, +# runewager-error.log) are opened by systemd, not the process; their +# permissions are set by systemd and are unaffected by this UMask. Grant +# log-reader access via group membership: usermod -aG root , or +# use a dedicated log group with setfacl on the logs/ directory. # ProtectSystem=full -ProtectHome=false +ProtectHome=true PrivateTmp=true NoNewPrivileges=true -UMask=0022 +UMask=0077 -# Explicit write grants under /var (belt-and-suspenders — /var is already -# writable under ProtectSystem=full, but listing these makes intent clear -# and ensures compatibility if ProtectSystem is ever tightened again). -ReadWritePaths=/var/www/html/Runewager/logs /var/www/html/Runewager/data /var/www/html/Runewager/qa +# Explicit write grants: /var paths for bot data, logs, and QA artifacts; +# /root/.npm and /root/.cache for Node.js/npm tooling caches (required with +# ProtectHome=true since /root itself is now restricted). +ReadWritePaths=/var/www/html/Runewager/logs /var/www/html/Runewager/data /var/www/html/Runewager/qa /root/.npm /root/.cache # File descriptor limit (for concurrent connections) LimitNOFILE=65536