openvibers/openvibe.network Commits

Add owner Tools worker fleet and routing controls (#795)

OP OpenVibers committed 2 days ago · 02db8dece8 · 8 files, +161 −1

* Add owner Tools worker fleet and routing controls

* Register Tools workers in the admin module test map

Changes

ddiff --git a/public/admin.html b/public/admin.html
iindex 4896810d..286b2bd3 100644
---- a/public/admin.html
++++ b/public/admin.html
@@@ -684,6 +684,7 @@
<script src="/js/admin/users.js" defer></script>
<script src="/js/admin/moderation.js" defer></script>
<script src="/js/admin/settings.js" defer></script>
++<script src="/js/admin/tools-workers.js" defer></script>
<script src="/js/admin/payments.js" defer></script>
<script src="/js/admin/glossary.js" defer></script>
<script src="/js/admin/ai.js" defer></script>
ddiff --git a/public/js/admin/settings.js b/public/js/admin/settings.js
iindex ec651dc1..3c960443 100644
---- a/public/js/admin/settings.js
++++ b/public/js/admin/settings.js
@@@ -372,6 +372,7 @@ async function loadToolsSettings() {
<div style="margin-top:16px"><button type="button" class="btn btn-primary" onclick="saveToolsSettings()"><i class="fa-solid fa-floppy-disk"></i> Save Network settings</button></div>
</form>`;
} catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }
++ finally { if (currentUser && currentUser.is_owner) await loadToolsWorkers(); }
}
let _toolsSettingsOriginal = {};
ddiff --git a/public/js/admin/tools-workers.js b/public/js/admin/tools-workers.js
nnew file mode 100644
iindex 00000000..ff2ad298
---- /dev/null
++++ b/public/js/admin/tools-workers.js
@@@ -0,0 +1,54 @@
++/* Owner-only Tools infrastructure controls. Credentials stay in this response/DOM only. */
++async function loadToolsWorkers() {
++ if (!currentUser || !currentUser.is_owner) return;
++ const parent = document.getElementById('settings-tools');
++ let panel = document.getElementById('tools-workers-panel');
++ if (!panel) { panel = document.createElement('section'); panel.id = 'tools-workers-panel'; parent.appendChild(panel); }
++ panel.innerHTML = '<h2>Tools workers</h2><p>Loading workers…</p>';
++ try {
++ const fleet = await api('/api/admin/tools-workers/fleet');
++ const value = n => n == null ? '—' : esc(Number(n).toLocaleString());
++ panel.innerHTML = `<h2>Tools workers</h2>
++<p><a href="https://openvibe.tools/workers" target="_blank" rel="noopener">Run Tools on your own machine</a>. Jobs today counts execution attempts in UTC and survives job expiry. Load is the one-minute system load.</p>
++<div style="overflow-x:auto"><table><thead><tr><th>Worker / location</th><th>Status</th><th>Load</th><th>Free / slots</th><th>Active jobs</th><th>Jobs today (${esc(fleet.day)})</th><th>Controls</th></tr></thead><tbody>
++${fleet.workers.map(w => `<tr><td>${esc(w.label)}<br><code>${esc(w.id)}</code><br>${esc(w.location)}</td><td>${w.revoked ? 'Revoked' : !w.online ? 'Offline' : w.draining ? 'Draining' : 'Online'}</td><td>${w.load == null ? '—' : esc(Number(w.load).toFixed(2))}</td><td>${value(w.free)} / ${value(w.slots)}</td><td>${value(w.active_jobs)}</td><td>${value(w.jobs_today)}</td><td>${w.id === 'host' ? 'Fallback' : w.revoked ? '' : `<button class="ovk-btn ovk-btn--quiet" data-worker-drain="${esc(w.id)}" data-draining="${w.draining ? 'false' : 'true'}">${w.draining ? 'Resume' : 'Drain'}</button> <button class="ovk-btn ovk-btn--quiet" data-worker-revoke="${esc(w.id)}">Revoke credential</button>`}</td></tr>`).join('')}
++</tbody></table></div>
++<button type="button" class="ovk-btn ovk-btn--quiet" id="tools-workers-refresh">Refresh workers</button>
++<form id="tools-workers-routing"><h3>Routing</h3><label><input type="checkbox" id="tools-workers-enabled" ${fleet.routing.enabled ? 'checked' : ''}> Enable worker routing</label><p><label for="tools-workers-order">Order (home, host, or worker ids; separated by commas)</label><br><textarea id="tools-workers-order" rows="3">${esc(fleet.routing.order.join(', '))}</textarea></p><p>Within each group, free slots and recent latency decide. Fetching jobs stay on the host. Draining stops new assignments and lets current jobs finish.</p><button class="ovk-btn ovk-btn--primary" type="submit">Save routing</button></form>
++<form id="tools-workers-create"><h3>Add a worker</h3><label>Label <input name="label" required maxlength="80" pattern="[A-Za-z0-9_ .-]+" placeholder="Dell tower one"></label> <label>Location <select name="location"><option value="home">Home</option><option value="host">Host</option></select></label> <button class="ovk-btn ovk-btn--primary" type="submit">Create one-time credential</button></form>
++<div id="tools-workers-credential" hidden><p>Save this token in a private file on the worker. It is shown only now and cannot be retrieved again.</p><pre id="tools-workers-token"></pre><button type="button" class="ovk-btn ovk-btn--quiet" id="tools-workers-clear">Clear credential</button></div>
++<p id="tools-workers-status" role="status"></p>`;
++ const status = message => { panel.querySelector('#tools-workers-status').textContent = message; };
++ const action = async (button, fn) => { button.disabled = true; try { await fn(); } catch (e) { status(e.message); } finally { button.disabled = false; } };
++ panel.querySelector('#tools-workers-refresh').onclick = () => loadToolsWorkers();
++ panel.querySelector('#tools-workers-clear').onclick = () => { panel.querySelector('#tools-workers-token').textContent = ''; panel.querySelector('#tools-workers-credential').hidden = true; };
++ for (const button of panel.querySelectorAll('[data-worker-drain]')) button.onclick = () => action(button, async () => {
++ await api(`/api/admin/tools-workers/workers/${button.dataset.workerDrain}/drain`, { method: 'PUT', body: { draining: button.dataset.draining === 'true' } });
++ await loadToolsWorkers();
++ });
++ for (const button of panel.querySelectorAll('[data-worker-revoke]')) button.onclick = () => action(button, async () => {
++ await api(`/api/admin/tools-workers/workers/${button.dataset.workerRevoke}`, { method: 'DELETE' });
++ await loadToolsWorkers();
++ });
++ panel.querySelector('#tools-workers-routing').onsubmit = event => {
++ event.preventDefault();
++ return action(event.target.querySelector('button'), async () => {
++ const order = panel.querySelector('#tools-workers-order').value.split(/[\s,]+/).filter(Boolean);
++ await api('/api/admin/tools-workers/routing', { method: 'PUT', body: { enabled: panel.querySelector('#tools-workers-enabled').checked, order } });
++ await loadToolsWorkers();
++ });
++ };
++ panel.querySelector('#tools-workers-create').onsubmit = event => {
++ event.preventDefault();
++ return action(event.target.querySelector('button'), async () => {
++ const fields = new FormData(event.target);
++ const credential = await api('/api/admin/tools-workers/workers', { method: 'POST', body: { label: fields.get('label'), location: fields.get('location') } });
++ // Render immediately: a later fleet fetch must not lose the one-time response.
++ panel.querySelector('#tools-workers-token').textContent = `Worker id: ${credential.id}\nToken: ${credential.token}`;
++ panel.querySelector('#tools-workers-credential').hidden = false;
++ status('Worker created. Save the credential before refreshing.');
++ event.target.reset();
++ });
++ };
++ } catch (e) { panel.innerHTML = `<h2>Tools workers</h2><p>${esc(e.message)}</p><button class="ovk-btn ovk-btn--quiet" type="button" onclick="loadToolsWorkers()">Retry</button>`; }
++}
ddiff --git a/server/admin/service-proxies.js b/server/admin/service-proxies.js
iindex ae39fca7..9067e519 100644
---- a/server/admin/service-proxies.js
++++ b/server/admin/service-proxies.js
@@@ -66,6 +66,7 @@ function createServiceProxies({ requireAuth, signToken, services }) {
const live = () => services.live.internalUrl;
const chat = () => services.chat.internalUrl;
++ r.use('/tools-workers', require('./tools-workers').createToolsWorkers({ requireAuth, signToken, services }));
r.use('/streamer', requireAuth, admin, to(live, '/api/admin', 'OpenVibe.Live', 'Streamer proxy error'));
r.use('/streamer-mod', requireAuth, staff, to(live, '/api/mod', 'OpenVibe.Live', 'Mod proxy error'));
r.use('/streamer-tts', requireAuth, admin, to(chat, '/api/tts', 'OpenVibe.Chat', 'TTS proxy error'));
ddiff --git a/server/admin/tools-workers.js b/server/admin/tools-workers.js
nnew file mode 100644
iindex 00000000..9a0d98c1
---- /dev/null
++++ b/server/admin/tools-workers.js
@@@ -0,0 +1,40 @@
++'use strict';
++const express = require('express');
++const { requireOwner } = require('../auth/owner-guard');
++/** Fixed owner-only Tools paths; no caller-selected URL or path is forwarded. */
++function createToolsWorkers({ requireAuth, signToken, services }) {
++ const router = express.Router();
++ router.use((_req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
++ router.use(requireAuth, requireOwner);
++ router.use((req, res, next) => {
++ if (!['GET', 'HEAD'].includes(req.method)) {
++ const expected = new URL(req.app.locals.config?.baseUrl || 'https://openvibe.network').origin;
++ if (req.headers.origin !== expected) return res.status(403).json({ error: 'Origin required' });
++ }
++ next();
++ });
++ const forward = pathFor => async (req, res) => {
++ try {
++ const path = pathFor(req);
++ if (!path) return res.status(400).json({ error: 'Invalid worker id' });
++ const token = req.user && req.app.locals.privateKey && typeof signToken === 'function'
++ ? signToken(req.user, req.app.locals.privateKey, req.app.locals.config, { renew: req.tokenClaims || {} }) : req.token;
++ const target = new URL(path, services.tools.internalUrl);
++ const upstream = await fetch(target, {
++ method: req.method, headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', Origin: 'https://openvibe.tools' },
++ body: ['GET', 'HEAD'].includes(req.method) ? undefined : JSON.stringify(req.body || {}),
++ signal: AbortSignal.timeout(10000), redirect: 'error',
++ });
++ res.status(upstream.status).json(await upstream.json());
++ } catch { res.status(502).json({ error: 'Could not reach Tools worker controls' }); }
++ };
++ const worker = suffix => req => /^wrk_[a-f0-9]{24}$/.test(req.params.id) ? `/api/v1/owner/workers/${req.params.id}${suffix}` : null;
++ router.get('/fleet', forward(() => '/api/v1/owner/workers'));
++ router.post('/workers', forward(() => '/api/v1/owner/workers'));
++ router.put('/workers/:id/drain', forward(worker('/drain')));
++ router.delete('/workers/:id', forward(worker('')));
++ router.get('/routing', forward(() => '/api/v1/owner/worker-routing'));
++ router.put('/routing', forward(() => '/api/v1/owner/worker-routing'));
++ return router;
++}
++module.exports = { createToolsWorkers };
ddiff --git a/test/admin-modules.test.js b/test/admin-modules.test.js
iindex ae71d68e..7ff2d1b0 100644
---- a/test/admin-modules.test.js
++++ b/test/admin-modules.test.js
@@@ -12,7 +12,7 @@ for (const [, body] of html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/g)) as
const files = fs.readdirSync(path.join(pub, 'js', 'admin')).filter((f) => f.endsWith('.js')).sort();
// The module map, in load order (public/js/admin/…).
--const modules = ['core.js', 'dashboard.js', 'users.js', 'moderation.js', 'settings.js', 'payments.js', 'glossary.js', 'ai.js', 'inference.js', 'setup.js', 'content.js', 'analytics.js', 'deploy.js', 'boot.js'];
++const modules = ['core.js', 'dashboard.js', 'users.js', 'moderation.js', 'settings.js', 'tools-workers.js', 'payments.js', 'glossary.js', 'ai.js', 'inference.js', 'setup.js', 'content.js', 'analytics.js', 'deploy.js', 'boot.js'];
assert.deepStrictEqual(files, [...modules].sort(), 'each module file exists and there is no other');
const tags = [...html.matchAll(/<script\b([^>]*)\bsrc="\/js\/admin\/([^"]+)"([^>]*)><\/script>/g)];
const loaded = tags.map((m) => m[2]);
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 7e292fcc..22ae55be 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -110,6 +110,7 @@ const PATTERNS = {
};
// The reviewed list: where each goes, and why a stranger cannot choose it (or how it is guarded).
const REVIEWED = {
++ 'server/admin/tools-workers.js fetch': [1, 'Owner-only fixed Tools admin paths at the operator-configured internal URL; no caller URL; redirects refused.'],
'server/link/egress-settings.js fetch': [2, 'browser-only GET and PUT to the fixed same-origin /api/v1/me/nodes/egress settings route; no caller-chosen host or URL'],
'server/connect/egress.js http.request': [1, 'Connect gateway (E7): only the app definition\'s fixed API host, through openvibe-shared/egress (IP literals refused before the socket, public DNS answers only, redirects followed by hand with every hop re-checked); never a URL from the input (Opus review 11-06)'],
'server/home/activity.js fetch': [1, 'front door activity: fixed first-party loopback URLs (OV_LIVE/COMMUNITY/BLOG_INTERNAL_URL, operator config) for public lists, never a request value'],
ddiff --git a/test/tools-workers.test.js b/test/tools-workers.test.js
nnew file mode 100644
iindex 00000000..13a8b2f9
---- /dev/null
++++ b/test/tools-workers.test.js
@@@ -0,0 +1,62 @@
++'use strict';
++const assert = require('node:assert/strict');
++const http = require('node:http');
++const fs = require('node:fs');
++const vm = require('node:vm');
++const express = require('express');
++const { parseHTML } = require('linkedom');
++const { createServiceProxies } = require('../server/admin/service-proxies');
++(async () => {
++ const seen = [];
++ const upstream = http.createServer((req, res) => {
++ let body = ''; req.on('data', d => { body += d; });
++ req.on('end', () => { seen.push({ path: req.url, auth: req.headers.authorization, origin: req.headers.origin, method: req.method, body }); res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ ok: true })); });
++ });
++ await new Promise(r => upstream.listen(0, '127.0.0.1', r));
++ const app = express(); app.use(express.json());
++ app.locals.config = { baseUrl: 'https://openvibe.network' }; app.locals.privateKey = 'fixture';
++ const requireAuth = (req, res, next) => { if (!req.headers['x-test-user']) return res.sendStatus(401); req.user = { id: 'test', username: req.headers['x-test-user'], role: 'admin' }; req.token = 'original-fixture'; next(); };
++ app.use('/api/admin', createServiceProxies({ requireAuth, signToken: () => 'reminted-fixture', services: { tools: { internalUrl: `http://127.0.0.1:${upstream.address().port}` } } }));
++ const server = await new Promise(r => { const s = app.listen(0, '127.0.0.1', () => r(s)); });
++ const base = `http://127.0.0.1:${server.address().port}/api/admin/tools-workers`;
++ const id = 'wrk_' + '1'.repeat(24);
++ const owner = process.env.OWNER_USERNAME || 'goosely';
++ async function call(p, method = 'GET', who = owner, origin = 'https://openvibe.network') {
++ return fetch(base + p, { method, headers: { 'x-test-user': who, origin, 'content-type': 'application/json' }, body: method === 'GET' ? undefined : JSON.stringify({ draining: true }) });
++ }
++ try {
++ assert.equal((await call('/fleet', 'GET', 'other-admin')).status, 403);
++ assert.equal((await call('/workers', 'POST', owner, 'https://elsewhere.example')).status, 403);
++ assert.equal(seen.length, 0);
++ const response = await call('/fleet'); assert.equal(response.status, 200); assert.equal(response.headers.get('cache-control'), 'no-store');
++ assert.deepEqual(seen[0], { path: '/api/v1/owner/workers', method: 'GET', body: '', auth: 'Bearer reminted-fixture', origin: 'https://openvibe.tools' });
++ assert.equal((await call(`/workers/${id}/drain`, 'PUT')).status, 200);
++ assert.equal(seen.at(-1).path, `/api/v1/owner/workers/${id}/drain`);
++ assert.equal((await call('/workers/not-a-worker/drain', 'PUT')).status, 400);
++ assert.equal((await call('/workers/anything/extra', 'POST')).status, 404);
++ assert.equal((await call('/routing', 'PUT')).status, 200);
++ assert.equal(seen.at(-1).path, '/api/v1/owner/worker-routing');
++ const { document } = parseHTML('<html><body><div id="settings-tools"></div></body></html>');
++ const calls = [];
++ const fleet = { day: '2026-11-10', routing: { enabled: false, order: ['home', 'host'] }, workers: [{ id, label: '<img src=x onerror=bad>', location: 'home', online: true, revoked: false, draining: false, slots: 2, free: 1, active_jobs: 1, jobs_today: 7, load: 0.2 }] };
++ const context = vm.createContext({ document, currentUser: { is_owner: true }, esc: s => String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]),
++ FormData: class { constructor(form) { this.form = form; } get(name) { return this.form.querySelector(`[name="${name}"]`).value; } },
++ api: async (p, options) => { calls.push({ p, options }); return p.endsWith('/fleet') ? fleet : p.endsWith('/workers') ? { id, token: 'one-time-fixture' } : {}; },
++ });
++ vm.runInContext(fs.readFileSync('public/js/admin/tools-workers.js', 'utf8'), context);
++ await context.loadToolsWorkers();
++ assert.equal(document.querySelector('img'), null, 'worker labels are escaped');
++ assert.match(document.querySelector('#tools-workers-panel').textContent, /Jobs today/);
++ await document.querySelector('[data-worker-drain]').onclick();
++ assert.equal(calls.find(c => c.p.endsWith('/drain')).options.body.draining, true);
++ const form = document.querySelector('#tools-workers-create'); form.reset = () => {};
++ form.querySelector('[name="label"]').value = 'Tower';
++ await form.onsubmit({ preventDefault() {}, target: form });
++ assert.match(document.querySelector('#tools-workers-token').textContent, /one-time-fixture/);
++ document.querySelector('#tools-workers-clear').onclick();
++ assert.equal(document.querySelector('#tools-workers-token').textContent, '');
++ const count = calls.length; context.currentUser.is_owner = false;
++ await context.loadToolsWorkers(); assert.equal(calls.length, count, 'nonowners do not request fleet data');
++ console.log('Tools console: owner/origin gates, fixed proxy paths, reminted auth, no-store, fleet rendering, escaping, drain and one-time credential clear passed');
++ } finally { await new Promise(r => server.close(r)); await new Promise(r => upstream.close(r)); }
++})().catch(e => { console.error(e); process.exitCode = 1; });