Skip to content

fix: prevent multi-GB traffic overages after bandwidth limit is reached#3958

Open
WatchDogsDev wants to merge 1 commit intoMHSanaei:mainfrom
WatchDogsDev:fix/traffic-overage-enforcement
Open

fix: prevent multi-GB traffic overages after bandwidth limit is reached#3958
WatchDogsDev wants to merge 1 commit intoMHSanaei:mainfrom
WatchDogsDev:fix/traffic-overage-enforcement

Conversation

@WatchDogsDev
Copy link
Copy Markdown

Problem

When a client exceeds their bandwidth limit, the 10-second traffic check job often fails to stop them in time. Clients can overshoot their limit by 10 GB or more. There are three distinct causes:

Bucket A — in-flight gap (~10 s of traffic):
Between GetTraffic(reset=true) zeroing the Xray counter and RemoveUser() taking effect, the client keeps generating traffic. Those bytes land in the freshly-zeroed counter and are counted one cycle late — a minor but real overage.

Bucket B — active TCP connections survive removal (primary cause of multi-GB overages):
RemoveUser() removes the client from Xray's auth table but does not close existing established connections. A mid-download client on a persistent TLS tunnel keeps transferring until the connection closes naturally.

Bucket C — restart race (catastrophic, permanent data loss):
When RemoveUser() fails, needRestart=true is set and Xray restarts up to 30 seconds later. On restart all in-memory stats counters are zeroed — bytes transferred during that window are permanently lost and never credited to the client.


Changes

Fix 1 — Flush traffic to DB before Xray restart (Bucket C)

web/service/xray.go — New FlushTrafficToDB() method that fetches current Xray stats and persists them via inboundService.AddTraffic().

web/web.go — Call FlushTrafficToDB() inside the 30 s restart cron before RestartXray(false), so in-memory counters are saved before Xray zeros them.

Fix 2 — Drain residual per-user traffic after RemoveUser (Bucket A)

xray/api.go — New DrainUserTraffic(email) method that calls GetStats with Reset_: true for both user>>>email>>>traffic>>>uplink and >>>downlink, capturing bytes accumulated since the last bulk reset.

web/service/inbound.go disableInvalidClients() — After a successful RemoveUser(), calls DrainUserTraffic and adds the delta to the client's DB row with gorm.Expr("up + ?", ...).

Fix 3 — iptables DROP block for active TCP connections (Bucket B)

util/iptables/iptables.go (new) — Utility package managing a dedicated 3X-UI-BLOCK iptables chain:

  • EnsureChain() — creates the chain and adds a jump from INPUT (idempotent)
  • FlushChain() — removes all rules (used on startup)
  • BlockIP(ip, port) — inserts a DROP rule with a 3xui:block:<timestamp> comment
  • UnblockIP(ip, port) — removes the DROP rule
  • ListRules() — parses iptables -S 3X-UI-BLOCK into structured RuleEntry values

All rules are isolated to the custom chain — no OS/admin rules are touched. If iptables is not available, functions log a warning and return gracefully.

web/job/unblock_ips_job.go (new) — Background job scheduled @every 5m that enumerates rules in 3X-UI-BLOCK and removes any older than maxBlockAgeSecs (10 minutes).

web/service/inbound.go — Two helpers:

  • blockClientIPs(email) — reads known IPs from inbound_client_ips, gets the inbound port, calls iptables.BlockIP for each IP. Called after a successful RemoveUser() in disableInvalidClients().
  • unblockClientIPs(email) — mirror of the above using iptables.UnblockIP. Called after a successful AddUser() in autoRenewClients() so renewed clients can reconnect.

web/web.go — On startup calls iptables.EnsureChain() then iptables.FlushChain() to initialize the chain and clear any stale rules from a prior crash. Registers NewUnblockIPsJob() at @every 5m.


Testing

  1. Create a client with a small total limit (e.g. 50 MB).
  2. Start a large download through that client.
  3. Within one 10-second cycle of hitting the limit, verify:
    • client_traffics.enable = 0 in DB
    • iptables -L 3X-UI-BLOCK -n shows DROP rules for the client's IPs on the inbound port
    • Download stalls / connection is dropped
  4. Reset or renew the client:
    • Verify iptables rules are removed
    • Verify the client can reconnect
  5. Simulate a RemoveUser() gRPC failure → verify client_traffics totals are accurate after the subsequent Xray restart (no bytes permanently lost).

Notes

  • Requires iptables to be available on the host (standard on all Linux distributions used with 3x-ui). Absence is handled gracefully — normal disable logic continues, only the IP block step is skipped with a warning log.
  • IPv6 support (ip6tables) can be added as a follow-up.
  • The maxBlockAgeSecs constant in unblock_ips_job.go controls the auto-expiry window (default: 10 minutes).

Three layered fixes targeting the distinct causes of overage:

Bucket C (catastrophic): flush pending Xray stats to DB before every
scheduled Xray restart so in-memory counters are never silently zeroed.
- web/service/xray.go: add FlushTrafficToDB()
- web/web.go: call FlushTrafficToDB() in the 30 s restart cron before
  RestartXray(false)

Bucket A (in-flight gap): drain per-user Xray stats counters immediately
after RemoveUser() succeeds, capturing bytes accumulated since the last
bulk GetTraffic(reset=true) cycle.
- xray/api.go: add DrainUserTraffic(email) using GetStats gRPC with reset
- web/service/inbound.go: call DrainUserTraffic and persist delta in
  disableInvalidClients()

Bucket B (active TCP connections survive removal): insert iptables DROP
rules for each known client IP on the inbound port so established
connections are killed immediately, not just new ones.
- util/iptables/iptables.go: new package managing the 3X-UI-BLOCK chain
  (EnsureChain, FlushChain, BlockIP, UnblockIP, ListRules); gracefully
  degrades when iptables is unavailable
- web/job/unblock_ips_job.go: @every 5m cleanup job removes rules older
  than maxBlockAgeSecs
- web/service/inbound.go: blockClientIPs() called after successful
  RemoveUser(); unblockClientIPs() called after successful AddUser() in
  autoRenewClients() so renewed clients can reconnect
- web/web.go: EnsureChain + FlushChain on startup; register unblock job
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant