615 lines
28 KiB
Markdown
615 lines
28 KiB
Markdown
# 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 `<ClientOnly>` 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=<strong-random-password>
|
|
JWT_SECRET=<random-256-bit-hex>
|
|
ENCRYPTION_KEY=<random-256-bit-hex>
|
|
```
|
|
|
|
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.
|