Add device diagnostics and P2P access controls
This commit is contained in:
@@ -749,6 +749,11 @@
|
|||||||
<div class="card"><h2>24-hour p95 history</h2><div id="opsHistory" class="analytics-grid"></div></div>
|
<div class="card"><h2>24-hour p95 history</h2><div id="opsHistory" class="analytics-grid"></div></div>
|
||||||
<div id="opsAlerts" aria-live="polite"></div>
|
<div id="opsAlerts" aria-live="polite"></div>
|
||||||
</section>
|
</section>
|
||||||
|
<section id="p-devices" class="panel">
|
||||||
|
<div class="row"><div><h1 class="page">P2P devices</h1><p class="lead">Presence, stored holdings and device-level sharing controls.</p></div><span class="grow"></span><button class="btn" id="devicesRefresh" type="button">Refresh</button></div>
|
||||||
|
<div class="analytics-grid" id="deviceHealth"></div><p id="devicesStatus" class="msg" role="status" aria-live="polite"></p>
|
||||||
|
<div class="card"><div class="scroll"><table class="tbl" id="deviceTable"></table></div></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section id="p-deep-search" class="panel deep-search">
|
<section id="p-deep-search" class="panel deep-search">
|
||||||
<h1 class="page">Deep search</h1>
|
<h1 class="page">Deep search</h1>
|
||||||
@@ -848,6 +853,7 @@
|
|||||||
index: '<path d="M4 19V5M4 19h16M8 15l3-4 3 2 5-7"/><circle cx="19" cy="6" r="1.5"/>',
|
index: '<path d="M4 19V5M4 19h16M8 15l3-4 3 2 5-7"/><circle cx="19" cy="6" r="1.5"/>',
|
||||||
library: '<path d="M9 18V6l10-2v12"/><circle cx="6.5" cy="18" r="2.5"/><circle cx="16.5" cy="16" r="2.5"/>',
|
library: '<path d="M9 18V6l10-2v12"/><circle cx="6.5" cy="18" r="2.5"/><circle cx="16.5" cy="16" r="2.5"/>',
|
||||||
peers: '<circle cx="6" cy="12" r="2.5"/><circle cx="18" cy="6" r="2.5"/><circle cx="18" cy="18" r="2.5"/><path d="M8.3 10.9l7.4-3.8M8.3 13.1l7.4 3.8"/>',
|
peers: '<circle cx="6" cy="12" r="2.5"/><circle cx="18" cy="6" r="2.5"/><circle cx="18" cy="18" r="2.5"/><path d="M8.3 10.9l7.4-3.8M8.3 13.1l7.4 3.8"/>',
|
||||||
|
devices: '<rect x="6" y="3" width="12" height="18" rx="2"/><path d="M10 17h4"/>',
|
||||||
history: '<path d="M3 12a9 9 0 109-9 9 9 0 00-6.4 2.6L3 8"/><path d="M3 3v5h5M12 7v5l3 2"/>',
|
history: '<path d="M3 12a9 9 0 109-9 9 9 0 00-6.4 2.6L3 8"/><path d="M3 3v5h5M12 7v5l3 2"/>',
|
||||||
backups: '<path d="M4 5h16v15H4zM8 5V3h8v2M8 11h8M8 15h5"/>',
|
backups: '<path d="M4 5h16v15H4zM8 5V3h8v2M8 11h8M8 15h5"/>',
|
||||||
analytics: '<path d="M4 20h16M7 16V9M12 16V4M17 16v-5"/>',
|
analytics: '<path d="M4 20h16M7 16V9M12 16V4M17 16v-5"/>',
|
||||||
@@ -856,7 +862,7 @@
|
|||||||
audit: '<path d="M5 4h14v16H5zM8 8h8M8 12h8M8 16h5"/>',
|
audit: '<path d="M5 4h14v16H5zM8 8h8M8 12h8M8 16h5"/>',
|
||||||
access: '<circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M16 7l3 3M14 9l2 2"/>',
|
access: '<circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M16 7l3 3M14 9l2 2"/>',
|
||||||
};
|
};
|
||||||
const TABS = [['editor', 'Editor'], ['quality', 'Lyrics queue'], ['lyric-search', 'Lyrics search'], ['cache', 'Cache'], ['index', 'Search index'], ['reports', 'Reports'], ['library', 'Library'], ['peers', 'P2P'], ['history', 'History'], ['backups', 'Backups'], ['analytics', 'Stats'], ['ops', 'Performance'], ['deep-search', 'Deep search'], ['audit', 'Audit log'], ['access', 'API']];
|
const TABS = [['editor', 'Editor'], ['quality', 'Lyrics queue'], ['lyric-search', 'Lyrics search'], ['cache', 'Cache'], ['index', 'Search index'], ['reports', 'Reports'], ['library', 'Library'], ['peers', 'P2P'], ['devices', 'Devices'], ['history', 'History'], ['backups', 'Backups'], ['analytics', 'Stats'], ['ops', 'Performance'], ['deep-search', 'Deep search'], ['audit', 'Audit log'], ['access', 'API']];
|
||||||
let tab = 'editor';
|
let tab = 'editor';
|
||||||
function buildTabs() {
|
function buildTabs() {
|
||||||
const html = TABS.map(([k, l]) => `<button class="tab" role="tab" data-tab="${k}" aria-selected="false"><svg viewBox="0 0 24 24">${ICONS[k]}</svg><span>${l}</span>${k === 'reports' ? '<span class="badge hidden" data-badge>0</span>' : ''}</button>`).join('');
|
const html = TABS.map(([k, l]) => `<button class="tab" role="tab" data-tab="${k}" aria-selected="false"><svg viewBox="0 0 24 24">${ICONS[k]}</svg><span>${l}</span>${k === 'reports' ? '<span class="badge hidden" data-badge>0</span>' : ''}</button>`).join('');
|
||||||
@@ -882,6 +888,7 @@
|
|||||||
if (name === 'access') loadTokens();
|
if (name === 'access') loadTokens();
|
||||||
if (name === 'analytics') loadAnalytics();
|
if (name === 'analytics') loadAnalytics();
|
||||||
if (name === 'ops') loadOpsMetrics();
|
if (name === 'ops') loadOpsMetrics();
|
||||||
|
if (name === 'devices') loadDevices();
|
||||||
if (name === 'deep-search') loadDeepSearch();
|
if (name === 'deep-search') loadDeepSearch();
|
||||||
if (name === 'audit') loadAudit();
|
if (name === 'audit') loadAudit();
|
||||||
window.scrollTo({ top: 0 });
|
window.scrollTo({ top: 0 });
|
||||||
@@ -973,6 +980,25 @@
|
|||||||
} catch (error) { status.textContent = error.message || 'Monitoring unavailable.'; }
|
} catch (error) { status.textContent = error.message || 'Monitoring unavailable.'; }
|
||||||
}
|
}
|
||||||
$('opsRefresh')?.addEventListener('click', loadOpsMetrics);
|
$('opsRefresh')?.addEventListener('click', loadOpsMetrics);
|
||||||
|
async function loadDevices() {
|
||||||
|
$('devicesStatus').textContent = 'Loading registered devices…';
|
||||||
|
try {
|
||||||
|
const j = await api('/api/admin/p2p/devices');
|
||||||
|
$('deviceHealth').innerHTML = [['Online now',j.network.online],['Verified content',j.network.content],['Intake active',j.network.intakeActive],['Open intake',j.network.intakeOpen]].map(([a,b])=>`<div class="metric"><span>${esc(a)}</span><b>${esc(b)}</b></div>`).join('');
|
||||||
|
$('deviceTable').innerHTML = '<tr><th>Device</th><th>Presence</th><th>Holdings</th><th>Traffic</th><th>Controls</th></tr>' + (j.devices.map(d=>`<tr><td><b>${esc(d.fingerprintShort)}</b><br><small>${esc(d.profile)} · ${esc(d.userAgent)}</small></td><td>${d.banned?'Banned':d.online?'Online':'Last seen '+new Date(d.lastSeenAt).toLocaleString()}</td><td>${d.holdings} · ${bytes(d.holdingBytes)}</td><td>Sent ${bytes(d.bytesUp)} · received ${bytes(d.bytesDown)}</td><td><div class="row"><button class="btn sm" data-device-sync="${esc(d.id)}" ${!d.online?'disabled':''}>Re-announce</button><button class="btn sm" data-device-holdings="${esc(d.id)}">Revoke holdings</button><button class="btn sm dng" data-device-ban="${esc(d.id)}" data-banned="${d.banned}">${d.banned?'Unban':'Ban'}</button></div></td></tr>`).join('') || '<tr><td colspan="5" class="muted">No devices registered.</td></tr>');
|
||||||
|
$('devicesStatus').textContent = `${j.devices.length} devices shown`;
|
||||||
|
} catch (error) { $('devicesStatus').textContent = error.message || 'Could not load devices.'; }
|
||||||
|
}
|
||||||
|
$('devicesRefresh')?.addEventListener('click', loadDevices);
|
||||||
|
$('deviceTable')?.addEventListener('click', async e => {
|
||||||
|
const b=e.target.closest('button'); if(!b)return;
|
||||||
|
try {
|
||||||
|
if (b.dataset.deviceSync) await api(`/api/admin/p2p/devices/${b.dataset.deviceSync}/reannounce`,{method:'POST',body:{}});
|
||||||
|
if (b.dataset.deviceHoldings && confirm('Remove all reported holdings for this device?')) await api(`/api/admin/p2p/devices/${b.dataset.deviceHoldings}/holdings`,{method:'DELETE'});
|
||||||
|
if (b.dataset.deviceBan) await api(`/api/admin/p2p/devices/${b.dataset.deviceBan}/ban`,{method:'POST',body:{banned:b.dataset.banned!=='true'}});
|
||||||
|
await loadDevices();
|
||||||
|
} catch(error) { $('devicesStatus').textContent=error.message || 'Device action failed.'; }
|
||||||
|
});
|
||||||
async function loadCollections() {
|
async function loadCollections() {
|
||||||
try {
|
try {
|
||||||
const j = await api('/api/admin/collections'); if (!j.ok) return;
|
const j = await api('/api/admin/collections'); if (!j.ok) return;
|
||||||
|
|||||||
@@ -224,6 +224,7 @@
|
|||||||
let m;
|
let m;
|
||||||
try { m = JSON.parse(e.data); } catch { return; }
|
try { m = JSON.parse(e.data); } catch { return; }
|
||||||
if (m.type === 'hello') { myPeer = m.peer; wsRetry = 0; }
|
if (m.type === 'hello') { myPeer = m.peer; wsRetry = 0; }
|
||||||
|
if (m.type === 'force-sync') sync();
|
||||||
emit(m);
|
emit(m);
|
||||||
};
|
};
|
||||||
sock.onclose = () => {
|
sock.onclose = () => {
|
||||||
|
|||||||
35
server/p2p-admin.js
Normal file
35
server/p2p-admin.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { audit } from './audit-log.js';
|
||||||
|
|
||||||
|
export function registerP2pAdminRoutes(app, { db, adminAuth, p2pDb, hub, stats = async () => ({}) }) {
|
||||||
|
app.get('/api/admin/p2p/devices', adminAuth, async c => {
|
||||||
|
const offset = Math.max(0, Number(c.req.query('offset')) || 0);
|
||||||
|
const devices = await p2pDb.listAdminDevices({ limit: 100, offset });
|
||||||
|
return c.json({ ok: true, devices: devices.map(d => ({
|
||||||
|
id: d.device_id, fingerprint: d.fingerprint || '', fingerprintShort: (d.fingerprint || d.device_id).slice(0, 12),
|
||||||
|
profile: d.profile || '', userAgent: d.user_agent || '', share: !!Number(d.share), banned: !!d.banned_at,
|
||||||
|
lastSeenAt: Number(d.last_seen_at), online: hub.isOnline(d.device_id), holdings: Number(d.holdings), holdingBytes: Number(d.holding_bytes),
|
||||||
|
bytesUp: Number(d.bytes_up), bytesDown: Number(d.bytes_down),
|
||||||
|
})), network: { online: hub.onlineCount(), ...(await stats()) }, offset }, 200, { 'Cache-Control': 'no-store' });
|
||||||
|
});
|
||||||
|
app.post('/api/admin/p2p/devices/:id/ban', adminAuth, async c => {
|
||||||
|
const id = c.req.param('id'); if (!/^dev_[0-9a-f]{16}$/.test(id)) return c.json({ ok: false, error: 'Invalid device.' }, 400);
|
||||||
|
const body = await c.req.json().catch(() => ({})); const banned = body.banned === true;
|
||||||
|
const changed = await p2pDb.setDeviceBanned(id, banned);
|
||||||
|
if (!changed) return c.json({ ok: false, error: 'Device not found.' }, 404);
|
||||||
|
if (banned) hub.disconnect(id);
|
||||||
|
await audit(db, 'admin', banned ? 'p2p.device.ban' : 'p2p.device.unban', id);
|
||||||
|
return c.json({ ok: true, banned });
|
||||||
|
});
|
||||||
|
app.post('/api/admin/p2p/devices/:id/reannounce', adminAuth, async c => {
|
||||||
|
const id = c.req.param('id'); if (!/^dev_[0-9a-f]{16}$/.test(id)) return c.json({ ok: false, error: 'Invalid device.' }, 400);
|
||||||
|
const sent = hub.send(id, { type: 'force-sync' });
|
||||||
|
await audit(db, 'admin', 'p2p.device.reannounce', id, { sent });
|
||||||
|
return c.json({ ok: true, sent });
|
||||||
|
});
|
||||||
|
app.delete('/api/admin/p2p/devices/:id/holdings', adminAuth, async c => {
|
||||||
|
const id = c.req.param('id'); if (!/^dev_[0-9a-f]{16}$/.test(id)) return c.json({ ok: false, error: 'Invalid device.' }, 400);
|
||||||
|
const removed = await p2pDb.removeDeviceHolders(id);
|
||||||
|
await audit(db, 'admin', 'p2p.device.holdings.revoke', id, { removed });
|
||||||
|
return c.json({ ok: true, removed });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -37,6 +37,10 @@ export async function initP2pSchema() {
|
|||||||
fingerprint TEXT,
|
fingerprint TEXT,
|
||||||
profile TEXT,
|
profile TEXT,
|
||||||
share INTEGER NOT NULL DEFAULT 1,
|
share INTEGER NOT NULL DEFAULT 1,
|
||||||
|
user_agent TEXT,
|
||||||
|
banned_at INTEGER,
|
||||||
|
bytes_up INTEGER NOT NULL DEFAULT 0,
|
||||||
|
bytes_down INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at INTEGER NOT NULL,
|
created_at INTEGER NOT NULL,
|
||||||
last_seen_at INTEGER NOT NULL
|
last_seen_at INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
@@ -60,6 +64,10 @@ export async function initP2pSchema() {
|
|||||||
PRIMARY KEY (video_id, day)
|
PRIMARY KEY (video_id, day)
|
||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
|
for (const [name, decl] of [['user_agent','TEXT'],['banned_at','INTEGER'],['bytes_up','INTEGER NOT NULL DEFAULT 0'],['bytes_down','INTEGER NOT NULL DEFAULT 0']]) {
|
||||||
|
try { await db.execute(`ALTER TABLE p2p_devices ADD COLUMN ${name} ${decl}`); }
|
||||||
|
catch (e) { if (!/duplicate column/i.test(String(e.message))) throw e; }
|
||||||
|
}
|
||||||
// media_cache.sha256 — the cid of the current <id>.<gen>.mp4 (plan 009).
|
// media_cache.sha256 — the cid of the current <id>.<gen>.mp4 (plan 009).
|
||||||
try { await db.execute('ALTER TABLE media_cache ADD COLUMN sha256 TEXT'); }
|
try { await db.execute('ALTER TABLE media_cache ADD COLUMN sha256 TEXT'); }
|
||||||
catch (e) { if (!/duplicate column/i.test(String(e.message))) throw e; }
|
catch (e) { if (!/duplicate column/i.test(String(e.message))) throw e; }
|
||||||
@@ -117,11 +125,11 @@ export async function revokeContent(cid) {
|
|||||||
|
|
||||||
// ---- devices ------------------------------------------------------------------
|
// ---- devices ------------------------------------------------------------------
|
||||||
|
|
||||||
export async function createDevice({ deviceId, secretHash, fingerprint, profile, now }) {
|
export async function createDevice({ deviceId, secretHash, fingerprint, profile, userAgent, now }) {
|
||||||
await db.execute({
|
await db.execute({
|
||||||
sql: `INSERT INTO p2p_devices (device_id, secret_hash, fingerprint, profile, share, created_at, last_seen_at)
|
sql: `INSERT INTO p2p_devices (device_id, secret_hash, fingerprint, profile, share, user_agent, created_at, last_seen_at)
|
||||||
VALUES (?, ?, ?, ?, 1, ?, ?)`,
|
VALUES (?, ?, ?, ?, 1, ?, ?, ?)`,
|
||||||
args: [deviceId, secretHash, fingerprint || null, profile || null, now, now],
|
args: [deviceId, secretHash, fingerprint || null, profile || null, userAgent || null, now, now],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,6 +147,26 @@ export async function touchDevice(deviceId, { now, share, profile } = {}) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listAdminDevices({ limit = 100, offset = 0 } = {}) {
|
||||||
|
const r = await db.execute({ sql: `SELECT d.device_id,d.fingerprint,d.profile,d.share,d.user_agent,d.banned_at,d.bytes_up,d.bytes_down,d.created_at,d.last_seen_at,
|
||||||
|
COUNT(CASE WHEN h.status='active' THEN 1 END) AS holdings,
|
||||||
|
COALESCE(SUM(CASE WHEN h.status='active' THEN c.size ELSE 0 END),0) AS holding_bytes
|
||||||
|
FROM p2p_devices d LEFT JOIN p2p_holders h ON h.device_id=d.device_id LEFT JOIN p2p_content c ON c.cid=h.cid
|
||||||
|
GROUP BY d.device_id ORDER BY d.last_seen_at DESC LIMIT ? OFFSET ?`, args: [Math.max(1, Math.min(200, limit)), Math.max(0, offset)] });
|
||||||
|
return rowsOf(r);
|
||||||
|
}
|
||||||
|
export async function setDeviceBanned(deviceId, banned, now = Date.now()) {
|
||||||
|
const r = await db.execute({ sql: 'UPDATE p2p_devices SET banned_at=? WHERE device_id=?', args: [banned ? now : null, deviceId] });
|
||||||
|
return !!r.rowsAffected;
|
||||||
|
}
|
||||||
|
export async function removeDeviceHolders(deviceId, now = Date.now()) {
|
||||||
|
const r = await db.execute({ sql: "UPDATE p2p_holders SET status='removed',removed_at=? WHERE device_id=? AND status='active'", args: [now, deviceId] });
|
||||||
|
return Number(r.rowsAffected || 0);
|
||||||
|
}
|
||||||
|
export async function recordDeviceTraffic(deviceId, { up = 0, down = 0 } = {}) {
|
||||||
|
await db.execute({ sql: 'UPDATE p2p_devices SET bytes_up=bytes_up+?,bytes_down=bytes_down+? WHERE device_id=?', args: [Math.max(0, Number(up) || 0), Math.max(0, Number(down) || 0), deviceId] });
|
||||||
|
}
|
||||||
|
|
||||||
// ---- holders (persistent; never expired by time) ------------------------------
|
// ---- holders (persistent; never expired by time) ------------------------------
|
||||||
|
|
||||||
export async function upsertHolder({ cid, deviceId, trust = 'reported', now }) {
|
export async function upsertHolder({ cid, deviceId, trust = 'reported', now }) {
|
||||||
|
|||||||
@@ -40,6 +40,22 @@ test('content upsert/get/known/revoke', async () => {
|
|||||||
expect([...(await P.knownCids([CID2]))]).toEqual([]);
|
expect([...(await P.knownCids([CID2]))]).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('admin device inventory, ban state, and holding revocation', async () => {
|
||||||
|
const id = 'dev_00000000000000b1';
|
||||||
|
await P.createDevice({ deviceId: id, secretHash: 'hash', fingerprint: 'phone-fp', profile: 'family', userAgent: 'iPhone', now: T0 });
|
||||||
|
const adminCid = 'c'.repeat(64);
|
||||||
|
await P.upsertContent({ cid: adminCid, videoId: 'dQw4w9WgXcQ', size: 4096, origin: 'server', now: T0 });
|
||||||
|
await P.upsertHolder({ cid: adminCid, deviceId: id, now: T0 });
|
||||||
|
let [device] = await P.listAdminDevices();
|
||||||
|
expect(device).toMatchObject({ device_id: id, user_agent: 'iPhone', holdings: 1, holding_bytes: 4096 });
|
||||||
|
expect(await P.setDeviceBanned(id, true, T0 + 100)).toBe(true);
|
||||||
|
[device] = await P.listAdminDevices(); expect(device.banned_at).toBe(T0 + 100);
|
||||||
|
expect(await P.removeDeviceHolders(id, T0 + 200)).toBe(1);
|
||||||
|
[device] = await P.listAdminDevices(); expect(device.holdings).toBe(0);
|
||||||
|
expect(await P.setDeviceBanned(id, false, T0 + 300)).toBe(true);
|
||||||
|
[device] = await P.listAdminDevices(); expect(device.banned_at).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
test('holders persist, never expire, and a full report removes missing ones', async () => {
|
test('holders persist, never expire, and a full report removes missing ones', async () => {
|
||||||
await P.createDevice({ deviceId: 'dev_1', secretHash: 'h', fingerprint: 'fp', now: T0 });
|
await P.createDevice({ deviceId: 'dev_1', secretHash: 'h', fingerprint: 'fp', now: T0 });
|
||||||
await P.upsertHolder({ cid: CID, deviceId: 'dev_1', now: T0 });
|
await P.upsertHolder({ cid: CID, deviceId: 'dev_1', now: T0 });
|
||||||
@@ -66,8 +82,8 @@ test('views per day and window sums', async () => {
|
|||||||
expect(await P.viewsSince('vid00000001', T0 - 30 * 86400_000)).toBe(2);
|
expect(await P.viewsSince('vid00000001', T0 - 30 * 86400_000)).toBe(2);
|
||||||
expect(await P.viewsSince('vid00000001', T0 - 50 * 86400_000)).toBe(3);
|
expect(await P.viewsSince('vid00000001', T0 - 50 * 86400_000)).toBe(3);
|
||||||
const s = await P.p2pStats();
|
const s = await P.p2pStats();
|
||||||
expect(s.content).toBe(1);
|
expect(s.content).toBe(2);
|
||||||
expect(s.devices).toBe(1);
|
expect(s.devices).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('listSharedContent: newest verified file per video, only with sharing holders counted', async () => {
|
test('listSharedContent: newest verified file per video, only with sharing holders counted', async () => {
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export function createP2pHub({ getDevice, authorizeProfile = async () => '', ena
|
|||||||
const deviceId = String(m.device || '');
|
const deviceId = String(m.device || '');
|
||||||
const secret = String(m.secret || '');
|
const secret = String(m.secret || '');
|
||||||
const d = /^dev_[0-9a-f]{16}$/.test(deviceId) ? await getDevice(deviceId).catch(() => null) : null;
|
const d = /^dev_[0-9a-f]{16}$/.test(deviceId) ? await getDevice(deviceId).catch(() => null) : null;
|
||||||
if (!d || !same(d.secret_hash, sha(secret))) { try { ws.close(4401, 'unknown device'); } catch { /* gone */ } return; }
|
if (!d || d.banned_at || !same(d.secret_hash, sha(secret))) { try { ws.close(d?.banned_at ? 4403 : 4401, d?.banned_at ? 'device banned' : 'unknown device'); } catch { /* gone */ } return; }
|
||||||
ws.data.profile = await authorizeProfile(m.profile, m.profileSecret, deviceId).catch(() => '');
|
ws.data.profile = await authorizeProfile(m.profile, m.profileSecret, deviceId).catch(() => '');
|
||||||
ws.data.inventory = [];
|
ws.data.inventory = [];
|
||||||
ws.data.authed = true;
|
ws.data.authed = true;
|
||||||
@@ -107,6 +107,8 @@ export function createP2pHub({ getDevice, authorizeProfile = async () => '', ena
|
|||||||
websocket: { open, message, close },
|
websocket: { open, message, close },
|
||||||
isOnline: (deviceId) => online.has(deviceId),
|
isOnline: (deviceId) => online.has(deviceId),
|
||||||
send: (deviceId, msg) => { const ws = online.get(deviceId); if (!ws) return false; send(ws, msg); return true; },
|
send: (deviceId, msg) => { const ws = online.get(deviceId); if (!ws) return false; send(ws, msg); return true; },
|
||||||
|
disconnect: (deviceId) => { const ws = online.get(deviceId); if (!ws) return false; try { ws.close(4403, 'device banned'); } catch { /* gone */ } return true; },
|
||||||
|
onlineDevices: () => [...online.keys()],
|
||||||
onlineCount: () => online.size,
|
onlineCount: () => online.size,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,21 @@ test('auth on open, hello with opaque peer id, bad secret is closed', async () =
|
|||||||
expect(hub.isOnline('dev_000000000000000b')).toBe(false);
|
expect(hub.isOnline('dev_000000000000000b')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('banned devices cannot authenticate and admin disconnect closes an online peer', async () => {
|
||||||
|
await p2pDb.setDeviceBanned('dev_000000000000000a', true, NOW);
|
||||||
|
const hub = createP2pHub({ getDevice: p2pDb.getDevice });
|
||||||
|
const banned = fakeWs({ budget: [] });
|
||||||
|
await hub.websocket.message(banned, JSON.stringify({ type: 'auth', device: 'dev_000000000000000a', secret: SECRET_A }));
|
||||||
|
expect(banned.closed).toBe(4403);
|
||||||
|
await p2pDb.setDeviceBanned('dev_000000000000000a', false, NOW + 1);
|
||||||
|
const online = fakeWs({ budget: [] });
|
||||||
|
await hub.websocket.message(online, JSON.stringify({ type: 'auth', device: 'dev_000000000000000a', secret: SECRET_A }));
|
||||||
|
expect(hub.disconnect('dev_000000000000000a')).toBe(true);
|
||||||
|
expect(online.closed).toBe(4403);
|
||||||
|
hub.websocket.close(online);
|
||||||
|
expect(hub.isOnline('dev_000000000000000a')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
test('legacy unconfirmed signalling is refused; close drops presence', async () => {
|
test('legacy unconfirmed signalling is refused; close drops presence', async () => {
|
||||||
const hub = createP2pHub({ getDevice: p2pDb.getDevice });
|
const hub = createP2pHub({ getDevice: p2pDb.getDevice });
|
||||||
const a = fakeWs({ budget: [] });
|
const a = fakeWs({ budget: [] });
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ export function registerIntakeRoutes(app, deps) {
|
|||||||
);
|
);
|
||||||
if (!adm.ok) throw Object.assign(new Error('not admitted: ' + adm.reason), { status: 422 });
|
if (!adm.ok) throw Object.assign(new Error('not admitted: ' + adm.reason), { status: 422 });
|
||||||
await p2pDb.upsertHolder({ cid, deviceId: d.device_id, trust: 'challenged', now: now() });
|
await p2pDb.upsertHolder({ cid, deviceId: d.device_id, trust: 'challenged', now: now() });
|
||||||
|
await p2pDb.recordDeviceTraffic?.(d.device_id, { up: got });
|
||||||
let adopted = false;
|
let adopted = false;
|
||||||
if (adopt) {
|
if (adopt) {
|
||||||
try { adopted = !!(await adopt(tk.videoId, path, { sha256: cid, probe, meta: tk.meta })).adopted; keep = adopted; }
|
try { adopted = !!(await adopt(tk.videoId, path, { sha256: cid, probe, meta: tk.meta })).adopted; keep = adopted; }
|
||||||
|
|||||||
@@ -41,10 +41,12 @@ export function registerP2pRoutes(app, deps) {
|
|||||||
if (!m) return null;
|
if (!m) return null;
|
||||||
const d = await p2pDb.getDevice(m[1]);
|
const d = await p2pDb.getDevice(m[1]);
|
||||||
if (!d || !same(d.secret_hash, sha(m[2]))) return null;
|
if (!d || !same(d.secret_hash, sha(m[2]))) return null;
|
||||||
|
if (d.banned_at) return { ...d, _banned: true };
|
||||||
return d;
|
return d;
|
||||||
}
|
}
|
||||||
const requireDevice = async (c, next) => {
|
const requireDevice = async (c, next) => {
|
||||||
const d = await deviceOf(c);
|
const d = await deviceOf(c);
|
||||||
|
if (d?._banned) return c.json({ ok: false, error: 'device access revoked' }, 403);
|
||||||
if (!d) return c.json({ ok: false, error: 'unknown device' }, 401);
|
if (!d) return c.json({ ok: false, error: 'unknown device' }, 401);
|
||||||
c.set('device', d);
|
c.set('device', d);
|
||||||
await next();
|
await next();
|
||||||
@@ -67,6 +69,7 @@ export function registerP2pRoutes(app, deps) {
|
|||||||
deviceId, secretHash: sha(secret),
|
deviceId, secretHash: sha(secret),
|
||||||
fingerprint: String(body.fingerprint || '').slice(0, 128) || null,
|
fingerprint: String(body.fingerprint || '').slice(0, 128) || null,
|
||||||
profile: String(body.profile || '').slice(0, 64) || null,
|
profile: String(body.profile || '').slice(0, 64) || null,
|
||||||
|
userAgent: String(c.req.header('user-agent') || '').slice(0, 500),
|
||||||
now: t,
|
now: t,
|
||||||
});
|
});
|
||||||
return c.json({ ok: true, deviceId, secret });
|
return c.json({ ok: true, deviceId, secret });
|
||||||
|
|||||||
@@ -63,6 +63,15 @@ test('holdings need a valid device secret', async () => {
|
|||||||
expect((await req('/api/p2p/holdings', { method: 'POST', body: { items: [] }, dev: { ...dev, secret: 'e'.repeat(64) } })).status).toBe(401);
|
expect((await req('/api/p2p/holdings', { method: 'POST', body: { items: [] }, dev: { ...dev, secret: 'e'.repeat(64) } })).status).toBe(401);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('banned device is denied with an explicit forbidden response', async () => {
|
||||||
|
const dev = await newDevice();
|
||||||
|
await p2pDb.setDeviceBanned(dev.deviceId, true);
|
||||||
|
const r = await req('/api/p2p/holdings', { method: 'POST', body: { items: [] }, dev });
|
||||||
|
expect(r.status).toBe(403);
|
||||||
|
expect(await r.json()).toMatchObject({ ok: false, error: 'device access revoked' });
|
||||||
|
await p2pDb.setDeviceBanned(dev.deviceId, false);
|
||||||
|
});
|
||||||
|
|
||||||
test('report accepts verified cids, returns unknown ones, and challenges the server-held file', async () => {
|
test('report accepts verified cids, returns unknown ones, and challenges the server-held file', async () => {
|
||||||
const dev = await newDevice();
|
const dev = await newDevice();
|
||||||
const r = await (await req('/api/p2p/holdings', { method: 'POST', dev, body: { share: true, items: [
|
const r = await (await req('/api/p2p/holdings', { method: 'POST', dev, body: { share: true, items: [
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ import { createWarmQueue } from './warm-queue.js';
|
|||||||
import { registerDeepSearchRoutes } from './deep-search.js';
|
import { registerDeepSearchRoutes } from './deep-search.js';
|
||||||
import { dirname, join as pathJoin } from 'node:path';
|
import { dirname, join as pathJoin } from 'node:path';
|
||||||
import { createRequestMonitor, registerRequestMonitorRoutes } from './request-monitor.js';
|
import { createRequestMonitor, registerRequestMonitorRoutes } from './request-monitor.js';
|
||||||
|
import { registerP2pAdminRoutes } from './p2p-admin.js';
|
||||||
|
|
||||||
// A media proxy must not die because one client's stream hit an edge case
|
// A media proxy must not die because one client's stream hit an edge case
|
||||||
// (see /api/play cancel()): log and keep serving instead of crash-looping.
|
// (see /api/play cancel()): log and keep serving instead of crash-looping.
|
||||||
@@ -2381,6 +2382,8 @@ const p2pHub = createP2pHub({ getDevice: p2pDb.getDevice, enabled: () => P2P.ena
|
|||||||
directProfileFails.set(key, { count: old && Date.now() - old.at < 900000 ? old.count + 1 : 1, at: Date.now() });
|
directProfileFails.set(key, { count: old && Date.now() - old.at < 900000 ? old.count + 1 : 1, at: Date.now() });
|
||||||
if (directProfileFails.size > 5000) directProfileFails.clear(); return '';
|
if (directProfileFails.size > 5000) directProfileFails.clear(); return '';
|
||||||
} });
|
} });
|
||||||
|
registerP2pAdminRoutes(app, { db: notesDb, adminAuth: notes.requireAdminOrToken, p2pDb, hub: p2pHub,
|
||||||
|
stats: async () => ({ intakeOpen: intake.openTickets(), intakeActive: intake.active(), content: (await p2pDb.p2pStats()).content }) });
|
||||||
|
|
||||||
// GET /api/p2p/holders?v=<videoId>|cid=<sha256> — who holds a copy. Rows are
|
// GET /api/p2p/holders?v=<videoId>|cid=<sha256> — who holds a copy. Rows are
|
||||||
// persistent; `stale` flags a holder not re-verified for P2P_STALE_DAYS.
|
// persistent; `stale` flags a holder not re-verified for P2P_STALE_DAYS.
|
||||||
|
|||||||
Reference in New Issue
Block a user