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`); + }, +};