6232ef23a9
The peer table was empty because it was not consulting the peer registry; now peers are driven by PeerRegistry so the Connectivity page reflects actual connected cells. Exit-key handling is unified: all code paths now use the same key derivation so a store-service exit bridge and a manual WireGuard peer both produce consistent routing state. Two new egress exit types are added (sshuttle via SSH tunnel and proxy via redsocks SOCKS5), wiring through connectivity_manager, egress_manager, and app.py routes. This lets a cell route its traffic through an SSH host or a SOCKS5 proxy as an alternative to WireGuard exit nodes. ServiceStoreManager and ServiceBus updated so the egress lifecycle (install / uninstall) is cleanly signalled between components. Connectivity.jsx gains the Service Egress section, letting operators assign and reassign egress methods from the UI without touching config files. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1317 lines
50 KiB
React
1317 lines
50 KiB
React
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 (
|
|
<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>
|
|
);
|
|
}
|
|
|
|
// ── 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 (
|
|
<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-emerald-50 shrink-0">
|
|
<Server className="h-5 w-5 text-emerald-600" />
|
|
</div>
|
|
<div>
|
|
<h3 className="font-semibold text-gray-900">SSH Tunnel</h3>
|
|
<p className="text-sm text-gray-500">
|
|
Route traffic through an SSH server via sshuttle
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<StatusBadge status={status} />
|
|
</div>
|
|
|
|
<div className="grid grid-cols-3 gap-3">
|
|
<div className="col-span-2 flex flex-col gap-1.5">
|
|
<label htmlFor="ssh-host" className="text-sm font-medium text-gray-700">
|
|
Host <span className="text-red-500" aria-hidden="true">*</span>
|
|
</label>
|
|
<input
|
|
id="ssh-host"
|
|
type="text"
|
|
value={host}
|
|
onChange={(e) => setHost(e.target.value)}
|
|
placeholder="ssh.example.com"
|
|
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"
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-1.5">
|
|
<label htmlFor="ssh-port" className="text-sm font-medium text-gray-700">
|
|
Port
|
|
</label>
|
|
<input
|
|
id="ssh-port"
|
|
type="number"
|
|
min="1"
|
|
max="65535"
|
|
value={port}
|
|
onChange={(e) => setPort(e.target.value)}
|
|
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"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-1.5">
|
|
<label htmlFor="ssh-user" className="text-sm font-medium text-gray-700">
|
|
User <span className="text-red-500" aria-hidden="true">*</span>
|
|
</label>
|
|
<input
|
|
id="ssh-user"
|
|
type="text"
|
|
value={user}
|
|
onChange={(e) => 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"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-1.5">
|
|
<span className="text-sm font-medium text-gray-700">Authentication</span>
|
|
<div className="flex rounded-md border border-gray-200 overflow-hidden w-fit" role="group" aria-label="Authentication method">
|
|
<button
|
|
type="button"
|
|
onClick={() => setAuth('key')}
|
|
aria-pressed={auth === 'key'}
|
|
className={`px-3 py-1.5 text-sm font-medium transition-colors ${
|
|
auth === 'key'
|
|
? 'bg-primary-600 text-white'
|
|
: 'bg-white text-gray-600 hover:bg-gray-50'
|
|
}`}
|
|
>
|
|
Private key
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setAuth('password')}
|
|
aria-pressed={auth === 'password'}
|
|
className={`px-3 py-1.5 text-sm font-medium transition-colors ${
|
|
auth === 'password'
|
|
? 'bg-primary-600 text-white'
|
|
: 'bg-white text-gray-600 hover:bg-gray-50'
|
|
}`}
|
|
>
|
|
Password
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{auth === 'key' ? (
|
|
<div className="flex flex-col gap-1.5">
|
|
<label htmlFor="ssh-key" className="text-sm font-medium text-gray-700">
|
|
Private key <span className="text-red-500" aria-hidden="true">*</span>
|
|
</label>
|
|
<textarea
|
|
id="ssh-key"
|
|
value={privateKey}
|
|
onChange={(e) => setPrivateKey(e.target.value)}
|
|
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
|
|
rows={4}
|
|
autoComplete="off"
|
|
spellCheck="false"
|
|
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="ssh-key-hint"
|
|
/>
|
|
<p id="ssh-key-hint" className="text-xs text-gray-400">
|
|
Stored encrypted in the vault. Never displayed again after saving.
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col gap-1.5">
|
|
<label htmlFor="ssh-password" className="text-sm font-medium text-gray-700">
|
|
Password <span className="text-red-500" aria-hidden="true">*</span>
|
|
</label>
|
|
<input
|
|
id="ssh-password"
|
|
type="password"
|
|
value={password}
|
|
onChange={(e) => 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"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex flex-col gap-1.5">
|
|
<label htmlFor="ssh-known-hosts" className="text-sm font-medium text-gray-700">
|
|
Pinned host key (known_hosts line){' '}
|
|
<span className="text-red-500" aria-hidden="true">*</span>
|
|
</label>
|
|
<input
|
|
id="ssh-known-hosts"
|
|
type="text"
|
|
value={knownHosts}
|
|
onChange={(e) => setKnownHosts(e.target.value)}
|
|
placeholder="ssh.example.com ssh-ed25519 AAAA..."
|
|
autoComplete="off"
|
|
spellCheck="false"
|
|
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"
|
|
aria-describedby="ssh-known-hosts-hint"
|
|
/>
|
|
<p id="ssh-known-hosts-hint" className="text-xs text-gray-400">
|
|
Get it with: ssh-keyscan -t ed25519 ssh.example.com
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex justify-end pt-2 border-t border-gray-100">
|
|
<button
|
|
onClick={handleSave}
|
|
disabled={saving || !canSave}
|
|
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="Save SSH tunnel configuration"
|
|
>
|
|
{saving ? (
|
|
<RefreshCw className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<Save className="h-4 w-4" />
|
|
)}
|
|
{saving ? 'Saving…' : 'Save Configuration'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── 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 (
|
|
<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-sky-50 shrink-0">
|
|
<Network className="h-5 w-5 text-sky-600" />
|
|
</div>
|
|
<div>
|
|
<h3 className="font-semibold text-gray-900">Upstream Proxy</h3>
|
|
<p className="text-sm text-gray-500">
|
|
Route traffic through an HTTP or SOCKS5 proxy
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<StatusBadge status={status} />
|
|
</div>
|
|
|
|
<div className="grid grid-cols-3 gap-3">
|
|
<div className="flex flex-col gap-1.5">
|
|
<label htmlFor="proxy-scheme" className="text-sm font-medium text-gray-700">
|
|
Scheme
|
|
</label>
|
|
<div className="relative">
|
|
<select
|
|
id="proxy-scheme"
|
|
value={scheme}
|
|
onChange={(e) => setScheme(e.target.value)}
|
|
className="w-full appearance-none bg-white border border-gray-300 text-sm text-gray-800 rounded-md pl-3 pr-8 py-2 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
|
>
|
|
<option value="socks5">SOCKS5</option>
|
|
<option value="http">HTTP</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>
|
|
</div>
|
|
<div className="flex flex-col gap-1.5">
|
|
<label htmlFor="proxy-host" className="text-sm font-medium text-gray-700">
|
|
Host <span className="text-red-500" aria-hidden="true">*</span>
|
|
</label>
|
|
<input
|
|
id="proxy-host"
|
|
type="text"
|
|
value={host}
|
|
onChange={(e) => setHost(e.target.value)}
|
|
placeholder="proxy.example.com"
|
|
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"
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-1.5">
|
|
<label htmlFor="proxy-port" className="text-sm font-medium text-gray-700">
|
|
Port <span className="text-red-500" aria-hidden="true">*</span>
|
|
</label>
|
|
<input
|
|
id="proxy-port"
|
|
type="number"
|
|
min="1"
|
|
max="65535"
|
|
value={port}
|
|
onChange={(e) => setPort(e.target.value)}
|
|
placeholder="1080"
|
|
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"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="flex flex-col gap-1.5">
|
|
<label htmlFor="proxy-user" className="text-sm font-medium text-gray-700">
|
|
User
|
|
</label>
|
|
<input
|
|
id="proxy-user"
|
|
type="text"
|
|
value={user}
|
|
onChange={(e) => setUser(e.target.value)}
|
|
placeholder="optional"
|
|
autoComplete="off"
|
|
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"
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-1.5">
|
|
<label htmlFor="proxy-password" className="text-sm font-medium text-gray-700">
|
|
Password
|
|
</label>
|
|
<input
|
|
id="proxy-password"
|
|
type="password"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
placeholder="optional"
|
|
autoComplete="new-password"
|
|
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"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-end pt-2 border-t border-gray-100">
|
|
<button
|
|
onClick={handleSave}
|
|
disabled={saving || !canSave}
|
|
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="Save proxy configuration"
|
|
>
|
|
{saving ? (
|
|
<RefreshCw className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<Save className="h-4 w-4" />
|
|
)}
|
|
{saving ? 'Saving…' : 'Save Configuration'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── 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 (
|
|
<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>
|
|
);
|
|
}
|
|
|
|
// ── 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 (
|
|
<tr className="border-t border-gray-100">
|
|
<td className="py-3 px-4 text-sm font-medium text-gray-900 truncate max-w-[180px]">
|
|
{serviceId}
|
|
</td>
|
|
<td className="py-3 px-4">
|
|
<span className="text-sm text-gray-500">{currentLabel}</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 egress exit for ${serviceId}`}
|
|
>
|
|
{exitOptions.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 egress assignment for ${serviceId}`}
|
|
>
|
|
{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 [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 (
|
|
<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_ext']}
|
|
onUploaded={loadAll}
|
|
/>
|
|
<OpenvpnExitCard
|
|
exitInfo={exits['openvpn']}
|
|
onUploaded={loadAll}
|
|
/>
|
|
<TorExitCard
|
|
exitInfo={exits['tor']}
|
|
onToggled={loadAll}
|
|
/>
|
|
<SshuttleExitCard
|
|
exitInfo={exits['sshuttle']}
|
|
onSaved={loadAll}
|
|
/>
|
|
<ProxyExitCard
|
|
exitInfo={exits['proxy']}
|
|
onSaved={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>
|
|
|
|
{Object.keys(peerExits).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>
|
|
{Object.entries(peerExits).map(([name, exitVia]) => (
|
|
<PeerExitRow
|
|
key={name}
|
|
peer={{ name }}
|
|
currentExit={exitVia || 'default'}
|
|
onSaved={handlePeerExitSaved}
|
|
/>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
{/* Section 3: Service Egress */}
|
|
<section>
|
|
<div className="mb-4">
|
|
<h2 className="text-base font-semibold text-gray-900">
|
|
Service Egress
|
|
</h2>
|
|
<p className="text-sm text-gray-500">
|
|
Route outbound traffic from installed services through a specific
|
|
exit. Only services that declare egress support appear here.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Egress loading skeleton */}
|
|
{egressLoading && (
|
|
<div className="bg-white rounded-lg border border-gray-200 p-6 animate-pulse space-y-3">
|
|
<div className="h-4 bg-gray-200 rounded w-1/3" />
|
|
<div className="h-4 bg-gray-100 rounded w-1/2" />
|
|
<div className="h-4 bg-gray-100 rounded w-2/5" />
|
|
</div>
|
|
)}
|
|
|
|
{/* Egress error */}
|
|
{!egressLoading && egressError && (
|
|
<div className="bg-red-50 rounded-lg border border-red-200 p-4">
|
|
<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 service egress data
|
|
</p>
|
|
<p className="text-sm text-red-600 mt-1">{egressError}</p>
|
|
</div>
|
|
<button
|
|
onClick={loadEgress}
|
|
className="btn-secondary text-sm shrink-0"
|
|
>
|
|
Retry
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Egress content */}
|
|
{!egressLoading && !egressError && (() => {
|
|
const serviceIds = Object.keys(serviceEgress);
|
|
const exitOptions = buildServiceExitOptions(exits);
|
|
const hasConfiguredExits = exitOptions.length > 1;
|
|
|
|
if (serviceIds.length === 0) {
|
|
return (
|
|
<div className="bg-white rounded-lg border border-gray-200 py-12 text-center">
|
|
<Layers className="h-10 w-10 text-gray-300 mx-auto mb-3" />
|
|
<p className="text-sm font-medium text-gray-500">
|
|
No services with egress support installed
|
|
</p>
|
|
<p className="text-xs text-gray-400 mt-1">
|
|
Install a service from the{' '}
|
|
<a
|
|
href="/store"
|
|
className="text-primary-600 hover:underline"
|
|
>
|
|
Store
|
|
</a>{' '}
|
|
that supports egress routing to manage it here.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{!hasConfiguredExits && (
|
|
<div className="mb-3 flex items-start gap-2 rounded-lg border border-yellow-200 bg-yellow-50 px-4 py-3">
|
|
<AlertCircle className="h-4 w-4 text-yellow-600 mt-0.5 shrink-0" />
|
|
<p className="text-sm text-yellow-800">
|
|
No exit tunnels are configured yet. Upload a WireGuard or
|
|
OpenVPN config above, or enable Tor, to unlock additional
|
|
exit options.
|
|
</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">
|
|
Service
|
|
</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>
|
|
{serviceIds.map((svcId) => (
|
|
<ServiceEgressRow
|
|
key={svcId}
|
|
serviceId={svcId}
|
|
currentExit={serviceEgress[svcId]?.exit_via || 'default'}
|
|
exitOptions={exitOptions}
|
|
onSaved={handleServiceEgressSaved}
|
|
/>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</>
|
|
);
|
|
})()}
|
|
</section>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default Connectivity;
|