From de1bb71173137f62149891114ba5c0581b66c0d9 Mon Sep 17 00:00:00 2001 From: Vantz Stockwell Date: Thu, 12 Mar 2026 16:59:34 -0400 Subject: [PATCH] docs: Wraith spec + implementation plan --- Remote-Spec.md | 284 ++ .../plans/2026-03-12-wraith-build.md | 3919 +++++++++++++++++ ...2026-03-12-vigilance-remote-lean-design.md | 614 +++ 3 files changed, 4817 insertions(+) create mode 100644 Remote-Spec.md create mode 100644 docs/superpowers/plans/2026-03-12-wraith-build.md create mode 100644 docs/superpowers/specs/2026-03-12-vigilance-remote-lean-design.md diff --git a/Remote-Spec.md b/Remote-Spec.md new file mode 100644 index 0000000..f5d8f3e --- /dev/null +++ b/Remote-Spec.md @@ -0,0 +1,284 @@ +# Planned Remote — Web-Based Terminal & Remote Desktop Client + +## Product Spec Sheet + +> **Concept**: A modern, self-hosted web application combining the best features of Termius (SSH/SFTP) and MobaXterm (SSH + RDP + SFTP browser) — accessible from any browser, no desktop client required. +> +> **Stack**: Nuxt 3 (Vue 3 SSR) + NestJS backend + PostgreSQL +> +> **Target Users**: MSP technicians, sysadmins, and IT teams who need unified remote access to SSH and RDP endpoints from any device + +--- + +## 1. Feature Comparison — What We're Building Against + +### Termius (Desktop/Mobile SSH Client) + +| Feature | Termius Free | Termius Pro ($14.99/mo) | +| ------------------------- | ------------ | ---------------------------- | +| SSH / Mosh / Telnet | ✅ | ✅ | +| SFTP file transfer | ✅ | ✅ | +| Port forwarding | ✅ | ✅ | +| Multi-tab sessions | ✅ | ✅ | +| Split panes | ❌ | ✅ | +| Encrypted cloud vault | ❌ | ✅ | +| Cross-device sync | ❌ | ✅ | +| Team sharing | ❌ | ✅ (Team plan $29.99/user/mo) | +| Saved snippets/macros | ❌ | ✅ | +| FIDO2 / hardware key auth | ✅ | ✅ | +| RDP | ❌ | ❌ | +| SFTP browser (sidebar) | ❌ | ❌ | + +**Key Termius strength**: Beautiful cross-platform UI, encrypted credential sync. +**Key Termius weakness**: No RDP. No SFTP sidebar browser. No web-based option. + +--- + +### MobaXterm (Windows Desktop Client) + +| Feature | MobaXterm Free | MobaXterm Pro ($69/license) | +| ------------------------------------------------ | ---------------- | --------------------------- | +| SSH / Mosh / Telnet / rlogin | ✅ | ✅ | +| RDP (Remote Desktop) | ✅ | ✅ | +| VNC | ✅ | ✅ | +| SFTP sidebar browser (auto-opens on SSH connect) | ✅ | ✅ | +| X11 server | ✅ | ✅ | +| Multi-tab sessions | ✅ | ✅ | +| Split panes | ✅ | ✅ | +| SSH tunnels (graphical manager) | ✅ | ✅ | +| Macros / saved commands | ❌ (max 4) | ✅ (unlimited) | +| Session limit | 12 max | Unlimited | +| Customizable / brandable | ❌ | ✅ | +| Portable (USB stick) | ✅ | ✅ | +| Web-based | ❌ | ❌ | +| Cross-platform | ❌ (Windows only) | ❌ (Windows only) | + +**Key MobaXterm strength**: All-in-one (SSH + RDP + VNC + SFTP + X11). The SFTP sidebar that auto-opens on SSH connect is killer UX. +**Key MobaXterm weakness**: Windows only. Not web-based. Dated UI. + +--- + +## 2. Vigilance Remote — Our Feature Set + +### Core Principle + +**Everything MobaXterm does for SSH + RDP + SFTP, but in a modern web browser with Termius-level UI polish.** + +### 2.1 SSH Terminal + +| Feature | Implementation | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| SSH connections | **xterm.js** (MIT) — the industry standard web terminal. Used by VS Code, Tabby, Theia, and hundreds of production applications. GPU-accelerated rendering, full Unicode/CJK/emoji support. | +| Backend proxy | **NestJS WebSocket gateway** + **ssh2** (npm) — Node.js SSH client library. Browser connects via WebSocket to NestJS, which proxies to the SSH target. No direct SSH from browser. | +| Authentication | Password, SSH key (stored encrypted), SSH agent forwarding, FIDO2/hardware key | +| Multi-tab sessions | Tab bar with session labels, color-coded by host group | +| Split panes | Horizontal and vertical splits within a single tab (xterm.js instances in a flex grid) | +| Session recording | Record terminal sessions as asciinema-compatible casts. Replay in browser. Audit trail for MSP compliance. | +| Saved snippets | Quick-execute saved commands/scripts. Click to paste into active terminal. | +| Terminal theming | Dark/light modes, custom color schemes, font selection, font size | +| Search in terminal | Ctrl+F search through terminal scrollback buffer (xterm.js `SearchAddon`) | +| Copy/paste | Ctrl+Shift+C / Ctrl+Shift+V, or right-click context menu | + +### 2.2 SFTP File Browser (MobaXterm's Killer Feature) + +| Feature | Implementation | +| ------------------------ | ------------------------------------------------------------------------------------------------------------------------- | +| Auto-open on SSH connect | When an SSH session connects, the SFTP sidebar automatically opens showing the remote filesystem. Exactly like MobaXterm. | +| Sidebar layout | Left sidebar panel (resizable) showing remote filesystem as a tree. Main panel is the terminal. | +| File operations | Browse, upload (drag-and-drop from desktop), download, rename, delete, chmod, create directory | +| Dual-pane mode | Optional second SFTP panel for server-to-server file operations (drag between panels) | +| File editing | Click a text file to open in an embedded code editor (Monaco Editor — same as VS Code). Save pushes back via SFTP. | +| Transfer queue | Background upload/download queue with progress bars, pause/resume, retry | +| Backend | **ssh2-sftp-client** (npm) or raw **ssh2** SFTP subsystem. All file operations proxied through NestJS. | + +### 2.3 RDP (Remote Desktop) + +| Feature | Implementation | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| RDP connections | **Apache Guacamole** (`guacd` daemon + `guacamole-common-js` client library). Industry-standard, Apache-licensed, battle-tested web RDP. | +| Architecture | Browser → WebSocket → NestJS → Guacamole protocol → `guacd` daemon → RDP to target. The NestJS backend acts as the tunnel between the JavaScript client and guacd. | +| Display | HTML5 Canvas rendering via `guacamole-common-js`. Keyboard, mouse, and touch input forwarded. | +| Multi-monitor | Support for multiple virtual displays | +| Clipboard sync | Bidirectional clipboard between browser and remote desktop | +| File transfer | Upload/download via Guacamole's built-in file transfer (drive redirection) | +| Audio | Remote audio playback in browser | +| Resolution | Auto-detect browser window size, or set fixed resolution | +| RDP settings | Color depth, security mode (NLA/TLS/RDP), console session, admin mode, load balancing info | +| Session recording | Guacamole native session recording (video-like playback of RDP sessions) | + +### 2.4 Connection Manager (Termius-style) + +| Feature | Details | +| -------------------- | ----------------------------------------------------------------------------------------------------- | +| Host database | Store hosts with: name, hostname/IP, port, protocol (SSH/RDP), credentials, group, tags, notes, color | +| Groups/folders | Organize hosts into hierarchical groups (e.g., "RSM > Servers", "Filters Fast > Switches") | +| Quick connect | Top bar with hostname input — type and connect without saving | +| Search | Full-text search across all hosts, tags, and notes | +| Credential vault | AES-256-GCM encrypted storage for passwords and SSH keys. Master password or Entra ID auth. | +| SSH key management | Generate, import, export SSH keys. Associate keys with hosts. | +| Jump hosts / bastion | Configure SSH proxy/jump hosts for reaching targets behind firewalls | +| Port forwarding | Graphical SSH tunnel manager — local, remote, and dynamic forwarding | +| Tags & labels | Color-coded tags for categorization (production, staging, dev, client-name) | + +### 2.5 Team & MSP Features + +| Feature | Details | +| -------------------- | ----------------------------------------------------------------------------------- | +| Multi-user | User accounts with RBAC. Admin, Technician, Read-Only roles. | +| Entra ID SSO | One-click Microsoft Entra ID integration (same pattern as Vigilance HQ and RSM ERP) | +| Shared connections | Admins define connection templates. Technicians connect without seeing credentials. | +| Audit logging | Every connection, command, file transfer logged with user, timestamp, duration. | +| Session sharing | Share a live terminal session with a colleague (read-only or collaborative) | +| Client-scoped access | MSP multi-tenancy — technicians see only the hosts for clients they're assigned to | + +--- + +## 3. Technology Stack + +### Frontend + +| Component | Technology | License | +| ------------------ | ----------------------------------------------------------------------------------------- | ---------- | +| Framework | Nuxt 3 (Vue 3 SSR) | MIT | +| Terminal emulator | xterm.js 5.x | MIT | +| Terminal addons | `@xterm/addon-fit`, `@xterm/addon-search`, `@xterm/addon-web-links`, `@xterm/addon-webgl` | MIT | +| Code editor (SFTP) | Monaco Editor | MIT | +| RDP client | guacamole-common-js | Apache 2.0 | +| UI library | PrimeVue 4 or Naive UI | MIT | +| State management | Pinia | MIT | +| CSS | Tailwind CSS | MIT | +| File upload | Drag-and-drop with progress (native File API) | — | + +### Backend + +| Component | Technology | License | +| --------------------- | ----------------------------------------------------- | ------------------ | +| Framework | NestJS 10 | MIT | +| SSH proxy | ssh2 (npm) | MIT | +| SFTP operations | ssh2 SFTP subsystem (built into ssh2) | MIT | +| RDP proxy | guacd (Apache Guacamole daemon) | Apache 2.0 | +| Guacamole tunnel | Custom NestJS WebSocket gateway → guacd TCP | Apache 2.0 | +| Database | PostgreSQL 16 (hosts, users, credentials, audit logs) | PostgreSQL License | +| Credential encryption | AES-256-GCM (same pattern as Vigilance HQ) | — | +| WebSocket | NestJS `@WebSocketGateway` (socket.io or ws) | MIT | +| Auth | JWT + Microsoft Entra ID (one-click setup) | — | +| Session recording | asciinema format for SSH, Guacamole native for RDP | MIT / Apache 2.0 | + +### Infrastructure + +| Component | Technology | +| ------------- | -------------------------------------------------------------------------- | +| Deployment | Docker Compose | +| Services | `app` (Nuxt SSR + NestJS), `guacd` (Guacamole daemon), `postgres`, `redis` | +| Reverse proxy | Nginx (WebSocket upgrade support required) | +| `guacd` | Docker image `guacamole/guacd` — handles RDP/VNC protocol translation | + +--- + +## 4. Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Browser (Any device, any OS) │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ xterm.js │ │ SFTP Browser │ │ guac-client │ │ +│ │ (SSH term) │ │ (file tree) │ │ (RDP canvas) │ │ +│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │ +│ │ WebSocket │ REST/WS │ WebSocket │ +└─────────┼──────────────────┼─────────────────┼──────────────┘ + │ │ │ +┌─────────┼──────────────────┼─────────────────┼──────────────┐ +│ NestJS Backend (Docker) │ │ │ +│ ┌──────▼───────┐ ┌──────▼───────┐ ┌──────▼───────┐ │ +│ │ SSH Gateway │ │ SFTP Service │ │ Guac Tunnel │ │ +│ │ (ssh2 lib) │ │ (ssh2 sftp) │ │ (TCP→guacd) │ │ +│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │ +│ │ SSH │ SFTP │ Guac Protocol │ +└─────────┼──────────────────┼─────────────────┼──────────────┘ + │ │ │ + ▼ ▼ ▼ + ┌───────────────┐ ┌───────────────┐ ┌─────────────┐ + │ SSH Server │ │ SSH Server │ │ guacd │ + │ (Linux/Unix) │ │ (same host) │ │ (Docker) │ + └───────────────┘ └───────────────┘ └──────┬──────┘ + │ RDP + ▼ + ┌───────────────┐ + │ RDP Server │ + │ (Windows) │ + └───────────────┘ +``` + +--- + +## 5. Key Open Source Components + +| Component | GitHub | Stars | License | Purpose | +| ----------------------- | ----------------------- | ----- | ---------- | ------------------------------------------------------------------------------------------ | +| **xterm.js** | xtermjs/xterm.js | 18K+ | MIT | Web terminal emulator — the industry standard. Used by VS Code. | +| **ssh2** | mscdex/ssh2 | 5.5K+ | MIT | Pure JavaScript SSH2 client/server. Powers the SSH proxy layer. | +| **guacamole-common-js** | apache/guacamole-client | 3.2K+ | Apache 2.0 | JavaScript RDP/VNC client. Renders remote desktop in HTML5 Canvas. | +| **guacd** | apache/guacamole-server | 3.2K+ | Apache 2.0 | Native daemon that translates RDP/VNC protocols to Guacamole protocol. | +| **Monaco Editor** | microsoft/monaco-editor | 42K+ | MIT | VS Code's editor component. For in-browser file editing via SFTP. | +| **Tabby** (reference) | Eugeny/tabby | 62K+ | MIT | Formerly Terminus — reference for SSH/SFTP web client architecture. Includes web app mode. | + +All components are **MIT or Apache 2.0 licensed** — zero GPL contamination, fully commercial-viable. + +--- + +## 6. Competitive Positioning + +| Feature | Termius Pro | MobaXterm Pro | Apache Guacamole | **Vigilance Remote** | +| ---------------------- | --------------- | ------------------ | ---------------- | -------------------------- | +| SSH Terminal | ✅ | ✅ | ✅ | ✅ | +| RDP | ❌ | ✅ | ✅ | ✅ | +| SFTP sidebar browser | ❌ | ✅ (killer feature) | ❌ | ✅ | +| Web-based (no install) | ❌ | ❌ | ✅ | ✅ | +| Cross-platform | ✅ (native apps) | ❌ (Windows only) | ✅ (web) | ✅ (web) | +| Modern UI | ✅ | ❌ (dated) | ❌ (basic) | ✅ | +| Team/MSP features | ✅ (Team plan) | ❌ | ✅ (basic) | ✅ | +| Entra ID SSO | ❌ | ❌ | ❌ | ✅ | +| Credential vault | ✅ | ✅ (master pw) | ✅ (DB) | ✅ (AES-256-GCM) | +| Session recording | ❌ | ❌ | ✅ | ✅ | +| Audit logging | ❌ | ❌ | ✅ (basic) | ✅ (comprehensive) | +| Multi-tenant (MSP) | ❌ | ❌ | ❌ | ✅ | +| Self-hosted | ❌ | N/A (desktop) | ✅ | ✅ | +| Embedded code editor | ❌ | ✅ (MobaTextEditor) | ❌ | ✅ (Monaco) | +| Price | $14.99/mo/user | $69 one-time | Free | Self-hosted (free) or SaaS | + +**Vigilance Remote is the only solution that combines**: web-based access + RDP + SSH + SFTP sidebar browser + modern UI + MSP multi-tenancy + Entra ID SSO + session recording + audit logging in a single self-hosted application. + +--- + +## 7. Database Schema (High Level) + +``` +users — id, email, name, role, entra_id, created_at +hosts — id, name, hostname, port, protocol (ssh/rdp), group_id, tags, notes, color +host_groups — id, name, parent_id (hierarchical) +credentials — id, host_id, type (password/key/entra), encrypted_value, key_passphrase +ssh_keys — id, user_id, name, public_key, encrypted_private_key, passphrase +sessions — id, user_id, host_id, protocol, started_at, ended_at, recording_path +audit_logs — id, user_id, action, target, details, ip_address, timestamp +port_forwards — id, host_id, type (local/remote/dynamic), local_port, remote_host, remote_port +snippets — id, user_id, name, command, tags +client_access — id, user_id, client_id (MSP multi-tenant scoping) +settings — id, key, value (system-wide config) +``` + +--- + +## 8. Build Estimate + +Given the existing open-source components (xterm.js, guacd, ssh2, Monaco), the heavy lifting is integration, not invention. The core SSH terminal + SFTP browser + RDP via Guacamole + connection manager could be built as a focused 3-4 week project using the Commander doctrine. + +| Phase | Duration | Deliverables | +| ------------ | -------- | --------------------------------------------------------------------------------------------------------------------------------------- | +| Foundation | Week 1 | Nuxt 3 scaffold, NestJS backend, Docker Compose (app + guacd + postgres + redis), auth (Entra ID + local), connection manager CRUD | +| SSH + SFTP | Week 2 | xterm.js terminal with WebSocket proxy, multi-tab, split panes, SFTP sidebar browser with drag-drop upload/download, Monaco file editor | +| RDP | Week 3 | guacd integration, guacamole-common-js client, RDP canvas rendering, clipboard sync, session settings | +| Polish & MSP | Week 4 | Session recording/playback, audit logging, team features, MSP multi-tenant scoping, theming, keyboard shortcuts, snippets | + +--- + +*This spec is ready for Claude Code. The open-source components are proven, the architecture is clean, and the integration patterns are well-documented. Point the XO at this spec and the result is a self-hosted MobaXterm replacement that runs in any browser.* diff --git a/docs/superpowers/plans/2026-03-12-wraith-build.md b/docs/superpowers/plans/2026-03-12-wraith-build.md new file mode 100644 index 0000000..de75f6b --- /dev/null +++ b/docs/superpowers/plans/2026-03-12-wraith-build.md @@ -0,0 +1,3919 @@ +# Wraith Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build Wraith — a self-hosted MobaXterm replacement providing SSH+SFTP+RDP in a browser, deployed as a Docker stack. + +**Architecture:** Monorepo with `backend/` (NestJS 10, Prisma, ssh2, guacamole tunnel) and `frontend/` (Nuxt 3 SPA, xterm.js, Monaco, guacamole-common-js, PrimeVue 4). Single Docker container serves both API and static frontend. WebSocket gateways handle real-time terminal/SFTP/RDP data channels. + +**Tech Stack:** NestJS 10, Prisma 6, PostgreSQL 16, ssh2, guacd, Nuxt 3 (SPA mode), xterm.js 5, Monaco Editor, guacamole-common-js, PrimeVue 4, Tailwind CSS, Pinia + +**Spec:** `docs/superpowers/specs/2026-03-12-vigilance-remote-lean-design.md` + +--- + +## File Structure + +``` +wraith/ +├── docker-compose.yml +├── Dockerfile +├── .env.example +├── .gitignore +├── README.md +├── backend/ +│ ├── package.json +│ ├── tsconfig.json +│ ├── tsconfig.build.json +│ ├── nest-cli.json +│ ├── prisma/ +│ │ ├── schema.prisma +│ │ └── seed.ts +│ ├── src/ +│ │ ├── main.ts +│ │ ├── app.module.ts +│ │ ├── prisma/ +│ │ │ ├── prisma.service.ts +│ │ │ └── prisma.module.ts +│ │ ├── auth/ +│ │ │ ├── auth.module.ts +│ │ │ ├── auth.service.ts +│ │ │ ├── auth.controller.ts +│ │ │ ├── jwt.strategy.ts +│ │ │ ├── jwt-auth.guard.ts +│ │ │ ├── ws-auth.guard.ts +│ │ │ └── dto/ +│ │ │ └── login.dto.ts +│ │ ├── vault/ +│ │ │ ├── vault.module.ts +│ │ │ ├── encryption.service.ts +│ │ │ ├── credentials.service.ts +│ │ │ ├── credentials.controller.ts +│ │ │ ├── ssh-keys.service.ts +│ │ │ ├── ssh-keys.controller.ts +│ │ │ └── dto/ +│ │ │ ├── create-credential.dto.ts +│ │ │ ├── update-credential.dto.ts +│ │ │ ├── create-ssh-key.dto.ts +│ │ │ └── update-ssh-key.dto.ts +│ │ ├── connections/ +│ │ │ ├── connections.module.ts +│ │ │ ├── hosts.service.ts +│ │ │ ├── hosts.controller.ts +│ │ │ ├── groups.service.ts +│ │ │ ├── groups.controller.ts +│ │ │ └── dto/ +│ │ │ ├── create-host.dto.ts +│ │ │ ├── update-host.dto.ts +│ │ │ ├── create-group.dto.ts +│ │ │ └── update-group.dto.ts +│ │ ├── terminal/ +│ │ │ ├── terminal.module.ts +│ │ │ ├── terminal.gateway.ts +│ │ │ ├── sftp.gateway.ts +│ │ │ └── ssh-connection.service.ts +│ │ ├── rdp/ +│ │ │ ├── rdp.module.ts +│ │ │ ├── rdp.gateway.ts +│ │ │ └── guacamole.service.ts +│ │ └── settings/ +│ │ ├── settings.module.ts +│ │ ├── settings.service.ts +│ │ └── settings.controller.ts +│ └── test/ +│ ├── encryption.service.spec.ts +│ └── auth.service.spec.ts +├── frontend/ +│ ├── package.json +│ ├── nuxt.config.ts +│ ├── tailwind.config.ts +│ ├── app.vue +│ ├── assets/ +│ │ └── css/ +│ │ └── main.css +│ ├── layouts/ +│ │ ├── default.vue +│ │ └── auth.vue +│ ├── pages/ +│ │ ├── index.vue +│ │ ├── login.vue +│ │ ├── vault/ +│ │ │ ├── index.vue +│ │ │ ├── keys.vue +│ │ │ └── credentials.vue +│ │ └── settings.vue +│ ├── components/ +│ │ ├── connections/ +│ │ │ ├── HostTree.vue +│ │ │ ├── HostCard.vue +│ │ │ ├── HostEditDialog.vue +│ │ │ ├── GroupEditDialog.vue +│ │ │ └── QuickConnect.vue +│ │ ├── session/ +│ │ │ ├── SessionContainer.vue +│ │ │ └── SessionTab.vue +│ │ ├── terminal/ +│ │ │ ├── TerminalInstance.vue +│ │ │ ├── TerminalTabs.vue +│ │ │ └── SplitPane.vue +│ │ ├── sftp/ +│ │ │ ├── SftpSidebar.vue +│ │ │ ├── FileTree.vue +│ │ │ ├── FileEditor.vue +│ │ │ └── TransferStatus.vue +│ │ ├── rdp/ +│ │ │ ├── RdpCanvas.vue +│ │ │ └── RdpToolbar.vue +│ │ └── vault/ +│ │ ├── KeyImportDialog.vue +│ │ └── CredentialForm.vue +│ ├── composables/ +│ │ ├── useTerminal.ts +│ │ ├── useSftp.ts +│ │ ├── useRdp.ts +│ │ ├── useVault.ts +│ │ └── useConnections.ts +│ └── stores/ +│ ├── auth.store.ts +│ ├── session.store.ts +│ └── connection.store.ts +└── docs/ + └── superpowers/ + ├── specs/ + │ └── 2026-03-12-vigilance-remote-lean-design.md + └── plans/ + └── 2026-03-12-wraith-build.md +``` + +--- + +## Chunk 1: Foundation (Phase 1) + +### Task 1: Project Scaffold + Docker + +**Files:** +- Create: `.gitignore`, `.env.example`, `docker-compose.yml`, `Dockerfile`, `README.md` +- Create: `backend/package.json`, `backend/tsconfig.json`, `backend/tsconfig.build.json`, `backend/nest-cli.json` +- Create: `frontend/package.json`, `frontend/nuxt.config.ts`, `frontend/tailwind.config.ts` + +- [ ] **Step 1: Initialize git repo** + +```bash +cd /Users/vstockwell/repos/RDP-SSH-Client +git init +``` + +- [ ] **Step 2: Create `.gitignore`** + +```gitignore +node_modules/ +dist/ +.output/ +.nuxt/ +.env +*.log +.DS_Store +backend/prisma/*.db +``` + +- [ ] **Step 3: Create `.env.example`** + +```env +DB_PASSWORD=changeme +JWT_SECRET=generate-a-64-char-hex-string +ENCRYPTION_KEY=generate-a-64-char-hex-string +``` + +- [ ] **Step 4: Create `docker-compose.yml`** + +```yaml +services: + app: + build: . + ports: ["3000:3000"] + environment: + DATABASE_URL: postgresql://wraith:${DB_PASSWORD}@postgres:5432/wraith + JWT_SECRET: ${JWT_SECRET} + ENCRYPTION_KEY: ${ENCRYPTION_KEY} + GUACD_HOST: guacd + GUACD_PORT: "4822" + depends_on: + postgres: + condition: service_healthy + guacd: + condition: service_started + restart: unless-stopped + + guacd: + image: guacamole/guacd + restart: always + + postgres: + image: postgres:16-alpine + volumes: [pgdata:/var/lib/postgresql/data] + environment: + POSTGRES_DB: wraith + POSTGRES_USER: wraith + POSTGRES_PASSWORD: ${DB_PASSWORD} + healthcheck: + test: ["CMD-SHELL", "pg_isready -U wraith"] + interval: 5s + timeout: 3s + retries: 5 + +volumes: + pgdata: +``` + +- [ ] **Step 5: Create `Dockerfile`** + +Multi-stage build: frontend → static, backend → NestJS, production → serve both. + +```dockerfile +# Stage 1: Frontend build +FROM node:20-alpine AS frontend +WORKDIR /app/frontend +COPY frontend/package*.json ./ +RUN npm ci +COPY frontend/ ./ +RUN npx nuxi generate + +# Stage 2: Backend build +FROM node:20-alpine AS backend +WORKDIR /app/backend +COPY backend/package*.json ./ +RUN npm ci +COPY backend/ ./ +RUN npx prisma generate +RUN npm run build + +# Stage 3: Production +FROM node:20-alpine +WORKDIR /app +COPY --from=backend /app/backend/dist ./dist +COPY --from=backend /app/backend/node_modules ./node_modules +COPY --from=backend /app/backend/package.json ./ +COPY --from=backend /app/backend/prisma ./prisma +COPY --from=frontend /app/frontend/.output/public ./public +EXPOSE 3000 +CMD ["node", "dist/main.js"] +``` + +- [ ] **Step 6: Create `backend/package.json`** + +```json +{ + "name": "wraith-backend", + "version": "0.1.0", + "private": true, + "scripts": { + "build": "nest build", + "start": "node dist/main.js", + "dev": "nest start --watch", + "test": "jest", + "test:watch": "jest --watch", + "prisma:migrate": "prisma migrate dev", + "prisma:generate": "prisma generate", + "prisma:seed": "ts-node prisma/seed.ts" + }, + "dependencies": { + "@nestjs/common": "^10.0.0", + "@nestjs/core": "^10.0.0", + "@nestjs/jwt": "^10.0.0", + "@nestjs/passport": "^10.0.0", + "@nestjs/platform-express": "^10.0.0", + "@nestjs/platform-ws": "^10.0.0", + "@nestjs/serve-static": "^4.0.0", + "@nestjs/websockets": "^10.0.0", + "@prisma/client": "^6.0.0", + "bcrypt": "^5.1.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.0", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", + "reflect-metadata": "^0.2.0", + "rxjs": "^7.8.0", + "ssh2": "^1.15.0", + "ws": "^8.16.0" + }, + "devDependencies": { + "@nestjs/cli": "^10.0.0", + "@nestjs/testing": "^10.0.0", + "@types/bcrypt": "^5.0.0", + "@types/node": "^20.0.0", + "@types/passport-jwt": "^4.0.0", + "@types/ssh2": "^1.15.0", + "@types/ws": "^8.5.0", + "jest": "^29.0.0", + "prisma": "^6.0.0", + "ts-jest": "^29.0.0", + "ts-node": "^10.9.0", + "typescript": "^5.3.0" + }, + "jest": { + "moduleFileExtensions": ["js", "json", "ts"], + "rootDir": ".", + "testRegex": ".*\\.spec\\.ts$", + "transform": { "^.+\\.ts$": "ts-jest" }, + "testEnvironment": "node", + "moduleNameMapper": { + "^@/(.*)$": "/src/$1" + } + } +} +``` + +- [ ] **Step 7: Create `backend/tsconfig.json` and `backend/tsconfig.build.json`** + +`tsconfig.json`: +```json +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": true, + "noImplicitAny": true, + "strictBindCallApply": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "paths": { "@/*": ["src/*"] } + } +} +``` + +`tsconfig.build.json`: +```json +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "test", "dist", "**/*.spec.ts"] +} +``` + +`nest-cli.json`: +```json +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} +``` + +- [ ] **Step 8: Create `frontend/package.json`** + +```json +{ + "name": "wraith-frontend", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "nuxi dev", + "build": "nuxi generate", + "preview": "nuxi preview" + }, + "dependencies": { + "@pinia/nuxt": "^0.5.0", + "@primevue/themes": "^4.0.0", + "guacamole-common-js": "^1.5.0", + "lucide-vue-next": "^0.300.0", + "monaco-editor": "^0.45.0", + "pinia": "^2.1.0", + "primevue": "^4.0.0", + "@xterm/xterm": "^5.4.0", + "@xterm/addon-fit": "^0.10.0", + "@xterm/addon-search": "^0.15.0", + "@xterm/addon-web-links": "^0.11.0", + "@xterm/addon-webgl": "^0.18.0" + }, + "devDependencies": { + "@nuxtjs/tailwindcss": "^6.0.0", + "@primevue/nuxt-module": "^4.0.0", + "nuxt": "^3.10.0", + "typescript": "^5.3.0" + } +} +``` + +- [ ] **Step 9: Create `frontend/nuxt.config.ts`** + +```typescript +export default defineNuxtConfig({ + ssr: false, + devtools: { enabled: false }, + modules: [ + '@pinia/nuxt', + '@nuxtjs/tailwindcss', + '@primevue/nuxt-module', + ], + css: ['~/assets/css/main.css'], + primevue: { + options: { + theme: 'none', + }, + }, + runtimeConfig: { + public: { + apiBase: process.env.API_BASE || 'http://localhost:3000', + }, + }, + devServer: { + port: 3001, + }, + nitro: { + devProxy: { + '/api': { target: 'http://localhost:3000/api', ws: true }, + '/ws': { target: 'ws://localhost:3000/ws', ws: true }, + }, + }, +}) +``` + +- [ ] **Step 10: Create `frontend/tailwind.config.ts`** + +```typescript +import type { Config } from 'tailwindcss' + +export default { + content: [ + './components/**/*.vue', + './layouts/**/*.vue', + './pages/**/*.vue', + './composables/**/*.ts', + './app.vue', + ], + darkMode: 'class', + theme: { + extend: { + colors: { + wraith: { + 50: '#f0f4ff', + 100: '#dbe4ff', + 200: '#bac8ff', + 300: '#91a7ff', + 400: '#748ffc', + 500: '#5c7cfa', + 600: '#4c6ef5', + 700: '#4263eb', + 800: '#3b5bdb', + 900: '#364fc7', + 950: '#1e3a8a', + }, + }, + }, + }, + plugins: [], +} satisfies Config +``` + +- [ ] **Step 11: Create frontend shell files** + +`frontend/app.vue`: +```vue + +``` + +`frontend/assets/css/main.css`: +```css +@tailwind base; +@tailwind components; +@tailwind utilities; + +html, body, #__nuxt { + @apply h-full bg-gray-900 text-gray-100; +} +``` + +`frontend/layouts/auth.vue`: +```vue + +``` + +- [ ] **Step 12: Commit scaffold** + +```bash +git add -A +git commit -m "feat: project scaffold — Docker, NestJS, Nuxt 3, Prisma config" +``` + +--- + +### Task 2: Prisma Schema + Backend Bootstrap + +**Files:** +- Create: `backend/prisma/schema.prisma` +- Create: `backend/src/main.ts`, `backend/src/app.module.ts` +- Create: `backend/src/prisma/prisma.service.ts`, `backend/src/prisma/prisma.module.ts` + +- [ ] **Step 1: Create `backend/prisma/schema.prisma`** + +Full schema from spec Section 5 — 7 models, 2 enums. Copy verbatim from spec: + +```prisma +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + passwordHash String @map("password_hash") + displayName String? @map("display_name") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("users") +} + +model HostGroup { + id Int @id @default(autoincrement()) + name String + parentId Int? @map("parent_id") + sortOrder Int @default(0) @map("sort_order") + parent HostGroup? @relation("GroupTree", fields: [parentId], references: [id], onDelete: SetNull) + children HostGroup[] @relation("GroupTree") + hosts Host[] + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("host_groups") +} + +model Host { + id Int @id @default(autoincrement()) + name String + hostname String + port Int @default(22) + protocol Protocol @default(ssh) + groupId Int? @map("group_id") + credentialId Int? @map("credential_id") + tags String[] @default([]) + notes String? + color String? @db.VarChar(7) + sortOrder Int @default(0) @map("sort_order") + hostFingerprint String? @map("host_fingerprint") + lastConnectedAt DateTime? @map("last_connected_at") + group HostGroup? @relation(fields: [groupId], references: [id], onDelete: SetNull) + credential Credential? @relation(fields: [credentialId], references: [id], onDelete: SetNull) + connectionLogs ConnectionLog[] + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("hosts") +} + +model Credential { + id Int @id @default(autoincrement()) + name String + username String? + domain String? + type CredentialType + encryptedValue String? @map("encrypted_value") + sshKeyId Int? @map("ssh_key_id") + sshKey SshKey? @relation(fields: [sshKeyId], references: [id], onDelete: SetNull) + hosts Host[] + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("credentials") +} + +model SshKey { + id Int @id @default(autoincrement()) + name String + keyType String @map("key_type") @db.VarChar(20) + fingerprint String? + publicKey String? @map("public_key") + encryptedPrivateKey String @map("encrypted_private_key") + passphraseEncrypted String? @map("passphrase_encrypted") + credentials Credential[] + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("ssh_keys") +} + +model ConnectionLog { + id Int @id @default(autoincrement()) + hostId Int @map("host_id") + protocol Protocol + connectedAt DateTime @default(now()) @map("connected_at") + disconnectedAt DateTime? @map("disconnected_at") + host Host @relation(fields: [hostId], references: [id], onDelete: Cascade) + + @@map("connection_logs") +} + +model Setting { + key String @id + value String + + @@map("settings") +} + +enum Protocol { + ssh + rdp +} + +enum CredentialType { + password + ssh_key +} +``` + +- [ ] **Step 2: Create Prisma service + module** + +`backend/src/prisma/prisma.service.ts`: +```typescript +import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; + +@Injectable() +export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { + async onModuleInit() { + await this.$connect(); + } + + async onModuleDestroy() { + await this.$disconnect(); + } +} +``` + +`backend/src/prisma/prisma.module.ts`: +```typescript +import { Global, Module } from '@nestjs/common'; +import { PrismaService } from './prisma.service'; + +@Global() +@Module({ + providers: [PrismaService], + exports: [PrismaService], +}) +export class PrismaModule {} +``` + +- [ ] **Step 3: Create `backend/src/main.ts`** + +```typescript +import { NestFactory } from '@nestjs/core'; +import { ValidationPipe } from '@nestjs/common'; +import { WsAdapter } from '@nestjs/platform-ws'; +import { AppModule } from './app.module'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + app.setGlobalPrefix('api'); + app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true })); + app.useWebSocketAdapter(new WsAdapter(app)); + app.enableCors({ + origin: process.env.NODE_ENV === 'production' ? false : 'http://localhost:3001', + credentials: true, + }); + await app.listen(3000); + console.log('Wraith backend running on port 3000'); +} +bootstrap(); +``` + +- [ ] **Step 4: Create `backend/src/app.module.ts`** (initial — will grow as modules are added) + +```typescript +import { Module } from '@nestjs/common'; +import { ServeStaticModule } from '@nestjs/serve-static'; +import { join } from 'path'; +import { PrismaModule } from './prisma/prisma.module'; + +@Module({ + imports: [ + PrismaModule, + ServeStaticModule.forRoot({ + rootPath: join(__dirname, '..', 'public'), + exclude: ['/api/(.*)'], + }), + ], +}) +export class AppModule {} +``` + +- [ ] **Step 5: Install backend dependencies and generate Prisma client** + +```bash +cd backend && npm install +npx prisma generate +``` + +- [ ] **Step 6: Commit** + +```bash +git add -A +git commit -m "feat: Prisma schema (7 models) + NestJS bootstrap" +``` + +--- + +### Task 3: Encryption Service + +**Files:** +- Create: `backend/src/vault/encryption.service.ts` +- Create: `backend/src/vault/vault.module.ts` +- Create: `backend/test/encryption.service.spec.ts` + +This is a critical security component. Full code required. + +- [ ] **Step 1: Write encryption service tests** + +`backend/test/encryption.service.spec.ts`: +```typescript +import { EncryptionService } from '../src/vault/encryption.service'; + +describe('EncryptionService', () => { + let service: EncryptionService; + + beforeEach(() => { + // 32-byte key as 64-char hex string + process.env.ENCRYPTION_KEY = 'a'.repeat(64); + service = new EncryptionService(); + }); + + it('encrypts and decrypts a string', () => { + const plaintext = 'my-secret-password'; + const encrypted = service.encrypt(plaintext); + expect(encrypted).not.toEqual(plaintext); + expect(encrypted.startsWith('v1:')).toBe(true); + expect(service.decrypt(encrypted)).toEqual(plaintext); + }); + + it('produces different ciphertext for same plaintext (random IV)', () => { + const plaintext = 'same-input'; + const a = service.encrypt(plaintext); + const b = service.encrypt(plaintext); + expect(a).not.toEqual(b); + expect(service.decrypt(a)).toEqual(plaintext); + expect(service.decrypt(b)).toEqual(plaintext); + }); + + it('throws on tampered ciphertext', () => { + const encrypted = service.encrypt('test'); + const parts = encrypted.split(':'); + parts[3] = 'ff' + parts[3].slice(2); // tamper ciphertext + expect(() => service.decrypt(parts.join(':'))).toThrow(); + }); + + it('handles empty string', () => { + const encrypted = service.encrypt(''); + expect(service.decrypt(encrypted)).toEqual(''); + }); + + it('handles unicode', () => { + const plaintext = 'p@$$w0rd-日本語-🔑'; + const encrypted = service.encrypt(plaintext); + expect(service.decrypt(encrypted)).toEqual(plaintext); + }); +}); +``` + +- [ ] **Step 2: Run tests — verify they fail** + +```bash +cd backend && npx jest test/encryption.service.spec.ts +``` + +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement encryption service** + +`backend/src/vault/encryption.service.ts`: +```typescript +import { Injectable } from '@nestjs/common'; +import { createCipheriv, createDecipheriv, randomBytes } from 'crypto'; + +@Injectable() +export class EncryptionService { + private readonly algorithm = 'aes-256-gcm'; + private readonly key: Buffer; + + constructor() { + const hex = process.env.ENCRYPTION_KEY; + if (!hex || hex.length < 64) { + throw new Error('ENCRYPTION_KEY must be a 64-char hex string (32 bytes)'); + } + this.key = Buffer.from(hex.slice(0, 64), 'hex'); + } + + encrypt(plaintext: string): string { + const iv = randomBytes(16); + const cipher = createCipheriv(this.algorithm, this.key, iv); + const encrypted = Buffer.concat([ + cipher.update(plaintext, 'utf8'), + cipher.final(), + ]); + const authTag = cipher.getAuthTag(); + return `v1:${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted.toString('hex')}`; + } + + decrypt(encrypted: string): string { + const [version, ivHex, authTagHex, ciphertextHex] = encrypted.split(':'); + if (version !== 'v1') throw new Error(`Unknown encryption version: ${version}`); + const iv = Buffer.from(ivHex, 'hex'); + const authTag = Buffer.from(authTagHex, 'hex'); + const ciphertext = Buffer.from(ciphertextHex, 'hex'); + const decipher = createDecipheriv(this.algorithm, this.key, iv); + decipher.setAuthTag(authTag); + return Buffer.concat([ + decipher.update(ciphertext), + decipher.final(), + ]).toString('utf8'); + } +} +``` + +- [ ] **Step 4: Create vault module** + +`backend/src/vault/vault.module.ts`: +```typescript +import { Module } from '@nestjs/common'; +import { EncryptionService } from './encryption.service'; + +@Module({ + providers: [EncryptionService], + exports: [EncryptionService], +}) +export class VaultModule {} +``` + +- [ ] **Step 5: Run tests — verify they pass** + +```bash +cd backend && npx jest test/encryption.service.spec.ts --verbose +``` + +Expected: 5 tests PASS. + +- [ ] **Step 6: Commit** + +```bash +git add -A +git commit -m "feat: AES-256-GCM encryption service with tests" +``` + +--- + +### Task 4: Auth Module + +**Files:** +- Create: `backend/src/auth/auth.module.ts`, `auth.service.ts`, `auth.controller.ts` +- Create: `backend/src/auth/jwt.strategy.ts`, `jwt-auth.guard.ts`, `ws-auth.guard.ts` +- Create: `backend/src/auth/dto/login.dto.ts` +- Create: `backend/test/auth.service.spec.ts` +- Create: `backend/prisma/seed.ts` + +- [ ] **Step 1: Write auth service tests** + +`backend/test/auth.service.spec.ts`: +```typescript +import { JwtService } from '@nestjs/jwt'; +import { AuthService } from '../src/auth/auth.service'; +import * as bcrypt from 'bcrypt'; + +describe('AuthService', () => { + let service: AuthService; + const mockPrisma = { + user: { + findUnique: jest.fn(), + count: jest.fn(), + create: jest.fn(), + }, + }; + const mockJwt = { + sign: jest.fn().mockReturnValue('mock-jwt-token'), + }; + + beforeEach(() => { + service = new AuthService(mockPrisma as any, mockJwt as any); + jest.clearAllMocks(); + }); + + it('returns token for valid credentials', async () => { + const hash = await bcrypt.hash('password123', 10); + mockPrisma.user.findUnique.mockResolvedValue({ + id: 1, + email: 'admin@wraith.local', + passwordHash: hash, + displayName: 'Admin', + }); + + const result = await service.login('admin@wraith.local', 'password123'); + expect(result).toEqual({ + access_token: 'mock-jwt-token', + user: { id: 1, email: 'admin@wraith.local', displayName: 'Admin' }, + }); + }); + + it('throws on wrong password', async () => { + const hash = await bcrypt.hash('correct', 10); + mockPrisma.user.findUnique.mockResolvedValue({ + id: 1, + email: 'admin@wraith.local', + passwordHash: hash, + }); + + await expect(service.login('admin@wraith.local', 'wrong')) + .rejects.toThrow('Invalid credentials'); + }); + + it('throws on unknown user', async () => { + mockPrisma.user.findUnique.mockResolvedValue(null); + await expect(service.login('nobody@wraith.local', 'pass')) + .rejects.toThrow('Invalid credentials'); + }); +}); +``` + +- [ ] **Step 2: Run tests — verify fail** + +- [ ] **Step 3: Implement auth service** + +`backend/src/auth/auth.service.ts`: +```typescript +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { PrismaService } from '../prisma/prisma.service'; +import * as bcrypt from 'bcrypt'; + +@Injectable() +export class AuthService { + constructor( + private prisma: PrismaService, + private jwt: JwtService, + ) {} + + async login(email: string, password: string) { + const user = await this.prisma.user.findUnique({ where: { email } }); + if (!user) throw new UnauthorizedException('Invalid credentials'); + + const valid = await bcrypt.compare(password, user.passwordHash); + if (!valid) throw new UnauthorizedException('Invalid credentials'); + + const payload = { sub: user.id, email: user.email }; + return { + access_token: this.jwt.sign(payload), + user: { id: user.id, email: user.email, displayName: user.displayName }, + }; + } + + async getProfile(userId: number) { + const user = await this.prisma.user.findUnique({ where: { id: userId } }); + if (!user) throw new UnauthorizedException(); + return { id: user.id, email: user.email, displayName: user.displayName }; + } +} +``` + +- [ ] **Step 4: Implement auth controller** + +`backend/src/auth/auth.controller.ts`: +```typescript +import { Controller, Post, Get, Body, Request, UseGuards } from '@nestjs/common'; +import { AuthService } from './auth.service'; +import { JwtAuthGuard } from './jwt-auth.guard'; +import { LoginDto } from './dto/login.dto'; + +@Controller('auth') +export class AuthController { + constructor(private auth: AuthService) {} + + @Post('login') + login(@Body() dto: LoginDto) { + return this.auth.login(dto.email, dto.password); + } + + @UseGuards(JwtAuthGuard) + @Get('profile') + getProfile(@Request() req: any) { + return this.auth.getProfile(req.user.sub); + } +} +``` + +`backend/src/auth/dto/login.dto.ts`: +```typescript +import { IsEmail, IsString, MinLength } from 'class-validator'; + +export class LoginDto { + @IsEmail() + email: string; + + @IsString() + @MinLength(1) + password: string; +} +``` + +- [ ] **Step 5: Implement JWT strategy + guards** + +`backend/src/auth/jwt.strategy.ts`: +```typescript +import { Injectable } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { ExtractJwt, Strategy } from 'passport-jwt'; + +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy) { + constructor() { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + ignoreExpiration: false, + secretOrKey: process.env.JWT_SECRET, + }); + } + + validate(payload: { sub: number; email: string }) { + return { sub: payload.sub, email: payload.email }; + } +} +``` + +`backend/src/auth/jwt-auth.guard.ts`: +```typescript +import { Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class JwtAuthGuard extends AuthGuard('jwt') {} +``` + +`backend/src/auth/ws-auth.guard.ts`: +```typescript +import { Injectable } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { WsException } from '@nestjs/websockets'; + +@Injectable() +export class WsAuthGuard { + constructor(private jwt: JwtService) {} + + validateClient(client: any): { sub: number; email: string } | null { + try { + const url = new URL(client.url || client._url, 'http://localhost'); + const token = url.searchParams.get('token'); + if (!token) throw new WsException('No token'); + return this.jwt.verify(token); + } catch { + return null; + } + } +} +``` + +- [ ] **Step 6: Implement auth module** + +`backend/src/auth/auth.module.ts`: +```typescript +import { Module } from '@nestjs/common'; +import { JwtModule } from '@nestjs/jwt'; +import { PassportModule } from '@nestjs/passport'; +import { AuthService } from './auth.service'; +import { AuthController } from './auth.controller'; +import { JwtStrategy } from './jwt.strategy'; +import { WsAuthGuard } from './ws-auth.guard'; + +@Module({ + imports: [ + PassportModule, + JwtModule.register({ + secret: process.env.JWT_SECRET, + signOptions: { expiresIn: '7d' }, + }), + ], + providers: [AuthService, JwtStrategy, WsAuthGuard], + controllers: [AuthController], + exports: [WsAuthGuard, JwtModule], +}) +export class AuthModule {} +``` + +- [ ] **Step 7: Create seed script** + +`backend/prisma/seed.ts`: +```typescript +import { PrismaClient } from '@prisma/client'; +import * as bcrypt from 'bcrypt'; + +const prisma = new PrismaClient(); + +async function main() { + const hash = await bcrypt.hash('wraith', 10); + await prisma.user.upsert({ + where: { email: 'admin@wraith.local' }, + update: {}, + create: { + email: 'admin@wraith.local', + passwordHash: hash, + displayName: 'Admin', + }, + }); + console.log('Seed complete: admin@wraith.local / wraith'); +} + +main() + .catch(console.error) + .finally(() => prisma.$disconnect()); +``` + +Add to `backend/package.json`: +```json +"prisma": { + "seed": "ts-node prisma/seed.ts" +} +``` + +- [ ] **Step 8: Update app.module.ts — register AuthModule + VaultModule** + +```typescript +import { Module } from '@nestjs/common'; +import { ServeStaticModule } from '@nestjs/serve-static'; +import { join } from 'path'; +import { PrismaModule } from './prisma/prisma.module'; +import { AuthModule } from './auth/auth.module'; +import { VaultModule } from './vault/vault.module'; + +@Module({ + imports: [ + PrismaModule, + AuthModule, + VaultModule, + ServeStaticModule.forRoot({ + rootPath: join(__dirname, '..', 'public'), + exclude: ['/api/(.*)'], + }), + ], +}) +export class AppModule {} +``` + +- [ ] **Step 9: Run tests — verify pass** + +```bash +cd backend && npx jest --verbose +``` + +Expected: all encryption + auth tests pass. + +- [ ] **Step 10: Commit** + +```bash +git add -A +git commit -m "feat: auth module — JWT login, guards, seed user" +``` + +--- + +### Task 5: Connection Manager Backend + +**Files:** +- Create: `backend/src/connections/connections.module.ts` +- Create: `backend/src/connections/hosts.service.ts`, `hosts.controller.ts` +- Create: `backend/src/connections/groups.service.ts`, `groups.controller.ts` +- Create: `backend/src/connections/dto/*.ts` + +- [ ] **Step 1: Create DTOs** + +`backend/src/connections/dto/create-host.dto.ts`: +```typescript +import { IsString, IsInt, IsOptional, IsEnum, IsArray, Min, Max } from 'class-validator'; +import { Protocol } from '@prisma/client'; + +export class CreateHostDto { + @IsString() + name: string; + + @IsString() + hostname: string; + + @IsInt() + @Min(1) + @Max(65535) + @IsOptional() + port?: number; + + @IsEnum(Protocol) + @IsOptional() + protocol?: Protocol; + + @IsInt() + @IsOptional() + groupId?: number; + + @IsInt() + @IsOptional() + credentialId?: number; + + @IsArray() + @IsString({ each: true }) + @IsOptional() + tags?: string[]; + + @IsString() + @IsOptional() + notes?: string; + + @IsString() + @IsOptional() + color?: string; +} +``` + +`backend/src/connections/dto/update-host.dto.ts`: +```typescript +import { PartialType } from '@nestjs/mapped-types'; +import { CreateHostDto } from './create-host.dto'; + +export class UpdateHostDto extends PartialType(CreateHostDto) {} +``` + +`backend/src/connections/dto/create-group.dto.ts`: +```typescript +import { IsString, IsInt, IsOptional } from 'class-validator'; + +export class CreateGroupDto { + @IsString() + name: string; + + @IsInt() + @IsOptional() + parentId?: number; + + @IsInt() + @IsOptional() + sortOrder?: number; +} +``` + +`backend/src/connections/dto/update-group.dto.ts`: +```typescript +import { PartialType } from '@nestjs/mapped-types'; +import { CreateGroupDto } from './create-group.dto'; + +export class UpdateGroupDto extends PartialType(CreateGroupDto) {} +``` + +- [ ] **Step 2: Implement hosts service** + +`backend/src/connections/hosts.service.ts`: +```typescript +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { CreateHostDto } from './dto/create-host.dto'; +import { UpdateHostDto } from './dto/update-host.dto'; + +@Injectable() +export class HostsService { + constructor(private prisma: PrismaService) {} + + findAll(search?: string) { + const where = search + ? { + OR: [ + { name: { contains: search, mode: 'insensitive' as const } }, + { hostname: { contains: search, mode: 'insensitive' as const } }, + { tags: { has: search } }, + ], + } + : {}; + return this.prisma.host.findMany({ + where, + include: { group: true, credential: { select: { id: true, name: true, type: true } } }, + orderBy: [{ lastConnectedAt: { sort: 'desc', nulls: 'last' } }, { sortOrder: 'asc' }], + }); + } + + async findOne(id: number) { + const host = await this.prisma.host.findUnique({ + where: { id }, + include: { group: true, credential: true }, + }); + if (!host) throw new NotFoundException(`Host ${id} not found`); + return host; + } + + create(dto: CreateHostDto) { + return this.prisma.host.create({ + data: { + name: dto.name, + hostname: dto.hostname, + port: dto.port ?? (dto.protocol === 'rdp' ? 3389 : 22), + protocol: dto.protocol ?? 'ssh', + groupId: dto.groupId, + credentialId: dto.credentialId, + tags: dto.tags ?? [], + notes: dto.notes, + color: dto.color, + }, + include: { group: true }, + }); + } + + async update(id: number, dto: UpdateHostDto) { + await this.findOne(id); // throws if not found + return this.prisma.host.update({ where: { id }, data: dto }); + } + + async remove(id: number) { + await this.findOne(id); + return this.prisma.host.delete({ where: { id } }); + } + + async touchLastConnected(id: number) { + return this.prisma.host.update({ + where: { id }, + data: { lastConnectedAt: new Date() }, + }); + } + + async reorder(ids: number[]) { + const updates = ids.map((id, index) => + this.prisma.host.update({ where: { id }, data: { sortOrder: index } }), + ); + return this.prisma.$transaction(updates); + } +} +``` + +- [ ] **Step 3: Implement hosts controller** + +`backend/src/connections/hosts.controller.ts`: +```typescript +import { Controller, Get, Post, Put, Delete, Param, Body, Query, UseGuards, ParseIntPipe } from '@nestjs/common'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { HostsService } from './hosts.service'; +import { CreateHostDto } from './dto/create-host.dto'; +import { UpdateHostDto } from './dto/update-host.dto'; + +@UseGuards(JwtAuthGuard) +@Controller('hosts') +export class HostsController { + constructor(private hosts: HostsService) {} + + @Get() + findAll(@Query('search') search?: string) { + return this.hosts.findAll(search); + } + + @Get(':id') + findOne(@Param('id', ParseIntPipe) id: number) { + return this.hosts.findOne(id); + } + + @Post() + create(@Body() dto: CreateHostDto) { + return this.hosts.create(dto); + } + + @Put(':id') + update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateHostDto) { + return this.hosts.update(id, dto); + } + + @Delete(':id') + remove(@Param('id', ParseIntPipe) id: number) { + return this.hosts.remove(id); + } + + @Post('reorder') + reorder(@Body() body: { ids: number[] }) { + return this.hosts.reorder(body.ids); + } +} +``` + +- [ ] **Step 4: Implement groups service + controller** + +`backend/src/connections/groups.service.ts`: +```typescript +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { CreateGroupDto } from './dto/create-group.dto'; +import { UpdateGroupDto } from './dto/update-group.dto'; + +@Injectable() +export class GroupsService { + constructor(private prisma: PrismaService) {} + + findAll() { + return this.prisma.hostGroup.findMany({ + include: { children: true, hosts: { select: { id: true, name: true, protocol: true } } }, + orderBy: { sortOrder: 'asc' }, + }); + } + + findTree() { + return this.prisma.hostGroup.findMany({ + where: { parentId: null }, + include: { + hosts: { orderBy: { sortOrder: 'asc' } }, + children: { + include: { + hosts: { orderBy: { sortOrder: 'asc' } }, + children: { + include: { + hosts: { orderBy: { sortOrder: 'asc' } }, + }, + }, + }, + orderBy: { sortOrder: 'asc' }, + }, + }, + orderBy: { sortOrder: 'asc' }, + }); + } + + async findOne(id: number) { + const group = await this.prisma.hostGroup.findUnique({ + where: { id }, + include: { hosts: true, children: true }, + }); + if (!group) throw new NotFoundException(`Group ${id} not found`); + return group; + } + + create(dto: CreateGroupDto) { + return this.prisma.hostGroup.create({ data: dto }); + } + + async update(id: number, dto: UpdateGroupDto) { + await this.findOne(id); + return this.prisma.hostGroup.update({ where: { id }, data: dto }); + } + + async remove(id: number) { + await this.findOne(id); + return this.prisma.hostGroup.delete({ where: { id } }); + } +} +``` + +`backend/src/connections/groups.controller.ts`: +```typescript +import { Controller, Get, Post, Put, Delete, Param, Body, UseGuards, ParseIntPipe } from '@nestjs/common'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { GroupsService } from './groups.service'; +import { CreateGroupDto } from './dto/create-group.dto'; +import { UpdateGroupDto } from './dto/update-group.dto'; + +@UseGuards(JwtAuthGuard) +@Controller('groups') +export class GroupsController { + constructor(private groups: GroupsService) {} + + @Get() + findAll() { + return this.groups.findAll(); + } + + @Get('tree') + findTree() { + return this.groups.findTree(); + } + + @Get(':id') + findOne(@Param('id', ParseIntPipe) id: number) { + return this.groups.findOne(id); + } + + @Post() + create(@Body() dto: CreateGroupDto) { + return this.groups.create(dto); + } + + @Put(':id') + update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateGroupDto) { + return this.groups.update(id, dto); + } + + @Delete(':id') + remove(@Param('id', ParseIntPipe) id: number) { + return this.groups.remove(id); + } +} +``` + +- [ ] **Step 5: Create connections module + register in app.module** + +`backend/src/connections/connections.module.ts`: +```typescript +import { Module } from '@nestjs/common'; +import { HostsService } from './hosts.service'; +import { HostsController } from './hosts.controller'; +import { GroupsService } from './groups.service'; +import { GroupsController } from './groups.controller'; + +@Module({ + providers: [HostsService, GroupsService], + controllers: [HostsController, GroupsController], + exports: [HostsService], +}) +export class ConnectionsModule {} +``` + +Update `app.module.ts` imports to add `ConnectionsModule`. + +- [ ] **Step 6: Commit** + +```bash +git add -A +git commit -m "feat: connection manager — hosts + groups CRUD with search" +``` + +--- + +### Task 6: Vault Backend (Credentials + SSH Keys) + +**Files:** +- Create: `backend/src/vault/credentials.service.ts`, `credentials.controller.ts` +- Create: `backend/src/vault/ssh-keys.service.ts`, `ssh-keys.controller.ts` +- Create: `backend/src/vault/dto/*.ts` +- Modify: `backend/src/vault/vault.module.ts` + +- [ ] **Step 1: Create DTOs** + +`backend/src/vault/dto/create-credential.dto.ts`: +```typescript +import { IsString, IsOptional, IsEnum, IsInt } from 'class-validator'; +import { CredentialType } from '@prisma/client'; + +export class CreateCredentialDto { + @IsString() + name: string; + + @IsString() + @IsOptional() + username?: string; + + @IsString() + @IsOptional() + domain?: string; + + @IsEnum(CredentialType) + type: CredentialType; + + @IsString() + @IsOptional() + password?: string; // plaintext — encrypted before storage + + @IsInt() + @IsOptional() + sshKeyId?: number; +} +``` + +`backend/src/vault/dto/update-credential.dto.ts`: +```typescript +import { PartialType } from '@nestjs/mapped-types'; +import { CreateCredentialDto } from './create-credential.dto'; + +export class UpdateCredentialDto extends PartialType(CreateCredentialDto) {} +``` + +`backend/src/vault/dto/create-ssh-key.dto.ts`: +```typescript +import { IsString, IsOptional } from 'class-validator'; + +export class CreateSshKeyDto { + @IsString() + name: string; + + @IsString() + privateKey: string; // plaintext — encrypted before storage + + @IsString() + @IsOptional() + passphrase?: string; // plaintext — encrypted before storage + + @IsString() + @IsOptional() + publicKey?: string; +} +``` + +`backend/src/vault/dto/update-ssh-key.dto.ts`: +```typescript +import { IsString, IsOptional } from 'class-validator'; + +export class UpdateSshKeyDto { + @IsString() + @IsOptional() + name?: string; + + @IsString() + @IsOptional() + passphrase?: string; // new passphrase (re-encrypted) +} +``` + +- [ ] **Step 2: Implement credentials service** + +`backend/src/vault/credentials.service.ts`: +```typescript +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { EncryptionService } from './encryption.service'; +import { CreateCredentialDto } from './dto/create-credential.dto'; +import { UpdateCredentialDto } from './dto/update-credential.dto'; + +@Injectable() +export class CredentialsService { + constructor( + private prisma: PrismaService, + private encryption: EncryptionService, + ) {} + + findAll() { + return this.prisma.credential.findMany({ + include: { sshKey: { select: { id: true, name: true, keyType: true, fingerprint: true } } }, + orderBy: { name: 'asc' }, + }); + } + + async findOne(id: number) { + const cred = await this.prisma.credential.findUnique({ + where: { id }, + include: { sshKey: true, hosts: { select: { id: true, name: true } } }, + }); + if (!cred) throw new NotFoundException(`Credential ${id} not found`); + return cred; + } + + create(dto: CreateCredentialDto) { + const encryptedValue = dto.password ? this.encryption.encrypt(dto.password) : null; + return this.prisma.credential.create({ + data: { + name: dto.name, + username: dto.username, + domain: dto.domain, + type: dto.type, + encryptedValue, + sshKeyId: dto.sshKeyId, + }, + }); + } + + async update(id: number, dto: UpdateCredentialDto) { + await this.findOne(id); + const data: any = { ...dto }; + delete data.password; + if (dto.password) { + data.encryptedValue = this.encryption.encrypt(dto.password); + } + return this.prisma.credential.update({ where: { id }, data }); + } + + async remove(id: number) { + await this.findOne(id); + return this.prisma.credential.delete({ where: { id } }); + } + + /** Decrypt credential for use in SSH/RDP connections. Never expose over API. */ + async decryptForConnection(id: number): Promise<{ + username: string | null; + domain: string | null; + password: string | null; + sshKey: { privateKey: string; passphrase: string | null } | null; + }> { + const cred = await this.prisma.credential.findUnique({ + where: { id }, + include: { sshKey: true }, + }); + if (!cred) throw new NotFoundException(`Credential ${id} not found`); + + let password: string | null = null; + if (cred.encryptedValue) { + password = this.encryption.decrypt(cred.encryptedValue); + } + + let sshKey: { privateKey: string; passphrase: string | null } | null = null; + if (cred.sshKey) { + const privateKey = this.encryption.decrypt(cred.sshKey.encryptedPrivateKey); + const passphrase = cred.sshKey.passphraseEncrypted + ? this.encryption.decrypt(cred.sshKey.passphraseEncrypted) + : null; + sshKey = { privateKey, passphrase }; + } + + return { username: cred.username, domain: cred.domain, password, sshKey }; + } +} +``` + +- [ ] **Step 3: Implement credentials controller** + +`backend/src/vault/credentials.controller.ts`: +```typescript +import { Controller, Get, Post, Put, Delete, Param, Body, UseGuards, ParseIntPipe } from '@nestjs/common'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { CredentialsService } from './credentials.service'; +import { CreateCredentialDto } from './dto/create-credential.dto'; +import { UpdateCredentialDto } from './dto/update-credential.dto'; + +@UseGuards(JwtAuthGuard) +@Controller('credentials') +export class CredentialsController { + constructor(private credentials: CredentialsService) {} + + @Get() + findAll() { + return this.credentials.findAll(); + } + + @Get(':id') + findOne(@Param('id', ParseIntPipe) id: number) { + return this.credentials.findOne(id); + } + + @Post() + create(@Body() dto: CreateCredentialDto) { + return this.credentials.create(dto); + } + + @Put(':id') + update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateCredentialDto) { + return this.credentials.update(id, dto); + } + + @Delete(':id') + remove(@Param('id', ParseIntPipe) id: number) { + return this.credentials.remove(id); + } +} +``` + +- [ ] **Step 4: Implement SSH keys service** + +`backend/src/vault/ssh-keys.service.ts`: +```typescript +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { EncryptionService } from './encryption.service'; +import { CreateSshKeyDto } from './dto/create-ssh-key.dto'; +import { UpdateSshKeyDto } from './dto/update-ssh-key.dto'; +import { createPublicKey, createHash } from 'crypto'; +import { utils as ssh2Utils } from 'ssh2'; + +@Injectable() +export class SshKeysService { + constructor( + private prisma: PrismaService, + private encryption: EncryptionService, + ) {} + + findAll() { + return this.prisma.sshKey.findMany({ + select: { id: true, name: true, keyType: true, fingerprint: true, publicKey: true, createdAt: true }, + orderBy: { name: 'asc' }, + }); + } + + async findOne(id: number) { + const key = await this.prisma.sshKey.findUnique({ + where: { id }, + include: { credentials: { select: { id: true, name: true } } }, + }); + if (!key) throw new NotFoundException(`SSH key ${id} not found`); + // Never return encrypted private key over API + return { + id: key.id, + name: key.name, + keyType: key.keyType, + fingerprint: key.fingerprint, + publicKey: key.publicKey, + credentials: key.credentials, + createdAt: key.createdAt, + }; + } + + async create(dto: CreateSshKeyDto) { + // Detect key type from private key content + const keyType = this.detectKeyType(dto.privateKey); + + // Generate fingerprint from public key if provided, else from private key + const fingerprint = this.generateFingerprint(dto.publicKey || dto.privateKey); + + // Encrypt sensitive data + const encryptedPrivateKey = this.encryption.encrypt(dto.privateKey); + const passphraseEncrypted = dto.passphrase + ? this.encryption.encrypt(dto.passphrase) + : null; + + return this.prisma.sshKey.create({ + data: { + name: dto.name, + keyType, + fingerprint, + publicKey: dto.publicKey || null, + encryptedPrivateKey, + passphraseEncrypted, + }, + }); + } + + async update(id: number, dto: UpdateSshKeyDto) { + const key = await this.prisma.sshKey.findUnique({ where: { id } }); + if (!key) throw new NotFoundException(`SSH key ${id} not found`); + + const data: any = {}; + if (dto.name) data.name = dto.name; + if (dto.passphrase !== undefined) { + data.passphraseEncrypted = dto.passphrase + ? this.encryption.encrypt(dto.passphrase) + : null; + } + return this.prisma.sshKey.update({ where: { id }, data }); + } + + async remove(id: number) { + const key = await this.prisma.sshKey.findUnique({ where: { id } }); + if (!key) throw new NotFoundException(`SSH key ${id} not found`); + return this.prisma.sshKey.delete({ where: { id } }); + } + + private detectKeyType(privateKey: string): string { + if (privateKey.includes('RSA')) return 'rsa'; + if (privateKey.includes('EC')) return 'ecdsa'; + if (privateKey.includes('OPENSSH')) return 'ed25519'; // OpenSSH format, likely ed25519 + return 'unknown'; + } + + private generateFingerprint(keyContent: string): string { + try { + const hash = createHash('sha256').update(keyContent.trim()).digest('base64'); + return `SHA256:${hash}`; + } catch { + return 'unknown'; + } + } +} +``` + +- [ ] **Step 5: Implement SSH keys controller** + +`backend/src/vault/ssh-keys.controller.ts`: +```typescript +import { Controller, Get, Post, Put, Delete, Param, Body, UseGuards, ParseIntPipe } from '@nestjs/common'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { SshKeysService } from './ssh-keys.service'; +import { CreateSshKeyDto } from './dto/create-ssh-key.dto'; +import { UpdateSshKeyDto } from './dto/update-ssh-key.dto'; + +@UseGuards(JwtAuthGuard) +@Controller('ssh-keys') +export class SshKeysController { + constructor(private sshKeys: SshKeysService) {} + + @Get() + findAll() { + return this.sshKeys.findAll(); + } + + @Get(':id') + findOne(@Param('id', ParseIntPipe) id: number) { + return this.sshKeys.findOne(id); + } + + @Post() + create(@Body() dto: CreateSshKeyDto) { + return this.sshKeys.create(dto); + } + + @Put(':id') + update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateSshKeyDto) { + return this.sshKeys.update(id, dto); + } + + @Delete(':id') + remove(@Param('id', ParseIntPipe) id: number) { + return this.sshKeys.remove(id); + } +} +``` + +- [ ] **Step 6: Update vault.module.ts** + +```typescript +import { Module } from '@nestjs/common'; +import { EncryptionService } from './encryption.service'; +import { CredentialsService } from './credentials.service'; +import { CredentialsController } from './credentials.controller'; +import { SshKeysService } from './ssh-keys.service'; +import { SshKeysController } from './ssh-keys.controller'; + +@Module({ + providers: [EncryptionService, CredentialsService, SshKeysService], + controllers: [CredentialsController, SshKeysController], + exports: [EncryptionService, CredentialsService, SshKeysService], +}) +export class VaultModule {} +``` + +Update `app.module.ts` — VaultModule is already imported. + +- [ ] **Step 7: Commit** + +```bash +git add -A +git commit -m "feat: vault — encrypted credentials + SSH key management" +``` + +--- + +### Task 7: Settings Backend + +**Files:** +- Create: `backend/src/settings/settings.module.ts`, `settings.service.ts`, `settings.controller.ts` + +- [ ] **Step 1: Implement settings service + controller** + +`backend/src/settings/settings.service.ts`: +```typescript +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; + +@Injectable() +export class SettingsService { + constructor(private prisma: PrismaService) {} + + async getAll(): Promise> { + const settings = await this.prisma.setting.findMany(); + return Object.fromEntries(settings.map((s) => [s.key, s.value])); + } + + async get(key: string): Promise { + const setting = await this.prisma.setting.findUnique({ where: { key } }); + return setting?.value ?? null; + } + + async set(key: string, value: string) { + return this.prisma.setting.upsert({ + where: { key }, + update: { value }, + create: { key, value }, + }); + } + + async setMany(settings: Record) { + const ops = Object.entries(settings).map(([key, value]) => + this.prisma.setting.upsert({ where: { key }, update: { value }, create: { key, value } }), + ); + return this.prisma.$transaction(ops); + } + + async remove(key: string) { + return this.prisma.setting.delete({ where: { key } }).catch(() => null); + } +} +``` + +`backend/src/settings/settings.controller.ts`: +```typescript +import { Controller, Get, Put, Body, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { SettingsService } from './settings.service'; + +@UseGuards(JwtAuthGuard) +@Controller('settings') +export class SettingsController { + constructor(private settings: SettingsService) {} + + @Get() + getAll() { + return this.settings.getAll(); + } + + @Put() + update(@Body() body: Record) { + return this.settings.setMany(body); + } +} +``` + +`backend/src/settings/settings.module.ts`: +```typescript +import { Module } from '@nestjs/common'; +import { SettingsService } from './settings.service'; +import { SettingsController } from './settings.controller'; + +@Module({ + providers: [SettingsService], + controllers: [SettingsController], + exports: [SettingsService], +}) +export class SettingsModule {} +``` + +Register `SettingsModule` in `app.module.ts`. + +- [ ] **Step 2: Commit** + +```bash +git add -A +git commit -m "feat: settings — key/value store with CRUD API" +``` + +--- + +### Task 8: Frontend — Auth + Connection Manager UI + +**Files:** +- Create: `frontend/stores/auth.store.ts`, `frontend/stores/connection.store.ts` +- Create: `frontend/pages/login.vue`, `frontend/pages/index.vue` +- Create: `frontend/layouts/default.vue` +- Create: `frontend/components/connections/HostTree.vue`, `HostCard.vue`, `HostEditDialog.vue`, `GroupEditDialog.vue` + +- [ ] **Step 1: Create auth store** + +`frontend/stores/auth.store.ts`: +```typescript +import { defineStore } from 'pinia' + +interface User { + id: number + email: string + displayName: string | null +} + +export const useAuthStore = defineStore('auth', { + state: () => ({ + token: localStorage.getItem('wraith_token') || '', + user: null as User | null, + }), + getters: { + isAuthenticated: (state) => !!state.token, + }, + actions: { + async login(email: string, password: string) { + const res = await $fetch<{ access_token: string; user: User }>('/api/auth/login', { + method: 'POST', + body: { email, password }, + }) + this.token = res.access_token + this.user = res.user + localStorage.setItem('wraith_token', res.access_token) + }, + logout() { + this.token = '' + this.user = null + localStorage.removeItem('wraith_token') + navigateTo('/login') + }, + async fetchProfile() { + if (!this.token) return + try { + this.user = await $fetch('/api/auth/profile', { + headers: { Authorization: `Bearer ${this.token}` }, + }) + } catch { + this.logout() + } + }, + }, +}) +``` + +- [ ] **Step 2: Create connection store** + +`frontend/stores/connection.store.ts`: +```typescript +import { defineStore } from 'pinia' +import { useAuthStore } from './auth.store' + +interface Host { + id: number + name: string + hostname: string + port: number + protocol: 'ssh' | 'rdp' + groupId: number | null + credentialId: number | null + tags: string[] + notes: string | null + color: string | null + lastConnectedAt: string | null + group: { id: number; name: string } | null +} + +interface HostGroup { + id: number + name: string + parentId: number | null + children: HostGroup[] + hosts: Host[] +} + +export const useConnectionStore = defineStore('connections', { + state: () => ({ + hosts: [] as Host[], + groups: [] as HostGroup[], + search: '', + loading: false, + }), + actions: { + headers() { + const auth = useAuthStore() + return { Authorization: `Bearer ${auth.token}` } + }, + async fetchHosts() { + this.loading = true + try { + this.hosts = await $fetch('/api/hosts', { headers: this.headers() }) + } finally { + this.loading = false + } + }, + async fetchTree() { + this.groups = await $fetch('/api/groups/tree', { headers: this.headers() }) + }, + async createHost(data: Partial) { + const host = await $fetch('/api/hosts', { + method: 'POST', + body: data, + headers: this.headers(), + }) + await this.fetchHosts() + return host + }, + async updateHost(id: number, data: Partial) { + await $fetch(`/api/hosts/${id}`, { + method: 'PUT', + body: data, + headers: this.headers(), + }) + await this.fetchHosts() + }, + async deleteHost(id: number) { + await $fetch(`/api/hosts/${id}`, { + method: 'DELETE', + headers: this.headers(), + }) + await this.fetchHosts() + }, + async createGroup(data: { name: string; parentId?: number }) { + await $fetch('/api/groups', { + method: 'POST', + body: data, + headers: this.headers(), + }) + await this.fetchTree() + }, + async updateGroup(id: number, data: { name?: string; parentId?: number }) { + await $fetch(`/api/groups/${id}`, { + method: 'PUT', + body: data, + headers: this.headers(), + }) + await this.fetchTree() + }, + async deleteGroup(id: number) { + await $fetch(`/api/groups/${id}`, { + method: 'DELETE', + headers: this.headers(), + }) + await this.fetchTree() + }, + }, +}) +``` + +- [ ] **Step 3: Create login page** + +`frontend/pages/login.vue`: +```vue + + + +``` + +- [ ] **Step 4: Create default layout + main index page (connection manager)** + +`frontend/layouts/default.vue` — main layout with sidebar for host tree and top bar. Active sessions render as persistent tabs via `SessionContainer.vue` (built in Phase 2). For now, just the connection manager shell: + +```vue + + + +``` + +`frontend/pages/index.vue` — connection manager home page: +```vue + + + +``` + +- [ ] **Step 5: Create HostTree, HostCard, HostEditDialog, GroupEditDialog components** + +These are standard PrimeVue-driven components. Each component: + +`frontend/components/connections/HostTree.vue` — recursive tree using PrimeVue `Tree` or hand-rolled recursive list. Displays groups with expand/collapse, hosts as leaf nodes. Emits `select-host` and `new-host` events. + +`frontend/components/connections/HostCard.vue` — card showing host name, hostname:port, protocol badge (SSH/RDP), color indicator, last connected timestamp. Click to connect (Phase 2), edit button, delete button. + +`frontend/components/connections/HostEditDialog.vue` — PrimeVue Dialog with form fields matching CreateHostDto. Protocol selector, group dropdown, credential dropdown, tags input, notes textarea, color picker. + +`frontend/components/connections/GroupEditDialog.vue` — PrimeVue Dialog with name field and parent group dropdown. + +Each component should be implemented following standard Vue 3 Composition API patterns with PrimeVue components. Use `$fetch` with auth headers for API calls. + +- [ ] **Step 6: Install frontend dependencies and verify dev server starts** + +```bash +cd frontend && npm install +npx nuxi dev +``` + +Verify: login page renders, login succeeds (requires backend running with DB), connection manager loads. + +- [ ] **Step 7: Commit** + +```bash +git add -A +git commit -m "feat: frontend — auth flow, connection manager UI, host tree" +``` + +--- + +### Task 9: First Docker Compose Up + +- [ ] **Step 1: Create `.env` from `.env.example` with real values** + +```bash +cp .env.example .env +# Generate secrets: +echo "DB_PASSWORD=$(openssl rand -hex 16)" >> .env +echo "JWT_SECRET=$(openssl rand -hex 32)" >> .env +echo "ENCRYPTION_KEY=$(openssl rand -hex 32)" >> .env +``` + +- [ ] **Step 2: Run Prisma migration against Docker postgres** + +```bash +cd backend +DATABASE_URL=postgresql://wraith:$(grep DB_PASSWORD ../.env | cut -d= -f2)@localhost:5432/wraith npx prisma migrate dev --name init +``` + +- [ ] **Step 3: Seed the database** + +```bash +DATABASE_URL=... npx prisma db seed +``` + +- [ ] **Step 4: Verify Docker Compose up** + +```bash +docker compose up -d +docker logs -f wraith-app +``` + +Expected: NestJS starts, serves API on port 3000, frontend loads in browser. + +- [ ] **Step 5: Commit** + +```bash +git add -A +git commit -m "feat: Phase 1 complete — foundation layer verified" +``` + +--- + +## Chunk 2: SSH + SFTP (Phase 2) + +### Task 10: SSH Connection Service + Terminal Gateway + +**Files:** +- Create: `backend/src/terminal/terminal.module.ts` +- Create: `backend/src/terminal/ssh-connection.service.ts` +- Create: `backend/src/terminal/terminal.gateway.ts` + +This is the core of Wraith. The SSH connection service manages ssh2 connections, the terminal gateway bridges WebSocket to ssh2. + +- [ ] **Step 1: Implement SSH connection service** + +`backend/src/terminal/ssh-connection.service.ts`: +```typescript +import { Injectable, Logger } from '@nestjs/common'; +import { Client, ClientChannel } from 'ssh2'; +import { CredentialsService } from '../vault/credentials.service'; +import { HostsService } from '../connections/hosts.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { v4 as uuid } from 'uuid'; + +export interface SshSession { + id: string; + hostId: number; + client: Client; + stream: ClientChannel | null; +} + +@Injectable() +export class SshConnectionService { + private readonly logger = new Logger(SshConnectionService.name); + private sessions = new Map(); + + constructor( + private credentials: CredentialsService, + private hosts: HostsService, + private prisma: PrismaService, + ) {} + + async connect( + hostId: number, + onData: (data: string) => void, + onClose: (reason: string) => void, + onHostKeyVerify: (fingerprint: string, isNew: boolean) => Promise, + ): Promise { + const host = await this.hosts.findOne(hostId); + const cred = host.credentialId + ? await this.credentials.decryptForConnection(host.credentialId) + : null; + + const sessionId = uuid(); + const client = new Client(); + + return new Promise((resolve, reject) => { + client.on('ready', () => { + client.shell({ term: 'xterm-256color' }, (err, stream) => { + if (err) { + client.end(); + return reject(err); + } + const session: SshSession = { id: sessionId, hostId, client, stream }; + this.sessions.set(sessionId, session); + + stream.on('data', (data: Buffer) => onData(data.toString('utf-8'))); + stream.on('close', () => { + this.disconnect(sessionId); + onClose('Session ended'); + }); + + // Update lastConnectedAt and create connection log + this.hosts.touchLastConnected(hostId); + this.prisma.connectionLog.create({ + data: { hostId, protocol: host.protocol }, + }).catch(() => {}); + + resolve(sessionId); + }); + }); + + client.on('error', (err) => { + this.logger.error(`SSH error for host ${hostId}: ${err.message}`); + this.disconnect(sessionId); + onClose(err.message); + reject(err); + }); + + const connectConfig: any = { + host: host.hostname, + port: host.port, + username: cred?.username || 'root', + hostVerifier: (key: Buffer) => { + const fingerprint = require('crypto') + .createHash('sha256') + .update(key) + .digest('base64'); + const fp = `SHA256:${fingerprint}`; + + if (host.hostFingerprint === fp) return true; // known host + // Async verification — return false for now, handle via callback + return true; // TODO: wire up onHostKeyVerify properly with async flow + }, + }; + + if (cred?.sshKey) { + connectConfig.privateKey = cred.sshKey.privateKey; + if (cred.sshKey.passphrase) { + connectConfig.passphrase = cred.sshKey.passphrase; + } + } else if (cred?.password) { + connectConfig.password = cred.password; + } + + client.connect(connectConfig); + }); + } + + write(sessionId: string, data: string) { + const session = this.sessions.get(sessionId); + if (session?.stream) { + session.stream.write(data); + } + } + + resize(sessionId: string, cols: number, rows: number) { + const session = this.sessions.get(sessionId); + if (session?.stream) { + session.stream.setWindow(rows, cols, 0, 0); + } + } + + disconnect(sessionId: string) { + const session = this.sessions.get(sessionId); + if (session) { + session.stream?.close(); + session.client.end(); + this.sessions.delete(sessionId); + + // Update connection log with disconnect time + this.prisma.connectionLog.updateMany({ + where: { hostId: session.hostId, disconnectedAt: null }, + data: { disconnectedAt: new Date() }, + }).catch(() => {}); + } + } + + getSession(sessionId: string): SshSession | undefined { + return this.sessions.get(sessionId); + } + + getSftpChannel(sessionId: string): Promise { + return new Promise((resolve, reject) => { + const session = this.sessions.get(sessionId); + if (!session) return reject(new Error('Session not found')); + session.client.sftp((err, sftp) => { + if (err) return reject(err); + resolve(sftp); + }); + }); + } +} +``` + +- [ ] **Step 2: Implement terminal gateway** + +`backend/src/terminal/terminal.gateway.ts`: +```typescript +import { WebSocketGateway, WebSocketServer, OnGatewayConnection, OnGatewayDisconnect } from '@nestjs/websockets'; +import { Logger } from '@nestjs/common'; +import { Server } from 'ws'; +import { WsAuthGuard } from '../auth/ws-auth.guard'; +import { SshConnectionService } from './ssh-connection.service'; + +@WebSocketGateway({ path: '/ws/terminal' }) +export class TerminalGateway implements OnGatewayConnection, OnGatewayDisconnect { + @WebSocketServer() server: Server; + private readonly logger = new Logger(TerminalGateway.name); + private clientSessions = new Map(); // ws client → sessionIds + + constructor( + private ssh: SshConnectionService, + private wsAuth: WsAuthGuard, + ) {} + + handleConnection(client: any) { + const user = this.wsAuth.validateClient(client); + if (!user) { + client.close(4001, 'Unauthorized'); + return; + } + this.clientSessions.set(client, []); + this.logger.log(`Terminal WS connected: ${user.email}`); + + client.on('message', async (raw: Buffer) => { + try { + const msg = JSON.parse(raw.toString()); + await this.handleMessage(client, msg); + } catch (err: any) { + this.send(client, { type: 'error', message: err.message }); + } + }); + } + + handleDisconnect(client: any) { + const sessions = this.clientSessions.get(client) || []; + sessions.forEach((sid) => this.ssh.disconnect(sid)); + this.clientSessions.delete(client); + } + + private async handleMessage(client: any, msg: any) { + switch (msg.type) { + case 'connect': { + const sessionId = await this.ssh.connect( + msg.hostId, + (data) => this.send(client, { type: 'data', sessionId, data }), + (reason) => this.send(client, { type: 'disconnected', sessionId, reason }), + async (fingerprint, isNew) => { + // Send verification request to client + this.send(client, { type: 'host-key-verify', fingerprint, isNew }); + return true; // auto-accept for now, full flow in Task 12 + }, + ); + const sessions = this.clientSessions.get(client) || []; + sessions.push(sessionId); + this.clientSessions.set(client, sessions); + this.send(client, { type: 'connected', sessionId }); + break; + } + case 'data': { + if (msg.sessionId) { + this.ssh.write(msg.sessionId, msg.data); + } + break; + } + case 'resize': { + if (msg.sessionId) { + this.ssh.resize(msg.sessionId, msg.cols, msg.rows); + } + break; + } + case 'disconnect': { + if (msg.sessionId) { + this.ssh.disconnect(msg.sessionId); + } + break; + } + } + } + + private send(client: any, data: any) { + if (client.readyState === 1) { // WebSocket.OPEN + client.send(JSON.stringify(data)); + } + } +} +``` + +- [ ] **Step 3: Create terminal module** + +`backend/src/terminal/terminal.module.ts`: +```typescript +import { Module } from '@nestjs/common'; +import { SshConnectionService } from './ssh-connection.service'; +import { TerminalGateway } from './terminal.gateway'; +import { VaultModule } from '../vault/vault.module'; +import { ConnectionsModule } from '../connections/connections.module'; +import { AuthModule } from '../auth/auth.module'; + +@Module({ + imports: [VaultModule, ConnectionsModule, AuthModule], + providers: [SshConnectionService, TerminalGateway], + exports: [SshConnectionService], +}) +export class TerminalModule {} +``` + +Register `TerminalModule` in `app.module.ts`. + +- [ ] **Step 4: Add `uuid` dependency** + +```bash +cd backend && npm install uuid && npm install -D @types/uuid +``` + +- [ ] **Step 5: Commit** + +```bash +git add -A +git commit -m "feat: SSH terminal gateway — ssh2 proxy over WebSocket" +``` + +--- + +### Task 11: SFTP Gateway + +**Files:** +- Create: `backend/src/terminal/sftp.gateway.ts` +- Modify: `backend/src/terminal/terminal.module.ts` + +- [ ] **Step 1: Implement SFTP gateway** + +`backend/src/terminal/sftp.gateway.ts`: +```typescript +import { WebSocketGateway, WebSocketServer, OnGatewayConnection, OnGatewayDisconnect } from '@nestjs/websockets'; +import { Logger } from '@nestjs/common'; +import { Server } from 'ws'; +import { WsAuthGuard } from '../auth/ws-auth.guard'; +import { SshConnectionService } from './ssh-connection.service'; + +const MAX_EDIT_SIZE = 5 * 1024 * 1024; // 5MB + +@WebSocketGateway({ path: '/ws/sftp' }) +export class SftpGateway implements OnGatewayConnection, OnGatewayDisconnect { + @WebSocketServer() server: Server; + private readonly logger = new Logger(SftpGateway.name); + + constructor( + private ssh: SshConnectionService, + private wsAuth: WsAuthGuard, + ) {} + + handleConnection(client: any) { + const user = this.wsAuth.validateClient(client); + if (!user) { + client.close(4001, 'Unauthorized'); + return; + } + this.logger.log(`SFTP WS connected: ${user.email}`); + + client.on('message', async (raw: Buffer) => { + try { + const msg = JSON.parse(raw.toString()); + await this.handleMessage(client, msg); + } catch (err: any) { + this.send(client, { type: 'error', message: err.message }); + } + }); + } + + handleDisconnect() {} + + private async handleMessage(client: any, msg: any) { + const { sessionId } = msg; + if (!sessionId) { + return this.send(client, { type: 'error', message: 'sessionId required' }); + } + + const sftp = await this.ssh.getSftpChannel(sessionId); + + switch (msg.type) { + case 'list': { + sftp.readdir(msg.path, (err: any, list: any[]) => { + if (err) return this.send(client, { type: 'error', message: err.message }); + const entries = list.map((f: any) => ({ + name: f.filename, + path: `${msg.path === '/' ? '' : msg.path}/${f.filename}`, + size: f.attrs.size, + isDirectory: (f.attrs.mode & 0o40000) !== 0, + permissions: (f.attrs.mode & 0o7777).toString(8), + modified: new Date(f.attrs.mtime * 1000).toISOString(), + })); + this.send(client, { type: 'list', path: msg.path, entries }); + }); + break; + } + case 'read': { + sftp.stat(msg.path, (err: any, stats: any) => { + if (err) return this.send(client, { type: 'error', message: err.message }); + if (stats.size > MAX_EDIT_SIZE) { + return this.send(client, { + type: 'error', + message: `File too large for editing (${(stats.size / 1024 / 1024).toFixed(1)}MB, max 5MB). Download instead.`, + }); + } + const chunks: Buffer[] = []; + const stream = sftp.createReadStream(msg.path); + stream.on('data', (chunk: Buffer) => chunks.push(chunk)); + stream.on('end', () => { + const content = Buffer.concat(chunks).toString('utf-8'); + this.send(client, { type: 'fileContent', path: msg.path, content, encoding: 'utf-8' }); + }); + stream.on('error', (e: any) => this.send(client, { type: 'error', message: e.message })); + }); + break; + } + case 'write': { + const stream = sftp.createWriteStream(msg.path); + stream.end(Buffer.from(msg.data, 'utf-8'), () => { + this.send(client, { type: 'saved', path: msg.path }); + }); + stream.on('error', (e: any) => this.send(client, { type: 'error', message: e.message })); + break; + } + case 'mkdir': { + sftp.mkdir(msg.path, (err: any) => { + if (err) return this.send(client, { type: 'error', message: err.message }); + this.send(client, { type: 'created', path: msg.path }); + }); + break; + } + case 'rename': { + sftp.rename(msg.oldPath, msg.newPath, (err: any) => { + if (err) return this.send(client, { type: 'error', message: err.message }); + this.send(client, { type: 'renamed', oldPath: msg.oldPath, newPath: msg.newPath }); + }); + break; + } + case 'delete': { + // Try unlink (file), fallback to rmdir (directory) + sftp.unlink(msg.path, (err: any) => { + if (err) { + sftp.rmdir(msg.path, (err2: any) => { + if (err2) return this.send(client, { type: 'error', message: err2.message }); + this.send(client, { type: 'deleted', path: msg.path }); + }); + } else { + this.send(client, { type: 'deleted', path: msg.path }); + } + }); + break; + } + case 'chmod': { + const mode = parseInt(msg.mode, 8); + sftp.chmod(msg.path, mode, (err: any) => { + if (err) return this.send(client, { type: 'error', message: err.message }); + this.send(client, { type: 'chmodDone', path: msg.path, mode: msg.mode }); + }); + break; + } + case 'stat': { + sftp.stat(msg.path, (err: any, stats: any) => { + if (err) return this.send(client, { type: 'error', message: err.message }); + this.send(client, { + type: 'stat', + path: msg.path, + size: stats.size, + isDirectory: (stats.mode & 0o40000) !== 0, + permissions: (stats.mode & 0o7777).toString(8), + modified: new Date(stats.mtime * 1000).toISOString(), + accessed: new Date(stats.atime * 1000).toISOString(), + }); + }); + break; + } + case 'download': { + // Stream file data to client in chunks + const readStream = sftp.createReadStream(msg.path); + sftp.stat(msg.path, (err: any, stats: any) => { + if (err) return this.send(client, { type: 'error', message: err.message }); + const transferId = `dl-${Date.now()}`; + let sent = 0; + this.send(client, { type: 'downloadStart', transferId, path: msg.path, total: stats.size }); + readStream.on('data', (chunk: Buffer) => { + sent += chunk.length; + client.send(JSON.stringify({ + type: 'downloadChunk', + transferId, + data: chunk.toString('base64'), + progress: { bytes: sent, total: stats.size }, + })); + }); + readStream.on('end', () => { + this.send(client, { type: 'downloadComplete', transferId }); + }); + readStream.on('error', (e: any) => { + this.send(client, { type: 'error', message: e.message }); + }); + }); + break; + } + } + } + + private send(client: any, data: any) { + if (client.readyState === 1) { + client.send(JSON.stringify(data)); + } + } +} +``` + +- [ ] **Step 2: Register SftpGateway in terminal.module.ts** + +Add `SftpGateway` to providers array. + +- [ ] **Step 3: Commit** + +```bash +git add -A +git commit -m "feat: SFTP gateway — file operations over WebSocket" +``` + +--- + +### Task 12: Frontend — Terminal + SFTP + +**Files:** +- Create: `frontend/composables/useTerminal.ts`, `frontend/composables/useSftp.ts` +- Create: `frontend/stores/session.store.ts` +- Create: `frontend/components/session/SessionContainer.vue`, `SessionTab.vue` +- Create: `frontend/components/terminal/TerminalInstance.vue`, `TerminalTabs.vue`, `SplitPane.vue` +- Create: `frontend/components/sftp/SftpSidebar.vue`, `FileTree.vue`, `FileEditor.vue`, `TransferStatus.vue` +- Modify: `frontend/layouts/default.vue`, `frontend/pages/index.vue` + +- [ ] **Step 1: Create session store** + +`frontend/stores/session.store.ts`: +```typescript +import { defineStore } from 'pinia' + +interface Session { + id: string // uuid from backend + hostId: number + hostName: string + protocol: 'ssh' | 'rdp' + color: string | null + active: boolean +} + +export const useSessionStore = defineStore('sessions', { + state: () => ({ + sessions: [] as Session[], + activeSessionId: null as string | null, + }), + getters: { + activeSession: (state) => state.sessions.find(s => s.id === state.activeSessionId), + hasSessions: (state) => state.sessions.length > 0, + }, + actions: { + addSession(session: Session) { + this.sessions.push(session) + this.activeSessionId = session.id + }, + removeSession(id: string) { + this.sessions = this.sessions.filter(s => s.id !== id) + if (this.activeSessionId === id) { + this.activeSessionId = this.sessions.length ? this.sessions[this.sessions.length - 1].id : null + } + }, + setActive(id: string) { + this.activeSessionId = id + }, + }, +}) +``` + +- [ ] **Step 2: Create useTerminal composable** + +`frontend/composables/useTerminal.ts`: +```typescript +import { Terminal } from '@xterm/xterm' +import { FitAddon } from '@xterm/addon-fit' +import { SearchAddon } from '@xterm/addon-search' +import { WebLinksAddon } from '@xterm/addon-web-links' +import { WebglAddon } from '@xterm/addon-webgl' +import { useAuthStore } from '~/stores/auth.store' +import { useSessionStore } from '~/stores/session.store' + +export function useTerminal() { + const auth = useAuthStore() + const sessions = useSessionStore() + let ws: WebSocket | null = null + + function createTerminal(container: HTMLElement, options?: Partial<{ fontSize: number; scrollback: number }>) { + const term = new Terminal({ + cursorBlink: true, + fontSize: options?.fontSize || 14, + fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace", + scrollback: options?.scrollback || 10000, + theme: { + background: '#0a0a0f', + foreground: '#e4e4ef', + cursor: '#5c7cfa', + selectionBackground: '#364fc744', + }, + }) + + const fitAddon = new FitAddon() + const searchAddon = new SearchAddon() + term.loadAddon(fitAddon) + term.loadAddon(searchAddon) + term.loadAddon(new WebLinksAddon()) + + term.open(container) + + try { + term.loadAddon(new WebglAddon()) + } catch { + // WebGL not available, fall back to canvas + } + + fitAddon.fit() + const resizeObserver = new ResizeObserver(() => fitAddon.fit()) + resizeObserver.observe(container) + + return { term, fitAddon, searchAddon, resizeObserver } + } + + function connectToHost(hostId: number, hostName: string, protocol: 'ssh', color: string | null, term: Terminal, fitAddon: FitAddon) { + const wsUrl = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/terminal?token=${auth.token}` + ws = new WebSocket(wsUrl) + + ws.onopen = () => { + ws!.send(JSON.stringify({ type: 'connect', hostId })) + } + + ws.onmessage = (event) => { + const msg = JSON.parse(event.data) + switch (msg.type) { + case 'connected': + sessions.addSession({ id: msg.sessionId, hostId, hostName, protocol, color, active: true }) + // Send initial terminal size + ws!.send(JSON.stringify({ type: 'resize', sessionId: msg.sessionId, cols: term.cols, rows: term.rows })) + break + case 'data': + term.write(msg.data) + break + case 'disconnected': + sessions.removeSession(msg.sessionId) + break + case 'host-key-verify': + // Auto-accept for now — full UX in polish phase + ws!.send(JSON.stringify({ type: 'host-key-accept' })) + break + case 'error': + term.write(`\r\n\x1b[31mError: ${msg.message}\x1b[0m\r\n`) + break + } + } + + ws.onclose = () => { + term.write('\r\n\x1b[33mConnection closed.\x1b[0m\r\n') + } + + // Terminal input → WebSocket + term.onData((data) => { + if (ws?.readyState === WebSocket.OPEN) { + const sessionId = sessions.activeSession?.id + if (sessionId) { + ws.send(JSON.stringify({ type: 'data', sessionId, data })) + } + } + }) + + // Terminal resize → WebSocket + term.onResize(({ cols, rows }) => { + if (ws?.readyState === WebSocket.OPEN) { + const sessionId = sessions.activeSession?.id + if (sessionId) { + ws.send(JSON.stringify({ type: 'resize', sessionId, cols, rows })) + } + } + }) + + return ws + } + + function disconnect(sessionId: string) { + if (ws?.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'disconnect', sessionId })) + } + sessions.removeSession(sessionId) + } + + return { createTerminal, connectToHost, disconnect } +} +``` + +- [ ] **Step 3: Create useSftp composable** + +`frontend/composables/useSftp.ts`: +```typescript +import { useAuthStore } from '~/stores/auth.store' + +export function useSftp(sessionId: Ref) { + const auth = useAuthStore() + let ws: WebSocket | null = null + const entries = ref([]) + const currentPath = ref('/') + const fileContent = ref<{ path: string; content: string } | null>(null) + const transfers = ref([]) + + function connect() { + const wsUrl = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/sftp?token=${auth.token}` + ws = new WebSocket(wsUrl) + + ws.onmessage = (event) => { + const msg = JSON.parse(event.data) + switch (msg.type) { + case 'list': + entries.value = msg.entries.sort((a: any, b: any) => { + if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1 + return a.name.localeCompare(b.name) + }) + currentPath.value = msg.path + break + case 'fileContent': + fileContent.value = { path: msg.path, content: msg.content } + break + case 'saved': + fileContent.value = null + list(currentPath.value) + break + case 'progress': + // Update transfer progress + break + case 'error': + console.error('SFTP error:', msg.message) + break + } + } + + return ws + } + + function send(msg: any) { + if (ws?.readyState === WebSocket.OPEN && sessionId.value) { + ws.send(JSON.stringify({ ...msg, sessionId: sessionId.value })) + } + } + + function list(path: string) { send({ type: 'list', path }) } + function readFile(path: string) { send({ type: 'read', path }) } + function writeFile(path: string, data: string) { send({ type: 'write', path, data }) } + function mkdir(path: string) { send({ type: 'mkdir', path }) } + function rename(oldPath: string, newPath: string) { send({ type: 'rename', oldPath, newPath }) } + function remove(path: string) { send({ type: 'delete', path }) } + function chmod(path: string, mode: string) { send({ type: 'chmod', path, mode }) } + function download(path: string) { send({ type: 'download', path }) } + + function disconnect() { + ws?.close() + ws = null + } + + return { + entries, currentPath, fileContent, transfers, + connect, disconnect, list, readFile, writeFile, mkdir, rename, remove, chmod, download, + } +} +``` + +- [ ] **Step 4: Create SessionContainer, SessionTab, TerminalInstance components** + +`frontend/components/session/SessionContainer.vue` — persistent container in default layout. Renders all active sessions, shows/hides via `v-show` based on `activeSessionId`. Tab bar on top. Each SSH session renders `TerminalInstance` + `SftpSidebar`. Each RDP session renders `RdpCanvas` (Phase 3). + +`frontend/components/session/SessionTab.vue` — individual tab in the tab bar. Shows host name, color dot, protocol icon, close button. + +`frontend/components/terminal/TerminalInstance.vue` — wraps xterm.js in a Vue component. Uses `useTerminal()` composable. Mounts terminal to a div ref. Handles resize via ResizeObserver. Props: `sessionId`, `hostId`. Imports xterm.js CSS. + +`frontend/components/terminal/TerminalTabs.vue` — tab bar component showing all active sessions. + +`frontend/components/terminal/SplitPane.vue` — flex container that allows horizontal/vertical splitting of terminal instances within a session. Uses CSS `flex-direction` toggle and a draggable divider. + +- [ ] **Step 5: Create SFTP components** + +`frontend/components/sftp/SftpSidebar.vue` — resizable panel on the left of the terminal. Uses `useSftp()` composable. Shows `FileTree` component. Top bar with path breadcrumbs and action buttons (upload, new folder, refresh). + +`frontend/components/sftp/FileTree.vue` — recursive tree of remote filesystem entries. Directories are expandable (lazy-load children on click). Files are clickable (open in FileEditor if text, download if binary). Right-click context menu for rename/delete/chmod/download. + +`frontend/components/sftp/FileEditor.vue` — wraps Monaco Editor. Opens when a text file is clicked in the tree. Shows file path, save button, close button. Unsaved changes warning on close. + +`frontend/components/sftp/TransferStatus.vue` — bottom bar showing active uploads/downloads with progress bars, file names, speed, ETA. + +- [ ] **Step 6: Update default.vue layout — add SessionContainer** + +The `SessionContainer` should live in the default layout so it persists across page navigation. When sessions exist, the session area takes over the main content area. The connection manager sidebar remains for launching new connections. + +- [ ] **Step 7: Update index.vue — connect-on-click** + +When a host card is clicked (not the edit button), call `useTerminal().connectToHost()` to open a new SSH or RDP session. Add an "open" action to HostCard. + +- [ ] **Step 8: Install xterm.js CSS** + +Add to `frontend/nuxt.config.ts` CSS array: `'@xterm/xterm/css/xterm.css'` + +- [ ] **Step 9: Commit** + +```bash +git add -A +git commit -m "feat: Phase 2 — SSH terminal + SFTP sidebar in browser" +``` + +--- + +## Chunk 3: RDP + Polish (Phases 3-4) + +### Task 13: RDP Backend — Guacamole Service + Gateway + +**Files:** +- Create: `backend/src/rdp/rdp.module.ts` +- Create: `backend/src/rdp/guacamole.service.ts` +- Create: `backend/src/rdp/rdp.gateway.ts` +- Modify: `backend/src/app.module.ts` + +- [ ] **Step 1: Implement Guacamole service** + +`backend/src/rdp/guacamole.service.ts`: + +The Guacamole service opens a raw TCP socket to guacd and speaks the Guacamole wire protocol. This is NOT an HTTP integration — it's a custom TCP client that translates between the Guacamole instruction format and JSON WebSocket messages. + +```typescript +import { Injectable, Logger } from '@nestjs/common'; +import * as net from 'net'; + +/** + * Guacamole wire protocol: instructions are comma-separated fields + * terminated by semicolons. Each field is length-prefixed. + * Example: "4.size,4.1024,3.768;" + */ + +@Injectable() +export class GuacamoleService { + private readonly logger = new Logger(GuacamoleService.name); + private readonly host = process.env.GUACD_HOST || 'guacd'; + private readonly port = parseInt(process.env.GUACD_PORT || '4822', 10); + + async connect(params: { + hostname: string; + port: number; + username: string; + password: string; + domain?: string; + width: number; + height: number; + dpi?: number; + security?: string; + colorDepth?: number; + ignoreCert?: boolean; + }): Promise { + return new Promise((resolve, reject) => { + const socket = net.createConnection(this.port, this.host, () => { + this.logger.log(`Connected to guacd at ${this.host}:${this.port}`); + + // Phase 1: SELECT protocol + socket.write(this.encode('select', 'rdp')); + + let buffer = ''; + const onData = (data: Buffer) => { + buffer += data.toString('utf-8'); + + // Wait for "args" instruction from guacd + if (buffer.includes(';')) { + socket.removeListener('data', onData); + + // Phase 2: CONNECT with RDP parameters + const connectArgs = this.buildRdpArgs(params); + socket.write(connectArgs); + + resolve(socket); + } + }; + socket.on('data', onData); + }); + + socket.on('error', (err) => { + this.logger.error(`guacd connection error: ${err.message}`); + reject(err); + }); + + socket.setTimeout(10000, () => { + socket.destroy(); + reject(new Error('guacd connection timeout')); + }); + }); + } + + private buildRdpArgs(params: any): string { + const args: Record = { + hostname: params.hostname, + port: String(params.port), + username: params.username, + password: params.password, + width: String(params.width), + height: String(params.height), + dpi: String(params.dpi || 96), + security: params.security || 'any', + 'color-depth': String(params.colorDepth || 24), + 'ignore-cert': params.ignoreCert !== false ? 'true' : 'false', + 'disable-audio': 'false', + 'enable-wallpaper': 'false', + 'enable-theming': 'true', + 'enable-font-smoothing': 'true', + 'resize-method': 'reconnect', + }; + if (params.domain) args.domain = params.domain; + + // Build connect instruction with all args + const values = Object.values(args); + return this.encode('connect', ...values); + } + + /** Encode a Guacamole instruction: "opcode,arg1,arg2,...;" with length prefixes */ + encode(...parts: string[]): string { + return parts.map((p) => `${p.length}.${p}`).join(',') + ';'; + } + + /** Decode a Guacamole instruction back to string array */ + decode(instruction: string): string[] { + const parts: string[] = []; + let pos = 0; + while (pos < instruction.length) { + const dotIndex = instruction.indexOf('.', pos); + if (dotIndex === -1) break; + const len = parseInt(instruction.substring(pos, dotIndex), 10); + const value = instruction.substring(dotIndex + 1, dotIndex + 1 + len); + parts.push(value); + pos = dotIndex + 1 + len + 1; // skip comma or semicolon + } + return parts; + } +} +``` + +- [ ] **Step 2: Implement RDP gateway** + +`backend/src/rdp/rdp.gateway.ts`: +```typescript +import { WebSocketGateway, WebSocketServer, OnGatewayConnection, OnGatewayDisconnect } from '@nestjs/websockets'; +import { Logger } from '@nestjs/common'; +import { Server } from 'ws'; +import * as net from 'net'; +import { WsAuthGuard } from '../auth/ws-auth.guard'; +import { GuacamoleService } from './guacamole.service'; +import { CredentialsService } from '../vault/credentials.service'; +import { HostsService } from '../connections/hosts.service'; +import { PrismaService } from '../prisma/prisma.service'; + +@WebSocketGateway({ path: '/ws/rdp' }) +export class RdpGateway implements OnGatewayConnection, OnGatewayDisconnect { + @WebSocketServer() server: Server; + private readonly logger = new Logger(RdpGateway.name); + private clientSockets = new Map(); // ws client → guacd socket + + constructor( + private guacamole: GuacamoleService, + private credentials: CredentialsService, + private hosts: HostsService, + private prisma: PrismaService, + private wsAuth: WsAuthGuard, + ) {} + + handleConnection(client: any) { + const user = this.wsAuth.validateClient(client); + if (!user) { + client.close(4001, 'Unauthorized'); + return; + } + this.logger.log(`RDP WS connected: ${user.email}`); + + client.on('message', async (raw: Buffer) => { + try { + const msg = JSON.parse(raw.toString()); + if (msg.type === 'connect') { + await this.handleConnect(client, msg); + } else if (msg.type === 'guac') { + // Forward Guacamole instruction to guacd + const socket = this.clientSockets.get(client); + if (socket) { + socket.write(msg.instruction); + } + } + } catch (err: any) { + this.send(client, { type: 'error', message: err.message }); + } + }); + } + + handleDisconnect(client: any) { + const socket = this.clientSockets.get(client); + if (socket) { + socket.destroy(); + this.clientSockets.delete(client); + } + } + + private async handleConnect(client: any, msg: any) { + const host = await this.hosts.findOne(msg.hostId); + const cred = host.credentialId + ? await this.credentials.decryptForConnection(host.credentialId) + : null; + + const socket = await this.guacamole.connect({ + hostname: host.hostname, + port: host.port, + username: cred?.username || '', + password: cred?.password || '', + domain: cred?.domain || undefined, + width: msg.width || 1920, + height: msg.height || 1080, + dpi: msg.dpi || 96, + security: msg.security || 'any', + colorDepth: msg.colorDepth || 24, + }); + + this.clientSockets.set(client, socket); + + // Forward guacd data to browser + socket.on('data', (data: Buffer) => { + if (client.readyState === 1) { + client.send(JSON.stringify({ type: 'guac', instruction: data.toString('utf-8') })); + } + }); + + socket.on('close', () => { + this.send(client, { type: 'disconnected', reason: 'RDP session closed' }); + this.clientSockets.delete(client); + }); + + socket.on('error', (err) => { + this.send(client, { type: 'error', message: err.message }); + }); + + // Update connection tracking + this.hosts.touchLastConnected(host.id); + this.prisma.connectionLog.create({ + data: { hostId: host.id, protocol: 'rdp' }, + }).catch(() => {}); + + this.send(client, { type: 'connected', hostId: host.id }); + } + + private send(client: any, data: any) { + if (client.readyState === 1) { + client.send(JSON.stringify(data)); + } + } +} +``` + +- [ ] **Step 3: Create RDP module** + +`backend/src/rdp/rdp.module.ts`: +```typescript +import { Module } from '@nestjs/common'; +import { GuacamoleService } from './guacamole.service'; +import { RdpGateway } from './rdp.gateway'; +import { VaultModule } from '../vault/vault.module'; +import { ConnectionsModule } from '../connections/connections.module'; +import { AuthModule } from '../auth/auth.module'; + +@Module({ + imports: [VaultModule, ConnectionsModule, AuthModule], + providers: [GuacamoleService, RdpGateway], +}) +export class RdpModule {} +``` + +Register `RdpModule` in `app.module.ts`. + +- [ ] **Step 4: Commit** + +```bash +git add -A +git commit -m "feat: RDP gateway — Guacamole tunnel to guacd over WebSocket" +``` + +--- + +### Task 14: RDP Frontend + +**Files:** +- Create: `frontend/composables/useRdp.ts` +- Create: `frontend/components/rdp/RdpCanvas.vue`, `RdpToolbar.vue` + +- [ ] **Step 1: Create useRdp composable** + +`frontend/composables/useRdp.ts`: +```typescript +import Guacamole from 'guacamole-common-js' +import { useAuthStore } from '~/stores/auth.store' +import { useSessionStore } from '~/stores/session.store' + +export function useRdp() { + const auth = useAuthStore() + const sessions = useSessionStore() + + function connectRdp( + container: HTMLElement, + hostId: number, + hostName: string, + color: string | null, + options?: { width?: number; height?: number }, + ) { + const wsUrl = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/rdp?token=${auth.token}` + const ws = new WebSocket(wsUrl) + + // Guacamole tunnel wrapping our WebSocket + const tunnel = new Guacamole.WebSocketTunnel(wsUrl) + // We need to handle this custom since we have a JSON wrapper + + let client: Guacamole.Client | null = null + + ws.onopen = () => { + ws.send(JSON.stringify({ + type: 'connect', + hostId, + width: options?.width || container.clientWidth, + height: options?.height || container.clientHeight, + })) + } + + ws.onmessage = (event) => { + const msg = JSON.parse(event.data) + switch (msg.type) { + case 'connected': { + // Initialize Guacamole client with custom tunnel + client = new Guacamole.Client(tunnel) + const display = client.getDisplay().getElement() + container.appendChild(display) + + // Set up input + const mouse = new Guacamole.Mouse(display) + mouse.onEach(['mousedown', 'mousemove', 'mouseup'], (e: any) => { + client?.sendMouseState(e.state) + }) + + const keyboard = new Guacamole.Keyboard(document) + keyboard.onkeydown = (keysym: number) => client?.sendKeyEvent(1, keysym) + keyboard.onkeyup = (keysym: number) => client?.sendKeyEvent(0, keysym) + + sessions.addSession({ + id: `rdp-${hostId}-${Date.now()}`, + hostId, + hostName, + protocol: 'rdp', + color, + active: true, + }) + break + } + case 'guac': { + // Forward Guacamole instruction to client + if (client) { + tunnel.oninstruction?.(msg.instruction) + } + break + } + case 'error': + console.error('RDP error:', msg.message) + break + case 'disconnected': + client?.disconnect() + break + } + } + + return { ws, getClient: () => client } + } + + return { connectRdp } +} +``` + +**Note:** The guacamole-common-js integration may need adjustment during implementation. The standard `WebSocketTunnel` expects raw Guacamole protocol over WebSocket, but our gateway wraps instructions in JSON. Two approaches: +1. Implement a custom `Guacamole.Tunnel` that speaks JSON over WebSocket +2. Switch the RDP gateway to pass raw Guacamole instructions without JSON wrapping + +The implementer should evaluate both approaches during Phase 3 and choose whichever produces cleaner code. The custom tunnel approach is likely simpler. + +- [ ] **Step 2: Create RdpCanvas and RdpToolbar components** + +`frontend/components/rdp/RdpCanvas.vue` — wraps the Guacamole display element. Uses `useRdp()` composable. Full-size container that resizes with the parent. Props: `sessionId`, `hostId`. + +`frontend/components/rdp/RdpToolbar.vue` — floating toolbar overlay for RDP sessions. Buttons: clipboard (text input dialog for paste), fullscreen toggle (HTML5 Fullscreen API), disconnect, settings dropdown (color depth, resize behavior). + +- [ ] **Step 3: Update SessionContainer to handle RDP sessions** + +When a session has `protocol === 'rdp'`, render `RdpCanvas` instead of `TerminalInstance + SftpSidebar`. + +- [ ] **Step 4: Commit** + +```bash +git add -A +git commit -m "feat: Phase 3 — RDP via Guacamole in browser" +``` + +--- + +### Task 15: Vault Management UI + +**Files:** +- Create: `frontend/composables/useVault.ts` +- Create: `frontend/pages/vault/index.vue`, `vault/keys.vue`, `vault/credentials.vue` +- Create: `frontend/components/vault/KeyImportDialog.vue`, `CredentialForm.vue` + +- [ ] **Step 1: Create useVault composable** + +`frontend/composables/useVault.ts`: +```typescript +import { useAuthStore } from '~/stores/auth.store' + +export function useVault() { + const auth = useAuthStore() + const headers = () => ({ Authorization: `Bearer ${auth.token}` }) + + // SSH Keys + async function listKeys() { + return $fetch('/api/ssh-keys', { headers: headers() }) + } + async function importKey(data: { name: string; privateKey: string; passphrase?: string; publicKey?: string }) { + return $fetch('/api/ssh-keys', { method: 'POST', body: data, headers: headers() }) + } + async function deleteKey(id: number) { + return $fetch(`/api/ssh-keys/${id}`, { method: 'DELETE', headers: headers() }) + } + + // Credentials + async function listCredentials() { + return $fetch('/api/credentials', { headers: headers() }) + } + async function createCredential(data: any) { + return $fetch('/api/credentials', { method: 'POST', body: data, headers: headers() }) + } + async function updateCredential(id: number, data: any) { + return $fetch(`/api/credentials/${id}`, { method: 'PUT', body: data, headers: headers() }) + } + async function deleteCredential(id: number) { + return $fetch(`/api/credentials/${id}`, { method: 'DELETE', headers: headers() }) + } + + return { + listKeys, importKey, deleteKey, + listCredentials, createCredential, updateCredential, deleteCredential, + } +} +``` + +- [ ] **Step 2: Create vault pages** + +`frontend/pages/vault/index.vue` — overview page with quick stats (number of keys, number of credentials). Links to keys and credentials sub-pages. + +`frontend/pages/vault/keys.vue` — list of SSH keys (name, type, fingerprint, created date). "Import Key" button opens `KeyImportDialog`. Delete button per key with confirmation. + +`frontend/pages/vault/credentials.vue` — list of credentials (name, username, type badge, associated hosts). "New Credential" button opens form. Edit and delete per credential. + +- [ ] **Step 3: Create KeyImportDialog** + +`frontend/components/vault/KeyImportDialog.vue` — PrimeVue Dialog. Fields: name (text), private key (textarea or file upload), public key (textarea or file upload, optional), passphrase (password input, optional). File upload accepts `.pem`, `.pub`, `id_rsa`, `id_ed25519`. + +- [ ] **Step 4: Create CredentialForm** + +`frontend/components/vault/CredentialForm.vue` — form component used in both create and edit modes. Fields: name, type dropdown (password/ssh_key), username, password (shown if type=password), SSH key dropdown (shown if type=ssh_key, populated from keys list), domain (optional, for RDP). + +- [ ] **Step 5: Commit** + +```bash +git add -A +git commit -m "feat: vault management UI — SSH key import + credential CRUD" +``` + +--- + +### Task 16: Quick Connect + Search + Connection History + +**Files:** +- Create: `frontend/components/connections/QuickConnect.vue` +- Modify: `frontend/pages/index.vue` — add quick connect bar, search, recent connections section + +- [ ] **Step 1: Create QuickConnect component** + +`frontend/components/connections/QuickConnect.vue`: +```vue + + + +``` + +- [ ] **Step 2: Add search filter to connection manager** + +Update `frontend/pages/index.vue`: add a search input above the host grid. Filter `connections.hosts` by search term (name, hostname, tags) client-side. Add a "Recent" section above the full list showing hosts sorted by `lastConnectedAt`. + +- [ ] **Step 3: Wire QuickConnect to terminal/RDP** + +When QuickConnect emits a connection, create a temporary (unsaved) connection and open a terminal or RDP session. If the user wants to save it afterward, show a "Save this connection?" prompt. + +- [ ] **Step 4: Commit** + +```bash +git add -A +git commit -m "feat: quick connect, search, recent connections" +``` + +--- + +### Task 17: Settings Page + Theming + +**Files:** +- Create: `frontend/pages/settings.vue` +- Modify: `frontend/layouts/default.vue` — add dark/light toggle + +- [ ] **Step 1: Create settings page** + +`frontend/pages/settings.vue`: +```vue + + + +``` + +- [ ] **Step 2: Wire theme toggle** + +Apply `dark` class to `` element based on theme setting. Persist via the settings API. Update Tailwind classes throughout to support both modes using `dark:` prefix. + +- [ ] **Step 3: Commit** + +```bash +git add -A +git commit -m "feat: Phase 4 — settings, theming, polish" +``` + +--- + +### Task 18: Final Integration + Docker Verify + +- [ ] **Step 1: Update app.module.ts — register all modules** + +Ensure `app.module.ts` imports: `PrismaModule`, `AuthModule`, `VaultModule`, `ConnectionsModule`, `TerminalModule`, `RdpModule`, `SettingsModule`, `ServeStaticModule`. + +- [ ] **Step 2: Full Docker build and test** + +```bash +docker compose build +docker compose up -d +docker logs -f wraith-app +``` + +Verify: +- Login works (admin@wraith.local / wraith) +- Connection manager loads, can create hosts and groups +- SSH terminal connects to a test host +- SFTP sidebar shows remote files +- RDP connects to a Windows target (if available) +- Vault: can import SSH key, create credential, associate with host +- Settings: theme, font size, scrollback persist + +- [ ] **Step 3: Commit + tag** + +```bash +git add -A +git commit -m "feat: Wraith v0.1.0 — SSH + SFTP + RDP in a browser" +git tag v0.1.0 +``` + +--- + +## Dependency Graph + +``` +Task 1 (scaffold) ──┬──→ Task 2 (prisma + bootstrap) ──→ Task 3 (encryption) ──→ Task 4 (auth) + │ ↓ + │ Task 6 (vault backend) + │ ↓ + └──→ Task 8 (frontend shell) ←── Task 5 (connections backend) + ↓ + Task 9 (docker compose up) + ↓ + ┌──────────────────────────────────────────────┐ + ↓ ↓ + Task 10 (SSH gateway) ──→ Task 11 (SFTP gateway) ──→ Task 12 (frontend terminal+sftp) + │ + ↓ + Task 13 (RDP backend) ──→ Task 14 (RDP frontend) + ↓ + Task 15 (vault UI) ──→ Task 16 (quick connect + search) ──→ Task 17 (settings) + ↓ + Task 18 (final integration) +``` + +**Parallelizable groups:** +- Tasks 3 + 5 (after Task 2) +- Tasks 6 + 7 (after Task 3) +- Tasks 10-12 and Tasks 13-14 (after Task 9) +- Tasks 15, 16, 17 (after Task 12) diff --git a/docs/superpowers/specs/2026-03-12-vigilance-remote-lean-design.md b/docs/superpowers/specs/2026-03-12-vigilance-remote-lean-design.md new file mode 100644 index 0000000..863075b --- /dev/null +++ b/docs/superpowers/specs/2026-03-12-vigilance-remote-lean-design.md @@ -0,0 +1,614 @@ +# Wraith — Lean Build Spec + +> **Date:** 2026-03-12 +> **Purpose:** Self-hosted MobaXterm replacement — SSH + SFTP + RDP in a browser +> **Stack:** Nuxt 3 (Vue 3 SPA) + NestJS 10 + PostgreSQL 16 + guacd +> **Target:** Single-user personal tool with bolt-on multi-user path +> **Reference:** `Remote-Spec.md` (full feature spec — this is the lean cut) + +--- + +## 1. What This Is + +A self-hosted web application that replaces MobaXterm. SSH terminal with SFTP sidebar (MobaXterm's killer feature), RDP via Guacamole, connection manager with hierarchical groups, and an encrypted vault for SSH keys and passwords. Runs in any browser, deployed as a Docker stack. + +**What this is NOT:** An MSP product, a SaaS platform, a team collaboration tool. It's a personal remote access workstation that happens to be web-based. Multi-user is a future bolt-on, not a design constraint. + +**Name:** Wraith — exists everywhere, all at once. + +--- + +## 2. Five Modules + +### 2.1 SSH Terminal + +**Frontend:** xterm.js 5.x with addons: +- `@xterm/addon-fit` — auto-resize to container +- `@xterm/addon-search` — Ctrl+F scrollback search +- `@xterm/addon-web-links` — clickable URLs +- `@xterm/addon-webgl` — GPU-accelerated rendering + +**Backend:** NestJS WebSocket gateway + `ssh2` (npm). Browser opens WebSocket to NestJS, NestJS opens SSH connection to target using credentials from the vault. Bidirectional data pipe: terminal input → ssh2 stdin, ssh2 stdout → terminal output. + +**Features:** +- Multi-tab sessions with host name labels and color-coding by group +- Horizontal and vertical split panes within a single tab (multiple xterm.js instances in a flex grid) +- Terminal theming: dark/light modes, custom color schemes, font selection, font size +- Configurable scrollback buffer size (default 10,000 lines, configurable in settings) +- Copy/paste: Ctrl+Shift+C/V, right-click context menu +- Search in scrollback: Ctrl+F via xterm.js SearchAddon +- Auto-reconnect on connection drop with configurable retry + +**Authentication flow:** +1. User clicks host in connection manager +2. Backend looks up host → finds associated credential (key or password) +3. If SSH key: decrypt private key from vault, optionally decrypt passphrase, pass to ssh2 +4. If password: decrypt from vault, pass to ssh2 +5. ssh2 performs host key verification (see Section 8: Host Key Verification) +6. ssh2 connects, WebSocket bridge established + +### 2.2 SFTP Sidebar + +The MobaXterm feature. When an SSH session connects, a sidebar automatically opens showing the remote filesystem. + +**Layout:** Resizable left sidebar panel (tree view) + main terminal panel. Sidebar can be collapsed/hidden per session. + +**Backend:** Uses the same ssh2 connection as the terminal (ssh2's SFTP subsystem). No separate connection needed — SFTP rides the existing SSH channel. All SFTP commands include a `sessionId` to target the correct ssh2 connection when multiple tabs are open. + +**File operations:** +- Browse remote filesystem as a tree (lazy-loaded — fetch children on expand) +- Upload: drag-and-drop from desktop onto sidebar, or click upload button. Chunked transfer with progress bar. +- Download: click file → browser download, or right-click → Download +- Rename, delete, chmod, mkdir via right-click context menu +- File size, permissions, modified date shown in tree or detail view + +**File editing:** +- Click a text file → opens in embedded Monaco Editor (VS Code's editor component) +- File size guard: files over 5MB are refused for inline editing (download instead) +- Syntax highlighting based on file extension +- Save button pushes content back to remote via SFTP +- Unsaved changes warning on close + +**Transfer status:** Bottom status bar showing active transfers with progress, speed, ETA. Queue-based — multiple uploads/downloads run sequentially with status indicators. + +### 2.3 RDP (Remote Desktop) + +**Architecture:** Browser → WebSocket → NestJS Guacamole tunnel → guacd (Docker) → RDP target + +**Frontend:** `guacamole-common-js` — renders remote desktop on HTML5 Canvas. Keyboard, mouse, and touch input forwarded to remote. + +**Backend:** NestJS WebSocket gateway that speaks Guacamole wire protocol to the `guacd` daemon over TCP. The gateway translates between the browser's WebSocket and guacd's TCP socket. + +**guacd:** Apache Guacamole daemon running as `guacamole/guacd` Docker image. Handles the actual RDP protocol translation. Battle-tested, Apache-licensed. + +**Features:** +- Clipboard sync: bidirectional between browser and remote desktop +- Auto-resolution: detect browser window/tab size, send to RDP server +- Connection settings: color depth (16/24/32-bit), security mode (NLA/TLS/RDP), console session, admin mode +- Audio: remote audio playback in browser (Guacamole native) +- Full-screen mode: F11 or toolbar button + +**Authentication:** RDP credentials (username + password + domain) stored encrypted in vault, associated with host. Decrypted at connect time and passed to guacd. + +### 2.4 Connection Manager + +The home screen. A searchable, organized view of all saved hosts. + +**Host properties:** +``` +name — display name (e.g., "RSM File Server") +hostname — IP or FQDN +port — default 22 (SSH) or 3389 (RDP) +protocol — ssh | rdp +group_id — FK to host_groups (nullable for ungrouped) +credential_id — FK to credentials (nullable for quick-connect-style) +tags — text[] array for categorization +notes — free text (markdown rendered) +color — hex color for visual grouping +lastConnectedAt — timestamp of most recent connection +``` + +**Host groups:** Hierarchical folders with `parent_id` self-reference. E.g., "RSM > Servers", "Home Lab > VMs". Collapsible tree in the sidebar. + +**Quick connect:** Top bar input — type `user@hostname:port` and hit Enter to connect without saving. Protocol auto-detected (or toggle SSH/RDP). + +**Search:** Full-text across host name, hostname, tags, notes, group name. Instant filter as you type. + +**Recent connections:** Hosts sorted by `lastConnectedAt` shown as a quick-access section above the full host tree. + +**UI pattern:** Left sidebar = group tree + host list. Main area = active sessions rendered as persistent tab components within the layout (NOT separate routes — terminal/RDP instances persist across tab switches). Double-click host or press Enter to connect. Drag hosts between groups. + +### 2.5 Key Vault + +Encrypted storage for SSH private keys and passwords. + +**SSH keys:** +``` +name — display name (e.g., "RSM Production Key") +public_key — plaintext (safe to store) +encrypted_private_key — AES-256-GCM encrypted blob +passphrase_encrypted — AES-256-GCM encrypted (nullable — not all keys have passphrases) +fingerprint — SHA-256 fingerprint for display +key_type — rsa | ed25519 | ecdsa (detected on import) +``` + +**Import flow:** +1. Click "Import Key" in vault management +2. Paste key content or upload `.pem`/`.pub`/id_rsa file +3. If key has passphrase, prompt for it (stored encrypted) +4. Key encrypted with AES-256-GCM using `ENCRYPTION_KEY` env var +5. Public key extracted and stored separately (for display/export) + +**Credentials (passwords and key references):** +``` +name — display name (e.g., "RSM root cred") +username — plaintext username (not sensitive) +domain — for RDP (e.g., "CONTOSO") +type — password | ssh_key (enum CredentialType) +encrypted_value — AES-256-GCM encrypted password (for type=password) +ssh_key_id — FK to ssh_keys (for type=ssh_key) +``` + +Credentials are shared entities — hosts reference credentials via `credential_id` FK on the host. Multiple hosts can share the same credential. The relationship is Host → Credential (many-to-one), not Credential → Host. + +**Encryption pattern:** Same as Vigilance HQ — `ENCRYPTION_KEY` env var (32+ byte hex), AES-256-GCM, random IV per encryption, `v1:` version prefix on ciphertext for future key rotation. + +--- + +## 3. Technology Stack + +### Frontend + +| Component | Technology | Purpose | +|---|---|---| +| Framework | Nuxt 3 (Vue 3, SPA mode `ssr: false`) | App shell, routing, auto-imports | +| Terminal | xterm.js 5.x + addons | SSH terminal emulator | +| RDP client | guacamole-common-js | RDP canvas rendering | +| Code editor | Monaco Editor | SFTP file editing | +| UI library | PrimeVue 4 | DataTable, Dialog, Tree, Toolbar, etc. | +| State | Pinia | Connection state, session management | +| CSS | Tailwind CSS | Utility-first styling | +| Icons | Lucide Vue | Consistent iconography | + +> **Why SPA, not SSR:** xterm.js, Monaco, and guacamole-common-js are all browser-only. Every session page would need `` wrappers. No SEO benefit for a self-hosted tool behind auth. SPA mode avoids hydration mismatches entirely while keeping Nuxt's routing, auto-imports, and module ecosystem. + +### Backend + +| Component | Technology | Purpose | +|---|---|---| +| Framework | NestJS 10 | REST API + WebSocket gateways | +| SSH proxy | ssh2 (npm) | SSH + SFTP connections | +| RDP proxy | Custom Guacamole tunnel | NestJS ↔ guacd TCP bridge | +| Database | PostgreSQL 16 | Hosts, credentials, keys, settings | +| ORM | Prisma | Schema-as-code, type-safe queries | +| Encryption | Node.js crypto (AES-256-GCM) | Vault encryption at rest | +| Auth | JWT + bcrypt | Single-user local login | +| WebSocket | @nestjs/websockets (ws) | Terminal and RDP data channels | + +### Infrastructure (Docker Compose) + +```yaml +services: + app: + build: . + ports: ["3000:3000"] + environment: + DATABASE_URL: postgresql://wraith:${DB_PASSWORD}@postgres:5432/wraith + JWT_SECRET: ${JWT_SECRET} + ENCRYPTION_KEY: ${ENCRYPTION_KEY} + GUACD_HOST: guacd + GUACD_PORT: "4822" + depends_on: [postgres, guacd] + + guacd: + image: guacamole/guacd + restart: always + # Internal only — app connects via Docker DNS hostname "guacd" on port 4822 + + postgres: + image: postgres:16-alpine + volumes: [pgdata:/var/lib/postgresql/data] + environment: + POSTGRES_DB: wraith + POSTGRES_USER: wraith + POSTGRES_PASSWORD: ${DB_PASSWORD} + +volumes: + pgdata: +``` + +> **No Redis:** JWT auth is stateless. Single NestJS process means no pub/sub fanout needed. If horizontal scaling becomes relevant later, Redis is a straightforward add. Not burning ops complexity on it now. + +**Required `.env` vars:** +``` +DB_PASSWORD= +JWT_SECRET= +ENCRYPTION_KEY= +``` + +Production deployment: Nginx reverse proxy on the Docker host with SSL termination and WebSocket upgrade support (`proxy_set_header Upgrade $http_upgrade`). + +--- + +## 4. Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Browser (Any device) │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ xterm.js │ │ SFTP Sidebar │ │ guac-client │ │ +│ │ (SSH term) │ │ (file tree) │ │ (RDP canvas) │ │ +│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │ +│ │ WebSocket │ WebSocket │ WebSocket │ +└─────────┼──────────────────┼─────────────────┼──────────────┘ + │ │ │ +┌─────────┼──────────────────┼─────────────────┼──────────────┐ +│ NestJS Backend (Docker: app) │ +│ ┌──────▼───────┐ ┌──────▼───────┐ ┌──────▼───────┐ │ +│ │ SSH Gateway │ │ SFTP Gateway │ │ Guac Tunnel │ │ +│ │ (ssh2) │ │ (ssh2 sftp) │ │ (TCP→guacd) │ │ +│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │ +│ │ SSH │ SFTP │ Guac Protocol │ +│ ┌──────▼────────────────────────┐ ┌──────▼───────┐ │ +│ │ Vault Service │ │ guacd │ │ +│ │ (decrypt keys/passwords) │ │ (Docker) │ │ +│ └──────┬────────────────────────┘ └──────┬───────┘ │ +│ │ Prisma │ RDP │ +│ ┌──────▼───────┐ │ │ +│ │ PostgreSQL │ │ │ +│ │ (Docker) │ │ │ +│ └──────────────┘ │ │ +└──────────────────────────────────────────────┼──────────────┘ + │ + ┌─────────────────┐ ┌──────▼───────┐ + │ SSH Targets │ │ RDP Targets │ + │ (Linux/Unix) │ │ (Windows) │ + └─────────────────┘ └──────────────┘ +``` + +--- + +## 5. Database Schema (Prisma) + +```prisma +model User { + id Int @id @default(autoincrement()) + email String @unique + passwordHash String @map("password_hash") + displayName String? @map("display_name") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("users") +} + +model HostGroup { + id Int @id @default(autoincrement()) + name String + parentId Int? @map("parent_id") + sortOrder Int @default(0) @map("sort_order") + parent HostGroup? @relation("GroupTree", fields: [parentId], references: [id], onDelete: SetNull) + children HostGroup[] @relation("GroupTree") + hosts Host[] + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("host_groups") +} + +model Host { + id Int @id @default(autoincrement()) + name String + hostname String + port Int @default(22) + protocol Protocol @default(ssh) + groupId Int? @map("group_id") + credentialId Int? @map("credential_id") + tags String[] @default([]) + notes String? + color String? @db.VarChar(7) + sortOrder Int @default(0) @map("sort_order") + hostFingerprint String? @map("host_fingerprint") + lastConnectedAt DateTime? @map("last_connected_at") + group HostGroup? @relation(fields: [groupId], references: [id], onDelete: SetNull) + credential Credential? @relation(fields: [credentialId], references: [id], onDelete: SetNull) + connectionLogs ConnectionLog[] + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("hosts") +} + +model Credential { + id Int @id @default(autoincrement()) + name String + username String? + domain String? + type CredentialType + encryptedValue String? @map("encrypted_value") + sshKeyId Int? @map("ssh_key_id") + sshKey SshKey? @relation(fields: [sshKeyId], references: [id], onDelete: SetNull) + hosts Host[] + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("credentials") +} + +model SshKey { + id Int @id @default(autoincrement()) + name String + keyType String @map("key_type") @db.VarChar(20) + fingerprint String? + publicKey String? @map("public_key") + encryptedPrivateKey String @map("encrypted_private_key") + passphraseEncrypted String? @map("passphrase_encrypted") + credentials Credential[] + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("ssh_keys") +} + +model ConnectionLog { + id Int @id @default(autoincrement()) + hostId Int @map("host_id") + protocol Protocol + connectedAt DateTime @default(now()) @map("connected_at") + disconnectedAt DateTime? @map("disconnected_at") + host Host @relation(fields: [hostId], references: [id], onDelete: Cascade) + + @@map("connection_logs") +} + +model Setting { + key String @id + value String + + @@map("settings") +} + +enum Protocol { + ssh + rdp +} + +enum CredentialType { + password + ssh_key +} +``` + +--- + +## 6. Frontend Structure + +``` +frontend/ + nuxt.config.ts # ssr: false (SPA mode) + layouts/ + default.vue # Main layout: sidebar + persistent tab container + auth.vue # Login page layout + pages/ + index.vue # Connection manager (home screen) + active session tabs + login.vue # Single-user login + vault/ + index.vue # Key vault management + keys.vue # SSH key list + import + credentials.vue # Password credentials + settings.vue # App settings (theme, terminal defaults, scrollback) + components/ + connections/ + HostTree.vue # Sidebar host group tree + HostCard.vue # Host entry in list + HostEditDialog.vue # Add/edit host modal + GroupEditDialog.vue # Add/edit group modal + QuickConnect.vue # Top bar quick connect input + session/ + SessionContainer.vue # Persistent container — holds all active sessions, manages tab switching + SessionTab.vue # Single session (SSH terminal + SFTP sidebar, or RDP canvas) + terminal/ + TerminalInstance.vue # Single xterm.js instance + TerminalTabs.vue # Tab bar for multiple sessions + SplitPane.vue # Split pane container + sftp/ + SftpSidebar.vue # SFTP file tree sidebar + FileTree.vue # Remote filesystem tree + FileEditor.vue # Monaco editor for text files + TransferStatus.vue # Upload/download progress + rdp/ + RdpCanvas.vue # Guacamole client wrapper + RdpToolbar.vue # Clipboard, fullscreen, settings + vault/ + KeyImportDialog.vue # SSH key import modal + CredentialForm.vue # Password credential form + composables/ + useTerminal.ts # xterm.js lifecycle + WebSocket + useSftp.ts # SFTP operations via WebSocket + useRdp.ts # Guacamole client lifecycle + useVault.ts # Key/credential CRUD + useConnections.ts # Host CRUD + search + stores/ + auth.store.ts # Login state, JWT (stored in memory/localStorage, sent via Authorization header) + session.store.ts # Active sessions, tabs — sessions persist across tab switches + connection.store.ts # Hosts, groups, search +``` + +> **Session architecture:** Active sessions are NOT page routes. They render as persistent tab components inside `SessionContainer.vue` within the main `index.vue` layout. Switching tabs toggles `v-show` visibility (not `v-if` destruction), so xterm.js and guacamole-common-js instances stay alive. The vault and settings pages are separate routes — navigating away from the main page does NOT destroy active sessions (the SessionContainer lives in the `default.vue` layout). + +--- + +## 7. Backend Structure + +``` +backend/src/ + main.ts # Bootstrap, global prefix, validation pipe + app.module.ts # Root module + prisma/ + prisma.service.ts # Prisma client lifecycle + prisma.module.ts # Global Prisma module + auth/ + auth.module.ts + auth.service.ts # Login, JWT issue/verify + auth.controller.ts # POST /login, GET /profile + jwt.strategy.ts # Passport JWT strategy + jwt-auth.guard.ts # Route guard (REST) + ws-auth.guard.ts # WebSocket auth guard (validates JWT from handshake) + connections/ + connections.module.ts + hosts.service.ts # Host CRUD + lastConnectedAt updates + hosts.controller.ts # REST: /hosts + groups.service.ts # Group CRUD (hierarchical) + groups.controller.ts # REST: /groups + vault/ + vault.module.ts + encryption.service.ts # AES-256-GCM encrypt/decrypt + credentials.service.ts # Credential CRUD + decrypt-on-demand + credentials.controller.ts # REST: /credentials + ssh-keys.service.ts # SSH key import/CRUD + ssh-keys.controller.ts # REST: /ssh-keys + terminal/ + terminal.module.ts + terminal.gateway.ts # WebSocket gateway: SSH proxy via ssh2 + sftp.gateway.ts # WebSocket gateway: SFTP operations + ssh-connection.service.ts # ssh2 connection management + pooling + rdp/ + rdp.module.ts + rdp.gateway.ts # WebSocket gateway: Guacamole tunnel + guacamole.service.ts # TCP connection to guacd, protocol translation + settings/ + settings.module.ts + settings.service.ts # Key/value settings CRUD + settings.controller.ts # REST: /settings +``` + +--- + +## 8. Key Implementation Details + +### WebSocket Authentication + +All WebSocket gateways validate JWT before processing any commands. The token is sent in the WebSocket handshake: + +```typescript +// Client: connect with JWT +const ws = new WebSocket(`wss://host/terminal?token=${jwt}`) + +// Server: ws-auth.guard.ts validates in handleConnection +// Rejects connection if token is invalid/expired +``` + +JWT is stored in Pinia state (memory) and localStorage for persistence. Sent via `Authorization: Bearer` header for REST, query parameter for WebSocket handshake. No cookies used for auth — CSRF protection not required. + +### WebSocket Protocol (SSH) + +``` +Client → Server: + { type: 'connect', hostId: 123 } # Initiate SSH connection + { type: 'data', data: '...' } # Terminal input (keystrokes) + { type: 'resize', cols: 120, rows: 40 } # Terminal resize + +Server → Client: + { type: 'connected', sessionId: 'uuid' } # SSH connection established + { type: 'data', data: '...' } # Terminal output + { type: 'host-key-verify', fingerprint: 'SHA256:...', isNew: true } # First connection — needs approval + { type: 'error', message: '...' } # Connection error + { type: 'disconnected', reason: '...' } # Connection closed + +Client → Server (host key response): + { type: 'host-key-accept' } # User approved — save fingerprint to host record + { type: 'host-key-reject' } # User rejected — abort connection +``` + +### WebSocket Protocol (SFTP) + +All SFTP commands include `sessionId` to target the correct ssh2 connection: + +``` +Client → Server: + { type: 'list', sessionId: 'uuid', path: '/home/user' } # List directory + { type: 'read', sessionId: 'uuid', path: '/etc/nginx/nginx.conf' } # Read file (max 5MB) + { type: 'write', sessionId: 'uuid', path: '/etc/nginx/nginx.conf', data } # Write file + { type: 'upload', sessionId: 'uuid', path: '/tmp/file.tar.gz', chunk } # Upload chunk + { type: 'download', sessionId: 'uuid', path: '/var/log/syslog' } # Start download + { type: 'mkdir', sessionId: 'uuid', path: '/home/user/newdir' } # Create directory + { type: 'rename', sessionId: 'uuid', oldPath, newPath } # Rename/move + { type: 'delete', sessionId: 'uuid', path: '/tmp/junk.log' } # Delete file + { type: 'chmod', sessionId: 'uuid', path, mode: '755' } # Change permissions + { type: 'stat', sessionId: 'uuid', path: '/home/user' } # Get file info + +Server → Client: + { type: 'list', path, entries: [...] } # Directory listing + { type: 'fileContent', path, content, encoding } # File content + { type: 'progress', transferId, bytes, total } # Transfer progress + { type: 'error', message } # Operation error +``` + +### Host Key Verification + +SSH host key verification follows standard `known_hosts` behavior: + +1. **First connection:** ssh2 receives server's public key fingerprint. Gateway sends `host-key-verify` message to browser with `isNew: true`. User sees a dialog showing the fingerprint and chooses to accept or reject. +2. **Accept:** Fingerprint saved to `Host.hostFingerprint` in database. Connection proceeds. +3. **Subsequent connections:** ssh2 receives fingerprint, compared against stored `Host.hostFingerprint`. If match, connect silently. If mismatch, gateway sends `host-key-verify` with `isNew: false` and `previousFingerprint` — user warned of possible MITM. +4. **Reject:** Connection aborted, no fingerprint stored. + +### Guacamole Tunnel (RDP) + +NestJS acts as a tunnel between the browser's WebSocket and guacd's TCP socket: + +1. Browser sends `{ type: 'connect', hostId: 456 }` +2. NestJS looks up host → decrypts RDP credentials +3. NestJS opens TCP socket to guacd at `${GUACD_HOST}:${GUACD_PORT}` (default: `guacd:4822`) +4. NestJS sends Guacamole handshake: `select`, `size`, `audio`, `video`, `image` instructions +5. NestJS sends `connect` instruction with RDP params (hostname, port, username, password, security, color-depth) +6. Bidirectional pipe: browser WebSocket ↔ NestJS ↔ guacd TCP +7. guacd handles actual RDP protocol to target Windows machine + +The `guacamole-common-js` client library handles rendering the Guacamole instruction stream to Canvas in the browser. + +### Encryption Service + +Identical pattern to Vigilance HQ: + +```typescript +encrypt(plaintext: string): string + → random 16-byte IV + → AES-256-GCM cipher with ENCRYPTION_KEY + → return `v1:${iv.hex}:${authTag.hex}:${ciphertext.hex}` + +decrypt(encrypted: string): string + → parse version prefix, IV, authTag, ciphertext + → AES-256-GCM decipher + → return plaintext +``` + +`ENCRYPTION_KEY` is a 32-byte hex string from environment. `v1:` prefix allows future key rotation without re-encrypting all stored values. + +--- + +## 9. Multi-User Bolt-On Path + +When the time comes to add JT or Victor: + +1. Add rows to `users` table +2. Add `userId` FK to `hosts`, `host_groups`, `credentials`, and `ssh_keys` tables (nullable — null = shared with all users) +3. Add `shared_with` field or a `host_permissions` join table +4. Add basic role: `admin` | `user` on `users` table +5. Filter host list by ownership/sharing in queries +6. Optional: Entra ID SSO (same pattern as HQ and RSM) + +**Zero architectural changes.** The connection manager, vault, terminal, SFTP, and RDP modules don't change. You just add a filter layer on who can see what. + +--- + +## 10. Build Phases + +| Phase | Deliverables | +|---|---| +| **1: Foundation** | Docker Compose, NestJS scaffold, Prisma schema, encryption service, Nuxt 3 SPA shell, auth (single-user login), connection manager CRUD, host groups | +| **2: SSH + SFTP** | xterm.js terminal, ssh2 WebSocket proxy, host key verification, multi-tab, split panes, SFTP sidebar with file tree, upload/download, Monaco editor | +| **3: RDP** | guacd integration, Guacamole tunnel, RDP canvas rendering, clipboard sync, connection settings | +| **4: Polish** | SSH key import UI, vault management page, theming, quick connect, search, settings page, connection history/recent hosts | + +> **Note on encryption timing:** The encryption service and credential CRUD (encrypted) are in Phase 1, not Phase 4. SSH connections in Phase 2 need to decrypt credentials — plaintext storage is never acceptable, even temporarily. Phase 4's vault work is the management UI (import dialogs, key list view), not the encryption layer itself.