精简: 订阅极简输出: 去掉分流规则注入(Happ/Clash/Incy 远程规则源+remote_routing 拉取缓存+RemoteRoutingJob、Clash 远程规则合并簇、subJsonRules 注入); 保留多格式转换与默认极简模板

This commit is contained in:
Muse
2026-10-20 10:17:27 +00:00
parent 3cbf9164a4
commit 7d42b91ed6
25 changed files with 87 additions and 2100 deletions
+3 -357
View File
@@ -1,26 +1,22 @@
package sub package sub
import ( import (
"errors"
"fmt" "fmt"
"maps" "maps"
"strings" "strings"
"github.com/goccy/go-json" "github.com/goccy/go-json"
yaml "github.com/goccy/go-yaml"
"github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/database/model"
wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard" wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
) )
type SubClashService struct { type SubClashService struct {
enableRouting bool SubService *SubService
clashRules string
SubService *SubService
} }
func NewSubClashService(enableRouting bool, clashRules string, subService *SubService) *SubClashService { func NewSubClashService(subService *SubService) *SubClashService {
return &SubClashService{enableRouting: enableRouting, clashRules: clashRules, SubService: subService} return &SubClashService{SubService: subService}
} }
func (s *SubClashService) GetClash(subId string, host string) (string, string, error) { func (s *SubClashService) GetClash(subId string, host string) (string, string, error) {
@@ -98,19 +94,6 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
"rules": []string{"MATCH,PROXY"}, "rules": []string{"MATCH,PROXY"},
} }
if s.enableRouting {
resolved, remoteDocument, remote, resolveErr := resolveClashRoutingSource(s.clashRules)
if resolveErr == nil && strings.TrimSpace(resolved) != "" {
if remote {
if err := mergeRemoteClashRules(config, remoteDocument); err != nil {
return "", "", err
}
} else if err := mergeClashRulesYAML(config, resolved); err != nil {
return "", "", err
}
}
}
finalYAML, err := marshalClashYAML(config) finalYAML, err := marshalClashYAML(config)
if err != nil { if err != nil {
return "", "", err return "", "", err
@@ -789,340 +772,3 @@ func cloneMap(src map[string]any) map[string]any {
maps.Copy(dst, src) maps.Copy(dst, src)
return dst return dst
} }
func mergeClashRulesYAML(base map[string]any, raw string) error {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
var custom any
if err := yaml.Unmarshal([]byte(raw), &custom); err != nil {
mergeClashRules(base, linesToClashRules(raw))
return nil
}
switch typed := custom.(type) {
case []any:
mergeClashRules(base, typed)
case map[string]any:
for key, value := range typed {
if key == "rules" {
if ruleList, ok := asAnySlice(value); ok {
mergeClashRules(base, ruleList)
}
continue
}
base[key] = value
}
default:
mergeClashRules(base, linesToClashRules(raw))
}
return nil
}
// mergeRemoteClashRules lets remote update only the route graph (see
// remoteClashAllowedKey) and never mutates remote: cached documents are shared.
func mergeRemoteClashRules(base map[string]any, remote map[string]any) error {
if len(remote) == 0 {
return fmt.Errorf("remote Clash routing source must be a YAML map")
}
for key, value := range remote {
if !remoteClashAllowedKey(key) {
continue
}
if err := validateRemoteClashValue(key, value); err != nil {
return err
}
switch key {
case "rules":
rules, _ := asAnySlice(value)
mergeClashRules(base, rules)
case "proxy-groups":
groups, _ := asAnySlice(value)
base["proxy-groups"] = mergeClashProxyGroups(base["proxy-groups"], groups)
default:
base[key] = value
}
}
return validateClashRouteGraph(base)
}
func validateRemoteClashValue(key string, value any) error {
switch key {
case "rules":
rules, ok := asAnySlice(value)
if !ok {
return fmt.Errorf("remote Clash rules must be a list")
}
for _, rule := range rules {
text, ok := rule.(string)
if !ok || strings.TrimSpace(text) == "" {
return fmt.Errorf("remote Clash rules must contain non-empty strings")
}
}
case "proxy-groups":
groups, ok := asAnySlice(value)
if !ok {
return fmt.Errorf("remote Clash proxy-groups must be a list")
}
seen := make(map[string]struct{}, len(groups))
for _, groupValue := range groups {
group, ok := groupValue.(map[string]any)
if !ok {
return fmt.Errorf("remote Clash proxy-groups must contain named group maps with a type")
}
name, nameOK := group["name"].(string)
groupType, typeOK := group["type"].(string)
if !nameOK || !typeOK || strings.TrimSpace(name) == "" || strings.TrimSpace(groupType) == "" {
return fmt.Errorf("remote Clash proxy-groups must contain named group maps with a type")
}
name = strings.TrimSpace(name)
if _, duplicate := seen[name]; duplicate {
return fmt.Errorf("remote Clash proxy-group name %q is duplicated", name)
}
seen[name] = struct{}{}
if useValue, exists := group["use"]; exists {
use, ok := asAnySlice(useValue)
if !ok || len(use) > 0 {
return fmt.Errorf("remote Clash proxy-group %q cannot use proxy-providers", name)
}
}
}
case "rule-providers":
providers, ok := value.(map[string]any)
if !ok {
return fmt.Errorf("remote Clash rule-providers must be a map")
}
for name, provider := range providers {
if strings.TrimSpace(name) == "" {
return fmt.Errorf("remote Clash rule-provider name must not be empty")
}
if _, ok := provider.(map[string]any); !ok {
return fmt.Errorf("remote Clash rule-provider %q must be a map", name)
}
}
}
return nil
}
func remoteClashAllowedKey(key string) bool {
switch key {
case "proxy-groups", "rule-providers", "rules":
return true
default:
return false
}
}
func validateClashRouteGraph(config map[string]any) error {
known := map[string]struct{}{
"DIRECT": {}, "REJECT": {}, "REJECT-DROP": {}, "REJECT-TINYGIF": {}, "PASS": {}, "GLOBAL": {},
}
if proxies, ok := asAnySlice(config["proxies"]); ok {
for _, value := range proxies {
proxy, ok := value.(map[string]any)
if !ok {
continue
}
if name, ok := proxy["name"].(string); ok && strings.TrimSpace(name) != "" {
known[strings.TrimSpace(name)] = struct{}{}
}
}
}
groups, _ := asAnySlice(config["proxy-groups"])
for _, value := range groups {
if name := clashProxyGroupName(value); name != "" {
known[name] = struct{}{}
}
}
for _, value := range groups {
group, ok := value.(map[string]any)
if !ok {
continue
}
name := clashProxyGroupName(group)
refs, exists := group["proxies"]
if !exists {
continue
}
proxies, ok := asAnySlice(refs)
if !ok {
return fmt.Errorf("Clash proxy-group %q proxies must be a list", name)
}
for _, refValue := range proxies {
ref, ok := refValue.(string)
if !ok || strings.TrimSpace(ref) == "" {
return fmt.Errorf("Clash proxy-group %q contains an invalid proxy reference", name)
}
ref = strings.TrimSpace(ref)
if _, exists := known[ref]; !exists {
return fmt.Errorf("Clash proxy-group %q references unknown proxy or group %q", name, ref)
}
}
}
providers, _ := config["rule-providers"].(map[string]any)
for providerName, value := range providers {
provider, ok := value.(map[string]any)
if !ok {
continue
}
via, ok := provider["proxy"].(string)
if !ok || strings.TrimSpace(via) == "" {
continue
}
via = strings.TrimSpace(via)
if _, exists := known[via]; !exists {
return fmt.Errorf("Clash rule-provider %q references unknown proxy or group %q", providerName, via)
}
}
rules, _ := asAnySlice(config["rules"])
for _, value := range rules {
rule, ok := value.(string)
if !ok || strings.TrimSpace(rule) == "" {
return errors.New("Clash rules must contain non-empty strings")
}
parts := strings.Split(rule, ",")
for i := range parts {
parts[i] = strings.TrimSpace(parts[i])
}
if len(parts) < 2 {
return fmt.Errorf("invalid Clash rule %q", rule)
}
if strings.EqualFold(parts[0], "RULE-SET") {
if len(parts) < 3 {
return fmt.Errorf("invalid Clash RULE-SET rule %q", rule)
}
if _, exists := providers[parts[1]]; !exists {
return fmt.Errorf("Clash rule references unknown rule-provider %q", parts[1])
}
}
targetIndex := len(parts) - 1
// Mihomo IP rules may carry trailing no-resolve / src option flags.
for targetIndex >= 1 && (strings.EqualFold(parts[targetIndex], "no-resolve") || strings.EqualFold(parts[targetIndex], "src")) {
targetIndex--
}
if targetIndex < 1 {
return fmt.Errorf("invalid Clash rule target in %q", rule)
}
target := parts[targetIndex]
if _, exists := known[target]; !exists {
return fmt.Errorf("Clash rule references unknown proxy or group %q", target)
}
}
return nil
}
func mergeClashProxyGroups(baseValue any, remoteGroups []any) []any {
baseGroups, _ := asAnySlice(baseValue)
baseByName := make(map[string]any, len(baseGroups))
baseOrder := make([]string, 0, len(baseGroups))
for _, group := range baseGroups {
name := clashProxyGroupName(group)
if name == "" {
continue
}
baseByName[name] = group
baseOrder = append(baseOrder, name)
}
merged := make([]any, 0, len(remoteGroups)+len(baseGroups))
seen := make(map[string]struct{}, len(remoteGroups)+len(baseGroups))
for _, group := range remoteGroups {
name := clashProxyGroupName(group)
if name == "" {
continue
}
if _, duplicate := seen[name]; duplicate {
continue
}
seen[name] = struct{}{}
merged = append(merged, group)
}
for _, name := range baseOrder {
if _, replaced := seen[name]; replaced {
continue
}
merged = append(merged, baseByName[name])
}
return merged
}
func clashProxyGroupName(value any) string {
group, ok := value.(map[string]any)
if !ok {
return ""
}
name, _ := group["name"].(string)
return strings.TrimSpace(name)
}
func mergeClashRules(base map[string]any, customRules []any) {
if len(customRules) == 0 {
return
}
baseRules, _ := asAnySlice(base["rules"])
if hasClashMatchRule(customRules) {
base["rules"] = customRules
return
}
merged := make([]any, 0, len(customRules)+len(baseRules))
merged = append(merged, customRules...)
merged = append(merged, baseRules...)
base["rules"] = merged
}
func asAnySlice(value any) ([]any, bool) {
switch typed := value.(type) {
case []any:
return typed, true
case []string:
out := make([]any, 0, len(typed))
for _, item := range typed {
out = append(out, item)
}
return out, true
case []map[string]any:
out := make([]any, 0, len(typed))
for _, item := range typed {
out = append(out, item)
}
return out, true
default:
return nil, false
}
}
func hasClashMatchRule(rules []any) bool {
for _, rule := range rules {
ruleText, ok := rule.(string)
if !ok {
continue
}
parts := strings.SplitN(ruleText, ",", 2)
if strings.EqualFold(strings.TrimSpace(parts[0]), "MATCH") {
return true
}
}
return false
}
func linesToClashRules(raw string) []any {
lines := strings.Split(raw, "\n")
rules := make([]any, 0, len(lines))
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
rules = append(rules, line)
}
return rules
}
+23 -88
View File
@@ -45,16 +45,11 @@ type cachedSubTemplate struct {
// SUBController handles HTTP requests for subscription links and JSON configurations. // SUBController handles HTTP requests for subscription links and JSON configurations.
type SUBController struct { type SUBController struct {
subTitle string subTitle string
subSupportUrl string subSupportUrl string
subProfileUrl string subProfileUrl string
subAnnounce string subAnnounce string
subEnableRouting bool subHideSettings bool
subRoutingRules string
subHideSettings bool
subIncyEnableRouting bool
subIncyRoutingRules string
subPath string subPath string
subJsonPath string subJsonPath string
@@ -96,23 +91,15 @@ type subControllerConfig struct {
remarkTemplate string remarkTemplate string
updateInterval string updateInterval string
subJsonMux string subJsonMux string
subJsonRules string subJsonFinalMask string
subJsonFinalMask string subJsonObservatory string
subJsonObservatory string
subClashEnableRouting bool
subClashRules string
subTitle string subTitle string
subSupportURL string subSupportURL string
subProfileURL string subProfileURL string
subAnnounce string subAnnounce string
subEnableRouting bool subHideSettings bool
subRoutingRules string
subHideSettings bool
subIncyEnableRouting bool
subIncyRoutingRules string
} }
type SUBControllerOption func(*subControllerConfig) type SUBControllerOption func(*subControllerConfig)
@@ -173,10 +160,6 @@ func WithSUBJsonMux(value string) SUBControllerOption {
return func(config *subControllerConfig) { config.subJsonMux = value } return func(config *subControllerConfig) { config.subJsonMux = value }
} }
func WithSUBJsonRules(value string) SUBControllerOption {
return func(config *subControllerConfig) { config.subJsonRules = value }
}
func WithSUBJsonFinalMask(value string) SUBControllerOption { func WithSUBJsonFinalMask(value string) SUBControllerOption {
return func(config *subControllerConfig) { config.subJsonFinalMask = value } return func(config *subControllerConfig) { config.subJsonFinalMask = value }
} }
@@ -185,14 +168,6 @@ func WithSUBJsonObservatory(value string) SUBControllerOption {
return func(config *subControllerConfig) { config.subJsonObservatory = value } return func(config *subControllerConfig) { config.subJsonObservatory = value }
} }
func WithSUBClashEnableRouting(value bool) SUBControllerOption {
return func(config *subControllerConfig) { config.subClashEnableRouting = value }
}
func WithSUBClashRules(value string) SUBControllerOption {
return func(config *subControllerConfig) { config.subClashRules = value }
}
func WithSUBTitle(value string) SUBControllerOption { func WithSUBTitle(value string) SUBControllerOption {
return func(config *subControllerConfig) { config.subTitle = value } return func(config *subControllerConfig) { config.subTitle = value }
} }
@@ -209,26 +184,10 @@ func WithSUBAnnounce(value string) SUBControllerOption {
return func(config *subControllerConfig) { config.subAnnounce = value } return func(config *subControllerConfig) { config.subAnnounce = value }
} }
func WithSUBEnableRouting(value bool) SUBControllerOption {
return func(config *subControllerConfig) { config.subEnableRouting = value }
}
func WithSUBRoutingRules(value string) SUBControllerOption {
return func(config *subControllerConfig) { config.subRoutingRules = value }
}
func WithSUBHideSettings(value bool) SUBControllerOption { func WithSUBHideSettings(value bool) SUBControllerOption {
return func(config *subControllerConfig) { config.subHideSettings = value } return func(config *subControllerConfig) { config.subHideSettings = value }
} }
func WithSUBIncyEnableRouting(value bool) SUBControllerOption {
return func(config *subControllerConfig) { config.subIncyEnableRouting = value }
}
func WithSUBIncyRoutingRules(value string) SUBControllerOption {
return func(config *subControllerConfig) { config.subIncyRoutingRules = value }
}
func defaultSUBControllerConfig() subControllerConfig { func defaultSUBControllerConfig() subControllerConfig {
return subControllerConfig{ return subControllerConfig{
subPath: "/sub/", subPath: "/sub/",
@@ -248,19 +207,14 @@ func NewSUBController(g *gin.RouterGroup, options ...SUBControllerOption) *SUBCo
} }
sub := NewSubService(config.remarkTemplate) sub := NewSubService(config.remarkTemplate)
subJsonSvc := NewSubJsonService(config.subJsonMux, config.subJsonRules, config.subJsonFinalMask, sub) subJsonSvc := NewSubJsonService(config.subJsonMux, config.subJsonFinalMask, sub)
subJsonSvc.SetObservatoryConfig(config.subJsonObservatory) subJsonSvc.SetObservatoryConfig(config.subJsonObservatory)
a := &SUBController{ a := &SUBController{
subTitle: config.subTitle, subTitle: config.subTitle,
subSupportUrl: config.subSupportURL, subSupportUrl: config.subSupportURL,
subProfileUrl: config.subProfileURL, subProfileUrl: config.subProfileURL,
subAnnounce: config.subAnnounce, subAnnounce: config.subAnnounce,
subEnableRouting: config.subEnableRouting, subHideSettings: config.subHideSettings,
subRoutingRules: config.subRoutingRules,
subHideSettings: config.subHideSettings,
subIncyEnableRouting: config.subIncyEnableRouting,
subIncyRoutingRules: config.subIncyRoutingRules,
subPath: config.subPath, subPath: config.subPath,
subJsonPath: config.subJsonPath, subJsonPath: config.subJsonPath,
@@ -277,7 +231,7 @@ func NewSUBController(g *gin.RouterGroup, options ...SUBControllerOption) *SUBCo
subService: sub, subService: sub,
subJsonService: subJsonSvc, subJsonService: subJsonSvc,
subClashService: NewSubClashService(config.subClashEnableRouting, config.subClashRules, sub), subClashService: NewSubClashService(sub),
subTemplateCache: map[string]*cachedSubTemplate{}, subTemplateCache: map[string]*cachedSubTemplate{},
} }
@@ -426,15 +380,7 @@ func (a *SUBController) subs(c *gin.Context) {
header := fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000) header := fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000)
profileURL := fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI) profileURL := fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
metadata := a.metadataForSubRequest(func() *SubService { return subReq }, subId, profileURL) metadata := a.metadataForSubRequest(func() *SubService { return subReq }, subId, profileURL)
a.ApplyCommonHeaders(c, header, a.updateInterval, metadata.Title, metadata.SupportURL, metadata.ProfileURL, metadata.Announce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings) a.ApplyCommonHeaders(c, header, a.updateInterval, metadata.Title, metadata.SupportURL, metadata.ProfileURL, metadata.Announce, a.subHideSettings)
if a.subIncyEnableRouting && a.subIncyRoutingRules != "" {
incyRules, _, err := resolveIncyRoutingSource(a.subIncyRoutingRules)
if err == nil && strings.TrimSpace(incyRules) != "" {
result.WriteString(incyRules)
result.WriteString("\n")
}
}
if a.subEncrypt { if a.subEncrypt {
c.String(200, base64.StdEncoding.EncodeToString([]byte(result.String()))) c.String(200, base64.StdEncoding.EncodeToString([]byte(result.String())))
@@ -749,7 +695,7 @@ func (a *SUBController) serveJsonBody(c *gin.Context, alwaysReturnArray bool, co
} }
return subReq return subReq
}, subId, profileURL) }, subId, profileURL)
a.ApplyCommonHeaders(c, header, a.updateInterval, metadata.Title, metadata.SupportURL, metadata.ProfileURL, metadata.Announce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings) a.ApplyCommonHeaders(c, header, a.updateInterval, metadata.Title, metadata.SupportURL, metadata.ProfileURL, metadata.Announce, a.subHideSettings)
if rawDownload { if rawDownload {
c.Writer.Header().Set("Content-Disposition", `attachment; filename="subscription.json"`) c.Writer.Header().Set("Content-Disposition", `attachment; filename="subscription.json"`)
} }
@@ -797,7 +743,7 @@ func (a *SUBController) serveClashBody(c *gin.Context, rawDownload bool) bool {
} }
return subReq return subReq
}, subId, profileURL) }, subId, profileURL)
a.ApplyCommonHeaders(c, header, a.updateInterval, metadata.Title, metadata.SupportURL, metadata.ProfileURL, metadata.Announce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings) a.ApplyCommonHeaders(c, header, a.updateInterval, metadata.Title, metadata.SupportURL, metadata.ProfileURL, metadata.Announce, a.subHideSettings)
if rawDownload { if rawDownload {
c.Writer.Header().Set("Content-Disposition", `attachment; filename="subscription.yaml"`) c.Writer.Header().Set("Content-Disposition", `attachment; filename="subscription.yaml"`)
} else if metadata.Title != "" { } else if metadata.Title != "" {
@@ -817,8 +763,6 @@ func (a *SUBController) ApplyCommonHeaders(
profileSupportUrl string, profileSupportUrl string,
profileUrl string, profileUrl string,
profileAnnounce string, profileAnnounce string,
profileEnableRouting bool,
profileRoutingRules string,
profileHideSettings bool, profileHideSettings bool,
) { ) {
c.Writer.Header().Set("Subscription-Userinfo", header) c.Writer.Header().Set("Subscription-Userinfo", header)
@@ -838,15 +782,6 @@ func (a *SUBController) ApplyCommonHeaders(
c.Writer.Header().Set("Announce", "base64:"+base64.StdEncoding.EncodeToString([]byte(profileAnnounce))) c.Writer.Header().Set("Announce", "base64:"+base64.StdEncoding.EncodeToString([]byte(profileAnnounce)))
} }
// Advanced (Happ). Routing stays independent of the enable flag; remote
// values come only from the validated cache and never delay this response.
rules, remote, routingErr := resolveRoutingSource(remoteRoutingHapp, profileRoutingRules)
if profileEnableRouting {
c.Writer.Header().Set("Routing-Enable", "true")
}
if (routingErr == nil || !remote) && strings.TrimSpace(rules) != "" {
c.Writer.Header().Set("Routing", rules)
}
if profileHideSettings { if profileHideSettings {
c.Writer.Header().Set("Hide-Settings", "1") c.Writer.Header().Set("Hide-Settings", "1")
} }
+1 -1
View File
@@ -155,7 +155,7 @@ func TestLinkDisplayName(t *testing.T) {
func TestClashProxyFromExternalTrojanReality(t *testing.T) { func TestClashProxyFromExternalTrojanReality(t *testing.T) {
link := "trojan://provider-pass@37.27.201.56:8443?type=tcp&security=reality&sni=aws.amazon.com&pbk=PBK&sid=298b44&fp=chrome#srv" link := "trojan://provider-pass@37.27.201.56:8443?type=tcp&security=reality&sni=aws.amazon.com&pbk=PBK&sid=298b44&fp=chrome#srv"
svc := NewSubClashService(false, "", NewSubService("")) svc := NewSubClashService(NewSubService(""))
proxy := svc.clashProxyFromExternal(link, "DE-Provider") proxy := svc.clashProxyFromExternal(link, "DE-Provider")
if proxy == nil { if proxy == nil {
t.Fatal("expected a clash proxy, got nil") t.Fatal("expected a clash proxy, got nil")
+2 -2
View File
@@ -28,7 +28,7 @@ func TestJsonAndClashServeExternalLinkOnlySub(t *testing.T) {
base := NewSubService("") base := NewSubService("")
jsonService := NewSubJsonService("", "", "", base) jsonService := NewSubJsonService("", "", base)
jsonOut, _, err := jsonService.GetJson("ext-only", "sub.example.com", false) jsonOut, _, err := jsonService.GetJson("ext-only", "sub.example.com", false)
if err != nil { if err != nil {
t.Fatalf("GetJson err = %v", err) t.Fatalf("GetJson err = %v", err)
@@ -56,7 +56,7 @@ func TestJsonAndClashServeExternalLinkOnlySub(t *testing.T) {
t.Fatalf("standards-compliant GetJson profile count = %d, want 1", len(configs)) t.Fatalf("standards-compliant GetJson profile count = %d, want 1", len(configs))
} }
clashOut, _, err := NewSubClashService(false, "", base).GetClash("ext-only", "sub.example.com") clashOut, _, err := NewSubClashService(base).GetClash("ext-only", "sub.example.com")
if err != nil { if err != nil {
t.Fatalf("GetClash err = %v", err) t.Fatalf("GetClash err = %v", err)
} }
+6 -6
View File
@@ -261,7 +261,7 @@ func TestSub_HostAllowInsecure(t *testing.T) {
t.Fatalf("raw link should carry allowInsecure=1: %s", strings.Join(links, "\n")) t.Fatalf("raw link should carry allowInsecure=1: %s", strings.Join(links, "\n"))
} }
clash := NewSubClashService(false, "", NewSubService("")) clash := NewSubClashService(NewSubService(""))
yaml, _, err := clash.GetClash("s1", "req.example.com") yaml, _, err := clash.GetClash("s1", "req.example.com")
if err != nil { if err != nil {
t.Fatalf("GetClash: %v", err) t.Fatalf("GetClash: %v", err)
@@ -282,7 +282,7 @@ func TestSub_HostHeaderReachesClashAndJson(t *testing.T) {
HostHeader: "cdn.example.com", Path: "/ws-path", HostHeader: "cdn.example.com", Path: "/ws-path",
}) })
clash := NewSubClashService(false, "", NewSubService("")) clash := NewSubClashService(NewSubService(""))
yaml, _, err := clash.GetClash("s1", "req.example.com") yaml, _, err := clash.GetClash("s1", "req.example.com")
if err != nil { if err != nil {
t.Fatalf("GetClash: %v", err) t.Fatalf("GetClash: %v", err)
@@ -294,7 +294,7 @@ func TestSub_HostHeaderReachesClashAndJson(t *testing.T) {
t.Fatalf("clash ws-opts should carry the host record's path:\n%s", yaml) t.Fatalf("clash ws-opts should carry the host record's path:\n%s", yaml)
} }
js := NewSubJsonService("", "", "", NewSubService("")) js := NewSubJsonService("", "", NewSubService(""))
out, _, err := js.GetJson("s1", "req.example.com", false) out, _, err := js.GetJson("s1", "req.example.com", false)
if err != nil { if err != nil {
t.Fatalf("GetJson: %v", err) t.Fatalf("GetJson: %v", err)
@@ -336,7 +336,7 @@ func TestSub_HostSockoptJSON(t *testing.T) {
InboundId: ib.Id, SortOrder: 0, Remark: "SO", Address: "so.cdn.com", Port: 8443, Security: "tls", InboundId: ib.Id, SortOrder: 0, Remark: "SO", Address: "so.cdn.com", Port: 8443, Security: "tls",
SockoptParams: `{"tcpFastOpen":true}`, SockoptParams: `{"tcpFastOpen":true}`,
}) })
js := NewSubJsonService("", "", "", NewSubService("")) js := NewSubJsonService("", "", NewSubService(""))
out, _, err := js.GetJson("s1", "req.example.com", false) out, _, err := js.GetJson("s1", "req.example.com", false)
if err != nil { if err != nil {
t.Fatalf("GetJson: %v", err) t.Fatalf("GetJson: %v", err)
@@ -354,7 +354,7 @@ func TestSub_HostMuxJSON(t *testing.T) {
InboundId: ib.Id, SortOrder: 0, Remark: "MX", Address: "mx.cdn.com", Port: 8443, Security: "tls", InboundId: ib.Id, SortOrder: 0, Remark: "MX", Address: "mx.cdn.com", Port: 8443, Security: "tls",
MuxParams: `{"enabled":true,"concurrency":8}`, MuxParams: `{"enabled":true,"concurrency":8}`,
}) })
js := NewSubJsonService("", "", "", NewSubService("")) js := NewSubJsonService("", "", NewSubService(""))
out, _, err := js.GetJson("s1", "req.example.com", false) out, _, err := js.GetJson("s1", "req.example.com", false)
if err != nil { if err != nil {
t.Fatalf("GetJson: %v", err) t.Fatalf("GetJson: %v", err)
@@ -409,7 +409,7 @@ func TestSub_ExcludeFromSubTypes(t *testing.T) {
t.Fatalf("host not excluded from raw should appear in GetSubs") t.Fatalf("host not excluded from raw should appear in GetSubs")
} }
clash := NewSubClashService(false, "", NewSubService("")) clash := NewSubClashService(NewSubService(""))
yaml, _, err := clash.GetClash("s1", "req.example.com") yaml, _, err := clash.GetClash("s1", "req.example.com")
if err != nil { if err != nil {
t.Fatalf("GetClash: %v", err) t.Fatalf("GetClash: %v", err)
+3 -3
View File
@@ -51,7 +51,7 @@ func TestSub_JSONStripsFlowOnUnsupportedTransport(t *testing.T) {
t.Fatalf("raw link must not carry a flow on ws+tls: %s", joined) t.Fatalf("raw link must not carry a flow on ws+tls: %s", joined)
} }
clash := NewSubClashService(false, "", NewSubService("")) clash := NewSubClashService(NewSubService(""))
yaml, _, err := clash.GetClash("s1", "req.example.com") yaml, _, err := clash.GetClash("s1", "req.example.com")
if err != nil { if err != nil {
t.Fatalf("GetClash: %v", err) t.Fatalf("GetClash: %v", err)
@@ -60,7 +60,7 @@ func TestSub_JSONStripsFlowOnUnsupportedTransport(t *testing.T) {
t.Fatalf("clash proxy must not carry a flow on ws+tls:\n%s", yaml) t.Fatalf("clash proxy must not carry a flow on ws+tls:\n%s", yaml)
} }
js := NewSubJsonService("", "", "", NewSubService("")) js := NewSubJsonService("", "", NewSubService(""))
out, _, err := js.GetJson("s1", "req.example.com", false) out, _, err := js.GetJson("s1", "req.example.com", false)
if err != nil { if err != nil {
t.Fatalf("GetJson: %v", err) t.Fatalf("GetJson: %v", err)
@@ -76,7 +76,7 @@ func TestSub_JSONKeepsFlowOnTcpTLS(t *testing.T) {
seedFlowInbound(t, "s1", "tcpflow", 4602, seedFlowInbound(t, "s1", "tcpflow", 4602,
`{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"}}`) `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"}}`)
js := NewSubJsonService("", "", "", NewSubService("")) js := NewSubJsonService("", "", NewSubService(""))
out, _, err := js.GetJson("s1", "req.example.com", false) out, _, err := js.GetJson("s1", "req.example.com", false)
if err != nil { if err != nil {
t.Fatalf("GetJson: %v", err) t.Fatalf("GetJson: %v", err)
+1 -11
View File
@@ -34,7 +34,7 @@ type SubJsonService struct {
} }
// NewSubJsonService creates a new JSON subscription service with the given configuration. // NewSubJsonService creates a new JSON subscription service with the given configuration.
func NewSubJsonService(mux string, rules string, finalMask string, subService *SubService) *SubJsonService { func NewSubJsonService(mux string, finalMask string, subService *SubService) *SubJsonService {
var configJson map[string]any var configJson map[string]any
var defaultOutbounds []json_util.RawMessage var defaultOutbounds []json_util.RawMessage
_ = json.Unmarshal([]byte(defaultJson), &configJson) _ = json.Unmarshal([]byte(defaultJson), &configJson)
@@ -45,16 +45,6 @@ func NewSubJsonService(mux string, rules string, finalMask string, subService *S
} }
} }
if rules != "" {
var newRules []any
routing, _ := configJson["routing"].(map[string]any)
defaultRules, _ := routing["rules"].([]any)
_ = json.Unmarshal([]byte(rules), &newRules)
defaultRules = append(newRules, defaultRules...)
routing["rules"] = defaultRules
configJson["routing"] = routing
}
return &SubJsonService{ return &SubJsonService{
configJson: configJson, configJson: configJson,
defaultOutbounds: defaultOutbounds, defaultOutbounds: defaultOutbounds,
+18 -18
View File
@@ -37,7 +37,7 @@ func outboundSettings(t *testing.T, raw []byte) map[string]any {
func TestSubJsonServiceInjectsGlobalFinalMask(t *testing.T) { func TestSubJsonServiceInjectsGlobalFinalMask(t *testing.T) {
finalMask := `{"tcp":[{"type":"fragment","settings":{"packets":"tlshello","length":"100-200","delay":"10-20"}}],"udp":[{"type":"noise","settings":{"noise":[{"type":"base64","packet":"SGVsbG8="}]}}],"quicParams":{"congestion":"bbr"}}` finalMask := `{"tcp":[{"type":"fragment","settings":{"packets":"tlshello","length":"100-200","delay":"10-20"}}],"udp":[{"type":"noise","settings":{"noise":[{"type":"base64","packet":"SGVsbG8="}]}}],"quicParams":{"congestion":"bbr"}}`
svc := NewSubJsonService("", "", finalMask, nil) svc := NewSubJsonService("", finalMask, nil)
if hasDirectOutOutbound(svc) { if hasDirectOutOutbound(svc) {
t.Fatal("direct_out outbound must never be emitted") t.Fatal("direct_out outbound must never be emitted")
@@ -74,7 +74,7 @@ func TestSubJsonServiceInjectsGlobalFinalMask(t *testing.T) {
func TestSubJsonServiceMergesWithExistingFinalMask(t *testing.T) { func TestSubJsonServiceMergesWithExistingFinalMask(t *testing.T) {
finalMask := `{"tcp":[{"type":"fragment","settings":{"packets":"tlshello"}}]}` finalMask := `{"tcp":[{"type":"fragment","settings":{"packets":"tlshello"}}]}`
svc := NewSubJsonService("", "", finalMask, nil) svc := NewSubJsonService("", finalMask, nil)
stream := svc.streamData(`{ stream := svc.streamData(`{
"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}, "network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}},
@@ -94,7 +94,7 @@ func TestSubJsonServiceMergesWithExistingFinalMask(t *testing.T) {
} }
func TestSubJsonServiceNoFinalMaskWhenEmpty(t *testing.T) { func TestSubJsonServiceNoFinalMaskWhenEmpty(t *testing.T) {
svc := NewSubJsonService("", "", "", nil) svc := NewSubJsonService("", "", nil)
stream := svc.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`, "") stream := svc.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`, "")
if _, ok := stream["finalmask"]; ok { if _, ok := stream["finalmask"]; ok {
t.Fatal("no finalmask should be emitted when subJsonFinalMask is empty") t.Fatal("no finalmask should be emitted when subJsonFinalMask is empty")
@@ -108,7 +108,7 @@ func TestSubJsonServiceNoFinalMaskWhenEmpty(t *testing.T) {
// the JSON subscription must emit that form, not an array, or v2ray clients fail // the JSON subscription must emit that form, not an array, or v2ray clients fail
// to import the config (#5401). // to import the config (#5401).
func TestSubJsonServicePinnedCertJoinedToString(t *testing.T) { func TestSubJsonServicePinnedCertJoinedToString(t *testing.T) {
svc := NewSubJsonService("", "", "", nil) svc := NewSubJsonService("", "", nil)
stream := svc.streamData(`{"network":"tcp","security":"tls","tlsSettings":{"serverName":"a.example.com","settings":{"pinnedPeerCertSha256":["aa11","bb22"]}}}`, "") stream := svc.streamData(`{"network":"tcp","security":"tls","tlsSettings":{"serverName":"a.example.com","settings":{"pinnedPeerCertSha256":["aa11","bb22"]}}}`, "")
tls, _ := stream["tlsSettings"].(map[string]any) tls, _ := stream["tlsSettings"].(map[string]any)
@@ -121,7 +121,7 @@ func TestSubJsonServicePinnedCertJoinedToString(t *testing.T) {
} }
func TestSubJsonServiceTLSCipherSuitesForwarded(t *testing.T) { func TestSubJsonServiceTLSCipherSuitesForwarded(t *testing.T) {
svc := NewSubJsonService("", "", "", nil) svc := NewSubJsonService("", "", nil)
stream := svc.streamData(`{"network":"tcp","security":"tls","tlsSettings":{"serverName":"a.example.com","cipherSuites":"TLS_AES_256_GCM_SHA384","settings":{}}}`, "") stream := svc.streamData(`{"network":"tcp","security":"tls","tlsSettings":{"serverName":"a.example.com","cipherSuites":"TLS_AES_256_GCM_SHA384","settings":{}}}`, "")
tls, _ := stream["tlsSettings"].(map[string]any) tls, _ := stream["tlsSettings"].(map[string]any)
@@ -140,7 +140,7 @@ func TestSubJsonServiceVlessFlattened(t *testing.T) {
inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VLESS, Settings: `{"encryption":"none"}`} inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VLESS, Settings: `{"encryption":"none"}`}
client := model.Client{ID: "uuid-1", Flow: "xtls-rprx-vision"} client := model.Client{ID: "uuid-1", Flow: "xtls-rprx-vision"}
settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genVless(&SubService{}, inbound, nil, client, "")) settings := outboundSettings(t, NewSubJsonService("", "", nil).genVless(&SubService{}, inbound, nil, client, ""))
if _, ok := settings["vnext"]; ok { if _, ok := settings["vnext"]; ok {
t.Fatal("vless outbound must not use vnext") t.Fatal("vless outbound must not use vnext")
} }
@@ -153,7 +153,7 @@ func TestSubJsonServiceVlessFlowSuppressedByDisableFlow(t *testing.T) {
inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VLESS, Settings: `{"encryption":"none"}`, DisableFlow: true} inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VLESS, Settings: `{"encryption":"none"}`, DisableFlow: true}
client := model.Client{ID: "uuid-1", Flow: "xtls-rprx-vision"} client := model.Client{ID: "uuid-1", Flow: "xtls-rprx-vision"}
settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genVless(&SubService{}, inbound, nil, client, "")) settings := outboundSettings(t, NewSubJsonService("", "", nil).genVless(&SubService{}, inbound, nil, client, ""))
if _, ok := settings["flow"]; ok { if _, ok := settings["flow"]; ok {
t.Fatalf("DisableFlow inbound must not carry a flow in the JSON outbound: %#v", settings) t.Fatalf("DisableFlow inbound must not carry a flow in the JSON outbound: %#v", settings)
} }
@@ -163,7 +163,7 @@ func TestSubJsonServiceVmessFlattened(t *testing.T) {
inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VMESS, Settings: `{}`} inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VMESS, Settings: `{}`}
client := model.Client{ID: "uuid-2"} client := model.Client{ID: "uuid-2"}
settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genVnext(inbound, nil, client, "")) settings := outboundSettings(t, NewSubJsonService("", "", nil).genVnext(inbound, nil, client, ""))
if _, ok := settings["vnext"]; ok { if _, ok := settings["vnext"]; ok {
t.Fatal("vmess outbound must not use vnext") t.Fatal("vmess outbound must not use vnext")
} }
@@ -179,7 +179,7 @@ func TestSubJsonServiceServerUsesServersArray(t *testing.T) {
trojan := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.Trojan, Settings: `{}`} trojan := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.Trojan, Settings: `{}`}
client := model.Client{Password: "p4ss"} client := model.Client{Password: "p4ss"}
settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genServer(&SubService{}, trojan, nil, client, "")) settings := outboundSettings(t, NewSubJsonService("", "", nil).genServer(&SubService{}, trojan, nil, client, ""))
server := firstServer(settings) server := firstServer(settings)
if server == nil { if server == nil {
t.Fatalf("trojan outbound must use a servers array, got: %#v", settings) t.Fatalf("trojan outbound must use a servers array, got: %#v", settings)
@@ -192,7 +192,7 @@ func TestSubJsonServiceServerUsesServersArray(t *testing.T) {
} }
ss := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.Shadowsocks, Settings: `{"method":"aes-256-gcm"}`} ss := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.Shadowsocks, Settings: `{"method":"aes-256-gcm"}`}
ssSettings := outboundSettings(t, NewSubJsonService("", "", "", nil).genServer(&SubService{}, ss, nil, client, "")) ssSettings := outboundSettings(t, NewSubJsonService("", "", nil).genServer(&SubService{}, ss, nil, client, ""))
ssServer := firstServer(ssSettings) ssServer := firstServer(ssSettings)
if ssServer == nil { if ssServer == nil {
t.Fatalf("shadowsocks outbound must use a servers array, got: %#v", ssSettings) t.Fatalf("shadowsocks outbound must use a servers array, got: %#v", ssSettings)
@@ -204,7 +204,7 @@ func TestSubJsonServiceServerUsesServersArray(t *testing.T) {
func TestSubJsonServiceXmuxSuppressesGlobalMux(t *testing.T) { func TestSubJsonServiceXmuxSuppressesGlobalMux(t *testing.T) {
globalMux := `{"enabled":true,"concurrency":8}` globalMux := `{"enabled":true,"concurrency":8}`
svc := NewSubJsonService(globalMux, "", "", nil) svc := NewSubJsonService(globalMux, "", nil)
// When xmux is present in xhttpSettings, the per-inbound xmux handles // When xmux is present in xhttpSettings, the per-inbound xmux handles
// multiplexing and the legacy outbound.Mux must NOT be set. // multiplexing and the legacy outbound.Mux must NOT be set.
@@ -251,7 +251,7 @@ func TestSubJsonServiceXmuxSuppressesGlobalMux(t *testing.T) {
func TestSubJsonServiceGlobalMuxWhenNoXmux(t *testing.T) { func TestSubJsonServiceGlobalMuxWhenNoXmux(t *testing.T) {
globalMux := `{"enabled":true,"concurrency":8}` globalMux := `{"enabled":true,"concurrency":8}`
svc := NewSubJsonService(globalMux, "", "", nil) svc := NewSubJsonService(globalMux, "", nil)
// When no xmux is present, the global subJsonMux should be used. // When no xmux is present, the global subJsonMux should be used.
stream := `{"network":"xhttp","security":"tls","tlsSettings":{"serverName":"example.com"},"xhttpSettings":{"path":"/api","mode":"packet-up"}}` stream := `{"network":"xhttp","security":"tls","tlsSettings":{"serverName":"example.com"},"xhttpSettings":{"path":"/api","mode":"packet-up"}}`
@@ -305,7 +305,7 @@ func realitySpiderXFromStream(t *testing.T, svc *SubJsonService, clientKey strin
} }
func TestSubJsonServiceRealityDataDerivesPerClientSpiderX(t *testing.T) { func TestSubJsonServiceRealityDataDerivesPerClientSpiderX(t *testing.T) {
svc := NewSubJsonService("", "", "", nil) svc := NewSubJsonService("", "", nil)
alice := realitySpiderXFromStream(t, svc, "subAlice") alice := realitySpiderXFromStream(t, svc, "subAlice")
if again := realitySpiderXFromStream(t, svc, "subAlice"); again != alice { if again := realitySpiderXFromStream(t, svc, "subAlice"); again != alice {
@@ -321,13 +321,13 @@ func TestSubJsonServiceRealityDataDerivesPerClientSpiderX(t *testing.T) {
// security whose settings key is missing or null previously panicked the // security whose settings key is missing or null previously panicked the
// subscription request. // subscription request.
func TestSubJsonServiceStreamDataMalformedInputs(t *testing.T) { func TestSubJsonServiceStreamDataMalformedInputs(t *testing.T) {
withMask := NewSubJsonService("", "", `{"tcp":[{"type":"fragment"}]}`, nil) withMask := NewSubJsonService("", `{"tcp":[{"type":"fragment"}]}`, nil)
stream := withMask.streamData("not-json", "clientKey") stream := withMask.streamData("not-json", "clientKey")
if _, ok := stream["finalmask"]; !ok { if _, ok := stream["finalmask"]; !ok {
t.Fatal("finalMask must still apply when stream settings fail to parse") t.Fatal("finalMask must still apply when stream settings fail to parse")
} }
svc := NewSubJsonService("", "", "", nil) svc := NewSubJsonService("", "", nil)
noReality := svc.streamData(`{"network":"tcp","security":"reality"}`, "clientKey") noReality := svc.streamData(`{"network":"tcp","security":"reality"}`, "clientKey")
if v, ok := noReality["realitySettings"]; ok { if v, ok := noReality["realitySettings"]; ok {
t.Fatalf("missing realitySettings must stay absent, got %v", v) t.Fatalf("missing realitySettings must stay absent, got %v", v)
@@ -339,7 +339,7 @@ func TestSubJsonServiceStreamDataMalformedInputs(t *testing.T) {
} }
func TestSubJsonServiceRealityDataSpiderXFallsBackWhenNoClientKey(t *testing.T) { func TestSubJsonServiceRealityDataSpiderXFallsBackWhenNoClientKey(t *testing.T) {
svc := NewSubJsonService("", "", "", nil) svc := NewSubJsonService("", "", nil)
stream := svc.streamData(`{ stream := svc.streamData(`{
"network":"tcp","security":"reality","tcpSettings":{"header":{"type":"none"}}, "network":"tcp","security":"reality","tcpSettings":{"header":{"type":"none"}},
@@ -384,7 +384,7 @@ func TestSubJsonServiceWireguard(t *testing.T) {
AllowedIPs: []string{"10.0.0.2/32", "fd00::2/128"}, AllowedIPs: []string{"10.0.0.2/32", "fd00::2/128"},
} }
raw := NewSubJsonService("", "", "", nil).genWireguard(inbound, client) raw := NewSubJsonService("", "", nil).genWireguard(inbound, client)
if raw == nil { if raw == nil {
t.Fatal("genWireguard returned nil for a valid wireguard client") t.Fatal("genWireguard returned nil for a valid wireguard client")
} }
@@ -428,7 +428,7 @@ func TestSubJsonServiceWireguardNoKey(t *testing.T) {
inbound := &model.Inbound{Listen: "203.0.113.9", Port: 51820, Protocol: model.WireGuard, Settings: `{}`} inbound := &model.Inbound{Listen: "203.0.113.9", Port: 51820, Protocol: model.WireGuard, Settings: `{}`}
client := model.Client{Email: "user"} client := model.Client{Email: "user"}
if raw := NewSubJsonService("", "", "", nil).genWireguard(inbound, client); raw != nil { if raw := NewSubJsonService("", "", nil).genWireguard(inbound, client); raw != nil {
t.Fatalf("genWireguard = %s, want nil for a keyless wireguard client", raw) t.Fatalf("genWireguard = %s, want nil for a keyless wireguard client", raw)
} }
} }
+9 -29
View File
@@ -25,29 +25,9 @@ func initMutDB(t *testing.T) {
t.Cleanup(func() { _ = database.CloseDB() }) t.Cleanup(func() { _ = database.CloseDB() })
} }
// --- json_service.go:40 — rules are merged into routing only when non-empty ---
func TestSubJsonService_CustomRulesPrepended(t *testing.T) {
rules := `[{"type":"field","domain":["geosite:ads"],"outboundTag":"block"}]`
svc := NewSubJsonService("", rules, "", nil)
routing, ok := svc.configJson["routing"].(map[string]any)
if !ok {
t.Fatalf("routing missing: %#v", svc.configJson["routing"])
}
got, _ := routing["rules"].([]any)
// default.json ships exactly 1 rule; the custom rule must be prepended.
if len(got) != 2 {
t.Fatalf("rules len = %d, want 2 (custom prepended to default)", len(got))
}
first, _ := got[0].(map[string]any)
if domains, _ := first["domain"].([]any); len(domains) != 1 || domains[0] != "geosite:ads" {
t.Fatalf("custom rule must come first, got %#v", got[0])
}
}
func TestSubJsonService_EmptyRulesLeavesDefault(t *testing.T) { func TestSubJsonService_EmptyRulesLeavesDefault(t *testing.T) {
svc := NewSubJsonService("", "", "", nil) svc := NewSubJsonService("", "", nil)
routing, _ := svc.configJson["routing"].(map[string]any) routing, _ := svc.configJson["routing"].(map[string]any)
got, _ := routing["rules"].([]any) got, _ := routing["rules"].([]any)
if len(got) != 1 { if len(got) != 1 {
@@ -67,12 +47,12 @@ func TestSubJsonService_MuxAttachedWhenConfigured(t *testing.T) {
wantMux bool wantMux bool
protocol model.Protocol protocol model.Protocol
}{ }{
{"vmess mux", NewSubJsonService(mux, "", "", nil).genVnext(&model.Inbound{Protocol: model.VMESS, Settings: `{}`}, nil, client, mux), true, model.VMESS}, {"vmess mux", NewSubJsonService(mux, "", nil).genVnext(&model.Inbound{Protocol: model.VMESS, Settings: `{}`}, nil, client, mux), true, model.VMESS},
{"vless mux", NewSubJsonService(mux, "", "", nil).genVless(&SubService{}, &model.Inbound{Protocol: model.VLESS, Settings: `{}`}, nil, client, mux), true, model.VLESS}, {"vless mux", NewSubJsonService(mux, "", nil).genVless(&SubService{}, &model.Inbound{Protocol: model.VLESS, Settings: `{}`}, nil, client, mux), true, model.VLESS},
{"server mux", NewSubJsonService(mux, "", "", nil).genServer(&SubService{}, &model.Inbound{Protocol: model.Trojan, Settings: `{}`}, nil, client, mux), true, model.Trojan}, {"server mux", NewSubJsonService(mux, "", nil).genServer(&SubService{}, &model.Inbound{Protocol: model.Trojan, Settings: `{}`}, nil, client, mux), true, model.Trojan},
{"vmess no mux", NewSubJsonService("", "", "", nil).genVnext(&model.Inbound{Protocol: model.VMESS, Settings: `{}`}, nil, client, ""), false, model.VMESS}, {"vmess no mux", NewSubJsonService("", "", nil).genVnext(&model.Inbound{Protocol: model.VMESS, Settings: `{}`}, nil, client, ""), false, model.VMESS},
{"vless no mux", NewSubJsonService("", "", "", nil).genVless(&SubService{}, &model.Inbound{Protocol: model.VLESS, Settings: `{}`}, nil, client, ""), false, model.VLESS}, {"vless no mux", NewSubJsonService("", "", nil).genVless(&SubService{}, &model.Inbound{Protocol: model.VLESS, Settings: `{}`}, nil, client, ""), false, model.VLESS},
{"server no mux", NewSubJsonService("", "", "", nil).genServer(&SubService{}, &model.Inbound{Protocol: model.Trojan, Settings: `{}`}, nil, client, ""), false, model.Trojan}, {"server no mux", NewSubJsonService("", "", nil).genServer(&SubService{}, &model.Inbound{Protocol: model.Trojan, Settings: `{}`}, nil, client, ""), false, model.Trojan},
} }
for _, tc := range cases { for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
@@ -103,7 +83,7 @@ func TestSubJsonService_FinalMaskMergingToEmptyNotAdded(t *testing.T) {
// finalMask is non-empty (passes the len(fm)==0 early return) but its only // finalMask is non-empty (passes the len(fm)==0 early return) but its only
// key is an empty tcp slice, which mergeFinalMask drops → merged is empty, // key is an empty tcp slice, which mergeFinalMask drops → merged is empty,
// so applyGlobalFinalMask must NOT set finalmask. // so applyGlobalFinalMask must NOT set finalmask.
svc := NewSubJsonService("", "", `{"tcp":[]}`, nil) svc := NewSubJsonService("", `{"tcp":[]}`, nil)
stream := svc.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`, "") stream := svc.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`, "")
if _, ok := stream["finalmask"]; ok { if _, ok := stream["finalmask"]; ok {
t.Fatalf("finalMask merging to empty must not add a finalmask key: %#v", stream["finalmask"]) t.Fatalf("finalMask merging to empty must not add a finalmask key: %#v", stream["finalmask"])
@@ -111,7 +91,7 @@ func TestSubJsonService_FinalMaskMergingToEmptyNotAdded(t *testing.T) {
// Sanity: a finalMask that DOES merge to something still gets set, so the // Sanity: a finalMask that DOES merge to something still gets set, so the
// guard is the only distinguishing factor. // guard is the only distinguishing factor.
svc2 := NewSubJsonService("", "", `{"tcp":[{"type":"fragment"}]}`, nil) svc2 := NewSubJsonService("", `{"tcp":[{"type":"fragment"}]}`, nil)
stream2 := svc2.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`, "") stream2 := svc2.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`, "")
if _, ok := stream2["finalmask"]; !ok { if _, ok := stream2["finalmask"]; !ok {
t.Fatal("non-empty finalMask must be set") t.Fatal("non-empty finalMask must be set")
-623
View File
@@ -1,623 +0,0 @@
package sub
import (
"context"
"crypto/tls"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
yaml "github.com/goccy/go-yaml"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
)
// Remote sources reuse the existing settings fields (one HTTPS URL = remote,
// else inline) so no second mode toggle can disagree with the field contents.
type remoteRoutingKind string
const (
remoteRoutingHapp remoteRoutingKind = "happ"
remoteRoutingClash remoteRoutingKind = "clash"
remoteRoutingCacheTTL = 10 * time.Minute
remoteRoutingRetryDelay = 30 * time.Second
remoteRoutingHTTPTimeout = 6 * time.Second
remoteRoutingHappMaxBody = 16 << 10 // 16 KiB; Happ emits the result in a response header
remoteRoutingHappMaxValue = 8 << 10 // normalized Routing header value
remoteRoutingClashMaxBody = 2 << 20 // 2 MiB
)
var errRemoteRoutingUnavailable = errors.New("remote routing source is temporarily unavailable")
type remoteRoutingKey struct {
kind remoteRoutingKind
source string
}
type remoteRoutingCacheEntry struct {
Source string `json:"source"`
Content string `json:"content"`
FetchedAt int64 `json:"fetchedAt"`
ETag string `json:"etag,omitempty"`
LastModified string `json:"lastModified,omitempty"`
Clash map[string]any `json:"-"`
}
func (e remoteRoutingCacheEntry) fetchedTime() time.Time {
return time.Unix(e.FetchedAt, 0)
}
type remoteRoutingFetch struct {
done chan struct{}
err error
}
type remoteRoutingResolver struct {
mu sync.Mutex
loadMu sync.Mutex
loaded bool
loadInFlight bool
entries map[remoteRoutingKey]remoteRoutingCacheEntry
inflight map[remoteRoutingKey]*remoteRoutingFetch
lastAttempt map[remoteRoutingKey]time.Time
client *http.Client
now func() time.Time
persist bool
}
func newRemoteRoutingResolver(client *http.Client, persist bool) *remoteRoutingResolver {
return &remoteRoutingResolver{
entries: make(map[remoteRoutingKey]remoteRoutingCacheEntry),
inflight: make(map[remoteRoutingKey]*remoteRoutingFetch),
lastAttempt: make(map[remoteRoutingKey]time.Time),
client: client,
now: time.Now,
persist: persist,
}
}
var routingSourceResolver = newRemoteRoutingResolver(newRemoteRoutingHTTPClient(), true)
// resolveRoutingSource serves a remote source from the validated cache without
// ever blocking on network; inline values pass through (bool reports remote).
func resolveRoutingSource(kind remoteRoutingKind, raw string) (string, bool, error) {
return routingSourceResolver.resolve(kind, raw)
}
func (r *remoteRoutingResolver) resolve(kind remoteRoutingKind, raw string) (string, bool, error) {
entry, remote, err := r.resolveEntry(kind, raw)
if !remote {
return raw, false, err
}
return entry.Content, true, err
}
func resolveClashRoutingSource(raw string) (string, map[string]any, bool, error) {
entry, remote, err := routingSourceResolver.resolveEntry(remoteRoutingClash, raw)
if !remote {
return raw, nil, false, err
}
return entry.Content, entry.Clash, true, err
}
func (r *remoteRoutingResolver) resolveEntry(kind remoteRoutingKind, raw string) (remoteRoutingCacheEntry, bool, error) {
source, remote, err := common.ParseRemoteRoutingURL(raw)
if err != nil {
return remoteRoutingCacheEntry{}, true, err
}
if !remote {
return remoteRoutingCacheEntry{}, false, nil
}
r.triggerPersistedLoad()
key := remoteRoutingKey{kind: kind, source: source}
now := r.now()
r.mu.Lock()
cached, hasCached := r.entries[key]
if hasCached && now.Sub(cached.fetchedTime()) < remoteRoutingCacheTTL {
r.mu.Unlock()
return cached, true, nil
}
if _, ok := r.inflight[key]; ok {
r.mu.Unlock()
if hasCached {
return cached, true, nil
}
return remoteRoutingCacheEntry{}, true, errRemoteRoutingUnavailable
}
if attemptedAt, attempted := r.lastAttempt[key]; attempted && now.Sub(attemptedAt) < remoteRoutingRetryDelay {
r.mu.Unlock()
if hasCached {
return cached, true, nil
}
return remoteRoutingCacheEntry{}, true, errRemoteRoutingUnavailable
}
fetch := &remoteRoutingFetch{done: make(chan struct{})}
r.inflight[key] = fetch
r.mu.Unlock()
common.GoRecover("remote-routing-refresh", func() { r.refresh(key, cached, hasCached, fetch) })
if hasCached {
return cached, true, nil
}
return remoteRoutingCacheEntry{}, true, errRemoteRoutingUnavailable
}
// RefreshRemoteRoutingSources warms and refreshes configured remote sources
// from the cron job. Concurrent resolver reads are safe; fetches coalesce.
func RefreshRemoteRoutingSources(happ, clash string) {
for kind, raw := range map[remoteRoutingKind]string{
remoteRoutingHapp: happ,
remoteRoutingClash: clash,
} {
_, remote, parseErr := common.ParseRemoteRoutingURL(raw)
if parseErr != nil {
logger.Warningf("Remote %s routing source is invalid", kind)
continue
}
if remote {
_ = routingSourceResolver.refreshSource(kind, raw)
}
}
}
func (r *remoteRoutingResolver) refreshSource(kind remoteRoutingKind, raw string) error {
source, remote, err := common.ParseRemoteRoutingURL(raw)
if err != nil || !remote {
return err
}
r.ensurePersistedLoaded()
key := remoteRoutingKey{kind: kind, source: source}
now := r.now()
r.mu.Lock()
previous, hasPrevious := r.entries[key]
if hasPrevious && now.Sub(previous.fetchedTime()) < remoteRoutingCacheTTL {
r.mu.Unlock()
return nil
}
if fetch, ok := r.inflight[key]; ok {
done := fetch.done
r.mu.Unlock()
<-done
return fetch.err
}
if attemptedAt, attempted := r.lastAttempt[key]; attempted && now.Sub(attemptedAt) < remoteRoutingRetryDelay {
r.mu.Unlock()
return errRemoteRoutingUnavailable
}
fetch := &remoteRoutingFetch{done: make(chan struct{})}
r.inflight[key] = fetch
r.mu.Unlock()
r.refresh(key, previous, hasPrevious, fetch)
return fetch.err
}
func (r *remoteRoutingResolver) refresh(key remoteRoutingKey, previous remoteRoutingCacheEntry, hasPrevious bool, fetch *remoteRoutingFetch) {
entry, err := r.fetch(key, previous, hasPrevious)
now := r.now()
r.mu.Lock()
r.lastAttempt[key] = now
if err == nil {
r.entries[key] = entry
}
fetch.err = err
delete(r.inflight, key)
close(fetch.done)
r.mu.Unlock()
if err != nil {
if hasPrevious {
logger.Warningf("Remote %s routing refresh from %s failed; keeping the last valid value", key.kind, remoteRoutingHost(key.source))
} else {
logger.Warningf("Remote %s routing refresh from %s failed; no validated value is cached", key.kind, remoteRoutingHost(key.source))
}
return
}
if r.persist {
r.persistEntry(key.kind, entry)
}
}
func (r *remoteRoutingResolver) fetch(key remoteRoutingKey, previous remoteRoutingCacheEntry, hasPrevious bool) (entry remoteRoutingCacheEntry, err error) {
// Remote bytes reach the YAML/JSON parsers below; a parser panic must
// degrade to a failed refresh (keeping last-good), not crash the panel.
defer func() {
if panicValue := recover(); panicValue != nil {
entry, err = remoteRoutingCacheEntry{}, fmt.Errorf("remote routing fetch panicked: %v", panicValue)
}
}()
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, key.source, nil)
if err != nil {
return remoteRoutingCacheEntry{}, err
}
req.Header.Set("User-Agent", "3x-ui-remote-routing/1.0")
if hasPrevious {
if previous.ETag != "" {
req.Header.Set("If-None-Match", previous.ETag)
}
if previous.LastModified != "" {
req.Header.Set("If-Modified-Since", previous.LastModified)
}
}
resp, err := r.client.Do(req)
if err != nil {
return remoteRoutingCacheEntry{}, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotModified {
if !hasPrevious {
return remoteRoutingCacheEntry{}, errors.New("remote source returned 304 without a cached value")
}
previous.FetchedAt = r.now().Unix()
if etag := strings.TrimSpace(resp.Header.Get("ETag")); etag != "" {
previous.ETag = etag
}
if modified := strings.TrimSpace(resp.Header.Get("Last-Modified")); modified != "" {
previous.LastModified = modified
}
return previous, nil
}
if key.kind == remoteRoutingHapp && isRemoteHappRedirect(resp.StatusCode) {
location := strings.TrimSpace(resp.Header.Get("Location"))
content, locationErr := normalizeHappRouting([]byte(location))
if locationErr != nil {
return remoteRoutingCacheEntry{}, fmt.Errorf("invalid Happ redirect target: %w", locationErr)
}
if len(content) > remoteRoutingHappMaxValue {
return remoteRoutingCacheEntry{}, errors.New("Happ routing header exceeds the size limit")
}
return remoteRoutingCacheEntry{
Source: key.source,
Content: content,
FetchedAt: r.now().Unix(),
}, nil
}
if resp.StatusCode != http.StatusOK {
return remoteRoutingCacheEntry{}, fmt.Errorf("remote source returned HTTP %d", resp.StatusCode)
}
limit := int64(remoteRoutingHappMaxBody)
if key.kind == remoteRoutingClash {
limit = remoteRoutingClashMaxBody
}
body, err := io.ReadAll(io.LimitReader(resp.Body, limit+1))
if err != nil {
return remoteRoutingCacheEntry{}, err
}
if int64(len(body)) > limit {
return remoteRoutingCacheEntry{}, errors.New("remote routing response exceeds the size limit")
}
content, clash, err := normalizeRemoteRoutingContent(key.kind, body)
if err != nil {
return remoteRoutingCacheEntry{}, err
}
if key.kind == remoteRoutingHapp && len(content) > remoteRoutingHappMaxValue {
return remoteRoutingCacheEntry{}, errors.New("Happ routing header exceeds the size limit")
}
return remoteRoutingCacheEntry{
Source: key.source,
Content: content,
FetchedAt: r.now().Unix(),
ETag: strings.TrimSpace(resp.Header.Get("ETag")),
LastModified: strings.TrimSpace(resp.Header.Get("Last-Modified")),
Clash: clash,
}, nil
}
func isRemoteHappRedirect(status int) bool {
switch status {
case http.StatusMovedPermanently, http.StatusFound, http.StatusSeeOther,
http.StatusTemporaryRedirect, http.StatusPermanentRedirect:
return true
default:
return false
}
}
func normalizeRemoteRoutingContent(kind remoteRoutingKind, body []byte) (string, map[string]any, error) {
switch kind {
case remoteRoutingHapp:
content, err := normalizeHappRouting(body)
return content, nil, err
case remoteRoutingClash:
return normalizeClashRouting(body)
default:
return "", nil, fmt.Errorf("unsupported remote routing kind %q", kind)
}
}
func normalizeHappRouting(body []byte) (string, error) {
text := strings.TrimSpace(string(body))
if text == "" {
return "", errors.New("empty Happ routing response")
}
if strings.HasPrefix(text, "{") {
compact, err := validateAndCompactJSONObject([]byte(text))
if err != nil {
return "", fmt.Errorf("invalid Happ routing JSON: %w", err)
}
return "happ://routing/onadd/" + base64.StdEncoding.EncodeToString(compact), nil
}
if strings.ContainsAny(text, "\r\n") {
return "", errors.New("Happ deeplink must be a single line")
}
payload := ""
for _, prefix := range []string{"happ://routing/onadd/", "happ://routing/add/"} {
if after, ok := strings.CutPrefix(text, prefix); ok {
payload = after
break
}
}
if payload == "" {
return "", errors.New("Happ response is neither routing JSON nor a routing deeplink")
}
decoded, err := decodeRoutingBase64(payload)
if err != nil {
return "", fmt.Errorf("invalid Happ routing payload: %w", err)
}
if _, err := validateAndCompactJSONObject(decoded); err != nil {
return "", fmt.Errorf("invalid Happ routing payload JSON: %w", err)
}
return text, nil
}
func validateAndCompactJSONObject(raw []byte) ([]byte, error) {
var object map[string]any
if err := json.Unmarshal(raw, &object); err != nil {
return nil, err
}
if object == nil {
return nil, errors.New("expected a JSON object")
}
return json.Marshal(object)
}
func decodeRoutingBase64(value string) ([]byte, error) {
value = strings.TrimSpace(value)
encodings := []*base64.Encoding{
base64.StdEncoding,
base64.RawStdEncoding,
base64.URLEncoding,
base64.RawURLEncoding,
}
var lastErr error
for _, encoding := range encodings {
decoded, err := encoding.DecodeString(value)
if err == nil {
return decoded, nil
}
lastErr = err
}
return nil, lastErr
}
func normalizeClashRouting(body []byte) (string, map[string]any, error) {
text := strings.TrimSpace(string(body))
if text == "" {
return "", nil, errors.New("empty Clash routing response")
}
var document map[string]any
if err := yaml.Unmarshal([]byte(text), &document); err != nil {
return "", nil, fmt.Errorf("invalid Clash routing YAML: %w", err)
}
if len(document) == 0 {
return "", nil, errors.New("Clash routing response must be a YAML map")
}
hasSupportedKey := false
for key := range document {
if remoteClashAllowedKey(key) {
hasSupportedKey = true
break
}
}
if !hasSupportedKey {
return "", nil, errors.New("Clash routing response has no supported routing keys")
}
base := map[string]any{
"proxies": []map[string]any{{"name": "validation-node", "type": "vless"}},
"proxy-groups": []map[string]any{{
"name": "PROXY", "type": "select", "proxies": []string{"validation-node", "DIRECT"},
}},
"rules": []string{"MATCH,PROXY"},
}
if err := mergeRemoteClashRules(base, document); err != nil {
return "", nil, fmt.Errorf("invalid remote Clash routing schema: %w", err)
}
return text, document, nil
}
func resolveIncyRoutingSource(raw string) (string, bool, error) {
source, remote, err := common.ParseRemoteRoutingURL(raw)
if err != nil || !remote {
return raw, remote, err
}
return "incy://autorouting/onadd/" + source, true, nil
}
func newRemoteRoutingHTTPClient() *http.Client {
transport := &http.Transport{
Proxy: nil,
DialContext: netsafe.SSRFGuardedDialContext,
ForceAttemptHTTP2: true,
TLSHandshakeTimeout: 4 * time.Second,
ResponseHeaderTimeout: 5 * time.Second,
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
}
return &http.Client{
Timeout: remoteRoutingHTTPTimeout,
Transport: transport,
CheckRedirect: checkRemoteRoutingRedirect,
}
}
func checkRemoteRoutingRedirect(req *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return errors.New("stopped after 5 redirects")
}
if strings.EqualFold(req.URL.Scheme, "happ") {
// routing.help-style services publish the deeplink as the final Location;
// hand the 3xx back to fetch(), which validates it without a request.
return http.ErrUseLastResponse
}
if !strings.EqualFold(req.URL.Scheme, "https") || req.URL.Hostname() == "" || req.URL.User != nil {
return errors.New("remote routing redirect must stay on an absolute HTTPS URL")
}
// The guarded dialer re-resolves, validates and connects to the same public
// address, including on every HTTPS redirect hop.
return nil
}
func remoteRoutingHost(source string) string {
u, err := url.Parse(source)
if err != nil || u.Hostname() == "" {
return "unknown host"
}
return u.Hostname()
}
func remoteRoutingSettingKey(kind remoteRoutingKind) string {
return "_subRemoteRoutingCache_" + string(kind)
}
func (r *remoteRoutingResolver) ensurePersistedLoaded() {
if !r.persist {
return
}
r.mu.Lock()
loaded := r.loaded
r.mu.Unlock()
if loaded {
return
}
r.loadMu.Lock()
defer r.loadMu.Unlock()
r.mu.Lock()
loaded = r.loaded
r.mu.Unlock()
if loaded {
return
}
db := database.GetDB()
if db == nil {
return
}
sqlDB, err := db.DB()
if err != nil || sqlDB.Ping() != nil {
return
}
r.loadPersisted()
r.mu.Lock()
r.loaded = true
r.mu.Unlock()
}
// triggerPersistedLoad keeps SQLite off the subscription request path: requests
// schedule at most one background load; the startup job loads synchronously.
func (r *remoteRoutingResolver) triggerPersistedLoad() {
if !r.persist {
return
}
r.mu.Lock()
if r.loaded || r.loadInFlight {
r.mu.Unlock()
return
}
r.loadInFlight = true
r.mu.Unlock()
common.GoRecover("remote-routing-cache-load", func() {
defer func() {
r.mu.Lock()
r.loadInFlight = false
r.mu.Unlock()
}()
r.ensurePersistedLoaded()
})
}
func (r *remoteRoutingResolver) loadPersisted() {
loaded := make(map[remoteRoutingKey]remoteRoutingCacheEntry, 2)
for _, kind := range []remoteRoutingKind{remoteRoutingHapp, remoteRoutingClash} {
var setting model.Setting
err := database.GetDB().Where("key = ?", remoteRoutingSettingKey(kind)).First(&setting).Error
if err != nil {
continue
}
var entry remoteRoutingCacheEntry
if json.Unmarshal([]byte(setting.Value), &entry) != nil || entry.Source == "" || entry.Content == "" || entry.FetchedAt <= 0 {
continue
}
if _, remote, err := common.ParseRemoteRoutingURL(entry.Source); err != nil || !remote {
continue
}
normalized, clash, err := normalizeRemoteRoutingContent(kind, []byte(entry.Content))
if err != nil {
continue
}
if kind == remoteRoutingHapp && len(normalized) > remoteRoutingHappMaxValue {
continue
}
entry.Content = normalized
entry.Clash = clash
loaded[remoteRoutingKey{kind: kind, source: entry.Source}] = entry
}
r.mu.Lock()
for key, entry := range loaded {
current, exists := r.entries[key]
if !exists || entry.FetchedAt > current.FetchedAt {
r.entries[key] = entry
}
}
r.mu.Unlock()
}
func (r *remoteRoutingResolver) persistEntry(kind remoteRoutingKind, entry remoteRoutingCacheEntry) {
db := database.GetDB()
if db == nil {
return
}
encoded, err := json.Marshal(entry)
if err != nil {
return
}
key := remoteRoutingSettingKey(kind)
var setting model.Setting
err = db.Where("key = ?", key).First(&setting).Error
if database.IsNotFound(err) {
err = db.Create(&model.Setting{Key: key, Value: string(encoded)}).Error
} else if err == nil {
setting.Value = string(encoded)
err = db.Save(&setting).Error
}
if err != nil {
logger.Warningf("Could not persist the last valid %s remote routing value", kind)
}
}
-750
View File
@@ -1,750 +0,0 @@
package sub
import (
"encoding/base64"
"errors"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/gin-gonic/gin"
yaml "github.com/goccy/go-yaml"
"github.com/mhsanaei/3x-ui/v3/internal/database"
)
func mergeRemoteClashRulesYAML(base map[string]any, raw string) error {
var remote map[string]any
if err := yaml.Unmarshal([]byte(strings.TrimSpace(raw)), &remote); err != nil {
return err
}
return mergeRemoteClashRules(base, remote)
}
type remoteRoutingRoundTripper func(*http.Request) (*http.Response, error)
func (fn remoteRoutingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
return fn(req)
}
func remoteRoutingTestClient(fn remoteRoutingRoundTripper) *http.Client {
return &http.Client{Transport: fn}
}
func remoteRoutingResponse(status int, body string) *http.Response {
return &http.Response{
StatusCode: status,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(body)),
}
}
func waitRemoteRoutingIdle(t *testing.T, resolver *remoteRoutingResolver) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for {
resolver.mu.Lock()
inflight := len(resolver.inflight)
resolver.mu.Unlock()
if inflight == 0 {
return
}
if time.Now().After(deadline) {
t.Fatal("remote routing refresh did not finish")
}
time.Sleep(time.Millisecond)
}
}
func waitRemoteRoutingLoadIdle(t *testing.T, resolver *remoteRoutingResolver) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for {
resolver.mu.Lock()
loading := resolver.loadInFlight
resolver.mu.Unlock()
if !loading {
return
}
if time.Now().After(deadline) {
t.Fatal("persisted routing cache load did not finish")
}
time.Sleep(time.Millisecond)
}
}
func primeRemoteRouting(t *testing.T, resolver *remoteRoutingResolver, kind remoteRoutingKind, source string) string {
t.Helper()
if err := resolver.refreshSource(kind, source); err != nil {
t.Fatalf("prime remote routing: %v", err)
}
value, remote, err := resolver.resolve(kind, source)
if err != nil || !remote || value == "" {
t.Fatalf("primed resolve got=%q remote=%v err=%v", value, remote, err)
}
return value
}
func TestNormalizeHappRoutingAcceptsJSONAndDeeplink(t *testing.T) {
deeplink, err := normalizeHappRouting([]byte(`{"Name":"RoscomVPN","GlobalProxy":"true"}`))
if err != nil {
t.Fatalf("normalize JSON: %v", err)
}
const prefix = "happ://routing/onadd/"
if !strings.HasPrefix(deeplink, prefix) {
t.Fatalf("deeplink = %q", deeplink)
}
decoded, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(deeplink, prefix))
if err != nil || !strings.Contains(string(decoded), `"Name":"RoscomVPN"`) {
t.Fatalf("decoded payload = %q, err=%v", decoded, err)
}
if got, err := normalizeHappRouting([]byte(deeplink + "\n")); err != nil || got != deeplink {
t.Fatalf("ready deeplink got=%q err=%v", got, err)
}
if _, err := normalizeHappRouting([]byte("routing.help")); err == nil {
t.Fatal("invalid Happ response was accepted")
}
}
func TestRemoteRoutingResolverAcceptsHappRedirect(t *testing.T) {
deeplink, err := normalizeHappRouting([]byte(`{"Name":"redirected"}`))
if err != nil {
t.Fatalf("normalize: %v", err)
}
var requests atomic.Int32
client := remoteRoutingTestClient(func(req *http.Request) (*http.Response, error) {
requests.Add(1)
response := remoteRoutingResponse(http.StatusFound, "")
response.Header.Set("Location", deeplink)
response.Request = req
return response, nil
})
client.CheckRedirect = checkRemoteRoutingRedirect
resolver := newRemoteRoutingResolver(client, false)
const source = "https://routing.example/"
if err := resolver.refreshSource(remoteRoutingHapp, source); err != nil {
t.Fatalf("refresh redirect: %v", err)
}
got, remote, err := resolver.resolve(remoteRoutingHapp, source)
if err != nil || !remote || got != deeplink {
t.Fatalf("redirect resolve got=%q remote=%v err=%v", got, remote, err)
}
if requests.Load() != 1 {
t.Fatalf("network requests = %d, want 1", requests.Load())
}
}
func TestRemoteRoutingResolverHandlesHappNotModified(t *testing.T) {
var requests atomic.Int32
client := remoteRoutingTestClient(func(req *http.Request) (*http.Response, error) {
if requests.Add(1) == 1 {
response := remoteRoutingResponse(http.StatusOK, `{"Name":"etagged"}`)
response.Header.Set("ETag", `"v1"`)
return response, nil
}
if req.Header.Get("If-None-Match") != `"v1"` {
t.Errorf("If-None-Match = %q", req.Header.Get("If-None-Match"))
}
return remoteRoutingResponse(http.StatusNotModified, ""), nil
})
resolver := newRemoteRoutingResolver(client, false)
now := time.Unix(1_800_000_000, 0)
resolver.now = func() time.Time { return now }
const source = "https://example.com/default.json"
first := primeRemoteRouting(t, resolver, remoteRoutingHapp, source)
now = now.Add(remoteRoutingCacheTTL + time.Second)
second, _, err := resolver.resolve(remoteRoutingHapp, source)
if err != nil || second != first {
t.Fatalf("stale resolve got=%q err=%v", second, err)
}
waitRemoteRoutingIdle(t, resolver)
now = now.Add(time.Minute)
third, _, err := resolver.resolve(remoteRoutingHapp, source)
if err != nil || third != first {
t.Fatalf("refreshed cache got=%q err=%v", third, err)
}
if requests.Load() != 2 {
t.Fatalf("requests = %d, want 2", requests.Load())
}
}
func TestRemoteRoutingResolverDoesNotBlockAndCoalescesColdFetch(t *testing.T) {
var requests atomic.Int32
started := make(chan struct{})
release := make(chan struct{})
var startOnce sync.Once
client := remoteRoutingTestClient(func(*http.Request) (*http.Response, error) {
requests.Add(1)
startOnce.Do(func() { close(started) })
<-release
return remoteRoutingResponse(http.StatusOK, `{"Name":"RoscomVPN"}`), nil
})
resolver := newRemoteRoutingResolver(client, false)
const source = "https://example.com/default.json"
results := make(chan error, 8)
for range 8 {
go func() {
_, remote, err := resolver.resolve(remoteRoutingHapp, source)
if !remote {
results <- errors.New("source was not classified as remote")
return
}
results <- err
}()
}
<-started
for range 8 {
select {
case err := <-results:
if !errors.Is(err, errRemoteRoutingUnavailable) {
t.Fatalf("cold resolve err=%v", err)
}
case <-time.After(100 * time.Millisecond):
t.Fatal("cold resolve blocked on the remote fetch")
}
}
if got := requests.Load(); got != 1 {
t.Fatalf("requests = %d, want 1", got)
}
close(release)
waitRemoteRoutingIdle(t, resolver)
if got, _, err := resolver.resolve(remoteRoutingHapp, source); err != nil || !strings.HasPrefix(got, "happ://routing/onadd/") {
t.Fatalf("cached resolve got=%q err=%v", got, err)
}
if got := requests.Load(); got != 1 {
t.Fatalf("cached request count = %d, want 1", got)
}
}
func TestRemoteRoutingResolverServesStaleAfterFailedRefresh(t *testing.T) {
var requests atomic.Int32
refreshStarted := make(chan struct{})
releaseRefresh := make(chan struct{})
var startOnce sync.Once
fail := atomic.Bool{}
client := remoteRoutingTestClient(func(*http.Request) (*http.Response, error) {
requests.Add(1)
if fail.Load() {
startOnce.Do(func() { close(refreshStarted) })
<-releaseRefresh
return remoteRoutingResponse(http.StatusBadGateway, "bad gateway"), nil
}
return remoteRoutingResponse(http.StatusOK, `{"Name":"last-good"}`), nil
})
resolver := newRemoteRoutingResolver(client, false)
now := time.Unix(1_800_000_000, 0)
resolver.now = func() time.Time { return now }
const source = "https://example.com/default.json"
first := primeRemoteRouting(t, resolver, remoteRoutingHapp, source)
fail.Store(true)
now = now.Add(remoteRoutingCacheTTL + time.Second)
startedAt := time.Now()
stale, remote, err := resolver.resolve(remoteRoutingHapp, source)
if err != nil || !remote || stale != first {
t.Fatalf("stale resolve got=%q remote=%v err=%v", stale, remote, err)
}
if elapsed := time.Since(startedAt); elapsed > 100*time.Millisecond {
t.Fatalf("stale resolve blocked for %v", elapsed)
}
select {
case <-refreshStarted:
case <-time.After(time.Second):
t.Fatal("refresh did not run")
}
close(releaseRefresh)
waitRemoteRoutingIdle(t, resolver)
if got, _, err := resolver.resolve(remoteRoutingHapp, source); err != nil || got != first {
t.Fatalf("negative-cache resolve got=%q err=%v", got, err)
}
if got := requests.Load(); got != 2 {
t.Fatalf("requests = %d, want 2", got)
}
}
func TestRemoteRoutingResolverLoadsPersistedLastGood(t *testing.T) {
initSubDB(t)
deeplink, err := normalizeHappRouting([]byte(`{"Name":"persisted"}`))
if err != nil {
t.Fatalf("normalize: %v", err)
}
const source = "https://example.com/default.json"
entry := remoteRoutingCacheEntry{
Source: source, Content: deeplink, FetchedAt: time.Now().Add(-time.Hour).Unix(), ETag: `"v1"`,
}
newRemoteRoutingResolver(nil, false).persistEntry(remoteRoutingHapp, entry)
resolver := newRemoteRoutingResolver(remoteRoutingTestClient(func(*http.Request) (*http.Response, error) {
return remoteRoutingResponse(http.StatusServiceUnavailable, "offline"), nil
}), true)
resolver.ensurePersistedLoaded()
got, remote, err := resolver.resolve(remoteRoutingHapp, source)
if err != nil || !remote || got != deeplink {
t.Fatalf("persisted resolve got=%q remote=%v err=%v", got, remote, err)
}
waitRemoteRoutingIdle(t, resolver)
}
func TestRemoteRoutingResolverDoesNotBlockOnPersistedLoad(t *testing.T) {
started := make(chan struct{})
release := make(chan struct{})
var startOnce sync.Once
resolver := newRemoteRoutingResolver(remoteRoutingTestClient(func(*http.Request) (*http.Response, error) {
startOnce.Do(func() { close(started) })
<-release
return remoteRoutingResponse(http.StatusServiceUnavailable, "offline"), nil
}), true)
resolver.loadMu.Lock()
loadLocked := true
t.Cleanup(func() {
if loadLocked {
resolver.loadMu.Unlock()
}
})
startedAt := time.Now()
_, remote, err := resolver.resolve(remoteRoutingHapp, "https://example.com/default.json")
if !remote || !errors.Is(err, errRemoteRoutingUnavailable) {
t.Fatalf("resolve remote=%v err=%v", remote, err)
}
if elapsed := time.Since(startedAt); elapsed > 100*time.Millisecond {
t.Fatalf("resolve blocked on persisted cache load for %v", elapsed)
}
resolver.loadMu.Unlock()
loadLocked = false
close(release)
select {
case <-started:
case <-time.After(time.Second):
t.Fatal("background refresh did not start")
}
waitRemoteRoutingIdle(t, resolver)
waitRemoteRoutingLoadIdle(t, resolver)
}
func TestRemoteRoutingResolverRejectsOversizedPersistedHappValue(t *testing.T) {
initSubDB(t)
deeplink, err := normalizeHappRouting([]byte(`{"Name":"` + strings.Repeat("x", remoteRoutingHappMaxValue) + `"}`))
if err != nil || len(deeplink) <= remoteRoutingHappMaxValue {
t.Fatalf("oversized fixture length=%d err=%v", len(deeplink), err)
}
const source = "https://example.com/oversized.json"
newRemoteRoutingResolver(nil, false).persistEntry(remoteRoutingHapp, remoteRoutingCacheEntry{
Source: source, Content: deeplink, FetchedAt: time.Now().Unix(),
})
resolver := newRemoteRoutingResolver(nil, true)
resolver.ensurePersistedLoaded()
resolver.mu.Lock()
_, exists := resolver.entries[remoteRoutingKey{kind: remoteRoutingHapp, source: source}]
resolver.mu.Unlock()
if exists {
t.Fatal("oversized persisted Happ routing value was loaded")
}
}
func TestRemoteRoutingResolverDoesNotReplaceClashCacheWithInvalidSchema(t *testing.T) {
var requests atomic.Int32
client := remoteRoutingTestClient(func(*http.Request) (*http.Response, error) {
if requests.Add(1) == 1 {
return remoteRoutingResponse(http.StatusOK, "rules:\n - MATCH,PROXY\n"), nil
}
return remoteRoutingResponse(http.StatusOK, "rules: not-a-list\n"), nil
})
resolver := newRemoteRoutingResolver(client, false)
now := time.Unix(1_800_000_000, 0)
resolver.now = func() time.Time { return now }
const source = "https://example.com/routing.yaml"
first := primeRemoteRouting(t, resolver, remoteRoutingClash, source)
now = now.Add(remoteRoutingCacheTTL + time.Second)
second, _, err := resolver.resolve(remoteRoutingClash, source)
if err != nil || second != first {
t.Fatalf("invalid refresh replaced last-good: got=%q err=%v", second, err)
}
waitRemoteRoutingIdle(t, resolver)
second, _, err = resolver.resolve(remoteRoutingClash, source)
if err != nil || second != first {
t.Fatalf("invalid refresh replaced last-good after completion: got=%q err=%v", second, err)
}
if requests.Load() != 2 {
t.Fatalf("requests = %d, want 2", requests.Load())
}
}
func TestApplyCommonHeadersResolvesRemoteHappAndFailsClosed(t *testing.T) {
gin.SetMode(gin.TestMode)
oldResolver := routingSourceResolver
t.Cleanup(func() { routingSourceResolver = oldResolver })
routingSourceResolver = newRemoteRoutingResolver(remoteRoutingTestClient(func(*http.Request) (*http.Response, error) {
return remoteRoutingResponse(http.StatusOK, `{"Name":"RoscomVPN"}`), nil
}), false)
const source = "https://example.com/default.json"
primeRemoteRouting(t, routingSourceResolver, remoteRoutingHapp, source)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
(&SUBController{}).ApplyCommonHeaders(ctx, "", "12", "", "", "", "", true, source, false)
if recorder.Header().Get("Routing-Enable") != "true" || !strings.HasPrefix(recorder.Header().Get("Routing"), "happ://routing/onadd/") {
t.Fatalf("headers = %#v", recorder.Header())
}
recorder = httptest.NewRecorder()
ctx, _ = gin.CreateTestContext(recorder)
(&SUBController{}).ApplyCommonHeaders(ctx, "", "12", "", "", "", "", false, source, false)
if recorder.Header().Get("Routing-Enable") != "" || !strings.HasPrefix(recorder.Header().Get("Routing"), "happ://routing/onadd/") {
t.Fatalf("independent routing headers = %#v", recorder.Header())
}
routingSourceResolver = newRemoteRoutingResolver(remoteRoutingTestClient(func(*http.Request) (*http.Response, error) {
return remoteRoutingResponse(http.StatusOK, "routing.help"), nil
}), false)
recorder = httptest.NewRecorder()
ctx, _ = gin.CreateTestContext(recorder)
(&SUBController{}).ApplyCommonHeaders(ctx, "", "12", "", "", "", "", true, "https://example.com/bad", false)
if recorder.Header().Get("Routing-Enable") != "true" || recorder.Header().Get("Routing") != "" {
t.Fatalf("invalid remote source leaked routing headers: %#v", recorder.Header())
}
waitRemoteRoutingIdle(t, routingSourceResolver)
}
func TestResolveIncyRemoteSourceUsesAutorouting(t *testing.T) {
got, remote, err := resolveIncyRoutingSource("https://example.com/DEFAULT.JSON")
if err != nil || !remote || got != "incy://autorouting/onadd/https://example.com/DEFAULT.JSON" {
t.Fatalf("got=%q remote=%v err=%v", got, remote, err)
}
inline := "incy://routing/onadd/abc"
if got, remote, err := resolveIncyRoutingSource(inline); err != nil || remote || got != inline {
t.Fatalf("inline got=%q remote=%v err=%v", got, remote, err)
}
}
func TestMergeRemoteClashRulesPreservesGeneratedProxies(t *testing.T) {
originalProxy := map[string]any{"name": "vpn-node", "type": "vless"}
base := map[string]any{
"proxies": []map[string]any{originalProxy},
"proxy-groups": []map[string]any{{
"name": "PROXY", "type": "select", "proxies": []string{"vpn-node", "DIRECT"},
}},
"rules": []string{"MATCH,PROXY"},
}
remote := `
proxies:
- name: attacker-controlled
proxy-providers:
prov:
url: <SUBSCRIPTION PLACEHOLDER>
external-controller: 0.0.0.0:9090
allow-lan: true
mixed-port: 7890
dns:
enable: true
tun:
enable: true
proxy-groups:
- name: VPN
type: select
include-all: true
- name: PROXY
type: select
proxies: [VPN]
rule-providers:
roscom:
type: http
url: https://example.com/rules.mrs
rules:
- RULE-SET,roscom,PROXY
- MATCH,PROXY
`
if err := mergeRemoteClashRulesYAML(base, remote); err != nil {
t.Fatalf("merge: %v", err)
}
proxies, ok := base["proxies"].([]map[string]any)
if !ok || len(proxies) != 1 || proxies[0]["name"] != "vpn-node" {
t.Fatalf("generated proxies were replaced: %#v", base["proxies"])
}
if _, exists := base["proxy-providers"]; exists {
t.Fatal("remote proxy-providers were imported")
}
if _, exists := base["external-controller"]; exists {
t.Fatal("unsafe top-level key was imported")
}
for _, key := range []string{"allow-lan", "mixed-port", "dns", "tun"} {
if _, exists := base[key]; exists {
t.Fatalf("client-local key %q was imported", key)
}
}
if _, exists := base["rule-providers"]; !exists {
t.Fatal("rule-providers were not imported")
}
groups, ok := asAnySlice(base["proxy-groups"])
if !ok || len(groups) != 2 || clashProxyGroupName(groups[0]) != "VPN" || clashProxyGroupName(groups[1]) != "PROXY" {
t.Fatalf("proxy groups = %#v", base["proxy-groups"])
}
rules, ok := asAnySlice(base["rules"])
if !ok || len(rules) != 2 || rules[1] != "MATCH,PROXY" {
t.Fatalf("rules = %#v", base["rules"])
}
}
func TestMergeRemoteClashRulesKeepsBaseProxyGroupWhenRemoteOmitsIt(t *testing.T) {
base := map[string]any{
"proxies": []map[string]any{{"name": "vpn-node", "type": "vless"}},
"proxy-groups": []map[string]any{{
"name": "PROXY", "type": "select", "proxies": []string{"vpn-node", "DIRECT"},
}},
"rules": []string{"MATCH,PROXY"},
}
if err := mergeRemoteClashRulesYAML(base, `proxy-groups:
- name: Extra
type: select
proxies: [PROXY]
rules:
- MATCH,PROXY
`); err != nil {
t.Fatalf("merge: %v", err)
}
groups, ok := asAnySlice(base["proxy-groups"])
if !ok || len(groups) != 2 || clashProxyGroupName(groups[0]) != "Extra" || clashProxyGroupName(groups[1]) != "PROXY" {
t.Fatalf("proxy groups = %#v", base["proxy-groups"])
}
}
func TestRemoteRoutingRejectsOversizedHappValues(t *testing.T) {
largeJSON := `{"Name":"large","Rules":"` + strings.Repeat("a", remoteRoutingHappMaxValue) + `"}`
largeDeeplink, err := normalizeHappRouting([]byte(largeJSON))
if err != nil {
t.Fatalf("prepare large deeplink: %v", err)
}
tests := []struct {
name string
response func(*http.Request) *http.Response
wantErr string
}{
{
name: "response body",
response: func(*http.Request) *http.Response {
return remoteRoutingResponse(http.StatusOK, strings.Repeat("x", remoteRoutingHappMaxBody+1))
},
wantErr: "response exceeds the size limit",
},
{
name: "normalized header",
response: func(*http.Request) *http.Response {
return remoteRoutingResponse(http.StatusOK, largeJSON)
},
wantErr: "header exceeds the size limit",
},
{
name: "redirect header",
response: func(req *http.Request) *http.Response {
response := remoteRoutingResponse(http.StatusFound, "")
response.Header.Set("Location", largeDeeplink)
response.Request = req
return response
},
wantErr: "header exceeds the size limit",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client := remoteRoutingTestClient(func(req *http.Request) (*http.Response, error) {
return tt.response(req), nil
})
client.CheckRedirect = checkRemoteRoutingRedirect
resolver := newRemoteRoutingResolver(client, false)
err := resolver.refreshSource(remoteRoutingHapp, "https://example.com/rules")
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("err=%v, want %q", err, tt.wantErr)
}
})
}
}
func TestRemoteRoutingRefreshTurnsPanicsIntoErrors(t *testing.T) {
client := remoteRoutingTestClient(func(*http.Request) (*http.Response, error) {
panic("transport exploded")
})
resolver := newRemoteRoutingResolver(client, false)
err := resolver.refreshSource(remoteRoutingHapp, "https://example.com/rules")
if err == nil || !strings.Contains(err.Error(), "panicked") {
t.Fatalf("err=%v, want the panic converted into an error", err)
}
// The inflight slot must be released so later refreshes are not wedged.
waitRemoteRoutingIdle(t, resolver)
}
func TestRemoteRoutingHTTPClientRejectsLoopback(t *testing.T) {
resolver := newRemoteRoutingResolver(newRemoteRoutingHTTPClient(), false)
startedAt := time.Now()
err := resolver.refreshSource(remoteRoutingHapp, "https://127.0.0.1:1/rules")
if err == nil {
t.Fatal("loopback remote source was accepted")
}
if elapsed := time.Since(startedAt); elapsed > 2*time.Second {
t.Fatalf("loopback rejection took %v", elapsed)
}
}
func TestRemoteRoutingPersistedLoadRetriesAfterDatabaseBecomesReady(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "x-ui.db")
if err := database.InitDB(dbPath); err != nil {
t.Fatalf("init db: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
deeplink, err := normalizeHappRouting([]byte(`{"Name":"persisted-after-ready"}`))
if err != nil {
t.Fatalf("normalize: %v", err)
}
const source = "https://example.com/default.json"
newRemoteRoutingResolver(nil, false).persistEntry(remoteRoutingHapp, remoteRoutingCacheEntry{
Source: source, Content: deeplink, FetchedAt: time.Now().Unix(),
})
if err := database.CloseDB(); err != nil {
t.Fatalf("close db: %v", err)
}
resolver := newRemoteRoutingResolver(remoteRoutingTestClient(func(*http.Request) (*http.Response, error) {
return remoteRoutingResponse(http.StatusServiceUnavailable, "offline"), nil
}), true)
if _, _, err := resolver.resolve(remoteRoutingHapp, source); !errors.Is(err, errRemoteRoutingUnavailable) {
t.Fatalf("closed-db resolve err=%v", err)
}
waitRemoteRoutingIdle(t, resolver)
waitRemoteRoutingLoadIdle(t, resolver)
if err := database.InitDB(dbPath); err != nil {
t.Fatalf("reopen db: %v", err)
}
resolver.triggerPersistedLoad()
waitRemoteRoutingLoadIdle(t, resolver)
got, remote, err := resolver.resolve(remoteRoutingHapp, source)
if err != nil || !remote || got != deeplink {
t.Fatalf("reloaded resolve got=%q remote=%v err=%v", got, remote, err)
}
}
func TestRemoteClashRouteGraphValidation(t *testing.T) {
tests := []struct {
name string
remote string
wantErr string
}{
{
name: "missing group name",
remote: "proxy-groups:\n - type: select\n proxies: [vpn-node]\nrules:\n - MATCH,PROXY\n",
wantErr: "named group maps",
},
{
name: "duplicate group name",
remote: "proxy-groups:\n - {name: A, type: select, proxies: [vpn-node]}\n - {name: A, type: select, proxies: [vpn-node]}\nrules:\n - MATCH,A\n",
wantErr: "duplicated",
},
{
name: "unknown group reference",
remote: "proxy-groups:\n - {name: A, type: select, proxies: [missing]}\nrules:\n - MATCH,A\n",
wantErr: "unknown proxy or group",
},
{
name: "remote proxy provider use",
remote: "proxy-groups:\n - name: A\n type: select\n use: [manual-provider]\nrules:\n - MATCH,A\n",
wantErr: "cannot use proxy-providers",
},
{
name: "unknown rule provider",
remote: "proxy-groups:\n - {name: A, type: select, proxies: [vpn-node]}\nrules:\n - RULE-SET,missing,A\n - MATCH,A\n",
wantErr: "unknown rule-provider",
},
{
name: "unknown rule target",
remote: "proxy-groups:\n - {name: A, type: select, proxies: [vpn-node]}\nrules:\n - MATCH,missing\n",
wantErr: "unknown proxy or group",
},
{
name: "unknown provider download proxy",
remote: "proxy-groups:\n - {name: A, type: select, proxies: [vpn-node]}\nrule-providers:\n p: {type: http, url: https://example.com/p.mrs, proxy: missing}\nrules:\n - RULE-SET,p,A\n - MATCH,A\n",
wantErr: "rule-provider \"p\" references unknown",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
base := map[string]any{
"proxies": []map[string]any{{"name": "vpn-node", "type": "vless"}},
"proxy-groups": []map[string]any{{
"name": "PROXY", "type": "select", "proxies": []string{"vpn-node", "DIRECT"},
}},
"rules": []string{"MATCH,PROXY"},
}
err := mergeRemoteClashRulesYAML(base, tt.remote)
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("err=%v, want %q", err, tt.wantErr)
}
})
}
}
func TestRemoteClashRouteGraphAcceptsLogicalRulesAndCachedDocument(t *testing.T) {
const remote = `
proxy-groups:
- name: Auto
type: url-test
include-all: true
- name: Video
type: select
proxies: [Auto, DIRECT]
rule-providers:
video:
type: http
url: https://example.com/video.mrs
proxy: Auto
rules:
- RULE-SET,video,Video
- AND,((NETWORK,TCP),(DST-PORT,443)),Video
- GEOIP,private,DIRECT,no-resolve
- IP-CIDR,192.168.0.0/16,DIRECT,no-resolve,src
- MATCH,Auto
`
var requests atomic.Int32
resolver := newRemoteRoutingResolver(remoteRoutingTestClient(func(*http.Request) (*http.Response, error) {
requests.Add(1)
return remoteRoutingResponse(http.StatusOK, remote), nil
}), false)
const source = "https://example.com/routing.yaml"
if err := resolver.refreshSource(remoteRoutingClash, source); err != nil {
t.Fatalf("refresh: %v", err)
}
entry, remoteSource, err := resolver.resolveEntry(remoteRoutingClash, source)
if err != nil || !remoteSource || entry.Clash == nil {
t.Fatalf("entry remote=%v parsed=%v err=%v", remoteSource, entry.Clash != nil, err)
}
base := map[string]any{
"proxies": []map[string]any{{"name": "vpn-node", "type": "vless"}},
"proxy-groups": []map[string]any{{
"name": "PROXY", "type": "select", "proxies": []string{"vpn-node", "DIRECT"},
}},
"rules": []string{"MATCH,PROXY"},
}
if err := mergeRemoteClashRules(base, entry.Clash); err != nil {
t.Fatalf("merge cached document: %v", err)
}
if requests.Load() != 1 {
t.Fatalf("requests=%d, want 1", requests.Load())
}
}
-42
View File
@@ -145,11 +145,6 @@ func (s *Server) initRouter() (*gin.Engine, error) {
SubJsonMux = "" SubJsonMux = ""
} }
SubJsonRules, err := s.settingService.GetSubJsonRules()
if err != nil {
SubJsonRules = ""
}
SubJsonFinalMask, err := s.settingService.GetSubJsonFinalMask() SubJsonFinalMask, err := s.settingService.GetSubJsonFinalMask()
if err != nil { if err != nil {
SubJsonFinalMask = "" SubJsonFinalMask = ""
@@ -160,16 +155,6 @@ func (s *Server) initRouter() (*gin.Engine, error) {
SubJsonObservatory = "" SubJsonObservatory = ""
} }
SubClashEnableRouting, err := s.settingService.GetSubClashEnableRouting()
if err != nil {
SubClashEnableRouting = false
}
SubClashRules, err := s.settingService.GetSubClashRules()
if err != nil {
SubClashRules = ""
}
SubTitle, err := s.settingService.GetSubTitle() SubTitle, err := s.settingService.GetSubTitle()
if err != nil { if err != nil {
SubTitle = "" SubTitle = ""
@@ -190,31 +175,11 @@ func (s *Server) initRouter() (*gin.Engine, error) {
SubAnnounce = "" SubAnnounce = ""
} }
SubEnableRouting, err := s.settingService.GetSubEnableRouting()
if err != nil {
return nil, err
}
SubRoutingRules, err := s.settingService.GetSubRoutingRules()
if err != nil {
SubRoutingRules = ""
}
SubHideSettings, err := s.settingService.GetSubHideSettings() SubHideSettings, err := s.settingService.GetSubHideSettings()
if err != nil { if err != nil {
SubHideSettings = false SubHideSettings = false
} }
SubIncyEnableRouting, err := s.settingService.GetSubIncyEnableRouting()
if err != nil {
SubIncyEnableRouting = false
}
SubIncyRoutingRules, err := s.settingService.GetSubIncyRoutingRules()
if err != nil {
SubIncyRoutingRules = ""
}
// set per-request localizer from headers/cookies // set per-request localizer from headers/cookies
engine.Use(locale.LocalizerMiddleware()) engine.Use(locale.LocalizerMiddleware())
@@ -284,20 +249,13 @@ func (s *Server) initRouter() (*gin.Engine, error) {
WithSUBRemarkTemplate(RemarkTemplate), WithSUBRemarkTemplate(RemarkTemplate),
WithSUBUpdateInterval(SubUpdates), WithSUBUpdateInterval(SubUpdates),
WithSUBJsonMux(SubJsonMux), WithSUBJsonMux(SubJsonMux),
WithSUBJsonRules(SubJsonRules),
WithSUBJsonFinalMask(SubJsonFinalMask), WithSUBJsonFinalMask(SubJsonFinalMask),
WithSUBJsonObservatory(SubJsonObservatory), WithSUBJsonObservatory(SubJsonObservatory),
WithSUBClashEnableRouting(SubClashEnableRouting),
WithSUBClashRules(SubClashRules),
WithSUBTitle(SubTitle), WithSUBTitle(SubTitle),
WithSUBSupportURL(SubSupportUrl), WithSUBSupportURL(SubSupportUrl),
WithSUBProfileURL(SubProfileUrl), WithSUBProfileURL(SubProfileUrl),
WithSUBAnnounce(SubAnnounce), WithSUBAnnounce(SubAnnounce),
WithSUBEnableRouting(SubEnableRouting),
WithSUBRoutingRules(SubRoutingRules),
WithSUBHideSettings(SubHideSettings), WithSUBHideSettings(SubHideSettings),
WithSUBIncyEnableRouting(SubIncyEnableRouting),
WithSUBIncyRoutingRules(SubIncyRoutingRules),
) )
return engine, nil return engine, nil
@@ -44,7 +44,7 @@ func TestSubJson_BalancerMemberTagUsesProtocol(t *testing.T) {
Remark: "proto", Strategy: "random", InboundIds: []int{vm.Id}, SortOrder: 1, Enabled: true, Remark: "proto", Strategy: "random", InboundIds: []int{vm.Id}, SortOrder: 1, Enabled: true,
}) })
js := NewSubJsonService("", "", "", NewSubService("")) js := NewSubJsonService("", "", NewSubService(""))
out, _, err := js.GetJson("s1", "req.example.com", true) out, _, err := js.GetJson("s1", "req.example.com", true)
if err != nil { if err != nil {
t.Fatalf("GetJson: %v", err) t.Fatalf("GetJson: %v", err)
+11 -12
View File
@@ -56,8 +56,7 @@ func TestSubJson_BalancerDocument(t *testing.T) {
Remark: "auto", Strategy: "leastLoad", InboundIds: []int{tcp.Id, ws.Id}, SortOrder: 1, Enabled: true, Remark: "auto", Strategy: "leastLoad", InboundIds: []int{tcp.Id, ws.Id}, SortOrder: 1, Enabled: true,
}) })
rules := `[{"type":"field","domain":["geosite:example"],"outboundTag":"proxy"}]` js := NewSubJsonService("", "", NewSubService(""))
js := NewSubJsonService("", rules, "", NewSubService(""))
out, _, err := js.GetJson("s1", "req.example.com", true) out, _, err := js.GetJson("s1", "req.example.com", true)
if err != nil { if err != nil {
t.Fatalf("GetJson: %v", err) t.Fatalf("GetJson: %v", err)
@@ -103,8 +102,8 @@ func TestSubJson_BalancerDocument(t *testing.T) {
t.Fatalf("balancer catch-all rule missing balancerTag: %s", ruleJSON) t.Fatalf("balancer catch-all rule missing balancerTag: %s", ruleJSON)
} }
proxyRules := strings.Count(string(ruleJSON), `"balancerTag"`) proxyRules := strings.Count(string(ruleJSON), `"balancerTag"`)
if proxyRules != 2 { // custom rule + default catch-all if proxyRules != 1 { // default catch-all only (custom sub rules were removed)
t.Fatalf("balancerTag rules = %d, want 2: %s", proxyRules, ruleJSON) t.Fatalf("balancerTag rules = %d, want 1: %s", proxyRules, ruleJSON)
} }
observatory, _ := balancerDoc["burstObservatory"].(map[string]any) observatory, _ := balancerDoc["burstObservatory"].(map[string]any)
@@ -155,7 +154,7 @@ func TestSubJson_BalancerOrderInterleavesWithInbounds(t *testing.T) {
Remark: "bal", Strategy: "roundRobin", InboundIds: []int{later.Id, first.Id}, SortOrder: 1, Enabled: true, Remark: "bal", Strategy: "roundRobin", InboundIds: []int{later.Id, first.Id}, SortOrder: 1, Enabled: true,
}) })
js := NewSubJsonService("", "", "", NewSubService("")) js := NewSubJsonService("", "", NewSubService(""))
out, _, err := js.GetJson("s1", "req.example.com", true) out, _, err := js.GetJson("s1", "req.example.com", true)
if err != nil { if err != nil {
t.Fatalf("GetJson: %v", err) t.Fatalf("GetJson: %v", err)
@@ -189,7 +188,7 @@ func TestSubJson_BalancerDisabledAndEmptySkipped(t *testing.T) {
Remark: "nomembers", Strategy: "random", InboundIds: []int{inbound.Id + 100}, SortOrder: 1, Enabled: true, Remark: "nomembers", Strategy: "random", InboundIds: []int{inbound.Id + 100}, SortOrder: 1, Enabled: true,
}) })
js := NewSubJsonService("", "", "", NewSubService("")) js := NewSubJsonService("", "", NewSubService(""))
out, _, err := js.GetJson("s1", "req.example.com", true) out, _, err := js.GetJson("s1", "req.example.com", true)
if err != nil { if err != nil {
t.Fatalf("GetJson: %v", err) t.Fatalf("GetJson: %v", err)
@@ -213,7 +212,7 @@ func TestSubJson_BalancerTagDedup(t *testing.T) {
Remark: "dedup", Strategy: "leastPing", InboundIds: []int{a.Id, b.Id}, SortOrder: 1, Enabled: true, Remark: "dedup", Strategy: "leastPing", InboundIds: []int{a.Id, b.Id}, SortOrder: 1, Enabled: true,
}) })
js := NewSubJsonService("", "", "", NewSubService("")) js := NewSubJsonService("", "", NewSubService(""))
out, _, err := js.GetJson("s1", "req.example.com", true) out, _, err := js.GetJson("s1", "req.example.com", true)
if err != nil { if err != nil {
t.Fatalf("GetJson: %v", err) t.Fatalf("GetJson: %v", err)
@@ -241,7 +240,7 @@ func TestSubJson_BalancerObservatoryConditional(t *testing.T) {
Remark: "pinger", Strategy: "leastPing", InboundIds: []int{lp.Id}, SortOrder: 2, Enabled: true, Remark: "pinger", Strategy: "leastPing", InboundIds: []int{lp.Id}, SortOrder: 2, Enabled: true,
}) })
js := NewSubJsonService("", "", "", NewSubService("")) js := NewSubJsonService("", "", NewSubService(""))
js.SetObservatoryConfig(`{"destination":"https://probe.example/204","httpMethod":"GET","sampling":5}`) js.SetObservatoryConfig(`{"destination":"https://probe.example/204","httpMethod":"GET","sampling":5}`)
out, _, err := js.GetJson("s1", "req.example.com", true) out, _, err := js.GetJson("s1", "req.example.com", true)
if err != nil { if err != nil {
@@ -287,7 +286,7 @@ func TestSubJson_BalancerExcludesDisabledInbound(t *testing.T) {
Remark: "bal", Strategy: "random", InboundIds: []int{a.Id, b.Id}, SortOrder: 1, Enabled: true, Remark: "bal", Strategy: "random", InboundIds: []int{a.Id, b.Id}, SortOrder: 1, Enabled: true,
}) })
js := NewSubJsonService("", "", "", NewSubService("")) js := NewSubJsonService("", "", NewSubService(""))
out, _, err := js.GetJson("s1", "req.example.com", true) out, _, err := js.GetJson("s1", "req.example.com", true)
if err != nil { if err != nil {
t.Fatalf("GetJson: %v", err) t.Fatalf("GetJson: %v", err)
@@ -322,7 +321,7 @@ func TestSubJson_BalancerSkippedWhenAllMembersDisabled(t *testing.T) {
Remark: "empty", Strategy: "random", InboundIds: []int{only.Id}, SortOrder: 1, Enabled: true, Remark: "empty", Strategy: "random", InboundIds: []int{only.Id}, SortOrder: 1, Enabled: true,
}) })
js := NewSubJsonService("", "", "", NewSubService("")) js := NewSubJsonService("", "", NewSubService(""))
out, _, err := js.GetJson("s1", "req.example.com", true) out, _, err := js.GetJson("s1", "req.example.com", true)
if err != nil { if err != nil {
t.Fatalf("GetJson: %v", err) t.Fatalf("GetJson: %v", err)
@@ -345,7 +344,7 @@ func TestSubJson_BalancerObservatoryConnectivityDefaultEmpty(t *testing.T) {
Remark: "pinger", Strategy: "leastPing", InboundIds: []int{inb.Id}, SortOrder: 1, Enabled: true, Remark: "pinger", Strategy: "leastPing", InboundIds: []int{inb.Id}, SortOrder: 1, Enabled: true,
}) })
js := NewSubJsonService("", "", "", NewSubService("")) js := NewSubJsonService("", "", NewSubService(""))
out, _, err := js.GetJson("s1", "req.example.com", true) out, _, err := js.GetJson("s1", "req.example.com", true)
if err != nil { if err != nil {
t.Fatalf("GetJson: %v", err) t.Fatalf("GetJson: %v", err)
@@ -386,7 +385,7 @@ func TestSubJson_BalancerObservatoryAlwaysEmittedForProbingStrategies(t *testing
Remark: "pinger", Strategy: "leastPing", InboundIds: []int{a.Id, b.Id}, SortOrder: 1, Enabled: true, Remark: "pinger", Strategy: "leastPing", InboundIds: []int{a.Id, b.Id}, SortOrder: 1, Enabled: true,
}) })
js := NewSubJsonService("", "", "", NewSubService("")) js := NewSubJsonService("", "", NewSubService(""))
js.SetObservatoryConfig(`{"enabled":false}`) js.SetObservatoryConfig(`{"enabled":false}`)
out, _, err := js.GetJson("s1", "req.example.com", true) out, _, err := js.GetJson("s1", "req.example.com", true)
if err != nil { if err != nil {
+1 -1
View File
@@ -29,7 +29,7 @@ func TestSubJson_ObservatoryConfigInvalidValuesFallBack(t *testing.T) {
} }
for _, tc := range cases { for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
js := NewSubJsonService("", "", "", NewSubService("")) js := NewSubJsonService("", "", NewSubService(""))
js.SetObservatoryConfig(tc.cfg) js.SetObservatoryConfig(tc.cfg)
out, _, err := js.GetJson("s1", "req.example.com", true) out, _, err := js.GetJson("s1", "req.example.com", true)
if err != nil { if err != nil {
+5 -5
View File
@@ -68,7 +68,7 @@ func TestGetJsonToleratesHysteriaWithoutHysteriaSettings(t *testing.T) {
t.Fatalf("seed client_inbound: %v", err) t.Fatalf("seed client_inbound: %v", err)
} }
jsonService := NewSubJsonService("", "", "", NewSubService("")) jsonService := NewSubJsonService("", "", NewSubService(""))
out, _, err := jsonService.GetJson(subId, "sub.example.com", true) out, _, err := jsonService.GetJson(subId, "sub.example.com", true)
if err != nil { if err != nil {
t.Fatalf("GetJson: %v", err) t.Fatalf("GetJson: %v", err)
@@ -83,7 +83,7 @@ func TestGetJsonToleratesNonStringRealityShortId(t *testing.T) {
stream := `{"network":"tcp","security":"reality","realitySettings":{"serverNames":["sni.example.com"],"shortIds":[42],"settings":{"publicKey":"pk"}}}` stream := `{"network":"tcp","security":"reality","realitySettings":{"serverNames":["sni.example.com"],"shortIds":[42],"settings":{"publicKey":"pk"}}}`
seedSubInbound(t, "rlty1", "rlty", 46400, 1, stream) seedSubInbound(t, "rlty1", "rlty", 46400, 1, stream)
jsonService := NewSubJsonService("", "", "", NewSubService("")) jsonService := NewSubJsonService("", "", NewSubService(""))
out, _, err := jsonService.GetJson("rlty1", "sub.example.com", true) out, _, err := jsonService.GetJson("rlty1", "sub.example.com", true)
if err != nil { if err != nil {
t.Fatalf("GetJson: %v", err) t.Fatalf("GetJson: %v", err)
@@ -99,7 +99,7 @@ func TestGetClashEmitsPinnedCertSha256(t *testing.T) {
stream := `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"pin.sni","settings":{"pinnedPeerCertSha256":["` + pin + `"]}}}` stream := `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"pin.sni","settings":{"pinnedPeerCertSha256":["` + pin + `"]}}}`
seedSubInbound(t, "pin1", "pin", 46300, 1, stream) seedSubInbound(t, "pin1", "pin", 46300, 1, stream)
out, _, err := NewSubClashService(false, "", NewSubService("")).GetClash("pin1", "sub.example.com") out, _, err := NewSubClashService(NewSubService("")).GetClash("pin1", "sub.example.com")
if err != nil { if err != nil {
t.Fatalf("GetClash: %v", err) t.Fatalf("GetClash: %v", err)
} }
@@ -113,7 +113,7 @@ func TestJsonAndClashTolerateExternalProxyMissingPort(t *testing.T) {
stream := `{"network":"tcp","security":"none","externalProxy":[{"forceTls":"same","dest":"cdn.example.com"}]}` stream := `{"network":"tcp","security":"none","externalProxy":[{"forceTls":"same","dest":"cdn.example.com"}]}`
seedSubInbound(t, "extp1", "extp", 46500, 1, stream) seedSubInbound(t, "extp1", "extp", 46500, 1, stream)
jsonService := NewSubJsonService("", "", "", NewSubService("")) jsonService := NewSubJsonService("", "", NewSubService(""))
jsonOut, _, err := jsonService.GetJson("extp1", "sub.example.com", true) jsonOut, _, err := jsonService.GetJson("extp1", "sub.example.com", true)
if err != nil { if err != nil {
t.Fatalf("GetJson: %v", err) t.Fatalf("GetJson: %v", err)
@@ -122,7 +122,7 @@ func TestJsonAndClashTolerateExternalProxyMissingPort(t *testing.T) {
t.Fatal("GetJson returned empty for an externalProxy entry missing port") t.Fatal("GetJson returned empty for an externalProxy entry missing port")
} }
clashOut, _, err := NewSubClashService(false, "", NewSubService("")).GetClash("extp1", "sub.example.com") clashOut, _, err := NewSubClashService(NewSubService("")).GetClash("extp1", "sub.example.com")
if err != nil { if err != nil {
t.Fatalf("GetClash: %v", err) t.Fatalf("GetClash: %v", err)
} }
+1 -1
View File
@@ -209,7 +209,7 @@ func TestGetSubsScale(t *testing.T) {
t.Fatalf("GetSubs links = %d, want 3", len(links)) t.Fatalf("GetSubs links = %d, want 3", len(links))
} }
jsonSvc := NewSubJsonService("", "", "", &SubService{}) jsonSvc := NewSubJsonService("", "", &SubService{})
start = time.Now() start = time.Now()
for range reps { for range reps {
body, _, err := jsonSvc.GetJson(scaleTargetSubId, "sub.example.com", false) body, _, err := jsonSvc.GetJson(scaleTargetSubId, "sub.example.com", false)
+2 -2
View File
@@ -51,7 +51,7 @@ func TestSub_HostVlessRoute_JSON(t *testing.T) {
ib := seedSubInbound(t, "s1", "vrj", 4501, 1, wsTLSStream) ib := seedSubInbound(t, "s1", "vrj", 4501, 1, wsTLSStream)
seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "J", Address: "j.cdn.com", Port: 8443, Security: "tls", VlessRoute: "443"}) seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "J", Address: "j.cdn.com", Port: 8443, Security: "tls", VlessRoute: "443"})
js := NewSubJsonService("", "", "", NewSubService("")) js := NewSubJsonService("", "", NewSubService(""))
out, _, err := js.GetJson("s1", "req.example.com", false) out, _, err := js.GetJson("s1", "req.example.com", false)
if err != nil { if err != nil {
t.Fatalf("GetJson: %v", err) t.Fatalf("GetJson: %v", err)
@@ -69,7 +69,7 @@ func TestSub_HostVlessRoute_Clash(t *testing.T) {
ib := seedSubInbound(t, "s1", "vrc", 4502, 1, wsTLSStream) ib := seedSubInbound(t, "s1", "vrc", 4502, 1, wsTLSStream)
seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "C", Address: "c.cdn.com", Port: 8443, Security: "tls", VlessRoute: "443"}) seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "C", Address: "c.cdn.com", Port: 8443, Security: "tls", VlessRoute: "443"})
clash := NewSubClashService(false, "", NewSubService("")) clash := NewSubClashService(NewSubService(""))
yaml, _, err := clash.GetClash("s1", "req.example.com") yaml, _, err := clash.GetClash("s1", "req.example.com")
if err != nil { if err != nil {
t.Fatalf("GetClash: %v", err) t.Fatalf("GetClash: %v", err)
-2
View File
@@ -23,8 +23,6 @@ func TestJobCadencesAreValidCronSpecs(t *testing.T) {
"cadenceNodeHeartbeat": cadenceNodeHeartbeat, "cadenceNodeHeartbeat": cadenceNodeHeartbeat,
"cadenceNodeTraffic": cadenceNodeTraffic, "cadenceNodeTraffic": cadenceNodeTraffic,
"cadenceOutboundSub": cadenceOutboundSub, "cadenceOutboundSub": cadenceOutboundSub,
"cadenceRemoteRouting": cadenceRemoteRouting,
"cadenceCheckHash": cadenceCheckHash,
"cadenceCPUAlarm": cadenceCPUAlarm, "cadenceCPUAlarm": cadenceCPUAlarm,
} }
for name, spec := range cadences { for name, spec := range cadences {
-7
View File
@@ -51,10 +51,6 @@ type AllSetting struct {
SubSupportUrl string `json:"subSupportUrl" form:"subSupportUrl"` SubSupportUrl string `json:"subSupportUrl" form:"subSupportUrl"`
SubProfileUrl string `json:"subProfileUrl" form:"subProfileUrl"` SubProfileUrl string `json:"subProfileUrl" form:"subProfileUrl"`
SubAnnounce string `json:"subAnnounce" form:"subAnnounce"` SubAnnounce string `json:"subAnnounce" form:"subAnnounce"`
SubEnableRouting bool `json:"subEnableRouting" form:"subEnableRouting"`
SubRoutingRules string `json:"subRoutingRules" form:"subRoutingRules"`
SubIncyEnableRouting bool `json:"subIncyEnableRouting" form:"subIncyEnableRouting"`
SubIncyRoutingRules string `json:"subIncyRoutingRules" form:"subIncyRoutingRules"`
SubListen string `json:"subListen" form:"subListen"` SubListen string `json:"subListen" form:"subListen"`
SubPort int `json:"subPort" form:"subPort" validate:"gte=1,lte=65535"` SubPort int `json:"subPort" form:"subPort" validate:"gte=1,lte=65535"`
SubPath string `json:"subPath" form:"subPath"` SubPath string `json:"subPath" form:"subPath"`
@@ -70,10 +66,7 @@ type AllSetting struct {
SubClashEnable bool `json:"subClashEnable" form:"subClashEnable"` SubClashEnable bool `json:"subClashEnable" form:"subClashEnable"`
SubClashPath string `json:"subClashPath" form:"subClashPath"` SubClashPath string `json:"subClashPath" form:"subClashPath"`
SubClashURI string `json:"subClashURI" form:"subClashURI"` SubClashURI string `json:"subClashURI" form:"subClashURI"`
SubClashEnableRouting bool `json:"subClashEnableRouting" form:"subClashEnableRouting"`
SubClashRules string `json:"subClashRules" form:"subClashRules"`
SubJsonMux string `json:"subJsonMux" form:"subJsonMux"` SubJsonMux string `json:"subJsonMux" form:"subJsonMux"`
SubJsonRules string `json:"subJsonRules" form:"subJsonRules"`
SubJsonFinalMask string `json:"subJsonFinalMask" form:"subJsonFinalMask"` SubJsonFinalMask string `json:"subJsonFinalMask" form:"subJsonFinalMask"`
SubJsonObservatory string `json:"subJsonObservatory" form:"subJsonObservatory"` SubJsonObservatory string `json:"subJsonObservatory" form:"subJsonObservatory"`
SubThemeDir string `json:"subThemeDir" form:"subThemeDir"` SubThemeDir string `json:"subThemeDir" form:"subThemeDir"`
-31
View File
@@ -1,31 +0,0 @@
package job
import (
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/sub"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
)
// RemoteRoutingJob keeps remote Happ and Clash/Mihomo routing URLs warm: all
// network work runs here (cron + startup warm), never in a request handler.
type RemoteRoutingJob struct {
settingService service.SettingService
}
func NewRemoteRoutingJob() *RemoteRoutingJob {
return &RemoteRoutingJob{}
}
func (j *RemoteRoutingJob) Run() {
happ, err := j.settingService.GetSubRoutingRules()
if err != nil {
logger.Warning("Could not read Happ routing source:", err)
return
}
clash, err := j.settingService.GetSubClashRules()
if err != nil {
logger.Warning("Could not read Clash routing source:", err)
return
}
sub.RefreshRemoteRoutingSources(happ, clash)
}
-55
View File
@@ -81,11 +81,7 @@ var defaultValueMap = map[string]string{
"subSupportUrl": "", "subSupportUrl": "",
"subProfileUrl": "", "subProfileUrl": "",
"subAnnounce": "", "subAnnounce": "",
"subEnableRouting": "false",
"subRoutingRules": "",
"subHideSettings": "false", "subHideSettings": "false",
"subIncyEnableRouting": "false",
"subIncyRoutingRules": "",
"subListen": "", "subListen": "",
"subPort": "2096", "subPort": "2096",
"subPath": "/sub/", "subPath": "/sub/",
@@ -100,10 +96,7 @@ var defaultValueMap = map[string]string{
"subClashEnable": "false", "subClashEnable": "false",
"subClashPath": "/clash/", "subClashPath": "/clash/",
"subClashURI": "", "subClashURI": "",
"subClashEnableRouting": "false",
"subClashRules": "",
"subJsonMux": "", "subJsonMux": "",
"subJsonRules": "",
"subJsonFinalMask": "", "subJsonFinalMask": "",
"subJsonObservatory": "", "subJsonObservatory": "",
"subThemeDir": "", "subThemeDir": "",
@@ -623,26 +616,10 @@ func (s *SettingService) GetSubAnnounce() (string, error) {
return s.getString("subAnnounce") return s.getString("subAnnounce")
} }
func (s *SettingService) GetSubEnableRouting() (bool, error) {
return s.getBool("subEnableRouting")
}
func (s *SettingService) GetSubRoutingRules() (string, error) {
return s.getString("subRoutingRules")
}
func (s *SettingService) GetSubHideSettings() (bool, error) { func (s *SettingService) GetSubHideSettings() (bool, error) {
return s.getBool("subHideSettings") return s.getBool("subHideSettings")
} }
func (s *SettingService) GetSubIncyEnableRouting() (bool, error) {
return s.getBool("subIncyEnableRouting")
}
func (s *SettingService) GetSubIncyRoutingRules() (string, error) {
return s.getString("subIncyRoutingRules")
}
func (s *SettingService) GetSubListen() (string, error) { func (s *SettingService) GetSubListen() (string, error) {
return s.getString("subListen") return s.getString("subListen")
} }
@@ -711,22 +688,10 @@ func (s *SettingService) GetSubClashURI() (string, error) {
return s.getString("subClashURI") return s.getString("subClashURI")
} }
func (s *SettingService) GetSubClashEnableRouting() (bool, error) {
return s.getBool("subClashEnableRouting")
}
func (s *SettingService) GetSubClashRules() (string, error) {
return s.getString("subClashRules")
}
func (s *SettingService) GetSubJsonMux() (string, error) { func (s *SettingService) GetSubJsonMux() (string, error) {
return s.getString("subJsonMux") return s.getString("subJsonMux")
} }
func (s *SettingService) GetSubJsonRules() (string, error) {
return s.getString("subJsonRules")
}
func (s *SettingService) GetSubJsonFinalMask() (string, error) { func (s *SettingService) GetSubJsonFinalMask() (string, error) {
return s.getString("subJsonFinalMask") return s.getString("subJsonFinalMask")
} }
@@ -913,26 +878,6 @@ func validateSettingsURLs(allSetting *entity.AllSetting) error {
// the scheme instead of forcing SanitizeHTTPURL's http(s)-only rule. // the scheme instead of forcing SanitizeHTTPURL's http(s)-only rule.
allSetting.SubSupportUrl = common.EnsureURLScheme(allSetting.SubSupportUrl) allSetting.SubSupportUrl = common.EnsureURLScheme(allSetting.SubSupportUrl)
allSetting.SubProfileUrl = common.EnsureURLScheme(allSetting.SubProfileUrl) allSetting.SubProfileUrl = common.EnsureURLScheme(allSetting.SubProfileUrl)
for name, value := range map[string]*string{
"Happ routing source": &allSetting.SubRoutingRules,
"Clash/Mihomo routing source": &allSetting.SubClashRules,
"Incy routing source": &allSetting.SubIncyRoutingRules,
} {
if err := validateRemoteRoutingURLSetting(name, value); err != nil {
return err
}
}
return nil
}
func validateRemoteRoutingURLSetting(name string, value *string) error {
canonical, remote, err := common.ParseRemoteRoutingURL(*value)
if err != nil {
return common.NewError(name, err.Error())
}
if remote {
*value = canonical
}
return nil return nil
} }
@@ -128,8 +128,6 @@ func TestUpdateAllSettingPersistsClashSubscriptionSettings(t *testing.T) {
settings.SubClashEnable = true settings.SubClashEnable = true
settings.SubClashPath = "/clash-custom/" settings.SubClashPath = "/clash-custom/"
settings.SubClashURI = "https://subscriptions.example.com/clash-custom/" settings.SubClashURI = "https://subscriptions.example.com/clash-custom/"
settings.SubClashEnableRouting = true
settings.SubClashRules = "GEOIP,private,DIRECT"
if err := s.UpdateAllSetting(settings); err != nil { if err := s.UpdateAllSetting(settings); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -172,12 +170,6 @@ func TestUpdateAllSettingPersistsClashSubscriptionSettings(t *testing.T) {
if got.SubClashURI != "https://subscriptions.example.com/clash-custom/" { if got.SubClashURI != "https://subscriptions.example.com/clash-custom/" {
t.Fatalf("subClashURI = %q, want %q", got.SubClashURI, "https://subscriptions.example.com/clash-custom/") t.Fatalf("subClashURI = %q, want %q", got.SubClashURI, "https://subscriptions.example.com/clash-custom/")
} }
if !got.SubClashEnableRouting {
t.Fatal("subClashEnableRouting = false, want true")
}
if got.SubClashRules != "GEOIP,private,DIRECT" {
t.Fatalf("subClashRules = %q, want %q", got.SubClashRules, "GEOIP,private,DIRECT")
}
} }
func TestUpdateAllSettingRejectsInvalidClashUserAgentRegex(t *testing.T) { func TestUpdateAllSettingRejectsInvalidClashUserAgentRegex(t *testing.T) {
@@ -1,38 +0,0 @@
package service
import (
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
)
func TestValidateRemoteRoutingURLSettings(t *testing.T) {
tests := []struct {
name string
value string
want string
wantError string
}{
{name: "valid HTTPS", value: " https://example.com/rules#fragment ", want: "https://example.com/rules"},
{name: "credentials", value: "https://user:pass@example.com/rules", wantError: "must not contain URL credentials"},
{name: "missing host", value: "https:///rules", wantError: "absolute HTTPS URL"},
{name: "legacy HTTP stays inline", value: "http://example.com/rules", want: "http://example.com/rules"},
{name: "multiline Clash stays inline", value: "https://example.com/rules\nMATCH,PROXY", want: "https://example.com/rules\nMATCH,PROXY"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
settings := &entity.AllSetting{SubRoutingRules: tt.value}
err := validateSettingsURLs(settings)
if tt.wantError != "" {
if err == nil || !strings.Contains(err.Error(), tt.wantError) {
t.Fatalf("err=%v, want %q", err, tt.wantError)
}
return
}
if err != nil || settings.SubRoutingRules != tt.want {
t.Fatalf("value=%q err=%v", settings.SubRoutingRules, err)
}
})
}
}
-7
View File
@@ -295,9 +295,7 @@ const (
cadenceNodeTraffic = "@every 5s" cadenceNodeTraffic = "@every 5s"
cadenceOutboundSub = "@every 5m" cadenceOutboundSub = "@every 5m"
cadenceReapOrphans = "@every 5m" cadenceReapOrphans = "@every 5m"
cadenceRemoteRouting = "@every 5m"
cadenceXrayLogPrune = "@every 10m" cadenceXrayLogPrune = "@every 10m"
cadenceCheckHash = "@every 2m"
// cpu.Percent samples over a full minute (blocking), so a finer cadence just // cpu.Percent samples over a full minute (blocking), so a finer cadence just
// stacks overlapping samplers; subscribers rate-limit alerts to 1/min anyway. // stacks overlapping samplers; subscribers rate-limit alerts to 1/min anyway.
cadenceCPUAlarm = "@every 1m" cadenceCPUAlarm = "@every 1m"
@@ -348,11 +346,6 @@ func (s *Server) startTask(restartXray bool, loc *time.Location) {
_, _ = s.cron.AddJob(cadenceReapOrphans, job.NewReapSyncOrphansJob()) _, _ = s.cron.AddJob(cadenceReapOrphans, job.NewReapSyncOrphansJob())
// Warm permanent routing URLs immediately and refresh them outside the
// latency-sensitive subscription request path.
remoteRoutingJob := job.NewRemoteRoutingJob()
_, _ = s.cron.AddJob(cadenceRemoteRouting, remoteRoutingJob)
common.GoRecover("remote-routing-warm", remoteRoutingJob.Run)
// check client ips from log file every day // check client ips from log file every day
_, _ = s.cron.AddJob("@daily", job.NewClearLogsJob()) _, _ = s.cron.AddJob("@daily", job.NewClearLogsJob())