@bakapiano/ccsm 0.17.11 → 0.18.1
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/lib/devices.js +215 -0
- package/lib/tunnel.js +253 -0
- package/package.json +1 -1
- package/public/css/layout.css +7 -0
- package/public/css/responsive.css +123 -3
- package/public/css/terminals.css +15 -1
- package/public/css/wco.css +14 -13
- package/public/css/widgets.css +276 -2
- package/public/js/api.js +43 -2
- package/public/js/backend.js +66 -10
- package/public/js/components/App.js +38 -2
- package/public/js/components/HealthOverlay.js +12 -0
- package/public/js/components/MobileNavFab.js +29 -0
- package/public/js/components/PendingApprovalOverlay.js +86 -0
- package/public/js/components/Sidebar.js +13 -4
- package/public/js/components/TerminalView.js +19 -3
- package/public/js/icons.js +24 -0
- package/public/js/main.js +94 -11
- package/public/js/pages/RemotePage.js +446 -0
- package/public/js/state.js +10 -0
- package/scripts/dev.js +11 -0
- package/server.js +214 -8
package/server.js
CHANGED
|
@@ -16,6 +16,8 @@ const {
|
|
|
16
16
|
const webTerminal = require('./lib/webTerminal');
|
|
17
17
|
const persistedSessions = require('./lib/persistedSessions');
|
|
18
18
|
const folders = require('./lib/folders');
|
|
19
|
+
const tunnel = require('./lib/tunnel');
|
|
20
|
+
const devices = require('./lib/devices');
|
|
19
21
|
// Upstream CLI session-id capture used to live in lib/cliSessionWatcher
|
|
20
22
|
// (poll the CLI's transcript dir, match by cwd). It's gone now — for
|
|
21
23
|
// CLIs that expose a "set the UUID for a new session" flag (claude +
|
|
@@ -44,6 +46,7 @@ async function gracefulShutdown(reason) {
|
|
|
44
46
|
}
|
|
45
47
|
} catch {}
|
|
46
48
|
try { webTerminal.killAll(); } catch {}
|
|
49
|
+
try { tunnel.stop(); } catch {}
|
|
47
50
|
process.exit(0);
|
|
48
51
|
}
|
|
49
52
|
|
|
@@ -63,13 +66,87 @@ app.use((req, res, next) => {
|
|
|
63
66
|
if (origin && ALLOWED_ORIGINS.has(origin)) {
|
|
64
67
|
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
65
68
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
|
66
|
-
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
69
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Device-Id');
|
|
67
70
|
res.setHeader('Vary', 'Origin');
|
|
68
71
|
}
|
|
69
72
|
if (req.method === 'OPTIONS') return res.sendStatus(204);
|
|
70
73
|
next();
|
|
71
74
|
});
|
|
72
75
|
|
|
76
|
+
// Remote-access token guard. Once a token is set (via the Remote page
|
|
77
|
+
// → POST /api/tunnel/start), any /api/* request that wasn't direct
|
|
78
|
+
// loopback must present the token either as `Authorization: Bearer
|
|
79
|
+
// <token>` or `?token=<token>`.
|
|
80
|
+
// "Direct loopback" = the Host header is loopback-shaped AND no
|
|
81
|
+
// proxy injected an X-Forwarded-* header. devtunnel rewrites Host
|
|
82
|
+
// to `localhost:7788` (it's reverse-proxying via a local socket) but
|
|
83
|
+
// adds `x-forwarded-host` / `x-forwarded-for`; cloudflared does the
|
|
84
|
+
// same with `cf-connecting-ip` etc. Either header's mere presence
|
|
85
|
+
// flips us into "treat as remote" mode regardless of Host. Real
|
|
86
|
+
// browsers on the host machine set neither.
|
|
87
|
+
// /api/health is exempt so tunnel URL probes keep working without
|
|
88
|
+
// leaking the token.
|
|
89
|
+
function isDirectLoopback(req) {
|
|
90
|
+
if (req.headers['x-forwarded-host']) return false;
|
|
91
|
+
if (req.headers['x-forwarded-for']) return false;
|
|
92
|
+
if (req.headers['cf-connecting-ip']) return false;
|
|
93
|
+
const host = String(req.headers.host || '').toLowerCase();
|
|
94
|
+
return /^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/.test(host);
|
|
95
|
+
}
|
|
96
|
+
// Device-approval gate.
|
|
97
|
+
//
|
|
98
|
+
// Two-stage trust model:
|
|
99
|
+
// 1. NEW device wants in → must hit /api/devices/me with a valid
|
|
100
|
+
// token. That's the only place new pending records get created
|
|
101
|
+
// (see the handler below). Without the token, a stranger can't
|
|
102
|
+
// flood the host's pending queue with random device ids.
|
|
103
|
+
// 2. ALREADY-known device → its UUID is the credential. The host
|
|
104
|
+
// Approved it once; subsequent calls go through with just the
|
|
105
|
+
// X-Device-Id header (no token needed). Rotating the host token
|
|
106
|
+
// doesn't break existing approved devices — they keep working
|
|
107
|
+
// until the host explicitly Revokes them.
|
|
108
|
+
//
|
|
109
|
+
// This middleware reads-only: it never inserts. Unknown ids → 401 to
|
|
110
|
+
// nudge the caller to re-arrive via the share URL (which lands them
|
|
111
|
+
// on /api/devices/me with the embedded token and registers them).
|
|
112
|
+
const DEVICE_EXEMPT_PATHS = new Set(['/api/health', '/api/devices/me']);
|
|
113
|
+
async function deviceGate(req, res, next) {
|
|
114
|
+
if (DEVICE_EXEMPT_PATHS.has(req.path)) return next();
|
|
115
|
+
if (!req.path.startsWith('/api/')) return next();
|
|
116
|
+
if (isDirectLoopback(req)) return next();
|
|
117
|
+
const id = String(req.headers['x-device-id'] || (req.query && req.query.device) || '');
|
|
118
|
+
if (!id) return res.status(400).json({ error: 'device id required' });
|
|
119
|
+
const d = await devices.get(id);
|
|
120
|
+
if (!d) return res.status(401).json({ error: 'unknown device · open the share URL to register' });
|
|
121
|
+
// Bump lastSeen via record() — it short-circuits the write when the
|
|
122
|
+
// last flush was recent (see MIN_FLUSH_MS in lib/devices.js).
|
|
123
|
+
try { await devices.record(id, {
|
|
124
|
+
userAgent: req.headers['user-agent'] || '',
|
|
125
|
+
ip: String(req.headers['x-forwarded-for'] || req.socket.remoteAddress || '').split(',')[0].trim(),
|
|
126
|
+
}); } catch { /* lastSeen bump is best-effort */ }
|
|
127
|
+
if (d.status === 'approved') return next();
|
|
128
|
+
return res.status(403).json({
|
|
129
|
+
error: d.status === 'rejected' ? 'device rejected by host' : 'pending host approval',
|
|
130
|
+
pending: d.status === 'pending',
|
|
131
|
+
rejected: d.status === 'rejected',
|
|
132
|
+
deviceId: d.id,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
app.use(deviceGate);
|
|
136
|
+
|
|
137
|
+
// Final admin lock — all device management + tunnel-mutating routes are
|
|
138
|
+
// loopback-only. The remote browser already only sees /api/health and
|
|
139
|
+
// /api/devices/me through the gates above; this stops a remote from
|
|
140
|
+
// trying to enumerate or manipulate the approval list even if they
|
|
141
|
+
// somehow got past everything.
|
|
142
|
+
const HOST_ONLY_PREFIXES = ['/api/devices', '/api/tunnel'];
|
|
143
|
+
app.use((req, res, next) => {
|
|
144
|
+
if (!HOST_ONLY_PREFIXES.some((p) => req.path === p || req.path.startsWith(p + '/'))) return next();
|
|
145
|
+
if (req.path === '/api/devices/me') return next();
|
|
146
|
+
if (isDirectLoopback(req)) return next();
|
|
147
|
+
res.status(403).json({ error: 'host-only endpoint' });
|
|
148
|
+
});
|
|
149
|
+
|
|
73
150
|
// Dev mode = running from a checkout (not from an npm-install location).
|
|
74
151
|
// Used to gate two things: (a) serving static frontend from local public/
|
|
75
152
|
// so a contributor can iterate without pushing to GH Pages; (b) hot-reload
|
|
@@ -78,8 +155,20 @@ app.use((req, res, next) => {
|
|
|
78
155
|
// frontend lives at https://bakapiano.github.io/ccsm/ (router → per-version).
|
|
79
156
|
const IS_DEV = !__dirname.includes(`${path.sep}node_modules${path.sep}`) && process.env.CCSM_NO_DEV !== '1';
|
|
80
157
|
|
|
81
|
-
|
|
82
|
-
|
|
158
|
+
// Always serve public/ when it exists alongside server.js. In a
|
|
159
|
+
// checkout this is the live frontend used during dev. In an npm
|
|
160
|
+
// install this lets a tunneled session (Remote page) reach the
|
|
161
|
+
// frontend at the tunnel URL — the GH Pages hosted frontend is
|
|
162
|
+
// unreachable to a phone on cellular, but the locally-bundled
|
|
163
|
+
// public/ shipped in the package IS, via the tunnel. Same files
|
|
164
|
+
// either way; just no version router in front.
|
|
165
|
+
{
|
|
166
|
+
const publicDir = path.join(__dirname, 'public');
|
|
167
|
+
try {
|
|
168
|
+
if (require('node:fs').statSync(publicDir).isDirectory()) {
|
|
169
|
+
app.use(express.static(publicDir));
|
|
170
|
+
}
|
|
171
|
+
} catch { /* not bundled · API-only mode */ }
|
|
83
172
|
}
|
|
84
173
|
|
|
85
174
|
const reloadClients = new Set();
|
|
@@ -939,6 +1028,108 @@ app.post('/api/shutdown', (_req, res) => {
|
|
|
939
1028
|
setImmediate(() => gracefulShutdown('/api/shutdown'));
|
|
940
1029
|
});
|
|
941
1030
|
|
|
1031
|
+
// ---- remote / tunnel ----
|
|
1032
|
+
//
|
|
1033
|
+
// Lifecycle: the Remote page POSTs /start with { provider, token } —
|
|
1034
|
+
// we save the token (used by the middleware above for auth) and spawn
|
|
1035
|
+
// the chosen tunnel CLI. URL appears asynchronously in the CLI's
|
|
1036
|
+
// stdout; lib/tunnel parses it. /status returns the latest snapshot
|
|
1037
|
+
// for the page to poll.
|
|
1038
|
+
app.get('/api/tunnel/status', (_req, res) => {
|
|
1039
|
+
res.json(tunnel.status());
|
|
1040
|
+
});
|
|
1041
|
+
app.post('/api/tunnel/start', asyncH(async (req, res) => {
|
|
1042
|
+
const { provider, token } = req.body || {};
|
|
1043
|
+
if (!token || String(token).length < 8) {
|
|
1044
|
+
return res.status(400).json({ error: 'token required (≥ 8 chars)' });
|
|
1045
|
+
}
|
|
1046
|
+
tunnel.setToken(token);
|
|
1047
|
+
try {
|
|
1048
|
+
const result = await tunnel.start({ provider, port: currentPort });
|
|
1049
|
+
res.json(result);
|
|
1050
|
+
} catch (e) {
|
|
1051
|
+
res.status(400).json({ error: e.message, providers: tunnel.probe() });
|
|
1052
|
+
}
|
|
1053
|
+
}));
|
|
1054
|
+
app.post('/api/tunnel/stop', (_req, res) => {
|
|
1055
|
+
const stopped = tunnel.stop();
|
|
1056
|
+
res.json({ stopped, ...tunnel.status() });
|
|
1057
|
+
});
|
|
1058
|
+
app.post('/api/tunnel/token', (req, res) => {
|
|
1059
|
+
// Bare token update without touching the running tunnel.
|
|
1060
|
+
// POST { token: '' } to clear and disable remote auth.
|
|
1061
|
+
const t = (req.body && req.body.token) || '';
|
|
1062
|
+
tunnel.setToken(t);
|
|
1063
|
+
res.json(tunnel.status());
|
|
1064
|
+
});
|
|
1065
|
+
app.post('/api/tunnel/install', asyncH(async (req, res) => {
|
|
1066
|
+
const { provider } = req.body || {};
|
|
1067
|
+
try {
|
|
1068
|
+
const r = tunnel.installViaWinget(provider);
|
|
1069
|
+
res.json({ ok: true, ...r });
|
|
1070
|
+
} catch (e) {
|
|
1071
|
+
res.status(400).json({ error: e.message });
|
|
1072
|
+
}
|
|
1073
|
+
}));
|
|
1074
|
+
|
|
1075
|
+
// ---- devices ----
|
|
1076
|
+
//
|
|
1077
|
+
// /api/devices/me is callable from the remote browser BEFORE approval —
|
|
1078
|
+
// it's how the PendingApprovalOverlay polls for the host's decision.
|
|
1079
|
+
// Everything else is locked to loopback by the gate above.
|
|
1080
|
+
app.get('/api/devices/me', asyncH(async (req, res) => {
|
|
1081
|
+
const id = String(req.headers['x-device-id'] || (req.query && req.query.device) || '');
|
|
1082
|
+
if (!id) return res.status(400).json({ error: 'device id required' });
|
|
1083
|
+
// Token check applies HERE — this is the only endpoint where new
|
|
1084
|
+
// device records are created (record() inserts pending on first
|
|
1085
|
+
// sight). Demanding the token at registration time stops random
|
|
1086
|
+
// tunnel-URL scanners from filling the host's pending queue with
|
|
1087
|
+
// garbage entries. Already-known devices can re-poll without the
|
|
1088
|
+
// token (the existing record is returned as-is).
|
|
1089
|
+
const existing = await devices.get(id);
|
|
1090
|
+
if (!existing) {
|
|
1091
|
+
const tok = tunnel.getToken();
|
|
1092
|
+
if (tok && !isDirectLoopback(req)) {
|
|
1093
|
+
const auth = req.headers.authorization || '';
|
|
1094
|
+
const qTok = req.query && req.query.token;
|
|
1095
|
+
if (auth !== `Bearer ${tok}` && qTok !== tok) {
|
|
1096
|
+
return res.status(401).json({ error: 'token required to register a new device' });
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
const ua = req.headers['user-agent'] || '';
|
|
1101
|
+
const ip = String(req.headers['x-forwarded-for'] || req.socket.remoteAddress || '').split(',')[0].trim();
|
|
1102
|
+
const d = await devices.record(id, { userAgent: ua, ip });
|
|
1103
|
+
res.json(d);
|
|
1104
|
+
}));
|
|
1105
|
+
app.get('/api/devices', asyncH(async (_req, res) => {
|
|
1106
|
+
res.json({ devices: await devices.list() });
|
|
1107
|
+
}));
|
|
1108
|
+
app.post('/api/devices/:id/approve', asyncH(async (req, res) => {
|
|
1109
|
+
const d = await devices.approve(req.params.id, req.body && req.body.label);
|
|
1110
|
+
if (!d) return res.status(404).json({ error: 'device not found' });
|
|
1111
|
+
res.json(d);
|
|
1112
|
+
}));
|
|
1113
|
+
app.post('/api/devices/:id/reject', asyncH(async (req, res) => {
|
|
1114
|
+
const d = await devices.reject(req.params.id);
|
|
1115
|
+
if (!d) return res.status(404).json({ error: 'device not found' });
|
|
1116
|
+
res.json(d);
|
|
1117
|
+
}));
|
|
1118
|
+
app.post('/api/devices/:id/revoke', asyncH(async (req, res) => {
|
|
1119
|
+
const d = await devices.revoke(req.params.id);
|
|
1120
|
+
if (!d) return res.status(404).json({ error: 'device not found' });
|
|
1121
|
+
res.json(d);
|
|
1122
|
+
}));
|
|
1123
|
+
app.put('/api/devices/:id', asyncH(async (req, res) => {
|
|
1124
|
+
const d = await devices.rename(req.params.id, (req.body && req.body.label) || '');
|
|
1125
|
+
if (!d) return res.status(404).json({ error: 'device not found' });
|
|
1126
|
+
res.json(d);
|
|
1127
|
+
}));
|
|
1128
|
+
app.delete('/api/devices/:id', asyncH(async (req, res) => {
|
|
1129
|
+
const removed = await devices.remove(req.params.id);
|
|
1130
|
+
res.json({ removed });
|
|
1131
|
+
}));
|
|
1132
|
+
|
|
942
1133
|
// Restart: in production, spawn the restart-helper detached then
|
|
943
1134
|
// gracefulShutdown — the helper waits for the port to free and respawns
|
|
944
1135
|
// `ccsm.cmd` (with CCSM_NO_BROWSER so we don't pop a new window — the
|
|
@@ -1348,11 +1539,26 @@ function openInBrowser(url) {
|
|
|
1348
1539
|
try { ({ WebSocketServer } = require('ws')); } catch {}
|
|
1349
1540
|
if (WebSocketServer) {
|
|
1350
1541
|
const wss = new WebSocketServer({ noServer: true });
|
|
1351
|
-
server.on('upgrade', (req, socket, head) => {
|
|
1352
|
-
const
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1542
|
+
server.on('upgrade', async (req, socket, head) => {
|
|
1543
|
+
const direct = isDirectLoopback(req);
|
|
1544
|
+
// Non-loopback WS: device id alone gates entry. The host
|
|
1545
|
+
// explicitly Approved this device id earlier — that approval
|
|
1546
|
+
// IS the credential. No token check here (matches the device
|
|
1547
|
+
// gate above: token is only for /api/devices/me registration).
|
|
1548
|
+
if (!direct) {
|
|
1549
|
+
try {
|
|
1550
|
+
const u = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
|
1551
|
+
const devId = u.searchParams.get('device');
|
|
1552
|
+
if (!devId) { socket.destroy(); return; }
|
|
1553
|
+
const d = await devices.get(devId);
|
|
1554
|
+
if (!d || d.status !== 'approved') { socket.destroy(); return; }
|
|
1555
|
+
} catch { socket.destroy(); return; }
|
|
1556
|
+
} else {
|
|
1557
|
+
const origin = req.headers.origin;
|
|
1558
|
+
if (origin && !ALLOWED_ORIGINS.has(origin) && !/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/i.test(origin)) {
|
|
1559
|
+
socket.destroy();
|
|
1560
|
+
return;
|
|
1561
|
+
}
|
|
1356
1562
|
}
|
|
1357
1563
|
const m = req.url && req.url.match(/^\/ws\/terminal\/([^\/?#]+)/);
|
|
1358
1564
|
if (!m) { socket.destroy(); return; }
|