feat: connectivity — registry-driven peer table, sshuttle/proxy egress, egress UI

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>
This commit is contained in:
2026-06-10 08:36:15 -04:00
parent cc7a223fdf
commit 6232ef23a9
8 changed files with 1096 additions and 61 deletions
+646 -23
View File
@@ -10,8 +10,13 @@ import {
Upload,
ToggleLeft,
ToggleRight,
Layers,
Store,
Server,
Network,
Save,
} from 'lucide-react';
import { connectivityAPI, wireguardAPI } from '../services/api';
import { connectivityAPI, egressAPI } from '../services/api';
// ── Toast helpers (same pattern as Store.jsx) ─────────────────────────────────
@@ -365,13 +370,390 @@ function TorExitCard({ exitInfo, onToggled }) {
);
}
// ── 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', label: 'WireGuard External' },
{ 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 }) {
@@ -439,6 +821,98 @@ function PeerExitRow({ peer, currentExit, onSaved }) {
);
}
// ── 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() {
@@ -446,22 +920,27 @@ function Connectivity() {
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);
// 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, peersRes] = await Promise.all([
const [exitsRes, peerExitsRes] = 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
// 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; });
@@ -470,20 +949,14 @@ function Connectivity() {
setExits(exitsData);
}
const peerExitsData = peerExitsRes.data || {};
// 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
);
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 ||
@@ -495,9 +968,29 @@ function Connectivity() {
}
}, []);
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();
}, [loadAll]);
loadEgress();
}, [loadAll, loadEgress]);
const handleApplyRoutes = async () => {
setApplying(true);
@@ -520,6 +1013,13 @@ function Connectivity() {
setPeerExits((prev) => ({ ...prev, [peerName]: exitVia }));
};
const handleServiceEgressSaved = (serviceId, exitType) => {
setServiceEgress((prev) => ({
...prev,
[serviceId]: { ...prev[serviceId], exit_via: exitType },
}));
};
// ── Render ──────────────────────────────────────────────────────────────────
return (
@@ -594,7 +1094,7 @@ function Connectivity() {
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<WireguardExitCard
exitInfo={exits['wireguard'] || exits['wireguard_external']}
exitInfo={exits['wireguard_ext']}
onUploaded={loadAll}
/>
<OpenvpnExitCard
@@ -605,6 +1105,14 @@ function Connectivity() {
exitInfo={exits['tor']}
onToggled={loadAll}
/>
<SshuttleExitCard
exitInfo={exits['sshuttle']}
onSaved={loadAll}
/>
<ProxyExitCard
exitInfo={exits['proxy']}
onSaved={loadAll}
/>
</div>
{/* Apply Routes */}
@@ -642,7 +1150,7 @@ function Connectivity() {
</p>
</div>
{peers.length === 0 ? (
{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">
@@ -671,11 +1179,11 @@ function Connectivity() {
</tr>
</thead>
<tbody>
{peers.map((peer) => (
{Object.entries(peerExits).map(([name, exitVia]) => (
<PeerExitRow
key={peer.name}
peer={peer}
currentExit={peerExits[peer.name] || 'default'}
key={name}
peer={{ name }}
currentExit={exitVia || 'default'}
onSaved={handlePeerExitSaved}
/>
))}
@@ -684,6 +1192,121 @@ function Connectivity() {
</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>
+11 -3
View File
@@ -117,9 +117,7 @@ export const networkAPI = {
getDNSRecords: () => api.get('/api/dns/records'),
addDNSRecord: (record) => api.post('/api/dns/records', record),
removeDNSRecord: (record) => api.delete('/api/dns/records', { data: record }),
getDHCPLeases: () => api.get('/api/dhcp/leases'),
addDHCPReservation: (reservation) => api.post('/api/dhcp/reservations', reservation),
removeDHCPReservation: (reservation) => api.delete('/api/dhcp/reservations', { data: reservation }),
getDNSOverview: () => api.get('/api/dns/overview'),
getNTPStatus: () => api.get('/api/ntp/status'),
testNetwork: (data) => api.post('/api/network/test', data),
};
@@ -344,6 +342,7 @@ export const ddnsAPI = {
updateConfig: (data) => api.put('/api/ddns', data),
register: () => api.post('/api/ddns/register'),
getStatus: () => api.get('/api/ddns/status'),
syncRecords: () => api.post('/api/ddns/sync'),
};
// Setup Wizard API
@@ -353,12 +352,21 @@ export const setupAPI = {
complete: (payload) => api.post('/api/setup/complete', payload),
};
// Per-service Egress API
export const egressAPI = {
getStatus: () => api.get('/api/egress/status'),
setServiceExit: (serviceId, exitType) =>
api.put(`/api/egress/services/${serviceId}/exit`, { exit_type: exitType }),
};
// 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 }),
configureSshuttle: (cfg) => api.post('/api/connectivity/exits/sshuttle', cfg),
configureProxy: (cfg) => api.post('/api/connectivity/exits/proxy', cfg),
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 }),