From 901094f60a42ba440fc1881bf18d4a80da044f25 Mon Sep 17 00:00:00 2001 From: Dmitrii Iurco Date: Tue, 21 Apr 2026 01:14:49 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20routing=20page=20=E2=80=94=20port=20for?= =?UTF-8?q?warding=20tab,=20live=20iptables,=20diagnostics,=20firewall=20d?= =?UTF-8?q?elete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - routing_manager.remove_firewall_rule(): remove stored rule + iptables -D - routing_manager.get_live_iptables(): dump filter/nat tables from cell-wireguard - DELETE /api/routing/firewall/ endpoint (was missing) - GET /api/routing/live-iptables endpoint Frontend Routing.jsx — 7 tabs: - Overview: proper routing table with destination/gateway/interface columns - Port Forwarding: clean DNAT form (protocol, ext port → internal IP:port) - NAT Rules: MASQUERADE/SNAT only, cleaner layout - Peer Routes: IP route entries through VPN peers - Firewall: custom rules with working delete button - Live iptables: read-only terminal view of actual running rules in cell-wireguard - Diagnostics: ping + traceroute test from server with output display Co-Authored-By: Claude Sonnet 4.6 --- api/app.py | 15 + api/routing_manager.py | 43 + webui/src/pages/Routing.jsx | 1577 +++++++++++++++++------------------ webui/src/services/api.js | 1 + 4 files changed, 809 insertions(+), 827 deletions(-) diff --git a/api/app.py b/api/app.py index 4742029..228d0ee 100644 --- a/api/app.py +++ b/api/app.py @@ -1619,6 +1619,21 @@ def add_firewall_rule(): logger.error(f"Error adding firewall rule: {e}") return jsonify({"error": str(e)}), 500 +@app.route('/api/routing/firewall/', methods=['DELETE']) +def remove_firewall_rule(rule_id): + try: + result = routing_manager.remove_firewall_rule(rule_id) + return jsonify({'success': result}), (200 if result else 404) + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@app.route('/api/routing/live-iptables', methods=['GET']) +def get_live_iptables(): + try: + return jsonify(routing_manager.get_live_iptables()) + except Exception as e: + return jsonify({'error': str(e)}), 500 + @app.route('/api/routing/connectivity', methods=['POST']) def test_routing_connectivity(): """Test routing connectivity.""" diff --git a/api/routing_manager.py b/api/routing_manager.py index f117c88..5282557 100644 --- a/api/routing_manager.py +++ b/api/routing_manager.py @@ -485,6 +485,49 @@ class RoutingManager(BaseServiceManager): logger.error(f"Failed to get routing logs: {e}") return {'error': str(e)} + def remove_firewall_rule(self, rule_id: str) -> bool: + """Remove a stored firewall rule and delete it from iptables.""" + try: + rules = self._load_rules() + rule = next((r for r in rules['firewall_rules'] if r['id'] == rule_id), None) + if not rule: + return False + rules['firewall_rules'] = [r for r in rules['firewall_rules'] if r['id'] != rule_id] + self._save_rules(rules) + try: + cmd = ['iptables', '-D', rule['rule_type'], + '-s', rule['source'], '-d', rule['destination']] + if rule.get('protocol') and rule['protocol'] != 'ALL': + cmd += ['-p', rule['protocol'].lower()] + if rule.get('port'): + cmd += ['--dport', str(rule['port'])] + if rule.get('port_range'): + cmd += ['--dport', rule['port_range'].replace('-', ':')] + cmd += ['-j', rule['action']] + subprocess.run(cmd, capture_output=True, timeout=10) + except Exception as e: + logger.warning(f"iptables -D failed (rule may already be gone): {e}") + logger.info(f"Removed firewall rule {rule_id}") + return True + except Exception as e: + logger.error(f"Failed to remove firewall rule: {e}") + return False + + def get_live_iptables(self) -> dict: + """Return live iptables rules from the WireGuard container.""" + out = {} + for table in ('filter', 'nat'): + try: + r = subprocess.run( + ['docker', 'exec', 'cell-wireguard', + 'iptables', '-t', table, '-L', '-n', '-v', '--line-numbers'], + capture_output=True, text=True, timeout=10 + ) + out[table] = r.stdout if r.returncode == 0 else r.stderr + except Exception as e: + out[table] = str(e) + return out + def get_nat_rules(self): """Return all NAT rules.""" rules = self._load_rules() diff --git a/webui/src/pages/Routing.jsx b/webui/src/pages/Routing.jsx index 612e479..69403e4 100644 --- a/webui/src/pages/Routing.jsx +++ b/webui/src/pages/Routing.jsx @@ -1,827 +1,750 @@ -import { useState, useEffect } from 'react'; -import { Plus, Trash2, Wifi, Shield, Activity, Settings } from 'lucide-react'; -import { routingAPI } from '../services/api'; - -function Routing() { - const [routingStatus, setRoutingStatus] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [activeTab, setActiveTab] = useState('overview'); - // NAT management state - const [natRules, setNatRules] = useState([]); - const [natLoading, setNatLoading] = useState(false); - const [natError, setNatError] = useState(null); - const [showNatForm, setShowNatForm] = useState(false); - const [newNat, setNewNat] = useState({ source_network: '', target_interface: '', masquerade: true, nat_type: 'MASQUERADE', protocol: 'ALL', external_port: '', internal_ip: '', internal_port: '' }); - const [natSubmitting, setNatSubmitting] = useState(false); - // Peer Routes management state - const [peerRoutes, setPeerRoutes] = useState([]); - const [peersLoading, setPeersLoading] = useState(false); - const [peersError, setPeersError] = useState(null); - const [showPeerForm, setShowPeerForm] = useState(false); - const [newPeerRoute, setNewPeerRoute] = useState({ peer_name: '', peer_ip: '', allowed_networks: '', route_type: 'lan' }); - const [peerSubmitting, setPeerSubmitting] = useState(false); - // Firewall Rules management state - const [firewallRules, setFirewallRules] = useState([]); - const [fwLoading, setFwLoading] = useState(false); - const [fwError, setFwError] = useState(null); - const [showFwForm, setShowFwForm] = useState(false); - const [newFwRule, setNewFwRule] = useState({ rule_type: 'INPUT', source: '', destination: '', action: 'ACCEPT', protocol: 'ALL', port_range: '' }); - const [fwSubmitting, setFwSubmitting] = useState(false); - // Network Configuration state - const [networkStatus, setNetworkStatus] = useState(null); - const [networkLoading, setNetworkLoading] = useState(false); - const [networkError, setNetworkError] = useState(null); - const [isSettingUp, setIsSettingUp] = useState(false); - - useEffect(() => { - fetchRoutingStatus(); - fetchNatRules(); - fetchPeerRoutes(); - fetchFirewallRules(); - fetchNetworkStatus(); - }, []); - - const fetchRoutingStatus = async () => { - try { - const response = await routingAPI.getStatus(); - setRoutingStatus(response.data); - } catch (error) { - console.error('Failed to fetch routing status:', error); - } finally { - setIsLoading(false); - } - }; - - const fetchNatRules = async () => { - setNatLoading(true); - setNatError(null); - try { - const response = await routingAPI.getNatRules(); - setNatRules(response.data.nat_rules || []); - } catch (error) { - setNatError('Failed to load NAT rules'); - } finally { - setNatLoading(false); - } - }; - - const fetchPeerRoutes = async () => { - setPeersLoading(true); - setPeersError(null); - try { - const response = await routingAPI.getPeerRoutes(); - setPeerRoutes(response.data.peer_routes || []); - } catch (error) { - setPeersError('Failed to load peer routes'); - } finally { - setPeersLoading(false); - } - }; - - const fetchFirewallRules = async () => { - setFwLoading(true); - setFwError(null); - try { - const response = await routingAPI.getFirewallRules(); - setFirewallRules(response.data.firewall_rules || []); - } catch (error) { - setFwError('Failed to load firewall rules'); - } finally { - setFwLoading(false); - } - }; - - const fetchNetworkStatus = async () => { - setNetworkLoading(true); - setNetworkError(null); - try { - const response = await fetch('/api/routing/status'); - if (response.ok) { - const data = await response.json(); - setNetworkStatus(data); - } else { - throw new Error('Failed to fetch network status'); - } - } catch (error) { - console.error('Failed to fetch network status:', error); - setNetworkError('Failed to fetch network status'); - } finally { - setNetworkLoading(false); - } - }; - - const setupNetworkConfiguration = async () => { - setIsSettingUp(true); - setNetworkError(null); - try { - const response = await fetch('/api/routing/setup', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - }); - - if (response.ok) { - const data = await response.json(); - console.log('Network setup successful:', data); - // Refresh network status - await fetchNetworkStatus(); - } else { - const errorData = await response.json(); - throw new Error(errorData.error || 'Failed to setup network configuration'); - } - } catch (error) { - console.error('Failed to setup network configuration:', error); - setNetworkError(error.message); - } finally { - setIsSettingUp(false); - } - }; - - const handleNatInputChange = (e) => { - const { name, value, type, checked } = e.target; - setNewNat((prev) => ({ - ...prev, - [name]: type === 'checkbox' ? checked : value, - })); - }; - - const handleAddNatRule = async (e) => { - e.preventDefault(); - setNatSubmitting(true); - setNatError(null); - try { - await routingAPI.addNatRule(newNat); - setShowNatForm(false); - setNewNat({ source_network: '', target_interface: '', masquerade: true, nat_type: 'MASQUERADE', protocol: 'ALL', external_port: '', internal_ip: '', internal_port: '' }); - fetchNatRules(); - fetchRoutingStatus(); - } catch (error) { - setNatError('Failed to add NAT rule'); - } finally { - setNatSubmitting(false); - } - }; - - const handleDeleteNatRule = async (ruleId) => { - if (!window.confirm('Delete this NAT rule?')) return; - setNatLoading(true); - setNatError(null); - try { - await routingAPI.deleteNatRule(ruleId); - fetchNatRules(); - fetchRoutingStatus(); - } catch (error) { - setNatError('Failed to delete NAT rule'); - } finally { - setNatLoading(false); - } - }; - - const handlePeerInputChange = (e) => { - const { name, value } = e.target; - setNewPeerRoute((prev) => ({ ...prev, [name]: value })); - }; - - const handleAddPeerRoute = async (e) => { - e.preventDefault(); - setPeerSubmitting(true); - setPeersError(null); - try { - // allowed_networks: comma-separated string to array - const payload = { - ...newPeerRoute, - allowed_networks: newPeerRoute.allowed_networks.split(',').map((s) => s.trim()).filter(Boolean), - }; - await routingAPI.addPeerRoute(payload); - setShowPeerForm(false); - setNewPeerRoute({ peer_name: '', peer_ip: '', allowed_networks: '', route_type: 'lan' }); - fetchPeerRoutes(); - fetchRoutingStatus(); - } catch (error) { - setPeersError('Failed to add peer route'); - } finally { - setPeerSubmitting(false); - } - }; - - const handleDeletePeerRoute = async (peerName) => { - if (!window.confirm('Delete this peer route?')) return; - setPeersLoading(true); - setPeersError(null); - try { - await routingAPI.deletePeerRoute(peerName); - fetchPeerRoutes(); - fetchRoutingStatus(); - } catch (error) { - setPeersError('Failed to delete peer route'); - } finally { - setPeersLoading(false); - } - }; - - const handleFwInputChange = (e) => { - const { name, value } = e.target; - setNewFwRule((prev) => ({ ...prev, [name]: value })); - }; - - const handleAddFwRule = async (e) => { - e.preventDefault(); - setFwSubmitting(true); - setFwError(null); - try { - const payload = { ...newFwRule }; - if (!payload.port) delete payload.port; - await routingAPI.addFirewallRule(payload); - setShowFwForm(false); - setNewFwRule({ rule_type: 'INPUT', source: '', destination: '', action: 'ACCEPT', protocol: 'ALL', port_range: '' }); - fetchFirewallRules(); - fetchRoutingStatus(); - } catch (error) { - setFwError('Failed to add firewall rule'); - } finally { - setFwSubmitting(false); - } - }; - - const handleDeleteFwRule = async (ruleId) => { - if (!window.confirm('Delete this firewall rule?')) return; - setFwLoading(true); - setFwError(null); - try { - await routingAPI.deleteFirewallRule(ruleId); - fetchFirewallRules(); - fetchRoutingStatus(); - } catch (error) { - setFwError('Failed to delete firewall rule'); - } finally { - setFwLoading(false); - } - }; - - if (isLoading) { - return ( -
-
-
- ); - } - - const tabs = [ - { id: 'overview', name: 'Overview', icon: Activity }, - { id: 'network', name: 'Network Config', icon: Wifi }, - { id: 'nat', name: 'NAT Rules', icon: Shield }, - { id: 'peers', name: 'Peer Routes', icon: Wifi }, - { id: 'firewall', name: 'Firewall', icon: Settings }, - ]; - - return ( -
-
-

Routing & Gateway

-

- Manage VPN gateway, NAT rules, and routing configuration -

-
- - {/* Status Overview */} - {routingStatus && ( -
-
-
-
- -
-

NAT Rules

-

- {routingStatus.nat_rules_count || 0} -

-
-
-
- -
-
- -
-

Peer Routes

-

- {routingStatus.peer_routes_count || 0} -

-
-
-
- -
-
- -
-

Firewall Rules

-

- {routingStatus.firewall_rules_count || 0} -

-
-
-
- -
-
- -
-

Exit Nodes

-

- {routingStatus.exit_nodes_count || 0} -

-
-
-
-
-
- )} - - {/* Tabs */} -
- -
- - {/* Tab Content */} -
- {activeTab === 'overview' && ( -
-

Routing Overview

- {routingStatus?.routing_table && routingStatus.routing_table.length > 0 ? ( -
- {routingStatus.routing_table.map((route, index) => ( -
-
- - - {route.route} - -
-
- {route.parsed?.via && `via ${route.parsed.via}`} -
-
- ))} -
- ) : ( -

No routing table entries available.

- )} -
- )} - - {activeTab === 'network' && ( -
-
-

Network Configuration

- -
- - {networkLoading ? ( -
-
-
- ) : networkStatus ? ( -
- {/* Status cards */} -
- {[ - { label: 'Routing', value: networkStatus.status === 'online' ? 'Online' : 'Offline', ok: networkStatus.running }, - { label: 'NAT Rules', value: networkStatus.nat_rules_count ?? 0, ok: true }, - { label: 'Firewall Rules', value: networkStatus.firewall_rules_count ?? 0, ok: true }, - { label: 'Peer Routes', value: networkStatus.peer_routes_count ?? 0, ok: true }, - ].map(item => ( -
-

{item.label}

-

{item.value}

-
- ))} -
- - {/* Routing table */} - {networkStatus.routing_status?.routing_table?.length > 0 && ( -
-

Active Routes

-
- {networkStatus.routing_status.routing_table.map((r, i) => ( -
- {r.parsed?.destination || r.route} - via {r.parsed?.dev || '—'} - {r.parsed?.via && {r.parsed.via}} -
- ))} -
-
- )} - -
- -
-
- ) : ( -
-

Could not load network status

- -
- )} -
- )} - - {activeTab === 'nat' && ( -
-
-

NAT Rules

- -
- {showNatForm && ( -
-
- - - -
-
- - - - - -
-
- Advanced: Use DNAT for port forwarding, specify protocol/ports as needed. -
-
- -
- {natError &&

{natError}

} -
- )} - {natLoading ? ( -
Loading NAT rules...
- ) : natError ? ( -
{natError}
- ) : natRules.length === 0 ? ( -
No NAT rules configured.
- ) : ( - - - - - - - - - - - - - - - - {natRules.map((rule, idx) => ( - - - - - - - - - - - - ))} - -
Source NetworkTarget InterfaceMasqueradeTypeProtocolExt PortInt IPInt Port
{rule.source_network}{rule.target_interface}{rule.masquerade ? 'Yes' : 'No'}{rule.nat_type || 'MASQUERADE'}{rule.protocol || 'ALL'}{rule.external_port || '-'}{rule.internal_ip || '-'}{rule.internal_port || '-'} - -
- )} -
- )} - - {activeTab === 'peers' && ( -
-
-

Peer Routes

- -
- {showPeerForm && ( -
-
- - - - -
-
- -
- {peersError &&

{peersError}

} -
- )} - {peersLoading ? ( -
Loading peer routes...
- ) : peersError ? ( -
{peersError}
- ) : peerRoutes.length === 0 ? ( -
No peer routes configured.
- ) : ( - - - - - - - - - - - - {peerRoutes.map((route, idx) => ( - - - - - - - - ))} - -
Peer NamePeer IPAllowed NetworksRoute Type
{route.peer_name}{route.peer_ip}{Array.isArray(route.allowed_networks) ? route.allowed_networks.join(', ') : route.allowed_networks}{route.route_type} - -
- )} -
- )} - - {activeTab === 'firewall' && ( -
-
-

Firewall Rules

- -
- {showFwForm && ( -
-
- - - - - - -
-
- Advanced: Specify protocol and port/range for granular matching. -
-
- -
- {fwError &&

{fwError}

} -
- )} - {fwLoading ? ( -
Loading firewall rules...
- ) : fwError ? ( -
{fwError}
- ) : firewallRules.length === 0 ? ( -
No firewall rules configured.
- ) : ( - - - - - - - - - - - - - - {firewallRules.map((rule, idx) => ( - - - - - - - - - - ))} - -
Rule TypeSourceDestinationProtocolPort/RangeAction
{rule.rule_type}{rule.source}{rule.destination}{rule.protocol || 'ALL'}{rule.port_range || '-'}{rule.action} - -
- )} -
- )} -
-
- ); -} - -export default Routing; \ No newline at end of file +import { useState, useEffect } from 'react'; +import { Plus, Trash2, Wifi, Shield, Activity, Settings, Terminal, ArrowRightLeft, RefreshCw } 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 ( + + {children} + + ); +} + +function SectionHeader({ title, action }) { + return ( +
+

{title}

+ {action} +
+ ); +} + +function EmptyRow({ cols, message }) { + return ( + + {message} + + ); +} + +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 ( +
+
+

Routing & Gateway

+

Manage VPN gateway, NAT, port forwarding, and firewall rules

+
+ + {/* Summary strip */} + {!statusLoading && ( +
+ {[ + { 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 }) => ( +
+ +
+

{label}

+

{value}

+
+
+ ))} +
+ )} + + {/* Tabs */} +
+ +
+ + {/* ── Overview ── */} + {activeTab === 'overview' && ( +
+ + Refresh + + } /> + {statusLoading ? ( +
+ ) : routingTable.length > 0 ? ( + + + + + + + + + + + {routingTable.map((r, i) => { + const p = r.parsed || {}; + return ( + + + + + + + ); + })} + +
DestinationGatewayInterfaceFlags
{p.destination || r.route}{p.via || '—'}{p.dev || '—'}{p.metric ? `metric ${p.metric}` : ''}
+ ) : ( +

No routing table entries available.

+ )} +
+ )} + + {/* ── Port Forwarding ── */} + {activeTab === 'portfwd' && ( +
+ setShowPfForm(v => !v)}> + {showPfForm ? 'Cancel' : 'Add Rule'} + + } /> +

