fix: address code review issues in SpeedLimitService

Agent-Logs-Url: https://github.com/xAlokyx/3x-ui/sessions/9e10f937-9919-4186-8ac8-caa00f2593b8

Co-authored-by: xAlokyx <234771438+xAlokyx@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2026-04-04 05:30:05 +00:00 committed by GitHub
parent 51a4d33a91
commit 6122a803de
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 53 additions and 15 deletions

View file

@ -520,7 +520,9 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
}
s.xrayApi.Close()
// Propagate the new speed limits to tc (must happen after the save so the ID is stable).
// Propagate the new speed limits to tc after a successful save.
// Speed limits are applied after tx.Save so that a rollback does not leave
// stale tc rules pointing at settings that were never persisted.
oldInbound.SpeedLimitDown = inbound.SpeedLimitDown
oldInbound.SpeedLimitUp = inbound.SpeedLimitUp
saveErr := tx.Save(oldInbound).Error

View file

@ -16,13 +16,28 @@ const (
tcRootClass = "1:1"
tcDefaultClass = "1:65535"
tcDefaultPrio = "65535"
// tcClassOffset is added to the inbound ID to derive the HTB class minor number.
// This avoids collision with the reserved default class (65535) and the root class (1).
// Inbound IDs start at 1; with an offset of 100 they map to 101-65434 (well within range).
tcClassOffset = 100
// tcMinBurstKB is the minimum burst size in KB for tc rate-limit rules.
tcMinBurstKB = 32
)
// SpeedLimitService manages per-inbound bandwidth limiting using Linux tc (traffic control).
// Download limits are enforced via HTB egress classes; upload limits via ingress police filters.
// NOTE: Only IPv4 traffic is rate-limited. IPv6 connections bypass these limits.
// NOTE: tc is only available on Linux. On other platforms all methods are no-ops.
// The service is stateless all state lives in the kernel's tc tables.
type SpeedLimitService struct{}
// tcAvailable reports whether the tc binary is accessible.
// It is checked lazily on first use; failures are logged as warnings.
func tcAvailable() bool {
_, err := exec.LookPath("tc")
return err == nil
}
// getDefaultInterface returns the name of the primary network interface used for the default route.
func (s *SpeedLimitService) getDefaultInterface() (string, error) {
out, err := exec.Command("ip", "route", "show", "default").Output()
@ -75,15 +90,16 @@ func (s *SpeedLimitService) ensureIngress(iface string) {
runTC("qdisc", "add", "dev", iface, "handle", "ffff:", "ingress")
}
// classID returns the HTB class identifier string for the given inbound.
func classID(inbound *model.Inbound) string {
return fmt.Sprintf("1:%d", inbound.Id)
// tcHandle returns the HTB class handle string for the given inbound (e.g. "1:101").
// The offset prevents collisions with reserved class IDs (1 and 65535).
func tcHandle(inbound *model.Inbound) string {
return fmt.Sprintf("1:%d", inbound.Id+tcClassOffset)
}
// prioStr returns the tc filter priority string for the given inbound.
// Using the inbound ID keeps it unique and stable across service restarts.
func prioStr(inbound *model.Inbound) string {
return fmt.Sprintf("%d", inbound.Id)
// tcPrio returns the tc filter priority string for the given inbound.
// The offset keeps priorities in a distinct range from reserved values.
func tcPrio(inbound *model.Inbound) string {
return fmt.Sprintf("%d", inbound.Id+tcClassOffset)
}
// kbitRate converts a KB/s value to a kbit/s string suitable for tc rate arguments.
@ -92,22 +108,31 @@ func kbitRate(kbps int64) string {
}
// burstValue returns a sensible burst size string for the given KB/s rate.
// A minimum of tcMinBurstKB is enforced to avoid excessive drops on bursty traffic.
func burstValue(kbps int64) string {
burst := kbps / 8
if burst < 1 {
burst = 1
if burst < tcMinBurstKB {
burst = tcMinBurstKB
}
return fmt.Sprintf("%dk", burst)
}
// ApplySpeedLimit configures tc rules to enforce download/upload limits for the inbound.
// Calling with SpeedLimitDown == 0 && SpeedLimitUp == 0 removes any existing rules.
// Returns an error if tc is unavailable or the default interface cannot be determined;
// individual tc sub-commands that fail are only logged (not returned) to keep the
// inbound operation from failing.
func (s *SpeedLimitService) ApplySpeedLimit(inbound *model.Inbound) error {
if inbound.SpeedLimitDown == 0 && inbound.SpeedLimitUp == 0 {
s.RemoveSpeedLimit(inbound)
return nil
}
if !tcAvailable() {
logger.Warning("speed limit: 'tc' binary not found; speed limits will not be enforced")
return nil
}
iface, err := s.getDefaultInterface()
if err != nil {
return fmt.Errorf("speed limit: %v", err)
@ -117,30 +142,33 @@ func (s *SpeedLimitService) ApplySpeedLimit(inbound *model.Inbound) error {
s.RemoveSpeedLimit(inbound)
port := fmt.Sprintf("%d", inbound.Port)
prio := prioStr(inbound)
handle := tcHandle(inbound)
prio := tcPrio(inbound)
if inbound.SpeedLimitDown > 0 {
// Egress: limit traffic leaving the server (= client download).
// Note: only IPv4 traffic is matched; IPv6 is not rate-limited.
s.ensureHTBRoot(iface)
rate := kbitRate(inbound.SpeedLimitDown)
burst := burstValue(inbound.SpeedLimitDown)
if err := runTC("class", "add", "dev", iface, "parent", tcRootClass,
"classid", classID(inbound), "htb", "rate", rate, "burst", burst); err != nil {
"classid", handle, "htb", "rate", rate, "burst", burst); err != nil {
logger.Warningf("speed limit: failed to add egress class for inbound %d: %v", inbound.Id, err)
}
if err := runTC("filter", "add", "dev", iface, "parent", tcRootHandle,
"protocol", "ip", "prio", prio,
"u32", "match", "ip", "sport", port, "0xffff",
"flowid", classID(inbound)); err != nil {
"flowid", handle); err != nil {
logger.Warningf("speed limit: failed to add egress filter for inbound %d: %v", inbound.Id, err)
}
}
if inbound.SpeedLimitUp > 0 {
// Ingress: police traffic arriving at the server (= client upload).
// Note: only IPv4 traffic is matched; IPv6 is not rate-limited.
s.ensureIngress(iface)
rate := kbitRate(inbound.SpeedLimitUp)
@ -159,17 +187,21 @@ func (s *SpeedLimitService) ApplySpeedLimit(inbound *model.Inbound) error {
// RemoveSpeedLimit deletes any tc rules previously applied for the inbound.
func (s *SpeedLimitService) RemoveSpeedLimit(inbound *model.Inbound) {
if !tcAvailable() {
return
}
iface, err := s.getDefaultInterface()
if err != nil {
logger.Debugf("speed limit cleanup: %v", err)
return
}
prio := prioStr(inbound)
prio := tcPrio(inbound)
// Delete egress filter and class (errors are expected when rules don't exist).
runTC("filter", "del", "dev", iface, "parent", tcRootHandle, "prio", prio)
runTC("class", "del", "dev", iface, "classid", classID(inbound))
runTC("class", "del", "dev", iface, "classid", tcHandle(inbound))
// Delete ingress filter.
runTC("filter", "del", "dev", iface, "parent", "ffff:", "prio", prio)
@ -178,6 +210,10 @@ func (s *SpeedLimitService) RemoveSpeedLimit(inbound *model.Inbound) {
// RestoreAllLimits re-applies tc rules for every enabled inbound that has a speed limit set.
// This should be called once on server startup since tc rules do not survive reboots.
func (s *SpeedLimitService) RestoreAllLimits(inbounds []*model.Inbound) {
if !tcAvailable() {
logger.Warning("speed limit: 'tc' binary not found; per-inbound speed limits are disabled")
return
}
for _, inbound := range inbounds {
if !inbound.Enable {
continue