lobsterboard 0.5.0 → 0.5.2

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.
@@ -1,4 +1,4 @@
1
- /* LobsterBoard v0.5.0 - Dashboard Styles */
1
+ /* LobsterBoard v0.5.2 - Dashboard Styles */
2
2
  /* LobsterBoard Dashboard - Generated Styles */
3
3
 
4
4
  :root {
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * LobsterBoard v0.5.0
2
+ * LobsterBoard v0.5.2
3
3
  * Dashboard builder with customizable widgets
4
4
  * https://github.com/curbob/LobsterBoard
5
5
  * @license MIT
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * LobsterBoard v0.5.0
2
+ * LobsterBoard v0.5.2
3
3
  * Dashboard builder with customizable widgets
4
4
  * https://github.com/curbob/LobsterBoard
5
5
  * @license MIT
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * LobsterBoard v0.5.0
2
+ * LobsterBoard v0.5.2
3
3
  * Dashboard builder with customizable widgets
4
4
  * https://github.com/curbob/LobsterBoard
5
5
  * @license MIT
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * LobsterBoard v0.5.0
2
+ * LobsterBoard v0.5.2
3
3
  * Dashboard builder with customizable widgets
4
4
  * https://github.com/curbob/LobsterBoard
5
5
  * @license MIT
package/js/builder.js CHANGED
@@ -652,7 +652,7 @@ async function loadServersList() {
652
652
  <div class="server-item" style="display:flex;align-items:center;justify-content:space-between;padding:8px 12px;background:var(--bg-tertiary);border-radius:6px;margin-bottom:8px;">
653
653
  <div>
654
654
  <strong style="font-size:13px;">${_escHtml(s.name)}</strong>
655
- ${s.type === 'local' ? '<span style="color:#8b949e;font-size:11px;margin-left:8px;">(built-in)</span>' : `<span style="color:#8b949e;font-size:11px;margin-left:8px;">${_escHtml(s.url || '')}</span>`}
655
+ ${s.type === 'local' ? '<span style="color:#8b949e;font-size:11px;margin-left:8px;">(built-in)</span>' : `<span style="color:#8b949e;font-size:11px;margin-left:8px;">${_escHtml(s.url || '')} ${s.encrypted ? '🔐' : ''}</span>`}
656
656
  </div>
657
657
  <div style="display:flex;gap:6px;">
658
658
  ${s.type !== 'local' ? `
@@ -718,7 +718,8 @@ async function testServerConnection() {
718
718
  });
719
719
  if (res.ok) {
720
720
  const data = await res.json();
721
- resultEl.innerHTML = `<span style="color:#3fb950;">✓ Connected to ${_escHtml(data.serverName || 'server')}</span>`;
721
+ const encStatus = data.encrypted ? ' 🔐' : ' ⚠️ unencrypted';
722
+ resultEl.innerHTML = `<span style="color:#3fb950;">✓ Connected to ${_escHtml(data.serverName || 'server')}${encStatus}</span>`;
722
723
  } else {
723
724
  resultEl.innerHTML = `<span style="color:#f85149;">HTTP ${res.status}</span>`;
724
725
  }
@@ -732,7 +733,8 @@ async function testServer(id) {
732
733
  const res = await fetch(`/api/servers/${id}/test`, { method: 'POST' });
733
734
  const data = await res.json();
734
735
  if (data.status === 'ok') {
735
- alert(`✓ Connected to ${data.serverName || 'server'}`);
736
+ const encStatus = data.localEncryption ? '🔐 Encrypted' : '⚠️ Not encrypted';
737
+ alert(`✓ Connected to ${data.serverName || 'server'}\n${encStatus}`);
736
738
  } else {
737
739
  alert(`Connection failed: ${data.message || 'Unknown error'}`);
738
740
  }
@@ -1546,8 +1548,8 @@ function showProperties(widget) {
1546
1548
  document.getElementById('prop-endpoint').value = widget.properties.endpoint || '';
1547
1549
  }
1548
1550
 
1549
- // Show server dropdown for system widgets
1550
- const systemWidgets = ['uptime-monitor', 'docker-containers', 'disk-usage', 'network-speed', 'cpu-memory'];
1551
+ // Show server dropdown for system/remote widgets
1552
+ const systemWidgets = ['uptime-monitor', 'docker-containers', 'disk-usage', 'network-speed', 'cpu-memory', 'ai-usage', 'openclaw-release', 'auth-status', 'cron-jobs', 'system-log', 'session-count', 'activity-list'];
1551
1553
  const serverGroup = document.getElementById('prop-server-group');
1552
1554
  if (serverGroup && systemWidgets.includes(widget.type)) {
1553
1555
  serverGroup.style.display = 'block';
package/js/widgets.js CHANGED
@@ -446,6 +446,7 @@ const WIDGETS = {
446
446
  apiKeyName: 'OPENCLAW_API',
447
447
  properties: {
448
448
  title: 'Auth Type',
449
+ server: 'local',
449
450
  endpoint: '/api/status',
450
451
  refreshInterval: 30
451
452
  },
@@ -465,15 +466,25 @@ const WIDGETS = {
465
466
  </div>
466
467
  </div>`,
467
468
  generateJs: (props) => `
468
- // Auth Status Widget: ${props.id}
469
+ // Auth Status Widget: ${props.id} — ${props.server === 'local' ? 'local' : 'remote: ' + props.server}
469
470
  async function update_${props.id.replace(/-/g, '_')}() {
471
+ const serverId = '${props.server || 'local'}';
472
+ const dot = document.getElementById('${props.id}-dot');
473
+ const val = document.getElementById('${props.id}-value');
470
474
  try {
471
- const res = await fetch('/api/auth');
472
- const data = await res.json();
473
- const dot = document.getElementById('${props.id}-dot');
474
- const val = document.getElementById('${props.id}-value');
475
- if (data.status === 'ok') {
476
- const isMonthly = data.mode === 'Monthly';
475
+ let authData;
476
+ if (serverId === 'local') {
477
+ const res = await fetch('/api/auth');
478
+ authData = await res.json();
479
+ } else {
480
+ const res = await fetch('/api/servers/' + serverId + '/stats');
481
+ const data = await res.json();
482
+ if (data.error) throw new Error(data.error);
483
+ if (!data.openclaw?.auth) throw new Error('Auth data not available');
484
+ authData = { status: 'ok', mode: data.openclaw.auth.mode };
485
+ }
486
+ if (authData.status === 'ok' || authData.mode) {
487
+ const isMonthly = authData.mode === 'Monthly';
477
488
  val.textContent = isMonthly ? 'Max' : 'API';
478
489
  dot.className = 'kpi-indicator ' + (isMonthly ? 'green' : 'yellow');
479
490
  } else {
@@ -481,7 +492,7 @@ const WIDGETS = {
481
492
  }
482
493
  } catch (e) {
483
494
  console.error('Auth status widget error:', e);
484
- document.getElementById('${props.id}-value').textContent = '—';
495
+ val.textContent = '—';
485
496
  }
486
497
  }
487
498
  update_${props.id.replace(/-/g, '_')}();
@@ -632,6 +643,7 @@ const WIDGETS = {
632
643
  hasApiKey: false,
633
644
  properties: {
634
645
  title: 'OpenClaw',
646
+ server: 'local',
635
647
  openclawUrl: '',
636
648
  refreshInterval: 3600
637
649
  },
@@ -658,21 +670,38 @@ const WIDGETS = {
658
670
  </div>`,
659
671
  generateJs: (props) => `
660
672
  async function update_${props.id.replace(/-/g, '_')}() {
673
+ const serverId = '${props.server || 'local'}';
661
674
  const currentEl = document.getElementById('${props.id}-current');
662
675
  const arrowEl = document.getElementById('${props.id}-arrow');
663
676
  const latestEl = document.getElementById('${props.id}-latest');
664
677
  const statusEl = document.getElementById('${props.id}-status');
665
678
 
666
679
  try {
667
- const res = await fetch('/api/releases');
668
- const data = await res.json();
669
- if (data.status !== 'ok') throw new Error(data.message);
680
+ let cur, lat;
681
+
682
+ if (serverId === 'local') {
683
+ // Local: fetch from /api/releases
684
+ const res = await fetch('/api/releases');
685
+ const data = await res.json();
686
+ if (data.status !== 'ok') throw new Error(data.message);
687
+ cur = (data.current || '').replace(/^v/, '');
688
+ lat = (data.latest || '').replace(/^v/, '');
689
+ } else {
690
+ // Remote: fetch from server stats and get openclaw.version
691
+ const res = await fetch('/api/servers/' + serverId + '/stats');
692
+ const data = await res.json();
693
+ if (data.error) throw new Error(data.error);
694
+ if (!data.openclaw) throw new Error('OpenClaw not installed on remote');
695
+ cur = (data.openclaw.version || '').replace(/^v/, '');
696
+ // Fetch latest from GitHub
697
+ const ghRes = await fetch('https://api.github.com/repos/openclaw/openclaw/releases/latest');
698
+ const ghData = await ghRes.json();
699
+ lat = (ghData.tag_name || '').replace(/^v/, '');
700
+ }
670
701
 
671
- const cur = (data.current || '').replace(/^v/, '');
672
- const lat = (data.latest || '').replace(/^v/, '');
673
702
  // Strip -N suffixes for comparison (e.g. 2026.2.22-2 matches 2026.2.22)
674
- const curBase = cur.replace(/-\d+$/, '');
675
- const latBase = lat.replace(/-\d+$/, '');
703
+ const curBase = cur.replace(/-\\d+$/, '');
704
+ const latBase = lat.replace(/-\\d+$/, '');
676
705
  const isUpToDate = cur === lat || curBase === latBase || cur.startsWith(latBase + '-');
677
706
 
678
707
  if (!cur || cur === 'unknown') {
@@ -695,7 +724,7 @@ const WIDGETS = {
695
724
  }
696
725
  } catch (e) {
697
726
  currentEl.textContent = '—';
698
- statusEl.textContent = 'Error';
727
+ statusEl.textContent = e.message || 'Error';
699
728
  console.error('OpenClaw Release widget error:', e);
700
729
  }
701
730
  }
@@ -840,6 +869,7 @@ const WIDGETS = {
840
869
  apiKeyName: 'OPENCLAW_API',
841
870
  properties: {
842
871
  title: 'Today',
872
+ server: 'local',
843
873
  endpoint: '/api/today',
844
874
  maxItems: 10,
845
875
  refreshInterval: 60
@@ -863,13 +893,22 @@ const WIDGETS = {
863
893
  </div>
864
894
  </div>`,
865
895
  generateJs: (props) => `
866
- // Activity List Widget: ${props.id} (Today style)
896
+ // Activity List Widget: ${props.id} ${props.server === 'local' ? 'local' : 'remote: ' + props.server}
867
897
  async function update_${props.id.replace(/-/g, '_')}() {
898
+ const serverId = '${props.server || 'local'}';
899
+ const list = document.getElementById('${props.id}-list');
900
+ const badge = document.getElementById('${props.id}-badge');
868
901
  try {
869
- const res = await fetch('${props.endpoint || '/api/today'}');
870
- const data = await res.json();
871
- const list = document.getElementById('${props.id}-list');
872
- const badge = document.getElementById('${props.id}-badge');
902
+ let data;
903
+ if (serverId === 'local') {
904
+ const res = await fetch('${props.endpoint || '/api/today'}');
905
+ data = await res.json();
906
+ } else {
907
+ const res = await fetch('/api/servers/' + serverId + '/stats');
908
+ const stats = await res.json();
909
+ if (stats.error) throw new Error(stats.error);
910
+ data = stats.openclaw?.today || { date: new Date().toISOString().split('T')[0], activities: [] };
911
+ }
873
912
 
874
913
  if (data.date && badge) {
875
914
  const d = new Date(data.date + 'T12:00:00');
@@ -892,7 +931,10 @@ const WIDGETS = {
892
931
  '<div style="flex-shrink:0;font-size:0.85em;color:#8b949e;margin-left:8px;">' + _esc(icon) + ' ' + source + '</div>' +
893
932
  '</div>';
894
933
  }).join('');
895
- } catch (e) { console.error('Today widget error:', e); }
934
+ } catch (e) {
935
+ console.error('Today widget error:', e);
936
+ list.innerHTML = '<div style="padding:8px;color:#f85149;font-size:calc(12px * var(--font-scale,1));">Error: ' + _esc(e.message) + '</div>';
937
+ }
896
938
  }
897
939
  update_${props.id.replace(/-/g, '_')}();
898
940
  setInterval(update_${props.id.replace(/-/g, '_')}, ${(props.refreshInterval || 60) * 1000});
@@ -910,6 +952,7 @@ const WIDGETS = {
910
952
  hasApiKey: false,
911
953
  properties: {
912
954
  title: 'AI Usage',
955
+ server: 'local',
913
956
  providers: 'all',
914
957
  hideUnauthenticated: true,
915
958
  showPlan: true,
@@ -931,15 +974,34 @@ const WIDGETS = {
931
974
  </div>
932
975
  </div>`,
933
976
  generateJs: (props) => `
934
- // AI Usage Widget: ${props.id}
977
+ // AI Usage Widget: ${props.id} — ${props.server === 'local' ? 'local' : 'remote: ' + props.server}
935
978
  async function update_${props.id.replace(/-/g, '_')}() {
936
979
  const content = document.getElementById('${props.id}-content');
937
980
  const badge = document.getElementById('${props.id}-badge');
938
981
  try {
982
+ const serverId = '${props.server || 'local'}';
939
983
  const providers = '${props.providers || 'all'}';
940
- const url = providers === 'all' ? '/api/ai-usage' : '/api/ai-usage/' + providers;
941
- const res = await fetch(url);
942
- const json = await res.json();
984
+ let json;
985
+
986
+ if (serverId === 'local') {
987
+ // Local: fetch from /api/ai-usage
988
+ const url = providers === 'all' ? '/api/ai-usage' : '/api/ai-usage/' + providers;
989
+ const res = await fetch(url);
990
+ json = await res.json();
991
+ } else {
992
+ // Remote: fetch from server stats endpoint
993
+ const res = await fetch('/api/servers/' + serverId + '/stats');
994
+ const data = await res.json();
995
+ if (data.error) {
996
+ json = { status: 'error', message: data.error };
997
+ } else if (data.aiUsage && data.aiUsage.providers) {
998
+ json = { status: 'ok', providers: data.aiUsage.providers };
999
+ } else if (data.aiUsage === undefined) {
1000
+ json = { status: 'error', message: 'AI usage not enabled on remote agent (enableAiUsage: false)' };
1001
+ } else {
1002
+ json = { status: 'error', message: 'No AI providers found on remote server' };
1003
+ }
1004
+ }
943
1005
 
944
1006
  if (json.status !== 'ok') {
945
1007
  content.innerHTML = '<div style="color:#f85149;font-size:12px;">' + _esc(json.message || 'Error') + '</div>';
@@ -1430,6 +1492,7 @@ const WIDGETS = {
1430
1492
  hasApiKey: false,
1431
1493
  properties: {
1432
1494
  title: 'Cron',
1495
+ server: 'local',
1433
1496
  endpoint: '/api/cron',
1434
1497
  columns: 1,
1435
1498
  refreshInterval: 30
@@ -1451,14 +1514,24 @@ const WIDGETS = {
1451
1514
  </div>
1452
1515
  </div>`,
1453
1516
  generateJs: (props) => `
1454
- // Cron Jobs Widget: ${props.id}
1517
+ // Cron Jobs Widget: ${props.id} — ${props.server === 'local' ? 'local' : 'remote: ' + props.server}
1455
1518
  async function update_${props.id.replace(/-/g, '_')}() {
1519
+ const serverId = '${props.server || 'local'}';
1520
+ const list = document.getElementById('${props.id}-list');
1521
+ const badge = document.getElementById('${props.id}-badge');
1456
1522
  try {
1457
- const res = await fetch('${props.endpoint || '/api/cron'}');
1458
- const json = await res.json();
1459
- const jobs = json.jobs || [];
1460
- const list = document.getElementById('${props.id}-list');
1461
- const badge = document.getElementById('${props.id}-badge');
1523
+ let jobs;
1524
+ if (serverId === 'local') {
1525
+ const res = await fetch('${props.endpoint || '/api/cron'}');
1526
+ const json = await res.json();
1527
+ jobs = json.jobs || [];
1528
+ } else {
1529
+ const res = await fetch('/api/servers/' + serverId + '/stats');
1530
+ const data = await res.json();
1531
+ if (data.error) throw new Error(data.error);
1532
+ if (!data.openclaw?.cron) throw new Error('Cron data not available');
1533
+ jobs = data.openclaw.cron.jobs || [];
1534
+ }
1462
1535
  if (!jobs.length) {
1463
1536
  list.innerHTML = '<div class="cron-item"><span class="cron-name" style="opacity:0.5;">No cron jobs found</span></div>';
1464
1537
  badge.textContent = '0';
@@ -1486,7 +1559,7 @@ const WIDGETS = {
1486
1559
  badge.textContent = jobs.length + ' jobs';
1487
1560
  } catch (e) {
1488
1561
  console.error('Cron jobs widget error:', e);
1489
- document.getElementById('${props.id}-list').innerHTML = '<div class="cron-item"><span class="cron-name">Error loading</span></div>';
1562
+ list.innerHTML = '<div class="cron-item"><span class="cron-name">Error: ' + _esc(e.message) + '</span></div>';
1490
1563
  }
1491
1564
  }
1492
1565
  update_${props.id.replace(/-/g, '_')}();
@@ -1505,6 +1578,7 @@ const WIDGETS = {
1505
1578
  hasApiKey: false,
1506
1579
  properties: {
1507
1580
  title: 'System Log',
1581
+ server: 'local',
1508
1582
  endpoint: '/api/system-log',
1509
1583
  maxLines: 50,
1510
1584
  refreshInterval: 10
@@ -1537,20 +1611,29 @@ const WIDGETS = {
1537
1611
  if (level === 'OK') return 'ok';
1538
1612
  return 'info';
1539
1613
  }
1614
+ // System Log Widget: ${props.id} — ${props.server === 'local' ? 'local' : 'remote: ' + props.server}
1540
1615
  async function update_${props.id.replace(/-/g, '_')}() {
1616
+ const serverId = '${props.server || 'local'}';
1541
1617
  try {
1542
- const res = await fetch('${props.endpoint || '/api/system-log'}?max=${props.maxLines || 50}');
1543
- const json = await res.json();
1544
- // Handle both new format (json.entries) and old format (json.lines)
1545
- let entries = json.entries || [];
1546
- if (!entries.length && json.lines && json.lines.length) {
1547
- entries = json.lines.map(line => {
1548
- let level = 'INFO';
1549
- if (/\\b(error|fatal)\\b/i.test(line)) level = 'ERROR';
1550
- else if (/\\bwarn/i.test(line)) level = 'WARN';
1551
- else if (/\\b(ok|success|ready|started)\\b/i.test(line)) level = 'OK';
1552
- return { time: new Date().toISOString(), level, category: 'system', message: line };
1553
- });
1618
+ let entries = [];
1619
+ if (serverId === 'local') {
1620
+ const res = await fetch('${props.endpoint || '/api/system-log'}?max=${props.maxLines || 50}');
1621
+ const json = await res.json();
1622
+ entries = json.entries || [];
1623
+ if (!entries.length && json.lines && json.lines.length) {
1624
+ entries = json.lines.map(line => {
1625
+ let level = 'INFO';
1626
+ if (/\\b(error|fatal)\\b/i.test(line)) level = 'ERROR';
1627
+ else if (/\\bwarn/i.test(line)) level = 'WARN';
1628
+ else if (/\\b(ok|success|ready|started)\\b/i.test(line)) level = 'OK';
1629
+ return { time: new Date().toISOString(), level, category: 'system', message: line };
1630
+ });
1631
+ }
1632
+ } else {
1633
+ const res = await fetch('/api/servers/' + serverId + '/stats');
1634
+ const data = await res.json();
1635
+ if (data.error) throw new Error(data.error);
1636
+ entries = data.openclaw?.systemLog?.entries || [];
1554
1637
  }
1555
1638
  const log = document.getElementById('${props.id}-log');
1556
1639
  const badge = document.getElementById('${props.id}-badge');
@@ -2115,6 +2198,7 @@ const WIDGETS = {
2115
2198
  apiKeyName: 'OPENCLAW_API',
2116
2199
  properties: {
2117
2200
  title: 'Sessions',
2201
+ server: 'local',
2118
2202
  endpoint: '/api/sessions',
2119
2203
  refreshInterval: 30
2120
2204
  },
@@ -2133,13 +2217,23 @@ const WIDGETS = {
2133
2217
  </div>
2134
2218
  </div>`,
2135
2219
  generateJs: (props) => `
2136
- // Session Count Widget: ${props.id}
2220
+ // Session Count Widget: ${props.id} — ${props.server === 'local' ? 'local' : 'remote: ' + props.server}
2137
2221
  async function update_${props.id.replace(/-/g, '_')}() {
2222
+ const serverId = '${props.server || 'local'}';
2138
2223
  try {
2139
- const res = await fetch('${props.endpoint || '/api/sessions'}');
2140
- const json = await res.json();
2141
- const data = json.data || json;
2142
- document.getElementById('${props.id}-count').textContent = data.active || data.length || 0;
2224
+ let count;
2225
+ if (serverId === 'local') {
2226
+ const res = await fetch('${props.endpoint || '/api/sessions'}');
2227
+ const json = await res.json();
2228
+ const data = json.data || json;
2229
+ count = data.active || data.length || 0;
2230
+ } else {
2231
+ const res = await fetch('/api/servers/' + serverId + '/stats');
2232
+ const data = await res.json();
2233
+ if (data.error) throw new Error(data.error);
2234
+ count = data.openclaw?.sessions?.active || data.openclaw?.sessions?.recent24h || 0;
2235
+ }
2236
+ document.getElementById('${props.id}-count').textContent = count;
2143
2237
  } catch (e) {
2144
2238
  document.getElementById('${props.id}-count').textContent = '—';
2145
2239
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lobsterboard",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "Self-hosted drag-and-drop dashboard builder with 50 widgets, template gallery, and custom pages. Works standalone or with OpenClaw.",
5
5
  "keywords": [
6
6
  "dashboard",
package/server.cjs CHANGED
@@ -11,6 +11,47 @@ const fs = require('fs');
11
11
  const path = require('path');
12
12
  const os = require('os');
13
13
  const si = require('systeminformation');
14
+ const crypto = require('crypto');
15
+
16
+ // ─────────────────────────────────────────────
17
+ // ECDH Crypto utilities for encrypted agent communication
18
+ // ─────────────────────────────────────────────
19
+ const ECDH_CURVE = 'prime256v1';
20
+ const AES_ALGORITHM = 'aes-256-gcm';
21
+ const IV_LENGTH = 12;
22
+ const AUTH_TAG_LENGTH = 16;
23
+
24
+ function generateEcdhKeyPair() {
25
+ const ecdh = crypto.createECDH(ECDH_CURVE);
26
+ ecdh.generateKeys();
27
+ return {
28
+ publicKey: ecdh.getPublicKey('base64'),
29
+ privateKey: ecdh.getPrivateKey('base64'),
30
+ };
31
+ }
32
+
33
+ function deriveSharedSecret(privateKeyBase64, theirPublicKeyBase64) {
34
+ const ecdh = crypto.createECDH(ECDH_CURVE);
35
+ ecdh.setPrivateKey(Buffer.from(privateKeyBase64, 'base64'));
36
+ const sharedPoint = ecdh.computeSecret(Buffer.from(theirPublicKeyBase64, 'base64'));
37
+ return crypto.createHash('sha256').update(sharedPoint).digest();
38
+ }
39
+
40
+ function decryptPayload(encryptedBase64, keyBuffer) {
41
+ const packed = Buffer.from(encryptedBase64, 'base64');
42
+ const iv = packed.subarray(0, IV_LENGTH);
43
+ const authTag = packed.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH);
44
+ const ciphertext = packed.subarray(IV_LENGTH + AUTH_TAG_LENGTH);
45
+
46
+ const decipher = crypto.createDecipheriv(AES_ALGORITHM, keyBuffer, iv, { authTagLength: AUTH_TAG_LENGTH });
47
+ decipher.setAuthTag(authTag);
48
+
49
+ const decrypted = Buffer.concat([
50
+ decipher.update(ciphertext),
51
+ decipher.final(),
52
+ ]);
53
+ return JSON.parse(decrypted.toString('utf8'));
54
+ }
14
55
 
15
56
  const PORT = process.env.PORT || 8080;
16
57
  const HOST = process.env.HOST || '127.0.0.1';
@@ -300,7 +341,6 @@ const SECRETS_FILE = path.join(__dirname, 'secrets.json');
300
341
  // ─────────────────────────────────────────────
301
342
  // Server-side Session Authentication
302
343
  // ─────────────────────────────────────────────
303
- const crypto = require('crypto');
304
344
 
305
345
  const DASHBOARD_PASSWORD = process.env.DASHBOARD_PASSWORD || null;
306
346
  const SESSION_TTL_MS = (parseInt(process.env.SESSION_TTL_HOURS) || 24) * 60 * 60 * 1000;
@@ -1839,10 +1879,11 @@ const server = http.createServer(async (req, res) => {
1839
1879
  // GET /api/servers - List all servers
1840
1880
  if (req.method === 'GET' && pathname === '/api/servers') {
1841
1881
  const servers = loadServers();
1842
- // Mask API keys for security
1882
+ // Mask API keys and secrets for security
1843
1883
  const masked = servers.map(s => ({
1844
1884
  ...s,
1845
- apiKey: s.apiKey ? s.apiKey.slice(0, 10) + '...' : undefined
1885
+ apiKey: s.apiKey ? s.apiKey.slice(0, 10) + '...' : undefined,
1886
+ sharedSecret: s.sharedSecret ? '🔐' : undefined, // Just indicate presence
1846
1887
  }));
1847
1888
  sendJson(res, 200, { servers: masked });
1848
1889
  return;
@@ -1852,7 +1893,7 @@ const server = http.createServer(async (req, res) => {
1852
1893
  if (req.method === 'POST' && pathname === '/api/servers') {
1853
1894
  let body = '';
1854
1895
  req.on('data', c => body += c);
1855
- req.on('end', () => {
1896
+ req.on('end', async () => {
1856
1897
  try {
1857
1898
  const { name, url, apiKey } = JSON.parse(body);
1858
1899
  if (!name || !url || !apiKey) {
@@ -1863,9 +1904,58 @@ const server = http.createServer(async (req, res) => {
1863
1904
  if (servers.find(s => s.id === id)) {
1864
1905
  return sendJson(res, 400, { error: 'Server with this name already exists' });
1865
1906
  }
1866
- servers.push({ id, name, url, apiKey, type: 'remote' });
1907
+
1908
+ // Generate ECDH key pair for encrypted communication
1909
+ const keyPair = generateEcdhKeyPair();
1910
+
1911
+ // Perform handshake with agent
1912
+ let sharedSecret = null;
1913
+ let encrypted = false;
1914
+ try {
1915
+ const handshakeRes = await fetch(url + '/handshake', {
1916
+ method: 'POST',
1917
+ headers: {
1918
+ 'X-API-Key': apiKey,
1919
+ 'Content-Type': 'application/json',
1920
+ },
1921
+ body: JSON.stringify({
1922
+ clientId: id,
1923
+ publicKey: keyPair.publicKey,
1924
+ }),
1925
+ signal: AbortSignal.timeout(10000),
1926
+ });
1927
+
1928
+ if (handshakeRes.ok) {
1929
+ const handshakeData = await handshakeRes.json();
1930
+ if (handshakeData.publicKey) {
1931
+ sharedSecret = deriveSharedSecret(keyPair.privateKey, handshakeData.publicKey);
1932
+ encrypted = true;
1933
+ console.log(`🔐 Encrypted connection established with server: ${name}`);
1934
+ }
1935
+ }
1936
+ } catch (e) {
1937
+ // Handshake failed - agent may not support encryption, continue without it
1938
+ console.log(`⚠️ Handshake failed for ${name}, using unencrypted: ${e.message}`);
1939
+ }
1940
+
1941
+ const serverEntry = {
1942
+ id,
1943
+ name,
1944
+ url,
1945
+ apiKey,
1946
+ type: 'remote',
1947
+ encrypted,
1948
+ };
1949
+
1950
+ // Store shared secret (base64 encoded) if encryption is enabled
1951
+ if (sharedSecret) {
1952
+ serverEntry.sharedSecret = sharedSecret.toString('base64');
1953
+ serverEntry.clientId = id;
1954
+ }
1955
+
1956
+ servers.push(serverEntry);
1867
1957
  saveServers(servers);
1868
- sendJson(res, 200, { status: 'success', id });
1958
+ sendJson(res, 200, { status: 'success', id, encrypted });
1869
1959
  } catch (e) {
1870
1960
  sendJson(res, 400, { error: e.message });
1871
1961
  }
@@ -1924,11 +2014,72 @@ const server = http.createServer(async (req, res) => {
1924
2014
  signal: AbortSignal.timeout(5000),
1925
2015
  })
1926
2016
  .then(r => r.json())
1927
- .then(data => sendJson(res, 200, { status: 'ok', serverName: data.serverName }))
2017
+ .then(data => sendJson(res, 200, {
2018
+ status: 'ok',
2019
+ serverName: data.serverName,
2020
+ agentEncryption: data.encrypted || false,
2021
+ localEncryption: server.encrypted || false,
2022
+ }))
1928
2023
  .catch(e => sendJson(res, 200, { status: 'error', message: e.message }));
1929
2024
  return;
1930
2025
  }
1931
2026
 
2027
+ // POST /api/servers/:id/handshake - Re-establish encryption with a server
2028
+ if (req.method === 'POST' && pathname.match(/^\/api\/servers\/[^/]+\/handshake$/)) {
2029
+ const id = pathname.split('/')[3];
2030
+ const servers = loadServers();
2031
+ const serverIdx = servers.findIndex(s => s.id === id);
2032
+ if (serverIdx === -1) return sendJson(res, 404, { error: 'Server not found' });
2033
+ const server = servers[serverIdx];
2034
+ if (server.type === 'local') {
2035
+ return sendJson(res, 400, { error: 'Local server does not need handshake' });
2036
+ }
2037
+
2038
+ (async () => {
2039
+ try {
2040
+ // Generate new key pair
2041
+ const keyPair = generateEcdhKeyPair();
2042
+
2043
+ const handshakeRes = await fetch(server.url + '/handshake', {
2044
+ method: 'POST',
2045
+ headers: {
2046
+ 'X-API-Key': server.apiKey,
2047
+ 'Content-Type': 'application/json',
2048
+ },
2049
+ body: JSON.stringify({
2050
+ clientId: id,
2051
+ publicKey: keyPair.publicKey,
2052
+ }),
2053
+ signal: AbortSignal.timeout(10000),
2054
+ });
2055
+
2056
+ if (!handshakeRes.ok) {
2057
+ const err = await handshakeRes.json().catch(() => ({ error: 'HTTP ' + handshakeRes.status }));
2058
+ return sendJson(res, 500, { error: err.error || 'Handshake failed' });
2059
+ }
2060
+
2061
+ const handshakeData = await handshakeRes.json();
2062
+ if (!handshakeData.publicKey) {
2063
+ return sendJson(res, 500, { error: 'Agent did not return public key' });
2064
+ }
2065
+
2066
+ const sharedSecret = deriveSharedSecret(keyPair.privateKey, handshakeData.publicKey);
2067
+
2068
+ // Update server config
2069
+ servers[serverIdx].encrypted = true;
2070
+ servers[serverIdx].sharedSecret = sharedSecret.toString('base64');
2071
+ servers[serverIdx].clientId = id;
2072
+ saveServers(servers);
2073
+
2074
+ console.log(`🔐 Re-established encrypted connection with server: ${server.name}`);
2075
+ sendJson(res, 200, { status: 'ok', encrypted: true });
2076
+ } catch (e) {
2077
+ sendJson(res, 500, { error: e.message });
2078
+ }
2079
+ })();
2080
+ return;
2081
+ }
2082
+
1932
2083
  // GET /api/servers/:id/stats - Fetch stats from a remote server
1933
2084
  if (req.method === 'GET' && pathname.match(/^\/api\/servers\/[^/]+\/stats$/)) {
1934
2085
  const id = pathname.split('/')[3];
@@ -1938,16 +2089,40 @@ const server = http.createServer(async (req, res) => {
1938
2089
  if (server.type === 'local') {
1939
2090
  return sendJson(res, 400, { error: 'Use /api/stats/stream for local' });
1940
2091
  }
2092
+
2093
+ // Build headers - include client ID if we have encryption set up
2094
+ const headers = { 'X-API-Key': server.apiKey };
2095
+ if (server.encrypted && server.clientId) {
2096
+ headers['X-Client-ID'] = server.clientId;
2097
+ }
2098
+
1941
2099
  // Fetch from remote agent
1942
2100
  fetch(server.url + '/stats', {
1943
- headers: { 'X-API-Key': server.apiKey },
2101
+ headers,
1944
2102
  signal: AbortSignal.timeout(10000),
1945
2103
  })
1946
2104
  .then(r => {
1947
2105
  if (!r.ok) throw new Error('HTTP ' + r.status);
1948
2106
  return r.json();
1949
2107
  })
1950
- .then(data => sendJson(res, 200, data))
2108
+ .then(data => {
2109
+ // Decrypt if response is encrypted
2110
+ if (data.encrypted && server.sharedSecret) {
2111
+ try {
2112
+ const keyBuffer = Buffer.from(server.sharedSecret, 'base64');
2113
+ const decrypted = decryptPayload(data.encrypted, keyBuffer);
2114
+ decrypted._remote = true;
2115
+ decrypted._encrypted = true;
2116
+ return sendJson(res, 200, decrypted);
2117
+ } catch (e) {
2118
+ console.error('Decryption failed:', e.message);
2119
+ return sendJson(res, 500, { error: 'Decryption failed: ' + e.message });
2120
+ }
2121
+ }
2122
+ // Plain response (backward compatible)
2123
+ data._remote = true;
2124
+ sendJson(res, 200, data);
2125
+ })
1951
2126
  .catch(e => sendJson(res, 500, { error: e.message }));
1952
2127
  return;
1953
2128
  }
@@ -2439,20 +2614,26 @@ const server = http.createServer(async (req, res) => {
2439
2614
  try {
2440
2615
  let currentVersion = 'unknown';
2441
2616
  try {
2442
- // Use process.execPath to find the node binary's lib directory
2443
- const nodeDir = path.dirname(path.dirname(process.execPath)); // e.g. /Users/x/.nvm/versions/node/v24.13.0
2444
- const candidates = [
2445
- path.join(nodeDir, 'lib/node_modules/openclaw/package.json'),
2446
- path.join(os.homedir(), '.nvm/versions/node', process.version, 'lib/node_modules/openclaw/package.json'),
2447
- '/usr/local/lib/node_modules/openclaw/package.json'
2448
- ];
2449
- for (const cand of candidates) {
2450
- try {
2451
- currentVersion = JSON.parse(fs.readFileSync(cand, 'utf8')).version;
2452
- break;
2453
- } catch (_) {}
2454
- }
2455
- } catch (_) {}
2617
+ // Run openclaw --version to get the actual running version
2618
+ const { execSync } = require('child_process');
2619
+ currentVersion = execSync('openclaw --version 2>/dev/null', { encoding: 'utf8', timeout: 5000 }).trim();
2620
+ } catch (_) {
2621
+ // Fallback: try reading from package.json
2622
+ try {
2623
+ const nodeDir = path.dirname(path.dirname(process.execPath));
2624
+ const candidates = [
2625
+ path.join(nodeDir, 'lib/node_modules/openclaw/package.json'),
2626
+ path.join(os.homedir(), '.nvm/versions/node', process.version, 'lib/node_modules/openclaw/package.json'),
2627
+ '/usr/local/lib/node_modules/openclaw/package.json'
2628
+ ];
2629
+ for (const cand of candidates) {
2630
+ try {
2631
+ currentVersion = JSON.parse(fs.readFileSync(cand, 'utf8')).version;
2632
+ break;
2633
+ } catch (_) {}
2634
+ }
2635
+ } catch (_) {}
2636
+ }
2456
2637
 
2457
2638
  const ghRes = await fetch('https://api.github.com/repos/openclaw/openclaw/releases/latest');
2458
2639
  const ghData = await ghRes.json();