精简: 订阅极简输出: 去掉分流规则注入(Happ/Clash/Incy 远程规则源+remote_routing 拉取缓存+RemoteRoutingJob、Clash 远程规则合并簇、subJsonRules 注入); 保留多格式转换与默认极简模板
This commit is contained in:
@@ -1,26 +1,22 @@
|
||||
package sub
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"strings"
|
||||
|
||||
"github.com/goccy/go-json"
|
||||
yaml "github.com/goccy/go-yaml"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
|
||||
)
|
||||
|
||||
type SubClashService struct {
|
||||
enableRouting bool
|
||||
clashRules string
|
||||
SubService *SubService
|
||||
SubService *SubService
|
||||
}
|
||||
|
||||
func NewSubClashService(enableRouting bool, clashRules string, subService *SubService) *SubClashService {
|
||||
return &SubClashService{enableRouting: enableRouting, clashRules: clashRules, SubService: subService}
|
||||
func NewSubClashService(subService *SubService) *SubClashService {
|
||||
return &SubClashService{SubService: subService}
|
||||
}
|
||||
|
||||
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"},
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
@@ -789,340 +772,3 @@ func cloneMap(src map[string]any) map[string]any {
|
||||
maps.Copy(dst, src)
|
||||
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
@@ -45,16 +45,11 @@ type cachedSubTemplate struct {
|
||||
|
||||
// SUBController handles HTTP requests for subscription links and JSON configurations.
|
||||
type SUBController struct {
|
||||
subTitle string
|
||||
subSupportUrl string
|
||||
subProfileUrl string
|
||||
subAnnounce string
|
||||
subEnableRouting bool
|
||||
subRoutingRules string
|
||||
subHideSettings bool
|
||||
|
||||
subIncyEnableRouting bool
|
||||
subIncyRoutingRules string
|
||||
subTitle string
|
||||
subSupportUrl string
|
||||
subProfileUrl string
|
||||
subAnnounce string
|
||||
subHideSettings bool
|
||||
|
||||
subPath string
|
||||
subJsonPath string
|
||||
@@ -96,23 +91,15 @@ type subControllerConfig struct {
|
||||
remarkTemplate string
|
||||
updateInterval string
|
||||
|
||||
subJsonMux string
|
||||
subJsonRules string
|
||||
subJsonFinalMask string
|
||||
subJsonObservatory string
|
||||
subClashEnableRouting bool
|
||||
subClashRules string
|
||||
subJsonMux string
|
||||
subJsonFinalMask string
|
||||
subJsonObservatory string
|
||||
|
||||
subTitle string
|
||||
subSupportURL string
|
||||
subProfileURL string
|
||||
subAnnounce string
|
||||
subEnableRouting bool
|
||||
subRoutingRules string
|
||||
subHideSettings bool
|
||||
|
||||
subIncyEnableRouting bool
|
||||
subIncyRoutingRules string
|
||||
subTitle string
|
||||
subSupportURL string
|
||||
subProfileURL string
|
||||
subAnnounce string
|
||||
subHideSettings bool
|
||||
}
|
||||
|
||||
type SUBControllerOption func(*subControllerConfig)
|
||||
@@ -173,10 +160,6 @@ func WithSUBJsonMux(value string) SUBControllerOption {
|
||||
return func(config *subControllerConfig) { config.subJsonMux = value }
|
||||
}
|
||||
|
||||
func WithSUBJsonRules(value string) SUBControllerOption {
|
||||
return func(config *subControllerConfig) { config.subJsonRules = value }
|
||||
}
|
||||
|
||||
func WithSUBJsonFinalMask(value string) SUBControllerOption {
|
||||
return func(config *subControllerConfig) { config.subJsonFinalMask = value }
|
||||
}
|
||||
@@ -185,14 +168,6 @@ func WithSUBJsonObservatory(value string) SUBControllerOption {
|
||||
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 {
|
||||
return func(config *subControllerConfig) { config.subTitle = value }
|
||||
}
|
||||
@@ -209,26 +184,10 @@ func WithSUBAnnounce(value string) SUBControllerOption {
|
||||
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 {
|
||||
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 {
|
||||
return subControllerConfig{
|
||||
subPath: "/sub/",
|
||||
@@ -248,19 +207,14 @@ func NewSUBController(g *gin.RouterGroup, options ...SUBControllerOption) *SUBCo
|
||||
}
|
||||
|
||||
sub := NewSubService(config.remarkTemplate)
|
||||
subJsonSvc := NewSubJsonService(config.subJsonMux, config.subJsonRules, config.subJsonFinalMask, sub)
|
||||
subJsonSvc := NewSubJsonService(config.subJsonMux, config.subJsonFinalMask, sub)
|
||||
subJsonSvc.SetObservatoryConfig(config.subJsonObservatory)
|
||||
a := &SUBController{
|
||||
subTitle: config.subTitle,
|
||||
subSupportUrl: config.subSupportURL,
|
||||
subProfileUrl: config.subProfileURL,
|
||||
subAnnounce: config.subAnnounce,
|
||||
subEnableRouting: config.subEnableRouting,
|
||||
subRoutingRules: config.subRoutingRules,
|
||||
subHideSettings: config.subHideSettings,
|
||||
|
||||
subIncyEnableRouting: config.subIncyEnableRouting,
|
||||
subIncyRoutingRules: config.subIncyRoutingRules,
|
||||
subTitle: config.subTitle,
|
||||
subSupportUrl: config.subSupportURL,
|
||||
subProfileUrl: config.subProfileURL,
|
||||
subAnnounce: config.subAnnounce,
|
||||
subHideSettings: config.subHideSettings,
|
||||
|
||||
subPath: config.subPath,
|
||||
subJsonPath: config.subJsonPath,
|
||||
@@ -277,7 +231,7 @@ func NewSUBController(g *gin.RouterGroup, options ...SUBControllerOption) *SUBCo
|
||||
|
||||
subService: sub,
|
||||
subJsonService: subJsonSvc,
|
||||
subClashService: NewSubClashService(config.subClashEnableRouting, config.subClashRules, sub),
|
||||
subClashService: NewSubClashService(sub),
|
||||
|
||||
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)
|
||||
profileURL := fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
|
||||
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)
|
||||
|
||||
if a.subIncyEnableRouting && a.subIncyRoutingRules != "" {
|
||||
incyRules, _, err := resolveIncyRoutingSource(a.subIncyRoutingRules)
|
||||
if err == nil && strings.TrimSpace(incyRules) != "" {
|
||||
result.WriteString(incyRules)
|
||||
result.WriteString("\n")
|
||||
}
|
||||
}
|
||||
a.ApplyCommonHeaders(c, header, a.updateInterval, metadata.Title, metadata.SupportURL, metadata.ProfileURL, metadata.Announce, a.subHideSettings)
|
||||
|
||||
if a.subEncrypt {
|
||||
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
|
||||
}, 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 {
|
||||
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
|
||||
}, 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 {
|
||||
c.Writer.Header().Set("Content-Disposition", `attachment; filename="subscription.yaml"`)
|
||||
} else if metadata.Title != "" {
|
||||
@@ -817,8 +763,6 @@ func (a *SUBController) ApplyCommonHeaders(
|
||||
profileSupportUrl string,
|
||||
profileUrl string,
|
||||
profileAnnounce string,
|
||||
profileEnableRouting bool,
|
||||
profileRoutingRules string,
|
||||
profileHideSettings bool,
|
||||
) {
|
||||
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)))
|
||||
}
|
||||
|
||||
// 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 {
|
||||
c.Writer.Header().Set("Hide-Settings", "1")
|
||||
}
|
||||
|
||||
@@ -155,7 +155,7 @@ func TestLinkDisplayName(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"
|
||||
svc := NewSubClashService(false, "", NewSubService(""))
|
||||
svc := NewSubClashService(NewSubService(""))
|
||||
proxy := svc.clashProxyFromExternal(link, "DE-Provider")
|
||||
if proxy == nil {
|
||||
t.Fatal("expected a clash proxy, got nil")
|
||||
|
||||
@@ -28,7 +28,7 @@ func TestJsonAndClashServeExternalLinkOnlySub(t *testing.T) {
|
||||
|
||||
base := NewSubService("")
|
||||
|
||||
jsonService := NewSubJsonService("", "", "", base)
|
||||
jsonService := NewSubJsonService("", "", base)
|
||||
jsonOut, _, err := jsonService.GetJson("ext-only", "sub.example.com", false)
|
||||
if err != nil {
|
||||
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))
|
||||
}
|
||||
|
||||
clashOut, _, err := NewSubClashService(false, "", base).GetClash("ext-only", "sub.example.com")
|
||||
clashOut, _, err := NewSubClashService(base).GetClash("ext-only", "sub.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetClash err = %v", err)
|
||||
}
|
||||
|
||||
@@ -261,7 +261,7 @@ func TestSub_HostAllowInsecure(t *testing.T) {
|
||||
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")
|
||||
if err != nil {
|
||||
t.Fatalf("GetClash: %v", err)
|
||||
@@ -282,7 +282,7 @@ func TestSub_HostHeaderReachesClashAndJson(t *testing.T) {
|
||||
HostHeader: "cdn.example.com", Path: "/ws-path",
|
||||
})
|
||||
|
||||
clash := NewSubClashService(false, "", NewSubService(""))
|
||||
clash := NewSubClashService(NewSubService(""))
|
||||
yaml, _, err := clash.GetClash("s1", "req.example.com")
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
js := NewSubJsonService("", "", "", NewSubService(""))
|
||||
js := NewSubJsonService("", "", NewSubService(""))
|
||||
out, _, err := js.GetJson("s1", "req.example.com", false)
|
||||
if err != nil {
|
||||
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",
|
||||
SockoptParams: `{"tcpFastOpen":true}`,
|
||||
})
|
||||
js := NewSubJsonService("", "", "", NewSubService(""))
|
||||
js := NewSubJsonService("", "", NewSubService(""))
|
||||
out, _, err := js.GetJson("s1", "req.example.com", false)
|
||||
if err != nil {
|
||||
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",
|
||||
MuxParams: `{"enabled":true,"concurrency":8}`,
|
||||
})
|
||||
js := NewSubJsonService("", "", "", NewSubService(""))
|
||||
js := NewSubJsonService("", "", NewSubService(""))
|
||||
out, _, err := js.GetJson("s1", "req.example.com", false)
|
||||
if err != nil {
|
||||
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")
|
||||
}
|
||||
|
||||
clash := NewSubClashService(false, "", NewSubService(""))
|
||||
clash := NewSubClashService(NewSubService(""))
|
||||
yaml, _, err := clash.GetClash("s1", "req.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetClash: %v", err)
|
||||
|
||||
@@ -51,7 +51,7 @@ func TestSub_JSONStripsFlowOnUnsupportedTransport(t *testing.T) {
|
||||
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")
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
js := NewSubJsonService("", "", "", NewSubService(""))
|
||||
js := NewSubJsonService("", "", NewSubService(""))
|
||||
out, _, err := js.GetJson("s1", "req.example.com", false)
|
||||
if err != nil {
|
||||
t.Fatalf("GetJson: %v", err)
|
||||
@@ -76,7 +76,7 @@ func TestSub_JSONKeepsFlowOnTcpTLS(t *testing.T) {
|
||||
seedFlowInbound(t, "s1", "tcpflow", 4602,
|
||||
`{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"}}`)
|
||||
|
||||
js := NewSubJsonService("", "", "", NewSubService(""))
|
||||
js := NewSubJsonService("", "", NewSubService(""))
|
||||
out, _, err := js.GetJson("s1", "req.example.com", false)
|
||||
if err != nil {
|
||||
t.Fatalf("GetJson: %v", err)
|
||||
|
||||
@@ -34,7 +34,7 @@ type SubJsonService struct {
|
||||
}
|
||||
|
||||
// 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 defaultOutbounds []json_util.RawMessage
|
||||
_ = 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{
|
||||
configJson: configJson,
|
||||
defaultOutbounds: defaultOutbounds,
|
||||
|
||||
@@ -37,7 +37,7 @@ func outboundSettings(t *testing.T, raw []byte) map[string]any {
|
||||
|
||||
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"}}`
|
||||
svc := NewSubJsonService("", "", finalMask, nil)
|
||||
svc := NewSubJsonService("", finalMask, nil)
|
||||
|
||||
if hasDirectOutOutbound(svc) {
|
||||
t.Fatal("direct_out outbound must never be emitted")
|
||||
@@ -74,7 +74,7 @@ func TestSubJsonServiceInjectsGlobalFinalMask(t *testing.T) {
|
||||
|
||||
func TestSubJsonServiceMergesWithExistingFinalMask(t *testing.T) {
|
||||
finalMask := `{"tcp":[{"type":"fragment","settings":{"packets":"tlshello"}}]}`
|
||||
svc := NewSubJsonService("", "", finalMask, nil)
|
||||
svc := NewSubJsonService("", finalMask, nil)
|
||||
|
||||
stream := svc.streamData(`{
|
||||
"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}},
|
||||
@@ -94,7 +94,7 @@ func TestSubJsonServiceMergesWithExistingFinalMask(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"}}}`, "")
|
||||
if _, ok := stream["finalmask"]; ok {
|
||||
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
|
||||
// to import the config (#5401).
|
||||
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"]}}}`, "")
|
||||
|
||||
tls, _ := stream["tlsSettings"].(map[string]any)
|
||||
@@ -121,7 +121,7 @@ func TestSubJsonServicePinnedCertJoinedToString(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":{}}}`, "")
|
||||
|
||||
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"}`}
|
||||
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 {
|
||||
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}
|
||||
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 {
|
||||
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: `{}`}
|
||||
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 {
|
||||
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: `{}`}
|
||||
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)
|
||||
if server == nil {
|
||||
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"}`}
|
||||
ssSettings := outboundSettings(t, NewSubJsonService("", "", "", nil).genServer(&SubService{}, ss, nil, client, ""))
|
||||
ssSettings := outboundSettings(t, NewSubJsonService("", "", nil).genServer(&SubService{}, ss, nil, client, ""))
|
||||
ssServer := firstServer(ssSettings)
|
||||
if ssServer == nil {
|
||||
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) {
|
||||
globalMux := `{"enabled":true,"concurrency":8}`
|
||||
svc := NewSubJsonService(globalMux, "", "", nil)
|
||||
svc := NewSubJsonService(globalMux, "", nil)
|
||||
|
||||
// When xmux is present in xhttpSettings, the per-inbound xmux handles
|
||||
// multiplexing and the legacy outbound.Mux must NOT be set.
|
||||
@@ -251,7 +251,7 @@ func TestSubJsonServiceXmuxSuppressesGlobalMux(t *testing.T) {
|
||||
|
||||
func TestSubJsonServiceGlobalMuxWhenNoXmux(t *testing.T) {
|
||||
globalMux := `{"enabled":true,"concurrency":8}`
|
||||
svc := NewSubJsonService(globalMux, "", "", nil)
|
||||
svc := NewSubJsonService(globalMux, "", nil)
|
||||
|
||||
// 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"}}`
|
||||
@@ -305,7 +305,7 @@ func realitySpiderXFromStream(t *testing.T, svc *SubJsonService, clientKey strin
|
||||
}
|
||||
|
||||
func TestSubJsonServiceRealityDataDerivesPerClientSpiderX(t *testing.T) {
|
||||
svc := NewSubJsonService("", "", "", nil)
|
||||
svc := NewSubJsonService("", "", nil)
|
||||
|
||||
alice := realitySpiderXFromStream(t, svc, "subAlice")
|
||||
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
|
||||
// subscription request.
|
||||
func TestSubJsonServiceStreamDataMalformedInputs(t *testing.T) {
|
||||
withMask := NewSubJsonService("", "", `{"tcp":[{"type":"fragment"}]}`, nil)
|
||||
withMask := NewSubJsonService("", `{"tcp":[{"type":"fragment"}]}`, nil)
|
||||
stream := withMask.streamData("not-json", "clientKey")
|
||||
if _, ok := stream["finalmask"]; !ok {
|
||||
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")
|
||||
if v, ok := noReality["realitySettings"]; ok {
|
||||
t.Fatalf("missing realitySettings must stay absent, got %v", v)
|
||||
@@ -339,7 +339,7 @@ func TestSubJsonServiceStreamDataMalformedInputs(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSubJsonServiceRealityDataSpiderXFallsBackWhenNoClientKey(t *testing.T) {
|
||||
svc := NewSubJsonService("", "", "", nil)
|
||||
svc := NewSubJsonService("", "", nil)
|
||||
|
||||
stream := svc.streamData(`{
|
||||
"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"},
|
||||
}
|
||||
|
||||
raw := NewSubJsonService("", "", "", nil).genWireguard(inbound, client)
|
||||
raw := NewSubJsonService("", "", nil).genWireguard(inbound, client)
|
||||
if raw == nil {
|
||||
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: `{}`}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,29 +25,9 @@ func initMutDB(t *testing.T) {
|
||||
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) {
|
||||
svc := NewSubJsonService("", "", "", nil)
|
||||
svc := NewSubJsonService("", "", nil)
|
||||
routing, _ := svc.configJson["routing"].(map[string]any)
|
||||
got, _ := routing["rules"].([]any)
|
||||
if len(got) != 1 {
|
||||
@@ -67,12 +47,12 @@ func TestSubJsonService_MuxAttachedWhenConfigured(t *testing.T) {
|
||||
wantMux bool
|
||||
protocol model.Protocol
|
||||
}{
|
||||
{"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},
|
||||
{"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},
|
||||
{"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},
|
||||
{"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},
|
||||
{"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},
|
||||
{"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},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
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
|
||||
// key is an empty tcp slice, which mergeFinalMask drops → merged is empty,
|
||||
// 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"}}}`, "")
|
||||
if _, ok := stream["finalmask"]; ok {
|
||||
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
|
||||
// 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"}}}`, "")
|
||||
if _, ok := stream2["finalmask"]; !ok {
|
||||
t.Fatal("non-empty finalMask must be set")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -145,11 +145,6 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
||||
SubJsonMux = ""
|
||||
}
|
||||
|
||||
SubJsonRules, err := s.settingService.GetSubJsonRules()
|
||||
if err != nil {
|
||||
SubJsonRules = ""
|
||||
}
|
||||
|
||||
SubJsonFinalMask, err := s.settingService.GetSubJsonFinalMask()
|
||||
if err != nil {
|
||||
SubJsonFinalMask = ""
|
||||
@@ -160,16 +155,6 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
||||
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()
|
||||
if err != nil {
|
||||
SubTitle = ""
|
||||
@@ -190,31 +175,11 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
||||
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()
|
||||
if err != nil {
|
||||
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
|
||||
engine.Use(locale.LocalizerMiddleware())
|
||||
|
||||
@@ -284,20 +249,13 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
||||
WithSUBRemarkTemplate(RemarkTemplate),
|
||||
WithSUBUpdateInterval(SubUpdates),
|
||||
WithSUBJsonMux(SubJsonMux),
|
||||
WithSUBJsonRules(SubJsonRules),
|
||||
WithSUBJsonFinalMask(SubJsonFinalMask),
|
||||
WithSUBJsonObservatory(SubJsonObservatory),
|
||||
WithSUBClashEnableRouting(SubClashEnableRouting),
|
||||
WithSUBClashRules(SubClashRules),
|
||||
WithSUBTitle(SubTitle),
|
||||
WithSUBSupportURL(SubSupportUrl),
|
||||
WithSUBProfileURL(SubProfileUrl),
|
||||
WithSUBAnnounce(SubAnnounce),
|
||||
WithSUBEnableRouting(SubEnableRouting),
|
||||
WithSUBRoutingRules(SubRoutingRules),
|
||||
WithSUBHideSettings(SubHideSettings),
|
||||
WithSUBIncyEnableRouting(SubIncyEnableRouting),
|
||||
WithSUBIncyRoutingRules(SubIncyRoutingRules),
|
||||
)
|
||||
|
||||
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,
|
||||
})
|
||||
|
||||
js := NewSubJsonService("", "", "", NewSubService(""))
|
||||
js := NewSubJsonService("", "", NewSubService(""))
|
||||
out, _, err := js.GetJson("s1", "req.example.com", true)
|
||||
if err != nil {
|
||||
t.Fatalf("GetJson: %v", err)
|
||||
|
||||
@@ -56,8 +56,7 @@ func TestSubJson_BalancerDocument(t *testing.T) {
|
||||
Remark: "auto", Strategy: "leastLoad", InboundIds: []int{tcp.Id, ws.Id}, SortOrder: 1, Enabled: true,
|
||||
})
|
||||
|
||||
rules := `[{"type":"field","domain":["geosite:example"],"outboundTag":"proxy"}]`
|
||||
js := NewSubJsonService("", rules, "", NewSubService(""))
|
||||
js := NewSubJsonService("", "", NewSubService(""))
|
||||
out, _, err := js.GetJson("s1", "req.example.com", true)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
proxyRules := strings.Count(string(ruleJSON), `"balancerTag"`)
|
||||
if proxyRules != 2 { // custom rule + default catch-all
|
||||
t.Fatalf("balancerTag rules = %d, want 2: %s", proxyRules, ruleJSON)
|
||||
if proxyRules != 1 { // default catch-all only (custom sub rules were removed)
|
||||
t.Fatalf("balancerTag rules = %d, want 1: %s", proxyRules, ruleJSON)
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
|
||||
js := NewSubJsonService("", "", "", NewSubService(""))
|
||||
js := NewSubJsonService("", "", NewSubService(""))
|
||||
out, _, err := js.GetJson("s1", "req.example.com", true)
|
||||
if err != nil {
|
||||
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,
|
||||
})
|
||||
|
||||
js := NewSubJsonService("", "", "", NewSubService(""))
|
||||
js := NewSubJsonService("", "", NewSubService(""))
|
||||
out, _, err := js.GetJson("s1", "req.example.com", true)
|
||||
if err != nil {
|
||||
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,
|
||||
})
|
||||
|
||||
js := NewSubJsonService("", "", "", NewSubService(""))
|
||||
js := NewSubJsonService("", "", NewSubService(""))
|
||||
out, _, err := js.GetJson("s1", "req.example.com", true)
|
||||
if err != nil {
|
||||
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,
|
||||
})
|
||||
|
||||
js := NewSubJsonService("", "", "", NewSubService(""))
|
||||
js := NewSubJsonService("", "", NewSubService(""))
|
||||
js.SetObservatoryConfig(`{"destination":"https://probe.example/204","httpMethod":"GET","sampling":5}`)
|
||||
out, _, err := js.GetJson("s1", "req.example.com", true)
|
||||
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,
|
||||
})
|
||||
|
||||
js := NewSubJsonService("", "", "", NewSubService(""))
|
||||
js := NewSubJsonService("", "", NewSubService(""))
|
||||
out, _, err := js.GetJson("s1", "req.example.com", true)
|
||||
if err != nil {
|
||||
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,
|
||||
})
|
||||
|
||||
js := NewSubJsonService("", "", "", NewSubService(""))
|
||||
js := NewSubJsonService("", "", NewSubService(""))
|
||||
out, _, err := js.GetJson("s1", "req.example.com", true)
|
||||
if err != nil {
|
||||
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,
|
||||
})
|
||||
|
||||
js := NewSubJsonService("", "", "", NewSubService(""))
|
||||
js := NewSubJsonService("", "", NewSubService(""))
|
||||
out, _, err := js.GetJson("s1", "req.example.com", true)
|
||||
if err != nil {
|
||||
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,
|
||||
})
|
||||
|
||||
js := NewSubJsonService("", "", "", NewSubService(""))
|
||||
js := NewSubJsonService("", "", NewSubService(""))
|
||||
js.SetObservatoryConfig(`{"enabled":false}`)
|
||||
out, _, err := js.GetJson("s1", "req.example.com", true)
|
||||
if err != nil {
|
||||
|
||||
@@ -29,7 +29,7 @@ func TestSubJson_ObservatoryConfigInvalidValuesFallBack(t *testing.T) {
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
js := NewSubJsonService("", "", "", NewSubService(""))
|
||||
js := NewSubJsonService("", "", NewSubService(""))
|
||||
js.SetObservatoryConfig(tc.cfg)
|
||||
out, _, err := js.GetJson("s1", "req.example.com", true)
|
||||
if err != nil {
|
||||
|
||||
@@ -68,7 +68,7 @@ func TestGetJsonToleratesHysteriaWithoutHysteriaSettings(t *testing.T) {
|
||||
t.Fatalf("seed client_inbound: %v", err)
|
||||
}
|
||||
|
||||
jsonService := NewSubJsonService("", "", "", NewSubService(""))
|
||||
jsonService := NewSubJsonService("", "", NewSubService(""))
|
||||
out, _, err := jsonService.GetJson(subId, "sub.example.com", true)
|
||||
if err != nil {
|
||||
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"}}}`
|
||||
seedSubInbound(t, "rlty1", "rlty", 46400, 1, stream)
|
||||
|
||||
jsonService := NewSubJsonService("", "", "", NewSubService(""))
|
||||
jsonService := NewSubJsonService("", "", NewSubService(""))
|
||||
out, _, err := jsonService.GetJson("rlty1", "sub.example.com", true)
|
||||
if err != nil {
|
||||
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 + `"]}}}`
|
||||
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 {
|
||||
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"}]}`
|
||||
seedSubInbound(t, "extp1", "extp", 46500, 1, stream)
|
||||
|
||||
jsonService := NewSubJsonService("", "", "", NewSubService(""))
|
||||
jsonService := NewSubJsonService("", "", NewSubService(""))
|
||||
jsonOut, _, err := jsonService.GetJson("extp1", "sub.example.com", true)
|
||||
if err != nil {
|
||||
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")
|
||||
}
|
||||
|
||||
clashOut, _, err := NewSubClashService(false, "", NewSubService("")).GetClash("extp1", "sub.example.com")
|
||||
clashOut, _, err := NewSubClashService(NewSubService("")).GetClash("extp1", "sub.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetClash: %v", err)
|
||||
}
|
||||
|
||||
@@ -209,7 +209,7 @@ func TestGetSubsScale(t *testing.T) {
|
||||
t.Fatalf("GetSubs links = %d, want 3", len(links))
|
||||
}
|
||||
|
||||
jsonSvc := NewSubJsonService("", "", "", &SubService{})
|
||||
jsonSvc := NewSubJsonService("", "", &SubService{})
|
||||
start = time.Now()
|
||||
for range reps {
|
||||
body, _, err := jsonSvc.GetJson(scaleTargetSubId, "sub.example.com", false)
|
||||
|
||||
@@ -51,7 +51,7 @@ func TestSub_HostVlessRoute_JSON(t *testing.T) {
|
||||
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"})
|
||||
|
||||
js := NewSubJsonService("", "", "", NewSubService(""))
|
||||
js := NewSubJsonService("", "", NewSubService(""))
|
||||
out, _, err := js.GetJson("s1", "req.example.com", false)
|
||||
if err != nil {
|
||||
t.Fatalf("GetJson: %v", err)
|
||||
@@ -69,7 +69,7 @@ func TestSub_HostVlessRoute_Clash(t *testing.T) {
|
||||
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"})
|
||||
|
||||
clash := NewSubClashService(false, "", NewSubService(""))
|
||||
clash := NewSubClashService(NewSubService(""))
|
||||
yaml, _, err := clash.GetClash("s1", "req.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetClash: %v", err)
|
||||
|
||||
@@ -23,8 +23,6 @@ func TestJobCadencesAreValidCronSpecs(t *testing.T) {
|
||||
"cadenceNodeHeartbeat": cadenceNodeHeartbeat,
|
||||
"cadenceNodeTraffic": cadenceNodeTraffic,
|
||||
"cadenceOutboundSub": cadenceOutboundSub,
|
||||
"cadenceRemoteRouting": cadenceRemoteRouting,
|
||||
"cadenceCheckHash": cadenceCheckHash,
|
||||
"cadenceCPUAlarm": cadenceCPUAlarm,
|
||||
}
|
||||
for name, spec := range cadences {
|
||||
|
||||
@@ -51,10 +51,6 @@ type AllSetting struct {
|
||||
SubSupportUrl string `json:"subSupportUrl" form:"subSupportUrl"`
|
||||
SubProfileUrl string `json:"subProfileUrl" form:"subProfileUrl"`
|
||||
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"`
|
||||
SubPort int `json:"subPort" form:"subPort" validate:"gte=1,lte=65535"`
|
||||
SubPath string `json:"subPath" form:"subPath"`
|
||||
@@ -70,10 +66,7 @@ type AllSetting struct {
|
||||
SubClashEnable bool `json:"subClashEnable" form:"subClashEnable"`
|
||||
SubClashPath string `json:"subClashPath" form:"subClashPath"`
|
||||
SubClashURI string `json:"subClashURI" form:"subClashURI"`
|
||||
SubClashEnableRouting bool `json:"subClashEnableRouting" form:"subClashEnableRouting"`
|
||||
SubClashRules string `json:"subClashRules" form:"subClashRules"`
|
||||
SubJsonMux string `json:"subJsonMux" form:"subJsonMux"`
|
||||
SubJsonRules string `json:"subJsonRules" form:"subJsonRules"`
|
||||
SubJsonFinalMask string `json:"subJsonFinalMask" form:"subJsonFinalMask"`
|
||||
SubJsonObservatory string `json:"subJsonObservatory" form:"subJsonObservatory"`
|
||||
SubThemeDir string `json:"subThemeDir" form:"subThemeDir"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -81,11 +81,7 @@ var defaultValueMap = map[string]string{
|
||||
"subSupportUrl": "",
|
||||
"subProfileUrl": "",
|
||||
"subAnnounce": "",
|
||||
"subEnableRouting": "false",
|
||||
"subRoutingRules": "",
|
||||
"subHideSettings": "false",
|
||||
"subIncyEnableRouting": "false",
|
||||
"subIncyRoutingRules": "",
|
||||
"subListen": "",
|
||||
"subPort": "2096",
|
||||
"subPath": "/sub/",
|
||||
@@ -100,10 +96,7 @@ var defaultValueMap = map[string]string{
|
||||
"subClashEnable": "false",
|
||||
"subClashPath": "/clash/",
|
||||
"subClashURI": "",
|
||||
"subClashEnableRouting": "false",
|
||||
"subClashRules": "",
|
||||
"subJsonMux": "",
|
||||
"subJsonRules": "",
|
||||
"subJsonFinalMask": "",
|
||||
"subJsonObservatory": "",
|
||||
"subThemeDir": "",
|
||||
@@ -623,26 +616,10 @@ func (s *SettingService) GetSubAnnounce() (string, error) {
|
||||
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) {
|
||||
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) {
|
||||
return s.getString("subListen")
|
||||
}
|
||||
@@ -711,22 +688,10 @@ func (s *SettingService) GetSubClashURI() (string, error) {
|
||||
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) {
|
||||
return s.getString("subJsonMux")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetSubJsonRules() (string, error) {
|
||||
return s.getString("subJsonRules")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetSubJsonFinalMask() (string, error) {
|
||||
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.
|
||||
allSetting.SubSupportUrl = common.EnsureURLScheme(allSetting.SubSupportUrl)
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -128,8 +128,6 @@ func TestUpdateAllSettingPersistsClashSubscriptionSettings(t *testing.T) {
|
||||
settings.SubClashEnable = true
|
||||
settings.SubClashPath = "/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 {
|
||||
t.Fatal(err)
|
||||
@@ -172,12 +170,6 @@ func TestUpdateAllSettingPersistsClashSubscriptionSettings(t *testing.T) {
|
||||
if 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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -295,9 +295,7 @@ const (
|
||||
cadenceNodeTraffic = "@every 5s"
|
||||
cadenceOutboundSub = "@every 5m"
|
||||
cadenceReapOrphans = "@every 5m"
|
||||
cadenceRemoteRouting = "@every 5m"
|
||||
cadenceXrayLogPrune = "@every 10m"
|
||||
cadenceCheckHash = "@every 2m"
|
||||
// cpu.Percent samples over a full minute (blocking), so a finer cadence just
|
||||
// stacks overlapping samplers; subscribers rate-limit alerts to 1/min anyway.
|
||||
cadenceCPUAlarm = "@every 1m"
|
||||
@@ -348,11 +346,6 @@ func (s *Server) startTask(restartXray bool, loc *time.Location) {
|
||||
|
||||
_, _ = 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
|
||||
_, _ = s.cron.AddJob("@daily", job.NewClearLogsJob())
|
||||
|
||||
Reference in New Issue
Block a user