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:
parent
0cc86474e9
commit
7524690ebd
7 changed files with 208 additions and 6 deletions
97
backend/package-lock.json
generated
97
backend/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
93
backend/src/integrations/ssh.ts
Normal file
93
backend/src/integrations/ssh.ts
Normal 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()
|
||||
}
|
||||
},
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ export type IntegrationType =
|
|||
| 'aws'
|
||||
| 'uptime_kuma'
|
||||
| 'weather'
|
||||
| 'ssh'
|
||||
|
||||
export interface IntegrationConfig {
|
||||
[key: string]: string
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ const integrationTypes = [
|
|||
'aws',
|
||||
'uptime_kuma',
|
||||
'weather',
|
||||
'ssh',
|
||||
] as const
|
||||
|
||||
const createSchema = z.object({
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue