lobstakit-cloud 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/server.js ADDED
@@ -0,0 +1,1357 @@
1
+ /**
2
+ * LobstaKit Cloud — Express Server
3
+ *
4
+ * Setup wizard and management proxy for LobstaCloud gateways.
5
+ *
6
+ * When unconfigured: serves the setup wizard UI
7
+ * When configured: reverse-proxies to the OpenClaw gateway on port 3001
8
+ * except /manage routes which always serve the dashboard
9
+ */
10
+
11
+ const express = require('express');
12
+ const path = require('path');
13
+ const { execSync, exec } = require('child_process');
14
+ const fs = require('fs');
15
+ const crypto = require('crypto');
16
+ const config = require('./lib/config');
17
+ const gateway = require('./lib/gateway');
18
+ const proxyMiddleware = require('./lib/proxy');
19
+
20
+ const app = express();
21
+ const PORT = process.env.PORT || 3000;
22
+
23
+ // ─── Dashboard Authentication ────────────────────────────────────────────────
24
+
25
+ const LOBSTAKIT_CONFIG_PATH = require('os').homedir() + '/.lobstakit/config.json';
26
+ const LOBSTAKIT_PROVISION_PATH = require('os').homedir() + '/.lobstakit/provision.json';
27
+
28
+ function getLobstaKitConfig() {
29
+ try {
30
+ return JSON.parse(fs.readFileSync(LOBSTAKIT_CONFIG_PATH, 'utf8'));
31
+ } catch(e) {
32
+ return {};
33
+ }
34
+ }
35
+
36
+ function getProvisionData() {
37
+ try {
38
+ return JSON.parse(fs.readFileSync(LOBSTAKIT_PROVISION_PATH, 'utf8'));
39
+ } catch(e) {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ function saveLobstaKitConfig(cfg) {
45
+ const dir = path.dirname(LOBSTAKIT_CONFIG_PATH);
46
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
47
+ fs.writeFileSync(LOBSTAKIT_CONFIG_PATH, JSON.stringify(cfg, null, 2));
48
+ }
49
+
50
+ // Active sessions (in-memory, cleared on restart)
51
+ const activeSessions = new Map();
52
+
53
+ // Auth middleware — protect API routes (except auth endpoints, health, and static files)
54
+ function requireAuth(req, res, next) {
55
+ const publicPaths = ['/api/auth/login', '/api/auth/setup', '/api/auth/status', '/api/health', '/api/provision'];
56
+ if (publicPaths.some(p => req.path === p)) return next();
57
+
58
+ // Only protect API routes
59
+ if (!req.path.startsWith('/api/')) return next();
60
+
61
+ const lobstaConfig = getLobstaKitConfig();
62
+ // If no password set yet, allow all (setup flow)
63
+ if (!lobstaConfig.passwordHash) return next();
64
+
65
+ const token = req.headers.authorization?.replace('Bearer ', '');
66
+ if (!token || !activeSessions.has(token)) {
67
+ return res.status(401).json({ error: 'Authentication required' });
68
+ }
69
+ next();
70
+ }
71
+
72
+ // Parse JSON bodies
73
+ app.use(express.json());
74
+
75
+ // ─── Static Files (before auth — login page must be accessible) ──────────────
76
+
77
+ app.use(express.static(path.join(__dirname, 'public')));
78
+
79
+ // ─── Auth API Routes (no auth required) ──────────────────────────────────────
80
+
81
+ // POST /api/auth/setup — set initial email + password (only works if no password set)
82
+ app.post('/api/auth/setup', (req, res) => {
83
+ const lobstaConfig = getLobstaKitConfig();
84
+ if (lobstaConfig.passwordHash) {
85
+ return res.status(403).json({ error: 'Account already set up. Use /api/auth/change to update.' });
86
+ }
87
+ let { email, password } = req.body;
88
+
89
+ // Fallback to provision email if not provided
90
+ if (!email) {
91
+ const provision = getProvisionData();
92
+ if (provision && provision.email) {
93
+ email = provision.email;
94
+ }
95
+ }
96
+
97
+ if (!email || !email.includes('@')) {
98
+ return res.status(400).json({ error: 'A valid email address is required' });
99
+ }
100
+ if (!password || password.length < 6) {
101
+ return res.status(400).json({ error: 'Password must be at least 6 characters' });
102
+ }
103
+ const salt = crypto.randomBytes(16).toString('hex');
104
+ const hash = crypto.scryptSync(password, salt, 64).toString('hex');
105
+ lobstaConfig.email = email.toLowerCase().trim();
106
+ lobstaConfig.passwordHash = hash;
107
+ lobstaConfig.passwordSalt = salt;
108
+ lobstaConfig.sessionSecret = crypto.randomBytes(32).toString('hex');
109
+ lobstaConfig.setupComplete = true;
110
+ saveLobstaKitConfig(lobstaConfig);
111
+
112
+ // Auto-login after setup
113
+ const sessionToken = crypto.randomBytes(32).toString('hex');
114
+ activeSessions.set(sessionToken, { created: Date.now(), ip: req.ip });
115
+
116
+ res.json({ status: 'ok', token: sessionToken });
117
+ });
118
+
119
+ // POST /api/auth/login — authenticate with email + password
120
+ app.post('/api/auth/login', (req, res) => {
121
+ const lobstaConfig = getLobstaKitConfig();
122
+ if (!lobstaConfig.passwordHash) {
123
+ return res.status(400).json({ error: 'No account set up. Complete setup first.' });
124
+ }
125
+ const { email, password } = req.body;
126
+
127
+ // Verify email matches (case-insensitive)
128
+ const storedEmail = (lobstaConfig.email || '').toLowerCase().trim();
129
+ const providedEmail = (email || '').toLowerCase().trim();
130
+ if (!providedEmail || providedEmail !== storedEmail) {
131
+ return res.status(401).json({ error: 'Incorrect email or password' });
132
+ }
133
+
134
+ // Verify password
135
+ const hash = crypto.scryptSync(password || '', lobstaConfig.passwordSalt, 64).toString('hex');
136
+ if (hash !== lobstaConfig.passwordHash) {
137
+ return res.status(401).json({ error: 'Incorrect email or password' });
138
+ }
139
+
140
+ const sessionToken = crypto.randomBytes(32).toString('hex');
141
+ activeSessions.set(sessionToken, { created: Date.now(), ip: req.ip });
142
+
143
+ // Clean old sessions (keep max 10)
144
+ if (activeSessions.size > 10) {
145
+ const oldest = [...activeSessions.entries()].sort((a, b) => a[1].created - b[1].created);
146
+ activeSessions.delete(oldest[0][0]);
147
+ }
148
+
149
+ res.json({ status: 'ok', token: sessionToken });
150
+ });
151
+
152
+ // POST /api/auth/logout
153
+ app.post('/api/auth/logout', (req, res) => {
154
+ const token = req.headers.authorization?.replace('Bearer ', '');
155
+ if (token) activeSessions.delete(token);
156
+ res.json({ status: 'ok' });
157
+ });
158
+
159
+ // GET /api/auth/status — check if auth is configured and if current session is valid
160
+ app.get('/api/auth/status', (req, res) => {
161
+ const lobstaConfig = getLobstaKitConfig();
162
+ const token = req.headers.authorization?.replace('Bearer ', '');
163
+ const isAuthenticated = token && activeSessions.has(token);
164
+ const response = {
165
+ setupComplete: !!lobstaConfig.setupComplete,
166
+ passwordSet: !!lobstaConfig.passwordHash,
167
+ authenticated: isAuthenticated
168
+ };
169
+ // Include email for login pre-fill (always — it's just an email, not a secret)
170
+ if (lobstaConfig.email) {
171
+ response.email = lobstaConfig.email;
172
+ }
173
+ res.json(response);
174
+ });
175
+
176
+ // POST /api/auth/change — change password and/or email (requires current password)
177
+ app.post('/api/auth/change', (req, res) => {
178
+ const lobstaConfig = getLobstaKitConfig();
179
+ const token = req.headers.authorization?.replace('Bearer ', '');
180
+ if (!token || !activeSessions.has(token)) {
181
+ return res.status(401).json({ error: 'Authentication required' });
182
+ }
183
+
184
+ const { currentPassword, newPassword, newEmail } = req.body;
185
+ if (!lobstaConfig.passwordHash) return res.status(400).json({ error: 'No account set up' });
186
+
187
+ const hash = crypto.scryptSync(currentPassword, lobstaConfig.passwordSalt, 64).toString('hex');
188
+ if (hash !== lobstaConfig.passwordHash) return res.status(401).json({ error: 'Incorrect current password' });
189
+
190
+ // Update email if provided
191
+ if (newEmail !== undefined && newEmail !== null) {
192
+ if (!newEmail || !newEmail.includes('@')) {
193
+ return res.status(400).json({ error: 'Invalid email address' });
194
+ }
195
+ lobstaConfig.email = newEmail.toLowerCase().trim();
196
+ }
197
+
198
+ // Update password if provided
199
+ if (newPassword) {
200
+ if (newPassword.length < 6) return res.status(400).json({ error: 'New password must be at least 6 characters' });
201
+ const salt = crypto.randomBytes(16).toString('hex');
202
+ lobstaConfig.passwordHash = crypto.scryptSync(newPassword, salt, 64).toString('hex');
203
+ lobstaConfig.passwordSalt = salt;
204
+ }
205
+
206
+ saveLobstaKitConfig(lobstaConfig);
207
+
208
+ // Invalidate all sessions if password changed
209
+ if (newPassword) {
210
+ activeSessions.clear();
211
+ }
212
+
213
+ res.json({ status: 'ok' });
214
+ });
215
+
216
+ // GET /api/provision — return provisioning data (email, subdomain, plan) if available
217
+ app.get('/api/provision', (req, res) => {
218
+ const provision = getProvisionData();
219
+ if (provision) {
220
+ res.json({ provisioned: true, email: provision.email || null, subdomain: provision.subdomain || null, plan: provision.plan || null });
221
+ } else {
222
+ res.json({ provisioned: false });
223
+ }
224
+ });
225
+
226
+ // ─── Auth Middleware (protects all subsequent API routes) ─────────────────────
227
+
228
+ app.use(requireAuth);
229
+
230
+ // ─── API Routes (always available) ──────────────────────────────────────────
231
+
232
+ /**
233
+ * GET /api/status — Current configuration and gateway status
234
+ */
235
+ app.get('/api/status', (req, res) => {
236
+ const configured = config.isConfigured();
237
+ const running = gateway.isGatewayRunning();
238
+ const subdomain = config.getSubdomain();
239
+ const currentConfig = config.readConfig();
240
+
241
+ // Detect active channels
242
+ let channel = 'web';
243
+ if (currentConfig?.channels?.telegram?.enabled) channel = 'telegram';
244
+ else if (currentConfig?.channels?.discord?.enabled) channel = 'discord';
245
+
246
+ res.json({
247
+ configured,
248
+ gatewayRunning: running,
249
+ subdomain,
250
+ model: currentConfig?.agents?.defaults?.model?.primary || null,
251
+ channel
252
+ });
253
+ });
254
+
255
+ /**
256
+ * POST /api/setup — Accept wizard data, write config, start gateway
257
+ */
258
+ app.post('/api/setup', async (req, res) => {
259
+ try {
260
+ const { apiKey, model, channel, telegramBotToken, telegramUserId, discordBotToken, discordServerId, privateMemory } = req.body;
261
+
262
+ // Validate required fields
263
+ if (!apiKey || !model) {
264
+ return res.status(400).json({
265
+ error: 'Missing required fields',
266
+ details: {
267
+ apiKey: !apiKey ? 'Required' : null,
268
+ model: !model ? 'Required' : null
269
+ }
270
+ });
271
+ }
272
+
273
+ const selectedChannel = channel || 'web';
274
+
275
+ // Validate channel-specific fields
276
+ if (selectedChannel === 'telegram') {
277
+ if (!telegramBotToken || !telegramUserId) {
278
+ return res.status(400).json({
279
+ error: 'Telegram bot token and user ID are required'
280
+ });
281
+ }
282
+ if (!/^\d+:[A-Za-z0-9_-]+$/.test(telegramBotToken)) {
283
+ return res.status(400).json({
284
+ error: 'Invalid Telegram bot token format'
285
+ });
286
+ }
287
+ } else if (selectedChannel === 'discord') {
288
+ if (!discordBotToken || !discordServerId) {
289
+ return res.status(400).json({
290
+ error: 'Discord bot token and server ID are required'
291
+ });
292
+ }
293
+ }
294
+
295
+ // Write the config
296
+ config.writeConfig({ apiKey, model, channel: selectedChannel, telegramBotToken, telegramUserId, discordBotToken, discordServerId, privateMemory });
297
+
298
+ // Restart the gateway to pick up the new config
299
+ const result = gateway.restartGateway();
300
+
301
+ if (!result.success) {
302
+ // Config is written but gateway failed to start
303
+ return res.status(500).json({
304
+ error: 'Config saved but gateway failed to start',
305
+ details: result.error
306
+ });
307
+ }
308
+
309
+ // Wait a moment for the gateway to initialize
310
+ await new Promise(resolve => setTimeout(resolve, 2000));
311
+
312
+ const running = gateway.isGatewayRunning();
313
+
314
+ res.json({
315
+ success: true,
316
+ gatewayRunning: running,
317
+ message: running
318
+ ? 'Configuration saved and gateway started successfully!'
319
+ : 'Configuration saved. Gateway is starting up...'
320
+ });
321
+ } catch (err) {
322
+ console.error('[setup] Error:', err);
323
+ res.status(500).json({ error: 'Setup failed', details: err.message });
324
+ }
325
+ });
326
+
327
+ /**
328
+ * POST /api/restart — Restart the gateway service
329
+ */
330
+ app.post('/api/restart', (req, res) => {
331
+ const result = gateway.restartGateway();
332
+ res.json({
333
+ success: result.success,
334
+ gatewayRunning: gateway.isGatewayRunning(),
335
+ error: result.error || null
336
+ });
337
+ });
338
+
339
+ /**
340
+ * GET /api/gateway-status — Check if gateway is running
341
+ */
342
+ app.get('/api/gateway-status', (req, res) => {
343
+ res.json({
344
+ running: gateway.isGatewayRunning()
345
+ });
346
+ });
347
+
348
+ /**
349
+ * GET /api/logs — Get recent gateway logs
350
+ */
351
+ app.get('/api/logs', (req, res) => {
352
+ const lines = parseInt(req.query.lines) || 50;
353
+ const logs = gateway.getGatewayLogs(Math.min(lines, 200));
354
+ res.json({ logs });
355
+ });
356
+
357
+ // ─── Security API Routes ─────────────────────────────────────────────────────
358
+
359
+ /**
360
+ * GET /api/security/status — Check all security components
361
+ */
362
+ app.get('/api/security/status', (req, res) => {
363
+ const status = {};
364
+
365
+ // Check UFW firewall
366
+ try {
367
+ const ufwOutput = execSync('ufw status 2>/dev/null', { stdio: 'pipe', timeout: 5000 }).toString();
368
+ status.firewall = { installed: true, active: ufwOutput.includes('Status: active') };
369
+ } catch {
370
+ try {
371
+ execSync('command -v ufw', { stdio: 'pipe' });
372
+ status.firewall = { installed: true, active: false };
373
+ } catch {
374
+ status.firewall = { installed: false, active: false };
375
+ }
376
+ }
377
+
378
+ // Check fail2ban
379
+ try {
380
+ const f2bState = execSync('systemctl is-active fail2ban 2>/dev/null', { stdio: 'pipe', timeout: 5000 }).toString().trim();
381
+ status.fail2ban = { installed: true, active: f2bState === 'active' };
382
+ } catch {
383
+ try {
384
+ execSync('command -v fail2ban-client', { stdio: 'pipe' });
385
+ status.fail2ban = { installed: true, active: false };
386
+ } catch {
387
+ status.fail2ban = { installed: false, active: false };
388
+ }
389
+ }
390
+
391
+ // Check SSH hardened (our config file exists)
392
+ try {
393
+ status.ssh = { hardened: fs.existsSync('/etc/ssh/sshd_config.d/lobstacloud.conf') };
394
+ } catch {
395
+ status.ssh = { hardened: false };
396
+ }
397
+
398
+ // Check kernel hardening
399
+ try {
400
+ status.kernel = { hardened: fs.existsSync('/etc/sysctl.d/99-lobstacloud.conf') };
401
+ } catch {
402
+ status.kernel = { hardened: false };
403
+ }
404
+
405
+ // Check unattended-upgrades
406
+ try {
407
+ execSync('dpkg -l unattended-upgrades 2>/dev/null | grep -q "^ii"', { stdio: 'pipe', timeout: 5000 });
408
+ status.autoUpdates = { installed: true };
409
+ } catch {
410
+ status.autoUpdates = { installed: false };
411
+ }
412
+
413
+ // Check Tailscale
414
+ try {
415
+ execSync('command -v tailscale', { stdio: 'pipe' });
416
+ status.tailscale = { installed: true };
417
+ } catch {
418
+ status.tailscale = { installed: false };
419
+ }
420
+
421
+ // Calculate score
422
+ const checks = [
423
+ status.firewall.active,
424
+ status.fail2ban.active,
425
+ status.ssh.hardened,
426
+ status.kernel.hardened,
427
+ status.autoUpdates.installed,
428
+ status.tailscale.installed
429
+ ];
430
+ status.score = { passed: checks.filter(Boolean).length, total: checks.length };
431
+
432
+ res.json(status);
433
+ });
434
+
435
+ /**
436
+ * POST /api/security/harden — Run the full hardening script on demand
437
+ */
438
+ app.post('/api/security/harden', (req, res) => {
439
+ const results = [];
440
+ const { installTailscale = true } = req.body || {};
441
+ const aptEnv = { ...process.env, DEBIAN_FRONTEND: 'noninteractive' };
442
+
443
+ function runStep(label, fn) {
444
+ try {
445
+ fn();
446
+ results.push({ step: label, success: true });
447
+ } catch (e) {
448
+ const errStr = e.stderr ? e.stderr.toString() : e.message;
449
+ results.push({ step: label, success: false, error: errStr.slice(0, 300) });
450
+ }
451
+ }
452
+
453
+ // 1. SSH Hardening
454
+ runStep('SSH Hardening', () => {
455
+ const sshConfig = [
456
+ 'PermitRootLogin prohibit-password',
457
+ 'PasswordAuthentication no',
458
+ 'PermitEmptyPasswords no',
459
+ 'PubkeyAuthentication yes',
460
+ 'MaxAuthTries 3',
461
+ 'LoginGraceTime 30',
462
+ 'ClientAliveInterval 300',
463
+ 'ClientAliveCountMax 2',
464
+ 'X11Forwarding no',
465
+ 'AllowAgentForwarding no',
466
+ 'AllowTcpForwarding no'
467
+ ].join('\n') + '\n';
468
+ fs.mkdirSync('/etc/ssh/sshd_config.d', { recursive: true });
469
+ fs.writeFileSync('/etc/ssh/sshd_config.d/lobstacloud.conf', sshConfig);
470
+ execSync('sshd -t && systemctl reload ssh', { stdio: 'pipe', timeout: 10000 });
471
+ });
472
+
473
+ // 2. Firewall (UFW)
474
+ runStep('Firewall (UFW)', () => {
475
+ execSync('apt-get install -y -qq ufw', { stdio: 'pipe', timeout: 120000, env: aptEnv });
476
+ execSync('ufw default deny incoming', { stdio: 'pipe', timeout: 5000 });
477
+ execSync('ufw default allow outgoing', { stdio: 'pipe', timeout: 5000 });
478
+ execSync("ufw allow 22/tcp comment 'SSH'", { stdio: 'pipe', timeout: 5000 });
479
+ execSync("ufw allow 80/tcp comment 'HTTP (Caddy)'", { stdio: 'pipe', timeout: 5000 });
480
+ execSync("ufw allow 443/tcp comment 'HTTPS (Caddy)'", { stdio: 'pipe', timeout: 5000 });
481
+ execSync('ufw --force enable', { stdio: 'pipe', timeout: 10000 });
482
+ });
483
+
484
+ // 3. Fail2ban
485
+ runStep('Fail2ban', () => {
486
+ execSync('apt-get install -y -qq fail2ban', { stdio: 'pipe', timeout: 120000, env: aptEnv });
487
+ const f2bConfig = [
488
+ '[DEFAULT]',
489
+ 'bantime = 1h',
490
+ 'findtime = 10m',
491
+ 'maxretry = 3',
492
+ '',
493
+ '[sshd]',
494
+ 'enabled = true',
495
+ 'port = ssh',
496
+ 'logpath = /var/log/auth.log',
497
+ 'maxretry = 3',
498
+ 'bantime = 24h'
499
+ ].join('\n') + '\n';
500
+ fs.writeFileSync('/etc/fail2ban/jail.local', f2bConfig);
501
+ execSync('systemctl enable --now fail2ban', { stdio: 'pipe', timeout: 15000 });
502
+ });
503
+
504
+ // 4. Auto security updates
505
+ runStep('Auto Security Updates', () => {
506
+ execSync('apt-get install -y -qq unattended-upgrades', { stdio: 'pipe', timeout: 120000, env: aptEnv });
507
+ const autoConfig = [
508
+ 'APT::Periodic::Update-Package-Lists "1";',
509
+ 'APT::Periodic::Unattended-Upgrade "1";',
510
+ 'APT::Periodic::AutocleanInterval "7";'
511
+ ].join('\n') + '\n';
512
+ fs.writeFileSync('/etc/apt/apt.conf.d/20auto-upgrades', autoConfig);
513
+ });
514
+
515
+ // 5. Kernel hardening
516
+ runStep('Kernel Hardening', () => {
517
+ const sysctlConfig = [
518
+ 'net.ipv4.ip_forward = 0',
519
+ 'net.ipv4.conf.all.accept_redirects = 0',
520
+ 'net.ipv4.conf.default.accept_redirects = 0',
521
+ 'net.ipv4.conf.all.send_redirects = 0',
522
+ 'net.ipv4.tcp_syncookies = 1',
523
+ 'net.ipv4.conf.all.log_martians = 1',
524
+ 'net.ipv4.conf.all.accept_source_route = 0',
525
+ 'net.ipv4.conf.all.rp_filter = 1'
526
+ ].join('\n') + '\n';
527
+ fs.writeFileSync('/etc/sysctl.d/99-lobstacloud.conf', sysctlConfig);
528
+ execSync('sysctl -p /etc/sysctl.d/99-lobstacloud.conf 2>/dev/null', { stdio: 'pipe', timeout: 10000 });
529
+ });
530
+
531
+ // 6. Tailscale (optional)
532
+ if (installTailscale) {
533
+ runStep('Install Tailscale', () => {
534
+ try {
535
+ execSync('command -v tailscale', { stdio: 'pipe' });
536
+ // Already installed — skip
537
+ } catch {
538
+ execSync('curl -fsSL https://tailscale.com/install.sh | sh', { stdio: 'pipe', timeout: 120000 });
539
+ }
540
+ });
541
+ }
542
+
543
+ const passed = results.filter(r => r.success).length;
544
+ console.log(`[security] Harden complete: ${passed}/${results.length} steps succeeded`);
545
+
546
+ res.json({
547
+ success: passed === results.length,
548
+ results,
549
+ summary: `${passed}/${results.length} steps completed successfully`
550
+ });
551
+ });
552
+
553
+ // ─── Memory API Routes ────────────────────────────────────────────────────────
554
+
555
+ /**
556
+ * GET /api/memory/status — Check current memory embedding config
557
+ */
558
+ app.get('/api/memory/status', (req, res) => {
559
+ try {
560
+ const currentConfig = config.readConfig();
561
+ const memorySearch = currentConfig?.agents?.defaults?.memorySearch;
562
+ const provider = memorySearch?.provider || 'auto';
563
+ const isPrivate = provider === 'local';
564
+
565
+ // Check if local model file exists
566
+ const modelPath = '/root/.node-llama-cpp/models/hf_ggml-org_embeddinggemma-300M-GGUF/embeddinggemma-300M-Q8_0.gguf';
567
+ const modelDownloaded = fs.existsSync(modelPath);
568
+
569
+ let modelSize = '313MB';
570
+ if (modelDownloaded) {
571
+ try {
572
+ const stats = fs.statSync(modelPath);
573
+ modelSize = Math.round(stats.size / 1024 / 1024) + 'MB';
574
+ } catch {}
575
+ }
576
+
577
+ // Check if memory plugin is active
578
+ const memoryPlugin = currentConfig?.plugins?.slots?.memory;
579
+ const memoryEnabled = memoryPlugin === 'memory-core' || memoryPlugin === undefined; // default is memory-core
580
+
581
+ res.json({
582
+ provider,
583
+ isPrivate,
584
+ modelDownloaded,
585
+ modelSize,
586
+ memoryEnabled,
587
+ modelName: 'embeddinggemma-300M'
588
+ });
589
+ } catch (err) {
590
+ console.error('[memory] Status check error:', err);
591
+ res.status(500).json({ error: 'Failed to check memory status' });
592
+ }
593
+ });
594
+
595
+ /**
596
+ * POST /api/memory/toggle — Switch between local and remote memory embeddings
597
+ */
598
+ app.post('/api/memory/toggle', (req, res) => {
599
+ try {
600
+ const { mode } = req.body;
601
+
602
+ if (!mode || !['local', 'remote'].includes(mode)) {
603
+ return res.status(400).json({ error: 'Mode must be "local" or "remote"' });
604
+ }
605
+
606
+ const currentConfig = config.readConfig();
607
+ if (!currentConfig) {
608
+ return res.status(400).json({ error: 'Gateway not configured. Run setup first.' });
609
+ }
610
+
611
+ // Ensure agents.defaults exists
612
+ if (!currentConfig.agents) currentConfig.agents = {};
613
+ if (!currentConfig.agents.defaults) currentConfig.agents.defaults = {};
614
+
615
+ if (mode === 'local') {
616
+ currentConfig.agents.defaults.memorySearch = {
617
+ provider: 'local',
618
+ fallback: 'none',
619
+ local: {
620
+ modelPath: 'hf:ggml-org/embeddinggemma-300M-GGUF/embeddinggemma-300M-Q8_0.gguf'
621
+ },
622
+ query: {
623
+ hybrid: {
624
+ enabled: true,
625
+ vectorWeight: 0.7,
626
+ textWeight: 0.3
627
+ }
628
+ },
629
+ cache: {
630
+ enabled: true,
631
+ maxEntries: 50000
632
+ }
633
+ };
634
+ } else {
635
+ // Remote mode — remove memorySearch entirely (auto-detect)
636
+ delete currentConfig.agents.defaults.memorySearch;
637
+ }
638
+
639
+ // Write config
640
+ config.writeRawConfig(currentConfig);
641
+
642
+ // Restart gateway to pick up changes
643
+ const result = gateway.restartGateway();
644
+
645
+ console.log(`[memory] Switched to ${mode} mode`);
646
+
647
+ res.json({
648
+ success: true,
649
+ mode,
650
+ isPrivate: mode === 'local',
651
+ message: `Memory embeddings switched to ${mode === 'local' ? 'private (local)' : 'cloud'} mode` +
652
+ (result.success ? ' and gateway restarted' : ' (gateway restart pending)')
653
+ });
654
+ } catch (err) {
655
+ console.error('[memory] Toggle error:', err);
656
+ res.status(500).json({ error: 'Failed to toggle memory mode', details: err.message });
657
+ }
658
+ });
659
+
660
+ /**
661
+ * POST /api/memory/download — Trigger model download
662
+ */
663
+ app.post('/api/memory/download', (req, res) => {
664
+ const os = require('os');
665
+ const modelDir = path.join(os.homedir(), '.node-llama-cpp', 'models', 'hf_ggml-org_embeddinggemma-300M-GGUF');
666
+ const modelFile = path.join(modelDir, 'embeddinggemma-300M-Q8_0.gguf');
667
+ const partFile = modelFile + '.part';
668
+
669
+ if (fs.existsSync(modelFile)) {
670
+ const size = fs.statSync(modelFile).size;
671
+ return res.json({ status: 'already_downloaded', size });
672
+ }
673
+
674
+ // Respond immediately, download in background
675
+ res.json({ status: 'downloading' });
676
+
677
+ // Download to .part file, then rename on completion
678
+ const url = 'https://huggingface.co/ggml-org/embeddinggemma-300M-GGUF/resolve/main/embeddinggemma-300M-Q8_0.gguf';
679
+ exec(`mkdir -p "${modelDir}" && curl -L -o "${partFile}" "${url}" && mv "${partFile}" "${modelFile}"`, (err) => {
680
+ if (err) {
681
+ console.error('[memory] Model download failed:', err.message);
682
+ try { fs.unlinkSync(partFile); } catch (e) {}
683
+ } else {
684
+ console.log('[memory] Model downloaded successfully to', modelFile);
685
+ }
686
+ });
687
+ });
688
+
689
+ /**
690
+ * GET /api/memory/download/status — Check model download progress
691
+ */
692
+ app.get('/api/memory/download/status', (req, res) => {
693
+ const os = require('os');
694
+ const modelDir = path.join(os.homedir(), '.node-llama-cpp', 'models', 'hf_ggml-org_embeddinggemma-300M-GGUF');
695
+ const modelFile = path.join(modelDir, 'embeddinggemma-300M-Q8_0.gguf');
696
+ const partFile = modelFile + '.part';
697
+
698
+ if (fs.existsSync(modelFile)) {
699
+ const size = fs.statSync(modelFile).size;
700
+ return res.json({
701
+ status: 'downloaded',
702
+ size,
703
+ sizeHuman: (size / 1024 / 1024).toFixed(0) + 'MB'
704
+ });
705
+ }
706
+
707
+ if (fs.existsSync(partFile)) {
708
+ try {
709
+ const size = fs.statSync(partFile).size;
710
+ const total = 328576992; // known file size ~313MB
711
+ const progress = Math.min(99, Math.round((size / total) * 100));
712
+ return res.json({
713
+ status: 'downloading',
714
+ progress,
715
+ downloaded: size,
716
+ total
717
+ });
718
+ } catch (e) {
719
+ return res.json({ status: 'downloading', progress: 0 });
720
+ }
721
+ }
722
+
723
+ return res.json({ status: 'not_downloaded' });
724
+ });
725
+
726
+ // ─── Channel Management API Routes ───────────────────────────────────────────
727
+
728
+ /**
729
+ * GET /api/channels — List all available channels with status
730
+ */
731
+ app.get('/api/channels', (req, res) => {
732
+ const currentConfig = config.readConfig();
733
+ const channels = currentConfig?.channels || {};
734
+
735
+ const channelList = [
736
+ {
737
+ id: 'web',
738
+ name: 'Web Chat',
739
+ icon: '🌐',
740
+ configured: true,
741
+ status: 'connected',
742
+ details: { summary: 'Built-in — always active' }
743
+ },
744
+ {
745
+ id: 'telegram',
746
+ name: 'Telegram',
747
+ icon: '📱',
748
+ configured: !!channels.telegram?.enabled,
749
+ status: channels.telegram?.enabled ? 'connected' : 'not_configured',
750
+ details: channels.telegram?.enabled ? {
751
+ summary: 'Bot token configured',
752
+ allowFrom: channels.telegram.allowFrom || []
753
+ } : {}
754
+ },
755
+ {
756
+ id: 'discord',
757
+ name: 'Discord',
758
+ icon: '🎮',
759
+ configured: !!channels.discord?.enabled,
760
+ status: channels.discord?.enabled ? 'connected' : 'not_configured',
761
+ details: channels.discord?.enabled ? {
762
+ summary: 'Bot connected',
763
+ allowedGuilds: channels.discord.allowedGuilds || []
764
+ } : {}
765
+ }
766
+ ];
767
+
768
+ res.json({ channels: channelList });
769
+ });
770
+
771
+ /**
772
+ * POST /api/channels/:type — Configure a channel
773
+ */
774
+ app.post('/api/channels/:type', async (req, res) => {
775
+ try {
776
+ const { type } = req.params;
777
+
778
+ if (!['telegram', 'discord'].includes(type)) {
779
+ return res.status(400).json({ error: 'Unsupported channel type: ' + type });
780
+ }
781
+
782
+ const currentConfig = config.readConfig();
783
+ if (!currentConfig) {
784
+ return res.status(400).json({ error: 'Gateway not configured. Run setup first.' });
785
+ }
786
+
787
+ if (!currentConfig.channels) {
788
+ currentConfig.channels = {};
789
+ }
790
+
791
+ if (type === 'telegram') {
792
+ const { botToken, userId } = req.body;
793
+ if (!botToken || !userId) {
794
+ return res.status(400).json({ error: 'Bot token and user ID are required' });
795
+ }
796
+ if (!/^\d+:[A-Za-z0-9_-]+$/.test(botToken)) {
797
+ return res.status(400).json({ error: 'Invalid Telegram bot token format' });
798
+ }
799
+ currentConfig.channels.telegram = {
800
+ enabled: true,
801
+ botToken,
802
+ allowFrom: [userId],
803
+ dmPolicy: 'allowlist'
804
+ };
805
+ } else if (type === 'discord') {
806
+ const { botToken, serverId } = req.body;
807
+ if (!botToken || !serverId) {
808
+ return res.status(400).json({ error: 'Bot token and server ID are required' });
809
+ }
810
+ currentConfig.channels.discord = {
811
+ enabled: true,
812
+ botToken,
813
+ allowedGuilds: [serverId]
814
+ };
815
+ }
816
+
817
+ // Write updated config
818
+ config.writeRawConfig(currentConfig);
819
+
820
+ // Restart gateway to pick up changes
821
+ const result = gateway.restartGateway();
822
+
823
+ res.json({
824
+ success: true,
825
+ message: type.charAt(0).toUpperCase() + type.slice(1) + ' channel configured' + (result.success ? ' and gateway restarted' : ' (gateway restart pending)')
826
+ });
827
+ } catch (err) {
828
+ console.error('[channels] POST error:', err);
829
+ res.status(500).json({ error: 'Failed to configure channel', details: err.message });
830
+ }
831
+ });
832
+
833
+ /**
834
+ * DELETE /api/channels/:type — Remove a channel
835
+ */
836
+ app.delete('/api/channels/:type', async (req, res) => {
837
+ try {
838
+ const { type } = req.params;
839
+
840
+ if (!['telegram', 'discord'].includes(type)) {
841
+ return res.status(400).json({ error: 'Unsupported channel type: ' + type });
842
+ }
843
+
844
+ const currentConfig = config.readConfig();
845
+ if (!currentConfig || !currentConfig.channels) {
846
+ return res.status(400).json({ error: 'No channels configured' });
847
+ }
848
+
849
+ if (currentConfig.channels[type]) {
850
+ delete currentConfig.channels[type];
851
+ }
852
+
853
+ // Write updated config
854
+ config.writeRawConfig(currentConfig);
855
+
856
+ // Restart gateway
857
+ const result = gateway.restartGateway();
858
+
859
+ res.json({
860
+ success: true,
861
+ message: type.charAt(0).toUpperCase() + type.slice(1) + ' channel removed' + (result.success ? ' and gateway restarted' : '')
862
+ });
863
+ } catch (err) {
864
+ console.error('[channels] DELETE error:', err);
865
+ res.status(500).json({ error: 'Failed to remove channel', details: err.message });
866
+ }
867
+ });
868
+
869
+ // ─── Tailscale API Routes ────────────────────────────────────────────────────
870
+
871
+ /**
872
+ * GET /api/tailscale/status — Check Tailscale connection status
873
+ */
874
+ app.get('/api/tailscale/status', (req, res) => {
875
+ try {
876
+ // Check if tailscale is installed
877
+ try {
878
+ execSync('command -v tailscale', { stdio: 'pipe' });
879
+ } catch {
880
+ return res.json({
881
+ installed: false,
882
+ connected: false,
883
+ status: 'not_installed',
884
+ message: 'Tailscale is not installed'
885
+ });
886
+ }
887
+
888
+ // Get tailscale status
889
+ try {
890
+ const output = execSync('tailscale status --json 2>/dev/null', {
891
+ stdio: 'pipe',
892
+ timeout: 10000
893
+ }).toString();
894
+ const statusData = JSON.parse(output);
895
+
896
+ const backendState = statusData.BackendState || 'Unknown';
897
+ const connected = backendState === 'Running';
898
+ const selfNode = statusData.Self || {};
899
+ const hostname = selfNode.HostName || null;
900
+ const tailscaleIPs = selfNode.TailscaleIPs || [];
901
+ const online = selfNode.Online || false;
902
+
903
+ // Build response
904
+ const response = {
905
+ installed: true,
906
+ connected,
907
+ online,
908
+ status: backendState.toLowerCase(),
909
+ hostname,
910
+ tailscaleIPs,
911
+ message: connected ? `Connected as ${hostname || 'unknown'}` : `State: ${backendState}`
912
+ };
913
+
914
+ // Add serve info if connected
915
+ if (connected) {
916
+ let serveActive = false;
917
+ try {
918
+ const serveOutput = execSync('tailscale serve status 2>&1', {
919
+ stdio: 'pipe',
920
+ timeout: 5000
921
+ }).toString().trim();
922
+ serveActive = serveOutput && !serveOutput.includes('No serve config');
923
+ } catch (e) {
924
+ // serve status command failed — treat as not active
925
+ }
926
+
927
+ const dnsName = (selfNode.DNSName || '').replace(/\.$/, '') || null;
928
+
929
+ let gatewayToken = null;
930
+ const os = require('os');
931
+ const cfgPaths = [
932
+ os.homedir() + '/.clawdbot/clawdbot.json',
933
+ os.homedir() + '/.openclaw/openclaw.json'
934
+ ];
935
+ const cfgPath = cfgPaths.find(p => fs.existsSync(p));
936
+ if (cfgPath) {
937
+ try {
938
+ const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
939
+ gatewayToken = cfg.gateway?.auth?.token || null;
940
+ } catch (e) {}
941
+ }
942
+
943
+ response.serve = {
944
+ active: serveActive,
945
+ dnsName,
946
+ url: dnsName ? `https://${dnsName}/` : null,
947
+ webchatUrl: dnsName && gatewayToken
948
+ ? `https://${dnsName}/?token=${gatewayToken}`
949
+ : (dnsName ? `https://${dnsName}/` : null)
950
+ };
951
+ }
952
+
953
+ return res.json(response);
954
+ } catch (e) {
955
+ // tailscale installed but not running / not logged in
956
+ return res.json({
957
+ installed: true,
958
+ connected: false,
959
+ status: 'stopped',
960
+ message: 'Tailscale is installed but not connected'
961
+ });
962
+ }
963
+ } catch (err) {
964
+ console.error('[tailscale] Status check error:', err);
965
+ res.status(500).json({ error: 'Failed to check Tailscale status' });
966
+ }
967
+ });
968
+
969
+ /**
970
+ * POST /api/tailscale/connect — Connect Tailscale with an auth key
971
+ */
972
+ app.post('/api/tailscale/connect', (req, res) => {
973
+ try {
974
+ const { authKey } = req.body;
975
+
976
+ if (!authKey || typeof authKey !== 'string') {
977
+ return res.status(400).json({ error: 'Auth key is required' });
978
+ }
979
+
980
+ // Validate auth key format (tskey-auth-...)
981
+ if (!authKey.startsWith('tskey-auth-') && !authKey.startsWith('tskey-')) {
982
+ return res.status(400).json({
983
+ error: 'Invalid auth key format. Tailscale auth keys start with tskey-auth-'
984
+ });
985
+ }
986
+
987
+ // Check if tailscale is installed — auto-install if not
988
+ try {
989
+ execSync('command -v tailscale', { stdio: 'pipe' });
990
+ } catch {
991
+ try {
992
+ console.log('[tailscale] Not installed, auto-installing...');
993
+ execSync('curl -fsSL https://tailscale.com/install.sh | sh', {
994
+ stdio: 'pipe',
995
+ timeout: 120000
996
+ });
997
+ console.log('[tailscale] Auto-install completed');
998
+ } catch (installErr) {
999
+ const errMsg = installErr.stderr ? installErr.stderr.toString() : installErr.message;
1000
+ return res.status(400).json({
1001
+ error: 'Tailscale is not installed and auto-install failed.',
1002
+ details: errMsg.slice(0, 300),
1003
+ hint: 'Run manually: curl -fsSL https://tailscale.com/install.sh | sh'
1004
+ });
1005
+ }
1006
+ }
1007
+
1008
+ // Run tailscale up with the auth key
1009
+ try {
1010
+ execSync(`tailscale up --authkey=${authKey} --accept-routes --accept-dns`, {
1011
+ stdio: 'pipe',
1012
+ timeout: 30000
1013
+ });
1014
+
1015
+ // Verify connection and get self info
1016
+ let connected = false;
1017
+ let statusData = null;
1018
+ try {
1019
+ const output = execSync('tailscale status --json', {
1020
+ stdio: 'pipe',
1021
+ timeout: 10000
1022
+ }).toString();
1023
+ statusData = JSON.parse(output);
1024
+ connected = statusData.BackendState === 'Running';
1025
+ } catch (e) {
1026
+ // Status check failed but connect might have succeeded
1027
+ }
1028
+
1029
+ if (!connected || !statusData) {
1030
+ return res.json({
1031
+ success: true,
1032
+ connected: false,
1033
+ message: 'Tailscale auth key accepted. Connection establishing...'
1034
+ });
1035
+ }
1036
+
1037
+ const selfNode = statusData.Self || {};
1038
+ const dnsName = (selfNode.DNSName || '').replace(/\.$/, '');
1039
+ const hostName = selfNode.HostName || null;
1040
+
1041
+ // Read gateway config to get port and token
1042
+ const os = require('os');
1043
+ const configPaths = [
1044
+ os.homedir() + '/.clawdbot/clawdbot.json',
1045
+ os.homedir() + '/.openclaw/openclaw.json'
1046
+ ];
1047
+ let configPath = configPaths.find(p => fs.existsSync(p));
1048
+ let gatewayPort = 18789;
1049
+ let gatewayToken = null;
1050
+
1051
+ if (configPath) {
1052
+ try {
1053
+ const gwConfig = JSON.parse(fs.readFileSync(configPath, 'utf8'));
1054
+ gatewayPort = gwConfig.gateway?.port || 18789;
1055
+ gatewayToken = gwConfig.gateway?.auth?.token || null;
1056
+ } catch (e) {
1057
+ console.error('[tailscale] Config read error:', e.message);
1058
+ }
1059
+ }
1060
+
1061
+ // Set up Tailscale Serve to proxy gateway
1062
+ let serveEnabled = false;
1063
+ let serveError = null;
1064
+ let serveEnableUrl = null;
1065
+ try {
1066
+ execSync(`tailscale serve --bg http://127.0.0.1:${gatewayPort} 2>&1`, {
1067
+ stdio: 'pipe',
1068
+ timeout: 15000
1069
+ });
1070
+ serveEnabled = true;
1071
+ } catch (e) {
1072
+ serveError = (e.stderr ? e.stderr.toString() : '') || e.message;
1073
+ if (serveError.includes('not enabled')) {
1074
+ const match = serveError.match(/(https:\/\/login\.tailscale\.com\/f\/serve\S+)/);
1075
+ serveEnableUrl = match ? match[1] : 'https://login.tailscale.com/admin/settings';
1076
+ }
1077
+ }
1078
+
1079
+ // Patch gateway config for Tailscale auth
1080
+ if (configPath) {
1081
+ try {
1082
+ const gwConfig = JSON.parse(fs.readFileSync(configPath, 'utf8'));
1083
+ if (!gwConfig.gateway) gwConfig.gateway = {};
1084
+ if (!gwConfig.gateway.auth) gwConfig.gateway.auth = {};
1085
+ if (!gwConfig.gateway.controlUi) gwConfig.gateway.controlUi = {};
1086
+ if (!gwConfig.gateway.tailscale) gwConfig.gateway.tailscale = {};
1087
+
1088
+ gwConfig.gateway.auth.allowTailscale = true;
1089
+ gwConfig.gateway.controlUi.allowInsecureAuth = true;
1090
+ gwConfig.gateway.tailscale.mode = 'serve';
1091
+
1092
+ fs.writeFileSync(configPath, JSON.stringify(gwConfig, null, 2));
1093
+
1094
+ // Restart gateway to pick up new config
1095
+ try {
1096
+ execSync('systemctl restart openclaw-gateway 2>/dev/null || systemctl restart clawdbot-gateway 2>/dev/null', {
1097
+ stdio: 'pipe',
1098
+ timeout: 10000
1099
+ });
1100
+ } catch (e) {
1101
+ // Non-fatal — gateway will pick up config on next restart
1102
+ }
1103
+ } catch (e) {
1104
+ console.error('[tailscale] Config patch error:', e.message);
1105
+ }
1106
+ }
1107
+
1108
+ return res.json({
1109
+ success: true,
1110
+ connected: true,
1111
+ hostname: hostName,
1112
+ dnsName,
1113
+ tailscaleIPs: selfNode.TailscaleIPs || [],
1114
+ message: `Successfully connected to Tailscale as ${hostName || 'this device'}`,
1115
+ serve: {
1116
+ enabled: serveEnabled,
1117
+ url: serveEnabled && dnsName ? `https://${dnsName}/` : null,
1118
+ webchatUrl: serveEnabled && dnsName && gatewayToken
1119
+ ? `https://${dnsName}/?token=${gatewayToken}`
1120
+ : (serveEnabled && dnsName ? `https://${dnsName}/` : null),
1121
+ enableUrl: serveEnableUrl || null,
1122
+ error: !serveEnabled ? (serveError || 'Tailscale Serve not enabled') : null
1123
+ },
1124
+ gateway: {
1125
+ port: gatewayPort,
1126
+ configured: !!configPath
1127
+ }
1128
+ });
1129
+ } catch (e) {
1130
+ const errMsg = e.stderr ? e.stderr.toString() : e.message;
1131
+ return res.status(500).json({
1132
+ error: 'Failed to connect Tailscale',
1133
+ details: errMsg
1134
+ });
1135
+ }
1136
+ } catch (err) {
1137
+ console.error('[tailscale] Connect error:', err);
1138
+ res.status(500).json({ error: 'Failed to connect Tailscale', details: err.message });
1139
+ }
1140
+ });
1141
+
1142
+ /**
1143
+ * POST /api/tailscale/install — Install Tailscale on demand (self-install users)
1144
+ */
1145
+ app.post('/api/tailscale/install', (req, res) => {
1146
+ try {
1147
+ // Check if already installed
1148
+ try {
1149
+ execSync('command -v tailscale', { stdio: 'pipe' });
1150
+ return res.json({ success: true, message: 'Tailscale is already installed' });
1151
+ } catch {
1152
+ // Not installed — proceed
1153
+ }
1154
+
1155
+ console.log('[tailscale] Installing Tailscale...');
1156
+ execSync('curl -fsSL https://tailscale.com/install.sh | sh', {
1157
+ stdio: 'pipe',
1158
+ timeout: 120000
1159
+ });
1160
+
1161
+ // Verify install
1162
+ try {
1163
+ execSync('command -v tailscale', { stdio: 'pipe' });
1164
+ } catch {
1165
+ return res.status(500).json({ error: 'Install script ran but tailscale binary not found' });
1166
+ }
1167
+
1168
+ console.log('[tailscale] Installed successfully');
1169
+ res.json({ success: true, message: 'Tailscale installed successfully' });
1170
+ } catch (err) {
1171
+ const errMsg = err.stderr ? err.stderr.toString() : err.message;
1172
+ console.error('[tailscale] Install failed:', errMsg);
1173
+ res.status(500).json({
1174
+ error: 'Failed to install Tailscale',
1175
+ details: errMsg.slice(0, 500)
1176
+ });
1177
+ }
1178
+ });
1179
+
1180
+ /**
1181
+ * POST /api/tailscale/disconnect — Disconnect Tailscale
1182
+ */
1183
+ app.post('/api/tailscale/disconnect', (req, res) => {
1184
+ try {
1185
+ execSync('tailscale down', { stdio: 'pipe', timeout: 10000 });
1186
+ res.json({ success: true, message: 'Tailscale disconnected' });
1187
+ } catch (err) {
1188
+ const errMsg = err.stderr ? err.stderr.toString() : err.message;
1189
+ res.status(500).json({ error: 'Failed to disconnect', details: errMsg });
1190
+ }
1191
+ });
1192
+
1193
+ /**
1194
+ * POST /api/tailscale/setup-serve — Retry Tailscale Serve setup
1195
+ * Used when the user enables Serve on their tailnet and wants to retry
1196
+ */
1197
+ app.post('/api/tailscale/setup-serve', (req, res) => {
1198
+ try {
1199
+ // Get gateway port from config
1200
+ const os = require('os');
1201
+ const configPaths = [
1202
+ os.homedir() + '/.clawdbot/clawdbot.json',
1203
+ os.homedir() + '/.openclaw/openclaw.json'
1204
+ ];
1205
+ const configPath = configPaths.find(p => fs.existsSync(p));
1206
+ let port = 18789;
1207
+ let gatewayToken = null;
1208
+ if (configPath) {
1209
+ try {
1210
+ const gwConfig = JSON.parse(fs.readFileSync(configPath, 'utf8'));
1211
+ port = gwConfig.gateway?.port || 18789;
1212
+ gatewayToken = gwConfig.gateway?.auth?.token || null;
1213
+ } catch (e) {}
1214
+ }
1215
+
1216
+ execSync(`tailscale serve --bg http://127.0.0.1:${port} 2>&1`, {
1217
+ stdio: 'pipe',
1218
+ timeout: 15000
1219
+ });
1220
+
1221
+ // Get DNS name for URL
1222
+ const selfJson = JSON.parse(
1223
+ execSync('tailscale status --json 2>/dev/null', { stdio: 'pipe', timeout: 10000 }).toString()
1224
+ );
1225
+ const dnsName = (selfJson.Self?.DNSName || '').replace(/\.$/, '');
1226
+
1227
+ res.json({
1228
+ status: 'ok',
1229
+ url: dnsName ? `https://${dnsName}/` : null,
1230
+ webchatUrl: dnsName && gatewayToken
1231
+ ? `https://${dnsName}/?token=${gatewayToken}`
1232
+ : (dnsName ? `https://${dnsName}/` : null)
1233
+ });
1234
+ } catch (e) {
1235
+ const msg = (e.stderr ? e.stderr.toString() : '') || e.message;
1236
+ if (msg.includes('not enabled')) {
1237
+ const match = msg.match(/(https:\/\/login\.tailscale\.com\/f\/serve\S+)/);
1238
+ res.json({
1239
+ status: 'not_enabled',
1240
+ enableUrl: match ? match[1] : 'https://login.tailscale.com/admin/settings',
1241
+ error: msg
1242
+ });
1243
+ } else {
1244
+ res.json({ status: 'error', error: msg });
1245
+ }
1246
+ }
1247
+ });
1248
+
1249
+ // ─── Gateway Info API (for Web Chat links) ──────────────────────────────────
1250
+
1251
+ /**
1252
+ * GET /api/gateway/info — Returns gateway port and auth info for Web Chat links
1253
+ */
1254
+ app.get('/api/gateway/info', (req, res) => {
1255
+ const os = require('os');
1256
+ const configPath = path.join(os.homedir(), '.clawdbot', 'clawdbot.json');
1257
+ try {
1258
+ const gwConfig = JSON.parse(fs.readFileSync(configPath, 'utf8'));
1259
+ const gw = gwConfig.gateway || {};
1260
+ const port = gw.port || 18789;
1261
+ const auth = gw.auth || {};
1262
+ const token = auth.token || null;
1263
+
1264
+ // Get Tailscale IP
1265
+ let tailscaleIP = null;
1266
+ try {
1267
+ const tsIP = execSync('tailscale ip -4 2>/dev/null', { stdio: 'pipe', timeout: 5000 }).toString().trim();
1268
+ if (tsIP) tailscaleIP = tsIP;
1269
+ } catch (e) {}
1270
+
1271
+ res.json({
1272
+ port,
1273
+ authMode: auth.mode || 'token',
1274
+ token: token, // Full token — UI masks it
1275
+ tailscaleIP,
1276
+ localUrl: `http://localhost:${port}/`,
1277
+ tailscaleUrl: tailscaleIP ? `http://${tailscaleIP}:${port}/` : null
1278
+ });
1279
+ } catch (e) {
1280
+ res.json({
1281
+ port: 18789,
1282
+ authMode: 'unknown',
1283
+ token: null,
1284
+ tailscaleIP: null,
1285
+ localUrl: 'http://localhost:18789/',
1286
+ tailscaleUrl: null
1287
+ });
1288
+ }
1289
+ });
1290
+
1291
+ // ─── Setup route (always shows wizard, even when configured — for reconfiguration) ──
1292
+
1293
+ app.get('/setup', (req, res) => {
1294
+ res.sendFile(path.join(__dirname, 'public', 'index.html'));
1295
+ });
1296
+
1297
+ // ─── Management Dashboard (always available) ────────────────────────────────
1298
+
1299
+ app.get('/manage', (req, res) => {
1300
+ res.sendFile(path.join(__dirname, 'public', 'manage.html'));
1301
+ });
1302
+
1303
+ app.get('/manage/*', (req, res) => {
1304
+ res.sendFile(path.join(__dirname, 'public', 'manage.html'));
1305
+ });
1306
+
1307
+ // ─── Root route — wizard or proxy ───────────────────────────────────────────
1308
+
1309
+ app.get('/', (req, res, next) => {
1310
+ if (config.isConfigured()) {
1311
+ // Proxy to gateway
1312
+ return proxyMiddleware(req, res, next);
1313
+ }
1314
+ // Serve setup wizard
1315
+ res.sendFile(path.join(__dirname, 'public', 'index.html'));
1316
+ });
1317
+
1318
+ // ─── Everything else — proxy if configured, 404 otherwise ───────────────────
1319
+
1320
+ app.use((req, res, next) => {
1321
+ // Don't proxy API routes or static assets
1322
+ if (req.path.startsWith('/api/') || req.path.startsWith('/css/') || req.path.startsWith('/js/')) {
1323
+ return next();
1324
+ }
1325
+
1326
+ if (config.isConfigured()) {
1327
+ return proxyMiddleware(req, res, next);
1328
+ }
1329
+
1330
+ // Not configured — serve the wizard for any path
1331
+ res.sendFile(path.join(__dirname, 'public', 'index.html'));
1332
+ });
1333
+
1334
+ // ─── Start Server ───────────────────────────────────────────────────────────
1335
+
1336
+ const server = app.listen(PORT, () => {
1337
+ const configured = config.isConfigured();
1338
+ console.log(`
1339
+ 🦞 LobstaKit Cloud v1.0.0
1340
+ ─────────────────────────
1341
+ Port: ${PORT}
1342
+ Status: ${configured ? '✅ Configured' : '⚙️ Setup required'}
1343
+ Gateway: ${gateway.isGatewayRunning() ? '🟢 Running' : '🔴 Stopped'}
1344
+ Dashboard: http://localhost:${PORT}/manage
1345
+ `);
1346
+ });
1347
+
1348
+ // WebSocket upgrade support for proxy
1349
+ server.on('upgrade', (req, socket, head) => {
1350
+ if (config.isConfigured()) {
1351
+ proxyMiddleware.upgrade(req, socket, head);
1352
+ } else {
1353
+ socket.destroy();
1354
+ }
1355
+ });
1356
+
1357
+ module.exports = app;