Feature/client firewall filtering (#2418)

* 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)

* added Comprehensive documentation in README and docs/ for firewall
filtering

* validate firewall IPs

* check for iptables before enabling the firewall and inform the user if
it is missing

* updated firewall docs

* fix imports

* remove extra import

* Document all allowed IP/cidr/port/proto combinations that are allowed
and check on save

* add note on firewall being experimental and how to opt a single client
out of the firewall.

* cleanup more imports

* add tests

* Fix firewall IPv6 validation and test expectations

Updated validation to correctly handle plain and bracketed IPv6 addresses, and fixed test to expect string from schema instead of object.

* added comments to firewall rules and updated tests

* fix auto-import

* fix typescript errors

* recreate sql migrations and rebase

* improve tests, typechecking, documentation

* fix formatting, fix types

* improve type

* added note for including host's IP in client firewall

* updated language to include cidr and protocol options

* another language update

* refer to docs for firewall allowed IPs

---------

Co-authored-by: Bernd Storath <999999bst@gmail.com>
This commit is contained in:
Ian Foster
2026-03-04 23:47:46 -08:00
committed by GitHub
parent e5b2c3d10b
commit 47f81dd66a
22 changed files with 1940 additions and 9 deletions
+33
View File
@@ -16,6 +16,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 +265,24 @@ class WireGuard {
await this.#syncWireguardConfig(wgInterface);
WG_DEBUG(`Wireguard Interface ${wgInterface.name} started successfully.`);
// Check if firewall was enabled but iptables isn't available
if (wgInterface.firewallEnabled) {
const enableIpv6 = !WG_ENV.DISABLE_IPV6;
const iptablesAvailable = await firewall.isAvailable(enableIpv6);
if (!iptablesAvailable) {
const requiredTools = enableIpv6 ? 'iptables/ip6tables' : 'iptables';
console.warn(
`WARNING: Per-Client Firewall is enabled but ${requiredTools} is not available. Disabling firewall feature. Please install ${requiredTools} to use this feature.`
);
await Database.interfaces.setFirewallEnabled(false);
wgInterface.firewallEnabled = false; // Update local copy
}
}
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.');
+363
View File
@@ -0,0 +1,363 @@
import debug from 'debug';
import { isIPv6 } from 'is-ip';
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;
// Cache iptables availability check result
let iptablesAvailable: boolean | null = null;
type ParsedEntry = {
ip: string;
port?: number;
proto?: 'tcp' | 'udp' | 'both';
};
type FirewallClient = Pick<
ClientType,
| 'id'
| 'name'
| 'ipv4Address'
| 'ipv6Address'
| 'allowedIps'
| 'firewallIps'
| 'enabled'
>;
/**
* Sanitize a client identifier for use in an iptables comment.
* Strips all characters except ASCII alphanumeric, space, underscore, hyphen, and dot.
* Combines with client ID for a safe, identifiable comment.
* Truncates to 256 bytes (iptables comment module limit).
*/
function sanitizeComment(clientId: number, clientName: string): string {
const safe = clientName.replace(/[^a-zA-Z0-9 _.-]/g, '');
const comment = `client ${clientId}: ${safe}`;
return comment.slice(0, 256);
}
/**
* 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"
* - CIDR:port: "10.0.0.0/24:443"
* - CIDR:port/proto: "10.0.0.0/24:443/tcp" or "10.0.0.0/24:53/udp"
*
* Note: Protocol (/tcp or /udp) requires a port. "IP/tcp" or "CIDR/tcp" without
* a port is invalid and will throw an error.
*
* @throws {Error} If protocol is specified without a port
*/
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 && match[1] && match[2]) {
return {
ip: match[1],
port: parseInt(match[2], 10),
proto: proto ?? 'both',
};
}
// Just bracketed IPv6 without port
const ipMatch = remaining.match(/^\[(.+)\]$/);
if (ipMatch && ipMatch[1]) {
if (proto) {
throw new Error(
`Invalid firewall entry "${entry}": Protocol (/${proto}) requires a port. Use format like "[${ipMatch[1]}]:443/${proto}"`
);
}
return { ip: ipMatch[1] };
}
if (proto) {
throw new Error(
`Invalid firewall entry "${entry}": Protocol (/${proto}) requires a port`
);
}
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)
if (proto) {
throw new Error(
`Invalid firewall entry "${entry}": Protocol (/${proto}) requires a port. Use format like "${remaining}:443/${proto}"`
);
}
return { ip: remaining };
}
/**
* Generate iptables rule arguments for a single firewall entry
*/
function generateRuleArgs(
clientIp: string,
entry: ParsedEntry,
comment?: string,
action: 'A' | 'D' = 'A'
): string[] {
const rules: string[] = [];
const commentArg = comment ? ` -m comment --comment "${comment}"` : '';
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}${commentArg} -j ACCEPT`
);
}
if (entry.proto === 'udp' || entry.proto === 'both') {
rules.push(
`${baseArgs} -p udp --dport ${entry.port}${commentArg} -j ACCEPT`
);
}
} else {
// No port - allow all traffic to destination
rules.push(`${baseArgs}${commentArg} -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: FirewallClient,
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(', ')}`
);
const comment = sanitizeComment(client.id, client.name);
for (const ipEntry of effectiveIps) {
const parsed = parseFirewallEntry(ipEntry);
const baseIp = parsed.ip.split('/')[0] ?? parsed.ip; // Handle CIDR by checking base IP
const destIsIpv6 = isIPv6(baseIp);
if (destIsIpv6) {
if (enableIpv6) {
const rules = generateRuleArgs(client.ipv6Address, parsed, comment);
for (const rule of rules) {
await exec(`ip6tables ${rule}`);
}
}
} else {
const rules = generateRuleArgs(client.ipv4Address, parsed, comment);
for (const rule of rules) {
await exec(`iptables ${rule}`);
}
}
}
},
/**
* Full rebuild of firewall rules from database state
*/
async rebuildRules(
wgInterface: InterfaceType,
clients: FirewallClient[],
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`);
},
/**
* Check if iptables (and optionally ip6tables) are available on the system.
* @param enableIpv6 - If true, also check for ip6tables. Defaults to true.
*/
async isAvailable(enableIpv6: boolean = true): Promise<boolean> {
// Return cached result if we've already checked
if (iptablesAvailable !== null) {
return iptablesAvailable;
}
try {
// Check for iptables (always required)
await exec('iptables --version');
FW_DEBUG('iptables is available');
// Check for ip6tables (only if IPv6 is enabled)
if (enableIpv6) {
await exec('ip6tables --version');
FW_DEBUG('ip6tables is available');
} else {
FW_DEBUG('IPv6 disabled, skipping ip6tables check');
}
iptablesAvailable = true;
return true;
} catch (error) {
iptablesAvailable = false;
FW_DEBUG('iptables/ip6tables is not available:', error);
return false;
}
},
/**
* Clear the availability cache to force a re-check
*/
clearAvailabilityCache(): void {
iptablesAvailable = null;
},
};
export const firewallTestExports = {
parseFirewallEntry,
generateRuleArgs,
sanitizeComment,
};
+63
View File
@@ -1,6 +1,8 @@
import type { ZodSchema } from 'zod';
import z from 'zod';
import type { H3Event, EventHandlerRequest } from 'h3';
import { isIP } from 'is-ip';
import isCidr from 'is-cidr';
export type ID = number;
@@ -91,6 +93,65 @@ export const AllowedIpsSchema = z
.array(AddressSchema, { message: t('zod.allowedIps') })
.min(1, { message: t('zod.allowedIps') });
// Validation for firewall IP entries
const FirewallIpEntrySchema = z
.string({ message: t('zod.client.firewallIps') })
.min(1, { message: t('zod.client.firewallIps') })
.refine(
(entry) => {
// Check if protocol suffix is present
const hasProto = /\/(tcp|udp)$/i.test(entry);
const entryWithoutProto = entry.replace(/\/(tcp|udp)$/i, '');
// If protocol was specified without a port, it's invalid
if (hasProto) {
// Protocol requires port, so check for IP:port format
const portMatch = entryWithoutProto.match(/^(.+):(\d+)$/);
if (!portMatch) {
return false;
}
const [, ipPart, portPart] = portMatch;
const port = parseInt(portPart!, 10);
const cleanIp = ipPart!.replace(/^\[|\]$/g, '');
return (isIP(cleanIp) || isCidr(cleanIp)) && port >= 1 && port <= 65535;
}
// Check if it's just IP or CIDR first (handles IPv6 addresses)
if (isIP(entryWithoutProto) || isCidr(entryWithoutProto)) {
return true;
}
// Check if it's bracketed IPv6 without port: [::1]
const bracketedMatch = entryWithoutProto.match(/^\[(.+)\]$/);
if (bracketedMatch) {
const innerIp = bracketedMatch[1];
return isIP(innerIp!) || isCidr(innerIp!);
}
// Check if it's IP:port format (IPv4:port or [IPv6]:port)
const portMatch = entryWithoutProto.match(/^(.+):(\d+)$/);
if (portMatch) {
const [, ipPart, portPart] = portMatch;
const port = parseInt(portPart!, 10);
// Remove IPv6 brackets if present
const cleanIp = ipPart!.replace(/^\[|\]$/g, '');
// Validate IP and port
return (isIP(cleanIp) || isCidr(cleanIp)) && port >= 1 && port <= 65535;
}
return false;
},
{
message: t('zod.client.firewallIpsInvalid'),
}
);
export const FirewallIpsSchema = z.array(FirewallIpEntrySchema, {
message: t('zod.client.firewallIps'),
});
export const FileSchema = z.object({
file: z.string({ message: t('zod.file') }),
});
@@ -197,3 +258,5 @@ export function validateZod<T>(
export function assertUnreachable(_: never): never {
throw new Error("Didn't expect to get here");
}
export const typesTestExports = { FirewallIpEntrySchema };