Browse Source

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)
pull/2418/head
Ian Foster 6 months ago
parent
commit
09df646d64
  1. 19
      src/app/pages/admin/interface.vue
  2. 6
      src/app/pages/clients/[id].vue
  3. 12
      src/i18n/locales/en.json
  4. 2
      src/server/api/information.get.ts
  5. 73
      src/server/database/migrations/meta/0003_snapshot.json
  6. 2
      src/server/database/repositories/client/schema.ts
  7. 1
      src/server/database/repositories/client/types.ts
  8. 4
      src/server/database/repositories/interface/schema.ts
  9. 1
      src/server/database/repositories/interface/types.ts
  10. 20
      src/server/utils/WireGuard.ts
  11. 252
      src/server/utils/firewall.ts

19
src/app/pages/admin/interface.vue

@ -124,6 +124,15 @@
:description="$t('awg.i5Description')"
/>
</FormGroup>
<FormGroup>
<FormHeading>{{ $t('admin.interface.firewall') }}</FormHeading>
<FormSwitchField
id="firewallEnabled"
v-model="data.firewallEnabled"
:label="$t('admin.interface.firewallEnabled')"
:description="$t('admin.interface.firewallEnabledDesc')"
/>
</FormGroup>
<FormGroup>
<FormHeading>{{ $t('form.actions') }}</FormHeading>
<FormPrimaryActionField type="submit" :label="$t('form.save')" />
@ -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() {

6
src/app/pages/clients/[id].vue

@ -61,6 +61,12 @@
name="serverAllowedIps"
/>
</FormGroup>
<FormGroup v-if="globalStore.information?.firewallEnabled">
<FormHeading :description="$t('client.firewallIpsDesc')">
{{ $t('client.firewallIps') }}
</FormHeading>
<FormNullArrayField v-model="data.firewallIps" name="firewallIps" />
</FormGroup>
<FormGroup>
<FormHeading :description="$t('client.dnsDesc')">
{{ $t('general.dns') }}

12
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",

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

73
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": {

2
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<string[]>()
.notNull(),
// Firewall-enforced allowed IPs (null = use allowedIps)
firewallIps: text('firewall_ips', { mode: 'json' }).$type<string[] | null>(),
persistentKeepalive: int('persistent_keepalive').notNull(),
mtu: int().notNull(),
jC: int('j_c'),

1
src/server/database/repositories/client/types.ts

@ -71,6 +71,7 @@ export const ClientUpdateSchema = schemaForType<UpdateClientType>()(
postDown: HookSchema,
allowedIps: AllowedIpsSchema.nullable(),
serverAllowedIps: serverAllowedIps,
firewallIps: AllowedIpsSchema.nullable(),
mtu: MtuSchema,
jC: JcSchema,
jMin: JminSchema,

4
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)`),

1
src/server/database/repositories/interface/types.ts

@ -50,6 +50,7 @@ export const InterfaceUpdateSchema = schemaForType<InterfaceUpdateType>()(
port: PortSchema,
device: device,
enabled: EnabledSchema,
firewallEnabled: EnabledSchema,
})
);

20
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.');

252
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<void> {
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<void> {
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<void> {
// 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<void> {
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<void> {
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`);
},
};
Loading…
Cancel
Save