mirror of
https://github.com/masterking32/MasterHttpRelayVPN.git
synced 2026-05-17 21:24:37 +03:00
Add multi-relay URL support with round-robin and random endpoint selection
This commit is contained in:
+17
-6
@@ -23,12 +23,23 @@
|
|||||||
# Allowed: any non-empty string
|
# Allowed: any non-empty string
|
||||||
AES_ENCRYPTION_KEY = "c4710a45afed2fdc00e0522c70802e71"
|
AES_ENCRYPTION_KEY = "c4710a45afed2fdc00e0522c70802e71"
|
||||||
|
|
||||||
# RELAY_URL:
|
# RELAY_URLS:
|
||||||
# The final HTTP or HTTPS endpoint used by the client for sending encrypted batches.
|
# Array of relay endpoints used by the client for sending encrypted batches.
|
||||||
# This can point directly to the Go server or to a PHP relay/fronting endpoint.
|
# Each entry can point directly to the Go server or to a PHP relay/fronting endpoint.
|
||||||
# Default: none, required
|
# The client chooses one endpoint per request using RELAY_URL_SELECTION.
|
||||||
# Allowed: any non-empty http:// or https:// URL
|
# Example:
|
||||||
RELAY_URL = "http://127.0.0.1/relay.php"
|
# 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
|
# HTTP DISGUISE / HEADER SHAPE
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ type Client struct {
|
|||||||
socksConnections *SOCKSConnectionStore
|
socksConnections *SOCKSConnectionStore
|
||||||
chunkPolicy ChunkPolicy
|
chunkPolicy ChunkPolicy
|
||||||
headerBuilder *relayHeaderBuilder
|
headerBuilder *relayHeaderBuilder
|
||||||
|
relayURLs []string
|
||||||
|
|
||||||
connMu sync.Mutex
|
connMu sync.Mutex
|
||||||
conns map[net.Conn]struct{}
|
conns map[net.Conn]struct{}
|
||||||
@@ -46,6 +47,7 @@ type Client struct {
|
|||||||
idlePongStreak atomic.Int64
|
idlePongStreak atomic.Int64
|
||||||
pingState atomic.Int32
|
pingState atomic.Int32
|
||||||
batchCursor atomic.Uint64
|
batchCursor atomic.Uint64
|
||||||
|
relayURLCursor atomic.Uint64
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(cfg config.Config, lg *logger.Logger) *Client {
|
func New(cfg config.Config, lg *logger.Logger) *Client {
|
||||||
@@ -58,6 +60,7 @@ func New(cfg config.Config, lg *logger.Logger) *Client {
|
|||||||
socksConnections: NewSOCKSConnectionStore(),
|
socksConnections: NewSOCKSConnectionStore(),
|
||||||
chunkPolicy: newChunkPolicy(cfg),
|
chunkPolicy: newChunkPolicy(cfg),
|
||||||
headerBuilder: newRelayHeaderBuilder(cfg, lg),
|
headerBuilder: newRelayHeaderBuilder(cfg, lg),
|
||||||
|
relayURLs: cfg.RelayEndpointURLs(),
|
||||||
conns: make(map[net.Conn]struct{}),
|
conns: make(map[net.Conn]struct{}),
|
||||||
workCh: make(chan struct{}, 1),
|
workCh: make(chan struct{}, 1),
|
||||||
}
|
}
|
||||||
@@ -216,3 +219,22 @@ func generateClientSessionKey() string {
|
|||||||
|
|
||||||
return fmt.Sprintf("%s_%s", now, hex.EncodeToString(random))
|
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))]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -224,18 +224,29 @@ func buildRefererCandidates(cfg config.Config) []string {
|
|||||||
return []string{cfg.HTTPReferer}
|
return []string{cfg.HTTPReferer}
|
||||||
}
|
}
|
||||||
|
|
||||||
relayURL, err := url.Parse(cfg.RelayURL)
|
candidates := make([]string, 0)
|
||||||
if err != nil || relayURL.Scheme == "" || relayURL.Host == "" {
|
seen := make(map[string]struct{})
|
||||||
return nil
|
for _, rawRelayURL := range cfg.RelayEndpointURLs() {
|
||||||
}
|
relayURL, err := url.Parse(rawRelayURL)
|
||||||
|
if err != nil || relayURL.Scheme == "" || relayURL.Host == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
base := relayURL.Scheme + "://" + relayURL.Host
|
base := relayURL.Scheme + "://" + relayURL.Host
|
||||||
return []string{
|
for _, candidate := range []string{
|
||||||
base + "/",
|
base + "/",
|
||||||
base + "/index.html",
|
base + "/index.html",
|
||||||
base + "/home",
|
base + "/home",
|
||||||
base + "/api/status",
|
base + "/api/status",
|
||||||
|
} {
|
||||||
|
if _, exists := seen[candidate]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[candidate] = struct{}{}
|
||||||
|
candidates = append(candidates, candidate)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return candidates
|
||||||
}
|
}
|
||||||
|
|
||||||
func pickRandomString(values ...string) string {
|
func pickRandomString(values ...string) string {
|
||||||
|
|||||||
@@ -51,3 +51,36 @@ func TestBuildRelayURLAddsRandomQuerySuffixWhenEnabled(t *testing.T) {
|
|||||||
t.Fatalf("expected one randomized query suffix key, got query %q", parsed.RawQuery)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -475,7 +475,7 @@ func (c *Client) reclaimExpiredReorder() {
|
|||||||
|
|
||||||
func (w *sendWorker) postBatch(ctx context.Context, c *Client, batch protocol.Batch, body []byte) error {
|
func (w *sendWorker) postBatch(ctx context.Context, c *Client, batch protocol.Batch, body []byte) error {
|
||||||
pingOnly := isPingOnlyBatch(batch)
|
pingOnly := isPingOnlyBatch(batch)
|
||||||
relayURL := c.cfg.RelayURL
|
relayURL := c.nextRelayURL()
|
||||||
if c.headerBuilder != nil {
|
if c.headerBuilder != nil {
|
||||||
relayURL = c.headerBuilder.BuildRelayURL(relayURL)
|
relayURL = c.headerBuilder.BuildRelayURL(relayURL)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
func TestBuildPollBatchSkipsWhenTransportBusy(t *testing.T) {
|
||||||
cfg := testClientConfig()
|
cfg := testClientConfig()
|
||||||
client := New(cfg, nil)
|
client := New(cfg, nil)
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import (
|
|||||||
type Config struct {
|
type Config struct {
|
||||||
AESEncryptionKey string
|
AESEncryptionKey string
|
||||||
RelayURL string
|
RelayURL string
|
||||||
|
RelayURLs []string
|
||||||
|
RelayURLSelection string
|
||||||
HTTPUserAgentsFile string
|
HTTPUserAgentsFile string
|
||||||
HTTPHeaderProfile string
|
HTTPHeaderProfile string
|
||||||
HTTPRandomizeHeaders bool
|
HTTPRandomizeHeaders bool
|
||||||
@@ -77,6 +79,7 @@ func Load(path string) (Config, error) {
|
|||||||
cfg := Config{
|
cfg := Config{
|
||||||
SOCKSHost: "127.0.0.1",
|
SOCKSHost: "127.0.0.1",
|
||||||
SOCKSPort: 1080,
|
SOCKSPort: 1080,
|
||||||
|
RelayURLSelection: "round_robin",
|
||||||
HTTPUserAgentsFile: "user-agents.txt",
|
HTTPUserAgentsFile: "user-agents.txt",
|
||||||
HTTPHeaderProfile: "browser",
|
HTTPHeaderProfile: "browser",
|
||||||
HTTPRandomizeHeaders: true,
|
HTTPRandomizeHeaders: true,
|
||||||
@@ -151,6 +154,10 @@ func Load(path string) (Config, error) {
|
|||||||
cfg.AESEncryptionKey = trimString(value)
|
cfg.AESEncryptionKey = trimString(value)
|
||||||
case "RELAY_URL":
|
case "RELAY_URL":
|
||||||
cfg.RelayURL = trimString(value)
|
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":
|
case "HTTP_USER_AGENTS_FILE":
|
||||||
cfg.HTTPUserAgentsFile = trimString(value)
|
cfg.HTTPUserAgentsFile = trimString(value)
|
||||||
case "HTTP_HEADER_PROFILE":
|
case "HTTP_HEADER_PROFILE":
|
||||||
@@ -495,7 +502,18 @@ func (c Config) ValidateClient() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if strings.TrimSpace(c.RelayURL) == "" {
|
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 {
|
if c.HTTPRequestTimeoutMS < 1 {
|
||||||
@@ -657,6 +675,68 @@ func (c Config) ValidateServer() error {
|
|||||||
return nil
|
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 {
|
func (c Config) validateShared() error {
|
||||||
if strings.TrimSpace(c.AESEncryptionKey) == "" {
|
if strings.TrimSpace(c.AESEncryptionKey) == "" {
|
||||||
return fmt.Errorf("AES_ENCRYPTION_KEY is required")
|
return fmt.Errorf("AES_ENCRYPTION_KEY is required")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -355,8 +355,6 @@ func (s *Server) processPacketLocked(session *ClientSession, packet protocol.Pac
|
|||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unsupported packet type: %s", packet.Type)
|
return nil, fmt.Errorf("unsupported packet type: %s", packet.Type)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) getOrCreateSession(clientSessionKey string) *ClientSession {
|
func (s *Server) getOrCreateSession(clientSessionKey string) *ClientSession {
|
||||||
|
|||||||
Reference in New Issue
Block a user