openvibers/openvibe.connect Commits
The inspector (CON-8 v0): a share's page lists the requests through it (#7)
Changes
@@@ -55,6 +55,7 @@ With a person's OpenVibe token as a Bearer (or the session on connect.openvibe.w| `DELETE /api/v1/shares/:id` | close it (410 from then on) || `POST /api/v1/shares/:id/key` | a new key → `{ share, key }`; the connected computer is disconnected || `GET /api/v1/shares/:id/visits` | who opened it in the last 7 days |++| `GET /api/v1/shares/:id/requests` `?after=<seq>` | the last 100 requests through it, newest first (the inspector, below) |## How it works@@@ -68,6 +69,9 @@ browser ──https──▶ Cloudflare ──▶ nginx ──▶ Connect (5240)- `server/edge/frames.js` — the wire format: JSON control messages, binary data frames per stream.- `server/edge/gate.js` — the one-time codes and the signed host-only cookie.- `server/edge/proxy.js` — a visitor's request on a share host: the gate, then the tunnel, streaming both ways.++- `server/edge/inspector.js` — the share page's Requests list (CON-8 v0): the last 100 requests per share, in memory++ only, with the method, the path (query values shown as "…"), status, time, bytes and the visitor's name. No headers,++ no bodies; a restart forgets them.- `public/ov-connect.js` — the client.The v0 transport is a WebSocket on 443 through Cloudflare (ADR-057's fallback path), so nothing needs a UDP port or a@@@ -135,6 +135,23 @@.cn-visits { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }.cn-visits li { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 4px 12px; padding: 10px 14px; border-radius: 10px; background: var(--ovk-panel); border: 1px solid var(--ovk-line); }.cn-visits span { color: var(--ovk-muted); font-size: 14px; }++/* The inspector (CON-8): one line per request, the status first; on a phone the path wraps under it. */++.cn-reqs { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; }++.cn-req { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: baseline; gap: 2px 12px; padding: 8px 12px; border-radius: 10px; background: var(--ovk-panel); border: 1px solid var(--ovk-line); }++.cn-req-badge { min-width: 3.2em; padding: 1px 8px; border-radius: 999px; font: 700 12.5px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; text-align: center; background: color-mix(in srgb, var(--ovk-muted) 16%, transparent); }++.cn-req.is-s2 .cn-req-badge { background: color-mix(in srgb, #22c55e 20%, transparent); }++.cn-req.is-s3 .cn-req-badge { background: color-mix(in srgb, var(--ovk-accent, #4f7cff) 18%, transparent); }++.cn-req.is-s4 .cn-req-badge { background: color-mix(in srgb, #f59e0b 22%, transparent); }++.cn-req.is-s5 .cn-req-badge, .cn-req.is-err .cn-req-badge { background: color-mix(in srgb, #ef4444 22%, transparent); }++.cn-req-line { min-width: 0; overflow-wrap: anywhere; font-size: 13.5px; }++.cn-req-line b { margin-right: 4px; }++.cn-req time { color: var(--ovk-muted); font-size: 12.5px; font-variant-numeric: tabular-nums; }++.cn-req-meta { grid-column: 2 / -1; color: var(--ovk-muted); font-size: 12.5px; }++.cn-req-meta:empty { display: none; }++.cn-req.is-new { animation: cn-req-in .5s ease-out; }++@keyframes cn-req-in { from { background: color-mix(in srgb, var(--ovk-accent, #4f7cff) 14%, var(--ovk-panel)); } }++@media (prefers-reduced-motion: reduce) { .cn-req.is-new { animation: none; } }++@media (max-width: 520px) { .cn-req { grid-template-columns: auto minmax(0, 1fr); } .cn-req time { grid-column: 2; grid-row: 1; justify-self: end; } .cn-req-line { grid-column: 1 / -1; } }.cn-policy { padding: 20px; border-radius: var(--ovk-radius, 16px); border: 1px solid var(--ovk-line); background: var(--ovk-panel); }.cn-policy h2 { margin: 0; }.cn-danger { display: grid; gap: 12px; }@@@ -30,3 +30,68 @@}setTimeout(ask, 4000);})();++++/* The inspector (CON-8): new requests through the share appear at the top of the list, every 3 seconds while the page++ is visible, for up to 30 minutes; a request still open is asked about again until it ends. The rows match++ server/views/index.js requestRow. */++(function () {++ 'use strict';++ var box = document.querySelector('[data-cn-reqs]');++ if (!box || !window.fetch) return;++ var id = box.getAttribute('data-cn-reqs');++ var list = box.querySelector('[data-cn-reqs-list]');++ var empty = box.querySelector('[data-cn-reqs-empty]');++ var KEEP = 100;++ var ERRORS = { local_refused: 'nothing listening', timeout: 'timed out', visitor_gone: 'visitor left', too_large: 'too large', tunnel_closed: 'dropped', bad_status: 'odd answer', bad_headers: 'odd answer', local_error: 'app stopped' };++ var started = Date.now();++ function size(n) { n = Number(n) || 0; return n < 1024 ? n + ' B' : n < 1048576 ? (n / 1024).toFixed(1) + ' KB' : (n / 1048576).toFixed(1) + ' MB'; }++ function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }++ function row(q) {++ var tone = q.open ? 'open' : q.error && !q.status ? 'err' : q.status >= 500 ? 's5' : q.status >= 400 ? 's4' : q.status >= 300 ? 's3' : 's2';++ var li = el('li', 'cn-req is-' + tone);++ li.setAttribute('data-seq', q.seq);++ li.appendChild(el('span', 'cn-req-badge', q.open ? (q.ws ? 'open' : '…') : q.status ? String(q.status) : (ERRORS[q.error] || q.error || '?')));++ var line = el('code', 'cn-req-line');++ line.appendChild(el('b', null, q.ws ? 'WS' : q.method));++ line.appendChild(document.createTextNode(' ' + q.path));++ li.appendChild(line);++ var meta = [q.ms != null ? q.ms + ' ms' : null, q.in ? size(q.in) + ' in' : null, q.out ? size(q.out) + ' out' : null,++ q.error && q.status ? (ERRORS[q.error] || q.error) : null, q.who ? '@' + q.who : null].filter(Boolean).join(' · ');++ li.appendChild(el('span', 'cn-req-meta', meta));++ var t = el('time', null, String(q.at).slice(11, 19));++ t.setAttribute('datetime', q.at);++ li.appendChild(t);++ return li;++ }++ function seqs() { return Array.prototype.map.call(list.children, function (li) { return Number(li.getAttribute('data-seq')) || 0; }); }++ function after() {++ // The oldest request still open is asked about again, so it ends on the page too.++ var open = Array.prototype.filter.call(list.children, function (li) { return li.classList.contains('is-open'); })++ .map(function (li) { return Number(li.getAttribute('data-seq')) || 0; });++ if (open.length) return Math.min.apply(null, open) - 1;++ var all = seqs();++ return all.length ? Math.max.apply(null, all) : 0;++ }++ function apply(items) {++ // Newest first from the API; inserted oldest first so the newest ends on top.++ items.slice().reverse().forEach(function (q) {++ var old = list.querySelector('[data-seq="' + q.seq + '"]');++ var li = row(q);++ if (old) { list.replaceChild(li, old); return; }++ li.classList.add('is-new');++ list.insertBefore(li, list.firstChild);++ });++ while (list.children.length > KEEP) list.removeChild(list.lastChild);++ if (empty) empty.hidden = list.children.length > 0;++ }++ function ask() {++ if (Date.now() - started > 30 * 60 * 1000) return;++ if (document.hidden) { setTimeout(ask, 3000); return; }++ fetch('/api/v1/shares/' + encodeURIComponent(id) + '/requests?after=' + after(), { credentials: 'same-origin', headers: { accept: 'application/json' } })++ .then(function (r) { return r.ok ? r.json() : null; })++ .then(function (j) { if (j && Array.isArray(j.requests) && j.requests.length) apply(j.requests); })++ .catch(function () { /* the next ask tries again */ })++ .then(function () { setTimeout(ask, 3000); });++ }++ setTimeout(ask, 3000);++})();nnew file mode 100644@@@ -0,0 +1,70 @@++'use strict';++++/**++ * The inspector (CON-8 v0): the last 100 requests through each share, for its owner, so someone sharing an app can++ * see what reaches it as it happens (the share page lists them and adds new ones every few seconds).++ *++ * Memory only: a restart forgets them and nothing is written anywhere. Each entry holds what a log line would:++ * the method; the path, with every query value replaced by "…" (a token in a query string never reaches a page);++ * the status, the time to the end, the bytes each way; the visitor's OpenVibe name when the share asked for one;++ * whether it was a WebSocket; and why it failed (timeout, nothing listening…).++ * No headers, no bodies, no addresses.++ *++ * begin(shareId, { method, path, who, ws }) → entry; set entry.status / add to entry.in and entry.out as it goes++ * end(entry, { status?, error? }) → stamps the time taken (once)++ * list(shareId, { after }) → newest first, only entries after that sequence number++ * forget(shareId) → a closed share's list goes++ */++++const KEEP = 100;++const PATH_MAX = 300;++++/** The path as the inspector shows it: query values become "…", and only the first 12 keys are kept. */++function redact(url) {++ const s = String(url || '/');++ const q = s.indexOf('?');++ const path = (q === -1 ? s : s.slice(0, q)).slice(0, PATH_MAX) || '/';++ if (q === -1) return path;++ const keys = s.slice(q + 1).split('&').map((kv) => kv.split('=')[0]).filter(Boolean).slice(0, 12)++ .map((k) => k.slice(0, 40).replace(/[^\w.%~[\]-]/g, ''));++ return keys.length ? `${path}?${keys.map((k) => `${k}=…`).join('&')}` : path;++}++++function createInspector({ now = () => Date.now(), keep = KEEP } = {}) {++ const rings = new Map(); // share id → { seq, items }++++ function begin(shareId, { method = 'GET', path = '/', who = null, ws = false } = {}) {++ let r = rings.get(shareId);++ if (!r) { r = { seq: 0, items: [] }; rings.set(shareId, r); }++ const entry = {++ seq: ++r.seq, at: new Date(now()).toISOString(), method: String(method).slice(0, 12), path: redact(path),++ who: who && who.username ? String(who.username) : null, ws: Boolean(ws),++ status: null, ms: null, in: 0, out: 0, error: null, started: now(),++ };++ r.items.push(entry);++ if (r.items.length > keep) r.items.splice(0, r.items.length - keep);++ return entry;++ }++++ function end(entry, { status = null, error = null } = {}) {++ if (!entry || entry.ms != null) return;++ if (status != null) entry.status = status;++ if (error) entry.error = String(error).slice(0, 40);++ entry.ms = Math.max(0, now() - entry.started);++ }++++ const wire = (e) => ({ seq: e.seq, at: e.at, method: e.method, path: e.path, who: e.who, ws: e.ws, status: e.status, ms: e.ms, in: e.in, out: e.out, error: e.error, open: e.ms == null });++++ function list(shareId, { after = 0 } = {}) {++ const r = rings.get(shareId);++ if (!r) return [];++ const from = Number(after) || 0;++ return r.items.filter((e) => e.seq > from).reverse().map(wire);++ }++++ function forget(shareId) { rings.delete(shareId); }++++ return { begin, end, list, forget, KEEP: keep };++}++++module.exports = { createInspector, redact, KEEP };@@@ -37,6 +37,8 @@ function privateCache(value) {}function createProxy({ config, shares, tunnels, gate, log = console, now = () => Date.now() }) {++ // The last requests through each share, for its owner's page (CON-8 v0; memory only).++ const inspector = require('./inspector').createInspector({ now });const domain = config.shares.domain;const cache = new Map(); // host → { share, at }const windows = new Map(); // share id → { start, count }@@@ -231,6 +233,9 @@ function createProxy({ config, shares, tunnels, gate, log = console, now = () =>if (!admitted) return;const { who, tunnel } = admitted;visit(share, who);++ const rec = inspector.begin(share.id, { method: req.method, path: url, who });++ res.on('finish', () => inspector.end(rec, { status: res.statusCode }));++ res.on('close', () => inspector.end(rec, { error: res.writableFinished ? null : 'visitor_gone' }));const port = (tunnel.clientInfo && tunnel.clientInfo.port) || share.local_port;let finished = false;@@@ -244,11 +249,13 @@ function createProxy({ config, shares, tunnels, gate, log = console, now = () =>tunnel.abort(id, 'bad_status');return sendProblem(req, res, 502, 'bad_response', 'The app answered something odd', 'The app on the other end sent a response OpenVibe Connect cannot pass on.');}++ rec.status = status;try { res.writeHead(status, responseHeaders(m.headers)); } catch { tunnel.abort(id, 'bad_headers'); res.destroy(); }return undefined;},onData(buf) {addBytes(share, 'out', buf.length);++ rec.out += buf.length;if (!res.write(buf)) {tunnel.send({ t: 'pause', id });res.once('drain', () => tunnel.send({ t: 'resume', id }));@@@ -258,6 +265,7 @@ function createProxy({ config, shares, tunnels, gate, log = console, now = () =>onAbort(m) {finished = true;clearTimeout(headTimer);++ if (m && m.code) rec.error = String(m.code).slice(0, 40);if (res.headersSent) { res.destroy(); return; }if (m && m.code === 'local_refused') {sendProblem(req, res, 502, 'local_refused', 'Nothing is listening', `${share.owner_username}'s computer is connected, but nothing answered on port ${port}.`);@@@ -273,6 +281,7 @@ function createProxy({ config, shares, tunnels, gate, log = console, now = () =>headTimer = setTimeout(() => {if (res.headersSent || finished) return;finished = true;++ rec.error = 'timeout';tunnel.abort(id, 'timeout');sendProblem(req, res, 504, 'timeout', 'That took too long', `The app on ${share.owner_username}'s computer did not answer in ${Math.round(config.shares.requestTimeoutMs / 1000)} seconds.`);}, config.shares.requestTimeoutMs);@@@ -282,8 +291,9 @@ function createProxy({ config, shares, tunnels, gate, log = console, now = () =>req.on('data', (chunk) => {received += chunk.length;addBytes(share, 'in', chunk.length);++ rec.in += chunk.length;if (received > config.shares.maxBodyBytes) {-- if (!finished) { finished = true; tunnel.abort(id, 'too_large'); sendProblem(req, res, 413, 'too_large', 'Too large', 'That request is larger than a share takes.'); }++ if (!finished) { finished = true; rec.error = 'too_large'; tunnel.abort(id, 'too_large'); sendProblem(req, res, 413, 'too_large', 'Too large', 'That request is larger than a share takes.'); }req.destroy();return;}@@@ -305,6 +315,8 @@ function createProxy({ config, shares, tunnels, gate, log = console, now = () =>if (!admitted) return;const { who, tunnel } = admitted;visit(share, who);++ const rec = inspector.begin(share.id, { method: req.method, path: String(req.url || '/'), who, ws: true });++ socket.on('close', () => inspector.end(rec));const port = (tunnel.clientInfo && tunnel.clientInfo.port) || share.local_port;const lines = [`${req.method} ${req.url} HTTP/1.1`];@@@ -313,13 +325,17 @@ function createProxy({ config, shares, tunnels, gate, log = console, now = () =>let opened = false;const id = tunnel.open({onData(buf) {++ if (!opened) rec.status = /^HTTP\/1\.[01] (\d{3})/.test(buf.toString('latin1', 0, 16)) ? Number(RegExp.$1) : null;opened = true;addBytes(share, 'out', buf.length);++ rec.out += buf.length;if (!socket.write(buf)) { tunnel.send({ t: 'pause', id }); socket.once('drain', () => tunnel.send({ t: 'resume', id })); }},onEnd() { tunnel.release(id); socket.end(); },onAbort(m) {++ if (m && m.code) rec.error = String(m.code).slice(0, 40);if (!opened && m && m.code === 'local_refused') {++ rec.status = 502;socket.end(`HTTP/1.1 502 Bad Gateway\r\ncontent-type: text/plain\r\nconnection: close\r\n\r\nNothing answered on port ${port}.`);} else socket.destroy();},@@@ -327,9 +343,10 @@ function createProxy({ config, shares, tunnels, gate, log = console, now = () =>onResume() { socket.resume(); },});tunnel.send({ t: 'raw', id, head: headText });-- if (head && head.length) { addBytes(share, 'in', head.length); tunnel.data(id, head); }++ if (head && head.length) { addBytes(share, 'in', head.length); rec.in += head.length; tunnel.data(id, head); }socket.on('data', (chunk) => {addBytes(share, 'in', chunk.length);++ rec.in += chunk.length;tunnel.data(id, chunk);if (tunnel.congested) { socket.pause(); tunnel.drained().then(() => socket.resume()); }});@@@ -342,7 +359,7 @@ function createProxy({ config, shares, tunnels, gate, log = console, now = () =>/** The grant refused someone: counted on the share page as "turned away". */function denied(share, who) { visit(share, who, { denied: true }); }-- return { handle, upgrade, isShareHost, flush, forget, denied, stop, responseHeaders, forwardHeaders };++ return { handle, upgrade, isShareHost, flush, forget, denied, stop, responseHeaders, forwardHeaders, inspector };}/** Write an HTTP answer straight to an upgrade's socket (the gate's 302/401/404/502 before any tunnel opens). */@@@ -11,6 +11,8 @@* DELETE /shares/:id close it (the host answers 410 from then on)* POST /shares/:id/key a new key → { share, key }; the connected computer is disconnected* GET /shares/:id/visits who opened it in the last 7 days++ * GET /shares/:id/requests the last 100 requests through it, newest first (?after=<seq>: only newer ones);++ * memory only, query values shown as "…" (edge/inspector.js)** Errors are application/problem+json with a `code`.*/@@@ -72,6 +74,7 @@ function createApi(ctx) {const closed = await shares.revoke(owner(req), share.id);tunnels.close(share.id, 4003, 'share closed');proxy.forget(closed.host);++ proxy.inspector.forget(share.id);return res.json({ share: shape(closed) });}));@@@ -83,6 +86,11 @@ function createApi(ctx) {return res.json({ share: shape(updated), key });}));++ r.get('/shares/:id/requests', guard(async (req, res) => {++ const share = await mine(req, res);++ return share ? res.json({ requests: proxy.inspector.list(share.id, { after: req.query.after }), keep: proxy.inspector.KEEP }) : undefined;++ }));++r.get('/shares/:id/visits', guard(async (req, res) => {const share = await mine(req, res);return share ? res.json({ visits: await shares.visits(share.id) }) : undefined;@@@ -81,7 +81,7 @@ function createPageRoutes(ctx) {async function showShare(req, res, share, extra = {}, status = 200) {const tunnel = tunnels.get(share.id);const visits = await shares.visits(share.id);-- return page(req, res, { title: share.host, body: views.sharePage({ share, connected: Boolean(tunnel), tunnel, visits, baseUrl: config.baseUrl, staff: req.viewer.role === 'admin', ...extra }) }, status);++ return page(req, res, { title: share.host, body: views.sharePage({ share, connected: Boolean(tunnel), tunnel, visits, requests: ctx.proxy ? ctx.proxy.inspector.list(share.id) : [], baseUrl: config.baseUrl, staff: req.viewer.role === 'admin', ...extra }) }, status);}r.get('/shares/:id', async (req, res) => {@@@ -126,6 +126,7 @@ function createPageRoutes(ctx) {const closed = await shares.revoke(owner(req), share.id);tunnels.close(share.id, 4003, 'share closed');proxy.forget(closed.host);++ proxy.inspector.forget(share.id);return showShare(req, res, closed, { notice: 'Closed. The address answers "closed" from now on.' });});@@@ -190,7 +190,24 @@ function keyBlock(share, key, baseUrl) {</section>`;}--function sharePage({ share, connected, tunnel = null, visits = [], key = null, baseUrl, notice = null, error = null, staff = false, now = Date.now() }) {++/** Bytes as a person reads them. */++function size(n) {++ const b = Number(n) || 0;++ return b < 1024 ? `${b} B` : b < 1024 * 1024 ? `${(b / 1024).toFixed(1)} KB` : `${(b / 1024 / 1024).toFixed(1)} MB`;++}++++const REQ_ERRORS = { local_refused: 'nothing listening', timeout: 'timed out', visitor_gone: 'visitor left', too_large: 'too large', tunnel_closed: 'dropped', bad_status: 'odd answer', bad_headers: 'odd answer', local_error: 'app stopped' };++++/** One request in the inspector (public/js/connect.js draws the same row for new ones). */++function requestRow(q) {++ const tone = q.open ? 'open' : q.error && !q.status ? 'err' : q.status >= 500 ? 's5' : q.status >= 400 ? 's4' : q.status >= 300 ? 's3' : 's2';++ const badge = q.open ? (q.ws ? 'open' : '…') : q.status ? String(q.status) : (REQ_ERRORS[q.error] || q.error || '?');++ const meta = [q.ms != null ? `${q.ms} ms` : null, q.in ? `${size(q.in)} in` : null, q.out ? `${size(q.out)} out` : null,++ q.error && q.status ? (REQ_ERRORS[q.error] || q.error) : null, q.who ? `@${q.who}` : null].filter(Boolean).join(' · ');++ return `<li class="cn-req is-${tone}" data-seq="${esc(q.seq)}"><span class="cn-req-badge">${esc(badge)}</span><code class="cn-req-line"><b>${esc(q.ws ? 'WS' : q.method)}</b> ${esc(q.path)}</code><span class="cn-req-meta">${esc(meta)}</span><time datetime="${esc(q.at)}">${esc(String(q.at).slice(11, 19))}</time></li>`;++}++++function sharePage({ share, connected, tunnel = null, visits = [], requests = [], key = null, baseUrl, notice = null, error = null, staff = false, now = Date.now() }) {const closed = share.status !== 'active';const state = closed ? 'Closed' : connected ? 'Connected' : 'Not connected';const stateText = closed ? `Closed ${ago(share.revoked_at, now)}. The address answers "closed" and stays yours.`@@@ -231,6 +248,10 @@ ${key ? keyBlock(share, key, baseUrl) : ''}</dl>${!key && !closed ? `<section class="cn-run"><h2>Connect it</h2><div class="cn-copy"><code id="cn-run">node ov-connect.js --key ovc_… --port ${esc(share.local_port)}</code></div><p class="cn-fine">The key was shown once, when it was made. <a href="${esc(CLIENT_URL(baseUrl))}">Download ov-connect.js</a> (Node 22 or newer).</p></section>` : ''}<section aria-labelledby="cn-visits-h"><h2 id="cn-visits-h">Who opened it (7 days)</h2>${visitRows}</section>++${closed ? '' : `<section class="cn-inspector" aria-labelledby="cn-reqs-h" data-cn-reqs="${esc(share.id)}"><h2 id="cn-reqs-h">Requests</h2>++<p class="cn-fine">The last 100 through this share, newest first, added as they happen. Kept in memory only, never written down: no headers, no bodies, and query values show as "…".</p>++<ol class="cn-reqs" data-cn-reqs-list>${requests.map(requestRow).join('')}</ol>++<p class="cn-empty" data-cn-reqs-empty${requests.length ? ' hidden' : ''}>No requests yet${connected ? '. Open the address and they appear here' : ''}.</p></section>`}${policy}${danger}</div>`;@@@ -249,4 +270,4 @@ function message({ title, text, link = null }) {function notFound() { return message({ title: 'Not found', text: 'There is no page here.', link: { href: '/', label: 'OpenVibe Connect' } }); }--module.exports = { home, sharePage, grantDenied, message, notFound, MODE_TEXT, fmtBytes, ago };++module.exports = { home, sharePage, grantDenied, message, notFound, MODE_TEXT, fmtBytes, ago, requestRow, size };@@@ -254,6 +254,29 @@ async function until(fn, ms = 3000) {assert.strictEqual(s.connected, true);});++ await check('the inspector (CON-8): the requests through the share, newest first, query values hidden, the owner\'s only', async () => {++ const r = await t.share(made.share.host, '/hello?token=s3cret-value&page=2', { cookie: alexCookie, headers: { accept: 'application/json' } });++ assert.strictEqual(r.status, 200);++ let list = [];++ await until(async () => { list = (await t.get(`/api/v1/shares/${made.share.id}/requests`, { as: alex })).json().requests; return list[0] && !list[0].open; });++ const top = list[0];++ assert.deepStrictEqual([top.method, top.path, top.status, top.who], ['GET', '/hello?token=…&page=…', 200, 'Alex_Dev']);++ assert.ok(top.ms >= 0 && top.out > 0, JSON.stringify(top));++ assert.ok(!JSON.stringify(list).includes('s3cret'), 'a query value never reaches the list');++ assert.ok(list.some((q) => q.ws && q.status === 101), 'the WebSocket is there, with its 101');++ assert.ok(list.some((q) => q.method === 'POST' && q.in > 0), 'a body counts its bytes in');++ assert.ok(list.every((q, i) => i === 0 || q.seq < list[i - 1].seq), 'newest first');++ const newer = (await t.get(`/api/v1/shares/${made.share.id}/requests?after=${top.seq}`, { as: alex })).json().requests;++ assert.deepStrictEqual(newer, [], 'after= answers only newer ones');++ const page = await t.get(`/shares/${made.share.id}`, { as: alex });++ assert.ok(page.text.includes(`data-cn-reqs="${made.share.id}"`));++ assert.ok(page.text.includes('<b>GET</b> /hello?token=…&page=…'), 'the page lists it');++ assert.strictEqual((await t.get(`/api/v1/shares/${made.share.id}/requests`, { as: bob })).status, 404, 'nobody else');++ const dead = (await t.get('/api/v1/shares', { as: alex })).json().shares.find((s) => s.label === 'dead');++ const refused = (await t.get(`/api/v1/shares/${dead.id}/requests`, { as: alex })).json().requests[0];++ assert.deepStrictEqual([refused.status, refused.error], [502, 'local_refused'], 'nothing listening is named');++ });++await check('another person cannot see, change or close someone else\'s share', async () => {assert.strictEqual((await t.get(`/api/v1/shares/${made.share.id}`, { as: bob })).status, 404);assert.strictEqual((await t.get(`/api/v1/shares/${made.share.id}`, { as: bob, method: 'DELETE' })).status, 404);
@@ -55,6 +55,7 @@ With a person's OpenVibe token as a Bearer (or the session on connect.openvibe.w
@@ -55,6 +55,7 @@ With a person's OpenVibe token as a Bearer (or the session on connect.openvibe.w
| `DELETE /api/v1/shares/:id` | close it (410 from then on) |
| `DELETE /api/v1/shares/:id` | close it (410 from then on) |
| `POST /api/v1/shares/:id/key` | a new key → `{ share, key }`; the connected computer is disconnected |
| `POST /api/v1/shares/:id/key` | a new key → `{ share, key }`; the connected computer is disconnected |
| `GET /api/v1/shares/:id/visits` | who opened it in the last 7 days |
| `GET /api/v1/shares/:id/visits` | who opened it in the last 7 days |
+| `GET /api/v1/shares/:id/requests` `?after=<seq>` | the last 100 requests through it, newest first (the inspector, below) |
## How it works
## How it works
@@ -68,6 +69,9 @@ browser ──https──▶ Cloudflare ──▶ nginx ──▶ Connect (5240)
@@ -68,6 +69,9 @@ browser ──https──▶ Cloudflare ──▶ nginx ──▶ Connect (5240)
- `server/edge/frames.js` — the wire format: JSON control messages, binary data frames per stream.
- `server/edge/frames.js` — the wire format: JSON control messages, binary data frames per stream.
- `server/edge/gate.js` — the one-time codes and the signed host-only cookie.
- `server/edge/gate.js` — the one-time codes and the signed host-only cookie.
- `server/edge/proxy.js` — a visitor's request on a share host: the gate, then the tunnel, streaming both ways.
- `server/edge/proxy.js` — a visitor's request on a share host: the gate, then the tunnel, streaming both ways.
+- `server/edge/inspector.js` — the share page's Requests list (CON-8 v0): the last 100 requests per share, in memory
+ only, with the method, the path (query values shown as "…"), status, time, bytes and the visitor's name. No headers,
+ no bodies; a restart forgets them.
- `public/ov-connect.js` — the client.
- `public/ov-connect.js` — the client.
The v0 transport is a WebSocket on 443 through Cloudflare (ADR-057's fallback path), so nothing needs a UDP port or a
The v0 transport is a WebSocket on 443 through Cloudflare (ADR-057's fallback path), so nothing needs a UDP port or a
@@ -135,6 +135,23 @@
@@ -135,6 +135,23 @@
.cn-visits { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
.cn-visits { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
.cn-visits li { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 4px 12px; padding: 10px 14px; border-radius: 10px; background: var(--ovk-panel); border: 1px solid var(--ovk-line); }
.cn-visits li { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 4px 12px; padding: 10px 14px; border-radius: 10px; background: var(--ovk-panel); border: 1px solid var(--ovk-line); }
.cn-visits span { color: var(--ovk-muted); font-size: 14px; }
.cn-visits span { color: var(--ovk-muted); font-size: 14px; }
+/* The inspector (CON-8): one line per request, the status first; on a phone the path wraps under it. */
+.cn-reqs { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; }
+.cn-req { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: baseline; gap: 2px 12px; padding: 8px 12px; border-radius: 10px; background: var(--ovk-panel); border: 1px solid var(--ovk-line); }
+.cn-req-badge { min-width: 3.2em; padding: 1px 8px; border-radius: 999px; font: 700 12.5px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; text-align: center; background: color-mix(in srgb, var(--ovk-muted) 16%, transparent); }
+.cn-req.is-s2 .cn-req-badge { background: color-mix(in srgb, #22c55e 20%, transparent); }
+.cn-req.is-s3 .cn-req-badge { background: color-mix(in srgb, var(--ovk-accent, #4f7cff) 18%, transparent); }
+.cn-req.is-s4 .cn-req-badge { background: color-mix(in srgb, #f59e0b 22%, transparent); }
+.cn-req.is-s5 .cn-req-badge, .cn-req.is-err .cn-req-badge { background: color-mix(in srgb, #ef4444 22%, transparent); }
+.cn-req-line { min-width: 0; overflow-wrap: anywhere; font-size: 13.5px; }
+.cn-req-line b { margin-right: 4px; }
+.cn-req time { color: var(--ovk-muted); font-size: 12.5px; font-variant-numeric: tabular-nums; }
+.cn-req-meta { grid-column: 2 / -1; color: var(--ovk-muted); font-size: 12.5px; }
+.cn-req-meta:empty { display: none; }
+.cn-req.is-new { animation: cn-req-in .5s ease-out; }
+@keyframes cn-req-in { from { background: color-mix(in srgb, var(--ovk-accent, #4f7cff) 14%, var(--ovk-panel)); } }
+@media (prefers-reduced-motion: reduce) { .cn-req.is-new { animation: none; } }
+@media (max-width: 520px) { .cn-req { grid-template-columns: auto minmax(0, 1fr); } .cn-req time { grid-column: 2; grid-row: 1; justify-self: end; } .cn-req-line { grid-column: 1 / -1; } }
.cn-policy { padding: 20px; border-radius: var(--ovk-radius, 16px); border: 1px solid var(--ovk-line); background: var(--ovk-panel); }
.cn-policy { padding: 20px; border-radius: var(--ovk-radius, 16px); border: 1px solid var(--ovk-line); background: var(--ovk-panel); }
.cn-policy h2 { margin: 0; }
.cn-policy h2 { margin: 0; }
.cn-danger { display: grid; gap: 12px; }
.cn-danger { display: grid; gap: 12px; }
@@ -30,3 +30,68 @@
@@ -30,3 +30,68 @@
}
}
setTimeout(ask, 4000);
setTimeout(ask, 4000);
})();
})();
+
+/* The inspector (CON-8): new requests through the share appear at the top of the list, every 3 seconds while the page
+ is visible, for up to 30 minutes; a request still open is asked about again until it ends. The rows match
+ server/views/index.js requestRow. */
+(function () {
+ 'use strict';
+ var box = document.querySelector('[data-cn-reqs]');
+ if (!box || !window.fetch) return;
+ var id = box.getAttribute('data-cn-reqs');
+ var list = box.querySelector('[data-cn-reqs-list]');
+ var empty = box.querySelector('[data-cn-reqs-empty]');
+ var KEEP = 100;
+ var ERRORS = { local_refused: 'nothing listening', timeout: 'timed out', visitor_gone: 'visitor left', too_large: 'too large', tunnel_closed: 'dropped', bad_status: 'odd answer', bad_headers: 'odd answer', local_error: 'app stopped' };
+ var started = Date.now();
+ function size(n) { n = Number(n) || 0; return n < 1024 ? n + ' B' : n < 1048576 ? (n / 1024).toFixed(1) + ' KB' : (n / 1048576).toFixed(1) + ' MB'; }
+ function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }
+ function row(q) {
+ var tone = q.open ? 'open' : q.error && !q.status ? 'err' : q.status >= 500 ? 's5' : q.status >= 400 ? 's4' : q.status >= 300 ? 's3' : 's2';
+ var li = el('li', 'cn-req is-' + tone);
+ li.setAttribute('data-seq', q.seq);
+ li.appendChild(el('span', 'cn-req-badge', q.open ? (q.ws ? 'open' : '…') : q.status ? String(q.status) : (ERRORS[q.error] || q.error || '?')));
+ var line = el('code', 'cn-req-line');
+ line.appendChild(el('b', null, q.ws ? 'WS' : q.method));
+ line.appendChild(document.createTextNode(' ' + q.path));
+ li.appendChild(line);
+ var meta = [q.ms != null ? q.ms + ' ms' : null, q.in ? size(q.in) + ' in' : null, q.out ? size(q.out) + ' out' : null,
+ q.error && q.status ? (ERRORS[q.error] || q.error) : null, q.who ? '@' + q.who : null].filter(Boolean).join(' · ');
+ li.appendChild(el('span', 'cn-req-meta', meta));
+ var t = el('time', null, String(q.at).slice(11, 19));
+ t.setAttribute('datetime', q.at);
+ li.appendChild(t);
+ return li;
+ }
+ function seqs() { return Array.prototype.map.call(list.children, function (li) { return Number(li.getAttribute('data-seq')) || 0; }); }
+ function after() {
+ // The oldest request still open is asked about again, so it ends on the page too.
+ var open = Array.prototype.filter.call(list.children, function (li) { return li.classList.contains('is-open'); })
+ .map(function (li) { return Number(li.getAttribute('data-seq')) || 0; });
+ if (open.length) return Math.min.apply(null, open) - 1;
+ var all = seqs();
+ return all.length ? Math.max.apply(null, all) : 0;
+ }
+ function apply(items) {
+ // Newest first from the API; inserted oldest first so the newest ends on top.
+ items.slice().reverse().forEach(function (q) {
+ var old = list.querySelector('[data-seq="' + q.seq + '"]');
+ var li = row(q);
+ if (old) { list.replaceChild(li, old); return; }
+ li.classList.add('is-new');
+ list.insertBefore(li, list.firstChild);
+ });
+ while (list.children.length > KEEP) list.removeChild(list.lastChild);
+ if (empty) empty.hidden = list.children.length > 0;
+ }
+ function ask() {
+ if (Date.now() - started > 30 * 60 * 1000) return;
+ if (document.hidden) { setTimeout(ask, 3000); return; }
+ fetch('/api/v1/shares/' + encodeURIComponent(id) + '/requests?after=' + after(), { credentials: 'same-origin', headers: { accept: 'application/json' } })
+ .then(function (r) { return r.ok ? r.json() : null; })
+ .then(function (j) { if (j && Array.isArray(j.requests) && j.requests.length) apply(j.requests); })
+ .catch(function () { /* the next ask tries again */ })
+ .then(function () { setTimeout(ask, 3000); });
+ }
+ setTimeout(ask, 3000);
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,70 @@
@@ -0,0 +1,70 @@
+'use strict';
+
+/**
+ * The inspector (CON-8 v0): the last 100 requests through each share, for its owner, so someone sharing an app can
+ * see what reaches it as it happens (the share page lists them and adds new ones every few seconds).
+ *
+ * Memory only: a restart forgets them and nothing is written anywhere. Each entry holds what a log line would:
+ * the method; the path, with every query value replaced by "…" (a token in a query string never reaches a page);
+ * the status, the time to the end, the bytes each way; the visitor's OpenVibe name when the share asked for one;
+ * whether it was a WebSocket; and why it failed (timeout, nothing listening…).
+ * No headers, no bodies, no addresses.
+ *
+ * begin(shareId, { method, path, who, ws }) → entry; set entry.status / add to entry.in and entry.out as it goes
+ * end(entry, { status?, error? }) → stamps the time taken (once)
+ * list(shareId, { after }) → newest first, only entries after that sequence number
+ * forget(shareId) → a closed share's list goes
+ */
+
+const KEEP = 100;
+const PATH_MAX = 300;
+
+/** The path as the inspector shows it: query values become "…", and only the first 12 keys are kept. */
+function redact(url) {
+ const s = String(url || '/');
+ const q = s.indexOf('?');
+ const path = (q === -1 ? s : s.slice(0, q)).slice(0, PATH_MAX) || '/';
+ if (q === -1) return path;
+ const keys = s.slice(q + 1).split('&').map((kv) => kv.split('=')[0]).filter(Boolean).slice(0, 12)
+ .map((k) => k.slice(0, 40).replace(/[^\w.%~[\]-]/g, ''));
+ return keys.length ? `${path}?${keys.map((k) => `${k}=…`).join('&')}` : path;
+}
+
+function createInspector({ now = () => Date.now(), keep = KEEP } = {}) {
+ const rings = new Map(); // share id → { seq, items }
+
+ function begin(shareId, { method = 'GET', path = '/', who = null, ws = false } = {}) {
+ let r = rings.get(shareId);
+ if (!r) { r = { seq: 0, items: [] }; rings.set(shareId, r); }
+ const entry = {
+ seq: ++r.seq, at: new Date(now()).toISOString(), method: String(method).slice(0, 12), path: redact(path),
+ who: who && who.username ? String(who.username) : null, ws: Boolean(ws),
+ status: null, ms: null, in: 0, out: 0, error: null, started: now(),
+ };
+ r.items.push(entry);
+ if (r.items.length > keep) r.items.splice(0, r.items.length - keep);
+ return entry;
+ }
+
+ function end(entry, { status = null, error = null } = {}) {
+ if (!entry || entry.ms != null) return;
+ if (status != null) entry.status = status;
+ if (error) entry.error = String(error).slice(0, 40);
+ entry.ms = Math.max(0, now() - entry.started);
+ }
+
+ const wire = (e) => ({ seq: e.seq, at: e.at, method: e.method, path: e.path, who: e.who, ws: e.ws, status: e.status, ms: e.ms, in: e.in, out: e.out, error: e.error, open: e.ms == null });
+
+ function list(shareId, { after = 0 } = {}) {
+ const r = rings.get(shareId);
+ if (!r) return [];
+ const from = Number(after) || 0;
+ return r.items.filter((e) => e.seq > from).reverse().map(wire);
+ }
+
+ function forget(shareId) { rings.delete(shareId); }
+
+ return { begin, end, list, forget, KEEP: keep };
+}
+
+module.exports = { createInspector, redact, KEEP };
@@ -37,6 +37,8 @@ function privateCache(value) {
@@ -37,6 +37,8 @@ function privateCache(value) {
}
}
function createProxy({ config, shares, tunnels, gate, log = console, now = () => Date.now() }) {
function createProxy({ config, shares, tunnels, gate, log = console, now = () => Date.now() }) {
+ // The last requests through each share, for its owner's page (CON-8 v0; memory only).
+ const inspector = require('./inspector').createInspector({ now });
const domain = config.shares.domain;
const domain = config.shares.domain;
const cache = new Map(); // host → { share, at }
const cache = new Map(); // host → { share, at }
const windows = new Map(); // share id → { start, count }
const windows = new Map(); // share id → { start, count }
@@ -231,6 +233,9 @@ function createProxy({ config, shares, tunnels, gate, log = console, now = () =>
@@ -231,6 +233,9 @@ function createProxy({ config, shares, tunnels, gate, log = console, now = () =>
if (!admitted) return;
if (!admitted) return;
const { who, tunnel } = admitted;
const { who, tunnel } = admitted;
visit(share, who);
visit(share, who);
+ const rec = inspector.begin(share.id, { method: req.method, path: url, who });
+ res.on('finish', () => inspector.end(rec, { status: res.statusCode }));
+ res.on('close', () => inspector.end(rec, { error: res.writableFinished ? null : 'visitor_gone' }));
const port = (tunnel.clientInfo && tunnel.clientInfo.port) || share.local_port;
const port = (tunnel.clientInfo && tunnel.clientInfo.port) || share.local_port;
let finished = false;
let finished = false;
@@ -244,11 +249,13 @@ function createProxy({ config, shares, tunnels, gate, log = console, now = () =>
@@ -244,11 +249,13 @@ function createProxy({ config, shares, tunnels, gate, log = console, now = () =>
tunnel.abort(id, 'bad_status');
tunnel.abort(id, 'bad_status');
return sendProblem(req, res, 502, 'bad_response', 'The app answered something odd', 'The app on the other end sent a response OpenVibe Connect cannot pass on.');
return sendProblem(req, res, 502, 'bad_response', 'The app answered something odd', 'The app on the other end sent a response OpenVibe Connect cannot pass on.');
}
}
+ rec.status = status;
try { res.writeHead(status, responseHeaders(m.headers)); } catch { tunnel.abort(id, 'bad_headers'); res.destroy(); }
try { res.writeHead(status, responseHeaders(m.headers)); } catch { tunnel.abort(id, 'bad_headers'); res.destroy(); }
return undefined;
return undefined;
},
},
onData(buf) {
onData(buf) {
addBytes(share, 'out', buf.length);
addBytes(share, 'out', buf.length);
+ rec.out += buf.length;
if (!res.write(buf)) {
if (!res.write(buf)) {
tunnel.send({ t: 'pause', id });
tunnel.send({ t: 'pause', id });
res.once('drain', () => tunnel.send({ t: 'resume', id }));
res.once('drain', () => tunnel.send({ t: 'resume', id }));
@@ -258,6 +265,7 @@ function createProxy({ config, shares, tunnels, gate, log = console, now = () =>
@@ -258,6 +265,7 @@ function createProxy({ config, shares, tunnels, gate, log = console, now = () =>
onAbort(m) {
onAbort(m) {
finished = true;
finished = true;
clearTimeout(headTimer);
clearTimeout(headTimer);
+ if (m && m.code) rec.error = String(m.code).slice(0, 40);
if (res.headersSent) { res.destroy(); return; }
if (res.headersSent) { res.destroy(); return; }
if (m && m.code === 'local_refused') {
if (m && m.code === 'local_refused') {
sendProblem(req, res, 502, 'local_refused', 'Nothing is listening', `${share.owner_username}'s computer is connected, but nothing answered on port ${port}.`);
sendProblem(req, res, 502, 'local_refused', 'Nothing is listening', `${share.owner_username}'s computer is connected, but nothing answered on port ${port}.`);
@@ -273,6 +281,7 @@ function createProxy({ config, shares, tunnels, gate, log = console, now = () =>
@@ -273,6 +281,7 @@ function createProxy({ config, shares, tunnels, gate, log = console, now = () =>
headTimer = setTimeout(() => {
headTimer = setTimeout(() => {
if (res.headersSent || finished) return;
if (res.headersSent || finished) return;
finished = true;
finished = true;
+ rec.error = 'timeout';
tunnel.abort(id, 'timeout');
tunnel.abort(id, 'timeout');
sendProblem(req, res, 504, 'timeout', 'That took too long', `The app on ${share.owner_username}'s computer did not answer in ${Math.round(config.shares.requestTimeoutMs / 1000)} seconds.`);
sendProblem(req, res, 504, 'timeout', 'That took too long', `The app on ${share.owner_username}'s computer did not answer in ${Math.round(config.shares.requestTimeoutMs / 1000)} seconds.`);
}, config.shares.requestTimeoutMs);
}, config.shares.requestTimeoutMs);
@@ -282,8 +291,9 @@ function createProxy({ config, shares, tunnels, gate, log = console, now = () =>
@@ -282,8 +291,9 @@ function createProxy({ config, shares, tunnels, gate, log = console, now = () =>
req.on('data', (chunk) => {
req.on('data', (chunk) => {
received += chunk.length;
received += chunk.length;
addBytes(share, 'in', chunk.length);
addBytes(share, 'in', chunk.length);
+ rec.in += chunk.length;
if (received > config.shares.maxBodyBytes) {
if (received > config.shares.maxBodyBytes) {
- if (!finished) { finished = true; tunnel.abort(id, 'too_large'); sendProblem(req, res, 413, 'too_large', 'Too large', 'That request is larger than a share takes.'); }
+ if (!finished) { finished = true; rec.error = 'too_large'; tunnel.abort(id, 'too_large'); sendProblem(req, res, 413, 'too_large', 'Too large', 'That request is larger than a share takes.'); }
req.destroy();
req.destroy();
return;
return;
}
}
@@ -305,6 +315,8 @@ function createProxy({ config, shares, tunnels, gate, log = console, now = () =>
@@ -305,6 +315,8 @@ function createProxy({ config, shares, tunnels, gate, log = console, now = () =>
if (!admitted) return;
if (!admitted) return;
const { who, tunnel } = admitted;
const { who, tunnel } = admitted;
visit(share, who);
visit(share, who);
+ const rec = inspector.begin(share.id, { method: req.method, path: String(req.url || '/'), who, ws: true });
+ socket.on('close', () => inspector.end(rec));
const port = (tunnel.clientInfo && tunnel.clientInfo.port) || share.local_port;
const port = (tunnel.clientInfo && tunnel.clientInfo.port) || share.local_port;
const lines = [`${req.method} ${req.url} HTTP/1.1`];
const lines = [`${req.method} ${req.url} HTTP/1.1`];
@@ -313,13 +325,17 @@ function createProxy({ config, shares, tunnels, gate, log = console, now = () =>
@@ -313,13 +325,17 @@ function createProxy({ config, shares, tunnels, gate, log = console, now = () =>
let opened = false;
let opened = false;
const id = tunnel.open({
const id = tunnel.open({
onData(buf) {
onData(buf) {
+ if (!opened) rec.status = /^HTTP\/1\.[01] (\d{3})/.test(buf.toString('latin1', 0, 16)) ? Number(RegExp.$1) : null;
opened = true;
opened = true;
addBytes(share, 'out', buf.length);
addBytes(share, 'out', buf.length);
+ rec.out += buf.length;
if (!socket.write(buf)) { tunnel.send({ t: 'pause', id }); socket.once('drain', () => tunnel.send({ t: 'resume', id })); }
if (!socket.write(buf)) { tunnel.send({ t: 'pause', id }); socket.once('drain', () => tunnel.send({ t: 'resume', id })); }
},
},
onEnd() { tunnel.release(id); socket.end(); },
onEnd() { tunnel.release(id); socket.end(); },
onAbort(m) {
onAbort(m) {
+ if (m && m.code) rec.error = String(m.code).slice(0, 40);
if (!opened && m && m.code === 'local_refused') {
if (!opened && m && m.code === 'local_refused') {
+ rec.status = 502;
socket.end(`HTTP/1.1 502 Bad Gateway\r\ncontent-type: text/plain\r\nconnection: close\r\n\r\nNothing answered on port ${port}.`);
socket.end(`HTTP/1.1 502 Bad Gateway\r\ncontent-type: text/plain\r\nconnection: close\r\n\r\nNothing answered on port ${port}.`);
} else socket.destroy();
} else socket.destroy();
},
},
@@ -327,9 +343,10 @@ function createProxy({ config, shares, tunnels, gate, log = console, now = () =>
@@ -327,9 +343,10 @@ function createProxy({ config, shares, tunnels, gate, log = console, now = () =>
onResume() { socket.resume(); },
onResume() { socket.resume(); },
});
});
tunnel.send({ t: 'raw', id, head: headText });
tunnel.send({ t: 'raw', id, head: headText });
- if (head && head.length) { addBytes(share, 'in', head.length); tunnel.data(id, head); }
+ if (head && head.length) { addBytes(share, 'in', head.length); rec.in += head.length; tunnel.data(id, head); }
socket.on('data', (chunk) => {
socket.on('data', (chunk) => {
addBytes(share, 'in', chunk.length);
addBytes(share, 'in', chunk.length);
+ rec.in += chunk.length;
tunnel.data(id, chunk);
tunnel.data(id, chunk);
if (tunnel.congested) { socket.pause(); tunnel.drained().then(() => socket.resume()); }
if (tunnel.congested) { socket.pause(); tunnel.drained().then(() => socket.resume()); }
});
});
@@ -342,7 +359,7 @@ function createProxy({ config, shares, tunnels, gate, log = console, now = () =>
@@ -342,7 +359,7 @@ function createProxy({ config, shares, tunnels, gate, log = console, now = () =>
/** The grant refused someone: counted on the share page as "turned away". */
/** The grant refused someone: counted on the share page as "turned away". */
function denied(share, who) { visit(share, who, { denied: true }); }
function denied(share, who) { visit(share, who, { denied: true }); }
- return { handle, upgrade, isShareHost, flush, forget, denied, stop, responseHeaders, forwardHeaders };
+ return { handle, upgrade, isShareHost, flush, forget, denied, stop, responseHeaders, forwardHeaders, inspector };
}
}
/** Write an HTTP answer straight to an upgrade's socket (the gate's 302/401/404/502 before any tunnel opens). */
/** Write an HTTP answer straight to an upgrade's socket (the gate's 302/401/404/502 before any tunnel opens). */
@@ -11,6 +11,8 @@
@@ -11,6 +11,8 @@
* DELETE /shares/:id close it (the host answers 410 from then on)
* DELETE /shares/:id close it (the host answers 410 from then on)
* POST /shares/:id/key a new key → { share, key }; the connected computer is disconnected
* POST /shares/:id/key a new key → { share, key }; the connected computer is disconnected
* GET /shares/:id/visits who opened it in the last 7 days
* GET /shares/:id/visits who opened it in the last 7 days
+ * GET /shares/:id/requests the last 100 requests through it, newest first (?after=<seq>: only newer ones);
+ * memory only, query values shown as "…" (edge/inspector.js)
*
*
* Errors are application/problem+json with a `code`.
* Errors are application/problem+json with a `code`.
*/
*/
@@ -72,6 +74,7 @@ function createApi(ctx) {
@@ -72,6 +74,7 @@ function createApi(ctx) {
const closed = await shares.revoke(owner(req), share.id);
const closed = await shares.revoke(owner(req), share.id);
tunnels.close(share.id, 4003, 'share closed');
tunnels.close(share.id, 4003, 'share closed');
proxy.forget(closed.host);
proxy.forget(closed.host);
+ proxy.inspector.forget(share.id);
return res.json({ share: shape(closed) });
return res.json({ share: shape(closed) });
}));
}));
@@ -83,6 +86,11 @@ function createApi(ctx) {
@@ -83,6 +86,11 @@ function createApi(ctx) {
return res.json({ share: shape(updated), key });
return res.json({ share: shape(updated), key });
}));
}));
+ r.get('/shares/:id/requests', guard(async (req, res) => {
+ const share = await mine(req, res);
+ return share ? res.json({ requests: proxy.inspector.list(share.id, { after: req.query.after }), keep: proxy.inspector.KEEP }) : undefined;
+ }));
+
r.get('/shares/:id/visits', guard(async (req, res) => {
r.get('/shares/:id/visits', guard(async (req, res) => {
const share = await mine(req, res);
const share = await mine(req, res);
return share ? res.json({ visits: await shares.visits(share.id) }) : undefined;
return share ? res.json({ visits: await shares.visits(share.id) }) : undefined;
@@ -81,7 +81,7 @@ function createPageRoutes(ctx) {
@@ -81,7 +81,7 @@ function createPageRoutes(ctx) {
async function showShare(req, res, share, extra = {}, status = 200) {
async function showShare(req, res, share, extra = {}, status = 200) {
const tunnel = tunnels.get(share.id);
const tunnel = tunnels.get(share.id);
const visits = await shares.visits(share.id);
const visits = await shares.visits(share.id);
- return page(req, res, { title: share.host, body: views.sharePage({ share, connected: Boolean(tunnel), tunnel, visits, baseUrl: config.baseUrl, staff: req.viewer.role === 'admin', ...extra }) }, status);
+ return page(req, res, { title: share.host, body: views.sharePage({ share, connected: Boolean(tunnel), tunnel, visits, requests: ctx.proxy ? ctx.proxy.inspector.list(share.id) : [], baseUrl: config.baseUrl, staff: req.viewer.role === 'admin', ...extra }) }, status);
}
}
r.get('/shares/:id', async (req, res) => {
r.get('/shares/:id', async (req, res) => {
@@ -126,6 +126,7 @@ function createPageRoutes(ctx) {
@@ -126,6 +126,7 @@ function createPageRoutes(ctx) {
const closed = await shares.revoke(owner(req), share.id);
const closed = await shares.revoke(owner(req), share.id);
tunnels.close(share.id, 4003, 'share closed');
tunnels.close(share.id, 4003, 'share closed');
proxy.forget(closed.host);
proxy.forget(closed.host);
+ proxy.inspector.forget(share.id);
return showShare(req, res, closed, { notice: 'Closed. The address answers "closed" from now on.' });
return showShare(req, res, closed, { notice: 'Closed. The address answers "closed" from now on.' });
});
});
@@ -190,7 +190,24 @@ function keyBlock(share, key, baseUrl) {
@@ -190,7 +190,24 @@ function keyBlock(share, key, baseUrl) {
</section>`;
</section>`;
}
}
-function sharePage({ share, connected, tunnel = null, visits = [], key = null, baseUrl, notice = null, error = null, staff = false, now = Date.now() }) {
+/** Bytes as a person reads them. */
+function size(n) {
+ const b = Number(n) || 0;
+ return b < 1024 ? `${b} B` : b < 1024 * 1024 ? `${(b / 1024).toFixed(1)} KB` : `${(b / 1024 / 1024).toFixed(1)} MB`;
+}
+
+const REQ_ERRORS = { local_refused: 'nothing listening', timeout: 'timed out', visitor_gone: 'visitor left', too_large: 'too large', tunnel_closed: 'dropped', bad_status: 'odd answer', bad_headers: 'odd answer', local_error: 'app stopped' };
+
+/** One request in the inspector (public/js/connect.js draws the same row for new ones). */
+function requestRow(q) {
+ const tone = q.open ? 'open' : q.error && !q.status ? 'err' : q.status >= 500 ? 's5' : q.status >= 400 ? 's4' : q.status >= 300 ? 's3' : 's2';
+ const badge = q.open ? (q.ws ? 'open' : '…') : q.status ? String(q.status) : (REQ_ERRORS[q.error] || q.error || '?');
+ const meta = [q.ms != null ? `${q.ms} ms` : null, q.in ? `${size(q.in)} in` : null, q.out ? `${size(q.out)} out` : null,
+ q.error && q.status ? (REQ_ERRORS[q.error] || q.error) : null, q.who ? `@${q.who}` : null].filter(Boolean).join(' · ');
+ return `<li class="cn-req is-${tone}" data-seq="${esc(q.seq)}"><span class="cn-req-badge">${esc(badge)}</span><code class="cn-req-line"><b>${esc(q.ws ? 'WS' : q.method)}</b> ${esc(q.path)}</code><span class="cn-req-meta">${esc(meta)}</span><time datetime="${esc(q.at)}">${esc(String(q.at).slice(11, 19))}</time></li>`;
+}
+
+function sharePage({ share, connected, tunnel = null, visits = [], requests = [], key = null, baseUrl, notice = null, error = null, staff = false, now = Date.now() }) {
const closed = share.status !== 'active';
const closed = share.status !== 'active';
const state = closed ? 'Closed' : connected ? 'Connected' : 'Not connected';
const state = closed ? 'Closed' : connected ? 'Connected' : 'Not connected';
const stateText = closed ? `Closed ${ago(share.revoked_at, now)}. The address answers "closed" and stays yours.`
const stateText = closed ? `Closed ${ago(share.revoked_at, now)}. The address answers "closed" and stays yours.`
@@ -231,6 +248,10 @@ ${key ? keyBlock(share, key, baseUrl) : ''}
@@ -231,6 +248,10 @@ ${key ? keyBlock(share, key, baseUrl) : ''}
</dl>
</dl>
${!key && !closed ? `<section class="cn-run"><h2>Connect it</h2><div class="cn-copy"><code id="cn-run">node ov-connect.js --key ovc_… --port ${esc(share.local_port)}</code></div><p class="cn-fine">The key was shown once, when it was made. <a href="${esc(CLIENT_URL(baseUrl))}">Download ov-connect.js</a> (Node 22 or newer).</p></section>` : ''}
${!key && !closed ? `<section class="cn-run"><h2>Connect it</h2><div class="cn-copy"><code id="cn-run">node ov-connect.js --key ovc_… --port ${esc(share.local_port)}</code></div><p class="cn-fine">The key was shown once, when it was made. <a href="${esc(CLIENT_URL(baseUrl))}">Download ov-connect.js</a> (Node 22 or newer).</p></section>` : ''}
<section aria-labelledby="cn-visits-h"><h2 id="cn-visits-h">Who opened it (7 days)</h2>${visitRows}</section>
<section aria-labelledby="cn-visits-h"><h2 id="cn-visits-h">Who opened it (7 days)</h2>${visitRows}</section>
+${closed ? '' : `<section class="cn-inspector" aria-labelledby="cn-reqs-h" data-cn-reqs="${esc(share.id)}"><h2 id="cn-reqs-h">Requests</h2>
+<p class="cn-fine">The last 100 through this share, newest first, added as they happen. Kept in memory only, never written down: no headers, no bodies, and query values show as "…".</p>
+<ol class="cn-reqs" data-cn-reqs-list>${requests.map(requestRow).join('')}</ol>
+<p class="cn-empty" data-cn-reqs-empty${requests.length ? ' hidden' : ''}>No requests yet${connected ? '. Open the address and they appear here' : ''}.</p></section>`}
${policy}
${policy}
${danger}
${danger}
</div>`;
</div>`;
@@ -249,4 +270,4 @@ function message({ title, text, link = null }) {
@@ -249,4 +270,4 @@ function message({ title, text, link = null }) {
function notFound() { return message({ title: 'Not found', text: 'There is no page here.', link: { href: '/', label: 'OpenVibe Connect' } }); }
function notFound() { return message({ title: 'Not found', text: 'There is no page here.', link: { href: '/', label: 'OpenVibe Connect' } }); }
-module.exports = { home, sharePage, grantDenied, message, notFound, MODE_TEXT, fmtBytes, ago };
+module.exports = { home, sharePage, grantDenied, message, notFound, MODE_TEXT, fmtBytes, ago, requestRow, size };
@@ -254,6 +254,29 @@ async function until(fn, ms = 3000) {
@@ -254,6 +254,29 @@ async function until(fn, ms = 3000) {
assert.strictEqual(s.connected, true);
assert.strictEqual(s.connected, true);
});
});
+ await check('the inspector (CON-8): the requests through the share, newest first, query values hidden, the owner\'s only', async () => {
+ const r = await t.share(made.share.host, '/hello?token=s3cret-value&page=2', { cookie: alexCookie, headers: { accept: 'application/json' } });
+ assert.strictEqual(r.status, 200);
+ let list = [];
+ await until(async () => { list = (await t.get(`/api/v1/shares/${made.share.id}/requests`, { as: alex })).json().requests; return list[0] && !list[0].open; });
+ const top = list[0];
+ assert.deepStrictEqual([top.method, top.path, top.status, top.who], ['GET', '/hello?token=…&page=…', 200, 'Alex_Dev']);
+ assert.ok(top.ms >= 0 && top.out > 0, JSON.stringify(top));
+ assert.ok(!JSON.stringify(list).includes('s3cret'), 'a query value never reaches the list');
+ assert.ok(list.some((q) => q.ws && q.status === 101), 'the WebSocket is there, with its 101');
+ assert.ok(list.some((q) => q.method === 'POST' && q.in > 0), 'a body counts its bytes in');
+ assert.ok(list.every((q, i) => i === 0 || q.seq < list[i - 1].seq), 'newest first');
+ const newer = (await t.get(`/api/v1/shares/${made.share.id}/requests?after=${top.seq}`, { as: alex })).json().requests;
+ assert.deepStrictEqual(newer, [], 'after= answers only newer ones');
+ const page = await t.get(`/shares/${made.share.id}`, { as: alex });
+ assert.ok(page.text.includes(`data-cn-reqs="${made.share.id}"`));
+ assert.ok(page.text.includes('<b>GET</b> /hello?token=…&page=…'), 'the page lists it');
+ assert.strictEqual((await t.get(`/api/v1/shares/${made.share.id}/requests`, { as: bob })).status, 404, 'nobody else');
+ const dead = (await t.get('/api/v1/shares', { as: alex })).json().shares.find((s) => s.label === 'dead');
+ const refused = (await t.get(`/api/v1/shares/${dead.id}/requests`, { as: alex })).json().requests[0];
+ assert.deepStrictEqual([refused.status, refused.error], [502, 'local_refused'], 'nothing listening is named');
+ });
+
await check('another person cannot see, change or close someone else\'s share', async () => {
await check('another person cannot see, change or close someone else\'s share', async () => {
assert.strictEqual((await t.get(`/api/v1/shares/${made.share.id}`, { as: bob })).status, 404);
assert.strictEqual((await t.get(`/api/v1/shares/${made.share.id}`, { as: bob })).status, 404);
assert.strictEqual((await t.get(`/api/v1/shares/${made.share.id}`, { as: bob, method: 'DELETE' })).status, 404);
assert.strictEqual((await t.get(`/api/v1/shares/${made.share.id}`, { as: bob, method: 'DELETE' })).status, 404);