Skip to content
Merged
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
50 changes: 50 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,56 @@ If you use `--error-pages`, add a `401.html` to that directory; otherwise the
challenge falls back to the proxy's built-in plain response.


### Restricting a service by client address

To serve a service only to certain networks, deploy it with `--allow-ip`:

kamal-proxy deploy service1 --target web-1:3000 --allow-ip 10.0.0.0/8,203.0.113.7

Requests from anywhere else get a `403`. The flag takes addresses or CIDR
ranges, and may be repeated or comma-separated. Metrics have their own list:

kamal-proxy run --metrics-port 9090 --metrics-allow-ip 10.0.0.0/8

Things worth knowing:

* **It matches the address that connected**, not any header. `X-Forwarded-For`
and friends are written by the client, so honouring them by default would
make the list decorative — anyone could send `X-Forwarded-For: 10.0.0.1`.
* **Behind a load balancer or CDN, say so with `--trusted-proxy`.** Only when
the connecting address is inside one of those ranges is the forwarded chain
consulted, and then the client is the nearest address in the chain that none
of your proxies wrote. **List every hop, not just the one that connects to
kamal-proxy** — behind a CDN in front of a load balancer, list both, or the
CDN's edge address becomes the one matched against `--allow-ip`.
* **If the chain cannot be resolved, the request is denied.** A trusted edge
that stops sending the header denies everything rather than silently falling
back to the edge's own address, which would be a bypass whenever your allow
list contains the proxy's own range. Denials are logged with the address the
decision used and why, rate-limited per service.
* **List your IPv6 ranges too.** A client reaching the proxy over IPv6 is
matched on its IPv6 address; an IPv4-only list denies it. The proxy warns at
deploy when a list has no IPv6 ranges. (`::ffff:` forms of IPv4 addresses are
matched against IPv4 ranges, so those do not need listing separately.)
* **The health check path stays open**, so downstream load balancers can still
see the service drain during a deploy. Deploying with both `--allow-ip` and a
health check path of `/` is rejected.
* **`--client-ip-header` requires `--trusted-proxy`.** Without it the deploy is
rejected, because the header would be ignored while appearing to be honoured.
With it, be sure the header names one your edge *overwrites or strips* on
every request — a header the edge merely passes through can be set by anyone.
* **Redeploying without the flag removes the restriction**, and rolling the
proxy image back to a version without this feature removes it silently.
* **Check what the proxy actually sees.** The access log's `client_addr` is the
connecting address; `remote_addr` is the client's own claim and is not what
the filter uses. Under some Docker port drivers every request appears to come
from the bridge gateway, in which case a list cannot distinguish anyone.
* **A `--path-prefix` service is routing, not a security boundary** — the same
caveat as basic auth above.

If you use `--error-pages`, add a `403.html` to that directory.


### Automatic TLS

