From 7524690ebd59c5302a3a6d5e28f3161c24ff9626 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 21:06:16 +0000 Subject: [PATCH] Add SSH integration adapter for local infra without an API Many self-hosted machines have no management API, so add an SSH-based adapter (using ssh2) that connects with password or key auth and probes hostname/disk/mem/load via a single shell command, surfacing health status like the other integrations. Also fixes routes/integrations.ts's hardcoded type enum, which was out of sync with the IntegrationType union and rejected the new 'ssh' type. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BbJV5nm8KPVH1oNJYKpnoF --- backend/package-lock.json | 97 ++++++++++++++++++++++++++++ backend/package.json | 2 + backend/src/integrations/registry.ts | 8 +-- backend/src/integrations/ssh.ts | 93 ++++++++++++++++++++++++++ backend/src/integrations/types.ts | 1 + backend/src/routes/integrations.ts | 1 + src/pages/Settings.tsx | 12 ++++ 7 files changed, 208 insertions(+), 6 deletions(-) create mode 100644 backend/src/integrations/ssh.ts diff --git a/backend/package-lock.json b/backend/package-lock.json index 29e5a6b..b76b1d8 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -12,10 +12,12 @@ "@aws-sdk/client-sts": "^3.1072.0", "@fastify/cors": "^10.0.1", "@fastify/jwt": "^9.0.4", + "@types/ssh2": "^1.15.5", "bcryptjs": "^2.4.3", "better-sqlite3": "^11.8.1", "dotenv": "^16.6.1", "fastify": "^5.2.1", + "ssh2": "^1.17.0", "zod": "^3.24.1" }, "devDependencies": { @@ -1188,6 +1190,30 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/ssh2": { + "version": "1.15.5", + "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.5.tgz", + "integrity": "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==", + "license": "MIT", + "dependencies": { + "@types/node": "^18.11.18" + } + }, + "node_modules/@types/ssh2/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/ssh2/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "license": "MIT" + }, "node_modules/abstract-logging": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", @@ -1239,6 +1265,15 @@ ], "license": "MIT" }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, "node_modules/asn1.js": { "version": "5.4.1", "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", @@ -1300,6 +1335,15 @@ ], "license": "MIT" }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, "node_modules/bcryptjs": { "version": "2.4.3", "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", @@ -1373,6 +1417,15 @@ "ieee754": "^1.1.13" } }, + "node_modules/buildcheck": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz", + "integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==", + "optional": true, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/chownr": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", @@ -1392,6 +1445,20 @@ "url": "https://opencollective.com/express" } }, + "node_modules/cpu-features": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", + "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "buildcheck": "~0.0.6", + "nan": "^2.19.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -1910,6 +1977,13 @@ "obliterator": "^2.0.4" } }, + "node_modules/nan": { + "version": "2.27.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.27.0.tgz", + "integrity": "sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ==", + "license": "MIT", + "optional": true + }, "node_modules/napi-build-utils": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", @@ -2289,6 +2363,23 @@ "node": ">= 10.x" } }, + "node_modules/ssh2": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz", + "integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==", + "hasInstallScript": true, + "dependencies": { + "asn1": "^0.2.6", + "bcrypt-pbkdf": "^1.0.2" + }, + "engines": { + "node": ">=10.16.0" + }, + "optionalDependencies": { + "cpu-features": "~0.0.10", + "nan": "^2.23.0" + } + }, "node_modules/steed": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/steed/-/steed-1.1.3.tgz", @@ -2427,6 +2518,12 @@ "node": "*" } }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "license": "Unlicense" + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", diff --git a/backend/package.json b/backend/package.json index f09b3cf..6a61b30 100644 --- a/backend/package.json +++ b/backend/package.json @@ -13,10 +13,12 @@ "@aws-sdk/client-sts": "^3.1072.0", "@fastify/cors": "^10.0.1", "@fastify/jwt": "^9.0.4", + "@types/ssh2": "^1.15.5", "bcryptjs": "^2.4.3", "better-sqlite3": "^11.8.1", "dotenv": "^16.6.1", "fastify": "^5.2.1", + "ssh2": "^1.17.0", "zod": "^3.24.1" }, "devDependencies": { diff --git a/backend/src/integrations/registry.ts b/backend/src/integrations/registry.ts index 0d791f2..40e193d 100644 --- a/backend/src/integrations/registry.ts +++ b/backend/src/integrations/registry.ts @@ -6,12 +6,7 @@ import { netbird } from './netbird.js' import { cloudflare } from './cloudflare.js' import { weather } from './weather.js' import { aws } from './aws.js' - -const notImplemented: IntegrationAdapter = { - async testConnection() { - return { ok: false, message: 'Test connection not yet implemented for this integration type' } - }, -} +import { ssh } from './ssh.js' export const adapterRegistry: Record = { uptime_kuma: uptimeKuma, @@ -21,4 +16,5 @@ export const adapterRegistry: Record = { cloudflare, aws, weather, + ssh, } diff --git a/backend/src/integrations/ssh.ts b/backend/src/integrations/ssh.ts new file mode 100644 index 0000000..028e6ba --- /dev/null +++ b/backend/src/integrations/ssh.ts @@ -0,0 +1,93 @@ +import { Client } from 'ssh2' +import type { IntegrationAdapter, Resource } from './types.js' + +const PROBE_CMD = + 'echo HOSTNAME:$(hostname); echo DISK:$(df -P / | awk \'NR==2{print $5}\' | tr -d \'%\'); echo MEM:$(free | awk \'/Mem:/{printf "%.0f", $3/$2*100}\'); echo LOAD:$(cut -d\' \' -f1 /proc/loadavg)' + +function connect(config: Record, secrets: Record): Promise { + return new Promise((resolve, reject) => { + const conn = new Client() + conn.on('ready', () => resolve(conn)) + conn.on('error', (err) => reject(err)) + conn.connect({ + host: config.host, + port: Number(config.port) || 22, + username: config.username, + password: secrets.password || undefined, + privateKey: secrets.privateKey || undefined, + passphrase: secrets.passphrase || undefined, + readyTimeout: 8000, + }) + }) +} + +function exec(conn: Client, command: string): Promise { + return new Promise((resolve, reject) => { + conn.exec(command, (err, stream) => { + if (err) return reject(err) + let output = '' + stream.on('data', (chunk: Buffer) => { output += chunk.toString() }) + stream.stderr.on('data', () => {}) + stream.on('close', () => resolve(output)) + stream.on('error', reject) + }) + }) +} + +function requireConfig(config: Record, secrets: Record): string | null { + if (!config.host) return 'Missing host' + if (!config.username) return 'Missing username' + if (!secrets.password && !secrets.privateKey) return 'Missing password or private key' + return null +} + +export const ssh: IntegrationAdapter = { + async testConnection(config, secrets) { + const missing = requireConfig(config, secrets) + if (missing) return { ok: false, message: missing } + try { + const conn = await connect(config, secrets) + conn.end() + return { ok: true, message: 'Connected' } + } catch (err) { + return { ok: false, message: err instanceof Error ? err.message : 'Connection failed' } + } + }, + + async listResources(config, secrets): Promise { + if (requireConfig(config, secrets)) return [] + let conn: Client + try { + conn = await connect(config, secrets) + } catch { + return [] + } + try { + const output = await exec(conn, PROBE_CMD) + const hostname = output.match(/HOSTNAME:(.+)/)?.[1]?.trim() || config.host + const disk = Number(output.match(/DISK:(\d+)/)?.[1]) + const mem = Number(output.match(/MEM:(\d+)/)?.[1]) + const load = output.match(/LOAD:([\d.]+)/)?.[1] + + const parts: string[] = [] + if (!Number.isNaN(disk)) parts.push(`Disk ${disk}%`) + if (!Number.isNaN(mem)) parts.push(`Mem ${mem}%`) + if (load) parts.push(`Load ${load}`) + + const critical = disk >= 90 || mem >= 90 + const warning = disk >= 75 || mem >= 75 + + return [ + { + name: hostname, + status: critical ? 'critical' : warning ? 'warning' : 'healthy', + detail: parts.join(' ยท ') || undefined, + }, + ] + } catch { + return [] + } finally { + conn.end() + } + }, +} diff --git a/backend/src/integrations/types.ts b/backend/src/integrations/types.ts index c25ab42..184f93f 100644 --- a/backend/src/integrations/types.ts +++ b/backend/src/integrations/types.ts @@ -6,6 +6,7 @@ export type IntegrationType = | 'aws' | 'uptime_kuma' | 'weather' + | 'ssh' export interface IntegrationConfig { [key: string]: string diff --git a/backend/src/routes/integrations.ts b/backend/src/routes/integrations.ts index 6380405..3c6198c 100644 --- a/backend/src/routes/integrations.ts +++ b/backend/src/routes/integrations.ts @@ -13,6 +13,7 @@ const integrationTypes = [ 'aws', 'uptime_kuma', 'weather', + 'ssh', ] as const const createSchema = z.object({ diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 299c850..30e6d23 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -46,6 +46,18 @@ const integrationTypeDefs: { type: string; name: string; fields: FieldDef[] }[] { type: 'aws', name: 'AWS', fields: [{ key: 'accessKey', label: 'Access Key' }, { key: 'secretKey', label: 'Secret', secret: true }, { key: 'region', label: 'Region' }] }, { type: 'uptime_kuma', name: 'Uptime Kuma', fields: [{ key: 'baseUrl', label: 'URL' }, { key: 'apiKey', label: 'API Key', secret: true }] }, { type: 'weather', name: 'Weather API', fields: [{ key: 'location', label: 'Location' }, { key: 'units', label: 'Units' }] }, + { + type: 'ssh', + name: 'SSH Host', + fields: [ + { key: 'host', label: 'Host / IP' }, + { key: 'port', label: 'Port (default 22)' }, + { key: 'username', label: 'Username' }, + { key: 'password', label: 'Password', secret: true }, + { key: 'privateKey', label: 'Private Key (PEM)', secret: true }, + { key: 'passphrase', label: 'Key Passphrase', secret: true }, + ], + }, ] const cardBase: React.CSSProperties = {