Files
3x-ui/internal/sub/clash_external_quota_test.go
T
Emma Hermione 0b90dc2bed feat(sub): map shadowsocks TCP HTTP headers to Mihomo's obfs plugin (#6838)
A shadowsocks inbound with a TCP HTTP header was dropped from Mihomo
subscriptions, although Mihomo's simple-obfs speaks the same wire format
(#6735). Such a node is now emitted with plugin: obfs, mode http and the request
Host header. Mihomo always requests GET /, and xray-core rejects a request whose
path is not one of the header's paths, so only headers that accept / map;
others stay dropped, as do non-shadowsocks protocols and the legacy Clash
endpoint.
2026-10-24 07:10:30 +02:00

119 lines
4.2 KiB
Go

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"
)
// A shadowsocks node over kcp has no Clash form, so both the inbound path and the link
// path must drop it; the userinfo is base64("aes-256-gcm:clientpw").
const (
clashDroppedExternalLink = "ss://YWVzLTI1Ni1nY206Y2xpZW50cHc@198.51.100.9:8443?type=kcp#unsupported"
kcpShadowsocksStream = `{"network":"kcp","security":"none"}`
)
func clashSubRouter(t *testing.T) *gin.Engine {
t.Helper()
oldDistFS := distFS
distFS = testDistFS
t.Cleanup(func() { distFS = oldDistFS })
gin.SetMode(gin.TestMode)
router := gin.New()
NewSUBController(
router.Group("/"),
WithSUBJsonEnabled(true),
WithSUBClashEnabled(true),
WithSUBEncryption(false),
)
return router
}
func fetchClashSub(t *testing.T, router *gin.Engine, path string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, path, nil)
req.Host = "sub.example.com"
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
return w
}
func seedClashQuotaSub(t *testing.T, subID string, expiry int64) {
t.Helper()
db := database.GetDB()
seedSubInbound(t, subID, "A", 10001, 1, wsTLSStream)
if err := db.Create(&xray.ClientTraffic{Email: "A@e", Up: 11, Down: 22, Total: 1024, ExpiryTime: expiry}).Error; err != nil {
t.Fatalf("seed A traffic: %v", err)
}
rec := &model.ClientRecord{Email: "B@e", SubID: subID, UUID: "22222222-2222-4222-8222-222222222222", Enable: true, ExpiryTime: expiry}
if err := db.Create(rec).Error; err != nil {
t.Fatalf("seed B client: %v", err)
}
if err := db.Create(&xray.ClientTraffic{Email: "B@e", Up: 100, Down: 200, Total: 2048, ExpiryTime: expiry}).Error; err != nil {
t.Fatalf("seed B traffic: %v", err)
}
if err := db.Create(&model.ClientExternalLink{ClientId: rec.Id, Kind: model.ExternalLinkKindLink, Value: clashDroppedExternalLink, SortIndex: 1}).Error; err != nil {
t.Fatalf("seed B external link: %v", err)
}
}
// B's node cannot be represented in Clash, but B still owns quota: the header
// must keep counting B's traffic, not quietly serve A's numbers alone.
func TestClashQuotaHeaderCountsDroppedExternalLink(t *testing.T) {
initSubDB(t)
subID := "clash-quota-drop"
expiry := time.Now().Add(24 * time.Hour).UnixMilli()
seedClashQuotaSub(t, subID, expiry)
w := fetchClashSub(t, clashSubRouter(t), "/clash/"+subID+"?view=raw")
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
}
if strings.Contains(w.Body.String(), "198.51.100.9") {
t.Fatalf("unrepresentable node leaked into the profile: %s", w.Body.String())
}
wantHeader := fmt.Sprintf("upload=111; download=222; total=3072; expire=%d", expiry/1000)
if got := w.Header().Get("Subscription-Userinfo"); got != wantHeader {
t.Fatalf("Subscription-Userinfo = %q, want %q", got, wantHeader)
}
}
// Nothing to serve is answered the same way whether the unrepresentable node is
// an inbound or an external link — the drop must not depend on where it came from.
func TestClashAllUnrepresentableNodesAnswerAlike(t *testing.T) {
statuses := make(map[string]int, 2)
for _, source := range []string{"inbound", "external-link"} {
t.Run(source, func(t *testing.T) {
initSubDB(t)
if source == "inbound" {
seedSubInbound(t, "clash-parity", "obfs", 10001, 1, kcpShadowsocksStream)
} else {
rec := &model.ClientRecord{Email: "B@e", SubID: "clash-parity", UUID: "22222222-2222-4222-8222-222222222222", Enable: true}
if err := database.GetDB().Create(rec).Error; err != nil {
t.Fatalf("seed client: %v", err)
}
if err := database.GetDB().Create(&model.ClientExternalLink{ClientId: rec.Id, Kind: model.ExternalLinkKindLink, Value: clashDroppedExternalLink, SortIndex: 1}).Error; err != nil {
t.Fatalf("seed external link: %v", err)
}
}
w := fetchClashSub(t, clashSubRouter(t), "/clash/clash-parity?view=raw")
if w.Body.Len() != 0 {
t.Fatalf("body = %q, want empty", w.Body.String())
}
statuses[source] = w.Code
})
}
if statuses["inbound"] != statuses["external-link"] {
t.Fatalf("status differs by node source: %v", statuses)
}
}