Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,7 @@ Resin 内置了 GeoIP 服务,用于支持 Platform 的 RegionFilters 功能。
* 如果 Platform 配置的节点分配策略是“偏好低延迟”,那么 $\text{Score} = \text{Latency}$
* 如果是“偏好闲置 IP”,那么 $\text{Score} = \text{LeaseCount}$
* 如果“均衡”,那么 $\text{Score} = (\text{LeaseCount} + 1) \times \text{Latency}$
* 如果是“轮询”,则跳过 P2C 评分,按可路由集合顺序依次选取节点

> Latency 为空时统一按 LeaseCount 打分(即使策略是 PREFER_LOW_LATENCY)。

Expand Down Expand Up @@ -1193,7 +1194,7 @@ Body(partial patch 示例):
"reverse_proxy_miss_action": "TREAT_AS_EMPTY|REJECT",
"reverse_proxy_empty_account_behavior": "RANDOM|FIXED_HEADER|ACCOUNT_HEADER_RULE",
"reverse_proxy_fixed_account_header": "Authorization\nX-Account-Id",
"allocation_policy": "BALANCED|PREFER_LOW_LATENCY|PREFER_IDLE_IP",
"allocation_policy": "BALANCED|PREFER_LOW_LATENCY|PREFER_IDLE_IP|ROUND_ROBIN",
"passive_circuit_breaker_disabled": false,
"updated_at": "2026-02-10T12:34:56Z"
}
Expand Down Expand Up @@ -1239,7 +1240,7 @@ Body:
* `sticky_ttl`:合法 Go duration。
* `regex_filters`:节点标签过滤规则列表,语义与校验见“节点标签过滤规则”。
* `region_filters`:每项为 ISO 3166-1 alpha-2 小写代码。
* 枚举字段:`reverse_proxy_miss_action` 仅 `TREAT_AS_EMPTY|REJECT`;`reverse_proxy_empty_account_behavior` 仅 `RANDOM|FIXED_HEADER|ACCOUNT_HEADER_RULE`;`allocation_policy` 仅 `BALANCED|PREFER_LOW_LATENCY|PREFER_IDLE_IP`。
* 枚举字段:`reverse_proxy_miss_action` 仅 `TREAT_AS_EMPTY|REJECT`;`reverse_proxy_empty_account_behavior` 仅 `RANDOM|FIXED_HEADER|ACCOUNT_HEADER_RULE`;`allocation_policy` 仅 `BALANCED|PREFER_LOW_LATENCY|PREFER_IDLE_IP|ROUND_ROBIN`。
* `passive_circuit_breaker_disabled`:布尔值。设为 `true` 后,此 Platform 的用户代理请求失败不会增加节点熔断计数;主动探测不受影响。成功请求仍会清除节点连续失败计数并可恢复熔断节点。
* 组合约束:当 `reverse_proxy_empty_account_behavior=FIXED_HEADER` 时,`reverse_proxy_fixed_account_header` 必填;其值支持多行,每行一个合法 HTTP Header 字段名(会按顺序尝试提取)。

