소스 검색

feat(limitip): let operators exempt trusted addresses from the IP limit (#6230)

* 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 <[email protected]>
Co-authored-by: Sanaei <[email protected]>
n0ctal 20 시간 전
부모
커밋
d6472740dc

+ 8 - 0
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",

+ 2 - 0
frontend/src/generated/examples.ts

@@ -5,6 +5,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "expireDiff": 0,
     "externalTrafficInformEnable": false,
     "externalTrafficInformURI": "",
+    "ipLimitAllowlist": "",
     "ldapAutoCreate": false,
     "ldapAutoDelete": false,
     "ldapBaseDN": "",
@@ -117,6 +118,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "hasTgBotToken": false,
     "hasTwoFactorToken": false,
     "hasWarpSecret": false,
+    "ipLimitAllowlist": "",
     "ldapAutoCreate": false,
     "ldapAutoDelete": false,
     "ldapBaseDN": "",

+ 8 - 0
frontend/src/generated/schemas.ts

@@ -15,6 +15,9 @@ export const SCHEMAS: Record<string, unknown> = {
       "externalTrafficInformURI": {
         "type": "string"
       },
+      "ipLimitAllowlist": {
+        "type": "string"
+      },
       "ldapAutoCreate": {
         "type": "boolean"
       },
@@ -348,6 +351,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "expireDiff",
       "externalTrafficInformEnable",
       "externalTrafficInformURI",
+      "ipLimitAllowlist",
       "ldapAutoCreate",
       "ldapAutoDelete",
       "ldapBaseDN",
@@ -486,6 +490,9 @@ export const SCHEMAS: Record<string, unknown> = {
       "hasWarpSecret": {
         "type": "boolean"
       },
+      "ipLimitAllowlist": {
+        "type": "string"
+      },
       "ldapAutoCreate": {
         "type": "boolean"
       },
@@ -826,6 +833,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "hasTgBotToken",
       "hasTwoFactorToken",
       "hasWarpSecret",
+      "ipLimitAllowlist",
       "ldapAutoCreate",
       "ldapAutoDelete",
       "ldapBaseDN",

+ 2 - 0
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;

+ 2 - 0
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(),

+ 1 - 0
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;

+ 12 - 0
frontend/src/pages/settings/GeneralTab.tsx

@@ -191,6 +191,18 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
               />
             </SettingListItem>
 
+            <SettingListItem
+              paddings="small"
+              title={t('pages.settings.ipLimitAllowlist')}
+              description={t('pages.settings.ipLimitAllowlistDesc')}
+            >
+              <Input
+                value={allSetting.ipLimitAllowlist}
+                placeholder="203.0.113.10,198.51.100.0/24"
+                onChange={(e) => updateSetting({ ipLimitAllowlist: e.target.value })}
+              />
+            </SettingListItem>
+
             <SettingListItem paddings="small" title={t('pages.settings.panelOutbound')} description={t('pages.settings.panelOutboundDesc')}>
               <Select
                 style={{ width: '100%' }}

+ 1 - 0
frontend/src/schemas/setting.ts

@@ -13,6 +13,7 @@ export const AllSettingSchema = z.object({
   webBasePath: absolutePath.optional(),
   sessionMaxAge: z.number().int().min(1).max(525600).optional(),
   trustedProxyCIDRs: z.string().optional(),
+  ipLimitAllowlist: z.string().optional(),
   panelOutbound: z.string().optional(),
   pageSize: z.number().int().min(0).max(1000).optional(),
   expireDiff: nonNegativeInt.optional(),

+ 40 - 1
internal/web/entity/check_valid_test.go

@@ -1,6 +1,9 @@
 package entity
 
-import "testing"
+import (
+	"strings"
+	"testing"
+)
 
 func TestCheckValidSmtpFrom(t *testing.T) {
 	base := func() *AllSetting {
@@ -39,3 +42,39 @@ func TestCheckValidWildcardListenPortConflict(t *testing.T) {
 		t.Errorf("distinct specific listens on the same port should be allowed: %v", err)
 	}
 }
+
+// The allowlist and the trusted-proxy list share one validator, so this also
+// pins that each list still reports its own message (#5378).
+func TestCheckValidIPOrCIDRLists(t *testing.T) {
+	base := func() *AllSetting {
+		return &AllSetting{WebPort: 2053, SubPort: 2096}
+	}
+
+	for _, v := range []string{"", "203.0.113.10", "198.51.100.0/24", " 203.0.113.10 , 2001:db8::/32 ", "203.0.113.10,,"} {
+		s := base()
+		s.IpLimitAllowlist = v
+		if err := s.CheckValid(); err != nil {
+			t.Errorf("ipLimitAllowlist=%q: unexpected error %v", v, err)
+		}
+	}
+
+	for _, v := range []string{"nonsense", "203.0.113.10/33", "203.0.113.10, oops"} {
+		s := base()
+		s.IpLimitAllowlist = v
+		err := s.CheckValid()
+		if err == nil {
+			t.Errorf("ipLimitAllowlist=%q: want error, got nil", v)
+			continue
+		}
+		if !strings.Contains(err.Error(), "IP limit allowlist entry is not valid:") {
+			t.Errorf("ipLimitAllowlist=%q: error %q does not name the setting", v, err)
+		}
+	}
+
+	s := base()
+	s.TrustedProxyCIDRs = "127.0.0.1/32, bogus"
+	err := s.CheckValid()
+	if err == nil || !strings.Contains(err.Error(), "trusted proxy CIDR is not valid: bogus") {
+		t.Errorf("trustedProxyCIDRs error = %v, want it to name the trusted-proxy list and the bad entry", err)
+	}
+}

+ 27 - 11
internal/web/entity/entity.go

@@ -26,6 +26,7 @@ type AllSetting struct {
 	WebBasePath       string `json:"webBasePath" form:"webBasePath"`
 	SessionMaxAge     int    `json:"sessionMaxAge" form:"sessionMaxAge" validate:"gte=1,lte=525600"`
 	TrustedProxyCIDRs string `json:"trustedProxyCIDRs" form:"trustedProxyCIDRs"`
+	IpLimitAllowlist  string `json:"ipLimitAllowlist" form:"ipLimitAllowlist"`
 	PanelOutbound     string `json:"panelOutbound" form:"panelOutbound"`
 
 	PageSize                  int    `json:"pageSize" form:"pageSize" validate:"gte=0,lte=1000"`
@@ -152,6 +153,24 @@ func pathHasForbiddenChar(s string) bool {
 	return false
 }
 
+// checkIPOrCIDRList rejects the first comma-separated entry that is neither a
+// bare address nor a CIDR, naming it with the caller's message.
+func checkIPOrCIDRList(list, message string) error {
+	for entry := range strings.SplitSeq(list, ",") {
+		entry = strings.TrimSpace(entry)
+		if entry == "" {
+			continue
+		}
+		if ip := net.ParseIP(entry); ip != nil {
+			continue
+		}
+		if _, _, err := net.ParseCIDR(entry); err != nil {
+			return common.NewError(message, entry)
+		}
+	}
+	return nil
+}
+
 func (s *AllSetting) CheckValid() error {
 	if s.WebListen != "" {
 		ip := net.ParseIP(s.WebListen)
@@ -234,17 +253,14 @@ func (s *AllSetting) CheckValid() error {
 		s.SubClashPath += "/"
 	}
 
-	for cidr := range strings.SplitSeq(s.TrustedProxyCIDRs, ",") {
-		cidr = strings.TrimSpace(cidr)
-		if cidr == "" {
-			continue
-		}
-		if ip := net.ParseIP(cidr); ip != nil {
-			continue
-		}
-		if _, _, err := net.ParseCIDR(cidr); err != nil {
-			return common.NewError("trusted proxy CIDR is not valid:", cidr)
-		}
+	if err := checkIPOrCIDRList(s.TrustedProxyCIDRs, "trusted proxy CIDR is not valid:"); err != nil {
+		return err
+	}
+
+	// Rejected here rather than skipped at scan time: a typo in an allowlist
+	// entry silently leaves the address unprotected until a trusted network gets banned.
+	if err := checkIPOrCIDRList(s.IpLimitAllowlist, "IP limit allowlist entry is not valid:"); err != nil {
+		return err
 	}
 
 	_, err := time.LoadLocation(s.TimeLocation)

+ 25 - 2
internal/web/job/check_client_ip_job.go

@@ -35,6 +35,7 @@ type CheckClientIpJob struct {
 	disAllowedIps []string
 	bannedSeen    map[string]int64
 	xrayService   service.XrayService
+	allowlist     ipLimitAllowlist
 }
 
 var job *CheckClientIpJob
@@ -67,7 +68,13 @@ func (j *CheckClientIpJob) Run() {
 	if hasLimit {
 		f2bInstalled = j.checkFail2BanInstalled()
 	}
-	j.processObserved(observed, j.resolveEnforce(hasLimit, f2bInstalled), true)
+	// Read only when the limit is actually applied: this runs every 10s and
+	// most panels carry no IP limit at all.
+	enforce := j.resolveEnforce(hasLimit, f2bInstalled)
+	if enforce {
+		j.allowlist = j.loadAllowlist()
+	}
+	j.processObserved(observed, enforce, true)
 }
 
 // resolveEnforce decides whether limits can actually be enforced this run.
@@ -126,6 +133,18 @@ func (j *CheckClientIpJob) hasLimitIp() bool {
 	return err == nil && probe > 0
 }
 
+// loadAllowlist reads the operator's trusted addresses once per scan; a bad
+// read leaves the list empty, which enforces the limit as before rather than
+// silently exempting everyone.
+func (j *CheckClientIpJob) loadAllowlist() ipLimitAllowlist {
+	raw, err := (&service.SettingService{}).GetIpLimitAllowlist()
+	if err != nil {
+		logger.Warning("[LimitIP] could not read the allowlist, enforcing without it:", err)
+		return ipLimitAllowlist{}
+	}
+	return parseIpLimitAllowlist(raw)
+}
+
 const ipScanChunk = 400
 
 func chunkEmails(s []string, size int) [][]string {
@@ -510,7 +529,11 @@ func (j *CheckClientIpJob) updateInboundClientIps(tx *gorm.DB, inboundClientIps
 	j.disAllowedIps = []string{}
 
 	// historical db-only ips are excluded from this count on purpose.
-	keptLive, bannedLive := selectIpsToBan(liveIps, limitIp)
+	limitedIps, allowedIps := j.allowlist.split(liveIps)
+	keptLive, bannedLive := selectIpsToBan(limitedIps, limitIp)
+	// Allowlisted addresses stay connected and out of the count: charging them
+	// against the limit would still cut the shared network the entry protects.
+	keptLive = append(keptLive, allowedIps...)
 	actionable := j.filterAdvancedSinceLastBan(clientEmail, bannedLive)
 	if len(actionable) > 0 {
 		shouldCleanLog = true

+ 49 - 0
internal/web/job/check_client_ip_job_integration_test.go

@@ -419,3 +419,52 @@ func TestHasLimitIp_ProbesClientRecords(t *testing.T) {
 		t.Fatal("hasLimitIp = false with a limit_ip=2 client present")
 	}
 }
+
+// The mirror of TestUpdateInboundClientIps_ExcessLiveIpIsStillBanned: with the
+// older address on the operator's allowlist nothing may be banned, it must not
+// consume the limit, and no fail2ban line may be written for it (#5378).
+func TestUpdateInboundClientIps_AllowlistedIpIsNeitherCountedNorBanned(t *testing.T) {
+	setupIntegrationDB(t)
+
+	const email = "issue5378-office"
+	seedInboundWithClient(t, "inbound-issue5378", email, 1)
+
+	now := time.Now().Unix()
+	row := seedClientIps(t, email, []IPWithTimestamp{
+		{IP: "203.0.113.10", Timestamp: now - 60},
+	})
+
+	j := NewCheckClientIpJob()
+	j.allowlist = parseIpLimitAllowlist("203.0.113.0/24")
+
+	live := []IPWithTimestamp{
+		{IP: "203.0.113.10", Timestamp: now - 5},
+		{IP: "192.0.2.9", Timestamp: now},
+	}
+
+	inbound, err := j.getInboundByEmail(email)
+	if err != nil {
+		t.Fatalf("getInboundByEmail: %v", err)
+	}
+	_, banned := j.updateInboundClientIps(database.GetDB(), row, inbound, email, 1, live, true, false)
+
+	if banned {
+		t.Fatal("an allowlisted address pushed the client over its limit and something was banned")
+	}
+	if len(j.disAllowedIps) != 0 {
+		t.Fatalf("disAllowedIps = %v, want none", j.disAllowedIps)
+	}
+
+	persisted := ipSet(readClientIps(t, email))
+	for _, ip := range []string{"203.0.113.10", "192.0.2.9"} {
+		if _, ok := persisted[ip]; !ok {
+			t.Errorf("%s must still be persisted; got %v", ip, persisted)
+		}
+	}
+
+	if body, err := os.ReadFile(readIpLimitLogPath()); err == nil {
+		if contains(string(body), "203.0.113.10") {
+			t.Fatalf("an allowlisted address reached the fail2ban log:\n%s", body)
+		}
+	}
+}

+ 80 - 0
internal/web/job/ip_limit_allowlist.go

@@ -0,0 +1,80 @@
+package job
+
+import (
+	"net/netip"
+	"strings"
+)
+
+// ipLimitAllowlist holds the operator's trusted addresses and networks. An IP
+// that matches is neither counted towards a client's IP limit nor banned:
+// counting it would still cut the office or campus NAT the entry exists to
+// protect, which is the whole point of the setting (#5378).
+type ipLimitAllowlist struct {
+	prefixes []netip.Prefix
+	addrs    []netip.Addr
+}
+
+// parseIpLimitAllowlist reads the comma-separated form the settings validator
+// enforces, each entry either a CIDR or a bare address. Entries that do not
+// parse are skipped rather than failing the scan: the validator rejects them on
+// save, so anything reaching here is either valid or a hand-edited database.
+func parseIpLimitAllowlist(raw string) ipLimitAllowlist {
+	var list ipLimitAllowlist
+	for _, field := range strings.Split(raw, ",") {
+		field = strings.TrimSpace(field)
+		if field == "" {
+			continue
+		}
+		if prefix, err := netip.ParsePrefix(field); err == nil {
+			list.prefixes = append(list.prefixes, prefix.Masked())
+			continue
+		}
+		if addr, err := netip.ParseAddr(field); err == nil {
+			list.addrs = append(list.addrs, addr.Unmap())
+		}
+	}
+	return list
+}
+
+func (l ipLimitAllowlist) empty() bool {
+	return len(l.prefixes) == 0 && len(l.addrs) == 0
+}
+
+func (l ipLimitAllowlist) contains(ip string) bool {
+	if l.empty() {
+		return false
+	}
+	addr, err := netip.ParseAddr(strings.TrimSpace(ip))
+	if err != nil {
+		return false
+	}
+	addr = addr.Unmap()
+	for _, allowed := range l.addrs {
+		if allowed == addr {
+			return true
+		}
+	}
+	for _, prefix := range l.prefixes {
+		if prefix.Contains(addr) {
+			return true
+		}
+	}
+	return false
+}
+
+// split separates the entries an allowlist protects from the ones the limit
+// still applies to, preserving the caller's ordering in both.
+func (l ipLimitAllowlist) split(entries []IPWithTimestamp) (limited, allowed []IPWithTimestamp) {
+	if l.empty() {
+		return entries, nil
+	}
+	limited = make([]IPWithTimestamp, 0, len(entries))
+	for _, entry := range entries {
+		if l.contains(entry.IP) {
+			allowed = append(allowed, entry)
+			continue
+		}
+		limited = append(limited, entry)
+	}
+	return limited, allowed
+}

+ 70 - 0
internal/web/job/ip_limit_allowlist_test.go

@@ -0,0 +1,70 @@
+package job
+
+import "testing"
+
+// Addresses in the examples below come from the documentation ranges reserved
+// by RFC 5737 and RFC 3849.
+func TestIpLimitAllowlistMatchesAddressesAndNetworks(t *testing.T) {
+	list := parseIpLimitAllowlist("203.0.113.10, 198.51.100.0/24 , 2001:db8::/32, not-an-ip")
+
+	for _, ip := range []string{"203.0.113.10", "198.51.100.7", "2001:db8::1"} {
+		if !list.contains(ip) {
+			t.Fatalf("%s should be allowlisted", ip)
+		}
+	}
+	for _, ip := range []string{"203.0.113.11", "192.0.2.5", "2001:db9::1", ""} {
+		if list.contains(ip) {
+			t.Fatalf("%s must not be allowlisted", ip)
+		}
+	}
+}
+
+// A typo must not disable the limit for everybody, so an unparsable entry is
+// dropped and the rest of the list keeps working.
+func TestIpLimitAllowlistIgnoresUnparsableEntries(t *testing.T) {
+	list := parseIpLimitAllowlist("nonsense, 203.0.113.0/24")
+	if !list.contains("203.0.113.5") {
+		t.Fatal("a valid entry stopped working because a neighbouring one was malformed")
+	}
+	if list.contains("192.0.2.1") {
+		t.Fatal("a malformed entry must not widen the allowlist")
+	}
+	if parseIpLimitAllowlist("nonsense").empty() != true {
+		t.Fatal("a list of only malformed entries must be empty, not permissive")
+	}
+}
+
+// The point of the setting: a shared address is neither banned nor counted, so
+// the office NAT it protects does not consume the client's limit either.
+func TestIpLimitAllowlistSplitKeepsAllowedOutOfTheCount(t *testing.T) {
+	live := []IPWithTimestamp{
+		{IP: "203.0.113.10", Timestamp: 1},
+		{IP: "192.0.2.1", Timestamp: 2},
+		{IP: "192.0.2.2", Timestamp: 3},
+	}
+	list := parseIpLimitAllowlist("203.0.113.10")
+
+	limited, allowed := list.split(live)
+	if len(allowed) != 1 || allowed[0].IP != "203.0.113.10" {
+		t.Fatalf("allowed = %v, want the allowlisted address alone", allowed)
+	}
+	if len(limited) != 2 {
+		t.Fatalf("limited = %v, want the two ordinary addresses", limited)
+	}
+
+	kept, banned := selectIpsToBan(limited, 2)
+	if len(banned) != 0 {
+		t.Fatalf("banned = %v, want none: the allowlisted address must not push an ordinary one over the limit", banned)
+	}
+	if len(kept) != 2 {
+		t.Fatalf("kept = %v, want both ordinary addresses", kept)
+	}
+}
+
+func TestIpLimitAllowlistEmptyListChangesNothing(t *testing.T) {
+	live := []IPWithTimestamp{{IP: "192.0.2.1", Timestamp: 1}, {IP: "192.0.2.2", Timestamp: 2}}
+	limited, allowed := parseIpLimitAllowlist("").split(live)
+	if allowed != nil || len(limited) != 2 {
+		t.Fatalf("empty allowlist changed the input: limited=%v allowed=%v", limited, allowed)
+	}
+}

+ 7 - 0
internal/web/service/setting.go

@@ -64,6 +64,7 @@ var defaultValueMap = map[string]string{
 	"webBasePath":                 normalizeBasePath(getEnv("XUI_INIT_WEB_BASE_PATH", "/")),
 	"sessionMaxAge":               "360",
 	"trustedProxyCIDRs":           DefaultTrustedProxyCIDRs,
+	"ipLimitAllowlist":            "",
 	"pageSize":                    "25",
 	"expireDiff":                  "0",
 	"trafficDiff":                 "0",
@@ -650,6 +651,12 @@ func (s *SettingService) GetSessionMaxAge() (int, error) {
 	return s.getInt("sessionMaxAge")
 }
 
+// GetIpLimitAllowlist returns the operator's trusted addresses and networks,
+// which the IP limit neither counts nor bans.
+func (s *SettingService) GetIpLimitAllowlist() (string, error) {
+	return s.getString("ipLimitAllowlist")
+}
+
 func (s *SettingService) GetTrustedProxyCIDRs() (string, error) {
 	return s.getString("trustedProxyCIDRs")
 }

+ 3 - 1
internal/web/translation/ar-EG.json

@@ -1368,7 +1368,9 @@
       "secretClear": "مسح",
       "secretClearUndo": "تراجع عن المسح",
       "calendarGregorian": "Gregorian (Standard)",
-      "calendarJalalian": "Jalalian (شمسی)"
+      "calendarJalalian": "Jalalian (شمسی)",
+      "ipLimitAllowlist": "قائمة سماح حد IP",
+      "ipLimitAllowlistDesc": "عناوين وشبكات لا يحسبها حد IP ولا يحظرها، حتى لا يستهلك عنوان مكتب أو حرم جامعي مشترك حد العميل. IPs/CIDRs مفصولة بفواصل."
     },
     "xray": {
       "save": "احفظ",

+ 3 - 1
internal/web/translation/en-US.json

@@ -1485,7 +1485,9 @@
       "secretClear": "Clear",
       "secretClearUndo": "Undo clear",
       "calendarGregorian": "Gregorian (Standard)",
-      "calendarJalalian": "Jalalian (شمسی)"
+      "calendarJalalian": "Jalalian (شمسی)",
+      "ipLimitAllowlist": "IP limit allowlist",
+      "ipLimitAllowlistDesc": "Addresses and networks that the IP limit never counts and never bans, so a shared office or campus address cannot use up a client's limit. Comma-separated, IP or CIDR."
     },
     "xray": {
       "save": "Save",

+ 3 - 1
internal/web/translation/es-ES.json

@@ -1368,7 +1368,9 @@
       "secretClear": "Borrar",
       "secretClearUndo": "Deshacer borrado",
       "calendarGregorian": "Gregorian (Standard)",
-      "calendarJalalian": "Jalalian (شمسی)"
+      "calendarJalalian": "Jalalian (شمسی)",
+      "ipLimitAllowlist": "Lista de permitidos del límite de IP",
+      "ipLimitAllowlistDesc": "Direcciones y redes que el límite de IP nunca cuenta ni banea, para que una dirección compartida de oficina o campus no agote el límite de un cliente. IP/CIDR separados por coma."
     },
     "xray": {
       "save": "Guardar configuración",

+ 3 - 1
internal/web/translation/fa-IR.json

@@ -1368,7 +1368,9 @@
       "secretClear": "پاک کردن",
       "secretClearUndo": "لغو پاک کردن",
       "calendarGregorian": "Gregorian (Standard)",
-      "calendarJalalian": "Jalalian (شمسی)"
+      "calendarJalalian": "Jalalian (شمسی)",
+      "ipLimitAllowlist": "فهرست مجاز محدودیت IP",
+      "ipLimitAllowlistDesc": "نشانی‌ها و شبکه‌هایی که محدودیت IP هرگز آن‌ها را نمی‌شمارد و مسدود نمی‌کند، تا نشانی مشترک یک اداره یا دانشگاه محدودیت کاربر را مصرف نکند. IPها/CIDRها (با کاما)."
     },
     "xray": {
       "save": "ذخیره",

+ 3 - 1
internal/web/translation/id-ID.json

@@ -1368,7 +1368,9 @@
       "secretClear": "Hapus",
       "secretClearUndo": "Batalkan hapus",
       "calendarGregorian": "Gregorian (Standard)",
-      "calendarJalalian": "Jalalian (شمسی)"
+      "calendarJalalian": "Jalalian (شمسی)",
+      "ipLimitAllowlist": "Daftar izin batas IP",
+      "ipLimitAllowlistDesc": "Alamat dan jaringan yang tidak pernah dihitung maupun diblokir oleh batas IP, sehingga alamat kantor atau kampus bersama tidak menghabiskan batas klien. IP/CIDR (dipisahkan koma)."
     },
     "xray": {
       "save": "Simpan",

+ 3 - 1
internal/web/translation/ja-JP.json

@@ -1368,7 +1368,9 @@
       "secretClear": "クリア",
       "secretClearUndo": "クリアを取り消す",
       "calendarGregorian": "Gregorian (Standard)",
-      "calendarJalalian": "Jalalian (شمسی)"
+      "calendarJalalian": "Jalalian (شمسی)",
+      "ipLimitAllowlist": "IP 制限の許可リスト",
+      "ipLimitAllowlistDesc": "IP 制限がカウントもブロックもしないアドレスとネットワーク。オフィスや学内の共有アドレスがクライアントの上限を使い切らないようにします。IP/CIDR (カンマ区切り)。"
     },
     "xray": {
       "importRules": "ルールをインポート",

+ 3 - 1
internal/web/translation/pt-BR.json

@@ -1368,7 +1368,9 @@
       "secretClear": "Limpar",
       "secretClearUndo": "Desfazer limpeza",
       "calendarGregorian": "Gregorian (Standard)",
-      "calendarJalalian": "Jalalian (شمسی)"
+      "calendarJalalian": "Jalalian (شمسی)",
+      "ipLimitAllowlist": "Lista de permissões do limite de IP",
+      "ipLimitAllowlistDesc": "Endereços e redes que o limite de IP nunca conta nem bane, para que um endereço compartilhado de escritório ou campus não esgote o limite de um cliente. IPs/CIDRs separados por vírgula."
     },
     "xray": {
       "importRules": "Importar regras",

+ 3 - 1
internal/web/translation/ru-RU.json

@@ -1368,7 +1368,9 @@
       "secretClear": "Очистить",
       "secretClearUndo": "Отменить очистку",
       "calendarGregorian": "Григорианский (обычный)",
-      "calendarJalalian": "Джалали (شمسی)"
+      "calendarJalalian": "Джалали (شمسی)",
+      "ipLimitAllowlist": "Доверенные адреса для лимита",
+      "ipLimitAllowlistDesc": "Адреса и подсети, которые лимит не считает и не банит: общий офисный или студенческий адрес не израсходует лимит клиента. Через запятую, адрес или подсеть."
     },
     "xray": {
       "importRules": "Импорт правил",

+ 3 - 1
internal/web/translation/tr-TR.json

@@ -1368,7 +1368,9 @@
       "secretClear": "Temizle",
       "secretClearUndo": "Temizlemeyi geri al",
       "calendarGregorian": "Gregorian (Standard)",
-      "calendarJalalian": "Jalalian (شمسی)"
+      "calendarJalalian": "Jalalian (شمسی)",
+      "ipLimitAllowlist": "IP limiti izin listesi",
+      "ipLimitAllowlistDesc": "IP limitinin asla saymadığı ve engellemediği adresler ve ağlar; böylece ortak bir ofis veya kampüs adresi kullanıcının limitini tüketmez. IP'ler/CIDR'ler (virgülle ayrılmış)."
     },
     "xray": {
       "save": "Kaydet",

+ 3 - 1
internal/web/translation/uk-UA.json

@@ -1368,7 +1368,9 @@
       "secretClear": "Очистити",
       "secretClearUndo": "Скасувати очищення",
       "calendarGregorian": "Григоріанський (звичайний)",
-      "calendarJalalian": "Джалалі (شمسی)"
+      "calendarJalalian": "Джалалі (شمسی)",
+      "ipLimitAllowlist": "Довірені адреси для ліміту",
+      "ipLimitAllowlistDesc": "Адреси та підмережі, які ліміт не рахує і не банить: спільна офісна чи студентська адреса не витратить ліміт клієнта. Через кому, адреса або підмережа."
     },
     "xray": {
       "save": "Зберегти",

+ 3 - 1
internal/web/translation/vi-VN.json

@@ -1368,7 +1368,9 @@
       "secretClear": "Xóa",
       "secretClearUndo": "Hoàn tác xóa",
       "calendarGregorian": "Gregorian (Standard)",
-      "calendarJalalian": "Jalalian (شمسی)"
+      "calendarJalalian": "Jalalian (شمسی)",
+      "ipLimitAllowlist": "Danh sách cho phép của giới hạn IP",
+      "ipLimitAllowlistDesc": "Các địa chỉ và mạng mà giới hạn IP không bao giờ tính và không bao giờ chặn, để một địa chỉ dùng chung của văn phòng hoặc trường học không dùng hết giới hạn của người dùng. IPs/CIDRs cách nhau bằng dấu phẩy."
     },
     "xray": {
       "importRules": "Nhập quy tắc",

+ 3 - 1
internal/web/translation/zh-CN.json

@@ -1368,7 +1368,9 @@
       "secretClear": "清除",
       "secretClearUndo": "撤销清除",
       "calendarGregorian": "Gregorian (Standard)",
-      "calendarJalalian": "Jalalian (شمسی)"
+      "calendarJalalian": "Jalalian (شمسی)",
+      "ipLimitAllowlist": "IP 限制白名单",
+      "ipLimitAllowlistDesc": "IP 限制永远不会计入也不会封禁的地址和网段,避免办公室或校园的共享地址耗尽客户端的限额。IP/CIDR(逗号分隔)。"
     },
     "xray": {
       "importRules": "导入规则",

+ 3 - 1
internal/web/translation/zh-TW.json

@@ -1368,7 +1368,9 @@
       "secretClear": "清除",
       "secretClearUndo": "復原清除",
       "calendarGregorian": "Gregorian (Standard)",
-      "calendarJalalian": "Jalalian (شمسی)"
+      "calendarJalalian": "Jalalian (شمسی)",
+      "ipLimitAllowlist": "IP 限制白名單",
+      "ipLimitAllowlistDesc": "IP 限制永遠不會計入也不會封鎖的位址與網段,避免辦公室或校園的共用位址耗盡客戶端的額度。IP/CIDR(逗號分隔)。"
     },
     "xray": {
       "save": "儲存",