feat(sub): bake Happ/INCY routing profiles into the JSON subscription (#6402)

* feat(sub): parse generic Happ/INCY routing payloads for the JSON subscription

Accepts the routing-rules format emitted for Happ and INCY (inline JSON,
happ:// or incy:// deeplink, or a remote https:// URL resolved through the
existing remote routing cache). The JSON subscription will bake these
rules into its documents so header-ignoring clients still get routing.

* feat(sub): bake Happ/INCY routing profiles into JSON subscription documents

When subJsonRoutingRules is set, every emitted document (per-inbound and
balancer alike) carries the profile's dns and routing rules baked in, so
header-ignoring clients like Happ and INCY still get routing; the legacy
simple-rules merge only applies when no profile is set. The balancer
document builder keeps rewriting proxy-tag rules to the balancer.

* feat(sub): add the subJsonRoutingRules setting

Plumbed from the settings store through the subscription server into
SubJsonService, so admins can set a routing profile once and every JSON
subscription document carries it.

* chore(api): regenerate OpenAPI artifacts for subJsonRoutingRules

* feat(web): routing profile editor for the JSON subscription

A textarea inside the JSON card accepts the routing profile (inline JSON,
happ/incy deeplink, or https URL) with a remote-source badge; the badge
helper moves to a shared module. Keys added to all 13 locales.

* fix(sub): warm and lazily resolve the baked JSON routing source

The routing profile was resolved once at service construction: a remote
URL that was cold at that moment baked default routing forever, and the
cron job never warmed it. The job now warms the subJsonRoutingRules URL,
and the profile resolves per request with an in-memory memo (a failed
resolve is not cached), so a warmed cache takes effect without a restart.

* feat(sub): fall back to the JSON routing profile for the Routing header

Happ and INCY download the geo files a routing profile references
through the Routing response header. When the Happ header setting was
blank the header stayed unset, and clients fetched no geo files even
though a JSON routing profile was configured. A blank setting now falls
back to the JSON profile: happ/incy deeplinks pass through, inline JSON
and remote URLs are normalized to a happ:// deeplink; an unusable or
oversized value leaves the header unset. Locale captions mention the
fallback.

* fix(sub): pass routingRules arg at call sites added by main

Main gained four NewSubJsonService call sites after this branch forked;
update them to the five-arg signature so internal/sub builds again.

* fix(sub): address code review findings on the baked JSON routing

The memoised baked template never invalidated, so an edited remote
profile kept serving the superseded dns/routing subtrees until a panel
restart; bakedTemplate now re-resolves the spec per request and rebuilds
only when the payload actually changed (regression-tested).

subJsonRoutingRules shared the happ persistence row with
subRoutingRules, so only the last-written setting survived a restart;
it now resolves under its own jsonhapp kind with the same validation
and size caps. The setting also joins validateSettingsURLs, so remote
values are canonicalised and bad URLs are rejected on save.

Also: drop the unreachable half of the remote-source guard, cut the
overlong comment blocks to the two-line convention, and deduplicate
remoteSourceBadge in the General tab. Merges upstream/main (call sites
for the widened NewSubJsonService signature).

* style(sub): gofumpt the json_routing imports

* fix(sub): accept happ add/ deeplinks and bound the routing warning

The baked-JSON routing parser only recognised happ://routing/onadd/, but
normalizeHappRouting treats happ://routing/add/ as an equally valid routing
deeplink. An operator pasting the add/ form got the Routing header set, so
the panel looked configured, while every JSON subscription document silently
carried the default routing instead of their profile.

resolveJsonRoutingSpec logged one warning per call and bakedTemplate calls it
once per emitted document, so a single fetch of an unusable profile wrote one
identical warning per document. On the public subscription server that floods
the 10240-entry buffer the panel's log view reads, evicting real entries. Log
only when the message changes, and reset on a successful resolve so a profile
that recovers and fails again is still reported.

Also resolve the template once in buildBalancerConfig: two resolves could
straddle a profile refresh and pair one revision's dns with the other's
routing.
This commit is contained in:
DIMFLIX
2026-09-10 18:05:54 +03:00
committed by GitHub
parent ed5465d0f2
commit 2dd903ea8e
47 changed files with 1302 additions and 113 deletions
+29 -17
View File
@@ -45,14 +45,15 @@ 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
happConfig HappConfig
subTitle string
subSupportUrl string
subProfileUrl string
subAnnounce string
subEnableRouting bool
subRoutingRules string
subJsonRoutingRules string
subHideSettings bool
happConfig HappConfig
subIncyEnableRouting bool
subIncyRoutingRules string
@@ -99,6 +100,7 @@ type subControllerConfig struct {
subJsonMux string
subJsonRules string
subJsonRoutingRules string
subJsonFinalMask string
subJsonObservatory string
subClashEnableRouting bool
@@ -179,6 +181,10 @@ func WithSUBJsonRules(value string) SUBControllerOption {
return func(config *subControllerConfig) { config.subJsonRules = value }
}
func WithSUBJsonRoutingRules(value string) SUBControllerOption {
return func(config *subControllerConfig) { config.subJsonRoutingRules = value }
}
func WithSUBJsonFinalMask(value string) SUBControllerOption {
return func(config *subControllerConfig) { config.subJsonFinalMask = value }
}
@@ -254,17 +260,18 @@ 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.subJsonRules, config.subJsonFinalMask, config.subJsonRoutingRules, 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,
happConfig: config.happConfig,
subTitle: config.subTitle,
subSupportUrl: config.subSupportURL,
subProfileUrl: config.subProfileURL,
subAnnounce: config.subAnnounce,
subEnableRouting: config.subEnableRouting,
subRoutingRules: config.subRoutingRules,
subJsonRoutingRules: config.subJsonRoutingRules,
subHideSettings: config.subHideSettings,
happConfig: config.happConfig,
subIncyEnableRouting: config.subIncyEnableRouting,
subIncyRoutingRules: config.subIncyRoutingRules,
@@ -853,6 +860,11 @@ func (a *SUBController) ApplyCommonHeaders(
}
rules, remote, routingErr := resolveRoutingSource(remoteRoutingHapp, profileRoutingRules)
if strings.TrimSpace(profileRoutingRules) == "" {
// Happ/INCY fetch the geo files the baked JSON rules reference through
// this header, so a blank Happ setting falls back to the JSON profile.
rules, remote, routingErr = jsonRoutingHeaderSource(a.subJsonRoutingRules), false, nil
}
// The off values undo a previously pushed setting, so they ride the same
// opt-in as every other Happ header rather than reaching every Happ client.
happManaged := a.happConfig.AutoDetect && c.Request != nil && IsHappClient(c.GetHeader("User-Agent"))
+1 -1
View File
@@ -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)
+3 -3
View File
@@ -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)
+2 -2
View File
@@ -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)
+4 -4
View File
@@ -56,7 +56,7 @@ func TestSubJson_InfoNode_Active(t *testing.T) {
sub := NewSubService("{{EMAIL}}|📊{{TRAFFIC_LEFT}}")
sub.subInfoNodeEnable = true
jsonSvc := NewSubJsonService("", "", "", sub)
jsonSvc := NewSubJsonService("", "", "", "", sub)
out, _, err := jsonSvc.GetJson("sub-json", "sub.example.com", false)
if err != nil {
@@ -115,7 +115,7 @@ func TestSubJson_InfoNode_Expired(t *testing.T) {
sub := NewSubService("{{INBOUND}}")
sub.subInfoNodeEnable = true
sub.subExpiredTemplate = service.DefaultSubExpiredTemplate
jsonSvc := NewSubJsonService("", "", "", sub)
jsonSvc := NewSubJsonService("", "", "", "", sub)
out, _, err := jsonSvc.GetJson("sub-json-exp", "sub.example.com", false)
if err != nil {
@@ -175,7 +175,7 @@ func TestSubJson_InfoNode_Depleted(t *testing.T) {
sub := NewSubService("{{INBOUND}}")
sub.subInfoNodeEnable = true
sub.subTrafficDepletedTemplate = service.DefaultSubTrafficDepletedTemplate
jsonSvc := NewSubJsonService("", "", "", sub)
jsonSvc := NewSubJsonService("", "", "", "", sub)
out, _, err := jsonSvc.GetJson("sub-json-dep", "sub.example.com", false)
if err != nil {
@@ -234,7 +234,7 @@ func TestSubJson_InfoNode_StatusActive(t *testing.T) {
sub := NewSubService("{{EMAIL}}|{{STATUS_EMOJI}} {{STATUS}}")
sub.subInfoNodeEnable = true
jsonSvc := NewSubJsonService("", "", "", sub)
jsonSvc := NewSubJsonService("", "", "", "", sub)
out, _, err := jsonSvc.GetJson("sub-json-status", "sub.example.com", false)
if err != nil {
+366
View File
@@ -0,0 +1,366 @@
package sub
import (
"encoding/json"
"errors"
"fmt"
"maps"
"slices"
"strings"
"sync/atomic"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
)
// jsonRoutingSpec is the canonical form of the generic Happ/INCY routing
// payload (inline JSON, happ:// or incy:// deeplink, or remote URL).
type jsonRoutingSpec struct {
DomainStrategy string
RemoteDNSDomain string
RemoteDNSIP string
DomesticDNSDomain string
DomesticDNSIP string
DnsHosts map[string]string
RouteOrder []string // e.g. {"block","proxy","direct"}; default {"block","direct","proxy"}
DirectSites []string
DirectIp []string
ProxySites []string
ProxyIp []string
BlockSites []string
BlockIp []string
}
func (s jsonRoutingSpec) empty() bool {
return s.DomainStrategy == "" && s.RemoteDNSDomain == "" && s.RemoteDNSIP == "" &&
s.DomesticDNSDomain == "" && s.DomesticDNSIP == "" && len(s.DnsHosts) == 0 &&
len(s.RouteOrder) == 0 && len(s.DirectSites) == 0 && len(s.DirectIp) == 0 &&
len(s.ProxySites) == 0 && len(s.ProxyIp) == 0 && len(s.BlockSites) == 0 && len(s.BlockIp) == 0
}
// equal reports whether two specs carry the same routing payload, so a
// rebuilt template only replaces the memoised one when the profile changed.
func (s jsonRoutingSpec) equal(other jsonRoutingSpec) bool {
return s.DomainStrategy == other.DomainStrategy &&
s.RemoteDNSDomain == other.RemoteDNSDomain && s.RemoteDNSIP == other.RemoteDNSIP &&
s.DomesticDNSDomain == other.DomesticDNSDomain && s.DomesticDNSIP == other.DomesticDNSIP &&
maps.Equal(s.DnsHosts, other.DnsHosts) && slices.Equal(s.RouteOrder, other.RouteOrder) &&
slices.Equal(s.DirectSites, other.DirectSites) && slices.Equal(s.DirectIp, other.DirectIp) &&
slices.Equal(s.ProxySites, other.ProxySites) && slices.Equal(s.ProxyIp, other.ProxyIp) &&
slices.Equal(s.BlockSites, other.BlockSites) && slices.Equal(s.BlockIp, other.BlockIp)
}
// Both happ forms appear in the wild; normalizeHappRouting accepts each.
var jsonRoutingDeeplinkPrefixes = []string{
"happ://routing/onadd/", "happ://routing/add/", "incy://routing/onadd/",
}
// bakedTemplate resolves once per emitted document, so an unusable profile
// must not write one identical warning per document on every public fetch.
var lastJsonRoutingWarning atomic.Value
// resolveJsonRoutingSpec parses the routing payload, degrading to an empty
// spec on error — a bad setting must never take the subscription server down.
func resolveJsonRoutingSpec(raw string) jsonRoutingSpec {
spec, remote, err := parseJsonRoutingSpec(raw)
if err != nil {
warning := "subJsonRoutingRules: " + err.Error()
if remote {
warning = "subJsonRoutingRules: remote source unavailable, emitting default routing"
}
if previous, _ := lastJsonRoutingWarning.Load().(string); previous != warning {
lastJsonRoutingWarning.Store(warning)
logger.Warning(warning)
}
return jsonRoutingSpec{}
}
lastJsonRoutingWarning.Store("")
return spec
}
// jsonRoutingHeaderSource turns the JSON routing setting into a Routing header
// value; blank means unusable, and the header then stays unset.
func jsonRoutingHeaderSource(raw string) string {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return ""
}
if strings.HasPrefix(trimmed, "incy://") {
_, rest, ok := cutAnyPrefix(trimmed, jsonRoutingDeeplinkPrefixes)
if !ok {
return ""
}
decoded, err := decodeRoutingBase64(rest)
if err != nil {
return ""
}
if _, err := validateAndCompactJSONObject(decoded); err != nil || len(trimmed) > remoteRoutingHappMaxValue {
return ""
}
return trimmed
}
resolved, _, err := resolveRoutingSource(remoteRoutingJson, trimmed)
if err != nil {
return ""
}
content, err := normalizeHappRouting([]byte(resolved))
if err != nil || len(content) > remoteRoutingHappMaxValue {
return ""
}
return content
}
// parseJsonRoutingSpec resolves raw (inline JSON, happ:// or incy:// deeplink,
// or https:// URL) into a spec; the caller degrades on error, never fails.
func parseJsonRoutingSpec(raw string) (jsonRoutingSpec, bool, error) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return jsonRoutingSpec{}, false, nil
}
if _, remote, err := common.ParseRemoteRoutingURL(trimmed); remote {
if err != nil {
return jsonRoutingSpec{}, true, err
}
resolved, remote, err := resolveRoutingSource(remoteRoutingJson, trimmed)
if err != nil || !remote {
return jsonRoutingSpec{}, true, err
}
trimmed = resolved
}
payload := []byte(trimmed)
if _, rest, ok := cutAnyPrefix(trimmed, jsonRoutingDeeplinkPrefixes); ok {
decoded, err := decodeRoutingBase64(rest)
if err != nil {
return jsonRoutingSpec{}, false, fmt.Errorf("invalid routing deeplink payload: %w", err)
}
payload = decoded
} else if !strings.HasPrefix(trimmed, "{") {
return jsonRoutingSpec{}, false, errors.New("routing payload must be a JSON object or a happ/incy deeplink")
}
var object map[string]any
if err := json.Unmarshal(payload, &object); err != nil {
return jsonRoutingSpec{}, false, fmt.Errorf("invalid routing payload JSON: %w", err)
}
if object == nil {
return jsonRoutingSpec{}, false, errors.New("routing payload must be a JSON object")
}
spec, err := buildJsonRoutingSpec(object)
if err != nil {
return jsonRoutingSpec{}, false, err
}
return spec, false, nil
}
func cutAnyPrefix(s string, prefixes []string) (string, string, bool) {
for _, prefix := range prefixes {
if after, ok := strings.CutPrefix(s, prefix); ok {
return prefix, after, true
}
}
return "", "", false
}
func buildJsonRoutingSpec(object map[string]any) (jsonRoutingSpec, error) {
spec := jsonRoutingSpec{}
var err error
if spec.DomainStrategy, err = routingString(object, "DomainStrategy"); err != nil {
return spec, err
}
if spec.RemoteDNSDomain, err = routingString(object, "RemoteDNSDomain"); err != nil {
return spec, err
}
if spec.RemoteDNSIP, err = routingString(object, "RemoteDNSIP"); err != nil {
return spec, err
}
if spec.DomesticDNSDomain, err = routingString(object, "DomesticDNSDomain"); err != nil {
return spec, err
}
if spec.DomesticDNSIP, err = routingString(object, "DomesticDNSIP"); err != nil {
return spec, err
}
if spec.DirectSites, err = routingList(object, "DirectSites"); err != nil {
return spec, err
}
if spec.DirectIp, err = routingList(object, "DirectIp"); err != nil {
return spec, err
}
if spec.ProxySites, err = routingList(object, "ProxySites"); err != nil {
return spec, err
}
if spec.ProxyIp, err = routingList(object, "ProxyIp"); err != nil {
return spec, err
}
if spec.BlockSites, err = routingList(object, "BlockSites"); err != nil {
return spec, err
}
if spec.BlockIp, err = routingList(object, "BlockIp"); err != nil {
return spec, err
}
if spec.DnsHosts, err = routingHosts(object, "DnsHosts"); err != nil {
return spec, err
}
order, err := routingString(object, "RouteOrder")
if err != nil {
return spec, err
}
for _, segment := range strings.Split(order, "-") {
switch segment {
case "block", "proxy", "direct":
spec.RouteOrder = append(spec.RouteOrder, segment)
}
}
return spec, nil
}
func routingString(object map[string]any, key string) (string, error) {
value, ok := object[key]
if !ok || value == nil {
return "", nil
}
text, ok := value.(string)
if !ok {
return "", fmt.Errorf("routing field %q must be a string", key)
}
return text, nil
}
func routingList(object map[string]any, key string) ([]string, error) {
value, ok := object[key]
if !ok || value == nil {
return nil, nil
}
entries, ok := value.([]any)
if !ok {
return nil, fmt.Errorf("routing field %q must be an array of strings", key)
}
list := make([]string, 0, len(entries))
for _, entry := range entries {
text, ok := entry.(string)
if !ok {
return nil, fmt.Errorf("routing field %q must be an array of strings", key)
}
list = append(list, text)
}
return list, nil
}
func routingHosts(object map[string]any, key string) (map[string]string, error) {
value, ok := object[key]
if !ok || value == nil {
return nil, nil
}
raw, ok := value.(map[string]any)
if !ok {
return nil, fmt.Errorf("routing field %q must be a string-to-string map", key)
}
hosts := make(map[string]string, len(raw))
for name, entry := range raw {
address, ok := entry.(string)
if !ok {
return nil, fmt.Errorf("routing field %q must be a string-to-string map", key)
}
hosts[name] = address
}
return hosts, nil
}
func routeOrderGroups(order []string) []string {
if len(order) == 0 {
return []string{"block", "direct", "proxy"}
}
return order
}
// applyJsonRouting patches the base template with the spec's dns and routing
// subtrees, mirroring the njs patcher panel admins previously ran behind nginx.
func applyJsonRouting(configJson map[string]any, spec jsonRoutingSpec) {
domestic := spec.DomesticDNSDomain
if domestic == "" {
ip := spec.DomesticDNSIP
if ip == "" {
ip = "77.88.8.8"
}
domestic = "https://" + ip + "/dns-query"
}
remote := spec.RemoteDNSDomain
if remote == "" {
ip := spec.RemoteDNSIP
if ip == "" {
ip = "8.8.8.8"
}
remote = "https://" + ip + "/dns-query"
}
dns := map[string]any{
"tag": "dns_out",
"queryStrategy": "UseIP",
"servers": []any{},
}
if len(spec.DirectSites) > 0 {
dns["servers"] = append(dns["servers"].([]any), map[string]any{
"address": domestic,
"domains": spec.DirectSites,
})
}
dns["servers"] = append(dns["servers"].([]any), map[string]any{
"address": remote,
"skipFallback": false,
})
if len(spec.DnsHosts) > 0 {
dns["hosts"] = spec.DnsHosts
}
domainStrategy := spec.DomainStrategy
if domainStrategy == "" {
domainStrategy = "IPIfNonMatch"
}
groups := map[string][]map[string]any{
"block": {
{"domain": stringList(spec.BlockSites), "outboundTag": "block"},
{"ip": stringList(spec.BlockIp), "outboundTag": "block"},
},
"direct": {
{"domain": stringList(spec.DirectSites), "outboundTag": "direct"},
{"ip": stringList(spec.DirectIp), "outboundTag": "direct"},
},
"proxy": {
{"domain": stringList(spec.ProxySites), "outboundTag": "proxy"},
{"ip": stringList(spec.ProxyIp), "outboundTag": "proxy"},
},
}
rules := make([]any, 0, len(routeOrderGroups(spec.RouteOrder))*2+1)
for _, group := range routeOrderGroups(spec.RouteOrder) {
for _, rule := range groups[group] {
var key string
if _, ok := rule["domain"]; ok {
key = "domain"
} else {
key = "ip"
}
if len(rule[key].([]string)) == 0 {
continue
}
entry := map[string]any{"type": "field", key: rule[key], "outboundTag": rule["outboundTag"]}
rules = append(rules, entry)
}
}
rules = append(rules, map[string]any{"type": "field", "network": "tcp,udp", "outboundTag": "proxy"})
configJson["dns"] = dns
configJson["routing"] = map[string]any{
"domainStrategy": domainStrategy,
"rules": rules,
}
}
func stringList(list []string) []string {
if len(list) == 0 {
return nil
}
return list
}
+447
View File
@@ -0,0 +1,447 @@
package sub
import (
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
)
const bakedRoutingPayload = `{
"DomainStrategy": "IPIfNonMatch",
"RemoteDNSDomain": "https://8.8.8.8/dns-query",
"RemoteDNSIP": "8.8.8.8",
"DomesticDNSDomain": "https://77.88.8.8/dns-query",
"DomesticDNSIP": "77.88.8.8",
"DnsHosts": {"lknpd.nalog.ru": "213.24.64.181"},
"RouteOrder": "block-proxy-direct",
"DirectSites": ["geosite:category-ru"],
"DirectIp": ["geoip:private"],
"ProxySites": ["geosite:youtube"],
"BlockSites": ["geosite:category-ads"]
}`
func ruleSignatures(t *testing.T, doc map[string]any) []string {
t.Helper()
routing, _ := doc["routing"].(map[string]any)
rules, _ := routing["rules"].([]any)
signatures := make([]string, 0, len(rules))
for _, rule := range rules {
m, _ := rule.(map[string]any)
target, _ := m["outboundTag"].(string)
if target == "" {
target = "balancer:" + m["balancerTag"].(string)
}
kind := "ip"
if _, has := m["domain"]; has {
kind = "domain"
}
if _, has := m["network"]; has {
kind = "network"
}
signatures = append(signatures, kind+"->"+target)
}
return signatures
}
func assertBakedRouting(t *testing.T, doc map[string]any, wantRules []string, proxyTag string) {
t.Helper()
dns, _ := doc["dns"].(map[string]any)
if dns == nil {
t.Fatalf("doc has no dns:\n%v", doc)
}
if dns["tag"] != "dns_out" || dns["queryStrategy"] != "UseIP" {
t.Fatalf("dns header = %v", dns)
}
servers, _ := dns["servers"].([]any)
if len(servers) != 2 {
t.Fatalf("dns servers = %d, want 2 (domestic + remote): %v", len(servers), servers)
}
first, _ := servers[0].(map[string]any)
if first["address"] != "https://77.88.8.8/dns-query" {
t.Fatalf("domestic dns = %v", first)
}
if domains, _ := first["domains"].([]any); strings.Join(stringify(domains), ",") != "geosite:category-ru" {
t.Fatalf("domestic dns domains = %v", first["domains"])
}
second, _ := servers[1].(map[string]any)
if second["address"] != "https://8.8.8.8/dns-query" {
t.Fatalf("remote dns = %v", second)
}
hosts, _ := dns["hosts"].(map[string]any)
if hosts["lknpd.nalog.ru"] != "213.24.64.181" {
t.Fatalf("dns hosts = %v", dns["hosts"])
}
routing, _ := doc["routing"].(map[string]any)
if routing["domainStrategy"] != "IPIfNonMatch" {
t.Fatalf("domainStrategy = %v", routing["domainStrategy"])
}
want := make([]string, 0, len(wantRules))
for _, rule := range wantRules {
want = append(want, strings.Replace(rule, "PROXY", proxyTag, 1))
}
got := ruleSignatures(t, doc)
if strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("rules = %v\nwant %v", got, want)
}
}
func TestSubJson_BakedRoutingInEveryDocument(t *testing.T) {
seedSubDB(t)
seedSubInbound(t, "s1", "tcpin", 4801, 1, `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"}}`)
js := NewSubJsonService("", "", "", bakedRoutingPayload, NewSubService(""))
out, _, err := js.GetJson("s1", "req.example.com", true)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
docs := parseSubJsonDocs(t, out)
if len(docs) != 1 {
t.Fatalf("docs = %d, want 1:\n%s", len(docs), out)
}
want := []string{"domain->block", "domain->PROXY", "domain->direct", "ip->direct", "network->PROXY"}
assertBakedRouting(t, docs[0], want, "proxy")
}
func TestSubJson_BakedRoutingReplacesLegacyRules(t *testing.T) {
seedSubDB(t)
seedSubInbound(t, "s1", "tcpin", 4802, 1, `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"}}`)
legacy := `[{"type":"field","domain":["geosite:example"],"outboundTag":"proxy"}]`
js := NewSubJsonService("", legacy, "", bakedRoutingPayload, NewSubService(""))
out, _, err := js.GetJson("s1", "req.example.com", true)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
docs := parseSubJsonDocs(t, out)
routing, _ := docs[0]["routing"].(map[string]any)
ruleJSON, _ := json.Marshal(routing["rules"])
if strings.Contains(string(ruleJSON), "geosite:example") {
t.Fatalf("legacy subJsonRules must not leak into baked docs: %s", ruleJSON)
}
}
func TestSubJson_BakedRoutingWithBalancer(t *testing.T) {
seedSubDB(t)
tcp := seedSubInbound(t, "s1", "tcpin", 4803, 1, `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"}}`)
seedSubBalancer(t, &model.SubBalancer{
Remark: "auto", Strategy: "leastLoad", InboundIds: []int{tcp.Id}, SortOrder: 1, Enabled: true,
})
js := NewSubJsonService("", "", "", bakedRoutingPayload, NewSubService(""))
out, _, err := js.GetJson("s1", "req.example.com", true)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
docs := parseSubJsonDocs(t, out)
if len(docs) != 2 {
t.Fatalf("docs = %d, want 2 (inbound + balancer):\n%s", len(docs), out)
}
// Manual doc keeps the plain proxy tag.
assertBakedRouting(t, findDocByRemarks(docs, "tcpin-tcpin@e"), []string{
"domain->block", "domain->PROXY", "domain->direct", "ip->direct", "network->PROXY",
}, "proxy")
// Balancer doc routes proxy groups into the balancer.
balancerDoc := findDocByRemarks(docs, "auto")
want := []string{"domain->block", "domain->balancer:balancer", "domain->direct", "ip->direct", "network->balancer:balancer"}
assertBakedRouting(t, balancerDoc, want, "balancer:balancer")
}
func TestSubJson_BakedRoutingInvalidFallsBackToDefault(t *testing.T) {
seedSubDB(t)
seedSubInbound(t, "s1", "tcpin", 4804, 1, `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"}}`)
js := NewSubJsonService("", "", "", "not json at all", NewSubService(""))
out, _, err := js.GetJson("s1", "req.example.com", true)
if err != nil {
t.Fatalf("GetJson must survive a bad routing payload: %v", err)
}
docs := parseSubJsonDocs(t, out)
routing, _ := docs[0]["routing"].(map[string]any)
rules, _ := json.Marshal(routing["rules"])
if !strings.Contains(string(rules), `"outboundTag":"proxy"`) {
t.Fatalf("default routing missing: %s", rules)
}
}
func TestSubJson_LegacyRulesStillWorkWithoutBakedRouting(t *testing.T) {
seedSubDB(t)
seedSubInbound(t, "s1", "tcpin", 4805, 1, `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"}}`)
legacy := `[{"type":"field","domain":["geosite:example"],"outboundTag":"proxy"}]`
js := NewSubJsonService("", legacy, "", "", NewSubService(""))
out, _, err := js.GetJson("s1", "req.example.com", true)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
docs := parseSubJsonDocs(t, out)
routing, _ := docs[0]["routing"].(map[string]any)
ruleJSON, _ := json.Marshal(routing["rules"])
if !strings.Contains(string(ruleJSON), "geosite:example") {
t.Fatalf("legacy rules missing: %s", ruleJSON)
}
}
func TestSubJson_BakedRoutingRemoteWarmsAfterColdStart(t *testing.T) {
seedSubDB(t)
seedSubInbound(t, "s1", "tcpin", 4806, 1, `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"}}`)
oldResolver := routingSourceResolver
t.Cleanup(func() { routingSourceResolver = oldResolver })
const source = "https://example.com/DEFAULT.JSON"
routingSourceResolver = newRemoteRoutingResolver(remoteRoutingTestClient(func(*http.Request) (*http.Response, error) {
return remoteRoutingResponse(200, mustMarshal(t, fullRoutingPayload())), nil
}), false)
js := NewSubJsonService("", "", "", source, NewSubService(""))
// Cold: no request has primed the resolver cache yet.
out, _, err := js.GetJson("s1", "req.example.com", true)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
docs := parseSubJsonDocs(t, out)
routing, _ := docs[0]["routing"].(map[string]any)
if routing["domainStrategy"] != "AsIs" {
t.Fatalf("cold doc must keep default routing: %v", routing["domainStrategy"])
}
// The cron job warms the cache; the next request must bake the profile.
primeRemoteRouting(t, routingSourceResolver, remoteRoutingJson, source)
out, _, err = js.GetJson("s1", "req.example.com", true)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
docs = parseSubJsonDocs(t, out)
routing, _ = docs[0]["routing"].(map[string]any)
if routing["domainStrategy"] != "IPIfNonMatch" {
t.Fatalf("warm doc must carry the profile: %v", routing["domainStrategy"])
}
dns, _ := docs[0]["dns"].(map[string]any)
servers, _ := dns["servers"].([]any)
if len(servers) != 2 {
t.Fatalf("warm doc dns servers = %v", servers)
}
}
func TestApplyCommonHeadersFallsBackToJsonRoutingProfile(t *testing.T) {
gin.SetMode(gin.TestMode)
var object map[string]any
if err := json.Unmarshal([]byte(bakedRoutingPayload), &object); err != nil {
t.Fatalf("payload: %v", err)
}
happDeeplink := "happ://routing/onadd/" + base64.StdEncoding.EncodeToString([]byte(mustMarshal(t, object)))
incyDeeplink := "incy://routing/onadd/" + base64.StdEncoding.EncodeToString([]byte(mustMarshal(t, map[string]any{"Name": "RoscomVPN"})))
cases := []struct {
name string
jsonRules string
happRules string
want string
}{
{name: "inline json becomes a happ deeplink", jsonRules: bakedRoutingPayload, want: happDeeplink},
{name: "happ deeplink passes through", jsonRules: happDeeplink, want: happDeeplink},
{name: "incy deeplink passes through", jsonRules: incyDeeplink, want: incyDeeplink},
{name: "blank profile keeps the header unset", jsonRules: "", want: ""},
{name: "unusable profile keeps the header unset", jsonRules: "happ://routing/onadd/%%%", want: ""},
{name: "explicit happ rules take precedence", jsonRules: bakedRoutingPayload, happRules: "happ://routing/onadd/" + base64.StdEncoding.EncodeToString([]byte(`{"A":1}`)), want: "happ://routing/onadd/" + base64.StdEncoding.EncodeToString([]byte(`{"A":1}`))},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
(&SUBController{subJsonRoutingRules: tc.jsonRules}).ApplyCommonHeaders(ctx, "", "12", "", "", "", "", false, tc.happRules, false)
if got := recorder.Header().Get("Routing"); got != tc.want {
t.Fatalf("Routing = %q, want %q", got, tc.want)
}
})
}
}
func TestApplyCommonHeadersJsonRoutingRemoteFailsClosed(t *testing.T) {
gin.SetMode(gin.TestMode)
oldResolver := routingSourceResolver
t.Cleanup(func() { routingSourceResolver = oldResolver })
const source = "https://example.com/DEFAULT.JSON"
routingSourceResolver = newRemoteRoutingResolver(remoteRoutingTestClient(func(*http.Request) (*http.Response, error) {
return remoteRoutingResponse(200, mustMarshal(t, fullRoutingPayload())), nil
}), false)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
(&SUBController{subJsonRoutingRules: source}).ApplyCommonHeaders(ctx, "", "12", "", "", "", "", false, "", false)
if got := recorder.Header().Get("Routing"); got != "" {
t.Fatalf("cold cache must keep the header unset, got %q", got)
}
primeRemoteRouting(t, routingSourceResolver, remoteRoutingJson, source)
recorder = httptest.NewRecorder()
ctx, _ = gin.CreateTestContext(recorder)
(&SUBController{subJsonRoutingRules: source}).ApplyCommonHeaders(ctx, "", "12", "", "", "", "", false, "", false)
got := recorder.Header().Get("Routing")
if !strings.HasPrefix(got, "happ://routing/onadd/") {
t.Fatalf("warm cache Routing = %q", got)
}
decoded, err := decodeRoutingBase64(strings.TrimPrefix(got, "happ://routing/onadd/"))
if err != nil {
t.Fatalf("deeplink payload: %v", err)
}
var payload map[string]any
if err := json.Unmarshal(decoded, &payload); err != nil {
t.Fatalf("deeplink JSON: %v", err)
}
if payload["Name"] != "RoscomVPN" {
t.Fatalf("deeplink payload = %v", payload)
}
waitRemoteRoutingIdle(t, routingSourceResolver)
}
func TestSubJson_BakedRoutingRemoteUpdateReachesDocuments(t *testing.T) {
seedSubDB(t)
seedSubInbound(t, "s1", "tcpin", 4807, 1, `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"}}`)
oldResolver := routingSourceResolver
t.Cleanup(func() { routingSourceResolver = oldResolver })
const source = "https://example.com/DEFAULT.JSON"
current := mustMarshal(t, fullRoutingPayload())
routingSourceResolver = newRemoteRoutingResolver(remoteRoutingTestClient(func(*http.Request) (*http.Response, error) {
return remoteRoutingResponse(200, current), nil
}), false)
js := NewSubJsonService("", "", "", source, NewSubService(""))
// A cold resolver fails closed (default routing); prime the cache first.
primeRemoteRouting(t, routingSourceResolver, remoteRoutingJson, source)
out, _, err := js.GetJson("s1", "req.example.com", true)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
docs := parseSubJsonDocs(t, out)
routing, _ := docs[0]["routing"].(map[string]any)
if routing["domainStrategy"] != "IPIfNonMatch" {
t.Fatalf("first doc must carry the profile: %v", routing["domainStrategy"])
}
// The operator edits the published profile; after the cache TTL expires,
// the next request must re-bake the template with the new payload.
updated := fullRoutingPayload()
updated["DomainStrategy"] = "AsIs"
current = mustMarshal(t, updated)
waitRemoteRoutingIdle(t, routingSourceResolver)
staleKey := remoteRoutingKey{kind: remoteRoutingJson, source: source}
routingSourceResolver.mu.Lock()
entry := routingSourceResolver.entries[staleKey]
entry.FetchedAt = time.Now().Add(-remoteRoutingCacheTTL - time.Minute).Unix()
routingSourceResolver.entries[staleKey] = entry
delete(routingSourceResolver.lastAttempt, staleKey)
routingSourceResolver.mu.Unlock()
primeRemoteRouting(t, routingSourceResolver, remoteRoutingJson, source)
out, _, err = js.GetJson("s1", "req.example.com", true)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
docs = parseSubJsonDocs(t, out)
routing, _ = docs[0]["routing"].(map[string]any)
if routing["domainStrategy"] != "AsIs" {
t.Fatalf("updated profile must reach the documents without a restart: %v", routing["domainStrategy"])
}
waitRemoteRoutingIdle(t, routingSourceResolver)
}
func TestRemoteRoutingJsonHasItsOwnPersistedRow(t *testing.T) {
seedSubDB(t)
const source = "https://example.com/DEFAULT.JSON"
// Two happ-payload settings pointing at different sources must not
// overwrite each other's persisted cache rows.
happSource := "https://example.com/HAPP.json"
for _, tc := range []struct {
kind remoteRoutingKind
source string
payload string
}{
{kind: remoteRoutingHapp, source: happSource, payload: `{"Name":"happ-profile"}`},
{kind: remoteRoutingJson, source: source, payload: `{"Name":"json-profile"}`},
} {
deeplink, err := normalizeHappRouting([]byte(tc.payload))
if err != nil {
t.Fatalf("normalize: %v", err)
}
newRemoteRoutingResolver(nil, false).persistEntry(tc.kind, remoteRoutingCacheEntry{
Source: tc.source, Content: deeplink, FetchedAt: time.Now().Unix(),
})
}
for _, tc := range []struct {
kind remoteRoutingKind
source string
want string
}{
{kind: remoteRoutingHapp, source: happSource, want: "happ-profile"},
{kind: remoteRoutingJson, source: source, want: "json-profile"},
} {
resolver := newRemoteRoutingResolver(nil, true)
resolver.now = func() time.Time { return time.Unix(1_800_000_000, 0) }
resolver.ensurePersistedLoaded()
got, remote, err := resolver.resolve(tc.kind, tc.source)
if err != nil || !remote {
t.Fatalf("resolve kind=%s: remote=%v err=%v", tc.kind, remote, err)
}
decoded, err := decodeRoutingBase64(strings.TrimPrefix(got, "happ://routing/onadd/"))
if err != nil {
t.Fatalf("decode kind=%s: %v", tc.kind, err)
}
var payload map[string]any
if json.Unmarshal(decoded, &payload) != nil || payload["Name"] != tc.want {
t.Fatalf("kind=%s payload = %s", tc.kind, decoded)
}
}
}
const maxSubLogScan = 10240
func routingWarningCount(t *testing.T) int {
t.Helper()
n := 0
for _, line := range logger.GetLogs(maxSubLogScan, "warning") {
if strings.Contains(line, "subJsonRoutingRules") {
n++
}
}
return n
}
// A public subscription fetch must not write one warning per emitted document:
// the 10k in-memory buffer the panel's log view reads is evicted by the flood.
func TestSubJson_BadRoutingProfileWarnsOncePerRequest(t *testing.T) {
seedSubDB(t)
for i, name := range []string{"w1", "w2", "w3", "w4", "w5", "w6"} {
seedSubInbound(t, "s1", name, 4870+i, 1, `{"network":"tcp","security":"none"}`)
}
js := NewSubJsonService("", "", "", "not json at all", NewSubService(""))
before := routingWarningCount(t)
out, _, err := js.GetJson("s1", "req.example.com", true)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
docs := parseSubJsonDocs(t, out)
if len(docs) < 6 {
t.Fatalf("docs = %d, want >= 6:\n%s", len(docs), out)
}
if got := routingWarningCount(t) - before; got > 1 {
t.Fatalf("one request emitting %d documents logged %d warnings, want at most 1", len(docs), got)
}
}
+192
View File
@@ -0,0 +1,192 @@
package sub
import (
"encoding/base64"
"encoding/json"
"net/http"
"strings"
"testing"
)
func mustMarshal(t *testing.T, v any) string {
t.Helper()
data, err := json.Marshal(v)
if err != nil {
t.Fatalf("marshal: %v", err)
}
return string(data)
}
func b64Std(s string) string { return base64.StdEncoding.EncodeToString([]byte(s)) }
func b64URL(s string) string { return base64.RawURLEncoding.EncodeToString([]byte(s)) }
func fullRoutingPayload() map[string]any {
return map[string]any{
"Name": "RoscomVPN",
"DomainStrategy": "IPIfNonMatch",
"RemoteDNSDomain": "https://8.8.8.8/dns-query",
"RemoteDNSIP": "8.8.8.8",
"DomesticDNSDomain": "https://77.88.8.8/dns-query",
"DomesticDNSIP": "77.88.8.8",
"DnsHosts": map[string]any{"lknpd.nalog.ru": "213.24.64.181"},
"RouteOrder": "block-proxy-direct",
"DirectSites": []any{"geosite:category-ru", "geosite:private"},
"DirectIp": []any{"geoip:private"},
"ProxySites": []any{"geosite:youtube"},
"ProxyIp": []any{},
"BlockSites": []any{"geosite:category-ads"},
"BlockIp": []any{},
}
}
func TestParseJsonRoutingSpecMapsAllFields(t *testing.T) {
spec, remote, err := parseJsonRoutingSpec(mustMarshal(t, fullRoutingPayload()))
if err != nil || remote {
t.Fatalf("parse: err=%v remote=%v", err, remote)
}
want := jsonRoutingSpec{
DomainStrategy: "IPIfNonMatch",
RemoteDNSDomain: "https://8.8.8.8/dns-query",
RemoteDNSIP: "8.8.8.8",
DomesticDNSDomain: "https://77.88.8.8/dns-query",
DomesticDNSIP: "77.88.8.8",
DnsHosts: map[string]string{"lknpd.nalog.ru": "213.24.64.181"},
RouteOrder: []string{"block", "proxy", "direct"},
DirectSites: []string{"geosite:category-ru", "geosite:private"},
DirectIp: []string{"geoip:private"},
ProxySites: []string{"geosite:youtube"},
BlockSites: []string{"geosite:category-ads"},
}
if spec.DomainStrategy != want.DomainStrategy || spec.RemoteDNSIP != want.RemoteDNSIP ||
spec.DomesticDNSDomain != want.DomesticDNSDomain || len(spec.DnsHosts) != 1 || spec.DnsHosts["lknpd.nalog.ru"] != "213.24.64.181" ||
strings.Join(spec.RouteOrder, ",") != strings.Join(want.RouteOrder, ",") ||
strings.Join(spec.DirectSites, ",") != strings.Join(want.DirectSites, ",") ||
strings.Join(spec.DirectIp, ",") != strings.Join(want.DirectIp, ",") ||
strings.Join(spec.ProxySites, ",") != strings.Join(want.ProxySites, ",") ||
strings.Join(spec.BlockSites, ",") != strings.Join(want.BlockSites, ",") {
t.Fatalf("spec = %+v\nwant %+v", spec, want)
}
}
func TestParseJsonRoutingSpecPartialPayload(t *testing.T) {
spec, _, err := parseJsonRoutingSpec(`{"DirectSites":["geosite:private"],"DomainStrategy":"AsIs"}`)
if err != nil {
t.Fatalf("parse: %v", err)
}
if spec.DomainStrategy != "AsIs" || len(spec.DirectSites) != 1 || spec.DirectSites[0] != "geosite:private" {
t.Fatalf("spec = %+v", spec)
}
if len(spec.RouteOrder) != 0 || len(spec.DnsHosts) != 0 || spec.RemoteDNSIP != "" {
t.Fatalf("unset fields must stay zero: %+v", spec)
}
if spec.empty() {
t.Fatalf("empty() must report false when any field is set: %+v", spec)
}
}
func TestParseJsonRoutingSpecRouteOrderUnknownSegments(t *testing.T) {
spec, _, err := parseJsonRoutingSpec(`{"RouteOrder":"block-foo-direct"}`)
if err != nil {
t.Fatalf("parse: %v", err)
}
if strings.Join(spec.RouteOrder, ",") != "block,direct" {
t.Fatalf("RouteOrder = %v", spec.RouteOrder)
}
}
func TestParseJsonRoutingSpecDeeplinks(t *testing.T) {
payload := mustMarshal(t, fullRoutingPayload())
cases := []string{
"happ://routing/onadd/" + b64Std(payload),
"incy://routing/onadd/" + b64Std(payload),
"happ://routing/onadd/" + b64URL(payload),
}
for _, raw := range cases {
spec, _, err := parseJsonRoutingSpec(raw)
if err != nil {
t.Fatalf("parse %q: %v", raw[:32], err)
}
if spec.DomainStrategy != "IPIfNonMatch" || len(spec.DirectSites) != 2 || spec.RouteOrder[1] != "proxy" {
t.Fatalf("spec from %q = %+v", raw[:32], spec)
}
}
}
func TestParseJsonRoutingSpecRejectsBadPayloads(t *testing.T) {
cases := []string{
"not json at all",
"[1,2,3]",
`{"DirectSites":"geosite:private"}`,
`{"DirectSites":["a",1]}`,
`{"DnsHosts":{"a":1}}`,
`{"DomainStrategy":5}`,
"happ://routing/onadd/!!!!not-base64!!!!",
}
for _, raw := range cases {
if _, _, err := parseJsonRoutingSpec(raw); err == nil {
t.Fatalf("payload %q was accepted", raw)
}
}
}
func TestParseJsonRoutingSpecEmpty(t *testing.T) {
for _, raw := range []string{"", " ", "\n"} {
spec, remote, err := parseJsonRoutingSpec(raw)
if err != nil || remote || !spec.empty() {
t.Fatalf("raw=%q spec=%+v remote=%v err=%v", raw, spec, remote, err)
}
}
}
func TestParseJsonRoutingSpecRemoteURL(t *testing.T) {
oldResolver := routingSourceResolver
t.Cleanup(func() { routingSourceResolver = oldResolver })
routingSourceResolver = newRemoteRoutingResolver(remoteRoutingTestClient(func(*http.Request) (*http.Response, error) {
return remoteRoutingResponse(200, mustMarshal(t, fullRoutingPayload())), nil
}), false)
const source = "https://example.com/DEFAULT.JSON"
primeRemoteRouting(t, routingSourceResolver, remoteRoutingJson, source)
spec, _, err := parseJsonRoutingSpec(source)
if err != nil {
t.Fatalf("parse: err=%v", err)
}
if spec.DomainStrategy != "IPIfNonMatch" || len(spec.BlockSites) != 1 {
t.Fatalf("spec = %+v", spec)
}
}
func TestParseJsonRoutingSpecRemoteUnavailable(t *testing.T) {
oldResolver := routingSourceResolver
t.Cleanup(func() { routingSourceResolver = oldResolver })
routingSourceResolver = newRemoteRoutingResolver(remoteRoutingTestClient(func(*http.Request) (*http.Response, error) {
return remoteRoutingResponse(200, "routing.help"), nil
}), false)
if _, _, err := parseJsonRoutingSpec("https://example.com/bad"); err == nil {
t.Fatal("unavailable remote source must error")
}
}
// normalizeHappRouting accepts happ://routing/add/ as a routing deeplink
// (remote_routing.go), so the baked-JSON parser has to accept it too.
func TestParseJsonRoutingSpecAcceptsAddDeeplink(t *testing.T) {
payload := mustMarshal(t, fullRoutingPayload())
for _, prefix := range []string{"happ://routing/onadd/", "happ://routing/add/", "incy://routing/onadd/"} {
t.Run(prefix, func(t *testing.T) {
if _, err := normalizeHappRouting([]byte(prefix + b64Std(payload))); err != nil &&
!strings.HasPrefix(prefix, "incy://") {
t.Fatalf("normalizeHappRouting rejects %s: %v", prefix, err)
}
spec, remote, err := parseJsonRoutingSpec(prefix + b64Std(payload))
if err != nil || remote {
t.Fatalf("parse %s: err=%v remote=%v", prefix, err, remote)
}
if spec.empty() {
t.Fatalf("parse %s: spec is empty, routing would not be baked", prefix)
}
if spec.DomainStrategy != "IPIfNonMatch" {
t.Fatalf("parse %s: DomainStrategy = %q", prefix, spec.DomainStrategy)
}
})
}
}
+50 -9
View File
@@ -9,6 +9,7 @@ import (
"slices"
"sort"
"strings"
"sync"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
@@ -30,11 +31,22 @@ type SubJsonService struct {
mux string
observatory subBalancerObservatoryConfig
// bakedRouting is re-resolved per request: a remote URL may be cold at
// construction time and warm up later via the cron job.
routingRules string
bakedRoutingMu sync.Mutex
bakedRouting *bakedRoutingState
SubService *SubService
}
type bakedRoutingState struct {
spec jsonRoutingSpec
configJson map[string]any
}
// 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, rules string, finalMask string, routingRules string, subService *SubService) *SubJsonService {
var configJson map[string]any
var defaultOutbounds []json_util.RawMessage
_ = json.Unmarshal([]byte(defaultJson), &configJson)
@@ -45,7 +57,9 @@ func NewSubJsonService(mux string, rules string, finalMask string, subService *S
}
}
if rules != "" {
// A baked routing profile replaces the template's dns and routing subtrees
// outright; the legacy simple-rules setting only applies without a profile.
if routingRules == "" && rules != "" {
var newRules []any
routing, _ := configJson["routing"].(map[string]any)
defaultRules, _ := routing["rules"].([]any)
@@ -60,11 +74,35 @@ func NewSubJsonService(mux string, rules string, finalMask string, subService *S
defaultOutbounds: defaultOutbounds,
finalMask: finalMask,
mux: mux,
routingRules: routingRules,
observatory: defaultSubBalancerObservatoryConfig(),
SubService: subService,
}
}
// Re-resolved per call so an upstream edit reaches the documents without a
// restart; a failed resolve keeps the last good template.
func (s *SubJsonService) bakedTemplate() map[string]any {
if s.routingRules == "" {
return s.configJson
}
spec := resolveJsonRoutingSpec(s.routingRules)
s.bakedRoutingMu.Lock()
defer s.bakedRoutingMu.Unlock()
if s.bakedRouting != nil {
if spec.empty() || spec.equal(s.bakedRouting.spec) {
return s.bakedRouting.configJson
}
} else if spec.empty() {
return s.configJson
}
template := make(map[string]any, len(s.configJson)+2)
maps.Copy(template, s.configJson)
applyJsonRouting(template, spec)
s.bakedRouting = &bakedRoutingState{spec: spec, configJson: template}
return template
}
// GetJson generates a JSON subscription configuration for the given subscription ID and host.
func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bool) (string, string, error) {
subReq := s.SubService.ForRequest(host)
@@ -153,7 +191,7 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
newOutbounds := []json_util.RawMessage{outbound}
newOutbounds = append(newOutbounds, s.defaultOutbounds...)
newConfigJson := make(map[string]any)
maps.Copy(newConfigJson, s.configJson)
maps.Copy(newConfigJson, s.bakedTemplate())
newConfigJson["outbounds"] = newOutbounds
newConfigJson["remarks"] = remark
newConfig, _ := json.MarshalIndent(newConfigJson, "", " ")
@@ -455,9 +493,12 @@ func (s *SubJsonService) buildBalancerConfig(balancer *model.SubBalancer, entrie
outbounds := append([]json_util.RawMessage{}, proxies...)
outbounds = append(outbounds, s.defaultOutbounds...)
// The routing subtree in s.configJson is shared by every emitted document;
// clone it (and each rule map) before pointing rules at the balancer.
baseRouting, _ := s.configJson["routing"].(map[string]any)
// One template per document: two resolves could straddle a profile refresh
// and pair this document's dns with the other revision's routing.
template := s.bakedTemplate()
// Clone the shared routing subtree (and each rule map) before pointing
// rules at the balancer.
baseRouting, _ := template["routing"].(map[string]any)
routing := make(map[string]any, len(baseRouting)+1)
maps.Copy(routing, baseRouting)
baseRules, _ := baseRouting["rules"].([]any)
@@ -493,8 +534,8 @@ func (s *SubJsonService) buildBalancerConfig(balancer *model.SubBalancer, entrie
}
routing["balancers"] = []any{balancerEntry}
newConfigJson := make(map[string]any, len(s.configJson)+2)
maps.Copy(newConfigJson, s.configJson)
newConfigJson := make(map[string]any, len(template)+2)
maps.Copy(newConfigJson, template)
newConfigJson["outbounds"] = outbounds
newConfigJson["remarks"] = balancer.Remark
newConfigJson["routing"] = routing
@@ -614,7 +655,7 @@ func (s *SubJsonService) getConfig(subReq *SubService, inbound *model.Inbound, c
newOutbounds = append(newOutbounds, s.defaultOutbounds...)
newConfigJson := make(map[string]any)
maps.Copy(newConfigJson, s.configJson)
maps.Copy(newConfigJson, s.bakedTemplate())
transport, _ := newStream["network"].(string)
newConfigJson["outbounds"] = newOutbounds
+21 -21
View File
@@ -36,7 +36,7 @@ func outboundSettings(t *testing.T, raw []byte) map[string]any {
}
func TestDefaultJSONUsesCompatibleLocalInbounds(t *testing.T) {
svc := NewSubJsonService("", "", "", nil)
svc := NewSubJsonService("", "", "", "", nil)
inbounds, ok := svc.configJson["inbounds"].([]any)
if !ok {
t.Fatalf("default JSON inbounds = %#v, want array", svc.configJson["inbounds"])
@@ -81,7 +81,7 @@ func TestDefaultJSONUsesCompatibleLocalInbounds(t *testing.T) {
func TestSubJsonServiceVisionFlowDisablesTCPMuxOnly(t *testing.T) {
globalMux := `{"enabled":true,"concurrency":8,"xudpConcurrency":16,"xudpProxyUDP443":"reject"}`
svc := NewSubJsonService(globalMux, "", "", nil)
svc := NewSubJsonService(globalMux, "", "", "", nil)
inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VLESS, Settings: `{"encryption":"none"}`}
decode := func(raw []byte) map[string]any {
@@ -119,7 +119,7 @@ func TestSubJsonServiceVisionFlowDisablesTCPMuxOnly(t *testing.T) {
func TestSubJsonServiceInjectsGlobalFinalMask(t *testing.T) {
finalMask := `{"tcp":[{"type":"fragment","settings":{"packets":"tlshello","length":"100-200","delay":"10-20"}}],"udp":[{"type":"noise","settings":{"noise":[{"type":"base64","packet":"SGVsbG8="}]}}],"quicParams":{"congestion":"bbr"}}`
svc := NewSubJsonService("", "", finalMask, nil)
svc := NewSubJsonService("", "", finalMask, "", nil)
if hasDirectOutOutbound(svc) {
t.Fatal("direct_out outbound must never be emitted")
@@ -156,7 +156,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"}},
@@ -176,7 +176,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")
@@ -190,7 +190,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)
@@ -203,7 +203,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)
@@ -222,7 +222,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")
}
@@ -235,7 +235,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)
}
@@ -245,7 +245,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")
}
@@ -261,7 +261,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)
@@ -274,7 +274,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)
@@ -286,7 +286,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.
@@ -333,7 +333,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"}}`
@@ -387,7 +387,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 {
@@ -403,13 +403,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)
@@ -421,7 +421,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"}},
@@ -466,7 +466,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")
}
@@ -510,13 +510,13 @@ 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)
}
}
func TestSubJsonServiceSkipsAmneziaWG(t *testing.T) {
if got := NewSubJsonService("", "", "", nil).getConfig(&SubService{address: "sub.example.com"}, &model.Inbound{Listen: "203.0.113.8", Port: 51820, Protocol: model.AmneziaWG}, model.Client{}, "sub.example.com"); len(got) != 0 {
if got := NewSubJsonService("", "", "", "", nil).getConfig(&SubService{address: "sub.example.com"}, &model.Inbound{Listen: "203.0.113.8", Port: 51820, Protocol: model.AmneziaWG}, model.Client{}, "sub.example.com"); len(got) != 0 {
t.Fatalf("getConfig emitted %d unsupported AmneziaWG Xray config(s)", len(got))
}
}
+10 -10
View File
@@ -29,7 +29,7 @@ func initMutDB(t *testing.T) {
func TestSubJsonService_CustomRulesPrepended(t *testing.T) {
rules := `[{"type":"field","domain":["geosite:ads"],"outboundTag":"block"}]`
svc := NewSubJsonService("", rules, "", nil)
svc := NewSubJsonService("", rules, "", "", nil)
routing, ok := svc.configJson["routing"].(map[string]any)
if !ok {
@@ -47,7 +47,7 @@ func TestSubJsonService_CustomRulesPrepended(t *testing.T) {
}
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 +67,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 +103,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 +111,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")
+27 -17
View File
@@ -30,6 +30,7 @@ type remoteRoutingKind string
const (
remoteRoutingHapp remoteRoutingKind = "happ"
remoteRoutingJson remoteRoutingKind = "jsonhapp"
remoteRoutingClash remoteRoutingKind = "clash"
remoteRoutingCacheTTL = 10 * time.Minute
@@ -40,6 +41,12 @@ const (
remoteRoutingClashMaxBody = 2 << 20 // 2 MiB
)
// isHappPayloadKind reports whether the kind carries a happ-payload source:
// same validation and size caps, but a separate persisted cache row.
func isHappPayloadKind(kind remoteRoutingKind) bool {
return kind == remoteRoutingHapp || kind == remoteRoutingJson
}
var errRemoteRoutingUnavailable = errors.New("remote routing source is temporarily unavailable")
type remoteRoutingKey struct {
@@ -167,19 +174,22 @@ func (r *remoteRoutingResolver) resolveEntry(kind remoteRoutingKind, raw string)
}
// 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,
// from the cron job; concurrent resolver reads are safe, fetches coalesce.
func RefreshRemoteRoutingSources(happ, clash, jsonRouting string) {
for kind, raw := range map[remoteRoutingKind][]string{
remoteRoutingHapp: {happ},
remoteRoutingJson: {jsonRouting},
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)
for _, source := range raw {
_, remote, parseErr := common.ParseRemoteRoutingURL(source)
if parseErr != nil {
logger.Warningf("Remote %s routing source is invalid", kind)
continue
}
if remote {
_ = routingSourceResolver.refreshSource(kind, source)
}
}
}
}
@@ -286,7 +296,7 @@ func (r *remoteRoutingResolver) fetch(key remoteRoutingKey, previous remoteRouti
}
return previous, nil
}
if key.kind == remoteRoutingHapp && isRemoteHappRedirect(resp.StatusCode) {
if isHappPayloadKind(key.kind) && isRemoteHappRedirect(resp.StatusCode) {
location := strings.TrimSpace(resp.Header.Get("Location"))
content, locationErr := normalizeHappRouting([]byte(location))
if locationErr != nil {
@@ -321,7 +331,7 @@ func (r *remoteRoutingResolver) fetch(key remoteRoutingKey, previous remoteRouti
if err != nil {
return remoteRoutingCacheEntry{}, err
}
if key.kind == remoteRoutingHapp && len(content) > remoteRoutingHappMaxValue {
if isHappPayloadKind(key.kind) && len(content) > remoteRoutingHappMaxValue {
return remoteRoutingCacheEntry{}, errors.New("Happ routing header exceeds the size limit")
}
return remoteRoutingCacheEntry{
@@ -346,7 +356,7 @@ func isRemoteHappRedirect(status int) bool {
func normalizeRemoteRoutingContent(kind remoteRoutingKind, body []byte) (string, map[string]any, error) {
switch kind {
case remoteRoutingHapp:
case remoteRoutingHapp, remoteRoutingJson:
content, err := normalizeHappRouting(body)
return content, nil, err
case remoteRoutingClash:
@@ -573,7 +583,7 @@ func (r *remoteRoutingResolver) triggerPersistedLoad() {
func (r *remoteRoutingResolver) loadPersisted() {
loaded := make(map[remoteRoutingKey]remoteRoutingCacheEntry, 2)
for _, kind := range []remoteRoutingKind{remoteRoutingHapp, remoteRoutingClash} {
for _, kind := range []remoteRoutingKind{remoteRoutingHapp, remoteRoutingJson, remoteRoutingClash} {
var setting model.Setting
err := database.GetDB().Where("key = ?", remoteRoutingSettingKey(kind)).First(&setting).Error
if err != nil {
@@ -590,7 +600,7 @@ func (r *remoteRoutingResolver) loadPersisted() {
if err != nil {
continue
}
if kind == remoteRoutingHapp && len(normalized) > remoteRoutingHappMaxValue {
if isHappPayloadKind(kind) && len(normalized) > remoteRoutingHappMaxValue {
continue
}
entry.Content = normalized
+6
View File
@@ -150,6 +150,11 @@ func (s *Server) initRouter() (*gin.Engine, error) {
SubJsonRules = ""
}
SubJsonRoutingRules, err := s.settingService.GetSubJsonRoutingRules()
if err != nil {
SubJsonRoutingRules = ""
}
SubJsonFinalMask, err := s.settingService.GetSubJsonFinalMask()
if err != nil {
SubJsonFinalMask = ""
@@ -310,6 +315,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
WithSUBUpdateInterval(SubUpdates),
WithSUBJsonMux(SubJsonMux),
WithSUBJsonRules(SubJsonRules),
WithSUBJsonRoutingRules(SubJsonRoutingRules),
WithSUBJsonFinalMask(SubJsonFinalMask),
WithSUBJsonObservatory(SubJsonObservatory),
WithSUBClashEnableRouting(SubClashEnableRouting),
@@ -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)
+12 -12
View File
@@ -57,7 +57,7 @@ func TestSubJson_BalancerDocument(t *testing.T) {
})
rules := `[{"type":"field","domain":["geosite:example"],"outboundTag":"proxy"}]`
js := NewSubJsonService("", rules, "", NewSubService(""))
js := NewSubJsonService("", rules, "", "", NewSubService(""))
out, _, err := js.GetJson("s1", "req.example.com", true)
if err != nil {
t.Fatalf("GetJson: %v", err)
@@ -155,7 +155,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 +189,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 +213,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 +241,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 +287,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 +322,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 +345,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 +386,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 {
@@ -444,7 +444,7 @@ func TestSubJson_BalancerLeastLoadCosts(t *testing.T) {
MemberWeights: map[int]float64{fast.Id: 0.2}, 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)
@@ -476,7 +476,7 @@ func TestSubJson_BalancerLeastLoadWithoutWeightsOmitsCosts(t *testing.T) {
Remark: "plain", Strategy: "leastLoad", 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)
@@ -498,7 +498,7 @@ func TestSubJson_BalancerCostsSkippedForNonLeastLoadStrategy(t *testing.T) {
MemberWeights: map[int]float64{a.Id: 0.5}, 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)
+1 -1
View File
@@ -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 {
+3 -3
View File
@@ -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)
@@ -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)
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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)