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.
- package/dist/lobsterboard.css +1 -1
- package/dist/lobsterboard.esm.js +1 -1
- package/dist/lobsterboard.esm.min.js +1 -1
- package/dist/lobsterboard.umd.js +1 -1
- package/dist/lobsterboard.umd.min.js +1 -1
- package/js/builder.js +7 -5
- package/js/widgets.js +144 -50
- package/package.json +1 -1
- package/server.cjs +204 -23
package/dist/lobsterboard.css
CHANGED
package/dist/lobsterboard.esm.js
CHANGED
package/dist/lobsterboard.umd.js
CHANGED
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
668
|
-
|
|
669
|
-
if (
|
|
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(
|
|
675
|
-
const latBase = lat.replace(
|
|
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}
|
|
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
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
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) {
|
|
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
|
-
|
|
941
|
-
|
|
942
|
-
|
|
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
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
entries
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
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
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
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
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
|
-
|
|
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, {
|
|
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
|
|
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 =>
|
|
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
|
-
//
|
|
2443
|
-
const
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
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();
|