Sfoglia il codice sorgente

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 <[email protected]>
Co-authored-by: Sanaei <[email protected]>
Duxxie 16 ore fa
parent
commit
380aff4d82

+ 13 - 7
frontend/src/pages/settings/SubscriptionGeneralTab.tsx

@@ -1,4 +1,4 @@
-import { Alert, Button, Input, InputNumber, Switch, Tabs } from 'antd';
+import { Alert, Button, Input, InputNumber, Switch, Tabs, Tag } from 'antd';
 import { BranchesOutlined, CompassOutlined, IdcardOutlined, InfoCircleOutlined, NodeIndexOutlined, SafetyCertificateOutlined, SettingOutlined } from '@ant-design/icons';
 import { useTranslation } from 'react-i18next';
 import { useNavigate } from 'react-router';
@@ -15,6 +15,12 @@ interface SubscriptionGeneralTabProps {
   updateSetting: (patch: Partial<AllSetting>) => void;
 }
 
+const isRemoteRoutingSource = (value: string) => /^https:\/\/\S+$/i.test(value.trim());
+
+const remoteSourceBadge = (value: string) => (
+  isRemoteRoutingSource(value) ? <Tag color="blue">HTTPS URL</Tag> : undefined
+);
+
 export default function SubscriptionGeneralTab({ allSetting, updateSetting }: SubscriptionGeneralTabProps) {
   const { t } = useTranslation();
   const navigate = useNavigate();
@@ -193,8 +199,8 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
             <SettingListItem paddings="small" title={t('pages.settings.subEnableRouting')} description={t('pages.settings.subEnableRoutingDesc')}>
               <Switch checked={allSetting.subEnableRouting} onChange={(v) => updateSetting({ subEnableRouting: v })} />
             </SettingListItem>
-            <SettingListItem paddings="small" title={t('pages.settings.subRoutingRules')} description={t('pages.settings.subRoutingRulesDesc')}>
-              <Input.TextArea value={allSetting.subRoutingRules} placeholder="happ://routing/add/..."
+            <SettingListItem paddings="small" title={t('pages.settings.subRoutingRules')} badge={remoteSourceBadge(allSetting.subRoutingRules)} description={t('pages.settings.subRoutingRulesDesc')}>
+              <Input.TextArea value={allSetting.subRoutingRules} placeholder="happ://routing/onadd/... or https://.../DEFAULT.DEEPLINK"
                 onChange={(e) => updateSetting({ subRoutingRules: e.target.value })} />
             </SettingListItem>
             <SettingListItem paddings="small" title={t('pages.settings.subHideSettings')} description={t('pages.settings.subHideSettingsDesc')}>
@@ -211,11 +217,11 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
             <SettingListItem paddings="small" title={t('pages.settings.subClashEnableRouting')} description={t('pages.settings.subClashEnableRoutingDesc')}>
               <Switch checked={allSetting.subClashEnableRouting} onChange={(v) => updateSetting({ subClashEnableRouting: v })} />
             </SettingListItem>
-            <SettingListItem paddings="small" title={t('pages.settings.subClashRoutingRules')} description={t('pages.settings.subClashRoutingRulesDesc')}>
+            <SettingListItem paddings="small" title={t('pages.settings.subClashRoutingRules')} badge={remoteSourceBadge(allSetting.subClashRules)} description={t('pages.settings.subClashRoutingRulesDesc')}>
               <Input.TextArea
                 value={allSetting.subClashRules}
                 rows={8}
-                placeholder={'GEOSITE,category-ir,DIRECT\nGEOIP,private,DIRECT'}
+                placeholder={'https://.../routing.yaml\n\nor inline rules:\nGEOSITE,category-ir,DIRECT'}
                 onChange={(e) => updateSetting({ subClashRules: e.target.value })}
               />
             </SettingListItem>
@@ -230,8 +236,8 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
             <SettingListItem paddings="small" title={t('pages.settings.subIncyEnableRouting')} description={t('pages.settings.subIncyEnableRoutingDesc')}>
               <Switch checked={allSetting.subIncyEnableRouting} onChange={(v) => updateSetting({ subIncyEnableRouting: v })} />
             </SettingListItem>
-            <SettingListItem paddings="small" title={t('pages.settings.subIncyRoutingRules')} description={t('pages.settings.subIncyRoutingRulesDesc')}>
-              <Input.TextArea value={allSetting.subIncyRoutingRules} placeholder="incy://routing/onadd/..."
+            <SettingListItem paddings="small" title={t('pages.settings.subIncyRoutingRules')} badge={remoteSourceBadge(allSetting.subIncyRoutingRules)} description={t('pages.settings.subIncyRoutingRulesDesc')}>
+              <Input.TextArea value={allSetting.subIncyRoutingRules} placeholder="incy://routing/onadd/... or https://.../DEFAULT.JSON"
                 onChange={(e) => updateSetting({ subIncyRoutingRules: e.target.value })} />
             </SettingListItem>
           </>

+ 250 - 2
internal/sub/clash_service.go

@@ -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
internal/sub/controller.go

@@ -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 - 0
internal/sub/remote_routing.go

@@ -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 - 0
internal/sub/remote_routing_test.go

@@ -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())
+	}
+}

+ 27 - 1
internal/util/common/url.go

@@ -1,6 +1,10 @@
 package common
 
-import "strings"
+import (
+	"errors"
+	"net/url"
+	"strings"
+)
 
 // EnsureURLScheme prepends https:// to a URL that carries no scheme, so
 // subscription apps and browsers don't resolve it relative to the panel's own
@@ -19,3 +23,25 @@ func EnsureURLScheme(raw string) string {
 	}
 	return "https://" + trimmed
 }
