mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-31 10:15:00 +03:00
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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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, "="))
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user