Skip to content

Commit 4561544

Browse files
committed
refactor(core): replace single proxy pool with configurable egress lanes
- Introduce EgressLane model: each lane has its own upstream proxies and match rules - Migrate legacy single-pool configs automatically via MigrateLanes() - Update proxy refresh, equality checks, and counting to operate per-lane - Add validation for lane IDs, names, and max count (16) - Remove deprecated proxypool import from manager.go
1 parent 347710a commit 4561544

13 files changed

Lines changed: 1016 additions & 213 deletions

File tree

README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,11 +66,14 @@
6666

6767
#### 🧭 Маршрутизация и выходы
6868

69-
- Категории **block / direct / proxy / WARP / Opera** с настраиваемым приоритетом
69+
- Категории **block / direct / WARP / Opera** + свои полосы прокси, приоритет настраивается
70+
- **Полосы прокси** — сколько угодно независимых выходов, у каждого свой набор
71+
socks5/http-апстримов (URL-список или вручную) и свои правила: например, зона
72+
`.ru` уходит через один, а `.com` — через другой. Внутри полосы —
73+
балансировка по живым (Observatory), полоса без живых прокси пропускается
7074
- **geosite/geoip** пресеты (авто-загрузка баз)
7175
- **Cloudflare WARP** (WireGuard) — выход через WARP по правилам
7276
- **Opera VPN** — бесплатный выход с выбором региона (Европа / Азия / Америка)
73-
- **Прокси** — список из URL или вручную, балансировка по живым (Observatory)
7477

7578
#### 📊 Пользователи и статистика
7679