+
+// ParseRemoteRoutingURL classifies a routing settings value: one single-line
+// absolute HTTPS URL is a remote source (canonicalized); anything else is inline.
+func ParseRemoteRoutingURL(raw string) (string, bool, error) {
+	trimmed := strings.TrimSpace(raw)
+	if trimmed == "" || strings.ContainsAny(trimmed, "\r\n") {
+		return "", false, nil
+	}
+	if !strings.HasPrefix(strings.ToLower(trimmed), "https://") {
+		return "", false, nil
+	}
+	u, err := url.Parse(trimmed)
+	if err != nil || u.Host == "" || u.Hostname() == "" {
+		return "", true, errors.New("must be an absolute HTTPS URL")
+	}
+	if u.User != nil {
+		return "", true, errors.New("must not contain URL credentials")
+	}
+	u.Scheme = "https"
+	u.Fragment = ""
+	return u.String(), true, nil
+}

+ 26 - 0
internal/util/common/url_test.go

@@ -27,3 +27,29 @@ func TestEnsureURLScheme(t *testing.T) {
 		})
 	}
 }
+
+func TestParseRemoteRoutingURLKeepsInlineCompatibility(t *testing.T) {
+	tests := []struct {
+		name       string
+		input      string
+		wantSource string
+		wantRemote bool
+		wantErr    bool
+	}{
+		{name: "deeplink stays inline", input: "happ://routing/onadd/abc"},
+		{name: "plain HTTP stays inline", input: "http://example.com/rules"},
+		{name: "multiline stays inline", input: "https://example.com/rules\nMATCH,PROXY"},
+		{name: "HTTPS source", input: "  https://example.com/rules#ignored  ", wantSource: "https://example.com/rules", wantRemote: true},
+		{name: "uppercase scheme", input: "HTTPS://example.com/rules", wantSource: "https://example.com/rules", wantRemote: true},
+		{name: "credentials rejected", input: "https://user:[email protected]/rules", wantRemote: true, wantErr: true},
+		{name: "missing host rejected", input: "https:///rules", wantRemote: true, wantErr: true},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got, remote, err := ParseRemoteRoutingURL(tt.input)
+			if got != tt.wantSource || remote != tt.wantRemote || (err != nil) != tt.wantErr {
+				t.Fatalf("got=%q remote=%v err=%v", got, remote, err)
+			}
+		})
+	}
+}

+ 1 - 0
internal/web/cadence_test.go

@@ -23,6 +23,7 @@ func TestJobCadencesAreValidCronSpecs(t *testing.T) {
 		"cadenceNodeHeartbeat": cadenceNodeHeartbeat,
 		"cadenceNodeTraffic":   cadenceNodeTraffic,
 		"cadenceOutboundSub":   cadenceOutboundSub,
+		"cadenceRemoteRouting": cadenceRemoteRouting,
 		"cadenceCheckHash":     cadenceCheckHash,
 		"cadenceCPUAlarm":      cadenceCPUAlarm,
 	}

+ 31 - 0
internal/web/job/remote_routing_job.go

@@ -0,0 +1,31 @@
+package job
+
+import (
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+	"github.com/mhsanaei/3x-ui/v3/internal/sub"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
+)
+
+// RemoteRoutingJob keeps remote Happ and Clash/Mihomo routing URLs warm: all
+// network work runs here (cron + startup warm), never in a request handler.
+type RemoteRoutingJob struct {
+	settingService service.SettingService
+}
+
+func NewRemoteRoutingJob() *RemoteRoutingJob {
+	return &RemoteRoutingJob{}
+}
+
+func (j *RemoteRoutingJob) Run() {
+	happ, err := j.settingService.GetSubRoutingRules()
+	if err != nil {
+		logger.Warning("Could not read Happ routing source:", err)
+		return
+	}
+	clash, err := j.settingService.GetSubClashRules()
+	if err != nil {
+		logger.Warning("Could not read Clash routing source:", err)
+		return
+	}
+	sub.RefreshRemoteRoutingSources(happ, clash)
+}

+ 20 - 0
internal/web/service/setting.go

@@ -1327,6 +1327,26 @@ func validateSettingsURLs(allSetting *entity.AllSetting) error {
 	// the scheme instead of forcing SanitizeHTTPURL's http(s)-only rule.
 	allSetting.SubSupportUrl = common.EnsureURLScheme(allSetting.SubSupportUrl)
 	allSetting.SubProfileUrl = common.EnsureURLScheme(allSetting.SubProfileUrl)
+	for name, value := range map[string]*string{
+		"Happ routing source":         &allSetting.SubRoutingRules,
+		"Clash/Mihomo routing source": &allSetting.SubClashRules,
+		"Incy routing source":         &allSetting.SubIncyRoutingRules,
+	} {
+		if err := validateRemoteRoutingURLSetting(name, value); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func validateRemoteRoutingURLSetting(name string, value *string) error {
+	canonical, remote, err := common.ParseRemoteRoutingURL(*value)
+	if err != nil {
+		return common.NewError(name, err.Error())
+	}
+	if remote {
+		*value = canonical
+	}
 	return nil
 }
 

+ 38 - 0
internal/web/service/setting_remote_routing_test.go

@@ -0,0 +1,38 @@
+package service
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
+)
+
+func TestValidateRemoteRoutingURLSettings(t *testing.T) {
+	tests := []struct {
+		name      string
+		value     string
+		want      string
+		wantError string
+	}{
+		{name: "valid HTTPS", value: " https://example.com/rules#fragment ", want: "https://example.com/rules"},
+		{name: "credentials", value: "https://user:[email protected]/rules", wantError: "must not contain URL credentials"},
+		{name: "missing host", value: "https:///rules", wantError: "absolute HTTPS URL"},
+		{name: "legacy HTTP stays inline", value: "http://example.com/rules", want: "http://example.com/rules"},
+		{name: "multiline Clash stays inline", value: "https://example.com/rules\nMATCH,PROXY", want: "https://example.com/rules\nMATCH,PROXY"},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			settings := &entity.AllSetting{SubRoutingRules: tt.value}
+			err := validateSettingsURLs(settings)
+			if tt.wantError != "" {
+				if err == nil || !strings.Contains(err.Error(), tt.wantError) {
+					t.Fatalf("err=%v, want %q", err, tt.wantError)
+				}
+				return
+			}
+			if err != nil || settings.SubRoutingRules != tt.want {
+				t.Fatalf("value=%q err=%v", settings.SubRoutingRules, err)
+			}
+		})
+	}
+}

