feat: add quota limiter and quota API (#107)

This commit is contained in:
Usishchev Yury
2026-06-20 08:44:42 +02:00
committed by GitHub
parent b7d807b464
commit b23553d37a
16 changed files with 1343 additions and 4 deletions
+7
View File
@@ -145,6 +145,13 @@ func Register(r *gin.Engine, opts *Options) {
config.PUT("/limiters/:limiter", updateLimiter) config.PUT("/limiters/:limiter", updateLimiter)
config.DELETE("/limiters/:limiter", deleteLimiter) config.DELETE("/limiters/:limiter", deleteLimiter)
config.GET("/quotas", getQuotaList)
config.GET("/quotas/:quota", getQuota)
config.POST("/quotas", createQuota)
config.PUT("/quotas/:quota", updateQuota)
config.DELETE("/quotas/:quota", deleteQuota)
config.POST("/quotas/:quota/reset", resetQuota)
config.GET("/climiters", getConnLimiterList) config.GET("/climiters", getConnLimiterList)
config.GET("/climiters/:limiter", getConnLimiter) config.GET("/climiters/:limiter", getConnLimiter)
config.POST("/climiters", createConnLimiter) config.POST("/climiters", createConnLimiter)
+23
View File
@@ -35,6 +35,29 @@ func fillServiceStatus(svc *config.ServiceConfig) {
OutputBytes: st.Get(stats.KindOutputBytes), OutputBytes: st.Get(stats.KindOutputBytes),
} }
} }
var stopped bool
for _, qname := range svc.Quotas {
lim := registry.QuotaLimiterRegistry().Get(qname)
if lim == nil {
continue
}
qs := lim.Snapshot()
if qs.Blocked {
stopped = true
}
svc.Status.Quotas = append(svc.Status.Quotas, config.ServiceQuota{
Name: qname,
Used: qs.Used,
Limit: qs.Limit,
StartsAt: qs.StartsAtUnix,
ExpiresAt: qs.ExpiresAtUnix,
Active: qs.Active,
Expired: qs.Expired,
Blocked: qs.Blocked,
Direction: qs.Direction,
})
}
svc.Status.StoppedByLimit = stopped
for _, ev := range status.Events() { for _, ev := range status.Events() {
if !ev.Time.IsZero() { if !ev.Time.IsZero() {
svc.Status.Events = append(svc.Status.Events, config.ServiceEvent{ svc.Status.Events = append(svc.Status.Events, config.ServiceEvent{
+350
View File
@@ -0,0 +1,350 @@
package api
import (
"fmt"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/go-gost/x/config"
parser "github.com/go-gost/x/config/parsing/quota"
"github.com/go-gost/x/limiter/quota"
"github.com/go-gost/x/registry"
)
func fillQuotaStatus(q *config.QuotaConfig) {
lim := registry.QuotaLimiterRegistry().Get(q.Name)
if lim == nil {
return
}
s := lim.Snapshot()
q.Status = &config.QuotaStatus{
Used: s.Used,
Limit: s.Limit,
StartsAt: s.StartsAtUnix,
ExpiresAt: s.ExpiresAtUnix,
Active: s.Active,
Expired: s.Expired,
Blocked: s.Blocked,
Direction: s.Direction,
}
}
// swagger:parameters getQuotaListRequest
type getQuotaListRequest struct {
}
// successful operation.
// swagger:response getQuotaListResponse
type getQuotaListResponse struct {
// in: body
Data quotaList
}
type quotaList struct {
Count int `json:"count"`
List []*config.QuotaConfig `json:"list"`
}
func getQuotaList(ctx *gin.Context) {
// swagger:route GET /config/quotas Quota getQuotaListRequest
//
// Get quota list.
//
// Security:
// basicAuth: []
//
// Responses:
// 200: getQuotaListResponse
var req getQuotaListRequest
ctx.ShouldBindQuery(&req)
list := config.Global().Quotas
for _, q := range list {
if q == nil {
continue
}
fillQuotaStatus(q)
}
ctx.JSON(http.StatusOK, Response{
Data: quotaList{
Count: len(list),
List: list,
},
})
}
// swagger:parameters getQuotaRequest
type getQuotaRequest struct {
// in: path
// required: true
Quota string `uri:"quota" json:"quota"`
}
// successful operation.
// swagger:response getQuotaResponse
type getQuotaResponse struct {
// in: body
Data *config.QuotaConfig
}
func getQuota(ctx *gin.Context) {
// swagger:route GET /config/quotas/{quota} Quota getQuotaRequest
//
// Get quota.
//
// Security:
// basicAuth: []
//
// Responses:
// 200: getQuotaResponse
var req getQuotaRequest
ctx.ShouldBindUri(&req)
var resp getQuotaResponse
for _, q := range config.Global().Quotas {
if q == nil {
continue
}
if q.Name == req.Quota {
fillQuotaStatus(q)
resp.Data = q
}
}
ctx.JSON(http.StatusOK, Response{
Data: resp.Data,
})
}
// swagger:parameters createQuotaRequest
type createQuotaRequest struct {
// in: body
Data config.QuotaConfig `json:"data"`
}
// successful operation.
// swagger:response createQuotaResponse
type createQuotaResponse struct {
Data Response
}
func createQuota(ctx *gin.Context) {
// swagger:route POST /config/quotas Quota createQuotaRequest
//
// Create a new quota, the name of the quota must be unique in quota list.
//
// Security:
// basicAuth: []
//
// Responses:
// 200: createQuotaResponse
var req createQuotaRequest
ctx.ShouldBindJSON(&req.Data)
name := strings.TrimSpace(req.Data.Name)
if name == "" {
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeInvalid, "quota name is required"))
return
}
req.Data.Name = name
req.Data.Status = nil
if registry.QuotaLimiterRegistry().IsRegistered(name) {
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeDup, fmt.Sprintf("quota %s already exists", name)))
return
}
v := parser.ParseQuotaLimiter(&req.Data)
if err := registry.QuotaLimiterRegistry().Register(name, v); err != nil {
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeDup, fmt.Sprintf("quota %s already exists", name)))
return
}
config.OnUpdate(func(c *config.Config) error {
c.Quotas = append(c.Quotas, &req.Data)
return nil
})
ctx.JSON(http.StatusOK, Response{
Msg: "OK",
})
}
// swagger:parameters updateQuotaRequest
type updateQuotaRequest struct {
// in: path
// required: true
Quota string `uri:"quota" json:"quota"`
// in: body
Data config.QuotaConfig `json:"data"`
}
// successful operation.
// swagger:response updateQuotaResponse
type updateQuotaResponse struct {
Data Response
}
func updateQuota(ctx *gin.Context) {
// swagger:route PUT /config/quotas/{quota} Quota updateQuotaRequest
//
// Update quota by name, the quota must already exist. The cumulative counter
// is preserved across the update as long as the window is unchanged.
//
// Security:
// basicAuth: []
//
// Responses:
// 200: updateQuotaResponse
var req updateQuotaRequest
ctx.ShouldBindUri(&req)
ctx.ShouldBindJSON(&req.Data)
name := strings.TrimSpace(req.Quota)
if !registry.QuotaLimiterRegistry().IsRegistered(name) {
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeNotFound, fmt.Sprintf("quota %s not found", name)))
return
}
req.Data.Name = name
req.Data.Status = nil
v := parser.ParseQuotaLimiter(&req.Data)
registry.QuotaLimiterRegistry().Unregister(name)
if err := registry.QuotaLimiterRegistry().Register(name, v); err != nil {
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeDup, fmt.Sprintf("quota %s already exists", name)))
return
}
config.OnUpdate(func(c *config.Config) error {
for i := range c.Quotas {
if c.Quotas[i].Name == name {
c.Quotas[i] = &req.Data
break
}
}
return nil
})
ctx.JSON(http.StatusOK, Response{
Msg: "OK",
})
}
// swagger:parameters deleteQuotaRequest
type deleteQuotaRequest struct {
// in: path
// required: true
Quota string `uri:"quota" json:"quota"`
}
// successful operation.
// swagger:response deleteQuotaResponse
type deleteQuotaResponse struct {
Data Response
}
func deleteQuota(ctx *gin.Context) {
// swagger:route DELETE /config/quotas/{quota} Quota deleteQuotaRequest
//
// Delete quota by name. Services referencing it stop being limited (fail-open).
//
// Security:
// basicAuth: []
//
// Responses:
// 200: deleteQuotaResponse
var req deleteQuotaRequest
ctx.ShouldBindUri(&req)
name := strings.TrimSpace(req.Quota)
if !registry.QuotaLimiterRegistry().IsRegistered(name) {
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeNotFound, fmt.Sprintf("quota %s not found", name)))
return
}
registry.QuotaLimiterRegistry().Unregister(name)
config.OnUpdate(func(c *config.Config) error {
quotas := c.Quotas
c.Quotas = nil
for _, q := range quotas {
if q.Name == name {
continue
}
c.Quotas = append(c.Quotas, q)
}
return nil
})
ctx.JSON(http.StatusOK, Response{
Msg: "OK",
})
}
// swagger:parameters resetQuotaRequest
type resetQuotaRequest struct {
// in: path
// required: true
Quota string `uri:"quota" json:"quota"`
// in: body
Data resetQuotaData `json:"data"`
}
type resetQuotaData struct {
Used *uint64 `json:"used,omitempty"`
}
// successful operation.
// swagger:response resetQuotaResponse
type resetQuotaResponse struct {
Data Response
}
func resetQuota(ctx *gin.Context) {
// swagger:route POST /config/quotas/{quota}/reset Quota resetQuotaRequest
//
// Overwrite the cumulative counter of a quota (defaults to 0). The new value
// is applied immediately and persisted; if the quota was blocking, services
// resume once the value is below the limit.
//
// Security:
// basicAuth: []
//
// Responses:
// 200: resetQuotaResponse
var req resetQuotaRequest
ctx.ShouldBindUri(&req)
ctx.ShouldBindJSON(&req.Data)
name := strings.TrimSpace(req.Quota)
lim := registry.QuotaLimiterRegistry().Get(name)
if lim == nil {
writeError(ctx, NewError(http.StatusBadRequest, ErrCodeNotFound, fmt.Sprintf("quota %s not found", name)))
return
}
used := uint64(0)
if req.Data.Used != nil {
used = *req.Data.Used
}
lim.Update(quota.Update{Used: &used})
ctx.JSON(http.StatusOK, Response{
Msg: "OK",
})
}
+55
View File
@@ -470,6 +470,7 @@ type ServiceConfig struct {
Resolver string `yaml:",omitempty" json:"resolver,omitempty"` Resolver string `yaml:",omitempty" json:"resolver,omitempty"`
Hosts string `yaml:",omitempty" json:"hosts,omitempty"` Hosts string `yaml:",omitempty" json:"hosts,omitempty"`
Limiter string `yaml:",omitempty" json:"limiter,omitempty"` Limiter string `yaml:",omitempty" json:"limiter,omitempty"`
Quotas []string `yaml:",omitempty" json:"quotas,omitempty"`
CLimiter string `yaml:"climiter,omitempty" json:"climiter,omitempty"` CLimiter string `yaml:"climiter,omitempty" json:"climiter,omitempty"`
RLimiter string `yaml:"rlimiter,omitempty" json:"rlimiter,omitempty"` RLimiter string `yaml:"rlimiter,omitempty" json:"rlimiter,omitempty"`
Logger string `yaml:",omitempty" json:"logger,omitempty"` Logger string `yaml:",omitempty" json:"logger,omitempty"`
@@ -489,6 +490,8 @@ type ServiceStatus struct {
State string `yaml:"state" json:"state"` State string `yaml:"state" json:"state"`
Events []ServiceEvent `yaml:",omitempty" json:"events,omitempty"` Events []ServiceEvent `yaml:",omitempty" json:"events,omitempty"`
Stats *ServiceStats `yaml:",omitempty" json:"stats,omitempty"` Stats *ServiceStats `yaml:",omitempty" json:"stats,omitempty"`
StoppedByLimit bool `yaml:"stoppedByLimit,omitempty" json:"stoppedByLimit,omitempty"`
Quotas []ServiceQuota `yaml:",omitempty" json:"quotas,omitempty"`
} }
type ServiceEvent struct { type ServiceEvent struct {
@@ -504,6 +507,57 @@ type ServiceStats struct {
OutputBytes uint64 `yaml:"outputBytes" json:"outputBytes"` OutputBytes uint64 `yaml:"outputBytes" json:"outputBytes"`
} }
// QuotaConfig is a named cumulative traffic-volume limiter; the same name
// referenced by several services (ServiceConfig.Quotas) shares one counter.
type QuotaConfig struct {
Name string `json:"name"`
Limit string `yaml:",omitempty" json:"limit,omitempty"`
StartsAt string `yaml:"startsAt,omitempty" json:"startsAt,omitempty"`
ExpiresAt string `yaml:"expiresAt,omitempty" json:"expiresAt,omitempty"`
Direction string `yaml:",omitempty" json:"direction,omitempty"`
Flush string `yaml:",omitempty" json:"flush,omitempty"`
Store *QuotaStoreConfig `yaml:",omitempty" json:"store,omitempty"`
// read-only
Status *QuotaStatus `yaml:",omitempty" json:"status,omitempty"`
}
type QuotaStoreConfig struct {
Type string `json:"type"` // "file" (default) | "redis" (stub, not implemented)
File string `yaml:",omitempty" json:"file,omitempty"`
Redis *QuotaRedisConfig `yaml:",omitempty" json:"redis,omitempty"`
}
type QuotaRedisConfig struct {
Addr string `json:"addr"`
DB int `yaml:",omitempty" json:"db,omitempty"`
Username string `yaml:",omitempty" json:"username,omitempty"`
Password string `yaml:",omitempty" json:"password,omitempty"`
Key string `yaml:",omitempty" json:"key,omitempty"`
}
type QuotaStatus struct {
Used uint64 `yaml:"used" json:"used"`
Limit uint64 `yaml:"limit" json:"limit"`
StartsAt int64 `yaml:"startsAt,omitempty" json:"startsAt,omitempty"`
ExpiresAt int64 `yaml:"expiresAt,omitempty" json:"expiresAt,omitempty"`
Active bool `yaml:"active" json:"active"`
Expired bool `yaml:"expired,omitempty" json:"expired,omitempty"`
Blocked bool `yaml:"blocked" json:"blocked"`
Direction string `yaml:"direction,omitempty" json:"direction,omitempty"`
}
type ServiceQuota struct {
Name string `yaml:"name" json:"name"`
Used uint64 `yaml:"used" json:"used"`
Limit uint64 `yaml:"limit" json:"limit"`
StartsAt int64 `yaml:"startsAt,omitempty" json:"startsAt,omitempty"`
ExpiresAt int64 `yaml:"expiresAt,omitempty" json:"expiresAt,omitempty"`
Active bool `yaml:"active" json:"active"`
Expired bool `yaml:"expired,omitempty" json:"expired,omitempty"`
Blocked bool `yaml:"blocked" json:"blocked"`
Direction string `yaml:"direction,omitempty" json:"direction,omitempty"`
}
type ChainConfig struct { type ChainConfig struct {
Name string `json:"name"` Name string `json:"name"`
Hops []*HopConfig `json:"hops"` Hops []*HopConfig `json:"hops"`
@@ -573,6 +627,7 @@ type Config struct {
SDs []*SDConfig `yaml:"sds,omitempty" json:"sds,omitempty"` SDs []*SDConfig `yaml:"sds,omitempty" json:"sds,omitempty"`
Recorders []*RecorderConfig `yaml:",omitempty" json:"recorders,omitempty"` Recorders []*RecorderConfig `yaml:",omitempty" json:"recorders,omitempty"`
Limiters []*LimiterConfig `yaml:",omitempty" json:"limiters,omitempty"` Limiters []*LimiterConfig `yaml:",omitempty" json:"limiters,omitempty"`
Quotas []*QuotaConfig `yaml:",omitempty" json:"quotas,omitempty"`
CLimiters []*LimiterConfig `yaml:"climiters,omitempty" json:"climiters,omitempty"` CLimiters []*LimiterConfig `yaml:"climiters,omitempty" json:"climiters,omitempty"`
RLimiters []*LimiterConfig `yaml:"rlimiters,omitempty" json:"rlimiters,omitempty"` RLimiters []*LimiterConfig `yaml:"rlimiters,omitempty" json:"rlimiters,omitempty"`
Observers []*ObserverConfig `yaml:",omitempty" json:"observers,omitempty"` Observers []*ObserverConfig `yaml:",omitempty" json:"observers,omitempty"`
+12
View File
@@ -35,11 +35,13 @@ import (
limiter_parser "github.com/go-gost/x/config/parsing/limiter" limiter_parser "github.com/go-gost/x/config/parsing/limiter"
logger_parser "github.com/go-gost/x/config/parsing/logger" logger_parser "github.com/go-gost/x/config/parsing/logger"
observer_parser "github.com/go-gost/x/config/parsing/observer" observer_parser "github.com/go-gost/x/config/parsing/observer"
quota_parser "github.com/go-gost/x/config/parsing/quota"
recorder_parser "github.com/go-gost/x/config/parsing/recorder" recorder_parser "github.com/go-gost/x/config/parsing/recorder"
resolver_parser "github.com/go-gost/x/config/parsing/resolver" resolver_parser "github.com/go-gost/x/config/parsing/resolver"
router_parser "github.com/go-gost/x/config/parsing/router" router_parser "github.com/go-gost/x/config/parsing/router"
sd_parser "github.com/go-gost/x/config/parsing/sd" sd_parser "github.com/go-gost/x/config/parsing/sd"
service_parser "github.com/go-gost/x/config/parsing/service" service_parser "github.com/go-gost/x/config/parsing/service"
quota "github.com/go-gost/x/limiter/quota"
"github.com/go-gost/x/registry" "github.com/go-gost/x/registry"
) )
@@ -252,6 +254,16 @@ func register(cfg *config.Config) error {
} }
} }
{
var entries []named[*quota.Limiter]
for _, c := range cfg.Quotas {
entries = append(entries, named[*quota.Limiter]{c.Name, quota_parser.ParseQuotaLimiter(c)})
}
if err := registerGroup(entries, registry.QuotaLimiterRegistry()); err != nil {
return err
}
}
{ {
var entries []named[conn.ConnLimiter] var entries []named[conn.ConnLimiter]
for _, c := range cfg.CLimiters { for _, c := range cfg.CLimiters {
+104
View File
@@ -0,0 +1,104 @@
package quota
import (
"strings"
"time"
"github.com/alecthomas/units"
"github.com/go-gost/core/logger"
"github.com/go-gost/x/config"
xquota "github.com/go-gost/x/limiter/quota"
)
// ParseQuotaLimiter is best-effort: malformed fields are logged and treated as unset.
func ParseQuotaLimiter(cfg *config.QuotaConfig) *xquota.Limiter {
if cfg == nil {
return nil
}
log := logger.Default().WithFields(map[string]any{
"kind": "quota",
"quota": cfg.Name,
})
var limit uint64
if s := strings.TrimSpace(cfg.Limit); s != "" && s != "0" {
if v, err := units.ParseBase2Bytes(s); err != nil {
log.Warnf("quota: parse limit %q: %v", s, err)
} else if v > 0 {
limit = uint64(v)
}
}
var dir xquota.Direction
switch strings.ToLower(strings.TrimSpace(cfg.Direction)) {
case "in":
dir = xquota.DirectionIn
case "out":
dir = xquota.DirectionOut
default:
dir = xquota.DirectionTotal
}
var flush time.Duration
if s := strings.TrimSpace(cfg.Flush); s != "" {
if d, err := time.ParseDuration(s); err != nil {
log.Warnf("quota: parse flush %q: %v", s, err)
} else {
flush = d
}
}
return xquota.NewLimiter(cfg.Name, xquota.Options{
Limit: limit,
StartsAt: parseQuotaTime(cfg.StartsAt, log, "startsAt"),
ExpiresAt: parseQuotaTime(cfg.ExpiresAt, log, "expiresAt"),
Direction: dir,
Flush: flush,
Store: parseQuotaStore(cfg.Store, log),
Logger: log,
})
}
func parseQuotaTime(s string, log logger.Logger, field string) time.Time {
s = strings.TrimSpace(s)
if s == "" {
return time.Time{}
}
t, err := time.Parse(time.RFC3339, s)
if err != nil {
log.Warnf("quota: parse %s %q: %v", field, s, err)
return time.Time{}
}
return t
}
func parseQuotaStore(sc *config.QuotaStoreConfig, log logger.Logger) xquota.Store {
const defaultFile = "gost-quota.json"
if sc == nil || sc.Type == "" || strings.EqualFold(sc.Type, "file") {
path := defaultFile
if sc != nil && strings.TrimSpace(sc.File) != "" {
path = sc.File
}
return xquota.NewFileStore(path)
}
if strings.EqualFold(sc.Type, "redis") {
log.Warnf("quota: redis store not implemented; counter will not be persisted")
var rc config.QuotaRedisConfig
if sc.Redis != nil {
rc = *sc.Redis
}
return xquota.NewRedisStore(xquota.RedisConfig{
Addr: rc.Addr,
Username: rc.Username,
Password: rc.Password,
DB: rc.DB,
Key: rc.Key,
})
}
log.Warnf("quota: unknown store type %q; using file store %q", sc.Type, defaultFile)
return xquota.NewFileStore(defaultFile)
}
+5
View File
@@ -29,6 +29,7 @@ import (
logger_parser "github.com/go-gost/x/config/parsing/logger" logger_parser "github.com/go-gost/x/config/parsing/logger"
selector_parser "github.com/go-gost/x/config/parsing/selector" selector_parser "github.com/go-gost/x/config/parsing/selector"
tls_util "github.com/go-gost/x/internal/util/tls" tls_util "github.com/go-gost/x/internal/util/tls"
quota_wrapper "github.com/go-gost/x/limiter/quota/wrapper"
cache_limiter "github.com/go-gost/x/limiter/traffic/cache" cache_limiter "github.com/go-gost/x/limiter/traffic/cache"
"github.com/go-gost/x/metadata" "github.com/go-gost/x/metadata"
mdutil "github.com/go-gost/x/metadata/util" mdutil "github.com/go-gost/x/metadata/util"
@@ -246,6 +247,10 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
return nil, err return nil, err
} }
for _, qname := range cfg.Quotas {
ln = quota_wrapper.WrapListener(ln, strings.TrimSpace(qname))
}
handlerLogger := serviceLogger.WithFields(map[string]any{ handlerLogger := serviceLogger.WithFields(map[string]any{
"kind": "handler", "kind": "handler",
}) })
+326
View File
@@ -0,0 +1,326 @@
// Package quota implements a persisted, long-term traffic-volume limiter that
// can be shared across services by name. Unlike the rate limiters in
// limiter/traffic and limiter/rate, it accumulates total bytes within a window
// [startsAt, expiresAt) and stops the referencing service(s) once a byte limit
// is reached. Enforcement is fail-open outside the window.
package quota
import (
"errors"
"sync"
"sync/atomic"
"time"
"github.com/go-gost/core/logger"
xlogger "github.com/go-gost/x/logger"
)
var ErrQuotaExceeded = errors.New("quota: traffic limit reached")
const defaultFlushInterval = 10 * time.Second
type Direction int
const (
DirectionTotal Direction = iota
DirectionIn
DirectionOut
)
func (d Direction) String() string {
switch d {
case DirectionIn:
return "in"
case DirectionOut:
return "out"
default:
return "total"
}
}
// Options seeds a Limiter. Limit and the window are config-authoritative; a
// persisted counter is restored only for a matching window (see NewLimiter).
type Options struct {
Limit uint64
StartsAt time.Time
ExpiresAt time.Time
Direction Direction
Flush time.Duration
Store Store
Logger logger.Logger
}
// Update overwrites runtime state; a nil field is left unchanged.
type Update struct {
Used *uint64
Limit *uint64
StartsAt *time.Time
ExpiresAt *time.Time
}
type Snapshot struct {
Used uint64
Limit uint64
StartsAtUnix int64
ExpiresAtUnix int64
Active bool
Expired bool
Blocked bool
Direction string
}
type Limiter struct {
name string
direction Direction
flush time.Duration
store Store
log logger.Logger
used atomic.Uint64
limit atomic.Uint64
startsAt atomic.Int64 // unixnano; 0 = unset
expiresAt atomic.Int64 // unixnano; 0 = unset
blocked atomic.Bool
dirty atomic.Bool
closed atomic.Bool
mu sync.Mutex
waitCh chan struct{} // closed+replaced to broadcast a state change
closeOnce sync.Once
done chan struct{}
}
func NewLimiter(name string, opts Options) *Limiter {
l := &Limiter{
name: name,
direction: opts.Direction,
flush: opts.Flush,
store: opts.Store,
log: opts.Logger,
waitCh: make(chan struct{}),
done: make(chan struct{}),
}
if l.flush <= 0 {
l.flush = defaultFlushInterval
}
if l.log == nil {
l.log = xlogger.Nop()
}
l.limit.Store(opts.Limit)
sa := unixNanoOrZero(opts.StartsAt)
ea := unixNanoOrZero(opts.ExpiresAt)
l.startsAt.Store(sa)
l.expiresAt.Store(ea)
// Restore the counter only within the same window: a changed window (a new
// period pushed via config) starts fresh.
if l.store != nil {
if rec, ok, err := l.store.Load(name); err != nil {
l.log.Warnf("quota: load %s: %v", name, err)
} else if ok && rec.StartsAt == sa && rec.ExpiresAt == ea {
l.used.Store(rec.Used)
}
}
l.blocked.Store(l.enforcing(time.Now()) && l.used.Load() >= l.limit.Load())
go l.run()
return l
}
func (l *Limiter) active(now time.Time) bool {
n := now.UnixNano()
if sa := l.startsAt.Load(); sa != 0 && n < sa {
return false
}
if ea := l.expiresAt.Load(); ea != 0 && n >= ea {
return false
}
return true
}
func (l *Limiter) enforcing(now time.Time) bool {
return l.limit.Load() > 0 && l.active(now)
}
func (l *Limiter) Blocked() bool {
if l.closed.Load() || !l.blocked.Load() {
return false
}
return l.enforcing(time.Now())
}
func (l *Limiter) AddIn(n int) { l.add(int64(n), DirectionIn) }
func (l *Limiter) AddOut(n int) { l.add(int64(n), DirectionOut) }
func (l *Limiter) add(n int64, dir Direction) {
if n <= 0 || l.closed.Load() {
return
}
switch l.direction {
case DirectionIn:
if dir != DirectionIn {
return
}
case DirectionOut:
if dir != DirectionOut {
return
}
}
if !l.active(time.Now()) {
return
}
used := l.used.Add(uint64(n))
l.dirty.Store(true)
if lim := l.limit.Load(); lim > 0 && used >= lim {
l.block()
}
}
// block is hot-path: no lock, no I/O, no wakeup (parking only happens on the
// next Accept).
func (l *Limiter) block() {
if l.blocked.CompareAndSwap(false, true) {
l.log.Warnf("quota: %s reached limit %d bytes, stopping", l.name, l.limit.Load())
}
}
// WaitChan must be fetched before checking Blocked to avoid a missed wakeup.
func (l *Limiter) WaitChan() <-chan struct{} {
l.mu.Lock()
defer l.mu.Unlock()
return l.waitCh
}
func (l *Limiter) notify() {
l.mu.Lock()
close(l.waitCh)
l.waitCh = make(chan struct{})
l.mu.Unlock()
}
func (l *Limiter) Update(u Update) {
if u.Used != nil {
l.used.Store(*u.Used)
}
if u.Limit != nil {
l.limit.Store(*u.Limit)
}
if u.StartsAt != nil {
l.startsAt.Store(unixNanoOrZero(*u.StartsAt))
}
if u.ExpiresAt != nil {
l.expiresAt.Store(unixNanoOrZero(*u.ExpiresAt))
}
l.reevaluate()
}
func (l *Limiter) reevaluate() {
now := time.Now()
shouldBlock := l.enforcing(now) && l.used.Load() >= l.limit.Load()
l.blocked.Store(shouldBlock)
if !shouldBlock {
l.notify()
}
l.flushNow()
}
func (l *Limiter) Snapshot() Snapshot {
now := time.Now()
ea := l.expiresAt.Load()
return Snapshot{
Used: l.used.Load(),
Limit: l.limit.Load(),
StartsAtUnix: nanoToSec(l.startsAt.Load()),
ExpiresAtUnix: nanoToSec(ea),
Active: l.active(now),
Expired: ea != 0 && now.UnixNano() >= ea,
Blocked: l.Blocked(),
Direction: l.direction.String(),
}
}
func (l *Limiter) run() {
flushT := time.NewTicker(l.flush)
defer flushT.Stop()
boundT := time.NewTicker(time.Second)
defer boundT.Stop()
wasActive := l.active(time.Now())
for {
select {
case <-l.done:
return
case <-flushT.C:
if l.dirty.Swap(false) {
l.persist()
}
case <-boundT.C:
now := time.Now()
act := l.active(now)
if act == wasActive {
continue
}
wasActive = act
if act {
l.blocked.Store(l.limit.Load() > 0 && l.used.Load() >= l.limit.Load())
} else {
l.blocked.Store(false)
}
l.notify()
l.persist()
}
}
}
func (l *Limiter) flushNow() {
l.dirty.Store(false)
l.persist()
}
func (l *Limiter) persist() {
if l.store == nil {
return
}
rec := Record{
Used: l.used.Load(),
Limit: l.limit.Load(),
StartsAt: l.startsAt.Load(),
ExpiresAt: l.expiresAt.Load(),
UpdatedAt: time.Now().UnixNano(),
}
if err := l.store.Save(l.name, rec); err != nil {
l.log.Warnf("quota: save %s: %v", l.name, err)
}
}
// Close makes the limiter inert (Blocked false, counting a no-op) so deleting a
// shared quota releases the referencing services instead of tearing them down.
// Called by the registry on Unregister.
func (l *Limiter) Close() error {
l.closeOnce.Do(func() {
l.closed.Store(true)
close(l.done)
l.flushNow()
l.notify()
})
return nil
}
func unixNanoOrZero(t time.Time) int64 {
if t.IsZero() {
return 0
}
return t.UnixNano()
}
func nanoToSec(n int64) int64 {
if n == 0 {
return 0
}
return n / int64(time.Second)
}
+195
View File
@@ -0,0 +1,195 @@
package quota
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
)
func newTestLimiter(t *testing.T, opts Options) *Limiter {
t.Helper()
l := NewLimiter("test", opts)
t.Cleanup(func() { l.Close() })
return l
}
func TestLimiterBlocksAtLimit(t *testing.T) {
l := newTestLimiter(t, Options{Limit: 100})
l.AddIn(60)
if l.Blocked() {
t.Fatalf("should not block at 60/100")
}
l.AddOut(40)
if !l.Blocked() {
t.Fatalf("should block at 100/100")
}
if s := l.Snapshot(); !s.Blocked || s.Used != 100 {
t.Fatalf("snapshot=%+v, want blocked used=100", s)
}
}
func TestLimiterUnblockOnOverwrite(t *testing.T) {
l := newTestLimiter(t, Options{Limit: 100})
l.AddIn(150)
if !l.Blocked() {
t.Fatalf("expected blocked")
}
zero := uint64(0)
l.Update(Update{Used: &zero})
if l.Blocked() {
t.Fatalf("expected unblocked after reset")
}
if got := l.Snapshot().Used; got != 0 {
t.Fatalf("used=%d, want 0", got)
}
}
func TestLimiterDirection(t *testing.T) {
l := newTestLimiter(t, Options{Limit: 100, Direction: DirectionIn})
l.AddOut(500)
if u := l.Snapshot().Used; u != 0 {
t.Fatalf("outbound counted under DirectionIn: used=%d", u)
}
l.AddIn(100)
if !l.Blocked() {
t.Fatalf("expected blocked after inbound reaches limit")
}
}
func TestLimiterWindowNotStarted(t *testing.T) {
l := newTestLimiter(t, Options{Limit: 100, StartsAt: time.Now().Add(time.Hour)})
l.AddIn(500)
s := l.Snapshot()
if s.Used != 0 {
t.Fatalf("counted before startsAt: used=%d", s.Used)
}
if s.Active {
t.Fatalf("should be inactive before startsAt")
}
if l.Blocked() {
t.Fatalf("should not block before startsAt")
}
}
func TestLimiterFailOpenAfterExpiry(t *testing.T) {
l := newTestLimiter(t, Options{Limit: 100, ExpiresAt: time.Now().Add(-time.Hour)})
l.AddIn(500)
s := l.Snapshot()
if s.Used != 0 {
t.Fatalf("counted after expiry: used=%d", s.Used)
}
if !s.Expired {
t.Fatalf("snapshot should report expired")
}
if l.Blocked() {
t.Fatalf("should be fail-open (unblocked) after expiry")
}
}
func TestLimiterPersistsToDisk(t *testing.T) {
path := filepath.Join(t.TempDir(), "quota.json")
store := NewFileStore(path)
l := NewLimiter("svc", Options{Limit: 1000, Store: store})
l.AddIn(300)
l.AddOut(200)
l.Close()
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read store file: %v", err)
}
var data map[string]Record
if err := json.Unmarshal(b, &data); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if rec := data["svc"]; rec.Used != 500 {
t.Fatalf("persisted used=%d, want 500", rec.Used)
}
}
func TestLimiterRestoresFromDisk(t *testing.T) {
path := filepath.Join(t.TempDir(), "quota.json")
if err := os.WriteFile(path, []byte(`{"svc":{"used":777,"limit":1000}}`), 0o644); err != nil {
t.Fatal(err)
}
store := NewFileStore(path)
l := NewLimiter("svc", Options{Limit: 1000, Store: store})
t.Cleanup(func() { l.Close() })
if got := l.Snapshot().Used; got != 777 {
t.Fatalf("restored used=%d, want 777", got)
}
}
func TestWaitChanNotifiesOnReset(t *testing.T) {
l := newTestLimiter(t, Options{Limit: 100})
l.AddIn(200)
if !l.Blocked() {
t.Fatal("expected blocked")
}
ch := l.WaitChan()
zero := uint64(0)
l.Update(Update{Used: &zero})
select {
case <-ch:
case <-time.After(time.Second):
t.Fatal("WaitChan not notified after reset")
}
if l.Blocked() {
t.Fatal("expected unblocked after reset")
}
}
func TestClosedLimiterIsInert(t *testing.T) {
l := NewLimiter("c", Options{Limit: 100})
l.AddIn(200)
if !l.Blocked() {
t.Fatal("expected blocked")
}
ch := l.WaitChan()
l.Close()
select {
case <-ch:
case <-time.After(time.Second):
t.Fatal("WaitChan not notified on close")
}
if l.Blocked() {
t.Fatal("closed limiter must be inert (not blocking)")
}
l.AddIn(1000)
if u := l.Snapshot().Used; u != 200 {
t.Fatalf("closed limiter counted: used=%d, want 200", u)
}
}
func TestLimiterResetsOnWindowChange(t *testing.T) {
path := filepath.Join(t.TempDir(), "quota.json")
store := NewFileStore(path)
t1 := time.Now().Add(time.Hour)
l1 := NewLimiter("svc", Options{Limit: 1000, ExpiresAt: t1, Store: store})
l1.AddIn(500)
l1.Close()
t2 := time.Now().Add(2 * time.Hour)
l2 := NewLimiter("svc", Options{Limit: 1000, ExpiresAt: t2, Store: store})
t.Cleanup(func() { l2.Close() })
if u := l2.Snapshot().Used; u != 0 {
t.Fatalf("counter not reset on window change: used=%d, want 0", u)
}
}
+16
View File
@@ -0,0 +1,16 @@
package quota
type Record struct {
Used uint64 `json:"used"`
Limit uint64 `json:"limit"`
StartsAt int64 `json:"startsAt"` // unixnano; 0 = unset
ExpiresAt int64 `json:"expiresAt"` // unixnano; 0 = unset
UpdatedAt int64 `json:"updatedAt"`
}
// Store persists per-name quota records. Implementations must be concurrency-safe.
type Store interface {
// Load returns ok=false when no record exists yet.
Load(name string) (rec Record, ok bool, err error)
Save(name string, rec Record) error
}
+64
View File
@@ -0,0 +1,64 @@
package quota
import (
"encoding/json"
"os"
"sync"
)
// fileStore persists all records into one JSON file. A single instance is shared
// per path so concurrent per-name saves merge instead of clobbering each other.
type fileStore struct {
path string
mu sync.Mutex
data map[string]Record
}
var fileStores sync.Map // path -> *fileStore
func NewFileStore(path string) Store {
if v, ok := fileStores.Load(path); ok {
return v.(*fileStore)
}
fs := &fileStore{path: path, data: make(map[string]Record)}
fs.load()
actual, _ := fileStores.LoadOrStore(path, fs)
return actual.(*fileStore)
}
func (fs *fileStore) load() {
b, err := os.ReadFile(fs.path)
if err != nil {
return
}
data := make(map[string]Record)
if json.Unmarshal(b, &data) == nil {
fs.data = data
}
}
func (fs *fileStore) Load(name string) (Record, bool, error) {
fs.mu.Lock()
defer fs.mu.Unlock()
rec, ok := fs.data[name]
return rec, ok, nil
}
func (fs *fileStore) Save(name string, rec Record) error {
fs.mu.Lock()
defer fs.mu.Unlock()
fs.data[name] = rec
return fs.writeLocked()
}
func (fs *fileStore) writeLocked() error {
b, err := json.MarshalIndent(fs.data, "", " ")
if err != nil {
return err
}
tmp := fs.path + ".tmp"
if err := os.WriteFile(tmp, b, 0o644); err != nil {
return err
}
return os.Rename(tmp, fs.path)
}
+31
View File
@@ -0,0 +1,31 @@
package quota
type RedisConfig struct {
Addr string
Username string
Password string
DB int
Key string
}
// redisStore is a no-op placeholder: a redis-configured quota behaves as
// in-memory only until the backend is implemented.
//
// TODO: implement using github.com/go-redis/redis/v8 (already a dependency).
// Suggested schema: HSET <Key> <name> <json(Record)> for Save, HGET for Load.
// See internal/loader/redis.go and recorder/redis.go for the patterns.
type redisStore struct {
cfg RedisConfig
}
func NewRedisStore(cfg RedisConfig) Store {
return &redisStore{cfg: cfg}
}
func (s *redisStore) Load(name string) (Record, bool, error) {
return Record{}, false, nil
}
func (s *redisStore) Save(name string, rec Record) error {
return nil
}
+78
View File
@@ -0,0 +1,78 @@
package wrapper
import (
"context"
"net"
"syscall"
"github.com/go-gost/x/ctx"
xio "github.com/go-gost/x/internal/io"
"github.com/go-gost/x/limiter/quota"
"github.com/go-gost/x/registry"
)
type quotaConn struct {
net.Conn
name string
}
func WrapConn(c net.Conn, name string) net.Conn {
if c == nil || name == "" {
return c
}
return &quotaConn{Conn: c, name: name}
}
func (c *quotaConn) Read(b []byte) (n int, err error) {
lim := registry.QuotaLimiterRegistry().Get(c.name)
if lim != nil && lim.Blocked() {
return 0, quota.ErrQuotaExceeded
}
n, err = c.Conn.Read(b)
if lim != nil && n > 0 {
lim.AddIn(n)
}
return
}
func (c *quotaConn) Write(b []byte) (n int, err error) {
lim := registry.QuotaLimiterRegistry().Get(c.name)
if lim != nil && lim.Blocked() {
return 0, quota.ErrQuotaExceeded
}
n, err = c.Conn.Write(b)
if lim != nil && n > 0 {
lim.AddOut(n)
}
return
}
// Forward optional capabilities so wrapping does not hide them from handlers.
func (c *quotaConn) SyscallConn() (syscall.RawConn, error) {
if sc, ok := c.Conn.(syscall.Conn); ok {
return sc.SyscallConn()
}
return nil, xio.ErrUnsupported
}
func (c *quotaConn) CloseRead() error {
if sc, ok := c.Conn.(xio.CloseRead); ok {
return sc.CloseRead()
}
return xio.ErrUnsupported
}
func (c *quotaConn) CloseWrite() error {
if sc, ok := c.Conn.(xio.CloseWrite); ok {
return sc.CloseWrite()
}
return xio.ErrUnsupported
}
func (c *quotaConn) Context() context.Context {
if cc, ok := c.Conn.(ctx.Context); ok {
return cc.Context()
}
return nil
}
+58
View File
@@ -0,0 +1,58 @@
// Package wrapper adapts a named quota onto a listener and its connections,
// resolving the quota.Limiter by name from the registry on every call so
// create/update/delete takes effect live and several services can share one.
package wrapper
import (
"net"
"sync"
"github.com/go-gost/core/listener"
"github.com/go-gost/x/registry"
)
type quotaListener struct {
listener.Listener
name string
done chan struct{}
once sync.Once
}
func WrapListener(ln listener.Listener, name string) listener.Listener {
if ln == nil || name == "" {
return ln
}
return &quotaListener{Listener: ln, name: name, done: make(chan struct{})}
}
func (ln *quotaListener) Accept() (net.Conn, error) {
for {
lim := registry.QuotaLimiterRegistry().Get(ln.name)
if lim == nil {
break
}
ch := lim.WaitChan() // before Blocked, to avoid a missed wakeup
if !lim.Blocked() {
break
}
// Park (not error) so the service's accept loop survives; ln.done wakes
// it on listener close.
select {
case <-ch:
case <-ln.done:
return nil, net.ErrClosed
}
}
c, err := ln.Listener.Accept()
if err != nil {
return nil, err
}
return WrapConn(c, ln.name), nil
}
func (ln *quotaListener) Close() error {
// Wake a parked Accept, but do not close the (possibly shared) limiter.
ln.once.Do(func() { close(ln.done) })
return ln.Listener.Close()
}
+8
View File
@@ -0,0 +1,8 @@
package registry
import "github.com/go-gost/x/limiter/quota"
// quotaLimiterRegistry holds shared quota limiters; Unregister closes them.
type quotaLimiterRegistry struct {
registry[*quota.Limiter]
}
+7
View File
@@ -23,6 +23,7 @@ import (
"github.com/go-gost/core/router" "github.com/go-gost/core/router"
"github.com/go-gost/core/sd" "github.com/go-gost/core/sd"
"github.com/go-gost/core/service" "github.com/go-gost/core/service"
"github.com/go-gost/x/limiter/quota"
) )
var ( var (
@@ -49,6 +50,7 @@ var (
trafficLimiterReg reg.Registry[traffic.TrafficLimiter] = new(trafficLimiterRegistry) trafficLimiterReg reg.Registry[traffic.TrafficLimiter] = new(trafficLimiterRegistry)
connLimiterReg reg.Registry[conn.ConnLimiter] = new(connLimiterRegistry) connLimiterReg reg.Registry[conn.ConnLimiter] = new(connLimiterRegistry)
rateLimiterReg reg.Registry[rate.RateLimiter] = new(rateLimiterRegistry) rateLimiterReg reg.Registry[rate.RateLimiter] = new(rateLimiterRegistry)
quotaLimiterReg reg.Registry[*quota.Limiter] = new(quotaLimiterRegistry)
ingressReg reg.Registry[ingress.Ingress] = new(ingressRegistry) ingressReg reg.Registry[ingress.Ingress] = new(ingressRegistry)
routerReg reg.Registry[router.Router] = new(routerRegistry) routerReg reg.Registry[router.Router] = new(routerRegistry)
@@ -200,6 +202,11 @@ func RateLimiterRegistry() reg.Registry[rate.RateLimiter] {
return rateLimiterReg return rateLimiterReg
} }
// QuotaLimiterRegistry returns the global registry of quota limiter instances.
func QuotaLimiterRegistry() reg.Registry[*quota.Limiter] {
return quotaLimiterReg
}
// IngressRegistry returns the global registry of ingress instances. // IngressRegistry returns the global registry of ingress instances.
func IngressRegistry() reg.Registry[ingress.Ingress] { func IngressRegistry() reg.Registry[ingress.Ingress] {
return ingressReg return ingressReg