diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 061f84946..cfd95cdb2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -158,8 +158,11 @@ jobs: mv xray xray-linux-${{ matrix.platform }} # mtg-multi (MTProto sidecar) ships prebuilt release binaries whose # platform labels match our matrix, so download and unpack the matching - # archive. Only the platforms the fork publishes are packaged. - MTG_MULTI_VER="v1.14.0" + # archive. Only the platforms the fork publishes are packaged. The tag + # is resolved from the fork's latest release so it never needs bumping + # here; the token only lifts the API rate limit for a public read. + MTG_MULTI_VER=$(curl -sfL -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" "https://api.github.com/repos/mhsanaei/mtg-multi/releases/latest" | sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p' | head -n 1) + if [ -z "$MTG_MULTI_VER" ]; then echo "could not resolve the latest mtg-multi release tag"; exit 1; fi case "${{ matrix.platform }}" in amd64|arm64|armv7|armv6|386) MTG_PKG="mtg-multi-${MTG_MULTI_VER#v}-linux-${{ matrix.platform }}" @@ -284,8 +287,10 @@ jobs: Rename-Item xray.exe xray-windows-amd64.exe # mtg-multi (MTProto sidecar) publishes a prebuilt Windows binary, so - # download and unpack it instead of compiling. - $MTG_MULTI_VER = "v1.14.0" + # download and unpack it instead of compiling. The tag tracks the + # fork's latest release so it never needs bumping here. + $MTG_MULTI_VER = (Invoke-RestMethod -Uri "https://api.github.com/repos/mhsanaei/mtg-multi/releases/latest" -Headers @{ Authorization = "Bearer ${{ secrets.GITHUB_TOKEN }}"; "User-Agent" = "x-ui-release" }).tag_name + if (-not $MTG_MULTI_VER) { throw "could not resolve the latest mtg-multi release tag" } $MTG_PKG = "mtg-multi-$($MTG_MULTI_VER.TrimStart('v'))-windows-amd64" curl.exe -sfLRO "https://github.com/mhsanaei/mtg-multi/releases/download/$MTG_MULTI_VER/$MTG_PKG.zip" Expand-Archive -Path "$MTG_PKG.zip" -DestinationPath "mtg-tmp" -Force diff --git a/CLAUDE.md b/CLAUDE.md index fe2b10c2b..5d4466694 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,10 +15,13 @@ file locations when it can answer in one hop. (`github.com/mhsanaei/mtg-multi`, a multi-secret fork built from source; `internal/mtproto/`) — outside Xray, one process per inbound serving each client's FakeTLS secret via the fork's `[secrets]` section (plus per-client - ad-tags via `[secret-ad-tags]`). Client and ad-tag edits are hot-applied - through the fork's management API (`PUT /secrets`, bearer-token guarded) so - connections survive; the manager falls back to a process restart on older - binaries. + ad-tags via `[secret-ad-tags]` and per-client data quota / expiry via + `[secret-limits]`, mapped from the client's `totalGB`/`expiryTime`). Client, + ad-tag and quota/expiry edits are hot-applied through the fork's management API + (`PUT /secrets`, bearer-token guarded) so connections survive; the manager + falls back to a process restart on older binaries. A client's panel-side + traffic reset also calls `POST /secrets/{name}/reset-quota` so a renewed client + is not re-blocked by the sidecar's quota counter. - Storage: SQLite by default (`/etc/x-ui/x-ui.db` on Linux; the executable dir on Windows), PostgreSQL optional (`XUI_DB_TYPE` / `XUI_DB_DSN`). The CGo SQLite driver (`mattn/go-sqlite3`) needs a C compiler — `CGO_ENABLED=0` builds fail. diff --git a/DockerInit.sh b/DockerInit.sh index b41dcccdc..10a821306 100755 --- a/DockerInit.sh +++ b/DockerInit.sh @@ -25,7 +25,11 @@ case $1 in FNAME="amd64" ;; esac -MTG_MULTI_VER="v1.14.0" +MTG_MULTI_VER=$(curl -sfL "https://api.github.com/repos/mhsanaei/mtg-multi/releases/latest" | sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p' | head -n 1) +if [ -z "$MTG_MULTI_VER" ]; then + echo "DockerInit: could not resolve the latest mtg-multi release tag" >&2 + exit 1 +fi mkdir -p build/bin cd build/bin curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.6.27/Xray-linux-${ARCH}.zip" diff --git a/internal/mtproto/manager.go b/internal/mtproto/manager.go index 43026f36b..87824049f 100644 --- a/internal/mtproto/manager.go +++ b/internal/mtproto/manager.go @@ -9,6 +9,7 @@ import ( "fmt" "net" "net/http" + "net/url" "os" "slices" "strconv" @@ -26,9 +27,11 @@ import ( // client's own advertising-tag override, emitted into the [secret-ad-tags] // section; empty falls back to the instance-level tag. type SecretEntry struct { - Name string - Secret string - AdTag string + Name string + Secret string + AdTag string + QuotaBytes int64 + ExpiresUnix int64 } // Instance is the desired runtime configuration of one mtproto inbound. A single @@ -100,12 +103,12 @@ func (inst Instance) structuralFingerprint() string { // secretsFingerprint identifies the reloadable secret config regardless of // client order, so a reordered clients array in the stored settings does not // read as a change. It moves whenever a client is added, removed, disabled, -// re-keyed, or re-tagged — all of which mtg applies in place without dropping -// connections. +// re-keyed, re-tagged, or re-limited (quota/expiry) — all of which mtg applies +// in place without dropping connections. func (inst Instance) secretsFingerprint() string { pairs := make([]string, 0, len(inst.Secrets)) for _, e := range inst.Secrets { - pairs = append(pairs, e.Name+"="+e.Secret+";tag="+e.AdTag) + pairs = append(pairs, fmt.Sprintf("%s=%s;tag=%s;q=%d;exp=%d", e.Name, e.Secret, e.AdTag, e.QuotaBytes, e.ExpiresUnix)) } slices.Sort(pairs) return strings.Join(pairs, "|") @@ -182,10 +185,12 @@ func InstanceFromInbound(ib *model.Inbound) (Instance, bool) { PublicIPv4 string `json:"publicIpv4"` PublicIPv6 string `json:"publicIpv6"` Clients []struct { - Email string `json:"email"` - Secret string `json:"secret"` - AdTag string `json:"adTag"` - Enable bool `json:"enable"` + Email string `json:"email"` + Secret string `json:"secret"` + AdTag string `json:"adTag"` + Enable bool `json:"enable"` + TotalGB int64 `json:"totalGB"` + ExpiryTime int64 `json:"expiryTime"` } `json:"clients"` } if err := json.Unmarshal([]byte(settings), &parsed); err != nil { @@ -196,7 +201,14 @@ func InstanceFromInbound(ib *model.Inbound) (Instance, bool) { if !c.Enable || c.Secret == "" || c.Email == "" { continue } - secrets = append(secrets, SecretEntry{Name: c.Email, Secret: c.Secret, AdTag: usableAdTag(c.AdTag)}) + entry := SecretEntry{Name: c.Email, Secret: c.Secret, AdTag: usableAdTag(c.AdTag)} + if c.TotalGB > 0 { + entry.QuotaBytes = c.TotalGB + } + if c.ExpiryTime > 0 { + entry.ExpiresUnix = c.ExpiryTime / 1000 + } + secrets = append(secrets, entry) } if len(secrets) == 0 { return Instance{}, false @@ -449,6 +461,53 @@ func (m *Manager) CollectTraffic() ([]Traffic, []string) { return out, online } +func (m *Manager) HasRunning() bool { + m.mu.Lock() + defer m.mu.Unlock() + for _, cur := range m.procs { + if cur.proc != nil && cur.proc.IsRunning() { + return true + } + } + return false +} + +func (m *Manager) ResetQuota(email string) { + if email == "" { + return + } + type target struct { + port int + token string + } + m.mu.Lock() + targets := make([]target, 0, len(m.procs)) + for _, cur := range m.procs { + if cur.proc != nil && cur.proc.IsRunning() { + targets = append(targets, target{cur.apiPort, cur.apiToken}) + } + } + m.mu.Unlock() + for _, t := range targets { + resetQuota(t.port, t.token, email) + } +} + +func resetQuota(port int, token, email string) { + client := http.Client{Timeout: 3 * time.Second} + endpoint := fmt.Sprintf("http://127.0.0.1:%d/secrets/%s/reset-quota", port, url.PathEscape(email)) + req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, endpoint, nil) + if err != nil { + return + } + authorize(req, token) + resp, err := client.Do(req) + if err != nil { + return + } + _ = resp.Body.Close() +} + // FreeLocalPort asks the OS for an unused loopback TCP port. It is used both // for mtg's /stats API endpoint and to allocate the per-inbound SOCKS egress // bridge port persisted into mtproto inbound settings. @@ -525,6 +584,18 @@ func renderConfig(inst Instance, apiPort int, apiToken string) string { } fmt.Fprintf(&b, "%q = %q\n", e.Name, e.AdTag) } + for _, e := range inst.Secrets { + if e.QuotaBytes <= 0 && e.ExpiresUnix <= 0 { + continue + } + fmt.Fprintf(&b, "\n[secret-limits.%q]\n", e.Name) + if e.QuotaBytes > 0 { + fmt.Fprintf(&b, "quota = %q\n", quotaString(e.QuotaBytes)) + } + if e.ExpiresUnix > 0 { + fmt.Fprintf(&b, "expires = %q\n", expiresString(e.ExpiresUnix)) + } + } b.WriteString("\n[secrets]\n") for _, e := range inst.Secrets { fmt.Fprintf(&b, "%q = %q\n", e.Name, e.Secret) @@ -549,8 +620,10 @@ type statsUser struct { } type secretPutEntry struct { - Secret string `json:"secret"` - AdTag string `json:"ad_tag,omitempty"` + Secret string `json:"secret"` + AdTag string `json:"ad_tag,omitempty"` + Quota string `json:"quota,omitempty"` + Expires string `json:"expires,omitempty"` } type secretsPutBody struct { @@ -560,11 +633,26 @@ type secretsPutBody struct { func secretsPayload(inst Instance) secretsPutBody { secrets := make(map[string]secretPutEntry, len(inst.Secrets)) for _, e := range inst.Secrets { - secrets[e.Name] = secretPutEntry{Secret: e.Secret, AdTag: e.AdTag} + entry := secretPutEntry{Secret: e.Secret, AdTag: e.AdTag} + if e.QuotaBytes > 0 { + entry.Quota = quotaString(e.QuotaBytes) + } + if e.ExpiresUnix > 0 { + entry.Expires = expiresString(e.ExpiresUnix) + } + secrets[e.Name] = entry } return secretsPutBody{Secrets: secrets} } +func quotaString(bytes int64) string { + return strconv.FormatInt(bytes, 10) + "B" +} + +func expiresString(unix int64) string { + return time.Unix(unix, 0).UTC().Format(time.RFC3339) +} + // newAPIToken mints the bearer token one mtg process and its manager share for // the lifetime of that process. The management API can replace the whole // secret set, so even though it only listens on loopback it must not be open diff --git a/internal/mtproto/manager_test.go b/internal/mtproto/manager_test.go index 9b27638f6..8114e6f09 100644 --- a/internal/mtproto/manager_test.go +++ b/internal/mtproto/manager_test.go @@ -22,7 +22,7 @@ func TestInstanceFromInbound(t *testing.T) { `"routeThroughXray":true,"routeXrayPort":50000,` + `"adTag":" 0123456789abcdef0123456789abcdef ",` + `"clients":[` + - `{"email":"alice","secret":"` + aliceSecret + `","adTag":"fedcba9876543210fedcba9876543210","enable":true},` + + `{"email":"alice","secret":"` + aliceSecret + `","adTag":"fedcba9876543210fedcba9876543210","enable":true,"totalGB":1073741824,"expiryTime":1893456000000},` + `{"email":"bob","secret":"","enable":true},` + `{"email":"carol","secret":"eeaa","enable":false}]}`, } @@ -42,6 +42,12 @@ func TestInstanceFromInbound(t *testing.T) { if inst.Secrets[0].AdTag != "fedcba9876543210fedcba9876543210" { t.Fatalf("the client ad-tag must be parsed, got %q", inst.Secrets[0].AdTag) } + if inst.Secrets[0].QuotaBytes != 1073741824 { + t.Fatalf("totalGB must map to the byte quota, got %d", inst.Secrets[0].QuotaBytes) + } + if inst.Secrets[0].ExpiresUnix != 1893456000 { + t.Fatalf("expiryTime (ms) must map to a unix-second deadline, got %d", inst.Secrets[0].ExpiresUnix) + } if inst.Port != 8443 || inst.Id != 3 { t.Fatalf("bad instance %+v", inst) } @@ -186,6 +192,54 @@ func TestRenderConfigXrayEgress(t *testing.T) { } } +func TestRenderConfigSecretLimits(t *testing.T) { + cfg := renderConfig(Instance{ + Secrets: []SecretEntry{ + {Name: "alice", Secret: "ee11", QuotaBytes: 1073741824, ExpiresUnix: 1893456000}, + {Name: "bob", Secret: "ee22", QuotaBytes: 500}, + {Name: "carol", Secret: "ee33"}, + }, + Listen: "0.0.0.0", Port: 443, + }, 6000, "") + + for _, want := range []string{ + "[secret-limits.\"alice\"]", + `quota = "1073741824B"`, + `expires = "2030-01-01T00:00:00Z"`, + "[secret-limits.\"bob\"]", + `quota = "500B"`, + } { + if !strings.Contains(cfg, want) { + t.Fatalf("limits config missing %q:\n%s", want, cfg) + } + } + if strings.Contains(cfg, "[secret-limits.\"carol\"]") { + t.Fatalf("a client without a limit must not get a table:\n%s", cfg) + } + if strings.LastIndex(cfg, "[secrets]") < strings.Index(cfg, "[secret-limits.\"alice\"]") { + t.Fatalf("[secret-limits] must precede the final [secrets] section:\n%s", cfg) + } + if strings.Contains(cfg, "usage-state-file") { + t.Fatalf("no usage-state-file must be emitted; quota is an in-memory guard:\n%s", cfg) + } +} + +func TestSecretsPayloadLimits(t *testing.T) { + body := secretsPayload(Instance{Secrets: []SecretEntry{ + {Name: "alice", Secret: "ee11", QuotaBytes: 1073741824, ExpiresUnix: 1893456000}, + {Name: "bob", Secret: "ee22"}, + }}) + if got := body.Secrets["alice"].Quota; got != "1073741824B" { + t.Fatalf("quota must be sent as an exact byte count, got %q", got) + } + if got := body.Secrets["alice"].Expires; got != "2030-01-01T00:00:00Z" { + t.Fatalf("expiry must be sent as RFC3339, got %q", got) + } + if body.Secrets["bob"].Quota != "" || body.Secrets["bob"].Expires != "" { + t.Fatalf("an unlimited client must carry no quota/expiry: %+v", body.Secrets["bob"]) + } +} + func TestFingerprintSplit(t *testing.T) { base := Instance{Secrets: []SecretEntry{{Name: "a", Secret: "ee"}}, Listen: "0.0.0.0", Port: 443} @@ -224,6 +278,8 @@ func TestFingerprintSplit(t *testing.T) { "clientAdTag": func(i *Instance) { i.Secrets = []SecretEntry{{Name: "a", Secret: "ee", AdTag: "0123456789abcdef0123456789abcdef"}} }, + "quota": func(i *Instance) { i.Secrets = []SecretEntry{{Name: "a", Secret: "ee", QuotaBytes: 1 << 30}} }, + "expiry": func(i *Instance) { i.Secrets = []SecretEntry{{Name: "a", Secret: "ee", ExpiresUnix: 1893456000}} }, } { t.Run("secrets/"+name, func(t *testing.T) { changed := base @@ -241,7 +297,7 @@ func TestFingerprintSplit(t *testing.T) { t.Run("orderInsensitive", func(t *testing.T) { forward := Instance{Secrets: []SecretEntry{{Name: "alice", Secret: "ee11"}, {Name: "bob", Secret: "ee22"}}} reversed := Instance{Secrets: []SecretEntry{{Name: "bob", Secret: "ee22"}, {Name: "alice", Secret: "ee11"}}} - if got, want := forward.secretsFingerprint(), "alice=ee11;tag=|bob=ee22;tag="; got != want { + if got, want := forward.secretsFingerprint(), "alice=ee11;tag=;q=0;exp=0|bob=ee22;tag=;q=0;exp=0"; got != want { t.Fatalf("secrets fingerprint must join sorted pairs: got %q, want %q", got, want) } if forward.secretsFingerprint() != reversed.secretsFingerprint() { diff --git a/internal/web/service/client_traffic.go b/internal/web/service/client_traffic.go index 34304b928..94bffead7 100644 --- a/internal/web/service/client_traffic.go +++ b/internal/web/service/client_traffic.go @@ -122,9 +122,13 @@ func (s *ClientService) BulkResetTraffic(inboundSvc *InboundService, emails []st } func (s *ClientService) ResetAllClientTraffics(inboundSvc *InboundService, id int) error { - return submitTrafficWrite(func() error { + err := submitTrafficWrite(func() error { return s.resetAllClientTrafficsLocked(id) }) + if err == nil { + inboundSvc.resetAllMtprotoQuotas() + } + return err } func (s *ClientService) resetAllClientTrafficsLocked(id int) error { diff --git a/internal/web/service/inbound_mtproto.go b/internal/web/service/inbound_mtproto.go index d8414e7c0..839008b9b 100644 --- a/internal/web/service/inbound_mtproto.go +++ b/internal/web/service/inbound_mtproto.go @@ -103,3 +103,55 @@ func (s *InboundService) applyLocalMtproto(inboundId int) { logger.Debug("mtproto: immediate client apply failed for inbound", inboundId, ":", err) } } + +func (s *InboundService) resetMtprotoClientQuota(email string) { + mgr := mtproto.GetManager() + if !mgr.HasRunning() { + return + } + id, ok := s.localMtprotoInboundIdForEmail(email) + if !ok { + return + } + s.applyLocalMtproto(id) + mgr.ResetQuota(email) +} + +func (s *InboundService) resetAllMtprotoQuotas() { + mgr := mtproto.GetManager() + if !mgr.HasRunning() { + return + } + desired, err := s.DesiredMtprotoInstances() + if err != nil { + return + } + mgr.Reconcile(desired) + for _, inst := range desired { + for _, sec := range inst.Secrets { + mgr.ResetQuota(sec.Name) + } + } +} + +func (s *InboundService) localMtprotoInboundIdForEmail(email string) (int, bool) { + db := database.GetDB() + var inbounds []*model.Inbound + if err := db.Model(model.Inbound{}). + Where("protocol = ? AND node_id IS NULL", model.MTProto). + Find(&inbounds).Error; err != nil { + return 0, false + } + for _, ib := range inbounds { + inst, ok := mtproto.InstanceFromInbound(ib) + if !ok { + continue + } + for _, sec := range inst.Secrets { + if sec.Name == email { + return ib.Id, true + } + } + } + return 0, false +} diff --git a/internal/web/service/inbound_traffic.go b/internal/web/service/inbound_traffic.go index 983f3484b..adeb61b5c 100644 --- a/internal/web/service/inbound_traffic.go +++ b/internal/web/service/inbound_traffic.go @@ -509,7 +509,7 @@ func (s *InboundService) delClientStatsByEmails(tx *gorm.DB, emails []string) er } func (s *InboundService) ResetClientTrafficByEmail(clientEmail string) error { - return submitTrafficWrite(func() error { + err := submitTrafficWrite(func() error { return database.GetDB().Transaction(func(tx *gorm.DB) error { if err := adjustGroupBaselinesForRemovedTraffic(tx, []string{clientEmail}); err != nil { return err @@ -525,6 +525,10 @@ func (s *InboundService) ResetClientTrafficByEmail(clientEmail string) error { return tx.Where("email = ?", clientEmail).Delete(&model.NodeClientTraffic{}).Error }) }) + if err == nil { + s.resetMtprotoClientQuota(clientEmail) + } + return err } func (s *InboundService) ResetClientTraffic(id int, clientEmail string) (needRestart bool, err error) { @@ -533,6 +537,9 @@ func (s *InboundService) ResetClientTraffic(id int, clientEmail string) (needRes needRestart, inner = s.resetClientTrafficLocked(id, clientEmail) return inner }) + if err == nil { + s.resetMtprotoClientQuota(clientEmail) + } return } @@ -646,9 +653,13 @@ func (s *InboundService) resetClientTrafficLocked(id int, clientEmail string) (b } func (s *InboundService) ResetAllTraffics() error { - return submitTrafficWrite(func() error { + err := submitTrafficWrite(func() error { return s.resetAllTrafficsLocked() }) + if err == nil { + s.resetAllMtprotoQuotas() + } + return err } func (s *InboundService) resetAllTrafficsLocked() error {