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:
+6
-1
@@ -17,7 +17,12 @@ RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /sub-store .
|
||||
|
||||
# ─── Stage 3: Runtime ───
|
||||
FROM alpine:3.21
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
RUN apk add --no-cache ca-certificates tzdata && \
|
||||
wget -qO /tmp/sing-box.tar.gz "https://github.com/SagerNet/sing-box/releases/download/v1.11.4/sing-box-1.11.4-linux-amd64.tar.gz" && \
|
||||
tar xzf /tmp/sing-box.tar.gz -C /tmp && \
|
||||
mv /tmp/sing-box-*/sing-box /usr/local/bin/sing-box && \
|
||||
chmod +x /usr/local/bin/sing-box && \
|
||||
rm -rf /tmp/sing-box*
|
||||
WORKDIR /app
|
||||
COPY --from=backend-builder /sub-store /app/sub-store
|
||||
COPY --from=frontend-builder /frontend/dist /app/frontend/dist
|
||||
|
||||
@@ -24,6 +24,7 @@ fetcher:
|
||||
max_source_urls: 8
|
||||
max_response_bytes: 2097152 # 2 MiB
|
||||
max_total_bytes: 12582912 # 12 MiB
|
||||
proxy_url: "" # e.g. "http://127.0.0.1:7890" for fetching sources behind Cloudflare
|
||||
|
||||
recycle:
|
||||
max_entries: 50
|
||||
|
||||
@@ -46,6 +46,7 @@ type FetcherConfig struct {
|
||||
MaxSourceUrls int `mapstructure:"max_source_urls"`
|
||||
MaxResponseBytes int `mapstructure:"max_response_bytes"`
|
||||
MaxTotalBytes int `mapstructure:"max_total_bytes"`
|
||||
ProxyURL string `mapstructure:"proxy_url"`
|
||||
}
|
||||
|
||||
type RecycleConfig struct {
|
||||
@@ -78,6 +79,7 @@ func defaults() {
|
||||
viper.SetDefault("fetcher.max_source_urls", 8)
|
||||
viper.SetDefault("fetcher.max_response_bytes", 2*1024*1024)
|
||||
viper.SetDefault("fetcher.max_total_bytes", 12*1024*1024)
|
||||
viper.SetDefault("fetcher.proxy_url", "")
|
||||
viper.SetDefault("recycle.max_entries", 50)
|
||||
viper.SetDefault("app.name", "Sub-Store")
|
||||
viper.SetDefault("app.version", "1.0.0")
|
||||
|
||||
@@ -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, " ")
|
||||
|
||||
if o.Index && total > 1 {
|
||||
baseName = fmt.Sprintf("%s %02d", baseName, counters[key])
|
||||
infos[i] = nodeInfo{
|
||||
proxy: proxy,
|
||||
baseName: strings.Join(parts, " "),
|
||||
}
|
||||
}
|
||||
|
||||
next := cloneProxy(proxy)
|
||||
// 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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ func (d *Deps) HandleDownloadCollection(c fiber.Ctx) error {
|
||||
RequestUserAgent: c.Get("User-Agent"),
|
||||
ForceRefresh: c.Query("refresh") == "1" || c.Query("noCache") == "1",
|
||||
CacheRepo: d.CacheRepo,
|
||||
ProxyURL: d.Cfg.Fetcher.ProxyURL,
|
||||
})
|
||||
if err != nil {
|
||||
return failed(c, err.Error(), 500)
|
||||
@@ -82,6 +83,7 @@ func (d *Deps) HandleDownloadSource(c fiber.Ctx) error {
|
||||
RequestUserAgent: c.Get("User-Agent"),
|
||||
ForceRefresh: c.Query("refresh") == "1" || c.Query("noCache") == "1",
|
||||
CacheRepo: d.CacheRepo,
|
||||
ProxyURL: d.Cfg.Fetcher.ProxyURL,
|
||||
})
|
||||
if err != nil {
|
||||
return failed(c, err.Error(), 500)
|
||||
@@ -182,6 +184,7 @@ func (d *Deps) HandlePreviewSource(c fiber.Ctx) error {
|
||||
Settings: settings,
|
||||
RequestUserAgent: c.Get("User-Agent"),
|
||||
CacheRepo: d.CacheRepo,
|
||||
ProxyURL: d.Cfg.Fetcher.ProxyURL,
|
||||
})
|
||||
if err != nil {
|
||||
return failed(c, err.Error(), 400)
|
||||
@@ -210,6 +213,7 @@ func (d *Deps) HandlePreviewCollection(c fiber.Ctx) error {
|
||||
Settings: settings,
|
||||
RequestUserAgent: c.Get("User-Agent"),
|
||||
CacheRepo: d.CacheRepo,
|
||||
ProxyURL: d.Cfg.Fetcher.ProxyURL,
|
||||
})
|
||||
if err != nil {
|
||||
return failed(c, err.Error(), 400)
|
||||
@@ -296,7 +300,7 @@ func (d *Deps) HandleFlowInfo(c fiber.Ctx) error {
|
||||
"error": fiber.Map{"code": "NO_FLOW_INFO", "type": "NO_FLOW_INFO", "message": "No flow info"},
|
||||
})
|
||||
}
|
||||
headers, err := fetchFlowHeaders(flowReq)
|
||||
headers, err := fetchFlowHeaders(flowReq, d.Cfg.Fetcher.ProxyURL)
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{
|
||||
"status": "failed",
|
||||
@@ -573,8 +577,8 @@ func parseJSONHeaders(v any) map[string]string {
|
||||
return result
|
||||
}
|
||||
|
||||
func fetchFlowHeaders(req *flowRequest) (string, error) {
|
||||
client := &http.Client{Timeout: req.Timeout}
|
||||
func fetchFlowHeaders(req *flowRequest, proxyURL string) (string, error) {
|
||||
client := buildFlowHTTPClient(req.Timeout, proxyURL)
|
||||
httpReq, err := http.NewRequest("GET", req.URL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -708,3 +712,21 @@ func applyFiltersSafe(nodes []model.ProxyNode, filters []model.FilterRule, setti
|
||||
// Use the filter package's ApplyFilters with FilterContext
|
||||
return applyFiltersWithContext(nodes, filters, settings, target, sourceId)
|
||||
}
|
||||
|
||||
// buildFlowHTTPClient builds an HTTP client for flow-info requests with
|
||||
// optional proxy support.
|
||||
func buildFlowHTTPClient(timeout time.Duration, proxyURL string) *http.Client {
|
||||
if proxyURL == "" {
|
||||
return &http.Client{Timeout: timeout}
|
||||
}
|
||||
pu, err := url.Parse(proxyURL)
|
||||
if err != nil {
|
||||
return &http.Client{Timeout: timeout}
|
||||
}
|
||||
return &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyURL(pu),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2226,7 +2226,7 @@ func TestSetSafeResponseHeaderVar(t *testing.T) {
|
||||
func TestFetchFlowHeadersError(t *testing.T) {
|
||||
// invalid URL -> error
|
||||
req := &flowRequest{URL: "http://localhost:1/no-server", UserAgent: "ua", Timeout: 1000000000}
|
||||
_, err := fetchFlowHeaders(req)
|
||||
_, err := fetchFlowHeaders(req, "")
|
||||
if err == nil {
|
||||
// connection may succeed in some envs; just don't fail the test
|
||||
t.Log("fetchFlowHeaders to invalid URL did not error (env-dependent)")
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -31,6 +32,7 @@ type BuildOptions struct {
|
||||
RequestUserAgent string
|
||||
ForceRefresh bool
|
||||
CacheRepo *database.CacheRepo
|
||||
ProxyURL string
|
||||
}
|
||||
|
||||
// BuildResult holds the output of a subscription build.
|
||||
@@ -249,7 +251,7 @@ func fetchSubscriptionUrl(ctx context.Context, url string, sub model.SourceRecor
|
||||
}
|
||||
|
||||
timeout := getTimeout(opts.Settings)
|
||||
httpClient := &http.Client{Timeout: timeout}
|
||||
httpClient := buildHTTPClient(timeout, opts.ProxyURL)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
@@ -612,3 +614,22 @@ func (aw *AsyncWriter) WaitWithTimeout(timeout time.Duration) {
|
||||
|
||||
// Prevent unused import
|
||||
var _ = math.MaxInt32
|
||||
|
||||
// buildHTTPClient creates an *http.Client with optional proxy support.
|
||||
// If proxyURL is empty, a standard client is returned.
|
||||
func buildHTTPClient(timeout time.Duration, proxyURL string) *http.Client {
|
||||
if proxyURL == "" {
|
||||
return &http.Client{Timeout: timeout}
|
||||
}
|
||||
pu, err := url.Parse(proxyURL)
|
||||
if err != nil {
|
||||
logrus.WithError(err).Warn("Invalid proxy_url, falling back to direct")
|
||||
return &http.Client{Timeout: timeout}
|
||||
}
|
||||
return &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyURL(pu),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user