dev_arc_aws/backend/src/routes/integrations.ts

158 lines
5.8 KiB
TypeScript
Raw Normal View History

import type { FastifyInstance } from 'fastify'
import { z } from 'zod'
import { db, logEvent } from '../db/index.js'
Add Phase 1a: core SSH terminal (Termix migration) Implements the minimal-viable terminal described in TERMIX_MIGRATION.md Phase 1a: a real interactive SSH session in the browser over a WebSocket, using xterm.js on the frontend and ssh2 on the backend. Reuses ArchNest's existing SSH integrations (host/port/username/ password/privateKey/passphrase) instead of introducing a second, duplicate host-management system the way Termix has one. Backend: new /api/terminal WebSocket route (registered via @fastify/websocket) handling connect/input/resize/disconnect messages, authenticated via a JWT passed as a query param (browsers can't set custom headers on the WS handshake). Extracted the integration secret loader out of routes/integrations.ts into db/secrets.ts so the new terminal route can reuse it without duplicating the decrypt logic. Frontend: new Terminal.tsx page listing configured SSH hosts and rendering an xterm.js terminal wired to the WebSocket; wired into App.tsx at /terminal. vite.config.ts's dev proxy now forwards WebSocket upgrades (ws: true) so this works under `npm run dev`. Verified end-to-end against a real (test) ssh2-based SSH server: connect, shell banner, keystroke echo, and prompt redraw all worked correctly over the actual WebSocket protocol. Deliberately deferred to Phase 1b/1c per the migration doc: jump-host chaining, tab/split-pane UI, terminal theme/font settings, OPKSSH cert auth, tmux session monitor, session recording. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BbJV5nm8KPVH1oNJYKpnoF
2026-06-19 10:52:04 +00:00
import { encryptSecret } from '../db/crypto.js'
import { loadSecrets } from '../db/secrets.js'
import { adapterRegistry } from '../integrations/registry.js'
import type { IntegrationType, Resource } from '../integrations/types.js'
const integrationTypes = [
'proxmox',
'docker',
'netbird',
'cloudflare',
'aws',
'uptime_kuma',
'weather',
'ssh',
'remote_desktop',
] as const
const createSchema = z.object({
type: z.enum(integrationTypes),
name: z.string().min(1).max(128),
config: z.record(z.string(), z.string()).default({}),
secrets: z.record(z.string(), z.string()).default({}),
})
interface IntegrationRow {
id: number
type: string
name: string
enabled: number
status: string
config_json: string
last_checked_at: string | null
created_at: string
}
function serialize(row: IntegrationRow) {
return {
id: row.id,
type: row.type,
name: row.name,
enabled: !!row.enabled,
status: row.status,
config: JSON.parse(row.config_json),
lastCheckedAt: row.last_checked_at,
createdAt: row.created_at,
}
}
export async function integrationRoutes(app: FastifyInstance) {
app.addHook('onRequest', app.authenticate)
app.get('/api/integrations', async () => {
const rows = db.prepare('SELECT * FROM integrations ORDER BY created_at').all() as IntegrationRow[]
return { integrations: rows.map(serialize) }
})
app.post('/api/integrations', async (req, reply) => {
const parsed = createSchema.safeParse(req.body)
if (!parsed.success) {
return reply.code(400).send({ error: parsed.error.issues[0]?.message ?? 'Invalid input' })
}
const { type, name, config, secrets } = parsed.data
const result = db
.prepare('INSERT INTO integrations (type, name, config_json) VALUES (?, ?, ?)')
.run(type, name, JSON.stringify(config))
const integrationId = Number(result.lastInsertRowid)
const insertSecret = db.prepare(
'INSERT INTO secrets (integration_id, key, value_encrypted) VALUES (?, ?, ?)'
)
for (const [key, value] of Object.entries(secrets)) {
insertSecret.run(integrationId, key, encryptSecret(value))
}
const row = db.prepare('SELECT * FROM integrations WHERE id = ?').get(integrationId) as IntegrationRow
logEvent('integration_created', `${name} integration added`, type)
return reply.code(201).send({ integration: serialize(row) })
})
app.put('/api/integrations/:id', async (req, reply) => {
const id = Number((req.params as { id: string }).id)
const parsed = createSchema.partial().safeParse(req.body)
if (!parsed.success) {
return reply.code(400).send({ error: parsed.error.issues[0]?.message ?? 'Invalid input' })
}
const existing = db.prepare('SELECT * FROM integrations WHERE id = ?').get(id) as
| IntegrationRow
| undefined
if (!existing) return reply.code(404).send({ error: 'Not found' })
const name = parsed.data.name ?? existing.name
const config = parsed.data.config ?? JSON.parse(existing.config_json)
db.prepare('UPDATE integrations SET name = ?, config_json = ? WHERE id = ?').run(
name,
JSON.stringify(config),
id
)
if (parsed.data.secrets) {
const upsert = db.prepare(
`INSERT INTO secrets (integration_id, key, value_encrypted) VALUES (?, ?, ?)
ON CONFLICT(integration_id, key) DO UPDATE SET value_encrypted = excluded.value_encrypted`
)
for (const [key, value] of Object.entries(parsed.data.secrets)) {
upsert.run(id, key, encryptSecret(value))
}
}
const row = db.prepare('SELECT * FROM integrations WHERE id = ?').get(id) as IntegrationRow
return { integration: serialize(row) }
})
app.delete('/api/integrations/:id', async (req, reply) => {
const id = Number((req.params as { id: string }).id)
const existing = db.prepare('SELECT * FROM integrations WHERE id = ?').get(id) as IntegrationRow | undefined
db.prepare('DELETE FROM integrations WHERE id = ?').run(id)
if (existing) logEvent('integration_deleted', `${existing.name} integration removed`, existing.type)
return reply.code(204).send()
})
app.post('/api/integrations/:id/test', async (req, reply) => {
const id = Number((req.params as { id: string }).id)
const row = db.prepare('SELECT * FROM integrations WHERE id = ?').get(id) as
| IntegrationRow
| undefined
if (!row) return reply.code(404).send({ error: 'Not found' })
const adapter = adapterRegistry[row.type as IntegrationType]
const config = JSON.parse(row.config_json)
const secrets = loadSecrets(id)
const result = await adapter.testConnection(config, secrets)
const status = result.ok ? 'connected' : 'error'
db.prepare("UPDATE integrations SET status = ?, last_checked_at = datetime('now') WHERE id = ?").run(
status,
id
)
logEvent('integration_tested', `${row.name} test ${result.ok ? 'succeeded' : 'failed'}`, row.type)
return result
})
app.get('/api/integrations/resources', async () => {
const rows = db.prepare("SELECT * FROM integrations WHERE enabled = 1 AND status = 'connected'").all() as IntegrationRow[]
const resources: (Resource & { integration: string })[] = []
for (const row of rows) {
const adapter = adapterRegistry[row.type as IntegrationType]
if (!adapter.listResources) continue
const config = JSON.parse(row.config_json)
const secrets = loadSecrets(row.id)
try {
const found = await adapter.listResources(config, secrets)
for (const r of found) resources.push({ ...r, integration: row.name })
} catch {
// adapter unreachable — skip, connection test already surfaces this
}
}
return { resources }
})
}