Configure outbound HTTP and gRPC egress through forward proxies — corporate egress gateways, SOCKS5 tunnels, and HTTPS proxies with self-signed certificates — using the same YAML-driven model across every consumer in go-atlas.
- Overview
- Components
- YAML reference
- Programmatic API
- Wiring patterns
- TLS to the proxy
- SOCKS5 vs SOCKS5h
- SSRF interaction
- Defaults and operator guidance
- Known limitations
Proxy support lives in three layers:
- Config struct — the shared
proxyconfig.Configdefines the YAML schema and validation for both transports;transport/proxydial/factory.HTTPClientOptions(cfg)/GRPCClientOptions(cfg)materialize it into option slices. - Client options — the
WithProxy*family of functional options ontransport/http/clientandtransport/grpc/clientapplies the options to the respective clients. - Shared dialer —
transport/proxydialimplements HTTP CONNECT (RFC 7231 §4.3.6), SOCKS5, and the*tls.Configmerge rules used by both clients.
Passthrough is the default. When the proxy block is omitted from YAML (or the receiver is nil):
transport/http/clientdisables environment proxies under its default SSRF protection.WithoutSSRFProtection()restoreshttp.ProxyFromEnvironmentlookup.transport/grpc/clientuses grpc-go's built-in env lookup (HTTPS_PROXY/HTTP_PROXY/NO_PROXY).
An explicit configuration overrides the env-based default — including the Mode: none mode, which disables proxy resolution entirely.
| Type | Purpose |
|---|---|
proxyconfig.Config |
Shared YAML-driven proxy schema; proxydial/factory.HTTPClientOptions(p) → []httpclient.Option, GRPCClientOptions(p) → []grpcclient.Option |
proxyconfig.Auth |
Username + secret-redacted password |
proxyconfig.Mode |
Mode enum: none / url / host (or empty = passthrough) |
| Option | Description |
|---|---|
WithProxy(host, port, auth) |
Route through http://host:port with optional *url.Userinfo |
WithProxyURL(u) |
Route through any proxy URL (http / https / socks5 / socks5h) |
WithProxyFunc(f) |
Custom resolver matching http.Transport.Proxy signature |
WithoutProxy() |
Disable proxy resolution entirely, including env-var lookup |
WithProxyTLSConfig(cfg) |
Custom *tls.Config for the handshake to an https:// proxy |
HTTPClientSetter |
Interface SetHTTPClient(*http.Client) for sub-components that adopt the parent's client |
| Option | Description |
|---|---|
WithProxy(host, port, auth) |
Route through http://host:port with optional *url.Userinfo |
WithProxyURL(u) |
Route through any proxy URL (http / https / socks5 / socks5h) |
WithProxyFunc(f) |
Custom resolver matching http.Transport.Proxy signature |
WithoutProxy() |
Disable proxy resolution including grpc-go's env lookup |
WithProxyTLSConfig(cfg) |
Custom *tls.Config for the handshake to an https:// proxy |
Both transports agree on TCP-level options through a shared dialer that exposes HTTP CONNECT, SOCKS5, TLS-config merging, and a default Dialer with
DialTimeout / DialKeepAlive of 30s each.
Both http_proxy.yaml and grpc_proxy.yaml load into the shared proxyconfig.Config struct and use the same schema:
proxy:
# <string> Proxy resolution mode. One of: none | url | host.
# Omit (or leave empty) to keep the underlying transport's default —
# the standard HTTP_PROXY/HTTPS_PROXY/NO_PROXY env vars.
# none — disable proxy entirely, including env-based lookup
# url — route via a single proxy URL
# host — route via host + port (optionally auth for credentials)
mode: url
# <string> Full proxy URL. Required when mode is "url".
# Must include an explicit port — port-less URLs are rejected at
# config load.
# Supported schemes: http, https, socks5, socks5h.
url: http://proxy.corp.example:3128
# <string> Proxy hostname. Required when mode is "host".
host: proxy.corp.example
# <int> Proxy port (1–65535). Required when mode is "host".
port: 3128
# <object> Proxy credentials. Optional even when mode is "host".
# Used only when mode is "host".
auth:
username: svc-account
# Use $__secret{...} to pull the password from the secrets manager.
password: $__secret{vault:proxy-password}| Mode | Effect |
|---|---|
| (empty) | Passthrough — env vars decide |
none |
Disable proxy resolution (even env vars) |
url |
Send all traffic to url (must include port) |
host |
Build http://host:port from fields; auth.password supports secret expansion |
The Validate() method enforces:
Mode∈{"", "none", "url", "host"};- URL mode requires a non-empty
urlwith an explicit port and a supported scheme (http/https/socks5/socks5h); - Host mode requires non-empty
hostand a port in[1, 65535]; - Mutual exclusion: fields irrelevant to the current mode must be empty
(e.g. setting
urlwhilemode: hostfails validation — this catches YAML typos that would otherwise be silently dropped).
The YAML loader supports !include <file>, so every consumer can reuse the same template. This is how
config/templates/opa.yaml wires proxy settings for GitLab and S3 policy sources:
opa:
gitlab:
endpoint: https://gitlab.example.com
token: $__secret{vault:gitlab-token}
projectID: 42
# Outbound HTTP proxy for GitLab API requests.
!include http_proxy.yaml
s3:
bucket: my-policies-bucket
region: us-east-1
# Outbound HTTP proxy for S3 API requests.
!include http_proxy.yamlWith no override, protected Atlas clients connect directly; gRPC and the default AWS SDK client retain their environment behavior.
OIDC/OPA factories reject the url/host modes shown in this schema example.
import (
"net/url"
httpclient "github.com/altessa-s/go-atlas/transport/http/client"
)
// Host/port with credentials (most common).
c := httpclient.New(
httpclient.WithoutSSRFProtection(),
httpclient.WithProxy("proxy.corp", 3128, url.UserPassword("svc", token)),
httpclient.WithLogger(logger),
)
// Full URL (use for socks5 or https proxy).
u, _ := url.Parse("socks5://proxy.corp:1080")
c = httpclient.New(httpclient.WithoutSSRFProtection(), httpclient.WithProxyURL(u))
// Opt out of env-based proxy entirely.
c = httpclient.New(httpclient.WithoutProxy())
// Custom resolver — branch per-request.
c = httpclient.New(httpclient.WithoutSSRFProtection(), httpclient.WithProxyFunc(func(req *http.Request) (*url.URL, error) {
if strings.HasSuffix(req.URL.Host, ".internal") {
return nil, nil // no proxy
}
return corporateProxy, nil
}))import (
"context"
"crypto/tls"
"crypto/x509"
grpcclient "github.com/altessa-s/go-atlas/transport/grpc/client"
)
ctx := context.Background()
// Host/port proxy.
c, err := grpcclient.New(ctx, "service.example.com:443",
grpcclient.WithProxy("proxy.corp", 3128, nil),
)
// HTTPS proxy with a self-signed CA.
pool := x509.NewCertPool()
pool.AppendCertsFromPEM(corpProxyCA)
c, err = grpcclient.New(ctx, "service.example.com:443",
grpcclient.WithProxyURL(mustParse("https://proxy.corp:3128")),
grpcclient.WithProxyTLSConfig(&tls.Config{RootCAs: pool}),
)
// Disable env-based proxy.
c, err = grpcclient.New(ctx, "service.example.com:443",
grpcclient.WithoutProxy(),
)Protocols that do not go through net/http use the transport/proxydial package via its fluent factory at
transport/proxydial/factory. The factory takes a *proxyconfig.Config and returns a DialContextFunc
(func(ctx, network, addr) (net.Conn, error)) that any library accepting a custom dialer can consume.
import (
proxydialfactory "github.com/altessa-s/go-atlas/transport/proxydial/factory"
"github.com/wneessen/go-mail"
)
// Build the dialer once, when the SMTP provider starts.
dialFunc, err := proxydialfactory.New(cfg.SMTP.Proxy).
UseLogger(logger).
Build()
if err != nil {
return nil, fmt.Errorf("build smtp proxy dialer: %w", err)
}
mailOpts := []mail.Option{ /* host, auth, TLS policy, ... */ }
if dialFunc != nil {
mailOpts = append(mailOpts, mail.WithDialContextFunc(dialFunc))
}
client, err := mail.NewClient(cfg.SMTP.Host, mailOpts...)A nil result means proxying is opted out of — caller should leave the
library on its default direct dialer. Concrete behavior by Mode:
| Mode | Build() result |
|---|---|
empty / none |
nil — direct dial |
url |
tunnel resolved from cfg.URL scheme (http/https/socks5/socks5h) |
host |
http-proxy tunnel to cfg.Host:cfg.Port with optional cfg.Auth |
The builder also exposes UseDialer (custom *net.Dialer) and UseProxyTLSConfig (TLS to the proxy itself) for callers that need to
override the defaults.
For consumers without a proxyconfig.Config config (proxy comes from env, runtime override, custom resolver), use proxydial.FromURL directly:
proxyURL, _ := url.Parse(os.Getenv("HTTPS_PROXY"))
dialFunc, err := proxydial.FromURL(proxyURL,
proxydial.WithDialer(&net.Dialer{Timeout: 5 * time.Second}),
)Real-world fits: SMTP (go-mail), IMAP/POP3, LDAP, AMQP, Kafka, NATS, MQTT, Redis, MongoDB, PostgreSQL/MySQL drivers — every library that exposes a
custom dialer hook accepts a DialContextFunc produced by proxydial.
Every consumer that supports proxy configuration follows the same pattern: materialize the config via proxyfactory.HTTPClientOptions(cfg.Proxy) /
GRPCClientOptions(cfg.Proxy) (package transport/proxydial/factory) and pass the options to the target client's constructor.
The factory rejects explicit proxy modes before the wiring below; only empty/none modes reach this code.
auth/oidc/factory/builder.go:
proxyOpts, err := proxyfactory.HTTPClientOptions(cfg.Proxy)
if err != nil {
return nil, b.WrapError(err, "failed to materialize oidc proxy options")
}
opts = append(opts, oidc.WithHTTPClient(httpclient.New(proxyOpts...)))The Provider's HTTP client is shared with a revocation loader passed via oidc.WithRevocationLoader that implements
SetHTTPClient(*http.Client), so discovery, JWKS refresh, introspection, userinfo, and URL-based revocation use the same connection pool and
proxy. The same client also reaches a loader wrapped in a ready-made storage (WithRevocationStorage), which is how the YAML factory wires a
revocation.source.url — see OIDC revocation.
Explicit proxy modes are rejected before this wiring.
auth/opa/factory/builder.go:
proxyOpts, err := proxyfactory.HTTPClientOptions(gl.Proxy)
if err != nil {
return nil, b.WrapError(err, "failed to materialize gitlab proxy options")
}
clientOpts := append([]httpclient.Option{httpclient.WithLogger(b.Logger())}, proxyOpts...)
opts = append(opts, gitlab.WithHTTPClient(httpclient.New(clientOpts...)))S3 is the one consumer that conditionally injects the resilient HTTP client — only for explicit mode none; modes url and host are rejected. Without an override the
AWS SDK keeps its own HTTP client (which already honors HTTP_PROXY/HTTPS_PROXY/NO_PROXY) and its own retry layer, avoiding double-retry with
go-atlas's breaker:
proxyOpts, err := proxyfactory.HTTPClientOptions(s3Cfg.Proxy)
if err != nil {
return nil, b.WrapError(err, "failed to materialize s3 proxy options")
}
awsCfgOpts = slices.AppendIfFunc(awsCfgOpts, len(proxyOpts) > 0, func() []func(*awsconfig.LoadOptions) error {
return []func(*awsconfig.LoadOptions) error{awsconfig.WithHTTPClient(httpclient.New(proxyOpts...))}
})observability/tracing/adapters/otlp/grpc_client.go:
opts := []grpcclient.Option{}
opts = slices.AppendIf(opts, c.insecure, grpcclient.WithInsecure())
if c.retry { /* ... */ }
// Caller-supplied options come last so they win over defaults.
// Used by the tracing factory to inject proxy resolvers from proxyconfig.Config.
opts = append(opts, c.extraClientOptions...)
client, err := grpcclient.New(ctx, c.endpoint, opts...)Use WithProxyTLSConfig(*tls.Config) when the client must connect to an https:// proxy that presents a certificate outside the system root store:
- Corporate proxy fronted by a self-signed CA.
- Proxy requiring client-certificate (mTLS) authentication.
- Pinning a specific minimum TLS version for the proxy leg only.
The caller's *tls.Config is cloned before use and never mutated. Two fields get safe defaults filled in only when left at their zero value:
ServerName← proxy URL's hostname (so SNI/verification targets the proxy, not the upstream);MinVersion←tls.VersionTLS12.
Every other field (RootCAs, Certificates, InsecureSkipVerify, …) is preserved verbatim.
WithProxyTLSConfig has no effect for:
- plain
http://proxies (no TLS handshake to the proxy); socks5:///socks5h://proxies (the SOCKS handshake is not TLS).
Both schemes are accepted by validation, but they behave identically in this implementation: golang.org/x/net/proxy.SOCKS5 always sends
ATYP=DomainName for hostname addresses, so DNS resolution happens on the proxy side regardless of scheme.
Callers that genuinely need client-side DNS (for example to bypass a proxy's split-horizon resolver) must pre-resolve to an IP literal before handing the address to the client.
SSRF protection is enabled by default. Protected HTTP clients disable environment proxies and reject explicit proxies,
custom TLS dialers and non-standard round trippers during construction with a panic wrapping ErrSSRFUnsafeTransport.
A proxy can resolve and dial the final destination outside the guarded dialer, so adding its CIDR does not make it safe.
For a trusted proxy, explicitly use WithoutSSRFProtection() and enforce destination restrictions at the proxy:
httpclient.New(httpclient.WithoutSSRFProtection(), httpclient.WithProxyURL(corporateProxy))OIDC and OPA factories return ErrSSRFUnsafeTransport for explicit proxy modes url and host.
Construct providers/sources directly with an explicitly configured client when this opt-out is needed.
OPA S3 retains the AWS SDK client when no proxy mode is configured; that client has its own environment proxy behavior.
Both transports share proxydial.DefaultDialer() with:
| Field | Value |
|---|---|
Timeout |
30s |
KeepAlive |
30s |
These are exported as proxydial.DefaultDialTimeout and proxydial.DefaultDialKeepAlive so tests and alternative transports can reuse them without
duplicating literals.
URL-mode proxies must include an explicit port. The YAML validator rejects http://proxy.corp with a clear error — stdlib's implicit port map
(80/443/1080) is bypassed deliberately because the custom dial path does not use it. Operators see the error at config load, not at every dial.
Putting credentials into url: http://user:pass@proxy:3128 works, but secrets are harder to mask in logs and harder to rotate independently of the
URL. The host / port / auth form with password: $__secret{...} keeps the secret out of plain YAML and gets redacted in fmt / JSON / YAML
/ slog output automatically.
- OTLP
Protocol: http:TracingOTLP.Proxy(a shared*proxyconfig.Config) only wires into the gRPC exporter path. The config validator rejects the combinationProtocol: http+Proxy: {...}at load time — the HTTP exporter must use env-var proxy (HTTPS_PROXY/HTTP_PROXY/NO_PROXY) instead. Full HTTP-exporter wiring would require separateotlp.WithHTTPClientOptions([]otlptracehttp.Option)plumbing and is tracked as a future enhancement. - OPA — S3: the AWS SDK has its own retry layer, so go-atlas's resilient
client is injected only for explicit mode
none; modesurlandhostare rejected. Inject a separately constructed S3 client for custom HTTP behavior. - Client-side DNS for SOCKS5: not available — see SOCKS5 vs SOCKS5h.
- Configuration guide — loader, env vars, templates
- Architecture — package layout, layering
- OIDC —
oidc.proxyfield, JWKS / introspection clients transport/http/client— HTTP client API referencetransport/grpc/client— gRPC client API reference