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
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,47 @@ Things worth knowing:

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

### Denying abusive clients

An allow list is the wrong shape for a service the whole internet is meant to
reach. When a scraper or a misbehaving bot is hammering a public service right
now, deploy it with deny rules instead:

kamal-proxy deploy service1 --target web-1:3000 --deny-ip 203.0.113.0/24
kamal-proxy deploy service1 --target web-1:3000 --deny-user-agent 'BadBot/.*'

Matching requests get a `403`. `--deny-ip` takes addresses or CIDR ranges and
may be repeated or comma-separated. `--deny-user-agent` takes an RE2 pattern
matched against the full `User-Agent` value, compiled once at deploy, and may
be repeated (but not comma-separated — patterns can contain commas).

Things worth knowing:

* **The client is resolved exactly as it is for `--allow-ip`** — the connecting
address unless `--trusted-proxy` says the peer is yours, in which case the
forwarded chain is walked with the same rules. Denying the connecting peer
behind a trusted load balancer would deny everyone.
* **Deny runs first.** Before the allow list (an address on both lists is
denied), before the TLS redirect (a `403` solicits nothing), before the rate
limit (a denied client never spends budget), and before basic auth (a denied
network never learns credentials are wanted).
* **IPv6 addresses are matched exactly as written** — an explicit address
denies that address alone, with none of the rate limiter's `/64` grouping. A
deny names exactly what you wrote; write the CIDR if you mean the network.
* **A missing `User-Agent` is not a crime.** Only an explicit `^$` pattern
denies requests that send no `User-Agent` at all — even `.*` does not.
Patterns match the whole header value: `BadBot/.*` means that agent, not any
agent mentioning it somewhere.
* **The health check path stays open**, and deploying deny rules with a health
check path of `/` is rejected, same as `--allow-ip`.
* **Denials are counted** in the `kamal_proxy_denials_total` metric, labeled by
service and rule kind (`ip`, `user_agent`), and logged rate-limited per
service.
* **Redeploying without the flags removes the block.** Rules live in the
service's deploy options like every other knob.

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

### Rate limiting a service per client

