Add Tailscale integration and harden all routes against crash-on-throw
First of the six planned live integrations (Proxmox, Synology, Semaphore, Tailscale, Gitea, Dockhand), reusing Sloth Manager's existing Tailscale adapter logic. Generalizes the "integrations" table already scaffolded in the foundation pass into a working config-in-UI + encrypted-credentials flow, following the same pattern as the DNS providers module — an "Add integration" form only offers types with an implemented adapter (currently just Tailscale), so the framework is ready for the next five integrations without further schema/plumbing changes. - server/src/integrations/tailscale/adapter.ts: ported from Sloth Manager's backend/src/adapters/tailscale.js — listDevices/setAuthorized/deleteDevice against the Tailscale API, now config-based (tailnet + apiKey) instead of reading process.env, and returning a ping() result instead of throwing. - server/src/routes/integrations.ts: generic integration CRUD (admin) + a "test connection" endpoint, plus Tailscale-specific device routes (dashboard-and-basic-actions depth per the plan: authorize/deauthorize/ remove, gated to operator+, audit-logged). - web: an Integrations page (provider-style manage/browse split, matching the DNS page's UX) with a device table, and a live Tailscale widget on the Dashboard. Bug found and fixed while testing: hitting the Tailscale device routes on a non-Tailscale integration row crashed the ENTIRE server process, not just that request — the generic adapter registry throws for unimplemented types, and that throw happened inside an async handler with no surrounding try/catch, which Express 4 doesn't catch, so it became an unhandled rejection that (on modern Node) kills the process. Fixed at the source (check the row's type before ever constructing an adapter) and, since the same "a helper throws before any local try/catch runs" shape existed wherever a route calls into loadDnsProviderConfig/loadIntegrationConfig (both call decryptSecret, which throws if CREDENTIALS_ENCRYPTION_KEY is ever wrong/missing after data was already encrypted with a different key), added a small asyncHandler() wrapper and applied it to every route handler across every router — a single bad request should never be able to take the whole app down for every user. Verified: full build passes. Fresh HTTP-layer tests against a running server (17 checks) cover not-implemented-type rejection, missing-field validation, a real network call to api.tailscale.com with a bogus key (clean ok:false, not a crash), role gating at every tier, credential non-leakage, disabled-integration blocking, and the wrong_type case that originally crashed the server — confirmed it now returns 400 cleanly and the server stays up. Re-ran the existing DNS (13 checks) and Servers/Tasks suites afterward to confirm the asyncHandler sweep didn't regress anything — all passing. Authorizing/removing a real device still needs a real Tailscale API key to verify end-to-end. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3bb0c767b9
commit
9c2742c30f
21 files changed
+1234
-84
No files matched your search
@@ -0,0 +1,292 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
api,
|
||||
type CurrentUser,
|
||||
type IntegrationSummary,
|
||||
type IntegrationType,
|
||||
type TailscaleDevice,
|
||||
type TailscaleDevicesResponse,
|
||||
} from "../api/client";
|
||||
import IntegrationForm from "../components/IntegrationForm";
|
||||
|
||||
const TYPE_LABELS: Record<IntegrationType, string> = {
|
||||
tailscale: "Tailscale",
|
||||
proxmox: "Proxmox",
|
||||
synology: "Synology",
|
||||
semaphore: "Semaphore",
|
||||
gitea: "Gitea",
|
||||
dockhand: "Dockhand",
|
||||
};
|
||||
|
||||
export default function Integrations({ user }: { user: CurrentUser }) {
|
||||
const isAdmin = user.role === "admin";
|
||||
const canEdit = user.role === "admin" || user.role === "operator";
|
||||
|
||||
const [integrations, setIntegrations] = useState<IntegrationSummary[] | null>(null);
|
||||
const [managing, setManaging] = useState(false);
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [tailscaleData, setTailscaleData] = useState<TailscaleDevicesResponse | null>(null);
|
||||
const [tailscaleError, setTailscaleError] = useState<string | null>(null);
|
||||
const [loadingDevices, setLoadingDevices] = useState(false);
|
||||
|
||||
function loadIntegrations() {
|
||||
api.integrations
|
||||
.list()
|
||||
.then((res) => {
|
||||
setIntegrations(res.integrations);
|
||||
if (!selectedId && res.integrations.length > 0) setSelectedId(res.integrations[0].id);
|
||||
})
|
||||
.catch((err) => setError(String(err)));
|
||||
}
|
||||
|
||||
useEffect(loadIntegrations, []);
|
||||
|
||||
const selected = integrations?.find((i) => i.id === selectedId) ?? null;
|
||||
|
||||
function loadTailscaleDevices(id: number) {
|
||||
setLoadingDevices(true);
|
||||
setTailscaleError(null);
|
||||
api.integrations.tailscale
|
||||
.devices(id)
|
||||
.then((res) => setTailscaleData(res))
|
||||
.catch((err) => setTailscaleError(err instanceof Error ? err.message : String(err)))
|
||||
.finally(() => setLoadingDevices(false));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (selected?.type === "tailscale" && selected.enabled && !managing) {
|
||||
loadTailscaleDevices(selected.id);
|
||||
} else {
|
||||
setTailscaleData(null);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedId, managing, integrations]);
|
||||
|
||||
async function toggleEnabled(i: IntegrationSummary) {
|
||||
try {
|
||||
await api.integrations.update(i.id, { enabled: !i.enabled });
|
||||
loadIntegrations();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function removeIntegration(i: IntegrationSummary) {
|
||||
if (!confirm(`Delete integration "${i.name}"?`)) return;
|
||||
try {
|
||||
await api.integrations.remove(i.id);
|
||||
if (selectedId === i.id) setSelectedId(null);
|
||||
loadIntegrations();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function setDeviceAuthorized(device: TailscaleDevice, authorized: boolean) {
|
||||
if (!selectedId) return;
|
||||
try {
|
||||
await api.integrations.tailscale.setAuthorized(selectedId, device.id, authorized);
|
||||
loadTailscaleDevices(selectedId);
|
||||
} catch (err) {
|
||||
setTailscaleError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function removeDevice(device: TailscaleDevice) {
|
||||
if (!selectedId) return;
|
||||
if (!confirm(`Remove device "${device.label || device.hostname}" from the tailnet?`)) return;
|
||||
try {
|
||||
await api.integrations.tailscale.remove(selectedId, device.id);
|
||||
loadTailscaleDevices(selectedId);
|
||||
} catch (err) {
|
||||
setTailscaleError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="d-flex align-items-center mb-3">
|
||||
<h2 className="page-title mb-0">Integrations</h2>
|
||||
{isAdmin && (
|
||||
<button className="btn btn-outline-secondary ms-auto" onClick={() => setManaging((v) => !v)}>
|
||||
{managing ? "Back to browsing" : "Manage integrations"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{error && <div className="alert alert-danger">{error}</div>}
|
||||
|
||||
{managing ? (
|
||||
<>
|
||||
<IntegrationForm onCreated={loadIntegrations} onCancel={() => setManaging(false)} />
|
||||
<div className="card">
|
||||
<div className="table-responsive">
|
||||
<table className="table table-vcenter card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Status</th>
|
||||
<th className="w-1">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{integrations?.map((i) => (
|
||||
<tr key={i.id}>
|
||||
<td>{i.name}</td>
|
||||
<td>{TYPE_LABELS[i.type]}</td>
|
||||
<td>
|
||||
<button
|
||||
className={`badge ${i.enabled ? "bg-green-lt text-green" : "bg-secondary-lt text-secondary"} border-0`}
|
||||
onClick={() => toggleEnabled(i)}
|
||||
>
|
||||
{i.enabled ? "Enabled" : "Disabled"}
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<button className="btn btn-sm btn-outline-danger" onClick={() => removeIntegration(i)}>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{integrations?.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="text-secondary text-center">
|
||||
No integrations configured yet.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{integrations?.length === 0 ? (
|
||||
<div className="card">
|
||||
<div className="card-body text-secondary">
|
||||
No integrations configured yet.
|
||||
{isAdmin && (
|
||||
<>
|
||||
{" "}
|
||||
<button className="btn btn-link p-0 align-baseline" onClick={() => setManaging(true)}>
|
||||
Add one
|
||||
</button>
|
||||
.
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-3" style={{ maxWidth: 320 }}>
|
||||
<select
|
||||
className="form-select"
|
||||
value={selectedId ?? ""}
|
||||
onChange={(e) => setSelectedId(Number(e.target.value))}
|
||||
>
|
||||
{integrations?.map((i) => (
|
||||
<option key={i.id} value={i.id} disabled={!i.enabled}>
|
||||
{i.name} ({TYPE_LABELS[i.type]}){!i.enabled ? " — disabled" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{selected?.type === "tailscale" ? (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">
|
||||
Devices
|
||||
{tailscaleData && (
|
||||
<span className="text-secondary fw-normal ms-2">
|
||||
{tailscaleData.summary.online}/{tailscaleData.summary.total} online
|
||||
{tailscaleData.summary.unauthorized > 0 && `, ${tailscaleData.summary.unauthorized} awaiting authorization`}
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
<div className="card-actions">
|
||||
<button className="btn btn-sm btn-outline-secondary" onClick={() => loadTailscaleDevices(selected.id)} disabled={loadingDevices}>
|
||||
{loadingDevices ? "Refreshing…" : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{tailscaleError && <div className="alert alert-danger m-3 mb-0">{tailscaleError}</div>}
|
||||
<div className="table-responsive">
|
||||
<table className="table table-vcenter card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Device</th>
|
||||
<th>Address</th>
|
||||
<th>OS</th>
|
||||
<th>Status</th>
|
||||
<th>Last seen</th>
|
||||
{canEdit && <th className="w-1">Actions</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tailscaleData?.devices.map((d) => (
|
||||
<tr key={d.id}>
|
||||
<td>
|
||||
{d.label || d.hostname}
|
||||
{d.isExitNode && <span className="badge bg-purple-lt ms-2">Exit node</span>}
|
||||
</td>
|
||||
<td className="text-secondary">{d.primaryAddress}</td>
|
||||
<td>{d.os || "—"}</td>
|
||||
<td>
|
||||
{d.online ? (
|
||||
<span className="badge bg-green-lt text-green me-1">Online</span>
|
||||
) : (
|
||||
<span className="badge bg-secondary-lt text-secondary me-1">Offline</span>
|
||||
)}
|
||||
{!d.authorized && <span className="badge bg-yellow-lt text-yellow">Unauthorized</span>}
|
||||
</td>
|
||||
<td className="text-secondary">{d.lastSeen ? new Date(d.lastSeen).toLocaleString() : "—"}</td>
|
||||
{canEdit && (
|
||||
<td>
|
||||
<div className="btn-list flex-nowrap">
|
||||
{d.authorized ? (
|
||||
<button className="btn btn-sm" onClick={() => setDeviceAuthorized(d, false)}>
|
||||
Deauthorize
|
||||
</button>
|
||||
) : (
|
||||
<button className="btn btn-sm btn-primary" onClick={() => setDeviceAuthorized(d, true)}>
|
||||
Authorize
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-sm btn-outline-danger" onClick={() => removeDevice(d)}>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
{tailscaleData?.devices.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={canEdit ? 6 : 5} className="text-secondary text-center">
|
||||
No devices in this tailnet.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card">
|
||||
<div className="card-body text-secondary">
|
||||
{selected ? `${TYPE_LABELS[selected.type]} isn't wired up in the UI yet.` : "Select an integration."}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user