feat: make the WireGuard interface name configurable (#2826)

* add WG_INTERFACE env var

* Scope firewall chains per interface
This commit is contained in:
Carlo Alberto Aprigliano 2026-09-24 12:09:44 +02:00 • committed by GitHub
parent eb748ed8fd
commit 092fc0a197
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1381 additions and 69 deletions

View file

@ -12,6 +12,7 @@ You can set these environment variables to configure the container. They are not
| `DISABLE_IPV6` | `false` | `true` | If IPv6 support should be disabled |
| `DISABLE_VERSION_CHECK` | `false` | `true` | If wg-easy should check for new updates |
| `TRUSTED_PROXIES` | | `172.18.0.2,fd00:1234::/64` | Proxy IP addresses or CIDRs allowed to forward request metadata |
| `WG_INTERFACE` | `wg0` | `wg1` | Name of the WireGuard interface |
## Trusted Proxies
@ -32,6 +33,15 @@ request protocol remains controlled by `INSECURE`. Invalid addresses prevent
wg-easy from starting so that configuration errors are not silently ignored.
Restart the container after changing this setting.
## WireGuard Interface
Set `WG_INTERFACE` to specify a custom interface name, useful if you are running multiple wg-easy instances on the same host with `network_mode: host`:
```yaml
environment:
- WG_INTERFACE=wg1
```
/// note | IPv6 Caveats
Disabling IPv6 will disable the creation of the default IPv6 firewall rules and won't add a IPv6 address to the interface and clients.

View file

@ -127,13 +127,13 @@ networks:
**_PostUp_**
```shell
iptables -A INPUT -p udp -m udp --dport {{port}} -j ACCEPT; ip6tables -A INPUT -p udp -m udp --dport {{port}} -j ACCEPT; iptables -t nat -A PREROUTING -i wg0 -p udp --dport 53 -j DNAT --to-destination 10.42.42.43; iptables -t nat -A PREROUTING -i wg0 -p tcp --dport 53 -j DNAT --to-destination 10.42.42.43; ip6tables -t nat -A PREROUTING -i wg0 -p udp --dport 53 -j DNAT --to-destination fdcc:ad94:bacf:61a3::2b; ip6tables -t nat -A PREROUTING -i wg0 -p tcp --dport 53 -j DNAT --to-destination fdcc:ad94:bacf:61a3::2b; iptables -A FORWARD -i wg0 -j ACCEPT; iptables -A FORWARD -o wg0 -j ACCEPT; ip6tables -A FORWARD -i wg0 -j ACCEPT; ip6tables -A FORWARD -o wg0 -j ACCEPT; iptables -t nat -A POSTROUTING -s {{ipv4Cidr}} -o {{device}} -j MASQUERADE; ip6tables -t nat -A POSTROUTING -s {{ipv6Cidr}} -o {{device}} -j MASQUERADE;
iptables -A INPUT -p udp -m udp --dport {{port}} -j ACCEPT; ip6tables -A INPUT -p udp -m udp --dport {{port}} -j ACCEPT; iptables -t nat -A PREROUTING -i {{interface}} -p udp --dport 53 -j DNAT --to-destination 10.42.42.43; iptables -t nat -A PREROUTING -i {{interface}} -p tcp --dport 53 -j DNAT --to-destination 10.42.42.43; ip6tables -t nat -A PREROUTING -i {{interface}} -p udp --dport 53 -j DNAT --to-destination fdcc:ad94:bacf:61a3::2b; ip6tables -t nat -A PREROUTING -i {{interface}} -p tcp --dport 53 -j DNAT --to-destination fdcc:ad94:bacf:61a3::2b; iptables -A FORWARD -i {{interface}} -j ACCEPT; iptables -A FORWARD -o {{interface}} -j ACCEPT; ip6tables -A FORWARD -i {{interface}} -j ACCEPT; ip6tables -A FORWARD -o {{interface}} -j ACCEPT; iptables -t nat -A POSTROUTING -s {{ipv4Cidr}} -o {{device}} -j MASQUERADE; ip6tables -t nat -A POSTROUTING -s {{ipv6Cidr}} -o {{device}} -j MASQUERADE;
```
**_PostDown_**
```shell
iptables -D INPUT -p udp -m udp --dport {{port}} -j ACCEPT || true; ip6tables -D INPUT -p udp -m udp --dport {{port}} -j ACCEPT || true; iptables -t nat -D PREROUTING -i wg0 -p udp --dport 53 -j DNAT --to-destination 10.42.42.43 || true; iptables -t nat -D PREROUTING -i wg0 -p tcp --dport 53 -j DNAT --to-destination 10.42.42.43 || true; ip6tables -t nat -D PREROUTING -i wg0 -p udp --dport 53 -j DNAT --to-destination fdcc:ad94:bacf:61a3::2b || true; ip6tables -t nat -D PREROUTING -i wg0 -p tcp --dport 53 -j DNAT --to-destination fdcc:ad94:bacf:61a3::2b || true; iptables -D FORWARD -i wg0 -j ACCEPT || true; iptables -D FORWARD -o wg0 -j ACCEPT || true; ip6tables -D FORWARD -i wg0 -j ACCEPT || true; ip6tables -D FORWARD -o wg0 -j ACCEPT || true; iptables -t nat -D POSTROUTING -s {{ipv4Cidr}} -o {{device}} -j MASQUERADE || true; ip6tables -t nat -D POSTROUTING -s {{ipv6Cidr}} -o {{device}} -j MASQUERADE || true;
iptables -D INPUT -p udp -m udp --dport {{port}} -j ACCEPT || true; ip6tables -D INPUT -p udp -m udp --dport {{port}} -j ACCEPT || true; iptables -t nat -D PREROUTING -i {{interface}} -p udp --dport 53 -j DNAT --to-destination 10.42.42.43 || true; iptables -t nat -D PREROUTING -i {{interface}} -p tcp --dport 53 -j DNAT --to-destination 10.42.42.43 || true; ip6tables -t nat -D PREROUTING -i {{interface}} -p udp --dport 53 -j DNAT --to-destination fdcc:ad94:bacf:61a3::2b || true; ip6tables -t nat -D PREROUTING -i {{interface}} -p tcp --dport 53 -j DNAT --to-destination fdcc:ad94:bacf:61a3::2b || true; iptables -D FORWARD -i {{interface}} -j ACCEPT || true; iptables -D FORWARD -o {{interface}} -j ACCEPT || true; ip6tables -D FORWARD -i {{interface}} -j ACCEPT || true; ip6tables -D FORWARD -o {{interface}} -j ACCEPT || true; iptables -t nat -D POSTROUTING -s {{ipv4Cidr}} -o {{device}} -j MASQUERADE || true; ip6tables -t nat -D POSTROUTING -s {{ipv6Cidr}} -o {{device}} -j MASQUERADE || true;
```
3. Restart `wg-easy` to apply changes:

View file

@ -88,13 +88,13 @@ In the Admin Panel of your WireGuard server, go to the `Hooks` tab and add the f
1. PostUp
```shell
nft add table inet wg_table; nft add chain inet wg_table prerouting { type nat hook prerouting priority 100 \; }; nft add chain inet wg_table postrouting { type nat hook postrouting priority 100 \; }; nft add rule inet wg_table postrouting ip saddr {{ipv4Cidr}} oifname {{device}} masquerade; nft add rule inet wg_table postrouting ip6 saddr {{ipv6Cidr}} oifname {{device}} masquerade; nft add chain inet wg_table input { type filter hook input priority 0 \; policy accept \; }; nft add rule inet wg_table input udp dport {{port}} accept; nft add rule inet wg_table input tcp dport {{uiPort}} accept; nft add chain inet wg_table forward { type filter hook forward priority 0 \; policy accept \; }; nft add rule inet wg_table forward iifname "wg0" accept; nft add rule inet wg_table forward oifname "wg0" accept;
nft add table inet wg_table_{{interface}}; nft add chain inet wg_table_{{interface}} prerouting { type nat hook prerouting priority 100 \; }; nft add chain inet wg_table_{{interface}} postrouting { type nat hook postrouting priority 100 \; }; nft add rule inet wg_table_{{interface}} postrouting ip saddr {{ipv4Cidr}} oifname {{device}} masquerade; nft add rule inet wg_table_{{interface}} postrouting ip6 saddr {{ipv6Cidr}} oifname {{device}} masquerade; nft add chain inet wg_table_{{interface}} input { type filter hook input priority 0 \; policy accept \; }; nft add rule inet wg_table_{{interface}} input udp dport {{port}} accept; nft add rule inet wg_table_{{interface}} input tcp dport {{uiPort}} accept; nft add chain inet wg_table_{{interface}} forward { type filter hook forward priority 0 \; policy accept \; }; nft add rule inet wg_table_{{interface}} forward iifname "{{interface}}" accept; nft add rule inet wg_table_{{interface}} forward oifname "{{interface}}" accept;
```
2. PostDown
```shell
nft delete table inet wg_table
nft delete table inet wg_table_{{interface}}
```
If you don't have iptables loaded on your server, you could see many errors in the logs or in the UI. You can ignore them.

View file

@ -86,13 +86,13 @@ Then go to Admin → Hooks and add:
PostUp
```shell
iptables -A INPUT -p udp -m udp --dport {{port}} -j ACCEPT; iptables -A FORWARD -i wg0 -j ACCEPT; iptables -A FORWARD -o wg0 -j ACCEPT; ip6tables -A INPUT -p udp -m udp --dport {{port}} -j ACCEPT; ip6tables -A FORWARD -i wg0 -j ACCEPT; ip6tables -A FORWARD -o wg0 -j ACCEPT
iptables -A INPUT -p udp -m udp --dport {{port}} -j ACCEPT; iptables -A FORWARD -i {{interface}} -j ACCEPT; iptables -A FORWARD -o {{interface}} -j ACCEPT; ip6tables -A INPUT -p udp -m udp --dport {{port}} -j ACCEPT; ip6tables -A FORWARD -i {{interface}} -j ACCEPT; ip6tables -A FORWARD -o {{interface}} -j ACCEPT
```
PostDown
```shell
iptables -D INPUT -p udp -m udp --dport {{port}} -j ACCEPT; iptables -D FORWARD -i wg0 -j ACCEPT; iptables -D FORWARD -o wg0 -j ACCEPT; ip6tables -D INPUT -p udp -m udp --dport {{port}} -j ACCEPT; ip6tables -D FORWARD -i wg0 -j ACCEPT; ip6tables -D FORWARD -o wg0 -j ACCEPT
iptables -D INPUT -p udp -m udp --dport {{port}} -j ACCEPT; iptables -D FORWARD -i {{interface}} -j ACCEPT; iptables -D FORWARD -o {{interface}} -j ACCEPT; ip6tables -D INPUT -p udp -m udp --dport {{port}} -j ACCEPT; ip6tables -D FORWARD -i {{interface}} -j ACCEPT; ip6tables -D FORWARD -o {{interface}} -j ACCEPT
```
/// warning | Important: When using nftables use the following hooks instead.
@ -100,13 +100,13 @@ iptables -D INPUT -p udp -m udp --dport {{port}} -j ACCEPT; iptables -D FORWARD
PostUp
```shell
nft add chain ip filter WG_EASY; nft add rule ip filter DOCKER-USER jump WG_EASY; nft add rule ip filter WG_EASY iifname {{device}} accept; nft add rule ip filter WG_EASY oifname {{device}} accept; nft add chain ip6 filter WG_EASY; nft add rule ip6 filter DOCKER-USER jump WG_EASY; nft add rule ip6 filter WG_EASY iifname {{device}} accept; nft add rule ip6 filter WG_EASY oifname {{device}} accept;
nft add chain ip filter WG_EASY_{{interface}}; nft add rule ip filter DOCKER-USER jump WG_EASY_{{interface}}; nft add rule ip filter WG_EASY_{{interface}} iifname {{device}} accept; nft add rule ip filter WG_EASY_{{interface}} oifname {{device}} accept; nft add chain ip6 filter WG_EASY_{{interface}}; nft add rule ip6 filter DOCKER-USER jump WG_EASY_{{interface}}; nft add rule ip6 filter WG_EASY_{{interface}} iifname {{device}} accept; nft add rule ip6 filter WG_EASY_{{interface}} oifname {{device}} accept;
```
PostDown
```shell
nft delete rule ip filter DOCKER-USER handle $(nft -a list chain ip filter DOCKER-USER | awk '/jump WG_EASY/ {print $NF}'); nft flush chain ip filter WG_EASY; nft delete chain ip filter WG_EASY; nft delete rule ip6 filter DOCKER-USER handle $(nft -a list chain ip6 filter DOCKER-USER | awk '/jump WG_EASY/ {print $NF}'); nft flush chain ip6 filter WG_EASY; nft delete chain ip6 filter WG_EASY
nft delete rule ip filter DOCKER-USER handle $(nft -a list chain ip filter DOCKER-USER | awk '/jump WG_EASY_{{interface}}/ {print $NF}'); nft flush chain ip filter WG_EASY_{{interface}}; nft delete chain ip filter WG_EASY_{{interface}}; nft delete rule ip6 filter DOCKER-USER handle $(nft -a list chain ip6 filter DOCKER-USER | awk '/jump WG_EASY_{{interface}}/ {print $NF}'); nft flush chain ip6 filter WG_EASY_{{interface}}; nft delete chain ip6 filter WG_EASY_{{interface}}
```
///

View file

@ -3,6 +3,7 @@ import { consola } from 'consola';
import { eq } from 'drizzle-orm';
import { db, schema } from '#cli/db';
import { WG_ENV } from '#server/utils/config';
import { wg } from '#server/utils/wgHelper';
import { encodeQRCodeTerm } from '#server/utils/qr';
@ -34,7 +35,7 @@ export default defineCommand({
consola.info('Generating QR code for client...');
const wgInterface = await db.query.wgInterface.findFirst({
where: eq(schema.wgInterface.name, 'wg0'),
where: eq(schema.wgInterface.name, WG_ENV.WG_INTERFACE),
});
if (!wgInterface) {
consola.error('WireGuard interface not found');
@ -42,7 +43,7 @@ export default defineCommand({
}
const userConfig = await db.query.userConfig.findFirst({
where: eq(schema.userConfig.id, 'wg0'),
where: eq(schema.userConfig.id, wgInterface.name),
});
if (!userConfig) {
consola.error('User config not found');

View file

@ -1,7 +1,9 @@
import Database from '#server/utils/Database';
import { definePermissionEventHandler } from '#server/utils/handler';
import { cachedGetIpInformation } from '#server/utils/ip';
import { getIpInformation } from '#server/utils/ip';
export default definePermissionEventHandler('admin', 'any', async () => {
const result = await cachedGetIpInformation();
const wgInterface = await Database.interfaces.get();
const result = await getIpInformation(wgInterface.name);
return result;
});

View file

@ -1,7 +1,9 @@
import Database from '#server/utils/Database';
import { defineSetupEventHandler } from '#server/utils/handler';
import { cachedGetIpInformation } from '#server/utils/ip';
import { getIpInformation } from '#server/utils/ip';
export default defineSetupEventHandler(4, async () => {
const result = await cachedGetIpInformation();
const wgInterface = await Database.interfaces.get();
const result = await getIpInformation(wgInterface.name);
return result;
});

View file

@ -0,0 +1,5 @@
UPDATE `hooks_table` SET
`pre_up` = replace(`pre_up`, 'wg0', '{{interface}}'),
`post_up` = replace(`post_up`, 'wg0', '{{interface}}'),
`pre_down` = replace(`pre_down`, 'wg0', '{{interface}}'),
`post_down` = replace(`post_down`, 'wg0', '{{interface}}');

File diff suppressed because it is too large Load diff

View file

@ -64,6 +64,13 @@
"when": 1788760134302,
"tag": "0008_thin_patch",
"breakpoints": true
},
{
"idx": 9,
"version": "6",
"when": 1790082534310,
"tag": "0009_dusty_prodigy",
"breakpoints": true
}
]
}

View file

@ -9,6 +9,7 @@ import type {
UpdateClientType,
} from './types';
import { WG_ENV } from '#server/utils/config';
import Database from '#server/utils/Database';
import { nextIP } from '#server/utils/ip';
import type { ID } from '#server/utils/types';
@ -162,7 +163,7 @@ export class ClientService {
const clients = await tx.query.client.findMany().execute();
const clientInterface = await tx.query.wgInterface
.findFirst({
where: eq(wgInterface.name, 'wg0'),
where: eq(wgInterface.name, WG_ENV.WG_INTERFACE),
})
.execute();
@ -191,7 +192,7 @@ export class ClientService {
name,
// TODO: properly assign user id
userId: 1,
interfaceId: 'wg0',
interfaceId: WG_ENV.WG_INTERFACE,
expiresAt,
privateKey,
publicKey,
@ -228,7 +229,7 @@ export class ClientService {
return this.#db.transaction(async (tx) => {
const clientInterface = await tx.query.wgInterface
.findFirst({
where: eq(wgInterface.name, 'wg0'),
where: eq(wgInterface.name, WG_ENV.WG_INTERFACE),
})
.execute();
@ -264,7 +265,7 @@ export class ClientService {
.values({
name,
userId: 1,
interfaceId: 'wg0',
interfaceId: WG_ENV.WG_INTERFACE,
privateKey,
publicKey,
preSharedKey,

View file

@ -3,6 +3,7 @@ import { eq, sql } from 'drizzle-orm';
import { hooks } from './schema';
import type { HooksUpdateType } from './types';
import { WG_ENV } from '#server/utils/config';
import type { DBType } from '#db/sqlite';
function createPreparedStatement(db: DBType) {
@ -23,7 +24,9 @@ export class HooksService {
}
async get() {
const hooks = await this.#statements.get.execute({ interface: 'wg0' });
const hooks = await this.#statements.get.execute({
interface: WG_ENV.WG_INTERFACE,
});
if (!hooks) {
throw new Error('Hooks not found');
}
@ -34,7 +37,7 @@ export class HooksService {
return this.#db
.update(hooks)
.set(data)
.where(eq(hooks.id, 'wg0'))
.where(eq(hooks.id, WG_ENV.WG_INTERFACE))
.execute();
}
}

View file

@ -4,6 +4,7 @@ import { parseCidr } from 'cidr-tools';
import { wgInterface } from './schema';
import type { InterfaceCidrUpdateType, InterfaceUpdateType } from './types';
import { WG_ENV } from '#server/utils/config';
import { nextIPFromUsedAddresses } from '#server/utils/ip';
import { client as clientSchema } from '#db/schema';
import type { DBType } from '#db/sqlite';
@ -42,7 +43,7 @@ export class InterfaceService {
async get() {
const wgInterface = await this.#statements.get.execute({
interface: 'wg0',
interface: WG_ENV.WG_INTERFACE,
});
if (!wgInterface) {
throw new Error('Interface not found');
@ -52,7 +53,7 @@ export class InterfaceService {
updateKeyPair(privateKey: string, publicKey: string) {
return this.#statements.updateKeyPair.execute({
interface: 'wg0',
interface: WG_ENV.WG_INTERFACE,
privateKey,
publicKey,
});
@ -62,13 +63,13 @@ export class InterfaceService {
return this.#db
.update(wgInterface)
.set(data)
.where(eq(wgInterface.name, 'wg0'))
.where(eq(wgInterface.name, WG_ENV.WG_INTERFACE))
.execute();
}
setFirewallEnabled(firewallEnabled: boolean) {
return this.#statements.setFirewallEnabled.execute({
interface: 'wg0',
interface: WG_ENV.WG_INTERFACE,
firewallEnabled,
});
}
@ -77,7 +78,7 @@ export class InterfaceService {
return this.#db.transaction(async (tx) => {
const oldCidr = await tx.query.wgInterface
.findFirst({
where: eq(wgInterface.name, 'wg0'),
where: eq(wgInterface.name, WG_ENV.WG_INTERFACE),
columns: { ipv4Cidr: true, ipv6Cidr: true },
})
.execute();
@ -89,7 +90,7 @@ export class InterfaceService {
await tx
.update(wgInterface)
.set(data)
.where(eq(wgInterface.name, 'wg0'))
.where(eq(wgInterface.name, WG_ENV.WG_INTERFACE))
.execute();
const clients = await tx.query.client.findMany().execute();

View file

@ -3,6 +3,7 @@ import { eq, sql } from 'drizzle-orm';
import { userConfig } from './schema';
import type { UserConfigUpdateType } from './types';
import { WG_ENV } from '#server/utils/config';
import { wgInterface } from '#db/schema';
import type { DBType } from '#db/sqlite';
@ -24,7 +25,9 @@ export class UserConfigService {
}
async get() {
const userConfig = await this.#statements.get.execute({ interface: 'wg0' });
const userConfig = await this.#statements.get.execute({
interface: WG_ENV.WG_INTERFACE,
});
if (!userConfig) {
throw new Error('User config not found');
@ -45,13 +48,13 @@ export class UserConfigService {
await tx
.update(userConfig)
.set({ host, port })
.where(eq(userConfig.id, 'wg0'))
.where(eq(userConfig.id, WG_ENV.WG_INTERFACE))
.execute();
await tx
.update(wgInterface)
.set({ port })
.where(eq(wgInterface.name, 'wg0'))
.where(eq(wgInterface.name, WG_ENV.WG_INTERFACE))
.execute();
});
}
@ -60,7 +63,7 @@ export class UserConfigService {
return this.#db
.update(userConfig)
.set(data)
.where(eq(userConfig.id, 'wg0'))
.where(eq(userConfig.id, WG_ENV.WG_INTERFACE))
.execute();
}
}

View file

@ -21,6 +21,7 @@ const db = drizzle({ client, schema });
export async function connect() {
await migrate();
await renameInterface(db);
const dbService = new DBService(db);
if (WG_INITIAL_ENV.ENABLED) {
@ -122,16 +123,38 @@ async function initialSetup(db: DBServiceType) {
}
}
// Renames the interface to the one specified in `WG_INTERFACE` then hooks, users, and clients via `ON UPDATE CASCADE`
async function renameInterface(db: DBType) {
const wgInterface = await db.query.wgInterface.findFirst();
if (!wgInterface) {
throw new Error('Interface not found');
}
if (wgInterface.name === WG_ENV.WG_INTERFACE) {
return;
}
DB_DEBUG(
`Renaming Interface ${wgInterface.name} to ${WG_ENV.WG_INTERFACE}...`
);
await db
.update(schema.wgInterface)
.set({ name: WG_ENV.WG_INTERFACE })
.where(eq(schema.wgInterface.name, wgInterface.name))
.execute();
}
async function disableIpv6(db: DBType) {
// This should match the initial value migration
const postUpMatch =
' ip6tables -t nat -A POSTROUTING -s {{ipv6Cidr}} -o {{device}} -j MASQUERADE; ip6tables -A INPUT -p udp -m udp --dport {{port}} -j ACCEPT; ip6tables -A FORWARD -i wg0 -j ACCEPT; ip6tables -A FORWARD -o wg0 -j ACCEPT;';
' ip6tables -t nat -A POSTROUTING -s {{ipv6Cidr}} -o {{device}} -j MASQUERADE; ip6tables -A INPUT -p udp -m udp --dport {{port}} -j ACCEPT; ip6tables -A FORWARD -i {{interface}} -j ACCEPT; ip6tables -A FORWARD -o {{interface}} -j ACCEPT;';
const postDownMatch =
' ip6tables -t nat -D POSTROUTING -s {{ipv6Cidr}} -o {{device}} -j MASQUERADE; ip6tables -D INPUT -p udp -m udp --dport {{port}} -j ACCEPT; ip6tables -D FORWARD -i wg0 -j ACCEPT; ip6tables -D FORWARD -o wg0 -j ACCEPT;';
' ip6tables -t nat -D POSTROUTING -s {{ipv6Cidr}} -o {{device}} -j MASQUERADE; ip6tables -D INPUT -p udp -m udp --dport {{port}} -j ACCEPT; ip6tables -D FORWARD -i {{interface}} -j ACCEPT; ip6tables -D FORWARD -o {{interface}} -j ACCEPT;';
await db.transaction(async (tx) => {
const hooks = await tx.query.hooks.findFirst({
where: eq(schema.hooks.id, 'wg0'),
where: eq(schema.hooks.id, WG_ENV.WG_INTERFACE),
});
if (!hooks) {
@ -146,7 +169,7 @@ async function disableIpv6(db: DBType) {
postUp: hooks.postUp.replace(postUpMatch, ''),
postDown: hooks.postDown.replace(postDownMatch, ''),
})
.where(eq(schema.hooks.id, 'wg0'))
.where(eq(schema.hooks.id, WG_ENV.WG_INTERFACE))
.execute();
} else {
DB_DEBUG('IPv6 Post Up hooks already disabled, skipping...');
@ -159,7 +182,7 @@ async function disableIpv6(db: DBType) {
postUp: hooks.postUp.replace(postUpMatch, ''),
postDown: hooks.postDown.replace(postDownMatch, ''),
})
.where(eq(schema.hooks.id, 'wg0'))
.where(eq(schema.hooks.id, WG_ENV.WG_INTERFACE))
.execute();
} else {
DB_DEBUG('IPv6 Post Down hooks already disabled, skipping...');

View file

@ -2,6 +2,7 @@ import { createDebug } from 'obug';
import packageJson from '@@/package.json';
import { exec } from '#server/utils/cmd';
import { parseInterfaceName } from '#server/utils/interfaceName';
import { parseTrustedProxies } from '#server/utils/trustedProxy';
import {
OAUTH_PROVIDERS,
@ -50,6 +51,8 @@ export const WG_ENV = {
PORT: assertEnv('PORT'),
/** If IPv6 should be disabled */
DISABLE_IPV6: process.env.DISABLE_IPV6 === 'true',
/** Name of the WireGuard interface */
WG_INTERFACE: parseInterfaceName(process.env.WG_INTERFACE),
WG_EXECUTABLE: await detectAwg(),
DISABLE_VERSION_CHECK: process.env.DISABLE_VERSION_CHECK === 'true',
/** List of enabled and configured OAuth providers */

View file

@ -9,6 +9,14 @@ import type { UserConfigType } from '#db/repositories/userConfig/types';
const FW_DEBUG = createDebug('Firewall');
const CHAIN_NAME = 'WG_CLIENTS';
/**
* Tie chains to a specific interface
* Prevents instances in the same network namespace from wiping each other's rules
*/
function chainName(interfaceName: string) {
return `${CHAIN_NAME}_${interfaceName}`;
}
// Mutex to prevent concurrent rule rebuilds
let rebuildInProgress = false;
let rebuildQueued = false;
@ -131,6 +139,7 @@ function parseFirewallEntry(entry: string): ParsedEntry {
* Generate iptables rule arguments for a single firewall entry
*/
function generateRuleArgs(
interfaceName: string,
clientIp: string,
entry: ParsedEntry,
comment?: string,
@ -138,7 +147,7 @@ function generateRuleArgs(
): string[] {
const rules: string[] = [];
const commentArg = comment ? ` -m comment --comment "${comment}"` : '';
const baseArgs = `-${action} ${CHAIN_NAME} -s ${clientIp} -d ${entry.ip}`;
const baseArgs = `-${action} ${chainName(interfaceName)} -s ${clientIp} -d ${entry.ip}`;
if (entry.port) {
// Port-specific rules
@ -165,24 +174,25 @@ export const firewall = {
* Initialize the custom chain if it doesn't exist
*/
async initChain(interfaceName: string, enableIpv6: boolean): Promise<void> {
const chain = chainName(interfaceName);
FW_DEBUG(
`Initializing firewall chain ${CHAIN_NAME} for interface ${interfaceName}`
`Initializing firewall chain ${chain} 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(`iptables -N ${chain} 2>/dev/null || true`);
if (enableIpv6) {
await exec(`ip6tables -N ${CHAIN_NAME} 2>/dev/null || true`);
await exec(`ip6tables -N ${chain} 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}`
`iptables -C FORWARD -i ${interfaceName} -j ${chain} 2>/dev/null || iptables -I FORWARD 1 -i ${interfaceName} -j ${chain}`
);
if (enableIpv6) {
await exec(
`ip6tables -C FORWARD -i ${interfaceName} -j ${CHAIN_NAME} 2>/dev/null || ip6tables -I FORWARD 1 -i ${interfaceName} -j ${CHAIN_NAME}`
`ip6tables -C FORWARD -i ${interfaceName} -j ${chain} 2>/dev/null || ip6tables -I FORWARD 1 -i ${interfaceName} -j ${chain}`
);
}
},
@ -190,11 +200,12 @@ export const firewall = {
/**
* Flush all rules in the custom chain
*/
async flushChain(enableIpv6: boolean): Promise<void> {
FW_DEBUG(`Flushing firewall chain ${CHAIN_NAME}`);
await exec(`iptables -F ${CHAIN_NAME} 2>/dev/null || true`);
async flushChain(interfaceName: string, enableIpv6: boolean): Promise<void> {
const chain = chainName(interfaceName);
FW_DEBUG(`Flushing firewall chain ${chain}`);
await exec(`iptables -F ${chain} 2>/dev/null || true`);
if (enableIpv6) {
await exec(`ip6tables -F ${CHAIN_NAME} 2>/dev/null || true`);
await exec(`ip6tables -F ${chain} 2>/dev/null || true`);
}
},
@ -202,6 +213,7 @@ export const firewall = {
* Apply firewall rules for a single client
*/
async applyClientRules(
interfaceName: string,
client: FirewallClient,
defaultAllowedIps: string[],
enableIpv6: boolean
@ -226,13 +238,23 @@ export const firewall = {
if (destIsIpv6) {
if (enableIpv6) {
const rules = generateRuleArgs(client.ipv6Address, parsed, comment);
const rules = generateRuleArgs(
interfaceName,
client.ipv6Address,
parsed,
comment
);
for (const rule of rules) {
await exec(`ip6tables ${rule}`);
}
}
} else {
const rules = generateRuleArgs(client.ipv4Address, parsed, comment);
const rules = generateRuleArgs(
interfaceName,
client.ipv4Address,
parsed,
comment
);
for (const rule of rules) {
await exec(`iptables ${rule}`);
}
@ -271,12 +293,13 @@ export const firewall = {
await this.initChain(wgInterface.name, enableIpv6);
// Flush existing rules
await this.flushChain(enableIpv6);
await this.flushChain(wgInterface.name, enableIpv6);
// Apply rules for each enabled client
for (const client of clients) {
if (!client.enabled) continue;
await this.applyClientRules(
wgInterface.name,
client,
userConfig.defaultAllowedIps,
enableIpv6
@ -284,9 +307,10 @@ export const firewall = {
}
// Add final DROP for any traffic not explicitly allowed
await exec(`iptables -A ${CHAIN_NAME} -j DROP`);
const chain = chainName(wgInterface.name);
await exec(`iptables -A ${chain} -j DROP`);
if (enableIpv6) {
await exec(`ip6tables -A ${CHAIN_NAME} -j DROP`);
await exec(`ip6tables -A ${chain} -j DROP`);
}
FW_DEBUG('Firewall rules rebuilt successfully');
@ -309,24 +333,33 @@ export const firewall = {
interfaceName: string,
enableIpv6: boolean
): Promise<void> {
const chain = chainName(interfaceName);
FW_DEBUG(`Removing firewall filtering for interface ${interfaceName}`);
// Remove jump rules from FORWARD chain
// This disconnects the interface from the old shared chain, but it won't delete the chain itself.
// Other wg-easy instances on the same host may still rely on it.
await exec(
`iptables -D FORWARD -i ${interfaceName} -j ${chain} 2>/dev/null || true`
);
await exec(
`iptables -D FORWARD -i ${interfaceName} -j ${CHAIN_NAME} 2>/dev/null || true`
);
if (enableIpv6) {
await exec(
`ip6tables -D FORWARD -i ${interfaceName} -j ${chain} 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(`iptables -X ${CHAIN_NAME} 2>/dev/null || true`);
await exec(`iptables -F ${chain} 2>/dev/null || true`);
await exec(`iptables -X ${chain} 2>/dev/null || true`);
if (enableIpv6) {
await exec(`ip6tables -F ${CHAIN_NAME} 2>/dev/null || true`);
await exec(`ip6tables -X ${CHAIN_NAME} 2>/dev/null || true`);
await exec(`ip6tables -F ${chain} 2>/dev/null || true`);
await exec(`ip6tables -X ${chain} 2>/dev/null || true`);
}
},

View file

@ -0,0 +1,21 @@
/**
* Default interface name
*/
const DEFAULT_INTERFACE_NAME = 'wg0';
/**
* `wg-quick` compliant interface naming
*/
const INTERFACE_NAME_REGEX = /^[a-zA-Z0-9_=+.-]{1,15}$/;
export function parseInterfaceName(value: string | undefined): string {
if (!value) {
return DEFAULT_INTERFACE_NAME;
}
if (!INTERFACE_NAME_REGEX.test(value)) {
throw new Error(`Invalid interface name: ${value}`);
}
return value;
}

View file

@ -96,7 +96,7 @@ async function getReverseDns(ip: string) {
}
}
function getPrivateInformation() {
function getPrivateInformation(interfaceName: string) {
const interfaces = networkInterfaces();
const interfaceNames = Object.keys(interfaces);
@ -104,7 +104,7 @@ function getPrivateInformation() {
const obj: Record<string, { ipv4: string[]; ipv6: string[] }> = {};
for (const name of interfaceNames) {
if (name === 'wg0') {
if (name === interfaceName) {
continue;
}
@ -132,10 +132,10 @@ function getPrivateInformation() {
return obj;
}
async function getIpInformation() {
export async function getIpInformation(interfaceName: string) {
const results = [];
const publicInfo = await getPublicInformation();
const publicInfo = await cachedGetPublicInformation();
if (publicInfo.ipv4) {
results.push({
value: publicInfo.ipv4,
@ -155,7 +155,7 @@ async function getIpInformation() {
});
}
const privateInfo = getPrivateInformation();
const privateInfo = getPrivateInformation(interfaceName);
for (const [name, { ipv4, ipv6 }] of Object.entries(privateInfo)) {
for (const ip of ipv4) {
results.push({
@ -175,9 +175,9 @@ async function getIpInformation() {
}
/**
* Fetch IP Information
* Fetch public IP Information
* @cache Response is cached for 15 min
*/
export const cachedGetIpInformation = cacheFunction(getIpInformation, {
const cachedGetPublicInformation = cacheFunction(getPublicInformation, {
expiry: 15 * 60 * 1000,
});

View file

@ -19,6 +19,7 @@ export function removeNewlines(templ: string) {
* - ipv4Cidr: IPv4 CIDR
* - ipv6Cidr: IPv6 CIDR
* - device: Network device
* - interface: WireGuard interface
* - port: Port number
* - uiPort: UI port number
*/
@ -27,6 +28,7 @@ export function iptablesTemplate(templ: string, wgInterface: InterfaceType) {
ipv4Cidr: wgInterface.ipv4Cidr,
ipv6Cidr: wgInterface.ipv6Cidr,
device: wgInterface.device,
interface: wgInterface.name,
port: wgInterface.port.toString(),
uiPort: WG_ENV.PORT,
});

View file

@ -18,7 +18,7 @@ describe('firewall', () => {
describe('IPv4-only chain management', () => {
test('does not invoke ip6tables when initializing and flushing', async () => {
await firewall.initChain('wg0', false);
await firewall.flushChain(false);
await firewall.flushChain('wg0', false);
expect(execMock).toHaveBeenCalledWith(
expect.stringContaining('iptables -C FORWARD -i wg0')
@ -40,6 +40,30 @@ describe('firewall', () => {
});
});
describe('interface scoped chains', () => {
test('uses one chain per interface', async () => {
await firewall.initChain('wg1', false);
expect(execMock).toHaveBeenCalledWith(
expect.stringContaining('iptables -N WG_CLIENTS_wg1')
);
expect(execMock).not.toHaveBeenCalledWith(
expect.stringContaining('iptables -N WG_CLIENTS ')
);
});
test('stops referencing the legacy chain when removing filtering', async () => {
await firewall.removeFiltering('wg1', false);
expect(execMock).toHaveBeenCalledWith(
expect.stringContaining('iptables -D FORWARD -i wg1 -j WG_CLIENTS ')
);
expect(execMock).toHaveBeenCalledWith(
expect.stringContaining('iptables -X WG_CLIENTS_wg1')
);
});
});
describe('isValidFirewallEntry', () => {
test('invalid ips', () => {
expect(() => typesTestExports.FirewallIpEntrySchema.parse('')).toThrow();
@ -290,41 +314,43 @@ describe('firewall', () => {
describe('generateRuleArgs', () => {
test('includes comment when provided', () => {
const rules = firewallTestExports.generateRuleArgs(
'wg0',
'10.8.0.2',
{ ip: '10.0.0.1' },
'client 1: test'
);
expect(rules).toEqual([
'-A WG_CLIENTS -s 10.8.0.2 -d 10.0.0.1 -m comment --comment "client 1: test" -j ACCEPT',
'-A WG_CLIENTS_wg0 -s 10.8.0.2 -d 10.0.0.1 -m comment --comment "client 1: test" -j ACCEPT',
]);
});
test('omits comment when not provided', () => {
const rulesTcp = firewallTestExports.generateRuleArgs('10.8.0.2', {
const rulesTcp = firewallTestExports.generateRuleArgs('wg0', '10.8.0.2', {
ip: '10.0.0.1',
port: 80,
proto: 'tcp',
});
expect(rulesTcp).toEqual([
'-A WG_CLIENTS -s 10.8.0.2 -d 10.0.0.1 -p tcp --dport 80 -j ACCEPT',
'-A WG_CLIENTS_wg0 -s 10.8.0.2 -d 10.0.0.1 -p tcp --dport 80 -j ACCEPT',
]);
const rulesUdp = firewallTestExports.generateRuleArgs('10.8.0.2', {
const rulesUdp = firewallTestExports.generateRuleArgs('wg0', '10.8.0.2', {
ip: '10.0.0.1',
port: 80,
proto: 'udp',
});
expect(rulesUdp).toEqual([
'-A WG_CLIENTS -s 10.8.0.2 -d 10.0.0.1 -p udp --dport 80 -j ACCEPT',
'-A WG_CLIENTS_wg0 -s 10.8.0.2 -d 10.0.0.1 -p udp --dport 80 -j ACCEPT',
]);
});
test('comment with port generates two rules for both proto', () => {
const rules = firewallTestExports.generateRuleArgs(
'wg0',
'10.8.0.2',
{ ip: '10.0.0.1', port: 443, proto: 'both' },
'client 2: phone'
);
expect(rules).toEqual([
'-A WG_CLIENTS -s 10.8.0.2 -d 10.0.0.1 -p tcp --dport 443 -m comment --comment "client 2: phone" -j ACCEPT',
'-A WG_CLIENTS -s 10.8.0.2 -d 10.0.0.1 -p udp --dport 443 -m comment --comment "client 2: phone" -j ACCEPT',
'-A WG_CLIENTS_wg0 -s 10.8.0.2 -d 10.0.0.1 -p tcp --dport 443 -m comment --comment "client 2: phone" -j ACCEPT',
'-A WG_CLIENTS_wg0 -s 10.8.0.2 -d 10.0.0.1 -p udp --dport 443 -m comment --comment "client 2: phone" -j ACCEPT',
]);
});
});

View file

@ -0,0 +1,25 @@
import { describe, expect, test } from 'vitest';
import { parseInterfaceName } from '#server/utils/interfaceName';
describe('parseInterfaceName', () => {
test('returns the default interface when a custom name is not specified', () => {
expect(parseInterfaceName(undefined)).toBe('wg0');
expect(parseInterfaceName('')).toBe('wg0');
});
test('accepts names compliant with wg-quick', () => {
expect(parseInterfaceName('wg1')).toBe('wg1');
expect(parseInterfaceName('wg-home')).toBe('wg-home');
expect(parseInterfaceName('a'.repeat(15))).toBe('a'.repeat(15));
});
test('rejects invalid names', () => {
expect(() => parseInterfaceName('wg 0')).toThrow(
'Invalid interface name: wg 0'
);
expect(() => parseInterfaceName('wg0; id')).toThrow();
expect(() => parseInterfaceName('wg/0')).toThrow();
expect(() => parseInterfaceName('a'.repeat(16))).toThrow();
});
});