Expand Down Expand Up @@ -2332,7 +2333,7 @@ GeoIP 与订阅的下载都有错误重试的需求。
* `RESIN_DEFAULT_PLATFORM_REVERSE_PROXY_MISS_ACTION`:默认平台反代 miss 行为。枚举:`TREAT_AS_EMPTY|REJECT`。默认 `TREAT_AS_EMPTY`。
* `RESIN_DEFAULT_PLATFORM_REVERSE_PROXY_EMPTY_ACCOUNT_BEHAVIOR`:默认平台在反代 Account 为空时的行为。枚举:`RANDOM|FIXED_HEADER|ACCOUNT_HEADER_RULE`。默认 `ACCOUNT_HEADER_RULE`。
* `RESIN_DEFAULT_PLATFORM_REVERSE_PROXY_FIXED_ACCOUNT_HEADER`:默认平台固定提取 Header 列表(多行,每行一个 Header)。仅当上项为 `FIXED_HEADER` 时必须至少提供一个合法 Header。默认 `Authorization`。
* `RESIN_DEFAULT_PLATFORM_ALLOCATION_POLICY`:默认平台分配策略。枚举:`BALANCED|PREFER_LOW_LATENCY|PREFER_IDLE_IP`。默认 `BALANCED`。
* `RESIN_DEFAULT_PLATFORM_ALLOCATION_POLICY`:默认平台分配策略。枚举:`BALANCED|PREFER_LOW_LATENCY|PREFER_IDLE_IP|ROUND_ROBIN`。默认 `BALANCED`。
* `RESIN_PROBE_TIMEOUT`:单次探测请求超时。默认 "15s"。
* `RESIN_RESOURCE_FETCH_TIMEOUT`:资源下载(订阅/GeoIP)单次尝试超时。默认 "30s"。
* `RESIN_NODE_DNS_UPSTREAMS`:Resin 托管节点域名解析上游,JSON 字符串数组。默认值为 `["https://doh.pub/dns-query","https://dns.alidns.com/dns-query","tls://223.5.5.5?sni=dns.alidns.com","local"]`;设置后完全按数组顺序作为 failover 链。仅作用于内部 sing-box builder 解析节点域名,不影响订阅下载、GeoIP 下载等其他资源下载路径。
Expand Down
3 changes: 2 additions & 1 deletion internal/config/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -252,11 +252,12 @@ func LoadEnvConfig() (*EnvConfig, error) {
}
if !platform.AllocationPolicy(cfg.DefaultPlatformAllocationPolicy).IsValid() {
errs = append(errs, fmt.Sprintf(
"RESIN_DEFAULT_PLATFORM_ALLOCATION_POLICY: invalid value %q (allowed: %s, %s, %s)",
"RESIN_DEFAULT_PLATFORM_ALLOCATION_POLICY: invalid value %q (allowed: %s, %s, %s, %s)",
cfg.DefaultPlatformAllocationPolicy,
platform.AllocationPolicyBalanced,
platform.AllocationPolicyPreferLowLatency,
platform.AllocationPolicyPreferIdleIP,
platform.AllocationPolicyRoundRobin,
))
}
if cfg.ProbeTimeout <= 0 {
Expand Down
3 changes: 2 additions & 1 deletion internal/platform/policy.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const (
AllocationPolicyBalanced AllocationPolicy = "BALANCED"
AllocationPolicyPreferLowLatency AllocationPolicy = "PREFER_LOW_LATENCY"
AllocationPolicyPreferIdleIP AllocationPolicy = "PREFER_IDLE_IP"
AllocationPolicyRoundRobin AllocationPolicy = "ROUND_ROBIN"
)

// ParseAllocationPolicy normalizes external string input into a supported policy.
Expand All @@ -21,7 +22,7 @@ func ParseAllocationPolicy(raw string) AllocationPolicy {

func (p AllocationPolicy) IsValid() bool {
switch p {
case AllocationPolicyBalanced, AllocationPolicyPreferLowLatency, AllocationPolicyPreferIdleIP:
case AllocationPolicyBalanced, AllocationPolicyPreferLowLatency, AllocationPolicyPreferIdleIP, AllocationPolicyRoundRobin:
return true
default:
return false
Expand Down
2 changes: 2 additions & 0 deletions internal/platform/policy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ func TestParseAllocationPolicy(t *testing.T) {
{name: "balanced", in: "BALANCED", want: AllocationPolicyBalanced},
{name: "prefer_low_latency", in: "PREFER_LOW_LATENCY", want: AllocationPolicyPreferLowLatency},
{name: "prefer_idle_ip", in: "PREFER_IDLE_IP", want: AllocationPolicyPreferIdleIP},
{name: "round_robin", in: "ROUND_ROBIN", want: AllocationPolicyRoundRobin},
{name: "invalid_fallback", in: "UNKNOWN", want: AllocationPolicyBalanced},
{name: "empty_fallback", in: "", want: AllocationPolicyBalanced},
}
Expand All @@ -28,6 +29,7 @@ func TestAllocationPolicyIsValid(t *testing.T) {
AllocationPolicyBalanced,
AllocationPolicyPreferLowLatency,
AllocationPolicyPreferIdleIP,
AllocationPolicyRoundRobin,
}
for _, p := range valid {
if !p.IsValid() {
Expand Down
22 changes: 19 additions & 3 deletions internal/platform/routableview.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,16 @@ type ReadOnlyView interface {
Contains(h node.Hash) bool
Size() int
RandomPick(rng *rand.Rand) (node.Hash, bool)
RoundRobinPick() (node.Hash, bool)
Range(fn func(node.Hash) bool)
}

// RoutableView is a 64-shard concurrent set supporting O(1) random pick,
// O(1) add, O(1) remove, and O(1) contains.
type RoutableView struct {
shards [numShards]shard
size atomic.Int64 // total count across all shards
shards [numShards]shard
size atomic.Int64 // total count across all shards
roundRobinSeq atomic.Uint64
}

type shard struct {
Expand Down Expand Up @@ -106,6 +108,7 @@ func (rv *RoutableView) Clear() {
s.mu.Unlock()
}
rv.size.Store(0)
rv.roundRobinSeq.Store(0)
}

// RandomPick selects a random hash from the view.
Expand All @@ -115,8 +118,21 @@ func (rv *RoutableView) RandomPick(rng *rand.Rand) (node.Hash, bool) {
if total == 0 {
return node.Zero, false
}
return rv.pickByIndex(rng.IntN(total))
}

// RoundRobinPick selects the next hash in round-robin order.
// Returns ok=false if the view is empty.
func (rv *RoutableView) RoundRobinPick() (node.Hash, bool) {
total := rv.Size()
if total == 0 {
return node.Zero, false
}
idx := int(rv.roundRobinSeq.Add(1)-1) % total
return rv.pickByIndex(idx)
}

target := rng.IntN(total)
func (rv *RoutableView) pickByIndex(target int) (node.Hash, bool) {
for i := range rv.shards {
s := &rv.shards[i]
s.mu.RLock()
Expand Down
66 changes: 66 additions & 0 deletions internal/platform/routableview_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,35 @@ func TestRoutableView_RandomPick_Distribution(t *testing.T) {
}
}

func TestRoutableView_RoundRobinPick_Cycles(t *testing.T) {
rv := NewRoutableView()
h1 := makeHash(`{"type":"ss","server":"1.1.1.1"}`)
h2 := makeHash(`{"type":"ss","server":"2.2.2.2"}`)
h3 := makeHash(`{"type":"ss","server":"3.3.3.3"}`)
rv.Add(h1)
rv.Add(h2)
rv.Add(h3)

want := []node.Hash{h1, h2, h3, h1, h2}
for i, expected := range want {
got, ok := rv.RoundRobinPick()
if !ok {
t.Fatalf("pick %d: expected ok=true", i)
}
if got != expected {
t.Fatalf("pick %d: got %s, want %s", i, got.Hex(), expected.Hex())
}
}
}

func TestRoutableView_RoundRobinPick_Empty(t *testing.T) {
rv := NewRoutableView()
_, ok := rv.RoundRobinPick()
if ok {
t.Fatal("should return ok=false for empty view")
}
}

func TestRoutableView_Clear(t *testing.T) {
rv := NewRoutableView()
for i := 0; i < 10; i++ {
Expand Down Expand Up @@ -121,6 +150,43 @@ func TestRoutableView_Range(t *testing.T) {
}
}

func TestRoutableView_RoundRobinPick_Empty(t *testing.T) {
rv := NewRoutableView()
_, ok := rv.RoundRobinPick()
if ok {
t.Fatal("should return ok=false for empty view")
}
}

func TestRoutableView_RoundRobinPick_Cycles(t *testing.T) {
rv := NewRoutableView()
hashes := make([]node.Hash, 3)
for i := range hashes {
hashes[i] = makeHash(`{"type":"ss","idx":` + strconv.Itoa(i) + `}`)
rv.Add(hashes[i])
}

firstCycle := make([]node.Hash, 3)
for i := range firstCycle {
got, ok := rv.RoundRobinPick()
if !ok {
t.Fatalf("pick %d failed", i)
}
firstCycle[i] = got
}

// Second cycle should repeat the same order.
for i := range firstCycle {
got, ok := rv.RoundRobinPick()
if !ok {
t.Fatalf("second cycle pick %d failed", i)
}
if got != firstCycle[i] {
t.Fatalf("round robin cycle mismatch at %d: got %s, want %s", i, got.Hex(), firstCycle[i].Hex())
}
}
}

func TestRoutableView_ConcurrentAddRemove(t *testing.T) {
rv := NewRoutableView()
hashes := make([]node.Hash, 200)
Expand Down
8 changes: 8 additions & 0 deletions internal/routing/random.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,14 @@ func randomRoute(
return node.Zero, ErrNoAvailableNodes
}

if plat.AllocationPolicy == platform.AllocationPolicyRoundRobin {
h, ok := view.RoundRobinPick()
if !ok {
return node.Zero, ErrNoAvailableNodes
}
return h, nil
}

rng := randomRouteRNGPool.Get().(*rand.Rand)
defer randomRouteRNGPool.Put(rng)

Expand Down
73 changes: 73 additions & 0 deletions internal/routing/routing_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,53 @@ func TestRandomRoute_SingleNode(t *testing.T) {
}
}

func TestRandomRoute_RoundRobin(t *testing.T) {
pool, subMgr := setupPool(t)
h1 := makeRoutableNode(t, pool, subMgr, `{"rr":"1"}`, "10.0.0.1", "cloudflare.com", 50*time.Millisecond)
h2 := makeRoutableNode(t, pool, subMgr, `{"rr":"2"}`, "10.0.0.2", "cloudflare.com", 50*time.Millisecond)
h3 := makeRoutableNode(t, pool, subMgr, `{"rr":"3"}`, "10.0.0.3", "cloudflare.com", 50*time.Millisecond)

plat, ok := pool.GetPlatform(platID)
if !ok {
t.Fatal("platform not found")
}
plat.AllocationPolicy = platform.AllocationPolicyRoundRobin

router := makeRouter(pool, nil)

firstCycle := make([]node.Hash, 3)
for i := range firstCycle {
res, err := router.RouteRequest(platName, "", "example.com")
if err != nil {
t.Fatalf("route %d: %v", i, err)
}
firstCycle[i] = res.NodeHash
}

seen := map[node.Hash]bool{h1: false, h2: false, h3: false}
for _, h := range firstCycle {
if _, ok := seen[h]; !ok {
t.Fatalf("unexpected hash %s in first cycle", h.Hex())
}
seen[h] = true
}
for h, picked := range seen {
if !picked {
t.Fatalf("node %s not picked in first cycle", h.Hex())
}
}

for i := range firstCycle {
res, err := router.RouteRequest(platName, "", "example.com")
if err != nil {
t.Fatalf("second cycle route %d: %v", i, err)
}
if res.NodeHash != firstCycle[i] {
t.Fatalf("round robin mismatch at %d: got %s, want %s", i, res.NodeHash.Hex(), firstCycle[i].Hex())
}
}
}

func TestRandomRoute_MultipleNodes(t *testing.T) {
pool, subMgr := setupPool(t)
h1 := makeRoutableNode(t, pool, subMgr, `{"multi":"1"}`, "10.0.0.1", "cloudflare.com", 50*time.Millisecond)
Expand All @@ -152,6 +199,32 @@ func TestRandomRoute_MultipleNodes(t *testing.T) {
}
}

func TestRandomRoute_RoundRobin(t *testing.T) {
pool, subMgr := setupPool(t)
h1 := makeRoutableNode(t, pool, subMgr, `{"rr":"1"}`, "10.0.0.1", "cloudflare.com", 50*time.Millisecond)
h2 := makeRoutableNode(t, pool, subMgr, `{"rr":"2"}`, "10.0.0.2", "cloudflare.com", 50*time.Millisecond)
h3 := makeRoutableNode(t, pool, subMgr, `{"rr":"3"}`, "10.0.0.3", "cloudflare.com", 50*time.Millisecond)

plat, ok := pool.GetPlatform(platID)
if !ok {
t.Fatal("platform not found")
}
plat.AllocationPolicy = platform.AllocationPolicyRoundRobin

router := makeRouter(pool, nil)

want := []node.Hash{h1, h2, h3, h1, h2}
for i, expected := range want {
res, err := router.RouteRequest(platName, "", "example.com")
if err != nil {
t.Fatalf("iteration %d: %v", i, err)
}
if res.NodeHash != expected {
t.Fatalf("iteration %d: got %s, want %s", i, res.NodeHash.Hex(), expected.Hex())
}
}
}

// ── sticky lease tests ──────────────────────────────────────────

func TestStickyLease_CreateAndHit(t *testing.T) {
Expand Down
3 changes: 2 additions & 1 deletion internal/service/control_plane_platform.go
Original file line number Diff line number Diff line change
Expand Up @@ -216,10 +216,11 @@ func validatePlatformAllocationPolicy(raw string) *ServiceError {
return nil
}
return invalidArg(fmt.Sprintf(
"allocation_policy: must be %s, %s, or %s",
"allocation_policy: must be %s, %s, %s, or %s",
platform.AllocationPolicyBalanced,
platform.AllocationPolicyPreferLowLatency,
platform.AllocationPolicyPreferIdleIP,
platform.AllocationPolicyRoundRobin,
))
}

Expand Down
2 changes: 2 additions & 0 deletions webui/src/features/platforms/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const allocationPolicies: PlatformAllocationPolicy[] = [
"BALANCED",
"PREFER_LOW_LATENCY",
"PREFER_IDLE_IP",
"ROUND_ROBIN",
];

export const missActions: PlatformMissAction[] = ["TREAT_AS_EMPTY", "REJECT"];
Expand All @@ -22,6 +23,7 @@ export const allocationPolicyLabel: Record<PlatformAllocationPolicy, string> = {
BALANCED: "均衡",
PREFER_LOW_LATENCY: "优先低延迟",
PREFER_IDLE_IP: "优先空闲出口 IP",
ROUND_ROBIN: "轮询",
};

export const missActionLabel: Record<PlatformMissAction, string> = {
Expand Down
2 changes: 1 addition & 1 deletion webui/src/features/platforms/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export type PlatformMissAction = "TREAT_AS_EMPTY" | "REJECT";
export type PlatformEmptyAccountBehavior = "RANDOM" | "FIXED_HEADER" | "ACCOUNT_HEADER_RULE";
export type PlatformAllocationPolicy = "BALANCED" | "PREFER_LOW_LATENCY" | "PREFER_IDLE_IP";
export type PlatformAllocationPolicy = "BALANCED" | "PREFER_LOW_LATENCY" | "PREFER_IDLE_IP" | "ROUND_ROBIN";

export type Platform = {
id: string;
Expand Down
1 change: 1 addition & 0 deletions webui/src/features/systemConfig/SystemConfigPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ const ALLOCATION_POLICY_LABELS: Record<string, string> = {
BALANCED: "均衡",
PREFER_LOW_LATENCY: "优先低延迟",
PREFER_IDLE_IP: "优先空闲出口 IP",
ROUND_ROBIN: "轮询",
};

const MISS_ACTION_LABELS: Record<string, string> = {
Expand Down
1 change: 1 addition & 0 deletions webui/src/i18n/translations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,7 @@ Note: Once enabled, requests without authentication information are rejected ins
"均衡": "Balanced",
"优先低延迟": "Prefer low latency",
"优先空闲出口 IP": "Prefer idle egress IP",
"轮询": "Round robin",
"租约保持时长": "Lease Sticky TTL",
"租约保持时长(可选)": "Lease Sticky TTL (optional)",
"节点名正则过滤规则": "Node name regex filters",
Expand Down