To cap how fast a single client may hit a service, deploy it with
Expand Down
7 changes: 6 additions & 1 deletion internal/cmd/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,12 @@ func newDeployCommand() *deployCommand {
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 are --allow-ip and --rate-limit matched against the forwarded chain instead. List every hop, not just the one that connects to kamal-proxy")
deployCommand.cmd.Flags().StringSliceVar(&deployCommand.args.ServiceOptions.DenyIPs, "deny-ip", nil, "Refuse this service to these addresses or CIDR ranges (e.g. 203.0.113.0/24,198.51.100.7; default empty, deny nobody). Checked before --allow-ip: an address on both lists is denied. Denied clients get a 403 and never spend rate-limit budget. The health check path stays open")
// StringArray rather than StringSlice: an RE2 pattern may contain commas,
// as `Bad(Bot|Crawler){1,3}` does, and StringSlice would split it into two
// broken rules.
deployCommand.cmd.Flags().StringArrayVar(&deployCommand.args.ServiceOptions.DenyUserAgents, "deny-user-agent", nil, "Refuse requests whose full User-Agent matches this RE2 pattern (e.g. 'BadBot/.*'; may be specified multiple times). Checked after the IP rules. A missing User-Agent only matches an explicit '^$' pattern")
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 are --allow-ip, --deny-ip and --rate-limit matched against the forwarded chain instead. List every hop, not just the one that connects to kamal-proxy")
deployCommand.cmd.Flags().Float64Var(&deployCommand.args.ServiceOptions.RateLimit, "rate-limit", 0, "Max requests per second from a single client (default 0, no limit). Requests over the limit get a 429. Counts the connecting address unless --trusted-proxy is set; IPv6 clients are counted per /64, since a client can pick any address in its own. The health check path stays open")
deployCommand.cmd.Flags().IntVar(&deployCommand.args.ServiceOptions.RateLimitBurst, "rate-limit-burst", 0, "How many requests a client may make back to back before --rate-limit applies (default 0, meaning the rate rounded up)")
deployCommand.cmd.Flags().StringSliceVar(&deployCommand.args.ServiceOptions.RateLimitExempt, "rate-limit-exempt", nil, "Addresses or CIDR ranges that --rate-limit does not apply to (e.g. 10.0.0.0/8 for monitoring; default empty)")
Expand Down
20 changes: 20 additions & 0 deletions internal/metrics/metrics.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ type tracker interface {
SetDynamicRedirects(service string, hosts, rules int)
TrackDynamicRedirectPoll(service, outcome string)
TrackDynamicRedirect(service string, status int)
TrackDenial(service, rule string)
}

var Tracker tracker = &nullTracker{}
Expand All @@ -49,6 +50,7 @@ func (nullTracker) TrackCacheEviction(service, state string)
func (nullTracker) SetDynamicRedirects(service string, hosts, rules int) {}
func (nullTracker) TrackDynamicRedirectPoll(service, outcome string) {}
func (nullTracker) TrackDynamicRedirect(service string, status int) {}
func (nullTracker) TrackDenial(service, rule string) {}

type prometheusTracker struct {
httpRequests *prometheus.CounterVec
Expand All @@ -71,6 +73,9 @@ type prometheusTracker struct {
dynamicRedirectMapSize *prometheus.GaugeVec
dynamicRedirectPolls *prometheus.CounterVec
dynamicRedirects *prometheus.CounterVec

// Deny rule metrics
denials *prometheus.CounterVec
}

func NewPrometheusTracker() *prometheusTracker {
Expand Down Expand Up @@ -206,6 +211,16 @@ func NewPrometheusTracker() *prometheusTracker {
[]string{"service", "status"},
),

denials: prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "denials_total",
Namespace: "kamal",
Subsystem: "proxy",
Help: "Requests refused by --deny-ip or --deny-user-agent, labeled by service and rule kind (ip, user_agent).",
},
[]string{"service", "rule"},
),

certCount: prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Name: "certificates_total",
Expand All @@ -232,6 +247,7 @@ func NewPrometheusTracker() *prometheusTracker {
tracker.dynamicRedirectMapSize,
tracker.dynamicRedirectPolls,
tracker.dynamicRedirects,
tracker.denials,
)

return tracker
Expand Down Expand Up @@ -308,6 +324,10 @@ func (p *prometheusTracker) TrackDynamicRedirect(service string, status int) {
p.dynamicRedirects.WithLabelValues(service, strconv.Itoa(status)).Inc()
}

func (p *prometheusTracker) TrackDenial(service, rule string) {
p.denials.WithLabelValues(service, rule).Inc()
}

// Private

func normalizeMethod(method string) string {
Expand Down
22 changes: 22 additions & 0 deletions internal/server/cert_metrics_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ type fakeTracker struct {
redirectMapSizes map[string][2]int // service -> {hosts, rules}
redirectPolls map[string]int // "service:outcome" -> count
redirectHits map[string]int // "service:status" -> count

denials map[string]int // "service:rule" -> count
}

type certCountSample struct {
Expand All @@ -53,6 +55,8 @@ func newFakeTracker() *fakeTracker {
redirectMapSizes: make(map[string][2]int),
redirectPolls: make(map[string]int),
redirectHits: make(map[string]int),

denials: make(map[string]int),
}
}

Expand Down Expand Up @@ -108,6 +112,18 @@ func (f *fakeTracker) TrackDynamicRedirect(service string, status int) {
f.redirectHits[service+":"+strconv.Itoa(status)]++
}

func (f *fakeTracker) TrackDenial(service, rule string) {
f.mu.Lock()
defer f.mu.Unlock()
f.denials[service+":"+rule]++
}

func (f *fakeTracker) denialCount(service, rule string) int {
f.mu.Lock()
defer f.mu.Unlock()
return f.denials[service+":"+rule]
}

func (f *fakeTracker) redirectPollCount(service, outcome string) int {
f.mu.Lock()
defer f.mu.Unlock()
Expand Down Expand Up @@ -306,6 +322,12 @@ func (s *switchableTracker) TrackDynamicRedirect(service string, status int) {
}
}

func (s *switchableTracker) TrackDenial(service, rule string) {
if fake := s.current(); fake != nil {
fake.TrackDenial(service, rule)
}
}

// installFakeTracker points the tracker at a fresh capturing tracker for the
// duration of one test.
func installFakeTracker(t *testing.T) *fakeTracker {
Expand Down
Loading
Loading