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:
+26
-26
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user