diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..635e85f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.git +bin/ +dist/ +frontend/node_modules +frontend/.next +frontend/out +pkg/webui/dist +docs/ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 58697af..407517c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,3 +56,16 @@ jobs: - name: Build run: npm run build + + docker: + name: Docker image builds + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4.2.2 + - uses: docker/setup-buildx-action@v3 + - uses: docker/build-push-action@v6 + with: + context: . + push: false + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f23f35e..e44fae0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -70,4 +70,56 @@ jobs: curl -fsSL https://github.com/${{ github.repository }}/releases/latest/download/install.sh | bash ``` - The installer brings up the dashboard and prints a public URL via Cloudflare's free quick-tunnel — no firewall config or signup required. Pass `--no-tunnel` if you'd rather bind a local port directly. See the [README](https://github.com/${{ github.repository }}#readme) for full options. + The installer brings up the dashboard and prints a public URL via a Cloudflare quick-tunnel, no firewall config or signup required. Pass `--no-tunnel` to bind a local port directly, or `--private --no-tunnel` to keep it on localhost. + + ## Or run it with Docker + + ```bash + docker run -d --name infracanvas --pid=host --network=host \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -v infracanvas:/data \ + ghcr.io/${{ github.repository_owner }}/infracanvas:latest serve --no-tunnel --private + docker logs infracanvas # prints the URL with its token + ``` + + See the [README](https://github.com/${{ github.repository }}#readme) for full options. + + image: + name: Docker image + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4.2.2 + + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository_owner }}/infracanvas + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,value=latest + + - uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: | + VERSION=${{ github.ref_name }} + COMMIT=${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index df49ca7..6a51d9a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,277 +1,107 @@ -# InfraCanvas Architecture +# InfraCanvas architecture -> Living document. Update as you build. Replaces codebase re-analysis each session. +How the open-source binary is put together. Keep this in sync when you change the wire protocol or add a package. ---- - -## Overview - -InfraCanvas is a multi-VM infrastructure discovery and visualization platform. Two distinct products share this repo: - -- **OSS** (`infracanvas` CLI): self-hosted, single-binary, Cloudflare tunnel -- **SaaS** (`saas-backend/` + SaaS frontend): multi-tenant, hosted on `bytestrix` VM - ---- - -## System Architecture +## Processes ``` -┌─────────────────────────────────────────────────────────┐ -│ BROWSER │ -│ Next.js 14 SPA: React Flow canvas, Zustand state │ -└──────────────┬──────────────────────────────────────────┘ - │ WebSocket /ws/canvas HTTP /api/v1/* - │ -┌──────────────▼──────────────────────────────────────────┐ -│ RELAY / API SERVER (bytestrix) │ -│ │ -│ OSS mode: pkg/server (:8080, in Docker container) │ -│ SaaS mode: saas-backend/pkg/api (:8090, systemd) │ -│ │ -│ Postgres (port 5432) Redis (port 6379) │ -└──────────────┬──────────────────────────────────────────┘ - │ WebSocket /ws/agent - │ -┌──────────────▼──────────────────────────────────────────┐ -│ AGENT (on each VM) │ -│ pkg/agent: discovers host/docker/k8s, streams diffs │ -└─────────────────────────────────────────────────────────┘ +┌────────────────────────────────────────────────────────────┐ +│ Browser: Next.js static export, React Flow canvas, Zustand │ +└──────────────┬─────────────────────────────────────────────┘ + │ WebSocket /ws/canvas, HTTP /api/* +┌──────────────▼─────────────────────────────────────────────┐ +│ infracanvas serve │ +│ pkg/server relay: sessions, auth, embedded UI │ +│ pkg/agent in-process agent for this machine │ +│ pkg/clustermgr one virtual agent per connected cluster │ +└──────────────▲─────────────────────────────────────────────┘ + │ WebSocket /ws/agent (outbound from each VM) +┌──────────────┴─────────────────────────────────────────────┐ +│ infracanvas start on other VMs (join token) │ +└────────────────────────────────────────────────────────────┘ ``` ---- - -## OSS Stack +Everything, including the local machine's own agent and each Kubernetes cluster, talks to the relay through the same `/ws/agent` protocol. The local agent and cluster agents just dial `ws://127.0.0.1:` from inside the process. -### Binary: `infracanvas` (cmd/infracanvas/) +## Commands (`cmd/infracanvas/cmd/`) -| Subcommand | Purpose | +| Command | Purpose | |---|---| -| `serve` | All-in-one: relay + agent + embedded UI in one process | -| `agent` | Agent-only, connects to external relay | -| `discover` | One-shot discovery, prints JSON | -| `get` / `export` | Query running instance | -| `logs` | Stream container logs | -| `url` / `diagnose` | Operational helpers | - -### Serve mode flow +| `serve` | relay + local agent + embedded UI in one process; optional Cloudflare tunnel | +| `start` | agent only, streams to a hub (`--backend`, `--token`) | +| `agent` | older agent-only entry point | +| `discover` | one-shot discovery, prints JSON | +| `get`, `export`, `logs` | query a machine from the CLI | +| `url`, `diagnose`, `version` | helpers | -1. Bind HTTP on `127.0.0.1:7777` (with tunnel) or `0.0.0.0:7777` (no-tunnel) -2. Start Cloudflare quick-tunnel → print public URL with `?token=` -3. Start in-process agent connecting to `ws://127.0.0.1:7777/ws/agent` -4. Browser auto-pairs (local mode, no pair code needed) +## `serve` startup -### OSS Relay Server (`pkg/server/`) +1. Bind `127.0.0.1:7777` (tunnel or `--private`) or `0.0.0.0:7777` (`--no-tunnel`), falling back to a free port. +2. Mount the embedded dashboard behind the UI token. +3. Start `clustermgr` and reconnect clusters saved in the state directory. +4. Start the Cloudflare quick-tunnel unless disabled, and print the URL. +5. Run the local agent in a restart loop. -**Wire protocol**: all messages use `{"type": "...", "data": {...}}` JSON envelope. +## Wire protocol -| Direction | Message | Meaning | -|---|---|---| -| Agent → Server | `HELLO` | hostname + scope | -| Agent → Server | `GRAPH_SNAPSHOT` | full node/edge graph | -| Agent → Server | `GRAPH_DIFF` | incremental changes | -| Agent → Server | `ACTION_RESULT` | action completed | -| Agent → Server | `HEARTBEAT` | keepalive | -| Server → Agent | `PAIR_CODE` | assigned pair code | -| Server → Agent | `ACTION_REQUEST` | browser-triggered action | -| Browser → Server | `PAIR` | connect using pair code | -| Browser → Server | `BROWSER_ACTION` | trigger action on agent | -| Server → Browser | `AGENT_CONNECTED` | agent metadata | -| Server → Browser | `GRAPH_SNAPSHOT` / `GRAPH_DIFF` | relayed from agent | - -**Session lifecycle:** -- Agent connects → server creates `Session` (UUID + pair code e.g. `DUSK-DEER-755033`) -- Browser sends `PAIR {code}` → bound to session -- Last snapshot cached for late-joining browsers -- Local mode: browser auto-binds, no pair code needed - -### Agent (`pkg/agent/`) - -- `WSAgent` maintains WebSocket connection to relay -- On connect: sends `HELLO`, gets `PAIR_CODE` -- Every `RefreshSeconds`: runs orchestrator → computes diff vs previous snapshot → sends `GRAPH_DIFF` -- On first run or reconnect: sends full `GRAPH_SNAPSHOT` -- Handles `ACTION_REQUEST` from browser (docker stop/start, k8s ops, exec sessions, log streaming) +Every message is a JSON envelope `{"type": "...", "data": {...}}`. -### Discovery (`pkg/discovery/`) - -Three scopes, discovered independently: - -| Scope | Source | Key data | +| Direction | Type | Meaning | |---|---|---| -| `host` | `/proc`, `sysinfo` | CPU, memory, disk, network interfaces | -| `docker` | Docker socket | containers, images, networks, volumes | -| `kubernetes` | `~/.kube/config` or in-cluster | pods, deployments, services, namespaces | - -Output: `GraphOutput {nodes: GraphNode[], edges: GraphEdge[]}` from `pkg/output/` - -### Actions (`pkg/actions/`) - -Triggered by browser via `BROWSER_ACTION` → relay → `ACTION_REQUEST` on agent: - -- **docker**: start/stop/restart container, pull image, exec into container -- **host**: exec shell command, PTY terminal session -- **kubernetes**: scale deployment, update image, rollout restart - -Exec sessions: agent opens PTY or Docker exec, streams stdin/stdout via `EXEC_DATA` messages. - ---- - -## SaaS Stack - -### Backend: `saas-backend/` (binary: `infracanvas-api`, port 8090) - -**Packages:** -- `pkg/api/auth/`: JWT auth, registration, login, forgot-password -- `pkg/api/agents/`: agent registry, list agents per org -- `pkg/api/websocket/`: SaaS relay (agent ↔ browser via org/agent ID) -- `pkg/api/organizations/`: org management, API keys -- `pkg/api/billing/`: billing integration -- `pkg/middleware/`: JWT validation, org context -- `pkg/database/`: Postgres connection (pg driver) -- `pkg/models/`: User, Org, Agent, APIKey structs - -**Auth flow:** -- Register/login → JWT -- API keys for agent authentication (`/api/v1/org/api-keys`) -- Agent connects with API key → registered under org -- Browser connects with JWT → sees agents for their org - -**SaaS relay flow (different from OSS):** -- Agent authenticates with org API key -- `[relay/agent] HELLO agent=ByteStrix-5bcfabe8 org=bytestrix-657a9cc0` -- Browser connects → sees all agents in org -- Relay bridges browser ↔ specific agent by agent ID (not pair code) - -### SaaS Frontend (`~/infracanvas-saas-frontend/` on bytestrix, separate repo from OSS) - -Next.js 14 App Router SPA. Deployed as dev server on `:3001`. - -**Key routes** (`frontend/app/`): -- `/` → redirect to `/dashboard` -- `/auth/login`, `/auth/register`, `/auth/forgot-password` -- `/dashboard`: main canvas page - -**State management** (`frontend/store/vmStore.ts`): -- Zustand store: `vms: Record` -- VMState: `{status, hostname, scope, graph, error, lastUpdated}` -- `applyVMDiff()`: merges incremental diffs into graph state - -**WebSocket client** (`frontend/lib/wsManager.ts`): -- Module-level singleton (survives React navigation) -- Exponential backoff reconnect (1s → 30s, ±20% jitter) -- Derives WS URL from `window.location` (same-origin `/ws/canvas`) -- OSS mode: sends `PAIR {code: "local"}` for auto-pair -- SaaS mode: sends auth token, then selects agent by ID - -**Canvas rendering** (`frontend/components/canvas/`): -- React Flow for graph layout -- `frontend/lib/graphPreprocess.ts`: transforms `GraphOutput` → React Flow nodes/edges -- `frontend/lib/layout.ts`: hierarchical layout algorithm - ---- - -## Infrastructure on `bytestrix` VM (13.200.198.166) - -### Processes - -| Process | Port | Binary | +| agent → relay | `HELLO` | hostname, scope, machine ID, resume secret | +| agent → relay | `GRAPH_SNAPSHOT` | full graph; cached for browsers that attach later | +| agent → relay | `GRAPH_DIFF` | changes since the last graph | +| agent → relay | `DISCOVERY_ERROR` | discovery failed before any snapshot; cached until one arrives | +| agent → relay | `ACTION_RESULT`, `ACTION_PROGRESS` | outcome of a browser action | +| agent → relay | `LOG_DATA`, `EXEC_DATA`, `EXEC_END` | log and terminal streams | +| agent → relay | `HEARTBEAT` | keepalive every 15s | +| relay → agent | `PAIR_CODE` | pair code, plus a resume secret on first connect | +| relay → agent | `ACTION_REQUEST`, `COMMAND` | browser-triggered work | +| relay → agent | `EXEC_START`, `EXEC_INPUT`, `EXEC_RESIZE`, `EXEC_END` | terminal sessions | +| browser → relay | `PAIR`, `BROWSER_ACTION` | attach to a machine, trigger an action | +| relay → browser | `AGENT_CONNECTED`, `AGENT_DISCONNECTED`, `ERROR` | machine state | + +Sessions are keyed by machine ID. A reconnect that claims an existing machine ID must present that machine's resume secret or it's rejected. + +## Agent (`pkg/agent/`) + +- One goroutine reads from the relay; the main loop handles commands and heartbeats. +- Discovery runs in its own goroutine every `--refresh` seconds (default 30), so a slow pass never delays terminal input. Passes are serialized. +- The first successful pass is sent as `GRAPH_SNAPSHOT`, later ones as `GRAPH_DIFF`. +- Terminal input is written to each session's PTY by one goroutine per session, which keeps keystrokes in order. + +## Discovery (`pkg/discovery/`, `pkg/orchestrator/`) + +| Scope | Source | Produces | |---|---|---| -| `infracanvas-server` (Docker) | 8080 | OSS relay server | -| `infracanvas-api` (systemd) | 8090 | SaaS backend | -| Next.js dev server | 3001 | SaaS frontend | -| Postgres | 5432 | DB for SaaS | -| Redis | 6379 | Sessions | -| nginx | 3000 | Proxy → 3001 + 8090 | +| `host` | `/proc`, systemd, PM2 | host resources, services, processes, `CONNECTS_TO` edges from `/proc/net/tcp` | +| `docker` | Docker or Podman socket | containers, images, networks, volumes | +| `lxd` | LXD / Incus socket | containers | +| `kubernetes` | in-cluster config, `$KUBECONFIG`/`~/.kube/config`, or an uploaded kubeconfig | nodes, namespaces, workloads, pods, services, ingresses, storage, events | -### Nginx routing (port 3000, working dev URL) +The orchestrator runs the scopes in parallel. A failing scope is recorded in the snapshot's `errors` and the other scopes still ship. Inside Kubernetes, a resource kind the credential can't list (HTTP 403) is skipped and listed in `permission_issues`. -``` -/ws/ → localhost:8090 (SaaS relay WebSocket) -/api/ → localhost:8090 (SaaS REST API) -/ → localhost:3001 (Next.js frontend) -``` +After discovery: `pkg/relationships` builds edges, `pkg/health` sets node health, `internal/redactor` masks secrets, and `pkg/output` turns it into the graph the UI draws. -**Port 80 is hijacked by k8s iptables DNAT** → do not use port 80 for InfraCanvas. +## Actions (`pkg/actions/`) -### Dev URL +Browser `BROWSER_ACTION` → relay → `ACTION_REQUEST` → agent. -`http://13.200.198.166:3000` +- Docker / Podman / LXD: start, stop, restart, exec terminal, logs, update image +- Kubernetes: scale, rolling restart, update image, delete pod, exec, logs, port-forward +- Host: PTY shell, systemd start/stop, kill process, journal logs -### Agent connection (SaaS) +The relay rejects actions, terminals and cluster changes in `--read-only` mode, and per cluster when that cluster is marked read-only. Every action is written to the audit log (`pkg/audit`). -Agent on bytestrix connects to `localhost:8090` (same VM). Registered as `ByteStrix-5bcfabe8` under org `bytestrix-657a9cc0`. Install script downloaded from `/api/v1/install/binary/linux-amd64`. +## Frontend (`frontend/`) ---- +- `lib/wsManager.ts`: one WebSocket per machine, reconnect with exponential backoff (1s to 30s, ±20% jitter) +- `store/vmStore.ts`: Zustand store, `applyVMDiff()` merges diffs and skips empty ones +- `lib/graphPreprocess.ts`, `lib/layout.ts`: graph to React Flow nodes and layout +- `components/canvas/`: canvas, node detail panel, terminal (xterm.js), logs -## Key Data Models +The dashboard is built with `next build` as a static export and embedded into the Go binary with the `embed_full` build tag (`pkg/webui`). -### GraphNode - -```typescript -{ - id: string // e.g. "/container:abc123" - type: string // "host" | "container" | "pod" | "service" | ... - label: string - layer: string // "host" | "docker" | "kubernetes" - metadata: Record -} -``` - -### GraphEdge - -```typescript -{ - id: string - source: string // node id - target: string // node id - type: string // "runs" | "connects" | "exposes" | ... -} -``` +## State on disk -### GraphDiff - -```typescript -{ - timestamp: string - addedNodes: GraphNode[] - modifiedNodes: GraphNode[] - removedNodeIds: string[] - addedEdges: GraphEdge[] - removedEdgeIds: string[] -} -``` - ---- - -## OSS vs SaaS Comparison - -| | OSS (`infracanvas serve`) | SaaS | -|---|---|---| -| Auth | UI token (random hex) | JWT + API keys | -| Multi-VM | No (one relay = one VM) | Yes (org → many agents) | -| Pairing | Pair code or auto (local) | Agent ID selection | -| Hosting | User's own machine | bytestrix VM | -| Tunnel | Cloudflare quick-tunnel | Direct (nginx proxy) | -| DB | None | Postgres + Redis | - ---- - -## Development Workflow - -```bash -# Local dev (OSS): run everything in one command -./bin/infracanvas serve - -# SaaS frontend dev (separate repo on bytestrix) -# ssh bytestrix, then: -cd ~/infracanvas-saas-frontend && npm run dev -- --port 3001 -# OR managed by systemd: -sudo systemctl restart infracanvas-frontend - -# SaaS backend dev (on bytestrix) -sudo systemctl restart infracanvas-api -sudo journalctl -u infracanvas-api -f - -# Check agent connected -curl http://localhost:8090/api/v1/agents -H "Authorization: Bearer " -``` +Under `INFRACANVAS_STATE_DIR` (default `/var/lib/infracanvas` or the user cache dir): machine ID, resume secret, connected clusters and their kubeconfigs (`0600`), audit log. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b7ca855..1355d80 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,7 +4,7 @@ Thank you for your interest in contributing. This document covers how to set up ## Development setup -**Requirements:** Go 1.21+, Node.js 20+ +**Requirements:** Go 1.25+ (see `go.mod`), Node.js 20+ ```bash git clone https://github.com/bytestrix/InfraCanvas.git @@ -17,7 +17,7 @@ make all make test # Run the dashboard locally -./bin/infracanvas # → http://localhost:7777/?token=… +./bin/infracanvas serve --no-tunnel --private # → http://localhost:7777/?token=… ``` Frontend-only iteration (against a separately running binary): diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d121989 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,33 @@ +# syntax=docker/dockerfile:1 + +# ── Dashboard (Next.js static export) ───────────────────────────────────────── +FROM node:20-alpine AS ui +WORKDIR /src/frontend +COPY frontend/package.json frontend/package-lock.json ./ +RUN npm ci --no-audit --no-fund +COPY frontend/ ./ +RUN npm run build + +# ── Binary with the dashboard embedded ──────────────────────────────────────── +FROM golang:1.25-alpine AS build +ARG VERSION=dev +ARG COMMIT=unknown +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +COPY --from=ui /src/frontend/out/ pkg/webui/dist/ +RUN CGO_ENABLED=0 go build -tags embed_full \ + -ldflags "-s -w -X infracanvas/cmd/infracanvas/cmd.Version=${VERSION} -X infracanvas/cmd/infracanvas/cmd.GitCommit=${COMMIT}" \ + -o /out/infracanvas ./cmd/infracanvas + +# ── Runtime ─────────────────────────────────────────────────────────────────── +# Alpine rather than distroless: the host terminal and log tailing need a shell. +FROM alpine:3.20 +RUN apk add --no-cache ca-certificates bash +COPY --from=build /out/infracanvas /usr/local/bin/infracanvas +ENV INFRACANVAS_STATE_DIR=/data +VOLUME /data +EXPOSE 7777 +ENTRYPOINT ["infracanvas"] +CMD ["serve", "--no-tunnel"] diff --git a/README.md b/README.md index 8347e7f..ed559d1 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ +

