Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 34 additions & 2 deletions public/js/peers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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' });
Expand Down Expand Up @@ -2435,6 +2456,17 @@
loadGroups();
loadPeers();
loadGateways();
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(); if (batchSelectAll) batchSelectAll.checked = false; updateBatchBar(); 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);
Expand Down
12 changes: 12 additions & 0 deletions src/db/migrationList.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
6 changes: 6 additions & 0 deletions src/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,10 @@
"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.owner.bulk_assign": "Besitzer zuweisen",
"peers.owner.bulk_done": "Besitzer aktualisiert",
"peers.expires": "Ablaufdatum",
"peers.expires_never": "Nie",
"peers.expires_1d": "1 Tag",
Expand Down Expand Up @@ -720,6 +724,8 @@
"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.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",
Expand Down
6 changes: 6 additions & 0 deletions src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,10 @@
"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.owner.bulk_assign": "Assign owner",
"peers.owner.bulk_done": "Owners updated",
"peers.expires": "Expires",
"peers.expires_never": "Never",
"peers.expires_1d": "1 day",
Expand Down Expand Up @@ -720,6 +724,8 @@
"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.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",
Expand Down
47 changes: 43 additions & 4 deletions src/routes/api/peers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -51,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
*/
Expand All @@ -59,7 +91,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') });
Expand All @@ -85,7 +119,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 = {};
Expand Down Expand Up @@ -130,7 +164,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');
Expand All @@ -144,7 +179,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 = {};
Expand All @@ -168,6 +203,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) });
Expand Down
14 changes: 11 additions & 3 deletions src/services/peers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 };
Expand Down Expand Up @@ -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,
};
}

Expand Down Expand Up @@ -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),
Expand All @@ -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(
Expand All @@ -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
);

