diff --git a/roles/security/defaults/main.yaml b/roles/security/defaults/main.yaml new file mode 100644 index 0000000..b1df1f2 --- /dev/null +++ b/roles/security/defaults/main.yaml @@ -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: "" diff --git a/roles/security/tasks/main.yaml b/roles/security/tasks/main.yaml index 6a872cf..5fe4490 100644 --- a/roles/security/tasks/main.yaml +++ b/roles/security/tasks/main.yaml @@ -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 diff --git a/roles/security/templates/nftables.conf.j2 b/roles/security/templates/nftables.conf.j2 index 211e47b..2f910d1 100644 --- a/roles/security/templates/nftables.conf.j2 +++ b/roles/security/templates/nftables.conf.j2 @@ -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 %}