mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 13:53:12 -06:00
fix(#146) D: operator block — close the device_id-less gap + dashboard toggle
- Enforcement (deviceSocket): resolve identity ONCE via the SNAT-safe chain and check
blocked against the RESOLVED device_id (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. Still the first gate,
before flap/throttle/DB/playlist. Nulling the token still does NOT block (it
re-provisions) — the blocked column is the lever.
- Dashboard toggle: POST /api/devices/:id/{block,unblock} (write-gated + workspace-scoped
via checkDeviceOwnership) writes devices.blocked; takes effect on the device's NEXT
register with no restart. api.js + a Block/Unblock button in device-detail.js.
- Outage procedure documented in-code: direct SQLite
"UPDATE devices SET blocked = 1 WHERE id = <id>" works with the dashboard down.
Tests: blocked refused at handshake with no playlist build; device_id-less reconnect
with a mapped fingerprint still refused; unblock effective on next register, no restart.
Suite 262/262.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f037dd476a
commit
97d489223f
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -162,6 +162,7 @@ async function loadDevice(deviceId, activeTab = null) {
|
|||
</svg>
|
||||
${t('device.screenshot_btn')}
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-sm" id="blockDeviceBtn">${device.blocked ? 'Unblock' : 'Block'}</button>
|
||||
<button class="btn btn-danger btn-sm" id="deleteDeviceBtn">${t('device.remove')}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 = '<device_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);
|
||||
|
|
|
|||
72
server/test/operator-block.test.js
Normal file
72
server/test/operator-block.test.js
Normal file
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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 = '<device_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 = '<device_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}`);
|
||||
|
|
|
|||
Loading…
Reference in a new issue