mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-02 11:15:03 +03:00
661a169045
Remote routing sources are fetched documents the panel re-renders on every subscription request. A Clash YAML document stays small through aliases, which decode to one shared value but are written out in full, so it now has to fit a 4 MiB written-size budget and 16 levels of nesting when it is accepted (and when a persisted copy is loaded). A JSON routing profile could repeat route groups, each copy repeating every site list; a group now counts once, since a repeat can never match.
370 lines
11 KiB
Go
370 lines
11 KiB
Go
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":
|
|
// A repeat can never match (the first rule wins) and would copy every list again.
|
|
if !slices.Contains(spec.RouteOrder, segment) {
|
|
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
|
|
}
|