fix: fetcher proxy support, Dockerfile sing-box, rename index format

1. sslinks 403: Cloudflare blocks server IP. Add fetcher.proxy_url config
   so subscription/flow requests can route through an HTTP proxy.

2. Egress probe: Docker image lacked sing-box. Install sing-box v1.11.4
   in the Alpine runtime stage.

3. Collection rename: group by display baseName instead of internal
   groupKey, format index as -N instead of %02d. Fixes double-suffix
   issue where EnsureUniqueProxyNames appended -2/-3 onto existing  02.
This commit is contained in:
2026-07-28 17:38:57 +08:00
parent edae8235a6
commit 040e3ebab1
8 changed files with 110 additions and 64 deletions
+49 -54
View File
@@ -30,14 +30,17 @@ func resolveRenameOptions(opts *model.RenameOptions) model.RenameOptions {
// RenameCollectionNodes renames proxy nodes in the collection-level format:
//
// [emoji country flag] [alias] [country name] [city] [01...100]
// [emoji country flag] [alias] [country name] [city] [-1...-N]
//
// The alias is read from each node's "_sourceAlias" field (tagged by the
// subscription service from the source's Alias). Geographic info (flag,
// country, city) is auto-detected from the node's original name and server.
// Nodes are grouped by country+city+alias and numbered sequentially within
// each group, starting at 01. If only one node exists in a group, no number
// is appended.
//
// Nodes are grouped by their **display baseName** (the name without the index
// suffix) and numbered sequentially within each group, starting at 1. This
// ensures that nodes producing the same display name get unique suffixes
// (-1, -2, -3, …) in a single pass, so EnsureUniqueProxyNames never needs to
// append a secondary suffix.
//
// opts controls which fields are included in the output. A nil opts means all
// fields are included (full default format).
@@ -48,19 +51,16 @@ func RenameCollectionNodes(proxies []model.ProxyNode, opts *model.RenameOptions)
o := resolveRenameOptions(opts)
type groupKey struct {
alias string
country string
city string
// First pass: compute display baseName (without index) for each node.
type nodeInfo struct {
proxy model.ProxyNode
baseName string // display name without index suffix; "" means skip rename
}
groups := make(map[groupKey]int)
results := make([]model.ProxyNode, len(proxies))
// First pass: count group sizes
infos := make([]nodeInfo, len(proxies))
for i, proxy := range proxies {
if proxy == nil {
results[i] = proxy
infos[i] = nodeInfo{proxy: proxy}
continue
}
@@ -70,45 +70,10 @@ func RenameCollectionNodes(proxies []model.ProxyNode, opts *model.RenameOptions)
geo := util.DetectGeoWithServer(name, server)
if geo.CountryName == "" {
results[i] = proxy
infos[i] = nodeInfo{proxy: proxy}
continue
}
key := groupKey{
alias: alias,
country: geo.CountryCN,
city: geo.CityCN,
}
groups[key]++
}
// Second pass: assign names with numbering
counters := make(map[groupKey]int)
for i, proxy := range proxies {
if proxy == nil {
continue
}
name := ToString(proxy["name"])
alias := ToString(proxy["_sourceAlias"])
server := ToString(proxy["server"])
geo := util.DetectGeoWithServer(name, server)
if geo.CountryName == "" {
results[i] = proxy
continue
}
key := groupKey{
alias: alias,
country: geo.CountryCN,
city: geo.CityCN,
}
counters[key]++
total := groups[key]
// Build name parts based on enabled options
var parts []string
if o.Flag && geo.Flag != "" {
@@ -131,15 +96,45 @@ func RenameCollectionNodes(proxies []model.ProxyNode, opts *model.RenameOptions)
parts = append(parts, geo.CityCN)
}
baseName := strings.Join(parts, " ")
infos[i] = nodeInfo{
proxy: proxy,
baseName: strings.Join(parts, " "),
}
}
if o.Index && total > 1 {
baseName = fmt.Sprintf("%s %02d", baseName, counters[key])
// Count group sizes by baseName.
baseCounts := make(map[string]int)
for _, info := range infos {
if info.baseName != "" {
baseCounts[info.baseName]++
}
}
// Second pass: assign names with sequential numbering.
counters := make(map[string]int)
results := make([]model.ProxyNode, len(proxies))
for i, info := range infos {
if info.proxy == nil {
results[i] = info.proxy
continue
}
if info.baseName == "" {
// Geo detection failed — keep original name.
results[i] = info.proxy
continue
}
next := cloneProxy(proxy)
counters[info.baseName]++
total := baseCounts[info.baseName]
name := info.baseName
if o.Index && total > 1 {
name = fmt.Sprintf("%s-%d", name, counters[info.baseName])
}
next := cloneProxy(info.proxy)
delete(next, "_sourceAlias")
next["name"] = baseName
next["name"] = name
results[i] = next
}
+4 -4
View File
@@ -45,10 +45,10 @@ func TestRenameCollectionNodes_MultipleNodes(t *testing.T) {
if len(result) != 3 {
t.Fatalf("expected 3 nodes, got %d", len(result))
}
// All 3 should be numbered 01, 02, 03
// All 3 should be numbered -1, -2, -3
for i, node := range result {
name := node["name"].(string)
expected := fmt.Sprintf("%02d", i+1)
expected := fmt.Sprintf("-%d", i+1)
if !strings.Contains(name, expected) {
t.Errorf("node[%d]: expected number %s in name %q", i, expected, name)
}
@@ -222,7 +222,7 @@ func TestRenameCollectionNodes_DisableIndex(t *testing.T) {
for i, node := range result {
name := node["name"].(string)
// Should not contain number suffix
suffix := fmt.Sprintf("%02d", i+1)
suffix := fmt.Sprintf("-%d", i+1)
if strings.HasSuffix(name, suffix) {
t.Errorf("node[%d]: index should be disabled, got %q", i, name)
}
@@ -247,7 +247,7 @@ func TestRenameCollectionNodes_OnlyFlagAndIndex(t *testing.T) {
if strings.Contains(name, "[A]") {
t.Errorf("node[%d]: alias should not appear, got %q", i, name)
}
expected := fmt.Sprintf("🇭🇰 %02d", i+1)
expected := fmt.Sprintf("🇭🇰-%d", i+1)
if name != expected {
t.Errorf("node[%d]: expected %q, got %q", i, expected, name)
}