feat: per-peer access enforcement, live peer status, auto IP assignment

Server-side access control:
- firewall_manager.py: per-peer iptables FORWARD rules in WireGuard container;
  virtual IPs on Caddy (172.20.0.21-24) for per-service DROP/ACCEPT targeting
- CoreDNS Corefile regenerated with ACL blocks for blocked services per peer
- POST /api/wireguard/apply-enforcement re-applies rules after WireGuard restart;
  wg0.conf PostUp calls it via curl so rules restore automatically on container start

WireGuard fixes:
- _syncconf uses `wg set peer` instead of `wg syncconf` to avoid resetting ListenPort
- add_peer validates AllowedIPs must be /32 — rejects full/split tunnel CIDRs that
  would route internet or LAN traffic to that peer
- _config_file() checks for linuxserver wg_confs/ subdirectory first

UI:
- Peers page fetches /api/wireguard/peers/statuses for live handshake data;
  status badge now shows real Online/Offline + seconds since last handshake
- IP field removed from Add Peer form (auto-assigned from 10.0.0.0/24)

Tests (246 pass):
- test_firewall_manager.py: 22 tests for ACL generation, iptables rule correctness,
  comment tagging, clear_peer_rules filter logic
- test_peer_wg_integration.py: 10 tests for /32 enforcement, IP auto-assignment,
  syncconf called on add/remove
- test_wireguard_manager.py: updated to reflect correct IPs and /32 requirement

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-21 01:01:07 -04:00
parent 8e41568964
commit 53c7661812
13 changed files with 1457 additions and 378 deletions
+26 -26
View File
@@ -16,7 +16,6 @@ const SERVICES = [
const emptyForm = () => ({
name: '',
ip: '',
description: '',
public_key: '',
persistent_keepalive: 25,
@@ -78,14 +77,20 @@ function Peers() {
const fetchPeers = async () => {
try {
const [regResp, wgResp] = await Promise.all([peerAPI.getPeers(), wireguardAPI.getPeers()]);
const [regResp, statusResp] = await Promise.all([
peerAPI.getPeers(),
wireguardAPI.getPeerStatuses().catch(() => ({ data: {} })),
]);
const regPeers = regResp.data || [];
const wgMap = {};
(wgResp.data || []).forEach(p => { wgMap[p.name] = p; });
const statusMap = statusResp.data || {};
const merged = regPeers.map(p => ({
...p,
name: p.peer || p.name,
wg: wgMap[p.peer || p.name] || {},
online: statusMap[p.public_key]?.online ?? false,
last_handshake: statusMap[p.public_key]?.last_handshake ?? null,
last_handshake_seconds_ago: statusMap[p.public_key]?.last_handshake_seconds_ago ?? null,
transfer_rx: statusMap[p.public_key]?.transfer_rx ?? 0,
transfer_tx: statusMap[p.public_key]?.transfer_tx ?? 0,
}));
setPeers(merged);
} catch (err) {
@@ -135,8 +140,6 @@ PersistentKeepalive = ${peer.persistent_keepalive || 25}`;
const errs = {};
if (!data.name.trim()) errs.name = 'Name is required';
if (!/^[a-zA-Z0-9_-]+$/.test(data.name)) errs.name = 'Only letters, numbers, - and _ allowed';
if (!data.ip.trim()) errs.ip = 'IP address is required';
if (!/^\d{1,3}(\.\d{1,3}){3}(\/\d+)?$/.test(data.ip.trim())) errs.ip = 'Invalid IP address';
return errs;
};
@@ -167,11 +170,14 @@ PersistentKeepalive = ${peer.persistent_keepalive || 25}`;
service_access: formData.service_access,
peer_access: formData.peer_access,
};
await peerAPI.addPeer(peerData);
const addResult = await peerAPI.addPeer(peerData);
const assignedIp = addResult.data?.ip;
// Server-side AllowedIPs = peer's VPN IP only (/32).
// Full/split tunnel is a CLIENT-side setting (AllowedIPs in the client config).
await wireguardAPI.addPeer({
name: formData.name,
public_key: publicKey,
allowed_ips: formData.internet_access ? FULL_TUNNEL_IPS : SPLIT_TUNNEL_IPS,
allowed_ips: assignedIp ? `${assignedIp}/32` : `${peerData.ip}/32`,
persistent_keepalive: formData.persistent_keepalive,
});
@@ -201,7 +207,6 @@ PersistentKeepalive = ${peer.persistent_keepalive || 25}`;
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
ip: formData.ip,
description: formData.description,
internet_access: formData.internet_access,
service_access: formData.service_access,
@@ -449,7 +454,12 @@ PersistentKeepalive = ${peer.persistent_keepalive || 25}`;
</div>
</td>
<td className="px-6 py-4">
<span className="status-indicator status-online">Online</span>
<span className={`status-indicator ${peer.online ? 'status-online' : 'status-offline'}`}>
{peer.online ? 'Online' : 'Offline'}
</span>
{peer.last_handshake_seconds_ago != null && (
<div className="text-xs text-gray-400 mt-0.5">{peer.last_handshake_seconds_ago}s ago</div>
)}
</td>
<td className="px-6 py-4">
<div className="flex space-x-2">
@@ -493,18 +503,11 @@ PersistentKeepalive = ${peer.persistent_keepalive || 25}`;
{errors.name && <p className="text-xs text-red-600 mt-1">{errors.name}</p>}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">VPN IP *</label>
<input value={formData.ip}
onChange={e => { setFormData(f => ({ ...f, ip: e.target.value })); setErrors(e2 => ({ ...e2, ip: undefined })); }}
className={`input ${errors.ip ? 'border-red-500' : ''}`} placeholder="10.0.0.3" />
{errors.ip && <p className="text-xs text-red-600 mt-1">{errors.ip}</p>}
<label className="block text-sm font-medium text-gray-700 mb-1">Description</label>
<input value={formData.description} onChange={e => setFormData(f => ({ ...f, description: e.target.value }))}
className="input" placeholder="My laptop" />
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Description</label>
<input value={formData.description} onChange={e => setFormData(f => ({ ...f, description: e.target.value }))}
className="input" placeholder="My laptop" />
</div>
<AccessForm data={formData} onChange={updates => setFormData(f => ({ ...f, ...updates }))} />
@@ -587,11 +590,8 @@ PersistentKeepalive = ${peer.persistent_keepalive || 25}`;
<input value={formData.name} className="input bg-gray-50" disabled />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">VPN IP *</label>
<input value={formData.ip}
onChange={e => { setFormData(f => ({ ...f, ip: e.target.value })); setErrors(e2 => ({ ...e2, ip: undefined })); }}
className={`input ${errors.ip ? 'border-red-500' : ''}`} />
{errors.ip && <p className="text-xs text-red-600 mt-1">{errors.ip}</p>}
<label className="block text-sm font-medium text-gray-700 mb-1">VPN IP</label>
<input value={selectedPeer?.ip || ''} className="input bg-gray-50 font-mono" disabled />
</div>
</div>
<div>