+ 3 - 3
internal/web/translation/ar-EG.json

@@ -1158,17 +1158,17 @@
       "subEnableRouting": "تفعيل التوجيه",
       "subEnableRoutingDesc": "إعداد عام لتمكين التوجيه (Routing) في عميل VPN. (فقط لـ Happ)",
       "subRoutingRules": "قواعد التوجيه",
-      "subRoutingRulesDesc": "قواعد التوجيه العامة لعميل VPN. (فقط لـ Happ)",
+      "subRoutingRulesDesc": "ألصق رابط happ:// جاهزًا أو عنوان HTTPS دائمًا. تحدّث اللوحة القواعد البعيدة في الخلفية وتحتفظ بآخر قيمة صالحة، لذلك لا تنتظر طلبات الاشتراك المصدر. (فقط لـ Happ)",
       "subHideSettings": "إخفاء إعدادات الخادم",
       "subHideSettingsDesc": "إخفاء إمكانية عرض وتعديل إعدادات الخادم في عميل VPN. (فقط لـ Happ)",
       "subIncyEnableRouting": "تفعيل التوجيه",
       "subIncyEnableRoutingDesc": "حقن ملف تعريف التوجيه في محتوى الاشتراك لعميل Incy. (فقط لـ Incy)",
       "subIncyRoutingRules": "قواعد التوجيه",
-      "subIncyRoutingRulesDesc": "رابط توجيه Incy المُضاف إلى محتوى الاشتراك، مثل incy://routing/onadd/<base64>. (فقط لـ Incy)",
+      "subIncyRoutingRulesDesc": "ألصق رابط incy:// جاهزًا أو عنوان HTTPS دائمًا لملف JSON. ينشئ Incy ملف autorouting ويحدّثه تلقائيًا. (فقط لـ Incy)",
       "subClashEnableRouting": "تفعيل التوجيه",
       "subClashEnableRoutingDesc": "تضمين قواعد توجيه Clash/Mihomo العامة في اشتراكات YAML المُنشأة.",
       "subClashRoutingRules": "قواعد التوجيه العامة",
-      "subClashRoutingRulesDesc": "قواعد Clash/Mihomo التي تُضاف في بداية كل اشتراك YAML قبل MATCH,PROXY.",
+      "subClashRoutingRulesDesc": "ألصق قواعد/YAML أو عنوان HTTPS دائمًا. تحدّثه اللوحة في الخلفية، وتستورد المجموعات وموفري القواعد والقواعد فقط، وتحافظ على عقد VPN المُنشأة وآخر قيمة صالحة.",
       "subListen": "IP الاستماع",
       "subListenDesc": "عنوان IP لخدمة الاشتراك. (سيبه فاضي عشان يستمع على كل الـ IPs)",
       "subPort": "بورت الاستماع",

+ 3 - 3
internal/web/translation/en-US.json

@@ -1280,17 +1280,17 @@
       "subEnableRouting": "Enable routing",
       "subEnableRoutingDesc": "Global setting to enable routing in the VPN client. (Only for Happ)",
       "subRoutingRules": "Routing rules",
-      "subRoutingRulesDesc": "Global routing rules for the VPN client. (Only for Happ)",
+      "subRoutingRulesDesc": "Paste a ready happ:// deeplink or one permanent HTTPS URL returning a deeplink or JSON. The panel refreshes remote rules in the background and keeps the last valid value, so subscription requests never wait for the source. (Happ only)",
       "subHideSettings": "Hide server settings",
       "subHideSettingsDesc": "Hide the ability to view and edit server configurations in the VPN client. (Only for Happ)",
       "subIncyEnableRouting": "Enable routing",
       "subIncyEnableRoutingDesc": "Inject a routing profile into the subscription body for the Incy client. (Only for Incy)",
       "subIncyRoutingRules": "Routing rules",
-      "subIncyRoutingRulesDesc": "Incy routing deep-link added to the subscription body, e.g. incy://routing/onadd/<base64>. (Only for Incy)",
+      "subIncyRoutingRulesDesc": "Paste a ready incy:// deeplink or one permanent HTTPS URL returning JSON. An HTTPS URL becomes an autorouting profile refreshed by Incy itself. (Incy only)",
       "subClashEnableRouting": "Enable routing",
       "subClashEnableRoutingDesc": "Include global Clash/Mihomo routing rules in generated YAML subscriptions.",
       "subClashRoutingRules": "Global routing rules",
-      "subClashRoutingRulesDesc": "Default Clash/Mihomo rules prepended to every generated YAML subscription before MATCH,PROXY.",
+      "subClashRoutingRulesDesc": "Paste inline rules/YAML or one permanent HTTPS URL. The panel refreshes it in the background, imports only groups/providers/rules, preserves generated VPN nodes and keeps the last valid value.",
       "subListen": "Listen IP",
       "subListenDesc": "The IP address for the subscription service. (leave blank to listen on all IPs)",
       "subPort": "Listen Port",

+ 3 - 3
internal/web/translation/es-ES.json

@@ -1158,17 +1158,17 @@
       "subEnableRouting": "Habilitar enrutamiento",
       "subEnableRoutingDesc": "Configuración global para habilitar el enrutamiento en el cliente VPN. (Solo para Happ)",
       "subRoutingRules": "Reglas de enrutamiento",
