From bd9d48cc2721b12ab0a97b49ec9114a8677bc543 Mon Sep 17 00:00:00 2001 From: Chalvin Date: Fri, 18 Sep 2026 22:30:48 +0700 Subject: [PATCH 1/2] feat: add vps-firewall role (ufw default-deny ingress) --- ansible/roles/vps-firewall/tasks/main.yml | 38 +++++++++++++++++++++++ ansible/site.yml | 1 + 2 files changed, 39 insertions(+) create mode 100644 ansible/roles/vps-firewall/tasks/main.yml diff --git a/ansible/roles/vps-firewall/tasks/main.yml b/ansible/roles/vps-firewall/tasks/main.yml new file mode 100644 index 0000000..a924b32 --- /dev/null +++ b/ansible/roles/vps-firewall/tasks/main.yml @@ -0,0 +1,38 @@ +--- +- name: Install ufw + ansible.builtin.apt: + name: ufw + state: present + update_cache: true + +- name: Rate-limit new SSH connections + ansible.builtin.command: ufw limit {{ ansible_port | default(22) }}/tcp + register: ufw_rule_ssh + changed_when: "'Rule added' in ufw_rule_ssh.stdout" + +- name: Allow HAProxy HTTP and HTTPS + ansible.builtin.command: ufw allow {{ item }}/tcp + register: ufw_rule_http + changed_when: "'Rule added' in ufw_rule_http.stdout" + loop: + - "{{ haproxy_http_port }}" + - "{{ haproxy_https_port }}" + +- name: Allow WireGuard UDP + ansible.builtin.command: ufw allow {{ wireguard_port }}/udp + register: ufw_rule_wg + changed_when: "'Rule added' in ufw_rule_wg.stdout" + +- name: Default deny incoming + ansible.builtin.command: ufw default deny incoming + register: ufw_default + changed_when: "'Default incoming policy changed' in ufw_default.stdout" + +- name: Check ufw state + ansible.builtin.command: ufw status + register: ufw_status + changed_when: false + +- name: Enable ufw + ansible.builtin.command: ufw --force enable + when: "'Status: active' not in ufw_status.stdout" diff --git a/ansible/site.yml b/ansible/site.yml index c41cd9b..676d918 100644 --- a/ansible/site.yml +++ b/ansible/site.yml @@ -10,6 +10,7 @@ roles: - { role: wireguard-server, tags: [wireguard-server] } - { role: haproxy, tags: [haproxy] } + - { role: vps-firewall, tags: [vps-firewall] } - { role: vps-blocklist, tags: [vps-blocklist] } - { role: fail2ban, tags: [fail2ban] } - { role: crowdsec, tags: [crowdsec] } From f19d1a125d8312ceb62b7475335dec8c74411e36 Mon Sep 17 00:00:00 2001 From: Chalvin Date: Sun, 25 Oct 2026 14:51:07 +0700 Subject: [PATCH 2/2] fix: enforce VPS ingress policy with native nftables --- .github/workflows/lint.yml | 4 +- README.md | 8 + ansible/roles/vps-blocklist/handlers/main.yml | 8 +- ansible/roles/vps-blocklist/tasks/main.yml | 4 +- ansible/roles/vps-firewall/handlers/main.yml | 5 + ansible/roles/vps-firewall/tasks/main.yml | 61 +++---- .../templates/vps-firewall.nft.j2 | 16 ++ ansible/site.yml | 2 +- tests/test_vps_firewall.py | 158 ++++++++++++++++++ 9 files changed, 230 insertions(+), 36 deletions(-) create mode 100644 ansible/roles/vps-firewall/handlers/main.yml create mode 100644 ansible/roles/vps-firewall/templates/vps-firewall.nft.j2 create mode 100644 tests/test_vps_firewall.py diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 3e52f38..d4ad1b0 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -36,11 +36,13 @@ jobs: bash tests/test-security-config.sh bash tests/test-monitoring-alerts.sh - - name: Check application network isolation + - name: Check network isolation and VPS firewall run: | sudo apt-get install -y --no-install-recommends nftables iproute2 python tests/test_app_isolation.py render "$RUNNER_TEMP/app-isolation.nft" sudo unshare --net "$(command -v python)" tests/test_app_isolation.py "$RUNNER_TEMP/app-isolation.nft" + python tests/test_vps_firewall.py render "$RUNNER_TEMP/vps-firewall.nft" + sudo unshare --net "$(command -v python)" tests/test_vps_firewall.py "$RUNNER_TEMP/vps-firewall.nft" - name: Run ansible-lint run: cd ansible && ansible-lint diff --git a/README.md b/README.md index 37e77f4..58003bc 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,13 @@ outside Ansible's control. ## Security Design +The VPS uses nftables to deny unsolicited inbound traffic, allowing configured +SSH, HTTP/HTTPS and WireGuard ports plus ICMP and DHCP. Unsolicited forwarding +from the public interface is blocked, including accidentally published container +ports; established replies and WireGuard forwarding remain available. SSH abuse +protection remains with fail2ban. Firewall updates load only managed tables and +preserve existing NAT and ban state. + ### CrowdSec — web threat detection + proactive blocking Runs on the edge node as the **LAPI** server. It tails Caddy's JSON access log @@ -347,6 +354,7 @@ roles/ ├── wireguard-server/ # ingress-01: WG server + peer mgmt (wg0 + wg0-peers.conf) ├── mikrotik-wireguard/ # mikrotik-01: RouterOS WG tunnel + CGNAT watchdog (raw SSH) ├── haproxy/ # ingress-01: TCP forward + PROXY v2 + rate limiting +├── vps-firewall/ # ingress-01: default-deny ingress and public forwarding guard ├── vps-blocklist/ # ingress-01: nftables blocklist + banagent ban-ip tool ├── fail2ban/ # both nodes: SSH incremental banning via nftables ├── crowdsec/ # edge-01: LAPI + Hub + blocklist-import | ingress-01: bouncer diff --git a/ansible/roles/vps-blocklist/handlers/main.yml b/ansible/roles/vps-blocklist/handlers/main.yml index 69e8f72..5a0845e 100644 --- a/ansible/roles/vps-blocklist/handlers/main.yml +++ b/ansible/roles/vps-blocklist/handlers/main.yml @@ -1,5 +1,5 @@ --- -- name: Reload nftables - ansible.builtin.systemd: - name: nftables - state: reloaded +- name: Apply VPS blocklist + ansible.builtin.command: nft -f /etc/nftables.d/blocklist.nft + changed_when: true + when: not ansible_check_mode diff --git a/ansible/roles/vps-blocklist/tasks/main.yml b/ansible/roles/vps-blocklist/tasks/main.yml index 23e3cf5..dad1796 100644 --- a/ansible/roles/vps-blocklist/tasks/main.yml +++ b/ansible/roles/vps-blocklist/tasks/main.yml @@ -23,14 +23,14 @@ src: blocklist.nft.j2 dest: /etc/nftables.d/blocklist.nft mode: "0644" - notify: Reload nftables + notify: Apply VPS blocklist - name: Include blocklist in /etc/nftables.conf ansible.builtin.lineinfile: path: /etc/nftables.conf line: 'include "/etc/nftables.d/blocklist.nft"' state: present - notify: Reload nftables + notify: Apply VPS blocklist - name: Enable and start nftables ansible.builtin.systemd: diff --git a/ansible/roles/vps-firewall/handlers/main.yml b/ansible/roles/vps-firewall/handlers/main.yml new file mode 100644 index 0000000..25fe32a --- /dev/null +++ b/ansible/roles/vps-firewall/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: Apply VPS firewall + ansible.builtin.command: nft -f /etc/nftables.d/vps-firewall.nft + changed_when: true + when: not ansible_check_mode diff --git a/ansible/roles/vps-firewall/tasks/main.yml b/ansible/roles/vps-firewall/tasks/main.yml index a924b32..d8c44d6 100644 --- a/ansible/roles/vps-firewall/tasks/main.yml +++ b/ansible/roles/vps-firewall/tasks/main.yml @@ -1,38 +1,43 @@ --- -- name: Install ufw +- name: Install nftables ansible.builtin.apt: - name: ufw + name: nftables state: present update_cache: true -- name: Rate-limit new SSH connections - ansible.builtin.command: ufw limit {{ ansible_port | default(22) }}/tcp - register: ufw_rule_ssh - changed_when: "'Rule added' in ufw_rule_ssh.stdout" +- name: Create firewall configuration directory + ansible.builtin.file: + path: /etc/nftables.d + state: directory + mode: "0755" -- name: Allow HAProxy HTTP and HTTPS - ansible.builtin.command: ufw allow {{ item }}/tcp - register: ufw_rule_http - changed_when: "'Rule added' in ufw_rule_http.stdout" - loop: - - "{{ haproxy_http_port }}" - - "{{ haproxy_https_port }}" +- name: Install VPS ingress firewall + ansible.builtin.template: + src: vps-firewall.nft.j2 + dest: /etc/nftables.d/vps-firewall.nft + mode: "0600" + validate: nft --check -f %s + diff: false + notify: Apply VPS firewall -- name: Allow WireGuard UDP - ansible.builtin.command: ufw allow {{ wireguard_port }}/udp - register: ufw_rule_wg - changed_when: "'Rule added' in ufw_rule_wg.stdout" +- name: Preserve WireGuard NAT and dynamic ban tables when loading nftables + ansible.builtin.lineinfile: + path: /etc/nftables.conf + regexp: "^flush ruleset$" + state: absent -- name: Default deny incoming - ansible.builtin.command: ufw default deny incoming - register: ufw_default - changed_when: "'Default incoming policy changed' in ufw_default.stdout" +- name: Include the VPS firewall at boot + ansible.builtin.lineinfile: + path: /etc/nftables.conf + line: 'include "/etc/nftables.d/vps-firewall.nft"' + notify: Apply VPS firewall -- name: Check ufw state - ansible.builtin.command: ufw status - register: ufw_status - changed_when: false +- name: Enable nftables + ansible.builtin.systemd: + name: nftables + enabled: true + state: started + when: not ansible_check_mode -- name: Enable ufw - ansible.builtin.command: ufw --force enable - when: "'Status: active' not in ufw_status.stdout" +- name: Apply pending VPS rules + ansible.builtin.meta: flush_handlers diff --git a/ansible/roles/vps-firewall/templates/vps-firewall.nft.j2 b/ansible/roles/vps-firewall/templates/vps-firewall.nft.j2 new file mode 100644 index 0000000..11416f7 --- /dev/null +++ b/ansible/roles/vps-firewall/templates/vps-firewall.nft.j2 @@ -0,0 +1,16 @@ +add table inet vps_firewall +add chain inet vps_firewall input { type filter hook input priority 0; policy drop; } +add chain inet vps_firewall forward { type filter hook forward priority -5; policy accept; } +flush chain inet vps_firewall input +flush chain inet vps_firewall forward +add rule inet vps_firewall input iifname "lo" accept +add rule inet vps_firewall input ct state established,related accept +add rule inet vps_firewall input ct state invalid drop +add rule inet vps_firewall input ip protocol icmp accept +add rule inet vps_firewall input meta l4proto ipv6-icmp accept +add rule inet vps_firewall input tcp dport { {{ ansible_port | default(22) }}, {{ haproxy_http_port }}, {{ haproxy_https_port }} } accept +add rule inet vps_firewall input udp dport {{ wireguard_port }} accept +add rule inet vps_firewall input iifname "{{ ansible_default_ipv4.interface }}" meta nfproto ipv4 udp sport 67 udp dport 68 accept +add rule inet vps_firewall input iifname "{{ ansible_default_ipv4.interface }}" meta nfproto ipv6 udp sport 547 udp dport 546 accept +add rule inet vps_firewall forward ct state established,related accept +add rule inet vps_firewall forward iifname "{{ ansible_default_ipv4.interface }}" counter drop diff --git a/ansible/site.yml b/ansible/site.yml index 676d918..adad050 100644 --- a/ansible/site.yml +++ b/ansible/site.yml @@ -8,9 +8,9 @@ - network - distribution roles: + - { role: vps-firewall, tags: [vps-firewall] } - { role: wireguard-server, tags: [wireguard-server] } - { role: haproxy, tags: [haproxy] } - - { role: vps-firewall, tags: [vps-firewall] } - { role: vps-blocklist, tags: [vps-blocklist] } - { role: fail2ban, tags: [fail2ban] } - { role: crowdsec, tags: [crowdsec] } diff --git a/tests/test_vps_firewall.py b/tests/test_vps_firewall.py new file mode 100644 index 0000000..c535196 --- /dev/null +++ b/tests/test_vps_firewall.py @@ -0,0 +1,158 @@ +"""Exercise VPS ingress and forwarding in disposable network namespaces.""" + +import os +import socket +import subprocess +import sys +import threading +import time +from pathlib import Path + + +def render(path: Path) -> None: + from jinja2 import Environment + + root = Path(__file__).parents[1] + source = root / 'ansible/roles/vps-firewall/templates/vps-firewall.nft.j2' + path.write_text(Environment().from_string(source.read_text()).render( + ansible_port=2222, haproxy_http_port=80, haproxy_https_port=443, + wireguard_port=51820, ansible_default_ipv4={'interface': 'wan-test'}, + )) + path.with_suffix('.blocklist.nft').write_text( + (root / 'ansible/roles/vps-blocklist/templates/blocklist.nft.j2').read_text()) + + +def verify(path: Path) -> None: + assert os.geteuid() == 0, 'Root is required inside a disposable namespace' + assert Path('/proc/self/ns/net').readlink() != Path('/proc/1/ns/net').readlink(), 'Refusing to modify the host network namespace' + children = [] + + def command(*args: str) -> None: + subprocess.run(args, check=True, capture_output=True) + + def in_namespace(pid: int, *args: str) -> None: + command('nsenter', '-t', str(pid), '-n', *args) + + def peer(interface: str, host: str, client: str, ipv6: bool = False) -> int: + process = subprocess.Popen(['unshare', '--net', sys.executable, '-c', 'import time; time.sleep(180)']) + children.append(process) + for _ in range(100): + if Path(f'/proc/{process.pid}/ns/net').readlink() != Path('/proc/self/ns/net').readlink(): + break + time.sleep(0.01) + else: + raise AssertionError('Peer namespace did not initialize') + command('ip', 'link', 'add', interface, 'type', 'veth', 'peer', 'name', 'peer') + command('ip', 'link', 'set', 'peer', 'netns', str(process.pid)) + command('ip', 'address', 'add', host + '/24', 'dev', interface) + command('ip', 'link', 'set', interface, 'up') + in_namespace(process.pid, 'ip', 'link', 'set', 'lo', 'up') + in_namespace(process.pid, 'ip', 'address', 'add', client + '/24', 'dev', 'peer') + in_namespace(process.pid, 'ip', 'link', 'set', 'peer', 'up') + if ipv6: + command('ip', '-6', 'address', 'add', '2001:db8::1/64', 'dev', interface, 'nodad') + in_namespace(process.pid, 'ip', '-6', 'address', 'add', '2001:db8::2/64', 'dev', 'peer', 'nodad') + return process.pid + + probe = '''import socket,sys +address,port,kind,source=sys.argv[1:] +s=socket.socket(socket.AF_INET6 if ':' in address else socket.AF_INET, + socket.SOCK_DGRAM if kind=='udp' else socket.SOCK_STREAM) +s.settimeout(1) +if source:s.bind(('::' if ':' in address else '0.0.0.0',int(source))) +try: + s.connect((address,int(port))) + if kind=='udp':s.send(b'probe') + assert s.recv(16)==b'ok' +except OSError:sys.exit(1) +''' + + def check(pid: int, address: str, port: int, allowed: bool, kind: str = 'tcp', source: str = '') -> None: + result = subprocess.run(['nsenter', '-t', str(pid), '-n', sys.executable, '-c', probe, + address, str(port), kind, source], check=False, capture_output=True) + assert (result.returncode == 0) == allowed, (address, port, kind, allowed) + + def listener(family: int, address: str, port: int, kind: int) -> None: + server = socket.socket(family, kind) + if family == socket.AF_INET6: + server.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1) + server.bind((address, port)) + if kind == socket.SOCK_STREAM: + server.listen() + + def serve() -> None: + while True: + if kind == socket.SOCK_STREAM: + connection, _ = server.accept() + connection.sendall(b'ok') + connection.close() + else: + _, remote = server.recvfrom(16) + server.sendto(b'ok', remote) + + threading.Thread(target=serve, daemon=True).start() + + def peer_server(pid: int, port: int) -> None: + code = '''import socket,sys +s=socket.socket();s.bind(('0.0.0.0',int(sys.argv[1])));s.listen();print('ready',flush=True) +while True: + c,_=s.accept();c.sendall(b'ok');c.close() +''' + process = subprocess.Popen(['nsenter', '-t', str(pid), '-n', sys.executable, '-c', code, str(port)], stdout=subprocess.PIPE, text=True) + children.append(process) + assert process.stdout.readline().strip() == 'ready' + + try: + command('ip', 'link', 'set', 'lo', 'up') + Path('/proc/sys/net/ipv4/ip_forward').write_text('1') + wan = peer('wan-test', '192.0.2.1', '192.0.2.2', ipv6=True) + tunnel = peer('wg-test', '198.51.100.1', '198.51.100.2') + in_namespace(wan, 'ip', 'route', 'add', '198.51.100.0/24', 'via', '192.0.2.1') + in_namespace(tunnel, 'ip', 'route', 'add', 'default', 'via', '198.51.100.1') + peer_server(wan, 18080) + peer_server(tunnel, 9090) + for family, address in ((socket.AF_INET, '0.0.0.0'), (socket.AF_INET6, '::')): + for port in (22, 80, 443, 2222, 9999): + listener(family, address, port, socket.SOCK_STREAM) + for port in (68, 546, 51820, 9999): + listener(family, address, port, socket.SOCK_DGRAM) + fixture = '''add table ip fixture_nat +add chain ip fixture_nat prerouting { type nat hook prerouting priority -100; } +add rule ip fixture_nat prerouting iifname "wan-test" tcp dport 9998 dnat to 198.51.100.2:9090 +''' + subprocess.run(['nft', '-f', '-'], input=fixture, text=True, check=True) + command('nft', '-f', str(path.with_suffix('.blocklist.nft'))) + check(wan, '192.0.2.1', 9998, True) + command('nft', '--check', '-f', str(path)) + command('nft', '-f', str(path)) + command('nft', '-f', str(path)) + for address in ('192.0.2.1', '2001:db8::1'): + for port in (80, 443, 2222): + check(wan, address, port, True) + for port in (22, 9999): + check(wan, address, port, False) + check(wan, address, 51820, True, 'udp') + check(wan, address, 9999, False, 'udp') + check(wan, '192.0.2.1', 68, True, 'udp', '67') + check(wan, '2001:db8::1', 546, True, 'udp', '547') + check(wan, '192.0.2.1', 9998, False) + check(tunnel, '192.0.2.2', 18080, True) + with socket.create_connection(('127.0.0.1', 9999), timeout=1) as connection: + assert connection.recv(16) == b'ok' + subprocess.run(['nft', 'add', 'element', 'inet', 'blocklist', 'banned', '{ 192.0.2.2 timeout 1h }'], check=True) + command('nft', '-f', str(path.with_suffix('.blocklist.nft'))) + command('nft', '-f', str(path)) + check(wan, '192.0.2.1', 2222, False) + command('nft', 'list', 'table', 'ip', 'fixture_nat') + print('PASS: IPv4/IPv6 ingress, custom SSH, DHCP, loopback, tunnel/return forwarding, published-port denial, existing bans/NAT and repeatable loading.') + finally: + for child in reversed(children): + child.terminate() + child.wait(timeout=5) + + +if __name__ == '__main__': + if sys.argv[1] == 'render': + render(Path(sys.argv[2])) + else: + verify(Path(sys.argv[1]))