diff --git a/adapter/router.go b/adapter/router.go index ec23d9311f..e6533bd1cf 100644 --- a/adapter/router.go +++ b/adapter/router.go @@ -70,6 +70,7 @@ type Rule interface { Match(metadata *InboundContext) bool Outbound() string String() string + Limiters() []string } type DNSRule interface { diff --git a/box.go b/box.go index 0a66c88c3a..ac1625a88a 100644 --- a/box.go +++ b/box.go @@ -12,6 +12,7 @@ import ( "github.com/sagernet/sing-box/experimental" "github.com/sagernet/sing-box/experimental/libbox/platform" "github.com/sagernet/sing-box/inbound" + "github.com/sagernet/sing-box/limiter" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" "github.com/sagernet/sing-box/outbound" @@ -76,6 +77,9 @@ func New(options Options) (*Box, error) { if err != nil { return nil, E.Cause(err, "create log factory") } + if len(options.Limiters) > 0 { + ctx = limiter.WithDefault(ctx, logFactory.NewLogger("limiter"), options.Limiters) + } router, err := route.NewRouter( ctx, logFactory, diff --git a/docs/configuration/index.md b/docs/configuration/index.md index a4ade70726..05208ebb64 100644 --- a/docs/configuration/index.md +++ b/docs/configuration/index.md @@ -11,6 +11,7 @@ sing-box uses JSON for configuration files. "ntp": {}, "inbounds": [], "outbounds": [], + "limiters": [], "route": {}, "experimental": {} } @@ -25,6 +26,7 @@ sing-box uses JSON for configuration files. | `ntp` | [NTP](./ntp) | | `inbounds` | [Inbound](./inbound) | | `outbounds` | [Outbound](./outbound) | +| `limiters` | [Limiter](./limiter) | | `route` | [Route](./route) | | `experimental` | [Experimental](./experimental) | diff --git a/docs/configuration/index.zh.md b/docs/configuration/index.zh.md index 80b2ebd385..e532bd6234 100644 --- a/docs/configuration/index.zh.md +++ b/docs/configuration/index.zh.md @@ -10,6 +10,7 @@ sing-box 使用 JSON 作为配置文件格式。 "dns": {}, "inbounds": [], "outbounds": [], + "limiters": [], "route": {}, "experimental": {} } @@ -23,6 +24,7 @@ sing-box 使用 JSON 作为配置文件格式。 | `dns` | [DNS](./dns) | | `inbounds` | [入站](./inbound) | | `outbounds` | [出站](./outbound) | +| `limiters` | [限速](./limiter) | | `route` | [路由](./route) | | `experimental` | [实验性](./experimental) | diff --git a/docs/configuration/limiter/index.md b/docs/configuration/limiter/index.md new file mode 100644 index 0000000000..b0f1ec56c7 --- /dev/null +++ b/docs/configuration/limiter/index.md @@ -0,0 +1,56 @@ +# Limiter + +### Structure + +```json +{ + "limiters": [ + { + "tag": "limiter-a", + "download": "10M", + "upload": "1M", + "auth_user": [ + "user-a", + "user-b" + ], + "auth_user_independent": false, + "inbound": [ + "in-a", + "in-b" + ], + "inbound_independent": false + } + ] +} + +``` + +### Fields + +#### download upload + +==Required== + +Format: `[Integer][Unit]` e.g. `100M, 100m, 1G, 1g`. + +Supported units (case insensitive): `B, K, M, G, T, P, E`. + +#### tag + +The tag of the limiter, used in route rule. + +#### auth_user + +Apply limiter for a group of usernames, see each inbound for details. + +#### auth_user_independent + +Make each auth_user's limiter independent. If disabled, the same limiter will be shared. + +#### inbound + +Apply limiter for a group of inbounds. + +#### inbound_independent + +Make each inbound's limiter independent. If disabled, the same limiter will be shared. diff --git a/docs/configuration/limiter/index.zh.md b/docs/configuration/limiter/index.zh.md new file mode 100644 index 0000000000..342635de22 --- /dev/null +++ b/docs/configuration/limiter/index.zh.md @@ -0,0 +1,56 @@ +# 限速 + +### 结构 + +```json +{ + "limiters": [ + { + "tag": "limiter-a", + "download": "10M", + "upload": "1M", + "auth_user": [ + "user-a", + "user-b" + ], + "auth_user_independent": false, + "inbound": [ + "in-a", + "in-b" + ], + "inbound_independent": false + } + ] +} + +``` + +### 字段 + +#### download upload + +==必填== + +格式: `[Integer][Unit]` 例如: `100M, 100m, 1G, 1g`. + +支持的单位 (大小写不敏感): `B, K, M, G, T, P, E`. + +#### tag + +限速标签,在路由规则中使用。 + +#### auth_user + +用户组限速,参阅入站设置。 + +#### auth_user_independent + +使每个用户有单独的限速。关闭时将共享限速。 + +#### inbound + +入站组限速。 + +#### inbound_independent + +使每个入站有单独的限速。关闭时将共享限速。 diff --git a/docs/configuration/route/rule.md b/docs/configuration/route/rule.md index 3cee478dc3..77ce35ec6a 100644 --- a/docs/configuration/route/rule.md +++ b/docs/configuration/route/rule.md @@ -84,14 +84,22 @@ ], "clash_mode": "direct", "invert": false, - "outbound": "direct" + "outbound": "direct", + "limiter": [ + "limiter-a", + "limiter-b" + ] }, { "type": "logical", "mode": "and", "rules": [], "invert": false, - "outbound": "direct" + "outbound": "direct", + "limiter": [ + "limiter-a", + "limiter-b" + ] } ] } @@ -238,6 +246,10 @@ Invert match result. Tag of the target outbound. +#### limiter + +Tags of [Limiter](/configuration/limiter). Take effect for all connections matching this rule. + ### Logical Fields #### type diff --git a/docs/configuration/route/rule.zh.md b/docs/configuration/route/rule.zh.md index 4a09ed8e64..1bdb83071b 100644 --- a/docs/configuration/route/rule.zh.md +++ b/docs/configuration/route/rule.zh.md @@ -82,14 +82,22 @@ ], "clash_mode": "direct", "invert": false, - "outbound": "direct" + "outbound": "direct", + "limiter": [ + "limiter-a", + "limiter-b" + ] }, { "type": "logical", "mode": "and", "rules": [], "invert": false, - "outbound": "direct" + "outbound": "direct", + "limiter": [ + "limiter-a", + "limiter-b" + ] } ] } @@ -236,6 +244,10 @@ 目标出站的标签。 +#### limiter + +[限速](/zh/configuration/inbound) 标签。对所有匹配该规则的连接生效。 + ### 逻辑字段 #### type diff --git a/limiter/builder.go b/limiter/builder.go new file mode 100644 index 0000000000..78e330739d --- /dev/null +++ b/limiter/builder.go @@ -0,0 +1,111 @@ +package limiter + +import ( + "context" + "fmt" + "net" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/humanize" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/service" +) + +const ( + prefixTag = "tag" + prefixUser = "user" + prefixInbound = "inbound" +) + +var _ Manager = (*defaultManager)(nil) + +type limiterKey struct { + Prefix string + Name string +} + +type defaultManager struct { + mp map[limiterKey]*limiter +} + +func WithDefault(ctx context.Context, logger log.ContextLogger, options []option.Limiter) context.Context { + m := &defaultManager{mp: make(map[limiterKey]*limiter)} + for i, option := range options { + if err := m.createLimiter(ctx, option); err != nil { + logger.ErrorContext(ctx, fmt.Sprintf("id=%d, %s", i, err)) + } else { + logger.InfoContext(ctx, fmt.Sprintf("id=%d, tag=%s, users=%v, inbounds=%v, download=%s, upload=%s", + i, option.Tag, option.AuthUser, option.Inbound, option.Download, option.Upload)) + } + } + return service.ContextWith[Manager](ctx, m) +} + +func (m *defaultManager) createLimiter(ctx context.Context, option option.Limiter) (err error) { + var download, upload uint64 + if option.Download != "" { + download, err = humanize.ParseBytes(option.Download) + if err != nil { + return err + } + } + if option.Upload != "" { + upload, err = humanize.ParseBytes(option.Upload) + if err != nil { + return err + } + } + if download == 0 && upload == 0 { + return E.New("download/upload, at least one must be set") + } + if option.Tag == "" && len(option.AuthUser) == 0 && len(option.Inbound) == 0 { + return E.New("tag/user/inbound, at least one must be set") + } + var sharedLimiter *limiter + if option.Tag != "" || !option.AuthUserIndependent || !option.InboundIndependent { + sharedLimiter = newLimiter(download, upload) + } + if option.Tag != "" { + m.mp[limiterKey{prefixTag, option.Tag}] = sharedLimiter + } + for _, user := range option.AuthUser { + if option.AuthUserIndependent { + m.mp[limiterKey{prefixUser, user}] = newLimiter(download, upload) + } else { + m.mp[limiterKey{prefixUser, user}] = sharedLimiter + } + } + for _, inbound := range option.Inbound { + if option.InboundIndependent { + m.mp[limiterKey{prefixInbound, inbound}] = newLimiter(download, upload) + } else { + m.mp[limiterKey{prefixInbound, inbound}] = sharedLimiter + } + } + return +} + +func (m *defaultManager) NewConnWithLimiters(ctx context.Context, conn net.Conn, metadata *adapter.InboundContext, rule adapter.Rule) net.Conn { + var limiters []*limiter + if rule != nil { + for _, tag := range rule.Limiters() { + if v, ok := m.mp[limiterKey{prefixTag, tag}]; ok { + limiters = append(limiters, v) + } + } + } + if metadata != nil { + if v, ok := m.mp[limiterKey{prefixUser, metadata.User}]; ok { + limiters = append(limiters, v) + } + if v, ok := m.mp[limiterKey{prefixInbound, metadata.Inbound}]; ok { + limiters = append(limiters, v) + } + } + for _, limiter := range limiters { + conn = &connWithLimiter{Conn: conn, limiter: limiter, ctx: ctx} + } + return conn +} diff --git a/limiter/limiter.go b/limiter/limiter.go new file mode 100644 index 0000000000..aba19fa7c3 --- /dev/null +++ b/limiter/limiter.go @@ -0,0 +1,77 @@ +package limiter + +import ( + "context" + "net" + + "golang.org/x/time/rate" +) + +type limiter struct { + downloadLimiter *rate.Limiter + uploadLimiter *rate.Limiter +} + +func newLimiter(download, upload uint64) *limiter { + var downloadLimiter, uploadLimiter *rate.Limiter + if download > 0 { + downloadLimiter = rate.NewLimiter(rate.Limit(float64(download)), int(download)) + } + if upload > 0 { + uploadLimiter = rate.NewLimiter(rate.Limit(float64(upload)), int(upload)) + } + return &limiter{downloadLimiter: downloadLimiter, uploadLimiter: uploadLimiter} +} + +type connWithLimiter struct { + net.Conn + limiter *limiter + ctx context.Context +} + +func (conn *connWithLimiter) Read(p []byte) (n int, err error) { + if conn.limiter == nil || conn.limiter.uploadLimiter == nil { + return conn.Conn.Read(p) + } + b := conn.limiter.uploadLimiter.Burst() + if b < len(p) { + p = p[:b] + } + n, err = conn.Conn.Read(p) + if err != nil { + return + } + err = conn.limiter.uploadLimiter.WaitN(conn.ctx, n) + if err != nil { + return + } + return +} + +func (conn *connWithLimiter) Write(p []byte) (n int, err error) { + if conn.limiter == nil || conn.limiter.downloadLimiter == nil { + return conn.Conn.Write(p) + } + var nn int + b := conn.limiter.downloadLimiter.Burst() + for { + end := len(p) + if end == 0 { + break + } + if b < len(p) { + end = b + } + err = conn.limiter.downloadLimiter.WaitN(conn.ctx, end) + if err != nil { + return + } + nn, err = conn.Conn.Write(p[:end]) + n += nn + if err != nil { + return + } + p = p[end:] + } + return +} diff --git a/limiter/manager.go b/limiter/manager.go new file mode 100644 index 0000000000..49531f3589 --- /dev/null +++ b/limiter/manager.go @@ -0,0 +1,12 @@ +package limiter + +import ( + "context" + "net" + + "github.com/sagernet/sing-box/adapter" +) + +type Manager interface { + NewConnWithLimiters(ctx context.Context, conn net.Conn, metadata *adapter.InboundContext, rule adapter.Rule) net.Conn +} diff --git a/mkdocs.yml b/mkdocs.yml index 5632affae6..c7feef09cc 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -59,6 +59,8 @@ nav: - FakeIP: configuration/dns/fakeip.md - NTP: - configuration/ntp/index.md + - Limiter: + - configuration/limiter/index.md - Route: - configuration/route/index.md - GeoIP: configuration/route/geoip.md @@ -185,6 +187,8 @@ plugins: DNS Server: DNS 服务器 DNS Rule: DNS 规则 + Limiter: 限速 + Route: 路由 Route Rule: 路由规则 Protocol Sniff: 协议探测 diff --git a/option/config.go b/option/config.go index ec471112e7..ddb9ddec10 100644 --- a/option/config.go +++ b/option/config.go @@ -16,6 +16,7 @@ type _Options struct { Inbounds []Inbound `json:"inbounds,omitempty"` Outbounds []Outbound `json:"outbounds,omitempty"` Route *RouteOptions `json:"route,omitempty"` + Limiters []Limiter `json:"limiters,omitempty"` Experimental *ExperimentalOptions `json:"experimental,omitempty"` } diff --git a/option/limiter.go b/option/limiter.go new file mode 100644 index 0000000000..a45b6393a9 --- /dev/null +++ b/option/limiter.go @@ -0,0 +1,11 @@ +package option + +type Limiter struct { + Tag string `json:"tag"` + Download string `json:"download,omitempty"` + Upload string `json:"upload,omitempty"` + AuthUser Listable[string] `json:"auth_user,omitempty"` + AuthUserIndependent bool `json:"auth_user_independent,omitempty"` + Inbound Listable[string] `json:"inbound,omitempty"` + InboundIndependent bool `json:"inbound_independent,omitempty"` +} diff --git a/option/rule.go b/option/rule.go index f78a752d91..c813eea92e 100644 --- a/option/rule.go +++ b/option/rule.go @@ -80,6 +80,7 @@ type DefaultRule struct { ClashMode string `json:"clash_mode,omitempty"` Invert bool `json:"invert,omitempty"` Outbound string `json:"outbound,omitempty"` + Limiter Listable[string] `json:"limiter,omitempty"` } func (r DefaultRule) IsValid() bool { @@ -90,10 +91,11 @@ func (r DefaultRule) IsValid() bool { } type LogicalRule struct { - Mode string `json:"mode"` - Rules []DefaultRule `json:"rules,omitempty"` - Invert bool `json:"invert,omitempty"` - Outbound string `json:"outbound,omitempty"` + Mode string `json:"mode"` + Rules []DefaultRule `json:"rules,omitempty"` + Invert bool `json:"invert,omitempty"` + Outbound string `json:"outbound,omitempty"` + Limiter Listable[string] `json:"limiter,omitempty"` } func (r LogicalRule) IsValid() bool { diff --git a/route/router.go b/route/router.go index 9b302d5e1a..02314e6dd3 100644 --- a/route/router.go +++ b/route/router.go @@ -21,6 +21,7 @@ import ( "github.com/sagernet/sing-box/common/sniff" C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/experimental/libbox/platform" + "github.com/sagernet/sing-box/limiter" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/ntp" "github.com/sagernet/sing-box/option" @@ -85,6 +86,7 @@ type Router struct { pauseManager pause.Manager clashServer adapter.ClashServer v2rayServer adapter.V2RayServer + limiterManager limiter.Manager platformInterface platform.Interface } @@ -498,6 +500,9 @@ func (r *Router) Start() error { return E.Cause(err, "initialize time service") } } + if limiterManger := service.FromContext[limiter.Manager](r.ctx); limiterManger != nil { + r.limiterManager = limiterManger + } return nil } @@ -701,6 +706,11 @@ func (r *Router) RouteConnection(ctx context.Context, conn net.Conn, metadata ad if !common.Contains(detour.Network(), N.NetworkTCP) { return E.New("missing supported outbound, closing connection") } + + if r.limiterManager != nil { + conn = r.limiterManager.NewConnWithLimiters(ctx, conn, &metadata, matchedRule) + } + if r.clashServer != nil { trackerConn, tracker := r.clashServer.RoutedConnection(ctx, conn, metadata, matchedRule) defer tracker.Leave() diff --git a/route/rule_abstract.go b/route/rule_abstract.go index 38d4d57d41..bbbbe99f02 100644 --- a/route/rule_abstract.go +++ b/route/rule_abstract.go @@ -18,6 +18,7 @@ type abstractDefaultRule struct { allItems []RuleItem invert bool outbound string + limiters []string } func (r *abstractDefaultRule) Type() string { @@ -126,6 +127,10 @@ func (r *abstractDefaultRule) Outbound() string { return r.outbound } +func (r *abstractDefaultRule) Limiters() []string { + return r.limiters +} + func (r *abstractDefaultRule) String() string { if !r.invert { return strings.Join(F.MapToString(r.allItems), " ") @@ -139,6 +144,7 @@ type abstractLogicalRule struct { mode string invert bool outbound string + limiters []string } func (r *abstractLogicalRule) Type() string { @@ -191,6 +197,10 @@ func (r *abstractLogicalRule) Outbound() string { return r.outbound } +func (r *abstractLogicalRule) Limiters() []string { + return r.limiters +} + func (r *abstractLogicalRule) String() string { var op string switch r.mode { diff --git a/route/rule_default.go b/route/rule_default.go index 01322c13aa..780fd8cc7c 100644 --- a/route/rule_default.go +++ b/route/rule_default.go @@ -184,6 +184,9 @@ func NewDefaultRule(router adapter.Router, logger log.ContextLogger, options opt rule.items = append(rule.items, item) rule.allItems = append(rule.allItems, item) } + if len(options.Limiter) > 0 { + rule.limiters = append(rule.limiters, options.Limiter...) + } return rule, nil } @@ -216,5 +219,8 @@ func NewLogicalRule(router adapter.Router, logger log.ContextLogger, options opt } r.rules[i] = rule } + if len(options.Limiter) > 0 { + r.limiters = append(r.limiters, options.Limiter...) + } return r, nil }