dev_arc_aws/src/lib/api.ts
Samuel James 35fd7fc703
Add Docker-over-SSH management and push-agent monitoring (#31)
Expands the Containers feature with two new ways to see and manage Docker
containers without exposing the Docker Engine TCP socket, plus the docs and
roadmap entries that frame them.

Docker over SSH (management):
- Runs the `docker` CLI on a remote SSH host instead of talking to the Engine
  TCP API, reusing the existing SSH transport (jump-host chaining, host-key
  verification, key/password auth) via connectTarget + execCommand. No dockerd
  socket has to be exposed — the mesh + SSH auth are the gate.
- backend/src/ssh/docker.ts: list/logs/start/stop/restart/pause/unpause/remove
  and an interactive `docker exec` shell builder. Container refs are validated
  against a strict allowlist and single-quoted to prevent command injection;
  action verbs are whitelisted.
- backend/src/routes/dockerSsh.ts: REST routes mirroring the TCP Docker API
  shape (mutating actions gated by adminOnly) + a /api/docker-ssh/exec
  WebSocket modeled on the terminal PTY plumbing.
- Note: the SSH path uses the ssh2 key/password auth; it does not implement the
  OpenSSH-certificate (OPKSSH) fallback that the terminal route has.

Docker push-agent monitoring (self-hosted, read-only):
- A small bash agent (agent/archnest-docker-agent.sh) runs on each Docker VM,
  collects a rich snapshot (docker ps + inspect + a stats snapshot), masks
  secret-looking env values locally, and POSTs it to ArchNest. VMs need
  outbound-only mesh access — no exposed port, no SSH for monitoring.
- backend/src/routes/agents.ts: token-gated ingest
  (POST /api/agents/docker/report, ARCHNEST_AGENT_TOKEN, constant-time compare;
  503 when unset, so it is disabled by default) plus user-auth read endpoints
  (hosts list with staleness flag, per-host containers, single-container
  detail). New docker_agent_reports table (latest report per host).
- Ingest stores data only; it never executes anything from the agent.

Containers page:
- Host selector now spans Docker API, SSH, and Agent sources.
- Intra-page tabs: a Containers list plus dynamic, closeable per-container
  detail tabs opened by clicking a container name. Agent detail shows
  overview/state/stats/ports/networks/mounts/env(masked)/labels; docker/ssh
  degrade gracefully. Agent rows are read-only; docker/ssh keep management.

Docs/roadmap:
- docs/docker-agent-monitoring.md (design doc, written before implementation).
- ROADMAP.md: LXC management (paid), Docker monitoring agent tiering
  (push self-hosted now / pull-agent paid), terminal grid tiering.

Deferred (documented, not built here): the mesh-prerequisite setup gate, the
paid pull-agent (Option 2), per-host tokens, time-series metrics.

Requires ARCHNEST_AGENT_TOKEN in the backend env to enable agent ingest.
Verified: backend `tsc --noEmit` and frontend `tsc -b && vite build` both pass;
agent jq filters, byte conversion, and `bash -n` checked locally.

Co-authored-by: Samuel James <ssamjame@amazon.com>
Co-authored-by: Kiro <noreply@kiro.dev>
2026-06-20 16:24:57 -04:00

442 lines
17 KiB
TypeScript

const TOKEN_KEY = 'archnest_token'
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY)
}
export function setToken(token: string | null) {
if (token) localStorage.setItem(TOKEN_KEY, token)
else localStorage.removeItem(TOKEN_KEY)
}
export class ApiError extends Error {
status: number
constructor(status: number, message: string) {
super(message)
this.status = status
}
}
export async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
const token = getToken()
const headers: Record<string, string> = {
...(options.body ? { 'Content-Type': 'application/json' } : {}),
...(options.headers as Record<string, string> | undefined),
}
if (token) headers.Authorization = `Bearer ${token}`
const res = await fetch(`/api${path}`, { ...options, headers })
if (!res.ok) {
let message = res.statusText
try {
const body = await res.json()
message = body.error ?? message
} catch {
// ignore non-JSON error bodies
}
throw new ApiError(res.status, message)
}
if (res.status === 204) return undefined as T
return res.json() as Promise<T>
}
export const api = {
getSetupStatus: () => apiFetch<{ needsSetup: boolean }>('/system/setup-status'),
setup: (username: string, password: string) =>
apiFetch<{ token: string }>('/setup', { method: 'POST', body: JSON.stringify({ username, password }) }),
login: (username: string, password: string) =>
apiFetch<{ token: string }>('/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) }),
me: () => apiFetch<{ user: AuthUser }>('/auth/me'),
updateMe: (data: Partial<{ displayName: string | null; email: string | null; avatarDataUrl: string | null }>) =>
apiFetch<{ user: AuthUser }>('/auth/me', { method: 'PUT', body: JSON.stringify(data) }),
changePassword: (currentPassword: string, newPassword: string) =>
apiFetch<{ ok: boolean }>('/auth/password', { method: 'PUT', body: JSON.stringify({ currentPassword, newPassword }) }),
listSessions: () => apiFetch<{ sessions: AuthSession[] }>('/auth/sessions'),
revokeSession: (id: string) => apiFetch<{ ok: boolean }>(`/auth/sessions/${id}`, { method: 'DELETE' }),
logout: () => apiFetch<{ ok: boolean }>('/auth/logout', { method: 'POST' }),
listLoginEvents: (limit = 20) => apiFetch<{ events: LoginEvent[] }>(`/auth/login-events?limit=${limit}`),
listUsers: () => apiFetch<{ users: ManagedUser[] }>('/users'),
createUser: (data: { username: string; password: string; role: 'admin' | 'member'; displayName?: string | null; email?: string | null }) =>
apiFetch<{ user: ManagedUser }>('/users', { method: 'POST', body: JSON.stringify(data) }),
updateUser: (id: number, data: Partial<{ role: 'admin' | 'member'; active: boolean }>) =>
apiFetch<{ user: ManagedUser }>(`/users/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteUser: (id: number) => apiFetch<{ ok: boolean }>(`/users/${id}`, { method: 'DELETE' }),
listIntegrations: () => apiFetch<{ integrations: Integration[] }>('/integrations'),
createIntegration: (data: { type: string; name: string; config?: Record<string, string>; secrets?: Record<string, string> }) =>
apiFetch<{ integration: Integration }>('/integrations', { method: 'POST', body: JSON.stringify(data) }),
updateIntegration: (id: number, data: Partial<{ name: string; config: Record<string, string>; secrets: Record<string, string> }>) =>
apiFetch<{ integration: Integration }>(`/integrations/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteIntegration: (id: number) => apiFetch<void>(`/integrations/${id}`, { method: 'DELETE' }),
testIntegration: (id: number) => apiFetch<{ ok: boolean; message: string }>(`/integrations/${id}/test`, { method: 'POST' }),
listBookmarks: () => apiFetch<{ bookmarks: Bookmark[] }>('/bookmarks'),
listBookmarkCategories: () => apiFetch<{ categories: BookmarkCategory[] }>('/bookmarks/categories'),
createBookmarkCategory: (data: { name: string; icon?: string; sortOrder?: number }) =>
apiFetch<{ id: number }>('/bookmarks/categories', { method: 'POST', body: JSON.stringify(data) }),
createBookmark: (data: { categoryId?: number | null; title: string; url: string; icon?: string; favorite?: boolean }) =>
apiFetch<{ id: number }>('/bookmarks', { method: 'POST', body: JSON.stringify(data) }),
updateBookmark: (id: number, data: Partial<{ categoryId: number | null; title: string; url: string; icon: string; favorite: boolean }>) =>
apiFetch<{ ok: boolean }>(`/bookmarks/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteBookmark: (id: number) => apiFetch<void>(`/bookmarks/${id}`, { method: 'DELETE' }),
deleteAllBookmarks: () => apiFetch<void>('/bookmarks', { method: 'DELETE' }),
listEvents: (limit = 20) => apiFetch<{ events: Event[] }>(`/events?limit=${limit}`),
listResources: () => apiFetch<{ resources: Resource[] }>('/integrations/resources'),
listTunnels: () => apiFetch<{ tunnels: Tunnel[] }>('/tunnels'),
createTunnel: (data: {
name: string
integrationId: number
mode: 'local' | 'remote' | 'dynamic'
sourcePort: number
endpointHost?: string
endpointPort?: number
autoStart?: boolean
maxRetries?: number
retryIntervalMs?: number
}) => apiFetch<{ tunnel: Tunnel }>('/tunnels', { method: 'POST', body: JSON.stringify(data) }),
deleteTunnel: (id: number) => apiFetch<void>(`/tunnels/${id}`, { method: 'DELETE' }),
connectTunnel: (id: number) =>
apiFetch<{ ok: boolean; status: string; error: string | null; retryCount: number }>(`/tunnels/${id}/connect`, { method: 'POST' }),
disconnectTunnel: (id: number) =>
apiFetch<{ ok: boolean; status: string; error: string | null; retryCount: number }>(`/tunnels/${id}/disconnect`, { method: 'POST' }),
listFiles: (integrationId: number, path: string) =>
apiFetch<{ path: string; entries: FileEntry[] }>(`/files/${integrationId}/list?path=${encodeURIComponent(path)}`),
readFile: (integrationId: number, path: string) =>
apiFetch<{ content: string; encoding: 'utf8' | 'base64'; size: number; mode: number }>(
`/files/${integrationId}/content?path=${encodeURIComponent(path)}`,
),
writeFile: (integrationId: number, path: string, content: string, encoding: 'utf8' | 'base64' = 'utf8') =>
apiFetch<{ ok: boolean }>(`/files/${integrationId}/content`, { method: 'PUT', body: JSON.stringify({ path, content, encoding }) }),
mkdir: (integrationId: number, path: string) =>
apiFetch<{ ok: boolean }>(`/files/${integrationId}/mkdir`, { method: 'POST', body: JSON.stringify({ path }) }),
renameFile: (integrationId: number, from: string, to: string) =>
apiFetch<{ ok: boolean }>(`/files/${integrationId}/rename`, { method: 'POST', body: JSON.stringify({ from, to }) }),
deleteFile: (integrationId: number, path: string, isDirectory = false) =>
apiFetch<{ ok: boolean }>(`/files/${integrationId}/delete`, { method: 'POST', body: JSON.stringify({ path, isDirectory }) }),
chmodFile: (integrationId: number, path: string, mode: string) =>
apiFetch<{ ok: boolean }>(`/files/${integrationId}/chmod`, { method: 'POST', body: JSON.stringify({ path, mode }) }),
downloadFileUrl: (integrationId: number, path: string) =>
`/api/files/${integrationId}/download?path=${encodeURIComponent(path)}`,
uploadFile: async (integrationId: number, path: string, file: File) => {
const form = new FormData()
form.append('path', path)
form.append('file', file)
const token = getToken()
const res = await fetch(`/api/files/${integrationId}/upload`, {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
body: form,
})
if (!res.ok) {
let message = res.statusText
try {
message = (await res.json()).error ?? message
} catch {
// ignore non-JSON error bodies
}
throw new ApiError(res.status, message)
}
return res.json() as Promise<{ ok: boolean; path: string }>
},
listContainers: (integrationId: number) =>
apiFetch<{ containers: Container[] }>(`/docker/${integrationId}/containers`),
containerStats: (integrationId: number, id: string) =>
apiFetch<ContainerStats>(`/docker/${integrationId}/containers/${encodeURIComponent(id)}/stats`),
containerLogs: (integrationId: number, id: string, tail = 200) =>
apiFetch<{ logs: string }>(`/docker/${integrationId}/containers/${encodeURIComponent(id)}/logs?tail=${tail}`),
containerAction: (integrationId: number, id: string, action: 'start' | 'stop' | 'restart' | 'pause' | 'unpause') =>
apiFetch<{ ok: boolean }>(`/docker/${integrationId}/containers/${encodeURIComponent(id)}/${action}`, { method: 'POST' }),
removeContainer: (integrationId: number, id: string, force = false) =>
apiFetch<{ ok: boolean }>(`/docker/${integrationId}/containers/${encodeURIComponent(id)}/remove`, {
method: 'POST',
body: JSON.stringify({ force }),
}),
// Docker over SSH: runs the `docker` CLI on a remote SSH host instead of the
// Docker Engine TCP API. `integrationId` here is an SSH integration.
listSshContainers: (integrationId: number) =>
apiFetch<{ containers: SshContainer[] }>(`/docker-ssh/${integrationId}/containers`),
sshContainerLogs: (integrationId: number, id: string, tail = 200) =>
apiFetch<{ logs: string }>(`/docker-ssh/${integrationId}/containers/${encodeURIComponent(id)}/logs?tail=${tail}`),
sshContainerAction: (integrationId: number, id: string, action: 'start' | 'stop' | 'restart' | 'pause' | 'unpause') =>
apiFetch<{ ok: boolean }>(`/docker-ssh/${integrationId}/containers/${encodeURIComponent(id)}/${action}`, { method: 'POST' }),
removeSshContainer: (integrationId: number, id: string, force = false) =>
apiFetch<{ ok: boolean }>(`/docker-ssh/${integrationId}/containers/${encodeURIComponent(id)}/remove`, {
method: 'POST',
body: JSON.stringify({ force }),
}),
// Docker monitoring agents (push model). Read-only; agents POST reports to a
// token-gated ingest endpoint that the UI never calls.
listAgentHosts: () => apiFetch<{ hosts: AgentHost[] }>('/agents/docker/hosts'),
listAgentContainers: (hostId: string) =>
apiFetch<AgentHostContainers>(`/agents/docker/hosts/${encodeURIComponent(hostId)}/containers`),
getAgentContainer: (hostId: string, containerId: string) =>
apiFetch<{ container: AgentContainer }>(
`/agents/docker/hosts/${encodeURIComponent(hostId)}/containers/${encodeURIComponent(containerId)}`,
),
getHostMetrics: (integrationId: number) => apiFetch<HostMetrics>(`/integrations/${integrationId}/metrics`),
startTransfer: (data: { sourceIntegrationId: number; destIntegrationId: number; sourcePaths: string[]; destPath: string; move?: boolean }) =>
apiFetch<{ transferId: string }>('/transfers', { method: 'POST', body: JSON.stringify(data) }),
listTransfers: () => apiFetch<{ transfers: TransferProgress[] }>('/transfers'),
getTransfer: (id: string) => apiFetch<TransferProgress>(`/transfers/${id}`),
cancelTransfer: (id: string) => apiFetch<{ ok: boolean }>(`/transfers/${id}/cancel`, { method: 'POST' }),
exportData: () => apiFetch<DataExport>('/data/export'),
importData: (data: DataExport) =>
apiFetch<{ ok: boolean; imported: { integrations: number; bookmarkCategories: number; bookmarks: number; tunnels: number } }>('/data/import', {
method: 'POST',
body: JSON.stringify(data),
}),
}
export interface DataExport {
version: number
exportedAt?: string
integrations: Array<{ id: number; type: string; name: string; enabled: boolean; config: Record<string, string>; secrets: Record<string, string> }>
bookmarkCategories: Array<{ id: number; name: string; icon: string | null; sortOrder: number }>
bookmarks: Array<{ categoryId: number | null; title: string; url: string; icon: string | null; favorite: boolean }>
tunnels: Array<{ name: string; integrationId: number; mode: string; sourcePort: number; endpointHost: string; endpointPort: number; autoStart: boolean; maxRetries: number; retryIntervalMs: number }>
}
export interface AuthUser {
id: number
username: string
display_name: string | null
email: string | null
avatar_data_url: string | null
role?: 'admin' | 'member'
active?: boolean
}
export interface ManagedUser {
id: number
username: string
displayName: string | null
email: string | null
role: 'admin' | 'member'
active: boolean
createdAt: string
}
export interface AuthSession {
id: string
userAgent: string | null
ip: string | null
createdAt: string
lastSeenAt: string
current: boolean
}
export interface LoginEvent {
id: number
username: string | null
ip: string | null
userAgent: string | null
success: boolean
createdAt: string
}
export interface Integration {
id: number
type: string
name: string
enabled: boolean
status: string
config: Record<string, string>
secretKeys: string[]
lastCheckedAt: string | null
createdAt: string
}
export interface Tunnel {
id: number
name: string
integrationId: number
mode: 'local' | 'remote' | 'dynamic'
sourcePort: number
endpointHost: string
endpointPort: number
autoStart: boolean
maxRetries: number
retryIntervalMs: number
createdAt: string
status: 'stopped' | 'connecting' | 'connected' | 'retrying' | 'error'
error: string | null
retryCount: number
}
export interface Bookmark {
id: number
category_id: number | null
title: string
url: string
icon: string | null
favorite: number
status: string
last_checked_at: string | null
created_at: string
}
export interface BookmarkCategory {
id: number
name: string
icon: string | null
sort_order: number
}
export interface Event {
id: number
type: string
title: string
source: string | null
created_at: string
}
export interface FileEntry {
name: string
isDirectory: boolean
isSymlink: boolean
size: number
mode: number
mtime: number
}
export interface Container {
id: string
name: string
image: string
state: string
status: string
ports: { privatePort: number; publicPort?: number; type: string }[]
}
export interface SshContainer {
id: string
name: string
image: string
state: string
status: string
/** Raw `docker ps` ports string (e.g. "0.0.0.0:8080->80/tcp"). */
ports: string
}
export interface AgentHost {
hostId: string
hostname: string | null
reportedAt: string | null
receivedAt: string
containerCount: number
stale: boolean
}
export interface AgentContainerPort {
hostIp?: string
hostPort?: number | null
containerPort: number
proto: string
}
export interface AgentContainerStats {
cpuPercent?: number
memUsage?: number
memLimit?: number
netRxBytes?: number
netTxBytes?: number
blockReadBytes?: number
blockWriteBytes?: number
}
export interface AgentContainer {
id: string
name: string
image: string
imageId?: string
state: string
status: string
createdAt?: string
startedAt?: string
restartCount?: number
restartPolicy?: string
health?: string
ports: AgentContainerPort[]
networks: { name: string; ip?: string }[]
mounts: { type?: string; source?: string; destination?: string; rw?: boolean }[]
env: { key: string; value: string }[]
command?: string
labels?: Record<string, string>
stats?: AgentContainerStats
}
export interface AgentHostContainers {
hostId: string
hostname: string | null
reportedAt: string | null
receivedAt: string
stale: boolean
containers: AgentContainer[]
}
export interface ContainerStats {
cpuPercent: number
memUsage: number
memLimit: number
netRx: number
netTx: number
}
export interface Resource {
name: string
status: 'healthy' | 'warning' | 'critical' | 'unknown'
detail?: string
integration: string
}
export interface TransferProgress {
transferId: string
status: 'running' | 'completed' | 'failed' | 'cancelled'
sourceIntegrationId: number
destIntegrationId: number
sourcePaths: string[]
destPath: string
move: boolean
totalFiles: number
totalBytes: number
filesTransferred: number
bytesTransferred: number
currentFile: string | null
error: string | null
startedAt: number
finishedAt: number | null
}
export interface HostMetrics {
cpu: { percent: number | null; cores: number | null; load: [number, number, number] | null }
memory: { percent: number | null; usedGiB: number | null; totalGiB: number | null }
disk: { percent: number | null; usedHuman: string | null; totalHuman: string | null; availableHuman: string | null }
uptime: { seconds: number | null; formatted: string | null }
network: { interfaces: Array<{ name: string; ip: string; state: string }> }
system: { hostname: string | null; kernel: string | null; os: string | null }
processes: {
total: number | null
running: number | null
top: Array<{ pid: string; user: string; cpu: string; mem: string; command: string }>
}
ports: { source: 'ss' | 'netstat' | 'none'; ports: Array<{ protocol: string; localAddress: string; localPort: number; state?: string; process?: string }> }
firewall: { type: 'iptables' | 'none'; status: 'active' | 'inactive' | 'unknown'; chains: Array<{ name: string; policy: string; rules: unknown[] }> }
loginStats: {
recentLogins: Array<{ user: string; ip: string; time: string; status: string }>
failedLogins: Array<{ user: string; ip: string; time: string; status: string }>
totalLogins: number
uniqueIPs: number
}
}