-      "subRoutingRulesDesc": "Reglas de enrutamiento globales para el cliente VPN. (Solo para Happ)",
+      "subRoutingRulesDesc": "Pegue un enlace happ:// listo o una URL HTTPS permanente. El panel actualiza las reglas remotas en segundo plano y conserva el último valor válido, sin retrasar las solicitudes de suscripción. (Solo para Happ)",
       "subHideSettings": "Ocultar configuración del servidor",
       "subHideSettingsDesc": "Ocultar la posibilidad de ver y editar las configuraciones del servidor en el cliente VPN. (Solo para Happ)",
       "subIncyEnableRouting": "Habilitar enrutamiento",
       "subIncyEnableRoutingDesc": "Inyectar un perfil de enrutamiento en el cuerpo de la suscripción para el cliente Incy. (Solo para Incy)",
       "subIncyRoutingRules": "Reglas de enrutamiento",
-      "subIncyRoutingRulesDesc": "Enlace de enrutamiento de Incy añadido al cuerpo de la suscripción, p. ej. incy://routing/onadd/<base64>. (Solo para Incy)",
+      "subIncyRoutingRulesDesc": "Pegue un enlace incy:// listo o una URL HTTPS permanente a JSON. Incy crea un perfil de autorouting y lo actualiza automáticamente. (Solo para Incy)",
       "subClashEnableRouting": "Habilitar enrutamiento",
       "subClashEnableRoutingDesc": "Incluir reglas globales de enrutamiento Clash/Mihomo en las suscripciones YAML generadas.",
       "subClashRoutingRules": "Reglas globales de enrutamiento",
-      "subClashRoutingRulesDesc": "Reglas Clash/Mihomo agregadas al inicio de cada suscripción YAML antes de MATCH,PROXY.",
+      "subClashRoutingRulesDesc": "Pegue reglas/YAML o una URL HTTPS permanente. El panel la actualiza en segundo plano, importa solo grupos, proveedores de reglas y reglas, y conserva los nodos VPN generados y el último valor válido.",
       "subListen": "Listening IP",
       "subListenDesc": "Dejar en blanco por defecto para monitorear todas las IPs.",
       "subPort": "Puerto de Suscripción",

+ 3 - 3
internal/web/translation/fa-IR.json

@@ -1162,17 +1162,17 @@
       "subEnableRouting": "فعال‌سازی مسیریابی",
       "subEnableRoutingDesc": "تنظیمات سراسری برای فعال‌سازی مسیریابی در کلاینت VPN. (فقط برای Happ)",
       "subRoutingRules": "قوانین مسیریابی",
-      "subRoutingRulesDesc": "قوانین مسیریابی سراسری برای کلاینت VPN. (فقط برای Happ)",
+      "subRoutingRulesDesc": "یک پیوند آماده happ:// یا یک نشانی دائمی HTTPS وارد کنید. پنل قوانین راه‌دور را در پس‌زمینه به‌روزرسانی و آخرین مقدار معتبر را نگه می‌دارد، بنابراین درخواست اشتراک منتظر منبع نمی‌ماند. (فقط برای Happ)",
       "subHideSettings": "پنهان کردن تنظیمات سرور",
       "subHideSettingsDesc": "پنهان کردن توانایی مشاهده و ویرایش پیکربندی سرور در کلاینت VPN. (فقط برای Happ)",
       "subIncyEnableRouting": "فعال‌سازی مسیریابی",
       "subIncyEnableRoutingDesc": "تزریق پروفایل مسیریابی به بدنه اشتراک برای کلاینت Incy. (فقط برای Incy)",
       "subIncyRoutingRules": "قوانین مسیریابی",
-      "subIncyRoutingRulesDesc": "لینک مسیریابی Incy که به بدنه اشتراک افزوده می‌شود، مثلاً incy://routing/onadd/<base64>. (فقط برای Incy)",
+      "subIncyRoutingRulesDesc": "یک پیوند آماده incy:// یا یک نشانی دائمی HTTPS برای JSON وارد کنید. Incy یک نمایه autorouting می‌سازد و آن را خودکار به‌روزرسانی می‌کند. (فقط برای Incy)",
       "subClashEnableRouting": "فعال‌سازی مسیریابی",
       "subClashEnableRoutingDesc": "قوانین مسیریابی سراسری Clash/Mihomo را در اشتراک‌های YAML تولیدشده وارد کن.",
       "subClashRoutingRules": "قوانین مسیریابی سراسری",
-      "subClashRoutingRulesDesc": "قوانین Clash/Mihomo که پیش از MATCH,PROXY به ابتدای هر اشتراک YAML افزوده می‌شوند.",
+      "subClashRoutingRulesDesc": "قوانین/YAML یا یک نشانی دائمی HTTPS وارد کنید. پنل آن را در پس‌زمینه به‌روزرسانی می‌کند، فقط گروه‌ها، ارائه‌دهندگان قانون و قوانین را وارد می‌کند و گره‌های VPN ساخته‌شده و آخرین مقدار معتبر را حفظ می‌کند.",
       "subListen": "آدرس آی‌پی",
       "subListenDesc": "آدرس آی‌پی برای سرویس سابسکریپشن. برای گوش دادن به‌تمام آی‌پی‌ها خالی‌بگذارید",
       "subPort": "پورت",

+ 3 - 3
internal/web/translation/id-ID.json

@@ -1158,17 +1158,17 @@
       "subEnableRouting": "Aktifkan perutean",
       "subEnableRoutingDesc": "Pengaturan global untuk mengaktifkan perutean (routing) di klien VPN. (Hanya untuk Happ)",
       "subRoutingRules": "Aturan routing",
