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/bin/lobstakit.js +2 -0
- package/lib/config.js +176 -0
- package/lib/gateway.js +104 -0
- package/lib/proxy.js +33 -0
- package/package.json +16 -0
- package/public/css/styles.css +579 -0
- package/public/index.html +507 -0
- package/public/js/app.js +198 -0
- package/public/js/login.js +93 -0
- package/public/js/manage.js +1274 -0
- package/public/js/setup.js +755 -0
- package/public/login.html +73 -0
- package/public/manage.html +734 -0
- package/server.js +1357 -0
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;
|