host_firewall: per-service new-connection meters per source (/32, /64) and per range (/24, /48) from measured peaks, excess dropped with prefix fw-ratelimit:, replaces the ssh meter
This commit is contained in:
@@ -21,6 +21,33 @@ set {{ name }} {
|
||||
{% set scrapers = firewall.scrapers | default({}, true) %}
|
||||
{% set forward_guard = firewall.forward_guard | default({}, true) | dict2items | rejectattr('value', 'none') | list %}
|
||||
{% set blocklists = firewall.spamhaus or firewall.firehol %}
|
||||
{% macro port_values(map) -%}
|
||||
{{ map | default({}, true) | dict2items | rejectattr('value', 'none') | map(attribute='value') | map('string') | unique | join(' ') }}
|
||||
{%- endmacro %}
|
||||
{# Per-service limits apply only to ports the host serves: input for the public and geo ports, forward for DNATed traffic (port forwards of the host and Docker-published ports). #}
|
||||
{% set served_input = (port_values(firewall.public.tcp) ~ ' ' ~ port_values(firewall.geo.tcp)).split() %}
|
||||
{% set served_forward = (port_values(firewall.public.tcp) ~ ' ' ~ port_values(firewall.geo.tcp) ~ ' ' ~ (dnat | map(attribute='tcp') | map('default', {}, true) | map('dict2items') | sum(start=[]) | rejectattr('value', 'none') | map(attribute='value') | map('string') | join(' '))).split() %}
|
||||
{% set rl = firewall.ratelimit | default({}, true) %}
|
||||
{% set rl_factor = rl.range_factor | default({}, true) %}
|
||||
{% set rl_services = [] %}
|
||||
{% for svc in rl.services | default({}, true) | dict2items | rejectattr('value', 'none') %}
|
||||
{% set ports = [svc.value.ports] | flatten | map('string') | list %}
|
||||
{% set per = svc.value.per | default('minute', true) %}
|
||||
{% set burst = svc.value.burst | default(svc.value.rate * 2, true) %}
|
||||
{% set _ = rl_services.append({
|
||||
'name': svc.key | replace('-', '_'),
|
||||
'ports': ports | join(', '),
|
||||
'rate': svc.value.rate,
|
||||
'burst': burst,
|
||||
'per': per,
|
||||
'f4': svc.value.range_factor_v4 | default(rl_factor.v4 | default(6), true),
|
||||
'f6': svc.value.range_factor_v6 | default(rl_factor.v6 | default(3), true),
|
||||
'input': (ports | select('in', served_input) | list | length) > 0,
|
||||
'forward': (ports | select('in', served_forward) | list | length) > 0,
|
||||
}) %}
|
||||
{% endfor %}
|
||||
{% set rl_input = rl_services | selectattr('input') | list %}
|
||||
{% set rl_forward = rl_services | selectattr('forward') | list %}
|
||||
|
||||
# Declare-then-delete makes every load one atomic transaction that replaces
|
||||
# only these tables; Cilium, Docker, NetBird and CrowdSec tables stay untouched.
|
||||
@@ -49,12 +76,20 @@ table inet host {
|
||||
{{ addr_set('cluster_v6', 'ipv6_addr', cluster.v6) }}
|
||||
{{ addr_set('scrapers_v4', 'ipv4_addr', scrapers.v4) }}
|
||||
{{ addr_set('scrapers_v6', 'ipv6_addr', scrapers.v6) }}
|
||||
set ssh_meter_v4 {
|
||||
{% for svc in rl_services if svc.input or svc.forward %}
|
||||
set rl_{{ svc.name }}_v4 {
|
||||
type ipv4_addr; flags dynamic; timeout 10m; size 65535;
|
||||
}
|
||||
set ssh_meter_v6 {
|
||||
set rl_{{ svc.name }}_v6 {
|
||||
type ipv6_addr; flags dynamic; timeout 10m; size 65535;
|
||||
}
|
||||
set rl_{{ svc.name }}_v4r {
|
||||
type ipv4_addr; flags dynamic; timeout 10m; size 16384;
|
||||
}
|
||||
set rl_{{ svc.name }}_v6r {
|
||||
type ipv6_addr; flags dynamic; timeout 10m; size 16384;
|
||||
}
|
||||
{% endfor %}
|
||||
|
||||
chain input {
|
||||
type filter hook input priority filter; policy drop;
|
||||
@@ -127,6 +162,44 @@ table inet host {
|
||||
limit rate {{ firewall.log.in_rate }} log prefix "fw-in-drop: "
|
||||
drop
|
||||
}
|
||||
{% if rl_services | selectattr('input') | list or rl_forward %}
|
||||
|
||||
# Per-service limits on new inbound TCP connections (firewall.ratelimit): per source address (IPv4 /32, IPv6 /64) and per
|
||||
# range (IPv4 /24, IPv6 /48; the per-source rate times range_factor). Admin, cluster, VPN, guest and internal sources
|
||||
# are exempt. The excess drops with its own log prefix, which CrowdSec reads as a ratelimit event and not as a port scan.
|
||||
{% macro rl_exempt() %}
|
||||
iifname "wt0" return
|
||||
{% for iface in cluster_ifaces + internal_ifaces + guest_ifaces %}
|
||||
iifname "{{ iface }}" return
|
||||
{% endfor %}
|
||||
ip saddr @admin_v4 return
|
||||
ip6 saddr @admin_v6 return
|
||||
ip saddr @cluster_v4 return
|
||||
ip6 saddr @cluster_v6 return
|
||||
{% endmacro %}
|
||||
{% for hook, svcs in [('in', rl_input), ('fwd', rl_forward)] if svcs %}
|
||||
chain ratelimit_{{ hook }} {
|
||||
{{ rl_exempt() }}
|
||||
{% for svc in svcs %}
|
||||
meta l4proto tcp ct original proto-dst { {{ svc.ports }} } counter jump rl_{{ svc.name }}
|
||||
{% endfor %}
|
||||
}
|
||||
|
||||
{% endfor %}
|
||||
{% for svc in rl_services if svc.input or svc.forward %}
|
||||
chain rl_{{ svc.name }} {
|
||||
update @rl_{{ svc.name }}_v4 { ip saddr limit rate over {{ svc.rate }}/{{ svc.per }} burst {{ svc.burst }} packets } counter jump rl_drop
|
||||
update @rl_{{ svc.name }}_v6 { ip6 saddr & ffff:ffff:ffff:ffff:: limit rate over {{ svc.rate }}/{{ svc.per }} burst {{ svc.burst }} packets } counter jump rl_drop
|
||||
update @rl_{{ svc.name }}_v4r { ip saddr & 255.255.255.0 limit rate over {{ svc.rate * svc.f4 }}/{{ svc.per }} burst {{ svc.burst * svc.f4 }} packets } counter jump rl_drop
|
||||
update @rl_{{ svc.name }}_v6r { ip6 saddr & ffff:ffff:ffff:: limit rate over {{ svc.rate * svc.f6 }}/{{ svc.per }} burst {{ svc.burst * svc.f6 }} packets } counter jump rl_drop
|
||||
}
|
||||
|
||||
{% endfor %}
|
||||
chain rl_drop {
|
||||
limit rate {{ firewall.log.ratelimit_rate }} log prefix "fw-ratelimit: "
|
||||
drop
|
||||
}
|
||||
{% endif %}
|
||||
{% if firewall.spamhaus %}
|
||||
|
||||
# Own limits, so a listed scanner cannot use up the budget of fw-in-drop.
|
||||
@@ -204,6 +277,9 @@ table inet host {
|
||||
}
|
||||
|
||||
chain public {
|
||||
{% if rl_input %}
|
||||
ct state new jump ratelimit_in
|
||||
{% endif %}
|
||||
{% if list(firewall.public.tcp) %}
|
||||
tcp dport { {{ list(firewall.public.tcp) }} } accept
|
||||
{% endif %}
|
||||
@@ -232,18 +308,20 @@ table inet host {
|
||||
{% endif %}
|
||||
ip saddr @admin_v4 accept
|
||||
ip6 saddr @admin_v6 accept
|
||||
tcp dport 22 ct state new update @ssh_meter_v4 { ip saddr limit rate over {{ firewall.ssh_rate_limit }} } jump drop_log
|
||||
# One IPv6 client usually holds a whole /64.
|
||||
tcp dport 22 ct state new update @ssh_meter_v6 { ip6 saddr & ffff:ffff:ffff:ffff:: limit rate over {{ firewall.ssh_rate_limit }} } jump drop_log
|
||||
{% if firewall.geo_countries %}
|
||||
ip saddr @geo_v4 accept
|
||||
ip6 saddr @geo_v6 accept
|
||||
{% endif %}
|
||||
}
|
||||
{% if forward_guard or blocklists or firewall.scanners %}
|
||||
{% if forward_guard or blocklists or firewall.scanners or rl_forward %}
|
||||
|
||||
chain forward {
|
||||
type filter hook forward priority filter; policy accept;
|
||||
{% if rl_forward %}
|
||||
|
||||
# Docker-published ports and port forwards arrive here after DNAT; pod and guest egress is not DNATed.
|
||||
ct status dnat ct state new jump ratelimit_fwd
|
||||
{% endif %}
|
||||
{% if firewall.spamhaus %}
|
||||
|
||||
ip saddr @spamhaus_v4 jump spamhaus_in
|
||||
|
||||
Reference in New Issue
Block a user