feat(roles/security): extendend nftables config
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
---
|
||||
security_firewall_default_policy: drop
|
||||
|
||||
security_firewall_allowed_ports:
|
||||
- "22/tcp"
|
||||
- "80/tcp"
|
||||
- "443/tcp"
|
||||
|
||||
# Interfaces that accept all traffic (loopback is always recommended)
|
||||
security_nftables_trusted_interfaces:
|
||||
- lo
|
||||
|
||||
# IPs/CIDRs that bypass all filtering
|
||||
security_nftables_trusted_ips: []
|
||||
# - 10.0.0.0/8
|
||||
# - 192.168.1.100
|
||||
|
||||
# IPs/CIDRs dropped immediately on input and forward
|
||||
security_nftables_blocked_ips: []
|
||||
# - 203.0.113.0/24
|
||||
|
||||
# Extended port ranges with optional source filter
|
||||
# - { start: 8000, end: 9000, proto: tcp, source: "10.0.0.0/8", comment: "internal services" }
|
||||
security_nftables_allowed_port_ranges: []
|
||||
|
||||
security_nftables_forward_policy: accept
|
||||
security_nftables_output_policy: accept
|
||||
|
||||
# Rate limiting
|
||||
security_nftables_rate_limit_enabled: true
|
||||
security_nftables_icmp_rate: "10/second"
|
||||
security_nftables_new_conn_rate: "100/second"
|
||||
security_nftables_new_conn_burst: 50
|
||||
|
||||
# Log packets that hit the default drop policy
|
||||
security_nftables_log_dropped: false
|
||||
security_nftables_log_prefix: "nftables-dropped: "
|
||||
|
||||
# Raw nftables rule strings injected into the input chain
|
||||
# Each entry is a single nft rule line (without leading whitespace)
|
||||
security_nftables_custom_input_rules: []
|
||||
# - 'ip saddr 10.0.0.0/8 tcp dport 8080 accept comment "internal dashboard"'
|
||||
|
||||
# Raw nftables rule strings injected into the forward chain
|
||||
security_nftables_custom_forward_rules: []
|
||||
|
||||
# Raw nftables rule strings injected into the output chain
|
||||
security_nftables_custom_output_rules: []
|
||||
|
||||
# DNAT rules — port forwarding
|
||||
# Optional fields: proto (default: tcp), source, iif (input interface), comment
|
||||
# - { port: 8080, dest: "10.0.0.5:80", proto: "tcp", source: "0.0.0.0/0", comment: "web backend" }
|
||||
# - { port: 25565, dest: "10.13.45.1:25565", proto: "tcp", iif: "enp3s0", comment: "Minecraft" }
|
||||
security_nftables_dnat_rules: []
|
||||
|
||||
# SNAT rules — source NAT / masquerade
|
||||
# All match fields are optional; combine as needed:
|
||||
# source — match ip saddr (e.g. "10.0.0.0/24")
|
||||
# dest — match ip daddr (e.g. "10.13.45.1")
|
||||
# proto — protocol for dport/sport matching (e.g. "tcp", "udp")
|
||||
# dport — match destination port (requires proto)
|
||||
# sport — match source port (requires proto)
|
||||
# iif — match input interface name
|
||||
# oif — match output interface name
|
||||
# to — SNAT target address (e.g. "100.64.0.1")
|
||||
# masquerade — use masquerade instead of explicit SNAT (bool, default false)
|
||||
# comment — optional rule comment
|
||||
#
|
||||
# Examples:
|
||||
# - { source: "10.0.0.0/24", oif: "eth0", to: "203.0.113.1", comment: "outbound nat" }
|
||||
# - { source: "10.0.0.0/24", oif: "eth0", masquerade: true }
|
||||
# - { oif: "tailscale0", dest: "10.13.45.1", proto: "tcp", dport: "25565", to: "100.64.0.1", comment: "SNAT Minecraft via tailscale" }
|
||||
security_nftables_snat_rules: []
|
||||
|
||||
# Interfaces to masquerade (simple outbound NAT shorthand)
|
||||
security_nftables_masquerade_interfaces: []
|
||||
# - eth0
|
||||
|
||||
# Arbitrary nftables config appended verbatim at the end of the file
|
||||
# Use for anything not covered above (custom tables, chains, sets, etc.)
|
||||
security_nftables_custom_config: ""
|
||||
@@ -6,22 +6,14 @@
|
||||
update_cache: true
|
||||
tags: security
|
||||
|
||||
- name: Install nftables
|
||||
ansible.builtin.apt:
|
||||
name:
|
||||
- nftables
|
||||
state: present
|
||||
update_cache: true
|
||||
tags: security, nftables
|
||||
|
||||
- name: Render nftables configuration
|
||||
ansible.builtin.template:
|
||||
src: nftables.conf.j2
|
||||
dest: /etc/nftables.conf
|
||||
owner: root
|
||||
group: root
|
||||
mode: '0644'
|
||||
validate: 'nft -c -f %s'
|
||||
mode: "0644"
|
||||
validate: "nft -c -f %s"
|
||||
notify: Reload nftables
|
||||
tags: security, nftables
|
||||
|
||||
@@ -33,6 +25,6 @@
|
||||
tags: security, nftables
|
||||
|
||||
- name: Install and configure fail2ban
|
||||
include_role:
|
||||
ansible.builtin.include_role:
|
||||
name: geerlingguy.security
|
||||
tags: security
|
||||
|
||||
@@ -1,38 +1,300 @@
|
||||
#!/usr/sbin/nft -f
|
||||
#
|
||||
# Managed by Ansible — manual changes will be overwritten on next deployment
|
||||
#
|
||||
|
||||
table inet filter {
|
||||
{% set default_policy = security_firewall_default_policy | default('drop') %}
|
||||
{% set allowed_ports = security_firewall_allowed_ports | default([]) %}
|
||||
{% set trusted_interfaces = security_nftables_trusted_interfaces | default(['lo']) %}
|
||||
{% set rate_limit_enabled = security_nftables_rate_limit_enabled | default(true) %}
|
||||
{% set icmp_rate = security_nftables_icmp_rate | default('10/second') %}
|
||||
{% set ct_rate = security_nftables_new_conn_rate | default('100/second') %}
|
||||
{% set ct_rate_burst = security_nftables_new_conn_burst | default(50) %}
|
||||
{% set log_dropped = security_nftables_log_dropped | default(false) %}
|
||||
{% set log_prefix = security_nftables_log_prefix | default('nftables-dropped: ') %}
|
||||
{% set custom_input_rules = security_nftables_custom_input_rules | default([]) %}
|
||||
{% set custom_forward_rules = security_nftables_custom_forward_rules | default([]) %}
|
||||
{% set custom_output_rules = security_nftables_custom_output_rules | default([]) %}
|
||||
{% set dnat_rules = security_nftables_dnat_rules | default([]) %}
|
||||
{% set snat_rules = security_nftables_snat_rules | default([]) %}
|
||||
{% set masquerade_interfaces = security_nftables_masquerade_interfaces | default([]) %}
|
||||
{% set custom_raw = security_nftables_custom_config | default('') %}
|
||||
{% set forward_policy = security_nftables_forward_policy | default('accept') %}
|
||||
{% set output_policy = security_nftables_output_policy | default('accept') %}
|
||||
{% set blocked_ips = security_nftables_blocked_ips | default([]) %}
|
||||
{% set trusted_ips = security_nftables_trusted_ips | default([]) %}
|
||||
{% set allowed_port_ranges = security_nftables_allowed_port_ranges | default([]) %}
|
||||
|
||||
{% set blocked_v4 = blocked_ips | select('match', '.*\\..*') | list %}
|
||||
{% set blocked_v6 = blocked_ips | reject('match', '.*\\..*') | list %}
|
||||
{% set trusted_v4 = trusted_ips | select('match', '.*\\..*') | list %}
|
||||
{% set trusted_v6 = trusted_ips | reject('match', '.*\\..*') | list %}
|
||||
|
||||
{% set has_nat = (dnat_rules | length > 0) or (snat_rules | length > 0) or (masquerade_interfaces | length > 0) %}
|
||||
|
||||
table inet ansible_filter
|
||||
delete table inet ansible_filter
|
||||
|
||||
# Always attempt to delete the NAT table so stale rules don't survive
|
||||
# when all NAT variables are removed from host_vars.
|
||||
table ip ansible_nat
|
||||
delete table ip ansible_nat
|
||||
|
||||
table inet ansible_filter {
|
||||
|
||||
{% if blocked_v4 | length > 0 %}
|
||||
set blocked_v4 {
|
||||
type ipv4_addr
|
||||
flags interval
|
||||
elements = {
|
||||
{% for ip in blocked_v4 %}
|
||||
{{ ip }}{{ ',' if not loop.last else '' }}
|
||||
{% endfor %}
|
||||
}
|
||||
}
|
||||
|
||||
{% endif %}
|
||||
{% if blocked_v6 | length > 0 %}
|
||||
set blocked_v6 {
|
||||
type ipv6_addr
|
||||
flags interval
|
||||
elements = {
|
||||
{% for ip in blocked_v6 %}
|
||||
{{ ip }}{{ ',' if not loop.last else '' }}
|
||||
{% endfor %}
|
||||
}
|
||||
}
|
||||
|
||||
{% endif %}
|
||||
{% if trusted_v4 | length > 0 %}
|
||||
set trusted_v4 {
|
||||
type ipv4_addr
|
||||
flags interval
|
||||
elements = {
|
||||
{% for ip in trusted_v4 %}
|
||||
{{ ip }}{{ ',' if not loop.last else '' }}
|
||||
{% endfor %}
|
||||
}
|
||||
}
|
||||
|
||||
{% endif %}
|
||||
{% if trusted_v6 | length > 0 %}
|
||||
set trusted_v6 {
|
||||
type ipv6_addr
|
||||
flags interval
|
||||
elements = {
|
||||
{% for ip in trusted_v6 %}
|
||||
{{ ip }}{{ ',' if not loop.last else '' }}
|
||||
{% endfor %}
|
||||
}
|
||||
}
|
||||
|
||||
{% endif %}
|
||||
chain input {
|
||||
type filter hook input priority 0;
|
||||
policy {{ security_firewall_default_policy | default('drop') }};
|
||||
type filter hook input priority filter; policy {{ default_policy }};
|
||||
|
||||
# Conntrack: allow established, drop invalid
|
||||
ct state established,related accept
|
||||
iifname lo accept
|
||||
ct state invalid drop
|
||||
|
||||
# allow ICMP
|
||||
ip protocol icmp icmp type { echo-request, echo-reply, destination-unreachable, time-exceeded } accept
|
||||
ip6 nexthdr icmpv6 icmpv6 type { echo-request, echo-reply, destination-unreachable, packet-too-big, time-exceeded, parameter-problem } accept
|
||||
# Blocked IPs — earliest possible drop
|
||||
{% if blocked_v4 | length > 0 %}
|
||||
ip saddr @blocked_v4 counter drop
|
||||
{% endif %}
|
||||
{% if blocked_v6 | length > 0 %}
|
||||
ip6 saddr @blocked_v6 counter drop
|
||||
{% endif %}
|
||||
|
||||
{% for p in security_firewall_allowed_ports %}
|
||||
{% set parts = p.split('/') %}
|
||||
{% set port = parts[0] %}
|
||||
{% set proto = parts[1] if parts|length > 1 else 'tcp' %}
|
||||
{{ proto }} dport {{ port }} accept
|
||||
# Trusted interfaces (loopback, wireguard, etc.)
|
||||
{% for iface in trusted_interfaces %}
|
||||
iifname "{{ iface }}" accept
|
||||
{% endfor %}
|
||||
|
||||
reject with icmpx type port-unreachable
|
||||
# Trusted IPs — bypass all further filtering
|
||||
{% if trusted_v4 | length > 0 %}
|
||||
ip saddr @trusted_v4 accept
|
||||
{% endif %}
|
||||
{% if trusted_v6 | length > 0 %}
|
||||
ip6 saddr @trusted_v6 accept
|
||||
{% endif %}
|
||||
|
||||
# ICMP / ICMPv6 — essential types{% if rate_limit_enabled %} (rate-limited){% endif %}
|
||||
|
||||
{% if rate_limit_enabled %}
|
||||
ip protocol icmp icmp type { echo-request, echo-reply, destination-unreachable, time-exceeded } limit rate {{ icmp_rate }} accept
|
||||
ip6 nexthdr icmpv6 icmpv6 type { echo-request, echo-reply, destination-unreachable, packet-too-big, time-exceeded, parameter-problem, nd-neighbor-solicit, nd-neighbor-advert, nd-router-solicit, nd-router-advert } limit rate {{ icmp_rate }} accept
|
||||
{% else %}
|
||||
ip protocol icmp icmp type { echo-request, echo-reply, destination-unreachable, time-exceeded } accept
|
||||
ip6 nexthdr icmpv6 icmpv6 type { echo-request, echo-reply, destination-unreachable, packet-too-big, time-exceeded, parameter-problem, nd-neighbor-solicit, nd-neighbor-advert, nd-router-solicit, nd-router-advert } accept
|
||||
{% endif %}
|
||||
|
||||
# Allowed ports
|
||||
{% if allowed_ports | length > 0 %}
|
||||
{% for p in allowed_ports %}
|
||||
{% set parts = p.split('/') %}
|
||||
{% set port = parts[0] %}
|
||||
{% set proto = parts[1] if parts | length > 1 else 'tcp' %}
|
||||
{% if ':' in port %}
|
||||
{% set range_parts = port.split(':') %}
|
||||
{{ proto }} dport {{ range_parts[0] }}-{{ range_parts[1] }} accept
|
||||
{% else %}
|
||||
{{ proto }} dport {{ port }} accept
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if allowed_port_ranges | length > 0 %}
|
||||
# Allowed port ranges
|
||||
{% for rule in allowed_port_ranges %}
|
||||
{% set proto = rule.proto | default('tcp') %}
|
||||
{% set comment = ' comment "' + rule.comment + '"' if rule.comment is defined else '' %}
|
||||
{% if rule.source is defined %}
|
||||
ip saddr {{ rule.source }} {{ proto }} dport {{ rule.start }}-{{ rule.end }} accept{{ comment }}
|
||||
{% else %}
|
||||
{{ proto }} dport {{ rule.start }}-{{ rule.end }} accept{{ comment }}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if rate_limit_enabled %}
|
||||
# SYN flood protection: drop new connections that exceed the rate limit.
|
||||
# Placed after port-allow rules so only accepted ports are reachable,
|
||||
# and excess SYN packets to those ports are dropped.
|
||||
tcp flags & (fin | syn | rst | ack) == syn ct state new limit rate over {{ ct_rate }} burst {{ ct_rate_burst }} packets counter drop
|
||||
{% endif %}
|
||||
|
||||
{% if custom_input_rules | length > 0 %}
|
||||
# Custom input rules
|
||||
{% for rule in custom_input_rules %}
|
||||
{{ rule }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if log_dropped %}
|
||||
# Log before final reject/drop
|
||||
log prefix "{{ log_prefix }}" flags all counter
|
||||
{% endif %}
|
||||
{% if default_policy == 'drop' %}
|
||||
# Polite rejects before the implicit policy drop
|
||||
tcp dport 1-65535 counter reject with tcp reset
|
||||
counter reject with icmpx type port-unreachable
|
||||
{% endif %}
|
||||
}
|
||||
|
||||
chain forward {
|
||||
type filter hook forward priority 0;
|
||||
policy accept;
|
||||
type filter hook forward priority filter; policy {{ forward_policy }};
|
||||
|
||||
# Conntrack
|
||||
ct state established,related accept
|
||||
ct state invalid drop
|
||||
|
||||
{% if blocked_v4 | length > 0 %}
|
||||
ip saddr @blocked_v4 counter drop
|
||||
{% endif %}
|
||||
{% if blocked_v6 | length > 0 %}
|
||||
ip6 saddr @blocked_v6 counter drop
|
||||
{% endif %}
|
||||
|
||||
{% if dnat_rules | length > 0 %}
|
||||
# Accept forwarded traffic for DNAT destinations (needed if forward policy != accept)
|
||||
{% for rule in dnat_rules %}
|
||||
{% set proto = rule.proto | default('tcp') %}
|
||||
{% set comment = ' comment "DNAT fwd: ' + rule.comment + '"' if rule.comment is defined else '' %}
|
||||
{% set dest_parts = rule.dest.split(':') %}
|
||||
{% set dest_ip = dest_parts[0] %}
|
||||
{% set dest_port = dest_parts[1] if dest_parts | length > 1 else rule.port | string %}
|
||||
ip daddr {{ dest_ip }} {{ proto }} dport {{ dest_port }} accept{{ comment }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if custom_forward_rules | length > 0 %}
|
||||
# Custom forward rules
|
||||
{% for rule in custom_forward_rules %}
|
||||
{{ rule }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
}
|
||||
|
||||
chain output {
|
||||
type filter hook output priority 0;
|
||||
policy accept;
|
||||
type filter hook output priority filter; policy {{ output_policy }};
|
||||
|
||||
ct state established,related accept
|
||||
|
||||
{% if custom_output_rules | length > 0 %}
|
||||
# Custom output rules
|
||||
{% for rule in custom_output_rules %}
|
||||
{{ rule }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
}
|
||||
}
|
||||
|
||||
{% if security_nftables_custom_config is defined and security_nftables_custom_config | trim %}
|
||||
{{ security_nftables_custom_config | trim }}
|
||||
{% if has_nat %}
|
||||
table ip ansible_nat {
|
||||
|
||||
{% if dnat_rules | length > 0 %}
|
||||
chain prerouting {
|
||||
type nat hook prerouting priority dstnat; policy accept;
|
||||
|
||||
{% for rule in dnat_rules %}
|
||||
{% set proto = rule.proto | default('tcp') %}
|
||||
{% set comment = ' comment "' + rule.comment + '"' if rule.comment is defined else '' %}
|
||||
{% set dnat_match = [] %}
|
||||
{% if rule.iif is defined %}
|
||||
{% set _ = dnat_match.append('iifname "' + rule.iif + '"') %}
|
||||
{% endif %}
|
||||
{% if rule.source is defined %}
|
||||
{% set _ = dnat_match.append('ip saddr ' + rule.source) %}
|
||||
{% endif %}
|
||||
{% set _ = dnat_match.append(proto + ' dport ' + rule.port | string) %}
|
||||
{{ dnat_match | join(' ') }} dnat to {{ rule.dest }}{{ comment }}
|
||||
{% endfor %}
|
||||
}
|
||||
|
||||
{% endif %}
|
||||
{% if snat_rules | length > 0 or masquerade_interfaces | length > 0 %}
|
||||
chain postrouting {
|
||||
type nat hook postrouting priority srcnat; policy accept;
|
||||
|
||||
{% for rule in snat_rules %}
|
||||
{% set comment = ' comment "' + rule.comment + '"' if rule.comment is defined else '' %}
|
||||
{% set proto = rule.proto | default('') %}
|
||||
{% set match_parts = [] %}
|
||||
{% if rule.iif is defined %}
|
||||
{% set _ = match_parts.append('iifname "' + rule.iif + '"') %}
|
||||
{% endif %}
|
||||
{% if rule.oif is defined %}
|
||||
{% set _ = match_parts.append('oifname "' + rule.oif + '"') %}
|
||||
{% endif %}
|
||||
{% if rule.source is defined %}
|
||||
{% set _ = match_parts.append('ip saddr ' + rule.source) %}
|
||||
{% endif %}
|
||||
{% if rule.dest is defined %}
|
||||
{% set _ = match_parts.append('ip daddr ' + rule.dest) %}
|
||||
{% endif %}
|
||||
{% if proto %}
|
||||
{% if rule.dport is defined %}
|
||||
{% set _ = match_parts.append(proto + ' dport ' + rule.dport | string) %}
|
||||
{% endif %}
|
||||
{% if rule.sport is defined %}
|
||||
{% set _ = match_parts.append(proto + ' sport ' + rule.sport | string) %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if rule.masquerade | default(false) %}
|
||||
{{ match_parts | join(' ') }} masquerade{{ comment }}
|
||||
{% else %}
|
||||
{{ match_parts | join(' ') }} snat to {{ rule.to }}{{ comment }}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% for iface in masquerade_interfaces %}
|
||||
oifname "{{ iface }}" masquerade
|
||||
{% endfor %}
|
||||
}
|
||||
|
||||
{% endif %}
|
||||
}
|
||||
{% endif %}
|
||||
|
||||
{% if custom_raw | trim | length > 0 %}
|
||||
{{ custom_raw | trim }}
|
||||
{% endif %}
|
||||
|
||||
Reference in New Issue
Block a user