1a5da3a207
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>
760 lines
35 KiB
React
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;
|