Files
pic/tests/e2e/api/test_peer_access_update.py
T
roof c521fab1cb fix: merge CoreDNS ACL per-service and add reload plugin; add peer/cell e2e tests
- _build_acl_block: put all blocked IPs for a service in ONE acl block instead
  of one block per peer — the first block's allow-all was silently granting
  access to every peer after the first blocked one (first-match semantics)
- generate_corefile: add 'reload' plugin so SIGUSR1 triggers Corefile reload
  in newer CoreDNS builds (without it the signal was a no-op)
- tests/test_firewall_manager.py: new tests for single merged ACL block and
  the reload directive
- tests/e2e/api/test_peer_access_update.py: e2e tests for service_access,
  internet_access, and peer_access updates persisting live to iptables/CoreDNS
- tests/e2e/api/test_cell_to_cell.py: e2e tests for cell-to-cell connection
  management, permissions API, and cross-cell service access restrictions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 04:57:37 -04:00

242 lines
9.9 KiB
Python

"""
Peer access update E2E tests.
Verifies that editing a peer's service_access, internet_access, and peer_access
settings via PUT /api/peers/<name> is persisted and reflected in the live
iptables and CoreDNS state immediately after the call returns.
Run against a live cell:
PIC_HOST=localhost pytest tests/e2e/api/test_peer_access_update.py -v
"""
import time
import subprocess
import pytest
from helpers.api_client import PicAPIClient
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _get_peer(admin_client, name: str) -> dict:
r = admin_client.get(f'/api/peers/{name}')
assert r.status_code == 200, f'GET /api/peers/{name} failed: {r.status_code} {r.text}'
return r.json()
def _update_peer(admin_client, name: str, **fields) -> dict:
r = admin_client.put(f'/api/peers/{name}', json=fields)
assert r.status_code == 200, f'PUT /api/peers/{name} failed: {r.status_code} {r.text}'
return r.json()
def _wg_forward_rules(admin_client) -> str:
"""Return raw iptables FORWARD rules from inside the WireGuard container."""
r = admin_client.post('/api/debug/iptables-forward', json={})
if r.status_code == 200:
return r.text
# Fallback: read directly via docker exec (only works on the same host)
try:
result = subprocess.run(
['docker', 'exec', 'cell-wireguard', 'iptables', '-L', 'FORWARD', '-n', '--line-numbers'],
capture_output=True, text=True, timeout=5
)
return result.stdout
except Exception:
return ''
def _corefile_content(admin_client) -> str:
"""Return the current Corefile content via the API or direct read."""
r = admin_client.get('/api/network/dns/corefile')
if r.status_code == 200:
return r.text
# Fallback: read from the mapped config path
try:
with open('/home/roof/pic/config/dns/Corefile') as f:
return f.read()
except Exception:
return ''
# ---------------------------------------------------------------------------
# service_access update tests
# ---------------------------------------------------------------------------
class TestServiceAccessUpdate:
def test_restrict_all_services_creates_drop_rule(self, make_peer, admin_client):
"""Setting service_access=[] creates a DROP rule to Caddy for the peer."""
peer = make_peer('e2etest-svc-drop')
peer_ip = peer['ip']
_update_peer(admin_client, peer['name'],
internet_access=True,
service_access=[],
peer_access=True)
rules = _wg_forward_rules(admin_client)
assert rules, 'Could not read iptables rules'
# There should be a DROP rule for this peer IP targeting Caddy port 80
assert 'DROP' in rules and peer_ip.replace('.', '.') in rules, (
f'Expected DROP rule for {peer_ip} after service_access=[], '
f'but rules show:\n{rules}'
)
def test_allow_some_services_creates_accept_rule(self, make_peer, admin_client):
"""Setting service_access=['calendar'] keeps ACCEPT to Caddy; ACL blocks others."""
peer = make_peer('e2etest-svc-partial', service_access=[])
peer_ip = peer['ip']
# Start with no services, then grant calendar only
_update_peer(admin_client, peer['name'],
internet_access=True,
service_access=['calendar'],
peer_access=True)
rules = _wg_forward_rules(admin_client)
assert rules, 'Could not read iptables rules'
assert 'ACCEPT' in rules, (
f'Expected ACCEPT rule for {peer_ip} after service_access=[calendar], '
f'got:\n{rules}'
)
def test_service_access_reflected_in_peer_registry(self, make_peer, admin_client):
"""Updated service_access is persisted in the peer registry."""
peer = make_peer('e2etest-svc-persist',
service_access=['calendar', 'files', 'mail', 'webdav'])
_update_peer(admin_client, peer['name'],
internet_access=True,
service_access=['calendar'],
peer_access=True)
stored = _get_peer(admin_client, peer['name'])
assert stored.get('service_access') == ['calendar'], (
f"Expected service_access=['calendar'] in registry, got: {stored.get('service_access')}"
)
def test_blocked_service_appears_in_corefile_acl(self, make_peer, admin_client):
"""Blocked services for a peer appear in the CoreDNS Corefile ACL."""
peer = make_peer('e2etest-svc-acl', service_access=['calendar', 'files', 'mail', 'webdav'])
peer_ip = peer['ip']
# Block files and webdav
_update_peer(admin_client, peer['name'],
internet_access=True,
service_access=['calendar', 'mail'],
peer_access=True)
# Small delay for CoreDNS reload to process SIGUSR1
time.sleep(1)
corefile = _corefile_content(admin_client)
assert corefile, 'Could not read Corefile'
assert f'block net {peer_ip}/32' in corefile, (
f'Expected block rule for {peer_ip} in Corefile after removing files/webdav. '
f'Corefile:\n{corefile}'
)
def test_fully_allowed_peer_has_no_acl_block(self, make_peer, admin_client):
"""Peer with all services allowed has no ACL block entry in Corefile."""
peer = make_peer('e2etest-svc-full', service_access=['calendar'])
peer_ip = peer['ip']
# Grant all services
_update_peer(admin_client, peer['name'],
internet_access=True,
service_access=['calendar', 'files', 'mail', 'webdav'],
peer_access=True)
time.sleep(1)
corefile = _corefile_content(admin_client)
assert f'block net {peer_ip}/32' not in corefile, (
f'No ACL block expected for fully-allowed peer {peer_ip}, but found one. '
f'Corefile:\n{corefile}'
)
def test_multiple_peers_blocked_from_same_service_in_single_acl_block(
self, make_peer, admin_client
):
"""Two peers blocked from the same service share one acl block (not separate blocks)."""
peer_a = make_peer('e2etest-svc-multi-a',
service_access=['calendar', 'files', 'mail', 'webdav'])
peer_b = make_peer('e2etest-svc-multi-b',
service_access=['calendar', 'files', 'mail', 'webdav'])
# Block both from files
_update_peer(admin_client, peer_a['name'],
service_access=['calendar', 'mail', 'webdav'])
_update_peer(admin_client, peer_b['name'],
service_access=['calendar', 'mail', 'webdav'])
time.sleep(1)
corefile = _corefile_content(admin_client)
assert corefile, 'Could not read Corefile'
# Count how many acl blocks exist for the files service
domain = corefile.split('forward . ')[0].strip().rstrip('{').strip()
# Find acl blocks for files
acl_files_count = corefile.count('acl files.')
assert acl_files_count == 1, (
f'Expected exactly 1 acl block for files (both peers merged), '
f'got {acl_files_count}. Corefile:\n{corefile}'
)
assert peer_a['ip'] in corefile, f"peer_a IP {peer_a['ip']} not in Corefile ACL"
assert peer_b['ip'] in corefile, f"peer_b IP {peer_b['ip']} not in Corefile ACL"
# ---------------------------------------------------------------------------
# internet_access update tests
# ---------------------------------------------------------------------------
class TestInternetAccessUpdate:
def test_disable_internet_access_sets_config_reinstall_flag(self, make_peer, admin_client):
"""Changing internet_access sets config_needs_reinstall on the peer."""
peer = make_peer('e2etest-inet-flag')
r = _update_peer(admin_client, peer['name'],
internet_access=False,
service_access=['calendar', 'files', 'mail', 'webdav'])
assert r.get('config_changed') is True, (
f'Expected config_changed=True when internet_access changes, got: {r}'
)
stored = _get_peer(admin_client, peer['name'])
assert stored.get('internet_access') is False
assert stored.get('config_needs_reinstall') is True
def test_internet_access_persisted(self, make_peer, admin_client):
peer = make_peer('e2etest-inet-persist')
_update_peer(admin_client, peer['name'], internet_access=False,
service_access=['calendar', 'files', 'mail', 'webdav'])
stored = _get_peer(admin_client, peer['name'])
assert stored.get('internet_access') is False
# ---------------------------------------------------------------------------
# peer_access update tests
# ---------------------------------------------------------------------------
class TestPeerAccessUpdate:
def test_peer_access_persisted(self, make_peer, admin_client):
peer = make_peer('e2etest-peer-access')
_update_peer(admin_client, peer['name'],
internet_access=True,
service_access=['calendar', 'files', 'mail', 'webdav'],
peer_access=False)
stored = _get_peer(admin_client, peer['name'])
assert stored.get('peer_access') is False
def test_re_enabling_peer_access_persisted(self, make_peer, admin_client):
peer = make_peer('e2etest-peer-reenable')
_update_peer(admin_client, peer['name'], peer_access=False,
service_access=['calendar', 'files', 'mail', 'webdav'])
_update_peer(admin_client, peer['name'], peer_access=True,
service_access=['calendar', 'files', 'mail', 'webdav'])
stored = _get_peer(admin_client, peer['name'])
assert stored.get('peer_access') is True