diff --git a/.agents/skills/spawn-agent b/.agents/skills/spawn-agent new file mode 160000 index 0000000..288a767 --- /dev/null +++ b/.agents/skills/spawn-agent @@ -0,0 +1 @@ +Subproject commit 288a767d8b251004f7bd7999dcdf408cbbaa86c7 diff --git a/backend/internal/wireguard.js b/backend/internal/wireguard.js index aa35897..e47ddcf 100644 --- a/backend/internal/wireguard.js +++ b/backend/internal/wireguard.js @@ -205,14 +205,21 @@ const internalWireguard = { /** * Get all clients with live status and interface name correlation */ - async getClients(knex) { + async getClients(knex, access) { await this.getOrCreateInterface(knex); // Ensure structure exists - const dbClients = await knex("wg_client") + const query = knex("wg_client") .join("wg_interface", "wg_client.interface_id", "=", "wg_interface.id") .select("wg_client.*", "wg_interface.name as interface_name") .orderBy("wg_client.created_on", "desc"); + // Filter by owner if not admin + if (access && !access.token.hasScope("admin")) { + query.andWhere("wg_client.owner_user_id", access.token.getUserId(1)); + } + + const dbClients = await query; + const clients = dbClients.map((c) => ({ id: c.id, name: c.name, @@ -258,7 +265,7 @@ const internalWireguard = { /** * Create a new WireGuard client */ - async createClient(knex, data) { + async createClient(knex, data, access) { const iface = data.interface_id ? await knex("wg_interface").where("id", data.interface_id).first() : await this.getOrCreateInterface(knex); @@ -284,6 +291,7 @@ const internalWireguard = { persistent_keepalive: data.persistent_keepalive || WG_DEFAULT_PERSISTENT_KEEPALIVE, expires_at: data.expires_at || null, interface_id: iface.id, + owner_user_id: access ? access.token.getUserId(1) : 1, created_on: knex.fn.now(), modified_on: knex.fn.now(), }; @@ -299,8 +307,12 @@ const internalWireguard = { /** * Delete a WireGuard client */ - async deleteClient(knex, clientId) { - const client = await knex("wg_client").where("id", clientId).first(); + async deleteClient(knex, clientId, access) { + const query = knex("wg_client").where("id", clientId); + if (access && !access.token.hasScope("admin")) { + query.andWhere("owner_user_id", access.token.getUserId(1)); + } + const client = await query.first(); if (!client) { throw new Error("Client not found"); } @@ -314,8 +326,12 @@ const internalWireguard = { /** * Toggle a WireGuard client enabled/disabled */ - async toggleClient(knex, clientId, enabled) { - const client = await knex("wg_client").where("id", clientId).first(); + async toggleClient(knex, clientId, enabled, access) { + const query = knex("wg_client").where("id", clientId); + if (access && !access.token.hasScope("admin")) { + query.andWhere("owner_user_id", access.token.getUserId(1)); + } + const client = await query.first(); if (!client) { throw new Error("Client not found"); } @@ -333,8 +349,12 @@ const internalWireguard = { /** * Update a WireGuard client */ - async updateClient(knex, clientId, data) { - const client = await knex("wg_client").where("id", clientId).first(); + async updateClient(knex, clientId, data, access) { + const query = knex("wg_client").where("id", clientId); + if (access && !access.token.hasScope("admin")) { + query.andWhere("owner_user_id", access.token.getUserId(1)); + } + const client = await query.first(); if (!client) { throw new Error("Client not found"); } @@ -392,7 +412,7 @@ const internalWireguard = { /** * Create a new WireGuard Interface Endpoint */ - async createInterface(knex, data) { + async createInterface(knex, data, access) { const existingIfaces = await knex("wg_interface").select("name", "listen_port"); const newIndex = existingIfaces.length; @@ -416,6 +436,7 @@ const internalWireguard = { dns: data.dns || WG_DEFAULT_DNS, host: data.host || WG_HOST, isolate_clients: data.isolate_clients || false, + owner_user_id: access ? access.token.getUserId(1) : 1, post_up: "iptables -A FORWARD -i %i -j ACCEPT; iptables -A FORWARD -o %i -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE", post_down: "iptables -D FORWARD -i %i -j ACCEPT; iptables -D FORWARD -o %i -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE", created_on: knex.fn.now(), @@ -447,8 +468,12 @@ const internalWireguard = { /** * Update an existing Interface */ - async updateInterface(knex, id, data) { - const iface = await knex("wg_interface").where("id", id).first(); + async updateInterface(knex, id, data, access) { + const query = knex("wg_interface").where("id", id); + if (access && !access.token.hasScope("admin")) { + query.andWhere("owner_user_id", access.token.getUserId(1)); + } + const iface = await query.first(); if (!iface) throw new Error("Interface not found"); const updateData = { modified_on: knex.fn.now() }; @@ -466,8 +491,12 @@ const internalWireguard = { /** * Delete an interface */ - async deleteInterface(knex, id) { - const iface = await knex("wg_interface").where("id", id).first(); + async deleteInterface(knex, id, access) { + const query = knex("wg_interface").where("id", id); + if (access && !access.token.hasScope("admin")) { + query.andWhere("owner_user_id", access.token.getUserId(1)); + } + const iface = await query.first(); if (!iface) throw new Error("Interface not found"); try { @@ -487,7 +516,15 @@ const internalWireguard = { /** * Update Peering Links between WireGuard Interfaces */ - async updateInterfaceLinks(knex, id, linkedServers) { + async updateInterfaceLinks(knex, id, linkedServers, access) { + // Verify ownership + const query = knex("wg_interface").where("id", id); + if (access && !access.token.hasScope("admin")) { + query.andWhere("owner_user_id", access.token.getUserId(1)); + } + const iface = await query.first(); + if (!iface) throw new Error("Interface not found"); + // Clean up existing links where this interface is involved await knex("wg_server_link").where("interface_id_1", id).orWhere("interface_id_2", id).del(); @@ -507,8 +544,13 @@ const internalWireguard = { /** * Get the WireGuard interfaces info */ - async getInterfacesInfo(knex) { - const ifaces = await knex("wg_interface").select("*"); + async getInterfacesInfo(knex, access) { + const query = knex("wg_interface").select("*"); + // Filter by owner if not admin + if (access && !access.token.hasScope("admin")) { + query.andWhere("owner_user_id", access.token.getUserId(1)); + } + const ifaces = await query; const allLinks = await knex("wg_server_link").select("*"); return ifaces.map((i) => { diff --git a/backend/migrations/20260310000000_wireguard_owner.js b/backend/migrations/20260310000000_wireguard_owner.js new file mode 100644 index 0000000..026730e --- /dev/null +++ b/backend/migrations/20260310000000_wireguard_owner.js @@ -0,0 +1,35 @@ +/** + * Migration to add owner_user_id to WireGuard tables for user-based data isolation + */ +export async function up(knex) { + // 1. Add owner_user_id to wg_interface + await knex.schema.alterTable("wg_interface", (table) => { + table.integer("owner_user_id").unsigned().nullable(); + }); + + // 2. Add owner_user_id to wg_client + await knex.schema.alterTable("wg_client", (table) => { + table.integer("owner_user_id").unsigned().nullable(); + }); + + // 3. Backfill existing rows with admin user (id=1) + await knex("wg_interface").whereNull("owner_user_id").update({ owner_user_id: 1 }); + await knex("wg_client").whereNull("owner_user_id").update({ owner_user_id: 1 }); + + // 4. Make columns not nullable + await knex.schema.alterTable("wg_interface", (table) => { + table.integer("owner_user_id").unsigned().notNullable().defaultTo(1).alter(); + }); + await knex.schema.alterTable("wg_client", (table) => { + table.integer("owner_user_id").unsigned().notNullable().defaultTo(1).alter(); + }); +} + +export async function down(knex) { + await knex.schema.alterTable("wg_client", (table) => { + table.dropColumn("owner_user_id"); + }); + await knex.schema.alterTable("wg_interface", (table) => { + table.dropColumn("owner_user_id"); + }); +} diff --git a/backend/routes/wireguard.js b/backend/routes/wireguard.js index ea7f1ab..5f47b1b 100644 --- a/backend/routes/wireguard.js +++ b/backend/routes/wireguard.js @@ -21,7 +21,8 @@ router.use(jwtdecode()); router.get("/", async (_req, res, next) => { try { const knex = db(); - const ifaces = await internalWireguard.getInterfacesInfo(knex); + const access = res.locals.access; + const ifaces = await internalWireguard.getInterfacesInfo(knex, access); res.status(200).json(ifaces); } catch (err) { next(err); @@ -35,8 +36,9 @@ router.get("/", async (_req, res, next) => { router.post("/", async (req, res, next) => { try { const knex = db(); - const iface = await internalWireguard.createInterface(knex, req.body); - await internalAuditLog.add(res.locals.access, { + const access = res.locals.access; + const iface = await internalWireguard.createInterface(knex, req.body, access); + await internalAuditLog.add(access, { action: "created", object_type: "wireguard-server", object_id: iface.id, @@ -55,8 +57,9 @@ router.post("/", async (req, res, next) => { router.put("/:id", async (req, res, next) => { try { const knex = db(); - const iface = await internalWireguard.updateInterface(knex, req.params.id, req.body); - await internalAuditLog.add(res.locals.access, { + const access = res.locals.access; + const iface = await internalWireguard.updateInterface(knex, req.params.id, req.body, access); + await internalAuditLog.add(access, { action: "updated", object_type: "wireguard-server", object_id: iface.id, @@ -75,8 +78,9 @@ router.put("/:id", async (req, res, next) => { router.delete("/:id", async (req, res, next) => { try { const knex = db(); - const result = await internalWireguard.deleteInterface(knex, req.params.id); - await internalAuditLog.add(res.locals.access, { + const access = res.locals.access; + const result = await internalWireguard.deleteInterface(knex, req.params.id, access); + await internalAuditLog.add(access, { action: "deleted", object_type: "wireguard-server", object_id: req.params.id, @@ -95,8 +99,9 @@ router.delete("/:id", async (req, res, next) => { router.post("/:id/links", async (req, res, next) => { try { const knex = db(); - const result = await internalWireguard.updateInterfaceLinks(knex, req.params.id, req.body.linked_servers || []); - await internalAuditLog.add(res.locals.access, { + const access = res.locals.access; + const result = await internalWireguard.updateInterfaceLinks(knex, req.params.id, req.body.linked_servers || [], access); + await internalAuditLog.add(access, { action: "updated", object_type: "wireguard-server-links", object_id: req.params.id, @@ -115,7 +120,8 @@ router.post("/:id/links", async (req, res, next) => { router.get("/client", async (_req, res, next) => { try { const knex = db(); - const clients = await internalWireguard.getClients(knex); + const access = res.locals.access; + const clients = await internalWireguard.getClients(knex, access); res.status(200).json(clients); } catch (err) { next(err); @@ -129,8 +135,9 @@ router.get("/client", async (_req, res, next) => { router.post("/client", async (req, res, next) => { try { const knex = db(); - const client = await internalWireguard.createClient(knex, req.body); - await internalAuditLog.add(res.locals.access, { + const access = res.locals.access; + const client = await internalWireguard.createClient(knex, req.body, access); + await internalAuditLog.add(access, { action: "created", object_type: "wireguard-client", object_id: client.id, @@ -149,7 +156,12 @@ router.post("/client", async (req, res, next) => { router.get("/client/:id", async (req, res, next) => { try { const knex = db(); - const client = await knex("wg_client").where("id", req.params.id).first(); + const access = res.locals.access; + const query = knex("wg_client").where("id", req.params.id); + if (!access.token.hasScope("admin")) { + query.andWhere("owner_user_id", access.token.getUserId(1)); + } + const client = await query.first(); if (!client) { return res.status(404).json({ error: { message: "Client not found" } }); } @@ -166,8 +178,9 @@ router.get("/client/:id", async (req, res, next) => { router.put("/client/:id", async (req, res, next) => { try { const knex = db(); - const client = await internalWireguard.updateClient(knex, req.params.id, req.body); - await internalAuditLog.add(res.locals.access, { + const access = res.locals.access; + const client = await internalWireguard.updateClient(knex, req.params.id, req.body, access); + await internalAuditLog.add(access, { action: "updated", object_type: "wireguard-client", object_id: client.id, @@ -186,8 +199,9 @@ router.put("/client/:id", async (req, res, next) => { router.delete("/client/:id", async (req, res, next) => { try { const knex = db(); - const result = await internalWireguard.deleteClient(knex, req.params.id); - await internalAuditLog.add(res.locals.access, { + const access = res.locals.access; + const result = await internalWireguard.deleteClient(knex, req.params.id, access); + await internalAuditLog.add(access, { action: "deleted", object_type: "wireguard-client", object_id: req.params.id, @@ -206,8 +220,9 @@ router.delete("/client/:id", async (req, res, next) => { router.post("/client/:id/enable", async (req, res, next) => { try { const knex = db(); - const client = await internalWireguard.toggleClient(knex, req.params.id, true); - await internalAuditLog.add(res.locals.access, { + const access = res.locals.access; + const client = await internalWireguard.toggleClient(knex, req.params.id, true, access); + await internalAuditLog.add(access, { action: "enabled", object_type: "wireguard-client", object_id: client.id, @@ -226,8 +241,9 @@ router.post("/client/:id/enable", async (req, res, next) => { router.post("/client/:id/disable", async (req, res, next) => { try { const knex = db(); - const client = await internalWireguard.toggleClient(knex, req.params.id, false); - await internalAuditLog.add(res.locals.access, { + const access = res.locals.access; + const client = await internalWireguard.toggleClient(knex, req.params.id, false, access); + await internalAuditLog.add(access, { action: "disabled", object_type: "wireguard-client", object_id: client.id, @@ -246,7 +262,12 @@ router.post("/client/:id/disable", async (req, res, next) => { router.get("/client/:id/configuration", async (req, res, next) => { try { const knex = db(); - const client = await knex("wg_client").where("id", req.params.id).first(); + const access = res.locals.access; + const query = knex("wg_client").where("id", req.params.id); + if (!access.token.hasScope("admin")) { + query.andWhere("owner_user_id", access.token.getUserId(1)); + } + const client = await query.first(); if (!client) { return res.status(404).json({ error: { message: "Client not found" } }); } @@ -267,6 +288,15 @@ router.get("/client/:id/configuration", async (req, res, next) => { router.get("/client/:id/qrcode.svg", async (req, res, next) => { try { const knex = db(); + const access = res.locals.access; + const query = knex("wg_client").where("id", req.params.id); + if (!access.token.hasScope("admin")) { + query.andWhere("owner_user_id", access.token.getUserId(1)); + } + const client = await query.first(); + if (!client) { + return res.status(404).json({ error: { message: "Client not found" } }); + } const svg = await internalWireguard.getClientQRCode(knex, req.params.id); res.set("Content-Type", "image/svg+xml"); res.status(200).send(svg); @@ -282,7 +312,12 @@ router.get("/client/:id/qrcode.svg", async (req, res, next) => { router.get("/client/:id/configuration.zip", async (req, res, next) => { try { const knex = db(); - const client = await knex("wg_client").where("id", req.params.id).first(); + const access = res.locals.access; + const query = knex("wg_client").where("id", req.params.id); + if (!access.token.hasScope("admin")) { + query.andWhere("owner_user_id", access.token.getUserId(1)); + } + const client = await query.first(); if (!client) { return res.status(404).json({ error: { message: "Client not found" } }); } diff --git a/backend/yarn.lock b/backend/yarn.lock index ae65d12..20b7c61 100644 --- a/backend/yarn.lock +++ b/backend/yarn.lock @@ -2860,6 +2860,11 @@ supports-preserve-symlinks-flag@^1.0.0: resolved "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz" integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== +systeminformation@^5.31.3: + version "5.31.3" + resolved "https://registry.npmjs.org/systeminformation/-/systeminformation-5.31.3.tgz" + integrity sha512-vX0eeI7oGIr79NLiJRWnK8SyxDjyiNOEanaQnHRNyb5ep8QcpD8QMDvrukdrxV4pV4AKjwUDfaypXnWHMC/65A== + tar-fs@^2.0.0: version "2.1.4" resolved "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz"