mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-04 09:27:15 +00:00
Feature/fix external subscription client expiry (#6333)
* fix(sub): honor client expiry for external links * fix(ui): show client expiry on external links * fix(sub): address external expiry review
This commit is contained in:
@@ -39,6 +39,7 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
|
||||
}
|
||||
|
||||
var proxies []map[string]any
|
||||
var hasInactiveExternal bool
|
||||
|
||||
seenEmails := make(map[string]struct{})
|
||||
for _, inbound := range inbounds {
|
||||
@@ -56,6 +57,11 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
|
||||
}
|
||||
}
|
||||
for _, ext := range externalLinks {
|
||||
if !ext.Active {
|
||||
seenEmails[ext.Email] = struct{}{}
|
||||
hasInactiveExternal = true
|
||||
continue
|
||||
}
|
||||
for _, el := range expandEntry(ext) {
|
||||
name := el.Name
|
||||
if name == "" {
|
||||
@@ -68,17 +74,21 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
|
||||
}
|
||||
}
|
||||
|
||||
if len(proxies) == 0 {
|
||||
if len(proxies) == 0 && !hasInactiveExternal {
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
ensureUniqueProxyNames(proxies)
|
||||
|
||||
emails := make([]string, 0, len(seenEmails))
|
||||
for e := range seenEmails {
|
||||
emails = append(emails, e)
|
||||
}
|
||||
traffic, _ := subReq.AggregateTrafficByEmails(emails)
|
||||
header := fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000)
|
||||
if len(proxies) == 0 {
|
||||
return "", header, nil
|
||||
}
|
||||
|
||||
ensureUniqueProxyNames(proxies)
|
||||
|
||||
proxyNames := make([]string, 0, len(proxies)+1)
|
||||
for _, proxy := range proxies {
|
||||
@@ -116,7 +126,6 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
header := fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000)
|
||||
return string(finalYAML), header, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -345,7 +345,7 @@ func (a *SUBController) buildSubPageData(c *gin.Context) (PageData, bool) {
|
||||
subReq := a.subService.ForRequest(host)
|
||||
subReq.subscriptionBody = false
|
||||
subs, emails, lastOnline, traffic, err := subReq.getSubs(subId)
|
||||
if err != nil || len(subs) == 0 {
|
||||
if err != nil || subs == nil {
|
||||
writeSubError(c, err)
|
||||
return PageData{}, false
|
||||
}
|
||||
@@ -413,7 +413,7 @@ func (a *SUBController) subs(c *gin.Context) {
|
||||
subReq := a.subService.ForRequest(host)
|
||||
subReq.subscriptionBody = true
|
||||
subs, _, _, traffic, err := subReq.getSubs(subId)
|
||||
if err != nil || len(subs) == 0 {
|
||||
if err != nil || subs == nil {
|
||||
writeSubError(c, err)
|
||||
} else {
|
||||
var result strings.Builder
|
||||
@@ -742,7 +742,7 @@ func (a *SUBController) serveJsonBody(c *gin.Context, alwaysReturnArray bool, co
|
||||
writeSubError(c, err)
|
||||
return true
|
||||
}
|
||||
if len(jsonSub) == 0 {
|
||||
if len(jsonSub) == 0 && header == "" {
|
||||
return false
|
||||
}
|
||||
profileURL := fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
|
||||
@@ -793,7 +793,7 @@ func (a *SUBController) serveClashBody(c *gin.Context, rawDownload bool) bool {
|
||||
writeSubError(c, err)
|
||||
return true
|
||||
}
|
||||
if len(clashSub) == 0 {
|
||||
if len(clashSub) == 0 && header == "" {
|
||||
return false
|
||||
}
|
||||
profileURL := fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
|
||||
|
||||
@@ -14,8 +14,8 @@ import (
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/link"
|
||||
)
|
||||
|
||||
// externalLinkEntry is one client × external-link row, resolved for a
|
||||
// subscription request. Email/Enable come from the owning client.
|
||||
// externalLinkEntry is one client × external-link row resolved for a request.
|
||||
// Active applies the owning client's enabled and expiry state.
|
||||
type externalLinkEntry struct {
|
||||
Kind string
|
||||
Value string
|
||||
@@ -23,6 +23,7 @@ type externalLinkEntry struct {
|
||||
NamePrefix string
|
||||
Email string
|
||||
Enable bool
|
||||
Active bool
|
||||
}
|
||||
|
||||
// expandedLink is a single share link contributed by an entry, with the display
|
||||
@@ -32,9 +33,8 @@ type expandedLink struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
// getClientExternalLinksBySubId returns every external-link row attached to a
|
||||
// client that carries the given subId, in stable order. Stays inside
|
||||
// internal/sub + database + util/link — no dependency on the panel service layer.
|
||||
// getClientExternalLinksBySubId returns active rows with owner state attached.
|
||||
// Consumers keep inactive owners as metadata but omit their link values.
|
||||
func (s *SubService) getClientExternalLinksBySubId(subId string) ([]externalLinkEntry, error) {
|
||||
db := database.GetDB()
|
||||
var recs []model.ClientRecord
|
||||
@@ -74,6 +74,7 @@ func (s *SubService) getClientExternalLinksBySubId(subId string) ([]externalLink
|
||||
NamePrefix: r.NamePrefix,
|
||||
Email: rec.Email,
|
||||
Enable: rec.Enable,
|
||||
Active: rec.Enable && (rec.ExpiryTime <= 0 || rec.ExpiryTime > now),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package sub
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
|
||||
func seedInactiveExternalOnlySub(t *testing.T, subID, email string, enabled bool, expiry int64) {
|
||||
t.Helper()
|
||||
db := database.GetDB()
|
||||
rec := &model.ClientRecord{Email: email, SubID: subID, UUID: subID + "-uuid", Enable: true, ExpiryTime: expiry}
|
||||
if err := db.Create(rec).Error; err != nil {
|
||||
t.Fatalf("seed client: %v", err)
|
||||
}
|
||||
if !enabled {
|
||||
if err := db.Model(rec).Update("enable", false).Error; err != nil {
|
||||
t.Fatalf("disable client: %v", err)
|
||||
}
|
||||
}
|
||||
if err := db.Create(&xray.ClientTraffic{Email: email, Up: 11, Down: 22, Total: 1024, ExpiryTime: expiry}).Error; err != nil {
|
||||
t.Fatalf("seed traffic: %v", err)
|
||||
}
|
||||
link := "vless://11111111-1111-1111-1111-111111111111@example.com:443?type=tcp&security=reality&pbk=abc&sid=12&fp=chrome#external"
|
||||
if err := db.Create(&model.ClientExternalLink{ClientId: rec.Id, Kind: model.ExternalLinkKindLink, Value: link, SortIndex: 1}).Error; err != nil {
|
||||
t.Fatalf("seed external link: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInactiveExternalOnlySubRemainsKnownWithoutExposingLinks(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
states := []struct {
|
||||
name string
|
||||
enabled bool
|
||||
expiry int64
|
||||
}{
|
||||
{name: "disabled", enabled: false, expiry: time.Now().Add(time.Hour).UnixMilli()},
|
||||
{name: "expired", enabled: true, expiry: time.Now().Add(-time.Hour).UnixMilli()},
|
||||
}
|
||||
|
||||
for _, state := range states {
|
||||
t.Run(state.name, func(t *testing.T) {
|
||||
initSubDB(t)
|
||||
subID := "external-" + state.name
|
||||
email := state.name + "@example.com"
|
||||
seedInactiveExternalOnlySub(t, subID, email, state.enabled, state.expiry)
|
||||
|
||||
oldDistFS := distFS
|
||||
distFS = testDistFS
|
||||
t.Cleanup(func() { distFS = oldDistFS })
|
||||
|
||||
router := gin.New()
|
||||
NewSUBController(
|
||||
router.Group("/"),
|
||||
WithSUBJsonEnabled(true),
|
||||
WithSUBClashEnabled(true),
|
||||
WithSUBEncryption(false),
|
||||
)
|
||||
|
||||
wantHeader := fmt.Sprintf("upload=11; download=22; total=1024; expire=%d", state.expiry/1000)
|
||||
for _, path := range []string{"/sub/" + subID, "/json/" + subID + "?view=raw", "/clash/" + subID + "?view=raw"} {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
if err := database.GetDB().Model(&xray.ClientTraffic{}).Where("email = ?", email).Update("last_sub_fetch", 0).Error; err != nil {
|
||||
t.Fatalf("reset last_sub_fetch: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
req.Host = "sub.example.com"
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if w.Body.Len() != 0 {
|
||||
t.Fatalf("inactive external link leaked in body: %s", w.Body.String())
|
||||
}
|
||||
if got := w.Header().Get("Subscription-Userinfo"); got != wantHeader {
|
||||
t.Fatalf("Subscription-Userinfo = %q, want %q", got, wantHeader)
|
||||
}
|
||||
var traffic xray.ClientTraffic
|
||||
if err := database.GetDB().Where("email = ?", email).First(&traffic).Error; err != nil {
|
||||
t.Fatalf("load traffic: %v", err)
|
||||
}
|
||||
if traffic.LastSubFetch == 0 {
|
||||
t.Fatal("successful empty response did not update last_sub_fetch")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/sub/"+subID, nil)
|
||||
req.Host = "sub.example.com"
|
||||
req.Header.Set("Accept", "text/html")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("HTML status = %d, want 200; body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if strings.Contains(w.Body.String(), "11111111-1111-1111-1111-111111111111") {
|
||||
t.Fatalf("HTML page exposed inactive external link: %s", w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), `"links":[]`) {
|
||||
t.Fatalf("HTML page did not render an empty links list: %s", w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,7 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
|
||||
}
|
||||
|
||||
var header string
|
||||
var hasInactiveExternal bool
|
||||
|
||||
seenEmails := make(map[string]struct{})
|
||||
entries := make([]subConfigEntry, 0, len(inbounds))
|
||||
@@ -127,6 +128,11 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
|
||||
configArray = append(configArray, entry.configs...)
|
||||
}
|
||||
for _, ext := range externalLinks {
|
||||
if !ext.Active {
|
||||
seenEmails[ext.Email] = struct{}{}
|
||||
hasInactiveExternal = true
|
||||
continue
|
||||
}
|
||||
for _, el := range expandEntry(ext) {
|
||||
outbound := parsedExternalOutbound(el.Link)
|
||||
if outbound == nil {
|
||||
@@ -148,7 +154,7 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
|
||||
}
|
||||
}
|
||||
|
||||
if len(configArray) == 0 {
|
||||
if len(configArray) == 0 && !hasInactiveExternal {
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
@@ -157,6 +163,10 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
|
||||
emails = append(emails, e)
|
||||
}
|
||||
traffic, _ := subReq.AggregateTrafficByEmails(emails)
|
||||
header = fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000)
|
||||
if len(configArray) == 0 {
|
||||
return "", header, nil
|
||||
}
|
||||
|
||||
var finalJson []byte
|
||||
if len(configArray) == 1 && !alwaysReturnArray {
|
||||
@@ -165,7 +175,6 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
|
||||
finalJson, _ = json.MarshalIndent(configArray, "", " ")
|
||||
}
|
||||
|
||||
header = fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000)
|
||||
return string(finalJson), header, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -298,7 +298,7 @@ func TestGetClientExternalLinksBySubId(t *testing.T) {
|
||||
|
||||
// A client with two link rows: ordering by sort_index and email/enable
|
||||
// attribution from the owning client (the loop copies rec.Email/rec.Enable).
|
||||
rec := &model.ClientRecord{Email: "owner@x", SubID: "sub-ok", UUID: "u2", Enable: true}
|
||||
rec := &model.ClientRecord{Email: "owner@x", SubID: "sub-ok", UUID: "u2", Enable: true, ExpiryTime: time.Now().Add(time.Hour).UnixMilli()}
|
||||
if err := db.Create(rec).Error; err != nil {
|
||||
t.Fatalf("seed client: %v", err)
|
||||
}
|
||||
@@ -331,10 +331,12 @@ func TestGetClientExternalLinksBySubId(t *testing.T) {
|
||||
if out[0].Email != "owner@x" || out[0].Enable != true {
|
||||
t.Fatalf("attribution wrong: email=%q enable=%v", out[0].Email, out[0].Enable)
|
||||
}
|
||||
if !out[0].Active {
|
||||
t.Fatal("active owner marked inactive")
|
||||
}
|
||||
|
||||
// A DISABLED client must produce entries with Enable=false, proving the
|
||||
// value is read from the client row (Enable has a gorm default:true, so
|
||||
// flip it with a raw UPDATE that bypasses the default).
|
||||
// A disabled owner stays visible as metadata but cannot expose its link.
|
||||
// Enable has a gorm default:true, so update it after insertion.
|
||||
dis := &model.ClientRecord{Email: "off@x", SubID: "sub-off", UUID: "u3", Enable: true}
|
||||
if err := db.Create(dis).Error; err != nil {
|
||||
t.Fatalf("seed disabled client: %v", err)
|
||||
@@ -352,8 +354,26 @@ func TestGetClientExternalLinksBySubId(t *testing.T) {
|
||||
if len(offOut) != 1 {
|
||||
t.Fatalf("disabled client entries = %d, want 1", len(offOut))
|
||||
}
|
||||
if offOut[0].Email != "off@x" || offOut[0].Enable != false {
|
||||
t.Fatalf("disabled attribution wrong: email=%q enable=%v", offOut[0].Email, offOut[0].Enable)
|
||||
if offOut[0].Enable || offOut[0].Active {
|
||||
t.Fatalf("disabled owner state = enable:%v active:%v", offOut[0].Enable, offOut[0].Active)
|
||||
}
|
||||
|
||||
expired := &model.ClientRecord{Email: "expired@x", SubID: "sub-expired", UUID: "u4", Enable: true, ExpiryTime: time.Now().Add(-time.Hour).UnixMilli()}
|
||||
if err := db.Create(expired).Error; err != nil {
|
||||
t.Fatalf("seed expired client: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.ClientExternalLink{ClientId: expired.Id, Kind: model.ExternalLinkKindLink, Value: "trojan://d", SortIndex: 1}).Error; err != nil {
|
||||
t.Fatalf("seed expired client link: %v", err)
|
||||
}
|
||||
expiredOut, err := s.getClientExternalLinksBySubId("sub-expired")
|
||||
if err != nil {
|
||||
t.Fatalf("expired subId err = %v", err)
|
||||
}
|
||||
if len(expiredOut) != 1 {
|
||||
t.Fatalf("expired client entries = %d, want 1", len(expiredOut))
|
||||
}
|
||||
if !expiredOut[0].Enable || expiredOut[0].Active {
|
||||
t.Fatalf("expired owner state = enable:%v active:%v", expiredOut[0].Enable, expiredOut[0].Active)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -340,6 +340,13 @@ func (s *SubService) getSubs(subId string) ([]string, []string, int64, xray.Clie
|
||||
if ext.Enable {
|
||||
hasEnabledClient = true
|
||||
}
|
||||
if !ext.Active {
|
||||
seenEmails[ext.Email] = struct{}{}
|
||||
if result == nil {
|
||||
result = []string{}
|
||||
}
|
||||
continue
|
||||
}
|
||||
for _, el := range expandEntry(ext) {
|
||||
if link := applyRemarkToLink(el.Link, el.Name); link != "" {
|
||||
result = append(result, link)
|
||||
|
||||
Reference in New Issue
Block a user