dev_arc_aws/backend/src/routes/integrations.ts

162 lines
6.1 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) {
Show saved indicator for secret fields instead of appearing deleted (#18) * Add editable display-name field to generic integrations Lets users set a custom name for Proxmox, Docker, AWS, Remote Desktop, Netbird, Cloudflare, Uptime Kuma, and Weather integrations, separate from the host/IP field, mirroring the SSH host rename pattern. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4 * Surface the new-integration name field as a labeled input The name field for new generic integrations was a faint header input with only placeholder text, easy to miss. Move it into the form grid as a proper labeled "Name" field next to the other connection fields. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4 * Add file upload for SSH private key and certificate fields Lets users pick a key file from disk (e.g. ~/.ssh) instead of pasting its contents into the Private Key / OPKSSH Certificate fields. * Fix SSH private key paste corrupting multi-line PEM format Private Key and Certificate fields were single-line <input> elements, which strip newlines on paste and corrupt PEM-formatted keys (causing 'Unsupported key format' errors). Render them as multi-line textareas instead so pasted keys keep their line breaks. * Show saved indicator for secret fields instead of appearing blank/deleted GET /api/integrations never returns decrypted secret values (by design), so after navigating away and back, secret/key fields rendered empty - looking exactly like the saved key had been deleted, even though it was still intact and encrypted in the database. Expose which secret keys exist (names only, never values) via secretKeys, and use it to label fields as "saved" with an appropriate placeholder instead of blank. --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 08:53:56 -04:00
const secretKeys = (
db.prepare('SELECT key FROM secrets WHERE integration_id = ?').all(row.id) as { key: string }[]
).map((r) => r.key)
return {
id: row.id,
type: row.type,
name: row.name,
enabled: !!row.enabled,
status: row.status,
config: JSON.parse(row.config_json),
Show saved indicator for secret fields instead of appearing deleted (#18) * Add editable display-name field to generic integrations Lets users set a custom name for Proxmox, Docker, AWS, Remote Desktop, Netbird, Cloudflare, Uptime Kuma, and Weather integrations, separate from the host/IP field, mirroring the SSH host rename pattern. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4 * Surface the new-integration name field as a labeled input The name field for new generic integrations was a faint header input with only placeholder text, easy to miss. Move it into the form grid as a proper labeled "Name" field next to the other connection fields. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4 * Add file upload for SSH private key and certificate fields Lets users pick a key file from disk (e.g. ~/.ssh) instead of pasting its contents into the Private Key / OPKSSH Certificate fields. * Fix SSH private key paste corrupting multi-line PEM format Private Key and Certificate fields were single-line <input> elements, which strip newlines on paste and corrupt PEM-formatted keys (causing 'Unsupported key format' errors). Render them as multi-line textareas instead so pasted keys keep their line breaks. * Show saved indicator for secret fields instead of appearing blank/deleted GET /api/integrations never returns decrypted secret values (by design), so after navigating away and back, secret/key fields rendered empty - looking exactly like the saved key had been deleted, even though it was still intact and encrypted in the database. Expose which secret keys exist (names only, never values) via secretKeys, and use it to label fields as "saved" with an appropriate placeholder instead of blank. --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 08:53:56 -04:00
secretKeys,
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) }
})
Add auth Phase 3: multi-user accounts with admin/member roles (#28) Implements Phase 3 of the auth roadmap: multiple user accounts (cap 10), an admin/member role model, and admin-only gating of config-mutating routes. Dashboard data stays shared across all users (per the product decision in HANDOFF.md — this is a household/self-hosted dashboard, not a multi-tenant app), so there is no per-user data isolation. Schema (backend/src/db/index.ts): - Idempotent migration adds `role` (default 'admin') and `active` (default 1) columns to `users` when missing. The 'admin' default means the pre-existing single user is backfilled to admin on deploy and keeps full access; newly created users are inserted explicitly as 'member'. Verified against a production-like old schema (columns added, existing user backfilled to admin/active). Auth + access control: - `/api/setup` creates the first user as admin. Login enforces `active` (deactivated accounts get 403) and embeds the live role in the session. - `app.authenticate` now reads role+active fresh from the DB on every request (not from the possibly-stale JWT claim), rejects inactive accounts, and stashes the role on req.user. - New `requireAdmin` (auth + role check) and `adminOnly` (role check for routes already behind the plugin-level authenticate hook) decorators. User management (admin-only, in auth.ts): - GET/POST/PUT/DELETE /api/users — list, create (admin sets a temp password; no public signup), change role, activate/deactivate, delete. - 10-user cap enforced server-side; guard rails prevent removing the last active admin (demote/deactivate/delete) and deleting your own account; deactivating or deleting a user drops their sessions immediately. Admin-only route gating (members get 403): - integrations create/update/delete/test, tunnels create/delete, data export/import. Read routes and tunnel connect/disconnect stay open to all authenticated users, as do all the SSH/Docker/RDP tools and bookmarks (members are trusted to use the tooling, per product decision). Frontend: - api.ts: listUsers/createUser/updateUser/deleteUser + ManagedUser type; role+active added to AuthUser. - Settings: new admin-only "Users" section (create form, role toggle, activate/deactivate, delete, 10-cap indicator). Nav filters the Users tab by role and guards ?tab= deep-links. Data & Backup shows an admin-only notice for members; Integrations shows a read-only banner for members. (Backend remains the real enforcement boundary.) Verified end-to-end against a throwaway backend: role assignment, member 403s on every admin-only route + 200s on shared/read routes, admin 200/201s, last-admin guards (409/400), deactivation killing an active session and blocking re-login (then reactivation restoring it), and the 10-user cap (409 on the 11th). Both frontend and backend type-check clean. Co-authored-by: Samuel James <ssamjame@amazon.com> Co-authored-by: Kiro <noreply@kiro.dev>
2026-06-20 12:43:24 -04:00
app.post('/api/integrations', { onRequest: [app.adminOnly] }, 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) })
})
Add auth Phase 3: multi-user accounts with admin/member roles (#28) Implements Phase 3 of the auth roadmap: multiple user accounts (cap 10), an admin/member role model, and admin-only gating of config-mutating routes. Dashboard data stays shared across all users (per the product decision in HANDOFF.md — this is a household/self-hosted dashboard, not a multi-tenant app), so there is no per-user data isolation. Schema (backend/src/db/index.ts): - Idempotent migration adds `role` (default 'admin') and `active` (default 1) columns to `users` when missing. The 'admin' default means the pre-existing single user is backfilled to admin on deploy and keeps full access; newly created users are inserted explicitly as 'member'. Verified against a production-like old schema (columns added, existing user backfilled to admin/active). Auth + access control: - `/api/setup` creates the first user as admin. Login enforces `active` (deactivated accounts get 403) and embeds the live role in the session. - `app.authenticate` now reads role+active fresh from the DB on every request (not from the possibly-stale JWT claim), rejects inactive accounts, and stashes the role on req.user. - New `requireAdmin` (auth + role check) and `adminOnly` (role check for routes already behind the plugin-level authenticate hook) decorators. User management (admin-only, in auth.ts): - GET/POST/PUT/DELETE /api/users — list, create (admin sets a temp password; no public signup), change role, activate/deactivate, delete. - 10-user cap enforced server-side; guard rails prevent removing the last active admin (demote/deactivate/delete) and deleting your own account; deactivating or deleting a user drops their sessions immediately. Admin-only route gating (members get 403): - integrations create/update/delete/test, tunnels create/delete, data export/import. Read routes and tunnel connect/disconnect stay open to all authenticated users, as do all the SSH/Docker/RDP tools and bookmarks (members are trusted to use the tooling, per product decision). Frontend: - api.ts: listUsers/createUser/updateUser/deleteUser + ManagedUser type; role+active added to AuthUser. - Settings: new admin-only "Users" section (create form, role toggle, activate/deactivate, delete, 10-cap indicator). Nav filters the Users tab by role and guards ?tab= deep-links. Data & Backup shows an admin-only notice for members; Integrations shows a read-only banner for members. (Backend remains the real enforcement boundary.) Verified end-to-end against a throwaway backend: role assignment, member 403s on every admin-only route + 200s on shared/read routes, admin 200/201s, last-admin guards (409/400), deactivation killing an active session and blocking re-login (then reactivation restoring it), and the 10-user cap (409 on the 11th). Both frontend and backend type-check clean. Co-authored-by: Samuel James <ssamjame@amazon.com> Co-authored-by: Kiro <noreply@kiro.dev>
2026-06-20 12:43:24 -04:00
app.put('/api/integrations/:id', { onRequest: [app.adminOnly] }, 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) }
})
Add auth Phase 3: multi-user accounts with admin/member roles (#28) Implements Phase 3 of the auth roadmap: multiple user accounts (cap 10), an admin/member role model, and admin-only gating of config-mutating routes. Dashboard data stays shared across all users (per the product decision in HANDOFF.md — this is a household/self-hosted dashboard, not a multi-tenant app), so there is no per-user data isolation. Schema (backend/src/db/index.ts): - Idempotent migration adds `role` (default 'admin') and `active` (default 1) columns to `users` when missing. The 'admin' default means the pre-existing single user is backfilled to admin on deploy and keeps full access; newly created users are inserted explicitly as 'member'. Verified against a production-like old schema (columns added, existing user backfilled to admin/active). Auth + access control: - `/api/setup` creates the first user as admin. Login enforces `active` (deactivated accounts get 403) and embeds the live role in the session. - `app.authenticate` now reads role+active fresh from the DB on every request (not from the possibly-stale JWT claim), rejects inactive accounts, and stashes the role on req.user. - New `requireAdmin` (auth + role check) and `adminOnly` (role check for routes already behind the plugin-level authenticate hook) decorators. User management (admin-only, in auth.ts): - GET/POST/PUT/DELETE /api/users — list, create (admin sets a temp password; no public signup), change role, activate/deactivate, delete. - 10-user cap enforced server-side; guard rails prevent removing the last active admin (demote/deactivate/delete) and deleting your own account; deactivating or deleting a user drops their sessions immediately. Admin-only route gating (members get 403): - integrations create/update/delete/test, tunnels create/delete, data export/import. Read routes and tunnel connect/disconnect stay open to all authenticated users, as do all the SSH/Docker/RDP tools and bookmarks (members are trusted to use the tooling, per product decision). Frontend: - api.ts: listUsers/createUser/updateUser/deleteUser + ManagedUser type; role+active added to AuthUser. - Settings: new admin-only "Users" section (create form, role toggle, activate/deactivate, delete, 10-cap indicator). Nav filters the Users tab by role and guards ?tab= deep-links. Data & Backup shows an admin-only notice for members; Integrations shows a read-only banner for members. (Backend remains the real enforcement boundary.) Verified end-to-end against a throwaway backend: role assignment, member 403s on every admin-only route + 200s on shared/read routes, admin 200/201s, last-admin guards (409/400), deactivation killing an active session and blocking re-login (then reactivation restoring it), and the 10-user cap (409 on the 11th). Both frontend and backend type-check clean. Co-authored-by: Samuel James <ssamjame@amazon.com> Co-authored-by: Kiro <noreply@kiro.dev>
2026-06-20 12:43:24 -04:00
app.delete('/api/integrations/:id', { onRequest: [app.adminOnly] }, 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()
})
Add auth Phase 3: multi-user accounts with admin/member roles (#28) Implements Phase 3 of the auth roadmap: multiple user accounts (cap 10), an admin/member role model, and admin-only gating of config-mutating routes. Dashboard data stays shared across all users (per the product decision in HANDOFF.md — this is a household/self-hosted dashboard, not a multi-tenant app), so there is no per-user data isolation. Schema (backend/src/db/index.ts): - Idempotent migration adds `role` (default 'admin') and `active` (default 1) columns to `users` when missing. The 'admin' default means the pre-existing single user is backfilled to admin on deploy and keeps full access; newly created users are inserted explicitly as 'member'. Verified against a production-like old schema (columns added, existing user backfilled to admin/active). Auth + access control: - `/api/setup` creates the first user as admin. Login enforces `active` (deactivated accounts get 403) and embeds the live role in the session. - `app.authenticate` now reads role+active fresh from the DB on every request (not from the possibly-stale JWT claim), rejects inactive accounts, and stashes the role on req.user. - New `requireAdmin` (auth + role check) and `adminOnly` (role check for routes already behind the plugin-level authenticate hook) decorators. User management (admin-only, in auth.ts): - GET/POST/PUT/DELETE /api/users — list, create (admin sets a temp password; no public signup), change role, activate/deactivate, delete. - 10-user cap enforced server-side; guard rails prevent removing the last active admin (demote/deactivate/delete) and deleting your own account; deactivating or deleting a user drops their sessions immediately. Admin-only route gating (members get 403): - integrations create/update/delete/test, tunnels create/delete, data export/import. Read routes and tunnel connect/disconnect stay open to all authenticated users, as do all the SSH/Docker/RDP tools and bookmarks (members are trusted to use the tooling, per product decision). Frontend: - api.ts: listUsers/createUser/updateUser/deleteUser + ManagedUser type; role+active added to AuthUser. - Settings: new admin-only "Users" section (create form, role toggle, activate/deactivate, delete, 10-cap indicator). Nav filters the Users tab by role and guards ?tab= deep-links. Data & Backup shows an admin-only notice for members; Integrations shows a read-only banner for members. (Backend remains the real enforcement boundary.) Verified end-to-end against a throwaway backend: role assignment, member 403s on every admin-only route + 200s on shared/read routes, admin 200/201s, last-admin guards (409/400), deactivation killing an active session and blocking re-login (then reactivation restoring it), and the 10-user cap (409 on the 11th). Both frontend and backend type-check clean. Co-authored-by: Samuel James <ssamjame@amazon.com> Co-authored-by: Kiro <noreply@kiro.dev>
2026-06-20 12:43:24 -04:00
app.post('/api/integrations/:id/test', { onRequest: [app.adminOnly] }, 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 (err) {
app.log.warn(`listResources failed for integration "${row.name}" (${row.type}): ${err instanceof Error ? err.message : err}`)
}
}
return { resources }
})
}