import { useState, useEffect, useCallback } from 'react';
import {
Shield,
Lock,
Globe,
RefreshCw,
CheckCircle,
AlertCircle,
ChevronDown,
Upload,
ToggleLeft,
ToggleRight,
Layers,
Store,
Server,
Network,
Save,
} from 'lucide-react';
import { connectivityAPI, egressAPI } 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 (
{toasts.map((t) => (
{t.type === 'success' ? (
) : (
)}
{t.msg}
))}
);
}
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 (
Active
);
}
if (status === 'configured') {
return (
Configured
);
}
if (status === 'error') {
return (
Error
);
}
// not configured
return (
Not configured
);
}
// ── 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 (
WireGuard External
Route traffic through an external WireGuard VPN tunnel
);
}
// ── 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 (
OpenVPN
Route traffic through an OpenVPN tunnel
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 && (
Profile name is required
)}
);
}
// ── 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 (
Tor
Route selected peers through the Tor anonymity network
No configuration file required. Toggle the exit on or off — peers
assigned to Tor will have their traffic routed accordingly.
);
}
// ── sshuttle (SSH tunnel) card ────────────────────────────────────────────────
function SshuttleExitCard({ exitInfo, onSaved }) {
const [host, setHost] = useState('');
const [port, setPort] = useState('22');
const [user, setUser] = useState('');
const [auth, setAuth] = useState('key');
const [privateKey, setPrivateKey] = useState('');
const [password, setPassword] = useState('');
const [knownHosts, setKnownHosts] = useState('');
const [saving, setSaving] = useState(false);
const status = exitInfo?.status || 'not_configured';
const secretMissing = auth === 'key' ? !privateKey.trim() : !password;
const canSave =
host.trim() && port.trim() && user.trim() && knownHosts.trim() && !secretMissing;
const handleSave = async () => {
if (!canSave) return;
setSaving(true);
try {
const cfg = {
host: host.trim(),
port: Number(port),
user: user.trim(),
auth,
known_hosts: knownHosts.trim(),
};
if (auth === 'key') {
cfg.private_key = privateKey;
} else {
cfg.password = password;
}
await connectivityAPI.configureSshuttle(cfg);
toastEvent('SSH tunnel exit configured');
setPrivateKey('');
setPassword('');
onSaved();
} catch (err) {
const msg =
err.response?.data?.error ||
err.response?.data?.message ||
'Failed to configure SSH tunnel exit';
toastEvent(msg, 'error');
} finally {
setSaving(false);
}
};
return (
SSH Tunnel
Route traffic through an SSH server via sshuttle
setUser(e.target.value)}
placeholder="tunnel"
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-800 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
aria-required="true"
/>
Authentication
{auth === 'key' ? (
) : (
setPassword(e.target.value)}
autoComplete="new-password"
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-800 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
aria-required="true"
/>
)}
);
}
// ── Proxy (redsocks) card ─────────────────────────────────────────────────────
function ProxyExitCard({ exitInfo, onSaved }) {
const [scheme, setScheme] = useState('socks5');
const [host, setHost] = useState('');
const [port, setPort] = useState('');
const [user, setUser] = useState('');
const [password, setPassword] = useState('');
const [saving, setSaving] = useState(false);
const status = exitInfo?.status || 'not_configured';
const canSave = host.trim() && port.trim() && (!password || user.trim());
const handleSave = async () => {
if (!canSave) return;
setSaving(true);
try {
const cfg = {
scheme,
host: host.trim(),
port: Number(port),
};
if (user.trim()) cfg.user = user.trim();
if (password) cfg.password = password;
await connectivityAPI.configureProxy(cfg);
toastEvent('Proxy exit configured');
setPassword('');
onSaved();
} catch (err) {
const msg =
err.response?.data?.error ||
err.response?.data?.message ||
'Failed to configure proxy exit';
toastEvent(msg, 'error');
} finally {
setSaving(false);
}
};
return (
Upstream Proxy
Route traffic through an HTTP or SOCKS5 proxy
);
}
// ── Peer exit row ─────────────────────────────────────────────────────────────
const EXIT_OPTIONS = [
{ value: 'default', label: 'Default (direct)' },
{ value: 'wireguard_ext', label: 'WireGuard External' },
{ value: 'openvpn', label: 'OpenVPN' },
{ value: 'tor', label: 'Tor' },
{ value: 'sshuttle', label: 'SSH Tunnel (sshuttle)' },
{ value: 'proxy', label: 'Proxy (redsocks)' },
];
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 (
|
{peer.name}
|
{EXIT_OPTIONS.find((o) => o.value === (currentExit || 'default'))
?.label || 'Default (direct)'}
|
|
|
);
}
// ── Service egress row ────────────────────────────────────────────────────────
// Maps backend exit identifiers to human-readable labels.
// The available options shown are limited to exits that are actually installed,
// plus 'default' which is always valid.
function buildServiceExitOptions(installedExits) {
const always = [{ value: 'default', label: 'Default (direct internet)' }];
const optional = [
{ value: 'wireguard_ext', label: 'WireGuard External' },
{ value: 'openvpn', label: 'OpenVPN' },
{ value: 'tor', label: 'Tor' },
{ value: 'sshuttle', label: 'SSH Tunnel (sshuttle)' },
{ value: 'proxy', label: 'Proxy (redsocks)' },
];
const available = optional.filter(
(opt) =>
installedExits[opt.value]?.status === 'active' ||
installedExits[opt.value]?.status === 'configured'
);
return [...always, ...available];
}
function ServiceEgressRow({ serviceId, currentExit, exitOptions, onSaved }) {
const [selected, setSelected] = useState(currentExit || 'default');
const [saving, setSaving] = useState(false);
const isDirty = selected !== (currentExit || 'default');
const handleSave = async () => {
setSaving(true);
try {
await egressAPI.setServiceExit(serviceId, selected);
const label =
exitOptions.find((o) => o.value === selected)?.label || selected;
toastEvent(`Egress for ${serviceId} set to ${label}`);
onSaved(serviceId, selected);
} catch (err) {
const msg =
err.response?.data?.error ||
err.response?.data?.message ||
`Failed to update egress for ${serviceId}`;
toastEvent(msg, 'error');
// Roll the select back to the last-saved value so the UI stays consistent
setSelected(currentExit || 'default');
} finally {
setSaving(false);
}
};
const currentLabel =
exitOptions.find((o) => o.value === (currentExit || 'default'))?.label ||
'Default (direct internet)';
return (
|
{serviceId}
|
{currentLabel}
|
|
|
);
}
// ── 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 [isLoading, setIsLoading] = useState(true);
const [loadError, setLoadError] = useState(null);
const [applying, setApplying] = useState(false);
// Service egress state
// serviceEgress: { [service_id]: { exit_via, container_ip, has_egress } }
const [serviceEgress, setServiceEgress] = useState({});
const [egressLoading, setEgressLoading] = useState(true);
const [egressError, setEgressError] = useState(null);
const loadAll = useCallback(async () => {
setLoadError(null);
try {
const [exitsRes, peerExitsRes] = await Promise.all([
connectivityAPI.listExits().catch(() => ({ data: {} })),
connectivityAPI.getPeerExits().catch(() => ({ data: {} })),
]);
// API returns {exits: [{type, configured, iface_up, status}, ...]} —
// normalise to an object keyed by exit type.
const exitsData = exitsRes.data?.exits ?? exitsRes.data ?? {};
if (Array.isArray(exitsData)) {
const map = {};
exitsData.forEach((e) => { map[e.type] = e; });
setExits(map);
} else {
setExits(exitsData);
}
// Peer assignments come from the peer registry:
// {peers: {peer_name: exit_via}}
const peerExitsData = peerExitsRes.data?.peers ?? peerExitsRes.data ?? {};
setPeerExits(
Array.isArray(peerExitsData)
? Object.fromEntries(peerExitsData.map((p) => [p.name, p.exit_via]))
: peerExitsData
);
} 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);
}
}, []);
const loadEgress = useCallback(async () => {
setEgressError(null);
setEgressLoading(true);
try {
const res = await egressAPI.getStatus();
const data = res.data || {};
// data.services is { [service_id]: { exit_via, container_ip, has_egress } }
setServiceEgress(data.services || {});
} catch (err) {
const msg =
err.response?.data?.error ||
err.response?.data?.message ||
'Could not load service egress data.';
setEgressError(msg);
} finally {
setEgressLoading(false);
}
}, []);
useEffect(() => {
loadAll();
loadEgress();
}, [loadAll, loadEgress]);
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 }));
};
const handleServiceEgressSaved = (serviceId, exitType) => {
setServiceEgress((prev) => ({
...prev,
[serviceId]: { ...prev[serviceId], exit_via: exitType },
}));
};
// ── Render ──────────────────────────────────────────────────────────────────
return (
{/* Page header */}
Connectivity
Configure exit tunnels and control how each peer's traffic is routed
{/* Loading skeleton */}
{isLoading && (
{[1, 2, 3].map((n) => (
))}
)}
{/* Error state */}
{!isLoading && loadError && (
Failed to load connectivity data
{loadError}
)}
{/* Main content */}
{!isLoading && !loadError && (
{/* Section 1: Exit Tunnels */}
Exit Tunnels
Upload VPN configs or enable Tor to create exit options for your
peers
{/* Apply Routes */}
Apply exit routes
Commit all exit-tunnel changes to the routing table
{/* Section 2: Peer Exit Assignment */}
Peer Exit Assignment
Choose which exit tunnel each WireGuard peer uses
{Object.keys(peerExits).length === 0 ? (
No WireGuard peers found
Add peers on the WireGuard page first, then return here to
assign exits.
) : (
|
Peer Name
|
Current Exit
|
Change Exit
|
|
{Object.entries(peerExits).map(([name, exitVia]) => (
))}
)}
{/* Section 3: Service Egress */}
Service Egress
Route outbound traffic from installed services through a specific
exit. Only services that declare egress support appear here.
{/* Egress loading skeleton */}
{egressLoading && (
)}
{/* Egress error */}
{!egressLoading && egressError && (
Failed to load service egress data
{egressError}
)}
{/* Egress content */}
{!egressLoading && !egressError && (() => {
const serviceIds = Object.keys(serviceEgress);
const exitOptions = buildServiceExitOptions(exits);
const hasConfiguredExits = exitOptions.length > 1;
if (serviceIds.length === 0) {
return (
No services with egress support installed
Install a service from the{' '}
Store
{' '}
that supports egress routing to manage it here.
);
}
return (
<>
{!hasConfiguredExits && (
No exit tunnels are configured yet. Upload a WireGuard or
OpenVPN config above, or enable Tor, to unlock additional
exit options.
)}
|
Service
|
Current Exit
|
Change Exit
|
|
{serviceIds.map((svcId) => (
))}
>
);
})()}
)}
);
}
export default Connectivity;