From 079af04c81ba4dacff5bd7df34da577544b998be Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Thu, 25 Jun 2026 20:36:00 +0200 Subject: [PATCH 01/10] feat(peers): add nullable user_id (owner) column + index --- src/db/migrationList.js | 12 ++++++++++++ tests/peer_owner_migration.test.js | 16 ++++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 tests/peer_owner_migration.test.js diff --git a/src/db/migrationList.js b/src/db/migrationList.js index fbb1e156..62e1184f 100644 --- a/src/db/migrationList.js +++ b/src/db/migrationList.js @@ -1046,6 +1046,18 @@ const migrations = [ );`, detect: (db) => !!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='domains'").get(), }, + { + version: 59, + name: 'peer_owner_user_id', + // Peer → owner (users.id). NO inline REFERENCES: ALTER TABLE ADD COLUMN + // silently ignores REFERENCES in some SQLite versions (see 'add_gateway_support'). + // FK semantics live in the service layer (validation + null-on-user-delete). + sql: ` + ALTER TABLE peers ADD COLUMN user_id INTEGER; + CREATE INDEX IF NOT EXISTS idx_peers_user_id ON peers(user_id); + `, + detect: (db) => hasColumn(db, 'peers', 'user_id'), + }, ]; module.exports = { migrations }; diff --git a/tests/peer_owner_migration.test.js b/tests/peer_owner_migration.test.js new file mode 100644 index 00000000..2af99ccb --- /dev/null +++ b/tests/peer_owner_migration.test.js @@ -0,0 +1,16 @@ +'use strict'; +const crypto = require('crypto'); +process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || crypto.randomBytes(32).toString('hex'); +const { test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const { setup, teardown } = require('./helpers/setup'); +let getDb; +beforeEach(async () => { await setup(); getDb = require('../src/db/connection').getDb; }); +afterEach(teardown); +test('peers has a nullable user_id column', () => { + const col = getDb().prepare("PRAGMA table_info(peers)").all().find(c => c.name === 'user_id'); + assert.ok(col, 'user_id column missing'); assert.equal(col.notnull, 0); +}); +test('idx_peers_user_id exists', () => { + assert.ok(getDb().prepare("SELECT 1 FROM sqlite_master WHERE type='index' AND name='idx_peers_user_id'").get()); +}); From f86828e99561598fe940ab605686a15b677b90f2 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Thu, 25 Jun 2026 20:41:10 +0200 Subject: [PATCH 02/10] feat(peers): persist + return user_id in create/update --- src/services/peers.js | 14 +++++++++++--- tests/peer_owner_service.test.js | 29 +++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) create mode 100644 tests/peer_owner_service.test.js diff --git a/src/services/peers.js b/src/services/peers.js index 46dc6404..33fe41ab 100644 --- a/src/services/peers.js +++ b/src/services/peers.js @@ -79,8 +79,8 @@ async function create(data) { const result = db.prepare(` INSERT INTO peers (name, description, public_key, private_key_encrypted, preshared_key_encrypted, - allowed_ips, dns, persistent_keepalive, enabled, tags, expires_at, group_id, peer_type) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?) + allowed_ips, dns, persistent_keepalive, enabled, tags, expires_at, group_id, peer_type, user_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?) `).run( sanitize(data.name), sanitize(data.description) || null, @@ -93,7 +93,8 @@ async function create(data) { sanitize(data.tags) || '', data.expiresAt || null, data.groupId || null, - data.peerType || 'regular' + data.peerType || 'regular', + data.userId != null ? Number(data.userId) : null ); return { peerId: result.lastInsertRowid, ip, allowedIps }; @@ -132,6 +133,7 @@ async function create(data) { ip_address: ip, peer_type: data.peerType || 'regular', expires_at: data.expiresAt || null, + user_id: data.userId != null ? Number(data.userId) : null, }; } @@ -189,6 +191,9 @@ async function update(id, data) { // Handle group_id: explicit null clears group, undefined means no change const groupIdValue = data.groupId !== undefined ? (data.groupId || null) : undefined; + // Handle user_id: explicit null clears owner, undefined means no change + const userIdValue = data.userId !== undefined ? (data.userId == null ? null : Number(data.userId)) : undefined; + db.prepare(` UPDATE peers SET name = COALESCE(?, name), @@ -199,6 +204,7 @@ async function update(id, data) { tags = COALESCE(?, tags), expires_at = CASE WHEN ? = 1 THEN ? ELSE expires_at END, group_id = CASE WHEN ? = 1 THEN ? ELSE group_id END, + user_id = CASE WHEN ? = 1 THEN ? ELSE user_id END, updated_at = datetime('now') WHERE id = ? `).run( @@ -212,6 +218,8 @@ async function update(id, data) { expiresAtValue !== undefined ? expiresAtValue : null, groupIdValue !== undefined ? 1 : 0, groupIdValue !== undefined ? groupIdValue : null, + userIdValue !== undefined ? 1 : 0, + userIdValue !== undefined ? userIdValue : null, id ); diff --git a/tests/peer_owner_service.test.js b/tests/peer_owner_service.test.js new file mode 100644 index 00000000..f25d1f3d --- /dev/null +++ b/tests/peer_owner_service.test.js @@ -0,0 +1,29 @@ +'use strict'; +const crypto = require('crypto'); +process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || crypto.randomBytes(32).toString('hex'); +const { test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const { setup, teardown } = require('./helpers/setup'); +let peers, getDb; +beforeEach(async () => { await setup(); peers = require('../src/services/peers'); getDb = require('../src/db/connection').getDb; }); +afterEach(teardown); +function seedUser(n){ return getDb().prepare("INSERT INTO users (username,password_hash,role) VALUES (?,?,'admin')").run(n,'x').lastInsertRowid; } + +test('create persists userId, returns it, and getById reflects it', async () => { + const uid = seedUser('o1'); + const p = await peers.create({ name: 'devA', userId: uid }); + assert.equal(p.user_id, uid, 'create() return must include user_id'); + assert.equal(getDb().prepare('SELECT user_id FROM peers WHERE id=?').get(p.id).user_id, uid); +}); +test('create without userId stores null (backward compatible)', async () => { + const p = await peers.create({ name: 'legacy' }); + assert.equal(getDb().prepare('SELECT user_id FROM peers WHERE id=?').get(p.id).user_id, null); +}); +test('update sets/clears userId; undefined leaves unchanged', async () => { + const uid = seedUser('o2'); + const p = await peers.create({ name: 'devB', userId: uid }); + await peers.update(p.id, { name: 'devB' }); + assert.equal(getDb().prepare('SELECT user_id FROM peers WHERE id=?').get(p.id).user_id, uid); + await peers.update(p.id, { userId: null }); + assert.equal(getDb().prepare('SELECT user_id FROM peers WHERE id=?').get(p.id).user_id, null); +}); From e4be41e334d4da618051c099cb08b3a0021e7a53 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Thu, 25 Jun 2026 20:45:04 +0200 Subject: [PATCH 03/10] feat(users): clear peer owner on user delete (transactional) --- src/services/users.js | 5 ++++- tests/peer_owner_user_delete.test.js | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 tests/peer_owner_user_delete.test.js diff --git a/src/services/users.js b/src/services/users.js index 4f3b5a6c..3e4e2555 100644 --- a/src/services/users.js +++ b/src/services/users.js @@ -286,7 +286,10 @@ function remove(id) { } } - db.prepare('DELETE FROM users WHERE id = ?').run(id); + db.transaction(() => { + db.prepare('UPDATE peers SET user_id = NULL WHERE user_id = ?').run(id); + db.prepare('DELETE FROM users WHERE id = ?').run(id); + })(); activity.log('user_deleted', `User "${user.username}" deleted`, { source: 'admin', diff --git a/tests/peer_owner_user_delete.test.js b/tests/peer_owner_user_delete.test.js new file mode 100644 index 00000000..5f719219 --- /dev/null +++ b/tests/peer_owner_user_delete.test.js @@ -0,0 +1,17 @@ +'use strict'; +const crypto = require('crypto'); +process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || crypto.randomBytes(32).toString('hex'); +const { test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const { setup, teardown } = require('./helpers/setup'); +let peers, users, getDb; +beforeEach(async () => { await setup(); peers = require('../src/services/peers'); users = require('../src/services/users'); getDb = require('../src/db/connection').getDb; }); +afterEach(teardown); +// safe: setup() already seeds 1 admin, so removing this 2nd admin passes the last-admin guard. +test('removing a user clears user_id on their peers; peer survives', async () => { + const uid = getDb().prepare("INSERT INTO users (username,password_hash,role) VALUES ('owner','x','admin')").run().lastInsertRowid; + const p = await peers.create({ name: 'devC', userId: uid }); + users.remove(uid); + assert.equal(getDb().prepare('SELECT user_id FROM peers WHERE id=?').get(p.id).user_id, null); + assert.ok(getDb().prepare('SELECT 1 FROM peers WHERE id=?').get(p.id)); +}); From a11bb24c8faf592ff914bed372c34e786d80458c Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Thu, 25 Jun 2026 20:48:39 +0200 Subject: [PATCH 04/10] feat(peers): API accepts user_id (validated) + admin GET owner_name (prefetch) --- src/i18n/de.json | 1 + src/i18n/en.json | 1 + src/routes/api/peers.js | 24 ++++++++++++++++++++---- tests/peer_owner_api.test.js | 30 ++++++++++++++++++++++++++++++ 4 files changed, 52 insertions(+), 4 deletions(-) create mode 100644 tests/peer_owner_api.test.js diff --git a/src/i18n/de.json b/src/i18n/de.json index 1f611fcc..55285b40 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -720,6 +720,7 @@ "error.peers.name_exists": "Ein Peer mit diesem Namen existiert bereits", "error.peers.no_ips": "Keine verfügbaren IP-Adressen im Subnetz", "error.peers.failover_home_blocked": "Dieses Gateway ist aktuell das Zuhause failover-umgezogener Routen. Hole sie zuerst zurück oder ziehe sie dauerhaft um.", + "error.peers.owner_invalid": "Ausgewählter Besitzer existiert nicht", "error.client.hostname_required": "Hostname ist erforderlich", "error.client.invalid_hostname": "Ungültiger Hostname für Peer-Name", diff --git a/src/i18n/en.json b/src/i18n/en.json index a20873f3..60dc7445 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -720,6 +720,7 @@ "error.peers.name_exists": "A peer with this name already exists", "error.peers.no_ips": "No available IP addresses in subnet", "error.peers.failover_home_blocked": "This gateway is currently the home of failed-over routes. Restore them or permanently relocate them first.", + "error.peers.owner_invalid": "Selected owner does not exist", "error.client.hostname_required": "Hostname is required", "error.client.invalid_hostname": "Invalid hostname for peer name", diff --git a/src/routes/api/peers.js b/src/routes/api/peers.js index f11c28f8..cf29c267 100644 --- a/src/routes/api/peers.js +++ b/src/routes/api/peers.js @@ -14,6 +14,15 @@ const router = Router(); const peerCountFn = () => getDb().prepare('SELECT COUNT(*) as count FROM peers').get().count; +function validateOwner(req, res, userId) { + if (userId == null) return true; + const uid = Number(userId); + if (!Number.isInteger(uid) || !require('../../services/users').getById(uid)) { + res.status(400).json({ ok: false, error: req.t('error.peers.owner_invalid') }); return false; + } + return true; +} + const stripPeer = (p) => stripFields(p, ['private_key_encrypted', 'preshared_key_encrypted']); /** Map service-layer error messages to i18n keys */ @@ -59,7 +68,9 @@ router.get('/', async (req, res) => { const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 250, 1), 250); const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0); const list = await peers.getAll({ limit, offset }); - res.json({ ok: true, peers: list.map(stripPeer), limit, offset }); + const uMap = new Map(getDb().prepare('SELECT id, username FROM users').all().map(u => [u.id, u.username])); + const enriched = list.map(p => ({ ...p, owner_name: p.user_id != null ? (uMap.get(p.user_id) || null) : null })); + res.json({ ok: true, peers: enriched.map(stripPeer), limit, offset }); } catch (err) { logger.error({ error: err.message }, 'Failed to list peers'); res.status(500).json({ ok: false, error: req.t('error.peers.list') }); @@ -85,7 +96,7 @@ router.get('/:id', (req, res) => { */ router.post('/', requireLimit('vpn_peers', peerCountFn), async (req, res) => { try { - const { name, description, tags, expires_at, group_id, dns, is_gateway, api_port, proxy_port } = req.body; + const { name, description, tags, expires_at, group_id, dns, is_gateway, api_port, proxy_port, user_id } = req.body; // Field-level validation const fields = {}; @@ -130,7 +141,8 @@ router.post('/', requireLimit('vpn_peers', peerCountFn), async (req, res) => { }); } - const peer = await peers.create({ name, description, tags, expiresAt: expires_at || null, groupId: group_id !== undefined ? group_id : null, dns }); + if (!validateOwner(req, res, user_id)) return; + const peer = await peers.create({ name, description, tags, expiresAt: expires_at || null, groupId: group_id !== undefined ? group_id : null, dns, userId: user_id }); res.status(201).json({ ok: true, peer: stripPeer(peer) }); } catch (err) { logger.error({ error: err.message }, 'Failed to create peer'); @@ -144,7 +156,7 @@ router.post('/', requireLimit('vpn_peers', peerCountFn), async (req, res) => { */ router.put('/:id', async (req, res) => { try { - const { name, description, dns, persistentKeepalive, enabled, tags, expires_at, group_id } = req.body; + const { name, description, dns, persistentKeepalive, enabled, tags, expires_at, group_id, user_id } = req.body; // Field-level validation const fields = {}; @@ -168,6 +180,10 @@ router.put('/:id', async (req, res) => { if (group_id !== undefined) { updateData.groupId = group_id || null; } + if (user_id !== undefined && !validateOwner(req, res, user_id)) return; + if (user_id !== undefined) { + updateData.userId = user_id; + } const peer = await peers.update(req.params.id, updateData); res.json({ ok: true, peer: stripPeer(peer) }); diff --git a/tests/peer_owner_api.test.js b/tests/peer_owner_api.test.js new file mode 100644 index 00000000..28497115 --- /dev/null +++ b/tests/peer_owner_api.test.js @@ -0,0 +1,30 @@ +'use strict'; +const crypto = require('crypto'); +process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || crypto.randomBytes(32).toString('hex'); +const { test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const { setup, teardown, getAgent, getCsrf } = require('./helpers/setup'); +let getDb; +beforeEach(async () => { await setup(); getDb = require('../src/db/connection').getDb; }); +afterEach(teardown); +function seedUser(n){ return getDb().prepare("INSERT INTO users (username,password_hash,role) VALUES (?,?,'admin')").run(n,'x').lastInsertRowid; } + +test('POST with user_id persists owner (201, body.peer.user_id)', async () => { + const uid = seedUser('alice'); const agent = getAgent(); const csrf = getCsrf(); + const r = await agent.post('/api/v1/peers').set('X-CSRF-Token', csrf).send({ name: 'devD', user_id: uid }).expect(201); + assert.equal(r.body.peer.user_id, uid); +}); +test('PUT sets user_id and GET reflects owner_name', async () => { + const uid = seedUser('bob'); const agent = getAgent(); const csrf = getCsrf(); + const c = await agent.post('/api/v1/peers').set('X-CSRF-Token', csrf).send({ name: 'devE' }).expect(201); + const pid = c.body.peer.id; + await agent.put('/api/v1/peers/' + pid).set('X-CSRF-Token', csrf).send({ user_id: uid }).expect(200); + const list = await agent.get('/api/v1/peers').expect(200); + const row = list.body.peers.find(p => p.id === pid); + assert.equal(row.user_id, uid); assert.equal(row.owner_name, 'bob'); +}); +test('PUT rejects a non-existent user_id (400)', async () => { + const agent = getAgent(); const csrf = getCsrf(); + const c = await agent.post('/api/v1/peers').set('X-CSRF-Token', csrf).send({ name: 'devF' }).expect(201); + await agent.put('/api/v1/peers/' + c.body.peer.id).set('X-CSRF-Token', csrf).send({ user_id: 999999 }).expect(400); +}); From 2e8353174d06d912f073b42ea3bfac88b57f1082 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Thu, 25 Jun 2026 20:55:50 +0200 Subject: [PATCH 05/10] =?UTF-8?q?test(peers):=20regression=20=E2=80=94=20o?= =?UTF-8?q?wner=20mapping=20never=20client/portal-facing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/peer_owner_no_leak.test.js | 75 ++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 tests/peer_owner_no_leak.test.js diff --git a/tests/peer_owner_no_leak.test.js b/tests/peer_owner_no_leak.test.js new file mode 100644 index 00000000..3842343e --- /dev/null +++ b/tests/peer_owner_no_leak.test.js @@ -0,0 +1,75 @@ +'use strict'; +const crypto = require('crypto'); +process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || crypto.randomBytes(32).toString('hex'); + +const { test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const supertest = require('supertest'); +const { setup, teardown } = require('./helpers/setup'); + +// Home-vhost Host header (matches default GC_DNS_DOMAIN = 'gc.internal'). +// portalIdentity only establishes identity when Host === effectivePortalHost().host. +const HOME_HOST = 'home.gc.internal'; + +let app, getDb; +beforeEach(async () => { + await setup(); + getDb = require('../src/db/connection').getDb; + app = require('../src/app').createApp(); +}); +afterEach(teardown); + +test('portal /device whitelists fields and never emits user_id/owner_name (behavioral)', async () => { + // Insert a peer so the handler returns data rather than the `unidentified` envelope. + // supertest connects from loopback (like Caddy); X-GC-Portal-Peer-IP is the + // reserved header that portalIdentity reads to establish req.portalPeerId. + // Host must match the portal home vhost (home.) for identity to pass + // the mgmt-vhost anti-forgery gate. + getDb().prepare( + `INSERT INTO peers (name, public_key, allowed_ips, enabled, peer_type) + VALUES ('devP','pk-no-leak-test','10.8.0.99/32',1,'regular')` + ).run(); + + const res = await supertest(app) + .get('/api/v1/portal/device') + .set('X-GC-Portal-Peer-IP', '10.8.0.99') + .set('Host', HOME_HOST) + .expect(200); + + assert.equal(res.body.ok, true); + assert.ok(res.body.data !== null, 'peer should be identified and data must be returned (not unidentified)'); + assert.ok(!('user_id' in res.body.data), 'portal /device must not expose user_id'); + assert.ok(!('owner_name' in res.body.data), 'portal /device must not expose owner_name'); +}); + +test('client-facing + portal route sources never reference user_id/owner_name', () => { + const candidates = [ + 'src/routes/api/client/peers.js', + 'src/routes/api/client/status.js', + 'src/routes/api/client/traffic.js', + 'src/routes/api/portal.js', + ].map(f => path.join(__dirname, '..', f)).filter(fs.existsSync); + + // Guard: a scan matching zero files is silent-green and worthless. + // At minimum portal.js + client/peers.js must be present. + assert.ok( + candidates.length >= 2, + `source scan matched only ${candidates.length} file(s) — expected at least 2 (portal.js + client/peers.js)` + ); + + for (const f of candidates) { + const src = fs.readFileSync(f, 'utf8'); + // Must not contain any reference to the owner-mapping columns. + assert.ok( + !/\buser_id\b|\bowner_name\b/.test(src), + `${path.basename(f)} must not reference owner mapping (user_id / owner_name)` + ); + // Must not return a raw peer object: res.json(peer) or res.json({ ...peer ... }). + assert.ok( + !/res\.json\(\s*peer\s*\)/.test(src) && !/res\.json\(\s*\{[^}]*\.\.\.\s*\w*peer\w*/.test(src), + `${path.basename(f)} must not return a raw peer object` + ); + } +}); From 2c41d63e71f7b2bf39946b2ea7af49169d095b4e Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Thu, 25 Jun 2026 21:01:13 +0200 Subject: [PATCH 06/10] test(peers): broaden owner-leak source scan to all client routes; fix comment --- tests/peer_owner_no_leak.test.js | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/tests/peer_owner_no_leak.test.js b/tests/peer_owner_no_leak.test.js index 3842343e..ab2061f4 100644 --- a/tests/peer_owner_no_leak.test.js +++ b/tests/peer_owner_no_leak.test.js @@ -23,10 +23,10 @@ afterEach(teardown); test('portal /device whitelists fields and never emits user_id/owner_name (behavioral)', async () => { // Insert a peer so the handler returns data rather than the `unidentified` envelope. - // supertest connects from loopback (like Caddy); X-GC-Portal-Peer-IP is the - // reserved header that portalIdentity reads to establish req.portalPeerId. - // Host must match the portal home vhost (home.) for identity to pass - // the mgmt-vhost anti-forgery gate. + // Identity is established via loopback (supertest behavior, like Caddy), the + // X-GC-Portal-Peer-IP header (reserved; read by portalIdentity to set req.portalPeerId), + // and the home Host vhost (matches effectivePortalHost().host for mgmt-vhost anti-forgery). + // This test FAILS if the peer is not identified (data would be null). getDb().prepare( `INSERT INTO peers (name, public_key, allowed_ips, enabled, peer_type) VALUES ('devP','pk-no-leak-test','10.8.0.99/32',1,'regular')` @@ -45,18 +45,23 @@ test('portal /device whitelists fields and never emits user_id/owner_name (behav }); test('client-facing + portal route sources never reference user_id/owner_name', () => { + const clientDir = path.join(__dirname, '..', 'src/routes/api/client'); + const clientFiles = fs.existsSync(clientDir) + ? fs.readdirSync(clientDir) + .filter(f => f.endsWith('.js')) + .map(f => path.join(clientDir, f)) + : []; + const portalFile = path.join(__dirname, '..', 'src/routes/api/portal.js'); const candidates = [ - 'src/routes/api/client/peers.js', - 'src/routes/api/client/status.js', - 'src/routes/api/client/traffic.js', - 'src/routes/api/portal.js', - ].map(f => path.join(__dirname, '..', f)).filter(fs.existsSync); + ...clientFiles, + ...(fs.existsSync(portalFile) ? [portalFile] : []), + ]; // Guard: a scan matching zero files is silent-green and worthless. - // At minimum portal.js + client/peers.js must be present. + // At minimum portal.js + at least one client/*.js file must be present. assert.ok( candidates.length >= 2, - `source scan matched only ${candidates.length} file(s) — expected at least 2 (portal.js + client/peers.js)` + `source scan matched only ${candidates.length} file(s) — expected at least 2 (portal.js + ≥1 client route)` ); for (const f of candidates) { From c202eb5fd1b24597d1bc2eaa7aff5fd4f40836c4 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Thu, 25 Jun 2026 21:04:13 +0200 Subject: [PATCH 07/10] feat(peers): POST /batch-owner bulk owner assignment (auth, limit, collision-free) --- src/i18n/de.json | 1 + src/i18n/en.json | 1 + src/routes/api/peers.js | 23 ++++++++++++++++++++++ tests/peer_owner_bulk.test.js | 36 +++++++++++++++++++++++++++++++++++ 4 files changed, 61 insertions(+) create mode 100644 tests/peer_owner_bulk.test.js diff --git a/src/i18n/de.json b/src/i18n/de.json index 55285b40..9383699a 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -721,6 +721,7 @@ "error.peers.no_ips": "Keine verfügbaren IP-Adressen im Subnetz", "error.peers.failover_home_blocked": "Dieses Gateway ist aktuell das Zuhause failover-umgezogener Routen. Hole sie zuerst zurück oder ziehe sie dauerhaft um.", "error.peers.owner_invalid": "Ausgewählter Besitzer existiert nicht", + "error.peers.bulk_invalid_ids": "Ungültige oder zu viele Geräte-IDs", "error.client.hostname_required": "Hostname ist erforderlich", "error.client.invalid_hostname": "Ungültiger Hostname für Peer-Name", diff --git a/src/i18n/en.json b/src/i18n/en.json index 60dc7445..8431ece2 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -721,6 +721,7 @@ "error.peers.no_ips": "No available IP addresses in subnet", "error.peers.failover_home_blocked": "This gateway is currently the home of failed-over routes. Restore them or permanently relocate them first.", "error.peers.owner_invalid": "Selected owner does not exist", + "error.peers.bulk_invalid_ids": "Invalid or too many peer IDs", "error.client.hostname_required": "Hostname is required", "error.client.invalid_hostname": "Invalid hostname for peer name", diff --git a/src/routes/api/peers.js b/src/routes/api/peers.js index cf29c267..744e7f79 100644 --- a/src/routes/api/peers.js +++ b/src/routes/api/peers.js @@ -60,6 +60,29 @@ router.post('/batch', async (req, res) => { } }); +// Bulk owner assignment. Registered BEFORE '/:id' so the literal path isn't +// captured as id="batch-owner". Inherits the same admin auth + CSRF (mount point). +router.post('/batch-owner', (req, res) => { + try { + const { peer_ids, user_id } = req.body; + const MAX = 500; + if (!Array.isArray(peer_ids) || peer_ids.length === 0 || peer_ids.length > MAX + || !peer_ids.every(n => Number.isInteger(n))) { + return res.status(400).json({ ok: false, error: req.t('error.peers.bulk_invalid_ids') }); + } + if (user_id != null && (!Number.isInteger(Number(user_id)) || !require('../../services/users').getById(Number(user_id)))) { + return res.status(400).json({ ok: false, error: req.t('error.peers.owner_invalid') }); + } + const db = getDb(); + const ph = peer_ids.map(() => '?').join(','); + const info = db.prepare(`UPDATE peers SET user_id = ?, updated_at = datetime('now') WHERE id IN (${ph})`) + .run(user_id == null ? null : Number(user_id), ...peer_ids); + res.json({ ok: true, affected: info.changes }); + } catch (err) { + res.status(500).json({ ok: false, error: req.t('common.error') }); + } +}); + /** * GET /api/peers — List all peers with live status */ diff --git a/tests/peer_owner_bulk.test.js b/tests/peer_owner_bulk.test.js new file mode 100644 index 00000000..9a85e0a8 --- /dev/null +++ b/tests/peer_owner_bulk.test.js @@ -0,0 +1,36 @@ +'use strict'; +const crypto = require('crypto'); +process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || crypto.randomBytes(32).toString('hex'); +const { test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const supertest = require('supertest'); +const { setup, teardown, getAgent, getCsrf } = require('./helpers/setup'); +let peers, getDb, app; +beforeEach(async () => { await setup(); peers = require('../src/services/peers'); getDb = require('../src/db/connection').getDb; app = require('../src/app').createApp(); }); +afterEach(teardown); +async function mk(n){ return (await peers.create({ name: n })).id; } +function seedUser(n){ return getDb().prepare("INSERT INTO users (username,password_hash,role) VALUES (?,?,'admin')").run(n,'x').lastInsertRowid; } + +test('batch-owner sets owner for many (hits bulk handler: body.affected)', async () => { + const uid = seedUser('bob'); const a = await mk('p1'); const b = await mk('p2'); + const agent = getAgent(); const csrf = getCsrf(); + const r = await agent.post('/api/v1/peers/batch-owner').set('X-CSRF-Token', csrf).send({ peer_ids: [a, b], user_id: uid }).expect(200); + assert.equal(r.body.ok, true); assert.equal(r.body.affected, 2); + assert.equal(getDb().prepare('SELECT user_id FROM peers WHERE id=?').get(a).user_id, uid); +}); +test('batch-owner user_id null clears ownership', async () => { + const uid = seedUser('carol'); const a = await mk('p3'); const agent = getAgent(); const csrf = getCsrf(); + await agent.post('/api/v1/peers/batch-owner').set('X-CSRF-Token', csrf).send({ peer_ids: [a], user_id: uid }).expect(200); + await agent.post('/api/v1/peers/batch-owner').set('X-CSRF-Token', csrf).send({ peer_ids: [a], user_id: null }).expect(200); + assert.equal(getDb().prepare('SELECT user_id FROM peers WHERE id=?').get(a).user_id, null); +}); +test('batch-owner rejects empty/too-large peer_ids and bad user_id (400)', async () => { + const agent = getAgent(); const csrf = getCsrf(); + await agent.post('/api/v1/peers/batch-owner').set('X-CSRF-Token', csrf).send({ peer_ids: [], user_id: null }).expect(400); + await agent.post('/api/v1/peers/batch-owner').set('X-CSRF-Token', csrf).send({ peer_ids: Array.from({length:501},(_,i)=>i+1), user_id: null }).expect(400); + await agent.post('/api/v1/peers/batch-owner').set('X-CSRF-Token', csrf).send({ peer_ids: [1], user_id: 999999 }).expect(400); +}); +test('batch-owner without auth/CSRF is rejected (401/403)', async () => { + const res = await supertest(app).post('/api/v1/peers/batch-owner').send({ peer_ids: [1], user_id: null }); + assert.ok([401, 403].includes(res.status), `expected 401/403, got ${res.status}`); +}); From ad3946d4e6aa6d2e1c50ff083aae56bec550297f Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Thu, 25 Jun 2026 21:11:23 +0200 Subject: [PATCH 08/10] feat(peers): owner select in add/edit dialogs (3 themes) + wiring + i18n --- public/js/peers.js | 26 +++++++++++++++++-- src/i18n/de.json | 2 ++ src/i18n/en.json | 2 ++ templates/aurora/partials/modals/peer-add.njk | 4 +++ .../aurora/partials/modals/peer-edit.njk | 4 +++ .../default/partials/modals/peer-add.njk | 4 +++ .../default/partials/modals/peer-edit.njk | 4 +++ templates/pro/partials/modals/peer-add.njk | 4 +++ templates/pro/partials/modals/peer-edit.njk | 4 +++ tests/peer_owner_ui.test.js | 18 +++++++++++++ 10 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 tests/peer_owner_ui.test.js diff --git a/public/js/peers.js b/public/js/peers.js index c3f40314..3084f670 100644 --- a/public/js/peers.js +++ b/public/js/peers.js @@ -385,6 +385,19 @@ }); } + // ─── Load owner selects ────────────────────────────────── + function populateOwnerSelects(ids) { + api.get('/api/v1/users').then(function (r) { + var users = (r.data || r.users || []); + (ids || ['add-peer-owner', 'edit-peer-owner']).forEach(function (id) { + var sel = document.getElementById(id); if (!sel) return; + sel.innerHTML = ''; + sel.appendChild(new Option((window.GC && GC.t && GC.t['peers.owner.none']) || '(no owner)', '')); + users.forEach(function (u) { sel.appendChild(new Option(u.username, String(u.id))); }); + }); + }).catch(function () {}); + } + // ─── Load peers ────────────────────────────────────────── async function loadPeers() { try { @@ -799,6 +812,8 @@ document.getElementById('add-peer-desc').value = ''; document.getElementById('add-peer-tags').value = ''; document.getElementById('add-peer-group').value = ''; + var addOwnerSel = document.getElementById('add-peer-owner'); + if (addOwnerSel) addOwnerSel.value = ''; document.getElementById('add-peer-expires').value = ''; document.getElementById('add-peer-expires-date').value = ''; document.getElementById('add-peer-expires-date').style.display = 'none'; @@ -838,7 +853,9 @@ var isGateway = !!(isGatewayEl && isGatewayEl.checked); var apiPort = apiPortEl ? parseInt(apiPortEl.value, 10) || 9876 : 9876; var proxyPort = proxyPortEl ? parseInt(proxyPortEl.value, 10) || 8080 : 8080; - var payload = { name: name, description: description, tags: tags, expires_at: expires_at, group_id: group_id, dns: dns || undefined }; + var addOwnerEl = document.getElementById('add-peer-owner'); + var payload = { name: name, description: description, tags: tags, expires_at: expires_at, group_id: group_id, dns: dns || undefined, + user_id: addOwnerEl ? (addOwnerEl.value === '' ? null : Number(addOwnerEl.value)) : null }; if (isGateway) { payload.is_gateway = true; payload.api_port = apiPort; @@ -1059,6 +1076,8 @@ renderGroupDropdowns(); document.getElementById('edit-peer-group').value = peer.group_id ? String(peer.group_id) : ''; + var editOwnerSel = document.getElementById('edit-peer-owner'); + if (editOwnerSel) editOwnerSel.value = peer.user_id != null ? String(peer.user_id) : ''; var editExpiresSel = document.getElementById('edit-peer-expires'); var editExpiresDate = document.getElementById('edit-peer-expires-date'); @@ -1204,7 +1223,9 @@ try { var dns = document.getElementById('edit-peer-dns') ? document.getElementById('edit-peer-dns').value.trim() : undefined; var expires_at = computeExpiresAt('edit-peer-expires', 'edit-peer-expires-date'); - var data = await api.put('/api/peers/' + id, { name: name, description: description, tags: tags, expires_at: expires_at, group_id: group_id, dns: dns || undefined }); + var editOwnerEl = document.getElementById('edit-peer-owner'); + var data = await api.put('/api/peers/' + id, { name: name, description: description, tags: tags, expires_at: expires_at, group_id: group_id, dns: dns || undefined, + user_id: editOwnerEl ? (editOwnerEl.value === '' ? null : Number(editOwnerEl.value)) : null }); if (!data.ok) { if (data.fields) { showFieldErrors(data.fields, { name: 'edit-peer-name', description: 'edit-peer-desc' }); @@ -2435,6 +2456,7 @@ loadGroups(); loadPeers(); loadGateways(); + populateOwnerSelects(); if (isAurora()) auroraInitStatusToggle(); setInterval(loadPeers, 15000); setInterval(loadGroups, 30000); diff --git a/src/i18n/de.json b/src/i18n/de.json index 9383699a..375e8d43 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -186,6 +186,8 @@ "error.tags.list": "Tags konnten nicht geladen werden", "error.tags.create": "Tag konnte nicht angelegt werden", "error.tags.delete": "Tag konnte nicht gelöscht werden", + "peers.owner.label": "Besitzer", + "peers.owner.none": "(kein Besitzer)", "peers.expires": "Ablaufdatum", "peers.expires_never": "Nie", "peers.expires_1d": "1 Tag", diff --git a/src/i18n/en.json b/src/i18n/en.json index 8431ece2..02a423df 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -186,6 +186,8 @@ "error.tags.list": "Failed to load tags", "error.tags.create": "Failed to create tag", "error.tags.delete": "Failed to delete tag", + "peers.owner.label": "Owner", + "peers.owner.none": "(no owner)", "peers.expires": "Expires", "peers.expires_never": "Never", "peers.expires_1d": "1 day", diff --git a/templates/aurora/partials/modals/peer-add.njk b/templates/aurora/partials/modals/peer-add.njk index 0ce01cba..c1229d2d 100644 --- a/templates/aurora/partials/modals/peer-add.njk +++ b/templates/aurora/partials/modals/peer-add.njk @@ -19,6 +19,10 @@ +
+ + +
diff --git a/templates/aurora/partials/modals/peer-edit.njk b/templates/aurora/partials/modals/peer-edit.njk index 75006092..49d2a8f6 100644 --- a/templates/aurora/partials/modals/peer-edit.njk +++ b/templates/aurora/partials/modals/peer-edit.njk @@ -20,6 +20,10 @@
+
+ + +
diff --git a/templates/default/partials/modals/peer-add.njk b/templates/default/partials/modals/peer-add.njk index 0ce01cba..c1229d2d 100644 --- a/templates/default/partials/modals/peer-add.njk +++ b/templates/default/partials/modals/peer-add.njk @@ -19,6 +19,10 @@
+
+ + +
diff --git a/templates/default/partials/modals/peer-edit.njk b/templates/default/partials/modals/peer-edit.njk index 622a2687..3d0eeb44 100644 --- a/templates/default/partials/modals/peer-edit.njk +++ b/templates/default/partials/modals/peer-edit.njk @@ -20,6 +20,10 @@
+
+ + +
diff --git a/templates/pro/partials/modals/peer-add.njk b/templates/pro/partials/modals/peer-add.njk index 418a1e7b..d3710228 100644 --- a/templates/pro/partials/modals/peer-add.njk +++ b/templates/pro/partials/modals/peer-add.njk @@ -19,6 +19,10 @@
+
+ + +
diff --git a/templates/pro/partials/modals/peer-edit.njk b/templates/pro/partials/modals/peer-edit.njk index 16d3e85f..80935297 100644 --- a/templates/pro/partials/modals/peer-edit.njk +++ b/templates/pro/partials/modals/peer-edit.njk @@ -20,6 +20,10 @@
+
+ + +
diff --git a/tests/peer_owner_ui.test.js b/tests/peer_owner_ui.test.js new file mode 100644 index 00000000..41d61f3d --- /dev/null +++ b/tests/peer_owner_ui.test.js @@ -0,0 +1,18 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); const path = require('node:path'); +test('owner selects exist in add+edit peer partials (3 themes)', () => { + for (const theme of ['aurora', 'default', 'pro']) { + const add = fs.readFileSync(path.join(__dirname, '..', 'templates', theme, 'partials', 'modals', 'peer-add.njk'), 'utf8'); + const edit = fs.readFileSync(path.join(__dirname, '..', 'templates', theme, 'partials', 'modals', 'peer-edit.njk'), 'utf8'); + assert.ok(add.includes('add-peer-owner'), `${theme} add`); + assert.ok(edit.includes('edit-peer-owner'), `${theme} edit`); + } +}); +test('peers.js loads users and sends user_id', () => { + const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'peers.js'), 'utf8'); + assert.ok(/add-peer-owner|edit-peer-owner/.test(js)); + assert.ok(/\/api\/v1\/users/.test(js)); + assert.ok(/user_id/.test(js)); +}); From 34047027a46e9b1f61ac4302d6dfd85d7b6a25d0 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Thu, 25 Jun 2026 21:16:18 +0200 Subject: [PATCH 09/10] feat(peers): bulk owner assignment in batch bar (3 themes) + i18n --- public/js/peers.js | 12 +++++++++++- src/i18n/de.json | 2 ++ src/i18n/en.json | 2 ++ templates/aurora/pages/peers.njk | 4 +++- templates/default/pages/peers.njk | 4 +++- templates/pro/pages/peers.njk | 4 +++- tests/peer_owner_bulk_ui.test.js | 18 ++++++++++++++++++ 7 files changed, 42 insertions(+), 4 deletions(-) create mode 100644 tests/peer_owner_bulk_ui.test.js diff --git a/public/js/peers.js b/public/js/peers.js index 3084f670..9099f643 100644 --- a/public/js/peers.js +++ b/public/js/peers.js @@ -2456,7 +2456,17 @@ loadGroups(); loadPeers(); loadGateways(); - populateOwnerSelects(); + populateOwnerSelects(['add-peer-owner', 'edit-peer-owner', 'peer-bulk-owner']); + + var applyBtn = document.getElementById('peer-bulk-owner-apply'); + if (applyBtn) applyBtn.addEventListener('click', function () { + var ids = Array.from(batchSelected).map(Number); + if (!ids.length) return; + var v = document.getElementById('peer-bulk-owner').value; + api.post('/api/v1/peers/batch-owner', { peer_ids: ids, user_id: v === '' ? null : Number(v) }) + .then(function () { batchSelected.clear(); loadPeers(); showToast((GC.t && GC.t['peers.owner.bulk_done']) || 'Owners updated'); }) + .catch(function (e) { showToast(e.message, 'error'); }); + }); if (isAurora()) auroraInitStatusToggle(); setInterval(loadPeers, 15000); setInterval(loadGroups, 30000); diff --git a/src/i18n/de.json b/src/i18n/de.json index 375e8d43..d7139287 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -188,6 +188,8 @@ "error.tags.delete": "Tag konnte nicht gelöscht werden", "peers.owner.label": "Besitzer", "peers.owner.none": "(kein Besitzer)", + "peers.owner.bulk_assign": "Besitzer zuweisen", + "peers.owner.bulk_done": "Besitzer aktualisiert", "peers.expires": "Ablaufdatum", "peers.expires_never": "Nie", "peers.expires_1d": "1 Tag", diff --git a/src/i18n/en.json b/src/i18n/en.json index 02a423df..484bab77 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -188,6 +188,8 @@ "error.tags.delete": "Failed to delete tag", "peers.owner.label": "Owner", "peers.owner.none": "(no owner)", + "peers.owner.bulk_assign": "Assign owner", + "peers.owner.bulk_done": "Owners updated", "peers.expires": "Expires", "peers.expires_never": "Never", "peers.expires_1d": "1 day", diff --git a/templates/aurora/pages/peers.njk b/templates/aurora/pages/peers.njk index 584e672e..e2918207 100644 --- a/templates/aurora/pages/peers.njk +++ b/templates/aurora/pages/peers.njk @@ -93,10 +93,12 @@