openvibers/openvibe.connect Commits
A11: relay authenticated Link browsing sessions (#76)
Changes
nnew file mode 100644@@@ -0,0 +1,3 @@++-- phase: expand++ALTER TABLE shares ADD COLUMN link_egress boolean NOT NULL DEFAULT false;++CREATE UNIQUE INDEX shares_link_egress_node ON shares(node_id) WHERE link_egress AND status = 'active';@@@ -102,7 +102,7 @@ async function createApp(opts = {}) {// API keys for a share (CON-16 v0): a program passes the gate with one; it opens the share's API, never its pages.const apiKeys = createApiKeys(s, { now: s.now });const oauth = createOAuth(s, { shares, scheme: config.shares.scheme, now: s.now });-- const proxy = createProxy({ config, shares, tunnels, gate, apiKeys, oauth, log, now: s.now });++ const proxy = createProxy({ config, shares, tunnels, gate, apiKeys, oauth, log, now: s.now, fetchImpl });const accountData = accountDataLib.create({ db: s.db, log });const accountSend = opts.accountSend || (config.oauth.clientSecret@@@ -40,7 +40,7 @@ function privateCache(value) {const AUDIT_MAX = 64 * 1024;const PRM = '/.well-known/oauth-protected-resource';--function createProxy({ config, shares, tunnels, gate, apiKeys = null, oauth = null, log = console, now = () => Date.now() }) {++function createProxy({ config, shares, tunnels, gate, apiKeys = null, oauth = null, log = console, now = () => Date.now(), fetchImpl = globalThis.fetch }) {// 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.domains || [config.shares.domain];@@@ -204,6 +204,18 @@ function createProxy({ config, shares, tunnels, gate, apiKeys = null, oauth = nu/** The share, the person and the tunnel for a request, or an answer already sent. */async function admit(req, res, share, parsedPath) {++ if (share.link_egress) {++ if (!await require('../link-egress').createEgressAuth({ config, fetchImpl }).open(share, req)) {++ sendProblem(req, res, 403, 'egress.refused', 'Connection refused', 'This session cannot use that computer.');++ return null;++ }++ const tunnel = tunnels.get(share.id);++ if (!tunnel || tunnel.busy) {++ sendProblem(req, res, 503, 'egress.offline', 'Computer unavailable', 'Your computer is offline or busy.');++ return null;++ }++ return { who: { subject: share.owner_subject, username: '' }, tunnel };++ }// An API key (CON-16) passes without signing in; a wrong or revoked one is refused, never sent to sign in.const bearer = /^Bearer\s+(ovsk_\S+)$/i.exec(String(req.headers.authorization || ''));let who = null;@@@ -37,6 +37,14 @@ function createNodeApi(ctx) {r.get('/shares', async (req, res) => res.json({ shares: (await shares.byNode(req.node)).map(brief) }));++ r.post('/egress-share', async (req, res) => {++ const identity = await require('../link-egress').createEgressAuth({ config }).identity(req.body?.network_token);++ if (!identity || identity.node_id !== req.node) return fail(res, req, 403, 'egress.owner', 'Network did not confirm this computer');++ const share = await shares.createEgress({ node: req.node, owner: identity.owner, name: identity.name, port: req.body.port });++ proxy.forget(share.host);++ return res.json({ share: brief(share) });++ });++r.post('/party-shares', async (req, res) => {const claim = verify(req.body && req.body.grant, config.partySharedSecret);if (!claim || claim.type !== 'create' || claim.node !== req.node)nnew file mode 100644@@@ -0,0 +1,24 @@++'use strict';++++// This route carries ONLY authenticated session traffic. A share cookie, API key or owner sign-in cannot open it.++function createEgressAuth({ config, fetchImpl = globalThis.fetch }) {++ async function network(path, body, token) {++ try {++ const r = await fetchImpl(`${config.networkInternalUrl}${path}`, { method: body ? 'POST' : 'GET',++ headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },++ ...(body ? { body: JSON.stringify(body) } : {}), signal: AbortSignal.timeout(5000), redirect: 'error' });++ return r.ok ? await r.json() : null;++ } catch { return null; }++ }++ return {++ identity: token => network('/api/v1/node/self/egress/identity', null, token),++ async open(share, req) {++ if (req.url !== '/__ov_egress' || req.method !== 'GET' || String(req.headers.upgrade).toLowerCase() !== 'websocket') return false;++ const session = req.headers['x-ov-session'], credential = req.headers['x-ov-egress'];++ if (typeof session !== 'string' || typeof credential !== 'string' || credential.length !== 43) return false;++ const result = await network('/api/v1/link-egress/open', { session_id: session, credential, node_id: share.node?.id });++ return !!result && result.node_id === share.node?.id && result.session_id === session;++ },++ };++}++module.exports = { createEgressAuth };@@@ -154,6 +154,7 @@ function createShares(s, { config }) {local_port: Number(row.local_port),mode: row.mode,party_slug: row.party_slug || null,++ ...(row.link_egress ? { link_egress: true } : {}),named: Array.isArray(row.named) ? row.named : [],status: row.status,policy_version: Number(row.policy_version),@@@ -304,6 +305,24 @@ function createShares(s, { config }) {return rows.map(view);}++ async function createEgress({ node, owner, name, port }) {++ if (!/^nod_[0-9A-HJKMNP-TV-Z]{26}$/.test(node) || !/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(owner) ||++ !Number.isInteger(port) || port < 1 || port > 65535) throw new ShareError(422, 'egress.invalid', 'Invalid computer');++ const old = await db.maybe("SELECT * FROM shares WHERE node_id = $1 AND link_egress AND status = 'active'", [node]);++ if (old) {++ if (old.owner_subject !== owner) throw new ShareError(403, 'egress.owner_mismatch', 'Computer owner changed');++ await db.exec('UPDATE shares SET local_port = $2, updated_at = now() WHERE id = $1', [old.id, port]);++ return get(old.id);++ }++ const id = s.newId('shr');++ const handle = node.slice(4).toLowerCase();++ const hash = sha256(crypto.randomBytes(32).toString('hex'));++ await db.exec(`INSERT INTO shares(id, owner_subject, owner_username, label, host, local_port, mode,++ key_prefix, key_hash, node_id, node_name, link_egress) VALUES ($1,$2,$3,'egress',$4,$5,'owner','',$6,$7,$8,true)`,++ [id, owner, handle, hostFor('egress', handle, domain), port, hash, node, String(name || 'My computer').slice(0, 100)]);++ return get(id);++ }++async function createParty({ party, owner, username, node, port }) {if (!/^party-[a-f0-9]{8}$/.test(String(party || '')) || !/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(owner || '')) ||!/^nod_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(node || ''))) throw new ShareError(422, 'party.invalid', 'Invalid party or machine');@@@ -448,7 +467,7 @@ function createShares(s, { config }) {return false;}-- return { create, createParty, partyMembers, closeParty, list, get, byHost, byKey, byNode, bindNode, setPolicy, rotateKey, revoke, expireDue, report, openReports, handleReport, addWaiter, takeWaiters, touchConnected, flushCounters, visits, allows, view, domain };++ return { create, createEgress, createParty, partyMembers, closeParty, list, get, byHost, byKey, byNode, bindNode, setPolicy, rotateKey, revoke, expireDue, report, openReports, handleReport, addWaiter, takeWaiters, touchConnected, flushCounters, visits, allows, view, domain };}function iso(v) {nnew file mode 100644@@@ -0,0 +1,87 @@++'use strict';++const assert = require('assert/strict');++const http = require('http');++const net = require('net');++const crypto = require('crypto');++const WebSocket = require('ws');++const { once } = require('events');++++(async () => {++ const fetchOriginal = globalThis.fetch;++ const credential = crypto.randomBytes(32).toString('base64url');++ let identity, checks = 0, allowed = true;++ globalThis.fetch = async (url, options = {}) => {++ if (String(url).endsWith('/api/v1/node/self/egress/identity')) return new Response(JSON.stringify(identity));++ if (String(url).endsWith('/api/v1/link-egress/open')) {++ checks++;++ const b = JSON.parse(options.body);++ return allowed && b.credential === credential && b.node_id === identity.node_id++ ? new Response(JSON.stringify({ node_id: b.node_id, session_id: b.session_id })) : new Response(null, { status: 403 });++ }++ return fetchOriginal(url, options);++ };++ const { boot } = require('./helpers/boot');++ const t = await boot({ env: { CONNECT_SHARE_DOMAIN: 'openvibe.link', CONNECT_SHARE_DOMAINS: 'openvibe.link' } });++ const endpoint = http.createServer();++ const streams = new WebSocket.Server({ noServer: true });++ endpoint.on('upgrade', (req, socket, head) => {++ if (req.headers['x-ov-egress'] !== credential || req.headers['x-ov-session'] !== 'ses_test') { socket.destroy(); return; }++ streams.handleUpgrade(req, socket, head, ws => ws.on('message', data => ws.send(data)));++ });++ await new Promise(resolve => endpoint.listen(0, '127.0.0.1', resolve));++ let tunnel;++ const sockets = new Map();++ try {++ const owner = t.network.addUser('egressowner');++ const node = t.network.addNode(owner, { name: 'Home PC' });++ identity = { node_id: node.id, owner: owner.subject, name: 'Home PC' };++ const made = await t.get('/api/v1/node/egress-share', { method: 'POST', bearer: t.network.nodeToken(node),++ json: { network_token: 'test-network-token', port: endpoint.address().port } });++ assert.equal(made.status, 200, 'Node creates its dedicated share after Network confirms ownership');++ const share = made.json().share;++ const ticket = await t.get(`/api/v1/node/shares/${share.id}/tunnel-token`, { method: 'POST', bearer: t.network.nodeToken(node) });++ tunnel = new WebSocket(`ws://127.0.0.1:${t.port}/tunnel`);++ const packet = (kind, id, data = Buffer.alloc(0)) => { const h = Buffer.alloc(5); h[0] = kind; h.writeUInt32BE(id, 1); return Buffer.concat([h, data]); };++ const ready = new Promise((resolve, reject) => {++ const timer = setTimeout(() => reject(Error('relay did not connect')), 5000);++ tunnel.on('message', (data, binary) => {++ if (binary) {++ const socket = sockets.get(data.readUInt32BE(1));++ if (socket) { if (data[0] === 1) socket.write(data.subarray(5)); else socket.end(); }++ return;++ }++ const frame = JSON.parse(data);++ if (frame.t === 'ready') { clearTimeout(timer); resolve(); }++ if (frame.t === 'raw') {++ const socket = net.connect(endpoint.address().port, '127.0.0.1', () => socket.write(frame.head));++ sockets.set(frame.id, socket);++ socket.on('data', b => { if (tunnel.readyState === WebSocket.OPEN) tunnel.send(packet(1, frame.id, b)); });++ socket.on('end', () => { if (tunnel.readyState === WebSocket.OPEN) tunnel.send(packet(2, frame.id)); });++ socket.on('error', () => socket.destroy());++ }++ });++ tunnel.on('error', reject);++ });++ await once(tunnel, 'open');++ tunnel.send(JSON.stringify({ t: 'hello', v: 1, client: 'egress-test', share: share.id,++ node_token: t.network.nodeToken(node), tunnel: ticket.json().tunnel_token, port: endpoint.address().port }));++ await ready;++ const connect = () => new WebSocket(`ws://127.0.0.1:${t.port}/__ov_egress`, { headers: { Host: share.host, 'X-OV-Session': 'ses_test', 'X-OV-Egress': credential } });++ const visitor = connect(); await once(visitor, 'open');++ const returned = once(visitor, 'message'); visitor.send(Buffer.from('through the existing relay'));++ assert.equal((await returned)[0].toString(), 'through the existing relay'); visitor.terminate();++ allowed = false;++ const refused = connect();++ const status = await new Promise((resolve, reject) => { refused.on('unexpected-response', (_, res) => { res.resume(); resolve(res.statusCode); refused.terminate(); }); refused.on('error', e => { if (!String(e.message).includes('closed before')) reject(e); }); });++ assert.equal(status, 403, 'a later owner refusal stops the next tunnel');++ assert.equal(checks, 2, 'each tunnel opens through Network');++ assert.ok(!String(t.logs()).includes(credential), 'credentials do not enter logs');++ process.stdout.write('Link egress relay: authenticated Node share, binary round-trip and fresh owner refusal passed\n');++ } finally {++ globalThis.fetch = fetchOriginal;++ if (tunnel) tunnel.terminate();++ for (const socket of sockets.values()) socket.destroy();++ for (const ws of streams.clients) ws.terminate();++ streams.close(); endpoint.close(); await t.close();++ }++})().catch(e => { process.stderr.write(`${e.stack}\n`); process.exitCode = 1; });nnew file mode 100644@@@ -0,0 +1,26 @@++'use strict';++const assert = require('assert/strict');++const { createEgressAuth } = require('../server/link-egress');++(async () => {++ const seen = [];++ let allowed = true;++ const gate = createEgressAuth({ config: { networkInternalUrl: 'http://network' }, fetchImpl: async (url, options) => {++ seen.push({ url, options });++ const b = JSON.parse(options.body);++ return { ok: allowed, json: async () => ({ node_id: b.node_id, session_id: b.session_id }) };++ } });++ const share = { node: { id: 'nod_mine' } };++ const req = { method: 'GET', url: '/__ov_egress', headers: { upgrade: 'websocket', 'x-ov-session': 'ses_mine', 'x-ov-egress': 'a'.repeat(43) } };++ assert.equal(await gate.open(share, req), true);++ allowed = false;++ assert.equal(await gate.open(share, req), false, 'successful ownership checks are never cached');++ assert.equal(seen.length, 2);++ assert.equal(JSON.parse(seen[0].options.body).node_id, 'nod_mine');++ assert.equal(await gate.open(share, { ...req, headers: { cookie: 'owner-session' } }), false, 'owner cookie is insufficient');++ assert.equal(await gate.open(share, { ...req, url: '/' }), false);++ assert.equal(await gate.open(share, { ...req, url: '/__ov_egress?credential=secret' }), false);++ assert.equal(await gate.open(share, { ...req, method: 'CONNECT' }), false);++ const offline = createEgressAuth({ config: { networkInternalUrl: 'http://network' }, fetchImpl: async () => { throw Error(); } });++ assert.equal(await offline.open(share, req), false, 'Network unavailable fails closed');++ console.log('Link egress gate: every-open authorization and fail-closed checks passed');++})().catch(e => { console.error(e); process.exitCode = 1; });
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
+-- phase: expand
+ALTER TABLE shares ADD COLUMN link_egress boolean NOT NULL DEFAULT false;
+CREATE UNIQUE INDEX shares_link_egress_node ON shares(node_id) WHERE link_egress AND status = 'active';
@@ -102,7 +102,7 @@ async function createApp(opts = {}) {
@@ -102,7 +102,7 @@ async function createApp(opts = {}) {
// API keys for a share (CON-16 v0): a program passes the gate with one; it opens the share's API, never its pages.
// API keys for a share (CON-16 v0): a program passes the gate with one; it opens the share's API, never its pages.
const apiKeys = createApiKeys(s, { now: s.now });
const apiKeys = createApiKeys(s, { now: s.now });
const oauth = createOAuth(s, { shares, scheme: config.shares.scheme, now: s.now });
const oauth = createOAuth(s, { shares, scheme: config.shares.scheme, now: s.now });
- const proxy = createProxy({ config, shares, tunnels, gate, apiKeys, oauth, log, now: s.now });
+ const proxy = createProxy({ config, shares, tunnels, gate, apiKeys, oauth, log, now: s.now, fetchImpl });
const accountData = accountDataLib.create({ db: s.db, log });
const accountData = accountDataLib.create({ db: s.db, log });
const accountSend = opts.accountSend || (config.oauth.clientSecret
const accountSend = opts.accountSend || (config.oauth.clientSecret
@@ -40,7 +40,7 @@ function privateCache(value) {
@@ -40,7 +40,7 @@ function privateCache(value) {
const AUDIT_MAX = 64 * 1024;
const AUDIT_MAX = 64 * 1024;
const PRM = '/.well-known/oauth-protected-resource';
const PRM = '/.well-known/oauth-protected-resource';
-function createProxy({ config, shares, tunnels, gate, apiKeys = null, oauth = null, log = console, now = () => Date.now() }) {
+function createProxy({ config, shares, tunnels, gate, apiKeys = null, oauth = null, log = console, now = () => Date.now(), fetchImpl = globalThis.fetch }) {
// The last requests through each share, for its owner's page (CON-8 v0; memory only).
// The last requests through each share, for its owner's page (CON-8 v0; memory only).
const inspector = require('./inspector').createInspector({ now });
const inspector = require('./inspector').createInspector({ now });
const domain = config.shares.domains || [config.shares.domain];
const domain = config.shares.domains || [config.shares.domain];
@@ -204,6 +204,18 @@ function createProxy({ config, shares, tunnels, gate, apiKeys = null, oauth = nu
@@ -204,6 +204,18 @@ function createProxy({ config, shares, tunnels, gate, apiKeys = null, oauth = nu
/** The share, the person and the tunnel for a request, or an answer already sent. */
/** The share, the person and the tunnel for a request, or an answer already sent. */
async function admit(req, res, share, parsedPath) {
async function admit(req, res, share, parsedPath) {
+ if (share.link_egress) {
+ if (!await require('../link-egress').createEgressAuth({ config, fetchImpl }).open(share, req)) {
+ sendProblem(req, res, 403, 'egress.refused', 'Connection refused', 'This session cannot use that computer.');
+ return null;
+ }
+ const tunnel = tunnels.get(share.id);
+ if (!tunnel || tunnel.busy) {
+ sendProblem(req, res, 503, 'egress.offline', 'Computer unavailable', 'Your computer is offline or busy.');
+ return null;
+ }
+ return { who: { subject: share.owner_subject, username: '' }, tunnel };
+ }
// An API key (CON-16) passes without signing in; a wrong or revoked one is refused, never sent to sign in.
// An API key (CON-16) passes without signing in; a wrong or revoked one is refused, never sent to sign in.
const bearer = /^Bearer\s+(ovsk_\S+)$/i.exec(String(req.headers.authorization || ''));
const bearer = /^Bearer\s+(ovsk_\S+)$/i.exec(String(req.headers.authorization || ''));
let who = null;
let who = null;
@@ -37,6 +37,14 @@ function createNodeApi(ctx) {
@@ -37,6 +37,14 @@ function createNodeApi(ctx) {
r.get('/shares', async (req, res) => res.json({ shares: (await shares.byNode(req.node)).map(brief) }));
r.get('/shares', async (req, res) => res.json({ shares: (await shares.byNode(req.node)).map(brief) }));
+ r.post('/egress-share', async (req, res) => {
+ const identity = await require('../link-egress').createEgressAuth({ config }).identity(req.body?.network_token);
+ if (!identity || identity.node_id !== req.node) return fail(res, req, 403, 'egress.owner', 'Network did not confirm this computer');
+ const share = await shares.createEgress({ node: req.node, owner: identity.owner, name: identity.name, port: req.body.port });
+ proxy.forget(share.host);
+ return res.json({ share: brief(share) });
+ });
+
r.post('/party-shares', async (req, res) => {
r.post('/party-shares', async (req, res) => {
const claim = verify(req.body && req.body.grant, config.partySharedSecret);
const claim = verify(req.body && req.body.grant, config.partySharedSecret);
if (!claim || claim.type !== 'create' || claim.node !== req.node)
if (!claim || claim.type !== 'create' || claim.node !== req.node)
new file mode 100644
new file mode 100644
@@ -0,0 +1,24 @@
@@ -0,0 +1,24 @@
+'use strict';
+
+// This route carries ONLY authenticated session traffic. A share cookie, API key or owner sign-in cannot open it.
+function createEgressAuth({ config, fetchImpl = globalThis.fetch }) {
+ async function network(path, body, token) {
+ try {
+ const r = await fetchImpl(`${config.networkInternalUrl}${path}`, { method: body ? 'POST' : 'GET',
+ headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
+ ...(body ? { body: JSON.stringify(body) } : {}), signal: AbortSignal.timeout(5000), redirect: 'error' });
+ return r.ok ? await r.json() : null;
+ } catch { return null; }
+ }
+ return {
+ identity: token => network('/api/v1/node/self/egress/identity', null, token),
+ async open(share, req) {
+ if (req.url !== '/__ov_egress' || req.method !== 'GET' || String(req.headers.upgrade).toLowerCase() !== 'websocket') return false;
+ const session = req.headers['x-ov-session'], credential = req.headers['x-ov-egress'];
+ if (typeof session !== 'string' || typeof credential !== 'string' || credential.length !== 43) return false;
+ const result = await network('/api/v1/link-egress/open', { session_id: session, credential, node_id: share.node?.id });
+ return !!result && result.node_id === share.node?.id && result.session_id === session;
+ },
+ };
+}
+module.exports = { createEgressAuth };
@@ -154,6 +154,7 @@ function createShares(s, { config }) {
@@ -154,6 +154,7 @@ function createShares(s, { config }) {
local_port: Number(row.local_port),
local_port: Number(row.local_port),
mode: row.mode,
mode: row.mode,
party_slug: row.party_slug || null,
party_slug: row.party_slug || null,
+ ...(row.link_egress ? { link_egress: true } : {}),
named: Array.isArray(row.named) ? row.named : [],
named: Array.isArray(row.named) ? row.named : [],
status: row.status,
status: row.status,
policy_version: Number(row.policy_version),
policy_version: Number(row.policy_version),
@@ -304,6 +305,24 @@ function createShares(s, { config }) {
@@ -304,6 +305,24 @@ function createShares(s, { config }) {
return rows.map(view);
return rows.map(view);
}
}
+ async function createEgress({ node, owner, name, port }) {
+ if (!/^nod_[0-9A-HJKMNP-TV-Z]{26}$/.test(node) || !/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(owner) ||
+ !Number.isInteger(port) || port < 1 || port > 65535) throw new ShareError(422, 'egress.invalid', 'Invalid computer');
+ const old = await db.maybe("SELECT * FROM shares WHERE node_id = $1 AND link_egress AND status = 'active'", [node]);
+ if (old) {
+ if (old.owner_subject !== owner) throw new ShareError(403, 'egress.owner_mismatch', 'Computer owner changed');
+ await db.exec('UPDATE shares SET local_port = $2, updated_at = now() WHERE id = $1', [old.id, port]);
+ return get(old.id);
+ }
+ const id = s.newId('shr');
+ const handle = node.slice(4).toLowerCase();
+ const hash = sha256(crypto.randomBytes(32).toString('hex'));
+ await db.exec(`INSERT INTO shares(id, owner_subject, owner_username, label, host, local_port, mode,
+ key_prefix, key_hash, node_id, node_name, link_egress) VALUES ($1,$2,$3,'egress',$4,$5,'owner','',$6,$7,$8,true)`,
+ [id, owner, handle, hostFor('egress', handle, domain), port, hash, node, String(name || 'My computer').slice(0, 100)]);
+ return get(id);
+ }
+
async function createParty({ party, owner, username, node, port }) {
async function createParty({ party, owner, username, node, port }) {
if (!/^party-[a-f0-9]{8}$/.test(String(party || '')) || !/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(owner || '')) ||
if (!/^party-[a-f0-9]{8}$/.test(String(party || '')) || !/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(owner || '')) ||
!/^nod_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(node || ''))) throw new ShareError(422, 'party.invalid', 'Invalid party or machine');
!/^nod_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(node || ''))) throw new ShareError(422, 'party.invalid', 'Invalid party or machine');
@@ -448,7 +467,7 @@ function createShares(s, { config }) {
@@ -448,7 +467,7 @@ function createShares(s, { config }) {
return false;
return false;
}
}
- return { create, createParty, partyMembers, closeParty, list, get, byHost, byKey, byNode, bindNode, setPolicy, rotateKey, revoke, expireDue, report, openReports, handleReport, addWaiter, takeWaiters, touchConnected, flushCounters, visits, allows, view, domain };
+ return { create, createEgress, createParty, partyMembers, closeParty, list, get, byHost, byKey, byNode, bindNode, setPolicy, rotateKey, revoke, expireDue, report, openReports, handleReport, addWaiter, takeWaiters, touchConnected, flushCounters, visits, allows, view, domain };
}
}
function iso(v) {
function iso(v) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,87 @@
@@ -0,0 +1,87 @@
+'use strict';
+const assert = require('assert/strict');
+const http = require('http');
+const net = require('net');
+const crypto = require('crypto');
+const WebSocket = require('ws');
+const { once } = require('events');
+
+(async () => {
+ const fetchOriginal = globalThis.fetch;
+ const credential = crypto.randomBytes(32).toString('base64url');
+ let identity, checks = 0, allowed = true;
+ globalThis.fetch = async (url, options = {}) => {
+ if (String(url).endsWith('/api/v1/node/self/egress/identity')) return new Response(JSON.stringify(identity));
+ if (String(url).endsWith('/api/v1/link-egress/open')) {
+ checks++;
+ const b = JSON.parse(options.body);
+ return allowed && b.credential === credential && b.node_id === identity.node_id
+ ? new Response(JSON.stringify({ node_id: b.node_id, session_id: b.session_id })) : new Response(null, { status: 403 });
+ }
+ return fetchOriginal(url, options);
+ };
+ const { boot } = require('./helpers/boot');
+ const t = await boot({ env: { CONNECT_SHARE_DOMAIN: 'openvibe.link', CONNECT_SHARE_DOMAINS: 'openvibe.link' } });
+ const endpoint = http.createServer();
+ const streams = new WebSocket.Server({ noServer: true });
+ endpoint.on('upgrade', (req, socket, head) => {
+ if (req.headers['x-ov-egress'] !== credential || req.headers['x-ov-session'] !== 'ses_test') { socket.destroy(); return; }
+ streams.handleUpgrade(req, socket, head, ws => ws.on('message', data => ws.send(data)));
+ });
+ await new Promise(resolve => endpoint.listen(0, '127.0.0.1', resolve));
+ let tunnel;
+ const sockets = new Map();
+ try {
+ const owner = t.network.addUser('egressowner');
+ const node = t.network.addNode(owner, { name: 'Home PC' });
+ identity = { node_id: node.id, owner: owner.subject, name: 'Home PC' };
+ const made = await t.get('/api/v1/node/egress-share', { method: 'POST', bearer: t.network.nodeToken(node),
+ json: { network_token: 'test-network-token', port: endpoint.address().port } });
+ assert.equal(made.status, 200, 'Node creates its dedicated share after Network confirms ownership');
+ const share = made.json().share;
+ const ticket = await t.get(`/api/v1/node/shares/${share.id}/tunnel-token`, { method: 'POST', bearer: t.network.nodeToken(node) });
+ tunnel = new WebSocket(`ws://127.0.0.1:${t.port}/tunnel`);
+ const packet = (kind, id, data = Buffer.alloc(0)) => { const h = Buffer.alloc(5); h[0] = kind; h.writeUInt32BE(id, 1); return Buffer.concat([h, data]); };
+ const ready = new Promise((resolve, reject) => {
+ const timer = setTimeout(() => reject(Error('relay did not connect')), 5000);
+ tunnel.on('message', (data, binary) => {
+ if (binary) {
+ const socket = sockets.get(data.readUInt32BE(1));
+ if (socket) { if (data[0] === 1) socket.write(data.subarray(5)); else socket.end(); }
+ return;
+ }
+ const frame = JSON.parse(data);
+ if (frame.t === 'ready') { clearTimeout(timer); resolve(); }
+ if (frame.t === 'raw') {
+ const socket = net.connect(endpoint.address().port, '127.0.0.1', () => socket.write(frame.head));
+ sockets.set(frame.id, socket);
+ socket.on('data', b => { if (tunnel.readyState === WebSocket.OPEN) tunnel.send(packet(1, frame.id, b)); });
+ socket.on('end', () => { if (tunnel.readyState === WebSocket.OPEN) tunnel.send(packet(2, frame.id)); });
+ socket.on('error', () => socket.destroy());
+ }
+ });
+ tunnel.on('error', reject);
+ });
+ await once(tunnel, 'open');
+ tunnel.send(JSON.stringify({ t: 'hello', v: 1, client: 'egress-test', share: share.id,
+ node_token: t.network.nodeToken(node), tunnel: ticket.json().tunnel_token, port: endpoint.address().port }));
+ await ready;
+ const connect = () => new WebSocket(`ws://127.0.0.1:${t.port}/__ov_egress`, { headers: { Host: share.host, 'X-OV-Session': 'ses_test', 'X-OV-Egress': credential } });
+ const visitor = connect(); await once(visitor, 'open');
+ const returned = once(visitor, 'message'); visitor.send(Buffer.from('through the existing relay'));
+ assert.equal((await returned)[0].toString(), 'through the existing relay'); visitor.terminate();
+ allowed = false;
+ const refused = connect();
+ const status = await new Promise((resolve, reject) => { refused.on('unexpected-response', (_, res) => { res.resume(); resolve(res.statusCode); refused.terminate(); }); refused.on('error', e => { if (!String(e.message).includes('closed before')) reject(e); }); });
+ assert.equal(status, 403, 'a later owner refusal stops the next tunnel');
+ assert.equal(checks, 2, 'each tunnel opens through Network');
+ assert.ok(!String(t.logs()).includes(credential), 'credentials do not enter logs');
+ process.stdout.write('Link egress relay: authenticated Node share, binary round-trip and fresh owner refusal passed\n');
+ } finally {
+ globalThis.fetch = fetchOriginal;
+ if (tunnel) tunnel.terminate();
+ for (const socket of sockets.values()) socket.destroy();
+ for (const ws of streams.clients) ws.terminate();
+ streams.close(); endpoint.close(); await t.close();
+ }
+})().catch(e => { process.stderr.write(`${e.stack}\n`); process.exitCode = 1; });
new file mode 100644
new file mode 100644
@@ -0,0 +1,26 @@
@@ -0,0 +1,26 @@
+'use strict';
+const assert = require('assert/strict');
+const { createEgressAuth } = require('../server/link-egress');
+(async () => {
+ const seen = [];
+ let allowed = true;
+ const gate = createEgressAuth({ config: { networkInternalUrl: 'http://network' }, fetchImpl: async (url, options) => {
+ seen.push({ url, options });
+ const b = JSON.parse(options.body);
+ return { ok: allowed, json: async () => ({ node_id: b.node_id, session_id: b.session_id }) };
+ } });
+ const share = { node: { id: 'nod_mine' } };
+ const req = { method: 'GET', url: '/__ov_egress', headers: { upgrade: 'websocket', 'x-ov-session': 'ses_mine', 'x-ov-egress': 'a'.repeat(43) } };
+ assert.equal(await gate.open(share, req), true);
+ allowed = false;
+ assert.equal(await gate.open(share, req), false, 'successful ownership checks are never cached');
+ assert.equal(seen.length, 2);
+ assert.equal(JSON.parse(seen[0].options.body).node_id, 'nod_mine');
+ assert.equal(await gate.open(share, { ...req, headers: { cookie: 'owner-session' } }), false, 'owner cookie is insufficient');
+ assert.equal(await gate.open(share, { ...req, url: '/' }), false);
+ assert.equal(await gate.open(share, { ...req, url: '/__ov_egress?credential=secret' }), false);
+ assert.equal(await gate.open(share, { ...req, method: 'CONNECT' }), false);
+ const offline = createEgressAuth({ config: { networkInternalUrl: 'http://network' }, fetchImpl: async () => { throw Error(); } });
+ assert.equal(await offline.open(share, req), false, 'Network unavailable fails closed');
+ console.log('Link egress gate: every-open authorization and fail-closed checks passed');
+})().catch(e => { console.error(e); process.exitCode = 1; });