Forward inbound traffic on a public port to an internal host.

+ + {showPfForm && ( +
+
+
+ + +
+
+ + setNewPf(p => ({...p, external_port: e.target.value}))} required /> +
+
+ + setNewPf(p => ({...p, internal_ip: e.target.value}))} required /> +
+
+ + setNewPf(p => ({...p, internal_port: e.target.value}))} required /> +
+
+ {pfError &&

{pfError}

} +
+ +
+
+ )} + + + + + + + + + + + + {pfRules.length === 0 ? ( + + ) : pfRules.map((r, i) => ( + + + + + + + + ))} + +
ProtocolExternal Port→ Internal IPInternal Port +
{r.protocol || 'ALL'}{r.external_port || '—'}{r.internal_ip || '—'}{r.internal_port || '—'} + +
+
+ )} + + {/* ── NAT Rules ── */} + {activeTab === 'nat' && ( +
+ setShowNatForm(v => !v)}> + {showNatForm ? 'Cancel' : 'Add Rule'} + + } /> + + {showNatForm && ( +
+
+
+ + setNewNat(p => ({...p, source_network: e.target.value}))} required /> +
+
+ + setNewNat(p => ({...p, target_interface: e.target.value}))} required /> +
+
+ + +
+
+ {natError &&

{natError}

} +
+ +
+
+ )} + + {natLoading ?
Loading…
: ( + + + + + + + + + + {natRules.length === 0 ? ( + + ) : natRules.map((r, i) => ( + + + + + + + ))} + +
Source NetworkInterfaceType +
{r.source_network}{r.target_interface}{r.nat_type || 'MASQUERADE'} + +
+ )} +
+ )} + + {/* ── Peer Routes ── */} + {activeTab === 'peers' && ( +
+ setShowPeerForm(v => !v)}> + {showPeerForm ? 'Cancel' : 'Add Route'} + + } /> +

