From d6472740dce76ce56bdd5dfbdf5201b879efd9d1 Mon Sep 17 00:00:00 2001 From: n0ctal <4c866w5fn9@privaterelay.appleid.com> Date: Tue, 18 Aug 2026 15:23:10 +0500 Subject: [PATCH] feat(limitip): let operators exempt trusted addresses from the IP limit (#6230) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(limitip): let operators exempt trusted addresses from the IP limit Behind a shared address — an office gateway, a campus NAT, a residential carrier — every user looks like the same client. One of them trips the IP limit and the address is disconnected and handed to fail2ban, taking the others with it. Today the only way out is editing jail.d by hand, which an update overwrites. Add an allowlist setting of addresses and networks. A matching address is neither banned nor counted towards the limit: counting it would still cut the shared network the entry exists to protect. Entries are validated on save rather than skipped at scan time — a typo would otherwise leave the address unprotected until someone noticed the bans. * fix(limitip): keep each doc comment on its function and one grammar for the list Three review follow-ups. loadAllowlist landed between hasLimitIp's doc comment and hasLimitIp itself, so godoc showed one function's rationale above another's body; it now sits after that function with its own comment. The parser advertised semicolons and whitespace as separators while the settings validator accepts commas only, making those forms unreachable through the panel and the API — a promise the software never keeps. Both sides now read the same comma-separated grammar. The dist stub was a build artifact and does not belong in the tree. * chore: drop the accidentally committed dist build stub internal/web/dist/.gitkeep is what make dist-stub creates locally. Committing it changes fresh-clone behaviour for everyone: today a bare go build fails loudly on //go:embed all:dist, which is the documented signal to run the stub target; with the file present the build succeeds and the panel serves an empty dist instead. * chore(i18n): translate the IP limit allowlist strings into the remaining locales Ten locales carried the English source text verbatim; only ru-RU and uk-UA were translated. The i18n dead-key test only checks that a key exists in every file, so an untranslated value passes it silently. Wording follows each locale's existing terms: the ipLimit noun already in the file, and the comma-separated IP/CIDR phrasing from trustedProxyCidrsDesc. * refactor(limitip): share one IP/CIDR list validator and read the allowlist only when enforcing The allowlist check in CheckValid was a line-for-line copy of the trusted-proxy loop directly above it. Both now call one helper, each passing its own message, so the two lists cannot drift apart. Run() read the allowlist on every 10s scan, including the majority of panels where no client carries an IP limit and the value is discarded. It is now read only once enforcement is known to apply. CheckValid had no test for either list. The new one pins that a malformed entry is rejected and that each list still names itself in the error, which is what the shared helper could otherwise break. --------- Co-authored-by: n0ctal Co-authored-by: Sanaei --- frontend/public/openapi.json | 8 ++ frontend/src/generated/examples.ts | 2 + frontend/src/generated/schemas.ts | 8 ++ frontend/src/generated/types.ts | 2 + frontend/src/generated/zod.ts | 2 + frontend/src/models/setting.ts | 1 + frontend/src/pages/settings/GeneralTab.tsx | 12 +++ frontend/src/schemas/setting.ts | 1 + internal/web/entity/check_valid_test.go | 41 +++++++++- internal/web/entity/entity.go | 38 ++++++--- internal/web/job/check_client_ip_job.go | 27 ++++++- .../check_client_ip_job_integration_test.go | 49 ++++++++++++ internal/web/job/ip_limit_allowlist.go | 80 +++++++++++++++++++ internal/web/job/ip_limit_allowlist_test.go | 70 ++++++++++++++++ internal/web/service/setting.go | 7 ++ internal/web/translation/ar-EG.json | 4 +- internal/web/translation/en-US.json | 4 +- internal/web/translation/es-ES.json | 4 +- internal/web/translation/fa-IR.json | 4 +- internal/web/translation/id-ID.json | 4 +- internal/web/translation/ja-JP.json | 4 +- internal/web/translation/pt-BR.json | 4 +- internal/web/translation/ru-RU.json | 4 +- internal/web/translation/tr-TR.json | 4 +- internal/web/translation/uk-UA.json | 4 +- internal/web/translation/vi-VN.json | 4 +- internal/web/translation/zh-CN.json | 4 +- internal/web/translation/zh-TW.json | 4 +- 28 files changed, 373 insertions(+), 27 deletions(-) create mode 100644 internal/web/job/ip_limit_allowlist.go create mode 100644 internal/web/job/ip_limit_allowlist_test.go diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index 8336ebcdc..6ec0768f7 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -41,6 +41,9 @@ "externalTrafficInformURI": { "type": "string" }, + "ipLimitAllowlist": { + "type": "string" + }, "ldapAutoCreate": { "type": "boolean" }, @@ -374,6 +377,7 @@ "expireDiff", "externalTrafficInformEnable", "externalTrafficInformURI", + "ipLimitAllowlist", "ldapAutoCreate", "ldapAutoDelete", "ldapBaseDN", @@ -512,6 +516,9 @@ "hasWarpSecret": { "type": "boolean" }, + "ipLimitAllowlist": { + "type": "string" + }, "ldapAutoCreate": { "type": "boolean" }, @@ -852,6 +859,7 @@ "hasTgBotToken", "hasTwoFactorToken", "hasWarpSecret", + "ipLimitAllowlist", "ldapAutoCreate", "ldapAutoDelete", "ldapBaseDN", diff --git a/frontend/src/generated/examples.ts b/frontend/src/generated/examples.ts index 7b7535ae3..177392f52 100644 --- a/frontend/src/generated/examples.ts +++ b/frontend/src/generated/examples.ts @@ -5,6 +5,7 @@ export const EXAMPLES: Record = { "expireDiff": 0, "externalTrafficInformEnable": false, "externalTrafficInformURI": "", + "ipLimitAllowlist": "", "ldapAutoCreate": false, "ldapAutoDelete": false, "ldapBaseDN": "", @@ -117,6 +118,7 @@ export const EXAMPLES: Record = { "hasTgBotToken": false, "hasTwoFactorToken": false, "hasWarpSecret": false, + "ipLimitAllowlist": "", "ldapAutoCreate": false, "ldapAutoDelete": false, "ldapBaseDN": "", diff --git a/frontend/src/generated/schemas.ts b/frontend/src/generated/schemas.ts index f4a8ab819..df0763111 100644 --- a/frontend/src/generated/schemas.ts +++ b/frontend/src/generated/schemas.ts @@ -15,6 +15,9 @@ export const SCHEMAS: Record = { "externalTrafficInformURI": { "type": "string" }, + "ipLimitAllowlist": { + "type": "string" + }, "ldapAutoCreate": { "type": "boolean" }, @@ -348,6 +351,7 @@ export const SCHEMAS: Record = { "expireDiff", "externalTrafficInformEnable", "externalTrafficInformURI", + "ipLimitAllowlist", "ldapAutoCreate", "ldapAutoDelete", "ldapBaseDN", @@ -486,6 +490,9 @@ export const SCHEMAS: Record = { "hasWarpSecret": { "type": "boolean" }, + "ipLimitAllowlist": { + "type": "string" + }, "ldapAutoCreate": { "type": "boolean" }, @@ -826,6 +833,7 @@ export const SCHEMAS: Record = { "hasTgBotToken", "hasTwoFactorToken", "hasWarpSecret", + "ipLimitAllowlist", "ldapAutoCreate", "ldapAutoDelete", "ldapBaseDN", diff --git a/frontend/src/generated/types.ts b/frontend/src/generated/types.ts index 695bbc945..49b54e9d8 100644 --- a/frontend/src/generated/types.ts +++ b/frontend/src/generated/types.ts @@ -13,6 +13,7 @@ export interface AllSetting { expireDiff: number; externalTrafficInformEnable: boolean; externalTrafficInformURI: string; + ipLimitAllowlist: string; ldapAutoCreate: boolean; ldapAutoDelete: boolean; ldapBaseDN: string; @@ -126,6 +127,7 @@ export interface AllSettingView { hasTgBotToken: boolean; hasTwoFactorToken: boolean; hasWarpSecret: boolean; + ipLimitAllowlist: string; ldapAutoCreate: boolean; ldapAutoDelete: boolean; ldapBaseDN: string; diff --git a/frontend/src/generated/zod.ts b/frontend/src/generated/zod.ts index fe67d9b5d..4068b1e6a 100644 --- a/frontend/src/generated/zod.ts +++ b/frontend/src/generated/zod.ts @@ -29,6 +29,7 @@ export const AllSettingSchema = z.object({ expireDiff: z.number().int().min(0), externalTrafficInformEnable: z.boolean(), externalTrafficInformURI: z.string(), + ipLimitAllowlist: z.string(), ldapAutoCreate: z.boolean(), ldapAutoDelete: z.boolean(), ldapBaseDN: z.string(), @@ -143,6 +144,7 @@ export const AllSettingViewSchema = z.object({ hasTgBotToken: z.boolean(), hasTwoFactorToken: z.boolean(), hasWarpSecret: z.boolean(), + ipLimitAllowlist: z.string(), ldapAutoCreate: z.boolean(), ldapAutoDelete: z.boolean(), ldapBaseDN: z.string(), diff --git a/frontend/src/models/setting.ts b/frontend/src/models/setting.ts index fb607aefe..464aa0d52 100644 --- a/frontend/src/models/setting.ts +++ b/frontend/src/models/setting.ts @@ -9,6 +9,7 @@ export class AllSetting { webBasePath = '/'; sessionMaxAge = 360; trustedProxyCIDRs = '127.0.0.1/32,::1/128'; + ipLimitAllowlist = ''; panelOutbound = ''; pageSize = 25; expireDiff = 0; diff --git a/frontend/src/pages/settings/GeneralTab.tsx b/frontend/src/pages/settings/GeneralTab.tsx index a195692bb..b4d8eae20 100644 --- a/frontend/src/pages/settings/GeneralTab.tsx +++ b/frontend/src/pages/settings/GeneralTab.tsx @@ -191,6 +191,18 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp /> + + updateSetting({ ipLimitAllowlist: e.target.value })} + /> + +