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>
442 lines
17 KiB
TypeScript
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
|
|
}
|
|
}
|