Kamal Proxy can automatically obtain and renew TLS certificates for your
Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ Proxy-side roadmap for the dash fork. The cross-repo release sequencing, strateg
| Item | Evidence | Anchor |
|---|---|---|
| Basic auth per service | port PR #216 (open); kamal#1604 | DONE — `--basic-auth <user>:<pass>` (`internal/server/basic_auth.go`). Deliberately **not** a `createMiddleware` middleware: that chain wraps `serviceRequestWithTarget`, which is where the HTTPS redirect lives, so a middleware there challenges before the 301 and the browser sends the password in cleartext. The check sits inline after `handleRedirectsIfNeeded`. Credentials are hashed CLI-side (salted SHA-256), so no plaintext crosses the RPC socket or reaches the state file. Per-path scoping is served by deploying a `--path-prefix` service with its own credential |
| IP allow/deny (CIDR) | discussions #143/#144 | middleware; client addr extraction exists (`logging_middleware.go:70`) |
| IP allow list (CIDR) | discussions #143/#144 | DONE — `--allow-ip`/`--trusted-proxy` on deploy, `--metrics-allow-ip` on run (`internal/server/ip_allow_list.go`). Allow-only; a static deny list is the wrong tool for the abuse-blocking people reach for it with. **Not** a `createMiddleware` middleware — that chain includes the cert manager's handler, so filtering there breaks ACME HTTP-01 and certificates fail to renew weeks later. Runs as the first check in `serviceRequestWithTarget`, before the HTTPS redirect. Matches the connecting peer, never a header, unless the peer is inside `--trusted-proxy`. The `logging_middleware.go:70` anchor this row used to name is the trap: that function's `remote_addr` is raw `X-Forwarded-For` with no trust check |
| Per-IP rate limiting (token bucket + burst + allowlist) | rejected #20 | global chain (`server.go:211 buildHandler`) or per-service; `golang.org/x/time/rate` |
| PROXY protocol | rejected #31, discussion #41 | `go-proxyproto` listener wrap in `server.go`; `run` flag |
| mTLS (`--tls-client-ca-path`) | port PR #204 (open); kamal#1628 | `tls.Config.ClientCAs/ClientAuth` on HTTPS listener (`server.go:158`) |
Expand Down
2 changes: 2 additions & 0 deletions internal/cmd/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ func newDeployCommand() *deployCommand {
deployCommand.cmd.Flags().StringVar(&deployCommand.args.ServiceOptions.ErrorPagePath, "error-pages", "", "Path to custom error pages")
deployCommand.cmd.Flags().IntSliceVar(&deployCommand.args.ServiceOptions.InterceptErrorStatuses, "intercept-errors", nil, "Replace these response statuses from the target with the proxy's error pages, as 4xx or 5xx codes (e.g. 502,503,504; default none)")
deployCommand.cmd.Flags().StringVar(&deployCommand.basicAuth, "basic-auth", "", "Require HTTP Basic credentials on every request to this service, as <username>:<password>. The health check path stays open. Use with --tls, or terminate TLS in front of the proxy -- Basic credentials are replayable and are sent on every request")
deployCommand.cmd.Flags().StringSliceVar(&deployCommand.args.ServiceOptions.AllowIPs, "allow-ip", nil, "Serve this service only to these addresses or CIDR ranges (e.g. 10.0.0.0/8,203.0.113.7; default empty, serve everyone). Matches the connecting address, so list IPv6 ranges too if clients reach the proxy over IPv6. The health check path stays open")
deployCommand.cmd.Flags().StringSliceVar(&deployCommand.args.ServiceOptions.TrustedProxies, "trusted-proxy", nil, "Addresses or CIDR ranges of proxies in front of this one. Only when the connecting address is one of these is --allow-ip matched against the forwarded chain instead. List every hop, not just the one that connects to kamal-proxy")

deployCommand.cmd.Flags().StringSliceVar(&deployCommand.args.TargetOptions.LogRequestHeaders, "log-request-header", nil, "Additional request header to log (may be specified multiple times)")
deployCommand.cmd.Flags().StringSliceVar(&deployCommand.args.TargetOptions.LogResponseHeaders, "log-response-header", nil, "Additional response header to log (may be specified multiple times)")
Expand Down
70 changes: 70 additions & 0 deletions internal/cmd/deploy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -468,3 +468,73 @@ func TestDeployCommand_BasicAuthAbsentLeavesServiceUnprotected(t *testing.T) {
require.NoError(t, cmd.preRun(cmd.cmd, []string{"test-service"}))
assert.Empty(t, cmd.args.ServiceOptions.BasicAuth)
}

func TestDeployCommand_AllowIPFlags(t *testing.T) {
tests := []struct {
name string
args []string
expectedAllow []string
expectedTrusted []string
expectedError string
}{
{
name: "unset leaves the service unrestricted",
args: []string{"--target=web:3000"},
},
{
name: "a single range",
args: []string{"--target=web:3000", "--allow-ip=10.0.0.0/8"},
expectedAllow: []string{"10.0.0.0/8"},
},
{
name: "comma-separated ranges",
args: []string{"--target=web:3000", "--allow-ip=10.0.0.0/8,203.0.113.7"},
expectedAllow: []string{"10.0.0.0/8", "203.0.113.7"},
},
{
name: "with trusted proxies",
args: []string{"--target=web:3000", "--allow-ip=10.0.0.0/8", "--trusted-proxy=172.16.0.0/12"},
expectedAllow: []string{"10.0.0.0/8"},
expectedTrusted: []string{"172.16.0.0/12"},
},
{
name: "a malformed range is rejected",
args: []string{"--target=web:3000", "--allow-ip=nonsense"},
expectedError: "allow-ip",
},
{
name: "trusted proxies without an allow list are rejected",
args: []string{"--target=web:3000", "--trusted-proxy=172.16.0.0/12"},
expectedError: "trusted-proxy requires allow-ip",
},
{
name: "a default route cannot be trusted",
args: []string{"--target=web:3000", "--allow-ip=10.0.0.0/8", "--trusted-proxy=0.0.0.0/0"},
expectedError: "default route",
},
{
name: "client-ip-header without trusted proxies is rejected",
args: []string{"--target=web:3000", "--allow-ip=10.0.0.0/8", "--client-ip-header=CF-Connecting-IP"},
expectedError: "requires trusted-proxy",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := newDeployCommand()
require.NoError(t, cmd.cmd.Flags().Parse(tt.args))

err := cmd.preRun(cmd.cmd, []string{"test-service"})

if tt.expectedError != "" {
require.ErrorIs(t, err, server.ErrServiceOptionsInvalid)
require.ErrorContains(t, err, tt.expectedError)
return
}

require.NoError(t, err)
assert.Equal(t, tt.expectedAllow, cmd.args.ServiceOptions.AllowIPs)
assert.Equal(t, tt.expectedTrusted, cmd.args.ServiceOptions.TrustedProxies)
})
}
}
1 change: 1 addition & 0 deletions internal/cmd/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ func newRunCommand() *runCommand {
runCommand.cmd.Flags().IntVar(&globalConfig.HttpPort, "http-port", getEnvInt("HTTP_PORT", server.DefaultHttpPort), "Port to serve HTTP traffic on")
runCommand.cmd.Flags().IntVar(&globalConfig.HttpsPort, "https-port", getEnvInt("HTTPS_PORT", server.DefaultHttpsPort), "Port to serve HTTPS traffic on")
runCommand.cmd.Flags().IntVar(&globalConfig.MetricsPort, "metrics-port", getEnvInt("METRICS_PORT", 0), "Publish metrics on the specified port (default zero to disable)")
runCommand.cmd.Flags().StringSliceVar(&globalConfig.MetricsAllowIPs, "metrics-allow-ip", nil, "Serve the metrics endpoint only to these addresses or CIDR ranges (default empty, serve everyone that can reach the port)")
runCommand.cmd.Flags().BoolVar(&globalConfig.HTTP3Enabled, "http3", false, "Enable HTTP/3")
runCommand.cmd.Flags().BoolVar(&runCommand.ignoreRestoreErrors, "ignore-restore-errors", getEnvBool("IGNORE_RESTORE_ERRORS", false), "Boot with an empty routing state when restoring the saved state fails")
runCommand.cmd.Flags().BoolVar(&runCommand.recheckTargetsOnRestore, "recheck-targets-on-restore", getEnvBool("RECHECK_TARGETS_ON_RESTORE", false), "Re-verify restored targets with health checks instead of assuming they are healthy")
Expand Down
4 changes: 4 additions & 0 deletions internal/server/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ type Config struct {
MetricsPort int
HTTP3Enabled bool

// MetricsAllowIPs restricts the metrics endpoint to these addresses and CIDR
// ranges. Empty (the default) serves everyone that can reach the port.
MetricsAllowIPs []string

ReadHeaderTimeout time.Duration
ReadTimeout time.Duration
WriteTimeout time.Duration
Expand Down
Loading
Loading