3x-ui/web/service/speedlimit.go
copilot-swe-agent[bot] 51a4d33a91
feat: add per-inbound download/upload speed limit (KB/s) via Linux tc
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>
2026-04-04 05:26:18 +00:00

192 lines
6.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package service
import (
"fmt"
"os/exec"
"strings"
"github.com/mhsanaei/3x-ui/v2/database/model"
"github.com/mhsanaei/3x-ui/v2/logger"
)
const (
tcMaxRate = "10gbit"
tcMaxBurst = "100m"
tcRootHandle = "1:"
tcRootClass = "1:1"
tcDefaultClass = "1:65535"
tcDefaultPrio = "65535"
)
// 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.
// The service is stateless all state lives in the kernel's tc tables.
type SpeedLimitService struct{}
// 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()
if err == nil && len(out) > 0 {
fields := strings.Fields(string(out))
for i, f := range fields {
if f == "dev" && i+1 < len(fields) {
return fields[i+1], nil
}
}
}
return "", fmt.Errorf("cannot determine default network interface")
}
func runTC(args ...string) error {
cmd := exec.Command("tc", args...)
output, err := cmd.CombinedOutput()
if err != nil {
logger.Debugf("tc %v: %v: %s", args, err, string(output))
}
return err
}
// ensureHTBRoot ensures the root HTB qdisc and required parent classes exist on the interface.
// It is safe to call multiple times (idempotent via add-then-change fallback).
func (s *SpeedLimitService) ensureHTBRoot(iface string) {
// Try to add; if it fails the qdisc already exists try change in case it is already HTB.
if err := runTC("qdisc", "add", "dev", iface, "root",
"handle", tcRootHandle, "htb", "default", tcDefaultPrio); err != nil {
runTC("qdisc", "change", "dev", iface, "root",
"handle", tcRootHandle, "htb", "default", tcDefaultPrio)
}
// Root class (1:1) — full speed ceiling for all children.
if err := runTC("class", "add", "dev", iface, "parent", tcRootHandle,
"classid", tcRootClass, "htb", "rate", tcMaxRate, "burst", tcMaxBurst); err != nil {
runTC("class", "change", "dev", iface, "parent", tcRootHandle,
"classid", tcRootClass, "htb", "rate", tcMaxRate, "burst", tcMaxBurst)
}
// Default leaf class (1:65535) — unlimited, used by unmatched traffic.
if err := runTC("class", "add", "dev", iface, "parent", tcRootClass,
"classid", tcDefaultClass, "htb", "rate", tcMaxRate, "burst", tcMaxBurst); err != nil {
runTC("class", "change", "dev", iface, "parent", tcRootClass,
"classid", tcDefaultClass, "htb", "rate", tcMaxRate, "burst", tcMaxBurst)
}
}
// ensureIngress ensures an ingress qdisc exists on the interface.
func (s *SpeedLimitService) ensureIngress(iface string) {
// Ignore error qdisc may already be present.
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)
}
// 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)
}
// kbitRate converts a KB/s value to a kbit/s string suitable for tc rate arguments.
func kbitRate(kbps int64) string {
return fmt.Sprintf("%dkbit", kbps*8)
}
// burstValue returns a sensible burst size string for the given KB/s rate.
func burstValue(kbps int64) string {
burst := kbps / 8
if burst < 1 {
burst = 1
}
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.
func (s *SpeedLimitService) ApplySpeedLimit(inbound *model.Inbound) error {
if inbound.SpeedLimitDown == 0 && inbound.SpeedLimitUp == 0 {
s.RemoveSpeedLimit(inbound)
return nil
}
iface, err := s.getDefaultInterface()
if err != nil {
return fmt.Errorf("speed limit: %v", err)
}
// Remove existing rules for this inbound first (idempotent).
s.RemoveSpeedLimit(inbound)
port := fmt.Sprintf("%d", inbound.Port)
prio := prioStr(inbound)
if inbound.SpeedLimitDown > 0 {
// Egress: limit traffic leaving the server (= client download).
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 {
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 {
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).
s.ensureIngress(iface)
rate := kbitRate(inbound.SpeedLimitUp)
burst := burstValue(inbound.SpeedLimitUp)
if err := runTC("filter", "add", "dev", iface, "parent", "ffff:",
"protocol", "ip", "prio", prio,
"u32", "match", "ip", "dport", port, "0xffff",
"police", "rate", rate, "burst", burst, "drop"); err != nil {
logger.Warningf("speed limit: failed to add ingress filter for inbound %d: %v", inbound.Id, err)
}
}
return nil
}
// RemoveSpeedLimit deletes any tc rules previously applied for the inbound.
func (s *SpeedLimitService) RemoveSpeedLimit(inbound *model.Inbound) {
iface, err := s.getDefaultInterface()
if err != nil {
logger.Debugf("speed limit cleanup: %v", err)
return
}
prio := prioStr(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))
// Delete ingress filter.
runTC("filter", "del", "dev", iface, "parent", "ffff:", "prio", prio)
}
// 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) {
for _, inbound := range inbounds {
if !inbound.Enable {
continue
}
if inbound.SpeedLimitDown > 0 || inbound.SpeedLimitUp > 0 {
if err := s.ApplySpeedLimit(inbound); err != nil {
logger.Errorf("speed limit restore: inbound %d: %v", inbound.Id, err)
}
}
}
}