-      "subRoutingRulesDesc": "Aturan routing global untuk klien VPN. (Hanya untuk Happ)",
+      "subRoutingRulesDesc": "Tempel deeplink happ:// siap pakai atau satu URL HTTPS permanen. Panel memperbarui aturan jarak jauh di latar belakang dan menyimpan nilai valid terakhir, sehingga permintaan langganan tidak menunggu sumber. (Hanya untuk Happ)",
       "subHideSettings": "Sembunyikan pengaturan server",
       "subHideSettingsDesc": "Menyembunyikan kemampuan untuk melihat dan mengedit konfigurasi server di klien VPN. (Hanya untuk Happ)",
       "subIncyEnableRouting": "Aktifkan perutean",
       "subIncyEnableRoutingDesc": "Menyuntikkan profil perutean ke dalam body langganan untuk klien Incy. (Hanya untuk Incy)",
       "subIncyRoutingRules": "Aturan routing",
-      "subIncyRoutingRulesDesc": "Tautan perutean Incy yang ditambahkan ke body langganan, mis. incy://routing/onadd/<base64>. (Hanya untuk Incy)",
+      "subIncyRoutingRulesDesc": "Tempel deeplink incy:// siap pakai atau URL HTTPS permanen ke JSON. Incy membuat profil autorouting dan memperbaruinya secara otomatis. (Hanya untuk Incy)",
       "subClashEnableRouting": "Aktifkan routing",
       "subClashEnableRoutingDesc": "Sertakan aturan routing global Clash/Mihomo dalam langganan YAML yang dibuat.",
       "subClashRoutingRules": "Aturan routing global",
-      "subClashRoutingRulesDesc": "Aturan Clash/Mihomo yang ditambahkan di awal setiap langganan YAML sebelum MATCH,PROXY.",
+      "subClashRoutingRulesDesc": "Tempel aturan/YAML atau satu URL HTTPS permanen. Panel memperbaruinya di latar belakang, hanya mengimpor grup, penyedia aturan, dan aturan, serta mempertahankan node VPN buatan panel dan nilai valid terakhir.",
       "subListen": "IP Pendengar",
       "subListenDesc": "Alamat IP untuk layanan langganan. (biarkan kosong untuk mendengarkan semua IP)",
       "subPort": "Port Pendengar",

+ 3 - 3
internal/web/translation/ja-JP.json

@@ -1158,17 +1158,17 @@
       "subEnableRouting": "ルーティングを有効化",
       "subEnableRoutingDesc": "VPNクライアントでルーティングを有効にするためのグローバル設定。(Happのみ)",
       "subRoutingRules": "ルーティングルール",
-      "subRoutingRulesDesc": "VPNクライアントのグローバルルーティングルール。(Happのみ)",
+      "subRoutingRulesDesc": "完成した happ:// ディープリンク、または永続的な HTTPS URL を入力します。パネルはリモートルールをバックグラウンドで更新し、最後の有効値を保持するため、サブスクリプション要求は取得を待ちません。(Happのみ)",
       "subHideSettings": "サーバー設定を非表示",
       "subHideSettingsDesc": "VPNクライアントでサーバー設定の表示・編集機能を非表示にします。(Happのみ)",
       "subIncyEnableRouting": "ルーティングを有効化",
       "subIncyEnableRoutingDesc": "Incyクライアント用に、サブスクリプション本文へルーティングプロファイルを挿入します。(Incyのみ)",
       "subIncyRoutingRules": "ルーティングルール",
-      "subIncyRoutingRulesDesc": "サブスクリプション本文に追加するIncyルーティングのディープリンク。例: incy://routing/onadd/<base64>。(Incyのみ)",
+      "subIncyRoutingRulesDesc": "完成した incy:// ディープリンク、または JSON への永続的な HTTPS URL を入力します。Incy は autorouting プロファイルを作成し、自動更新します。(Incyのみ)",
       "subClashEnableRouting": "ルーティングを有効化",
       "subClashEnableRoutingDesc": "生成されたYAMLサブスクリプションにClash/Mihomoのグローバルルーティングルールを含めます。",
       "subClashRoutingRules": "グローバルルーティングルール",
-      "subClashRoutingRulesDesc": "各YAMLサブスクリプションのMATCH,PROXYより前に追加されるClash/Mihomoルール。",
+      "subClashRoutingRulesDesc": "ルール/YAML、または永続的な HTTPS URL を入力します。パネルはバックグラウンドで更新し、グループ・ルールプロバイダー・ルールのみを取り込み、生成済み VPN ノードと最後の有効値を保持します。",
       "subListen": "監視IP",
       "subListenDesc": "サブスクリプションサービスが監視するIPアドレス(空白にするとすべてのIPを監視)",
       "subPort": "監視ポート",

+ 3 - 3
internal/web/translation/pt-BR.json

@@ -1158,17 +1158,17 @@
       "subEnableRouting": "Ativar roteamento",
       "subEnableRoutingDesc": "Configuração global para habilitar o roteamento no cliente VPN. (Apenas para Happ)",
       "subRoutingRules": "Regras de roteamento",
-      "subRoutingRulesDesc": "Regras de roteamento globais para o cliente VPN. (Apenas para Happ)",
+      "subRoutingRulesDesc": "Cole um deeplink happ:// pronto ou uma URL HTTPS permanente. O painel atualiza as regras remotas em segundo plano e mantém o último valor válido, sem atrasar as solicitações de assinatura. (Apenas para Happ)",
       "subHideSettings": "Ocultar configurações do servidor",
       "subHideSettingsDesc": "Ocultar a capacidade de visualizar e editar as configurações do servidor no cliente VPN. (Apenas para Happ)",
       "subIncyEnableRouting": "Ativar roteamento",
       "subIncyEnableRoutingDesc": "Injetar um perfil de roteamento no corpo da assinatura para o cliente Incy. (Apenas para Incy)",
       "subIncyRoutingRules": "Regras de roteamento",
-      "subIncyRoutingRulesDesc": "Link de roteamento do Incy adicionado ao corpo da assinatura, ex. incy://routing/onadd/<base64>. (Apenas para Incy)",
+      "subIncyRoutingRulesDesc": "Cole um deeplink incy:// pronto ou uma URL HTTPS permanente para JSON. O Incy cria um perfil de autorouting e o atualiza automaticamente. (Apenas para Incy)",
       "subClashEnableRouting": "Ativar roteamento",
       "subClashEnableRoutingDesc": "Incluir regras globais de roteamento Clash/Mihomo nas assinaturas YAML geradas.",
       "subClashRoutingRules": "Regras globais de roteamento",
