From c17ecddf1e90decd9f0f81789aaf537808f6e0fa Mon Sep 17 00:00:00 2001 From: Aleksandr Soloshenko Date: Thu, 23 Jul 2026 08:34:15 +0700 Subject: [PATCH] [init] initial implementation --- README.md | 248 +++++++++++++++++++++++++++++++++++++++++++++++++++++ client.go | 107 +++++++++++++++++++++++ config.go | 33 +++++++ errors.go | 14 +++ factory.go | 50 +++++++++++ go.mod | 11 +-- go.sum | 10 +++ module.go | 11 ++- options.go | 85 ++++++++++++++++++ 9 files changed, 561 insertions(+), 8 deletions(-) create mode 100644 README.md create mode 100644 client.go create mode 100644 config.go create mode 100644 errors.go create mode 100644 factory.go create mode 100644 options.go diff --git a/README.md b/README.md new file mode 100644 index 0000000..1ed18b7 --- /dev/null +++ b/README.md @@ -0,0 +1,248 @@ + + +[![Contributors][contributors-shield]][contributors-url] +[![Forks][forks-shield]][forks-url] +[![Stargazers][stars-shield]][stars-url] +[![Issues][issues-shield]][issues-url] +[![Apache 2.0 License][license-shield]][license-url] + +
+
+

httpfx

+

+ Uber Fx module for net/http.Client with SOCKS5, HTTP proxy, and environment-based proxy support. +

+ Report Bug + · + Request Feature +
+ +## Table of Contents +- [Table of Contents](#table-of-contents) +- [About The Project](#about-the-project) + - [Built With](#built-with) +- [Getting Started](#getting-started) + - [Prerequisites](#prerequisites) + - [Installation](#installation) +- [Usage](#usage) + - [Module Setup](#module-setup) + - [Configuration Reference](#configuration-reference) + - [Factory \& Per-Client Options](#factory--per-client-options) + - [Available Options](#available-options) + - [Proxy Examples](#proxy-examples) +- [Roadmap](#roadmap) +- [Contributing](#contributing) +- [License](#license) +- [Acknowledgments](#acknowledgments) + + +--- + +## About The Project + +`httpfx` is an [Uber Fx](https://uber-go.github.io/fx/) module that provides a configured `*http.Client` and a `Factory` for creating additional client instances. It supports: + +- **SOCKS5 proxy** via `golang.org/x/net/proxy` (`socks5://user:pass@host:port`) +- **HTTP-level proxy** via `net/http.Transport.Proxy` (`http://proxy:8080`) +- **Environment-based proxy** via `ALL_PROXY` env var +- **Per-host proxy bypass** for hosts that should connect directly +- **Per-client overrides** via functional options on the `Factory` +- **Transport tuning** — idle connections, timeouts, pool sizes + +### Built With + +- [![Go](https://img.shields.io/badge/Go-00ADD8?style=for-the-badge&logo=go&logoColor=white)](https://go.dev/) +- [![Uber Fx](https://img.shields.io/badge/Uber%20Fx-000000?style=for-the-badge)](https://uber-go.github.io/fx/) +- [![x/net](https://img.shields.io/badge/golang.org%2Fx%2Fnet-000000?style=for-the-badge)](https://pkg.go.dev/golang.org/x/net/proxy) + +

(back to top)

+ +--- + +## Getting Started + +### Prerequisites + +- Go 1.25+ +- An application using [Uber Fx](https://uber-go.github.io/fx/) for dependency injection + +### Installation + +```sh +go get github.com/go-core-fx/httpfx@latest +``` + +

(back to top)

