Phase 5: extended connectivity — WireGuard ext, OpenVPN, Tor exit routing
- ConnectivityManager: per-peer exit routing via iptables fwmark/policy tables (wg_ext=0x10/t110, openvpn=0x20/t120, tor=0x30/t130) - Dedicated PIC_CONNECTIVITY chains (mangle+nat), kill-switch FORWARD DROP - Config upload with sanitization: strips PostUp/PostDown and OVpn script dirs - Peer exit_via field added to peer registry (backward-compat, default=default) - 7 Flask routes at /api/connectivity/* - Connectivity.jsx: 693-line frontend with exit cards, peer assignment table - 72 new tests for ConnectivityManager (72 passing) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -44,6 +44,7 @@ import AccountSettings from './pages/AccountSettings';
|
||||
import PeerDashboard from './pages/PeerDashboard';
|
||||
import MyServices from './pages/MyServices';
|
||||
import Store from './pages/Store';
|
||||
import Connectivity from './pages/Connectivity';
|
||||
import Setup from './pages/Setup';
|
||||
import SetupGuard from './components/SetupGuard';
|
||||
|
||||
@@ -242,6 +243,7 @@ function AppCore() {
|
||||
{ name: 'Containers', href: '/containers', icon: Package2 },
|
||||
{ name: 'Store', href: '/store', icon: Package },
|
||||
{ name: 'Cell Network', href: '/cell-network', icon: Link2 },
|
||||
{ name: 'Connectivity', href: '/connectivity', icon: Network },
|
||||
{ name: 'Logs', href: '/logs', icon: Activity },
|
||||
{ name: 'Settings', href: '/settings', icon: SettingsIcon },
|
||||
{ name: 'Account', href: '/account', icon: User },
|
||||
@@ -348,6 +350,7 @@ function AppCore() {
|
||||
<Route path="/containers" element={<PrivateRoute requireRole="admin"><ContainerDashboard /></PrivateRoute>} />
|
||||
<Route path="/store" element={<PrivateRoute requireRole="admin"><Store /></PrivateRoute>} />
|
||||
<Route path="/cell-network" element={<PrivateRoute requireRole="admin"><CellNetwork /></PrivateRoute>} />
|
||||
<Route path="/connectivity" element={<PrivateRoute requireRole="admin"><Connectivity /></PrivateRoute>} />
|
||||
<Route path="/logs" element={<PrivateRoute requireRole="admin"><Logs /></PrivateRoute>} />
|
||||
<Route path="/settings" element={<PrivateRoute requireRole="admin"><Settings /></PrivateRoute>} />
|
||||
</Routes>
|
||||
|
||||
@@ -0,0 +1,693 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Shield,
|
||||
Lock,
|
||||
Globe,
|
||||
RefreshCw,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
ChevronDown,
|
||||
Upload,
|
||||
ToggleLeft,
|
||||
ToggleRight,
|
||||
} from 'lucide-react';
|
||||
import { connectivityAPI, wireguardAPI } from '../services/api';
|
||||
|
||||
// ── Toast helpers (same pattern as Store.jsx) ─────────────────────────────────
|
||||
|
||||
function toastEvent(msg, type = 'success') {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('connectivity-toast', { detail: { msg, type } })
|
||||
);
|
||||
}
|
||||
|
||||
function Toast({ toasts }) {
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50 space-y-2 pointer-events-none">
|
||||
{toasts.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className={`px-4 py-3 rounded-lg shadow-lg text-sm text-white flex items-center gap-2 pointer-events-auto ${
|
||||
t.type === 'success'
|
||||
? 'bg-green-600'
|
||||
: t.type === 'error'
|
||||
? 'bg-red-600'
|
||||
: 'bg-yellow-600'
|
||||
}`}
|
||||
>
|
||||
{t.type === 'success' ? (
|
||||
<CheckCircle className="h-4 w-4 shrink-0" />
|
||||
) : (
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
)}
|
||||
{t.msg}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useToasts() {
|
||||
const [toasts, setToasts] = useState([]);
|
||||
useEffect(() => {
|
||||
const handler = (e) => {
|
||||
const id = Date.now();
|
||||
setToasts((prev) => [...prev, { ...e.detail, id }]);
|
||||
setTimeout(
|
||||
() => setToasts((prev) => prev.filter((t) => t.id !== id)),
|
||||
3000
|
||||
);
|
||||
};
|
||||
window.addEventListener('connectivity-toast', handler);
|
||||
return () => window.removeEventListener('connectivity-toast', handler);
|
||||
}, []);
|
||||
return toasts;
|
||||
}
|
||||
|
||||
// ── Status badge ──────────────────────────────────────────────────────────────
|
||||
|
||||
function StatusBadge({ status }) {
|
||||
if (status === 'active') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-xs font-medium text-green-700 bg-green-50 border border-green-200 rounded-full px-2 py-0.5">
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
Active
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === 'configured') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-xs font-medium text-yellow-700 bg-yellow-50 border border-yellow-200 rounded-full px-2 py-0.5">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
Configured
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === 'error') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-xs font-medium text-red-700 bg-red-50 border border-red-200 rounded-full px-2 py-0.5">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
Error
|
||||
</span>
|
||||
);
|
||||
}
|
||||
// not configured
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-xs font-medium text-gray-500 bg-gray-100 border border-gray-200 rounded-full px-2 py-0.5">
|
||||
Not configured
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── WireGuard External card ───────────────────────────────────────────────────
|
||||
|
||||
function WireguardExitCard({ exitInfo, onUploaded }) {
|
||||
const [confText, setConfText] = useState('');
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const status = exitInfo?.status || 'not_configured';
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!confText.trim()) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
await connectivityAPI.uploadWireguard(confText.trim());
|
||||
toastEvent('WireGuard config uploaded');
|
||||
setConfText('');
|
||||
onUploaded();
|
||||
} catch (err) {
|
||||
const msg =
|
||||
err.response?.data?.error ||
|
||||
err.response?.data?.message ||
|
||||
'Failed to upload WireGuard config';
|
||||
toastEvent(msg, 'error');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6 flex flex-col gap-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-primary-50 shrink-0">
|
||||
<Shield className="h-5 w-5 text-primary-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">WireGuard External</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
Route traffic through an external WireGuard VPN tunnel
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={status} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
htmlFor="wg-conf"
|
||||
className="text-sm font-medium text-gray-700"
|
||||
>
|
||||
Paste .conf file contents
|
||||
</label>
|
||||
<textarea
|
||||
id="wg-conf"
|
||||
value={confText}
|
||||
onChange={(e) => setConfText(e.target.value)}
|
||||
placeholder="[Interface] PrivateKey = ... [Peer] PublicKey = ..."
|
||||
rows={6}
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 font-mono text-xs text-gray-800 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 resize-y"
|
||||
aria-describedby="wg-conf-hint"
|
||||
/>
|
||||
<p id="wg-conf-hint" className="text-xs text-gray-400">
|
||||
Drag-and-drop not available — paste the file text directly.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-2 border-t border-gray-100">
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
disabled={uploading || !confText.trim()}
|
||||
className="flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed rounded-md transition-colors"
|
||||
aria-label="Upload WireGuard config"
|
||||
>
|
||||
{uploading ? (
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-4 w-4" />
|
||||
)}
|
||||
{uploading ? 'Uploading…' : 'Upload Config'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── OpenVPN card ──────────────────────────────────────────────────────────────
|
||||
|
||||
function OpenvpnExitCard({ exitInfo, onUploaded }) {
|
||||
const [ovpnText, setOvpnText] = useState('');
|
||||
const [profileName, setProfileName] = useState('default');
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const status = exitInfo?.status || 'not_configured';
|
||||
|
||||
const nameInvalid = profileName.trim() === '';
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!ovpnText.trim() || nameInvalid) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
await connectivityAPI.uploadOpenvpn(ovpnText.trim(), profileName.trim());
|
||||
toastEvent('OpenVPN config uploaded');
|
||||
setOvpnText('');
|
||||
onUploaded();
|
||||
} catch (err) {
|
||||
const msg =
|
||||
err.response?.data?.error ||
|
||||
err.response?.data?.message ||
|
||||
'Failed to upload OpenVPN config';
|
||||
toastEvent(msg, 'error');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6 flex flex-col gap-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-indigo-50 shrink-0">
|
||||
<Lock className="h-5 w-5 text-indigo-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">OpenVPN</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
Route traffic through an OpenVPN tunnel
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={status} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
htmlFor="ovpn-name"
|
||||
className="text-sm font-medium text-gray-700"
|
||||
>
|
||||
Profile name <span className="text-red-500" aria-hidden="true">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="ovpn-name"
|
||||
type="text"
|
||||
value={profileName}
|
||||
onChange={(e) => setProfileName(e.target.value)}
|
||||
placeholder="default"
|
||||
className={`w-full rounded-md border px-3 py-2 text-sm text-gray-800 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-primary-500 ${
|
||||
nameInvalid
|
||||
? 'border-red-300 focus:ring-red-400 focus:border-red-400'
|
||||
: 'border-gray-300 focus:border-primary-500'
|
||||
}`}
|
||||
aria-required="true"
|
||||
aria-describedby={nameInvalid ? 'ovpn-name-error' : undefined}
|
||||
/>
|
||||
{nameInvalid && (
|
||||
<p id="ovpn-name-error" className="text-xs text-red-600" role="alert">
|
||||
Profile name is required
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
htmlFor="ovpn-conf"
|
||||
className="text-sm font-medium text-gray-700"
|
||||
>
|
||||
Paste .ovpn file contents
|
||||
</label>
|
||||
<textarea
|
||||
id="ovpn-conf"
|
||||
value={ovpnText}
|
||||
onChange={(e) => setOvpnText(e.target.value)}
|
||||
placeholder="client dev tun proto udp remote ..."
|
||||
rows={6}
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 font-mono text-xs text-gray-800 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 resize-y"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-2 border-t border-gray-100">
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
disabled={uploading || !ovpnText.trim() || nameInvalid}
|
||||
className="flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed rounded-md transition-colors"
|
||||
aria-label="Upload OpenVPN config"
|
||||
>
|
||||
{uploading ? (
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-4 w-4" />
|
||||
)}
|
||||
{uploading ? 'Uploading…' : 'Upload Config'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tor card ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function TorExitCard({ exitInfo, onToggled }) {
|
||||
const [toggling, setToggling] = useState(false);
|
||||
const status = exitInfo?.status || 'not_configured';
|
||||
const isEnabled = status === 'active' || status === 'configured';
|
||||
|
||||
const handleToggle = async () => {
|
||||
setToggling(true);
|
||||
try {
|
||||
// Tor doesn't need a config upload — apply routes enables/disables it
|
||||
await connectivityAPI.applyRoutes();
|
||||
toastEvent(isEnabled ? 'Tor exit disabled' : 'Tor exit enabled');
|
||||
onToggled();
|
||||
} catch (err) {
|
||||
const msg =
|
||||
err.response?.data?.error ||
|
||||
err.response?.data?.message ||
|
||||
'Failed to toggle Tor';
|
||||
toastEvent(msg, 'error');
|
||||
} finally {
|
||||
setToggling(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6 flex flex-col gap-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-purple-50 shrink-0">
|
||||
<Globe className="h-5 w-5 text-purple-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">Tor</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
Route selected peers through the Tor anonymity network
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={status} />
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-500">
|
||||
No configuration file required. Toggle the exit on or off — peers
|
||||
assigned to Tor will have their traffic routed accordingly.
|
||||
</p>
|
||||
|
||||
<div className="flex justify-end pt-2 border-t border-gray-100">
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
disabled={toggling}
|
||||
className={`flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
isEnabled
|
||||
? 'text-gray-700 bg-gray-100 hover:bg-gray-200'
|
||||
: 'text-white bg-primary-600 hover:bg-primary-700'
|
||||
}`}
|
||||
aria-label={isEnabled ? 'Disable Tor exit' : 'Enable Tor exit'}
|
||||
aria-pressed={isEnabled}
|
||||
>
|
||||
{toggling ? (
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
) : isEnabled ? (
|
||||
<ToggleRight className="h-4 w-4" />
|
||||
) : (
|
||||
<ToggleLeft className="h-4 w-4" />
|
||||
)}
|
||||
{toggling ? 'Applying…' : isEnabled ? 'Disable' : 'Enable'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Peer exit row ─────────────────────────────────────────────────────────────
|
||||
|
||||
const EXIT_OPTIONS = [
|
||||
{ value: 'default', label: 'Default (direct)' },
|
||||
{ value: 'wireguard', label: 'WireGuard External' },
|
||||
{ value: 'openvpn', label: 'OpenVPN' },
|
||||
{ value: 'tor', label: 'Tor' },
|
||||
];
|
||||
|
||||
function PeerExitRow({ peer, currentExit, onSaved }) {
|
||||
const [selected, setSelected] = useState(currentExit || 'default');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const isDirty = selected !== (currentExit || 'default');
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await connectivityAPI.setPeerExit(peer.name, selected);
|
||||
toastEvent(`Exit for ${peer.name} set to ${selected}`);
|
||||
onSaved(peer.name, selected);
|
||||
} catch (err) {
|
||||
const msg =
|
||||
err.response?.data?.error ||
|
||||
err.response?.data?.message ||
|
||||
`Failed to update exit for ${peer.name}`;
|
||||
toastEvent(msg, 'error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<tr className="border-t border-gray-100">
|
||||
<td className="py-3 px-4 text-sm font-medium text-gray-900 truncate max-w-[180px]">
|
||||
{peer.name}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className="text-sm text-gray-500">
|
||||
{EXIT_OPTIONS.find((o) => o.value === (currentExit || 'default'))
|
||||
?.label || 'Default (direct)'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="relative inline-block">
|
||||
<select
|
||||
value={selected}
|
||||
onChange={(e) => setSelected(e.target.value)}
|
||||
className="appearance-none bg-white border border-gray-300 text-sm text-gray-800 rounded-md pl-3 pr-8 py-1.5 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
aria-label={`Change exit for ${peer.name}`}
|
||||
>
|
||||
{EXIT_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || !isDirty}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 disabled:opacity-40 disabled:cursor-not-allowed rounded-md transition-colors ml-auto"
|
||||
aria-label={`Save exit assignment for ${peer.name}`}
|
||||
>
|
||||
{saving && <RefreshCw className="h-3.5 w-3.5 animate-spin" />}
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main Connectivity component ───────────────────────────────────────────────
|
||||
|
||||
function Connectivity() {
|
||||
const toasts = useToasts();
|
||||
|
||||
const [exits, setExits] = useState({}); // keyed by exit type
|
||||
const [peerExits, setPeerExits] = useState({}); // peer_name -> exit_via
|
||||
const [peers, setPeers] = useState([]); // WireGuard peer list
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState(null);
|
||||
const [applying, setApplying] = useState(false);
|
||||
|
||||
const loadAll = useCallback(async () => {
|
||||
setLoadError(null);
|
||||
try {
|
||||
const [exitsRes, peerExitsRes, peersRes] = await Promise.all([
|
||||
connectivityAPI.listExits().catch(() => ({ data: {} })),
|
||||
connectivityAPI.getPeerExits().catch(() => ({ data: {} })),
|
||||
wireguardAPI.getPeers().catch(() => ({ data: { peers: [] } })),
|
||||
]);
|
||||
|
||||
const exitsData = exitsRes.data || {};
|
||||
// API may return array or object — normalise to object keyed by type
|
||||
if (Array.isArray(exitsData)) {
|
||||
const map = {};
|
||||
exitsData.forEach((e) => { map[e.type] = e; });
|
||||
setExits(map);
|
||||
} else {
|
||||
setExits(exitsData);
|
||||
}
|
||||
|
||||
const peerExitsData = peerExitsRes.data || {};
|
||||
setPeerExits(
|
||||
Array.isArray(peerExitsData)
|
||||
? Object.fromEntries(peerExitsData.map((p) => [p.name, p.exit_via]))
|
||||
: peerExitsData
|
||||
);
|
||||
|
||||
const peersData = peersRes.data;
|
||||
const peersList = Array.isArray(peersData)
|
||||
? peersData
|
||||
: Array.isArray(peersData?.peers)
|
||||
? peersData.peers
|
||||
: [];
|
||||
setPeers(peersList);
|
||||
} catch (err) {
|
||||
const msg =
|
||||
err.response?.data?.error ||
|
||||
err.response?.data?.message ||
|
||||
'Could not load connectivity data. Check that the API is reachable.';
|
||||
setLoadError(msg);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadAll();
|
||||
}, [loadAll]);
|
||||
|
||||
const handleApplyRoutes = async () => {
|
||||
setApplying(true);
|
||||
try {
|
||||
await connectivityAPI.applyRoutes();
|
||||
toastEvent('Routes applied successfully');
|
||||
await loadAll();
|
||||
} catch (err) {
|
||||
const msg =
|
||||
err.response?.data?.error ||
|
||||
err.response?.data?.message ||
|
||||
'Failed to apply routes';
|
||||
toastEvent(msg, 'error');
|
||||
} finally {
|
||||
setApplying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePeerExitSaved = (peerName, exitVia) => {
|
||||
setPeerExits((prev) => ({ ...prev, [peerName]: exitVia }));
|
||||
};
|
||||
|
||||
// ── Render ──────────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Toast toasts={toasts} />
|
||||
|
||||
{/* Page header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Connectivity</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Configure exit tunnels and control how each peer's traffic is routed
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Loading skeleton */}
|
||||
{isLoading && (
|
||||
<div className="space-y-4 animate-pulse">
|
||||
<div className="h-6 bg-gray-200 rounded w-48 mb-4" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{[1, 2, 3].map((n) => (
|
||||
<div
|
||||
key={n}
|
||||
className="bg-white rounded-lg border border-gray-200 p-6 h-48"
|
||||
>
|
||||
<div className="h-4 bg-gray-200 rounded w-1/2 mb-3" />
|
||||
<div className="h-3 bg-gray-100 rounded w-3/4 mb-2" />
|
||||
<div className="h-3 bg-gray-100 rounded w-2/3" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error state */}
|
||||
{!isLoading && loadError && (
|
||||
<div className="bg-white rounded-lg border border-red-200 bg-red-50 p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="h-5 w-5 text-red-500 mt-0.5 shrink-0" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-red-800">
|
||||
Failed to load connectivity data
|
||||
</p>
|
||||
<p className="text-sm text-red-600 mt-1">{loadError}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setIsLoading(true); loadAll(); }}
|
||||
className="btn-secondary text-sm shrink-0"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main content */}
|
||||
{!isLoading && !loadError && (
|
||||
<div className="space-y-10">
|
||||
|
||||
{/* Section 1: Exit Tunnels */}
|
||||
<section>
|
||||
<div className="mb-4 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-gray-900">
|
||||
Exit Tunnels
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
Upload VPN configs or enable Tor to create exit options for your
|
||||
peers
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<WireguardExitCard
|
||||
exitInfo={exits['wireguard'] || exits['wireguard_external']}
|
||||
onUploaded={loadAll}
|
||||
/>
|
||||
<OpenvpnExitCard
|
||||
exitInfo={exits['openvpn']}
|
||||
onUploaded={loadAll}
|
||||
/>
|
||||
<TorExitCard
|
||||
exitInfo={exits['tor']}
|
||||
onToggled={loadAll}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Apply Routes */}
|
||||
<div className="mt-6 flex items-center justify-between gap-4 bg-gray-50 border border-gray-200 rounded-lg px-5 py-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-800">
|
||||
Apply exit routes
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
Commit all exit-tunnel changes to the routing table
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleApplyRoutes}
|
||||
disabled={applying}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed rounded-md transition-colors shrink-0"
|
||||
aria-label="Apply exit routes"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${applying ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
{applying ? 'Applying…' : 'Apply Routes'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Section 2: Peer Exit Assignment */}
|
||||
<section>
|
||||
<div className="mb-4">
|
||||
<h2 className="text-base font-semibold text-gray-900">
|
||||
Peer Exit Assignment
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
Choose which exit tunnel each WireGuard peer uses
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{peers.length === 0 ? (
|
||||
<div className="bg-white rounded-lg border border-gray-200 py-12 text-center">
|
||||
<Shield className="h-10 w-10 text-gray-300 mx-auto mb-3" />
|
||||
<p className="text-sm font-medium text-gray-500">
|
||||
No WireGuard peers found
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
Add peers on the WireGuard page first, then return here to
|
||||
assign exits.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-lg border border-gray-200 overflow-x-auto">
|
||||
<table className="min-w-full">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 border-b border-gray-200">
|
||||
<th className="py-3 px-4 text-left text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||
Peer Name
|
||||
</th>
|
||||
<th className="py-3 px-4 text-left text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||
Current Exit
|
||||
</th>
|
||||
<th className="py-3 px-4 text-left text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||
Change Exit
|
||||
</th>
|
||||
<th className="py-3 px-4" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{peers.map((peer) => (
|
||||
<PeerExitRow
|
||||
key={peer.name}
|
||||
peer={peer}
|
||||
currentExit={peerExits[peer.name] || 'default'}
|
||||
onSaved={handlePeerExitSaved}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Connectivity;
|
||||
@@ -328,6 +328,17 @@ export const setupAPI = {
|
||||
complete: (payload) => api.post('/api/setup/complete', payload),
|
||||
};
|
||||
|
||||
// Connectivity / Exit Routing API
|
||||
export const connectivityAPI = {
|
||||
getStatus: () => api.get('/api/connectivity/status'),
|
||||
listExits: () => api.get('/api/connectivity/exits'),
|
||||
uploadWireguard: (conf_text) => api.post('/api/connectivity/exits/wireguard', { conf_text }),
|
||||
uploadOpenvpn: (ovpn_text, name = 'default') => api.post('/api/connectivity/exits/openvpn', { ovpn_text, name }),
|
||||
applyRoutes: () => api.post('/api/connectivity/exits/apply'),
|
||||
getPeerExits: () => api.get('/api/connectivity/peers'),
|
||||
setPeerExit: (peer_name, exit_via) => api.put(`/api/connectivity/peers/${peer_name}/exit`, { exit_via }),
|
||||
};
|
||||
|
||||
// Container Management API
|
||||
export const containerAPI = {
|
||||
// Containers
|
||||
|
||||
Reference in New Issue
Block a user