-      "subClashRoutingRulesDesc": "Regras Clash/Mihomo adicionadas ao início de cada assinatura YAML antes de MATCH,PROXY.",
+      "subClashRoutingRulesDesc": "Cole regras/YAML ou uma URL HTTPS permanente. O painel a atualiza em segundo plano, importa apenas grupos, provedores de regras e regras, e preserva os nós VPN gerados e o último valor válido.",
       "subListen": "IP de Escuta",
       "subListenDesc": "O endereço IP para o serviço de assinatura. (deixe em branco para escutar em todos os IPs)",
       "subPort": "Porta de Escuta",

+ 3 - 3
internal/web/translation/ru-RU.json

@@ -1158,17 +1158,17 @@
       "subEnableRouting": "Включить маршрутизацию",
       "subEnableRoutingDesc": "Глобальная настройка для включения маршрутизации в VPN-клиенте. (Только для Happ)",
       "subRoutingRules": "Правила маршрутизации",
-      "subRoutingRulesDesc": "Глобальные правила маршрутизации для VPN-клиента. (Только для Happ)",
+      "subRoutingRulesDesc": "Вставьте готовый happ:// deeplink либо одну постоянную HTTPS-ссылку на deeplink или JSON. Панель обновляет удалённые правила в фоне и хранит последнее рабочее значение, поэтому запрос подписки не ждёт источник. (Только для Happ)",
       "subHideSettings": "Скрыть настройки сервера",
       "subHideSettingsDesc": "Скрыть возможность просмотра и редактирования конфигурации сервера в VPN-клиенте. (Только для Happ)",
       "subIncyEnableRouting": "Включить маршрутизацию",
       "subIncyEnableRoutingDesc": "Внедрять профиль маршрутизации в тело подписки для клиента Incy. (Только для Incy)",
       "subIncyRoutingRules": "Правила маршрутизации",
-      "subIncyRoutingRulesDesc": "Ссылка маршрутизации Incy, добавляемая в тело подписки, напр. incy://routing/onadd/<base64>. (Только для Incy)",
+      "subIncyRoutingRulesDesc": "Вставьте готовый incy:// deeplink либо одну постоянную HTTPS-ссылку на JSON. Для HTTPS-ссылки создаётся autorouting-профиль, который Incy обновляет самостоятельно. (Только для Incy)",
       "subClashEnableRouting": "Включить маршрутизацию",
       "subClashEnableRoutingDesc": "Добавлять глобальные правила маршрутизации Clash/Mihomo в сгенерированные YAML-подписки.",
       "subClashRoutingRules": "Глобальные правила маршрутизации",
-      "subClashRoutingRulesDesc": "Правила Clash/Mihomo, добавляемые в начало каждой YAML-подписки перед MATCH,PROXY.",
+      "subClashRoutingRulesDesc": "Вставьте правила/YAML либо одну постоянную HTTPS-ссылку. Панель обновляет её в фоне, импортирует только группы, провайдеры правил и правила, сохраняет созданные VPN-узлы и последнее рабочее значение.",
       "subListen": "Прослушивание IP",
       "subListenDesc": "Оставьте пустым по умолчанию, чтобы отслеживать все IP-адреса",
       "subPort": "Порт подписки",

+ 3 - 3
internal/web/translation/tr-TR.json

@@ -1158,17 +1158,17 @@
       "subEnableRouting": "Yönlendirmeyi etkinleştir",
       "subEnableRoutingDesc": "VPN istemcisinde yönlendirmeyi etkinleştirmek için genel ayar. (Yalnızca Happ için)",
       "subRoutingRules": "Yönlendirme kuralları",
-      "subRoutingRulesDesc": "VPN istemcisi için genel yönlendirme kuralları. (Yalnızca Happ için)",
+      "subRoutingRulesDesc": "Hazır bir happ:// derin bağlantısı veya kalıcı bir HTTPS URL'si yapıştırın. Panel uzak kuralları arka planda yeniler ve son geçerli değeri saklar; abonelik istekleri kaynağı beklemez. (Yalnızca Happ için)",
       "subHideSettings": "Sunucu ayarlarını gizle",
       "subHideSettingsDesc": "VPN istemcisinde sunucu yapılandırmalarını görüntüleme ve düzenleme özelliğini gizleyin. (Yalnızca Happ için)",
       "subIncyEnableRouting": "Yönlendirmeyi etkinleştir",
       "subIncyEnableRoutingDesc": "Incy istemcisi için abonelik gövdesine bir yönlendirme profili ekleyin. (Yalnızca Incy için)",
       "subIncyRoutingRules": "Yönlendirme kuralları",
-      "subIncyRoutingRulesDesc": "Abonelik gövdesine eklenen Incy yönlendirme bağlantısı, örn. incy://routing/onadd/<base64>. (Yalnızca Incy için)",
+      "subIncyRoutingRulesDesc": "Hazır bir incy:// derin bağlantısı veya JSON için kalıcı bir HTTPS URL'si yapıştırın. Incy bir autorouting profili oluşturur ve otomatik olarak günceller. (Yalnızca Incy için)",
       "subClashEnableRouting": "Yönlendirmeyi Etkinleştir",
       "subClashEnableRoutingDesc": "Oluşturulan YAML aboneliklerine genel Clash/Mihomo yönlendirme kurallarını ekler.",
       "subClashRoutingRules": "Genel Yönlendirme Kuralları",
