Files
pic/webui/src/pages/Routing.jsx
T
roof 1a5da3a207 docs(ui): clarify rule source separation on routing tabs
Added info banners on Firewall, Peer Routes, and Live iptables tabs
explaining that stored rules (NAT/Firewall/Peer Routes forms) and live
rules (pic-peer-* from Peers page, PostUp from wg0.conf) are separate
by design — Live iptables shows everything, each form tab shows only
what it manages.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 01:32:24 -04:00

760 lines
35 KiB
React

import { useState, useEffect } from 'react';
import { Plus, Trash2, Wifi, Shield, Activity, Settings, Terminal, ArrowRightLeft, RefreshCw, Info } from 'lucide-react';
import { routingAPI } from '../services/api';
const EMPTY_NAT = { source_network: '', target_interface: 'eth0', masquerade: true, nat_type: 'MASQUERADE', protocol: 'ALL', external_port: '', internal_ip: '', internal_port: '' };
const EMPTY_PF = { name: '', protocol: 'TCP', external_port: '', internal_ip: '', internal_port: '' };
const EMPTY_PEER = { peer_name: '', peer_ip: '', allowed_networks: '', route_type: 'lan' };
const EMPTY_FW = { rule_type: 'FORWARD', source: '', destination: '', action: 'ACCEPT', protocol: 'ALL', port_range: '' };
function Badge({ ok, children }) {
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${ok ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>
{children}
</span>
);
}
function SectionHeader({ title, action }) {
return (
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium text-gray-900">{title}</h3>
{action}
</div>
);
}
function EmptyRow({ cols, message }) {
return (
<tr>
<td colSpan={cols} className="px-4 py-8 text-center text-gray-400 text-sm">{message}</td>
</tr>
);
}
function Routing() {
const [activeTab, setActiveTab] = useState('overview');
const [routingStatus, setRoutingStatus] = useState(null);
const [statusLoading, setStatusLoading] = useState(true);
// NAT
const [natRules, setNatRules] = useState([]);
const [natLoading, setNatLoading] = useState(false);
const [showNatForm, setShowNatForm] = useState(false);
const [newNat, setNewNat] = useState(EMPTY_NAT);
const [natError, setNatError] = useState(null);
// Port Forwarding (DNAT subset)
const [pfRules, setPfRules] = useState([]);
const [showPfForm, setShowPfForm] = useState(false);
const [newPf, setNewPf] = useState(EMPTY_PF);
const [pfError, setPfError] = useState(null);
const [pfSubmitting, setPfSubmitting] = useState(false);
// Peer Routes
const [peerRoutes, setPeerRoutes] = useState([]);
const [peersLoading, setPeersLoading] = useState(false);
const [showPeerForm, setShowPeerForm] = useState(false);
const [newPeerRoute, setNewPeerRoute] = useState(EMPTY_PEER);
const [peersError, setPeersError] = useState(null);
// Firewall
const [firewallRules, setFirewallRules] = useState([]);
const [fwLoading, setFwLoading] = useState(false);
const [showFwForm, setShowFwForm] = useState(false);
const [newFwRule, setNewFwRule] = useState(EMPTY_FW);
const [fwError, setFwError] = useState(null);
// Live iptables
const [liveIptables, setLiveIptables] = useState(null);
const [iptLoading, setIptLoading] = useState(false);
// Diagnostics
const [diagTarget, setDiagTarget] = useState('');
const [diagResult, setDiagResult] = useState(null);
const [diagRunning, setDiagRunning] = useState(false);
useEffect(() => {
fetchStatus();
fetchNatRules();
fetchPeerRoutes();
fetchFirewallRules();
}, []);
// ── Fetch helpers ──────────────────────────────────────────────────────────
const fetchStatus = async () => {
try {
const r = await routingAPI.getStatus();
setRoutingStatus(r.data);
} catch {}
finally { setStatusLoading(false); }
};
const fetchNatRules = async () => {
setNatLoading(true);
try {
const r = await routingAPI.getNatRules();
const all = r.data.nat_rules || [];
setNatRules(all.filter(x => x.nat_type !== 'DNAT'));
setPfRules(all.filter(x => x.nat_type === 'DNAT'));
} catch { setNatError('Failed to load NAT rules'); }
finally { setNatLoading(false); }
};
const fetchPeerRoutes = async () => {
setPeersLoading(true);
try { const r = await routingAPI.getPeerRoutes(); setPeerRoutes(r.data.peer_routes || []); }
catch { setPeersError('Failed to load peer routes'); }
finally { setPeersLoading(false); }
};
const fetchFirewallRules = async () => {
setFwLoading(true);
try { const r = await routingAPI.getFirewallRules(); setFirewallRules(r.data.firewall_rules || []); }
catch { setFwError('Failed to load firewall rules'); }
finally { setFwLoading(false); }
};
const fetchLiveIptables = async () => {
setIptLoading(true);
try { const r = await routingAPI.getLiveIptables(); setLiveIptables(r.data); }
catch { setLiveIptables({ error: 'Failed to load' }); }
finally { setIptLoading(false); }
};
// ── NAT handlers ──────────────────────────────────────────────────────────
const handleAddNat = async (e) => {
e.preventDefault();
setNatError(null);
try {
await routingAPI.addNatRule(newNat);
setShowNatForm(false); setNewNat(EMPTY_NAT);
fetchNatRules(); fetchStatus();
} catch { setNatError('Failed to add NAT rule'); }
};
const handleDeleteNat = async (id) => {
if (!window.confirm('Delete this NAT rule?')) return;
try { await routingAPI.deleteNatRule(id); fetchNatRules(); fetchStatus(); }
catch { setNatError('Failed to delete NAT rule'); }
};
// ── Port Forwarding handlers ───────────────────────────────────────────────
const handleAddPf = async (e) => {
e.preventDefault();
setPfError(null); setPfSubmitting(true);
try {
await routingAPI.addNatRule({
source_network: '0.0.0.0/0',
target_interface: 'eth0',
masquerade: false,
nat_type: 'DNAT',
protocol: newPf.protocol,
external_port: newPf.external_port,
internal_ip: newPf.internal_ip,
internal_port: newPf.internal_port,
});
setShowPfForm(false); setNewPf(EMPTY_PF);
fetchNatRules();
} catch { setPfError('Failed to add port forwarding rule'); }
finally { setPfSubmitting(false); }
};
const handleDeletePf = async (id) => {
if (!window.confirm('Delete this port forwarding rule?')) return;
try { await routingAPI.deleteNatRule(id); fetchNatRules(); }
catch { setPfError('Failed to delete rule'); }
};
// ── Peer Route handlers ───────────────────────────────────────────────────
const handleAddPeer = async (e) => {
e.preventDefault();
setPeersError(null);
try {
await routingAPI.addPeerRoute({
...newPeerRoute,
allowed_networks: newPeerRoute.allowed_networks.split(',').map(s => s.trim()).filter(Boolean),
});
setShowPeerForm(false); setNewPeerRoute(EMPTY_PEER);
fetchPeerRoutes(); fetchStatus();
} catch { setPeersError('Failed to add peer route'); }
};
const handleDeletePeer = async (name) => {
if (!window.confirm('Delete this peer route?')) return;
try { await routingAPI.deletePeerRoute(name); fetchPeerRoutes(); fetchStatus(); }
catch { setPeersError('Failed to delete peer route'); }
};
// ── Firewall handlers ─────────────────────────────────────────────────────
const handleAddFw = async (e) => {
e.preventDefault();
setFwError(null);
try {
await routingAPI.addFirewallRule(newFwRule);
setShowFwForm(false); setNewFwRule(EMPTY_FW);
fetchFirewallRules(); fetchStatus();
} catch { setFwError('Failed to add firewall rule'); }
};
const handleDeleteFw = async (id) => {
if (!window.confirm('Delete this firewall rule?')) return;
try { await routingAPI.deleteFirewallRule(id); fetchFirewallRules(); fetchStatus(); }
catch { setFwError('Failed to delete firewall rule'); }
};
// ── Diagnostics ───────────────────────────────────────────────────────────
const handleRunDiag = async (e) => {
e.preventDefault();
if (!diagTarget.trim()) return;
setDiagRunning(true); setDiagResult(null);
try {
const r = await routingAPI.testConnectivity({ target_ip: diagTarget.trim() });
setDiagResult(r.data);
} catch (err) {
setDiagResult({ error: err.message });
} finally { setDiagRunning(false); }
};
// ── Summary counts ────────────────────────────────────────────────────────
const rs = routingStatus?.routing_status || routingStatus || {};
const routingTable = rs.routing_table || [];
// ── Tabs ──────────────────────────────────────────────────────────────────
const tabs = [
{ id: 'overview', name: 'Overview', icon: Activity },
{ id: 'portfwd', name: 'Port Forwarding', icon: ArrowRightLeft },
{ id: 'nat', name: 'NAT Rules', icon: Shield },
{ id: 'peers', name: 'Peer Routes', icon: Wifi },
{ id: 'firewall', name: 'Firewall', icon: Settings },
{ id: 'live', name: 'Live iptables', icon: Terminal },
{ id: 'diagnostics', name: 'Diagnostics', icon: Activity },
];
return (
<div>
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900">Routing & Gateway</h1>
<p className="mt-2 text-gray-600">Manage VPN gateway, NAT, port forwarding, and firewall rules</p>
</div>
{/* Summary strip */}
{!statusLoading && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
{[
{ label: 'NAT Rules', value: rs.nat_rules_count ?? natRules.length },
{ label: 'Port Forwards', value: pfRules.length },
{ label: 'Peer Routes', value: rs.peer_routes_count ?? peerRoutes.length },
{ label: 'Firewall Rules', value: rs.firewall_rules_count ?? firewallRules.length },
].map(({ label, value }) => (
<div key={label} className="card flex items-center">
<Shield className="h-7 w-7 text-primary-400 mr-3" />
<div>
<p className="text-xs text-gray-500">{label}</p>
<p className="text-xl font-semibold text-gray-900">{value}</p>
</div>
</div>
))}
</div>
)}
{/* Tabs */}
<div className="mb-6 border-b border-gray-200">
<nav className="-mb-px flex space-x-6 overflow-x-auto">
{tabs.map(tab => (
<button
key={tab.id}
onClick={() => {
setActiveTab(tab.id);
if (tab.id === 'live' && !liveIptables) fetchLiveIptables();
}}
className={`flex items-center whitespace-nowrap px-1 py-3 border-b-2 font-medium text-sm ${
activeTab === tab.id
? 'border-primary-500 text-primary-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
<tab.icon className="h-4 w-4 mr-1.5" />
{tab.name}
</button>
))}
</nav>
</div>
{/* ── Overview ── */}
{activeTab === 'overview' && (
<div className="card space-y-6">
<SectionHeader title="System Routing Table" action={
<button className="btn btn-secondary text-sm flex items-center" onClick={fetchStatus}>
<RefreshCw className="h-4 w-4 mr-1" /> Refresh
</button>
} />
{statusLoading ? (
<div className="flex justify-center py-8"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600" /></div>
) : routingTable.length > 0 ? (
<table className="min-w-full text-sm">
<thead>
<tr className="text-left text-gray-500 text-xs uppercase border-b">
<th className="pb-2 pr-6">Destination</th>
<th className="pb-2 pr-6">Gateway</th>
<th className="pb-2 pr-6">Interface</th>
<th className="pb-2">Flags</th>
</tr>
</thead>
<tbody>
{routingTable.map((r, i) => {
const p = r.parsed || {};
return (
<tr key={i} className="border-b last:border-0 hover:bg-gray-50">
<td className="py-2 pr-6 font-mono text-gray-900">{p.destination || r.route}</td>
<td className="py-2 pr-6 font-mono text-gray-500">{p.via || '—'}</td>
<td className="py-2 pr-6 text-gray-600">{p.dev || '—'}</td>
<td className="py-2 text-gray-400 text-xs">{p.metric ? `metric ${p.metric}` : ''}</td>
</tr>
);
})}
</tbody>
</table>
) : (
<p className="text-gray-400 text-sm">No routing table entries available.</p>
)}
</div>
)}
{/* ── Port Forwarding ── */}
{activeTab === 'portfwd' && (
<div className="card">
<SectionHeader title="Port Forwarding (DNAT)" action={
<button className="btn btn-primary flex items-center" onClick={() => setShowPfForm(v => !v)}>
<Plus className="h-4 w-4 mr-1" />{showPfForm ? 'Cancel' : 'Add Rule'}
</button>
} />
<p className="text-sm text-gray-500 mb-4">Forward inbound traffic on a public port to an internal host. Rules here are stored and re-applied on restart.</p>
{showPfForm && (
<form onSubmit={handleAddPf} className="mb-6 p-4 bg-gray-50 rounded-lg space-y-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<label className="block text-xs text-gray-500 mb-1">Protocol</label>
<select className="input w-full" value={newPf.protocol} onChange={e => setNewPf(p => ({...p, protocol: e.target.value}))}>
<option>TCP</option><option>UDP</option>
</select>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">External Port</label>
<input className="input w-full" placeholder="e.g. 8080" value={newPf.external_port}
onChange={e => setNewPf(p => ({...p, external_port: e.target.value}))} required />
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">Internal IP</label>
<input className="input w-full" placeholder="e.g. 172.20.0.5" value={newPf.internal_ip}
onChange={e => setNewPf(p => ({...p, internal_ip: e.target.value}))} required />
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">Internal Port</label>
<input className="input w-full" placeholder="e.g. 80" value={newPf.internal_port}
onChange={e => setNewPf(p => ({...p, internal_port: e.target.value}))} required />
</div>
</div>
{pfError && <p className="text-red-500 text-sm">{pfError}</p>}
<div className="flex justify-end">
<button type="submit" className="btn btn-primary" disabled={pfSubmitting}>
{pfSubmitting ? 'Adding…' : 'Add Forward Rule'}
</button>
</div>
</form>
)}
<table className="min-w-full text-sm">
<thead>
<tr className="text-left text-gray-500 text-xs uppercase border-b">
<th className="pb-2 pr-4">Protocol</th>
<th className="pb-2 pr-4">External Port</th>
<th className="pb-2 pr-4"> Internal IP</th>
<th className="pb-2 pr-4">Internal Port</th>
<th className="pb-2" />
</tr>
</thead>
<tbody>
{pfRules.length === 0 ? (
<EmptyRow cols={5} message="No port forwarding rules configured." />
) : pfRules.map((r, i) => (
<tr key={r.id || i} className="border-b last:border-0 hover:bg-gray-50">
<td className="py-2 pr-4">{r.protocol || 'ALL'}</td>
<td className="py-2 pr-4 font-mono">{r.external_port || '—'}</td>
<td className="py-2 pr-4 font-mono">{r.internal_ip || '—'}</td>
<td className="py-2 pr-4 font-mono">{r.internal_port || '—'}</td>
<td className="py-2 text-right">
<button className="text-red-500 hover:text-red-700" onClick={() => handleDeletePf(r.id || i)}>
<Trash2 className="h-4 w-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* ── NAT Rules ── */}
{activeTab === 'nat' && (
<div className="card">
<SectionHeader title="NAT / Masquerade Rules" action={
<button className="btn btn-primary flex items-center" onClick={() => setShowNatForm(v => !v)}>
<Plus className="h-4 w-4 mr-1" />{showNatForm ? 'Cancel' : 'Add Rule'}
</button>
} />
{showNatForm && (
<form onSubmit={handleAddNat} className="mb-6 p-4 bg-gray-50 rounded-lg space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-xs text-gray-500 mb-1">Source Network</label>
<input className="input w-full" placeholder="e.g. 10.0.0.0/24" value={newNat.source_network}
onChange={e => setNewNat(p => ({...p, source_network: e.target.value}))} required />
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">Outbound Interface</label>
<input className="input w-full" placeholder="e.g. eth0" value={newNat.target_interface}
onChange={e => setNewNat(p => ({...p, target_interface: e.target.value}))} required />
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">Type</label>
<select className="input w-full" value={newNat.nat_type}
onChange={e => setNewNat(p => ({...p, nat_type: e.target.value}))}>
<option value="MASQUERADE">MASQUERADE</option>
<option value="SNAT">SNAT</option>
</select>
</div>
</div>
{natError && <p className="text-red-500 text-sm">{natError}</p>}
<div className="flex justify-end">
<button type="submit" className="btn btn-primary">Add Rule</button>
</div>
</form>
)}
{natLoading ? <div className="py-8 text-center text-gray-400">Loading</div> : (
<table className="min-w-full text-sm">
<thead>
<tr className="text-left text-gray-500 text-xs uppercase border-b">
<th className="pb-2 pr-4">Source Network</th>
<th className="pb-2 pr-4">Interface</th>
<th className="pb-2 pr-4">Type</th>
<th className="pb-2" />
</tr>
</thead>
<tbody>
{natRules.length === 0 ? (
<EmptyRow cols={4} message="No NAT rules configured." />
) : natRules.map((r, i) => (
<tr key={r.id || i} className="border-b last:border-0 hover:bg-gray-50">
<td className="py-2 pr-4 font-mono">{r.source_network}</td>
<td className="py-2 pr-4">{r.target_interface}</td>
<td className="py-2 pr-4"><Badge ok>{r.nat_type || 'MASQUERADE'}</Badge></td>
<td className="py-2 text-right">
<button className="text-red-500 hover:text-red-700" onClick={() => handleDeleteNat(r.id || i)}>
<Trash2 className="h-4 w-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
{/* ── Peer Routes ── */}
{activeTab === 'peers' && (
<div className="card">
<SectionHeader title="Peer Routes" action={
<button className="btn btn-primary flex items-center" onClick={() => setShowPeerForm(v => !v)}>
<Plus className="h-4 w-4 mr-1" />{showPeerForm ? 'Cancel' : 'Add Route'}
</button>
} />
<div className="flex gap-2 p-3 mb-4 bg-blue-50 rounded-lg text-sm text-blue-800">
<Info className="h-4 w-4 mt-0.5 shrink-0" />
<span>OS-level <code className="font-mono">ip route</code> entries stored here. WireGuard per-peer access control (which traffic each peer is allowed) is on the <a href="/peers" className="underline font-medium">Peers page</a>, not here.</span>
</div>
{showPeerForm && (
<form onSubmit={handleAddPeer} className="mb-6 p-4 bg-gray-50 rounded-lg space-y-4">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label className="block text-xs text-gray-500 mb-1">Peer Name</label>
<input className="input w-full" placeholder="e.g. office" value={newPeerRoute.peer_name}
onChange={e => setNewPeerRoute(p => ({...p, peer_name: e.target.value}))} required />
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">Peer VPN IP</label>
<input className="input w-full" placeholder="e.g. 10.0.0.5" value={newPeerRoute.peer_ip}
onChange={e => setNewPeerRoute(p => ({...p, peer_ip: e.target.value}))} required />
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">Networks (comma-separated)</label>
<input className="input w-full" placeholder="e.g. 192.168.1.0/24" value={newPeerRoute.allowed_networks}
onChange={e => setNewPeerRoute(p => ({...p, allowed_networks: e.target.value}))} />
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">Type</label>
<select className="input w-full" value={newPeerRoute.route_type}
onChange={e => setNewPeerRoute(p => ({...p, route_type: e.target.value}))}>
<option value="lan">LAN</option>
<option value="exit">Exit</option>
<option value="split">Split</option>
</select>
</div>
</div>
{peersError && <p className="text-red-500 text-sm">{peersError}</p>}
<div className="flex justify-end">
<button type="submit" className="btn btn-primary">Add Route</button>
</div>
</form>
)}
{peersLoading ? <div className="py-8 text-center text-gray-400">Loading</div> : (
<table className="min-w-full text-sm">
<thead>
<tr className="text-left text-gray-500 text-xs uppercase border-b">
<th className="pb-2 pr-4">Peer</th>
<th className="pb-2 pr-4">VPN IP</th>
<th className="pb-2 pr-4">Networks</th>
<th className="pb-2 pr-4">Type</th>
<th className="pb-2" />
</tr>
</thead>
<tbody>
{peerRoutes.length === 0 ? (
<EmptyRow cols={5} message="No peer routes configured." />
) : peerRoutes.map((r, i) => (
<tr key={r.peer_name || i} className="border-b last:border-0 hover:bg-gray-50">
<td className="py-2 pr-4 font-medium">{r.peer_name}</td>
<td className="py-2 pr-4 font-mono">{r.peer_ip}</td>
<td className="py-2 pr-4 font-mono text-xs">
{Array.isArray(r.allowed_networks) ? r.allowed_networks.join(', ') : r.allowed_networks}
</td>
<td className="py-2 pr-4">{r.route_type}</td>
<td className="py-2 text-right">
<button className="text-red-500 hover:text-red-700" onClick={() => handleDeletePeer(r.peer_name)}>
<Trash2 className="h-4 w-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
{/* ── Firewall ── */}
{activeTab === 'firewall' && (
<div className="card">
<SectionHeader title="Custom Firewall Rules" action={
<button className="btn btn-primary flex items-center" onClick={() => setShowFwForm(v => !v)}>
<Plus className="h-4 w-4 mr-1" />{showFwForm ? 'Cancel' : 'Add Rule'}
</button>
} />
<div className="flex gap-2 p-3 mb-4 bg-blue-50 rounded-lg text-sm text-blue-800">
<Info className="h-4 w-4 mt-0.5 shrink-0" />
<span>
This tab only shows rules added here. The <strong>Live iptables</strong> tab shows all running rules including per-peer VPN rules (managed on the <a href="/peers" className="underline font-medium">Peers page</a>) and WireGuard PostUp rules. They are intentionally separate.
</span>
</div>
{showFwForm && (
<form onSubmit={handleAddFw} className="mb-6 p-4 bg-gray-50 rounded-lg space-y-4">
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
<div>
<label className="block text-xs text-gray-500 mb-1">Chain</label>
<select className="input w-full" value={newFwRule.rule_type}
onChange={e => setNewFwRule(p => ({...p, rule_type: e.target.value}))}>
<option value="INPUT">INPUT</option>
<option value="FORWARD">FORWARD</option>
<option value="OUTPUT">OUTPUT</option>
</select>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">Source CIDR</label>
<input className="input w-full" placeholder="e.g. 10.0.0.0/8" value={newFwRule.source}
onChange={e => setNewFwRule(p => ({...p, source: e.target.value}))} required />
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">Destination CIDR</label>
<input className="input w-full" placeholder="e.g. 0.0.0.0/0" value={newFwRule.destination}
onChange={e => setNewFwRule(p => ({...p, destination: e.target.value}))} required />
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">Protocol</label>
<select className="input w-full" value={newFwRule.protocol}
onChange={e => setNewFwRule(p => ({...p, protocol: e.target.value}))}>
<option value="ALL">ALL</option>
<option value="TCP">TCP</option>
<option value="UDP">UDP</option>
<option value="ICMP">ICMP</option>
</select>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">Port / Range</label>
<input className="input w-full" placeholder="e.g. 80 or 1000-2000" value={newFwRule.port_range}
onChange={e => setNewFwRule(p => ({...p, port_range: e.target.value}))} />
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">Action</label>
<select className="input w-full" value={newFwRule.action}
onChange={e => setNewFwRule(p => ({...p, action: e.target.value}))}>
<option value="ACCEPT">ACCEPT</option>
<option value="DROP">DROP</option>
<option value="REJECT">REJECT</option>
</select>
</div>
</div>
{fwError && <p className="text-red-500 text-sm">{fwError}</p>}
<div className="flex justify-end">
<button type="submit" className="btn btn-primary">Add Rule</button>
</div>
</form>
)}
{fwLoading ? <div className="py-8 text-center text-gray-400">Loading</div> : (
<table className="min-w-full text-sm">
<thead>
<tr className="text-left text-gray-500 text-xs uppercase border-b">
<th className="pb-2 pr-4">Chain</th>
<th className="pb-2 pr-4">Source</th>
<th className="pb-2 pr-4">Destination</th>
<th className="pb-2 pr-4">Proto</th>
<th className="pb-2 pr-4">Port</th>
<th className="pb-2 pr-4">Action</th>
<th className="pb-2" />
</tr>
</thead>
<tbody>
{firewallRules.length === 0 ? (
<EmptyRow cols={7} message="No custom firewall rules configured." />
) : firewallRules.map((r, i) => (
<tr key={r.id || i} className="border-b last:border-0 hover:bg-gray-50">
<td className="py-2 pr-4">{r.rule_type}</td>
<td className="py-2 pr-4 font-mono text-xs">{r.source}</td>
<td className="py-2 pr-4 font-mono text-xs">{r.destination}</td>
<td className="py-2 pr-4">{r.protocol || 'ALL'}</td>
<td className="py-2 pr-4 font-mono">{r.port_range || r.port || '—'}</td>
<td className="py-2 pr-4">
<Badge ok={r.action === 'ACCEPT'}>{r.action}</Badge>
</td>
<td className="py-2 text-right">
<button className="text-red-500 hover:text-red-700" onClick={() => handleDeleteFw(r.id)}>
<Trash2 className="h-4 w-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
{/* ── Live iptables ── */}
{activeTab === 'live' && (
<div className="card">
<SectionHeader title="Live iptables (WireGuard container)" action={
<button className="btn btn-secondary flex items-center" onClick={fetchLiveIptables} disabled={iptLoading}>
<RefreshCw className={`h-4 w-4 mr-1 ${iptLoading ? 'animate-spin' : ''}`} /> Refresh
</button>
} />
<div className="flex gap-2 p-3 mb-4 bg-blue-50 rounded-lg text-sm text-blue-800">
<Info className="h-4 w-4 mt-0.5 shrink-0" />
<span>
Read-only view of ALL rules running in cell-wireguard. Includes: <strong>pic-peer-*</strong> rules from the Peers page, <strong>MASQUERADE</strong> from wg0.conf PostUp, and any rules added via the forms above. Rules here cannot be edited directly.
</span>
</div>
{iptLoading ? (
<div className="flex justify-center py-8"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600" /></div>
) : liveIptables ? (
<div className="space-y-4">
{Object.entries(liveIptables).map(([table, text]) => (
<div key={table}>
<h4 className="text-sm font-semibold text-gray-700 mb-1 uppercase tracking-wide">{table} table</h4>
<pre className="bg-gray-900 text-green-400 text-xs p-4 rounded-lg overflow-x-auto whitespace-pre font-mono leading-5">
{text || '(empty)'}
</pre>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-gray-400">
<Terminal className="h-10 w-10 mx-auto mb-2 opacity-30" />
<p className="text-sm">Click Refresh to load live iptables rules.</p>
</div>
)}
</div>
)}
{/* ── Diagnostics ── */}
{activeTab === 'diagnostics' && (
<div className="card">
<SectionHeader title="Connectivity Test" />
<p className="text-sm text-gray-500 mb-6">Ping and traceroute a target from the server to verify routing.</p>
<form onSubmit={handleRunDiag} className="flex gap-3 mb-6">
<input
className="input flex-1"
placeholder="Target IP or hostname (e.g. 8.8.8.8)"
value={diagTarget}
onChange={e => setDiagTarget(e.target.value)}
required
/>
<button type="submit" className="btn btn-primary flex items-center" disabled={diagRunning}>
{diagRunning
? <><div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2" />Running</>
: <><Activity className="h-4 w-4 mr-1" />Run Test</>}
</button>
</form>
{diagResult && (
<div className="space-y-4">
{diagResult.error && (
<div className="p-4 bg-red-50 rounded-lg text-red-700 text-sm">{diagResult.error}</div>
)}
{diagResult.ping && (
<div>
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-medium text-gray-700">Ping</span>
<Badge ok={diagResult.ping.success}>{diagResult.ping.success ? 'reachable' : 'unreachable'}</Badge>
</div>
<pre className="bg-gray-900 text-green-400 text-xs p-3 rounded-lg overflow-x-auto font-mono leading-5">
{diagResult.ping.output || diagResult.ping.error || '(no output)'}
</pre>
</div>
)}
{diagResult.traceroute && (
<div>
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-medium text-gray-700">Traceroute</span>
</div>
<pre className="bg-gray-900 text-green-400 text-xs p-3 rounded-lg overflow-x-auto font-mono leading-5">
{diagResult.traceroute.output || diagResult.traceroute.error || '(no output)'}
</pre>
</div>
)}
</div>
)}
</div>
)}
</div>
);
}
export default Routing;