diff --git a/client.toml b/client.toml index 3c9a738..a76d9fc 100644 --- a/client.toml +++ b/client.toml @@ -23,12 +23,23 @@ # Allowed: any non-empty string AES_ENCRYPTION_KEY = "c4710a45afed2fdc00e0522c70802e71" -# RELAY_URL: -# The final HTTP or HTTPS endpoint used by the client for sending encrypted batches. -# This can point directly to the Go server or to a PHP relay/fronting endpoint. -# Default: none, required -# Allowed: any non-empty http:// or https:// URL -RELAY_URL = "http://127.0.0.1/relay.php" +# RELAY_URLS: +# Array of relay endpoints used by the client for sending encrypted batches. +# Each entry can point directly to the Go server or to a PHP relay/fronting endpoint. +# The client chooses one endpoint per request using RELAY_URL_SELECTION. +# Example: +# RELAY_URLS = ["https://a.example/relay.php", "https://b.example/relay.php"] +# Default: one local relay URL +# Allowed: one or more http:// / https:// URLs +RELAY_URLS = ["http://127.0.0.1/relay.php"] + +# RELAY_URL_SELECTION: +# Selection algorithm used when RELAY_URLS contains more than one endpoint. +# "round_robin" = rotate endpoints in order per request +# "random" = choose a random endpoint per request +# Default: "round_robin" +# Allowed: "round_robin", "random" +RELAY_URL_SELECTION = "round_robin" # ============================================================================== # HTTP DISGUISE / HEADER SHAPE diff --git a/internal/client/client.go b/internal/client/client.go index 5ea31df..7645e60 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -33,6 +33,7 @@ type Client struct { socksConnections *SOCKSConnectionStore chunkPolicy ChunkPolicy headerBuilder *relayHeaderBuilder + relayURLs []string connMu sync.Mutex conns map[net.Conn]struct{} @@ -46,6 +47,7 @@ type Client struct { idlePongStreak atomic.Int64 pingState atomic.Int32 batchCursor atomic.Uint64 + relayURLCursor atomic.Uint64 } func New(cfg config.Config, lg *logger.Logger) *Client { @@ -58,6 +60,7 @@ func New(cfg config.Config, lg *logger.Logger) *Client { socksConnections: NewSOCKSConnectionStore(), chunkPolicy: newChunkPolicy(cfg), headerBuilder: newRelayHeaderBuilder(cfg, lg), + relayURLs: cfg.RelayEndpointURLs(), conns: make(map[net.Conn]struct{}), workCh: make(chan struct{}, 1), } @@ -216,3 +219,22 @@ func generateClientSessionKey() string { return fmt.Sprintf("%s_%s", now, hex.EncodeToString(random)) } + +func (c *Client) nextRelayURL() string { + if len(c.relayURLs) == 0 { + return c.cfg.RelayURL + } + if len(c.relayURLs) == 1 { + return c.relayURLs[0] + } + + switch c.cfg.RelayURLSelection { + case "random": + return c.relayURLs[randomIndex(len(c.relayURLs))] + case "round_robin": + fallthrough + default: + index := c.relayURLCursor.Add(1) - 1 + return c.relayURLs[index%uint64(len(c.relayURLs))] + } +} diff --git a/internal/client/http_headers.go b/internal/client/http_headers.go index 0190901..ec40330 100644 --- a/internal/client/http_headers.go +++ b/internal/client/http_headers.go @@ -224,18 +224,29 @@ func buildRefererCandidates(cfg config.Config) []string { return []string{cfg.HTTPReferer} } - relayURL, err := url.Parse(cfg.RelayURL) - if err != nil || relayURL.Scheme == "" || relayURL.Host == "" { - return nil - } + candidates := make([]string, 0) + seen := make(map[string]struct{}) + for _, rawRelayURL := range cfg.RelayEndpointURLs() { + relayURL, err := url.Parse(rawRelayURL) + if err != nil || relayURL.Scheme == "" || relayURL.Host == "" { + continue + } - base := relayURL.Scheme + "://" + relayURL.Host - return []string{ - base + "/", - base + "/index.html", - base + "/home", - base + "/api/status", + base := relayURL.Scheme + "://" + relayURL.Host + for _, candidate := range []string{ + base + "/", + base + "/index.html", + base + "/home", + base + "/api/status", + } { + if _, exists := seen[candidate]; exists { + continue + } + seen[candidate] = struct{}{} + candidates = append(candidates, candidate) + } } + return candidates } func pickRandomString(values ...string) string { diff --git a/internal/client/http_headers_test.go b/internal/client/http_headers_test.go index b89417f..ebe3936 100644 --- a/internal/client/http_headers_test.go +++ b/internal/client/http_headers_test.go @@ -51,3 +51,36 @@ func TestBuildRelayURLAddsRandomQuerySuffixWhenEnabled(t *testing.T) { t.Fatalf("expected one randomized query suffix key, got query %q", parsed.RawQuery) } } + +func TestBuildRefererCandidatesIncludesAllRelayHosts(t *testing.T) { + cfg := config.Config{ + RelayURLs: []string{ + "https://relay-a.example/relay", + "https://relay-b.example/relay", + }, + } + + candidates := buildRefererCandidates(cfg) + expected := map[string]bool{ + "https://relay-a.example/": false, + "https://relay-a.example/index.html": false, + "https://relay-a.example/home": false, + "https://relay-a.example/api/status": false, + "https://relay-b.example/": false, + "https://relay-b.example/index.html": false, + "https://relay-b.example/home": false, + "https://relay-b.example/api/status": false, + } + + for _, candidate := range candidates { + if _, ok := expected[candidate]; ok { + expected[candidate] = true + } + } + + for candidate, seen := range expected { + if !seen { + t.Fatalf("expected referer candidate %q to be present", candidate) + } + } +} diff --git a/internal/client/sender_workers.go b/internal/client/sender_workers.go index ae0738c..852b8bf 100644 --- a/internal/client/sender_workers.go +++ b/internal/client/sender_workers.go @@ -475,7 +475,7 @@ func (c *Client) reclaimExpiredReorder() { func (w *sendWorker) postBatch(ctx context.Context, c *Client, batch protocol.Batch, body []byte) error { pingOnly := isPingOnlyBatch(batch) - relayURL := c.cfg.RelayURL + relayURL := c.nextRelayURL() if c.headerBuilder != nil { relayURL = c.headerBuilder.BuildRelayURL(relayURL) } diff --git a/internal/client/sender_workers_test.go b/internal/client/sender_workers_test.go index 5542637..df165d1 100644 --- a/internal/client/sender_workers_test.go +++ b/internal/client/sender_workers_test.go @@ -308,6 +308,53 @@ func TestSendWorkerTransportReuseLimitStaysWithinConfiguredRange(t *testing.T) { } } +func TestNextRelayURLUsesRoundRobinByDefault(t *testing.T) { + cfg := testClientConfig() + cfg.RelayURLs = []string{ + "https://relay-a.example/relay", + "https://relay-b.example/relay", + "https://relay-c.example/relay", + } + cfg.RelayURLSelection = "round_robin" + + client := New(cfg, nil) + expected := []string{ + "https://relay-a.example/relay", + "https://relay-b.example/relay", + "https://relay-c.example/relay", + "https://relay-a.example/relay", + } + + for i, want := range expected { + if got := client.nextRelayURL(); got != want { + t.Fatalf("iteration %d: expected %q, got %q", i, want, got) + } + } +} + +func TestNextRelayURLRandomChoosesConfiguredRelaySet(t *testing.T) { + cfg := testClientConfig() + cfg.RelayURLs = []string{ + "https://relay-a.example/relay", + "https://relay-b.example/relay", + "https://relay-c.example/relay", + } + cfg.RelayURLSelection = "random" + + client := New(cfg, nil) + allowed := map[string]bool{ + "https://relay-a.example/relay": true, + "https://relay-b.example/relay": true, + "https://relay-c.example/relay": true, + } + + for i := 0; i < 50; i++ { + if got := client.nextRelayURL(); !allowed[got] { + t.Fatalf("unexpected relay URL selected: %q", got) + } + } +} + func TestBuildPollBatchSkipsWhenTransportBusy(t *testing.T) { cfg := testClientConfig() client := New(cfg, nil) diff --git a/internal/config/config.go b/internal/config/config.go index 184d0ea..2e89b44 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -18,6 +18,8 @@ import ( type Config struct { AESEncryptionKey string RelayURL string + RelayURLs []string + RelayURLSelection string HTTPUserAgentsFile string HTTPHeaderProfile string HTTPRandomizeHeaders bool @@ -77,6 +79,7 @@ func Load(path string) (Config, error) { cfg := Config{ SOCKSHost: "127.0.0.1", SOCKSPort: 1080, + RelayURLSelection: "round_robin", HTTPUserAgentsFile: "user-agents.txt", HTTPHeaderProfile: "browser", HTTPRandomizeHeaders: true, @@ -151,6 +154,10 @@ func Load(path string) (Config, error) { cfg.AESEncryptionKey = trimString(value) case "RELAY_URL": cfg.RelayURL = trimString(value) + case "RELAY_URLS": + cfg.RelayURLs = parseStringArray(value) + case "RELAY_URL_SELECTION": + cfg.RelayURLSelection = strings.ToLower(trimString(value)) case "HTTP_USER_AGENTS_FILE": cfg.HTTPUserAgentsFile = trimString(value) case "HTTP_HEADER_PROFILE": @@ -495,7 +502,18 @@ func (c Config) ValidateClient() error { } if strings.TrimSpace(c.RelayURL) == "" { - return fmt.Errorf("RELAY_URL is required") + if len(c.RelayURLs) == 0 { + return fmt.Errorf("RELAY_URL or RELAY_URLS is required") + } + } + + relayURLs := c.RelayEndpointURLs() + if len(relayURLs) == 0 { + return fmt.Errorf("at least one relay URL is required") + } + + if c.RelayURLSelection != "round_robin" && c.RelayURLSelection != "random" { + return fmt.Errorf("invalid RELAY_URL_SELECTION: %s", c.RelayURLSelection) } if c.HTTPRequestTimeoutMS < 1 { @@ -657,6 +675,68 @@ func (c Config) ValidateServer() error { return nil } +func (c Config) RelayEndpointURLs() []string { + urls := make([]string, 0, len(c.RelayURLs)+1) + for _, relayURL := range c.RelayURLs { + relayURL = strings.TrimSpace(relayURL) + if relayURL == "" { + continue + } + urls = append(urls, relayURL) + } + if len(urls) > 0 { + return urls + } + if relayURL := strings.TrimSpace(c.RelayURL); relayURL != "" { + return []string{relayURL} + } + return nil +} + +func parseCommaSeparatedStrings(raw string) []string { + if strings.TrimSpace(raw) == "" { + return nil + } + + parts := strings.Split(raw, ",") + values := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + values = append(values, part) + } + return values +} + +func parseStringArray(raw string) []string { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + + if strings.HasPrefix(raw, "[") && strings.HasSuffix(raw, "]") { + body := strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(raw, "["), "]")) + if body == "" { + return nil + } + + parts := strings.Split(body, ",") + values := make([]string, 0, len(parts)) + for _, part := range parts { + part = trimString(strings.TrimSpace(part)) + if part == "" { + continue + } + values = append(values, part) + } + return values + } + + return parseCommaSeparatedStrings(trimString(raw)) +} + func (c Config) validateShared() error { if strings.TrimSpace(c.AESEncryptionKey) == "" { return fmt.Errorf("AES_ENCRYPTION_KEY is required") diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..4f3ef48 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,32 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadParsesRelayURLsArray(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "client.toml") + content := ` +AES_ENCRYPTION_KEY = "test-key" +RELAY_URLS = ["https://a.example/relay.php", "https://b.example/relay.php"] +RELAY_URL_SELECTION = "round_robin" +` + if err := os.WriteFile(configPath, []byte(content), 0644); err != nil { + t.Fatalf("write config: %v", err) + } + + cfg, err := Load(configPath) + if err != nil { + t.Fatalf("load config: %v", err) + } + + if len(cfg.RelayURLs) != 2 { + t.Fatalf("expected 2 relay URLs, got %d", len(cfg.RelayURLs)) + } + if cfg.RelayURLs[0] != "https://a.example/relay.php" || cfg.RelayURLs[1] != "https://b.example/relay.php" { + t.Fatalf("unexpected relay URLs: %#v", cfg.RelayURLs) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 99d9a9b..1ab6b9e 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -355,8 +355,6 @@ func (s *Server) processPacketLocked(session *ClientSession, packet protocol.Pac default: return nil, fmt.Errorf("unsupported packet type: %s", packet.Type) } - - return nil, nil } func (s *Server) getOrCreateSession(clientSessionKey string) *ClientSession {