openvibers/openvibe.codes Commits
Sandbox worker: send the owner's subject on the Live check-in (#305)
Changes
@@@ -90,7 +90,7 @@ if [ ! -s "$SECRETS/network-client-secret" ]; thenfiif [ ! -f "$ENV_FILE" ]; theninstall -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)@@@ -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.liveCODES_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).@@@ -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 });@@@ -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();
@@ -90,7 +90,7 @@ if [ ! -s "$SECRETS/network-client-secret" ]; then
@@ -90,7 +90,7 @@ if [ ! -s "$SECRETS/network-client-secret" ]; then
fi
fi
if [ ! -f "$ENV_FILE" ]; then
if [ ! -f "$ENV_FILE" ]; then
install -m 0640 -o root -g "$SANDBOX_USER" "$here/sandbox-worker.env.example" "$ENV_FILE"
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
fi
# 6. The browser (optional)
# 6. The browser (optional)
@@ -31,11 +31,14 @@ CODES_SANDBOX_CODES_URL=http://127.0.0.1:4900
@@ -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
# 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
# (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_LIVE_URL=https://openvibe.live
CODES_SANDBOX_NETWORK_URL=https://openvibe.network
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=
CODES_SANDBOX_LIVE_STREAMS=
# How often: the check-ins (every 30 s) and the self-test (every 10 minutes).
# How often: the check-ins (every 30 s) and the self-test (every 10 minutes).
@@ -8,8 +8,9 @@
@@ -8,8 +8,9 @@
* ready only when every isolation check passed AND the cgroup limits are there
* ready only when every isolation check passed AND the cgroup limits are there
* announce every CODES_SANDBOX_ANNOUNCE_MS (30 s):
* 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
* 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)
* 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
* 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
* 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');
@@ -38,6 +39,9 @@ const { PORTS } = require('./egress-proxy');
const VERSION = require('../package.json').version;
const VERSION = require('../package.json').version;
const STREAM = /^[A-Za-z0-9_-]{1,64}$/;
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 SESSION = /^[a-z0-9]{10}$/;
const MB = 1024 * 1024;
const MB = 1024 * 1024;
@@ -87,6 +91,8 @@ function loadConfig(env = process.env) {
@@ -87,6 +91,8 @@ function loadConfig(env = process.env) {
live: {
live: {
url: trim(env.CODES_SANDBOX_LIVE_URL || 'https://openvibe.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)),
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: {
network: {
tokenUrl: `${trim(env.CODES_SANDBOX_NETWORK_URL || 'https://openvibe.network')}/oauth/token`,
tokenUrl: `${trim(env.CODES_SANDBOX_NETWORK_URL || 'https://openvibe.network')}/oauth/token`,
@@ -198,12 +204,18 @@ function createWorker(opts = {}) {
@@ -198,12 +204,18 @@ function createWorker(opts = {}) {
const streams = new Set(config.live.streams);
const streams = new Set(config.live.streams);
for (const s of sessions.values()) if (s.stream && s.sbx.state === 'running') streams.add(s.stream);
for (const s of sessions.values()) if (s.stream && s.sbx.state === 'running') streams.add(s.stream);
if (tokens && streams.size) {
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;
let token = null;
try { token = await tokens.getToken(); } catch (err) { log.error('[sandbox] no Network service token:', err.code || err.message); }
try { token = await tokens.getToken(); } catch (err) { log.error('[sandbox] no Network service token:', err.code || err.message); }
for (const stream of streams) {
for (const stream of streams) {
if (!token) { lastAnnounce.live[stream] = { error: 'no_token' }; continue; }
if (!token) { lastAnnounce.live[stream] = { error: 'no_token' }; continue; }
try {
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() };
lastAnnounce.live[stream] = { status, at: new Date(now()).toISOString() };
} catch (err) { lastAnnounce.live[stream] = { error: err.code || err.name || 'failed' }; }
} catch (err) { lastAnnounce.live[stream] = { error: err.code || err.name || 'failed' }; }
}
}
@@ -296,6 +308,7 @@ function createWorker(opts = {}) {
@@ -296,6 +308,7 @@ function createWorker(opts = {}) {
async function start() {
async function start() {
if (!support) support = await probeSupport();
if (!support) support = await probeSupport();
if (!support.ok) log.error(`[sandbox] cannot make sandboxes: ${support.reason}`);
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) limiter = await detectLimits({ mode: config.limitsMode });
if (!limiter.ok) log.error(`[sandbox] no limits: ${limiter.reason}`);
if (!limiter.ok) log.error(`[sandbox] no limits: ${limiter.reason}`);
fs.mkdirSync(config.runtimeDir, { recursive: true, mode: 0o750 });
fs.mkdirSync(config.runtimeDir, { recursive: true, mode: 0o750 });
@@ -195,7 +195,7 @@ async function check(name, fn) {
@@ -195,7 +195,7 @@ async function check(name, fn) {
return sbx;
return sbx;
};
};
const config = {
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'),
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() };
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) {
@@ -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);
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();
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']);
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);
const body = JSON.parse(calls[0].init.body);
assert.strictEqual(body.ready, true);
assert.strictEqual(body.ready, true);
assert.strictEqual(body.isolation.network_namespace, true);
assert.strictEqual(body.isolation.network_namespace, true);
@@ -252,6 +255,17 @@ async function check(name, fn) {
@@ -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 () => {
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: '' } } });
const w = make({ tokens: null, config: { ...config, codes: { url: 'http://codes.test', secret: '' } } });
await w.selfTest();
await w.selfTest();