-      "subClashRoutingRulesDesc": "Her YAML aboneliğinin başına MATCH,PROXY öncesinde eklenen varsayılan Clash/Mihomo kuralları.",
+      "subClashRoutingRulesDesc": "Kurallar/YAML veya kalıcı bir HTTPS URL'si yapıştırın. Panel bunu arka planda yeniler, yalnızca grupları, kural sağlayıcılarını ve kuralları içe aktarır; oluşturulan VPN düğümlerini ve son geçerli değeri korur.",
       "subListen": "Dinleme IP",
       "subListenDesc": "Abonelik hizmeti için IP adresi. (tüm IP'leri dinlemek için boş bırakın)",
       "subPort": "Dinleme Portu",

+ 3 - 3
internal/web/translation/uk-UA.json

@@ -1158,17 +1158,17 @@
       "subEnableRouting": "Увімкнути маршрутизацію",
       "subEnableRoutingDesc": "Глобальне налаштування для увімкнення маршрутизації у VPN-клієнті. (Тільки для Happ)",
       "subRoutingRules": "Правила маршрутизації",
-      "subRoutingRulesDesc": "Глобальні правила маршрутизації для VPN-клієнта. (Тільки для Happ)",
+      "subRoutingRulesDesc": "Вставте готове посилання happ:// або одну постійну HTTPS-адресу. Панель оновлює віддалені правила у фоні та зберігає останнє коректне значення, тому запит підписки не чекає на джерело. (Тільки для Happ)",
       "subHideSettings": "Приховати налаштування сервера",
       "subHideSettingsDesc": "Приховати можливість перегляду та редагування конфігурації сервера у VPN-клієнті. (Тільки для Happ)",
       "subIncyEnableRouting": "Увімкнути маршрутизацію",
       "subIncyEnableRoutingDesc": "Вставляти профіль маршрутизації в тіло підписки для клієнта Incy. (Тільки для Incy)",
       "subIncyRoutingRules": "Правила маршрутизації",
-      "subIncyRoutingRulesDesc": "Посилання маршрутизації Incy, що додається в тіло підписки, напр. incy://routing/onadd/<base64>. (Тільки для Incy)",
+      "subIncyRoutingRulesDesc": "Вставте готове посилання incy:// або постійну HTTPS-адресу JSON. Incy створює профіль autorouting і автоматично його оновлює. (Тільки для Incy)",
       "subClashEnableRouting": "Увімкнути маршрутизацію",
       "subClashEnableRoutingDesc": "Додавати глобальні правила маршрутизації Clash/Mihomo до згенерованих YAML-підписок.",
       "subClashRoutingRules": "Глобальні правила маршрутизації",
-      "subClashRoutingRulesDesc": "Правила Clash/Mihomo, що додаються на початок кожної YAML-підписки перед MATCH,PROXY.",
+      "subClashRoutingRulesDesc": "Вставте правила/YAML або одну постійну HTTPS-адресу. Панель оновлює її у фоні, імпортує лише групи, постачальників правил і правила, зберігаючи створені VPN-вузли та останнє коректне значення.",
       "subListen": "Слухати IP",
       "subListenDesc": "IP-адреса для служби підписки. (залиште порожнім, щоб слухати всі IP-адреси)",
       "subPort": "Слухати порт",

+ 3 - 3
internal/web/translation/vi-VN.json

@@ -1158,17 +1158,17 @@
       "subEnableRouting": "Bật định tuyến",
       "subEnableRoutingDesc": "Cài đặt toàn cục để bật định tuyến trong ứng dụng khách VPN. (Chỉ dành cho Happ)",
       "subRoutingRules": "Quy tắc định tuyến",
-      "subRoutingRulesDesc": "Quy tắc định tuyến toàn cầu cho client VPN. (Chỉ dành cho Happ)",
+      "subRoutingRulesDesc": "Dán deeplink happ:// có sẵn hoặc một URL HTTPS cố định. Bảng điều khiển cập nhật quy tắc từ xa trong nền và giữ giá trị hợp lệ gần nhất, nên yêu cầu đăng ký không phải chờ nguồn. (Chỉ dành cho Happ)",
       "subHideSettings": "Ẩn cài đặt máy chủ",
       "subHideSettingsDesc": "Ẩn khả năng xem và chỉnh sửa cấu hình máy chủ trong ứng dụng khách VPN. (Chỉ dành cho Happ)",
       "subIncyEnableRouting": "Bật định tuyến",
       "subIncyEnableRoutingDesc": "Chèn hồ sơ định tuyến vào nội dung đăng ký cho ứng dụng Incy. (Chỉ dành cho Incy)",
       "subIncyRoutingRules": "Quy tắc định tuyến",
-      "subIncyRoutingRulesDesc": "Liên kết định tuyến Incy được thêm vào nội dung đăng ký, ví dụ incy://routing/onadd/<base64>. (Chỉ dành cho Incy)",
+      "subIncyRoutingRulesDesc": "Dán deeplink incy:// có sẵn hoặc URL HTTPS cố định tới JSON. Incy tạo hồ sơ autorouting và tự động cập nhật. (Chỉ dành cho Incy)",
       "subClashEnableRouting": "Bật định tuyến",
       "subClashEnableRoutingDesc": "Bao gồm quy tắc định tuyến Clash/Mihomo toàn cầu trong các đăng ký YAML được tạo.",
       "subClashRoutingRules": "Quy tắc định tuyến toàn cầu",
-      "subClashRoutingRulesDesc": "Quy tắc Clash/Mihomo được thêm vào đầu mỗi đăng ký YAML trước MATCH,PROXY.",
+      "subClashRoutingRulesDesc": "Dán quy tắc/YAML hoặc một URL HTTPS cố định. Bảng điều khiển cập nhật trong nền, chỉ nhập nhóm, nhà cung cấp quy tắc và quy tắc, đồng thời giữ các nút VPN đã tạo và giá trị hợp lệ gần nhất.",
       "subListen": "Listening IP",
       "subListenDesc": "Mặc định để trống để nghe tất cả các IP",
       "subPort": "Cổng gói đăng ký",