InfraCanvas

+

- InfraCanvas: Your infrastructure, as a live map + A live map of everything running on your servers: containers, pods, services, volumes, networks and plain processes, with a terminal one click away.

@@ -9,486 +11,103 @@

- Website · Live demo · Quick start · - Trust · - Clusters · - Multiple VMs · - Features · - How it works · - Security · - Contributing + Install options · + Security · + Website

---- - -## What is it? - -You SSH into a server and start piecing things together: `docker ps`, `kubectl get pods`, `ss -tlnp`, `systemctl list-units`, `df -h`... ten commands later you still have no real picture of **what's running and how it all connects**. - -InfraCanvas replaces that ritual with one binary you run yourself. It discovers every container, pod, service, volume and network, plus systemd services and processes on plain VMs, and serves a **live, interactive topology map** in your browser. Nodes are green when healthy, red when not. Open a terminal inside any container, tail logs, restart a service, or scale a deployment, all without leaving the page. Point it at [several VMs](#multiple-vms-one-dashboard) or [several clusters](#clusters-kubernetes-with-zero-install) and they all land in one dashboard. +

+ InfraCanvas canvas: a host with its Kubernetes cluster, Docker runtime, containers, networks, volumes and systemd services drawn as connected nodes +

-You get a **map**, not a list: what runs where, what talks to what, and what's broken, at a glance. Self-hosted end to end: your infrastructure data never leaves your machines. +You SSH into a box and run `docker ps`, `kubectl get pods`, `ss -tlnp`, `systemctl list-units`, `df -h`, and you still have to piece together what talks to what. InfraCanvas is one Go binary that does that discovery for you and draws it as a graph in your browser. It refreshes every 30 seconds. From any node you can open a shell, tail logs, restart, scale or kill. ---- +It runs on your machine. The self-hosted binary has no account, no telemetry and no InfraCanvas backend. ## Quick start -Install and run, one command: - -```bash -curl -fsSL https://github.com/bytestrix/InfraCanvas/releases/latest/download/install.sh | bash -``` - -It installs the binary, runs it as a systemd service, and opens a temporary Cloudflare tunnel so you get a working HTTPS URL immediately, no domain or certs to set up first: - -``` -✓ InfraCanvas installed and running - - Open in your browser: - https://shy-pine-2f1a.trycloudflare.com/?token=a8f3e2b1c9d4f02e - - Auth token: a8f3e2b1c9d4f02e (saved in /etc/infracanvas/config.env) -``` - -Running it on your own laptop rather than a VM? Add `--private --no-tunnel` so nothing leaves the machine: - -```bash -curl -fsSL https://github.com/bytestrix/InfraCanvas/releases/latest/download/install.sh | bash -s -- --private --no-tunnel -``` - -Don't want Cloudflare involved on a VM either? Pass `--no-tunnel` to bind the port yourself and put [your own reverse proxy in front](#self-hosting-without-cloudflare), or `--private` alone to bind `127.0.0.1` only and reach it over an SSH tunnel. - -- **Have Docker or a local Kubernetes context** (kind, minikube, Docker Desktop) on this machine? InfraCanvas auto-discovers local kubeconfig contexts (`$KUBECONFIG` or `~/.kube/config`) and connects to the current context at startup. To skip local kubeconfig auto-discovery, run `infracanvas serve --discover-local-kubeconfig=false` (or set `INFRACANVAS_DISCOVER_LOCAL_KUBECONFIG=false`). -- **Want to point it at a real cluster?** Click **+** next to **Clusters** in the sidebar and drop a kubeconfig. The dashboard talks to that cluster's API server directly, the same way `kubectl` does; the file is read into memory by this same local process and never transmitted anywhere. A picker appears if it has multiple contexts. [Full details](#clusters-kubernetes-with-zero-install), or expand below if you don't have a kubeconfig handy. - -
-Don't have a kubeconfig handy? - -If `kubectl` already works on this machine, you already have one: it's whatever `$KUBECONFIG` points to, or `~/.kube/config` by default: - -```bash -cat ~/.kube/config -``` - -You can paste that whole file in. If it has clusters/contexts you don't want to hand over, trim it to just the one you're connecting first: +**On your laptop or any machine, localhost only:** ```bash -kubectl config view --minify --flatten > this-cluster-only.yaml +os=$(uname -s | tr '[:upper:]' '[:lower:]'); arch=$(uname -m) +[ "$arch" = x86_64 ] && arch=amd64; [ "$arch" = aarch64 ] && arch=arm64 +curl -fsSLo infracanvas "https://github.com/bytestrix/InfraCanvas/releases/latest/download/infracanvas-$os-$arch" +chmod +x infracanvas && ./infracanvas serve --no-tunnel --private ``` -`--flatten` matters: it inlines any certs the file references by path, so the copy is self-contained. +Open the `http://localhost:7777/?token=…` URL it prints. It finds Docker, Podman and your current kubeconfig context on its own. -No local kubeconfig yet? Generate one from your cloud provider, then drop that file in instead: +**With Docker:** ```bash -aws eks update-kubeconfig --name --region # EKS -gcloud container clusters get-credentials --zone # GKE -az aks get-credentials --resource-group --name # AKS +docker run -d --name infracanvas --pid=host --network=host \ + -v /var/run/docker.sock:/var/run/docker.sock -v infracanvas:/data \ + ghcr.io/bytestrix/infracanvas:latest serve --no-tunnel --private +docker logs infracanvas # prints the URL ``` -One caveat: EKS/GKE/AKS-generated kubeconfigs typically authenticate via an `exec:` plugin (`aws`, `gcloud`, `az`) rather than an embedded token; that CLI needs to be installed and logged in on whichever machine runs `infracanvas serve`. This is automatically true if you're running it wherever `kubectl` already works for you, as above; it can bite you if you copy the file to a different machine that doesn't have that CLI. - -
- -**Add more, once it's running:** - -- **Another VM**: click **+ Add machine** in the sidebar; it hands you a ready-to-paste install command for that machine, join token included. [Details](#multiple-vms-one-dashboard) -- **Another cluster**: click **+** next to **Clusters** again, once per kubeconfig. -- **Don't want to run the dashboard yourself?** [InfraCanvas Cloud](https://cloud.infracanvas.app) does it for you: first 3 VMs free, no credit card. - -
-Other ways to run it - -
- -
-From source, in a terminal: no install, nothing persists - -No VM, no systemd, no public URL, nothing to trust yet. Clone it, build it, run it on the machine you're already on: +**On a Linux VM, as a systemd service:** ```bash -git clone https://github.com/bytestrix/InfraCanvas.git -cd InfraCanvas && make all -./bin/infracanvas serve --no-tunnel --private -# → http://localhost:7777/?token=… -``` - -Requires Go 1.21+ and Node.js 20+. Open the URL it prints. `--private` binds `127.0.0.1` only, `--no-tunnel` skips Cloudflare entirely; nothing leaves your machine. - -
- -
-Release binary: no installer, no systemd - -Grab a prebuilt binary from [Releases](https://github.com/bytestrix/InfraCanvas/releases/latest), make it executable, run it: - -```bash -curl -fsSLO https://github.com/bytestrix/InfraCanvas/releases/latest/download/infracanvas-linux-amd64 -chmod +x infracanvas-linux-amd64 - -./infracanvas-linux-amd64 serve --no-tunnel --private # localhost only, SSH-tunnel in -./infracanvas-linux-amd64 serve --no-tunnel # bind 0.0.0.0:7777, open the port yourself -``` - -Just a static binary you can delete when done, no firewall changes made on your behalf. - -
- -
-Kubernetes cluster, no VM to run this on - -Grab the binary and run it wherever `kubectl` already works for you (laptop, bastion, anywhere with network access to the cluster): - -```bash -os=$(uname -s | tr '[:upper:]' '[:lower:]'); arch=$(uname -m); [ "$arch" = x86_64 ] && arch=amd64; [ "$arch" = aarch64 ] && arch=arm64 -curl -fsSLO "https://github.com/bytestrix/InfraCanvas/releases/latest/download/infracanvas-$os-$arch" -chmod +x infracanvas-* -./infracanvas-* serve --no-tunnel --private -``` - -
- -
-One-liner install flags - -Every flag below works through the curl installer too ([read the script first](install-agent.sh), it's one file of plain bash): - -```bash -# Skip Cloudflare tunnel; bind 0.0.0.0:7777 directly -curl -fsSL https://github.com/bytestrix/InfraCanvas/releases/latest/download/install.sh | bash -s -- --no-tunnel - -# Bind 127.0.0.1 only; reach via SSH tunnel (implies --no-tunnel) curl -fsSL https://github.com/bytestrix/InfraCanvas/releases/latest/download/install.sh | bash -s -- --private - -# Custom port (default 7777), only matters with --no-tunnel -curl -fsSL https://github.com/bytestrix/InfraCanvas/releases/latest/download/install.sh | bash -s -- --port 8888 - -# Read-only: viewers can look, not touch; public demos and dashboards on a TV -curl -fsSL https://github.com/bytestrix/InfraCanvas/releases/latest/download/install.sh | bash -s -- --read-only - -# Disable local kubeconfig auto-discovery (keeps Docker auto-discovery on) -INFRACANVAS_DISCOVER_LOCAL_KUBECONFIG=false infracanvas serve - -# Agent-only: join this VM to an existing hub (see Multiple VMs below) -curl -fsSL https://github.com/bytestrix/InfraCanvas/releases/latest/download/install.sh | bash -s -- --join --token - -# Pin a specific version -curl -fsSL https://github.com/bytestrix/InfraCanvas/releases/latest/download/install.sh | bash -s -- --version v0.12.1 ``` -
- -
- ---- - -## Can I trust this on my VM? - -You should ask that about anything you run on a production box. Here's the model, verifiable in this repo: - -- **Your data never leaves your machines.** Discovery, the relay, and the dashboard all run in one process on your VM. There is no cloud backend, no account, no telemetry, no phone-home. The only outbound connection is the optional Cloudflare tunnel, and you can turn it off. -- **The tunnel is a convenience, not a requirement.** `--no-tunnel` binds a port you open yourself; `--private` binds `127.0.0.1` so the only way in is your own SSH. Zero third parties involved. -- **Everything is readable before you run it.** The [installer](install-agent.sh) is one file of plain bash. The whole product is AGPL-3.0: build it from source in two commands and run exactly what you compiled. -- **Auth by default.** Every install generates a random token; without it every request gets `401`. Secrets in env vars are [redacted](#security-model) before they ever reach the UI layer. -- **Runs as your user, not root**, with only the access you already have (docker group, your kubeconfig). - -Full details in the [Security model](#security-model) and [SECURITY.md](SECURITY.md). - ---- - -## Multiple VMs, one dashboard - -The mechanics behind the self-host path in [Quick start](#quick-start): one VM runs the dashboard (the **hub**); every other VM streams to it over an **outbound-only** WebSocket, no ports opened, nothing installed beyond the agent. The dashboard's **+ Add machine** button gives you the join command below pre-filled with the right host/token; this is what to run if you'd rather do it by hand. - -```bash -# On the hub VM: -infracanvas serve # prints a join token + ready-made join command - -# On every other VM (copy the command serve printed): -curl -fsSL https://github.com/bytestrix/InfraCanvas/releases/latest/download/install.sh \ - | sudo bash -s -- --join --token - -# or, with the binary already there: -infracanvas start --backend --token -``` - -Each machine appears in the sidebar's **Machines** list within seconds; click to switch between live canvases. Agents reconnect automatically and keep their identity across restarts. - -Prefer fully isolated dashboards instead? Just install normally on each VM: every one gets its own URL. - ---- - -## Clusters: Kubernetes with zero install +Leave off `--private` and the installer opens a Cloudflare quick-tunnel and prints a public HTTPS URL. That's handy, but it puts a dashboard with terminal access on the internet behind a token, so read [the install guide](docs/install.md#on-a-linux-vm-with-the-installer) first. -If you already run Kubernetes, you don't need to put an agent anywhere. Click **+** next to **Clusters** in the sidebar and drop a kubeconfig; the dashboard talks to your cluster's API server directly, the same way `kubectl` does, running from the same machine as `infracanvas serve`. +## What it shows -The kubeconfig **never leaves your machine**: it's read straight into memory by the same process serving your dashboard, saved locally at `0600` permissions next to your other InfraCanvas state, and never transmitted anywhere. If the cluster's API server is reachable from wherever `infracanvas serve` is running, this is the whole setup: no pod to deploy, no RBAC to review, no trust given to anything beyond your own kubeconfig's existing permissions. +| Source | What you get | +|---|---| +| **Docker / Podman** | containers, images, volumes, networks, port mappings, env (secrets masked) | +| **Kubernetes** | nodes, namespaces, deployments, statefulsets, daemonsets, jobs, pods, services, ingresses, PVCs, events | +| **LXD / Incus** | containers with status, memory and network | +| **Plain VMs** | systemd services, PM2 apps and listening processes, with `CONNECTS_TO` edges built from `/proc/net/tcp` (e.g. `next-server → postgres :5432`) | -A kubeconfig with several contexts shows a picker so you connect exactly the cluster(s) you mean to; nothing gets added silently. Add as many as you like; each shows up as its own entry under **Clusters**. +Nodes are green, amber or red from real state: container status and health checks, pod phase, ready replicas, zombie processes, and host CPU, memory and disk thresholds. The canvas starts at the host; click a group to drill into it, so a cluster with hundreds of pods doesn't land on screen all at once. ---- +**From any node:** shell into a container, pod or the host; tail logs from containers, pods or systemd units; restart, stop, start, scale, rolling-restart, change an image, or kill a process. `--read-only` turns all of that off for demos and status screens. -## Features +**More than one machine:** other VMs join a hub with an outbound-only agent, and Kubernetes clusters connect by dropping in a kubeconfig, with nothing installed in the cluster. See [Multiple machines and clusters](docs/multi-machine.md). -### Live topology map -Every container, pod, service, volume and network drawn as connected nodes with edges showing what talks to what. Not a list, a map. Updates every 30 seconds, diff-only. +## How it compares -Starts minimal, just the host. Click **•••** on any node to drill into what's inside it (Kubernetes → Deployments → a specific pod, for example), so a box running a full cluster doesn't dump hundreds of nodes on you at once. +| Tool | Covers | View | +|---|---|---| +| **InfraCanvas** | Docker, Podman, Kubernetes, LXD/Incus, systemd, PM2, processes | one graph across all of them | +| Weave Scope | Docker, Kubernetes, processes | graph; no longer maintained | +| Portainer | Docker, Kubernetes | lists and forms | +| Lens, Headlamp, k9s | Kubernetes | Kubernetes resources only | -### Works on any VM, even without Docker or Kubernetes -Plain VMs running nginx, postgres, node via systemd or PM2 get real workload nodes on the canvas. Listening ports and established connections are mapped from `/proc/net/tcp`, so you get real `CONNECTS_TO` edges (e.g. `next-server → postgres :5432`) without any config. - -### LXC / LXD / Incus -Containers managed by LXD or Incus are auto-discovered from the local socket and drawn on the canvas alongside Docker and Kubernetes: name, status, memory, and network, no config. Interactive terminal sessions plus one-click start/stop/restart actions are also supported for these containers. - -### Podman -Podman's daemon speaks the same API as Docker's, so the agent talks to it with no separate integration: if there's no Docker socket, it checks Podman's rootless socket (`/run/user//podman/podman.sock`) and then the rootful one, in that order, before falling back to `DOCKER_HOST` if you've set it. Containers, images, volumes, and networks all show up the same way they would under Docker, and the runtime node on the canvas correctly labels itself "Podman" instead of "Docker." - -### Terminals, logs and actions built in -- **Container terminal**: full interactive shell inside any container -- **VM shell**: host PTY, no SSH needed -- **Logs**: tail any container, pod, or systemd service with one click, color-coded -- **Actions**: restart, stop, start, scale, rolling-restart, update image, service start/stop, process kill, all from the node panel - -### Health at a glance -Green / amber / red from real container state, pod phase, and zombie process detection. An alert banner appears automatically when something breaks. - -### Inspect everything -Env vars (secrets auto-masked), port mappings, volume mounts, image details, service unit, main PID, restart count, established connections. - -### Zero dependencies -One static Go binary with the dashboard embedded. Works with Docker, Kubernetes, plain systemd services, PM2, none of them required. - -### Many VMs, one canvas -Run the dashboard on one VM and join the rest as outbound-only agents; no inbound ports on the joined machines. Switch between them from the sidebar. See [Multiple VMs](#multiple-vms-one-dashboard). - -### Kubernetes clusters, no agent required -Drop a kubeconfig and see/control that cluster immediately: no install anywhere, kubeconfig never leaves your machine. See [Clusters](#clusters-kubernetes-with-zero-install). - -### Secure by default -Binds localhost. Tunnel is optional and outbound-only. Random per-install auth token, separate token for joining agents. Secret redaction before data leaves the discovery layer. Runs as your user, not root. Optional `--read-only` mode for public dashboards. - ---- +If you used Weave Scope, this covers the same ground and adds LXD, systemd and PM2. ## How it works -

Architecture diagram: infracanvas serve runs the relay, dashboard, and discovery in one process; your browser reaches it directly or through an optional outbound-only Cloudflare tunnel; other VMs join as outbound agents; Kubernetes clusters connect directly via kubeconfig with no agent installed

- -One binary, one URL. The dashboard, relay and discovery agent all run in the same process on the machine you're inspecting. Your browser is just a client. The tunnel is optional; with `--no-tunnel` or `--private` it drops out entirely and nothing leaves your network. - -Adding more VMs keeps the same shape: the hub's relay accepts extra agents, each connecting **outbound** to the hub; joined VMs open no inbound port and serve no UI, they only push their graph to the hub. - -Kubernetes clusters connect a third way, with no agent at all: drop a kubeconfig and the hub talks to that cluster's API server directly, the same way `kubectl` does. See [Clusters](#clusters-kubernetes-with-zero-install). - ---- - -## Self-hosting without Cloudflare - -The default install uses a Cloudflare quick-tunnel for zero-config HTTPS. If you want to use your own domain and reverse proxy instead, pass `--no-tunnel`: - -```bash -curl -fsSL https://github.com/bytestrix/InfraCanvas/releases/latest/download/install.sh | bash -s -- --no-tunnel -# Binds 0.0.0.0:7777, no cloudflared process started -``` - -Then point your reverse proxy at `127.0.0.1:7777`. - -
-Nginx + Let's Encrypt - -```nginx -server { - listen 80; - server_name infra.yourdomain.com; - return 301 https://$host$request_uri; -} - -server { - listen 443 ssl; - server_name infra.yourdomain.com; - - ssl_certificate /etc/letsencrypt/live/infra.yourdomain.com/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/infra.yourdomain.com/privkey.pem; - - location / { - proxy_pass http://127.0.0.1:7777; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - proxy_set_header Host $host; - proxy_read_timeout 300s; - } -} -``` - -Get a cert: `sudo certbot --nginx -d infra.yourdomain.com` - -
- -
-Caddy (auto-HTTPS, simplest option) - -```caddy -infra.yourdomain.com { - reverse_proxy localhost:7777 -} -``` - -Caddy handles TLS automatically. No certbot needed. - -
- -
-SSH tunnel (no domain needed) - -Keep the server private (`--private` binds `127.0.0.1` only), then forward to your laptop: - -```bash -# On your laptop: -ssh -L 7777:127.0.0.1:7777 user@your-server -# Open: http://localhost:7777/?token= -``` - -No public exposure, no domain, no TLS setup. - -
- ---- - -## Security model - -**The exposed URL.** Default mode binds `127.0.0.1:7777` and exposes it through a Cloudflare quick-tunnel, outbound-only from your VM, HTTPS-terminated at Cloudflare's edge. Pass `--no-tunnel` to bind `0.0.0.0` directly, or `--private` to bind `127.0.0.1` only and reach it via SSH tunnel. - -**The auth token.** Every install generates a random 24-character token saved to `/etc/infracanvas/config.env`. The dashboard requires it on first visit (`?token=…`); after that it's in an HTTP-only cookie. Without the token, every request returns `401`. Treat the URL+token like an SSH key for the box. +

Architecture: infracanvas serve runs discovery, the relay and the dashboard in one process; the browser connects directly or through an optional Cloudflare tunnel; other VMs join as outbound agents; clusters connect through their kubeconfig

-**Joining agents.** In hub mode the relay issues a separate join token; an agent that can't present it is rejected at the WebSocket handshake. Joined VMs connect outbound only; the hub never dials into them, so they need no open port. The join token alone doesn't prove *which* machine a connection is, though: each machine ID is issued its own resume secret on first connect, and a reconnect claiming that ID without it is rejected outright rather than silently taking over an existing session. +`infracanvas serve` runs discovery, a WebSocket relay and the embedded Next.js dashboard in one process. Each discovery pass builds a graph, and the agent sends only the diff to the browser. Joined VMs run `infracanvas start` and push their own graph to the hub. Details in [ARCHITECTURE.md](ARCHITECTURE.md). -**Read-only mode.** Pass `--read-only` to turn the dashboard into a viewer: the relay rejects every action, terminal request, and cluster connect/disconnect server-side. Topology and logs still work. Use this for public demos or a wall-mounted status screen. Each connected cluster also has its own independent read-only toggle, so you can keep some clusters view-only without turning on global read-only mode. +## Security, in short -**Permission preview.** Before you commit to connecting a kubeconfig, the Add Cluster dialog shows what it can actually do (view, exec, restart/kill, scale/edit, read Secrets), checked live against the cluster, nothing persisted or connected until you confirm. +- A random 24-character token is required for every request; without it you get `401`. +- Env vars and command-line flags that look like secrets are masked before they reach the UI. +- Every action and terminal session is written to a local audit log. +- The systemd service runs as the user who ran the installer, not root, unless you install from a root shell. -**Audit log.** Every write action, terminal session, and read-only-blocked attempt is recorded locally and viewable from the dashboard's Audit tab. Attributed by machine, not by user; there's no per-user login in the self-hosted version. +Full model, including what the tunnel exposes: [docs/security.md](docs/security.md). Vulnerability reports: [SECURITY.md](SECURITY.md). -**Secret redaction.** Env vars whose names contain `SECRET`, `TOKEN`, `KEY`, `PASSWORD`, `CREDENTIAL`, `AUTH`, or `PASSWD` are replaced with `[REDACTED]` before they leave the discovery layer. +## Hosted version -**Runs as you, not root.** The systemd unit runs as `$SUDO_USER`. The agent inherits your `~/.kube/config` and docker group membership; no privilege escalation beyond what you already have. - -See [SECURITY.md](SECURITY.md) for the vulnerability disclosure policy. - ---- - -## Managing the service - -```bash -sudo systemctl status infracanvas -sudo systemctl restart infracanvas -sudo systemctl stop infracanvas -sudo journalctl -u infracanvas -f -``` - -Config in `/etc/infracanvas/config.env`: - -```bash -INFRACANVAS_UI_TOKEN=a8f3e2b1c9d4f02e -INFRACANVAS_PORT=7777 -INFRACANVAS_TUNNEL=true -INFRACANVAS_PRIVATE=false -INFRACANVAS_READONLY=false -``` - -On a VM installed with `--join`, the same file instead holds the hub address and join token: - -```bash -INFRACANVAS_BACKEND=https://hub.example.com -INFRACANVAS_TOKEN= -``` - -Edit, then `sudo systemctl restart infracanvas`. - -
-Uninstall - -```bash -curl -fsSL https://github.com/bytestrix/InfraCanvas/releases/latest/download/uninstall.sh | sudo bash -``` - -Removes: binary, systemd unit, `/etc/infracanvas/`, and the cached `cloudflared` binary (~30 MB). Or run locally: `sudo ./uninstall-agent.sh` - -
- ---- +[InfraCanvas Cloud](https://cloud.infracanvas.app) runs the hub for you. The first 3 VMs are free. The open-source binary above is the full product and doesn't need it. ## Contributing -Contributions welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) and the [Code of Conduct](CODE_OF_CONDUCT.md). Open an issue before a large PR. `make test` and `make lint` must pass, plus `cd frontend && npm run lint`. - -New here? Start with [`good first issue`](https://github.com/bytestrix/InfraCanvas/issues?q=is%3Aopen+label%3A%22good+first+issue%22). - -Clone and build it the same way as [Quick start](#quick-start) above (`make all`, requires Go 1.21+ and Node.js 20+); these are the other targets you'll want while working on it: - -
-Make targets - -```bash -make build-frontend # Next.js static export → pkg/webui/dist/ -make build # binary with embedded UI (requires dist/) -make build-stub # binary with placeholder UI, fast, for backend iteration -make release # cross-compile linux/darwin × amd64/arm64 → bin/release/ -make test # Go tests -make clean # remove bin/ and embedded dashboard -``` - -
- -
-Project layout - -``` -InfraCanvas/ -├── cmd/infracanvas/cmd/ -│ ├── serve.go # `infracanvas serve`: boots relay + UI + agent -│ ├── start.go # `infracanvas start`: agent-only mode -│ ├── discover.go # one-shot CLI discovery -│ └── … -├── pkg/ -│ ├── agent/ # WebSocket agent: discover, diff, exec, actions -│ ├── server/ # relay: WebSocket broker, sessions, auth, static UI -│ ├── webui/ # embedded dashboard (build-tagged) -│ ├── actions/ # Docker / K8s / Host action runners -│ ├── discovery/ # docker, host, kubernetes -│ ├── orchestrator/ # combines discovery sources into one snapshot -│ ├── output/ # graph builder -│ ├── relationships/ # edges between entities -│ ├── health/ # health status calculation -│ └── redactor/ # strips sensitive env vars -├── frontend/ -│ ├── app/page.tsx # dashboard shell, machine switcher, auto-connects local -│ ├── components/canvas/ # ReactFlow canvas, node detail panel, terminal, logs -│ ├── lib/wsManager.ts # WS client -│ └── store/vmStore.ts # Zustand state -├── install-agent.sh -└── uninstall-agent.sh -``` - -
- -See [ARCHITECTURE.md](ARCHITECTURE.md) for a deeper dive. - ---- +See [CONTRIBUTING.md](CONTRIBUTING.md). Issues labelled [`good first issue`](https://github.com/bytestrix/InfraCanvas/issues?q=is%3Aopen+label%3A%22good+first+issue%22) are scoped for a first PR. Build with `make all` (Go 1.25+, Node.js 20+); `make test`, `make lint` and `cd frontend && npm run lint` must pass. ## License -GNU Affero General Public License v3.0. See [LICENSE](LICENSE). - -- Free for any personal or internal company use -- Fork, modify, redistribute: keep changes open source -- If you run this as a paid cloud service for customers, your modifications must be open source too - ---- - -## Contributors +[AGPL-3.0](LICENSE). Free to use inside your company and to modify. If you distribute it, or run a modified version as a service for others, you have to publish your changes. Contributors diff --git a/cmd/infracanvas/cmd/serve.go b/cmd/infracanvas/cmd/serve.go index dec523e..03d82e2 100644 --- a/cmd/infracanvas/cmd/serve.go +++ b/cmd/infracanvas/cmd/serve.go @@ -67,7 +67,9 @@ and it appears in the sidebar's machine list. Environment variables: INFRACANVAS_UI_TOKEN Auth token (default: random per run) INFRACANVAS_AGENT_TOKEN Join token other VMs use (default: random per run) - INFRACANVAS_SCOPE Discovery scopes (default: host,docker,kubernetes) + INFRACANVAS_SCOPE Discovery scopes (default: host,docker,lxd,kubernetes) + INFRACANVAS_TUNNEL false = same as --no-tunnel + INFRACANVAS_PRIVATE true = same as --private INFRACANVAS_DISCOVER_LOCAL_KUBECONFIG Override --discover-local-kubeconfig`, RunE: runServe, @@ -76,7 +78,7 @@ Environment variables: func init() { rootCmd.AddCommand(serveCmd) serveCmd.Flags().IntVar(&servePort, "port", defaultPort, "Local port (auto-falls-back if taken)") - serveCmd.Flags().BoolVar(&servePrivate, "private", false, "With --no-tunnel: bind 127.0.0.1 instead of 0.0.0.0") + serveCmd.Flags().BoolVar(&servePrivate, "private", false, "Bind 127.0.0.1 only, no tunnel (reach it over SSH)") serveCmd.Flags().BoolVar(&serveNoTunnel, "no-tunnel", false, "Disable Cloudflare tunnel; bind the port directly") serveCmd.Flags().BoolVar(&serveReadOnly, "read-only", false, "Block actions and terminals; viewers can only look (for public demos)") serveCmd.Flags().StringVar(&serveUIToken, "token", "", "Override the UI auth token") @@ -87,6 +89,16 @@ func init() { } func runServe(cmd *cobra.Command, args []string) error { + // config.env written by the installer sets these; an explicit flag wins. + if v, ok := parseBoolEnv("INFRACANVAS_TUNNEL"); ok && !cmd.Flags().Changed("no-tunnel") { + serveNoTunnel = !v + } + if v, ok := parseBoolEnv("INFRACANVAS_PRIVATE"); ok && !cmd.Flags().Changed("private") { + servePrivate = v + } + if servePrivate { + serveNoTunnel = true + } useTunnel := !serveNoTunnel host := "0.0.0.0" if useTunnel || servePrivate { diff --git a/docs/install.md b/docs/install.md new file mode 100644 index 0000000..df3e19c --- /dev/null +++ b/docs/install.md @@ -0,0 +1,201 @@ +# Installing and running InfraCanvas + +Every method below runs the same binary. Pick by where you want the dashboard to live. + +- [Local, on your own machine](#local-on-your-own-machine) +- [Docker](#docker) +- [On a Linux VM with the installer](#on-a-linux-vm-with-the-installer) +- [From source](#from-source) +- [Installer flags](#installer-flags) +- [Serve flags and environment variables](#serve-flags-and-environment-variables) +- [Putting it behind your own domain](#putting-it-behind-your-own-domain) +- [Managing the service](#managing-the-service) +- [Uninstall](#uninstall) + +## Local, on your own machine + +Download the release binary and run it bound to `127.0.0.1`, with no tunnel. Nothing is installed and nothing leaves the machine. + +```bash +os=$(uname -s | tr '[:upper:]' '[:lower:]'); arch=$(uname -m) +[ "$arch" = x86_64 ] && arch=amd64; [ "$arch" = aarch64 ] && arch=arm64 +curl -fsSLo infracanvas "https://github.com/bytestrix/InfraCanvas/releases/latest/download/infracanvas-$os-$arch" +chmod +x infracanvas +./infracanvas serve --no-tunnel --private +# → http://localhost:7777/?token=… +``` + +Builds exist for linux and darwin, amd64 and arm64. It picks up Docker, Podman and your current kubeconfig context automatically. + +## Docker + +```bash +docker run -d --name infracanvas --pid=host --network=host \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -v infracanvas:/data \ + ghcr.io/bytestrix/infracanvas:latest serve --no-tunnel --private + +docker logs infracanvas # prints the URL with its token +``` + +Why the flags: + +- `--pid=host` lets it read the host's processes from `/proc`. Without it you only see the container's own processes. +- `--network=host` lets it read the host's listening ports and connections, and makes `--private` bind the host's `127.0.0.1:7777`. Drop `--private` to listen on all interfaces. +- `/var/run/docker.sock` is how it reads and controls containers. Mount it `:ro` if you only want to look; actions and container terminals will then fail. +- `/data` holds the machine ID, connected clusters and the audit log. + +Limits compared to a native install: the "VM shell" terminal opens a shell inside the InfraCanvas container, not on the host, and systemd log tailing needs `journalctl`, which the image doesn't include. To see a Kubernetes cluster, connect it from the dashboard with a kubeconfig (see [Clusters](multi-machine.md#kubernetes-clusters)). + +## On a Linux VM with the installer + +```bash +curl -fsSL https://github.com/bytestrix/InfraCanvas/releases/latest/download/install.sh | bash +``` + +The script ([read it first](../install-agent.sh), it's one file of bash): + +1. downloads the binary to `/usr/local/bin/infracanvas` +2. writes `/etc/infracanvas/config.env` with a random 24-character auth token +3. installs a systemd unit that runs as the user who invoked it (see `--run-user`) +4. by default opens a Cloudflare quick-tunnel and prints an `https://….trycloudflare.com/?token=…` URL + +The quick-tunnel gives you HTTPS with no domain or open port, but it also makes the dashboard reachable from the internet by anyone with the URL and token. The dashboard includes terminals and write actions, so treat that URL like an SSH key. If you don't want that: + +```bash +# localhost only; reach it with `ssh -L 7777:127.0.0.1:7777 user@vm` +curl -fsSL https://github.com/bytestrix/InfraCanvas/releases/latest/download/install.sh | bash -s -- --private + +# bind 0.0.0.0:7777 and put your own reverse proxy in front +curl -fsSL https://github.com/bytestrix/InfraCanvas/releases/latest/download/install.sh | bash -s -- --no-tunnel +``` + +Example output: + +``` +✓ InfraCanvas installed and running + + Open in your browser: + https://shy-pine-2f1a.trycloudflare.com/?token=a8f3e2b1c9d4f02e7b61c5d0 + + Auth token: a8f3e2b1c9d4f02e7b61c5d0 (saved in /etc/infracanvas/config.env) +``` + +## From source + +Requires Go 1.25+ (see `go.mod`) and Node.js 20+. + +```bash +git clone https://github.com/bytestrix/InfraCanvas.git +cd InfraCanvas && make all +./bin/infracanvas serve --no-tunnel --private +``` + +## Installer flags + +Pass them after `bash -s --`: + +| Flag | Effect | +|---|---| +| `--private` | Bind `127.0.0.1` only. Implies `--no-tunnel`. | +| `--no-tunnel` | Don't start the Cloudflare tunnel; bind `0.0.0.0` on the port. | +| `--port ` | Listen port, default 7777. Falls back to a free port if taken. | +| `--read-only` | Viewers can look but not act: actions, terminals and cluster changes are rejected server-side. | +| `--run-user ` | User the service runs as. Default: the user running the installer (`$SUDO_USER` under sudo). If run directly as root: a user with `~/.kube/config`, then one in the `docker` group, then root. | +| `--version ` | Install a specific release tag, e.g. `v0.19.4`. | +| `--join --token ` | Agent-only install that streams to an existing hub. See [Multiple machines](multi-machine.md). | + +## Serve flags and environment variables + +`infracanvas serve --help` lists everything. The ones you'll use: + +| Flag | Env var | Default | +|---|---|---| +| `--port` | `INFRACANVAS_PORT` (read by the systemd unit) | `7777` | +| `--no-tunnel` | `INFRACANVAS_TUNNEL=false` | tunnel on | +| `--private` | `INFRACANVAS_PRIVATE=true` | off; implies `--no-tunnel` | +| `--read-only` | `INFRACANVAS_READONLY=true` | off | +| `--token` | `INFRACANVAS_UI_TOKEN` | random | +| `--agent-token` | `INFRACANVAS_AGENT_TOKEN` | random | +| `--discover` | `INFRACANVAS_SCOPE` | `host,docker,lxd,kubernetes` | +| `--refresh` | | `30` seconds | +| `--discover-local-kubeconfig` | `INFRACANVAS_DISCOVER_LOCAL_KUBECONFIG` | `true` | +| | `INFRACANVAS_STATE_DIR` | `/var/lib/infracanvas` if it exists, else the user cache dir | + +## Putting it behind your own domain + +Install with `--no-tunnel`, then point a reverse proxy at `127.0.0.1:7777`. The dashboard uses WebSockets, so the proxy has to pass `Upgrade` headers. + +**Caddy** (gets a certificate automatically): + +```caddy +infra.example.com { + reverse_proxy localhost:7777 +} +``` + +**Nginx + Let's Encrypt:** + +```nginx +server { + listen 80; + server_name infra.example.com; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl; + server_name infra.example.com; + + ssl_certificate /etc/letsencrypt/live/infra.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/infra.example.com/privkey.pem; + + location / { + proxy_pass http://127.0.0.1:7777; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_read_timeout 300s; + } +} +``` + +Get the certificate with `sudo certbot --nginx -d infra.example.com`. + +**SSH tunnel** (no domain, nothing public): install with `--private`, then from your laptop run `ssh -L 7777:127.0.0.1:7777 user@your-server` and open `http://localhost:7777/?token=`. + +## Managing the service + +```bash +sudo systemctl status infracanvas +sudo systemctl restart infracanvas +sudo journalctl -u infracanvas -f +``` + +`/etc/infracanvas/config.env` on a hub: + +```bash +INFRACANVAS_UI_TOKEN=a8f3e2b1c9d4f02e7b61c5d0 +INFRACANVAS_PORT=7777 +INFRACANVAS_TUNNEL=true +INFRACANVAS_PRIVATE=false +INFRACANVAS_READONLY=false +``` + +On a machine installed with `--join`: + +```bash +INFRACANVAS_BACKEND=https://hub.example.com +INFRACANVAS_TOKEN= +``` + +Edit it, then `sudo systemctl restart infracanvas`. Installs from v0.19.4 or earlier bake the tunnel and private settings into the systemd unit; re-run the installer once to pick up edits to those two. + +## Uninstall + +```bash +curl -fsSL https://github.com/bytestrix/InfraCanvas/releases/latest/download/uninstall.sh | sudo bash +``` + +Removes the binary, the systemd unit, `/etc/infracanvas/` and the cached `cloudflared` binary (about 30 MB). From a clone: `sudo ./uninstall-agent.sh`. diff --git a/docs/multi-machine.md b/docs/multi-machine.md new file mode 100644 index 0000000..36c49dc --- /dev/null +++ b/docs/multi-machine.md @@ -0,0 +1,59 @@ +# Multiple machines and Kubernetes clusters + +One dashboard can show many machines and clusters. Each gets its own entry in the sidebar. + +## Other VMs + +One machine runs the dashboard (the hub). Every other machine runs the agent and streams to the hub over an outbound WebSocket, so it needs no open port. + +The easy way: click **+ Add machine** in the sidebar. It gives you an install command with the hub URL and join token filled in. + +By hand: + +```bash +# On the hub: +infracanvas serve # prints the join token and a ready-made join command + +# On each other VM: +curl -fsSL https://github.com/bytestrix/InfraCanvas/releases/latest/download/install.sh \ + | sudo bash -s -- --join --token + +# or, with the binary already there: +infracanvas start --backend --token +``` + +Agents reconnect on their own and keep their identity across restarts. + +If you'd rather have separate dashboards, install normally on each VM; each gets its own URL. + +## Kubernetes clusters + +A cluster doesn't need anything installed in it. Click **+** next to **Clusters** and drop a kubeconfig. The hub talks to the API server directly, the way `kubectl` does, so the API server has to be reachable from the machine running `infracanvas serve`. + +- A kubeconfig with several contexts shows a picker; nothing is added until you choose. +- The dialog shows what the credential can do before you connect. +- An unreachable API server is reported right away and not saved. +- Adding the same context again updates the existing entry instead of creating a second one. +- If the credential can't list some resource kinds (nodes, secrets, persistent volumes), those are skipped and the rest of the cluster still shows. + +On startup InfraCanvas also connects to the current context in `$KUBECONFIG` or `~/.kube/config`. Turn that off with `--discover-local-kubeconfig=false` or `INFRACANVAS_DISCOVER_LOCAL_KUBECONFIG=false`. + +### Getting a kubeconfig + +If `kubectl` works on this machine you already have one, at `$KUBECONFIG` or `~/.kube/config`. To share only one cluster from it: + +```bash +kubectl config view --minify --flatten > this-cluster-only.yaml +``` + +`--flatten` inlines certificates the file references by path, so the copy is self-contained. + +From a cloud provider: + +```bash +aws eks update-kubeconfig --name --region # EKS +gcloud container clusters get-credentials --zone # GKE +az aks get-credentials --resource-group --name # AKS +``` + +These kubeconfigs authenticate with an `exec:` plugin (`aws`, `gcloud`, `az`). That CLI has to be installed and logged in on the machine running `infracanvas serve`. diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 0000000..da3df22 --- /dev/null +++ b/docs/security.md @@ -0,0 +1,63 @@ +# Security model + +InfraCanvas gives whoever opens the dashboard terminals and write actions on your machines. This page describes what protects that, so you can decide how to expose it. To report a vulnerability, see [SECURITY.md](../SECURITY.md). + +## Where your data goes + +Discovery, the relay and the dashboard run in one process on your machine. There is no InfraCanvas backend, account, telemetry or update check in the self-hosted binary. The only outbound connections are: + +- the Cloudflare quick-tunnel, if you leave it on (`--no-tunnel` or `--private` turn it off) +- Kubernetes API servers of clusters you connect +- the hub, on machines installed with `--join` + +## Network exposure + +| Mode | Listens on | Reachable from | +|---|---|---| +| installer default | `127.0.0.1:7777` + Cloudflare tunnel | the internet, via the `trycloudflare.com` URL | +| `--no-tunnel` | `0.0.0.0:7777` | anything that can reach the port | +| `--private` | `127.0.0.1:7777` | this machine only; use an SSH tunnel | + +The tunnel is outbound-only from your machine and HTTPS-terminated at Cloudflare's edge. It still makes the dashboard public to anyone who has the URL and token. + +## Authentication + +- Every install generates a random 24-character hex token (12 random bytes) and saves it in `/etc/infracanvas/config.env`. +- The dashboard needs it on first visit (`?token=…`), then keeps it in an HTTP-only cookie. Requests without it get `401`. +- Failed attempts are rate-limited per client IP. +- There is no per-user login in the self-hosted version. Anyone with the token has full access, so treat the URL plus token like an SSH key. + +## Joining machines + +- In hub mode the relay issues a separate join token. An agent without it is rejected at the WebSocket handshake. +- Joined machines connect outbound to the hub; the hub never dials them and they open no port. +- Each machine ID gets its own resume secret on first connect. A reconnect that claims an existing machine ID without that secret is rejected, so one joined machine can't take over another's session. + +## Read-only mode + +`--read-only` makes the relay reject every action, terminal request, cluster preview and cluster connect/disconnect on the server side. Topology and logs keep working. Use it for public demos or a status screen. Each connected cluster also has its own read-only toggle. + +## Kubernetes clusters + +- A kubeconfig you upload is parsed in memory by the same process, saved at `0600` under the state directory, and not sent anywhere else. +- Connecting runs the kubeconfig's authentication, including any `exec:` credential plugin (`aws`, `gcloud`, `az`), on the machine running `infracanvas serve`. Only connect kubeconfigs you trust. +- Before you connect, the dialog runs `SelfSubjectAccessReview` checks and shows what the credential can do: view, exec, restart/kill, scale/edit, read Secrets. +- Resource kinds the credential can't list are skipped rather than failing the connection. + +## Secret redaction + +Before data leaves the discovery layer: + +- env vars whose names match `password`, `passwd`, `pwd`, `secret`, `token`, `key`, `credential`, `auth`, `api_key`, `access_key`, `private_key`, `jwt` or `bearer` (case-insensitive) are replaced with `[REDACTED]` +- values that look like JWTs, AWS secret keys, PEM private keys or long base64 strings are replaced too +- `--password=…`-style arguments in process command lines are masked + +Kubernetes Secret values are not put into the graph; only the Secret name, its key names and metadata are. + +## Audit log + +Every write action, terminal session and read-only-blocked attempt is recorded locally and shown in the dashboard's Audit tab. Entries are attributed by machine, not by person. + +## Which user it runs as + +The systemd unit runs as the user who ran the installer (`$SUDO_USER` when run with `sudo`). If you run the installer directly as root, it picks a user with `~/.kube/config`, then a user in the `docker` group, and falls back to root. Override with `--run-user`. The service has the access that user has: docker group membership gives it root-equivalent control of containers, and its kubeconfig decides what it can do in Kubernetes. diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 63b1e38..0f00851 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -811,6 +811,10 @@ function MainContent({ vm, vmKey, view, onSwitchToCanvas, clusterSession, onRemo return } + if (!vm?.graph && vm?.discoveryError) { + return + } + if (view === 'canvas') { if (isLoading) return return ( @@ -932,6 +936,33 @@ function LoadingContent() { ) } +/* ── Discovery failed before the first canvas ── */ +function DiscoveryErrorContent({ message, retrySeconds }: { message: string; retrySeconds?: number }) { + return ( +
+
+
+
+ +
+
+

Discovery failed

+

+ The agent is connected but couldn't read this machine or cluster{retrySeconds ? `. Retrying every ${retrySeconds}s.` : '.'} +

+
+
+

+ {message} +

+

+ A kubeconfig context pointing at a deleted or unreachable cluster causes this. Remove it from the Clusters list, or start with --discover-local-kubeconfig=false to skip local contexts. +

+
+
+ ) +} + /* ── Error ── */ function ErrorContent({ message }: { message: string }) { return ( diff --git a/frontend/lib/wsManager.ts b/frontend/lib/wsManager.ts index 20982b5..fe3df74 100644 --- a/frontend/lib/wsManager.ts +++ b/frontend/lib/wsManager.ts @@ -133,6 +133,10 @@ function handleMessage(code: string, msg: WsInbound): void { } break + case 'DISCOVERY_ERROR': + store.setVMDiscoveryError(code, msg.data.message, msg.data.retrySeconds) + break + case 'GRAPH_DIFF': store.applyVMDiff(code, msg.data) break diff --git a/frontend/store/vmStore.ts b/frontend/store/vmStore.ts index a1ad3ec..2be40cd 100644 --- a/frontend/store/vmStore.ts +++ b/frontend/store/vmStore.ts @@ -18,6 +18,7 @@ interface VMStore { setVMGraph: (code: string, graph: GraphOutput) => void applyVMDiff: (code: string, diff: GraphDiff) => void setVMError: (code: string, error: string) => void + setVMDiscoveryError: (code: string, message: string, retrySeconds?: number) => void clearVMError: (code: string) => void setVMDisconnected: (code: string) => void } @@ -42,6 +43,7 @@ export const useVMStore = create((set) => ({ readOnly: false, graph: null, error: null, + discoveryError: null, lastUpdated: null, }, }, @@ -83,6 +85,7 @@ export const useVMStore = create((set) => ({ [code]: { ...state.vms[code], graph, + discoveryError: null, lastUpdated: Date.now(), }, }, @@ -147,6 +150,17 @@ export const useVMStore = create((set) => ({ }, })), + setVMDiscoveryError: (code, message, retrySeconds) => + set((state) => ({ + vms: { + ...state.vms, + [code]: { + ...state.vms[code], + discoveryError: { message, retrySeconds }, + }, + }, + })), + clearVMError: (code) => set((state) => ({ vms: { diff --git a/frontend/types/index.ts b/frontend/types/index.ts index bcf2ea9..51d01f8 100644 --- a/frontend/types/index.ts +++ b/frontend/types/index.ts @@ -137,6 +137,12 @@ export interface WsError { data: { message: string } } +// Sent by an agent whose discovery has failed before it produced any canvas. +export interface WsDiscoveryError { + type: 'DISCOVERY_ERROR' + data: { message: string; retrySeconds?: number } +} + export interface WsLogData { type: 'LOG_DATA' data: { @@ -168,6 +174,7 @@ export type WsInbound = | WsAgentConnected | WsAgentDisconnected | WsError + | WsDiscoveryError | WsLogData | WsExecData | WsExecEnd @@ -184,6 +191,8 @@ export interface VMState { readOnly: boolean graph: GraphOutput | null error: string | null + // Why the agent hasn't produced a canvas yet; cleared by the first snapshot. + discoveryError: { message: string; retrySeconds?: number } | null lastUpdated: number | null } diff --git a/install-agent.sh b/install-agent.sh index eda9498..6fcbef8 100755 --- a/install-agent.sh +++ b/install-agent.sh @@ -9,9 +9,10 @@ # --private Imply --no-tunnel and bind 127.0.0.1 (SSH-tunnel only) # --read-only Viewers can look but not touch: actions and terminals # are blocked server-side (for public demos) -# --run-user Run the systemd service as this user (default: auto-detect -# from $SUDO_USER, then any user with ~/.kube/config, then -# any user in the docker group; falls back to root) +# --run-user Run the systemd service as this user (default: the user +# running the installer, or $SUDO_USER under sudo; if run as +# root directly, a user with ~/.kube/config, then one in the +# docker group; falls back to root) # --version Install a specific release tag (default: latest) # --join Agent-only install: connect this VM to an existing # InfraCanvas hub instead of running its own dashboard. @@ -248,10 +249,12 @@ fi # Cascade: # 1. --run-user flag (explicit override) # 2. $SUDO_USER (when run via plain `sudo …/install.sh`) -# 3. First non-root user in /home/* whose ~/.kube/config is readable -# 4. First non-root user in /home/* who is a member of the docker group -# 5. First non-root user with a real shell in /home/* -# 6. root (last resort — Kubernetes/Docker discovery may be empty) +# 3. The invoking user, when the script itself isn't running as root +# (`curl … | bash` as a normal user; run_priv sudo's each step) +# 4. First non-root user in /home/* whose ~/.kube/config is readable +# 5. First non-root user in /home/* who is a member of the docker group +# 6. First non-root user with a real shell in /home/* +# 7. root (last resort — Kubernetes/Docker discovery may be empty) pick_run_user() { local u if [[ -n "$RUN_USER_OVERRIDE" ]]; then @@ -264,6 +267,9 @@ pick_run_user() { && getent passwd "$SUDO_USER" >/dev/null 2>&1; then echo "$SUDO_USER"; return fi + if [[ $EUID -ne 0 ]]; then + id -un; return + fi # Scan /home for a candidate for h in /home/*/; do u="$(basename "$h")" @@ -328,7 +334,9 @@ if [[ -n "$JOIN_URL" ]]; then EXEC_START="${INSTALL_DIR}/infracanvas start" UNIT_DESC="InfraCanvas agent (streaming to hub)" else - EXEC_START="${INSTALL_DIR}/infracanvas serve --port \${INFRACANVAS_PORT}${SERVE_FLAGS}" + # Tunnel, private and read-only come from config.env (EnvironmentFile), so + # editing that file and restarting the service changes them. + EXEC_START="${INSTALL_DIR}/infracanvas serve --port \${INFRACANVAS_PORT}" UNIT_DESC="InfraCanvas dashboard and agent" fi diff --git a/internal/redactor/redactor_test.go b/internal/redactor/redactor_test.go new file mode 100644 index 0000000..891485a --- /dev/null +++ b/internal/redactor/redactor_test.go @@ -0,0 +1,57 @@ +package redactor + +import ( + "strings" + "testing" +) + +const placeholder = "[REDACTED]" + +func TestRedactValueByKeyName(t *testing.T) { + r := NewRedactor(true) + for _, key := range []string{"DB_PASSWORD", "GITHUB_TOKEN", "AWS_SECRET_ACCESS_KEY", "api_key", "Authorization", "MYSQL_PWD"} { + if got := r.RedactValue(key, "hunter2"); got != placeholder { + t.Errorf("%s: got %q, want redacted", key, got) + } + } + for _, key := range []string{"PATH", "HOME", "NODE_ENV", "PORT"} { + if got := r.RedactValue(key, "value"); got != "value" { + t.Errorf("%s: got %q, want unchanged", key, got) + } + } +} + +func TestRedactValueByPattern(t *testing.T) { + r := NewRedactor(true) + cases := map[string]string{ + "jwt": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0In0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U", + "private key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBg\n-----END PRIVATE KEY-----", + } + for name, v := range cases { + if got := r.RedactValue("SOME_VAR", v); got != placeholder { + t.Errorf("%s not redacted: %q", name, got) + } + } +} + +func TestRedactCommandLine(t *testing.T) { + r := NewRedactor(true) + got := r.RedactCommandLine("postgres --password=hunter2 --port 5432 --token abc123") + if strings.Contains(got, "hunter2") || strings.Contains(got, "abc123") { + t.Fatalf("secret left in command line: %q", got) + } + if !strings.Contains(got, "--port 5432") { + t.Fatalf("non-secret flag was changed: %q", got) + } +} + +func TestDisabledRedactorPassesThrough(t *testing.T) { + r := NewRedactor(false) + if got := r.RedactValue("DB_PASSWORD", "hunter2"); got != "hunter2" { + t.Fatalf("disabled redactor changed value: %q", got) + } + env := map[string]string{"DB_PASSWORD": "x"} + if got := r.RedactEnvVars(env); got["DB_PASSWORD"] != "x" { + t.Fatalf("disabled redactor changed env: %v", got) + } +} diff --git a/pkg/agent/ws_agent.go b/pkg/agent/ws_agent.go index aa8d26e..b95f88a 100644 --- a/pkg/agent/ws_agent.go +++ b/pkg/agent/ws_agent.go @@ -52,6 +52,13 @@ type WSConfig struct { // file. Clusters virtual agents use a per-cluster stable ID so the relay // can resume their session across restarts, same as a real VM agent. MachineIDOverride string + // ResumeSecret and OnResumeSecret let a MachineIDOverride agent keep its + // session's resume secret across agent restarts within one process. A + // real VM agent persists the secret to disk; a Clusters virtual agent + // doesn't, and without this a restarted agent reconnects with no secret + // and the relay rejects it as a hijack attempt. + ResumeSecret string + OnResumeSecret func(secret string) // HostnameOverride, when set, is sent as HELLO's hostname instead of // os.Hostname(). Clusters virtual agents run in-process on the machine @@ -119,8 +126,9 @@ type WSAgent struct { connMu sync.Mutex lastGraph *output.GraphOutput lastGraphMu sync.RWMutex - execSessions sync.Map // sessionID → *execSession - pfSessions sync.Map // key (ns/pod:local) → *actions.PortForwardSession + discoverMu sync.Mutex // one discovery pass at a time + execSessions sync.Map // sessionID → *execSession + pfSessions sync.Map // key (ns/pod:local) → *actions.PortForwardSession // resumeSecret proves ownership of this agent's MachineID on reconnect — // required since the shared hub join token alone doesn't distinguish one @@ -141,7 +149,7 @@ func NewWSAgent(cfg *WSConfig) (*WSAgent, error) { cfg.RefreshSeconds = 5 } - agentResumeSecret := "" + agentResumeSecret := cfg.ResumeSecret if cfg.MachineIDOverride == "" { agentResumeSecret = loadResumeSecret() } @@ -200,6 +208,9 @@ func (a *WSAgent) setResumeSecret(secret string) { if a.cfg.MachineIDOverride == "" { saveResumeSecret(secret) } + if a.cfg.OnResumeSecret != nil { + a.cfg.OnResumeSecret(secret) + } } // Run connects to the backend, prints the pair code, and streams graph data @@ -267,6 +278,7 @@ func (a *WSAgent) Run(ctx context.Context) error { } if err != nil { log.Printf("initial discovery error: %v", err) + a.sendDiscoveryError(err) } else { if err := a.sendSnapshot(graph); err != nil { log.Printf("failed to send initial snapshot: %v", err) @@ -274,13 +286,18 @@ func (a *WSAgent) Run(ctx context.Context) error { a.setLastGraph(snap, graph) } - // Periodic refresh ticker. - ticker := time.NewTicker(time.Duration(a.cfg.RefreshSeconds) * time.Second) - defer ticker.Stop() + // Periodic discovery runs in its own goroutine. A pass takes seconds + // (docker, /proc, remote API servers), and running it in the select loop + // below stalled command handling for that long: terminal keystrokes + // queued up and then arrived all at once. + go a.refreshLoop(ctx) heartbeat := time.NewTicker(15 * time.Second) defer heartbeat.Stop() + inputs := newExecInputQueues(a) + defer inputs.closeAll() + for { select { case <-ctx.Done(): @@ -291,6 +308,31 @@ func (a *WSAgent) Run(ctx context.Context) error { "timestamp": time.Now().UTC().Format(time.RFC3339), }) + case env := <-commandCh: + switch env.Type { + case "EXEC_INPUT": + inputs.enqueue(env.Data) + case "EXEC_END": + inputs.close(env.Data) + a.handleServerCommand(ctx, env) + default: + a.handleServerCommand(ctx, env) + } + } + } +} + +// refreshLoop runs discovery every RefreshSeconds and sends the result as a +// diff (or as a snapshot if browsers haven't had one yet). +func (a *WSAgent) refreshLoop(ctx context.Context) { + ticker := time.NewTicker(time.Duration(a.cfg.RefreshSeconds) * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: snap, graph, err := a.collectAndFormatGraph(ctx) if a.cfg.OnDiscoveryResult != nil { @@ -298,18 +340,23 @@ func (a *WSAgent) Run(ctx context.Context) error { } if err != nil { log.Printf("discovery error: %v", err) + if !a.hasLastGraph() { + a.sendDiscoveryError(err) + } continue } - diff := a.computeDiff(graph) - if diff != nil { + if !a.hasLastGraph() { + // The first pass failed, so browsers never got a snapshot and + // a diff would have nothing to apply to. + if err := a.sendSnapshot(graph); err != nil { + log.Printf("failed to send snapshot: %v", err) + } + } else if diff := a.computeDiff(graph); diff != nil { if err := a.sendDiff(diff); err != nil { log.Printf("failed to send diff: %v", err) } } a.setLastGraph(snap, graph) - - case env := <-commandCh: - a.handleServerCommand(ctx, env) } } } @@ -535,6 +582,8 @@ func discoveryResult(snap *models.InfraSnapshot, err error) error { } func (a *WSAgent) collectAndFormatGraph(ctx context.Context) (*models.InfraSnapshot, *output.GraphOutput, error) { + a.discoverMu.Lock() + defer a.discoverMu.Unlock() snap, err := a.orch.Discover(ctx, a.cfg.Scope) if err != nil { return nil, nil, fmt.Errorf("discovery failed: %w", err) @@ -554,6 +603,23 @@ func (a *WSAgent) collectAndFormatGraph(ctx context.Context) (*models.InfraSnaps return snap, &graph, nil } +func (a *WSAgent) hasLastGraph() bool { + a.lastGraphMu.RLock() + defer a.lastGraphMu.RUnlock() + return a.lastGraph != nil +} + +// sendDiscoveryError tells browsers why no canvas has arrived yet, so they +// show the error instead of an endless "Discovering infrastructure…". +func (a *WSAgent) sendDiscoveryError(err error) { + if sendErr := a.send("DISCOVERY_ERROR", map[string]interface{}{ + "message": err.Error(), + "retrySeconds": a.cfg.RefreshSeconds, + }); sendErr != nil { + log.Printf("failed to send discovery error: %v", sendErr) + } +} + func (a *WSAgent) sendSnapshot(graph *output.GraphOutput) error { raw, err := marshalEnvelope("GRAPH_SNAPSHOT", graph) if err != nil { @@ -1331,6 +1397,57 @@ func (a *WSAgent) loadExecSession(sessionID string) (*execSession, bool) { } } +// execInputQueues gives each terminal session a single writer goroutine so +// keystrokes reach the PTY in the order they were typed, without the read +// loop waiting on a slow write. All methods are called from Run's select +// loop only, so enqueue and close never race. +type execInputQueues struct { + a *WSAgent + queues map[string]chan json.RawMessage +} + +func newExecInputQueues(a *WSAgent) *execInputQueues { + return &execInputQueues{a: a, queues: make(map[string]chan json.RawMessage)} +} + +func execSessionID(data json.RawMessage) string { + var req struct { + SessionID string `json:"session_id"` + } + _ = json.Unmarshal(data, &req) + return req.SessionID +} + +func (q *execInputQueues) enqueue(data json.RawMessage) { + id := execSessionID(data) + ch, ok := q.queues[id] + if !ok { + ch = make(chan json.RawMessage, 256) + q.queues[id] = ch + go func() { + for d := range ch { + q.a.handleExecInput(d) + } + }() + } + ch <- data +} + +func (q *execInputQueues) close(data json.RawMessage) { + id := execSessionID(data) + if ch, ok := q.queues[id]; ok { + close(ch) + delete(q.queues, id) + } +} + +func (q *execInputQueues) closeAll() { + for id, ch := range q.queues { + close(ch) + delete(q.queues, id) + } +} + func (a *WSAgent) handleExecInput(data json.RawMessage) { var req struct { SessionID string `json:"session_id"` diff --git a/pkg/agent/ws_agent_test.go b/pkg/agent/ws_agent_test.go index a235b36..1b4913c 100644 --- a/pkg/agent/ws_agent_test.go +++ b/pkg/agent/ws_agent_test.go @@ -1,6 +1,12 @@ package agent import ( + "encoding/base64" + "encoding/json" + "fmt" + "io" + "os" + "strings" "testing" "infracanvas/pkg/actions" @@ -24,3 +30,37 @@ func TestMapFrontendActionTypeLXDContainerActions(t *testing.T) { } } } + +func TestExecInputQueuesPreserveOrder(t *testing.T) { + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + defer r.Close() + + a := &WSAgent{} + a.execSessions.Store("s1", &execSession{ptmx: w}) + + q := newExecInputQueues(a) + var want strings.Builder + for i := 0; i < 300; i++ { + chunk := fmt.Sprintf("%d,", i) + want.WriteString(chunk) + data, _ := json.Marshal(map[string]string{ + "session_id": "s1", + "data": base64.StdEncoding.EncodeToString([]byte(chunk)), + }) + q.enqueue(data) + } + + got := make([]byte, want.Len()) + if _, err := io.ReadFull(r, got); err != nil { + t.Fatal(err) + } + q.closeAll() + _ = w.Close() + + if string(got) != want.String() { + t.Fatalf("terminal input reordered:\n got %q\nwant %q", got, want.String()) + } +} diff --git a/pkg/audit/audit_test.go b/pkg/audit/audit_test.go new file mode 100644 index 0000000..9380b72 --- /dev/null +++ b/pkg/audit/audit_test.go @@ -0,0 +1,76 @@ +package audit + +import ( + "bufio" + "encoding/json" + "os" + "testing" +) + +func reset() { + mu.Lock() + defer mu.Unlock() + if file != nil { + _ = file.Close() + } + file = nil + recent = nil +} + +func TestRecentNewestFirstAndPersisted(t *testing.T) { + t.Setenv("INFRACANVAS_STATE_DIR", t.TempDir()) + reset() + t.Cleanup(reset) + Init() + + LogActionRequested("a1", "restart_container", "m1", "host1", "c1", "") + LogActionCompleted("a1", true, "ok") + LogActionBlocked("action_blocked", "stop_container", "m1", "host1") + + got := Recent(10) + if len(got) != 3 { + t.Fatalf("want 3 entries, got %d", len(got)) + } + if got[0].Event != "action_blocked" || got[2].Event != "action_requested" { + t.Fatalf("wrong order: %s, %s", got[0].Event, got[2].Event) + } + if got[0].Success == nil || *got[0].Success { + t.Error("blocked action should record success=false") + } + + f, err := os.Open(logPath()) + if err != nil { + t.Fatal(err) + } + defer f.Close() + lines := 0 + sc := bufio.NewScanner(f) + for sc.Scan() { + var e Entry + if err := json.Unmarshal(sc.Bytes(), &e); err != nil { + t.Fatalf("bad log line %q: %v", sc.Text(), err) + } + lines++ + } + if lines != 3 { + t.Fatalf("want 3 lines on disk, got %d", lines) + } + if fi, _ := os.Stat(logPath()); fi.Mode().Perm() != 0o600 { + t.Errorf("audit log perm = %o, want 600", fi.Mode().Perm()) + } +} + +func TestRecentReloadsAfterRestart(t *testing.T) { + t.Setenv("INFRACANVAS_STATE_DIR", t.TempDir()) + reset() + t.Cleanup(reset) + Init() + LogExecRequested("m1", "host1", "pod/x", "default") + + reset() // simulate a process restart + Init() + got := Recent(0) + if len(got) != 1 || got[0].Event != "exec_requested" { + t.Fatalf("history not reloaded: %+v", got) + } +} diff --git a/pkg/clustermgr/clustermgr.go b/pkg/clustermgr/clustermgr.go index 98a1471..f87b508 100644 --- a/pkg/clustermgr/clustermgr.go +++ b/pkg/clustermgr/clustermgr.go @@ -28,6 +28,7 @@ import ( "infracanvas/internal/redactor" "infracanvas/pkg/agent" + k8sdiscovery "infracanvas/pkg/discovery/kubernetes" "infracanvas/pkg/runstate" ) @@ -168,7 +169,10 @@ type Manager struct { // to the local server always succeeds regardless of whether the cluster // itself is reachable. This tracks the thing that actually matters. lastError map[string]error - rootCtx context.Context // long-lived; never an inbound request's context + // secrets holds each cluster session's relay resume secret so a + // restarted virtual agent can reclaim its own session. + secrets map[string]string + rootCtx context.Context // long-lived; never an inbound request's context } // NewManager creates a Manager. backendURL is the local server's own @@ -182,6 +186,7 @@ func NewManager(backendURL, agentToken string, refreshSeconds int) *Manager { enableRedact: true, cancels: make(map[string]context.CancelFunc), lastError: make(map[string]error), + secrets: make(map[string]string), } } @@ -323,9 +328,11 @@ func (m *Manager) Add(name string, kubeconfigBytes []byte, contextName string, r return Entry{}, err } // Fail fast on an unreachable/misconfigured cluster rather than persisting - // a dead entry — mirrors kubernetes.Discovery.IsAvailable()'s connectivity - // probe timeout, which exists for the same reason. + // a dead entry that sits in the sidebar with an "error" status. restCfg.Timeout = 8 * time.Second + if err := pingCluster(restCfg); err != nil { + return Entry{}, err + } serverURL := "" if c, ok := cfg.Contexts[contextName]; ok { @@ -333,36 +340,68 @@ func (m *Manager) Add(name string, kubeconfigBytes []byte, contextName string, r serverURL = cl.Server } } + if name == "" { + name = contextName + } - id := randomID() if err := os.MkdirAll(clusterDir(), 0o700); err != nil { return Entry{}, fmt.Errorf("create cluster dir: %w", err) } - path := filepath.Join(clusterDir(), id+".kubeconfig") - if err := os.WriteFile(path, kubeconfigBytes, 0o600); err != nil { - return Entry{}, fmt.Errorf("save kubeconfig: %w", err) - } - if name == "" { - name = contextName + // Re-adding a cluster that's already connected (same API server and + // context) updates the existing entry in place instead of creating a + // duplicate: new kubeconfig, name and read-only flag, same ID. + var entry runstate.ClusterEntry + replaced := false + if s, err := runstate.Read(); err == nil { + for _, e := range s.Clusters { + if e.ServerURL == serverURL && e.ContextName == contextName { + entry = e + replaced = true + break + } + } } - entry := runstate.ClusterEntry{ - ID: id, - Name: name, - ContextName: contextName, - ServerURL: serverURL, - KubeconfigPath: path, - AddedAt: time.Now().UTC(), - ReadOnly: readOnly, + if !replaced { + id := randomID() + entry = runstate.ClusterEntry{ + ID: id, + ContextName: contextName, + ServerURL: serverURL, + KubeconfigPath: filepath.Join(clusterDir(), id+".kubeconfig"), + AddedAt: time.Now().UTC(), + } + } + entry.Name = name + entry.ReadOnly = readOnly + + if err := os.WriteFile(entry.KubeconfigPath, kubeconfigBytes, 0o600); err != nil { + return Entry{}, fmt.Errorf("save kubeconfig: %w", err) } if err := runstate.Update(func(s *runstate.State) { + for i := range s.Clusters { + if s.Clusters[i].ID == entry.ID { + s.Clusters[i] = entry + return + } + } s.Clusters = append(s.Clusters, entry) }); err != nil { - _ = os.Remove(path) + if !replaced { + _ = os.Remove(entry.KubeconfigPath) + } return Entry{}, fmt.Errorf("persist cluster: %w", err) } + m.mu.Lock() + if cancel, ok := m.cancels[entry.ID]; ok { + cancel() + delete(m.cancels, entry.ID) + } + delete(m.lastError, entry.ID) + m.mu.Unlock() + rootCtx := m.rootCtx if rootCtx == nil { rootCtx = context.Background() @@ -371,6 +410,16 @@ func (m *Manager) Add(name string, kubeconfigBytes []byte, contextName string, r return Entry{ClusterEntry: entry, Online: true}, nil } +// pingCluster checks the API server is reachable with the given credentials. +// A variable so tests can stub it out. +var pingCluster = func(cfg *rest.Config) error { + d, err := k8sdiscovery.NewDiscoveryFromConfig(cfg) + if err != nil { + return err + } + return d.Ping() +} + // Remove stops the virtual agent and deletes the cluster's persisted entry // and on-disk kubeconfig file. func (m *Manager) Remove(id string) error { @@ -380,6 +429,7 @@ func (m *Manager) Remove(id string) error { delete(m.cancels, id) } delete(m.lastError, id) + delete(m.secrets, id) m.mu.Unlock() var path string @@ -440,6 +490,12 @@ func (m *Manager) startAgent(parent context.Context, entry runstate.ClusterEntry KubeConfig: restCfg, MachineIDOverride: "cluster-" + entry.ID, HostnameOverride: entry.Name, + ResumeSecret: m.resumeSecret(entry.ID), + OnResumeSecret: func(secret string) { + m.mu.Lock() + m.secrets[entry.ID] = secret + m.mu.Unlock() + }, OnDiscoveryResult: func(discErr error) { m.mu.Lock() m.lastError[entry.ID] = discErr @@ -465,6 +521,12 @@ func (m *Manager) startAgent(parent context.Context, entry runstate.ClusterEntry }() } +func (m *Manager) resumeSecret(id string) string { + m.mu.Lock() + defer m.mu.Unlock() + return m.secrets[id] +} + func randomID() string { b := make([]byte, 8) if _, err := rand.Read(b); err != nil { diff --git a/pkg/clustermgr/clustermgr_test.go b/pkg/clustermgr/clustermgr_test.go new file mode 100644 index 0000000..7d2279c --- /dev/null +++ b/pkg/clustermgr/clustermgr_test.go @@ -0,0 +1,126 @@ +package clustermgr + +import ( + "errors" + "testing" + + "k8s.io/client-go/rest" + + "infracanvas/pkg/runstate" +) + +const testKubeconfig = `apiVersion: v1 +kind: Config +current-context: dev +clusters: +- name: dev + cluster: + server: https://127.0.0.1:1 +contexts: +- name: dev + context: + cluster: dev + user: dev +- name: other + context: + cluster: dev + user: dev +users: +- name: dev + user: + token: abc +` + +func stubPing(t *testing.T, err error) { + t.Helper() + orig := pingCluster + pingCluster = func(*rest.Config) error { return err } + t.Cleanup(func() { pingCluster = orig }) +} + +func newTestManager(t *testing.T) *Manager { + t.Helper() + t.Setenv("INFRACANVAS_STATE_DIR", t.TempDir()) + m := NewManager("ws://127.0.0.1:1", "tok", 30) + t.Cleanup(func() { + entries, _ := m.List() + for _, e := range entries { + _ = m.Remove(e.ID) + } + }) + return m +} + +func TestParseContexts(t *testing.T) { + ctxs, err := ParseContexts([]byte(testKubeconfig)) + if err != nil { + t.Fatal(err) + } + if len(ctxs) != 2 { + t.Fatalf("want 2 contexts, got %d", len(ctxs)) + } + for _, c := range ctxs { + if c.ServerURL != "https://127.0.0.1:1" { + t.Errorf("context %s: server %q", c.Name, c.ServerURL) + } + if c.Current != (c.Name == "dev") { + t.Errorf("context %s: current=%v", c.Name, c.Current) + } + } + if _, err := ParseContexts([]byte("not yaml: [")); err == nil { + t.Error("expected error for invalid kubeconfig") + } +} + +func TestAddUnreachableClusterIsNotPersisted(t *testing.T) { + m := newTestManager(t) + stubPing(t, errors.New("connection refused")) + + if _, err := m.Add("", []byte(testKubeconfig), "dev", false); err == nil { + t.Fatal("expected Add to fail for an unreachable cluster") + } + s, err := runstate.Read() + if err != nil { + t.Fatal(err) + } + if len(s.Clusters) != 0 { + t.Fatalf("unreachable cluster was persisted: %+v", s.Clusters) + } +} + +func TestAddSameContextTwiceUpdatesInPlace(t *testing.T) { + m := newTestManager(t) + stubPing(t, nil) + + first, err := m.Add("", []byte(testKubeconfig), "dev", false) + if err != nil { + t.Fatal(err) + } + second, err := m.Add("renamed", []byte(testKubeconfig), "dev", true) + if err != nil { + t.Fatal(err) + } + if first.ID != second.ID { + t.Fatalf("re-adding the same context created a new entry: %s vs %s", first.ID, second.ID) + } + + entries, err := m.List() + if err != nil { + t.Fatal(err) + } + if len(entries) != 1 { + t.Fatalf("want 1 cluster, got %d", len(entries)) + } + if entries[0].Name != "renamed" || !entries[0].ReadOnly { + t.Errorf("entry not updated: %+v", entries[0]) + } + + // A different context on the same server is a different cluster entry. + if _, err := m.Add("", []byte(testKubeconfig), "other", false); err != nil { + t.Fatal(err) + } + entries, _ = m.List() + if len(entries) != 2 { + t.Fatalf("want 2 clusters, got %d", len(entries)) + } +} diff --git a/pkg/discovery/kubernetes/discovery.go b/pkg/discovery/kubernetes/discovery.go index 2ec6493..035c030 100644 --- a/pkg/discovery/kubernetes/discovery.go +++ b/pkg/discovery/kubernetes/discovery.go @@ -7,7 +7,7 @@ import ( "infracanvas/internal/models" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" ) @@ -18,6 +18,7 @@ type Discovery struct { config *rest.Config cache *Cache connectedContexts int + skipped []string } // NewDiscovery creates a new Kubernetes discovery instance, resolving the @@ -93,130 +94,127 @@ func NewDiscoveryFromConfig(config *rest.Config) (*Discovery, error) { // IsAvailable checks if Kubernetes is available and accessible func (d *Discovery) IsAvailable() bool { + return d.Ping() == nil +} + +// Ping checks that the API server is reachable and accepts our credentials. +// It hits /version, which every authenticated user can read, instead of +// listing Nodes: a namespace-scoped or read-only credential can't list +// cluster-scoped resources, and that used to mark a working cluster as +// unavailable. +func (d *Discovery) Ping() error { if d.clientset == nil { - return false + return fmt.Errorf("kubernetes client not initialized") } - // 2s was sized for a local/in-cluster API server. For a Clusters - // direct-connect target reached over the public internet, that's often - // shorter than a single real round trip (TLS handshake + auth + list), - // so a perfectly reachable remote cluster would fail this check on - // nothing but ordinary latency and get marked "error". 8s comfortably - // covers a normal home/office connection while still catching a - // genuinely unreachable endpoint well before the 30s discovery timeout. + // 8s covers a remote API server over an ordinary home/office connection + // (TLS handshake + auth) while still catching an unreachable endpoint well + // before the 30s discovery timeout. ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) defer cancel() - // Try to list nodes as a connectivity check - _, err := d.clientset.CoreV1().Nodes().List(ctx, metav1.ListOptions{Limit: 1}) - return err == nil + if _, err := d.clientset.Discovery().RESTClient().Get().AbsPath("/version").Do(ctx).Raw(); err != nil { + return fmt.Errorf("cannot reach Kubernetes API server %s: %w", d.config.Host, err) + } + return nil +} + +// Skipped returns the resource kinds the last DiscoverAll pass could not list +// because the credential lacks RBAC access to them. +func (d *Discovery) Skipped() []string { + return d.skipped } -// DiscoverAll performs a complete Kubernetes discovery +// DiscoverAll performs a complete Kubernetes discovery. A resource kind the +// credential isn't allowed to list (403) is skipped and recorded in Skipped() +// instead of failing the whole pass, so a narrowly scoped kubeconfig still +// gets a canvas. Any other error (network, timeout, 5xx) fails the pass. func (d *Discovery) DiscoverAll() (*models.Cluster, []models.Node, []models.Namespace, []models.Deployment, []models.StatefulSet, []models.DaemonSet, []models.Job, []models.CronJob, []models.Pod, []models.K8sService, []models.Ingress, []models.ConfigMap, []models.Secret, []models.PersistentVolumeClaim, []models.PersistentVolume, []models.StorageClass, []models.Event, error) { d.connectedContexts = 0 - if !d.IsAvailable() { - return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("Kubernetes is not available") - } - - // Get cluster info - cluster, err := d.GetClusterInfo(context.Background()) - if err != nil { - return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("failed to get cluster info: %w", err) - } - - // Get nodes - nodes, err := d.GetNodes(context.Background()) - if err != nil { - return cluster, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("failed to get nodes: %w", err) - } - - // Get namespaces - namespaces, err := d.GetNamespaces(context.Background()) - if err != nil { - return cluster, nodes, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("failed to get namespaces: %w", err) - } - - // Get workloads across all namespaces - deployments, err := d.GetDeployments(context.Background(), "") - if err != nil { - return cluster, nodes, namespaces, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("failed to get deployments: %w", err) - } - - statefulsets, err := d.GetStatefulSets(context.Background(), "") - if err != nil { - return cluster, nodes, namespaces, deployments, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("failed to get statefulsets: %w", err) - } - - daemonsets, err := d.GetDaemonSets(context.Background(), "") - if err != nil { - return cluster, nodes, namespaces, deployments, statefulsets, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("failed to get daemonsets: %w", err) - } - - jobs, err := d.GetJobs(context.Background(), "") - if err != nil { - return cluster, nodes, namespaces, deployments, statefulsets, daemonsets, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("failed to get jobs: %w", err) - } - - cronjobs, err := d.GetCronJobs(context.Background(), "") - if err != nil { - return cluster, nodes, namespaces, deployments, statefulsets, daemonsets, jobs, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("failed to get cronjobs: %w", err) - } - - // Get pods - pods, err := d.GetPods(context.Background(), "") - if err != nil { - return cluster, nodes, namespaces, deployments, statefulsets, daemonsets, jobs, cronjobs, nil, nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("failed to get pods: %w", err) - } - - // Get services and ingress - services, err := d.GetServices(context.Background(), "") - if err != nil { - return cluster, nodes, namespaces, deployments, statefulsets, daemonsets, jobs, cronjobs, pods, nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("failed to get services: %w", err) - } - - ingresses, err := d.GetIngresses(context.Background(), "") - if err != nil { - return cluster, nodes, namespaces, deployments, statefulsets, daemonsets, jobs, cronjobs, pods, services, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("failed to get ingresses: %w", err) - } - - // Get config and secrets - configmaps, err := d.GetConfigMaps(context.Background(), "") - if err != nil { - return cluster, nodes, namespaces, deployments, statefulsets, daemonsets, jobs, cronjobs, pods, services, ingresses, nil, nil, nil, nil, nil, nil, fmt.Errorf("failed to get configmaps: %w", err) - } - - secrets, err := d.GetSecrets(context.Background(), "") - if err != nil { - return cluster, nodes, namespaces, deployments, statefulsets, daemonsets, jobs, cronjobs, pods, services, ingresses, configmaps, nil, nil, nil, nil, nil, fmt.Errorf("failed to get secrets: %w", err) - } - - // Get storage - pvcs, err := d.GetPVCs(context.Background(), "") - if err != nil { - return cluster, nodes, namespaces, deployments, statefulsets, daemonsets, jobs, cronjobs, pods, services, ingresses, configmaps, secrets, nil, nil, nil, nil, fmt.Errorf("failed to get pvcs: %w", err) - } - - pvs, err := d.GetPVs(context.Background()) - if err != nil { - return cluster, nodes, namespaces, deployments, statefulsets, daemonsets, jobs, cronjobs, pods, services, ingresses, configmaps, secrets, pvcs, nil, nil, nil, fmt.Errorf("failed to get pvs: %w", err) - } - - storageclasses, err := d.GetStorageClasses(context.Background()) - if err != nil { - return cluster, nodes, namespaces, deployments, statefulsets, daemonsets, jobs, cronjobs, pods, services, ingresses, configmaps, secrets, pvcs, pvs, nil, nil, fmt.Errorf("failed to get storageclasses: %w", err) - } - - // Get events - events, err := d.GetEvents(context.Background(), "") - if err != nil { - return cluster, nodes, namespaces, deployments, statefulsets, daemonsets, jobs, cronjobs, pods, services, ingresses, configmaps, secrets, pvcs, pvs, storageclasses, nil, fmt.Errorf("failed to get events: %w", err) + d.skipped = nil + fail := func(err error) (*models.Cluster, []models.Node, []models.Namespace, []models.Deployment, []models.StatefulSet, []models.DaemonSet, []models.Job, []models.CronJob, []models.Pod, []models.K8sService, []models.Ingress, []models.ConfigMap, []models.Secret, []models.PersistentVolumeClaim, []models.PersistentVolume, []models.StorageClass, []models.Event, error) { + return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, err + } + + if err := d.Ping(); err != nil { + return fail(err) + } + + ctx := context.Background() + var firstErr error + // check records a 403/404 as skipped and keeps any other error as the + // pass's error. + check := func(kind string, err error) { + if err == nil || firstErr != nil { + return + } + if apierrors.IsForbidden(err) || apierrors.IsNotFound(err) { + d.skipped = append(d.skipped, kind) + return + } + firstErr = fmt.Errorf("failed to get %s: %w", kind, err) + } + + cluster, err := d.GetClusterInfo(ctx) + if err != nil { + return fail(fmt.Errorf("failed to get cluster info: %w", err)) + } + + nodes, err := d.GetNodes(ctx) + check("nodes", err) + namespaces, err := d.GetNamespaces(ctx) + check("namespaces", err) + deployments, err := d.GetDeployments(ctx, "") + check("deployments", err) + statefulsets, err := d.GetStatefulSets(ctx, "") + check("statefulsets", err) + daemonsets, err := d.GetDaemonSets(ctx, "") + check("daemonsets", err) + jobs, err := d.GetJobs(ctx, "") + check("jobs", err) + cronjobs, err := d.GetCronJobs(ctx, "") + check("cronjobs", err) + pods, err := d.GetPods(ctx, "") + check("pods", err) + services, err := d.GetServices(ctx, "") + check("services", err) + ingresses, err := d.GetIngresses(ctx, "") + check("ingresses", err) + configmaps, err := d.GetConfigMaps(ctx, "") + check("configmaps", err) + secrets, err := d.GetSecrets(ctx, "") + check("secrets", err) + pvcs, err := d.GetPVCs(ctx, "") + check("persistentvolumeclaims", err) + pvs, err := d.GetPVs(ctx) + check("persistentvolumes", err) + storageclasses, err := d.GetStorageClasses(ctx) + check("storageclasses", err) + events, err := d.GetEvents(ctx, "") + check("events", err) + + if firstErr != nil { + return fail(firstErr) + } + // Nothing workload-level was readable: the canvas would be empty with no + // explanation, so report it as an error the UI can show. + if contains(d.skipped, "pods") && contains(d.skipped, "deployments") { + return fail(fmt.Errorf("credential cannot list pods or deployments cluster-wide (RBAC forbidden)")) } d.connectedContexts = 1 return cluster, nodes, namespaces, deployments, statefulsets, daemonsets, jobs, cronjobs, pods, services, ingresses, configmaps, secrets, pvcs, pvs, storageclasses, events, nil } +func contains(list []string, s string) bool { + for _, v := range list { + if v == s { + return true + } + } + return false +} + // ConnectedContexts returns how many local kubeconfig contexts were // successfully connected in the last discovery pass. func (d *Discovery) ConnectedContexts() int { diff --git a/pkg/discovery/kubernetes/discovery_test.go b/pkg/discovery/kubernetes/discovery_test.go new file mode 100644 index 0000000..157086e --- /dev/null +++ b/pkg/discovery/kubernetes/discovery_test.go @@ -0,0 +1,87 @@ +package kubernetes + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "k8s.io/client-go/rest" +) + +// fakeAPIServer answers /version, returns empty lists for paths whose last +// segment is in allowed, and 403 for every other list call. +func fakeAPIServer(t *testing.T, allowed ...string) *httptest.Server { + t.Helper() + ok := map[string]bool{} + for _, a := range allowed { + ok[a] = true + } + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/version" { + _, _ = w.Write([]byte(`{"major":"1","minor":"31","gitVersion":"v1.31.0"}`)) + return + } + parts := strings.Split(strings.TrimSuffix(r.URL.Path, "/"), "/") + if ok[parts[len(parts)-1]] { + _, _ = w.Write([]byte(`{"items":[]}`)) + return + } + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"kind":"Status","apiVersion":"v1","status":"Failure","reason":"Forbidden","code":403}`)) + })) +} + +func TestDiscoverAllSkipsForbiddenResources(t *testing.T) { + srv := fakeAPIServer(t, "pods", "deployments", "services") + defer srv.Close() + + d, err := NewDiscoveryFromConfig(&rest.Config{Host: srv.URL}) + if err != nil { + t.Fatal(err) + } + cluster, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, err := d.DiscoverAll() + if err != nil { + t.Fatalf("DiscoverAll with forbidden nodes/secrets should succeed, got %v", err) + } + if cluster == nil || cluster.Version != "v1.31.0" { + t.Fatalf("unexpected cluster: %+v", cluster) + } + skipped := d.Skipped() + for _, want := range []string{"nodes", "secrets", "persistentvolumes"} { + if !contains(skipped, want) { + t.Errorf("expected %q in skipped, got %v", want, skipped) + } + } + if contains(skipped, "pods") { + t.Errorf("pods should not be skipped: %v", skipped) + } +} + +func TestDiscoverAllFailsWhenNoWorkloadsReadable(t *testing.T) { + srv := fakeAPIServer(t) + defer srv.Close() + + d, err := NewDiscoveryFromConfig(&rest.Config{Host: srv.URL}) + if err != nil { + t.Fatal(err) + } + if _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, err := d.DiscoverAll(); err == nil { + t.Fatal("expected an error when pods and deployments are both forbidden") + } +} + +func TestPingUnreachable(t *testing.T) { + srv := httptest.NewServer(http.NotFoundHandler()) + url := srv.URL + srv.Close() + + d, err := NewDiscoveryFromConfig(&rest.Config{Host: url}) + if err != nil { + t.Fatal(err) + } + if err := d.Ping(); err == nil || !strings.Contains(err.Error(), url) { + t.Fatalf("expected error naming %s, got %v", url, err) + } +} diff --git a/pkg/orchestrator/orchestrator.go b/pkg/orchestrator/orchestrator.go index ac35b65..15f8d04 100644 --- a/pkg/orchestrator/orchestrator.go +++ b/pkg/orchestrator/orchestrator.go @@ -300,8 +300,8 @@ func (o *Orchestrator) discoverKubernetes(snapshot *models.InfraSnapshot, mu *sy } // Check if Kubernetes is available - if !o.kubernetesDiscovery.IsAvailable() { - return fmt.Errorf("Kubernetes is not available") + if err := o.kubernetesDiscovery.Ping(); err != nil { + return err } cluster, nodes, namespaces, deployments, statefulsets, daemonsets, jobs, cronjobs, pods, services, ingresses, configmaps, secrets, pvcs, pvs, storageclasses, events, err := o.kubernetesDiscovery.DiscoverAll() @@ -316,6 +316,9 @@ func (o *Orchestrator) discoverKubernetes(snapshot *models.InfraSnapshot, mu *sy if cluster != nil { snapshot.Entities[cluster.ID] = cluster } + for _, kind := range o.kubernetesDiscovery.Skipped() { + snapshot.Metadata.PermissionIssues = append(snapshot.Metadata.PermissionIssues, "kubernetes: no permission to list "+kind) + } if o.localKubeconfigAutoDiscovery.Ran && o.localKubeconfigAutoDiscovery.DiscoveredContexts > 0 { o.localKubeconfigAutoDiscovery.ConnectedContexts = o.kubernetesDiscovery.ConnectedContexts() snapshot.Metadata.LocalKubeconfigAutoDiscovery = o.localKubeconfigAutoDiscovery diff --git a/pkg/runstate/state_test.go b/pkg/runstate/state_test.go new file mode 100644 index 0000000..0a31d93 --- /dev/null +++ b/pkg/runstate/state_test.go @@ -0,0 +1,66 @@ +package runstate + +import ( + "os" + "testing" +) + +func TestReadMissingFileReturnsZeroState(t *testing.T) { + t.Setenv("INFRACANVAS_STATE_DIR", t.TempDir()) + s, err := Read() + if err != nil { + t.Fatal(err) + } + if len(s.Clusters) != 0 { + t.Fatalf("want empty state, got %+v", s) + } +} + +func TestUpdateRoundTripAndPermissions(t *testing.T) { + dir := t.TempDir() + t.Setenv("INFRACANVAS_STATE_DIR", dir) + + if err := Update(func(s *State) { + s.Clusters = append(s.Clusters, ClusterEntry{ID: "a", Name: "one"}) + }); err != nil { + t.Fatal(err) + } + if err := Update(func(s *State) { + s.Clusters = append(s.Clusters, ClusterEntry{ID: "b", Name: "two"}) + }); err != nil { + t.Fatal(err) + } + + s, err := Read() + if err != nil { + t.Fatal(err) + } + if len(s.Clusters) != 2 || s.Clusters[0].ID != "a" || s.Clusters[1].ID != "b" { + t.Fatalf("unexpected clusters: %+v", s.Clusters) + } + if s.UpdatedAt.IsZero() { + t.Error("UpdatedAt not set") + } + + info, err := os.Stat(dir) + if err != nil { + t.Fatal(err) + } + if perm := info.Mode().Perm(); perm != 0o700 { + t.Errorf("state dir perm = %o, want 700", perm) + } + fi, err := os.Stat(Path()) + if err != nil { + t.Fatal(err) + } + if perm := fi.Mode().Perm(); perm&0o077 != 0 { + t.Errorf("state file readable by others: %o", perm) + } +} + +func TestDirPrefersEnv(t *testing.T) { + t.Setenv("INFRACANVAS_STATE_DIR", "/custom/dir") + if got := Dir(); got != "/custom/dir" { + t.Fatalf("Dir() = %q", got) + } +} diff --git a/pkg/server/clusters_auth_test.go b/pkg/server/clusters_auth_test.go new file mode 100644 index 0000000..97515e9 --- /dev/null +++ b/pkg/server/clusters_auth_test.go @@ -0,0 +1,56 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "infracanvas/pkg/clustermgr" +) + +func postJSON(t *testing.T, url, body string, header map[string]string) int { + t.Helper() + req, err := http.NewRequest(http.MethodPost, url, strings.NewReader(body)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/json") + for k, v := range header { + req.Header.Set(k, v) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + return resp.StatusCode +} + +// Cluster routes run kubeconfig auth on the hub, so the join token that +// every joined VM holds must not reach them. +func TestClusterRoutesRejectAgentToken(t *testing.T) { + s := NewWithOptions(Options{LocalMode: true, UIToken: "ui", AgentToken: "agent"}) + s.SetClusterManager(clustermgr.NewManager("ws://127.0.0.1:1", "agent", 30)) + srv := httptest.NewServer(s.Handler()) + defer srv.Close() + + bearer := map[string]string{"Authorization": "Bearer agent"} + for _, path := range []string{"/api/clusters", "/api/clusters/preview"} { + if code := postJSON(t, srv.URL+path, `{"kubeconfig":"x"}`, bearer); code != http.StatusUnauthorized { + t.Errorf("%s with agent token: got %d, want 401", path, code) + } + } +} + +func TestReadOnlyBlocksClusterPreview(t *testing.T) { + s := NewWithOptions(Options{LocalMode: true, UIToken: "ui", ReadOnly: true}) + s.SetClusterManager(clustermgr.NewManager("ws://127.0.0.1:1", "", 30)) + srv := httptest.NewServer(s.Handler()) + defer srv.Close() + + code := postJSON(t, srv.URL+"/api/clusters/preview?token=ui", `{"kubeconfig":"apiVersion: v1","context":"x"}`, nil) + if code != http.StatusForbidden { + t.Fatalf("preview in read-only mode: got %d, want 403", code) + } +} diff --git a/pkg/server/server.go b/pkg/server/server.go index 414ecbc..c71f887 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -36,6 +36,7 @@ const ( MsgLogData = "LOG_DATA" MsgExecData = "EXEC_DATA" MsgExecEnd = "EXEC_END" + MsgDiscoveryError = "DISCOVERY_ERROR" // Server → Agent (and Browser → Server → Agent) MsgPairCode = "PAIR_CODE" @@ -386,9 +387,9 @@ func NewWithOptions(opts Options) *Server { s.mux.HandleFunc("/api/sessions", s.requireUIToken(s.handleSessions)) s.mux.HandleFunc("/api/audit", s.requireUIToken(s.handleAudit)) s.mux.HandleFunc("/api/join-info", s.requireUIOrAgentToken(s.handleJoinInfo)) - s.mux.HandleFunc("/api/clusters", s.requireUIOrAgentToken(s.handleClusters)) - s.mux.HandleFunc("/api/clusters/preview", s.requireUIOrAgentToken(s.handleClusterPreview)) - s.mux.HandleFunc("/api/clusters/", s.requireUIOrAgentToken(s.handleClusterByID)) + s.mux.HandleFunc("/api/clusters", s.requireUIToken(s.handleClusters)) + s.mux.HandleFunc("/api/clusters/preview", s.requireUIToken(s.handleClusterPreview)) + s.mux.HandleFunc("/api/clusters/", s.requireUIToken(s.handleClusterByID)) return s } @@ -748,9 +749,9 @@ func (s *Server) handleClusters(w http.ResponseWriter, r *http.Request) { // handleClusterPreview serves POST /api/clusters/preview — checks what a // kubeconfig context can actually do (view, exec, restart, scale, read -// secrets) before the user commits to connecting it. Read-only in every -// sense: no cluster is added, no virtual agent starts, nothing is persisted, -// so this is allowed even in --read-only mode. +// secrets) before the user commits to connecting it. Nothing is persisted, +// but calling the API server runs the kubeconfig's auth, including any +// `exec:` credential plugin, so read-only mode blocks it the same as Add. func (s *Server) handleClusterPreview(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) @@ -770,6 +771,10 @@ func (s *Server) handleClusterPreview(w http.ResponseWriter, r *http.Request) { http.Error(w, "kubeconfig is required", http.StatusBadRequest) return } + if s.readOnly { + http.Error(w, "read-only mode: cluster preview is disabled", http.StatusForbidden) + return + } preview, err := clustermgr.PreviewPermissions([]byte(req.Kubeconfig), req.Context) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) @@ -1037,12 +1042,24 @@ func (s *Server) routeAgentMessage(sess *Session, env Envelope, raw []byte) { sess.mu.Lock() sess.LastSnapshot = make([]byte, len(raw)) copy(sess.LastSnapshot, raw) + sess.LastDiscoveryError = nil sess.NodeCount = len(snap.Data.Nodes) sess.mu.Unlock() broadcastToBrowsers(sess, raw) log.Printf("[agent] GRAPH_SNAPSHOT %d bytes → %d browsers", len(raw), sess.BrowserCount()) + case MsgDiscoveryError: + // Only meaningful before the first snapshot; cache it so a browser + // that opens the machine later sees the error too. + sess.mu.Lock() + if sess.LastSnapshot == nil { + sess.LastDiscoveryError = make([]byte, len(raw)) + copy(sess.LastDiscoveryError, raw) + } + sess.mu.Unlock() + broadcastToBrowsers(sess, raw) + case MsgGraphDiff: broadcastToBrowsers(sess, raw) log.Printf("[agent] GRAPH_DIFF → %d browsers", sess.BrowserCount()) @@ -1222,9 +1239,12 @@ func (s *Server) handleBrowserWS(w http.ResponseWriter, r *http.Request) { // Replay the last cached snapshot so the browser doesn't wait for the next tick. sess.mu.RLock() lastSnap := sess.LastSnapshot + lastDiscErr := sess.LastDiscoveryError sess.mu.RUnlock() if lastSnap != nil { _ = conn.WriteMessage(websocket.TextMessage, lastSnap) + } else if lastDiscErr != nil { + _ = conn.WriteMessage(websocket.TextMessage, lastDiscErr) } // Attaching to an offline machine shows its last-known state; tell the diff --git a/pkg/server/session.go b/pkg/server/session.go index ea8b642..bd4cce0 100644 --- a/pkg/server/session.go +++ b/pkg/server/session.go @@ -39,7 +39,10 @@ type Session struct { NodeCount int PairedAt time.Time LastSnapshot []byte // cached for late-joining browsers - mu sync.RWMutex + // LastDiscoveryError is the agent's most recent DISCOVERY_ERROR while it + // has never produced a snapshot; cleared on the first snapshot. + LastDiscoveryError []byte + mu sync.RWMutex } func (s *Session) BrowserCount() int {