Skip to content
75 changes: 45 additions & 30 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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 */ }
}

Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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 }) });
}
}
}
Expand All @@ -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 */ }
}

Expand Down Expand Up @@ -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 }) });
}
}

Expand All @@ -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();

/**
Expand Down Expand Up @@ -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;
Expand Down
34 changes: 22 additions & 12 deletions runewager.service
Original file line number Diff line number Diff line change
Expand Up @@ -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 <loguser>, 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
Expand Down