internal/core/manager.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import (
1313
"github.com/AppsGanin/rospanel/internal/logbuf"
1414
"github.com/AppsGanin/rospanel/internal/model"
1515
"github.com/AppsGanin/rospanel/internal/opera"
16-
"github.com/AppsGanin/rospanel/internal/proxypool"
1716
"github.com/AppsGanin/rospanel/internal/store"
1817
"github.com/AppsGanin/rospanel/internal/sysstat"
1918
"github.com/AppsGanin/rospanel/internal/xray"
@@ -101,7 +100,8 @@ type Manager struct {
101100
geoIP []string // cached geoip category codes
102101

103102
proxyMu sync.Mutex
104-
proxies []model.ProxyEndpoint // current proxy-pool egress endpoints
103+
// proxies holds the current egress proxies of each lane, keyed by lane ID.
104+
proxies map[string][]model.ProxyEndpoint
105105

106106
guard *bruteGuard
107107

@@ -142,8 +142,8 @@ func New(st *store.Store, sup *xray.Supervisor, opts xray.Options, tls TLSPaths,
142142
}
143143
if set, err := st.GetSettings(); err == nil {
144144
m.tz = loadLocation(set.Timezone)
145-
logbuf.SetLocation(m.tz) // stamp log lines in the operator's zone, not the server's
146-
m.proxies = proxypool.Parse(set.Routing.ProxyManual) // manual seed (instant)
145+
logbuf.SetLocation(m.tz) // stamp log lines in the operator's zone, not the server's
146+
m.proxies = seedProxiesFromManual(set.Routing) // manual seed (instant)
147147
if set.OperaEnabled {
148148
// Bring the helper up in the background so a cold-cache download can't
149149
// stall startup; the "opera" lane falls back to direct until it's ready.

internal/core/manager_proxy.go

Lines changed: 83 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -37,44 +37,89 @@ func (m *Manager) currentProxyRefresh() time.Duration {
3737
return proxyRefreshDuration(set.Routing.ProxyRefreshMinutes)
3838
}
3939

40-
// buildProxies parses the manual proxy list and merges in whatever the source
41-
// URLs serve (best-effort per-URL fetch — failures are skipped).
42-
func (m *Manager) buildProxies(rc model.RoutingConfig) []model.ProxyEndpoint {
43-
lines := append([]string{}, rc.ProxyManual...)
44-
for _, url := range rc.ProxyURLs {
45-
if url = strings.TrimSpace(url); url == "" {
40+
// buildProxies resolves the proxies of every enabled egress lane: its manual
41+
// entries merged with whatever its source URLs serve (best-effort per-URL fetch —
42+
// failures are skipped). Lanes with no usable proxies are left out of the map, so
43+
// the generator sees them as inactive.
44+
func (m *Manager) buildProxies(rc model.RoutingConfig) map[string][]model.ProxyEndpoint {
45+
out := make(map[string][]model.ProxyEndpoint, len(rc.Lanes))
46+
for _, lane := range rc.Lanes {
47+
if !lane.Enabled {
4648
continue
4749
}
48-
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
49-
fetched, err := proxypool.Fetch(ctx, url)
50-
cancel()
51-
if err != nil {
52-
logWarn("proxypool: fetch failed", "url", url, "err", err)
50+
lines := append([]string{}, lane.Manual...)
51+
for _, url := range lane.URLs {
52+
if url = strings.TrimSpace(url); url == "" {
53+
continue
54+
}
55+
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
56+
fetched, err := proxypool.Fetch(ctx, url)
57+
cancel()
58+
if err != nil {
59+
logWarn("proxypool: fetch failed", "lane", lane.ID, "url", url, "err", err)
60+
continue
61+
}
62+
lines = append(lines, fetched...)
63+
}
64+
if eps := proxypool.Parse(lines); len(eps) > 0 {
65+
out[lane.ID] = eps
66+
}
67+
}
68+
return out
69+
}
70+
71+
// seedProxiesFromManual resolves only the manual entries of each enabled lane —
72+
// no network. Used at boot to have something in the pool instantly.
73+
func seedProxiesFromManual(rc model.RoutingConfig) map[string][]model.ProxyEndpoint {
74+
out := make(map[string][]model.ProxyEndpoint, len(rc.Lanes))
75+
for _, lane := range rc.Lanes {
76+
if !lane.Enabled {
5377
continue
5478
}
55-
lines = append(lines, fetched...)
79+
if eps := proxypool.Parse(lane.Manual); len(eps) > 0 {
80+
out[lane.ID] = eps
81+
}
5682
}
57-
return proxypool.Parse(lines)
83+
return out
5884
}
5985

60-
func (m *Manager) getProxies() []model.ProxyEndpoint {
86+
func (m *Manager) getProxies() map[string][]model.ProxyEndpoint {
6187
m.proxyMu.Lock()
6288
defer m.proxyMu.Unlock()
63-
return append([]model.ProxyEndpoint(nil), m.proxies...)
89+
out := make(map[string][]model.ProxyEndpoint, len(m.proxies))
90+
for id, eps := range m.proxies {
91+
out[id] = append([]model.ProxyEndpoint(nil), eps...)
92+
}
93+
return out
6494
}
6595

66-
func (m *Manager) setProxies(p []model.ProxyEndpoint) {
96+
func (m *Manager) setProxies(p map[string][]model.ProxyEndpoint) {
6797
m.proxyMu.Lock()
6898
m.proxies = p
6999
m.proxyMu.Unlock()
70100
}
71101

72-
// ProxyCount reports how many proxies are currently in the pool (parsed from the
73-
// URL + manual sources).
102+
// ProxyCount reports how many proxies are currently live across all lanes.
74103
func (m *Manager) ProxyCount() int {
75104
m.proxyMu.Lock()
76105
defer m.proxyMu.Unlock()
77-
return len(m.proxies)
106+
n := 0
107+
for _, eps := range m.proxies {
108+
n += len(eps)
109+
}
110+
return n
111+
}
112+
113+
// ProxyCounts reports how many proxies each lane currently has, keyed by lane ID
114+
// (a lane with none is absent). Feeds the per-lane status badges in the panel.
115+
func (m *Manager) ProxyCounts() map[string]int {
116+
m.proxyMu.Lock()
117+
defer m.proxyMu.Unlock()
118+
out := make(map[string]int, len(m.proxies))
119+
for id, eps := range m.proxies {
120+
out[id] = len(eps)
121+
}
122+
return out
78123
}
79124

80125
// SeedProxies loads the proxy pool synchronously from current settings WITHOUT
@@ -122,11 +167,25 @@ func (m *Manager) proxyLoop() {
122167
}
123168
}
124169

125-
// proxiesEqual reports whether a and b are the same multiset of endpoints,
126-
// ignoring order: the pool is health-balanced (Observatory) so the order in the
127-
// config is irrelevant, and a source URL that returns the same proxies shuffled
128-
// must not trigger a needless Xray restart.
129-
func proxiesEqual(a, b []model.ProxyEndpoint) bool {
170+
// proxiesEqual reports whether a and b give every lane the same multiset of
171+
// endpoints, ignoring order: each lane is health-balanced (Observatory) so the
172+
// order in the config is irrelevant, and a source URL that returns the same
173+
// proxies shuffled must not trigger a needless Xray restart.
174+
func proxiesEqual(a, b map[string][]model.ProxyEndpoint) bool {
175+
if len(a) != len(b) {
176+
return false
177+
}
178+
for id, pa := range a {
179+
pb, ok := b[id]
180+
if !ok || !endpointsEqual(pa, pb) {
181+
return false
182+
}
183+
}
184+
return true
185+
}
186+
187+
// endpointsEqual compares one lane's endpoints as an unordered multiset.
188+
func endpointsEqual(a, b []model.ProxyEndpoint) bool {
130189
if len(a) != len(b) {
131190
return false
132191
}

internal/core/manager_settings.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,11 +207,17 @@ func (m *Manager) SetProxyMode(enabled bool, typ string, port int, user, pass st
207207
// later toggles reuse them. Enabling Opera downloads + launches the helper for
208208
// the chosen region.
209209
func (m *Manager) ApplyRouting(cfg model.RoutingConfig, warpEnabled, operaEnabled bool, operaCountry string) error {
210+
// Fold a legacy single-pool payload (an older panel build) into a lane, then
211+
// validate — so what we persist is always in the lane model.
212+
cfg.MigrateLanes()
213+
if err := cfg.ValidateLanes(); err != nil {
214+
return invalid("%s", err)
215+
}
210216
set, err := m.store.GetSettings()
211217
if err != nil {
212218
return err
213219
}
214-
logInfo("routing: applying", "warp", warpEnabled, "opera", operaEnabled, "country", operaCountry)
220+
logInfo("routing: applying", "warp", warpEnabled, "opera", operaEnabled, "country", operaCountry, "lanes", len(cfg.Lanes))
215221
set.WarpEnabled = warpEnabled
216222
if warpEnabled && !set.WarpRegistered() {
217223
logInfo("warp: registering new Cloudflare WARP account")

internal/model/model.go

Lines changed: 133 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)