diff --git a/frontend/js/api.js b/frontend/js/api.js index 786516b..6ff0649 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -32,6 +32,10 @@ export const api = { getDevice: (id) => request(`/devices/${id}`), updateDevice: (id, data) => request(`/devices/${id}`, { method: 'PUT', body: JSON.stringify(data) }), deleteDevice: (id) => request(`/devices/${id}`, { method: 'DELETE' }), + // #146 Item D: operator block/unblock — refuses the device at its next register with + // no restart. Server enforces via the SNAT-safe identity chain (deviceSocket). + blockDevice: (id) => request(`/devices/${id}/block`, { method: 'POST' }), + unblockDevice: (id) => request(`/devices/${id}/unblock`, { method: 'POST' }), // #109 PiP overlay: push/clear a floating overlay on a device or group. `id` may be a // device id OR a group id (the server resolves + expands). Needs full scope (no-op for JWT). diff --git a/frontend/js/views/device-detail.js b/frontend/js/views/device-detail.js index 329c148..cc5ef1c 100644 --- a/frontend/js/views/device-detail.js +++ b/frontend/js/views/device-detail.js @@ -162,6 +162,7 @@ async function loadDevice(deviceId, activeTab = null) { ${t('device.screenshot_btn')} + @@ -793,6 +794,19 @@ async function setupActions(device) { } catch (err) { showToast(err.message, 'error'); } }); + // #146 Item D: operator block/unblock — takes effect on the device's next register, + // no restart. Server enforces even a device_id-less reconnect via the identity chain. + const blockBtn = document.getElementById('blockDeviceBtn'); + blockBtn?.addEventListener('click', async () => { + blockBtn.disabled = true; + try { + if (device.blocked) { await api.unblockDevice(device.id); device.blocked = 0; showToast('Device unblocked', 'success'); } + else { await api.blockDevice(device.id); device.blocked = 1; showToast('Device blocked — refused on next reconnect', 'success'); } + blockBtn.textContent = device.blocked ? 'Unblock' : 'Block'; + } catch (err) { showToast(err.message, 'error'); } + finally { blockBtn.disabled = false; } + }); + // Delete (double-click to confirm) const deleteBtn = document.getElementById('deleteDeviceBtn'); let deleteConfirming = false; diff --git a/server/routes/devices.js b/server/routes/devices.js index 93d4fef..8945a15 100644 --- a/server/routes/devices.js +++ b/server/routes/devices.js @@ -231,6 +231,28 @@ router.put('/:id', (req, res) => { res.json(stripDeviceSecrets(updated)); }); +// #146 Item D: operator BLOCK / UNBLOCK toggle. Writes devices.blocked; the device +// socket re-reads `blocked` on every register, so the block takes effect on the +// device's NEXT register with NO server restart (and, via the #146 identity chain, is +// enforced even if that reconnect arrives without a device_id). Write-gated + workspace- +// scoped by checkDeviceOwnership. OUTAGE PROCEDURE (dashboard down): set it by hand via +// direct SQLite — `UPDATE devices SET blocked = 1 WHERE id = '';` (0 to +// unblock) — same column, same next-register effect. +router.post('/:id/block', (req, res) => { + const device = checkDeviceOwnership(req, res); + if (!device) return; + db.prepare("UPDATE devices SET blocked = 1, updated_at = strftime('%s','now') WHERE id = ?").run(req.params.id); + console.warn(`[blocked] device ${req.params.id} blocked via dashboard (user ${req.user.id})`); + res.json({ success: true, id: req.params.id, blocked: true }); +}); +router.post('/:id/unblock', (req, res) => { + const device = checkDeviceOwnership(req, res); + if (!device) return; + db.prepare("UPDATE devices SET blocked = 0, updated_at = strftime('%s','now') WHERE id = ?").run(req.params.id); + console.log(`[blocked] device ${req.params.id} unblocked via dashboard (user ${req.user.id})`); + res.json({ success: true, id: req.params.id, blocked: false }); +}); + // Delete device router.delete('/:id', (req, res) => { const device = checkDeviceOwnership(req, res); diff --git a/server/test/operator-block.test.js b/server/test/operator-block.test.js new file mode 100644 index 0000000..bcfae08 --- /dev/null +++ b/server/test/operator-block.test.js @@ -0,0 +1,72 @@ +'use strict'; + +// #146 hardening (Item D) — operator block kill switch. Enforced at the device:register +// handshake BEFORE throttle/DB/playlist. #146 fix: resolve identity via the fallback +// chain so a blocked device that reconnects WITHOUT a device_id (but with a mapped +// fingerprint) is STILL caught (the old `if (device_id)` gate let it slip). Unblock takes +// effect on the next register with NO restart. In-process. + +const os = require('node:os'); +const path = require('node:path'); +const crypto = require('node:crypto'); +process.env.DATA_DIR = path.join(os.tmpdir(), 'st-block-' + crypto.randomBytes(4).toString('hex')); +process.env.SELF_HOSTED = 'true'; +process.env.NODE_ENV = 'test'; + +const { test, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const http = require('node:http'); +const { Server } = require('socket.io'); +const ioClient = require('socket.io-client'); +const { db } = require('../db/database'); +const setupDeviceSocket = require('../ws/deviceSocket'); + +let httpServer, io, base; +before(async () => { + db.pragma('foreign_keys = OFF'); + db.prepare("INSERT INTO devices (id, device_token, status, blocked) VALUES ('blk-dev', 'tok', 'offline', 1)").run(); + db.prepare("INSERT OR REPLACE INTO device_fingerprints (fingerprint, device_id) VALUES ('fp-blk', 'blk-dev')").run(); + db.pragma('foreign_keys = ON'); + httpServer = http.createServer(); + io = new Server(httpServer); + setupDeviceSocket(io); + await new Promise((r) => httpServer.listen(0, r)); + base = `http://127.0.0.1:${httpServer.address().port}`; +}); +after(() => { try { io.close(); } catch { /* */ } try { httpServer.close(); } catch { /* */ } }); + +const connect = () => ioClient(`${base}/device`, { transports: ['websocket'], reconnection: false, forceNew: true }); + +// register(payload) -> { authError, registered, gotPlaylist } +function register(payload) { + return new Promise((resolve) => { + const s = connect(); + let done = false, gotPlaylist = false; + const fin = (r) => { if (done) return; done = true; try { s.close(); } catch { /* */ } resolve({ ...r, gotPlaylist }); }; + s.on('device:playlist-update', () => { gotPlaylist = true; }); + s.on('connect', () => s.emit('device:register', payload)); + s.on('device:auth-error', (e) => fin({ authError: e && e.error })); + s.on('device:registered', () => setTimeout(() => fin({ registered: true }), 100)); + setTimeout(() => fin({ timeout: true }), 2000); + }); +} + +test('a blocked device is refused at handshake, cheaply (no playlist build)', async () => { + const r = await register({ device_id: 'blk-dev', device_token: 'tok' }); + assert.equal(r.authError, 'Device blocked', 'refused with the block error'); + assert.ok(!r.registered, 'never registered'); + assert.ok(!r.gotPlaylist, 'no playlist was built — short-circuited before heavy work'); +}); + +test('a blocked device reconnecting WITHOUT device_id but with a mapped fingerprint is still refused', async () => { + const r = await register({ fingerprint: 'fp-blk' }); // no device_id — resolves via device_fingerprints + assert.equal(r.authError, 'Device blocked', 'caught via the fingerprint->device_id identity chain'); + assert.ok(!r.registered); +}); + +test('unblock takes effect on the NEXT register, no restart', async () => { + db.prepare("UPDATE devices SET blocked = 0 WHERE id = 'blk-dev'").run(); + const r = await register({ device_id: 'blk-dev', device_token: 'tok' }); + assert.ok(r.registered, 'registers once unblocked — same running server, no restart'); + assert.ok(!r.authError); +}); diff --git a/server/ws/deviceSocket.js b/server/ws/deviceSocket.js index c7a1a7f..e4fac2e 100644 --- a/server/ws/deviceSocket.js +++ b/server/ws/deviceSocket.js @@ -290,18 +290,25 @@ module.exports = function setupDeviceSocket(io) { socket.on('device:register', (data) => { const { pairing_code, device_id, device_token, device_info, fingerprint } = data; - // #143 operator KILL SWITCH — the FIRST gate, before the fingerprint block, - // the reconnect throttle, any DB writes, or playlist build. A device flagged - // `blocked` is refused immediately. Settable by DIRECT SQLite during an outage - // (dashboard down): UPDATE devices SET blocked = 1 WHERE id = ''; - // The row is re-read on every register, so a hand-edited UPDATE takes effect on - // the device's NEXT reconnect with NO server restart. Unblock: blocked = 0. + // #146: resolve identity ONCE via the SNAT-safe chain (device_id -> fingerprint + // -> token -> global anon), used by BOTH the operator block and the flap limiter. + const ident = resolveIdentity({ device_id, fingerprint, device_token }); + + // #143 operator KILL SWITCH — the FIRST gate, before the fingerprint block, the + // throttle, any DB writes, or playlist build. #146: resolve the effective + // device_id via the identity chain (device_id directly, OR fingerprint->device_id) + // so a blocked device that reconnects WITHOUT a device_id is STILL caught — the + // old `if (device_id)` gate let a device_id-less reconnect slip past. Settable by + // DIRECT SQLite during an outage (dashboard down), takes effect on the device's + // NEXT register with NO restart (the row is re-read every register): + // UPDATE devices SET blocked = 1 WHERE id = ''; (0 to unblock) // Unlike nulling the token (#143: that re-provisioned instead of locking out), - // this is an explicit, enforceable block. - if (device_id) { - const blk = db.prepare('SELECT blocked FROM devices WHERE id = ?').get(device_id); + // `blocked` is an explicit, enforceable lever. Also settable via the dashboard + // (routes/devices.js POST /:id/block) — same DB write, same next-register effect. + if (ident.deviceId) { + const blk = db.prepare('SELECT blocked FROM devices WHERE id = ?').get(ident.deviceId); if (blk && blk.blocked) { - console.warn(`[blocked] refused device ${device_id} (operator block) from ${getClientIp(socket)}`); + console.warn(`[blocked] refused device ${ident.deviceId} (operator block, via ${ident.kind})`); socket.emit('device:auth-error', { error: 'Device blocked' }); process.nextTick(() => { try { socket.disconnect(true); } catch (_) { /* */ } }); return; @@ -311,11 +318,9 @@ module.exports = function setupDeviceSocket(io) { // #146 Item B: SUSTAINED flap limiter — BEFORE fingerprint tracking, throttle, // DB writes, or playlist build, so a refusal is cheap. Skips same-socket playlist // refreshes (currentDeviceId===device_id — a periodic pull, not a new connection). - // Keyed via the SNAT-safe identity chain (device_id -> fingerprint -> token -> - // global anon), NEVER IP. Over the sustained rate -> refuse + disconnect. + // Keyed via the same SNAT-safe identity, NEVER IP. const isRefreshConnect = device_id && currentDeviceId === device_id; if (!isRefreshConnect) { - const ident = resolveIdentity({ device_id, fingerprint, device_token }); const fv = flapLimiter.check(ident.key); if (!fv.allow) { console.warn(`[flap] refused ${ident.kind} ${ident.deviceId || ident.key} reason=${fv.reason} retry=${fv.retryAfterMs}ms trips=${fv.trips || 0}`);