mirror of
https://github.com/wg-easy/wg-easy.git
synced 2026-04-22 23:37:06 +08:00
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:
@@ -33,6 +33,7 @@ You have found the easiest way to install & manage WireGuard on any Linux host!
|
||||
- IPv6 support
|
||||
- CIDR support
|
||||
- 2FA support
|
||||
- Per-client firewall filtering (requires iptables)
|
||||
|
||||
> [!NOTE]
|
||||
> To better manage documentation for this project, it has its own site here: [https://wg-easy.github.io/wg-easy/latest](https://wg-easy.github.io/wg-easy/latest)
|
||||
|
||||
@@ -6,6 +6,20 @@ hide:
|
||||
|
||||
Here are some frequently asked questions or errors about `wg-easy`. If you have a question that is not answered here, please feel free to open a discussion on GitHub.
|
||||
|
||||
## How do I restrict client access to specific networks or servers?
|
||||
|
||||
Use the **Per-Client Firewall** feature to enforce server-side restrictions on what each client can access.
|
||||
|
||||
**Requirements:** This feature requires `iptables` (and `ip6tables` for IPv6) to be installed on the host system.
|
||||
|
||||
1. Enable "Per-Client Firewall" in **Admin Panel → Interface**
|
||||
2. Edit a client and configure "Firewall Allowed IPs"
|
||||
3. Specify which destinations the client should be allowed to access
|
||||
|
||||
Unlike "Allowed IPs" which only controls client-side routing, firewall rules are enforced by the server and cannot be bypassed.
|
||||
|
||||
See the [Admin Panel Guide](guides/admin/#per-client-firewall) and [Client Guide](guides/clients/#firewall-allowed-ips) for detailed configuration.
|
||||
|
||||
## Error: WireGuard exited with the error: Cannot find device "wg0"
|
||||
|
||||
This error indicates that the WireGuard interface `wg0` does not exist. This can happen if the WireGuard kernel module is not loaded or if the interface was not created properly.
|
||||
|
||||
@@ -2,4 +2,42 @@
|
||||
title: Admin Panel
|
||||
---
|
||||
|
||||
TODO
|
||||
## Interface Settings
|
||||
|
||||
### Per-Client Firewall
|
||||
|
||||
Enable server-side firewall filtering to enforce network access restrictions per client.
|
||||
|
||||
When enabled, each client can have custom "Firewall Allowed IPs" configured that restrict which destinations they can access through the VPN. These restrictions are enforced by the server using iptables/ip6tables and cannot be bypassed by the client.
|
||||
|
||||
/// warning | Experimental Feature
|
||||
|
||||
This feature is currently experimental. While functional, it should be thoroughly tested in your environment before relying on it for production security requirements. Always verify that firewall rules are working as expected using test traffic or by manually inspecting the rules.
|
||||
|
||||
///
|
||||
|
||||
**Requirements:**
|
||||
|
||||
- `iptables` must be installed on the host system
|
||||
- `ip6tables` must be installed if IPv6 is enabled (default)
|
||||
- The feature cannot be enabled if these tools are not available
|
||||
|
||||
/// note
|
||||
Most Linux distributions include iptables by default. If you're running in a minimal container environment, you may need to install the `iptables` package on the host system.
|
||||
///
|
||||
|
||||
**Enable this feature if you want to:**
|
||||
|
||||
- Restrict certain clients to only access specific servers or networks
|
||||
- Prevent clients from accessing the internet while allowing LAN access
|
||||
- Enforce port-based restrictions (e.g., only allow HTTP/HTTPS)
|
||||
- Separate routing configuration from security enforcement
|
||||
|
||||
**How it works:**
|
||||
|
||||
1. Enable "Per-Client Firewall" in Admin Panel → Interface
|
||||
2. Edit any client to see the new "Firewall Allowed IPs" field
|
||||
3. Specify allowed destinations (IPs, subnets, ports) for that client
|
||||
4. Server enforces these rules automatically
|
||||
|
||||
See [Edit Client → Firewall Allowed IPs](../clients/#firewall-allowed-ips) for detailed configuration syntax and examples.
|
||||
|
||||
@@ -19,7 +19,58 @@ Which IPs will be routed through the VPN.
|
||||
|
||||
This will not prevent the user from modifying it locally and accessing IP ranges that they should not be able to access.
|
||||
|
||||
Use firewall rules to prevent access to IP ranges that the user should not be able to access.
|
||||
Use the Firewall Allowed IPs feature to prevent access to IP ranges that the user should not be able to access.
|
||||
|
||||
## Firewall Allowed IPs
|
||||
|
||||
/// note | Attention
|
||||
|
||||
This field only appears when **Per-Client Firewall** is enabled in the Admin Panel → Interface settings.
|
||||
|
||||
///
|
||||
|
||||
Server-side firewall rules that restrict which destinations the client can access, regardless of their local configuration.
|
||||
|
||||
Unlike "Allowed IPs" which only controls routing on the client side, these rules are enforced by the server using iptables/ip6tables and cannot be bypassed by the client.
|
||||
|
||||
**Supported Formats:**
|
||||
|
||||
- `10.10.0.3`, `2001:db8::1` - Allow access to a single IP address
|
||||
- `10.10.0.0/24`, `2001:db8::/32` - Allow access to an entire subnet
|
||||
- `192.168.1.5:443` - Allow access to specific port (TCP+UDP)
|
||||
- `192.168.1.5:443/tcp` - Allow access to specific port (TCP only)
|
||||
- `192.168.1.5:443/udp` - Allow access to specific port (UDP only)
|
||||
- `10.10.0.0/24:443` - Allow access to an entire subnet on a specific port (TCP+UDP)
|
||||
- `10.10.0.0/24:443/tcp` - Allow access to an entire subnet on a specific port (TCP only)
|
||||
- `10.10.0.0/24:443/udp` - Allow access to an entire subnet on a specific port (UDP only)
|
||||
- `[2001:db8::1]:443` - IPv6 address with port (brackets required)
|
||||
- `[2001:db8::/32]:443/tcp` - IPv6 CIDR with port and protocol
|
||||
|
||||
/// warning | Invalid Formats
|
||||
|
||||
Protocol specifiers (`/tcp` or `/udp`) require a port number. The following formats are **not supported** and will result in an error:
|
||||
|
||||
- `10.10.0.3/tcp` (use `10.10.0.3:443/tcp` instead)
|
||||
- `10.10.0.0/24/udp` (use `10.10.0.0/24:53/udp` instead)
|
||||
|
||||
///
|
||||
|
||||
**Behavior:**
|
||||
|
||||
- **Empty**: Falls back to the client's "Allowed IPs" setting
|
||||
- **Specified**: Only listed destinations are accessible (allow-only, everything else is blocked)
|
||||
- **Disable for specific client**: To disable firewall filtering for a single client while keeping it enabled for others, add `0.0.0.0/0, ::/0` to allow all traffic
|
||||
|
||||
/// note
|
||||
To allow clients to reach the VPN server itself (e.g. for DNS), include the server's VPN address in the firewall allowed IPs.
|
||||
///
|
||||
|
||||
**Use Case Examples**:
|
||||
|
||||
- Allow only specific servers: `10.10.0.5`
|
||||
- Allow only internal network: `10.10.0.0/24, 192.168.1.0/24`
|
||||
- Allow only web browsing: `0.0.0.0/0:80, 0.0.0.0/0:443, [::/0]:80, [::/0]:443`
|
||||
- Block internet, allow LAN: Leave "Allowed IPs" as `0.0.0.0/0, ::/0` but set Firewall IPs to `10.0.0.0/8, 192.168.0.0/16`
|
||||
|
||||
## Server Allowed IPs
|
||||
|
||||
|
||||
@@ -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 globalStore.refreshInformation();
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
function submit() {
|
||||
|
||||
@@ -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') }}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
export const useGlobalStore = defineStore('Global', () => {
|
||||
const { data: information } = useFetch('/api/information', {
|
||||
method: 'get',
|
||||
});
|
||||
const { data: information, refresh: refreshInformation } = useFetch(
|
||||
'/api/information',
|
||||
{
|
||||
method: 'get',
|
||||
}
|
||||
);
|
||||
|
||||
const sortClient = ref(true); // Sort clients by name, true = asc, false = desc
|
||||
|
||||
@@ -22,6 +25,7 @@ export const useGlobalStore = defineStore('Global', () => {
|
||||
return {
|
||||
sortClient,
|
||||
information,
|
||||
refreshInformation,
|
||||
uiShowCharts,
|
||||
toggleCharts,
|
||||
uiChartType,
|
||||
|
||||
@@ -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 optional port and protocol filtering. See docs for syntax."
|
||||
},
|
||||
"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,9 @@
|
||||
"expiresAt": "Expires At",
|
||||
"address4": "IPv4 Address",
|
||||
"address6": "IPv6 Address",
|
||||
"serverAllowedIps": "Server Allowed IPs"
|
||||
"serverAllowedIps": "Server Allowed IPs",
|
||||
"firewallIps": "Firewall Allowed IPs",
|
||||
"firewallIpsInvalid": "Invalid firewall IP entry. See docs for supported syntax."
|
||||
},
|
||||
"user": {
|
||||
"username": "Username",
|
||||
|
||||
@@ -8,6 +8,26 @@ export default definePermissionEventHandler(
|
||||
event,
|
||||
validateZod(InterfaceUpdateSchema, event)
|
||||
);
|
||||
|
||||
// If enabling firewall, check if iptables is available
|
||||
if (data.firewallEnabled) {
|
||||
// Clear cache to force fresh check
|
||||
firewall.clearAvailabilityCache();
|
||||
|
||||
const iptablesAvailable = await firewall.isAvailable(
|
||||
!WG_ENV.DISABLE_IPV6
|
||||
);
|
||||
if (!iptablesAvailable) {
|
||||
const requiredTools = WG_ENV.DISABLE_IPV6
|
||||
? 'iptables'
|
||||
: 'iptables and ip6tables';
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: `Per-Client Firewall requires ${requiredTools} to be installed on the host system. Please install ${requiredTools} before enabling this feature.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await Database.interfaces.update(data);
|
||||
await WireGuard.saveConfig();
|
||||
return { success: true };
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE `clients_table` ADD `firewall_ips` text;--> statement-breakpoint
|
||||
ALTER TABLE `interfaces_table` ADD `firewall_enabled` integer DEFAULT false NOT NULL;
|
||||
@@ -0,0 +1,987 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "0f072f91-cd10-4702-ae7b-245255d69d1e",
|
||||
"prevId": "68c43b7a-772d-4c34-8278-e9fce5b53df1",
|
||||
"tables": {
|
||||
"clients_table": {
|
||||
"name": "clients_table",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"interface_id": {
|
||||
"name": "interface_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"ipv4_address": {
|
||||
"name": "ipv4_address",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"ipv6_address": {
|
||||
"name": "ipv6_address",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"pre_up": {
|
||||
"name": "pre_up",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "''"
|
||||
},
|
||||
"post_up": {
|
||||
"name": "post_up",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "''"
|
||||
},
|
||||
"pre_down": {
|
||||
"name": "pre_down",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "''"
|
||||
},
|
||||
"post_down": {
|
||||
"name": "post_down",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "''"
|
||||
},
|
||||
"private_key": {
|
||||
"name": "private_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"public_key": {
|
||||
"name": "public_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"pre_shared_key": {
|
||||
"name": "pre_shared_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"allowed_ips": {
|
||||
"name": "allowed_ips",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"server_allowed_ips": {
|
||||
"name": "server_allowed_ips",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"firewall_ips": {
|
||||
"name": "firewall_ips",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"persistent_keepalive": {
|
||||
"name": "persistent_keepalive",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"mtu": {
|
||||
"name": "mtu",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"j_c": {
|
||||
"name": "j_c",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"j_min": {
|
||||
"name": "j_min",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"j_max": {
|
||||
"name": "j_max",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"i1": {
|
||||
"name": "i1",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"i2": {
|
||||
"name": "i2",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"i3": {
|
||||
"name": "i3",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"i4": {
|
||||
"name": "i4",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"i5": {
|
||||
"name": "i5",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"dns": {
|
||||
"name": "dns",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"server_endpoint": {
|
||||
"name": "server_endpoint",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"enabled": {
|
||||
"name": "enabled",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(CURRENT_TIMESTAMP)"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(CURRENT_TIMESTAMP)"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"clients_table_ipv4_address_unique": {
|
||||
"name": "clients_table_ipv4_address_unique",
|
||||
"columns": [
|
||||
"ipv4_address"
|
||||
],
|
||||
"isUnique": true
|
||||
},
|
||||
"clients_table_ipv6_address_unique": {
|
||||
"name": "clients_table_ipv6_address_unique",
|
||||
"columns": [
|
||||
"ipv6_address"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"clients_table_user_id_users_table_id_fk": {
|
||||
"name": "clients_table_user_id_users_table_id_fk",
|
||||
"tableFrom": "clients_table",
|
||||
"tableTo": "users_table",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "restrict",
|
||||
"onUpdate": "cascade"
|
||||
},
|
||||
"clients_table_interface_id_interfaces_table_name_fk": {
|
||||
"name": "clients_table_interface_id_interfaces_table_name_fk",
|
||||
"tableFrom": "clients_table",
|
||||
"tableTo": "interfaces_table",
|
||||
"columnsFrom": [
|
||||
"interface_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"name"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "cascade"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"general_table": {
|
||||
"name": "general_table",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 1
|
||||
},
|
||||
"setup_step": {
|
||||
"name": "setup_step",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"session_password": {
|
||||
"name": "session_password",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"session_timeout": {
|
||||
"name": "session_timeout",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"metrics_prometheus": {
|
||||
"name": "metrics_prometheus",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"metrics_json": {
|
||||
"name": "metrics_json",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"metrics_password": {
|
||||
"name": "metrics_password",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(CURRENT_TIMESTAMP)"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(CURRENT_TIMESTAMP)"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"hooks_table": {
|
||||
"name": "hooks_table",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"pre_up": {
|
||||
"name": "pre_up",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"post_up": {
|
||||
"name": "post_up",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"pre_down": {
|
||||
"name": "pre_down",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"post_down": {
|
||||
"name": "post_down",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(CURRENT_TIMESTAMP)"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(CURRENT_TIMESTAMP)"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"hooks_table_id_interfaces_table_name_fk": {
|
||||
"name": "hooks_table_id_interfaces_table_name_fk",
|
||||
"tableFrom": "hooks_table",
|
||||
"tableTo": "interfaces_table",
|
||||
"columnsFrom": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"name"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "cascade"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"interfaces_table": {
|
||||
"name": "interfaces_table",
|
||||
"columns": {
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"device": {
|
||||
"name": "device",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"port": {
|
||||
"name": "port",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"private_key": {
|
||||
"name": "private_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"public_key": {
|
||||
"name": "public_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"ipv4_cidr": {
|
||||
"name": "ipv4_cidr",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"ipv6_cidr": {
|
||||
"name": "ipv6_cidr",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"mtu": {
|
||||
"name": "mtu",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"j_c": {
|
||||
"name": "j_c",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 7
|
||||
},
|
||||
"j_min": {
|
||||
"name": "j_min",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 10
|
||||
},
|
||||
"j_max": {
|
||||
"name": "j_max",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 1000
|
||||
},
|
||||
"s1": {
|
||||
"name": "s1",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 128
|
||||
},
|
||||
"s2": {
|
||||
"name": "s2",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 56
|
||||
},
|
||||
"s3": {
|
||||
"name": "s3",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"s4": {
|
||||
"name": "s4",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"h1": {
|
||||
"name": "h1",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"h2": {
|
||||
"name": "h2",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"h3": {
|
||||
"name": "h3",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"h4": {
|
||||
"name": "h4",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"i1": {
|
||||
"name": "i1",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"i2": {
|
||||
"name": "i2",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"i3": {
|
||||
"name": "i3",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"i4": {
|
||||
"name": "i4",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"i5": {
|
||||
"name": "i5",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"enabled": {
|
||||
"name": "enabled",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"firewall_enabled": {
|
||||
"name": "firewall_enabled",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(CURRENT_TIMESTAMP)"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(CURRENT_TIMESTAMP)"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"interfaces_table_port_unique": {
|
||||
"name": "interfaces_table_port_unique",
|
||||
"columns": [
|
||||
"port"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"one_time_links_table": {
|
||||
"name": "one_time_links_table",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"one_time_link": {
|
||||
"name": "one_time_link",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(CURRENT_TIMESTAMP)"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(CURRENT_TIMESTAMP)"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"one_time_links_table_one_time_link_unique": {
|
||||
"name": "one_time_links_table_one_time_link_unique",
|
||||
"columns": [
|
||||
"one_time_link"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"one_time_links_table_id_clients_table_id_fk": {
|
||||
"name": "one_time_links_table_id_clients_table_id_fk",
|
||||
"tableFrom": "one_time_links_table",
|
||||
"tableTo": "clients_table",
|
||||
"columnsFrom": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "cascade"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"users_table": {
|
||||
"name": "users_table",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"password": {
|
||||
"name": "password",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"totp_key": {
|
||||
"name": "totp_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"totp_verified": {
|
||||
"name": "totp_verified",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"enabled": {
|
||||
"name": "enabled",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(CURRENT_TIMESTAMP)"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(CURRENT_TIMESTAMP)"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"users_table_username_unique": {
|
||||
"name": "users_table_username_unique",
|
||||
"columns": [
|
||||
"username"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"user_configs_table": {
|
||||
"name": "user_configs_table",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"default_mtu": {
|
||||
"name": "default_mtu",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"default_persistent_keepalive": {
|
||||
"name": "default_persistent_keepalive",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"default_dns": {
|
||||
"name": "default_dns",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"default_allowed_ips": {
|
||||
"name": "default_allowed_ips",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"default_j_c": {
|
||||
"name": "default_j_c",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 7
|
||||
},
|
||||
"default_j_min": {
|
||||
"name": "default_j_min",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 10
|
||||
},
|
||||
"default_j_max": {
|
||||
"name": "default_j_max",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 1000
|
||||
},
|
||||
"default_i1": {
|
||||
"name": "default_i1",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"default_i2": {
|
||||
"name": "default_i2",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"default_i3": {
|
||||
"name": "default_i3",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"default_i4": {
|
||||
"name": "default_i4",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"default_i5": {
|
||||
"name": "default_i5",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"host": {
|
||||
"name": "host",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"port": {
|
||||
"name": "port",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(CURRENT_TIMESTAMP)"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(CURRENT_TIMESTAMP)"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"user_configs_table_id_interfaces_table_name_fk": {
|
||||
"name": "user_configs_table_id_interfaces_table_name_fk",
|
||||
"tableFrom": "user_configs_table",
|
||||
"tableTo": "interfaces_table",
|
||||
"columnsFrom": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"name"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "cascade"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,13 @@
|
||||
"when": 1770902426367,
|
||||
"tag": "0003_breezy_colossus",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "6",
|
||||
"when": 1771352889394,
|
||||
"tag": "0004_optimal_mandrill",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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'),
|
||||
|
||||
@@ -71,6 +71,7 @@ export const ClientUpdateSchema = schemaForType<UpdateClientType>()(
|
||||
postDown: HookSchema,
|
||||
allowedIps: AllowedIpsSchema.nullable(),
|
||||
serverAllowedIps: serverAllowedIps,
|
||||
firewallIps: FirewallIpsSchema.nullable(),
|
||||
mtu: MtuSchema,
|
||||
jC: JcSchema,
|
||||
jMin: JminSchema,
|
||||
|
||||
@@ -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)`),
|
||||
|
||||
@@ -18,6 +18,13 @@ function createPreparedStatement(db: DBType) {
|
||||
})
|
||||
.where(eq(wgInterface.name, sql.placeholder('interface')))
|
||||
.prepare(),
|
||||
setFirewallEnabled: db
|
||||
.update(wgInterface)
|
||||
.set({
|
||||
firewallEnabled: sql.placeholder('firewallEnabled') as never as boolean,
|
||||
})
|
||||
.where(eq(wgInterface.name, sql.placeholder('interface')))
|
||||
.prepare(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -56,6 +63,13 @@ export class InterfaceService {
|
||||
.execute();
|
||||
}
|
||||
|
||||
setFirewallEnabled(firewallEnabled: boolean) {
|
||||
return this.#statements.setFirewallEnabled.execute({
|
||||
interface: 'wg0',
|
||||
firewallEnabled,
|
||||
});
|
||||
}
|
||||
|
||||
updateCidr(data: InterfaceCidrUpdateType) {
|
||||
return this.#db.transaction(async (tx) => {
|
||||
const oldCidr = await tx.query.wgInterface
|
||||
|
||||
@@ -50,6 +50,7 @@ export const InterfaceUpdateSchema = schemaForType<InterfaceUpdateType>()(
|
||||
port: PortSchema,
|
||||
device: device,
|
||||
enabled: EnabledSchema,
|
||||
firewallEnabled: EnabledSchema,
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { firewallTestExports } from '../../server/utils/firewall';
|
||||
import { typesTestExports } from '../../server/utils/types';
|
||||
|
||||
describe('firewall', () => {
|
||||
describe('isValidFirewallEntry', () => {
|
||||
test('invalid ips', () => {
|
||||
expect(() => typesTestExports.FirewallIpEntrySchema.parse('')).toThrow();
|
||||
expect(() =>
|
||||
typesTestExports.FirewallIpEntrySchema.parse('255.255.255.256')
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
typesTestExports.FirewallIpEntrySchema.parse('1.1.1.256')
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
typesTestExports.FirewallIpEntrySchema.parse('1.1.1.1.1')
|
||||
).toThrow();
|
||||
|
||||
expect(() =>
|
||||
typesTestExports.FirewallIpEntrySchema.parse('[]:443/udp')
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
typesTestExports.FirewallIpEntrySchema.parse('[]:443')
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
typesTestExports.FirewallIpEntrySchema.parse('[::1]/32')
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
typesTestExports.FirewallIpEntrySchema.parse('[1.1.1.1]/32')
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
typesTestExports.FirewallIpEntrySchema.parse('[::g]/32')
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
typesTestExports.FirewallIpEntrySchema.parse('2001:dbx::1')
|
||||
).toThrow();
|
||||
});
|
||||
test('invalid port, protocol or cidr', () => {
|
||||
expect(() =>
|
||||
typesTestExports.FirewallIpEntrySchema.parse('1.1.1.1:80/tcpp')
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
typesTestExports.FirewallIpEntrySchema.parse('1.1.1.1:65536')
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
typesTestExports.FirewallIpEntrySchema.parse('1.1.1.1:0')
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
typesTestExports.FirewallIpEntrySchema.parse('1.1.1.1/33')
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
typesTestExports.FirewallIpEntrySchema.parse('1.1.1.1/32:0')
|
||||
).toThrow();
|
||||
});
|
||||
test('protocol without port', () => {
|
||||
expect(() =>
|
||||
typesTestExports.FirewallIpEntrySchema.parse('1.1.1.1/tcp')
|
||||
).toThrow();
|
||||
});
|
||||
test('valid entries', () => {
|
||||
expect(typesTestExports.FirewallIpEntrySchema.parse('1.1.1.1')).toBe(
|
||||
'1.1.1.1'
|
||||
);
|
||||
expect(typesTestExports.FirewallIpEntrySchema.parse('::/0')).toBe('::/0');
|
||||
expect(typesTestExports.FirewallIpEntrySchema.parse('::0/0')).toBe(
|
||||
'::0/0'
|
||||
);
|
||||
expect(typesTestExports.FirewallIpEntrySchema.parse('2001:db8::1')).toBe(
|
||||
'2001:db8::1'
|
||||
);
|
||||
expect(typesTestExports.FirewallIpEntrySchema.parse('::1')).toBe('::1');
|
||||
expect(
|
||||
typesTestExports.FirewallIpEntrySchema.parse('2001:db8::1/32')
|
||||
).toBe('2001:db8::1/32');
|
||||
expect(typesTestExports.FirewallIpEntrySchema.parse('[::1]')).toBe(
|
||||
'[::1]'
|
||||
);
|
||||
expect(typesTestExports.FirewallIpEntrySchema.parse('[::1/32]')).toBe(
|
||||
'[::1/32]'
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('parseFirewallEntry', () => {
|
||||
test('IPv4', () => {
|
||||
expect(firewallTestExports.parseFirewallEntry('1.1.1.1')).toEqual({
|
||||
ip: '1.1.1.1',
|
||||
});
|
||||
});
|
||||
test('IPv4 with Protocol', () => {
|
||||
expect(() =>
|
||||
firewallTestExports.parseFirewallEntry('1.1.1.1/tcp')
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
firewallTestExports.parseFirewallEntry('1.1.1.1/udp')
|
||||
).toThrow();
|
||||
});
|
||||
test('IPv4 with CIDR', () => {
|
||||
expect(firewallTestExports.parseFirewallEntry('1.1.1.1/32')).toEqual({
|
||||
ip: '1.1.1.1/32',
|
||||
});
|
||||
});
|
||||
test('IPv4 with CIDR and Protocol', () => {
|
||||
expect(() =>
|
||||
firewallTestExports.parseFirewallEntry('1.1.1.1/32/tcp')
|
||||
).toThrow();
|
||||
});
|
||||
test('IPv4 with Port', () => {
|
||||
expect(firewallTestExports.parseFirewallEntry('1.1.1.1:80')).toEqual({
|
||||
ip: '1.1.1.1',
|
||||
port: 80,
|
||||
proto: 'both',
|
||||
});
|
||||
});
|
||||
test('IPv4 with Port and Protocol', () => {
|
||||
expect(firewallTestExports.parseFirewallEntry('1.1.1.1:80/tcp')).toEqual({
|
||||
ip: '1.1.1.1',
|
||||
port: 80,
|
||||
proto: 'tcp',
|
||||
});
|
||||
expect(firewallTestExports.parseFirewallEntry('1.1.1.1:80/udp')).toEqual({
|
||||
ip: '1.1.1.1',
|
||||
port: 80,
|
||||
proto: 'udp',
|
||||
});
|
||||
});
|
||||
test('IPv4 with CIDR and Port', () => {
|
||||
expect(
|
||||
firewallTestExports.parseFirewallEntry('10.10.0.0/24:443')
|
||||
).toEqual({
|
||||
ip: '10.10.0.0/24',
|
||||
port: 443,
|
||||
proto: 'both',
|
||||
});
|
||||
});
|
||||
test('IPv4 with CIDR, Port and Protocol', () => {
|
||||
expect(
|
||||
firewallTestExports.parseFirewallEntry('10.10.0.0/24:443/tcp')
|
||||
).toEqual({
|
||||
ip: '10.10.0.0/24',
|
||||
port: 443,
|
||||
proto: 'tcp',
|
||||
});
|
||||
expect(
|
||||
firewallTestExports.parseFirewallEntry('10.10.0.0/24:443/udp')
|
||||
).toEqual({
|
||||
ip: '10.10.0.0/24',
|
||||
port: 443,
|
||||
proto: 'udp',
|
||||
});
|
||||
});
|
||||
test('IPv6', () => {
|
||||
expect(firewallTestExports.parseFirewallEntry('[2001:db8::1]')).toEqual({
|
||||
ip: '2001:db8::1',
|
||||
});
|
||||
expect(firewallTestExports.parseFirewallEntry('2001:db8::1')).toEqual({
|
||||
ip: '2001:db8::1',
|
||||
});
|
||||
});
|
||||
test('IPv6 with Protocol', () => {
|
||||
expect(() =>
|
||||
firewallTestExports.parseFirewallEntry('[2001:db8::1]/tcp')
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
firewallTestExports.parseFirewallEntry('2001:db8::1/udp')
|
||||
).toThrow();
|
||||
});
|
||||
test('IPv6 with CIDR', () => {
|
||||
expect(firewallTestExports.parseFirewallEntry('::0/0')).toEqual({
|
||||
ip: '::0/0',
|
||||
});
|
||||
expect(firewallTestExports.parseFirewallEntry('[::0/0]')).toEqual({
|
||||
ip: '::0/0',
|
||||
});
|
||||
});
|
||||
test('IPv6 with CIDR and Protocol', () => {
|
||||
expect(() =>
|
||||
firewallTestExports.parseFirewallEntry('::0/0/tcp')
|
||||
).toThrow();
|
||||
});
|
||||
test('IPv6 with Port', () => {
|
||||
expect(
|
||||
firewallTestExports.parseFirewallEntry('[2001:db8::1]:443')
|
||||
).toEqual({
|
||||
ip: '2001:db8::1',
|
||||
port: 443,
|
||||
proto: 'both',
|
||||
});
|
||||
});
|
||||
test('IPv6 with Port and Protocol', () => {
|
||||
expect(
|
||||
firewallTestExports.parseFirewallEntry('[2001:db8::1]:443/tcp')
|
||||
).toEqual({
|
||||
ip: '2001:db8::1',
|
||||
port: 443,
|
||||
proto: 'tcp',
|
||||
});
|
||||
expect(
|
||||
firewallTestExports.parseFirewallEntry('[2001:db8::1]:443/udp')
|
||||
).toEqual({
|
||||
ip: '2001:db8::1',
|
||||
port: 443,
|
||||
proto: 'udp',
|
||||
});
|
||||
});
|
||||
test('IPv6 with CIDR and Port', () => {
|
||||
expect(
|
||||
firewallTestExports.parseFirewallEntry('[2001:db8::/32]:443')
|
||||
).toEqual({
|
||||
ip: '2001:db8::/32',
|
||||
port: 443,
|
||||
proto: 'both',
|
||||
});
|
||||
});
|
||||
test('IPv6 with CIDR, Port and Protocol', () => {
|
||||
expect(
|
||||
firewallTestExports.parseFirewallEntry('[2001:db8::/32]:443/tcp')
|
||||
).toEqual({
|
||||
ip: '2001:db8::/32',
|
||||
port: 443,
|
||||
proto: 'tcp',
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('sanitizeComment', () => {
|
||||
test('basic ASCII name', () => {
|
||||
expect(firewallTestExports.sanitizeComment(1, 'My Laptop')).toBe(
|
||||
'client 1: My Laptop'
|
||||
);
|
||||
});
|
||||
test('strips non-ASCII and shell metacharacters', () => {
|
||||
expect(firewallTestExports.sanitizeComment(42, 'café')).toBe(
|
||||
'client 42: caf'
|
||||
);
|
||||
expect(firewallTestExports.sanitizeComment(5, 'a"; rm -rf /')).toBe(
|
||||
'client 5: a rm -rf '
|
||||
);
|
||||
expect(firewallTestExports.sanitizeComment(7, 'test$(cmd)`id`')).toBe(
|
||||
'client 7: testcmdid'
|
||||
);
|
||||
});
|
||||
test('preserves allowed punctuation', () => {
|
||||
expect(firewallTestExports.sanitizeComment(3, 'phone-2.lan_home')).toBe(
|
||||
'client 3: phone-2.lan_home'
|
||||
);
|
||||
});
|
||||
test('truncates to 256 bytes', () => {
|
||||
const longName = 'a'.repeat(300);
|
||||
const result = firewallTestExports.sanitizeComment(1, longName);
|
||||
expect(result.length).toBeLessThanOrEqual(256);
|
||||
expect(result).toBe('client 1: ' + 'a'.repeat(246));
|
||||
});
|
||||
});
|
||||
describe('generateRuleArgs', () => {
|
||||
test('includes comment when provided', () => {
|
||||
const rules = firewallTestExports.generateRuleArgs(
|
||||
'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',
|
||||
]);
|
||||
});
|
||||
test('omits comment when not provided', () => {
|
||||
const rulesTcp = firewallTestExports.generateRuleArgs('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',
|
||||
]);
|
||||
const rulesUdp = firewallTestExports.generateRuleArgs('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',
|
||||
]);
|
||||
});
|
||||
test('comment with port generates two rules for both proto', () => {
|
||||
const rules = firewallTestExports.generateRuleArgs(
|
||||
'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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user