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:
bobbanandClaude Sonnet 5 committed 2026-09-14 23:31:19 +02:00
1 parent 3bb0c767b9
commit 9c2742c30f
21 files changed
+1234 -84

No files matched your search

+49
View File
@@ -0,0 +1,49 @@
import type { IntegrationType } from "../db/schema.js";
import type { IntegrationField } from "./types.js";
/**
* Field schema per integration type. Only types with a working adapter appear
* here — the "Add integration" form only offers what's actually implemented.
*/
export const INTEGRATION_FIELDS: Partial<Record<IntegrationType, IntegrationField[]>> = {
tailscale: [
{
key: "tailnet",
label: "Tailnet",
secret: false,
placeholder: "yourorg.github — or - for your default tailnet",
},
{ key: "apiKey", label: "API key", secret: true, type: "password" },
],
};
/** Fixed base URL per integration type, stored on the row for display/reference. */
export const INTEGRATION_BASE_URLS: Partial<Record<IntegrationType, string>> = {
tailscale: "https://api.tailscale.com",
};
export function splitIntegrationConfig(
type: IntegrationType,
input: Record<string, string | boolean>,
): { secretFields: Record<string, string | boolean>; nonSecretFields: Record<string, string | boolean> } {
const fields = INTEGRATION_FIELDS[type] ?? [];
const secretFields: Record<string, string | boolean> = {};
const nonSecretFields: Record<string, string | boolean> = {};
for (const field of fields) {
if (input[field.key] === undefined) continue;
if (field.secret) secretFields[field.key] = input[field.key];
else nonSecretFields[field.key] = input[field.key];
}
return { secretFields, nonSecretFields };
}
export function validateIntegrationConfig(
type: IntegrationType,
merged: Record<string, string | boolean | undefined>,
): string[] {
const fields = INTEGRATION_FIELDS[type] ?? [];
return fields
.filter((f) => f.type !== "checkbox")
.filter((f) => merged[f.key] === undefined || merged[f.key] === "")
.map((f) => f.key);
}
@@ -0,0 +1,31 @@
import { eq } from "drizzle-orm";
import { db } from "../db/client.js";
import { integrations, integrationCredentials } from "../db/schema.js";
import { decryptSecret } from "../crypto.js";
import { createIntegrationAdapter } from "./registry.js";
import type { IntegrationConfig } from "./types.js";
export async function loadIntegrationConfig(integrationId: number) {
const [integration] = await db.select().from(integrations).where(eq(integrations.id, integrationId)).limit(1);
if (!integration) return null;
let secretFields: Record<string, string | boolean> = {};
if (integration.credentialId) {
const [cred] = await db
.select()
.from(integrationCredentials)
.where(eq(integrationCredentials.id, integration.credentialId))
.limit(1);
if (cred) secretFields = JSON.parse(decryptSecret(cred.encryptedSecret));
}
const nonSecretFields: Record<string, string | boolean> = integration.config ? JSON.parse(integration.config) : {};
const merged: IntegrationConfig = { ...nonSecretFields, ...secretFields };
return { integration, config: merged };
}
export async function getIntegrationAdapter(integrationId: number) {
const loaded = await loadIntegrationConfig(integrationId);
if (!loaded) return null;
return { integration: loaded.integration, adapter: createIntegrationAdapter(loaded.integration.type, loaded.config) };
}
+12
View File
@@ -0,0 +1,12 @@
import type { IntegrationType } from "../db/schema.js";
import type { IntegrationConfig } from "./types.js";
import { createTailscaleAdapter, type TailscaleAdapter } from "./tailscale/adapter.js";
export function createIntegrationAdapter(type: IntegrationType, config: IntegrationConfig): TailscaleAdapter {
switch (type) {
case "tailscale":
return createTailscaleAdapter(config as any);
default:
throw new Error(`Integration type "${type}" is not implemented yet`);
}
}
@@ -0,0 +1,99 @@
/**
* Tailscale adapter — uses the Tailscale API.
* Requires config: tailnet, apiKey
*
* API docs: https://tailscale.com/api
*/
const BASE = "https://api.tailscale.com";
export interface TailscaleConfig {
tailnet: string;
apiKey: string;
}
export interface TailscaleDevice {
id: string; // stable numeric ID used for device-level API calls (authorize/delete)
nodeId: string;
hostname: string;
label: string;
addresses: string[];
primaryAddress: string;
os: string;
lastSeen: string | null;
isExitNode: boolean;
authorized: boolean;
online: boolean | null;
}
export interface TailscaleAdapter {
ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }>;
listDevices(): Promise<TailscaleDevice[]>;
setAuthorized(deviceId: string, authorized: boolean): Promise<void>;
deleteDevice(deviceId: string): Promise<void>;
}
export function createTailscaleAdapter(config: TailscaleConfig): TailscaleAdapter {
function headers() {
return {
Authorization: `Bearer ${config.apiKey}`,
Accept: "application/json",
"Content-Type": "application/json",
};
}
function tailnetPath() {
return encodeURIComponent(config.tailnet || "-");
}
async function api(method: string, path: string, body?: unknown): Promise<any> {
const res = await fetch(`${BASE}${path}`, {
method,
headers: headers(),
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (res.status === 204) return null;
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.message || `Tailscale API error: HTTP ${res.status}`);
}
return data;
}
async function listDevices(): Promise<TailscaleDevice[]> {
const data = await api("GET", `/api/v2/tailnet/${tailnetPath()}/devices`);
return (data.devices || []).map((d: any) => ({
id: d.id,
nodeId: d.nodeId || d.id,
hostname: d.hostname || "",
label: d.displayName || d.hostname || "",
addresses: d.addresses || [],
primaryAddress: d.addresses?.[0] || "",
os: d.os || "",
lastSeen: d.lastSeen || null,
isExitNode: !!d.isExitNode,
authorized: !!d.authorized,
online: d.online ?? null,
}));
}
async function deleteDevice(deviceId: string): Promise<void> {
await api("DELETE", `/api/v2/device/${deviceId}`);
}
async function setAuthorized(deviceId: string, authorized: boolean): Promise<void> {
await api("POST", `/api/v2/device/${deviceId}/authorized`, { authorized });
}
async function ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }> {
const start = Date.now();
try {
await api("GET", `/api/v2/tailnet/${tailnetPath()}/devices`);
return { ok: true, latencyMs: Date.now() - start };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
return { ping, listDevices, setAuthorized, deleteDevice };
}
+9
View File
@@ -0,0 +1,9 @@
export interface IntegrationField {
key: string;
label: string;
secret: boolean;
type?: "text" | "password" | "checkbox";
placeholder?: string;
}
export type IntegrationConfig = Record<string, string | boolean | undefined>;