Add remote routing URL support (#6168)

* Add remote routing URL support

* Harden remote routing refresh

* fix(sub): harden remote routing fetch and accept Mihomo src rule flag

Remote routing bytes reach the YAML/JSON parsers from goroutines that run
outside Gin's recovery, so a parser panic on crafted input would take down
the whole panel. Contain it in fetch() (a panic now degrades to a failed
refresh that keeps the last-good value and releases the in-flight slot)
and start the refresh, cache-load and startup-warm goroutines through
common.GoRecover like the other background workers.

The route-graph validator only skipped a trailing no-resolve flag, so a
valid Mihomo rule like IP-CIDR,x,DIRECT,no-resolve,src was rejected as an
unknown target; skip both option flags.

Also deduplicate the HTTPS-source classification into
common.ParseRemoteRoutingURL so the save-time validator and the resolver
can never drift (internal/sub imports internal/web/service, so the copy
existed only to avoid the import cycle), move the test-only
mergeRemoteClashRulesYAML helper into the test file, and trim oversized
comment blocks.

---------

Co-authored-by: Duxxie <yelloduxx@users.noreply.github.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
This commit is contained in:
Duxxie
2026-08-18 17:02:11 +03:00
committed by GitHub
parent 3a2f9b48da
commit 380aff4d82
25 changed files with 1835 additions and 54 deletions
+250 -2
View File
@@ -1,6 +1,7 @@
package sub
import (
"errors"
"fmt"
"maps"
"strings"
@@ -98,8 +99,15 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
}
if s.enableRouting {
if err := mergeClashRulesYAML(config, s.clashRules); err != nil {
return "", "", err
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
}
}
}
@@ -814,6 +822,246 @@ func mergeClashRulesYAML(base map[string]any, raw string) error {
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
+10 -5
View File
@@ -422,8 +422,11 @@ func (a *SUBController) subs(c *gin.Context) {
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 != "" {
result.WriteString(a.subIncyRoutingRules)
result.WriteString("\n")
incyRules, _, err := resolveIncyRoutingSource(a.subIncyRoutingRules)
if err == nil && strings.TrimSpace(incyRules) != "" {
result.WriteString(incyRules)
result.WriteString("\n")
}
}
if a.subEncrypt {
@@ -828,12 +831,14 @@ func (a *SUBController) ApplyCommonHeaders(
c.Writer.Header().Set("Announce", "base64:"+base64.StdEncoding.EncodeToString([]byte(profileAnnounce)))
}
// Advanced (Happ)
// 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 profileRoutingRules != "" {
c.Writer.Header().Set("Routing", profileRoutingRules)
if (routingErr == nil || !remote) && strings.TrimSpace(rules) != "" {
c.Writer.Header().Set("Routing", rules)
}
if profileHideSettings {
c.Writer.Header().Set("Hide-Settings", "1")
+623
View File
@@ -0,0 +1,623 @@
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 strings.HasPrefix(text, prefix) {
payload = strings.TrimPrefix(text, prefix)
break
}
}
if payload == "" {
return "", errors.New("Happ response is neither routing JSON nor a routing deeplink")
}
decoded, err := decodeRoutingBase64(payload)
if err != nil {
return "", fmt.Errorf("invalid Happ routing payload: %w", err)
}
if _, err := validateAndCompactJSONObject(decoded); err != nil {
return "", fmt.Errorf("invalid Happ routing payload JSON: %w", err)
}
return text, nil
}
func validateAndCompactJSONObject(raw []byte) ([]byte, error) {
var object map[string]any
if err := json.Unmarshal(raw, &object); err != nil {
return nil, err
}
if object == nil {
return nil, errors.New("expected a JSON object")
}
return json.Marshal(object)
}
func decodeRoutingBase64(value string) ([]byte, error) {
value = strings.TrimSpace(value)
encodings := []*base64.Encoding{
base64.StdEncoding,
base64.RawStdEncoding,
base64.URLEncoding,
base64.RawURLEncoding,
}
var lastErr error
for _, encoding := range encodings {
decoded, err := encoding.DecodeString(value)
if err == nil {
return decoded, nil
}
lastErr = err
}
return nil, lastErr
}
func normalizeClashRouting(body []byte) (string, map[string]any, error) {
text := strings.TrimSpace(string(body))
if text == "" {
return "", nil, errors.New("empty Clash routing response")
}
var document map[string]any
if err := yaml.Unmarshal([]byte(text), &document); err != nil {
return "", nil, fmt.Errorf("invalid Clash routing YAML: %w", err)
}
if len(document) == 0 {
return "", nil, errors.New("Clash routing response must be a YAML map")
}
hasSupportedKey := false
for key := range document {
if remoteClashAllowedKey(key) {
hasSupportedKey = true
break
}
}
if !hasSupportedKey {
return "", nil, errors.New("Clash routing response has no supported routing keys")
}
base := map[string]any{
"proxies": []map[string]any{{"name": "validation-node", "type": "vless"}},
"proxy-groups": []map[string]any{{
"name": "PROXY", "type": "select", "proxies": []string{"validation-node", "DIRECT"},
}},
"rules": []string{"MATCH,PROXY"},
}
if err := mergeRemoteClashRules(base, document); err != nil {
return "", nil, fmt.Errorf("invalid remote Clash routing schema: %w", err)
}
return text, document, nil
}
func resolveIncyRoutingSource(raw string) (string, bool, error) {
source, remote, err := common.ParseRemoteRoutingURL(raw)
if err != nil || !remote {
return raw, remote, err
}
return "incy://autorouting/onadd/" + source, true, nil
}
func newRemoteRoutingHTTPClient() *http.Client {
transport := &http.Transport{
Proxy: nil,
DialContext: netsafe.SSRFGuardedDialContext,
ForceAttemptHTTP2: true,
TLSHandshakeTimeout: 4 * time.Second,
ResponseHeaderTimeout: 5 * time.Second,
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
}
return &http.Client{
Timeout: remoteRoutingHTTPTimeout,
Transport: transport,
CheckRedirect: checkRemoteRoutingRedirect,
}
}
func checkRemoteRoutingRedirect(req *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return errors.New("stopped after 5 redirects")
}
if strings.EqualFold(req.URL.Scheme, "happ") {
// routing.help-style services publish the deeplink as the final Location;
// hand the 3xx back to fetch(), which validates it without a request.
return http.ErrUseLastResponse
}
if !strings.EqualFold(req.URL.Scheme, "https") || req.URL.Hostname() == "" || req.URL.User != nil {
return errors.New("remote routing redirect must stay on an absolute HTTPS URL")
}
// The guarded dialer re-resolves, validates and connects to the same public
// address, including on every HTTPS redirect hop.
return nil
}
func remoteRoutingHost(source string) string {
u, err := url.Parse(source)
if err != nil || u.Hostname() == "" {
return "unknown host"
}
return u.Hostname()
}
func remoteRoutingSettingKey(kind remoteRoutingKind) string {
return "_subRemoteRoutingCache_" + string(kind)
}
func (r *remoteRoutingResolver) ensurePersistedLoaded() {
if !r.persist {
return
}
r.mu.Lock()
loaded := r.loaded
r.mu.Unlock()
if loaded {
return
}
r.loadMu.Lock()
defer r.loadMu.Unlock()
r.mu.Lock()
loaded = r.loaded
r.mu.Unlock()
if loaded {
return
}
db := database.GetDB()
if db == nil {
return
}
sqlDB, err := db.DB()
if err != nil || sqlDB.Ping() != nil {
return
}
r.loadPersisted()
r.mu.Lock()
r.loaded = true
r.mu.Unlock()
}
// triggerPersistedLoad keeps SQLite off the subscription request path: requests
// schedule at most one background load; the startup job loads synchronously.
func (r *remoteRoutingResolver) triggerPersistedLoad() {
if !r.persist {
return
}
r.mu.Lock()
if r.loaded || r.loadInFlight {
r.mu.Unlock()
return
}
r.loadInFlight = true
r.mu.Unlock()
common.GoRecover("remote-routing-cache-load", func() {
defer func() {
r.mu.Lock()
r.loadInFlight = false
r.mu.Unlock()
}()
r.ensurePersistedLoaded()
})
}
func (r *remoteRoutingResolver) loadPersisted() {
loaded := make(map[remoteRoutingKey]remoteRoutingCacheEntry, 2)
for _, kind := range []remoteRoutingKind{remoteRoutingHapp, remoteRoutingClash} {
var setting model.Setting
err := database.GetDB().Where("key = ?", remoteRoutingSettingKey(kind)).First(&setting).Error
if err != nil {
continue
}
var entry remoteRoutingCacheEntry
if json.Unmarshal([]byte(setting.Value), &entry) != nil || entry.Source == "" || entry.Content == "" || entry.FetchedAt <= 0 {
continue
}
if _, remote, err := common.ParseRemoteRoutingURL(entry.Source); err != nil || !remote {
continue
}
normalized, clash, err := normalizeRemoteRoutingContent(kind, []byte(entry.Content))
if err != nil {
continue
}
if kind == remoteRoutingHapp && len(normalized) > remoteRoutingHappMaxValue {
continue
}
entry.Content = normalized
entry.Clash = clash
loaded[remoteRoutingKey{kind: kind, source: entry.Source}] = entry
}
r.mu.Lock()
for key, entry := range loaded {
current, exists := r.entries[key]
if !exists || entry.FetchedAt > current.FetchedAt {
r.entries[key] = entry
}
}
r.mu.Unlock()
}
func (r *remoteRoutingResolver) persistEntry(kind remoteRoutingKind, entry remoteRoutingCacheEntry) {
db := database.GetDB()
if db == nil {
return
}
encoded, err := json.Marshal(entry)
if err != nil {
return
}
key := remoteRoutingSettingKey(kind)
var setting model.Setting
err = db.Where("key = ?", key).First(&setting).Error
if database.IsNotFound(err) {
err = db.Create(&model.Setting{Key: key, Value: string(encoded)}).Error
} else if err == nil {
setting.Value = string(encoded)
err = db.Save(&setting).Error
}
if err != nil {
logger.Warningf("Could not persist the last valid %s remote routing value", kind)
}
}
+750
View File
@@ -0,0 +1,750 @@
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())
}
}