Merge remote-tracking branch 'upstream/main' into sync-3.6.0

# Conflicts:
#	.github/workflows/claude-bot.yml
#	.github/workflows/release.yml
#	DockerInit.sh
#	frontend/package-lock.json
#	frontend/package.json
#	frontend/src/hooks/useClients.ts
#	frontend/src/layouts/AppSidebar.tsx
#	frontend/src/main.tsx
#	internal/config/version
#	internal/database/model/model.go
#	internal/web/service/client_wireguard.go
#	internal/web/service/inbound.go
This commit is contained in:
Kuzz007
2026-08-01 21:59:29 +03:00
250 changed files with 12904 additions and 5650 deletions
+2 -2
View File
@@ -103,7 +103,7 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
}
}
finalYAML, err := yaml.Marshal(config)
finalYAML, err := marshalClashYAML(config)
if err != nil {
return "", "", err
}
@@ -418,7 +418,7 @@ func (s *SubClashService) buildWireguardProxy(subReq *SubService, inbound *model
}
if dns, _ := inboundSettings["dns"].(string); dns != "" {
servers := make([]string, 0)
for _, server := range strings.Split(dns, ",") {
for server := range strings.SplitSeq(dns, ",") {
if server = strings.TrimSpace(server); server != "" {
servers = append(servers, server)
}
+89
View File
@@ -0,0 +1,89 @@
package sub
import (
"reflect"
"regexp"
"strings"
"github.com/goccy/go-yaml"
)
type yamlQuotedString string
func (s yamlQuotedString) MarshalYAML() ([]byte, error) {
return []byte("'" + strings.ReplaceAll(string(s), "'", "''") + "'"), nil
}
var yamlNonStringWords = map[string]bool{
"~": true, "null": true, "Null": true, "NULL": true,
"true": true, "True": true, "TRUE": true,
"false": true, "False": true, "FALSE": true,
"yes": true, "Yes": true, "YES": true,
"no": true, "No": true, "NO": true,
"on": true, "On": true, "ON": true,
"off": true, "Off": true, "OFF": true,
"y": true, "Y": true, "n": true, "N": true,
}
var yamlNonStringNumber = regexp.MustCompile(`^(?:` +
`[-+]?[0-9]+|` +
`0[oO]?[0-7]+|0[xX][0-9a-fA-F]+|` +
`[-+]?[0-9][0-9_]*(?::[0-5]?[0-9])+|` +
`[-+]?(?:[0-9]*\.[0-9]+|[0-9]+\.?[0-9]*)(?:[eE][-+]?[0-9]+)?|` +
`[-+]?\.(?:inf|Inf|INF)|\.(?:nan|NaN|NAN)|` +
`[0-9]{4}-[0-9]{1,2}-[0-9]{1,2}(?:[Tt ].*)?` +
`)$`)
func yamlScalarIsAmbiguous(s string) bool {
if s == "" {
return false
}
return yamlNonStringWords[s] || yamlNonStringNumber.MatchString(s)
}
func quoteAmbiguousYAMLScalars(v any) any {
if v == nil {
return nil
}
if s, ok := v.(string); ok {
if yamlScalarIsAmbiguous(s) {
return yamlQuotedString(s)
}
return s
}
rv := reflect.ValueOf(v)
switch rv.Kind() {
case reflect.Map:
out := make(map[string]any, rv.Len())
iter := rv.MapRange()
for iter.Next() {
key, ok := iter.Key().Interface().(string)
if !ok {
return v
}
out[key] = quoteAmbiguousYAMLScalars(iter.Value().Interface())
}
return out
case reflect.Slice, reflect.Array:
if rv.Kind() == reflect.Slice && rv.Type().Elem().Kind() == reflect.Uint8 {
return v
}
out := make([]any, rv.Len())
for i := range rv.Len() {
out[i] = quoteAmbiguousYAMLScalars(rv.Index(i).Interface())
}
return out
case reflect.Pointer, reflect.Interface:
if rv.IsNil() {
return v
}
return quoteAmbiguousYAMLScalars(rv.Elem().Interface())
default:
return v
}
}
func marshalClashYAML(config any) ([]byte, error) {
return yaml.Marshal(quoteAmbiguousYAMLScalars(config))
}
+129
View File
@@ -0,0 +1,129 @@
package sub
import (
"strings"
"testing"
"github.com/goccy/go-yaml"
)
func TestAmbiguousScalarsAreQuoted(t *testing.T) {
tests := []struct {
name string
value string
mustQuote bool
}{
{"reality short-id read as a float", "2351e1", true},
{"short exponent form", "0e1", true},
{"long exponent form", "12e34", true},
{"all digits", "123456", true},
{"leading zeros read as octal", "0177", true},
{"hex form", "0x1f", true},
{"decimal point", "1.5", true},
{"boolean word", "true", true},
{"single letter bool", "y", true},
{"null word", "null", true},
{"tilde", "~", true},
{"date", "2026-07-27", true},
{"sexagesimal", "12:30", true},
{"plain hex with letters", "6ba7b8", false},
{"letters only", "abcdef", false},
{"hostname", "example.com", false},
{"dotted quad", "1.2.3.4", false},
{"uuid", "6ba7b810-9dad-11d1-80b4-00c04fd430c8", false},
{"alpn token", "h2", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
out, err := marshalClashYAML(map[string]any{
"reality-opts": map[string]any{"short-id": tt.value},
})
if err != nil {
t.Fatalf("marshalClashYAML: %v", err)
}
got := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(string(out)), "reality-opts:"))
quoted := strings.Contains(got, "'"+tt.value+"'") || strings.Contains(got, `"`+tt.value+`"`)
if tt.mustQuote && !quoted {
t.Errorf("%q must be emitted quoted, got %q", tt.value, got)
}
if !tt.mustQuote && quoted {
t.Errorf("%q needs no quoting, got %q", tt.value, got)
}
var decoded map[string]any
if err := yaml.Unmarshal(out, &decoded); err != nil {
t.Fatalf("output must stay parseable: %v\n%s", err, out)
}
})
}
}
func TestClashShortIDIsQuotedInOutput(t *testing.T) {
out, err := marshalClashYAML(map[string]any{
"proxies": []any{map[string]any{
"name": "de-1",
"reality-opts": map[string]any{"short-id": "2351e1"},
}},
})
if err != nil {
t.Fatalf("marshalClashYAML: %v", err)
}
if !strings.Contains(string(out), `'2351e1'`) {
t.Errorf("short-id must be emitted as a quoted scalar, got:\n%s", out)
}
}
func TestAmbiguousPasswordIsQuoted(t *testing.T) {
out, err := marshalClashYAML(map[string]any{
"proxies": []any{map[string]any{
"name": "de-1",
"password": "80e12",
"obfs-password": "12345",
"pre-shared-key": "9e9",
}},
})
if err != nil {
t.Fatalf("marshalClashYAML: %v", err)
}
for _, want := range []string{`'80e12'`, `'12345'`, `'9e9'`} {
if !strings.Contains(string(out), want) {
t.Errorf("expected %s in output:\n%s", want, out)
}
}
}
func TestUnambiguousScalarsAreUnchanged(t *testing.T) {
config := map[string]any{
"port": 7890,
"mode": "rule",
"proxies": []any{map[string]any{"name": "de-1", "udp": true, "port": 443}},
"rules": []string{"MATCH,PROXY"},
"alpn": []string{"h2", "http/1.1"},
"nonempty": "example.com",
}
quoted, err := marshalClashYAML(config)
if err != nil {
t.Fatalf("marshalClashYAML: %v", err)
}
plain, err := yaml.Marshal(config)
if err != nil {
t.Fatalf("yaml.Marshal: %v", err)
}
if string(quoted) != string(plain) {
t.Errorf("unambiguous document changed:\n--- got ---\n%s\n--- want ---\n%s", quoted, plain)
}
}
func TestQuotedScalarEscapesQuotes(t *testing.T) {
out, err := marshalClashYAML(map[string]any{"password": "1e2'3"})
if err != nil {
t.Fatalf("marshalClashYAML: %v", err)
}
var decoded map[string]any
if err := yaml.Unmarshal(out, &decoded); err != nil {
t.Fatalf("output must stay parseable: %v\n%s", err, out)
}
if got := decoded["password"]; got != any("1e2'3") {
t.Errorf("password round-tripped to %#v\n%s", got, out)
}
}
+2 -2
View File
@@ -101,8 +101,8 @@ func linkDisplayName(rawLink string) string {
if rawLink == "" {
return ""
}
if strings.HasPrefix(rawLink, "vmess://") {
b64 := strings.TrimPrefix(rawLink, "vmess://")
if after, ok := strings.CutPrefix(rawLink, "vmess://"); ok {
b64 := after
raw, err := base64.StdEncoding.DecodeString(padBase64Sub(b64))
if err != nil {
raw, err = base64.RawURLEncoding.DecodeString(strings.TrimRight(b64, "="))
+54 -9
View File
@@ -16,8 +16,9 @@ import (
// with a short timeout so a slow/dead provider can't stall a client's sub.
const (
subscriptionCacheTTL = 5 * time.Minute
subscriptionMaxBytes = 2 << 20 // 2 MiB
subscriptionCacheTTL = 5 * time.Minute
subscriptionMaxBytes = 2 << 20 // 2 MiB
subscriptionCacheCapacity = 256
)
var subscriptionHTTPClient = &http.Client{Timeout: 6 * time.Second}
@@ -27,10 +28,19 @@ type subscriptionCacheEntry struct {
fetchedAt time.Time
}
type subscriptionFetch struct {
done chan struct{}
links []string
}
var subscriptionCache = struct {
sync.Mutex
m map[string]subscriptionCacheEntry
}{m: make(map[string]subscriptionCacheEntry)}
m map[string]subscriptionCacheEntry
inflight map[string]*subscriptionFetch
}{
m: make(map[string]subscriptionCacheEntry),
inflight: make(map[string]*subscriptionFetch),
}
// fetchSubscriptionLinks returns the share links contained in a remote
// subscription URL, using a short-lived cache. On any failure it returns the
@@ -44,24 +54,59 @@ func fetchSubscriptionLinks(rawURL string) []string {
subscriptionCache.Lock()
cached, ok := subscriptionCache.m[rawURL]
subscriptionCache.Unlock()
if ok && time.Since(cached.fetchedAt) < subscriptionCacheTTL {
subscriptionCache.Unlock()
return cached.links
}
if fetch, waiting := subscriptionCache.inflight[rawURL]; waiting {
subscriptionCache.Unlock()
<-fetch.done
return fetch.links
}
fetch := &subscriptionFetch{done: make(chan struct{})}
subscriptionCache.inflight[rawURL] = fetch
subscriptionCache.Unlock()
defer func() {
subscriptionCache.Lock()
close(fetch.done)
delete(subscriptionCache.inflight, rawURL)
subscriptionCache.Unlock()
}()
links, err := doFetchSubscriptionLinks(rawURL)
if err != nil {
// Serve stale on error rather than dropping the client's configs.
if ok {
return cached.links
fetch.links = cached.links
}
return nil
return fetch.links
}
subscriptionCache.Lock()
subscriptionCache.m[rawURL] = subscriptionCacheEntry{links: links, fetchedAt: time.Now()}
trimSubscriptionCacheLocked(rawURL)
subscriptionCache.Unlock()
return links
fetch.links = links
return fetch.links
}
func trimSubscriptionCacheLocked(keep string) {
for len(subscriptionCache.m) > subscriptionCacheCapacity {
var oldestURL string
var oldest time.Time
for rawURL, entry := range subscriptionCache.m {
if rawURL == keep {
continue
}
if oldestURL == "" || entry.fetchedAt.Before(oldest) {
oldestURL = rawURL
oldest = entry.fetchedAt
}
}
if oldestURL == "" {
return
}
delete(subscriptionCache.m, oldestURL)
}
}
func doFetchSubscriptionLinks(rawURL string) ([]string, error) {
+136
View File
@@ -4,10 +4,146 @@ import (
"errors"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
func resetSubscriptionCache(t *testing.T) {
t.Helper()
subscriptionCache.Lock()
previousEntries := subscriptionCache.m
previousInflight := subscriptionCache.inflight
subscriptionCache.m = make(map[string]subscriptionCacheEntry)
subscriptionCache.inflight = make(map[string]*subscriptionFetch)
subscriptionCache.Unlock()
t.Cleanup(func() {
subscriptionCache.Lock()
subscriptionCache.m = previousEntries
subscriptionCache.inflight = previousInflight
subscriptionCache.Unlock()
})
}
func TestFetchSubscriptionLinksSharesConcurrentRefresh(t *testing.T) {
resetSubscriptionCache(t)
var requests atomic.Int32
release := make(chan struct{})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
<-release
_, _ = w.Write([]byte("vless://uuid@example.com:443"))
}))
defer srv.Close()
const callers = 16
results := make(chan []string, callers)
var wg sync.WaitGroup
for range callers {
wg.Go(func() {
results <- fetchSubscriptionLinks(srv.URL)
})
}
time.Sleep(100 * time.Millisecond)
close(release)
wg.Wait()
close(results)
for links := range results {
if len(links) != 1 || links[0] != "vless://uuid@example.com:443" {
t.Fatalf("links = %#v", links)
}
}
if got := requests.Load(); got != 1 {
t.Fatalf("requests = %d, want 1", got)
}
}
func TestFetchSubscriptionLinksBoundsCacheSize(t *testing.T) {
resetSubscriptionCache(t)
var requests atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
_, _ = w.Write([]byte("vless://uuid@example.com:443"))
}))
defer srv.Close()
for i := range subscriptionCacheCapacity + 1 {
links := fetchSubscriptionLinks(srv.URL + "?id=" + strconv.Itoa(i))
if len(links) != 1 {
t.Fatalf("links at %d = %#v", i, links)
}
}
subscriptionCache.Lock()
entries := len(subscriptionCache.m)
subscriptionCache.Unlock()
if entries != subscriptionCacheCapacity {
t.Fatalf("cache entries = %d, want %d", entries, subscriptionCacheCapacity)
}
if got := requests.Load(); got != subscriptionCacheCapacity+1 {
t.Fatalf("requests = %d, want %d", got, subscriptionCacheCapacity+1)
}
}
func TestFetchSubscriptionLinksSharesStaleResultAfterRefreshFailure(t *testing.T) {
resetSubscriptionCache(t)
stale := []string{"vless://stale@example.com:443"}
release := make(chan struct{})
var staleRequests atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/stale" {
staleRequests.Add(1)
<-release
w.WriteHeader(http.StatusBadGateway)
return
}
_, _ = w.Write([]byte("vless://fresh@example.com:443"))
}))
defer srv.Close()
staleURL := srv.URL + "/stale"
subscriptionCache.Lock()
subscriptionCache.m[staleURL] = subscriptionCacheEntry{
links: stale,
fetchedAt: time.Now().Add(-subscriptionCacheTTL),
}
for i := range subscriptionCacheCapacity - 1 {
subscriptionCache.m["cached-"+strconv.Itoa(i)] = subscriptionCacheEntry{fetchedAt: time.Now()}
}
subscriptionCache.Unlock()
const callers = 16
results := make(chan []string, callers)
var wg sync.WaitGroup
for range callers {
wg.Go(func() {
results <- fetchSubscriptionLinks(staleURL)
})
}
time.Sleep(100 * time.Millisecond)
if links := fetchSubscriptionLinks(srv.URL + "/fresh"); len(links) != 1 || links[0] != "vless://fresh@example.com:443" {
t.Fatalf("fresh links = %#v", links)
}
close(release)
wg.Wait()
close(results)
for links := range results {
if len(links) != 1 || links[0] != stale[0] {
t.Fatalf("links = %#v, want %#v", links, stale)
}
}
if got := staleRequests.Load(); got != 1 {
t.Fatalf("requests = %d, want 1", got)
}
}
func TestDoFetchSubscriptionLinks_RejectsOversizedBody(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(strings.Repeat("a", subscriptionMaxBytes+1)))
+92
View File
@@ -0,0 +1,92 @@
package sub
import (
"net"
"net/netip"
"strings"
"sync"
"github.com/gin-gonic/gin"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
)
var warnSuppressedForwardedOnce sync.Once
var forwardedHeaderNames = [...]string{"X-Forwarded-Host", "X-Forwarded-Proto", "X-Real-IP"}
func (s *SubService) forwardedHeadersTrusted(c *gin.Context) (trusted bool) {
if !hasForwardedHeaders(c) {
return true
}
trusted = true
defer func() {
_ = recover()
}()
configured, err := s.settingService.GetTrustedProxyCIDRs()
if err != nil {
return true
}
configured = strings.TrimSpace(configured)
if configured == "" || configured == service.DefaultTrustedProxyCIDRs {
return true
}
return remoteAddrInCIDRs(c.Request.RemoteAddr, configured)
}
func hasForwardedHeaders(c *gin.Context) bool {
for _, name := range forwardedHeaderNames {
if c.GetHeader(name) != "" {
return true
}
}
return false
}
func warnSuppressedForwardedHeaders(c *gin.Context) {
present := make([]string, 0, 3)
for _, name := range forwardedHeaderNames {
if c.GetHeader(name) != "" {
present = append(present, name)
}
}
if len(present) == 0 {
return
}
headers := strings.Join(present, ", ")
logger.Debugf("sub: ignoring %s from %s, which is outside trustedProxyCIDRs", headers, c.Request.RemoteAddr)
warnSuppressedForwardedOnce.Do(func() {
logger.Warningf("sub: ignoring %s from %s because it is outside trustedProxyCIDRs; subscription URLs will use the request host. Add the proxy to that setting, or set subURI, if the generated links look wrong.", headers, c.Request.RemoteAddr)
})
}
func remoteAddrInCIDRs(remoteAddr, cidrs string) bool {
host := remoteAddr
if h, _, err := net.SplitHostPort(remoteAddr); err == nil {
host = h
}
addr, err := netip.ParseAddr(strings.TrimSpace(host))
if err != nil {
return false
}
addr = addr.Unmap()
for value := range strings.SplitSeq(cidrs, ",") {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if prefix, err := netip.ParsePrefix(value); err == nil {
if prefix.Contains(addr) {
return true
}
continue
}
if proxyIP, err := netip.ParseAddr(value); err == nil && proxyIP.Unmap() == addr.Unmap() {
return true
}
}
return false
}
+201
View File
@@ -0,0 +1,201 @@
package sub
import (
"net/http"
"net/http/httptest"
"testing"
"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/web/service"
)
func requestFrom(t *testing.T, remoteAddr string, headers map[string]string) *gin.Context {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/sub/abc", nil)
req.Host = "panel.example.com:2096"
req.RemoteAddr = remoteAddr
for k, v := range headers {
req.Header.Set(k, v)
}
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = req
return c
}
func setTrustedProxyCIDRs(t *testing.T, value string) {
t.Helper()
if err := database.GetDB().Create(&model.Setting{Key: "trustedProxyCIDRs", Value: value}).Error; err != nil {
t.Fatalf("set trustedProxyCIDRs: %v", err)
}
settingService := service.SettingService{}
stored, err := settingService.GetTrustedProxyCIDRs()
if err != nil {
t.Fatalf("read trustedProxyCIDRs back through SettingService: %v", err)
}
if stored != value {
t.Fatalf("SettingService reads trustedProxyCIDRs as %q, want %q — the key this helper writes has drifted", stored, value)
}
}
func TestResolveRequest_ForwardedHeaderTrust(t *testing.T) {
tests := []struct {
name string
stored *string
remoteAddr string
wantScheme string
wantHost string
wantHostWithPort string
wantHostHeader string
}{
{
name: "no stored row keeps trusting forwarded headers",
stored: nil,
remoteAddr: "203.0.113.9:51000",
wantScheme: "https",
wantHost: "sub.example.net",
wantHostWithPort: "sub.example.net",
wantHostHeader: "sub.example.net",
},
{
name: "empty stored value keeps trusting forwarded headers",
stored: new(""),
remoteAddr: "203.0.113.9:51000",
wantScheme: "https",
wantHost: "sub.example.net",
wantHostWithPort: "sub.example.net",
wantHostHeader: "sub.example.net",
},
{
name: "stored shipped default keeps trusting forwarded headers",
stored: new(service.DefaultTrustedProxyCIDRs),
remoteAddr: "203.0.113.9:51000",
wantScheme: "https",
wantHost: "sub.example.net",
wantHostWithPort: "sub.example.net",
wantHostHeader: "sub.example.net",
},
{
name: "declared boundary ignores an origin outside it",
stored: new("10.0.0.0/8"),
remoteAddr: "203.0.113.9:51000",
wantScheme: "http",
wantHost: "panel.example.com",
wantHostWithPort: "panel.example.com:2096",
wantHostHeader: "panel.example.com",
},
{
name: "declared boundary trusts an origin inside it",
stored: new("10.0.0.0/8"),
remoteAddr: "10.1.2.3:44000",
wantScheme: "https",
wantHost: "sub.example.net",
wantHostWithPort: "sub.example.net",
wantHostHeader: "sub.example.net",
},
{
name: "declared boundary ignores an unparsable origin",
stored: new("10.0.0.0/8"),
remoteAddr: "not-an-ip",
wantScheme: "http",
wantHost: "panel.example.com",
wantHostWithPort: "panel.example.com:2096",
wantHostHeader: "panel.example.com",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
initSubDB(t)
if tc.stored != nil {
setTrustedProxyCIDRs(t, *tc.stored)
}
s := &SubService{}
c := requestFrom(t, tc.remoteAddr, map[string]string{
"X-Forwarded-Host": "sub.example.net",
"X-Forwarded-Proto": "https",
})
scheme, host, hostWithPort, hostHeader := s.ResolveRequest(c)
if scheme != tc.wantScheme {
t.Errorf("scheme = %q, want %q", scheme, tc.wantScheme)
}
if host != tc.wantHost {
t.Errorf("host = %q, want %q", host, tc.wantHost)
}
if hostWithPort != tc.wantHostWithPort {
t.Errorf("hostWithPort = %q, want %q", hostWithPort, tc.wantHostWithPort)
}
if hostHeader != tc.wantHostHeader {
t.Errorf("hostHeader = %q, want %q", hostHeader, tc.wantHostHeader)
}
})
}
}
func TestResolveRequest_GatesRealIPFallback(t *testing.T) {
initSubDB(t)
setTrustedProxyCIDRs(t, "10.0.0.0/8")
s := &SubService{}
c := requestFrom(t, "203.0.113.9:51000", map[string]string{
"X-Real-IP": "198.51.100.7",
})
_, host, _, hostHeader := s.ResolveRequest(c)
if host != "panel.example.com" {
t.Errorf("host = %q, want the request host — X-Real-IP from an untrusted origin must be ignored", host)
}
if hostHeader != "panel.example.com" {
t.Errorf("hostHeader = %q, want the request host", hostHeader)
}
}
func TestHasForwardedHeaders(t *testing.T) {
tests := []struct {
name string
headers map[string]string
want bool
}{
{name: "no forwarded headers", want: false},
{name: "forwarded host", headers: map[string]string{"X-Forwarded-Host": "sub.example.net"}, want: true},
{name: "forwarded proto", headers: map[string]string{"X-Forwarded-Proto": "https"}, want: true},
{name: "real ip", headers: map[string]string{"X-Real-IP": "10.1.2.3"}, want: true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := hasForwardedHeaders(requestFrom(t, "10.1.2.3:1234", tc.headers)); got != tc.want {
t.Errorf("hasForwardedHeaders() = %v, want %v", got, tc.want)
}
})
}
}
func TestRemoteAddrInCIDRs(t *testing.T) {
tests := []struct {
name string
remoteAddr string
cidrs string
want bool
}{
{name: "inside cidr", remoteAddr: "10.1.2.3:1234", cidrs: "10.0.0.0/8", want: true},
{name: "ipv4 mapped address inside cidr", remoteAddr: "[::ffff:10.1.2.3]:1234", cidrs: "10.0.0.0/8", want: true},
{name: "outside cidr", remoteAddr: "203.0.113.9:1234", cidrs: "10.0.0.0/8", want: false},
{name: "bare address entry", remoteAddr: "192.168.1.5:80", cidrs: "192.168.1.5", want: true},
{name: "ipv6 loopback", remoteAddr: "[::1]:8080", cidrs: "::1/128", want: true},
{name: "no port", remoteAddr: "10.1.2.3", cidrs: "10.0.0.0/8", want: true},
{name: "unparsable origin", remoteAddr: "not-an-ip", cidrs: "10.0.0.0/8", want: false},
{name: "empty entries skipped", remoteAddr: "10.1.2.3:1", cidrs: " , 10.0.0.0/8 , ", want: true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := remoteAddrInCIDRs(tc.remoteAddr, tc.cidrs); got != tc.want {
t.Errorf("remoteAddrInCIDRs(%q, %q) = %v, want %v", tc.remoteAddr, tc.cidrs, got, tc.want)
}
})
}
}
+87
View File
@@ -0,0 +1,87 @@
package sub
import (
"fmt"
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
func seedFlowInbound(t *testing.T, subId, tag string, port int, stream string) *model.Inbound {
t.Helper()
db := database.GetDB()
uuid := "11111111-2222-4333-8444-" + fmt.Sprintf("%012d", port)
email := tag + "@e"
settings := fmt.Sprintf(
`{"clients":[{"id":%q,"email":%q,"subId":%q,"enable":true,"flow":"xtls-rprx-vision"}],"decryption":"none"}`,
uuid, email, subId)
ib := &model.Inbound{
UserId: 1, Tag: tag, Enable: true, Listen: "203.0.113.5", Port: port,
Protocol: model.VLESS, Remark: tag, Settings: settings, StreamSettings: stream,
SubSortIndex: 1,
}
if err := db.Create(ib).Error; err != nil {
t.Fatalf("seed inbound %s: %v", tag, err)
}
client := &model.ClientRecord{Email: email, SubID: subId, UUID: uuid, Enable: true}
if err := db.Create(client).Error; err != nil {
t.Fatalf("seed client %s: %v", email, err)
}
link := &model.ClientInbound{ClientId: client.Id, InboundId: ib.Id, FlowOverride: "xtls-rprx-vision"}
if err := db.Create(link).Error; err != nil {
t.Fatalf("seed client_inbound %s: %v", email, err)
}
return ib
}
// A vision flow left on a client after its inbound moved to a transport Vision
// cannot use is stripped from the raw link and the Clash proxy; the JSON
// subscription must agree instead of emitting an outbound xray rejects.
func TestSub_JSONStripsFlowOnUnsupportedTransport(t *testing.T) {
seedSubDB(t)
seedFlowInbound(t, "s1", "wsflow", 4601, wsTLSStream)
links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
if err != nil {
t.Fatalf("GetSubs: %v", err)
}
if joined := strings.Join(links, "\n"); strings.Contains(joined, "flow=") {
t.Fatalf("raw link must not carry a flow on ws+tls: %s", joined)
}
clash := NewSubClashService(false, "", NewSubService(""))
yaml, _, err := clash.GetClash("s1", "req.example.com")
if err != nil {
t.Fatalf("GetClash: %v", err)
}
if strings.Contains(yaml, "flow:") {
t.Fatalf("clash proxy must not carry a flow on ws+tls:\n%s", yaml)
}
js := NewSubJsonService("", "", "", NewSubService(""))
out, _, err := js.GetJson("s1", "req.example.com", false)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
if strings.Contains(out, `"flow"`) {
t.Fatalf("json outbound must not carry a flow on ws+tls:\n%s", out)
}
}
// The gate must not strip a flow the transport does support.
func TestSub_JSONKeepsFlowOnTcpTLS(t *testing.T) {
seedSubDB(t)
seedFlowInbound(t, "s1", "tcpflow", 4602,
`{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"}}`)
js := NewSubJsonService("", "", "", NewSubService(""))
out, _, err := js.GetJson("s1", "req.example.com", false)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
if !strings.Contains(out, `"flow": "xtls-rprx-vision"`) {
t.Fatalf("json outbound must keep the vision flow on tcp+tls:\n%s", out)
}
}
+7
View File
@@ -218,6 +218,13 @@ func (s *SubJsonService) getConfig(subReq *SubService, inbound *model.Inbound, c
case "vless":
vc := client
vc.ID = applyVlessRoute(client.ID, hostVlessRoute(extPrxy))
// Same gate the raw link and the Clash proxy apply: a flow left
// over from a transport Vision supported produces an outbound
// xray refuses to start.
newNetwork, _ := newStream["network"].(string)
if vc.Flow != "" && !vlessFlowAllowed(newNetwork, security, subReq.linkSettings(inbound)) {
vc.Flow = ""
}
newOutbounds = append(newOutbounds, s.genVless(subReq, inbound, streamSettings, vc, jsonMux(mux, hostMux)))
case "trojan", "shadowsocks":
newOutbounds = append(newOutbounds, s.genServer(subReq, inbound, streamSettings, client, jsonMux(mux, hostMux)))
+79 -22
View File
@@ -40,6 +40,26 @@ func (ctx remarkContext) configName() string {
// underscores only, so ordinary braces in a remark are left untouched.
var remarkVarRe = regexp.MustCompile(`\{\{([A-Z_]+)\}\}`)
// remarkToken is one {{TOKEN}} occurrence: its name and the byte range it spans
// in the segment it was found in.
type remarkToken struct {
name string
start int
end int
}
// remarkTokens locates every {{TOKEN}} in seg. Both the template-level filter and
// the value-level expansion walk a segment through this, so they share one notion
// of where a token begins and ends and what the literal text between two of them is.
func remarkTokens(seg string) []remarkToken {
locs := remarkVarRe.FindAllStringSubmatchIndex(seg, -1)
tokens := make([]remarkToken, len(locs))
for i, loc := range locs {
tokens[i] = remarkToken{name: seg[loc[2]:loc[3]], start: loc[0], end: loc[1]}
}
return tokens
}
// unlimitedMark is the value the human-readable quota/expiry tokens render when
// the client has no limit. A segment built only around such a token carries no
// information, so it is dropped rather than printed as "∞" (see expandRemarkVars).
@@ -107,7 +127,9 @@ func translateUISingleBrackets(template string) string {
// value. Unknown tokens resolve to "" (never the literal text). The template is
// split on "|" into segments: a segment whose only value is an unlimited quota
// or expiry (∞) drops out whole — decoration and separator included — so an
// unlimited client gets "host" instead of "host|📊∞|⏳∞D".
// unlimited client gets "host" instead of "host|📊∞|⏳∞D". Inside a surviving
// segment expandSegment also elides a hyphen separator an empty token would
// leave dangling.
func expandRemarkVars(template string, ctx remarkContext) string {
template = translateUISingleBrackets(template)
if !strings.Contains(template, "{{") {
@@ -129,18 +151,43 @@ func expandRemarkVars(template string, ctx remarkContext) string {
// — so it leaves no stray "|" separator or dangling decoration. A segment mixing,
// say, {{EMAIL}} with {{TRAFFIC_LEFT}} is kept, and a pure-literal segment (no
// tokens) is always kept.
//
// A hyphen standing alone between two adjacent tokens is treated as their
// separator and elided when no token before it has produced a value yet or when
// the token after it resolves to nothing. "{{INBOUND}}-{{EMAIL}}" gives "john"
// for an inbound with no remark, "🌐{{INBOUND}}-{{EMAIL}}" gives "🌐john" so
// leading decoration does not keep the separator alive, and
// "{{EMAIL}}-{{INBOUND}}-{{EMAIL}}" keeps a single separator when the middle
// token is empty. A hyphen anywhere else in the segment is literal text and is
// kept as written.
func expandSegment(seg string, ctx remarkContext) (string, bool) {
hasToken, hasOtherValue := false, false
out := remarkVarRe.ReplaceAllStringFunc(seg, func(m string) string {
hasToken = true
token := m[2 : len(m)-2]
val := remarkVarValue(token, ctx)
if val != "" && (!unlimitedDropTokens[token] || val != unlimitedMark) {
tokens := remarkTokens(seg)
hasToken, hasOtherValue := len(tokens) > 0, false
values := make([]string, len(tokens))
for i, tok := range tokens {
val := remarkVarValue(tok.name, ctx)
values[i] = val
if val != "" && (!unlimitedDropTokens[tok.name] || val != unlimitedMark) {
hasOtherValue = true
}
return val
})
return out, hasToken && !hasOtherValue
}
var result strings.Builder
start, wroteValue := 0, false
for i, tok := range tokens {
result.WriteString(seg[start:tok.start])
result.WriteString(values[i])
wroteValue = wroteValue || values[i] != ""
start = tok.end
if i+1 < len(tokens) {
between := seg[start:tokens[i+1].start]
if strings.TrimSpace(between) == "-" && (!wroteValue || values[i+1] == "") {
start = tokens[i+1].start
}
}
}
result.WriteString(seg[start:])
return result.String(), hasToken && !hasOtherValue
}
func remarkVarValue(token string, ctx remarkContext) string {
@@ -511,11 +558,17 @@ func filterRemarkTemplate(template string, remove map[string]bool) string {
return strings.Join(kept, "|")
}
// filterRemarkSegment drops whole token categories from one segment while it is
// still a template, before any value is known. Literal text touching a removed
// token goes with it and the surviving runs rejoin with a space, so filtering the
// usage tokens out of "{{EMAIL}} 📊{{TRAFFIC_LEFT}}" leaves "{{EMAIL}}". This is
// the template-level counterpart to expandSegment, which works one layer later on
// tokens that survive here but resolve to an empty value.
func filterRemarkSegment(seg string, remove map[string]bool) string {
locs := remarkVarRe.FindAllStringSubmatchIndex(seg, -1)
tokens := remarkTokens(seg)
hasRemove := false
for _, loc := range locs {
if remove[seg[loc[2]:loc[3]]] {
for _, tok := range tokens {
if remove[tok.name] {
hasRemove = true
break
}
@@ -525,28 +578,28 @@ func filterRemarkSegment(seg string, remove map[string]bool) string {
}
runs := make([]string, 0, 2)
runStart, leftRemoved := 0, false
for _, loc := range locs {
if !remove[seg[loc[2]:loc[3]]] {
for _, tok := range tokens {
if !remove[tok.name] {
continue
}
runs = appendKeptRun(runs, seg[runStart:loc[0]], leftRemoved, true)
runStart, leftRemoved = loc[1], true
runs = appendKeptRun(runs, seg[runStart:tok.start], leftRemoved, true)
runStart, leftRemoved = tok.end, true
}
runs = appendKeptRun(runs, seg[runStart:], leftRemoved, false)
return strings.Join(runs, " ")
}
func appendKeptRun(runs []string, run string, leftRemoved, rightRemoved bool) []string {
locs := remarkVarRe.FindAllStringSubmatchIndex(run, -1)
if len(locs) == 0 {
tokens := remarkTokens(run)
if len(tokens) == 0 {
return runs
}
start, end := 0, len(run)
if leftRemoved {
start = locs[0][0]
start = tokens[0].start
}
if rightRemoved {
end = locs[len(locs)-1][1]
end = tokens[len(tokens)-1].end
}
if frag := strings.TrimSpace(run[start:end]); frag != "" {
runs = append(runs, frag)
@@ -560,7 +613,11 @@ func (s *SubService) effectiveTemplate(email string) string {
s.usageShown = map[string]bool{}
}
if s.usageShown[email] {
return filterRemarkTemplate(translated, firstLinkOnlyBodyTokens)
remove := firstLinkOnlyBodyTokens
if s.showIdentityOnAllLinks {
remove = usageInfoTokens
}
return filterRemarkTemplate(translated, remove)
}
s.usageShown[email] = true
return translated
+72
View File
@@ -102,6 +102,41 @@ func TestExpandRemarkVars_EdgeCases(t *testing.T) {
}
}
// defaultRemarkTemplate mirrors the panel's shipped remark template, the one an
// inbound with no remark used to render with a leading hyphen.
const defaultRemarkTemplate = "{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D"
func TestExpandRemarkVars_DropsHyphenBetweenEmptyTokens(t *testing.T) {
cases := []struct {
name string
tmpl string
inbound string
email string
want string
}{
{name: "both values", tmpl: "{{INBOUND}}-{{EMAIL}}", inbound: "Germany", email: "john", want: "Germany-john"},
{name: "empty inbound", tmpl: "{{INBOUND}}-{{EMAIL}}", email: "john", want: "john"},
{name: "empty email", tmpl: "{{INBOUND}} - {{EMAIL}}", inbound: "Germany", want: "Germany"},
{name: "literal leading hyphen", tmpl: "-{{EMAIL}}", email: "john", want: "-john"},
{name: "literal leading hyphen before an empty token", tmpl: "-{{INBOUND}}-{{EMAIL}}", email: "john", want: "-john"},
{name: "empty var between two values keeps one separator", tmpl: "{{EMAIL}}-{{INBOUND}}-{{EMAIL}}", email: "john", want: "john-john"},
{name: "emoji decoration before an empty token", tmpl: "🌐{{INBOUND}}-{{EMAIL}}", email: "john", want: "🌐john"},
{name: "literal word before an empty token", tmpl: "Sub {{INBOUND}}-{{EMAIL}}", email: "john", want: "Sub john"},
{name: "decoration kept when both tokens resolve", tmpl: "🌐{{INBOUND}}-{{EMAIL}}", inbound: "Germany", email: "john", want: "🌐Germany-john"},
{name: "default template, empty inbound", tmpl: defaultRemarkTemplate, email: "john", want: "john"},
{name: "default template, empty email", tmpl: defaultRemarkTemplate, inbound: "Germany", want: "Germany"},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
ctx := expandCtx(model.Client{Email: tt.email}, xray.ClientTraffic{Enable: true}, &model.Inbound{Remark: tt.inbound})
if got := expandRemarkVars(tt.tmpl, ctx); got != tt.want {
t.Errorf("expandRemarkVars(%q) = %q, want %q", tt.tmpl, got, tt.want)
}
})
}
}
// An unlimited client drops the quota/expiry segments whole — decoration and the
// "|" separator included — instead of printing "📊∞|⏳∞D".
func TestExpandRemarkVars_DropUnlimitedSegments(t *testing.T) {
@@ -652,3 +687,40 @@ func TestEmailOnFirstLinkOnly(t *testing.T) {
t.Fatalf("second link should still carry the inbound name: %q", second)
}
}
func TestIdentityOnAllLinks(t *testing.T) {
const template = "{{INBOUND}}-{{EMAIL}}|{{USERNAME}}|📊{{TRAFFIC_LEFT}}|{{STATUS_EMOJI}}"
inbound := &model.Inbound{
Remark: "DE",
ClientStats: []xray.ClientTraffic{{
Email: "alice@x",
Enable: true,
Total: 100 * gb,
Up: 20 * gb,
}},
}
tests := []struct {
name string
enabled bool
wantSecond string
}{
{name: "disabled", wantSecond: "DE"},
{name: "enabled", enabled: true, wantSecond: "DE-alice@x|alice@x"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := &SubService{
remarkTemplate: template,
subscriptionBody: true,
showIdentityOnAllLinks: tt.enabled,
}
client := model.Client{Email: "alice@x"}
if got := s.genTemplatedRemark(inbound, client, "", "ws"); got != "DE-alice@x|alice@x|📊80.00GB|✅" {
t.Fatalf("first link = %q", got)
}
if got := s.genTemplatedRemark(inbound, client, "", "ws"); got != tt.wantSecond {
t.Fatalf("second link = %q, want %q", got, tt.wantSecond)
}
})
}
}
+34 -15
View File
@@ -41,9 +41,10 @@ type SubService struct {
// usageShown tracks, per client email, whether the info part of the template
// has already been emitted this request, so it appears on the first body
// link only. Per-request state; reset in PrepareForRequest.
usageShown map[string]bool
inboundService service.InboundService
settingService service.SettingService
usageShown map[string]bool
showIdentityOnAllLinks bool
inboundService service.InboundService
settingService service.SettingService
// nodesByID is populated per request from the Node table so
// resolveInboundAddress can return the node's address for any
// inbound whose NodeID is set. Keeps the per-link host derivation
@@ -198,6 +199,10 @@ func (s *SubService) loadRemarkSettings() {
if err != nil {
s.datepicker = "gregorian"
}
s.showIdentityOnAllLinks, err = s.settingService.GetSubShowIdentityOnAllLinks()
if err != nil {
s.showIdentityOnAllLinks = false
}
}
func (s *SubService) configuredPublicHost() string {
@@ -1726,12 +1731,6 @@ func applyExternalProxyTLSToStream(ep map[string]any, stream map[string]any, sec
}
if fp, ok := ep["fingerprint"].(string); ok && fp != "" {
tlsSettings["fingerprint"] = fp
settings, _ := tlsSettings["settings"].(map[string]any)
if settings == nil {
settings = map[string]any{}
tlsSettings["settings"] = settings
}
settings["fingerprint"] = fp
}
if alpn, ok := externalProxyALPNList(ep["alpn"]); ok {
tlsSettings["alpn"] = alpn
@@ -2119,6 +2118,15 @@ func buildXhttpExtra(xhttp map[string]any) map[string]any {
}
}
}
// Older clients still read the pre-#6258 names from the subscription
// extra JSON. Emit aliases after lifting legacy inputs so both old and
// new clients can consume the same link.
if v, ok := extra["sessionIDPlacement"].(string); ok && len(v) > 0 {
extra["sessionPlacement"] = v
}
if v, ok := extra["sessionIDKey"].(string); ok && len(v) > 0 {
extra["sessionKey"] = v
}
for _, field := range []string{"uplinkChunkSize"} {
if v, ok := nonZeroShareValue(xhttp[field]); ok {
@@ -2579,18 +2587,29 @@ type PageData struct {
// ResolveRequest extracts scheme and host info from request/headers consistently.
// ResolveRequest extracts scheme, host, and header information from an HTTP request.
func (s *SubService) ResolveRequest(c *gin.Context) (scheme string, host string, hostWithPort string, hostHeader string) {
trusted := s.forwardedHeadersTrusted(c)
if !trusted {
warnSuppressedForwardedHeaders(c)
}
forwarded := func(name string) string {
if !trusted {
return ""
}
return c.GetHeader(name)
}
// scheme
scheme = "http"
if c.Request.TLS != nil || strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https") {
if c.Request.TLS != nil || strings.EqualFold(forwarded("X-Forwarded-Proto"), "https") {
scheme = "https"
}
// base host (no port)
if h, err := getHostFromXFH(c.GetHeader("X-Forwarded-Host")); err == nil && h != "" {
if h, err := getHostFromXFH(forwarded("X-Forwarded-Host")); err == nil && h != "" {
host = h
}
if host == "" {
host = c.GetHeader("X-Real-IP")
host = forwarded("X-Real-IP")
}
if host == "" {
var err error
@@ -2601,7 +2620,7 @@ func (s *SubService) ResolveRequest(c *gin.Context) (scheme string, host string,
}
// host:port for URLs
hostWithPort = c.GetHeader("X-Forwarded-Host")
hostWithPort = forwarded("X-Forwarded-Host")
if hostWithPort == "" {
hostWithPort = c.Request.Host
}
@@ -2610,9 +2629,9 @@ func (s *SubService) ResolveRequest(c *gin.Context) (scheme string, host string,
}
// header display host
hostHeader = c.GetHeader("X-Forwarded-Host")
hostHeader = forwarded("X-Forwarded-Host")
if hostHeader == "" {
hostHeader = c.GetHeader("X-Real-IP")
hostHeader = forwarded("X-Real-IP")
}
if hostHeader == "" {
hostHeader = host
+50
View File
@@ -335,6 +335,8 @@ func TestBuildXhttpExtra_IncludesClientSideFieldsWhenPresent(t *testing.T) {
"mode": "packet-up",
"xPaddingBytes": "100-1000",
"uplinkHTTPMethod": "GET",
"sessionIDPlacement": "header",
"sessionIDKey": "X-Session",
"uplinkChunkSize": float64(4096),
"noGRPCHeader": true,
"scMinPostsIntervalMs": "20-40",
@@ -375,6 +377,16 @@ func TestBuildXhttpExtra_IncludesClientSideFieldsWhenPresent(t *testing.T) {
if extra["mode"] != "packet-up" {
t.Fatalf("extra[mode] = %#v, want packet-up", extra["mode"])
}
for key, want := range map[string]string{
"sessionIDPlacement": "header",
"sessionIDKey": "X-Session",
"sessionPlacement": "header",
"sessionKey": "X-Session",
} {
if extra[key] != want {
t.Fatalf("extra[%s] = %#v, want %q; extra %#v", key, extra[key], want, extra)
}
}
headers, ok := extra["headers"].(map[string]any)
if !ok {
@@ -388,6 +400,24 @@ func TestBuildXhttpExtra_IncludesClientSideFieldsWhenPresent(t *testing.T) {
}
}
func TestBuildXhttpExtra_LegacySessionFieldsEmitBothNames(t *testing.T) {
extra := buildXhttpExtra(map[string]any{
"sessionPlacement": "query",
"sessionKey": "sess",
})
for key, want := range map[string]string{
"sessionIDPlacement": "query",
"sessionIDKey": "sess",
"sessionPlacement": "query",
"sessionKey": "sess",
} {
if extra[key] != want {
t.Fatalf("extra[%s] = %#v, want %q; extra %#v", key, extra[key], want, extra)
}
}
}
func TestBuildXhttpExtra_LeavesDefaultClientSideFieldsOut(t *testing.T) {
extra := buildXhttpExtra(map[string]any{
"uplinkHTTPMethod": "",
@@ -775,6 +805,26 @@ func TestApplyExternalProxyTLSToStream_DoesNotLeakAcrossProxies(t *testing.T) {
}
}
func TestApplyExternalProxyTLSToStream_FingerprintNotDuplicated(t *testing.T) {
stream := map[string]any{
"security": "tls",
"tlsSettings": map[string]any{},
}
ep := map[string]any{"dest": "proxy.example.com", "fingerprint": "chrome"}
applyExternalProxyTLSToStream(ep, stream, "tls")
ts, _ := stream["tlsSettings"].(map[string]any)
if ts["fingerprint"] != "chrome" {
t.Fatalf("tlsSettings.fingerprint = %v, want %q", ts["fingerprint"], "chrome")
}
if settings, ok := ts["settings"].(map[string]any); ok {
if got, dup := settings["fingerprint"]; dup {
t.Fatalf("fingerprint must not be duplicated into tlsSettings.settings, got %v", got)
}
}
}
func TestApplyExternalProxyTLSParams_SetsPinnedPeerCert(t *testing.T) {
params := map[string]string{"security": "tls"}
ep := map[string]any{