Add per-client external link controls (#5650)

* Add enable toggle for external client links

* Document external link enable API fields

* Extend external client link metadata

* Fix external subscription cache status updates

* fix(sub): address the review on per-client external link controls

Blocking: the expiry filter dropped legacy rows. expiry_time was added
without a default, so AutoMigrate makes it nullable and backfills NULL,
and `expiry_time = 0 OR expiry_time > ?` is false for NULL under
three-valued logic — every external link written before the upgrade
vanished from all subscriptions. Add `default:0` on expiry_time and
last_fetch_at, make the predicate NULL-tolerant, and backfill the NULLs
a pre-fix build could already have written.

Rework fetch-status recording. It ran inside the singleflight in-flight
window, so every goroutine parked on the shared fetch waited for a DB
write to commit on the public, unauthenticated subscription path — and
because it was keyed on the row id, waiters and cache hits recorded
nothing, leaving rows that lost the race stuck on "Not fetched yet"
forever. fetchSubscriptionLinks now reports whether it did the network
fetch and expandEntry records afterwards, off the serving path, keyed on
kind+value so every row sharing the URL is stamped by the one fetch.
Keying on value also closes the recycled-rowid hazard: saves delete and
re-insert rows, and SQLite reuses rowids, so an in-flight write could
land on an unrelated client's row. The write no longer discards its
error either.

Drop the inert id round-trip. The panel never sent it, and the byId
branch was guarded by the exact kind+value equality that byKindValue
already keys on, so it could not change an outcome. Matching on
kind+value alone is what actually preserves fetch status across saves.

Reject a negative expiryTime instead of storing a row that is silently
invisible in every subscription — elsewhere a negative expiryTime means
"a duration from first use", so an API caller reusing that convention
got no error and no links.

Drop the ~50 lines of .client-form-* / .client-inbounds-field CSS that
no component renders; it is leftover from the WireGuard PR this one was
split from.

i18n: reuse the already-translated pages.inbounds.leaveBlankToNeverExpire
instead of shipping an English duplicate under pages.clients, and
translate namePrefix, lastFetchAt, lastFetchError and neverFetched into
all 12 non-English locales.

Cover the persistence path that had no test: the fetch-status writer over
a real DB against a failing then a succeeding server, a cache hit writing
nothing, and the negative-expiry rejection.

---------

Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
This commit is contained in:
jason zhang
2026-08-18 19:55:04 +08:00
committed by GitHub
parent 708a69acde
commit abd320994a
29 changed files with 728 additions and 84 deletions
+38 -19
View File
@@ -4,6 +4,7 @@ import (
"encoding/base64"
"net/url"
"strings"
"time"
"github.com/goccy/go-json"
@@ -16,11 +17,12 @@ import (
// externalLinkEntry is one client × external-link row, resolved for a
// subscription request. Email/Enable come from the owning client.
type externalLinkEntry struct {
Kind string
Value string
Remark string
Email string
Enable bool
Kind string
Value string
Remark string
NamePrefix string
Email string
Enable bool
}
// expandedLink is a single share link contributed by an entry, with the display
@@ -50,7 +52,10 @@ func (s *SubService) getClientExternalLinksBySubId(subId string) ([]externalLink
}
var rows []model.ClientExternalLink
now := time.Now().UnixMilli()
if err := db.Where("client_id IN ?", clientIds).
Where("(enable IS NULL OR enable = ?)", true).
Where("(expiry_time IS NULL OR expiry_time <= 0 OR expiry_time > ?)", now).
Order("client_id ASC, sort_index ASC, id ASC").
Find(&rows).Error; err != nil {
return nil, err
@@ -63,27 +68,28 @@ func (s *SubService) getClientExternalLinksBySubId(subId string) ([]externalLink
for _, r := range rows {
rec := byId[r.ClientId]
out = append(out, externalLinkEntry{
Kind: r.Kind,
Value: r.Value,
Remark: r.Remark,
Email: rec.Email,
Enable: rec.Enable,
Kind: r.Kind,
Value: r.Value,
Remark: r.Remark,
NamePrefix: r.NamePrefix,
Email: rec.Email,
Enable: rec.Enable,
})
}
return out, nil
}
// expandEntry turns one entry into the concrete share links it contributes. A
// "subscription" entry is fetched (cached) and its links keep their own names
// (URL #fragment / vmess ps). A "link" entry uses the row remark when set,
// otherwise the link's original name — never blank, so Clash/JSON do not fall
// back to the client email.
// expandEntry turns one entry into the concrete share links it contributes.
// Names are never blank, so Clash/JSON do not fall back to the client email.
func expandEntry(e externalLinkEntry) []expandedLink {
if e.Kind == model.ExternalLinkKindSubscription {
links := fetchSubscriptionLinks(e.Value)
out := make([]expandedLink, 0, len(links))
for _, l := range links {
out = append(out, expandedLink{Link: l, Name: linkDisplayName(l)})
res := fetchSubscriptionLinks(e.Value)
if res.fetched {
recordExternalSubscriptionFetch(e.Value, res.err)
}
out := make([]expandedLink, 0, len(res.links))
for _, l := range res.links {
out = append(out, expandedLink{Link: l, Name: prefixedLinkName(linkDisplayName(l), e.NamePrefix, e.Email)})
}
return out
}
@@ -129,6 +135,19 @@ func linkDisplayName(rawLink string) string {
return ""
}
// prefixedLinkName falls back to the client email so a prefixed row never
// renders as the bare prefix when the link carries no name of its own.
func prefixedLinkName(displayName, prefix, fallback string) string {
if strings.TrimSpace(prefix) == "" {
return displayName
}
name := displayName
if name == "" {
name = strings.TrimSpace(fallback)
}
return prefix + name
}
// applyRemarkToLink rewrites a share link's display name to remark (when set),
// leaving everything else byte-for-byte. vmess carries its remark in the base64
// JSON `ps`; every other scheme carries it in the URL #fragment.
+26
View File
@@ -5,6 +5,7 @@ import (
"net/url"
"strings"
"testing"
"time"
"github.com/goccy/go-json"
@@ -98,6 +99,31 @@ func TestExpandEntryLinkAppliesRemark(t *testing.T) {
}
}
func TestExpandEntrySubscriptionAppliesNamePrefix(t *testing.T) {
const subURL = "https://provider.example/sub-prefix"
subscriptionCache.Lock()
subscriptionCache.m[subURL] = subscriptionCacheEntry{
links: []string{"trojan://pw@b.com:8443#HK-01"},
fetchedAt: time.Now(),
}
subscriptionCache.Unlock()
t.Cleanup(func() {
subscriptionCache.Lock()
delete(subscriptionCache.m, subURL)
subscriptionCache.Unlock()
})
got := expandEntry(externalLinkEntry{
Kind: model.ExternalLinkKindSubscription,
Value: subURL,
NamePrefix: "[zjh] ",
Email: "zjh",
})
if len(got) != 1 || got[0].Name != "[zjh] HK-01" {
t.Fatalf("expandEntry = %#v", got)
}
}
func TestExpandEntryLinkFallsBackToOriginalName(t *testing.T) {
got := expandEntry(externalLinkEntry{Kind: model.ExternalLinkKindLink, Value: "trojan://pw@b.com:8443#orig", Remark: ""})
if len(got) != 1 || got[0].Name != "orig" {
+40 -6
View File
@@ -8,6 +8,10 @@ import (
"strings"
"sync"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
)
// External subscription fetching: a "subscription" external link is a remote
@@ -42,26 +46,34 @@ var subscriptionCache = struct {
inflight: make(map[string]*subscriptionFetch),
}
// subscriptionFetchResult reports whether this caller performed the network
// fetch, so only it records status and cache hits stay read-only.
type subscriptionFetchResult struct {
links []string
fetched bool
err error
}
// fetchSubscriptionLinks returns the share links contained in a remote
// subscription URL, using a short-lived cache. On any failure it returns the
// last cached value (if present) or nil — never an error, so the rest of the
// client's subscription still renders.
func fetchSubscriptionLinks(rawURL string) []string {
func fetchSubscriptionLinks(rawURL string) subscriptionFetchResult {
rawURL = strings.TrimSpace(rawURL)
if rawURL == "" {
return nil
return subscriptionFetchResult{}
}
subscriptionCache.Lock()
cached, ok := subscriptionCache.m[rawURL]
if ok && time.Since(cached.fetchedAt) < subscriptionCacheTTL {
subscriptionCache.Unlock()
return cached.links
return subscriptionFetchResult{links: cached.links}
}
if fetch, waiting := subscriptionCache.inflight[rawURL]; waiting {
subscriptionCache.Unlock()
<-fetch.done
return fetch.links
return subscriptionFetchResult{links: fetch.links}
}
fetch := &subscriptionFetch{done: make(chan struct{})}
subscriptionCache.inflight[rawURL] = fetch
@@ -78,7 +90,7 @@ func fetchSubscriptionLinks(rawURL string) []string {
if ok {
fetch.links = cached.links
}
return fetch.links
return subscriptionFetchResult{links: fetch.links, fetched: true, err: err}
}
subscriptionCache.Lock()
@@ -86,7 +98,7 @@ func fetchSubscriptionLinks(rawURL string) []string {
trimSubscriptionCacheLocked(rawURL)
subscriptionCache.Unlock()
fetch.links = links
return fetch.links
return subscriptionFetchResult{links: links, fetched: true}
}
func trimSubscriptionCacheLocked(keep string) {
@@ -109,6 +121,28 @@ func trimSubscriptionCacheLocked(keep string) {
}
}
// recordExternalSubscriptionFetch stamps status on every row holding this URL,
// keyed by value because row ids churn on save and the cache is per URL.
func recordExternalSubscriptionFetch(rawURL string, fetchErr error) {
rawURL = strings.TrimSpace(rawURL)
if rawURL == "" {
return
}
lastFetchError := ""
if fetchErr != nil {
lastFetchError = fetchErr.Error()
}
if err := database.GetDB().
Model(&model.ClientExternalLink{}).
Where("kind = ? AND value = ?", model.ExternalLinkKindSubscription, rawURL).
Updates(map[string]any{
"last_fetch_at": time.Now().UnixMilli(),
"last_fetch_error": lastFetchError,
}).Error; err != nil {
logger.Warningf("sub: recording fetch status for external subscription %q: %v", rawURL, err)
}
}
func doFetchSubscriptionLinks(rawURL string) ([]string, error) {
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, rawURL, nil)
if err != nil {
+124 -4
View File
@@ -10,6 +10,9 @@ import (
"sync/atomic"
"testing"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
func resetSubscriptionCache(t *testing.T) {
@@ -44,7 +47,7 @@ func TestFetchSubscriptionLinksSharesConcurrentRefresh(t *testing.T) {
var wg sync.WaitGroup
for range callers {
wg.Go(func() {
results <- fetchSubscriptionLinks(srv.URL)
results <- fetchSubscriptionLinks(srv.URL).links
})
}
@@ -73,7 +76,7 @@ func TestFetchSubscriptionLinksBoundsCacheSize(t *testing.T) {
defer srv.Close()
for i := range subscriptionCacheCapacity + 1 {
links := fetchSubscriptionLinks(srv.URL + "?id=" + strconv.Itoa(i))
links := fetchSubscriptionLinks(srv.URL + "?id=" + strconv.Itoa(i)).links
if len(links) != 1 {
t.Fatalf("links at %d = %#v", i, links)
}
@@ -122,12 +125,12 @@ func TestFetchSubscriptionLinksSharesStaleResultAfterRefreshFailure(t *testing.T
var wg sync.WaitGroup
for range callers {
wg.Go(func() {
results <- fetchSubscriptionLinks(staleURL)
results <- fetchSubscriptionLinks(staleURL).links
})
}
time.Sleep(100 * time.Millisecond)
if links := fetchSubscriptionLinks(srv.URL + "/fresh"); len(links) != 1 || links[0] != "vless://fresh@example.com:443" {
if links := fetchSubscriptionLinks(srv.URL + "/fresh").links; len(links) != 1 || links[0] != "vless://fresh@example.com:443" {
t.Fatalf("fresh links = %#v", links)
}
close(release)
@@ -178,3 +181,120 @@ func TestDoFetchSubscriptionLinks_AcceptsBodyAtLimit(t *testing.T) {
t.Fatalf("links = %v, want [%q]", links, link)
}
}
func TestRecordExternalSubscriptionFetchStampsEveryRowForTheURL(t *testing.T) {
initMutDB(t)
resetSubscriptionCache(t)
db := database.GetDB()
var failing atomic.Bool
failing.Store(true)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if failing.Load() {
w.WriteHeader(http.StatusBadGateway)
return
}
_, _ = w.Write([]byte("vless://uuid@example.com:443#Node"))
}))
defer srv.Close()
owners := []model.ClientRecord{
{Email: "one@example.com", SubID: "sub-fetch", UUID: "uuid-1", Enable: true},
{Email: "two@example.com", SubID: "sub-fetch", UUID: "uuid-2", Enable: true},
}
for i := range owners {
if err := db.Create(&owners[i]).Error; err != nil {
t.Fatalf("seed client %d: %v", i, err)
}
row := model.ClientExternalLink{
ClientId: owners[i].Id,
Kind: model.ExternalLinkKindSubscription,
Value: srv.URL,
}
if err := db.Create(&row).Error; err != nil {
t.Fatalf("seed external link %d: %v", i, err)
}
}
svc := NewSubService("")
entries, err := svc.getClientExternalLinksBySubId("sub-fetch")
if err != nil {
t.Fatalf("getClientExternalLinksBySubId: %v", err)
}
if len(entries) != 2 {
t.Fatalf("entries = %d, want 2", len(entries))
}
for _, e := range entries {
expandEntry(e)
}
var rows []model.ClientExternalLink
if err := db.Where("value = ?", srv.URL).Find(&rows).Error; err != nil {
t.Fatalf("read rows: %v", err)
}
if len(rows) != 2 {
t.Fatalf("rows = %d, want 2", len(rows))
}
for _, row := range rows {
if row.LastFetchAt <= 0 {
t.Fatalf("row %d lastFetchAt = %d, want a stamped timestamp", row.Id, row.LastFetchAt)
}
if row.LastFetchError != errBadStatus.Error() {
t.Fatalf("row %d lastFetchError = %q, want %q", row.Id, row.LastFetchError, errBadStatus)
}
}
failing.Store(false)
resetSubscriptionCache(t)
for _, e := range entries {
expandEntry(e)
}
if err := db.Where("value = ?", srv.URL).Find(&rows).Error; err != nil {
t.Fatalf("re-read rows: %v", err)
}
for _, row := range rows {
if row.LastFetchError != "" {
t.Fatalf("row %d lastFetchError = %q, want cleared after a good fetch", row.Id, row.LastFetchError)
}
if row.LastFetchAt <= 0 {
t.Fatalf("row %d lastFetchAt = %d, want a stamped timestamp", row.Id, row.LastFetchAt)
}
}
}
func TestExpandEntryCacheHitWritesNothing(t *testing.T) {
initMutDB(t)
resetSubscriptionCache(t)
db := database.GetDB()
const subURL = "https://provider.example/cached"
rec := model.ClientRecord{Email: "cached@example.com", SubID: "sub-cached", UUID: "uuid", Enable: true}
if err := db.Create(&rec).Error; err != nil {
t.Fatalf("seed client: %v", err)
}
row := model.ClientExternalLink{ClientId: rec.Id, Kind: model.ExternalLinkKindSubscription, Value: subURL}
if err := db.Create(&row).Error; err != nil {
t.Fatalf("seed external link: %v", err)
}
subscriptionCache.Lock()
subscriptionCache.m[subURL] = subscriptionCacheEntry{
links: []string{"vless://uuid@example.com:443#Node"},
fetchedAt: time.Now(),
}
subscriptionCache.Unlock()
if got := expandEntry(externalLinkEntry{Kind: model.ExternalLinkKindSubscription, Value: subURL}); len(got) != 1 {
t.Fatalf("expandEntry = %#v, want the cached link", got)
}
var after model.ClientExternalLink
if err := db.First(&after, row.Id).Error; err != nil {
t.Fatalf("read row: %v", err)
}
if after.LastFetchAt != 0 || after.LastFetchError != "" {
t.Fatalf("cache hit wrote fetch status: %#v", after)
}
}
+11
View File
@@ -6,6 +6,7 @@ import (
"path/filepath"
"strings"
"testing"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
@@ -24,6 +25,10 @@ func initMutDB(t *testing.T) {
t.Cleanup(func() { _ = database.CloseDB() })
}
func externalLinkEnabled(v bool) *bool {
return &v
}
// --- json_service.go:40 — rules are merged into routing only when non-empty ---
func TestSubJsonService_CustomRulesPrepended(t *testing.T) {
@@ -307,6 +312,12 @@ func TestGetClientExternalLinksBySubId(t *testing.T) {
if err := db.Create(&model.ClientExternalLink{ClientId: rec.Id, Kind: model.ExternalLinkKindLink, Value: "trojan://a", Remark: "first", SortIndex: 1}).Error; err != nil {
t.Fatalf("seed link a: %v", err)
}
if err := db.Create(&model.ClientExternalLink{ClientId: rec.Id, Kind: model.ExternalLinkKindLink, Value: "trojan://disabled", Remark: "disabled", Enable: externalLinkEnabled(false), SortIndex: 3}).Error; err != nil {
t.Fatalf("seed disabled link: %v", err)
}
if err := db.Create(&model.ClientExternalLink{ClientId: rec.Id, Kind: model.ExternalLinkKindLink, Value: "trojan://expired", Remark: "expired", ExpiryTime: time.Now().Add(-time.Hour).UnixMilli(), SortIndex: 4}).Error; err != nil {
t.Fatalf("seed expired link: %v", err)
}
out, err = s.getClientExternalLinksBySubId("sub-ok")
if err != nil {