const http = require('http'); const express = require('express'); const path = require('path'); const fs = require('fs'); const { WebSocketServer } = require('ws'); const { spawn } = require('child_process'); const pty = require('node-pty'); const app = express(); const server = http.createServer(app); const PORT = process.env.PORT || 7860; const TERMINAL_TOKEN = process.env.TERMINAL_TOKEN || ""; // set in env for security const JOB_WEBHOOK_TOKEN = process.env.JOB_WEBHOOK_TOKEN || ""; // bearer token for webhook auth const SLACK_BOT_TOKEN = process.env.SLACK_BOT_TOKEN || ""; // xoxb- token from Slack app const SLACK_CHANNEL_ID = process.env.SLACK_CHANNEL_ID || ""; // channel ID to post heartbeat const HEARTBEAT_LOG_PATH = process.env.HEARTBEAT_LOG_PATH || '/tmp/heartbeat.log'; // If behind a reverse proxy (nginx/traefik), trust X-Forwarded-* for req.ip app.set('trust proxy', true); // Serve static client app.use(express.static(path.join(__dirname, 'public'))); // JSON parser for webhook app.use(express.json()); // Basic liveness endpoint app.get('/health', (_req, res) => res.status(200).send('ok')); // Minimal webhook to send a heartbeat message to Slack app.post('/tasks/heartbeat', async (req, res) => { try { const authHeader = req.headers['authorization'] || ''; const bearer = authHeader.startsWith('Bearer ') ? authHeader.slice('Bearer '.length) : (req.query.token || ''); // Enforce only when JOB_WEBHOOK_TOKEN is set if (JOB_WEBHOOK_TOKEN && bearer !== JOB_WEBHOOK_TOKEN) { return res.sendStatus(401); } if (!SLACK_BOT_TOKEN || !SLACK_CHANNEL_ID) { return res.status(500).json({ error: 'Slack not configured' }); } const now = new Date().toISOString(); const host = req.headers.host || 'unknown-host'; const text = `Heartbeat: VPS web terminal alive at ${now} (host: ${host})`; const resp = await fetch('https://slack.com/api/chat.postMessage', { method: 'POST', headers: { 'Authorization': `Bearer ${SLACK_BOT_TOKEN}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ channel: SLACK_CHANNEL_ID, text }) }); const data = await resp.json().catch(() => ({})); const ip = req.ip; const ua = req.headers['user-agent'] || ''; const logLine = `${now}\tip=${ip}\tua=${JSON.stringify(ua)}\tstatus=${data && data.ok ? 'ok' : 'error'}\n`; try { const dir = path.dirname(HEARTBEAT_LOG_PATH); fs.mkdirSync(dir, { recursive: true }); fs.appendFileSync(HEARTBEAT_LOG_PATH, logLine, 'utf8'); } catch (_) { /* ignore log failures */ } // Kick off pCloud backup: ~/.claude -> /claude-vps/.claude and ~/.claude.json -> /claude-vps (non-blocking) try { const child = spawn('python', ['scripts/upload_claude_to_pcloud.py'], { cwd: __dirname, stdio: 'inherit', env: process.env, detached: false }); // no await; run in background } catch (_) { /* ignore spawn errors */ } if (!data.ok) { return res.status(502).json({ error: 'Slack API error', details: data }); } return res.status(202).json({ ok: true, ts: data.ts, channel: data.channel }); } catch (err) { return res.status(500).json({ error: 'server_error' }); } }); // WebSocket for terminal const wss = new WebSocketServer({ server, path: '/ws' }); wss.on('connection', (ws, req) => { try { // Simple shared-secret check via query string: /ws?token=... const url = new URL(req.url, `http://${req.headers.host}`); const token = url.searchParams.get('token') || ''; if (TERMINAL_TOKEN && token !== TERMINAL_TOKEN) { ws.close(1008, 'Invalid token'); // Policy Violation return; } const shell = process.env.SHELL || '/bin/bash'; const ptyProcess = pty.spawn(shell, ['-l'], { name: 'xterm-256color', cols: 80, rows: 24, cwd: process.env.HOME || process.cwd(), env: process.env }); ptyProcess.onData(data => { if (ws.readyState === ws.OPEN) { ws.send(JSON.stringify({ type: 'output', data })); } }); ws.on('message', (msg) => { try { const { type, data, cols, rows } = JSON.parse(msg.toString()); if (type === 'input' && typeof data === 'string') { ptyProcess.write(data); } else if (type === 'resize' && Number.isInteger(cols) && Number.isInteger(rows)) { ptyProcess.resize(cols, rows); } } catch (_) {/* ignore malformed */} }); ws.on('close', () => { try { ptyProcess.kill(); } catch (_) {} }); } catch (err) { try { ws.close(1011, 'Server error'); } catch (_) {} } }); server.listen(PORT, '0.0.0.0', () => { console.log(`Web terminal listening on :${PORT}`); });