+ Add OS-level routes that forward traffic for a network through a specific VPN peer. +

+ + {showPeerForm && ( +
+
+
+ + setNewPeerRoute(p => ({...p, peer_name: e.target.value}))} required /> +
+
+ + setNewPeerRoute(p => ({...p, peer_ip: e.target.value}))} required /> +
+
+ + setNewPeerRoute(p => ({...p, allowed_networks: e.target.value}))} /> +
+
+ + +
+
+ {peersError &&

{peersError}

} +
+ +
+
+ )} + + {peersLoading ?
Loading…
: ( + + + + + + + + + + + {peerRoutes.length === 0 ? ( + + ) : peerRoutes.map((r, i) => ( + + + + + + + + ))} + +
PeerVPN IPNetworksType +
{r.peer_name}{r.peer_ip} + {Array.isArray(r.allowed_networks) ? r.allowed_networks.join(', ') : r.allowed_networks} + {r.route_type} + +
+ )} +
+ )} + + {/* ── Firewall ── */} + {activeTab === 'firewall' && ( +
+ setShowFwForm(v => !v)}> + {showFwForm ? 'Cancel' : 'Add Rule'} + + } /> +

+ Custom iptables rules applied on the host. Per-peer VPN access rules are managed on the Peers page. +

+ + {showFwForm && ( +
+
+
+ + +
+
+ + setNewFwRule(p => ({...p, source: e.target.value}))} required /> +
+
+ + setNewFwRule(p => ({...p, destination: e.target.value}))} required /> +
+
+ + +
+
+ + setNewFwRule(p => ({...p, port_range: e.target.value}))} /> +
+
+ + +
+
+ {fwError &&

{fwError}

} +
+ +
+
+ )} + + {fwLoading ?
Loading…
: ( + + + + + + + + + + + + + {firewallRules.length === 0 ? ( + + ) : firewallRules.map((r, i) => ( + + + + + + + + + + ))} + +
ChainSourceDestinationProtoPortAction +
{r.rule_type}{r.source}{r.destination}{r.protocol || 'ALL'}{r.port_range || r.port || '—'} + {r.action} + + +
+ )} +
+ )} + + {/* ── Live iptables ── */} + {activeTab === 'live' && ( +
+ + Refresh + + } /> +

Read-only view of actual iptables rules running inside cell-wireguard.

+ + {iptLoading ? ( +
+ ) : liveIptables ? ( +
+ {Object.entries(liveIptables).map(([table, text]) => ( +
+

{table} table

+
+                    {text || '(empty)'}
+                  
+
+ ))} +
+ ) : ( +
+ +

Click Refresh to load live iptables rules.

+
+ )} +
+ )} + + {/* ── Diagnostics ── */} + {activeTab === 'diagnostics' && ( +
+ +

Ping and traceroute a target from the server to verify routing.

+ +
+ setDiagTarget(e.target.value)} + required + /> + +
+ + {diagResult && ( +
+ {diagResult.error && ( +
{diagResult.error}
+ )} + {diagResult.ping && ( +
+
+ Ping + {diagResult.ping.success ? 'reachable' : 'unreachable'} +
+
+                    {diagResult.ping.output || diagResult.ping.error || '(no output)'}
+                  
+
+ )} + {diagResult.traceroute && ( +
+
+ Traceroute +
+
+                    {diagResult.traceroute.output || diagResult.traceroute.error || '(no output)'}
+                  
+
+ )} +
+ )} +
+ )} +
+ ); +} + +export default Routing; diff --git a/webui/src/services/api.js b/webui/src/services/api.js index 5648e6c..4c9fd5b 100644 --- a/webui/src/services/api.js +++ b/webui/src/services/api.js @@ -137,6 +137,7 @@ export const routingAPI = { getFirewallRules: () => api.get('/api/routing/firewall'), addFirewallRule: (rule) => api.post('/api/routing/firewall', rule), deleteFirewallRule: (ruleId) => api.delete(`/api/routing/firewall/${ruleId}`), + getLiveIptables: () => api.get('/api/routing/live-iptables'), // Other addExitNode: (node) => api.post('/api/routing/exit-nodes', node), addBridgeRoute: (route) => api.post('/api/routing/bridge', route),