Expand Down
5 changes: 4 additions & 1 deletion src/services/users.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
4 changes: 3 additions & 1 deletion templates/aurora/pages/peers.njk
Original file line number Diff line number Diff line change
Expand Up @@ -93,10 +93,12 @@
<!-- Batch action bar (preserved for batch-mode JS flow) -->
<div id="batch-bar-peers" class="batch-bar" style="display:none">
<span id="batch-bar-peers-count"></span>
<div style="display:flex;gap:8px">
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<button class="btn btn-ghost" id="batch-enable-peers">{{ t('batch.enable') }}</button>
<button class="btn btn-ghost" id="batch-disable-peers">{{ t('batch.disable') }}</button>
<button class="btn btn-danger" id="batch-delete-peers">{{ t('batch.delete') }}</button>
<select id="peer-bulk-owner" style="width:auto"></select>
<button class="btn btn-ghost" id="peer-bulk-owner-apply">{{ t('peers.owner.bulk_assign') }}</button>
<button class="btn btn-ghost" id="batch-cancel-peers">{{ t('batch.cancel') }}</button>
</div>
</div>
Expand Down
4 changes: 4 additions & 0 deletions templates/aurora/partials/modals/peer-add.njk
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@
<option value="">{{ t('peer_groups.none') }}</option>
</select>
</div>
<div class="form-group">
<label class="form-label" for="add-peer-owner">{{ t('peers.owner.label') }}</label>
<select id="add-peer-owner" style="width:100%"></select>
</div>
<div class="form-group">
<label class="form-label" for="add-peer-tags">{{ t('peers.tags') or 'Tags' }}</label>
<input type="text" id="add-peer-tags" placeholder="{{ t('peers.tags_placeholder') or 'e.g. server, production' }}" autocomplete="off" maxlength="255">
Expand Down
4 changes: 4 additions & 0 deletions templates/aurora/partials/modals/peer-edit.njk
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@
<option value="">{{ t('peer_groups.none') }}</option>
</select>
</div>
<div class="form-group">
<label class="form-label" for="edit-peer-owner">{{ t('peers.owner.label') }}</label>
<select id="edit-peer-owner" style="width:100%"></select>
</div>
<div class="form-group">
<label class="form-label" for="edit-peer-tags">{{ t('peers.tags') or 'Tags' }}</label>
<input type="text" id="edit-peer-tags" placeholder="{{ t('peers.tags_placeholder') or 'e.g. server, production' }}" autocomplete="off" maxlength="255">
Expand Down
4 changes: 3 additions & 1 deletion templates/default/pages/peers.njk
Original file line number Diff line number Diff line change
Expand Up @@ -108,10 +108,12 @@
<!-- Batch action bar -->
<div id="batch-bar-peers" class="batch-bar" style="display:none">
<span id="batch-bar-peers-count"></span>
<div style="display:flex;gap:8px">
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<button class="btn btn-ghost" id="batch-enable-peers">{{ t('batch.enable') }}</button>
<button class="btn btn-ghost" id="batch-disable-peers">{{ t('batch.disable') }}</button>
<button class="btn btn-danger" id="batch-delete-peers">{{ t('batch.delete') }}</button>
<select id="peer-bulk-owner" style="width:auto"></select>
<button class="btn btn-ghost" id="peer-bulk-owner-apply">{{ t('peers.owner.bulk_assign') }}</button>
<button class="btn btn-ghost" id="batch-cancel-peers">{{ t('batch.cancel') }}</button>
</div>
</div>
Expand Down
4 changes: 4 additions & 0 deletions templates/default/partials/modals/peer-add.njk
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@
<option value="">{{ t('peer_groups.none') }}</option>
</select>
</div>
<div class="form-group">
<label class="form-label" for="add-peer-owner">{{ t('peers.owner.label') }}</label>
<select id="add-peer-owner" style="width:100%"></select>
</div>
<div class="form-group">
<label class="form-label" for="add-peer-tags">{{ t('peers.tags') or 'Tags' }}</label>
<input type="text" id="add-peer-tags" placeholder="{{ t('peers.tags_placeholder') or 'e.g. server, production' }}" autocomplete="off" maxlength="255">
Expand Down
4 changes: 4 additions & 0 deletions templates/default/partials/modals/peer-edit.njk
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@
<option value="">{{ t('peer_groups.none') }}</option>
</select>
</div>
<div class="form-group">
<label class="form-label" for="edit-peer-owner">{{ t('peers.owner.label') }}</label>
<select id="edit-peer-owner" style="width:100%"></select>
</div>
<div class="form-group">
<label class="form-label" for="edit-peer-tags">{{ t('peers.tags') or 'Tags' }}</label>
<input type="text" id="edit-peer-tags" placeholder="{{ t('peers.tags_placeholder') or 'e.g. server, production' }}" autocomplete="off" maxlength="255">
Expand Down
4 changes: 3 additions & 1 deletion templates/pro/pages/peers.njk
Original file line number Diff line number Diff line change
Expand Up @@ -108,10 +108,12 @@
<!-- Batch action bar -->
<div id="batch-bar-peers" class="batch-bar" style="display:none">
<span id="batch-bar-peers-count"></span>
<div style="display:flex;gap:8px">
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<button class="btn btn-ghost" id="batch-enable-peers">{{ t('batch.enable') }}</button>
<button class="btn btn-ghost" id="batch-disable-peers">{{ t('batch.disable') }}</button>
<button class="btn btn-danger" id="batch-delete-peers">{{ t('batch.delete') }}</button>
<select class="form-select" id="peer-bulk-owner" style="width:auto"></select>
<button class="btn btn-ghost" id="peer-bulk-owner-apply">{{ t('peers.owner.bulk_assign') }}</button>
<button class="btn btn-ghost" id="batch-cancel-peers">{{ t('batch.cancel') }}</button>
</div>
</div>
Expand Down
4 changes: 4 additions & 0 deletions templates/pro/partials/modals/peer-add.njk
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@
<option value="">{{ t('peer_groups.none') }}</option>
</select>
</div>
<div class="form-group">
<label class="form-label" for="add-peer-owner">{{ t('peers.owner.label') }}</label>
<select class="form-select" id="add-peer-owner" style="width:100%"></select>
</div>
<div class="form-group">
<label class="form-label" for="add-peer-tags">{{ t('peers.tags') or 'Tags' }}</label>
<input class="form-input" type="text" id="add-peer-tags" placeholder="{{ t('peers.tags_placeholder') or 'e.g. server, production' }}" autocomplete="off" maxlength="255">
Expand Down
Loading
Loading