Mega-Evolutions/Shellular
1
1import express from 'express';2import { spawn } from 'child_process';3import crypto from 'crypto';4import fs from 'fs';5import os from 'os';6import path from 'path';7import { fileURLToPath } from 'url';8 9const __dirname = path.dirname(fileURLToPath(import.meta.url));10 11const app = express();12const PORT = process.env.PORT || 3000;13const SECRET_KEY = process.env.SECRET_KEY;14 15if (!SECRET_KEY) {16 console.error('[ERROR] SECRET_KEY environment variable is not set. Exiting.');17 process.exit(1);18}19 20app.use(express.json());21app.use(express.static(path.join(__dirname, 'public')));22 23const sessions = new Set();24 25function stripAnsi(str) {26 return str.replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><~]/g, '');27}28 29function seedShellularConfig() {30 const hostId = process.env.SHELLULAR_HOST_ID;31 const keyB64 = process.env.SHELLULAR_KEY;32 const machineId = process.env.SHELLULAR_MACHINE_ID;33 34 if (!hostId || !keyB64 || !machineId) return;35 36 // Only seed if the saved SHELLULAR_MACHINE_ID matches the actual machine ID.37 // If they differ (e.g. new build generated a different machine-id), skip seeding38 // so shellular registers fresh rather than failing with "Machine ID mismatch".39 const actualMachineId = getHashedMachineId();40 if (actualMachineId && actualMachineId !== machineId) {41 console.warn(`[shellular] machine-id mismatch — skipping seed (set MACHINE_ID_RAW to fix)`);42 return;43 }44 45 const shellularDir = path.join(os.homedir(), '.shellular');46 const configFile = path.join(shellularDir, 'config.json');47 const keyFile = path.join(shellularDir, `shellular-${machineId}.e2ee`);48 49 try {50 fs.mkdirSync(shellularDir, { recursive: true });51 if (!fs.existsSync(configFile)) {52 fs.writeFileSync(configFile, JSON.stringify({ hostId, machineId }), 'utf-8');53 console.log(`[shellular] seeded config: hostId=${hostId}`);54 }55 if (!fs.existsSync(keyFile)) {56 fs.writeFileSync(keyFile, Buffer.from(keyB64, 'base64'), { mode: 0o600 });57 console.log(`[shellular] seeded key: ${keyFile}`);58 }59 } catch (err) {60 console.error('[shellular] failed to seed config:', err.message);61 }62}63 64function getHashedMachineId() {65 try {66 const raw = process.env.MACHINE_ID_RAW ||67 fs.readFileSync('/etc/machine-id', 'utf-8').trim();68 if (!raw) return null;69 return crypto.createHash('sha256').update(raw).digest('hex');70 } catch {71 return null;72 }73}74 75seedShellularConfig();76 77app.get('/api/shellular/machine-id', (_req, res) => {78 const id = getHashedMachineId();79 id ? res.json({ machineId: id }) : res.status(500).json({ error: 'Cannot read machine-id' });80});81 82app.get('/api/shellular/raw-machine-id', (_req, res) => {83 try {84 const raw = process.env.MACHINE_ID_RAW ||85 fs.readFileSync('/etc/machine-id', 'utf-8').trim();86 raw ? res.json({ raw }) : res.status(500).json({ error: 'Not available' });87 } catch {88 res.status(500).json({ error: 'Not available' });89 }90});91 92app.post('/api/shellular/seed-host', requireAuth, (req, res) => {93 const { hostId } = req.body || {};94 if (!hostId || typeof hostId !== 'string' || !hostId.trim()) {95 return res.status(400).json({ error: 'hostId is required' });96 }97 const machineId = getHashedMachineId();98 if (!machineId) return res.status(500).json({ error: 'Cannot read machine-id' });99 100 try {101 const shellularDir = path.join(os.homedir(), '.shellular');102 fs.mkdirSync(shellularDir, { recursive: true });103 fs.writeFileSync(104 path.join(shellularDir, 'config.json'),105 JSON.stringify({ hostId: hostId.trim(), machineId }, null, 2),106 'utf-8'107 );108 stopShellular();109 outputBuffer = '';110 broadcast({ type: 'clear' });111 setTimeout(startShellular, 600);112 res.json({ ok: true });113 } catch (err) {114 res.status(500).json({ error: err.message });115 }116});117 118app.post('/api/login', (req, res) => {119 const { key } = req.body;120 if (!key || key !== SECRET_KEY) {121 return res.status(401).json({ error: 'Invalid key' });122 }123 const token = crypto.randomUUID();124 sessions.add(token);125 res.json({ token });126});127 128app.post('/api/logout', (req, res) => {129 const token = req.headers.authorization?.split(' ')[1];130 sessions.delete(token);131 res.json({ ok: true });132});133 134function requireAuth(req, res, next) {135 const token = req.headers.authorization?.split(' ')[1];136 if (!token || !sessions.has(token)) {137 return res.status(401).json({ error: 'Unauthorized' });138 }139 next();140}141 142let shellularProc = null;143let outputBuffer = '';144let retryTimer = null;145const sseClients = new Set();146 147function send(res, payload) {148 res.write(`data: ${JSON.stringify(payload)}\n\n`);149}150 151function broadcast(payload) {152 const frame = `data: ${JSON.stringify(payload)}\n\n`;153 for (const client of sseClients) {154 client.write(frame);155 }156}157 158function startShellular() {159 if (shellularProc || retryTimer) return;160 161 broadcast({ type: 'status', status: 'starting' });162 163 shellularProc = spawn('npx', ['shellular', '--unknown-clients', 'always-allow'], {164 env: { ...process.env, FORCE_COLOR: '0' },165 stdio: ['ignore', 'pipe', 'pipe'],166 });167 168 let procOutput = '';169 170 const handleData = (chunk) => {171 const text = stripAnsi(chunk.toString());172 procOutput += text;173 outputBuffer += text;174 broadcast({ type: 'output', text });175 };176 177 shellularProc.stdout.on('data', handleData);178 shellularProc.stderr.on('data', handleData);179 180 shellularProc.on('error', (err) => {181 const text = `\n[spawn error] ${err.message}\n`;182 outputBuffer += text;183 broadcast({ type: 'output', text });184 shellularProc = null;185 broadcast({ type: 'status', status: 'error' });186 });187 188 shellularProc.on('exit', (code, signal) => {189 shellularProc = null;190 191 const isMachineIdMismatch = procOutput.includes('Machine ID mismatch');192 const isRegError = code === 1 && !signal && !isMachineIdMismatch &&193 (procOutput.includes('invalid_union') || procOutput.includes('Too many requests') ||194 procOutput.includes('host registration'));195 196 if (isRegError) {197 const WAIT = 30;198 const msg = `\n⚠ Registration rate-limited by shellular API.\n Retrying automatically in ${WAIT}s — please wait…\n`;199 outputBuffer += msg;200 broadcast({ type: 'output', text: msg });201 broadcast({ type: 'status', status: 'retrying' });202 203 retryTimer = setTimeout(() => {204 retryTimer = null;205 const msg2 = '\n[Retrying registration…]\n';206 outputBuffer += msg2;207 broadcast({ type: 'output', text: msg2 });208 startShellular();209 }, WAIT * 1000);210 } else {211 const text = code !== 0212 ? `\n[shellular exited — code=${code ?? '?'}, signal=${signal ?? 'none'}]\n`213 : '\n[shellular disconnected]\n';214 outputBuffer += text;215 broadcast({ type: 'output', text });216 broadcast({ type: 'status', status: 'stopped' });217 }218 });219 220 broadcast({ type: 'status', status: 'running' });221}222 223function stopShellular() {224 if (retryTimer) { clearTimeout(retryTimer); retryTimer = null; }225 if (!shellularProc) return;226 shellularProc.kill('SIGTERM');227 shellularProc = null;228}229 230app.get('/api/stream', requireAuth, (req, res) => {231 res.setHeader('Content-Type', 'text/event-stream');232 res.setHeader('Cache-Control', 'no-cache');233 res.setHeader('Connection', 'keep-alive');234 res.setHeader('X-Accel-Buffering', 'no');235 res.flushHeaders();236 237 send(res, { type: 'status', status: shellularProc ? 'running' : 'stopped' });238 if (outputBuffer) send(res, { type: 'output', text: outputBuffer });239 240 sseClients.add(res);241 req.on('close', () => sseClients.delete(res));242});243 244app.post('/api/shellular/start', requireAuth, (_req, res) => {245 startShellular();246 res.json({ ok: true, running: !!shellularProc });247});248 249app.post('/api/shellular/stop', requireAuth, (_req, res) => {250 stopShellular();251 outputBuffer = '';252 broadcast({ type: 'output', text: '' });253 res.json({ ok: true });254});255 256app.post('/api/shellular/restart', requireAuth, (_req, res) => {257 stopShellular();258 outputBuffer = '';259 broadcast({ type: 'clear' });260 setTimeout(startShellular, 600);261 res.json({ ok: true });262});263 264app.get('/api/status', requireAuth, (_req, res) => {265 res.json({ running: !!shellularProc });266});267 268app.get('/api/setup-status', requireAuth, (_req, res) => {269 const seeded = !!(270 process.env.SHELLULAR_HOST_ID &&271 process.env.SHELLULAR_KEY &&272 process.env.SHELLULAR_MACHINE_ID &&273 process.env.MACHINE_ID_RAW274 );275 res.json({ seeded });276});277 278app.get('/api/shellular/credentials', requireAuth, (_req, res) => {279 try {280 const shellularDir = path.join(os.homedir(), '.shellular');281 const configRaw = fs.readFileSync(path.join(shellularDir, 'config.json'), 'utf-8');282 const { hostId, machineId } = JSON.parse(configRaw);283 const keyFile = path.join(shellularDir, `shellular-${machineId}.e2ee`);284 const keyB64 = fs.readFileSync(keyFile).toString('base64');285 res.json({ hostId, machineId, keyB64 });286 } catch {287 res.status(404).json({ error: 'Not registered yet.' });288 }289});290 291app.get('/api/shellular/qr-data', requireAuth, (_req, res) => {292 try {293 const shellularDir = path.join(os.homedir(), '.shellular');294 const configRaw = fs.readFileSync(path.join(shellularDir, 'config.json'), 'utf-8');295 const { hostId, machineId } = JSON.parse(configRaw);296 const keyFile = path.join(shellularDir, `shellular-${machineId}.e2ee`);297 const keyB64 = fs.readFileSync(keyFile).toString('base64');298 res.json({ qrData: `${hostId}:${keyB64}` });299 } catch {300 res.status(404).json({ error: 'Config not seeded yet.' });301 }302});303 304app.listen(PORT, '0.0.0.0', () => {305 console.log(`Shellular Web UI → http://0.0.0.0:${PORT}`);306});307 