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