+ 3 - 3
internal/web/translation/zh-CN.json

@@ -1158,17 +1158,17 @@
       "subEnableRouting": "启用路由",
       "subEnableRoutingDesc": "在 VPN 客户端中启用路由的全局设置。(仅限 Happ)",
       "subRoutingRules": "路由规则",
-      "subRoutingRulesDesc": "VPN 用户端的全域路由规则。(仅限 Happ)",
+      "subRoutingRulesDesc": "粘贴现成的 happ:// 深层链接或一个固定 HTTPS URL。面板会在后台更新远程规则并保留最后一个有效值,因此订阅请求无需等待远程源。(仅限 Happ)",
       "subHideSettings": "隐藏服务器设置",
       "subHideSettingsDesc": "在 VPN 客户端中隐藏查看和编辑服务器配置的功能。(仅限 Happ)",
       "subIncyEnableRouting": "启用路由",
       "subIncyEnableRoutingDesc": "为 Incy 客户端将路由配置注入订阅内容中。(仅限 Incy)",
       "subIncyRoutingRules": "路由规则",
-      "subIncyRoutingRulesDesc": "添加到订阅内容的 Incy 路由深层链接,例如 incy://routing/onadd/<base64>。(仅限 Incy)",
+      "subIncyRoutingRulesDesc": "粘贴现成的 incy:// 深层链接或指向 JSON 的固定 HTTPS URL。Incy 会创建 autorouting 配置并自动更新。(仅限 Incy)",
       "subClashEnableRouting": "启用路由",
       "subClashEnableRoutingDesc": "在生成的 YAML 订阅中包含 Clash/Mihomo 全局路由规则。",
       "subClashRoutingRules": "全局路由规则",
-      "subClashRoutingRulesDesc": "添加到每个 YAML 订阅开头、MATCH,PROXY 之前的 Clash/Mihomo 规则。",
+      "subClashRoutingRulesDesc": "粘贴规则/YAML 或一个固定 HTTPS URL。面板会在后台更新,仅导入代理组、规则提供者和规则,并保留面板生成的 VPN 节点及最后一个有效值。",
       "subListen": "监听 IP",
       "subListenDesc": "订阅服务监听的 IP 地址(留空表示监听所有 IP)",
       "subPort": "监听端口",

+ 3 - 3
internal/web/translation/zh-TW.json

@@ -1158,17 +1158,17 @@
       "subEnableRouting": "啟用路由",
       "subEnableRoutingDesc": "在 VPN 用戶端中啟用路由的全域設定。(僅限 Happ)",
       "subRoutingRules": "路由規則",
-      "subRoutingRulesDesc": "VPN 用戶端的全域路由規則。(僅限 Happ)",
+      "subRoutingRulesDesc": "貼上現成的 happ:// 深層連結或一個固定 HTTPS URL。面板會在背景更新遠端規則並保留最後一個有效值,因此訂閱請求不需等待遠端來源。(僅限 Happ)",
       "subHideSettings": "隱藏伺服器設定",
       "subHideSettingsDesc": "在 VPN 用戶端中隱藏查看和編輯伺服器配置的功能。(僅限 Happ)",
       "subIncyEnableRouting": "啟用路由",
       "subIncyEnableRoutingDesc": "為 Incy 用戶端將路由設定檔注入訂閱內容中。(僅限 Incy)",
       "subIncyRoutingRules": "路由規則",
-      "subIncyRoutingRulesDesc": "加入訂閱內容的 Incy 路由深層連結,例如 incy://routing/onadd/<base64>。(僅限 Incy)",
+      "subIncyRoutingRulesDesc": "貼上現成的 incy:// 深層連結或指向 JSON 的固定 HTTPS URL。Incy 會建立 autorouting 設定並自動更新。(僅限 Incy)",
       "subClashEnableRouting": "啟用路由",
       "subClashEnableRoutingDesc": "在產生的 YAML 訂閱中包含 Clash/Mihomo 全域路由規則。",
       "subClashRoutingRules": "全域路由規則",
-      "subClashRoutingRulesDesc": "加入到每個 YAML 訂閱開頭、MATCH,PROXY 之前的 Clash/Mihomo 規則。",
+      "subClashRoutingRulesDesc": "貼上規則/YAML 或一個固定 HTTPS URL。面板會在背景更新,只匯入代理群組、規則提供者與規則,並保留面板產生的 VPN 節點及最後一個有效值。",
       "subListen": "監聽 IP",
       "subListenDesc": "訂閱服務監聽的 IP 地址(留空表示監聽所有 IP)",
       "subPort": "監聽埠",

+ 7 - 0
internal/web/web.go

@@ -296,6 +296,7 @@ const (
 	cadenceNodeTraffic   = "@every 5s"
 	cadenceOutboundSub   = "@every 5m"
 	cadenceReapOrphans   = "@every 5m"
+	cadenceRemoteRouting = "@every 5m"
 	cadenceXrayLogPrune  = "@every 10m"
 	cadenceCheckHash     = "@every 2m"
 	// cpu.Percent samples over a full minute (blocking), so a finer cadence just
@@ -343,6 +344,12 @@ func (s *Server) startTask(restartXray bool, loc *time.Location) {
 
 	_, _ = s.cron.AddJob(cadenceReapOrphans, job.NewReapSyncOrphansJob())
 
+	// Warm permanent routing URLs immediately and refresh them outside the
+	// latency-sensitive subscription request path.
+	remoteRoutingJob := job.NewRemoteRoutingJob()
+	_, _ = s.cron.AddJob(cadenceRemoteRouting, remoteRoutingJob)
+	common.GoRecover("remote-routing-warm", remoteRoutingJob.Run)
+
 	// check client ips from log file every day
 	_, _ = s.cron.AddJob("@daily", job.NewClearLogsJob())
 	_, _ = s.cron.AddJob(cadenceXrayLogPrune, job.NewPruneXrayLogsJob())