From e42853a0463cdd490aad66f2f3db932e8171c06e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 10:28:37 +0000 Subject: [PATCH 01/21] Allow self-signed TLS for Proxmox and fix critical fast-jwt vuln Proxmox ships with a self-signed cert by default, which Node's fetch rejected outright; route Proxmox requests through an undici Agent with rejectUnauthorized disabled so real Proxmox hosts can be connected. Also bump @fastify/jwt to v10, which pulls in a patched fast-jwt and resolves the critical advisories (crit-header bypass, algorithm confusion, cache collision, ReDoS, empty-HMAC-secret auth bypass) that npm audit flagged on the old v9/fast-jwt<=6.2.3 pairing. Verified auth still works end-to-end (setup, valid token, rejected bad token) after the upgrade; npm audit now reports 0 vulnerabilities. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BbJV5nm8KPVH1oNJYKpnoF --- backend/package-lock.json | 33 +++++++++++++++++++---------- backend/package.json | 3 ++- backend/src/integrations/proxmox.ts | 14 ++++++++++-- 3 files changed, 36 insertions(+), 14 deletions(-) diff --git a/backend/package-lock.json b/backend/package-lock.json index b76b1d8..4ca5241 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -11,13 +11,14 @@ "@aws-sdk/client-ec2": "^3.1072.0", "@aws-sdk/client-sts": "^3.1072.0", "@fastify/cors": "^10.0.1", - "@fastify/jwt": "^9.0.4", + "@fastify/jwt": "^10.1.0", "@types/ssh2": "^1.15.5", "bcryptjs": "^2.4.3", "better-sqlite3": "^11.8.1", "dotenv": "^16.6.1", "fastify": "^5.2.1", "ssh2": "^1.17.0", + "undici": "^8.5.0", "zod": "^3.24.1" }, "devDependencies": { @@ -955,9 +956,9 @@ "license": "MIT" }, "node_modules/@fastify/jwt": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/@fastify/jwt/-/jwt-9.1.0.tgz", - "integrity": "sha512-CiGHCnS5cPMdb004c70sUWhQTfzrJHAeTywt7nVw6dAiI0z1o4WRvU94xfijhkaId4bIxTCOjFgn4sU+Gvk43w==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/@fastify/jwt/-/jwt-10.1.0.tgz", + "integrity": "sha512-U1y8ZbxoH1Pjon3euzPJmbCkuYBM+hrQlFWLQWvKmJGCNT6mVsAolnVJdEWfXeQOKpgmuRVCIsPll5RLZxj10A==", "funding": [ { "type": "github", @@ -970,10 +971,10 @@ ], "license": "MIT", "dependencies": { - "@fastify/error": "^4.0.0", + "@fastify/error": "^4.2.0", "@lukeed/ms": "^2.0.2", - "fast-jwt": "^5.0.0", - "fastify-plugin": "^5.0.0", + "fast-jwt": "^6.2.0", + "fastify-plugin": "^5.0.1", "steed": "^1.1.3" } }, @@ -1619,15 +1620,16 @@ } }, "node_modules/fast-jwt": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/fast-jwt/-/fast-jwt-5.0.6.tgz", - "integrity": "sha512-LPE7OCGUl11q3ZgW681cEU2d0d2JZ37hhJAmetCgNyW8waVaJVZXhyFF6U2so1Iim58Yc7pfxJe2P7MNetQH2g==", + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/fast-jwt/-/fast-jwt-6.2.4.tgz", + "integrity": "sha512-IoQa53wI6TbARU2yelb0L44ggFQnP2qVcwswCSYHbCAWuwpr70icDb3QjG0v01I8Tt01rVGDkN/rRvpk0lKFTA==", "license": "Apache-2.0", "dependencies": { "@lukeed/ms": "^2.0.2", "asn1.js": "^5.4.1", "ecdsa-sig-formatter": "^1.0.11", - "mnemonist": "^0.40.0" + "mnemonist": "^0.40.0", + "safe-regex2": "^5.1.0" }, "engines": { "node": ">=20" @@ -2538,6 +2540,15 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.5.0.tgz", + "integrity": "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", diff --git a/backend/package.json b/backend/package.json index 6a61b30..3c8f9c4 100644 --- a/backend/package.json +++ b/backend/package.json @@ -12,13 +12,14 @@ "@aws-sdk/client-ec2": "^3.1072.0", "@aws-sdk/client-sts": "^3.1072.0", "@fastify/cors": "^10.0.1", - "@fastify/jwt": "^9.0.4", + "@fastify/jwt": "^10.1.0", "@types/ssh2": "^1.15.5", "bcryptjs": "^2.4.3", "better-sqlite3": "^11.8.1", "dotenv": "^16.6.1", "fastify": "^5.2.1", "ssh2": "^1.17.0", + "undici": "^8.5.0", "zod": "^3.24.1" }, "devDependencies": { diff --git a/backend/src/integrations/proxmox.ts b/backend/src/integrations/proxmox.ts index e67b3b3..8ccaf10 100644 --- a/backend/src/integrations/proxmox.ts +++ b/backend/src/integrations/proxmox.ts @@ -1,3 +1,4 @@ +import { Agent } from 'undici' import type { IntegrationAdapter, Resource } from './types.js' interface ProxmoxResourceEntry { @@ -12,6 +13,9 @@ function authHeader(apiKey: string): Record { return { Authorization: `PVEAPIToken=${apiKey}` } } +// Proxmox ships with a self-signed cert by default, which Node's fetch rejects out of the box. +const insecureDispatcher = new Agent({ connect: { rejectUnauthorized: false } }) + export const proxmox: IntegrationAdapter = { async testConnection(config, secrets) { const baseUrl = config.baseUrl?.replace(/\/$/, '') @@ -19,7 +23,10 @@ export const proxmox: IntegrationAdapter = { if (!baseUrl) return { ok: false, message: 'Missing baseUrl' } if (!apiKey) return { ok: false, message: 'Missing API token' } try { - const res = await fetch(`${baseUrl}/api2/json/version`, { headers: authHeader(apiKey) }) + const res = await fetch(`${baseUrl}/api2/json/version`, { + headers: authHeader(apiKey), + dispatcher: insecureDispatcher, + } as RequestInit) if (!res.ok) return { ok: false, message: `HTTP ${res.status}` } return { ok: true, message: 'Connected' } } catch (err) { @@ -31,7 +38,10 @@ export const proxmox: IntegrationAdapter = { const baseUrl = config.baseUrl?.replace(/\/$/, '') const apiKey = secrets.apiKey if (!baseUrl || !apiKey) return [] - const res = await fetch(`${baseUrl}/api2/json/cluster/resources?type=vm`, { headers: authHeader(apiKey) }) + const res = await fetch(`${baseUrl}/api2/json/cluster/resources?type=vm`, { + headers: authHeader(apiKey), + dispatcher: insecureDispatcher, + } as RequestInit) if (!res.ok) return [] const body = (await res.json()) as { data: ProxmoxResourceEntry[] } return body.data.map((entry) => ({ From 1d1f98f5aa2bbbbcea963f00907f2961a7c0da75 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 10:28:58 +0000 Subject: [PATCH 02/21] Update HANDOFF.md: Proxmox TLS and fast-jwt fixes are done --- HANDOFF.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/HANDOFF.md b/HANDOFF.md index 7b10d9e..4598ed4 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -61,7 +61,7 @@ export interface IntegrationAdapter { |---|---|---|---| | Docker | `docker.ts` | Pre-existing from an earlier session | Not touched this session | | Uptime Kuma | `uptimeKuma.ts` | Pre-existing from an earlier session | Not touched this session | -| Proxmox | `proxmox.ts` | Calls `{baseUrl}/api2/json/cluster/resources?type=vm` with a `PVEAPIToken` header; maps VM/CT `status` to health | **Known caveat, not yet fixed**: Proxmox typically uses a self-signed TLS cert by default, and Node's native `fetch` will reject it. No code-level workaround (e.g. a custom `Agent` with `rejectUnauthorized: false`, or documenting that users need a real cert) has been added yet. Flag this to the user if/when someone actually tries to connect a real Proxmox host. | +| Proxmox | `proxmox.ts` | Calls `{baseUrl}/api2/json/cluster/resources?type=vm` with a `PVEAPIToken` header; maps VM/CT `status` to health | Self-signed TLS certs (Proxmox's default) are explicitly allowed — requests go through an `undici` `Agent` with `rejectUnauthorized: false` set as the fetch `dispatcher`. Fixed in a follow-up session. | | NetBird | `netbird.ts` | Calls NetBird Management API `/api/peers` with a `Token` bearer header; defaults to `https://api.netbird.io` but respects `config.baseUrl` for self-hosted management servers; maps peer `connected` bool to healthy/critical | Verified against the real NetBird Cloud API (got a real 403 with a fake token, confirming live wiring) | | Cloudflare | `cloudflare.ts` | Calls `/client/v4/zones/{zoneId}` with a Bearer token; reports zone `status` as health | **Bug fixed this session**: originally called `res.json()` before checking `res.ok`, but Cloudflare returns plain-text bodies for some error cases, causing a JSON-parse crash. Fixed by checking `res.ok` immediately after `fetch()`. | | AWS | `aws.ts` | Uses `@aws-sdk/client-sts` (`GetCallerIdentityCommand`) for connection test, `@aws-sdk/client-ec2` (`DescribeInstancesCommand`) for resource listing; maps EC2 instance state to health, uses the `Name` tag (fallback to instance ID) for resource naming | New deps: `@aws-sdk/client-sts`, `@aws-sdk/client-ec2` (already in `backend/package.json`). User said they'll create a dedicated least-privilege IAM user for this in production — not yet done, just code-ready. | @@ -91,10 +91,10 @@ No system `sshd` was available in the sandbox (`apt-get install openssh-server` ## Things explicitly NOT done / open for follow-up (not yet actioned, no decision made) -1. **Proxmox self-signed TLS cert handling** — Node's `fetch` will reject Proxmox's default cert. No workaround added. -2. **`fast-jwt` vulnerability** — `@fastify/jwt` has a known critical transitive vuln in the version currently pinned; fixing it requires bumping to `@fastify/jwt` v10, which is a breaking change per npm's own advisory. Not attempted — needs a deliberate decision with the user since it could break auth. -3. **SSH private-key textarea UX** — see above, the single-line input may mishandle multi-line PEM keys. -4. **`/terminal` page** — entirely on hold, pending a separate Termix-fork integration the user is handing to another AI session. **Do not start this.** +1. ~~Proxmox self-signed TLS cert handling~~ — **done**, see adapter table above. +2. ~~`fast-jwt` vulnerability~~ — **done**: bumped `@fastify/jwt` to v10 (pulls in patched `fast-jwt`), `npm audit` now reports 0 vulnerabilities in `backend/`. Verified auth still works (setup, valid token, rejected bad token) after the bump. +3. **SSH private-key textarea UX** — see above, the single-line input may mishandle multi-line PEM keys. Not yet fixed. +4. **`/terminal` page** — entirely on hold, pending a separate Termix-fork integration the user is handing to another AI session. **Do not start this without the user explicitly confirming it's time.** 5. Registry/route enum duplication (`IntegrationType` vs. `integrationTypes` in routes/integrations.ts) — works correctly now but is a latent footgun for future integration types. Worth a refactor sometime, not urgent. ## Quick orientation for a new session From f2629a22f819cedcda55269cff6b122dbca9270f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 10:48:42 +0000 Subject: [PATCH 03/21] Document the Termix-to-ArchNest migration plan User wants full Termix feature parity (SSH terminal, tunnels, file manager, Docker management, RDP/VNC/Telnet) merged into ArchNest as a single app, single backend, single auth, single database, reskinned to ArchNest's look, with Termix's Electron app/installers/OIDC-LDAP-2FA/ translations explicitly dropped per the user's approved tradeoff. Splits the work into 5 phases (terminal, tunnels, file manager, Docker, RDP/VNC), each independently committable, plus a sub-split for Phase 1 itself given its real size (~5,000 lines across session management, jump-host chaining, OPKSSH cert auth, and tmux monitoring) so the first checkpoint is a working core terminal rather than one giant unreviewable change. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BbJV5nm8KPVH1oNJYKpnoF --- TERMIX_MIGRATION.md | 72 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 TERMIX_MIGRATION.md diff --git a/TERMIX_MIGRATION.md b/TERMIX_MIGRATION.md new file mode 100644 index 0000000..b38d125 --- /dev/null +++ b/TERMIX_MIGRATION.md @@ -0,0 +1,72 @@ +# Termix → ArchNest Migration Plan + +Status doc for porting Termix's full feature set into ArchNest as a single app, single backend, single auth, single database — reskinned to match ArchNest's design. Written so any session (human or AI) can see exactly what's done, what's next, and why decisions were made. + +Source: `https://github.com/SamuelSJames/Termix` (user's fork), cloned for reference at the time of writing. Upstream is `Termix-SSH/Termix`, an Electron + Express + Drizzle ORM self-hosted SSH/RDP/VNC management app — **not** a small terminal widget. It ships as its own Docker image with a `guacd` sidecar for RDP/VNC. + +## Decision: why merge into ArchNest's backend, not Termix's + +ArchNest's backend (Fastify + better-sqlite3 + JWT) is small and already has things worth keeping: the bookmarks system, the integration adapter framework (Proxmox/AWS/NetBird/Cloudflare/Weather/SSH health checks — see `backend/src/integrations/`), the audit log, and a working auth/profile system built this session. Termix's backend is much bigger but its *value* is the SSH/tunnel/file-manager/Docker/RDP feature logic, not its auth system (OIDC/LDAP/2FA) or its Drizzle schema. So: **port Termix's feature modules onto ArchNest's existing Fastify app and auth**, don't adopt Termix's backend wholesale. + +## What is explicitly NOT being ported (user-approved tradeoff) + +- Electron desktop app + native installers (Chocolatey/Flatpak/AppImage/MSI/Cask) — ArchNest is a web app. +- OIDC/LDAP/2FA/SSO and Termix's own multi-user auth system — replaced by ArchNest's existing JWT auth. User confirmed they don't currently use 2FA/OIDC/LDAP, so this is an accepted downgrade, not an oversight. +- ~30 language translations (i18n) — not a stated goal, not being ported. +- All Termix branding — logos, icons, About/product copy, links to Termix's Discord/docs/GitHub. Every ported UI component gets reskinned to ArchNest's Tailwind theme (gold `#C8A434`, the existing dark palette) as part of porting it, not as a separate pass. + +Everything else — SSH terminal, tunnels, file manager, Docker management, RDP/VNC/Telnet, host metrics — is in scope to fully port, feature-equivalent, just rebuilt on ArchNest's stack. + +## Phases + +Each phase is independently committable and testable. Do not start a later phase before the previous one is working end-to-end and committed — this is a large port and needs to land in reviewable chunks. + +### Phase 1 — SSH Terminal (IN PROGRESS) + +The actual `/terminal` page: a real interactive SSH terminal in the browser (xterm.js + WebSocket), reusing the SSH credentials already stored in ArchNest's integrations (no second "add a host" flow — Termix's separate host-manager concept is being merged into ArchNest's existing `integrations` table/SSH adapter, not duplicated). + +**Termix source files this phase is based on** (sizes as of the fork snapshot, for scoping): +- `src/backend/ssh/terminal.ts` (2,570 lines) — WebSocket route handling, message protocol (connect/data/resize/disconnect), output buffering. +- `src/backend/ssh/terminal-session-manager.ts` (570 lines) — session lifecycle, reattach-on-reconnect, per-user session caps, idle timeout, optional session logging to disk. +- `src/backend/ssh/ssh-connection-pool.ts` (225 lines) — connection reuse. +- `src/backend/ssh/host-resolver.ts`, `jump-host-chain.ts`, `terminal-jump-hosts.ts` (~900 lines combined) — jump-host / bastion chaining. +- `src/backend/ssh/auth-manager.ts`, `credential-username.ts`, `host-key-verifier.ts`, `terminal-auth-helpers.ts` (~950 lines combined) — credential resolution, host key verification/trust-on-first-use. +- `src/backend/ssh/opkssh-auth.ts`, `opkssh-cert-auth.ts` (~1,350 lines) — OPKSSH (OpenPubkey SSH) certificate auth. +- `src/backend/ssh/tmux-monitor.ts`, `tmux-helper.ts`, `tmux-monitor-helpers.ts` (~1,350 lines) — tmux session detection/monitoring inside the terminal. +- Frontend: `src/ui/features/terminal/*` — xterm.js wrapper, tab system, up-to-4-panel split screen, theme/font customization. + +**Scope split for this phase, given the size above:** + +- **Phase 1a (doing this now)**: core single-session SSH terminal. WebSocket connect/data/resize/disconnect, using ArchNest's existing SSH integration config/secrets (host/port/username/password/privateKey/passphrase — already in `backend/src/integrations/ssh.ts`) instead of Termix's separate host table. One terminal per tab, no split panes yet, no jump hosts, no OPKSSH, no tmux monitor, no session recording/logging. Ported onto Fastify's WebSocket support, reusing ArchNest's JWT auth for the WS handshake. +- **Phase 1b (follow-up, not blocking 1a)**: jump-host/bastion chaining, host-key verification/trust-on-first-use UI, tab system + up to 4 split panes, terminal theme/font customization settings. +- **Phase 1c (follow-up, lower priority)**: OPKSSH cert auth, tmux session monitor/reattach, session recording/logging to disk. + +Rationale for splitting: 1a alone is a real, useful terminal (matches what `/terminal` needs to stop being a placeholder) and is testable end-to-end on its own. Bundling jump-hosts/OPKSSH/tmux into the first pass risks a large unreviewable change with no working checkpoint in between. + +**Status:** scoping complete, implementation starting on Phase 1a. + +### Phase 2 — SSH Tunnels (NOT STARTED) + +Source: `src/backend/ssh/tunnel.ts` (2,414 lines) + `tunnel-c2s-relay.ts`, `tunnel-socks5-relay.ts`, `tunnel-ssh-primitives.ts`, `tunnel-utils.ts`, `tunnel-c2s-relay-utils.ts` (~830 lines combined) + frontend `src/ui/features/tunnel/*`. Local/remote/dynamic SOCKS forwarding, automatic reconnection, health monitoring. Builds on Phase 1's connection pool. Client-to-server tunnel presets (save/rename/load/delete) need a small new table in ArchNest's schema. + +### Phase 3 — Remote File Manager (NOT STARTED) + +Source: `src/backend/ssh/file-manager*.ts` (six files, ~3,900 lines combined: list/content/action/operation/download routes + session + utils) + frontend `src/ui/features/file-manager/*`. View/edit code/images/audio/video, upload/download/rename/delete/move, sudo support, server-to-server moves. Runs over the SSH connections from Phase 1. + +### Phase 4 — Docker Container Management (NOT STARTED) + +Source: `src/backend/ssh/docker.ts` (2,243 lines) + `docker-container-routes.ts` (1,093 lines) + `docker-console.ts` (751 lines) + frontend `src/ui/features/docker/*`. Start/stop/pause/remove containers, view stats, `docker exec` terminal. **Check for overlap** with ArchNest's existing `backend/src/integrations/docker.ts` adapter (currently just used for health-status resources) before porting — may be able to extend the existing adapter rather than bolt on a second, separate Docker code path. + +### Phase 5 — RDP/VNC/Telnet (NOT STARTED) + +Source: `src/backend/guacamole/*` + `guacamole-lite` dependency + frontend `src/ui/features/guacamole/*`. **Biggest infra lift**: requires a `guacd` sidecar container (see `docker/docker-compose.yml` in the Termix fork) added to ArchNest's own `docker-compose.yml` — this is a new runtime dependency, not just ported code. Should be scoped in detail (including how `guacd` networking/ports interact with ArchNest's existing deployment on `racknerd1`/NPM) before starting. + +### Also worth checking during/after the phases above + +- `src/backend/ssh/host-metrics*.ts` (~3,900 lines combined across 8 files) — CPU/memory/disk/network/uptime/firewall/port-monitor/log-viewer/users-permissions/certificates widgets. Not yet assigned to a phase; likely overlaps with ArchNest's existing SSH adapter health probe (`backend/src/integrations/ssh.ts`) and Infrastructure page — worth a deliberate decision on whether to fold these widgets into the existing Infrastructure page rather than recreate Termix's own dashboard-cards system (`src/ui/dashboard/*`). +- `src/backend/ssh/host-transfer.ts` (3,428 lines) — appears to be server-to-server file/data transfer; likely folds into Phase 3 (file manager) rather than being separate. +- Data export/import of SSH hosts/credentials/file-manager data — a nice-to-have, not yet scheduled. + +## Tracking + +Update the phase status lines above as work lands. Each phase should get its own commit(s) on `claude/wonderful-faraday-qxym5t`, following the existing commit message style (descriptive title + why, `Co-Authored-By`/`Claude-Session` trailer). From 71f49e07002d4c2a047b39da2f127d9f20a0eaec Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 10:52:04 +0000 Subject: [PATCH 04/21] Add Phase 1a: core SSH terminal (Termix migration) Implements the minimal-viable terminal described in TERMIX_MIGRATION.md Phase 1a: a real interactive SSH session in the browser over a WebSocket, using xterm.js on the frontend and ssh2 on the backend. Reuses ArchNest's existing SSH integrations (host/port/username/ password/privateKey/passphrase) instead of introducing a second, duplicate host-management system the way Termix has one. Backend: new /api/terminal WebSocket route (registered via @fastify/websocket) handling connect/input/resize/disconnect messages, authenticated via a JWT passed as a query param (browsers can't set custom headers on the WS handshake). Extracted the integration secret loader out of routes/integrations.ts into db/secrets.ts so the new terminal route can reuse it without duplicating the decrypt logic. Frontend: new Terminal.tsx page listing configured SSH hosts and rendering an xterm.js terminal wired to the WebSocket; wired into App.tsx at /terminal. vite.config.ts's dev proxy now forwards WebSocket upgrades (ws: true) so this works under `npm run dev`. Verified end-to-end against a real (test) ssh2-based SSH server: connect, shell banner, keystroke echo, and prompt redraw all worked correctly over the actual WebSocket protocol. Deliberately deferred to Phase 1b/1c per the migration doc: jump-host chaining, tab/split-pane UI, terminal theme/font settings, OPKSSH cert auth, tmux session monitor, session recording. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BbJV5nm8KPVH1oNJYKpnoF --- backend/package-lock.json | 61 ++++++++++++++ backend/package.json | 1 + backend/src/db/secrets.ts | 11 +++ backend/src/routes/integrations.ts | 12 +-- backend/src/routes/terminal.ts | 115 +++++++++++++++++++++++++ backend/src/server.ts | 4 + package-lock.json | 17 ++++ package.json | 2 + src/App.tsx | 2 + src/pages/Terminal.tsx | 131 +++++++++++++++++++++++++++++ vite.config.ts | 1 + 11 files changed, 347 insertions(+), 10 deletions(-) create mode 100644 backend/src/db/secrets.ts create mode 100644 backend/src/routes/terminal.ts create mode 100644 src/pages/Terminal.tsx diff --git a/backend/package-lock.json b/backend/package-lock.json index 4ca5241..fadea30 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -12,6 +12,7 @@ "@aws-sdk/client-sts": "^3.1072.0", "@fastify/cors": "^10.0.1", "@fastify/jwt": "^10.1.0", + "@fastify/websocket": "^11.2.0", "@types/ssh2": "^1.15.5", "bcryptjs": "^2.4.3", "better-sqlite3": "^11.8.1", @@ -1017,6 +1018,27 @@ "ipaddr.js": "^2.1.0" } }, + "node_modules/@fastify/websocket": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/@fastify/websocket/-/websocket-11.2.0.tgz", + "integrity": "sha512-3HrDPbAG1CzUCqnslgJxppvzaAZffieOVbLp1DAy1huCSynUWPifSvfdEDUR8HlJLp3sp1A36uOM2tJogADS8w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "duplexify": "^4.1.3", + "fastify-plugin": "^5.0.0", + "ws": "^8.16.0" + } + }, "node_modules/@lukeed/ms": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", @@ -1514,6 +1536,18 @@ "url": "https://dotenvx.com" } }, + "node_modules/duplexify": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", + "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.4.1", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1", + "stream-shift": "^1.0.2" + } + }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -2395,6 +2429,12 @@ "reusify": "^1.0.0" } }, + "node_modules/stream-shift": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", + "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", + "license": "MIT" + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -2568,6 +2608,27 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xml-naming": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", diff --git a/backend/package.json b/backend/package.json index 3c8f9c4..3328c22 100644 --- a/backend/package.json +++ b/backend/package.json @@ -13,6 +13,7 @@ "@aws-sdk/client-sts": "^3.1072.0", "@fastify/cors": "^10.0.1", "@fastify/jwt": "^10.1.0", + "@fastify/websocket": "^11.2.0", "@types/ssh2": "^1.15.5", "bcryptjs": "^2.4.3", "better-sqlite3": "^11.8.1", diff --git a/backend/src/db/secrets.ts b/backend/src/db/secrets.ts new file mode 100644 index 0000000..5a8d0ed --- /dev/null +++ b/backend/src/db/secrets.ts @@ -0,0 +1,11 @@ +import { db } from './index.js' +import { decryptSecret } from './crypto.js' + +export function loadSecrets(integrationId: number): Record { + const rows = db + .prepare('SELECT key, value_encrypted FROM secrets WHERE integration_id = ?') + .all(integrationId) as { key: string; value_encrypted: string }[] + const out: Record = {} + for (const row of rows) out[row.key] = decryptSecret(row.value_encrypted) + return out +} diff --git a/backend/src/routes/integrations.ts b/backend/src/routes/integrations.ts index 3c6198c..2f8d05a 100644 --- a/backend/src/routes/integrations.ts +++ b/backend/src/routes/integrations.ts @@ -1,7 +1,8 @@ import type { FastifyInstance } from 'fastify' import { z } from 'zod' import { db, logEvent } from '../db/index.js' -import { encryptSecret, decryptSecret } from '../db/crypto.js' +import { encryptSecret } from '../db/crypto.js' +import { loadSecrets } from '../db/secrets.js' import { adapterRegistry } from '../integrations/registry.js' import type { IntegrationType, Resource } from '../integrations/types.js' @@ -47,15 +48,6 @@ function serialize(row: IntegrationRow) { } } -function loadSecrets(integrationId: number): Record { - const rows = db - .prepare('SELECT key, value_encrypted FROM secrets WHERE integration_id = ?') - .all(integrationId) as { key: string; value_encrypted: string }[] - const out: Record = {} - for (const row of rows) out[row.key] = decryptSecret(row.value_encrypted) - return out -} - export async function integrationRoutes(app: FastifyInstance) { app.addHook('onRequest', app.authenticate) diff --git a/backend/src/routes/terminal.ts b/backend/src/routes/terminal.ts new file mode 100644 index 0000000..7e3fce1 --- /dev/null +++ b/backend/src/routes/terminal.ts @@ -0,0 +1,115 @@ +import type { FastifyInstance } from 'fastify' +import { Client } from 'ssh2' +import type { ClientChannel } from 'ssh2' +import { db } from '../db/index.js' +import { loadSecrets } from '../db/secrets.js' + +interface IntegrationRow { + id: number + type: string + config_json: string +} + +interface ClientMessage { + type: 'connect' | 'input' | 'resize' | 'disconnect' + integrationId?: number + cols?: number + rows?: number + data?: string +} + +function send(socket: { send: (data: string) => void }, payload: Record) { + socket.send(JSON.stringify(payload)) +} + +export async function terminalRoutes(app: FastifyInstance) { + app.get('/api/terminal', { websocket: true }, (socket, req) => { + let conn: Client | null = null + let stream: ClientChannel | null = null + + const cleanup = () => { + stream?.end() + conn?.end() + stream = null + conn = null + } + + socket.on('close', cleanup) + + socket.on('message', async (raw: Buffer) => { + let msg: ClientMessage + try { + msg = JSON.parse(raw.toString()) + } catch { + send(socket, { type: 'error', message: 'Invalid JSON' }) + return + } + + if (msg.type === 'connect') { + const query = req.query as { token?: string } + try { + await app.jwt.verify(query.token ?? '') + } catch { + send(socket, { type: 'error', message: 'Unauthorized' }) + socket.close() + return + } + + const row = db + .prepare('SELECT id, type, config_json FROM integrations WHERE id = ?') + .get(msg.integrationId) as IntegrationRow | undefined + if (!row || row.type !== 'ssh') { + send(socket, { type: 'error', message: 'SSH integration not found' }) + return + } + const config = JSON.parse(row.config_json) as Record + const secrets = loadSecrets(row.id) + + conn = new Client() + conn.on('ready', () => { + conn!.shell({ cols: msg.cols ?? 80, rows: msg.rows ?? 24, term: 'xterm-256color' }, (err, ch) => { + if (err) { + send(socket, { type: 'error', message: err.message }) + return + } + stream = ch + send(socket, { type: 'connected' }) + ch.on('data', (chunk: Buffer) => send(socket, { type: 'data', data: chunk.toString('utf8') })) + ch.stderr.on('data', (chunk: Buffer) => send(socket, { type: 'data', data: chunk.toString('utf8') })) + ch.on('close', () => { + send(socket, { type: 'closed' }) + cleanup() + }) + }) + }) + conn.on('error', (err) => { + send(socket, { type: 'error', message: err.message }) + }) + conn.connect({ + host: config.host, + port: Number(config.port) || 22, + username: config.username, + password: secrets.password || undefined, + privateKey: secrets.privateKey || undefined, + passphrase: secrets.passphrase || undefined, + readyTimeout: 8000, + }) + return + } + + if (msg.type === 'input') { + stream?.write(msg.data ?? '') + return + } + + if (msg.type === 'resize') { + stream?.setWindow(msg.rows ?? 24, msg.cols ?? 80, 0, 0) + return + } + + if (msg.type === 'disconnect') { + cleanup() + } + }) + }) +} diff --git a/backend/src/server.ts b/backend/src/server.ts index 3ccb11d..d80d285 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -2,10 +2,12 @@ import 'dotenv/config' import Fastify from 'fastify' import cors from '@fastify/cors' import jwt from '@fastify/jwt' +import websocket from '@fastify/websocket' import { authRoutes } from './routes/auth.js' import { integrationRoutes } from './routes/integrations.js' import { bookmarkRoutes } from './routes/bookmarks.js' import { eventRoutes } from './routes/events.js' +import { terminalRoutes } from './routes/terminal.js' const JWT_SECRET = process.env.ARCHNEST_JWT_SECRET if (!JWT_SECRET) { @@ -16,6 +18,7 @@ const app = Fastify({ logger: true }) await app.register(cors, { origin: process.env.ARCHNEST_CORS_ORIGIN ?? true }) await app.register(jwt, { secret: JWT_SECRET }) +await app.register(websocket) app.decorate('authenticate', async function (req, reply) { try { @@ -29,6 +32,7 @@ await app.register(authRoutes) await app.register(integrationRoutes) await app.register(bookmarkRoutes) await app.register(eventRoutes) +await app.register(terminalRoutes) app.get('/api/health', async () => ({ ok: true })) diff --git a/package-lock.json b/package-lock.json index 4029398..5aaa875 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,8 @@ "version": "0.0.0", "dependencies": { "@tailwindcss/vite": "^4.3.0", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", "lucide-react": "^1.17.0", "react": "^19.2.6", "react-dom": "^19.2.6", @@ -1551,6 +1553,21 @@ } } }, + "node_modules/@xterm/addon-fit": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", + "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", + "license": "MIT" + }, + "node_modules/@xterm/xterm": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", + "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, "node_modules/acorn": { "version": "8.17.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", diff --git a/package.json b/package.json index 897023b..59dbd16 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,8 @@ }, "dependencies": { "@tailwindcss/vite": "^4.3.0", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", "lucide-react": "^1.17.0", "react": "^19.2.6", "react-dom": "^19.2.6", diff --git a/src/App.tsx b/src/App.tsx index e714610..9ea2b22 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,7 @@ import TopBar from './components/TopBar' import Glance from './pages/Glance' import Infrastructure from './pages/Infrastructure' import BookNest from './pages/BookNest' +import Terminal from './pages/Terminal' import Settings from './pages/Settings' import Login from './pages/Login' import Enrollment from './pages/Enrollment' @@ -80,6 +81,7 @@ function Dashboard() { } /> } /> } /> + } /> } /> diff --git a/src/pages/Terminal.tsx b/src/pages/Terminal.tsx new file mode 100644 index 0000000..386a4c0 --- /dev/null +++ b/src/pages/Terminal.tsx @@ -0,0 +1,131 @@ +import { useEffect, useRef, useState } from 'react' +import { Terminal as XTerm } from '@xterm/xterm' +import { FitAddon } from '@xterm/addon-fit' +import '@xterm/xterm/css/xterm.css' +import { api, getToken, type Integration } from '../lib/api' + +const GOLD = '#C8A434' +const TEXT_SECONDARY = '#7A7D85' + +export default function Terminal() { + const [hosts, setHosts] = useState([]) + const [activeHostId, setActiveHostId] = useState(null) + const [connected, setConnected] = useState(false) + const containerRef = useRef(null) + const termRef = useRef(null) + const fitRef = useRef(null) + const wsRef = useRef(null) + + useEffect(() => { + api.listIntegrations().then(({ integrations }) => { + setHosts(integrations.filter((i) => i.type === 'ssh')) + }) + }, []) + + useEffect(() => { + if (!containerRef.current) return + const term = new XTerm({ + cursorBlink: true, + fontSize: 13, + fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', + theme: { background: '#15161A', foreground: '#E8E6E0', cursor: GOLD }, + }) + const fit = new FitAddon() + term.loadAddon(fit) + term.open(containerRef.current) + fit.fit() + termRef.current = term + fitRef.current = fit + + const onResize = () => fit.fit() + window.addEventListener('resize', onResize) + return () => { + window.removeEventListener('resize', onResize) + term.dispose() + wsRef.current?.close() + } + }, []) + + function connect(hostId: number) { + wsRef.current?.close() + setActiveHostId(hostId) + setConnected(false) + const term = termRef.current + if (!term) return + term.reset() + term.writeln('Connecting…') + + const token = getToken() + const proto = window.location.protocol === 'https:' ? 'wss' : 'ws' + const ws = new WebSocket(`${proto}://${window.location.host}/api/terminal?token=${encodeURIComponent(token ?? '')}`) + wsRef.current = ws + + ws.onopen = () => { + ws.send(JSON.stringify({ type: 'connect', integrationId: hostId, cols: term.cols, rows: term.rows })) + } + ws.onmessage = (event) => { + const msg = JSON.parse(event.data) + if (msg.type === 'connected') { + setConnected(true) + term.reset() + } else if (msg.type === 'data') { + term.write(msg.data) + } else if (msg.type === 'error') { + term.writeln(`\r\n\x1b[31mError: ${msg.message}\x1b[0m`) + setConnected(false) + } else if (msg.type === 'closed') { + term.writeln('\r\n\x1b[33mConnection closed.\x1b[0m') + setConnected(false) + } + } + ws.onclose = () => setConnected(false) + + term.onData((data) => { + if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'input', data })) + }) + term.onResize(({ cols, rows }) => { + if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'resize', cols, rows })) + }) + } + + return ( +
+
+

+ SSH Hosts +

+ {hosts.length === 0 && ( +

+ No SSH integrations configured. Add one in Settings → Integrations. +

+ )} +
+ {hosts.map((h) => ( + + ))} +
+
+ +
+
+ + {activeHostId ? (connected ? 'Connected' : 'Disconnected') : 'Select a host to connect'} +
+
+
+
+ ) +} diff --git a/vite.config.ts b/vite.config.ts index 5e7eb0f..4050fc6 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -9,6 +9,7 @@ export default defineConfig({ '/api': { target: 'http://localhost:4000', changeOrigin: true, + ws: true, }, }, }, From 067bf16c04e9d1a7b6fcd26f52f5279c9af3133b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 10:52:15 +0000 Subject: [PATCH 05/21] Mark Phase 1a complete in migration doc --- TERMIX_MIGRATION.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/TERMIX_MIGRATION.md b/TERMIX_MIGRATION.md index b38d125..b70b04e 100644 --- a/TERMIX_MIGRATION.md +++ b/TERMIX_MIGRATION.md @@ -43,7 +43,10 @@ The actual `/terminal` page: a real interactive SSH terminal in the browser (xte Rationale for splitting: 1a alone is a real, useful terminal (matches what `/terminal` needs to stop being a placeholder) and is testable end-to-end on its own. Bundling jump-hosts/OPKSSH/tmux into the first pass risks a large unreviewable change with no working checkpoint in between. -**Status:** scoping complete, implementation starting on Phase 1a. +**Status:** +- ✅ **Phase 1a — done.** `/terminal` is a real interactive SSH terminal: `backend/src/routes/terminal.ts` (WebSocket, connect/input/resize/disconnect over `ssh2`), `backend/src/db/secrets.ts` (shared secret loader), `src/pages/Terminal.tsx` (xterm.js + host picker, reuses ArchNest's existing SSH integrations — no duplicate host table). Verified end-to-end against a real test SSH server. No jump hosts, no tabs/split panes, no OPKSSH, no tmux monitor yet — see 1b/1c below. +- ⬜ Phase 1b — not started. +- ⬜ Phase 1c — not started. ### Phase 2 — SSH Tunnels (NOT STARTED) From 5d56a1d90242fe897b9c9632865237e6b605d905 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 11:04:46 +0000 Subject: [PATCH 06/21] Phase 1b: SSH jump-host chaining, TOFU host-key verification, multi-host Settings UI Terminal connections can now reference a jumpHostIntegrationId on the SSH integration config; the backend connects to the jump host first and tunnels to the real target via ssh2's forwardOut(), rather than connecting directly. Added an ssh_host_keys table and a hostVerifier callback that accepts and stores a host's fingerprint on first connect, then hard-rejects on any mismatch on subsequent connects (trust-on-first-use). Settings previously only ever showed/edited one integration per type, which silently prevented configuring more than one SSH host at all. Added a dedicated multi-host SSH section (per-host Save/Test/Delete, Add SSH Host, and a Jump Host dropdown) so jump-host chaining is actually usable from the UI. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BbJV5nm8KPVH1oNJYKpnoF --- TERMIX_MIGRATION.md | 6 +- backend/src/db/index.ts | 6 + backend/src/routes/terminal.ts | 102 ++++++++--- src/pages/Settings.tsx | 312 +++++++++++++++++++++++++++++++-- 4 files changed, 391 insertions(+), 35 deletions(-) diff --git a/TERMIX_MIGRATION.md b/TERMIX_MIGRATION.md index b70b04e..a64439d 100644 --- a/TERMIX_MIGRATION.md +++ b/TERMIX_MIGRATION.md @@ -45,7 +45,11 @@ Rationale for splitting: 1a alone is a real, useful terminal (matches what `/ter **Status:** - ✅ **Phase 1a — done.** `/terminal` is a real interactive SSH terminal: `backend/src/routes/terminal.ts` (WebSocket, connect/input/resize/disconnect over `ssh2`), `backend/src/db/secrets.ts` (shared secret loader), `src/pages/Terminal.tsx` (xterm.js + host picker, reuses ArchNest's existing SSH integrations — no duplicate host table). Verified end-to-end against a real test SSH server. No jump hosts, no tabs/split panes, no OPKSSH, no tmux monitor yet — see 1b/1c below. -- ⬜ Phase 1b — not started. +- 🟡 **Phase 1b — in progress.** Done so far: + - **Jump-host chaining**: an SSH integration's config can carry `jumpHostIntegrationId` referencing another SSH integration. `backend/src/routes/terminal.ts` connects to the jump host first, opens a `forwardOut()` channel to the real target, and connects the target `Client` over that channel (single-hop; mirrors Termix's core mechanism without its multi-hop/credential-sharing complexity). Verified end-to-end with two real test SSH servers (one as jump, one as target). + - **Host-key verification (TOFU)**: new `ssh_host_keys` table (`backend/src/db/index.ts`) stores a SHA-256 fingerprint per SSH integration on first successful connect; subsequent connects are rejected if the fingerprint changes, via `ssh2`'s `hostVerifier` connect option. No interactive accept/reject-changed-key UI yet — first-use accept-and-store, hard-reject on mismatch. Verified both the accept-on-first-use and reject-on-mismatch paths against a real test server. + - **Settings UI for multiple SSH hosts**: `src/pages/Settings.tsx` previously could only show/edit one integration per type, which silently broke multi-host SSH. Added a dedicated `SshHostsSection` with its own per-host cards (Save/Test/Delete) and an "Add SSH Host" flow, including a `Jump Host` dropdown populated from the other configured SSH hosts. + - Not yet done: tab system + up-to-4 split panes and terminal theme/font customization in `src/pages/Terminal.tsx` — that page is still single-session only. - ⬜ Phase 1c — not started. ### Phase 2 — SSH Tunnels (NOT STARTED) diff --git a/backend/src/db/index.ts b/backend/src/db/index.ts index da75948..8dee5ac 100644 --- a/backend/src/db/index.ts +++ b/backend/src/db/index.ts @@ -65,6 +65,12 @@ db.exec(` source TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); + + CREATE TABLE IF NOT EXISTS ssh_host_keys ( + integration_id INTEGER PRIMARY KEY REFERENCES integrations(id) ON DELETE CASCADE, + fingerprint TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); `) export function logEvent(type: string, title: string, source?: string | null) { diff --git a/backend/src/routes/terminal.ts b/backend/src/routes/terminal.ts index 7e3fce1..2a9f34f 100644 --- a/backend/src/routes/terminal.ts +++ b/backend/src/routes/terminal.ts @@ -1,6 +1,5 @@ import type { FastifyInstance } from 'fastify' -import { Client } from 'ssh2' -import type { ClientChannel } from 'ssh2' +import { Client, type ClientChannel, type ConnectConfig } from 'ssh2' import { db } from '../db/index.js' import { loadSecrets } from '../db/secrets.js' @@ -22,16 +21,56 @@ function send(socket: { send: (data: string) => void }, payload: Record + const secrets = loadSecrets(row.id) + return { id: row.id, config, secrets } +} + +function makeHostVerifier(integrationId: number): ConnectConfig['hostVerifier'] { + return (keyHash: string): boolean => { + const row = db + .prepare('SELECT fingerprint FROM ssh_host_keys WHERE integration_id = ?') + .get(integrationId) as { fingerprint: string } | undefined + if (!row) { + db.prepare('INSERT INTO ssh_host_keys (integration_id, fingerprint) VALUES (?, ?)').run(integrationId, keyHash) + return true + } + return row.fingerprint === keyHash + } +} + +function baseConnectConfig(host: ReturnType extends infer T ? NonNullable : never): ConnectConfig { + return { + host: host.config.host, + port: Number(host.config.port) || 22, + username: host.config.username, + password: host.secrets.password || undefined, + privateKey: host.secrets.privateKey || undefined, + passphrase: host.secrets.passphrase || undefined, + readyTimeout: 8000, + hostHash: 'sha256', + hostVerifier: makeHostVerifier(host.id), + } +} + export async function terminalRoutes(app: FastifyInstance) { app.get('/api/terminal', { websocket: true }, (socket, req) => { let conn: Client | null = null + let jumpConn: Client | null = null let stream: ClientChannel | null = null const cleanup = () => { stream?.end() conn?.end() + jumpConn?.end() stream = null conn = null + jumpConn = null } socket.on('close', cleanup) @@ -55,19 +94,15 @@ export async function terminalRoutes(app: FastifyInstance) { return } - const row = db - .prepare('SELECT id, type, config_json FROM integrations WHERE id = ?') - .get(msg.integrationId) as IntegrationRow | undefined - if (!row || row.type !== 'ssh') { + const target = msg.integrationId !== undefined ? loadSshHost(msg.integrationId) : null + if (!target) { send(socket, { type: 'error', message: 'SSH integration not found' }) return } - const config = JSON.parse(row.config_json) as Record - const secrets = loadSecrets(row.id) - conn = new Client() - conn.on('ready', () => { - conn!.shell({ cols: msg.cols ?? 80, rows: msg.rows ?? 24, term: 'xterm-256color' }, (err, ch) => { + const startShell = (client: Client) => { + conn = client + client.shell({ cols: msg.cols ?? 80, rows: msg.rows ?? 24, term: 'xterm-256color' }, (err, ch) => { if (err) { send(socket, { type: 'error', message: err.message }) return @@ -81,19 +116,42 @@ export async function terminalRoutes(app: FastifyInstance) { cleanup() }) }) - }) - conn.on('error', (err) => { + } + + const jumpHostId = target.config.jumpHostIntegrationId ? Number(target.config.jumpHostIntegrationId) : null + + if (jumpHostId) { + const jumpHost = loadSshHost(jumpHostId) + if (!jumpHost) { + send(socket, { type: 'error', message: 'Jump host integration not found' }) + return + } + jumpConn = new Client() + jumpConn.on('ready', () => { + jumpConn!.forwardOut('127.0.0.1', 0, target.config.host, Number(target.config.port) || 22, (err, sock) => { + if (err) { + send(socket, { type: 'error', message: `Jump host forward failed: ${err.message}` }) + return + } + const client = new Client() + client.on('ready', () => startShell(client)) + client.on('error', (err2) => send(socket, { type: 'error', message: err2.message })) + client.connect({ ...baseConnectConfig(target), sock }) + }) + }) + jumpConn.on('error', (err) => { + send(socket, { type: 'error', message: `Jump host error: ${err.message}` }) + }) + jumpConn.connect(baseConnectConfig(jumpHost)) + return + } + + const client = new Client() + client.on('ready', () => startShell(client)) + client.on('error', (err) => { send(socket, { type: 'error', message: err.message }) }) - conn.connect({ - host: config.host, - port: Number(config.port) || 22, - username: config.username, - password: secrets.password || undefined, - privateKey: secrets.privateKey || undefined, - passphrase: secrets.passphrase || undefined, - readyTimeout: 8000, - }) + client.connect(baseConnectConfig(target)) return } diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 30e6d23..f1c5ab7 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -46,18 +46,15 @@ const integrationTypeDefs: { type: string; name: string; fields: FieldDef[] }[] { type: 'aws', name: 'AWS', fields: [{ key: 'accessKey', label: 'Access Key' }, { key: 'secretKey', label: 'Secret', secret: true }, { key: 'region', label: 'Region' }] }, { type: 'uptime_kuma', name: 'Uptime Kuma', fields: [{ key: 'baseUrl', label: 'URL' }, { key: 'apiKey', label: 'API Key', secret: true }] }, { type: 'weather', name: 'Weather API', fields: [{ key: 'location', label: 'Location' }, { key: 'units', label: 'Units' }] }, - { - type: 'ssh', - name: 'SSH Host', - fields: [ - { key: 'host', label: 'Host / IP' }, - { key: 'port', label: 'Port (default 22)' }, - { key: 'username', label: 'Username' }, - { key: 'password', label: 'Password', secret: true }, - { key: 'privateKey', label: 'Private Key (PEM)', secret: true }, - { key: 'passphrase', label: 'Key Passphrase', secret: true }, - ], - }, +] + +const sshFields: FieldDef[] = [ + { key: 'host', label: 'Host / IP' }, + { key: 'port', label: 'Port (default 22)' }, + { key: 'username', label: 'Username' }, + { key: 'password', label: 'Password', secret: true }, + { key: 'privateKey', label: 'Private Key (PEM)', secret: true }, + { key: 'passphrase', label: 'Key Passphrase', secret: true }, ] const cardBase: React.CSSProperties = { @@ -349,6 +346,290 @@ function AppearanceSection() { ) } +function SshHostsSection() { + const [hosts, setHosts] = useState(null) + const [revealed, setRevealed] = useState>(new Set()) + const [drafts, setDrafts] = useState>>({}) + const [statusMsg, setStatusMsg] = useState>({}) + const [busy, setBusy] = useState>(new Set()) + const [newDrafts, setNewDrafts] = useState<{ key: number; values: Record }[]>([]) + const nextNewKey = useRef(-1) + + useEffect(() => { + refresh() + }, []) + + function refresh() { + api.listIntegrations().then(({ integrations }) => setHosts(integrations.filter((i) => i.type === 'ssh'))) + } + + function toggleReveal(key: string) { + setRevealed((prev) => { + const next = new Set(prev) + if (next.has(key)) next.delete(key) + else next.add(key) + return next + }) + } + + function setBusyFlag(id: number, value: boolean) { + setBusy((prev) => { + const next = new Set(prev) + if (value) next.add(id) + else next.delete(id) + return next + }) + } + + function addNewHost() { + const key = nextNewKey.current-- + setNewDrafts((prev) => [...prev, { key, values: {} }]) + } + + function setNewDraftField(key: number, fieldKey: string, value: string) { + setNewDrafts((prev) => prev.map((d) => (d.key === key ? { ...d, values: { ...d.values, [fieldKey]: value } } : d))) + } + + function removeNewDraft(key: number) { + setNewDrafts((prev) => prev.filter((d) => d.key !== key)) + } + + function setDraftField(id: number, fieldKey: string, value: string) { + setDrafts((prev) => ({ ...prev, [id]: { ...prev[id], [fieldKey]: value } })) + } + + const fieldsWithJumpHost = (excludeId?: number): FieldDef[] => [ + ...sshFields, + { key: 'jumpHostIntegrationId', label: 'Jump Host (optional)' }, + ] + + function buildPayload(fields: FieldDef[], values: Record) { + const config: Record = {} + const secrets: Record = {} + for (const f of fields) { + const value = values[f.key] + if (value === undefined) continue + if (f.secret) secrets[f.key] = value + else config[f.key] = value + } + return { config, secrets } + } + + async function handleSaveExisting(host: Integration) { + setBusyFlag(host.id, true) + setStatusMsg((prev) => ({ ...prev, [host.id]: '' })) + try { + const draft = drafts[host.id] ?? {} + const { config, secrets } = buildPayload(fieldsWithJumpHost(host.id), draft) + const { integration } = await api.updateIntegration(host.id, { config, secrets }) + setHosts((prev) => (prev ?? []).map((h) => (h.id === integration.id ? integration : h))) + setStatusMsg((prev) => ({ ...prev, [host.id]: 'Saved' })) + } catch (err) { + setStatusMsg((prev) => ({ ...prev, [host.id]: err instanceof ApiError ? err.message : 'Save failed' })) + } finally { + setBusyFlag(host.id, false) + } + } + + async function handleSaveNew(key: number, values: Record) { + setBusyFlag(key, true) + try { + const { config, secrets } = buildPayload(fieldsWithJumpHost(), values) + const name = values.host ? `SSH: ${values.host}` : 'SSH Host' + await api.createIntegration({ type: 'ssh', name, config, secrets }) + removeNewDraft(key) + refresh() + } catch (err) { + setStatusMsg((prev) => ({ ...prev, [key]: err instanceof ApiError ? err.message : 'Save failed' })) + } finally { + setBusyFlag(key, false) + } + } + + async function handleTest(host: Integration) { + setBusyFlag(host.id, true) + try { + const result = await api.testIntegration(host.id) + setStatusMsg((prev) => ({ ...prev, [host.id]: result.message })) + refresh() + } catch (err) { + setStatusMsg((prev) => ({ ...prev, [host.id]: err instanceof ApiError ? err.message : 'Test failed' })) + } finally { + setBusyFlag(host.id, false) + } + } + + async function handleDelete(host: Integration) { + setBusyFlag(host.id, true) + try { + await api.deleteIntegration(host.id) + refresh() + } catch (err) { + setStatusMsg((prev) => ({ ...prev, [host.id]: err instanceof ApiError ? err.message : 'Delete failed' })) + setBusyFlag(host.id, false) + } + } + + function renderFields( + fields: FieldDef[], + values: Record, + onChange: (fieldKey: string, value: string) => void, + idForReveal: number, + existing: Integration | undefined, + excludeHostId?: number, + ) { + return fields.map((f) => { + const key = `${idForReveal}-${f.key}` + if (f.key === 'jumpHostIntegrationId') { + const options = (hosts ?? []).filter((h) => h.id !== excludeHostId) + const savedValue = existing?.config[f.key] ?? '' + const value = values[f.key] ?? savedValue + return ( +
+ + +
+ ) + } + const isRevealed = revealed.has(key) + const savedValue = f.secret ? '' : existing?.config[f.key] ?? '' + const value = values[f.key] ?? savedValue + return ( +
+ +
+ onChange(f.key, e.target.value)} + placeholder={f.secret && existing ? '••••••••••••' : 'Not configured'} + /> + {f.secret && ( + + )} +
+
+ ) + }) + } + + if (!hosts) { + return ( +
+

Loading SSH hosts…

+
+ ) + } + + return ( +
+ {hosts.map((host) => { + const online = host.status === 'connected' + const draft = drafts[host.id] ?? {} + return ( +
+
+
+ + {host.name} +
+
+ {statusMsg[host.id] && {statusMsg[host.id]}} + + + +
+
+
+ {renderFields(fieldsWithJumpHost(host.id), draft, (k, v) => setDraftField(host.id, k, v), host.id, host, host.id)} +
+
+ ) + })} + + {newDrafts.map((d) => ( +
+
+ New SSH Host +
+ {statusMsg[d.key] && {statusMsg[d.key]}} + + +
+
+
+ {renderFields(fieldsWithJumpHost(), d.values, (k, v) => setNewDraftField(d.key, k, v), d.key, undefined)} +
+
+ ))} + + +
+ ) +} + function IntegrationsSection() { const [integrations, setIntegrations] = useState(null) const [revealed, setRevealed] = useState>(new Set()) @@ -441,6 +722,13 @@ function IntegrationsSection() { return (
+
+

SSH Hosts

+ +
+
+

Other Integrations

+
{integrationTypeDefs.map((def) => { const existing = integrations.find((i) => i.type === def.type) const online = existing?.status === 'connected' From 94b174c72e62815e1dcd75241715c94efdbc9f85 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 11:12:33 +0000 Subject: [PATCH 07/21] Phase 1b: terminal tabs, up to 4 split panes, theme/font customization Terminal.tsx is rebuilt around a reusable TerminalPane component (one xterm + WebSocket connection each) so a tab can hold 1, 2, or 4 panes (single / split-2 / 2x2 grid), each independently connectable to any SSH host. Added a small terminal preferences bar (theme preset, font size, font family) persisted to localStorage and applied per-pane. Also fixes two build-time issues surfaced while wiring this up: an unused parameter in Settings.tsx's fieldsWithJumpHost helper, and a stale JSX.Element reference that doesn't resolve under this project's tsc -b project-reference build (replaced with React.ReactElement). This completes Phase 1b of the Termix migration (see TERMIX_MIGRATION.md). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BbJV5nm8KPVH1oNJYKpnoF --- TERMIX_MIGRATION.md | 6 +- src/pages/Settings.tsx | 8 +- src/pages/Terminal.tsx | 363 +++++++++++++++++++++++++++++++++++------ 3 files changed, 322 insertions(+), 55 deletions(-) diff --git a/TERMIX_MIGRATION.md b/TERMIX_MIGRATION.md index a64439d..52666de 100644 --- a/TERMIX_MIGRATION.md +++ b/TERMIX_MIGRATION.md @@ -45,11 +45,13 @@ Rationale for splitting: 1a alone is a real, useful terminal (matches what `/ter **Status:** - ✅ **Phase 1a — done.** `/terminal` is a real interactive SSH terminal: `backend/src/routes/terminal.ts` (WebSocket, connect/input/resize/disconnect over `ssh2`), `backend/src/db/secrets.ts` (shared secret loader), `src/pages/Terminal.tsx` (xterm.js + host picker, reuses ArchNest's existing SSH integrations — no duplicate host table). Verified end-to-end against a real test SSH server. No jump hosts, no tabs/split panes, no OPKSSH, no tmux monitor yet — see 1b/1c below. -- 🟡 **Phase 1b — in progress.** Done so far: +- ✅ **Phase 1b — done.** - **Jump-host chaining**: an SSH integration's config can carry `jumpHostIntegrationId` referencing another SSH integration. `backend/src/routes/terminal.ts` connects to the jump host first, opens a `forwardOut()` channel to the real target, and connects the target `Client` over that channel (single-hop; mirrors Termix's core mechanism without its multi-hop/credential-sharing complexity). Verified end-to-end with two real test SSH servers (one as jump, one as target). - **Host-key verification (TOFU)**: new `ssh_host_keys` table (`backend/src/db/index.ts`) stores a SHA-256 fingerprint per SSH integration on first successful connect; subsequent connects are rejected if the fingerprint changes, via `ssh2`'s `hostVerifier` connect option. No interactive accept/reject-changed-key UI yet — first-use accept-and-store, hard-reject on mismatch. Verified both the accept-on-first-use and reject-on-mismatch paths against a real test server. - **Settings UI for multiple SSH hosts**: `src/pages/Settings.tsx` previously could only show/edit one integration per type, which silently broke multi-host SSH. Added a dedicated `SshHostsSection` with its own per-host cards (Save/Test/Delete) and an "Add SSH Host" flow, including a `Jump Host` dropdown populated from the other configured SSH hosts. - - Not yet done: tab system + up-to-4 split panes and terminal theme/font customization in `src/pages/Terminal.tsx` — that page is still single-session only. + - **Tabs + up to 4 split panes**: `src/pages/Terminal.tsx` rewritten around a `TerminalPane` component (one xterm + WebSocket connection each, reusable). Each tab holds 1/2/4 panes (single / split-2 / 2x2 grid); each pane connects independently to whichever SSH host is clicked while it's focused. + - **Terminal theme/font customization**: a preferences bar (theme preset, font size, font family) persisted to `localStorage` (`archnest-terminal-prefs`), applied per-pane on connect. + - Verified via a clean production build (`tsc -b && vite build`) — no real browser available in this environment to click through tabs/panes, so this is build/type verification only, not an interactive UI test. - ⬜ Phase 1c — not started. ### Phase 2 — SSH Tunnels (NOT STARTED) diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index f1c5ab7..c6ac2c4 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -398,7 +398,7 @@ function SshHostsSection() { setDrafts((prev) => ({ ...prev, [id]: { ...prev[id], [fieldKey]: value } })) } - const fieldsWithJumpHost = (excludeId?: number): FieldDef[] => [ + const fieldsWithJumpHost = (): FieldDef[] => [ ...sshFields, { key: 'jumpHostIntegrationId', label: 'Jump Host (optional)' }, ] @@ -420,7 +420,7 @@ function SshHostsSection() { setStatusMsg((prev) => ({ ...prev, [host.id]: '' })) try { const draft = drafts[host.id] ?? {} - const { config, secrets } = buildPayload(fieldsWithJumpHost(host.id), draft) + const { config, secrets } = buildPayload(fieldsWithJumpHost(), draft) const { integration } = await api.updateIntegration(host.id, { config, secrets }) setHosts((prev) => (prev ?? []).map((h) => (h.id === integration.id ? integration : h))) setStatusMsg((prev) => ({ ...prev, [host.id]: 'Saved' })) @@ -584,7 +584,7 @@ function SshHostsSection() {
- {renderFields(fieldsWithJumpHost(host.id), draft, (k, v) => setDraftField(host.id, k, v), host.id, host, host.id)} + {renderFields(fieldsWithJumpHost(), draft, (k, v) => setDraftField(host.id, k, v), host.id, host, host.id)}
) @@ -925,7 +925,7 @@ function AboutSection() { ) } -const sectionComponents: Record JSX.Element> = { +const sectionComponents: Record React.ReactElement> = { profile: ProfileSection, appearance: AppearanceSection, integrations: IntegrationsSection, diff --git a/src/pages/Terminal.tsx b/src/pages/Terminal.tsx index 386a4c0..2a5a348 100644 --- a/src/pages/Terminal.tsx +++ b/src/pages/Terminal.tsx @@ -2,19 +2,84 @@ import { useEffect, useRef, useState } from 'react' import { Terminal as XTerm } from '@xterm/xterm' import { FitAddon } from '@xterm/addon-fit' import '@xterm/xterm/css/xterm.css' +import { Settings2, Plus, X, Columns2, Grid2x2, SquareSlash } from 'lucide-react' import { api, getToken, type Integration } from '../lib/api' const GOLD = '#C8A434' const TEXT_SECONDARY = '#7A7D85' +interface TermTheme { + name: string + background: string + foreground: string + cursor: string +} + +const TERM_THEMES: TermTheme[] = [ + { name: 'ArchNest Dark', background: '#15161A', foreground: '#E8E6E0', cursor: GOLD }, + { name: 'Matrix', background: '#000000', foreground: '#33FF66', cursor: '#33FF66' }, + { name: 'Solarized', background: '#002B36', foreground: '#93A1A1', cursor: '#CB4B16' }, + { name: 'Midnight Blue', background: '#0B1021', foreground: '#C7D3F2', cursor: '#5FA8FF' }, +] + +const FONT_SIZES = [11, 12, 13, 14, 15, 16] +const FONT_FAMILIES = [ + { name: 'Monospace', value: 'ui-monospace, SFMono-Regular, Menlo, monospace' }, + { name: 'Fira Code', value: '"Fira Code", ui-monospace, monospace' }, + { name: 'JetBrains Mono', value: '"JetBrains Mono", ui-monospace, monospace' }, +] + +interface TerminalPrefs { + themeName: string + fontSize: number + fontFamily: string +} + +const PREFS_KEY = 'archnest-terminal-prefs' + +function loadPrefs(): TerminalPrefs { + try { + const raw = localStorage.getItem(PREFS_KEY) + if (raw) return { ...defaultPrefs(), ...JSON.parse(raw) } + } catch { + /* ignore malformed local storage */ + } + return defaultPrefs() +} + +function defaultPrefs(): TerminalPrefs { + return { themeName: TERM_THEMES[0].name, fontSize: 13, fontFamily: FONT_FAMILIES[0].value } +} + +function savePrefs(prefs: TerminalPrefs) { + localStorage.setItem(PREFS_KEY, JSON.stringify(prefs)) +} + +interface PaneState { + id: number + hostId: number | null +} + +interface TabState { + id: number + name: string + panes: PaneState[] +} + +let nextId = 1 +const genId = () => nextId++ + +function newTab(): TabState { + return { id: genId(), name: 'Terminal', panes: [{ id: genId(), hostId: null }] } +} + export default function Terminal() { const [hosts, setHosts] = useState([]) - const [activeHostId, setActiveHostId] = useState(null) - const [connected, setConnected] = useState(false) - const containerRef = useRef(null) - const termRef = useRef(null) - const fitRef = useRef(null) - const wsRef = useRef(null) + const [tabs, setTabs] = useState(() => [newTab()]) + const [activeTabId, setActiveTabId] = useState(() => tabs[0].id) + const [activePaneId, setActivePaneId] = useState(null) + const [prefs, setPrefs] = useState(loadPrefs) + const [showPrefs, setShowPrefs] = useState(false) useEffect(() => { api.listIntegrations().then(({ integrations }) => { @@ -22,13 +87,224 @@ export default function Terminal() { }) }, []) + useEffect(() => savePrefs(prefs), [prefs]) + + const activeTab = tabs.find((t) => t.id === activeTabId) ?? tabs[0] + + function addTab() { + const tab = newTab() + setTabs((prev) => [...prev, tab]) + setActiveTabId(tab.id) + } + + function closeTab(id: number) { + setTabs((prev) => { + const next = prev.filter((t) => t.id !== id) + if (next.length === 0) { + const t = newTab() + setActiveTabId(t.id) + return [t] + } + if (id === activeTabId) setActiveTabId(next[0].id) + return next + }) + } + + function updateActiveTab(fn: (tab: TabState) => TabState) { + setTabs((prev) => prev.map((t) => (t.id === activeTabId ? fn(t) : t))) + } + + function setPaneCount(count: number) { + updateActiveTab((tab) => { + const panes = [...tab.panes] + while (panes.length < count) panes.push({ id: genId(), hostId: null }) + while (panes.length > count) panes.pop() + return { ...tab, panes } + }) + } + + function setPaneHost(paneId: number, hostId: number) { + updateActiveTab((tab) => ({ + ...tab, + panes: tab.panes.map((p) => (p.id === paneId ? { ...p, hostId } : p)), + })) + setActivePaneId(paneId) + } + + const paneGridClass = + activeTab.panes.length === 1 + ? 'grid-cols-1 grid-rows-1' + : activeTab.panes.length === 2 + ? 'grid-cols-2 grid-rows-1' + : 'grid-cols-2 grid-rows-2' + + return ( +
+
+

+ SSH Hosts +

+ {hosts.length === 0 && ( +

+ No SSH integrations configured. Add one in Settings → Integrations. +

+ )} +
+ {hosts.map((h) => ( + + ))} +
+

+ Click a pane, then a host to connect it. +

+
+ +
+
+
+ {tabs.map((tab) => ( +
setActiveTabId(tab.id)} + className="flex shrink-0 cursor-pointer items-center gap-2 rounded-md px-3 py-1.5 text-xs" + style={{ + background: tab.id === activeTabId ? 'rgba(200,164,52,0.15)' : 'rgba(255,255,255,0.04)', + color: tab.id === activeTabId ? GOLD : '#E8E6E0', + }} + > + {tab.name} + { + e.stopPropagation() + closeTab(tab.id) + }} + /> +
+ ))} + +
+ +
+ + + + +
+
+ + {showPrefs && ( +
+ + + +
+ )} + +
+ {activeTab.panes.map((pane) => ( + setActivePaneId(pane.id)} + /> + ))} +
+
+
+ ) +} + +function TerminalPane({ + hostId, + hosts, + prefs, + active, + onFocus, +}: { + hostId: number | null + hosts: Integration[] + prefs: TerminalPrefs + active: boolean + onFocus: () => void +}) { + const [connected, setConnected] = useState(false) + const containerRef = useRef(null) + const termRef = useRef(null) + const fitRef = useRef(null) + const wsRef = useRef(null) + const lastHostIdRef = useRef(null) + useEffect(() => { if (!containerRef.current) return + const theme = TERM_THEMES.find((t) => t.name === prefs.themeName) ?? TERM_THEMES[0] const term = new XTerm({ cursorBlink: true, - fontSize: 13, - fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', - theme: { background: '#15161A', foreground: '#E8E6E0', cursor: GOLD }, + fontSize: prefs.fontSize, + fontFamily: prefs.fontFamily, + theme: { background: theme.background, foreground: theme.foreground, cursor: theme.cursor }, }) const fit = new FitAddon() term.loadAddon(fit) @@ -44,11 +320,22 @@ export default function Terminal() { term.dispose() wsRef.current?.close() } - }, []) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [prefs.themeName, prefs.fontSize, prefs.fontFamily]) - function connect(hostId: number) { + useEffect(() => { + fitRef.current?.fit() + }) + + useEffect(() => { + if (hostId === null || hostId === lastHostIdRef.current) return + lastHostIdRef.current = hostId + connect(hostId) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [hostId]) + + function connect(id: number) { wsRef.current?.close() - setActiveHostId(hostId) setConnected(false) const term = termRef.current if (!term) return @@ -61,7 +348,7 @@ export default function Terminal() { wsRef.current = ws ws.onopen = () => { - ws.send(JSON.stringify({ type: 'connect', integrationId: hostId, cols: term.cols, rows: term.rows })) + ws.send(JSON.stringify({ type: 'connect', integrationId: id, cols: term.cols, rows: term.rows })) } ws.onmessage = (event) => { const msg = JSON.parse(event.data) @@ -88,44 +375,22 @@ export default function Terminal() { }) } - return ( -
-
-

- SSH Hosts -

- {hosts.length === 0 && ( -

- No SSH integrations configured. Add one in Settings → Integrations. -

- )} -
- {hosts.map((h) => ( - - ))} -
-
+ const host = hosts.find((h) => h.id === hostId) -
-
- - {activeHostId ? (connected ? 'Connected' : 'Disconnected') : 'Select a host to connect'} -
-
+ return ( +
+
+ + {host ? (connected ? `Connected — ${host.name}` : `Disconnected — ${host.name}`) : 'Select a host to connect'}
+
) } From 27abbc8ce1626e1dec9fd12ed0c10d611cd60001 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 11:28:51 +0000 Subject: [PATCH 08/21] Phase 1c: OPKSSH cert auth, tmux session monitor/reattach, session logging - terminal.ts: connectWithCertificate() shells out to system ssh via node-pty for OpenSSH certificate auth (ssh2 has no native support); list_tmux WS message + tmuxSession connect param for tmux attach/create with shell-injection-safe name validation; sessionLogging config field appends terminal output to disk. - Settings.tsx: certificate secret field and sessionLogging checkbox for SSH host integrations. - Terminal.tsx: tmux session picker in each pane's header. - Verified end-to-end against a real test SSH server running real bash/tmux processes (plain shell, tmux create+list, session log written to disk). Cert auth path type-checks but is unverified in this sandbox (no ssh CLI available) - documented as a gap in TERMIX_MIGRATION.md. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BbJV5nm8KPVH1oNJYKpnoF --- TERMIX_MIGRATION.md | 6 +- backend/package-lock.json | 17 +++ backend/package.json | 1 + backend/src/routes/terminal.ts | 233 +++++++++++++++++++++++++++------ src/pages/Settings.tsx | 14 ++ src/pages/Terminal.tsx | 46 ++++++- 6 files changed, 272 insertions(+), 45 deletions(-) diff --git a/TERMIX_MIGRATION.md b/TERMIX_MIGRATION.md index 52666de..c5b8e07 100644 --- a/TERMIX_MIGRATION.md +++ b/TERMIX_MIGRATION.md @@ -52,7 +52,11 @@ Rationale for splitting: 1a alone is a real, useful terminal (matches what `/ter - **Tabs + up to 4 split panes**: `src/pages/Terminal.tsx` rewritten around a `TerminalPane` component (one xterm + WebSocket connection each, reusable). Each tab holds 1/2/4 panes (single / split-2 / 2x2 grid); each pane connects independently to whichever SSH host is clicked while it's focused. - **Terminal theme/font customization**: a preferences bar (theme preset, font size, font family) persisted to `localStorage` (`archnest-terminal-prefs`), applied per-pane on connect. - Verified via a clean production build (`tsc -b && vite build`) — no real browser available in this environment to click through tabs/panes, so this is build/type verification only, not an interactive UI test. -- ⬜ Phase 1c — not started. +- ✅ **Phase 1c — done, with one documented verification gap.** + - **OPKSSH / certificate auth**: `ssh2` (the npm library) has no support for OpenSSH certificates — confirmed by inspecting its type definitions and README, no certificate-related auth flow exists. Implemented `connectWithCertificate()` in `backend/src/routes/terminal.ts`: writes the stored private key + certificate to a temp dir (mode `0600`) and shells out to the system `ssh` binary (which natively understands `-o CertificateFile=`) under a real `node-pty` pty. Used automatically when an SSH integration has a `certificate` secret configured (new field added to Settings' SSH host form). Does **not** support jump-host chaining (documented limitation, not silently dropped — Termix's own OPKSSH path doesn't generally chain through jump hosts either). **Verification gap**: this sandbox has no `ssh` CLI installed (`apt-get install openssh-client` failed — mirror 404), so this path type-checks and is logically sound but has not been exercised end-to-end. Needs a real test against a cert-auth-enabled host before being considered fully verified; `openssh-client` is near-universal on real deployment targets, so this is a sandbox limitation, not an expected production gap. + - **tmux session monitor/reattach**: new WebSocket message `list_tmux` execs `tmux list-sessions` on the target host and returns session names; `connect` accepts an optional `tmuxSession` (validated against `^[A-Za-z0-9_-]{1,64}$` before being interpolated into a shell command, to prevent injection) which attaches to that tmux session or creates it if missing, via `exec('tmux attach -t || tmux new-session -s ', { pty: ... })` instead of a plain `client.shell()`. `src/pages/Terminal.tsx`'s pane header gained a tmux session picker (plain shell / new session / attach to an existing one). **Verified end-to-end** against a real test SSH server running real `bash`/`tmux` processes (via `node-pty`): listed zero sessions, created a `testsess` tmux session through the WS protocol, confirmed a follow-up `list_tmux` call returned `['testsess']`. + - **Session recording/logging to disk**: new SSH integration config field `sessionLogging` (checkbox in Settings' SSH host form). When set, all outbound terminal output (both the `ssh2` path and the cert-auth pty path) is appended to `/_.log`. No log browsing/download UI yet (not built — out of scope for this pass, not silently dropped). **Verified end-to-end**: a real shell session's output was confirmed present in its log file on disk. + - No real `ssh` CLI / no real OPKSSH certificate available in this sandbox to test against, see verification gap above. Everything else in this phase was tested against live processes, not mocked. ### Phase 2 — SSH Tunnels (NOT STARTED) diff --git a/backend/package-lock.json b/backend/package-lock.json index fadea30..828ad90 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -18,6 +18,7 @@ "better-sqlite3": "^11.8.1", "dotenv": "^16.6.1", "fastify": "^5.2.1", + "node-pty": "^1.1.0", "ssh2": "^1.17.0", "undici": "^8.5.0", "zod": "^3.24.1" @@ -2038,6 +2039,22 @@ "node": ">=10" } }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, + "node_modules/node-pty": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz", + "integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^7.1.0" + } + }, "node_modules/obliterator": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz", diff --git a/backend/package.json b/backend/package.json index 3328c22..b3b2a0d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -19,6 +19,7 @@ "better-sqlite3": "^11.8.1", "dotenv": "^16.6.1", "fastify": "^5.2.1", + "node-pty": "^1.1.0", "ssh2": "^1.17.0", "undici": "^8.5.0", "zod": "^3.24.1" diff --git a/backend/src/routes/terminal.ts b/backend/src/routes/terminal.ts index 2a9f34f..2b17d49 100644 --- a/backend/src/routes/terminal.ts +++ b/backend/src/routes/terminal.ts @@ -1,5 +1,9 @@ import type { FastifyInstance } from 'fastify' import { Client, type ClientChannel, type ConnectConfig } from 'ssh2' +import { spawn as spawnPty, type IPty } from 'node-pty' +import { mkdtempSync, rmSync, writeFileSync, mkdirSync, appendFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { db } from '../db/index.js' import { loadSecrets } from '../db/secrets.js' @@ -10,13 +14,18 @@ interface IntegrationRow { } interface ClientMessage { - type: 'connect' | 'input' | 'resize' | 'disconnect' + type: 'connect' | 'input' | 'resize' | 'disconnect' | 'list_tmux' integrationId?: number cols?: number rows?: number data?: string + tmuxSession?: string } +const TMUX_NAME_RE = /^[A-Za-z0-9_-]{1,64}$/ + +const SESSION_LOG_DIR = process.env.ARCHNEST_SESSION_LOG_DIR ?? './data/session-logs' + function send(socket: { send: (data: string) => void }, payload: Record) { socket.send(JSON.stringify(payload)) } @@ -31,6 +40,8 @@ function loadSshHost(integrationId: number) { return { id: row.id, config, secrets } } +type SshHost = NonNullable> + function makeHostVerifier(integrationId: number): ConnectConfig['hostVerifier'] { return (keyHash: string): boolean => { const row = db @@ -44,7 +55,7 @@ function makeHostVerifier(integrationId: number): ConnectConfig['hostVerifier'] } } -function baseConnectConfig(host: ReturnType extends infer T ? NonNullable : never): ConnectConfig { +function baseConnectConfig(host: SshHost): ConnectConfig { return { host: host.config.host, port: Number(host.config.port) || 22, @@ -58,19 +69,119 @@ function baseConnectConfig(host: ReturnType extends infer T } } +/** Connects to `target`, transparently chaining through its jump host (if configured) via forwardOut(). */ +function connectTarget( + target: SshHost, + onReady: (client: Client) => void, + onError: (message: string) => void, +): { conn: Client; jumpConn: Client | null } { + const jumpHostId = target.config.jumpHostIntegrationId ? Number(target.config.jumpHostIntegrationId) : null + + if (jumpHostId) { + const jumpHost = loadSshHost(jumpHostId) + if (!jumpHost) { + onError('Jump host integration not found') + return { conn: new Client(), jumpConn: null } + } + const jumpConn = new Client() + jumpConn.on('ready', () => { + jumpConn.forwardOut('127.0.0.1', 0, target.config.host, Number(target.config.port) || 22, (err, sock) => { + if (err) { + onError(`Jump host forward failed: ${err.message}`) + return + } + client.connect({ ...baseConnectConfig(target), sock }) + }) + }) + jumpConn.on('error', (err) => onError(`Jump host error: ${err.message}`)) + const client = new Client() + client.on('ready', () => onReady(client)) + client.on('error', (err) => onError(err.message)) + jumpConn.connect(baseConnectConfig(jumpHost)) + return { conn: client, jumpConn } + } + + const client = new Client() + client.on('ready', () => onReady(client)) + client.on('error', (err) => onError(err.message)) + client.connect(baseConnectConfig(target)) + return { conn: client, jumpConn: null } +} + +/** OPKSSH / certificate auth has no support in the ssh2 library, so this path shells out to the + * system `ssh` binary (which natively understands OpenSSH certificates via CertificateFile) under + * a real pty. Jump-host chaining is not supported on this path. */ +function connectWithCertificate( + target: SshHost, + cols: number, + rows: number, + onReady: (pty: IPty, keyDir: string) => void, + onError: (message: string) => void, +) { + const keyDir = mkdtempSync(join(tmpdir(), 'archnest-ssh-')) + const keyFile = join(keyDir, 'id_key') + const certFile = join(keyDir, 'id_key-cert.pub') + try { + writeFileSync(keyFile, target.secrets.privateKey ?? '', { mode: 0o600 }) + writeFileSync(certFile, target.secrets.certificate ?? '', { mode: 0o600 }) + } catch (err) { + rmSync(keyDir, { recursive: true, force: true }) + onError(err instanceof Error ? err.message : 'Failed to write certificate files') + return + } + + const args = [ + '-tt', + '-p', String(Number(target.config.port) || 22), + '-i', keyFile, + '-o', `CertificateFile=${certFile}`, + '-o', 'StrictHostKeyChecking=accept-new', + '-o', `UserKnownHostsFile=${join(keyDir, 'known_hosts')}`, + `${target.config.username}@${target.config.host}`, + ] + + let pty: IPty + try { + pty = spawnPty('ssh', args, { name: 'xterm-256color', cols, rows }) + } catch (err) { + rmSync(keyDir, { recursive: true, force: true }) + onError(err instanceof Error ? `Failed to spawn ssh: ${err.message}` : 'Failed to spawn ssh') + return + } + onReady(pty, keyDir) +} + +function sessionLogPath(integrationId: number) { + mkdirSync(SESSION_LOG_DIR, { recursive: true }) + const stamp = new Date().toISOString().replace(/[:.]/g, '-') + return join(SESSION_LOG_DIR, `${integrationId}_${stamp}.log`) +} + export async function terminalRoutes(app: FastifyInstance) { app.get('/api/terminal', { websocket: true }, (socket, req) => { let conn: Client | null = null let jumpConn: Client | null = null let stream: ClientChannel | null = null + let pty: IPty | null = null + let ptyKeyDir: string | null = null + let logPath: string | null = null + + const logData = (data: string) => { + if (logPath) appendFileSync(logPath, data) + } const cleanup = () => { stream?.end() conn?.end() jumpConn?.end() + pty?.kill() + if (ptyKeyDir) rmSync(ptyKeyDir, { recursive: true, force: true }) stream = null conn = null jumpConn = null + pty = null + ptyKeyDir = null + logPath = null } socket.on('close', cleanup) @@ -84,7 +195,7 @@ export async function terminalRoutes(app: FastifyInstance) { return } - if (msg.type === 'connect') { + if (msg.type === 'connect' || msg.type === 'list_tmux') { const query = req.query as { token?: string } try { await app.jwt.verify(query.token ?? '') @@ -100,68 +211,106 @@ export async function terminalRoutes(app: FastifyInstance) { return } - const startShell = (client: Client) => { + if (msg.type === 'list_tmux') { + const { conn: ephemeralConn, jumpConn: ephemeralJump } = connectTarget( + target, + (client) => { + client.exec("command -v tmux >/dev/null && tmux list-sessions -F '#S' 2>/dev/null", (err, ch) => { + if (err) { + send(socket, { type: 'tmux_sessions', sessions: [] }) + client.end() + ephemeralJump?.end() + return + } + let out = '' + ch.on('data', (chunk: Buffer) => (out += chunk.toString('utf8'))) + ch.on('close', () => { + const sessions = out.split('\n').map((s) => s.trim()).filter(Boolean) + send(socket, { type: 'tmux_sessions', sessions }) + client.end() + ephemeralJump?.end() + }) + }) + }, + (message) => send(socket, { type: 'tmux_sessions', sessions: [], error: message }), + ) + void ephemeralConn + return + } + + const cols = msg.cols ?? 80 + const rows = msg.rows ?? 24 + + if (target.secrets.certificate) { + connectWithCertificate( + target, + cols, + rows, + (p, keyDir) => { + pty = p + ptyKeyDir = keyDir + if (target.config.sessionLogging === 'true') logPath = sessionLogPath(target.id) + send(socket, { type: 'connected' }) + p.onData((data) => { + logData(data) + send(socket, { type: 'data', data }) + }) + p.onExit(() => { + send(socket, { type: 'closed' }) + cleanup() + }) + }, + (message) => send(socket, { type: 'error', message }), + ) + return + } + + const startSession = (client: Client) => { conn = client - client.shell({ cols: msg.cols ?? 80, rows: msg.rows ?? 24, term: 'xterm-256color' }, (err, ch) => { + const tmuxSession = msg.tmuxSession && TMUX_NAME_RE.test(msg.tmuxSession) ? msg.tmuxSession : null + const onChannel = (err: Error | undefined, ch: ClientChannel) => { if (err) { send(socket, { type: 'error', message: err.message }) return } stream = ch + if (target.config.sessionLogging === 'true') logPath = sessionLogPath(target.id) send(socket, { type: 'connected' }) - ch.on('data', (chunk: Buffer) => send(socket, { type: 'data', data: chunk.toString('utf8') })) + ch.on('data', (chunk: Buffer) => { + const text = chunk.toString('utf8') + logData(text) + send(socket, { type: 'data', data: text }) + }) ch.stderr.on('data', (chunk: Buffer) => send(socket, { type: 'data', data: chunk.toString('utf8') })) ch.on('close', () => { send(socket, { type: 'closed' }) cleanup() }) - }) - } - - const jumpHostId = target.config.jumpHostIntegrationId ? Number(target.config.jumpHostIntegrationId) : null - - if (jumpHostId) { - const jumpHost = loadSshHost(jumpHostId) - if (!jumpHost) { - send(socket, { type: 'error', message: 'Jump host integration not found' }) - return } - jumpConn = new Client() - jumpConn.on('ready', () => { - jumpConn!.forwardOut('127.0.0.1', 0, target.config.host, Number(target.config.port) || 22, (err, sock) => { - if (err) { - send(socket, { type: 'error', message: `Jump host forward failed: ${err.message}` }) - return - } - const client = new Client() - client.on('ready', () => startShell(client)) - client.on('error', (err2) => send(socket, { type: 'error', message: err2.message })) - client.connect({ ...baseConnectConfig(target), sock }) - }) - }) - jumpConn.on('error', (err) => { - send(socket, { type: 'error', message: `Jump host error: ${err.message}` }) - }) - jumpConn.connect(baseConnectConfig(jumpHost)) - return + if (tmuxSession) { + client.exec(`tmux attach -t ${tmuxSession} || tmux new-session -s ${tmuxSession}`, { + pty: { cols, rows, term: 'xterm-256color' }, + }, onChannel) + } else { + client.shell({ cols, rows, term: 'xterm-256color' }, onChannel) + } } - const client = new Client() - client.on('ready', () => startShell(client)) - client.on('error', (err) => { - send(socket, { type: 'error', message: err.message }) - }) - client.connect(baseConnectConfig(target)) + const result = connectTarget(target, startSession, (message) => send(socket, { type: 'error', message })) + conn = result.conn + jumpConn = result.jumpConn return } if (msg.type === 'input') { - stream?.write(msg.data ?? '') + if (pty) pty.write(msg.data ?? '') + else stream?.write(msg.data ?? '') return } if (msg.type === 'resize') { - stream?.setWindow(msg.rows ?? 24, msg.cols ?? 80, 0, 0) + if (pty) pty.resize(msg.cols ?? 80, msg.rows ?? 24) + else stream?.setWindow(msg.rows ?? 24, msg.cols ?? 80, 0, 0) return } diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index c6ac2c4..936ecc0 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -55,6 +55,7 @@ const sshFields: FieldDef[] = [ { key: 'password', label: 'Password', secret: true }, { key: 'privateKey', label: 'Private Key (PEM)', secret: true }, { key: 'passphrase', label: 'Key Passphrase', secret: true }, + { key: 'certificate', label: 'OPKSSH Certificate (id_key-cert.pub)', secret: true }, ] const cardBase: React.CSSProperties = { @@ -401,6 +402,7 @@ function SshHostsSection() { const fieldsWithJumpHost = (): FieldDef[] => [ ...sshFields, { key: 'jumpHostIntegrationId', label: 'Jump Host (optional)' }, + { key: 'sessionLogging', label: 'Record session to disk' }, ] function buildPayload(fields: FieldDef[], values: Record) { @@ -498,6 +500,18 @@ function SshHostsSection() {
) } + if (f.key === 'sessionLogging') { + const savedValue = existing?.config[f.key] === 'true' + const value = values[f.key] !== undefined ? values[f.key] === 'true' : savedValue + return ( +
+ +
+ ) + } const isRevealed = revealed.has(key) const savedValue = f.secret ? '' : existing?.config[f.key] ?? '' const value = values[f.key] ?? savedValue diff --git a/src/pages/Terminal.tsx b/src/pages/Terminal.tsx index 2a5a348..3166c73 100644 --- a/src/pages/Terminal.tsx +++ b/src/pages/Terminal.tsx @@ -291,6 +291,8 @@ function TerminalPane({ onFocus: () => void }) { const [connected, setConnected] = useState(false) + const [tmuxSessions, setTmuxSessions] = useState([]) + const [selectedTmux, setSelectedTmux] = useState('') const containerRef = useRef(null) const termRef = useRef(null) const fitRef = useRef(null) @@ -330,11 +332,28 @@ function TerminalPane({ useEffect(() => { if (hostId === null || hostId === lastHostIdRef.current) return lastHostIdRef.current = hostId + setSelectedTmux('') + fetchTmuxSessions(hostId) connect(hostId) // eslint-disable-next-line react-hooks/exhaustive-deps }, [hostId]) - function connect(id: number) { + function fetchTmuxSessions(id: number) { + setTmuxSessions([]) + const token = getToken() + const proto = window.location.protocol === 'https:' ? 'wss' : 'ws' + const ws = new WebSocket(`${proto}://${window.location.host}/api/terminal?token=${encodeURIComponent(token ?? '')}`) + ws.onopen = () => ws.send(JSON.stringify({ type: 'list_tmux', integrationId: id })) + ws.onmessage = (event) => { + const msg = JSON.parse(event.data) + if (msg.type === 'tmux_sessions') { + setTmuxSessions(msg.sessions ?? []) + ws.close() + } + } + } + + function connect(id: number, tmuxSession?: string) { wsRef.current?.close() setConnected(false) const term = termRef.current @@ -348,7 +367,7 @@ function TerminalPane({ wsRef.current = ws ws.onopen = () => { - ws.send(JSON.stringify({ type: 'connect', integrationId: id, cols: term.cols, rows: term.rows })) + ws.send(JSON.stringify({ type: 'connect', integrationId: id, cols: term.cols, rows: term.rows, tmuxSession })) } ws.onmessage = (event) => { const msg = JSON.parse(event.data) @@ -389,6 +408,29 @@ function TerminalPane({
{host ? (connected ? `Connected — ${host.name}` : `Disconnected — ${host.name}`) : 'Select a host to connect'} + {host && ( + + )}
From eaa971bb5aa243ed3024b44797b1ee31a922399f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 11:40:59 +0000 Subject: [PATCH 09/21] Phase 2: SSH tunnels (local/remote/dynamic SOCKS5 port forwarding) - backend/src/ssh/connect.ts: extracted shared SSH-connect logic (jump-host chaining, TOFU host-key verification) out of terminal.ts so tunnels can reuse it. - backend/src/tunnels/manager.ts + socks5.ts: in-memory tunnel runtime manager supporting local forward (forwardOut), remote forward (forwardIn), and dynamic SOCKS5 proxying, with automatic reconnect/retry and an auto-start-on-boot option. New `tunnels` table persists configs as the saved presets. - backend/src/routes/tunnels.ts: REST CRUD + connect/disconnect. - src/pages/Tunnels.tsx: new /tunnels page (sidebar entry added) to create, start/stop, and delete tunnels with live status polling. - Verified end-to-end against a real ssh2 test server handling real forwardOut/forwardIn requests and a real upstream TCP echo server - all three tunnel modes moved real data, and disconnect correctly tore down the local listener. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BbJV5nm8KPVH1oNJYKpnoF --- TERMIX_MIGRATION.md | 20 ++- backend/src/db/index.ts | 14 ++ backend/src/routes/terminal.ts | 89 +--------- backend/src/routes/tunnels.ts | 93 +++++++++++ backend/src/server.ts | 5 + backend/src/ssh/connect.ts | 87 ++++++++++ backend/src/tunnels/manager.ts | 218 +++++++++++++++++++++++++ backend/src/tunnels/socks5.ts | 61 +++++++ src/App.tsx | 2 + src/components/Sidebar.tsx | 2 + src/lib/api.ts | 35 ++++ src/pages/Tunnels.tsx | 287 +++++++++++++++++++++++++++++++++ 12 files changed, 824 insertions(+), 89 deletions(-) create mode 100644 backend/src/routes/tunnels.ts create mode 100644 backend/src/ssh/connect.ts create mode 100644 backend/src/tunnels/manager.ts create mode 100644 backend/src/tunnels/socks5.ts create mode 100644 src/pages/Tunnels.tsx diff --git a/TERMIX_MIGRATION.md b/TERMIX_MIGRATION.md index c5b8e07..ff57525 100644 --- a/TERMIX_MIGRATION.md +++ b/TERMIX_MIGRATION.md @@ -58,9 +58,25 @@ Rationale for splitting: 1a alone is a real, useful terminal (matches what `/ter - **Session recording/logging to disk**: new SSH integration config field `sessionLogging` (checkbox in Settings' SSH host form). When set, all outbound terminal output (both the `ssh2` path and the cert-auth pty path) is appended to `/_.log`. No log browsing/download UI yet (not built — out of scope for this pass, not silently dropped). **Verified end-to-end**: a real shell session's output was confirmed present in its log file on disk. - No real `ssh` CLI / no real OPKSSH certificate available in this sandbox to test against, see verification gap above. Everything else in this phase was tested against live processes, not mocked. -### Phase 2 — SSH Tunnels (NOT STARTED) +### Phase 2 — SSH Tunnels (DONE) -Source: `src/backend/ssh/tunnel.ts` (2,414 lines) + `tunnel-c2s-relay.ts`, `tunnel-socks5-relay.ts`, `tunnel-ssh-primitives.ts`, `tunnel-utils.ts`, `tunnel-c2s-relay-utils.ts` (~830 lines combined) + frontend `src/ui/features/tunnel/*`. Local/remote/dynamic SOCKS forwarding, automatic reconnection, health monitoring. Builds on Phase 1's connection pool. Client-to-server tunnel presets (save/rename/load/delete) need a small new table in ArchNest's schema. +Source: `src/backend/ssh/tunnel.ts` (2,414 lines) + `tunnel-c2s-relay.ts`, `tunnel-socks5-relay.ts`, `tunnel-ssh-primitives.ts`, `tunnel-utils.ts`, `tunnel-c2s-relay-utils.ts` (~830 lines combined) + frontend `src/ui/features/tunnel/*`. + +**Scope decision**: Termix distinguishes "S2S" (server-to-server, backend-managed) and "C2S" (client-to-server, routed through Termix's desktop/Electron app) tunnels. ArchNest has no desktop client (explicitly out of scope per the top of this doc), so only the **S2S model** was ported — a single persistent backend process manages all tunnels, same as Termix's S2S path. C2S's WebSocket data-multiplexing-to-a-desktop-client layer was not ported; it has no equivalent need in a pure web app. + +**What was built:** +- `backend/src/ssh/connect.ts` — extracted `loadSshHost`/`baseConnectConfig`/`connectTarget` (jump-host chaining + TOFU host-key verification) out of `terminal.ts` into a shared module, since tunnels need the exact same SSH-connection logic terminal sessions do. +- `backend/src/tunnels/manager.ts` — in-memory tunnel runtime manager (`Map`), mirroring Termix's `activeTunnels`/`connectionStatus` maps but scoped down to this app's needs. Three modes: + - **Local forward**: a `net.Server` listens on `sourcePort`; each inbound connection calls `client.forwardOut()` to `endpointHost:endpointPort` and pipes the two sockets together. + - **Remote forward**: `client.forwardIn('0.0.0.0', sourcePort)` asks the SSH server to bind that port; incoming `'tcp connection'` events are piped to a local `net.connect()` against `endpointHost:endpointPort`. + - **Dynamic (SOCKS5)**: a `net.Server` listens on `sourcePort` running a minimal SOCKS5 handshake (`backend/src/tunnels/socks5.ts`, CONNECT-only, no-auth — sufficient for this use case, not a general SOCKS5 server), then `forwardOut()`s to whatever target the client requested per-connection. + - Automatic reconnection: on SSH error/close or listener bind failure, schedules a retry after `retryIntervalMs`, up to `maxRetries`, then settles into an `error` status (mirrors Termix's retry/backoff but simplified to a fixed interval rather than exponential — sufficient for this scale). + - `startAutoStartTunnels()` is called once at server boot to bring up any tunnel with `autoStart` set. +- `backend/src/routes/tunnels.ts` — REST CRUD (`GET/POST /api/tunnels`, `DELETE /api/tunnels/:id`) plus `POST /api/tunnels/:id/connect` / `/disconnect`. Status (`stopped`/`connecting`/`connected`/`retrying`/`error` + retry count + last error) is read directly off the in-memory runtime state on every `GET /api/tunnels` (simple polling from the frontend every 3s — no SSE/EventSource, unlike Termix; not needed at this scale and keeps the implementation smaller). +- `backend/src/db/index.ts` — new `tunnels` table: `id, name, integration_id, mode, source_port, endpoint_host, endpoint_port, auto_start, max_retries, retry_interval_ms, created_at`. Each tunnel references an existing SSH `integrations` row (no separate host table, consistent with the rest of this migration) — no separate "preset" concept needed since a tunnel row already *is* the saved preset. +- `src/pages/Tunnels.tsx` — new page (`/tunnels`, added to the sidebar with a `Waypoints` icon) with a creation form (name, SSH host picker, mode, source port, endpoint host/port, auto-start) and a card grid showing each tunnel's status, mode, route, and Start/Stop/Delete actions, polling every 3 seconds. + +**Verified end-to-end** against a real test SSH server (extending the same real-`ssh2`-`Server` + `node-pty` pattern used in Phase 1c) that genuinely handles `tcpip` (forwardOut) and `tcpip-forward`/`cancel-tcpip-forward` (forwardIn) requests, plus a real upstream TCP echo server: created one tunnel of each mode (local/remote/dynamic), connected all three, and confirmed real data flowed through each — local forward and remote forward both delivered the upstream server's banner through the tunnel, and the dynamic tunnel completed a real SOCKS5 CONNECT handshake and relayed data. Also verified disconnect correctly tears down the local listener (`ECONNREFUSED` after stopping). All test artifacts (test SSH server, test backend instance, test DB, tokens) were cleaned up afterward. ### Phase 3 — Remote File Manager (NOT STARTED) diff --git a/backend/src/db/index.ts b/backend/src/db/index.ts index 8dee5ac..7ccfa42 100644 --- a/backend/src/db/index.ts +++ b/backend/src/db/index.ts @@ -71,6 +71,20 @@ db.exec(` fingerprint TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); + + CREATE TABLE IF NOT EXISTS tunnels ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + integration_id INTEGER NOT NULL REFERENCES integrations(id) ON DELETE CASCADE, + mode TEXT NOT NULL, + source_port INTEGER NOT NULL, + endpoint_host TEXT NOT NULL DEFAULT '', + endpoint_port INTEGER NOT NULL DEFAULT 0, + auto_start INTEGER NOT NULL DEFAULT 0, + max_retries INTEGER NOT NULL DEFAULT 3, + retry_interval_ms INTEGER NOT NULL DEFAULT 5000, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); `) export function logEvent(type: string, title: string, source?: string | null) { diff --git a/backend/src/routes/terminal.ts b/backend/src/routes/terminal.ts index 2b17d49..6a4c2b2 100644 --- a/backend/src/routes/terminal.ts +++ b/backend/src/routes/terminal.ts @@ -1,17 +1,10 @@ import type { FastifyInstance } from 'fastify' -import { Client, type ClientChannel, type ConnectConfig } from 'ssh2' +import { Client, type ClientChannel } from 'ssh2' import { spawn as spawnPty, type IPty } from 'node-pty' import { mkdtempSync, rmSync, writeFileSync, mkdirSync, appendFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { db } from '../db/index.js' -import { loadSecrets } from '../db/secrets.js' - -interface IntegrationRow { - id: number - type: string - config_json: string -} +import { loadSshHost, connectTarget, type SshHost } from '../ssh/connect.js' interface ClientMessage { type: 'connect' | 'input' | 'resize' | 'disconnect' | 'list_tmux' @@ -30,84 +23,6 @@ function send(socket: { send: (data: string) => void }, payload: Record - const secrets = loadSecrets(row.id) - return { id: row.id, config, secrets } -} - -type SshHost = NonNullable> - -function makeHostVerifier(integrationId: number): ConnectConfig['hostVerifier'] { - return (keyHash: string): boolean => { - const row = db - .prepare('SELECT fingerprint FROM ssh_host_keys WHERE integration_id = ?') - .get(integrationId) as { fingerprint: string } | undefined - if (!row) { - db.prepare('INSERT INTO ssh_host_keys (integration_id, fingerprint) VALUES (?, ?)').run(integrationId, keyHash) - return true - } - return row.fingerprint === keyHash - } -} - -function baseConnectConfig(host: SshHost): ConnectConfig { - return { - host: host.config.host, - port: Number(host.config.port) || 22, - username: host.config.username, - password: host.secrets.password || undefined, - privateKey: host.secrets.privateKey || undefined, - passphrase: host.secrets.passphrase || undefined, - readyTimeout: 8000, - hostHash: 'sha256', - hostVerifier: makeHostVerifier(host.id), - } -} - -/** Connects to `target`, transparently chaining through its jump host (if configured) via forwardOut(). */ -function connectTarget( - target: SshHost, - onReady: (client: Client) => void, - onError: (message: string) => void, -): { conn: Client; jumpConn: Client | null } { - const jumpHostId = target.config.jumpHostIntegrationId ? Number(target.config.jumpHostIntegrationId) : null - - if (jumpHostId) { - const jumpHost = loadSshHost(jumpHostId) - if (!jumpHost) { - onError('Jump host integration not found') - return { conn: new Client(), jumpConn: null } - } - const jumpConn = new Client() - jumpConn.on('ready', () => { - jumpConn.forwardOut('127.0.0.1', 0, target.config.host, Number(target.config.port) || 22, (err, sock) => { - if (err) { - onError(`Jump host forward failed: ${err.message}`) - return - } - client.connect({ ...baseConnectConfig(target), sock }) - }) - }) - jumpConn.on('error', (err) => onError(`Jump host error: ${err.message}`)) - const client = new Client() - client.on('ready', () => onReady(client)) - client.on('error', (err) => onError(err.message)) - jumpConn.connect(baseConnectConfig(jumpHost)) - return { conn: client, jumpConn } - } - - const client = new Client() - client.on('ready', () => onReady(client)) - client.on('error', (err) => onError(err.message)) - client.connect(baseConnectConfig(target)) - return { conn: client, jumpConn: null } -} - /** OPKSSH / certificate auth has no support in the ssh2 library, so this path shells out to the * system `ssh` binary (which natively understands OpenSSH certificates via CertificateFile) under * a real pty. Jump-host chaining is not supported on this path. */ diff --git a/backend/src/routes/tunnels.ts b/backend/src/routes/tunnels.ts new file mode 100644 index 0000000..41ede9b --- /dev/null +++ b/backend/src/routes/tunnels.ts @@ -0,0 +1,93 @@ +import type { FastifyInstance } from 'fastify' +import { z } from 'zod' +import { db, logEvent } from '../db/index.js' +import { + getStatus, + getTunnelRow, + startTunnel, + stopTunnel, + deleteTunnelRuntime, + type TunnelRow, +} from '../tunnels/manager.js' + +const createSchema = z.object({ + name: z.string().min(1).max(128), + integrationId: z.number().int(), + mode: z.enum(['local', 'remote', 'dynamic']), + sourcePort: z.number().int().min(1).max(65535), + endpointHost: z.string().default(''), + endpointPort: z.number().int().min(0).max(65535).default(0), + autoStart: z.boolean().default(false), + maxRetries: z.number().int().min(0).max(20).default(3), + retryIntervalMs: z.number().int().min(500).max(60000).default(5000), +}) + +function serialize(row: TunnelRow) { + return { + id: row.id, + name: row.name, + integrationId: row.integration_id, + mode: row.mode, + sourcePort: row.source_port, + endpointHost: row.endpoint_host, + endpointPort: row.endpoint_port, + autoStart: !!row.auto_start, + maxRetries: row.max_retries, + retryIntervalMs: row.retry_interval_ms, + createdAt: row.created_at, + ...getStatus(row.id), + } +} + +export async function tunnelRoutes(app: FastifyInstance) { + app.addHook('onRequest', app.authenticate) + + app.get('/api/tunnels', async () => { + const rows = db.prepare('SELECT * FROM tunnels ORDER BY created_at').all() as TunnelRow[] + return { tunnels: rows.map(serialize) } + }) + + app.post('/api/tunnels', async (req, reply) => { + const parsed = createSchema.safeParse(req.body) + if (!parsed.success) { + return reply.code(400).send({ error: parsed.error.issues[0]?.message ?? 'Invalid input' }) + } + const d = parsed.data + const result = db + .prepare( + `INSERT INTO tunnels (name, integration_id, mode, source_port, endpoint_host, endpoint_port, auto_start, max_retries, retry_interval_ms) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run(d.name, d.integrationId, d.mode, d.sourcePort, d.endpointHost, d.endpointPort, d.autoStart ? 1 : 0, d.maxRetries, d.retryIntervalMs) + const id = Number(result.lastInsertRowid) + const row = getTunnelRow(id)! + logEvent('tunnel_created', `${d.name} tunnel added`, d.mode) + return reply.code(201).send({ tunnel: serialize(row) }) + }) + + app.delete('/api/tunnels/:id', async (req, reply) => { + const id = Number((req.params as { id: string }).id) + const row = getTunnelRow(id) + if (!row) return reply.code(404).send({ error: 'Tunnel not found' }) + deleteTunnelRuntime(id) + db.prepare('DELETE FROM tunnels WHERE id = ?').run(id) + logEvent('tunnel_deleted', `${row.name} tunnel removed`, row.mode) + return { ok: true } + }) + + app.post('/api/tunnels/:id/connect', async (req, reply) => { + const id = Number((req.params as { id: string }).id) + const row = getTunnelRow(id) + if (!row) return reply.code(404).send({ error: 'Tunnel not found' }) + startTunnel(id) + return { ok: true, ...getStatus(id) } + }) + + app.post('/api/tunnels/:id/disconnect', async (req, reply) => { + const id = Number((req.params as { id: string }).id) + const row = getTunnelRow(id) + if (!row) return reply.code(404).send({ error: 'Tunnel not found' }) + stopTunnel(id) + return { ok: true, ...getStatus(id) } + }) +} diff --git a/backend/src/server.ts b/backend/src/server.ts index d80d285..9d87d65 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -8,6 +8,8 @@ import { integrationRoutes } from './routes/integrations.js' import { bookmarkRoutes } from './routes/bookmarks.js' import { eventRoutes } from './routes/events.js' import { terminalRoutes } from './routes/terminal.js' +import { tunnelRoutes } from './routes/tunnels.js' +import { startAutoStartTunnels } from './tunnels/manager.js' const JWT_SECRET = process.env.ARCHNEST_JWT_SECRET if (!JWT_SECRET) { @@ -33,6 +35,7 @@ await app.register(integrationRoutes) await app.register(bookmarkRoutes) await app.register(eventRoutes) await app.register(terminalRoutes) +await app.register(tunnelRoutes) app.get('/api/health', async () => ({ ok: true })) @@ -41,3 +44,5 @@ app.listen({ port, host: '0.0.0.0' }).catch((err) => { app.log.error(err) process.exit(1) }) + +startAutoStartTunnels() diff --git a/backend/src/ssh/connect.ts b/backend/src/ssh/connect.ts new file mode 100644 index 0000000..61d3f14 --- /dev/null +++ b/backend/src/ssh/connect.ts @@ -0,0 +1,87 @@ +import { Client, type ConnectConfig } from 'ssh2' +import { db } from '../db/index.js' +import { loadSecrets } from '../db/secrets.js' + +interface IntegrationRow { + id: number + type: string + config_json: string +} + +export function loadSshHost(integrationId: number) { + const row = db + .prepare('SELECT id, type, config_json FROM integrations WHERE id = ?') + .get(integrationId) as IntegrationRow | undefined + if (!row || row.type !== 'ssh') return null + const config = JSON.parse(row.config_json) as Record + const secrets = loadSecrets(row.id) + return { id: row.id, config, secrets } +} + +export type SshHost = NonNullable> + +function makeHostVerifier(integrationId: number): ConnectConfig['hostVerifier'] { + return (keyHash: string): boolean => { + const row = db + .prepare('SELECT fingerprint FROM ssh_host_keys WHERE integration_id = ?') + .get(integrationId) as { fingerprint: string } | undefined + if (!row) { + db.prepare('INSERT INTO ssh_host_keys (integration_id, fingerprint) VALUES (?, ?)').run(integrationId, keyHash) + return true + } + return row.fingerprint === keyHash + } +} + +export function baseConnectConfig(host: SshHost): ConnectConfig { + return { + host: host.config.host, + port: Number(host.config.port) || 22, + username: host.config.username, + password: host.secrets.password || undefined, + privateKey: host.secrets.privateKey || undefined, + passphrase: host.secrets.passphrase || undefined, + readyTimeout: 8000, + hostHash: 'sha256', + hostVerifier: makeHostVerifier(host.id), + } +} + +/** Connects to `target`, transparently chaining through its jump host (if configured) via forwardOut(). */ +export function connectTarget( + target: SshHost, + onReady: (client: Client) => void, + onError: (message: string) => void, +): { conn: Client; jumpConn: Client | null } { + const jumpHostId = target.config.jumpHostIntegrationId ? Number(target.config.jumpHostIntegrationId) : null + + if (jumpHostId) { + const jumpHost = loadSshHost(jumpHostId) + if (!jumpHost) { + onError('Jump host integration not found') + return { conn: new Client(), jumpConn: null } + } + const jumpConn = new Client() + jumpConn.on('ready', () => { + jumpConn.forwardOut('127.0.0.1', 0, target.config.host, Number(target.config.port) || 22, (err, sock) => { + if (err) { + onError(`Jump host forward failed: ${err.message}`) + return + } + client.connect({ ...baseConnectConfig(target), sock }) + }) + }) + jumpConn.on('error', (err) => onError(`Jump host error: ${err.message}`)) + const client = new Client() + client.on('ready', () => onReady(client)) + client.on('error', (err) => onError(err.message)) + jumpConn.connect(baseConnectConfig(jumpHost)) + return { conn: client, jumpConn } + } + + const client = new Client() + client.on('ready', () => onReady(client)) + client.on('error', (err) => onError(err.message)) + client.connect(baseConnectConfig(target)) + return { conn: client, jumpConn: null } +} diff --git a/backend/src/tunnels/manager.ts b/backend/src/tunnels/manager.ts new file mode 100644 index 0000000..edd6e70 --- /dev/null +++ b/backend/src/tunnels/manager.ts @@ -0,0 +1,218 @@ +import net from 'node:net' +import type { Client } from 'ssh2' +import { db } from '../db/index.js' +import { loadSshHost, connectTarget } from '../ssh/connect.js' +import { readSocks5Target, sendSocks5Success, sendSocks5Failure } from './socks5.js' + +export type TunnelMode = 'local' | 'remote' | 'dynamic' +export type TunnelStatus = 'stopped' | 'connecting' | 'connected' | 'retrying' | 'error' + +export interface TunnelRow { + id: number + name: string + integration_id: number + mode: TunnelMode + source_port: number + endpoint_host: string + endpoint_port: number + auto_start: number + max_retries: number + retry_interval_ms: number + created_at: string +} + +interface RuntimeState { + status: TunnelStatus + error: string | null + retryCount: number + client: Client | null + jumpConn: Client | null + server: net.Server | null + retryTimer: NodeJS.Timeout | null + stopRequested: boolean +} + +const runtimes = new Map() + +function emptyState(): RuntimeState { + return { + status: 'stopped', + error: null, + retryCount: 0, + client: null, + jumpConn: null, + server: null, + retryTimer: null, + stopRequested: false, + } +} + +function getState(id: number): RuntimeState { + let state = runtimes.get(id) + if (!state) { + state = emptyState() + runtimes.set(id, state) + } + return state +} + +export function getTunnelRow(id: number): TunnelRow | null { + return (db.prepare('SELECT * FROM tunnels WHERE id = ?').get(id) as TunnelRow | undefined) ?? null +} + +export function getStatus(id: number) { + const state = getState(id) + return { status: state.status, error: state.error, retryCount: state.retryCount } +} + +function teardownNetwork(state: RuntimeState) { + state.server?.close() + state.client?.end() + state.jumpConn?.end() + state.server = null + state.client = null + state.jumpConn = null +} + +function scheduleRetry(id: number, tunnel: TunnelRow, state: RuntimeState) { + if (state.stopRequested) return + if (state.retryCount >= tunnel.max_retries) { + state.status = 'error' + return + } + state.status = 'retrying' + state.retryCount += 1 + state.retryTimer = setTimeout(() => startTunnel(id), tunnel.retry_interval_ms) +} + +function bindLocalForward(client: Client, tunnel: TunnelRow, state: RuntimeState, onFail: (message: string) => void) { + const server = net.createServer((socket) => { + client.forwardOut( + socket.remoteAddress ?? '127.0.0.1', + socket.remotePort ?? 0, + tunnel.endpoint_host, + tunnel.endpoint_port, + (err, stream) => { + if (err) { + socket.destroy() + return + } + socket.pipe(stream).pipe(socket) + stream.on('close', () => socket.destroy()) + socket.on('close', () => stream.end()) + }, + ) + }) + server.on('error', (err) => onFail(err.message)) + server.listen(tunnel.source_port, '127.0.0.1') + state.server = server +} + +function bindRemoteForward(client: Client, tunnel: TunnelRow, state: RuntimeState, onFail: (message: string) => void) { + client.forwardIn('0.0.0.0', tunnel.source_port, (err) => { + if (err) { + onFail(err.message) + return + } + }) + client.on('tcp connection', (info, accept, reject) => { + if (info.destPort !== tunnel.source_port) { + reject() + return + } + const stream = accept() + const sock = net.connect(tunnel.endpoint_port, tunnel.endpoint_host) + sock.on('error', () => stream.end()) + stream.on('error', () => sock.destroy()) + sock.pipe(stream).pipe(sock) + }) +} + +function bindDynamicForward(client: Client, tunnel: TunnelRow, state: RuntimeState, onFail: (message: string) => void) { + const server = net.createServer((socket) => { + readSocks5Target(socket) + .then((target) => { + client.forwardOut(socket.remoteAddress ?? '127.0.0.1', socket.remotePort ?? 0, target.host, target.port, (err, stream) => { + if (err) { + sendSocks5Failure(socket) + socket.destroy() + return + } + sendSocks5Success(socket) + socket.pipe(stream).pipe(socket) + stream.on('close', () => socket.destroy()) + socket.on('close', () => stream.end()) + }) + }) + .catch(() => socket.destroy()) + }) + server.on('error', (err) => onFail(err.message)) + server.listen(tunnel.source_port, '127.0.0.1') + state.server = server +} + +export function startTunnel(id: number) { + const tunnel = getTunnelRow(id) + if (!tunnel) return + const state = getState(id) + state.stopRequested = false + state.status = 'connecting' + state.error = null + + const target = loadSshHost(tunnel.integration_id) + if (!target) { + state.status = 'error' + state.error = 'SSH integration not found' + return + } + + const onFail = (message: string) => { + if (state.stopRequested) return + state.error = message + teardownNetwork(state) + scheduleRetry(id, tunnel, state) + } + + const result = connectTarget( + target, + (client) => { + if (state.stopRequested) { + client.end() + return + } + state.client = client + state.status = 'connected' + state.error = null + state.retryCount = 0 + client.on('error', (err) => onFail(err.message)) + client.on('close', () => onFail('SSH connection closed')) + + if (tunnel.mode === 'local') bindLocalForward(client, tunnel, state, onFail) + else if (tunnel.mode === 'remote') bindRemoteForward(client, tunnel, state, onFail) + else bindDynamicForward(client, tunnel, state, onFail) + }, + onFail, + ) + state.jumpConn = result.jumpConn +} + +export function stopTunnel(id: number) { + const state = getState(id) + state.stopRequested = true + if (state.retryTimer) clearTimeout(state.retryTimer) + state.retryTimer = null + state.retryCount = 0 + state.status = 'stopped' + state.error = null + teardownNetwork(state) +} + +export function deleteTunnelRuntime(id: number) { + stopTunnel(id) + runtimes.delete(id) +} + +export function startAutoStartTunnels() { + const rows = db.prepare('SELECT * FROM tunnels WHERE auto_start = 1').all() as TunnelRow[] + for (const row of rows) startTunnel(row.id) +} diff --git a/backend/src/tunnels/socks5.ts b/backend/src/tunnels/socks5.ts new file mode 100644 index 0000000..301b54d --- /dev/null +++ b/backend/src/tunnels/socks5.ts @@ -0,0 +1,61 @@ +import type { Socket } from 'node:net' + +export interface Socks5Target { + host: string + port: number +} + +/** Minimal SOCKS5 handshake (no-auth only) + CONNECT request parser. Resolves once the + * client's target address is known, after which the caller is expected to pipe the + * raw bytes that follow straight through to an upstream connection. */ +export function readSocks5Target(socket: Socket): Promise { + return new Promise((resolve, reject) => { + let stage: 'greeting' | 'request' = 'greeting' + + const onData = (chunk: Buffer) => { + try { + if (stage === 'greeting') { + if (chunk[0] !== 0x05) throw new Error('Unsupported SOCKS version') + socket.write(Buffer.from([0x05, 0x00])) + stage = 'request' + return + } + + if (chunk[0] !== 0x05 || chunk[1] !== 0x01) { + throw new Error('Only CONNECT is supported') + } + const addrType = chunk[3] + let host: string + let offset: number + if (addrType === 0x01) { + host = `${chunk[4]}.${chunk[5]}.${chunk[6]}.${chunk[7]}` + offset = 8 + } else if (addrType === 0x03) { + const len = chunk[4] + host = chunk.subarray(5, 5 + len).toString('ascii') + offset = 5 + len + } else { + throw new Error('Unsupported SOCKS5 address type') + } + const port = chunk.readUInt16BE(offset) + socket.removeListener('data', onData) + resolve({ host, port }) + } catch (err) { + socket.removeListener('data', onData) + reject(err instanceof Error ? err : new Error('SOCKS5 parse error')) + } + } + + socket.on('data', onData) + socket.on('error', reject) + socket.on('close', () => reject(new Error('Socket closed before SOCKS5 handshake completed'))) + }) +} + +export function sendSocks5Success(socket: Socket) { + socket.write(Buffer.from([0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0])) +} + +export function sendSocks5Failure(socket: Socket) { + socket.write(Buffer.from([0x05, 0x01, 0x00, 0x01, 0, 0, 0, 0, 0, 0])) +} diff --git a/src/App.tsx b/src/App.tsx index 9ea2b22..217fbb2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,6 +6,7 @@ import Glance from './pages/Glance' import Infrastructure from './pages/Infrastructure' import BookNest from './pages/BookNest' import Terminal from './pages/Terminal' +import Tunnels from './pages/Tunnels' import Settings from './pages/Settings' import Login from './pages/Login' import Enrollment from './pages/Enrollment' @@ -82,6 +83,7 @@ function Dashboard() { } /> } /> } /> + } /> } /> diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index b2023a1..fc2414d 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -5,6 +5,7 @@ import { Server, Bookmark, Terminal, + Waypoints, Settings, ChevronLeft, ChevronRight, @@ -21,6 +22,7 @@ const navItems = [ { icon: Server, label: 'Infrastructure', route: '/infrastructure' }, { icon: Bookmark, label: 'BookNest', route: '/booknest' }, { icon: Terminal, label: 'Terminal', route: '/terminal' }, + { icon: Waypoints, label: 'Tunnels', route: '/tunnels' }, { icon: Settings, label: 'Settings', route: '/settings' }, ] diff --git a/src/lib/api.ts b/src/lib/api.ts index 74fd1f7..85eb15a 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -72,6 +72,24 @@ export const api = { listEvents: (limit = 20) => apiFetch<{ events: Event[] }>(`/events?limit=${limit}`), listResources: () => apiFetch<{ resources: Resource[] }>('/integrations/resources'), + + listTunnels: () => apiFetch<{ tunnels: Tunnel[] }>('/tunnels'), + createTunnel: (data: { + name: string + integrationId: number + mode: 'local' | 'remote' | 'dynamic' + sourcePort: number + endpointHost?: string + endpointPort?: number + autoStart?: boolean + maxRetries?: number + retryIntervalMs?: number + }) => apiFetch<{ tunnel: Tunnel }>('/tunnels', { method: 'POST', body: JSON.stringify(data) }), + deleteTunnel: (id: number) => apiFetch(`/tunnels/${id}`, { method: 'DELETE' }), + connectTunnel: (id: number) => + apiFetch<{ ok: boolean; status: string; error: string | null; retryCount: number }>(`/tunnels/${id}/connect`, { method: 'POST' }), + disconnectTunnel: (id: number) => + apiFetch<{ ok: boolean; status: string; error: string | null; retryCount: number }>(`/tunnels/${id}/disconnect`, { method: 'POST' }), } export interface AuthUser { @@ -93,6 +111,23 @@ export interface Integration { createdAt: string } +export interface Tunnel { + id: number + name: string + integrationId: number + mode: 'local' | 'remote' | 'dynamic' + sourcePort: number + endpointHost: string + endpointPort: number + autoStart: boolean + maxRetries: number + retryIntervalMs: number + createdAt: string + status: 'stopped' | 'connecting' | 'connected' | 'retrying' | 'error' + error: string | null + retryCount: number +} + export interface Bookmark { id: number category_id: number | null diff --git a/src/pages/Tunnels.tsx b/src/pages/Tunnels.tsx new file mode 100644 index 0000000..dc87725 --- /dev/null +++ b/src/pages/Tunnels.tsx @@ -0,0 +1,287 @@ +import { useEffect, useState } from 'react' +import { Plus, Play, Square, Trash2, ArrowRightLeft, ArrowLeftRight, Shuffle } from 'lucide-react' +import { api, type Tunnel, type Integration } from '../lib/api' + +const TEXT_PRIMARY = '#E8E6E0' +const TEXT_SECONDARY = '#7A7D85' +const GOLD = '#C8A434' + +const cardBase: React.CSSProperties = { + backgroundColor: 'rgba(10, 10, 12, 0.92)', + border: '1px solid rgba(200, 164, 52, 0.08)', + borderRadius: '12px', + padding: '20px', + boxShadow: '0 0 20px rgba(200, 164, 52, 0.03)', +} + +const MODE_LABEL: Record = { + local: 'Local Forward', + remote: 'Remote Forward', + dynamic: 'Dynamic (SOCKS5)', +} + +const MODE_ICON: Record> = { + local: ArrowRightLeft, + remote: ArrowLeftRight, + dynamic: Shuffle, +} + +const STATUS_COLOR: Record = { + stopped: TEXT_SECONDARY, + connecting: GOLD, + retrying: '#E0A030', + connected: '#2ECC71', + error: '#E74C3C', +} + +function inputStyle(): React.CSSProperties { + return { + backgroundColor: 'rgba(255,255,255,0.03)', + border: '1px solid rgba(255,255,255,0.08)', + borderRadius: '6px', + padding: '6px 10px', + color: TEXT_PRIMARY, + fontSize: '13px', + width: '100%', + } +} + +export default function Tunnels() { + const [tunnels, setTunnels] = useState([]) + const [hosts, setHosts] = useState([]) + const [showForm, setShowForm] = useState(false) + const [busyId, setBusyId] = useState(null) + const [error, setError] = useState(null) + + const [name, setName] = useState('') + const [integrationId, setIntegrationId] = useState('') + const [mode, setMode] = useState('local') + const [sourcePort, setSourcePort] = useState('') + const [endpointHost, setEndpointHost] = useState('') + const [endpointPort, setEndpointPort] = useState('') + const [autoStart, setAutoStart] = useState(false) + + function refresh() { + api.listTunnels().then(({ tunnels }) => setTunnels(tunnels)) + } + + useEffect(() => { + refresh() + api.listIntegrations().then(({ integrations }) => setHosts(integrations.filter((i) => i.type === 'ssh'))) + const interval = setInterval(refresh, 3000) + return () => clearInterval(interval) + }, []) + + async function handleCreate() { + if (!name.trim() || !integrationId || !sourcePort) { + setError('Name, SSH host, and source port are required') + return + } + setError(null) + try { + await api.createTunnel({ + name: name.trim(), + integrationId: Number(integrationId), + mode, + sourcePort: Number(sourcePort), + endpointHost: mode === 'dynamic' ? '' : endpointHost, + endpointPort: mode === 'dynamic' ? 0 : Number(endpointPort) || 0, + autoStart, + }) + setName('') + setIntegrationId('') + setSourcePort('') + setEndpointHost('') + setEndpointPort('') + setAutoStart(false) + setShowForm(false) + refresh() + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create tunnel') + } + } + + async function handleConnect(id: number) { + setBusyId(id) + try { + await api.connectTunnel(id) + refresh() + } finally { + setBusyId(null) + } + } + + async function handleDisconnect(id: number) { + setBusyId(id) + try { + await api.disconnectTunnel(id) + refresh() + } finally { + setBusyId(null) + } + } + + async function handleDelete(id: number) { + setBusyId(id) + try { + await api.deleteTunnel(id) + refresh() + } finally { + setBusyId(null) + } + } + + return ( +
+
+
+

+ SSH Tunnels +

+

+ Local / remote / dynamic SOCKS5 port forwarding through your configured SSH hosts. +

+
+ +
+ + {showForm && ( +
+ {error &&
{error}
} +
+
+ + setName(e.target.value)} placeholder="my-tunnel" /> +
+
+ + +
+
+ + +
+
+ + setSourcePort(e.target.value)} placeholder="8080" /> +
+ {mode !== 'dynamic' && ( + <> +
+ + setEndpointHost(e.target.value)} placeholder="127.0.0.1" /> +
+
+ + setEndpointPort(e.target.value)} placeholder="80" /> +
+ + )} +
+ +
+ + +
+
+ )} + +
+ {tunnels.map((t) => { + const Icon = MODE_ICON[t.mode] + const host = hosts.find((h) => h.id === t.integrationId) + return ( +
+
+
+ + + {t.name} + +
+ + {t.status} + {t.status === 'retrying' ? ` (${t.retryCount}/${t.maxRetries})` : ''} + +
+
+
{MODE_LABEL[t.mode]}
+
via {host?.name ?? `integration #${t.integrationId}`}
+
+ localhost:{t.sourcePort} {t.mode === 'dynamic' ? '(SOCKS5 proxy)' : `→ ${t.endpointHost}:${t.endpointPort}`} +
+ {t.error &&
{t.error}
} +
+
+ {t.status === 'connected' || t.status === 'connecting' || t.status === 'retrying' ? ( + + ) : ( + + )} + +
+
+ ) + })} + {tunnels.length === 0 && !showForm && ( +
No tunnels configured yet.
+ )} +
+
+ ) +} From 7edf4548d9ab30518f211959fb3880e9fadb4f88 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 11:56:04 +0000 Subject: [PATCH 10/21] Phase 3: remote file manager (SFTP list/edit/upload/download/rename/delete/chmod) Ephemeral per-request SFTP connections, whole-file-in-memory view/edit with a 50MB cap and binary detection, streaming download for files of any size, multipart upload. No sudo/permission-elevation or server-to-server transfer in this pass (documented gaps, matching Termix's own scope for the latter). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BbJV5nm8KPVH1oNJYKpnoF --- TERMIX_MIGRATION.md | 18 +- backend/package-lock.json | 46 +++++ backend/package.json | 1 + backend/src/routes/files.ts | 258 ++++++++++++++++++++++++++ backend/src/server.ts | 4 + backend/src/ssh/sftp.ts | 50 +++++ src/App.tsx | 2 + src/components/Sidebar.tsx | 2 + src/lib/api.ts | 49 +++++ src/pages/Files.tsx | 360 ++++++++++++++++++++++++++++++++++++ 10 files changed, 788 insertions(+), 2 deletions(-) create mode 100644 backend/src/routes/files.ts create mode 100644 backend/src/ssh/sftp.ts create mode 100644 src/pages/Files.tsx diff --git a/TERMIX_MIGRATION.md b/TERMIX_MIGRATION.md index ff57525..d01658d 100644 --- a/TERMIX_MIGRATION.md +++ b/TERMIX_MIGRATION.md @@ -78,9 +78,23 @@ Source: `src/backend/ssh/tunnel.ts` (2,414 lines) + `tunnel-c2s-relay.ts`, `tunn **Verified end-to-end** against a real test SSH server (extending the same real-`ssh2`-`Server` + `node-pty` pattern used in Phase 1c) that genuinely handles `tcpip` (forwardOut) and `tcpip-forward`/`cancel-tcpip-forward` (forwardIn) requests, plus a real upstream TCP echo server: created one tunnel of each mode (local/remote/dynamic), connected all three, and confirmed real data flowed through each — local forward and remote forward both delivered the upstream server's banner through the tunnel, and the dynamic tunnel completed a real SOCKS5 CONNECT handshake and relayed data. Also verified disconnect correctly tears down the local listener (`ECONNREFUSED` after stopping). All test artifacts (test SSH server, test backend instance, test DB, tokens) were cleaned up afterward. -### Phase 3 — Remote File Manager (NOT STARTED) +### Phase 3 — Remote File Manager (DONE, with documented gaps) -Source: `src/backend/ssh/file-manager*.ts` (six files, ~3,900 lines combined: list/content/action/operation/download routes + session + utils) + frontend `src/ui/features/file-manager/*`. View/edit code/images/audio/video, upload/download/rename/delete/move, sudo support, server-to-server moves. Runs over the SSH connections from Phase 1. +Source: `src/backend/ssh/file-manager*.ts` (six files, ~3,900 lines combined: list/content/action/operation/download routes + session + utils) + frontend `src/ui/features/file-manager/*`. + +**Scope decisions:** +- **Ephemeral SFTP connections** instead of Termix's pooled/long-lived sessions: each request opens a fresh SSH+SFTP connection (`backend/src/ssh/sftp.ts`'s `withSftp()`), does one operation, and tears the connection down. Simpler than managing a third long-lived connection lifecycle alongside terminal and tunnel sessions, and acceptable at this app's scale. +- **No sudo/permission-elevation support.** Termix falls back to shell commands piped a stored sudo password when SFTP returns a permission error; not ported in this pass (no privileged remote test target available in this sandbox to verify against safely — same category of gap as the OPKSSH cert-auth gap in Phase 1c). Documented here rather than silently dropped. +- **No server-to-server transfer** — this matches Termix's actual behavior (its own cross-host "transfer" is just sequential `download` then `upload` through the browser; same-host moves use shell `mv`/`cp`, which isn't ported since sudo isn't). Not a regression. +- **Whole-file-in-memory model** for view/edit, same as Termix: `GET/PUT /api/files/:id/content` reads/writes the entire file via `sftp.readFile`/`writeFile`. Files over 50MB (`MAX_EDITABLE_SIZE`) are rejected with a message pointing at download/upload instead. Binary detection (so binary files are shown as a "can't edit" message rather than mangled text) uses the same heuristic as Termix: scan the first 8KB for a null byte or a >1% ratio of other control bytes. +- **Streaming download** (`GET /api/files/:id/download`) for files of any size, via `sftp.createReadStream()` piped straight into the HTTP response rather than buffered in memory. + +**What was built:** +- `backend/src/ssh/sftp.ts` — `withSftp(integrationId, fn)`: opens an ephemeral SSH+SFTP connection (reusing `connect.ts`'s jump-host-chaining + TOFU logic from Phase 1/2), runs `fn`, then tears the connection down. +- `backend/src/routes/files.ts` — `GET /api/files/:id/list`, `GET/PUT /api/files/:id/content`, `POST /api/files/:id/mkdir`, `POST /api/files/:id/rename`, `POST /api/files/:id/delete`, `POST /api/files/:id/chmod`, `GET /api/files/:id/download`, `POST /api/files/:id/upload` (multipart, via newly-added `@fastify/multipart`, 1GB limit). +- `src/pages/Files.tsx` — new page (`/files`, sidebar entry with a `FolderOpen` icon): SSH host picker, breadcrumb-navigable directory browser, inline text editor for non-binary files, new-folder/rename/delete/chmod-via-octal-display/upload/download actions. + +**Verified end-to-end** against a real filesystem-backed SFTP server built specifically for this (using `ssh2`'s server-side low-level SFTP protocol API — genuine `OPEN`/`READ`/`WRITE`/`READDIR`/`RENAME`/`REMOVE`/`MKDIR`/`STAT`/`SETSTAT` handlers backed by real `fs` calls against a real directory on disk, not a mock). Confirmed by inspecting the actual files/permissions on disk after each operation (`cat`, `ls`, `stat -c '%a'`), not just the HTTP response: list, read, write, mkdir, rename, delete, chmod, upload, and download (byte-for-byte `diff` match against the uploaded source file) all round-tripped correctly. One real bug was caught and fixed during this verification: the download route's wrapping `Promise` was resolving immediately after `reply.send(stream)` instead of waiting for the response to actually finish, which raced Fastify into ending the HTTP response (and the route's `cleanup()` into closing the underlying SSH connection) before the SFTP stream had sent any data — produced a 0-byte download with a "stream closed prematurely" log line. Fixed by letting `reply.send(stream)`'s return value resolve the promise instead of resolving synchronously, and moving connection cleanup to the response's own `finish`/`close` events. All test artifacts (test SFTP server, test backend instance, test DB, tokens, temp files) were cleaned up afterward. ### Phase 4 — Docker Container Management (NOT STARTED) diff --git a/backend/package-lock.json b/backend/package-lock.json index 828ad90..39b3713 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -12,6 +12,7 @@ "@aws-sdk/client-sts": "^3.1072.0", "@fastify/cors": "^10.0.1", "@fastify/jwt": "^10.1.0", + "@fastify/multipart": "^10.0.0", "@fastify/websocket": "^11.2.0", "@types/ssh2": "^1.15.5", "bcryptjs": "^2.4.3", @@ -886,6 +887,12 @@ "fast-uri": "^3.0.0" } }, + "node_modules/@fastify/busboy": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.0.tgz", + "integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==", + "license": "MIT" + }, "node_modules/@fastify/cors": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-10.1.0.tgz", @@ -906,6 +913,22 @@ "mnemonist": "0.40.0" } }, + "node_modules/@fastify/deepmerge": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@fastify/deepmerge/-/deepmerge-3.2.1.tgz", + "integrity": "sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/@fastify/error": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", @@ -999,6 +1022,29 @@ "dequal": "^2.0.3" } }, + "node_modules/@fastify/multipart": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@fastify/multipart/-/multipart-10.0.0.tgz", + "integrity": "sha512-pUx3Z1QStY7E7kwvDTIvB6P+rF5lzP+iqPgZyJyG3yBJVPvQaZxzDHYbQD89rbY0ciXrMOyGi8ezHDVexLvJDA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^3.0.0", + "@fastify/deepmerge": "^3.0.0", + "@fastify/error": "^4.0.0", + "fastify-plugin": "^5.0.0", + "secure-json-parse": "^4.0.0" + } + }, "node_modules/@fastify/proxy-addr": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", diff --git a/backend/package.json b/backend/package.json index b3b2a0d..2ec0236 100644 --- a/backend/package.json +++ b/backend/package.json @@ -13,6 +13,7 @@ "@aws-sdk/client-sts": "^3.1072.0", "@fastify/cors": "^10.0.1", "@fastify/jwt": "^10.1.0", + "@fastify/multipart": "^10.0.0", "@fastify/websocket": "^11.2.0", "@types/ssh2": "^1.15.5", "bcryptjs": "^2.4.3", diff --git a/backend/src/routes/files.ts b/backend/src/routes/files.ts new file mode 100644 index 0000000..0738cdf --- /dev/null +++ b/backend/src/routes/files.ts @@ -0,0 +1,258 @@ +import type { FastifyInstance } from 'fastify' +import type { Client } from 'ssh2' +import { z } from 'zod' +import { withSftp } from '../ssh/sftp.js' +import { loadSshHost, connectTarget } from '../ssh/connect.js' + +const MAX_EDITABLE_SIZE = 50 * 1024 * 1024 // 50MB - above this, only download is offered + +function isBinary(buf: Buffer): boolean { + const sample = buf.subarray(0, 8000) + let suspicious = 0 + for (const byte of sample) { + if (byte === 0) return true + if (byte < 9 || (byte > 13 && byte < 32)) suspicious++ + } + return sample.length > 0 && suspicious / sample.length > 0.01 +} + +function parsePath(req: { query?: unknown; body?: unknown }, key = 'path'): string { + const fromQuery = (req.query as Record | undefined)?.[key] + const fromBody = (req.body as Record | undefined)?.[key] + const path = fromQuery ?? fromBody + if (!path) throw new Error('path is required') + return path +} + +export async function fileRoutes(app: FastifyInstance) { + app.addHook('onRequest', app.authenticate) + + app.get('/api/files/:integrationId/list', async (req, reply) => { + const integrationId = Number((req.params as { integrationId: string }).integrationId) + const path = (req.query as { path?: string }).path || '.' + try { + const entries = await withSftp(integrationId, (sftp) => + new Promise((resolve, reject) => { + sftp.readdir(path, (err, list) => { + if (err) reject(err) + else + resolve( + list + .map((e) => ({ + name: e.filename, + isDirectory: e.attrs.isDirectory(), + isSymlink: e.attrs.isSymbolicLink(), + size: e.attrs.size, + mode: e.attrs.mode, + mtime: e.attrs.mtime, + })) + .sort((a, b) => (a.isDirectory === b.isDirectory ? a.name.localeCompare(b.name) : a.isDirectory ? -1 : 1)), + ) + }) + }), + ) + return { path, entries } + } catch (err) { + return reply.code(400).send({ error: err instanceof Error ? err.message : 'Failed to list directory' }) + } + }) + + app.get('/api/files/:integrationId/content', async (req, reply) => { + const integrationId = Number((req.params as { integrationId: string }).integrationId) + let path: string + try { + path = parsePath(req) + } catch (err) { + return reply.code(400).send({ error: err instanceof Error ? err.message : 'Invalid request' }) + } + try { + const result = await withSftp(integrationId, (sftp) => + new Promise<{ content: string; encoding: 'utf8' | 'base64'; size: number; mode: number }>((resolve, reject) => { + sftp.stat(path, (statErr, stats) => { + if (statErr) { + reject(statErr) + return + } + if (stats.size > MAX_EDITABLE_SIZE) { + reject(new Error(`File is too large to view/edit (${Math.round(stats.size / 1024 / 1024)}MB, limit 50MB) - use download instead`)) + return + } + sftp.readFile(path, (readErr, buf) => { + if (readErr) { + reject(readErr) + return + } + const binary = isBinary(buf) + resolve({ + content: binary ? buf.toString('base64') : buf.toString('utf8'), + encoding: binary ? 'base64' : 'utf8', + size: stats.size, + mode: stats.mode, + }) + }) + }) + }), + ) + return result + } catch (err) { + return reply.code(400).send({ error: err instanceof Error ? err.message : 'Failed to read file' }) + } + }) + + app.put('/api/files/:integrationId/content', async (req, reply) => { + const integrationId = Number((req.params as { integrationId: string }).integrationId) + const bodySchema = z.object({ path: z.string().min(1), content: z.string(), encoding: z.enum(['utf8', 'base64']).default('utf8') }) + const parsed = bodySchema.safeParse(req.body) + if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0]?.message ?? 'Invalid input' }) + const { path, content, encoding } = parsed.data + try { + await withSftp(integrationId, (sftp) => + new Promise((resolve, reject) => { + const buf = Buffer.from(content, encoding === 'base64' ? 'base64' : 'utf8') + sftp.writeFile(path, buf, (err) => (err ? reject(err) : resolve())) + }), + ) + return { ok: true } + } catch (err) { + return reply.code(400).send({ error: err instanceof Error ? err.message : 'Failed to write file' }) + } + }) + + app.post('/api/files/:integrationId/mkdir', async (req, reply) => { + const integrationId = Number((req.params as { integrationId: string }).integrationId) + const bodySchema = z.object({ path: z.string().min(1) }) + const parsed = bodySchema.safeParse(req.body) + if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0]?.message ?? 'Invalid input' }) + try { + await withSftp(integrationId, (sftp) => + new Promise((resolve, reject) => sftp.mkdir(parsed.data.path, (err) => (err ? reject(err) : resolve()))), + ) + return { ok: true } + } catch (err) { + return reply.code(400).send({ error: err instanceof Error ? err.message : 'Failed to create directory' }) + } + }) + + app.post('/api/files/:integrationId/rename', async (req, reply) => { + const integrationId = Number((req.params as { integrationId: string }).integrationId) + const bodySchema = z.object({ from: z.string().min(1), to: z.string().min(1) }) + const parsed = bodySchema.safeParse(req.body) + if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0]?.message ?? 'Invalid input' }) + try { + await withSftp(integrationId, (sftp) => + new Promise((resolve, reject) => sftp.rename(parsed.data.from, parsed.data.to, (err) => (err ? reject(err) : resolve()))), + ) + return { ok: true } + } catch (err) { + return reply.code(400).send({ error: err instanceof Error ? err.message : 'Failed to rename' }) + } + }) + + app.post('/api/files/:integrationId/delete', async (req, reply) => { + const integrationId = Number((req.params as { integrationId: string }).integrationId) + const bodySchema = z.object({ path: z.string().min(1), isDirectory: z.boolean().default(false) }) + const parsed = bodySchema.safeParse(req.body) + if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0]?.message ?? 'Invalid input' }) + const { path, isDirectory } = parsed.data + try { + await withSftp(integrationId, (sftp) => + new Promise((resolve, reject) => { + if (isDirectory) sftp.rmdir(path, (err) => (err ? reject(err) : resolve())) + else sftp.unlink(path, (err) => (err ? reject(err) : resolve())) + }), + ) + return { ok: true } + } catch (err) { + return reply.code(400).send({ error: err instanceof Error ? err.message : 'Failed to delete' }) + } + }) + + app.post('/api/files/:integrationId/chmod', async (req, reply) => { + const integrationId = Number((req.params as { integrationId: string }).integrationId) + const bodySchema = z.object({ path: z.string().min(1), mode: z.string().regex(/^[0-7]{3,4}$/) }) + const parsed = bodySchema.safeParse(req.body) + if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0]?.message ?? 'Invalid input' }) + try { + await withSftp(integrationId, (sftp) => + new Promise((resolve, reject) => + sftp.chmod(parsed.data.path, parseInt(parsed.data.mode, 8), (err) => (err ? reject(err) : resolve())), + ), + ) + return { ok: true } + } catch (err) { + return reply.code(400).send({ error: err instanceof Error ? err.message : 'Failed to chmod' }) + } + }) + + app.get('/api/files/:integrationId/download', async (req, reply) => { + const integrationId = Number((req.params as { integrationId: string }).integrationId) + const path = (req.query as { path?: string }).path + if (!path) return reply.code(400).send({ error: 'path is required' }) + const target = loadSshHost(integrationId) + if (!target) return reply.code(404).send({ error: 'SSH integration not found' }) + + const filename = path.split('/').filter(Boolean).pop() ?? 'download' + reply.header('content-disposition', `attachment; filename="${filename.replace(/"/g, '')}"`) + reply.header('content-type', 'application/octet-stream') + + return new Promise((resolve, reject) => { + let conn: Client | null = null + let jumpConn: Client | null = null + let cleaned = false + const cleanup = () => { + if (cleaned) return + cleaned = true + conn?.end() + jumpConn?.end() + } + const result = connectTarget( + target, + (client) => { + conn = client + client.sftp((err, sftp) => { + if (err) { + cleanup() + resolve(reply.code(400).send({ error: err.message })) + return + } + const stream = sftp.createReadStream(path) + stream.on('error', (streamErr: Error) => { + cleanup() + reject(streamErr) + }) + // Tear down the SSH connection only once the HTTP response has + // actually finished flushing. Resolving the outer promise before + // that point (e.g. right after calling reply.send) lets Fastify + // consider the handler done and end the response early, which + // produced truncated/empty downloads. + reply.raw.on('finish', cleanup) + reply.raw.on('close', cleanup) + resolve(reply.send(stream)) + }) + }, + (message) => { + cleanup() + resolve(reply.code(400).send({ error: message })) + }, + ) + jumpConn = result.jumpConn + }) + }) + + app.post('/api/files/:integrationId/upload', async (req, reply) => { + const integrationId = Number((req.params as { integrationId: string }).integrationId) + const data = await req.file() + if (!data) return reply.code(400).send({ error: 'No file uploaded' }) + const dirPath = (data.fields.path as { value?: string } | undefined)?.value ?? '.' + const destPath = `${dirPath.replace(/\/$/, '')}/${data.filename}` + try { + const buf = await data.toBuffer() + await withSftp(integrationId, (sftp) => + new Promise((resolve, reject) => sftp.writeFile(destPath, buf, (err) => (err ? reject(err) : resolve()))), + ) + return { ok: true, path: destPath } + } catch (err) { + return reply.code(400).send({ error: err instanceof Error ? err.message : 'Failed to upload file' }) + } + }) +} diff --git a/backend/src/server.ts b/backend/src/server.ts index 9d87d65..74e6d03 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -3,12 +3,14 @@ import Fastify from 'fastify' import cors from '@fastify/cors' import jwt from '@fastify/jwt' import websocket from '@fastify/websocket' +import multipart from '@fastify/multipart' import { authRoutes } from './routes/auth.js' import { integrationRoutes } from './routes/integrations.js' import { bookmarkRoutes } from './routes/bookmarks.js' import { eventRoutes } from './routes/events.js' import { terminalRoutes } from './routes/terminal.js' import { tunnelRoutes } from './routes/tunnels.js' +import { fileRoutes } from './routes/files.js' import { startAutoStartTunnels } from './tunnels/manager.js' const JWT_SECRET = process.env.ARCHNEST_JWT_SECRET @@ -21,6 +23,7 @@ const app = Fastify({ logger: true }) await app.register(cors, { origin: process.env.ARCHNEST_CORS_ORIGIN ?? true }) await app.register(jwt, { secret: JWT_SECRET }) await app.register(websocket) +await app.register(multipart, { limits: { fileSize: 1024 * 1024 * 1024 } }) app.decorate('authenticate', async function (req, reply) { try { @@ -36,6 +39,7 @@ await app.register(bookmarkRoutes) await app.register(eventRoutes) await app.register(terminalRoutes) await app.register(tunnelRoutes) +await app.register(fileRoutes) app.get('/api/health', async () => ({ ok: true })) diff --git a/backend/src/ssh/sftp.ts b/backend/src/ssh/sftp.ts new file mode 100644 index 0000000..958746e --- /dev/null +++ b/backend/src/ssh/sftp.ts @@ -0,0 +1,50 @@ +import type { Client, SFTPWrapper } from 'ssh2' +import { loadSshHost, connectTarget, type SshHost } from './connect.js' + +/** Opens an ephemeral SSH+SFTP connection for a single operation, then tears it down. + * Simpler than a pooled/long-lived session (which Termix uses) - acceptable at this + * app's scale, and avoids a second connection-lifecycle to manage on top of the + * terminal/tunnel ones. */ +export function withSftp( + integrationId: number, + fn: (sftp: SFTPWrapper, host: SshHost) => Promise, +): Promise { + const target = loadSshHost(integrationId) + if (!target) return Promise.reject(new Error('SSH integration not found')) + + return new Promise((resolve, reject) => { + let conn: Client | null = null + let jumpConn: Client | null = null + const cleanup = () => { + conn?.end() + jumpConn?.end() + } + const result = connectTarget( + target, + (client) => { + conn = client + client.sftp((err, sftp) => { + if (err) { + cleanup() + reject(err) + return + } + fn(sftp, target) + .then((value) => { + cleanup() + resolve(value) + }) + .catch((fnErr) => { + cleanup() + reject(fnErr) + }) + }) + }, + (message) => { + cleanup() + reject(new Error(message)) + }, + ) + jumpConn = result.jumpConn + }) +} diff --git a/src/App.tsx b/src/App.tsx index 217fbb2..de5af04 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,6 +7,7 @@ import Infrastructure from './pages/Infrastructure' import BookNest from './pages/BookNest' import Terminal from './pages/Terminal' import Tunnels from './pages/Tunnels' +import Files from './pages/Files' import Settings from './pages/Settings' import Login from './pages/Login' import Enrollment from './pages/Enrollment' @@ -84,6 +85,7 @@ function Dashboard() { } /> } /> } /> + } /> } /> diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index fc2414d..aa238fe 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -6,6 +6,7 @@ import { Bookmark, Terminal, Waypoints, + FolderOpen, Settings, ChevronLeft, ChevronRight, @@ -23,6 +24,7 @@ const navItems = [ { icon: Bookmark, label: 'BookNest', route: '/booknest' }, { icon: Terminal, label: 'Terminal', route: '/terminal' }, { icon: Waypoints, label: 'Tunnels', route: '/tunnels' }, + { icon: FolderOpen, label: 'Files', route: '/files' }, { icon: Settings, label: 'Settings', route: '/settings' }, ] diff --git a/src/lib/api.ts b/src/lib/api.ts index 85eb15a..f596e80 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -90,6 +90,46 @@ export const api = { apiFetch<{ ok: boolean; status: string; error: string | null; retryCount: number }>(`/tunnels/${id}/connect`, { method: 'POST' }), disconnectTunnel: (id: number) => apiFetch<{ ok: boolean; status: string; error: string | null; retryCount: number }>(`/tunnels/${id}/disconnect`, { method: 'POST' }), + + listFiles: (integrationId: number, path: string) => + apiFetch<{ path: string; entries: FileEntry[] }>(`/files/${integrationId}/list?path=${encodeURIComponent(path)}`), + readFile: (integrationId: number, path: string) => + apiFetch<{ content: string; encoding: 'utf8' | 'base64'; size: number; mode: number }>( + `/files/${integrationId}/content?path=${encodeURIComponent(path)}`, + ), + writeFile: (integrationId: number, path: string, content: string, encoding: 'utf8' | 'base64' = 'utf8') => + apiFetch<{ ok: boolean }>(`/files/${integrationId}/content`, { method: 'PUT', body: JSON.stringify({ path, content, encoding }) }), + mkdir: (integrationId: number, path: string) => + apiFetch<{ ok: boolean }>(`/files/${integrationId}/mkdir`, { method: 'POST', body: JSON.stringify({ path }) }), + renameFile: (integrationId: number, from: string, to: string) => + apiFetch<{ ok: boolean }>(`/files/${integrationId}/rename`, { method: 'POST', body: JSON.stringify({ from, to }) }), + deleteFile: (integrationId: number, path: string, isDirectory = false) => + apiFetch<{ ok: boolean }>(`/files/${integrationId}/delete`, { method: 'POST', body: JSON.stringify({ path, isDirectory }) }), + chmodFile: (integrationId: number, path: string, mode: string) => + apiFetch<{ ok: boolean }>(`/files/${integrationId}/chmod`, { method: 'POST', body: JSON.stringify({ path, mode }) }), + downloadFileUrl: (integrationId: number, path: string) => + `/api/files/${integrationId}/download?path=${encodeURIComponent(path)}`, + uploadFile: async (integrationId: number, path: string, file: File) => { + const form = new FormData() + form.append('path', path) + form.append('file', file) + const token = getToken() + const res = await fetch(`/api/files/${integrationId}/upload`, { + method: 'POST', + headers: token ? { Authorization: `Bearer ${token}` } : undefined, + body: form, + }) + if (!res.ok) { + let message = res.statusText + try { + message = (await res.json()).error ?? message + } catch { + // ignore non-JSON error bodies + } + throw new ApiError(res.status, message) + } + return res.json() as Promise<{ ok: boolean; path: string }> + }, } export interface AuthUser { @@ -155,6 +195,15 @@ export interface Event { created_at: string } +export interface FileEntry { + name: string + isDirectory: boolean + isSymlink: boolean + size: number + mode: number + mtime: number +} + export interface Resource { name: string status: 'healthy' | 'warning' | 'critical' | 'unknown' diff --git a/src/pages/Files.tsx b/src/pages/Files.tsx new file mode 100644 index 0000000..65b9ae4 --- /dev/null +++ b/src/pages/Files.tsx @@ -0,0 +1,360 @@ +import { useEffect, useRef, useState } from 'react' +import { + Folder, + File as FileIcon, + ChevronRight, + Upload, + FolderPlus, + Trash2, + Download, + Pencil, + Save, + X, + RefreshCw, +} from 'lucide-react' +import { api, type FileEntry, type Integration } from '../lib/api' + +const TEXT_PRIMARY = '#E8E6E0' +const TEXT_SECONDARY = '#7A7D85' +const GOLD = '#C8A434' + +const cardBase: React.CSSProperties = { + backgroundColor: 'rgba(10, 10, 12, 0.92)', + border: '1px solid rgba(200, 164, 52, 0.08)', + borderRadius: '12px', + boxShadow: '0 0 20px rgba(200, 164, 52, 0.03)', +} + +function inputStyle(): React.CSSProperties { + return { + backgroundColor: 'rgba(255,255,255,0.03)', + border: '1px solid rgba(255,255,255,0.08)', + borderRadius: '6px', + padding: '6px 10px', + color: TEXT_PRIMARY, + fontSize: '13px', + } +} + +function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B` + const units = ['KB', 'MB', 'GB', 'TB'] + let v = bytes + let i = -1 + do { + v /= 1024 + i++ + } while (v >= 1024 && i < units.length - 1) + return `${v.toFixed(1)} ${units[i]}` +} + +function joinPath(dir: string, name: string): string { + if (dir === '.' || dir === '') return name + return `${dir.replace(/\/$/, '')}/${name}` +} + +export default function Files() { + const [hosts, setHosts] = useState([]) + const [integrationId, setIntegrationId] = useState('') + const [path, setPath] = useState('.') + const [entries, setEntries] = useState([]) + const [error, setError] = useState(null) + const [loading, setLoading] = useState(false) + + const [editingPath, setEditingPath] = useState(null) + const [editingContent, setEditingContent] = useState('') + const [editingEncoding, setEditingEncoding] = useState<'utf8' | 'base64'>('utf8') + const [savingEdit, setSavingEdit] = useState(false) + + const fileInputRef = useRef(null) + + useEffect(() => { + api.listIntegrations().then(({ integrations }) => { + const sshHosts = integrations.filter((i) => i.type === 'ssh') + setHosts(sshHosts) + if (sshHosts.length > 0) setIntegrationId(sshHosts[0].id) + }) + }, []) + + function refresh() { + if (!integrationId) return + setLoading(true) + setError(null) + api + .listFiles(integrationId, path) + .then(({ entries }) => setEntries(entries)) + .catch((err) => setError(err instanceof Error ? err.message : 'Failed to list directory')) + .finally(() => setLoading(false)) + } + + useEffect(() => { + refresh() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [integrationId, path]) + + function openDirectory(name: string) { + setPath(joinPath(path, name)) + } + + function goUp() { + if (path === '.' || path === '') return + const parts = path.split('/').filter(Boolean) + parts.pop() + setPath(parts.length === 0 ? '.' : parts.join('/')) + } + + async function openFile(name: string) { + if (!integrationId) return + const full = joinPath(path, name) + setError(null) + try { + const result = await api.readFile(integrationId, full) + setEditingPath(full) + setEditingContent(result.content) + setEditingEncoding(result.encoding) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to open file') + } + } + + async function saveEdit() { + if (!integrationId || !editingPath) return + setSavingEdit(true) + try { + await api.writeFile(integrationId, editingPath, editingContent, editingEncoding) + setEditingPath(null) + refresh() + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to save file') + } finally { + setSavingEdit(false) + } + } + + async function handleMkdir() { + if (!integrationId) return + const name = prompt('New folder name') + if (!name) return + try { + await api.mkdir(integrationId, joinPath(path, name)) + refresh() + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create directory') + } + } + + async function handleRename(entry: FileEntry) { + if (!integrationId) return + const next = prompt('Rename to', entry.name) + if (!next || next === entry.name) return + try { + await api.renameFile(integrationId, joinPath(path, entry.name), joinPath(path, next)) + refresh() + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to rename') + } + } + + async function handleDelete(entry: FileEntry) { + if (!integrationId) return + if (!confirm(`Delete ${entry.name}?`)) return + try { + await api.deleteFile(integrationId, joinPath(path, entry.name), entry.isDirectory) + refresh() + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to delete') + } + } + + function handleDownload(entry: FileEntry) { + if (!integrationId) return + window.open(api.downloadFileUrl(integrationId, joinPath(path, entry.name)), '_blank') + } + + async function handleUpload(file: File) { + if (!integrationId) return + setError(null) + try { + await api.uploadFile(integrationId, path, file) + refresh() + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to upload file') + } + } + + const breadcrumbs = path === '.' || path === '' ? [] : path.split('/').filter(Boolean) + + return ( +
+
+
+

+ Files +

+

+ Browse, edit, and transfer files on your remote SSH hosts. +

+
+ +
+ + {error && ( +
+ {error} + +
+ )} + +
+
+
+ + {breadcrumbs.map((part, i) => ( + + + + + ))} +
+
+ + + + { + const file = e.target.files?.[0] + if (file) handleUpload(file) + e.target.value = '' + }} + /> +
+
+ + + + {path !== '.' && path !== '' && ( + + + + )} + {entries.map((entry) => ( + + + + + + + ))} + {entries.length === 0 && !loading && ( + + + + )} + +
+ ../ +
(entry.isDirectory ? openDirectory(entry.name) : openFile(entry.name))} + > + {entry.isDirectory ? : } + {entry.name} + + {entry.isDirectory ? '' : formatSize(entry.size)} + + {(entry.mode & 0o777).toString(8)} + +
+ {!entry.isDirectory && ( + + )} + + +
+
+ Empty directory +
+
+ + {editingPath && ( +
+
+
+ + {editingPath} + +
+ + +
+
+ {editingEncoding === 'base64' ? ( +
Binary file - editing not supported. Use download instead.
+ ) : ( +