From 09df646d64a3bb1863a0bdef133af82d6226c7f4 Mon Sep 17 00:00:00 2001 From: Ian Foster Date: Fri, 23 Jan 2026 20:20:00 -0800 Subject: [PATCH] Add per-client firewall filtering Implement server-side firewall rules to restrict client network access, allowing administrators to enforce security policies that cannot be bypassed by clients modifying their local configuration. This feature addresses the limitation where "Allowed IPs" only controls client-side routing but doesn't prevent clients from accessing networks they shouldn't reach. The firewall rules are enforced on the server using iptables/ip6tables and provide true access control. Features: - Opt-in via "Enable Per-Client Firewall" toggle in admin interface - Per-client "Firewall Allowed IPs" field for granular control - Support for IPs, CIDRs, and port-based filtering - Protocol specification: TCP, UDP, or both (default) - IPv4 and IPv6 dual-stack support - Falls back to client's allowedIps when firewallIps is empty - Clean separation of routing (allowedIps) from security (firewallIps) Supported formats: - 10.10.0.3 (single IP) - 10.10.0.0/24 (CIDR range) - 192.168.1.5:443 (IP with port, both TCP+UDP) - 192.168.1.5:443/tcp (IP with specific protocol) - [2001:db8::1]:443 (IPv6 with port) Implementation: - New database columns: firewall_enabled (interfaces), firewall_ips (clients) - Migration 0003_add_firewall_filtering for schema updates - firewall.ts utility for iptables chain management (WG_CLIENTS chain) - Integration into WireGuard.ts for automatic rule application - UI components with conditional rendering based on firewall toggle Technical details: - Uses custom WG_CLIENTS iptables chain for isolation - Rebuild strategy: flush and recreate all rules on config save - Mutex protection via rebuildInProgress/rebuildQueued flags - Graceful cleanup when firewall is disabled - No new dependencies (uses existing is-ip, is-cidr packages) --- src/app/pages/admin/interface.vue | 19 +- src/app/pages/clients/[id].vue | 6 + src/i18n/locales/en.json | 12 +- src/server/api/information.get.ts | 2 + .../migrations/meta/0003_snapshot.json | 73 +++-- .../database/repositories/client/schema.ts | 2 + .../database/repositories/client/types.ts | 1 + .../database/repositories/interface/schema.ts | 4 + .../database/repositories/interface/types.ts | 1 + src/server/utils/WireGuard.ts | 20 ++ src/server/utils/firewall.ts | 252 ++++++++++++++++++ 11 files changed, 361 insertions(+), 31 deletions(-) create mode 100644 src/server/utils/firewall.ts diff --git a/src/app/pages/admin/interface.vue b/src/app/pages/admin/interface.vue index f14ad60d..91ddbfff 100644 --- a/src/app/pages/admin/interface.vue +++ b/src/app/pages/admin/interface.vue @@ -124,6 +124,15 @@ :description="$t('awg.i5Description')" /> + + {{ $t('admin.interface.firewall') }} + + {{ $t('form.actions') }} @@ -171,7 +180,15 @@ const _submit = useSubmit( { method: 'post', }, - { revert } + { + revert: async (success) => { + await revert(); + if (success) { + // Refresh global store information after successful save + await refreshNuxtData('/api/information'); + } + }, + } ); function submit() { diff --git a/src/app/pages/clients/[id].vue b/src/app/pages/clients/[id].vue index ec561a05..699cbd34 100644 --- a/src/app/pages/clients/[id].vue +++ b/src/app/pages/clients/[id].vue @@ -61,6 +61,12 @@ name="serverAllowedIps" /> + + + {{ $t('client.firewallIps') }} + + + {{ $t('general.dns') }} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index dbed156d..a5611456 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -120,7 +120,9 @@ "endpointDesc": "IP of the client from which the WireGuard connection is established", "search": "Search clients...", "config": "Configuration", - "viewConfig": "View Configuration" + "viewConfig": "View Configuration", + "firewallIps": "Firewall Allowed IPs", + "firewallIpsDesc": "Destination IPs/CIDRs this client can access (server-side enforcement). Leave empty to use Allowed IPs. Supports port filtering: 10.0.0.1:443 or 10.0.0.1:443/tcp" }, "dialog": { "change": "Change", @@ -175,7 +177,10 @@ "restart": "Restart Interface", "restartDesc": "Restart the WireGuard interface", "restartWarn": "Are you sure to restart the interface? This will disconnect all clients.", - "restartSuccess": "Interface restarted" + "restartSuccess": "Interface restarted", + "firewall": "Traffic Filtering", + "firewallEnabled": "Enable Per-Client Firewall", + "firewallEnabledDesc": "Restrict client traffic to specific destination IPs using iptables. When enabled, each client can be configured with allowed destinations." }, "introText": "Welcome to the admin panel.\n\nHere you can manage the general settings, the configuration, the interface settings and the hooks.\n\nStart by choosing one of the sections in the sidebar." }, @@ -196,7 +201,8 @@ "expiresAt": "Expires At", "address4": "IPv4 Address", "address6": "IPv6 Address", - "serverAllowedIps": "Server Allowed IPs" + "serverAllowedIps": "Server Allowed IPs", + "firewallIps": "Firewall Allowed IPs" }, "user": { "username": "Username", diff --git a/src/server/api/information.get.ts b/src/server/api/information.get.ts index af601eb3..ff9e2373 100644 --- a/src/server/api/information.get.ts +++ b/src/server/api/information.get.ts @@ -5,6 +5,7 @@ export default defineEventHandler(async () => { const updateAvailable = gt(latestRelease.version, RELEASE); const insecure = WG_ENV.INSECURE; const isAwg = WG_ENV.WG_EXECUTABLE === 'awg'; + const wgInterface = await Database.interfaces.get(); return { currentRelease: RELEASE, @@ -12,5 +13,6 @@ export default defineEventHandler(async () => { updateAvailable, insecure, isAwg, + firewallEnabled: wgInterface.firewallEnabled, }; }); diff --git a/src/server/database/migrations/meta/0003_snapshot.json b/src/server/database/migrations/meta/0003_snapshot.json index 708657e8..02ec5d67 100644 --- a/src/server/database/migrations/meta/0003_snapshot.json +++ b/src/server/database/migrations/meta/0003_snapshot.json @@ -1,7 +1,7 @@ { "version": "6", "dialect": "sqlite", - "id": "68c43b7a-772d-4c34-8278-e9fce5b53df1", + "id": "ceca9c04-d49e-45a2-a700-9a59816ab73b", "prevId": "e09bc17a-dab6-45a3-a09c-57af222b08fb", "tables": { "clients_table": { @@ -229,6 +229,13 @@ "notNull": true, "autoincrement": false, "default": "(CURRENT_TIMESTAMP)" + }, + "firewall_ips": { + "name": "firewall_ips", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false } }, "indexes": { @@ -543,68 +550,72 @@ "notNull": false, "autoincrement": false }, - "h1": { - "name": "h1", + "i1": { + "name": "i1", "type": "text", "primaryKey": false, "notNull": false, "autoincrement": false }, - "h2": { - "name": "h2", + "i2": { + "name": "i2", "type": "text", "primaryKey": false, "notNull": false, "autoincrement": false }, - "h3": { - "name": "h3", + "i3": { + "name": "i3", "type": "text", "primaryKey": false, "notNull": false, "autoincrement": false }, - "h4": { - "name": "h4", + "i4": { + "name": "i4", "type": "text", "primaryKey": false, "notNull": false, "autoincrement": false }, - "i1": { - "name": "i1", + "i5": { + "name": "i5", "type": "text", "primaryKey": false, "notNull": false, "autoincrement": false }, - "i2": { - "name": "i2", - "type": "text", + "h1": { + "name": "h1", + "type": "integer", "primaryKey": false, "notNull": false, - "autoincrement": false + "autoincrement": false, + "default": 0 }, - "i3": { - "name": "i3", - "type": "text", + "h2": { + "name": "h2", + "type": "integer", "primaryKey": false, "notNull": false, - "autoincrement": false + "autoincrement": false, + "default": 0 }, - "i4": { - "name": "i4", - "type": "text", + "h3": { + "name": "h3", + "type": "integer", "primaryKey": false, "notNull": false, - "autoincrement": false + "autoincrement": false, + "default": 0 }, - "i5": { - "name": "i5", - "type": "text", + "h4": { + "name": "h4", + "type": "integer", "primaryKey": false, "notNull": false, - "autoincrement": false + "autoincrement": false, + "default": 0 }, "enabled": { "name": "enabled", @@ -628,6 +639,14 @@ "notNull": true, "autoincrement": false, "default": "(CURRENT_TIMESTAMP)" + }, + "firewall_enabled": { + "name": "firewall_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 } }, "indexes": { diff --git a/src/server/database/repositories/client/schema.ts b/src/server/database/repositories/client/schema.ts index 64368ef6..f60e0547 100644 --- a/src/server/database/repositories/client/schema.ts +++ b/src/server/database/repositories/client/schema.ts @@ -34,6 +34,8 @@ export const client = sqliteTable('clients_table', { serverAllowedIps: text('server_allowed_ips', { mode: 'json' }) .$type() .notNull(), + // Firewall-enforced allowed IPs (null = use allowedIps) + firewallIps: text('firewall_ips', { mode: 'json' }).$type(), persistentKeepalive: int('persistent_keepalive').notNull(), mtu: int().notNull(), jC: int('j_c'), diff --git a/src/server/database/repositories/client/types.ts b/src/server/database/repositories/client/types.ts index ae2ba852..393ada7c 100644 --- a/src/server/database/repositories/client/types.ts +++ b/src/server/database/repositories/client/types.ts @@ -71,6 +71,7 @@ export const ClientUpdateSchema = schemaForType()( postDown: HookSchema, allowedIps: AllowedIpsSchema.nullable(), serverAllowedIps: serverAllowedIps, + firewallIps: AllowedIpsSchema.nullable(), mtu: MtuSchema, jC: JcSchema, jMin: JminSchema, diff --git a/src/server/database/repositories/interface/schema.ts b/src/server/database/repositories/interface/schema.ts index d465ebf3..bf1bd315 100644 --- a/src/server/database/repositories/interface/schema.ts +++ b/src/server/database/repositories/interface/schema.ts @@ -31,6 +31,10 @@ export const wgInterface = sqliteTable('interfaces_table', { i5: text(), // does nothing yet enabled: int({ mode: 'boolean' }).notNull(), + // Enable per-client firewall filtering via iptables + firewallEnabled: int('firewall_enabled', { mode: 'boolean' }) + .notNull() + .default(false), createdAt: text('created_at') .notNull() .default(sql`(CURRENT_TIMESTAMP)`), diff --git a/src/server/database/repositories/interface/types.ts b/src/server/database/repositories/interface/types.ts index 306f6546..5e46cfc6 100644 --- a/src/server/database/repositories/interface/types.ts +++ b/src/server/database/repositories/interface/types.ts @@ -50,6 +50,7 @@ export const InterfaceUpdateSchema = schemaForType()( port: PortSchema, device: device, enabled: EnabledSchema, + firewallEnabled: EnabledSchema, }) ); diff --git a/src/server/utils/WireGuard.ts b/src/server/utils/WireGuard.ts index 1fc56bfe..7008a3d5 100644 --- a/src/server/utils/WireGuard.ts +++ b/src/server/utils/WireGuard.ts @@ -2,6 +2,7 @@ import fs from 'node:fs/promises'; import debug from 'debug'; import { encodeQR } from 'qr'; import type { InterfaceType } from '#db/repositories/interface/types'; +import { firewall } from './firewall'; const WG_DEBUG = debug('WireGuard'); @@ -16,6 +17,21 @@ class WireGuard { const wgInterface = await Database.interfaces.get(); await this.#saveWireguardConfig(wgInterface); await this.#syncWireguardConfig(wgInterface); + await this.#applyFirewallRules(wgInterface); + } + + /** + * Apply firewall rules based on current config + */ + async #applyFirewallRules(wgInterface: InterfaceType) { + const clients = await Database.clients.getAll(); + const userConfig = await Database.userConfigs.get(); + await firewall.rebuildRules( + wgInterface, + clients, + userConfig, + !WG_ENV.DISABLE_IPV6 + ); } /** @@ -250,6 +266,10 @@ class WireGuard { await this.#syncWireguardConfig(wgInterface); WG_DEBUG(`Wireguard Interface ${wgInterface.name} started successfully.`); + WG_DEBUG('Applying firewall rules...'); + await this.#applyFirewallRules(wgInterface); + WG_DEBUG('Firewall rules applied successfully.'); + WG_DEBUG('Starting Cron Job...'); await this.startCronJob(); WG_DEBUG('Cron Job started successfully.'); diff --git a/src/server/utils/firewall.ts b/src/server/utils/firewall.ts new file mode 100644 index 00000000..707260dd --- /dev/null +++ b/src/server/utils/firewall.ts @@ -0,0 +1,252 @@ +import debug from 'debug'; +import { isIPv6 } from 'is-ip'; + +import { exec } from './cmd'; +import type { ClientType } from '#db/repositories/client/types'; +import type { InterfaceType } from '#db/repositories/interface/types'; +import type { UserConfigType } from '#db/repositories/userConfig/types'; + +const FW_DEBUG = debug('Firewall'); +const CHAIN_NAME = 'WG_CLIENTS'; + +// Mutex to prevent concurrent rule rebuilds +let rebuildInProgress = false; +let rebuildQueued = false; + +type ParsedEntry = { + ip: string; + port?: number; + proto?: 'tcp' | 'udp' | 'both'; +}; + +/** + * Parse a firewall entry string into its components. + * Supports formats: + * - IP: "10.0.0.1" or "2001:db8::1" + * - CIDR: "10.0.0.0/24" or "2001:db8::/32" + * - IP:port: "10.0.0.1:443" or "[2001:db8::1]:443" + * - IP:port/proto: "10.0.0.1:443/tcp" or "10.0.0.1:53/udp" + */ +function parseFirewallEntry(entry: string): ParsedEntry { + // Extract protocol suffix first: /tcp or /udp + let proto: 'tcp' | 'udp' | 'both' | undefined; + let remaining = entry; + + if (entry.endsWith('/tcp')) { + proto = 'tcp'; + remaining = entry.slice(0, -4); + } else if (entry.endsWith('/udp')) { + proto = 'udp'; + remaining = entry.slice(0, -4); + } + + // Handle IPv6 with port: [2001:db8::1]:443 + if (remaining.startsWith('[')) { + const match = remaining.match(/^\[(.+)\]:(\d+)$/); + if (match) { + return { ip: match[1], port: parseInt(match[2], 10), proto: proto ?? 'both' }; + } + // Just bracketed IPv6 without port + const ipMatch = remaining.match(/^\[(.+)\]$/); + if (ipMatch) { + return { ip: ipMatch[1] }; + } + return { ip: remaining }; + } + + // Handle IPv4 with port or CIDR with port + // Count colons to distinguish IPv6 from IPv4:port + const colonCount = (remaining.match(/:/g) || []).length; + + if (colonCount === 1) { + // Could be IPv4:port or CIDR:port + const lastColon = remaining.lastIndexOf(':'); + const possiblePort = remaining.slice(lastColon + 1); + if (/^\d+$/.test(possiblePort)) { + return { + ip: remaining.slice(0, lastColon), + port: parseInt(possiblePort, 10), + proto: proto ?? 'both', + }; + } + } + + // Plain IP or CIDR (IPv4 or IPv6) + return { ip: remaining }; +} + +/** + * Generate iptables rule arguments for a single firewall entry + */ +function generateRuleArgs( + clientIp: string, + entry: ParsedEntry, + action: 'A' | 'D' = 'A' +): string[] { + const rules: string[] = []; + const baseArgs = `-${action} ${CHAIN_NAME} -s ${clientIp} -d ${entry.ip}`; + + if (entry.port) { + // Port-specific rules + if (entry.proto === 'tcp' || entry.proto === 'both') { + rules.push(`${baseArgs} -p tcp --dport ${entry.port} -j ACCEPT`); + } + if (entry.proto === 'udp' || entry.proto === 'both') { + rules.push(`${baseArgs} -p udp --dport ${entry.port} -j ACCEPT`); + } + } else { + // No port - allow all traffic to destination + rules.push(`${baseArgs} -j ACCEPT`); + } + + return rules; +} + +export const firewall = { + /** + * Initialize the custom chain if it doesn't exist + */ + async initChain(interfaceName: string): Promise { + FW_DEBUG(`Initializing firewall chain ${CHAIN_NAME} for interface ${interfaceName}`); + + // Create chain if not exists (iptables returns error if exists, so we ignore) + await exec(`iptables -N ${CHAIN_NAME} 2>/dev/null || true`); + await exec(`ip6tables -N ${CHAIN_NAME} 2>/dev/null || true`); + + // Ensure chain is referenced from FORWARD (if not already) + // Insert at position 1 to process before generic ACCEPT rules + await exec( + `iptables -C FORWARD -i ${interfaceName} -j ${CHAIN_NAME} 2>/dev/null || iptables -I FORWARD 1 -i ${interfaceName} -j ${CHAIN_NAME}` + ); + await exec( + `ip6tables -C FORWARD -i ${interfaceName} -j ${CHAIN_NAME} 2>/dev/null || ip6tables -I FORWARD 1 -i ${interfaceName} -j ${CHAIN_NAME}` + ); + }, + + /** + * Flush all rules in the custom chain + */ + async flushChain(): Promise { + FW_DEBUG(`Flushing firewall chain ${CHAIN_NAME}`); + await exec(`iptables -F ${CHAIN_NAME} 2>/dev/null || true`); + await exec(`ip6tables -F ${CHAIN_NAME} 2>/dev/null || true`); + }, + + /** + * Apply firewall rules for a single client + */ + async applyClientRules( + client: ClientType, + defaultAllowedIps: string[], + enableIpv6: boolean + ): Promise { + // Determine which IPs to use for firewall rules + // Priority: firewallIps > allowedIps > defaultAllowedIps + const effectiveIps = + client.firewallIps && client.firewallIps.length > 0 + ? client.firewallIps + : client.allowedIps ?? defaultAllowedIps; + + FW_DEBUG( + `Applying firewall rules for client ${client.name} (${client.id}): ${effectiveIps.join(', ')}` + ); + + for (const ipEntry of effectiveIps) { + const parsed = parseFirewallEntry(ipEntry); + const destIsIpv6 = isIPv6(parsed.ip.split('/')[0]); // Handle CIDR by checking base IP + + if (destIsIpv6) { + if (enableIpv6) { + const rules = generateRuleArgs(client.ipv6Address, parsed); + for (const rule of rules) { + await exec(`ip6tables ${rule}`); + } + } + } else { + const rules = generateRuleArgs(client.ipv4Address, parsed); + for (const rule of rules) { + await exec(`iptables ${rule}`); + } + } + } + }, + + /** + * Full rebuild of firewall rules from database state + */ + async rebuildRules( + wgInterface: InterfaceType, + clients: ClientType[], + userConfig: UserConfigType, + enableIpv6: boolean + ): Promise { + if (!wgInterface.firewallEnabled) { + FW_DEBUG('Firewall filtering disabled, removing any existing rules'); + await this.removeFiltering(wgInterface.name); + return; + } + + // Handle concurrent rebuilds with queue + if (rebuildInProgress) { + FW_DEBUG('Rebuild already in progress, queuing'); + rebuildQueued = true; + return; + } + + rebuildInProgress = true; + + try { + FW_DEBUG('Rebuilding firewall rules...'); + + // Initialize chain structure + await this.initChain(wgInterface.name); + + // Flush existing rules + await this.flushChain(); + + // Apply rules for each enabled client + for (const client of clients) { + if (!client.enabled) continue; + await this.applyClientRules(client, userConfig.defaultAllowedIps, enableIpv6); + } + + // Add final DROP for any traffic not explicitly allowed + await exec(`iptables -A ${CHAIN_NAME} -j DROP`); + if (enableIpv6) { + await exec(`ip6tables -A ${CHAIN_NAME} -j DROP`); + } + + FW_DEBUG('Firewall rules rebuilt successfully'); + } finally { + rebuildInProgress = false; + + // If another rebuild was queued, run it now + if (rebuildQueued) { + rebuildQueued = false; + FW_DEBUG('Processing queued rebuild'); + await this.rebuildRules(wgInterface, clients, userConfig, enableIpv6); + } + } + }, + + /** + * Remove all firewall filtering (when feature is disabled) + */ + async removeFiltering(interfaceName: string): Promise { + FW_DEBUG(`Removing firewall filtering for interface ${interfaceName}`); + + // Remove jump rules from FORWARD chain + await exec( + `iptables -D FORWARD -i ${interfaceName} -j ${CHAIN_NAME} 2>/dev/null || true` + ); + await exec( + `ip6tables -D FORWARD -i ${interfaceName} -j ${CHAIN_NAME} 2>/dev/null || true` + ); + + // Flush and delete the chain + await exec(`iptables -F ${CHAIN_NAME} 2>/dev/null || true`); + await exec(`ip6tables -F ${CHAIN_NAME} 2>/dev/null || true`); + await exec(`iptables -X ${CHAIN_NAME} 2>/dev/null || true`); + await exec(`ip6tables -X ${CHAIN_NAME} 2>/dev/null || true`); + }, +};