mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-06 21:24:10 +00:00
193 lines
6.6 KiB
Go
193 lines
6.6 KiB
Go
|
|
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)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|