diff --git a/client.toml b/client.toml index c34c902..88dc039 100644 --- a/client.toml +++ b/client.toml @@ -2,6 +2,15 @@ AES_ENCRYPTION_KEY = "c4710a45afed2fdc00e0522c70802e71" RELAY_URL = "http://127.0.0.1/relay.php" # ============================================================================== +HTTP_USER_AGENTS_FILE = "user-agents.txt" +HTTP_HEADER_PROFILE = "browser" +HTTP_RANDOMIZE_HEADERS = true +HTTP_PADDING_HEADER = "X-Padding" +HTTP_PADDING_MIN_BYTES = 16 +HTTP_PADDING_MAX_BYTES = 48 +HTTP_REFERER = "" +HTTP_ACCEPT_LANGUAGE = "" +# ============================================================================== LOG_LEVEL = "DEBUG" # ============================================================================== SOCKS_HOST = "127.0.0.1" diff --git a/internal/client/client.go b/internal/client/client.go index 446800e..9946dcd 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -26,6 +26,7 @@ type Client struct { clientSessionKey string socksConnections *SOCKSConnectionStore chunkPolicy ChunkPolicy + headerBuilder *relayHeaderBuilder connMu sync.Mutex conns map[net.Conn]struct{} @@ -44,6 +45,7 @@ func New(cfg config.Config, lg *logger.Logger) *Client { clientSessionKey: clientSessionKey, socksConnections: NewSOCKSConnectionStore(), chunkPolicy: newChunkPolicy(cfg), + headerBuilder: newRelayHeaderBuilder(cfg, lg), conns: make(map[net.Conn]struct{}), workCh: make(chan struct{}, 1), } diff --git a/internal/client/http_headers.go b/internal/client/http_headers.go new file mode 100644 index 0000000..3d992d1 --- /dev/null +++ b/internal/client/http_headers.go @@ -0,0 +1,239 @@ +// ============================================================================== +// MasterHttpRelayVPN +// Author: MasterkinG32 +// Github: https://github.com/masterking32 +// Year: 2026 +// ============================================================================== +package client + +import ( + "bufio" + "crypto/rand" + "encoding/hex" + "math/big" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + + "masterhttprelayvpn/internal/config" + "masterhttprelayvpn/internal/logger" +) + +type relayHeaderBuilder struct { + cfg config.Config + userAgents []string + refererCandidates []string +} + +func newRelayHeaderBuilder(cfg config.Config, log *logger.Logger) *relayHeaderBuilder { + builder := &relayHeaderBuilder{ + cfg: cfg, + userAgents: loadUserAgents(cfg.HTTPUserAgentsFile, log), + } + + builder.refererCandidates = buildRefererCandidates(cfg) + return builder +} + +func (b *relayHeaderBuilder) Apply(req *http.Request) { + if ua := b.pickUserAgent(); ua != "" { + req.Header.Set("User-Agent", ua) + } + + if b.cfg.HTTPHeaderProfile == "browser" { + req.Header.Set("Accept", pickRandomString( + "*/*", + "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "application/json,text/plain,*/*", + )) + + req.Header.Set("Accept-Language", b.pickAcceptLanguage()) + req.Header.Set("Cache-Control", pickRandomString("no-cache", "max-age=0", "no-store")) + req.Header.Set("Pragma", "no-cache") + req.Header.Set("Sec-Fetch-Dest", "empty") + req.Header.Set("Sec-Fetch-Mode", "cors") + req.Header.Set("Sec-Fetch-Site", pickRandomString("same-origin", "same-site", "cross-site")) + req.Header.Set("Priority", pickRandomString("u=0, i", "u=1, i")) + if referer := b.pickReferer(); referer != "" { + req.Header.Set("Referer", referer) + } + } + + if b.cfg.HTTPRandomizeHeaders { + padding := randomPadding(b.cfg.HTTPPaddingMinBytes, b.cfg.HTTPPaddingMaxBytes) + if padding != "" { + headerName := strings.TrimSpace(b.cfg.HTTPPaddingHeader) + if headerName == "" { + headerName = "X-Padding" + } + req.Header.Set(headerName, padding) + } + + req.Header.Set("X-Request-Nonce", randomHex(8)) + } +} + +func (b *relayHeaderBuilder) pickUserAgent() string { + if len(b.userAgents) == 0 { + return "" + } + + return b.userAgents[randomIndex(len(b.userAgents))] +} + +func (b *relayHeaderBuilder) pickReferer() string { + if len(b.refererCandidates) == 0 { + return "" + } + + return b.refererCandidates[randomIndex(len(b.refererCandidates))] +} + +func (b *relayHeaderBuilder) pickAcceptLanguage() string { + if strings.TrimSpace(b.cfg.HTTPAcceptLanguage) != "" { + return b.cfg.HTTPAcceptLanguage + } + + return pickRandomString( + "en-US,en;q=0.9", + "en-GB,en;q=0.9", + "fa-IR,fa;q=0.9,en-US;q=0.7,en;q=0.6", + "de-DE,de;q=0.9,en-US;q=0.7,en;q=0.6", + ) +} + +func loadUserAgents(path string, log *logger.Logger) []string { + userAgents := readUserAgentsFromFile(path) + if len(userAgents) > 0 { + return userAgents + } + + if log != nil { + log.Warnf("user agents file %s not found or empty, using built-in defaults", path) + } + + return []string{ + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:137.0) Gecko/20100101 Firefox/137.0", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15", + } +} + +func readUserAgentsFromFile(path string) []string { + if strings.TrimSpace(path) == "" { + return nil + } + + candidates := []string{path} + if !filepath.IsAbs(path) { + candidates = append(candidates, filepath.Join(".", path)) + } + + for _, candidate := range candidates { + file, err := os.Open(candidate) + if err != nil { + continue + } + + userAgents := make([]string, 0, 16) + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + userAgents = append(userAgents, line) + } + + if len(userAgents) > 0 { + _ = file.Close() + return userAgents + } + + _ = file.Close() + } + + return nil +} + +func buildRefererCandidates(cfg config.Config) []string { + if strings.TrimSpace(cfg.HTTPReferer) != "" { + return []string{cfg.HTTPReferer} + } + + relayURL, err := url.Parse(cfg.RelayURL) + if err != nil || relayURL.Scheme == "" || relayURL.Host == "" { + return nil + } + + base := relayURL.Scheme + "://" + relayURL.Host + return []string{ + base + "/", + base + "/index.html", + base + "/home", + base + "/api/status", + } +} + +func pickRandomString(values ...string) string { + if len(values) == 0 { + return "" + } + + return values[randomIndex(len(values))] +} + +func randomIndex(length int) int { + if length <= 1 { + return 0 + } + + n, err := rand.Int(rand.Reader, big.NewInt(int64(length))) + if err != nil { + return 0 + } + + return int(n.Int64()) +} + +func randomPadding(minBytes int, maxBytes int) string { + if maxBytes <= 0 || maxBytes < minBytes { + return "" + } + + length := minBytes + if maxBytes > minBytes { + length += randomIndex(maxBytes - minBytes + 1) + } + + if length <= 0 { + return "" + } + + raw := make([]byte, (length+1)/2) + if _, err := rand.Read(raw); err != nil { + return "" + } + + padding := hex.EncodeToString(raw) + if len(padding) > length { + padding = padding[:length] + } + + return padding +} + +func randomHex(byteCount int) string { + if byteCount <= 0 { + return "" + } + + raw := make([]byte, byteCount) + if _, err := rand.Read(raw); err != nil { + return "" + } + + return hex.EncodeToString(raw) +} diff --git a/internal/client/sender_workers.go b/internal/client/sender_workers.go index 170d968..3fbfe53 100644 --- a/internal/client/sender_workers.go +++ b/internal/client/sender_workers.go @@ -122,7 +122,7 @@ func (c *Client) buildNextBatch() (protocol.Batch, []dequeuedPacket) { for len(selected) < c.cfg.MaxPacketsPerBatch { progress := false - for offset := 0; offset < len(connections); offset++ { + for offset := range connections { if len(selected) >= c.cfg.MaxPacketsPerBatch { break } @@ -197,6 +197,7 @@ func (c *Client) requeueSelected(selected []dequeuedPacket) { for socksConn, identityKeys := range grouped { socksConn.RequeueInFlightByIdentity(identityKeys) } + if len(grouped) > 0 { c.signalSendWork() } @@ -207,6 +208,7 @@ func (c *Client) markSelectedInFlight(selected []dequeuedPacket) { for _, entry := range selected { grouped[entry.socksConn] = append(grouped[entry.socksConn], entry.item) } + for socksConn, items := range grouped { socksConn.MarkInFlight(items) } @@ -221,9 +223,11 @@ func (c *Client) reclaimExpiredInFlight() { "socks_id=%d reclaimed inflight requeued=%d dropped=%d", socksConn.ID, requeued, dropped, ) + if requeued > 0 { c.signalSendWork() } + if dropped > 0 { socksConn.ConnectFailure = "max retry exceeded" socksConn.CompleteConnect(fmt.Errorf("max retry exceeded")) @@ -242,6 +246,9 @@ func (w *sendWorker) postBatch(ctx context.Context, c *Client, batch protocol.Ba req.Header.Set("Content-Type", "application/octet-stream") req.Header.Set("X-Relay-Version", fmt.Sprintf("%d", protocol.CurrentVersion)) + if c.headerBuilder != nil { + c.headerBuilder.Apply(req) + } resp, err := w.httpClient.Do(req) if err != nil { @@ -266,6 +273,7 @@ func (w *sendWorker) postBatch(ctx context.Context, c *Client, batch protocol.Ba if err != nil { return err } + if len(respBody) == 0 { return nil } @@ -274,13 +282,16 @@ func (w *sendWorker) postBatch(ctx context.Context, c *Client, batch protocol.Ba if err != nil { return err } + c.log.Debugf( "worker=%d received response batch=%s packets=%d bytes=%d", w.id, responseBatch.BatchID, len(responseBatch.Packets), len(respBody), ) + if err := c.applyResponseBatch(responseBatch); err != nil { return err } + return nil } @@ -290,6 +301,7 @@ func (c *Client) applyResponseBatch(batch protocol.Batch) error { "apply response packet=%s socks_id=%d seq=%d payload_bytes=%d final=%t", packet.Type, packet.SOCKSID, packet.Sequence, len(packet.Payload), packet.Final, ) + if err := c.applyResponsePacket(packet); err != nil { return err } @@ -359,6 +371,7 @@ func (c *Client) applyResponsePacket(packet protocol.Packet) error { "writing to local socket socks_id=%d bytes=%d", socksConn.ID, len(packet.Payload), ) + return socksConn.WriteToLocal(packet.Payload) case protocol.PacketTypeSOCKSCloseRead: @@ -368,12 +381,15 @@ func (c *Client) applyResponsePacket(packet protocol.Packet) error { "close_read applied socks_id=%d", socksConn.ID, ) + if err := socksConn.CloseLocalWrite(); err != nil { return err } + if socksConn.BothLocalSidesClosed() { return socksConn.CloseLocal() } + return nil case protocol.PacketTypeSOCKSCloseWrite: diff --git a/internal/config/config.go b/internal/config/config.go index 6b01439..ef6aa47 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -18,6 +18,14 @@ import ( type Config struct { AESEncryptionKey string RelayURL string + HTTPUserAgentsFile string + HTTPHeaderProfile string + HTTPRandomizeHeaders bool + HTTPPaddingHeader string + HTTPPaddingMinBytes int + HTTPPaddingMaxBytes int + HTTPReferer string + HTTPAcceptLanguage string ServerHost string ServerPort int SOCKSHost string @@ -46,6 +54,12 @@ func Load(path string) (Config, error) { cfg := Config{ SOCKSHost: "127.0.0.1", SOCKSPort: 1080, + HTTPUserAgentsFile: "user-agents.txt", + HTTPHeaderProfile: "browser", + HTTPRandomizeHeaders: true, + HTTPPaddingHeader: "X-Padding", + HTTPPaddingMinBytes: 16, + HTTPPaddingMaxBytes: 48, ServerHost: "127.0.0.1", ServerPort: 28080, LogLevel: "INFO", @@ -91,6 +105,34 @@ func Load(path string) (Config, error) { cfg.AESEncryptionKey = trimString(value) case "RELAY_URL": cfg.RelayURL = trimString(value) + case "HTTP_USER_AGENTS_FILE": + cfg.HTTPUserAgentsFile = trimString(value) + case "HTTP_HEADER_PROFILE": + cfg.HTTPHeaderProfile = trimString(value) + case "HTTP_RANDOMIZE_HEADERS": + randomize, err := strconv.ParseBool(value) + if err != nil { + return Config{}, fmt.Errorf("parse HTTP_RANDOMIZE_HEADERS: %w", err) + } + cfg.HTTPRandomizeHeaders = randomize + case "HTTP_PADDING_HEADER": + cfg.HTTPPaddingHeader = trimString(value) + case "HTTP_PADDING_MIN_BYTES": + size, err := strconv.Atoi(value) + if err != nil { + return Config{}, fmt.Errorf("parse HTTP_PADDING_MIN_BYTES: %w", err) + } + cfg.HTTPPaddingMinBytes = size + case "HTTP_PADDING_MAX_BYTES": + size, err := strconv.Atoi(value) + if err != nil { + return Config{}, fmt.Errorf("parse HTTP_PADDING_MAX_BYTES: %w", err) + } + cfg.HTTPPaddingMaxBytes = size + case "HTTP_REFERER": + cfg.HTTPReferer = trimString(value) + case "HTTP_ACCEPT_LANGUAGE": + cfg.HTTPAcceptLanguage = trimString(value) case "SERVER_HOST": cfg.ServerHost = trimString(value) case "SERVER_PORT": @@ -217,33 +259,55 @@ func (c Config) ValidateClient() error { if err := c.validateShared(); err != nil { return err } + if c.SOCKSAuth && (c.SOCKSUsername == "" || c.SOCKSPassword == "") { return fmt.Errorf("SOCKS auth enabled but username/password missing") } + if c.SOCKSPort < 1 || c.SOCKSPort > 65535 { return fmt.Errorf("invalid SOCKS_PORT: %d", c.SOCKSPort) } + if strings.TrimSpace(c.RelayURL) == "" { return fmt.Errorf("RELAY_URL is required") } + if c.HTTPRequestTimeoutMS < 1 { return fmt.Errorf("invalid HTTP_REQUEST_TIMEOUT_MS: %d", c.HTTPRequestTimeoutMS) } + if c.WorkerPollIntervalMS < 1 { return fmt.Errorf("invalid WORKER_POLL_INTERVAL_MS: %d", c.WorkerPollIntervalMS) } + if c.IdlePollIntervalMS < c.WorkerPollIntervalMS { return fmt.Errorf("IDLE_POLL_INTERVAL_MS must be >= WORKER_POLL_INTERVAL_MS") } + if c.AckTimeoutMS < 1 { return fmt.Errorf("invalid ACK_TIMEOUT_MS: %d", c.AckTimeoutMS) } + if c.MaxRetryCount < 0 { return fmt.Errorf("invalid MAX_RETRY_COUNT: %d", c.MaxRetryCount) } + + if c.HTTPHeaderProfile != "browser" && c.HTTPHeaderProfile != "minimal" { + return fmt.Errorf("invalid HTTP_HEADER_PROFILE: %s", c.HTTPHeaderProfile) + } + + if c.HTTPPaddingMinBytes < 0 { + return fmt.Errorf("invalid HTTP_PADDING_MIN_BYTES: %d", c.HTTPPaddingMinBytes) + } + + if c.HTTPPaddingMaxBytes < c.HTTPPaddingMinBytes { + return fmt.Errorf("HTTP_PADDING_MAX_BYTES must be >= HTTP_PADDING_MIN_BYTES") + } + if c.MaxQueueBytesPerSOCKS < c.MaxChunkSize { return fmt.Errorf("MAX_QUEUE_BYTES_PER_SOCKS must be >= MAX_CHUNK_SIZE") } + return nil } @@ -251,21 +315,27 @@ func (c Config) ValidateServer() error { if err := c.validateShared(); err != nil { return err } + if c.ServerPort < 1 || c.ServerPort > 65535 { return fmt.Errorf("invalid SERVER_PORT: %d", c.ServerPort) } + if c.SessionIdleTimeoutMS < 1 { return fmt.Errorf("invalid SESSION_IDLE_TIMEOUT_MS: %d", c.SessionIdleTimeoutMS) } + if c.SOCKSIdleTimeoutMS < 1 { return fmt.Errorf("invalid SOCKS_IDLE_TIMEOUT_MS: %d", c.SOCKSIdleTimeoutMS) } + if c.ReadBodyLimitBytes < c.MaxChunkSize { return fmt.Errorf("READ_BODY_LIMIT_BYTES must be >= MAX_CHUNK_SIZE") } + if c.MaxServerQueueBytes < c.MaxChunkSize { return fmt.Errorf("MAX_SERVER_QUEUE_BYTES must be >= MAX_CHUNK_SIZE") } + return nil } @@ -273,18 +343,23 @@ func (c Config) validateShared() error { if strings.TrimSpace(c.AESEncryptionKey) == "" { return fmt.Errorf("AES_ENCRYPTION_KEY is required") } + if c.MaxChunkSize < 1 { return fmt.Errorf("invalid MAX_CHUNK_SIZE: %d", c.MaxChunkSize) } + if c.MaxPacketsPerBatch < 1 { return fmt.Errorf("invalid MAX_PACKETS_PER_BATCH: %d", c.MaxPacketsPerBatch) } + if c.MaxBatchBytes < c.MaxChunkSize { return fmt.Errorf("MAX_BATCH_BYTES must be >= MAX_CHUNK_SIZE") } + if c.WorkerCount < 1 { return fmt.Errorf("invalid WORKER_COUNT: %d", c.WorkerCount) } + return nil } diff --git a/user-agents.txt b/user-agents.txt new file mode 100644 index 0000000..4c7cae8 --- /dev/null +++ b/user-agents.txt @@ -0,0 +1,12 @@ +# One user-agent per line. +# Empty lines and lines starting with # are ignored. +Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36 +Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:137.0) Gecko/20100101 Firefox/137.0 +Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Edge/135.0.0.0 Safari/537.36 +Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36 +Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15 +Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36 +Mozilla/5.0 (X11; Linux x86_64; rv:137.0) Gecko/20100101 Firefox/137.0 +Mozilla/5.0 (iPhone; CPU iPhone OS 18_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4 Mobile/15E148 Safari/604.1 +Mozilla/5.0 (Linux; Android 15; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Mobile Safari/537.36 +Mozilla/5.0 (Linux; Android 15; SM-S928B) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/27.0 Chrome/135.0.0.0 Mobile Safari/537.36