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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BbJV5nm8KPVH1oNJYKpnoF
This commit is contained in:
Claude 2026-06-18 21:06:16 +00:00
parent 0cc86474e9
commit 7524690ebd
No known key found for this signature in database
7 changed files with 208 additions and 6 deletions

View file

@ -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",

View file

@ -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": {

View file

@ -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<IntegrationType, IntegrationAdapter> = {
uptime_kuma: uptimeKuma,
@ -21,4 +16,5 @@ export const adapterRegistry: Record<IntegrationType, IntegrationAdapter> = {
cloudflare,
aws,
weather,
ssh,
}

View file

@ -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<string, string>, secrets: Record<string, string>): Promise<Client> {
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<string> {
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<string, string>, secrets: Record<string, string>): 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<Resource[]> {
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()
}
},
}

View file

@ -6,6 +6,7 @@ export type IntegrationType =
| 'aws'
| 'uptime_kuma'
| 'weather'
| 'ssh'
export interface IntegrationConfig {
[key: string]: string

View file

@ -13,6 +13,7 @@ const integrationTypes = [
'aws',
'uptime_kuma',
'weather',
'ssh',
] as const
const createSchema = z.object({

View file

@ -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 = {