diff --git a/docs/content/guides/clients.md b/docs/content/guides/clients.md index ec532b1b..292b854c 100644 --- a/docs/content/guides/clients.md +++ b/docs/content/guides/clients.md @@ -23,8 +23,11 @@ Use the Firewall Allowed IPs feature to prevent access to IP ranges that the use ## Firewall Allowed IPs -!!! note - This field only appears when **Per-Client Firewall** is enabled in the Admin Panel → Interface settings. +/// 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. @@ -37,7 +40,20 @@ Unlike "Allowed IPs" which only controls routing on the client side, these rules - `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:** diff --git a/src/server/utils/firewall.ts b/src/server/utils/firewall.ts index 3afb93f8..6878a5b4 100644 --- a/src/server/utils/firewall.ts +++ b/src/server/utils/firewall.ts @@ -29,6 +29,13 @@ type ParsedEntry = { * - 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 @@ -52,8 +59,18 @@ function parseFirewallEntry(entry: string): ParsedEntry { // Just bracketed IPv6 without port const ipMatch = remaining.match(/^\[(.+)\]$/); if (ipMatch) { + 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 }; } @@ -75,6 +92,11 @@ function parseFirewallEntry(entry: string): ParsedEntry { } // 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 }; } diff --git a/src/server/utils/types.ts b/src/server/utils/types.ts index bb1ffdab..c2045ace 100644 --- a/src/server/utils/types.ts +++ b/src/server/utils/types.ts @@ -99,7 +99,8 @@ const FirewallIpEntrySchema = z .min(1, { message: t('zod.client.firewallIps') }) .refine( (entry) => { - // Remove protocol suffix if present (/tcp, /udp) + // Check if protocol suffix is present + const hasProto = /\/(tcp|udp)$/i.test(entry); const entryWithoutProto = entry.replace(/\/(tcp|udp)$/i, ''); // Check if it's IP:port format @@ -115,6 +116,11 @@ const FirewallIpEntrySchema = z return (isIP(cleanIp) || isCidr(cleanIp)) && port >= 1 && port <= 65535; } + // If protocol was specified without a port, it's invalid + if (hasProto) { + return false; + } + // Check if it's just IP or CIDR return isIP(entryWithoutProto) || isCidr(entryWithoutProto); },