+ +--- + +## Usage + +### Module Setup + +```go +import ( + "time" + + "github.com/go-core-fx/httpfx" + "go.uber.org/fx" +) + +func main() { + fx.New( + fx.Provide(func() httpfx.Config { + return httpfx.Config{ + ProxyURL: "socks5://127.0.0.1:1080", + Bypass: "localhost,127.0.0.1", + Timeout: 30 * time.Second, + } + }), + httpfx.Module(), + // ... other modules + ).Run() +} +``` + +The module provides both a default `*http.Client` and a `Factory` for creating additional clients. + +### Configuration Reference + +| Field | Type | Default | Description | +| --------------------- | --------------- | ------- | --------------------------------------------------------------------------------- | +| `ProxyURL` | `string` | `""` | SOCKS5 proxy URL (e.g. `socks5://user:pass@host:port`). Takes highest precedence. | +| `ProxyFromEnv` | `bool` | `false` | Read proxy from `ALL_PROXY` env var when `ProxyURL` is empty. | +| `Bypass` | `string` | `""` | Comma-separated hosts to bypass the SOCKS proxy (e.g. `localhost,127.0.0.1`). | +| `Timeout` | `time.Duration` | `0` | Client-level request timeout. Zero means no timeout. | +| `MaxIdleConns` | `int` | `0` | Maximum idle (keep-alive) connections. Zero means no limit. | +| `MaxIdleConnsPerHost` | `int` | `0` | Maximum idle connections per host. Zero means Go default (2). | +| `IdleConnTimeout` | `time.Duration` | `0` | Maximum time a connection stays idle. Zero means no timeout. | + +**Proxy precedence:** `ProxyURL` → `ProxyFromEnv`. To disable all proxying, clear `ProxyURL` and set `ProxyFromEnv: false`. + +### Factory & Per-Client Options + +Inject `httpfx.Factory` to create additional clients with shared base config but per-client overrides: + +```go +func Handler(f httpfx.Factory) { + // Default client — uses factory base config + defaultClient := f.NewClient() + + // Override timeout for a fast endpoint + apiClient := f.NewClient(httpfx.WithTimeout(5 * time.Second)) + + // Disable proxy for internal service calls + internalClient := f.NewClient(httpfx.WithProxyURL("")) + + // Custom transport for a specific module + uploadClient := f.NewClient( + httpfx.WithTimeout(5 * time.Minute), + httpfx.WithMaxIdleConns(10), + ) +} +``` + +#### Available Options + +| Option | Description | +| ---------------------------- | -------------------------------------- | +| `WithProxyURL(url)` | Override SOCKS5 proxy URL | +| `WithProxyFromEnv(v)` | Override env-based proxy flag | +| `WithBypass(bypass)` | Override proxy bypass list | +| `WithTimeout(d)` | Override client timeout | +| `WithMaxIdleConns(n)` | Override max idle connections | +| `WithMaxIdleConnsPerHost(n)` | Override max idle connections per host | +| `WithIdleConnTimeout(d)` | Override idle connection timeout | + +### Proxy Examples + +**SOCKS5 with authentication:** + +```go +httpfx.Config{ + ProxyURL: "socks5://user:pass@127.0.0.1:1080", +} +``` + +**Environment-based (reads `ALL_PROXY`):** + +```go +httpfx.Config{ + ProxyFromEnv: true, +} +``` + +```sh +export ALL_PROXY="socks5://127.0.0.1:1080" +``` + +**SOCKS5 with bypass for local addresses and CIDR ranges:** + +```go +httpfx.Config{ + ProxyURL: "socks5://127.0.0.1:1080", + Bypass: "localhost,127.0.0.1,192.168.0.0/16", +} +``` + +

(back to top)

+ +--- + +## Roadmap + +- [x] SOCKS5 proxy support via `golang.org/x/net/proxy` +- [x] Environment-based proxy (`ALL_PROXY`) +- [x] Per-host proxy bypass +- [x] Factory with per-client functional options +- [x] Transport tuning (idle connections, timeouts) +- [ ] TLS configuration options +- [ ] Proxy authentication header support + +See the [open issues](https://github.com/go-core-fx/httpfx/issues) for a full list of proposed features. + +

(back to top)

