openvibers/openvibe.codes Commits

Sandbox worker: send the owner's subject on the Live check-in (#305)

OP OpenVibers committed 6 days ago · 53742477ef · 4 files, +39 −9

Live's registration refuses a worker that does not say whose streams it works
(403 sandbox_worker.subject_missing): the person goes in X-OV-Subject and must own
the stream. The worker now takes that subject from CODES_SANDBOX_LIVE_SUBJECT (a
usr_ id), sends it on every Live check-in, and sends none when it is missing or
malformed, recording no_subject in the announce report and logging why at start.
The env example and install.sh say to set it.

Changes

ddiff --git a/deploy/sandbox/install.sh b/deploy/sandbox/install.sh
iindex b5bd793..c9381ee 100755
---- a/deploy/sandbox/install.sh
++++ b/deploy/sandbox/install.sh
@@@ -90,7 +90,7 @@ if [ ! -s "$SECRETS/network-client-secret" ]; then
fi
if [ ! -f "$ENV_FILE" ]; then
install -m 0640 -o root -g "$SANDBOX_USER" "$here/sandbox-worker.env.example" "$ENV_FILE"
-- say "wrote $ENV_FILE from the example: set CODES_SANDBOX_WORKER_ID, CODES_SANDBOX_CLIENT_ID and CODES_SANDBOX_LIVE_STREAMS"
++ say "wrote $ENV_FILE from the example: set CODES_SANDBOX_WORKER_ID, CODES_SANDBOX_CLIENT_ID, CODES_SANDBOX_LIVE_SUBJECT and CODES_SANDBOX_LIVE_STREAMS"
fi
# 6. The browser (optional)
ddiff --git a/deploy/sandbox/sandbox-worker.env.example b/deploy/sandbox/sandbox-worker.env.example
iindex 3806d8a..18ac8a2 100644
---- a/deploy/sandbox/sandbox-worker.env.example
++++ b/deploy/sandbox/sandbox-worker.env.example
@@@ -31,11 +31,14 @@ CODES_SANDBOX_CODES_URL=http://127.0.0.1:4900
# Live: the check-in (POST /api/streams/agent/:id/plays/worker) with the worker's own Network service token
# (client credentials; audience openvibe.live; capability live.agent_sandbox.register). The client secret is the
--# credential network-client-secret. CODES_SANDBOX_LIVE_STREAMS: the agent streams this worker serves (comma list);
--# a running session's own stream is added for its lifetime.
++# credential network-client-secret. CODES_SANDBOX_CLIENT_ID is the Network client `codes-sandbox`.
++# CODES_SANDBOX_LIVE_SUBJECT is the owner's Network subject (usr_...): Live's registration requires it in X-OV-Subject
++# and refuses a check-in that does not name the stream's owner. CODES_SANDBOX_LIVE_STREAMS: the agent streams this
++# worker serves (comma list); a running session's own stream is added for its lifetime.
CODES_SANDBOX_LIVE_URL=https://openvibe.live
CODES_SANDBOX_NETWORK_URL=https://openvibe.network
--CODES_SANDBOX_CLIENT_ID=
++CODES_SANDBOX_CLIENT_ID=codes-sandbox
++CODES_SANDBOX_LIVE_SUBJECT=
CODES_SANDBOX_LIVE_STREAMS=
# How often: the check-ins (every 30 s) and the self-test (every 10 minutes).
ddiff --git a/sandbox/worker.js b/sandbox/worker.js
iindex 289161f..67b11fc 100644
---- a/sandbox/worker.js
++++ b/sandbox/worker.js
@@@ -8,8 +8,9 @@
* ready only when every isolation check passed AND the cgroup limits are there
* announce every CODES_SANDBOX_ANNOUNCE_MS (30 s):
* Live POST {LIVE}/api/streams/agent/:id/plays/worker with the worker's own Network service token
-- * (client credentials, audience openvibe.live, capability live.agent_sandbox.register), for
-- * each stream it serves: CODES_SANDBOX_LIVE_STREAMS and the streams of its running sessions
++ * (client credentials, audience openvibe.live, capability live.agent_sandbox.register) and
++ * CODES_SANDBOX_LIVE_SUBJECT, the owner's usr_ subject, in X-OV-Subject, for each stream it
++ * serves: CODES_SANDBOX_LIVE_STREAMS and the streams of its running sessions
* Codes POST {CODES}/internal/sandbox-workers/heartbeat, HMAC-signed (sandbox/signature.js)
* with the same body: the worker, ready, the checks, the isolation facts, capacity and what runs
* local API HTTP on a Unix socket (CODES_SANDBOX_SOCKET, 0660: only the worker's user and group): Codes' runner
@@@ -38,6 +39,9 @@ const { PORTS } = require('./egress-proxy');
const VERSION = require('../package.json').version;
const STREAM = /^[A-Za-z0-9_-]{1,64}$/;
++// A person's Network subject (the owner this worker works for): Live's registration names it in X-OV-Subject and
++// refuses the check-in unless it is the stream's owner.
++const SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const SESSION = /^[a-z0-9]{10}$/;
const MB = 1024 * 1024;
@@@ -87,6 +91,8 @@ function loadConfig(env = process.env) {
live: {
url: trim(env.CODES_SANDBOX_LIVE_URL || 'https://openvibe.live'),
streams: String(env.CODES_SANDBOX_LIVE_STREAMS || '').split(',').map((s) => s.trim()).filter((s) => STREAM.test(s)),
++ // The person whose streams this worker serves; Live checks it against each stream's owner.
++ subject: SUBJECT.test(String(env.CODES_SANDBOX_LIVE_SUBJECT || '').trim()) ? String(env.CODES_SANDBOX_LIVE_SUBJECT).trim() : '',
},
network: {
tokenUrl: `${trim(env.CODES_SANDBOX_NETWORK_URL || 'https://openvibe.network')}/oauth/token`,
@@@ -198,12 +204,18 @@ function createWorker(opts = {}) {
const streams = new Set(config.live.streams);
for (const s of sessions.values()) if (s.stream && s.sbx.state === 'running') streams.add(s.stream);
if (tokens && streams.size) {
++ if (!config.live.subject) {
++ // Live's registration requires the person the worker works for; without it every check-in is 403.
++ for (const stream of streams) lastAnnounce.live[stream] = { error: 'no_subject' };
++ return { codes: lastAnnounce.codes, live: { ...lastAnnounce.live } };
++ }
let token = null;
try { token = await tokens.getToken(); } catch (err) { log.error('[sandbox] no Network service token:', err.code || err.message); }
for (const stream of streams) {
if (!token) { lastAnnounce.live[stream] = { error: 'no_token' }; continue; }
try {
-- const status = await post(`${config.live.url}/api/streams/agent/${encodeURIComponent(stream)}/plays/worker`, body, { authorization: `Bearer ${token}` });
++ const status = await post(`${config.live.url}/api/streams/agent/${encodeURIComponent(stream)}/plays/worker`, body,
++ { authorization: `Bearer ${token}`, 'x-ov-subject': config.live.subject });
lastAnnounce.live[stream] = { status, at: new Date(now()).toISOString() };
} catch (err) { lastAnnounce.live[stream] = { error: err.code || err.name || 'failed' }; }
}
@@@ -296,6 +308,7 @@ function createWorker(opts = {}) {
async function start() {
if (!support) support = await probeSupport();
if (!support.ok) log.error(`[sandbox] cannot make sandboxes: ${support.reason}`);
++ if (!config.live.subject) log.error('[sandbox] no CODES_SANDBOX_LIVE_SUBJECT (the owner\'s usr_ subject): it cannot check in with Live');
if (!limiter) limiter = await detectLimits({ mode: config.limitsMode });
if (!limiter.ok) log.error(`[sandbox] no limits: ${limiter.reason}`);
fs.mkdirSync(config.runtimeDir, { recursive: true, mode: 0o750 });
ddiff --git a/test/sandbox-worker.test.js b/test/sandbox-worker.test.js
iindex c296a4c..9f45bdc 100644
---- a/test/sandbox-worker.test.js
++++ b/test/sandbox-worker.test.js
@@@ -195,7 +195,7 @@ async function check(name, fn) {
return sbx;
};
const config = {
-- ...loadConfig({ CODES_SANDBOX_RUNTIME_DIR: tmp, CODES_SANDBOX_LIVE_STREAMS: 'ags_owner1,bad stream!,ags_owner2', CODES_SANDBOX_LIVE_URL: 'http://live.test', CODES_SANDBOX_CODES_URL: 'http://codes.test', CODES_SANDBOX_WORKER_SECRET: CODES_SECRET, CODES_SANDBOX_MAX_SESSIONS: '2' }),
++ ...loadConfig({ CODES_SANDBOX_RUNTIME_DIR: tmp, CODES_SANDBOX_LIVE_STREAMS: 'ags_owner1,bad stream!,ags_owner2', CODES_SANDBOX_LIVE_SUBJECT: 'usr_01JAB2C3D4E5F6G7H8J9K0MNA1', CODES_SANDBOX_LIVE_URL: 'http://live.test', CODES_SANDBOX_CODES_URL: 'http://codes.test', CODES_SANDBOX_WORKER_SECRET: CODES_SECRET, CODES_SANDBOX_MAX_SESSIONS: '2' }),
socket: path.join(tmp, 'w.sock'),
};
const passed = { ok: true, checks: Object.fromEntries(require('../sandbox/selftest').CHECKS.map((c) => [c, true])), failed: [], at: new Date().toISOString() };
@@@ -231,7 +231,10 @@ async function check(name, fn) {
assert.strictEqual(signature.verify({ secrets: [CODES_SECRET], header: codes[0].init.headers[signature.HEADER], body: codes[0].init.body }).ok, true);
const live = calls.filter((c) => c.url.startsWith('http://live.test/')).map((c) => c.url).sort();
assert.deepStrictEqual(live, ['http://live.test/api/streams/agent/ags_owner1/plays/worker', 'http://live.test/api/streams/agent/ags_owner2/plays/worker', 'http://live.test/api/streams/agent/ags_session3/plays/worker']);
-- for (const c of calls.filter((x) => x.url.startsWith('http://live.test/'))) assert.strictEqual(c.init.headers.authorization, 'Bearer svc-token-for-live');
++ for (const c of calls.filter((x) => x.url.startsWith('http://live.test/'))) {
++ assert.strictEqual(c.init.headers.authorization, 'Bearer svc-token-for-live');
++ assert.strictEqual(c.init.headers['x-ov-subject'], 'usr_01JAB2C3D4E5F6G7H8J9K0MNA1', 'Live is told whose streams these are');
++ }
const body = JSON.parse(calls[0].init.body);
assert.strictEqual(body.ready, true);
assert.strictEqual(body.isolation.network_namespace, true);
@@@ -252,6 +255,17 @@ async function check(name, fn) {
}
});
++ await check('without the owner\'s subject there is no Live check-in: Live would refuse it', async () => {
++ const w = make({ config: { ...config, live: { ...config.live, subject: '' } } });
++ await w.selfTest();
++ calls.length = 0;
++ await w.openSession({ stream_id: 'ags_session3' });
++ const out = await w.announce();
++ assert.deepStrictEqual(calls.filter((c) => c.url.startsWith('http://live.test/')), [], 'nothing is sent to Live without a subject');
++ assert.strictEqual(out.live.ags_owner1.error, 'no_subject');
++ assert.strictEqual(out.live.ags_session3.error, 'no_subject');
++ });
++
await check('no service token, no Live check-in; no Codes secret, no heartbeat', async () => {
const w = make({ tokens: null, config: { ...config, codes: { url: 'http://codes.test', secret: '' } } });
await w.selfTest();