Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,13 @@ proxy:
tunnel_listen: ":8080" # Optional CONNECT/SOCKS5 listener
max_request_body_bytes: 1048576 # 1 MiB (default)
max_response_body_bytes: 0 # uncapped (default)
auth:
required: false # Default: false
# users:
# - login: "ci"
# password:
# type: env
# var: "IRON_PROXY_CI_PASSWORD"

tls:
ca_cert: "/etc/iron-proxy/ca.crt" # Required
Expand All @@ -251,6 +258,10 @@ transforms:
- "*.anthropic.com"
cidrs:
- "10.0.0.0/8"
rules:
- host: "api.openai.com"
proxy_logins: ["ci"]
source_cidrs: ["10.16.0.0/16"]

- name: secrets
config:
Expand All @@ -269,6 +280,33 @@ log:
level: "info" # debug, info, warn, error
```

### Proxy auth

`proxy.auth.required` enables client authentication for HTTP proxy requests,
HTTP `CONNECT`, and SOCKS5. Default is `false`.

HTTP and `CONNECT` clients use `Proxy-Authorization: Basic ...`. SOCKS5 clients
use username/password auth. In `tls.mode: sni-only`, raw HTTPS connections do
not carry auth metadata; when auth is required, use the tunnel listener.
Each user password is a secret source (`env`, `file`, AWS, 1Password, etc.)
resolved on startup and management reload.

```yaml
proxy:
tunnel_listen: ":8080"
auth:
required: true
users:
- login: "ci"
password:
type: env
var: "IRON_PROXY_CI_PASSWORD"
- login: "dev"
password:
type: file
path: "/run/secrets/iron_proxy_dev_password"
```

### DNS

Everything resolves to `proxy_ip` by default, which is what routes traffic
Expand All @@ -286,6 +324,19 @@ Unmatched requests get a `403 Forbidden`.
Domain patterns use glob matching: `*.example.com` matches any subdomain and
`example.com` itself.

Rules can also be scoped by authenticated proxy login and client source CIDR:

```yaml
transforms:
- name: allowlist
config:
rules:
- host: "api.openai.com"
methods: ["POST"]
proxy_logins: ["ci", "dev"]
source_cidrs: ["10.16.0.0/16"]
```

**Warn mode:** Set `warn: true` to observe what the allowlist would block without
actually enforcing it. Requests that would be rejected are allowed through but
annotated with `"action": "warn"` in the transform trace. This is useful for
Expand Down
14 changes: 11 additions & 3 deletions cmd/iron-proxy/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ func main() {
}

// Initialize proxy.
p := proxy.New(proxy.Options{
p, err := proxy.New(proxy.Options{
HTTPAddr: cfg.Proxy.HTTPListen,
HTTPSAddr: cfg.Proxy.HTTPSListen,
TunnelAddr: cfg.Proxy.TunnelListen,
Expand All @@ -214,10 +214,15 @@ func main() {
Resolver: resolver,
Guard: guard,
MCPPolicy: mcpHolder,
Auth: cfg.Proxy.Auth,
Logger: logger,
UpstreamResponseHeaderTimeout: time.Duration(cfg.Proxy.UpstreamResponseHeaderTimeout),
UpstreamProxy: cfg.Proxy.UpstreamProxy.ProxyFunc(),
})
if err != nil {
logger.Error("initializing proxy", slog.String("error", err.Error()))
os.Exit(1)
}

// Initialize metrics server.
metricsServer := metrics.New(cfg.Metrics.Listen, logger)
Expand All @@ -228,7 +233,7 @@ func main() {
mgmtServer = management.New(management.Options{
Addr: cfg.Management.Listen,
APIKey: os.Getenv(cfg.Management.APIKeyEnv),
Reload: newReloadFunc(*configPath, holder, mcpHolder, pgManager, bodyLimits, logger),
Reload: newReloadFunc(*configPath, holder, mcpHolder, pgManager, p, bodyLimits, logger),
Logger: logger,
Ctx: ctx,
})
Expand Down Expand Up @@ -587,7 +592,7 @@ func applyPostgresSync(ctx context.Context, mgr *postgres.Manager, local *postgr
// wrapped in *management.ValidationError so the management server returns
// 422 and the existing state is left untouched. Validation runs for every
// component before any state is mutated.
func newReloadFunc(configPath string, holder *transform.PipelineHolder, mcpHolder *mcp.PolicyHolder, pgManager *postgres.Manager, bodyLimits transform.BodyLimits, logger *slog.Logger) management.ReloadFunc {
func newReloadFunc(configPath string, holder *transform.PipelineHolder, mcpHolder *mcp.PolicyHolder, pgManager *postgres.Manager, p *proxy.Proxy, bodyLimits transform.BodyLimits, logger *slog.Logger) management.ReloadFunc {
return func(ctx context.Context) error {
newCfg, err := config.LoadConfig(configPath)
if err != nil {
Expand All @@ -608,6 +613,9 @@ func newReloadFunc(configPath string, holder *transform.PipelineHolder, mcpHolde
if err != nil {
return &management.ValidationError{Err: err}
}
if err := p.ReloadAuth(ctx, newCfg.Proxy.Auth); err != nil {
return &management.ValidationError{Err: err}
}
newPipeline.SetAuditFunc(holder.Load().AuditFunc())
holder.Store(newPipeline)
mcpHolder.Store(newPolicy)
Expand Down
51 changes: 46 additions & 5 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,11 +75,12 @@ type DNSRecord struct {

// Proxy configures the HTTP/HTTPS listener addresses.
type Proxy struct {
HTTPListen string `yaml:"http_listen"`
HTTPSListen string `yaml:"https_listen"`
TunnelListen string `yaml:"tunnel_listen"`
MaxRequestBodyBytes int64 `yaml:"max_request_body_bytes"`
MaxResponseBodyBytes int64 `yaml:"max_response_body_bytes"`
HTTPListen string `yaml:"http_listen"`
HTTPSListen string `yaml:"https_listen"`
TunnelListen string `yaml:"tunnel_listen"`
MaxRequestBodyBytes int64 `yaml:"max_request_body_bytes"`
MaxResponseBodyBytes int64 `yaml:"max_response_body_bytes"`
Auth ProxyAuth `yaml:"auth"`
// UpstreamResponseHeaderTimeout caps how long the proxy waits for an
// upstream response's headers before returning 502. Accepts Go duration
// syntax: "30s" (default), "5m", "2h". Useful for upstream endpoints
Expand All @@ -96,6 +97,20 @@ type Proxy struct {
UpstreamProxy UpstreamProxy `yaml:"upstream_proxy"`
}

// ProxyAuth configures optional client authentication for HTTP proxy,
// CONNECT, and SOCKS5 proxy modes. Empty config preserves the historical
// no-auth behavior.
type ProxyAuth struct {
Required bool `yaml:"required"`
Users []ProxyAuthUser `yaml:"users"`
}

// ProxyAuthUser is one proxy login. Password is a secrets source.
type ProxyAuthUser struct {
Login string `yaml:"login"`
Password yaml.Node `yaml:"password"`
}

// CIDRList is a list of CIDR strings whose presence in YAML is distinguishable
// from absence: an explicit empty list opts out of any default population,
// while an unset field signals "apply the default".
Expand Down Expand Up @@ -280,6 +295,9 @@ func Validate(cfg *Config) error {
if err := dnsguard.ValidateCIDRs(cfg.Proxy.UpstreamDenyCIDRs.Values); err != nil {
return fmt.Errorf("proxy.upstream_deny_cidrs: %w", err)
}
if err := validateProxyAuth(cfg.Proxy.Auth); err != nil {
return err
}

if cfg.Management.Listen != "" {
if cfg.Management.APIKeyEnv == "" {
Expand All @@ -305,3 +323,26 @@ func Validate(cfg *Config) error {

return nil
}

func validateProxyAuth(auth ProxyAuth) error {
seen := make(map[string]struct{}, len(auth.Users))
for i, user := range auth.Users {
if user.Login == "" {
return fmt.Errorf("proxy.auth.users[%d].login is required", i)
}
if _, ok := seen[user.Login]; ok {
return fmt.Errorf("proxy.auth.users[%d].login %q is duplicated", i, user.Login)
}
seen[user.Login] = struct{}{}
if user.Password.Kind == 0 {
return fmt.Errorf("proxy.auth.users[%d].password is required", i)
}
if user.Password.Kind != yaml.MappingNode {
return fmt.Errorf("proxy.auth.users[%d].password must be a secret source mapping", i)
}
}
if auth.Required && len(auth.Users) == 0 {
return fmt.Errorf("proxy.auth.users is required when proxy.auth.required is true")
}
return nil
}
56 changes: 56 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ func TestLoad_Defaults(t *testing.T) {
require.Equal(t, 1000, cfg.TLS.CertCacheSize)
require.Equal(t, ":9090", cfg.Metrics.Listen)
require.Equal(t, "info", cfg.Log.Level)
require.False(t, cfg.Proxy.Auth.Required)
require.Empty(t, cfg.Proxy.Auth.Users)
}

func TestLoad_OverrideDefaults(t *testing.T) {
Expand Down Expand Up @@ -179,6 +181,38 @@ tls:
`,
wantErr: "dns.records[0].value is required",
},
{
name: "proxy auth required without users",
yaml: `
dns:
proxy_ip: "10.0.0.1"
proxy:
auth:
required: true
tls:
ca_cert: "/tmp/ca.crt"
ca_key: "/tmp/ca.key"
`,
wantErr: "proxy.auth.users is required",
},
{
name: "proxy auth duplicate login",
yaml: `
dns:
proxy_ip: "10.0.0.1"
proxy:
auth:
users:
- login: ci
password: {type: env, var: ONE}
- login: ci
password: {type: env, var: TWO}
tls:
ca_cert: "/tmp/ca.crt"
ca_key: "/tmp/ca.key"
`,
wantErr: "duplicated",
},
}

for _, tt := range tests {
Expand All @@ -190,6 +224,28 @@ tls:
}
}

func TestLoad_ProxyAuthPasswordSource(t *testing.T) {
cfg, err := Load(strings.NewReader(`
dns:
proxy_ip: "10.0.0.1"
proxy:
auth:
required: true
users:
- login: ci
password:
type: env
var: IRON_PROXY_CI_PASSWORD
tls:
ca_cert: "/tmp/ca.crt"
ca_key: "/tmp/ca.key"
`))
require.NoError(t, err)
require.True(t, cfg.Proxy.Auth.Required)
require.Equal(t, "ci", cfg.Proxy.Auth.Users[0].Login)
require.NotZero(t, cfg.Proxy.Auth.Users[0].Password.Kind)
}

func TestLoad_UnknownFields(t *testing.T) {
yaml := `
dns:
Expand Down
Loading