fix(outbound): store subscription refreshes without trusting the row read

The refresh rewrote the whole row it read before a download whose length
the provider controls, so an admin's delete, disable or edit made in the
meantime was undone; Update had the mirror problem. Both now write only
their own columns, and a refresh of a row that was deleted or re-pointed
stores nothing.

Around the same path:
- the preview fetches without creating (and leaking) a scratch row, and
  Create keeps enabled=false instead of falling back to the column default;
- fetch errors name the host but not the subscription URL, which is a
  credential and ends up in the log and in lastError;
- each fetch closes its transport's idle connections;
- one document loads at most 2000 links, and the cut is reported;
- wireguard outbounds from a subscription always run on the userspace TUN;
- editing a subscription schedules an Xray reload like delete and move do.
This commit is contained in:
MHSanaei
2026-10-31 08:31:03 +00:00
parent 2d72d9c715
commit f1444bec3f
5 changed files with 353 additions and 51 deletions
@@ -6,12 +6,16 @@ import (
"errors"
"fmt"
"maps"
"net"
"net/http"
"net/http/httptest"
"net/url"
"slices"
"strconv"
"strings"
"sync/atomic"
"testing"
"time"
"gorm.io/gorm"
@@ -504,3 +508,190 @@ func TestNamesInternalHost(t *testing.T) {
}
}
}
// The provider decides when its answer arrives, so an admin's delete or disable in the
// meantime must survive the refresh that read the row before the download.
func TestOutboundSubscriptionRefreshKeepsAdminChangesMadeDuringTheFetch(t *testing.T) {
setupSettingTestDB(t)
const body = "vless://00000000-0000-4000-8000-000000000000@8.8.8.8:443?security=tls&type=tcp#B"
var during func(id int)
var subID int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
during(subID)
_, _ = w.Write([]byte(body))
}))
t.Cleanup(server.Close)
svc := &OutboundSubscriptionService{}
create := func() {
t.Helper()
sub := &model.OutboundSubscription{Url: server.URL, AllowPrivate: true, TagPrefix: "p-", Enabled: true}
if err := database.GetDB().Create(sub).Error; err != nil {
t.Fatalf("seed subscription: %v", err)
}
subID = sub.Id
}
create()
during = func(id int) { _ = svc.Delete(id) }
if _, err := svc.Refresh(subID); err == nil {
t.Fatalf("Refresh of a subscription deleted mid-fetch succeeded")
}
var count int64
database.GetDB().Model(&model.OutboundSubscription{}).Where("id = ?", subID).Count(&count)
if count != 0 {
t.Fatalf("subscription deleted during the fetch was re-created")
}
create()
during = func(id int) {
database.GetDB().Model(&model.OutboundSubscription{}).Where("id = ?", id).Update("enabled", false)
}
if _, err := svc.Refresh(subID); err != nil {
t.Fatalf("Refresh: %v", err)
}
got, err := svc.Get(subID)
if err != nil || got.Enabled || !strings.Contains(got.LastFetchedOutbounds, "8.8.8.8") {
t.Fatalf("after disable mid-fetch: enabled=%v outbounds=%q (%v), want disabled with the fetch stored", got.Enabled, got.LastFetchedOutbounds, err)
}
}
// A subscription URL is the credential for the provider account; fetch errors are
// logged and stored, so they may name the host but never the path or query.
func TestOutboundSubscriptionFetchErrorsKeepTheURLSecret(t *testing.T) {
setupSettingTestDB(t)
closed := httptest.NewServer(http.NotFoundHandler())
closed.Close()
const token = "s3cr3t-token"
sub := &model.OutboundSubscription{Url: closed.URL + "/sub/" + token + "?key=" + token, AllowPrivate: true, TagPrefix: "p-"}
if err := database.GetDB().Create(sub).Error; err != nil {
t.Fatalf("seed subscription: %v", err)
}
_, err := (&OutboundSubscriptionService{}).Refresh(sub.Id)
if err == nil || strings.Contains(err.Error(), token) {
t.Fatalf("Refresh error = %v, want a failure that does not quote the URL", err)
}
stored, _ := (&OutboundSubscriptionService{}).Get(sub.Id)
if stored.LastError == "" || strings.Contains(stored.LastError, token) {
t.Fatalf("lastError = %q, want a failure that does not quote the URL", stored.LastError)
}
_, err = (&OutboundSubscriptionService{}).Create("r", "https://provider.example/sub/"+token+"%zz", "", "", true, 600, false, false, false)
if err == nil || strings.Contains(err.Error(), token) {
t.Fatalf("Create error = %v, want a parse failure that does not quote the URL", err)
}
}
func TestOutboundSubscriptionPreviewStoresNothing(t *testing.T) {
setupSettingTestDB(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("vless://00000000-0000-4000-8000-000000000000@8.8.8.8:443?security=tls&type=tcp#B"))
}))
t.Cleanup(server.Close)
obs, err := (&OutboundSubscriptionService{}).Preview(server.URL, "", true, false)
if err != nil || len(obs) != 1 {
t.Fatalf("Preview = %v, %v; want the one outbound", obs, err)
}
var count int64
database.GetDB().Model(&model.OutboundSubscription{}).Count(&count)
if count != 0 {
t.Fatalf("Preview left %d subscription row(s) behind", count)
}
}
// Each fetch builds its own transport; whatever it leaves parked would otherwise live
// for as long as the provider keeps the connection open.
func TestOutboundSubscriptionRefreshLeavesNoIdleConnection(t *testing.T) {
setupSettingTestDB(t)
var open atomic.Int32
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("vless://00000000-0000-4000-8000-000000000000@8.8.8.8:443?security=tls&type=tcp#B"))
}))
server.Config.ConnState = func(_ net.Conn, state http.ConnState) {
switch state {
case http.StateNew:
open.Add(1)
case http.StateClosed, http.StateHijacked:
open.Add(-1)
}
}
server.Start()
t.Cleanup(server.Close)
sub := &model.OutboundSubscription{Url: server.URL, AllowPrivate: true, TagPrefix: "p-"}
if err := database.GetDB().Create(sub).Error; err != nil {
t.Fatalf("seed subscription: %v", err)
}
for range 3 {
if _, err := (&OutboundSubscriptionService{}).Refresh(sub.Id); err != nil {
t.Fatalf("Refresh: %v", err)
}
}
deadline := time.Now().Add(2 * time.Second)
for open.Load() != 0 && time.Now().Before(deadline) {
time.Sleep(10 * time.Millisecond)
}
if n := open.Load(); n != 0 {
t.Fatalf("%d connection(s) to the provider still open after the refreshes", n)
}
}
// One document must not turn into an unbounded number of outbounds: each is held,
// validated and merged into the core's config.
func TestOutboundSubscriptionRefreshCapsTheNumberOfLinks(t *testing.T) {
setupSettingTestDB(t)
subID := serveOutboundSubscription(t, "p-", func(int) string {
return strings.Repeat("ss://YWVzLTEyOC1nY206cA@8.8.8.8:8388#s\n", link.MaxSubscriptionOutbounds+5)
})
obs, err := (&OutboundSubscriptionService{}).Refresh(subID)
if err != nil {
t.Fatalf("Refresh: %v", err)
}
if len(obs) != link.MaxSubscriptionOutbounds {
t.Fatalf("stored %d outbounds, want the cap of %d", len(obs), link.MaxSubscriptionOutbounds)
}
sub, _ := (&OutboundSubscriptionService{}).Get(subID)
if !strings.Contains(sub.LastError, strconv.Itoa(link.MaxSubscriptionOutbounds)) {
t.Fatalf("lastError = %q, want the cut reported", sub.LastError)
}
}
// A provider's wireguard fields must not configure the host: the kernel TUN path
// installs its addresses and routes, so subscription outbounds stay in userspace.
func TestOutboundSubscriptionWireguardStaysOffKernelTun(t *testing.T) {
setupSettingTestDB(t)
subID := serveOutboundSubscription(t, "p-", func(int) string {
return "wireguard://JOSLNr3Kk8BViwrrTJ2M19gRBbGttnRfApY3nQ3fdT8%3D@8.8.8.8:51820?publickey=biSnuvUOX2pmtzf249FK8UGSq2PQslGSdoj4UqH4yew%3D&address=10.0.0.2%2F32#wg"
})
obs, err := (&OutboundSubscriptionService{}).Refresh(subID)
if err != nil || len(obs) != 1 {
t.Fatalf("Refresh = %v, %v", obs, err)
}
stored := `[{"tag":"p-old","protocol":"wireguard","settings":{"secretKey":"JOSLNr3Kk8BViwrrTJ2M19gRBbGttnRfApY3nQ3fdT8=","address":["10.0.0.2/32"],"peers":[{"publicKey":"biSnuvUOX2pmtzf249FK8UGSq2PQslGSdoj4UqH4yew=","endpoint":"8.8.8.8:51820"}]}}]`
old := &model.OutboundSubscription{Url: "https://provider.example/sub", TagPrefix: "q-", Enabled: true, LastFetchedOutbounds: stored}
if err := database.GetDB().Create(old).Error; err != nil {
t.Fatalf("seed subscription: %v", err)
}
if err := database.GetDB().Model(&model.OutboundSubscription{}).Where("id = ?", subID).Update("enabled", true).Error; err != nil {
t.Fatalf("enable: %v", err)
}
active, err := (&OutboundSubscriptionService{}).AllActiveOutbounds()
if err != nil || len(active) != 2 {
t.Fatalf("AllActiveOutbounds = %v, %v", active, err)
}
for _, ob := range append(obs, active...) {
settings, _ := ob.(map[string]any)["settings"].(map[string]any)
if settings["noKernelTun"] != true {
t.Fatalf("subscription wireguard outbound without noKernelTun: %v", ob)
}
}
}
func TestOutboundSubscriptionCreateKeepsItDisabled(t *testing.T) {
setupSettingTestDB(t)
sub, err := (&OutboundSubscriptionService{}).Create("r", "https://1.1.1.1/sub", "", "", false, 600, false, false, false)
if err != nil {
t.Fatalf("Create: %v", err)
}
stored, err := (&OutboundSubscriptionService{}).Get(sub.Id)
if err != nil || stored.Enabled {
t.Fatalf("stored enabled = %v (%v), want the subscription created disabled", stored.Enabled, err)
}
}