Spaces:
Paused
Paused
| 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}`); | |
| }); | |