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
4 changes: 3 additions & 1 deletion .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions ansible/roles/vps-blocklist/handlers/main.yml
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions ansible/roles/vps-blocklist/tasks/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions ansible/roles/vps-firewall/handlers/main.yml
Original file line number Diff line number Diff line change
@@ -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
43 changes: 43 additions & 0 deletions ansible/roles/vps-firewall/tasks/main.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
- name: Install nftables
ansible.builtin.apt:
name: nftables
state: present
update_cache: true

- name: Create firewall configuration directory
ansible.builtin.file:
path: /etc/nftables.d
state: directory
mode: "0755"

- 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: Preserve WireGuard NAT and dynamic ban tables when loading nftables
ansible.builtin.lineinfile:
path: /etc/nftables.conf
regexp: "^flush ruleset$"
state: absent

- 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: Enable nftables
ansible.builtin.systemd:
name: nftables
enabled: true
state: started
when: not ansible_check_mode

- name: Apply pending VPS rules
ansible.builtin.meta: flush_handlers
16 changes: 16 additions & 0 deletions ansible/roles/vps-firewall/templates/vps-firewall.nft.j2
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions ansible/site.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
- network
- distribution
roles:
- { role: vps-firewall, tags: [vps-firewall] }
- { role: wireguard-server, tags: [wireguard-server] }
- { role: haproxy, tags: [haproxy] }
- { role: vps-blocklist, tags: [vps-blocklist] }
Expand Down
158 changes: 158 additions & 0 deletions tests/test_vps_firewall.py
Original file line number Diff line number Diff line change
@@ -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]))
Loading