@@ -653,25 +653,145 @@ type RoutingConfig struct {
653653 DirectDomains []string `json:"direct_domains"`
654654 DirectIPs []string `json:"direct_ips"`
655655
656- // RoutingOrder is the precedence of the egress lanes (a permutation of
657- // "proxy"/"warp"/"opera"/"direct" — see xray.knownLanes); first-match-wins. The
658- // LAST lane is the catch-all ("everything else") — its specific rules are
659- // subsumed by a final rule. A config saved before a lane existed simply omits it;
660- // the generator back-fills any missing lane rather than dropping it.
656+ // RoutingOrder is the precedence of the egress lanes; first-match-wins. It is a
657+ // permutation of the built-in lanes ("warp"/"opera"/"direct") plus the ID of
658+ // every proxy lane in Lanes. The LAST lane is the catch-all ("everything else")
659+ // — its specific rules are subsumed by a final rule. A config saved before a
660+ // lane existed simply omits it; the generator back-fills any missing lane rather
661+ // than dropping it, and drops IDs of lanes that no longer exist.
661662 RoutingOrder []string `json:"routing_order"`
662663
663- // Outbound proxy pool: traffic matching ProxyDomains/ProxyIPs is load-balanced
664- // across the proxies fetched from ProxyURLs (each a list, one proxy per line)
665- // plus the ProxyManual entries.
666- ProxyURLs []string `json:"proxy_urls"`
667- ProxyManual []string `json:"proxy_manual"`
668- ProxyDomains []string `json:"proxy_domains"`
669- ProxyIPs []string `json:"proxy_ips"`
664+ // Lanes are the operator-defined proxy egress lanes. Each has its own upstream
665+ // proxies and its own match rules, so different destinations can leave through
666+ // different proxies (e.g. a ".ru" lane and a ".com" lane).
667+ Lanes []EgressLane `json:"lanes"`
670668
671- // ProxyRefreshMinutes is how often the URL-sourced proxy list is re-fetched.
669+ // ProxyRefreshMinutes is how often the URL-sourced proxy lists are re-fetched.
672670 // 0 means the default (30 min) — kept so configs saved before this was
673671 // selectable keep auto-refreshing; a negative value means "never".
674672 ProxyRefreshMinutes int `json:"proxy_refresh_minutes"`
673+
674+ // Deprecated: the pre-lanes single proxy pool. Only read, never written —
675+ // MigrateLanes folds these into a Lanes entry on load. Kept so a config saved
676+ // by an older build still upgrades cleanly.
677+ ProxyURLs []string `json:"proxy_urls,omitempty"`
678+ ProxyManual []string `json:"proxy_manual,omitempty"`
679+ ProxyDomains []string `json:"proxy_domains,omitempty"`
680+ ProxyIPs []string `json:"proxy_ips,omitempty"`
681+ }
682+
683+ // EgressLane is one named proxy egress: a set of upstream proxies traffic is
684+ // load-balanced across, plus the destinations that should take it. Traffic
685+ // matching Domains/IPs leaves through this lane's proxies; a lane with no live
686+ // proxies is skipped entirely, so its traffic falls through to the next lane.
687+ type EgressLane struct {
688+ // ID is the stable slug the routing order references and the Xray outbound /
689+ // balancer tags are derived from. See ValidLaneID for the charset.
690+ ID string `json:"id"`
691+ Name string `json:"name"` // display name ("Зона .ru")
692+ Enabled bool `json:"enabled"` // off ⇒ the lane emits nothing at all
693+ URLs []string `json:"urls"` // proxy-list sources, one proxy per line
694+ Manual []string `json:"manual"` // "scheme://[user:pass@]host:port" entries
695+ Domains []string `json:"domains"` // destinations routed through this lane
696+ IPs []string `json:"ips"` // CIDRs or "geoip:xx"
697+ }
698+
699+ // MaxEgressLanes caps how many lanes one config may define. Every active lane
700+ // costs an Xray balancer plus an Observatory probe subject, so the ceiling keeps
701+ // a hand-edited config from melting the box.
702+ const MaxEgressLanes = 16
703+
704+ // LegacyProxyLaneID is the ID the pre-lanes proxy pool migrates into. It is
705+ // deliberately the literal "proxy" — the string a pre-lanes RoutingOrder already
706+ // uses for the pool — so a saved precedence keeps pointing at the same lane
707+ // across the upgrade with no rewriting.
708+ const LegacyProxyLaneID = "proxy"
709+
710+ // builtinLanes are the egress lanes that always exist and are not proxy lanes.
711+ // Their names are reserved: a proxy lane may not take one as its ID.
712+ var builtinLanes = []string {"warp" , "opera" , "direct" }
713+
714+ // BuiltinLanes returns the always-present egress lanes, in default precedence
715+ // (the last one, "direct", is the default catch-all).
716+ func BuiltinLanes () []string {
717+ return append ([]string (nil ), builtinLanes ... )
718+ }
719+
720+ // ValidLaneID reports whether id is usable as a lane ID: 1–16 lowercase
721+ // alphanumerics, no dashes, and not a built-in lane name.
722+ //
723+ // The no-dash rule is load-bearing, not cosmetic. An Xray balancer selects its
724+ // members by TAG PREFIX, and a lane's members are tagged "proxy-<id>-<n>". Were
725+ // "-" allowed in an ID, lane "ru" (selector "proxy-ru-") would also select the
726+ // members of lane "ru-x" (tagged "proxy-ru-x-0") and silently steal its proxies.
727+ // Barring dashes from IDs makes the trailing "-" of the selector an unambiguous
728+ // terminator.
729+ func ValidLaneID (id string ) bool {
730+ if len (id ) == 0 || len (id ) > 16 {
731+ return false
732+ }
733+ for _ , b := range []byte (id ) {
734+ if (b < 'a' || b > 'z' ) && (b < '0' || b > '9' ) {
735+ return false
736+ }
737+ }
738+ for _ , r := range builtinLanes {
739+ if id == r {
740+ return false
741+ }
742+ }
743+ return true
744+ }
745+
746+ // MigrateLanes upgrades a config saved before egress lanes existed: the single
747+ // proxy pool becomes one lane (ID "proxy"), so its proxies, rules and place in
748+ // the routing order all survive. It also clears the deprecated fields on a config
749+ // that already has lanes, so they are never written back.
750+ func (rc * RoutingConfig ) MigrateLanes () {
751+ legacy := len (rc .ProxyURLs ) + len (rc .ProxyManual ) + len (rc .ProxyDomains ) + len (rc .ProxyIPs )
752+ if len (rc .Lanes ) == 0 && legacy > 0 {
753+ rc .Lanes = []EgressLane {{
754+ ID : LegacyProxyLaneID ,
755+ Name : "Прокси" ,
756+ Enabled : true ,
757+ URLs : rc .ProxyURLs ,
758+ Manual : rc .ProxyManual ,
759+ Domains : rc .ProxyDomains ,
760+ IPs : rc .ProxyIPs ,
761+ }}
762+ }
763+ rc .ProxyURLs , rc .ProxyManual , rc .ProxyDomains , rc .ProxyIPs = nil , nil , nil , nil
764+ }
765+
766+ // ValidateLanes checks the operator-supplied lanes before they are persisted.
767+ // Messages are user-facing (shown in the panel).
768+ func (rc * RoutingConfig ) ValidateLanes () error {
769+ if len (rc .Lanes ) > MaxEgressLanes {
770+ return fmt .Errorf ("слишком много полос: максимум %d" , MaxEgressLanes )
771+ }
772+ seen := make (map [string ]struct {}, len (rc .Lanes ))
773+ for _ , l := range rc .Lanes {
774+ if ! ValidLaneID (l .ID ) {
775+ return fmt .Errorf ("недопустимый идентификатор полосы %q: только латиница и цифры (до 16 символов), имена warp/opera/direct заняты" , l .ID )
776+ }
777+ if _ , dup := seen [l .ID ]; dup {
778+ return fmt .Errorf ("дублирующийся идентификатор полосы %q" , l .ID )
779+ }
780+ seen [l .ID ] = struct {}{}
781+ if strings .TrimSpace (l .Name ) == "" {
782+ return fmt .Errorf ("у полосы %q не задано название" , l .ID )
783+ }
784+ }
785+ return nil
786+ }
787+
788+ // LaneIDs returns the IDs of the configured proxy lanes, in config order.
789+ func (rc * RoutingConfig ) LaneIDs () []string {
790+ out := make ([]string , 0 , len (rc .Lanes ))
791+ for _ , l := range rc .Lanes {
792+ out = append (out , l .ID )
793+ }
794+ return out
675795}
676796
677797// ProxyEndpoint is one outbound proxy in the pool (parsed from a "scheme://
0 commit comments