+ +--- + +## Contributing + +Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**. + +1. Fork the Project +2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`) +3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`) +4. Push to the Branch (`git push origin feature/AmazingFeature`) +5. Open a Pull Request + +

(back to top)

+ +--- + +## License + +Distributed under the Apache License 2.0. See `LICENSE` for more information. + +

(back to top)

+ +--- + +## Acknowledgments + +- [Uber Fx](https://uber-go.github.io/fx/) — dependency injection framework +- [golang.org/x/net/proxy](https://pkg.go.dev/golang.org/x/net/proxy) — SOCKS5 and environment-based proxy dialers +- [Best-README-Template](https://github.com/othneildrew/Best-README-Template) — README structure + +

(back to top)

+ + +[contributors-shield]: https://img.shields.io/github/contributors/go-core-fx/httpfx.svg?style=for-the-badge +[contributors-url]: https://github.com/go-core-fx/httpfx/graphs/contributors +[forks-shield]: https://img.shields.io/github/forks/go-core-fx/httpfx.svg?style=for-the-badge +[forks-url]: https://github.com/go-core-fx/httpfx/network/members +[stars-shield]: https://img.shields.io/github/stars/go-core-fx/httpfx.svg?style=for-the-badge +[stars-url]: https://github.com/go-core-fx/httpfx/stargazers +[issues-shield]: https://img.shields.io/github/issues/go-core-fx/httpfx.svg?style=for-the-badge +[issues-url]: https://github.com/go-core-fx/httpfx/issues +[license-shield]: https://img.shields.io/github/license/go-core-fx/httpfx.svg?style=for-the-badge +[license-url]: https://github.com/go-core-fx/httpfx/blob/master/LICENSE diff --git a/client.go b/client.go new file mode 100644 index 0000000..5021736 --- /dev/null +++ b/client.go @@ -0,0 +1,107 @@ +package httpfx + +import ( + "context" + "fmt" + "net" + "net/http" + "net/url" + "os" + "strings" + + "golang.org/x/net/proxy" +) + +func newClient(config Config) (*http.Client, error) { + transport := &http.Transport{ + MaxIdleConns: config.MaxIdleConns, + MaxIdleConnsPerHost: config.MaxIdleConnsPerHost, + IdleConnTimeout: config.IdleConnTimeout, + } + + if err := applyProxy(transport, config); err != nil { + return nil, err + } + + return &http.Client{ + Transport: transport, + Timeout: config.Timeout, + }, nil +} + +func applyProxy(transport *http.Transport, config Config) error { + switch { + case config.ProxyURL != "": + return applySOCKSProxy(transport, config.ProxyURL, config.Bypass) + case config.ProxyFromEnv: + return applyEnvProxy(transport, config.Bypass) + default: + return nil + } +} + +func applySOCKSProxy(transport *http.Transport, rawURL, bypass string) error { + u, err := url.Parse(rawURL) + if err != nil { + return fmt.Errorf("%w", ErrInvalidProxyURL) + } + + if u.Hostname() == "" { + return fmt.Errorf("%w: empty hostname", ErrInvalidProxyURL) + } + + dialer, err := proxy.FromURL(u, proxy.Direct) + if err != nil { + return fmt.Errorf("%w: %w", ErrProxyDialFailed, err) + } + + dialer = applyBypass(dialer, bypass) + setTransportDialer(transport, dialer) + + return nil +} + +func applyEnvProxy(transport *http.Transport, bypass string) error { + hasProxyEnv := os.Getenv("ALL_PROXY") != "" || os.Getenv("all_proxy") != "" + + dialer := proxy.FromEnvironment() + + if dialer == proxy.Direct { + if hasProxyEnv { + return fmt.Errorf("%w: proxy environment variable set but invalid", ErrInvalidProxyURL) + } + return nil + } + + dialer = applyBypass(dialer, bypass) + setTransportDialer(transport, dialer) + + return nil +} + +func applyBypass(dialer proxy.Dialer, bypass string) proxy.Dialer { + bypass = strings.TrimSpace(bypass) + if bypass == "" { + return dialer + } + + perHost := proxy.NewPerHost(dialer, proxy.Direct) + perHost.AddFromString(bypass) + + return perHost +} + +func setTransportDialer(transport *http.Transport, dialer proxy.Dialer) { + if cd, ok := dialer.(proxy.ContextDialer); ok { + transport.DialContext = cd.DialContext + return + } + + transport.DialContext = func( + _ context.Context, + network, + addr string, + ) (net.Conn, error) { + return dialer.Dial(network, addr) + } +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..9c77bd2 --- /dev/null +++ b/config.go @@ -0,0 +1,33 @@ +package httpfx + +import "time" + +// Config holds the HTTP client configuration. +type Config struct { + // ProxyURL is an explicit SOCKS5 proxy URL (e.g., "socks5://user:pass@host:port"). + // Empty means no proxy. Takes precedence over ProxyFromEnv. + ProxyURL string + + // ProxyFromEnv enables reading the proxy from the ALL_PROXY environment variable. + // Used only when ProxyURL is empty. + ProxyFromEnv bool + + // Bypass is a comma-separated list of hosts that bypass the proxy + // (e.g., "localhost,127.0.0.1"). + Bypass string + + // Timeout is the HTTP client-level timeout. Zero means no timeout. + Timeout time.Duration + + // MaxIdleConns is the maximum number of idle (keep-alive) connections. + // Zero means no limit. + MaxIdleConns int + + // MaxIdleConnsPerHost is the maximum idle connections per host. + // Zero means DefaultMaxIdleConnsPerHost (2). + MaxIdleConnsPerHost int + + // IdleConnTimeout is the maximum time an idle connection is kept alive. + // Zero means no timeout. + IdleConnTimeout time.Duration +} diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..53d03af --- /dev/null +++ b/errors.go @@ -0,0 +1,14 @@ +package httpfx + +import "errors" + +var ( + // ErrInvalidConfig is returned when the HTTP client configuration is invalid. + ErrInvalidConfig = errors.New("invalid config") + + // ErrInvalidProxyURL is returned when the proxy URL cannot be parsed. + ErrInvalidProxyURL = errors.New("invalid proxy URL") + + // ErrProxyDialFailed is returned when the proxy dialer cannot be created. + ErrProxyDialFailed = errors.New("proxy dialer creation failed") +) diff --git a/factory.go b/factory.go new file mode 100644 index 0000000..8db0ceb --- /dev/null +++ b/factory.go @@ -0,0 +1,50 @@ +package httpfx + +import ( + "net/http" + + "go.uber.org/zap" +) + +// Factory creates [http.Client] instances with shared proxy and transport configuration. +type Factory interface { + // NewClient creates a new [http.Client] using the factory's base configuration, + // with optional per-client overrides via [Option]. + NewClient(opts ...Option) *http.Client +} + +type factory struct { + config Config + logger *zap.Logger +} + +// NewFactory creates a new Factory from the provided configuration. +func NewFactory(config Config, logger *zap.Logger) Factory { + return &factory{ + config: config, + logger: logger, + } +} + +// NewClient implements [Factory]. +func (f *factory) NewClient(opts ...Option) *http.Client { + cfg := f.config + + if len(opts) > 0 { + co := new(clientOptions) + for _, opt := range opts { + opt(co) + } + + cfg = co.apply(cfg) + } + + client, err := newClient(cfg) + if err != nil { + f.logger.Error("failed to create HTTP client", zap.Error(err)) + + return http.DefaultClient + } + + return client +} diff --git a/go.mod b/go.mod index 9cfe8b1..b77cbba 100644 --- a/go.mod +++ b/go.mod @@ -1,15 +1,16 @@ -module github.com/go-core-fx/template +module github.com/go-core-fx/httpfx -go 1.24.3 +go 1.25.0 require ( github.com/go-core-fx/logger v0.0.1 go.uber.org/fx v1.24.0 + go.uber.org/zap v1.28.0 + golang.org/x/net v0.57.0 ) require ( go.uber.org/dig v1.19.0 // indirect - go.uber.org/multierr v1.10.0 // indirect - go.uber.org/zap v1.27.0 // indirect - golang.org/x/sys v0.36.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/sys v0.47.0 // indirect ) diff --git a/go.sum b/go.sum index 4f7bf24..9cb2759 100644 --- a/go.sum +++ b/go.sum @@ -14,9 +14,19 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= +golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/module.go b/module.go index 2770bec..d549558 100644 --- a/module.go +++ b/module.go @@ -1,16 +1,21 @@ -package template +package httpfx import ( + "net/http" + "github.com/go-core-fx/logger" "go.uber.org/fx" ) -const ModuleName = "template" +const ModuleName = "httpfx" func Module() fx.Option { return fx.Module( ModuleName, logger.WithNamedLogger(ModuleName), - // fx.Provide(New), + fx.Provide(NewFactory), + fx.Provide(func(f Factory) *http.Client { + return f.NewClient() + }), ) } diff --git a/options.go b/options.go new file mode 100644 index 0000000..08b29df --- /dev/null +++ b/options.go @@ -0,0 +1,85 @@ +package httpfx + +import "time" + +// Option configures per-client overrides on [Factory.NewClient]. +type Option func(*clientOptions) + +type clientOptions struct { + proxyURL *string + proxyFromEnv *bool + bypass *string + timeout *time.Duration + maxIdleConns *int + maxIdleConnsPerHost *int + idleConnTimeout *time.Duration +} + +func (o *clientOptions) apply(base Config) Config { + cfg := base + + if o.proxyURL != nil { + cfg.ProxyURL = *o.proxyURL + } + + if o.proxyFromEnv != nil { + cfg.ProxyFromEnv = *o.proxyFromEnv + } + + if o.bypass != nil { + cfg.Bypass = *o.bypass + } + + if o.timeout != nil { + cfg.Timeout = *o.timeout + } + + if o.maxIdleConns != nil { + cfg.MaxIdleConns = *o.maxIdleConns + } + + if o.maxIdleConnsPerHost != nil { + cfg.MaxIdleConnsPerHost = *o.maxIdleConnsPerHost + } + + if o.idleConnTimeout != nil { + cfg.IdleConnTimeout = *o.idleConnTimeout + } + + return cfg +} + +// WithProxyURL overrides the SOCKS5 proxy URL for this client. +func WithProxyURL(rawURL string) Option { + return func(o *clientOptions) { o.proxyURL = &rawURL } +} + +// WithProxyFromEnv overrides the ProxyFromEnv flag for this client. +func WithProxyFromEnv(v bool) Option { + return func(o *clientOptions) { o.proxyFromEnv = &v } +} + +// WithBypass overrides the proxy bypass list for this client. +func WithBypass(bypass string) Option { + return func(o *clientOptions) { o.bypass = &bypass } +} + +// WithTimeout overrides the client-level timeout for this client. +func WithTimeout(t time.Duration) Option { + return func(o *clientOptions) { o.timeout = &t } +} + +// WithMaxIdleConns overrides the maximum idle connections for this client. +func WithMaxIdleConns(n int) Option { + return func(o *clientOptions) { o.maxIdleConns = &n } +} + +// WithMaxIdleConnsPerHost overrides the maximum idle connections per host for this client. +func WithMaxIdleConnsPerHost(n int) Option { + return func(o *clientOptions) { o.maxIdleConnsPerHost = &n } +} + +// WithIdleConnTimeout overrides the idle connection timeout for this client. +func WithIdleConnTimeout(t time.Duration) Option { + return func(o *clientOptions) { o.idleConnTimeout = &t } +}