Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 0 additions & 7 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,3 @@ build/
Thumbs.db
.last_rollback
data/admin-events.log
data/*.json
data/backups/*
users.json
giveaways.json
promo.json
env.json
analytics*.json
11 changes: 1 addition & 10 deletions deploy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -189,17 +189,8 @@ NPM_CMD="npm install --omit=dev"
NPM_OUT=""
if NPM_OUT=$(${NPM_CMD} 2>&1); then
say "Dependencies installed."
mkdir -p "$PROJECT_DIR/data" "$PROJECT_DIR/data/backups" "$PROJECT_DIR/logs"
mkdir -p "$PROJECT_DIR/data" "$PROJECT_DIR/logs"
touch "$PROJECT_DIR/data/sshv-sessions.json" "$PROJECT_DIR/data/admin-events.log"

if id -u "$APP_NAME" >/dev/null 2>&1; then
chown -R "$APP_NAME:$APP_NAME" "$PROJECT_DIR/data" "$PROJECT_DIR/logs" || true
[[ -f "$PROJECT_DIR/.env" ]] && chown "$APP_NAME:$APP_NAME" "$PROJECT_DIR/.env" || true
fi

chmod 0750 "$PROJECT_DIR/data" "$PROJECT_DIR/data/backups" "$PROJECT_DIR/logs" || true
chmod 0640 "$PROJECT_DIR/data/sshv-sessions.json" "$PROJECT_DIR/data/admin-events.log" || true
[[ -f "$PROJECT_DIR/.env" ]] && chmod 0600 "$PROJECT_DIR/.env" || true
else
warn "npm install failed — restarting bot on existing node_modules"
warn "npm output: $NPM_OUT"
Expand Down
163 changes: 42 additions & 121 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -926,60 +926,6 @@ async function sendCommandError(ctx, { command, reason, howToUse, example }) {
}



function resolveTlsCertPathIfAllowed(inputPath) {
const allowedDirs = [
PROJECT_DIR,
path.join(PROJECT_DIR, 'certs'),
'/etc/ssl',
'/etc/letsencrypt',
];
for (const baseDir of allowedDirs) {
try {
return validateSafePath(inputPath, baseDir);
} catch (_) {
// try next allowed directory
}
}
throw new Error('TLS path is outside allowed directories');
}

function isHealthHttpsConfigured() {
const tlsKeyPath = process.env.HTTPS_KEY_PATH;
const tlsCertPath = process.env.HTTPS_CERT_PATH;
if (!tlsKeyPath || !tlsCertPath) return false;
try {
resolveTlsCertPathIfAllowed(tlsKeyPath);
resolveTlsCertPathIfAllowed(tlsCertPath);
return true;
} catch (_) {
return false;
}
}

function requestHealthPayload() {
const port = Number(process.env.PORT || 3000);
const useHttps = isHealthHttpsConfigured();
const client = useHttps ? require('https') : require('http');
return new Promise((resolve, reject) => {
const req = client.request({
hostname: '127.0.0.1',
port,
path: '/health',
method: 'GET',
timeout: 5000,
...(useHttps ? { rejectUnauthorized: false } : {}),
}, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => resolve({ data, statusCode: res.statusCode, protocol: useHttps ? 'https' : 'http' }));
});
req.on('error', reject);
req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
req.end();
});
}

function normalizeSshvBufferLines(bufferValue) {
if (Array.isArray(bufferValue)) {
return bufferValue.map((line) => escapeMarkdownFull(String(line))).slice(-SSHV_MAX_BUFFER_LINES);
Expand Down Expand Up @@ -1256,25 +1202,17 @@ function commandNeedsConfirmation(commandText) {
const text = String(commandText || '');
const patterns = [
/\b(rm|mv|dd|shred|mkfs|chown|chmod|sudo|shutdown|reboot|kill)\b/i,
/\b(curl|wget)\b[^\n]*\|[^\n]*\b(sh|bash|zsh)\b/i,
/\b(rm|dd|shred|mkfs)\b[^\n]*\|/i,
/(^|\s)>>?\s*\S+/i,
/(^|[^|])\|([^|]|$)/,
/[;`$(){}]/,
/\b(curl|wget)\b[^\n]*\|[^\n]*\bsh\b/i,
/>{1,2}/,
/[;&`$(){}<>]/,
/\|/,
];
return patterns.some((re) => re.test(text));
}

function commandBlocked(commandText) {
const text = String(commandText || '');
const blockedPatterns = [
/(^|\s)&(\s|$)/,
/&&/,
/\|\|/,
/\bnohup\b/i,
/\bbg\b/i,
/\b(curl|wget)\b[^\n]*\|[^\n]*\b(sh|bash|zsh)\b/i,
];
const blockedPatterns = [/(^|\s)&(\s|$)/, /&&/, /\|\|/, /\bnohup\b/i, /\bbg\b/i];
return blockedPatterns.some((re) => re.test(text));
}

Expand Down Expand Up @@ -3506,36 +3444,6 @@ bot.command('sshv', async (ctx) => {
await renderSshvConsole(ctx, session, note);
});

bot.action('admin_cmd_announce_start', async (ctx) => {
if (!requireAdmin(ctx)) return;
const user = getUser(ctx);
clearPendingAction(user);
user.pendingAction = { type: 'await_announcement_text' };
await ctx.answerCbQuery('Send announcement text');
await replaceCallbackPanel(ctx, '📣 *Announcement Composer*\n\nSend the announcement text now. You can use normal text or HTML.', {
parse_mode: 'Markdown',
...Markup.inlineKeyboard([[Markup.button.callback('❌ Cancel', 'open_admin_dashboard')]]),
});
});

bot.action('admin_cmd_tips_dashboard', async (ctx) => {
if (!requireAdmin(ctx)) return;
await ctx.answerCbQuery();
await sendTipsDashboard(ctx);
});

bot.action('admin_cmd_tiptest', async (ctx) => {
if (!requireAdmin(ctx)) return;
await ctx.answerCbQuery();
const enabled = tipsStore.tips.filter((t) => t.enabled);
if (!enabled.length) {
await ctx.reply('No enabled tips to preview.');
return;
}
const tip = enabled[Math.floor(Math.random() * enabled.length)];
await ctx.reply(`Here is a random 4-hour tip preview:\n\n${tip.text}`, { parse_mode: 'Markdown' });
});

// =========================
// Admin Announce Command — /A /a /announce
// State machine:
Expand Down Expand Up @@ -3897,8 +3805,18 @@ bot.command('refreshuser', safeAdminHandler('refreshuser', { usage: '/refreshuse
bot.command('health', async (ctx) => {
if (!requireAdmin(ctx)) return;
try {
const { data, protocol } = await requestHealthPayload();
await ctx.reply(`🩺 *Health Endpoint (${protocol.toUpperCase()})*\n\n\`\`\`\n${JSON.stringify(JSON.parse(data), null, 2)}\n\`\`\``, { parse_mode: 'Markdown' });
const { data } = await new Promise((resolve, reject) => {
const opts = { hostname: '127.0.0.1', port, path: '/health', method: 'GET', timeout: 5000, rejectUnauthorized: false };
const req = require('https').request(opts, (res) => {
Comment on lines +3809 to +3810

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Health probes are hardwired to HTTPS and break when server falls back to HTTP.

The health server explicitly supports HTTP-only fallback, but both probe callsites always use an HTTPS client. In HTTP-only mode this fails even when /health is healthy.

💡 Suggested fix
- const req = require('https').request(opts, (res) => {
+ const requestHealth = (client) => new Promise((resolve, reject) => {
+   const req = client.request(opts, (res) => {
+     let body = '';
+     res.on('data', (c) => { body += c; });
+     res.on('end', () => resolve({ status: res.statusCode, data: body }));
+   });
+   req.on('error', reject);
+   req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
+   req.end();
+ });
+
+ let data;
+ try {
+   ({ data } = await requestHealth(require('https')));
+ } catch (_) {
+   ({ data } = await requestHealth(require('http')));
+ }

Also applies to: 4082-4085

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@index.js` around lines 3366 - 3367, The health probe is always using the
HTTPS client (require('https').request) even when the server is running in
HTTP-only mode; update the probe to pick the HTTP module dynamically and to only
set TLS-specific options when TLS is enabled: detect the server TLS mode (the
same flag/config used to choose the server fallback), choose const client =
isTls ? require('https') : require('http'), call client.request(opts, ...), and
remove or conditionally add rejectUnauthorized (and any other TLS-only options)
from opts when isTls is false so /health works in HTTP-only mode; update both
occurrences that create opts/req (including the one around req =
require('https').request and the other similar block at the other location).

let body = '';
res.on('data', (c) => { body += c; });
res.on('end', () => resolve({ status: res.statusCode, data: body }));
});
req.on('error', reject);
req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
req.end();
});
await ctx.reply(`🩺 *Health Endpoint*\n\n\`\`\`\n${JSON.stringify(JSON.parse(data), null, 2)}\n\`\`\``, { parse_mode: 'Markdown' });
} catch (e) {
await ctx.reply(`❌ Health check failed: ${e.message}`);
}
Expand Down Expand Up @@ -4621,8 +4539,18 @@ bot.action('pamenu_tools_health', async (ctx) => {
await ctx.answerCbQuery('Running health check...');
if (!requireAdmin(ctx)) return;
try {
const { data, protocol } = await requestHealthPayload();
await ctx.reply(`🩺 Health Check (${protocol.toUpperCase()}):\n\`\`\`\n${data.slice(0, 800)}\n\`\`\``, { parse_mode: 'Markdown' });
const httpsClient = require('https');
const port = Number(process.env.PORT || 3000);
const result = await new Promise((resolve, reject) => {
const req = httpsClient.get({ hostname: '127.0.0.1', port, path: '/health', rejectUnauthorized: false }, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => resolve(data));
});
req.on('error', reject);
req.setTimeout(5000, () => { req.destroy(); reject(new Error('timeout')); });
});
await ctx.reply(`🩺 Health Check:\n\`\`\`\n${result.slice(0, 800)}\n\`\`\``, { parse_mode: 'Markdown' });
} catch (e) {
await ctx.reply(`❌ Health check failed: ${e.message}`);
}
Expand Down Expand Up @@ -5714,20 +5642,14 @@ bot.action('sshv_editor_save', async (ctx) => {
await ctx.answerCbQuery('Send edited file content first.');
return;
}
try {
fs.writeFileSync(session.editorMode.filePath, draft, 'utf8');
adminLog('sshv_editor_save', { adminId: user.id, filePath: session.editorMode.filePath, size: draft.length });
session.buffer = `Saved ${session.editorMode.filePath}`;
session.editorMode = null;
user.pendingAction = { type: 'await_sshv_command' };
persistSshvSessions();
await ctx.answerCbQuery('Saved');
await renderSshvConsole(ctx, session);
} catch (e) {
adminLog('sshv_editor_save_failed', { error: e.message, filePath: session.editorMode.filePath, by: user.id });
await ctx.answerCbQuery('Save failed');
await ctx.reply(`Save failed: ${e.message}`);
}
fs.writeFileSync(session.editorMode.filePath, draft, 'utf8');
adminLog('sshv_editor_save', { adminId: user.id, filePath: session.editorMode.filePath, size: draft.length });
session.buffer = `Saved ${session.editorMode.filePath}`;
session.editorMode = null;
user.pendingAction = { type: 'await_sshv_command' };
persistSshvSessions();
await ctx.answerCbQuery('Saved');
await renderSshvConsole(ctx, session);
});

bot.action('sshv_editor_cancel', async (ctx) => {
Expand Down Expand Up @@ -7317,8 +7239,8 @@ function startTipsScheduler() {
logEvent('warn', 'Skipping helpful message: missing permission in target group', { target: tipsStore.targetGroup });
return;
}
await bot.telegram.sendMessage(tipsStore.targetGroup, formatTipForHtml(tip.text), {
parse_mode: 'HTML',
await bot.telegram.sendMessage(tipsStore.targetGroup, tip.text, {
parse_mode: 'Markdown',
disable_notification: true,
});
tipsStore.lastSentTipId = tip.id;
Expand Down Expand Up @@ -8944,8 +8866,8 @@ function startHealthServer() {
let cert = null;
if (tlsKeyPath && tlsCertPath) {
try {
key = fs.readFileSync(resolveTlsCertPathIfAllowed(tlsKeyPath));
cert = fs.readFileSync(resolveTlsCertPathIfAllowed(tlsCertPath));
key = fs.readFileSync(validateSafePath(tlsKeyPath, PROJECT_DIR));
cert = fs.readFileSync(validateSafePath(tlsCertPath, PROJECT_DIR));
} catch (e) {
_startupWarnings.push(`⚠️ Startup Warning
TLS cert/key read failed: ${e.message}
Expand Down Expand Up @@ -9697,15 +9619,14 @@ async function startBot() {
}

// Cleanup expired /sshv sessions every minute to keep memory usage bounded.
const sshvGcTimer = setInterval(() => {
setInterval(() => {
const now = Date.now();
for (const [adminId, session] of sshvSessions.entries()) {
if (now - (session.lastActivity || session.createdAt || 0) > SSHV_SESSION_TTL_MS) {
destroySshvSession(adminId);
}
}
}, 60 * 1000);
Comment on lines +9621 to +9629

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Module-scope SSHV GC interval should not keep load-only processes alive.

This timer is outside the runtime guard and can keep the event loop active during smoke/load checks. Make it non-blocking (or move under runtime startup).

💡 Suggested fix
-setInterval(() => {
+const sshvGcTimer = setInterval(() => {
   const now = Date.now();
   for (const [adminId, session] of sshvSessions.entries()) {
     if (now - (session.lastActivity || session.createdAt || 0) > SSHV_SESSION_TTL_MS) {
       destroySshvSession(adminId);
     }
   }
 }, 60 * 1000);
+sshvGcTimer.unref();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@index.js` around lines 8816 - 8824, The module-level setInterval creating the
SSHV GC keeps the Node event loop alive; change its creation so it doesn't block
process exit by either moving the interval into the runtime startup/init code
path (so it only runs when the service starts) or call .unref() on the interval
timer returned from setInterval. Locate the setInterval that iterates
sshvSessions and uses SSHV_SESSION_TTL_MS and destroySshvSession, capture its
return value (const timer = setInterval(...)) and invoke timer.unref() (or
relocate the entire block into the runtime guard/startup function) so load-only
or smoke-check processes can exit normally.

if (typeof sshvGcTimer.unref === 'function') sshvGcTimer.unref();

// Fallback for unmatched callback_data: always acknowledge and provide recovery path.
bot.action(/.*/, async (ctx) => {
Expand Down
6 changes: 0 additions & 6 deletions prod-run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -199,12 +199,6 @@ mkdir -p "$PROJECT_DIR/data"
mkdir -p "$PROJECT_DIR/data/backups"
touch "$MAIN_LOG" "$ERROR_LOG" "$ADMIN_EVENTS_LOG" "$SSHV_SESSIONS_FILE" # create if missing; safe on existing files

if id -u "$SERVICE_USER" >/dev/null 2>&1; then
chown -R "$SERVICE_USER:$SERVICE_GROUP" "$LOG_DIR" "$PROJECT_DIR/data"
fi
chmod 0750 "$PROJECT_DIR/data" "$PROJECT_DIR/data/backups" "$LOG_DIR"
chmod 0640 "$MAIN_LOG" "$ERROR_LOG" "$ADMIN_EVENTS_LOG" "$SSHV_SESSIONS_FILE"

say "Ensured runtime directories and log files exist"

# ---------------------------------------------------------
Expand Down
1 change: 0 additions & 1 deletion runewager.service
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@ ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
NoNewPrivileges=true
UMask=0077

# File descriptor limit (for concurrent connections)
LimitNOFILE=65536
Expand Down
Loading