Merge branch 'worktree-agent-a866869e' into feat/phase1-foundation

# Conflicts:
#	go.mod
#	go.sum
This commit is contained in:
Vantz Stockwell 2026-03-17 06:22:06 -04:00
commit 714b92292d
132 changed files with 1772 additions and 20049 deletions

@ -0,0 +1 @@
Subproject commit 4c32694a52625252175a95aca70aa703f8e6d09e

@ -0,0 +1 @@
Subproject commit ab5a5c7ae2dd869c14189edff3976051981d3739

@ -0,0 +1 @@
Subproject commit e8ed0139b31cea4237b72c354cd11d835a06e505

@ -0,0 +1 @@
Subproject commit 5179f5ab7650c2a919c41e3d08310a6ba463ed70

@ -0,0 +1 @@
Subproject commit 4de47352cdf7bb5dbd8fce43d25ce8823f1b7f88

@ -0,0 +1 @@
Subproject commit 41613586c503b24033e25a8a5f1943ee3527b184

View File

@ -1,3 +0,0 @@
DB_PASSWORD=changeme
JWT_SECRET=generate-a-64-char-hex-string
ENCRYPTION_KEY=generate-a-64-char-hex-string

View File

@ -1,30 +0,0 @@
# Stage 1: Frontend build
FROM node:20-alpine AS frontend
WORKDIR /app/frontend
COPY frontend/package*.json ./
RUN npm install
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 install
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=backend /app/backend/seed.js ./seed.js
COPY --from=frontend /app/frontend/.output/public ./public
RUN addgroup -S wraith && adduser -S wraith -G wraith && chown -R wraith:wraith /app
USER wraith
EXPOSE 3000
CMD ["sh", "-c", "ls -la prisma/migrations/ && ls -la prisma/migrations/*/ && npx prisma migrate deploy --schema prisma/schema.prisma && node seed.js; node dist/src/main.js"]

View File

@ -1,29 +0,0 @@
# Wraith
Self-hosted MobaXterm replacement — SSH + SFTP + RDP in a browser.
## Stack
- **Backend:** NestJS 10, Prisma 6, PostgreSQL 16, ssh2, guacd
- **Frontend:** Nuxt 3 (SPA), PrimeVue 4, Tailwind CSS, xterm.js 5
## Quick Start
```bash
cp .env.example .env
# Edit .env with real secrets
docker compose up -d
```
Default credentials: `admin@wraith.local` / `wraith`
## Development
```bash
# Backend
cd backend && npm install && npm run dev
# Frontend
cd frontend && npm install && npm run dev
```

View File

@ -1,284 +0,0 @@
# 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.*

View File

@ -1,8 +0,0 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

9614
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,81 +0,0 @@
{
"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/mapped-types": "^2.0.0",
"@nestjs/passport": "^10.0.0",
"@nestjs/throttler": "^6.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",
"argon2": "^0.44.0",
"helmet": "^8.0.0",
"bcrypt": "^5.1.0",
"cookie-parser": "^1.4.7",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"qrcode": "^1.5.4",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.0",
"speakeasy": "^2.0.0",
"ssh2": "^1.15.0",
"uuid": "^13.0.0",
"ws": "^8.16.0"
},
"devDependencies": {
"@nestjs/cli": "^10.0.0",
"@nestjs/testing": "^10.0.0",
"@types/bcrypt": "^5.0.0",
"@types/cookie-parser": "^1.4.8",
"@types/jest": "^30.0.0",
"@types/node": "^20.0.0",
"@types/passport-jwt": "^4.0.0",
"@types/qrcode": "^1.5.6",
"@types/speakeasy": "^2.0.10",
"@types/ssh2": "^1.15.0",
"@types/uuid": "^10.0.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"
},
"prisma": {
"seed": "ts-node prisma/seed.ts"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": ".",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.ts$": "ts-jest"
},
"testEnvironment": "node",
"moduleNameMapper": {
"^@/(.*)$": "<rootDir>/src/$1"
}
}
}

View File

@ -1,121 +0,0 @@
-- CreateSchema
CREATE SCHEMA IF NOT EXISTS "public";
-- CreateEnum
CREATE TYPE "Protocol" AS ENUM ('ssh', 'rdp');
-- CreateEnum
CREATE TYPE "CredentialType" AS ENUM ('password', 'ssh_key');
-- CreateTable
CREATE TABLE "users" (
"id" SERIAL NOT NULL,
"email" TEXT NOT NULL,
"password_hash" TEXT NOT NULL,
"display_name" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "host_groups" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"parent_id" INTEGER,
"sort_order" INTEGER NOT NULL DEFAULT 0,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "host_groups_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "hosts" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"hostname" TEXT NOT NULL,
"port" INTEGER NOT NULL DEFAULT 22,
"protocol" "Protocol" NOT NULL DEFAULT 'ssh',
"group_id" INTEGER,
"credential_id" INTEGER,
"tags" TEXT[] DEFAULT ARRAY[]::TEXT[],
"notes" TEXT,
"color" VARCHAR(7),
"sort_order" INTEGER NOT NULL DEFAULT 0,
"host_fingerprint" TEXT,
"last_connected_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "hosts_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "credentials" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"username" TEXT,
"domain" TEXT,
"type" "CredentialType" NOT NULL,
"encrypted_value" TEXT,
"ssh_key_id" INTEGER,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "credentials_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ssh_keys" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"key_type" VARCHAR(20) NOT NULL,
"fingerprint" TEXT,
"public_key" TEXT,
"encrypted_private_key" TEXT NOT NULL,
"passphrase_encrypted" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ssh_keys_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "connection_logs" (
"id" SERIAL NOT NULL,
"host_id" INTEGER NOT NULL,
"protocol" "Protocol" NOT NULL,
"connected_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"disconnected_at" TIMESTAMP(3),
CONSTRAINT "connection_logs_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "settings" (
"key" TEXT NOT NULL,
"value" TEXT NOT NULL,
CONSTRAINT "settings_pkey" PRIMARY KEY ("key")
);
-- CreateIndex
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
-- AddForeignKey
ALTER TABLE "host_groups" ADD CONSTRAINT "host_groups_parent_id_fkey" FOREIGN KEY ("parent_id") REFERENCES "host_groups"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "hosts" ADD CONSTRAINT "hosts_group_id_fkey" FOREIGN KEY ("group_id") REFERENCES "host_groups"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "hosts" ADD CONSTRAINT "hosts_credential_id_fkey" FOREIGN KEY ("credential_id") REFERENCES "credentials"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "credentials" ADD CONSTRAINT "credentials_ssh_key_id_fkey" FOREIGN KEY ("ssh_key_id") REFERENCES "ssh_keys"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "connection_logs" ADD CONSTRAINT "connection_logs_host_id_fkey" FOREIGN KEY ("host_id") REFERENCES "hosts"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -1,3 +0,0 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "totp_secret" TEXT;
ALTER TABLE "users" ADD COLUMN "totp_enabled" BOOLEAN NOT NULL DEFAULT false;

View File

@ -1,36 +0,0 @@
-- Delete duplicate admin users first (keep the one with lowest id)
DELETE FROM "users" WHERE "email" = 'admin@wraith.local' AND "id" != (SELECT MIN("id") FROM "users" WHERE "email" = 'admin@wraith.local');
-- Add role to users
ALTER TABLE "users" ADD COLUMN "role" TEXT NOT NULL DEFAULT 'user';
-- Backfill admin@wraith.local as admin
UPDATE "users" SET "role" = 'admin' WHERE "email" = 'admin@wraith.local';
-- Add user_id to all data tables
ALTER TABLE "hosts" ADD COLUMN "user_id" INTEGER;
ALTER TABLE "host_groups" ADD COLUMN "user_id" INTEGER;
ALTER TABLE "credentials" ADD COLUMN "user_id" INTEGER;
ALTER TABLE "ssh_keys" ADD COLUMN "user_id" INTEGER;
ALTER TABLE "connection_logs" ADD COLUMN "user_id" INTEGER;
-- Backfill existing data to the admin user
UPDATE "hosts" SET "user_id" = (SELECT "id" FROM "users" WHERE "email" = 'admin@wraith.local');
UPDATE "host_groups" SET "user_id" = (SELECT "id" FROM "users" WHERE "email" = 'admin@wraith.local');
UPDATE "credentials" SET "user_id" = (SELECT "id" FROM "users" WHERE "email" = 'admin@wraith.local');
UPDATE "ssh_keys" SET "user_id" = (SELECT "id" FROM "users" WHERE "email" = 'admin@wraith.local');
UPDATE "connection_logs" SET "user_id" = (SELECT "id" FROM "users" WHERE "email" = 'admin@wraith.local');
-- Make user_id NOT NULL after backfill
ALTER TABLE "hosts" ALTER COLUMN "user_id" SET NOT NULL;
ALTER TABLE "host_groups" ALTER COLUMN "user_id" SET NOT NULL;
ALTER TABLE "credentials" ALTER COLUMN "user_id" SET NOT NULL;
ALTER TABLE "ssh_keys" ALTER COLUMN "user_id" SET NOT NULL;
ALTER TABLE "connection_logs" ALTER COLUMN "user_id" SET NOT NULL;
-- Add foreign keys
ALTER TABLE "hosts" ADD CONSTRAINT "hosts_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE;
ALTER TABLE "host_groups" ADD CONSTRAINT "host_groups_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE;
ALTER TABLE "credentials" ADD CONSTRAINT "credentials_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE;
ALTER TABLE "ssh_keys" ADD CONSTRAINT "ssh_keys_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE;
ALTER TABLE "connection_logs" ADD CONSTRAINT "connection_logs_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE;

View File

@ -1,3 +0,0 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"

View File

@ -1,133 +0,0 @@
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")
role String @default("user")
totpSecret String? @map("totp_secret")
totpEnabled Boolean @default(false) @map("totp_enabled")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
hosts Host[]
hostGroups HostGroup[]
credentials Credential[]
sshKeys SshKey[]
connectionLogs ConnectionLog[]
@@map("users")
}
model HostGroup {
id Int @id @default(autoincrement())
name String
parentId Int? @map("parent_id")
userId Int @map("user_id")
sortOrder Int @default(0) @map("sort_order")
parent HostGroup? @relation("GroupTree", fields: [parentId], references: [id], onDelete: SetNull)
children HostGroup[] @relation("GroupTree")
hosts Host[]
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
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")
userId Int @map("user_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)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
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
userId Int @map("user_id")
encryptedValue String? @map("encrypted_value")
sshKeyId Int? @map("ssh_key_id")
sshKey SshKey? @relation(fields: [sshKeyId], references: [id], onDelete: SetNull)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
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")
userId Int @map("user_id")
encryptedPrivateKey String @map("encrypted_private_key")
passphraseEncrypted String? @map("passphrase_encrypted")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
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")
userId Int @map("user_id")
protocol Protocol
connectedAt DateTime @default(now()) @map("connected_at")
disconnectedAt DateTime? @map("disconnected_at")
host Host @relation(fields: [hostId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], 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
}

View File

@ -1,23 +0,0 @@
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',
role: 'admin',
},
});
console.log('Seed complete: admin@wraith.local / wraith (role: admin)');
}
main()
.catch(console.error)
.finally(() => prisma.$disconnect());

View File

@ -1,26 +0,0 @@
const { PrismaClient } = require('@prisma/client');
const bcrypt = require('bcrypt');
const prisma = new PrismaClient();
async function main() {
const anyAdmin = await prisma.user.findFirst({ where: { role: 'admin' } });
if (anyAdmin) {
console.log(`Seed: admin account exists (${anyAdmin.email}), skipping default seed`);
return;
}
const hash = await bcrypt.hash('wraith', 10);
await prisma.user.create({
data: {
email: 'admin@wraith.local',
passwordHash: hash,
displayName: 'Admin',
role: 'admin',
},
});
console.log('Seed complete: admin@wraith.local / wraith (role: admin)');
}
main()
.catch(console.error)
.finally(() => prisma.$disconnect());

View File

@ -1,43 +0,0 @@
// backend/src/__mocks__/prisma.mock.ts
export const mockPrismaService = {
user: {
findUnique: jest.fn(),
findFirst: jest.fn(),
findMany: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
credential: {
findUnique: jest.fn(),
findMany: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
sshKey: {
findUnique: jest.fn(),
findMany: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
connectionLog: {
create: jest.fn(),
updateMany: jest.fn(),
},
};
export function createMockPrisma() {
// Deep clone to prevent test bleed — re-attach jest.fn() since JSON.parse loses functions
const models = Object.keys(mockPrismaService) as Array<keyof typeof mockPrismaService>;
const mock: any = {};
for (const model of models) {
mock[model] = {};
const methods = Object.keys(mockPrismaService[model]);
for (const method of methods) {
mock[model][method] = jest.fn();
}
}
return mock;
}

View File

@ -1,30 +0,0 @@
import { Module } from '@nestjs/common';
import { ServeStaticModule } from '@nestjs/serve-static';
import { ThrottlerModule } from '@nestjs/throttler';
import { join } from 'path';
import { PrismaModule } from './prisma/prisma.module';
import { AuthModule } from './auth/auth.module';
import { VaultModule } from './vault/vault.module';
import { ConnectionsModule } from './connections/connections.module';
import { SettingsModule } from './settings/settings.module';
import { TerminalModule } from './terminal/terminal.module';
import { RdpModule } from './rdp/rdp.module';
@Module({
imports: [
PrismaModule,
AuthModule,
VaultModule,
ConnectionsModule,
SettingsModule,
TerminalModule,
RdpModule,
// Rate limiting (H-6) — permissive global default, tightened on auth endpoints
ThrottlerModule.forRoot([{ ttl: 60000, limit: 60 }]),
ServeStaticModule.forRoot({
rootPath: join(__dirname, '..', '..', 'public'),
exclude: ['/api/(.*)', '/ws/(.*)'],
}),
],
})
export class AppModule {}

View File

@ -1,30 +0,0 @@
// backend/src/auth/admin.guard.spec.ts
import { AdminGuard } from './admin.guard';
import { ExecutionContext, ForbiddenException } from '@nestjs/common';
function createMockContext(user: any): ExecutionContext {
return {
switchToHttp: () => ({
getRequest: () => ({ user }),
}),
} as any;
}
describe('AdminGuard', () => {
const guard = new AdminGuard();
it('should allow request when user has admin role', () => {
const ctx = createMockContext({ role: 'admin' });
expect(guard.canActivate(ctx)).toBe(true);
});
it('should throw ForbiddenException for non-admin role', () => {
const ctx = createMockContext({ role: 'user' });
expect(() => guard.canActivate(ctx)).toThrow(ForbiddenException);
});
it('should throw ForbiddenException when user is undefined (unauthenticated)', () => {
const ctx = createMockContext(undefined);
expect(() => guard.canActivate(ctx)).toThrow(ForbiddenException);
});
});

View File

@ -1,12 +0,0 @@
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
@Injectable()
export class AdminGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const req = context.switchToHttp().getRequest();
if (req.user?.role !== 'admin') {
throw new ForbiddenException('Admin access required');
}
return true;
}
}

View File

@ -1,99 +0,0 @@
// backend/src/auth/auth.controller.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtService } from '@nestjs/jwt';
describe('AuthController', () => {
let controller: AuthController;
let authService: any;
beforeEach(async () => {
authService = {
login: jest.fn(),
getProfile: jest.fn(),
totpSetup: jest.fn(),
listUsers: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
controllers: [AuthController],
providers: [
{ provide: AuthService, useValue: authService },
{ provide: JwtService, useValue: { sign: jest.fn(), verify: jest.fn() } },
],
}).compile();
controller = module.get(AuthController);
});
describe('login', () => {
it('should set httpOnly cookie and return user without access_token in body', async () => {
authService.login.mockResolvedValue({
access_token: 'jwt-token',
user: { id: 1, email: 'test@test.com', displayName: 'Test', role: 'admin' },
});
const res = { cookie: jest.fn() };
const result = await controller.login(
{ email: 'test@test.com', password: 'pass' } as any,
res,
);
expect(res.cookie).toHaveBeenCalledWith(
'wraith_token',
'jwt-token',
expect.objectContaining({
httpOnly: true,
sameSite: 'strict',
path: '/',
}),
);
expect(result).toEqual({ user: expect.objectContaining({ email: 'test@test.com' }) });
// Token must NOT be in the response body (C-2 security requirement)
expect(result).not.toHaveProperty('access_token');
});
it('should pass through requires_totp without setting cookie', async () => {
authService.login.mockResolvedValue({ requires_totp: true });
const res = { cookie: jest.fn() };
const result = await controller.login(
{ email: 'test@test.com', password: 'pass' } as any,
res,
);
expect(res.cookie).not.toHaveBeenCalled();
expect(result).toEqual({ requires_totp: true });
});
});
describe('logout', () => {
it('should clear the wraith_token cookie', () => {
const res = { clearCookie: jest.fn() };
const result = controller.logout(res);
expect(res.clearCookie).toHaveBeenCalledWith('wraith_token', { path: '/' });
expect(result).toEqual({ message: 'Logged out' });
});
});
describe('ws-ticket', () => {
it('should issue a 64-char hex ticket', () => {
const req = { user: { sub: 1, email: 'test@test.com', role: 'admin' } };
const result = controller.issueWsTicket(req);
expect(result).toHaveProperty('ticket');
expect(result.ticket).toHaveLength(64); // 32 random bytes → 64 hex chars
});
it('should consume ticket exactly once (single-use)', () => {
const req = { user: { sub: 5, email: 'once@test.com', role: 'user' } };
const { ticket } = controller.issueWsTicket(req);
const first = AuthController.consumeWsTicket(ticket);
expect(first).toEqual(expect.objectContaining({ sub: 5, email: 'once@test.com' }));
const second = AuthController.consumeWsTicket(ticket);
expect(second).toBeNull(); // Ticket is deleted after first use
});
});
});

View File

@ -1,158 +0,0 @@
import { Controller, Post, Get, Put, Delete, Body, Param, Request, Response, UseGuards, ParseIntPipe, InternalServerErrorException } from '@nestjs/common';
import { AuthService } from './auth.service';
import { JwtAuthGuard } from './jwt-auth.guard';
import { AdminGuard } from './admin.guard';
import { LoginDto } from './dto/login.dto';
import { UpdateProfileDto } from './dto/update-profile.dto';
import { JwtService } from '@nestjs/jwt';
import { randomBytes } from 'crypto';
// In-memory WS ticket store (short-lived, single-use) (C-3)
const wsTickets = new Map<string, { userId: number; email: string; role: string; expires: number }>();
// Clean up expired tickets every 60 seconds
setInterval(() => {
const now = Date.now();
for (const [ticket, data] of wsTickets) {
if (data.expires < now) wsTickets.delete(ticket);
}
}, 60000);
@Controller('auth')
export class AuthController {
constructor(
private auth: AuthService,
private jwt: JwtService,
) {}
@Post('login')
async login(@Body() dto: LoginDto, @Response({ passthrough: true }) res: any) {
const result = await this.auth.login(dto.email, dto.password, dto.totpCode);
if ('requires_totp' in result) {
return result;
}
// Set JWT as httpOnly cookie (C-2)
res.cookie('wraith_token', result.access_token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 4 * 60 * 60 * 1000, // 4 hours (H-1: down from 7 days)
path: '/',
});
// Return user info only — token is in the cookie, NOT in the response body
return { user: result.user };
}
@Post('logout')
@UseGuards(JwtAuthGuard)
logout(@Response({ passthrough: true }) res: any) {
res.clearCookie('wraith_token', { path: '/' });
return { message: 'Logged out' };
}
/**
* Issue a short-lived, single-use WebSocket ticket (C-3).
* Frontend calls this before opening a WS connection, then passes
* the ticket as ?ticket=<nonce> instead of the JWT.
*/
@Post('ws-ticket')
@UseGuards(JwtAuthGuard)
issueWsTicket(@Request() req: any) {
const ticket = randomBytes(32).toString('hex');
wsTickets.set(ticket, {
userId: req.user.sub,
email: req.user.email,
role: req.user.role,
expires: Date.now() + 30000, // 30 seconds
});
return { ticket };
}
/**
* Validate and consume a WS ticket. Called by WsAuthGuard.
* Returns the user payload or null if invalid/expired.
*/
static consumeWsTicket(ticket: string): { sub: number; email: string; role: string } | null {
const data = wsTickets.get(ticket);
if (!data) return null;
wsTickets.delete(ticket); // Single-use
if (data.expires < Date.now()) return null;
return { sub: data.userId, email: data.email, role: data.role };
}
@UseGuards(JwtAuthGuard)
@Get('profile')
getProfile(@Request() req: any) {
return this.auth.getProfile(req.user.sub);
}
@UseGuards(JwtAuthGuard)
@Put('profile')
async updateProfile(@Request() req: any, @Body() dto: UpdateProfileDto) {
try {
return await this.auth.updateProfile(req.user.sub, dto);
} catch (e: any) {
if (e.status) throw e;
throw new InternalServerErrorException('Profile update failed');
}
}
@UseGuards(JwtAuthGuard)
@Post('totp/setup')
totpSetup(@Request() req: any) {
return this.auth.totpSetup(req.user.sub);
}
@UseGuards(JwtAuthGuard)
@Post('totp/verify')
totpVerify(@Request() req: any, @Body() body: { code: string }) {
return this.auth.totpVerify(req.user.sub, body.code);
}
@UseGuards(JwtAuthGuard)
@Post('totp/disable')
totpDisable(@Request() req: any, @Body() body: { password: string }) {
return this.auth.totpDisable(req.user.sub, body.password);
}
// ─── Admin User Management ──────────────────────────────────────────────
@UseGuards(JwtAuthGuard, AdminGuard)
@Get('users')
listUsers() {
return this.auth.listUsers();
}
@UseGuards(JwtAuthGuard, AdminGuard)
@Post('users')
createUser(@Body() body: { email: string; password: string; displayName?: string; role?: string }) {
return this.auth.createUser(body);
}
@UseGuards(JwtAuthGuard, AdminGuard)
@Put('users/:id')
updateUser(@Param('id', ParseIntPipe) id: number, @Request() req: any, @Body() body: { email?: string; displayName?: string; role?: string }) {
return this.auth.adminUpdateUser(id, req.user.sub, body);
}
@UseGuards(JwtAuthGuard, AdminGuard)
@Post('users/:id/reset-password')
resetPassword(@Param('id', ParseIntPipe) id: number, @Body() body: { password: string }) {
return this.auth.adminResetPassword(id, body.password);
}
@UseGuards(JwtAuthGuard, AdminGuard)
@Post('users/:id/reset-totp')
resetTotp(@Param('id', ParseIntPipe) id: number) {
return this.auth.adminResetTotp(id);
}
@UseGuards(JwtAuthGuard, AdminGuard)
@Delete('users/:id')
deleteUser(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
return this.auth.adminDeleteUser(id, req.user.sub);
}
}

View File

@ -1,24 +0,0 @@
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';
import { AdminGuard } from './admin.guard';
import { VaultModule } from '../vault/vault.module';
@Module({
imports: [
PassportModule,
VaultModule, // EncryptionService for TOTP secret encryption (H-3)
JwtModule.register({
secret: process.env.JWT_SECRET,
signOptions: { expiresIn: '4h' }, // H-1: down from 7 days
}),
],
providers: [AuthService, JwtStrategy, WsAuthGuard, AdminGuard],
controllers: [AuthController],
exports: [WsAuthGuard, AdminGuard, JwtModule],
})
export class AuthModule {}

View File

@ -1,204 +0,0 @@
// backend/src/auth/auth.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { AuthService } from './auth.service';
import { PrismaService } from '../prisma/prisma.service';
import { JwtService } from '@nestjs/jwt';
import { EncryptionService } from '../vault/encryption.service';
import {
UnauthorizedException,
BadRequestException,
NotFoundException,
ForbiddenException,
} from '@nestjs/common';
import * as argon2 from 'argon2';
import * as bcrypt from 'bcrypt';
describe('AuthService', () => {
let service: AuthService;
let prisma: any;
let jwt: any;
let encryption: any;
beforeEach(async () => {
prisma = {
user: {
findUnique: jest.fn(),
findMany: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
};
jwt = { sign: jest.fn().mockReturnValue('mock-jwt-token') };
encryption = {
encrypt: jest.fn().mockResolvedValue('v2:encrypted'),
decrypt: jest.fn().mockResolvedValue('decrypted-secret'),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
AuthService,
{ provide: PrismaService, useValue: prisma },
{ provide: JwtService, useValue: jwt },
{ provide: EncryptionService, useValue: encryption },
],
}).compile();
service = module.get(AuthService);
// onModuleInit pre-computes the dummy hash for timing-safe login
await service.onModuleInit();
});
describe('login', () => {
it('should return access_token and user for valid Argon2id credentials', async () => {
const hash = await argon2.hash('correct-password', { type: argon2.argon2id });
prisma.user.findUnique.mockResolvedValue({
id: 1,
email: 'test@test.com',
passwordHash: hash,
displayName: 'Test User',
role: 'admin',
totpEnabled: false,
});
const result = await service.login('test@test.com', 'correct-password');
expect(result).toHaveProperty('access_token', 'mock-jwt-token');
expect((result as any).user.email).toBe('test@test.com');
});
it('should throw UnauthorizedException for wrong password', async () => {
const hash = await argon2.hash('correct', { type: argon2.argon2id });
prisma.user.findUnique.mockResolvedValue({
id: 1,
email: 'test@test.com',
passwordHash: hash,
totpEnabled: false,
});
await expect(service.login('test@test.com', 'wrong')).rejects.toThrow(UnauthorizedException);
});
it('should throw UnauthorizedException for non-existent user (constant time defense)', async () => {
prisma.user.findUnique.mockResolvedValue(null);
await expect(service.login('nobody@test.com', 'pass')).rejects.toThrow(UnauthorizedException);
});
it('should auto-upgrade bcrypt hash to Argon2id on successful login', async () => {
const bcryptHash = await bcrypt.hash('password', 10);
prisma.user.findUnique.mockResolvedValue({
id: 1,
email: 'legacy@test.com',
passwordHash: bcryptHash,
displayName: 'Legacy User',
role: 'user',
totpEnabled: false,
});
prisma.user.update.mockResolvedValue({});
await service.login('legacy@test.com', 'password');
expect(prisma.user.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 1 },
data: expect.objectContaining({
passwordHash: expect.stringContaining('$argon2id$'),
}),
}),
);
});
it('should return { requires_totp: true } when TOTP enabled but no code provided', async () => {
const hash = await argon2.hash('pass', { type: argon2.argon2id });
prisma.user.findUnique.mockResolvedValue({
id: 1,
email: 'totp@test.com',
passwordHash: hash,
totpEnabled: true,
totpSecret: 'v2:encrypted-secret',
});
const result = await service.login('totp@test.com', 'pass');
expect(result).toEqual({ requires_totp: true });
});
});
describe('createUser', () => {
it('should hash password with Argon2id before storage', async () => {
prisma.user.findUnique.mockResolvedValue(null);
prisma.user.create.mockResolvedValue({
id: 1,
email: 'new@test.com',
displayName: null,
role: 'user',
});
await service.createUser({ email: 'new@test.com', password: 'StrongPass1!' });
const createCall = prisma.user.create.mock.calls[0][0];
expect(createCall.data.passwordHash).toMatch(/^\$argon2id\$/);
});
it('should throw BadRequestException for duplicate email', async () => {
prisma.user.findUnique.mockResolvedValue({ id: 1 });
await expect(
service.createUser({ email: 'dup@test.com', password: 'pass' }),
).rejects.toThrow(BadRequestException);
});
});
describe('adminDeleteUser', () => {
it('should throw ForbiddenException on self-deletion attempt', async () => {
await expect(service.adminDeleteUser(1, 1)).rejects.toThrow(ForbiddenException);
});
it('should delete a different user', async () => {
prisma.user.findUnique.mockResolvedValue({ id: 2 });
prisma.user.delete.mockResolvedValue({ id: 2 });
await service.adminDeleteUser(2, 1);
expect(prisma.user.delete).toHaveBeenCalledWith({ where: { id: 2 } });
});
it('should throw NotFoundException when target user does not exist', async () => {
prisma.user.findUnique.mockResolvedValue(null);
await expect(service.adminDeleteUser(99, 1)).rejects.toThrow(NotFoundException);
});
});
describe('totpSetup', () => {
it('should encrypt TOTP secret before storage and return secret + qrCode', async () => {
prisma.user.findUnique.mockResolvedValue({ id: 1, email: 'test@test.com', totpEnabled: false });
prisma.user.update.mockResolvedValue({});
const result = await service.totpSetup(1);
expect(encryption.encrypt).toHaveBeenCalled();
expect(prisma.user.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ totpSecret: 'v2:encrypted' }),
}),
);
expect(result).toHaveProperty('secret');
expect(result).toHaveProperty('qrCode');
});
it('should throw BadRequestException if TOTP already enabled', async () => {
prisma.user.findUnique.mockResolvedValue({ id: 1, totpEnabled: true });
await expect(service.totpSetup(1)).rejects.toThrow(BadRequestException);
});
});
describe('updateProfile', () => {
it('should throw BadRequestException when changing password without providing current password', async () => {
prisma.user.findUnique.mockResolvedValue({ id: 1, passwordHash: 'hash' });
await expect(
service.updateProfile(1, { newPassword: 'new-password' }),
).rejects.toThrow(BadRequestException);
});
});
describe('adminUpdateUser', () => {
it('should throw ForbiddenException when admin tries to remove their own admin role', async () => {
prisma.user.findUnique.mockResolvedValue({ id: 1, role: 'admin' });
await expect(service.adminUpdateUser(1, 1, { role: 'user' })).rejects.toThrow(ForbiddenException);
});
});
});

View File

@ -1,299 +0,0 @@
import { Injectable, UnauthorizedException, BadRequestException, NotFoundException, ForbiddenException, OnModuleInit, Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { PrismaService } from '../prisma/prisma.service';
import { EncryptionService } from '../vault/encryption.service';
import * as argon2 from 'argon2';
import * as bcrypt from 'bcrypt'; // Keep for migration from bcrypt→argon2id
import * as speakeasy from 'speakeasy';
import * as QRCode from 'qrcode';
// Argon2id parameters — OWASP recommended, matches vault encryption
const ARGON2_OPTIONS: argon2.Options & { raw?: false } = {
type: argon2.argon2id,
memoryCost: 65536, // 64 MiB
timeCost: 3, // 3 iterations
parallelism: 4, // 4 threads
};
@Injectable()
export class AuthService implements OnModuleInit {
private readonly logger = new Logger(AuthService.name);
private dummyHash = '';
constructor(
private prisma: PrismaService,
private jwt: JwtService,
private encryption: EncryptionService,
) {}
async onModuleInit() {
// Pre-compute a dummy hash for constant-time login on non-existent users (H-8)
this.dummyHash = await argon2.hash('dummy-timing-defense', ARGON2_OPTIONS);
this.logger.log('Argon2id password hashing initialized');
}
/**
* Hash a password with Argon2id (H-9)
*/
private async hashPassword(password: string): Promise<string> {
return argon2.hash(password, ARGON2_OPTIONS);
}
/**
* Verify a password against a stored hash.
* Handles both Argon2id (new) and bcrypt (legacy) hashes.
* Auto-upgrades bcrypt hashes to Argon2id on successful login (H-9).
*/
private async verifyPassword(password: string, hash: string, userId?: number): Promise<boolean> {
if (hash.startsWith('$2b$') || hash.startsWith('$2a$')) {
// Legacy bcrypt hash — verify with bcrypt
const valid = await bcrypt.compare(password, hash);
if (valid && userId) {
// Auto-upgrade to Argon2id
const newHash = await this.hashPassword(password);
await this.prisma.user.update({ where: { id: userId }, data: { passwordHash: newHash } });
this.logger.log(`User ${userId} password auto-upgraded from bcrypt to Argon2id`);
}
return valid;
}
// Argon2id hash
return argon2.verify(hash, password);
}
/**
* Decrypt a TOTP secret from the database.
* Handles both encrypted (v2:...) and legacy plaintext secrets.
* Auto-migrates plaintext secrets to encrypted on read (H-3).
*/
private async getTotpSecret(user: { id: number; totpSecret: string | null }): Promise<string | null> {
if (!user.totpSecret) return null;
if (user.totpSecret.startsWith('v2:')) {
return this.encryption.decrypt(user.totpSecret);
}
// Plaintext — auto-migrate to encrypted
const plaintext = user.totpSecret;
const encrypted = await this.encryption.encrypt(plaintext);
await this.prisma.user.update({ where: { id: user.id }, data: { totpSecret: encrypted } });
this.logger.log(`User ${user.id} TOTP secret auto-encrypted`);
return plaintext;
}
async login(email: string, password: string, totpCode?: string) {
const user = await this.prisma.user.findUnique({ where: { email } });
if (!user) {
// Constant-time: run Argon2id verify against dummy hash (H-8)
// This prevents timing attacks that reveal whether an email exists
await argon2.verify(this.dummyHash, password);
throw new UnauthorizedException('Invalid credentials');
}
const valid = await this.verifyPassword(password, user.passwordHash, user.id);
if (!valid) throw new UnauthorizedException('Invalid credentials');
// If TOTP is enabled, require a valid code
if (user.totpEnabled && user.totpSecret) {
if (!totpCode) {
// Signal frontend to show TOTP input
return { requires_totp: true };
}
const secret = await this.getTotpSecret(user);
if (!secret) throw new UnauthorizedException('TOTP configuration error');
const verified = speakeasy.totp.verify({
secret,
encoding: 'base32',
token: totpCode,
window: 1,
});
if (!verified) throw new UnauthorizedException('Invalid TOTP code');
}
const payload = { sub: user.id, email: user.email, role: user.role };
return {
access_token: this.jwt.sign(payload),
user: { id: user.id, email: user.email, displayName: user.displayName, role: user.role },
};
}
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,
role: user.role,
totpEnabled: user.totpEnabled,
};
}
async updateProfile(userId: number, data: { email?: string; displayName?: string; currentPassword?: string; newPassword?: string }) {
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user) throw new UnauthorizedException();
const update: any = {};
if (data.email && data.email !== user.email) {
update.email = data.email;
}
if (data.displayName !== undefined) {
update.displayName = data.displayName;
}
if (data.newPassword) {
if (!data.currentPassword) {
throw new BadRequestException('Current password required to set new password');
}
const valid = await this.verifyPassword(data.currentPassword, user.passwordHash);
if (!valid) throw new BadRequestException('Current password is incorrect');
update.passwordHash = await this.hashPassword(data.newPassword);
}
if (Object.keys(update).length === 0) {
return { message: 'No changes' };
}
await this.prisma.user.update({ where: { id: userId }, data: update });
return { message: 'Profile updated' };
}
async totpSetup(userId: number) {
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user) throw new UnauthorizedException();
if (user.totpEnabled) throw new BadRequestException('TOTP already enabled');
const secret = speakeasy.generateSecret({
name: `Wraith (${user.email})`,
issuer: 'Wraith',
});
// Encrypt TOTP secret before storage (H-3)
const encryptedSecret = await this.encryption.encrypt(secret.base32);
await this.prisma.user.update({
where: { id: userId },
data: { totpSecret: encryptedSecret },
});
const qrCodeUrl = await QRCode.toDataURL(secret.otpauth_url!);
return {
secret: secret.base32,
qrCode: qrCodeUrl,
};
}
async totpVerify(userId: number, code: string) {
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user || !user.totpSecret) throw new BadRequestException('TOTP not set up');
const secret = await this.getTotpSecret(user);
if (!secret) throw new BadRequestException('TOTP configuration error');
const verified = speakeasy.totp.verify({
secret,
encoding: 'base32',
token: code,
window: 1,
});
if (!verified) throw new BadRequestException('Invalid code — try again');
await this.prisma.user.update({
where: { id: userId },
data: { totpEnabled: true },
});
return { message: 'TOTP enabled successfully' };
}
async totpDisable(userId: number, password: string) {
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user) throw new UnauthorizedException();
const valid = await this.verifyPassword(password, user.passwordHash);
if (!valid) throw new BadRequestException('Password incorrect');
await this.prisma.user.update({
where: { id: userId },
data: { totpEnabled: false, totpSecret: null },
});
return { message: 'TOTP disabled' };
}
// ─── Admin User Management ──────────────────────────────────────────────
async listUsers() {
return this.prisma.user.findMany({
select: { id: true, email: true, displayName: true, role: true, totpEnabled: true, createdAt: true },
orderBy: { createdAt: 'asc' },
});
}
async createUser(data: { email: string; password: string; displayName?: string; role?: string }) {
const existing = await this.prisma.user.findUnique({ where: { email: data.email } });
if (existing) throw new BadRequestException('Email already in use');
const hash = await this.hashPassword(data.password);
const user = await this.prisma.user.create({
data: {
email: data.email,
passwordHash: hash,
displayName: data.displayName || null,
role: data.role || 'user',
},
});
return { id: user.id, email: user.email, displayName: user.displayName, role: user.role };
}
async adminUpdateUser(targetId: number, adminId: number, data: { email?: string; displayName?: string; role?: string }) {
const target = await this.prisma.user.findUnique({ where: { id: targetId } });
if (!target) throw new NotFoundException('User not found');
// Prevent removing your own admin role
if (targetId === adminId && data.role && data.role !== 'admin') {
throw new ForbiddenException('Cannot remove your own admin role');
}
return this.prisma.user.update({
where: { id: targetId },
data: {
email: data.email,
displayName: data.displayName,
role: data.role,
},
select: { id: true, email: true, displayName: true, role: true },
});
}
async adminResetPassword(targetId: number, newPassword: string) {
const target = await this.prisma.user.findUnique({ where: { id: targetId } });
if (!target) throw new NotFoundException('User not found');
const hash = await this.hashPassword(newPassword);
await this.prisma.user.update({ where: { id: targetId }, data: { passwordHash: hash } });
return { message: 'Password reset' };
}
async adminResetTotp(targetId: number) {
const target = await this.prisma.user.findUnique({ where: { id: targetId } });
if (!target) throw new NotFoundException('User not found');
await this.prisma.user.update({
where: { id: targetId },
data: { totpEnabled: false, totpSecret: null },
});
return { message: 'TOTP reset' };
}
async adminDeleteUser(targetId: number, adminId: number) {
if (targetId === adminId) {
throw new ForbiddenException('Cannot delete your own account');
}
const target = await this.prisma.user.findUnique({ where: { id: targetId } });
if (!target) throw new NotFoundException('User not found');
// CASCADE will delete all their hosts, credentials, keys, logs
await this.prisma.user.delete({ where: { id: targetId } });
return { message: 'User deleted' };
}
}

View File

@ -1,14 +0,0 @@
import { IsEmail, IsString, MinLength, IsOptional } from 'class-validator';
export class LoginDto {
@IsEmail()
email: string;
@IsString()
@MinLength(1)
password: string;
@IsOptional()
@IsString()
totpCode?: string;
}

View File

@ -1,20 +0,0 @@
import { IsEmail, IsString, IsOptional, MinLength } from 'class-validator';
export class UpdateProfileDto {
@IsOptional()
@IsEmail()
email?: string;
@IsOptional()
@IsString()
displayName?: string;
@IsOptional()
@IsString()
currentPassword?: string;
@IsOptional()
@IsString()
@MinLength(6)
newPassword?: string;
}

View File

@ -1,13 +0,0 @@
// backend/src/auth/jwt-auth.guard.spec.ts
import { JwtAuthGuard } from './jwt-auth.guard';
describe('JwtAuthGuard', () => {
it('should be defined', () => {
expect(new JwtAuthGuard()).toBeDefined();
});
it('should be an instance of JwtAuthGuard', () => {
const guard = new JwtAuthGuard();
expect(guard).toBeInstanceOf(JwtAuthGuard);
});
});

View File

@ -1,5 +0,0 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}

View File

@ -1,23 +0,0 @@
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: (req: any) => {
// Cookie-based auth (C-2) — preferred in production
if (req?.cookies?.wraith_token) return req.cookies.wraith_token;
// Fallback: Authorization header (for migration / API clients)
return ExtractJwt.fromAuthHeaderAsBearerToken()(req);
},
ignoreExpiration: false,
secretOrKey: process.env.JWT_SECRET,
});
}
validate(payload: { sub: number; email: string; role: string }) {
return { sub: payload.sub, email: payload.email, role: payload.role };
}
}

View File

@ -1,56 +0,0 @@
// backend/src/auth/ws-auth.guard.spec.ts
import { WsAuthGuard } from './ws-auth.guard';
import { AuthController } from './auth.controller';
describe('WsAuthGuard', () => {
let guard: WsAuthGuard;
let jwt: any;
beforeEach(() => {
jwt = {
verify: jest.fn().mockReturnValue({ sub: 1, email: 'test@test.com' }),
};
guard = new WsAuthGuard(jwt as any);
});
it('should authenticate via httpOnly cookie', () => {
const req = { headers: { cookie: 'wraith_token=valid-jwt; other=stuff' }, url: '/ws' };
const result = guard.validateClient({}, req);
expect(jwt.verify).toHaveBeenCalledWith('valid-jwt');
expect(result).toEqual({ sub: 1, email: 'test@test.com' });
});
it('should authenticate via single-use WS ticket', () => {
const originalConsume = AuthController.consumeWsTicket;
AuthController.consumeWsTicket = jest.fn().mockReturnValue({
sub: 42,
email: 'ticket@test.com',
role: 'user',
});
const req = { url: '/api/ws/terminal?ticket=abc123', headers: {} };
const result = guard.validateClient({}, req);
expect(AuthController.consumeWsTicket).toHaveBeenCalledWith('abc123');
expect(result).toEqual({ sub: 42, email: 'ticket@test.com' });
// Restore
AuthController.consumeWsTicket = originalConsume;
});
it('should fall back to legacy URL token when no cookie or ticket', () => {
const req = { url: '/api/ws/terminal?token=legacy-jwt', headers: {} };
guard.validateClient({}, req);
expect(jwt.verify).toHaveBeenCalledWith('legacy-jwt');
});
it('should return null when no credentials are present', () => {
const req = { url: '/api/ws/terminal', headers: {} };
const result = guard.validateClient({}, req);
expect(result).toBeNull();
});
it('should return null when JWT verification fails', () => {
jwt.verify.mockImplementation(() => { throw new Error('invalid signature'); });
const req = { headers: { cookie: 'wraith_token=bad-jwt' }, url: '/ws' };
const result = guard.validateClient({}, req);
expect(result).toBeNull();
});
});

View File

@ -1,46 +0,0 @@
import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { WsException } from '@nestjs/websockets';
import { AuthController } from './auth.controller';
@Injectable()
export class WsAuthGuard {
constructor(private jwt: JwtService) {}
validateClient(client: any, req?: any): { sub: number; email: string } | null {
try {
let token: string | undefined;
// 1. Cookie-based auth (C-2) — preferred, sent automatically on WS upgrade
const cookieHeader = req?.headers?.cookie;
if (cookieHeader) {
const match = cookieHeader.match(/wraith_token=([^;]+)/);
if (match) token = match[1];
}
// 2. Single-use WS ticket (C-3) — short-lived nonce from POST /api/auth/ws-ticket
if (!token) {
const rawUrl = req?.url || client.url || client._url;
const url = new URL(rawUrl, 'http://localhost');
const ticket = url.searchParams.get('ticket');
if (ticket) {
const user = AuthController.consumeWsTicket(ticket);
if (user) return { sub: user.sub, email: user.email };
}
}
// 3. Legacy: JWT in URL query (kept for backwards compat during migration)
if (!token) {
const rawUrl = req?.url || client.url || client._url;
const url = new URL(rawUrl, 'http://localhost');
const urlToken = url.searchParams.get('token');
if (urlToken) token = urlToken;
}
if (!token) throw new WsException('No token');
return this.jwt.verify(token) as { sub: number; email: string };
} catch {
return null;
}
}
}

View File

@ -1,12 +0,0 @@
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 {}

View File

@ -1,14 +0,0 @@
import { IsString, IsInt, IsOptional } from 'class-validator';
export class CreateGroupDto {
@IsString()
name: string;
@IsInt()
@IsOptional()
parentId?: number;
@IsInt()
@IsOptional()
sortOrder?: number;
}

View File

@ -1,41 +0,0 @@
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;
}

View File

@ -1,4 +0,0 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateGroupDto } from './create-group.dto';
export class UpdateGroupDto extends PartialType(CreateGroupDto) {}

View File

@ -1,4 +0,0 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateHostDto } from './create-host.dto';
export class UpdateHostDto extends PartialType(CreateHostDto) {}

View File

@ -1,41 +0,0 @@
import { Controller, Get, Post, Put, Delete, Param, Body, Request, 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(@Request() req: any) {
return this.groups.findAll(req.user.sub);
}
@Get('tree')
findTree(@Request() req: any) {
return this.groups.findTree(req.user.sub);
}
@Get(':id')
findOne(@Request() req: any, @Param('id', ParseIntPipe) id: number) {
return this.groups.findOne(id, req.user.sub);
}
@Post()
create(@Request() req: any, @Body() dto: CreateGroupDto) {
return this.groups.create(req.user.sub, dto);
}
@Put(':id')
update(@Request() req: any, @Param('id', ParseIntPipe) id: number, @Body() dto: UpdateGroupDto) {
return this.groups.update(id, req.user.sub, dto);
}
@Delete(':id')
remove(@Request() req: any, @Param('id', ParseIntPipe) id: number) {
return this.groups.remove(id, req.user.sub);
}
}

View File

@ -1,62 +0,0 @@
import { Injectable, NotFoundException, ForbiddenException } 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(userId: number) {
return this.prisma.hostGroup.findMany({
where: { userId },
include: { children: true, hosts: { select: { id: true, name: true, protocol: true } } },
orderBy: { sortOrder: 'asc' },
});
}
findTree(userId: number) {
return this.prisma.hostGroup.findMany({
where: { parentId: null, userId },
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, userId: number) {
const group = await this.prisma.hostGroup.findUnique({
where: { id },
include: { hosts: true, children: true },
});
if (!group) throw new NotFoundException(`Group ${id} not found`);
if (group.userId !== userId) throw new ForbiddenException('Access denied');
return group;
}
create(userId: number, dto: CreateGroupDto) {
return this.prisma.hostGroup.create({ data: { ...dto, userId } });
}
async update(id: number, userId: number, dto: UpdateGroupDto) {
await this.findOne(id, userId);
return this.prisma.hostGroup.update({ where: { id }, data: dto });
}
async remove(id: number, userId: number) {
await this.findOne(id, userId);
return this.prisma.hostGroup.delete({ where: { id } });
}
}

View File

@ -1,41 +0,0 @@
import { Controller, Get, Post, Put, Delete, Param, Body, Query, Request, 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(@Request() req: any, @Query('search') search?: string) {
return this.hosts.findAll(req.user.sub, search);
}
@Get(':id')
findOne(@Request() req: any, @Param('id', ParseIntPipe) id: number) {
return this.hosts.findOne(id, req.user.sub);
}
@Post()
create(@Request() req: any, @Body() dto: CreateHostDto) {
return this.hosts.create(req.user.sub, dto);
}
@Put(':id')
update(@Request() req: any, @Param('id', ParseIntPipe) id: number, @Body() dto: UpdateHostDto) {
return this.hosts.update(id, req.user.sub, dto);
}
@Delete(':id')
remove(@Request() req: any, @Param('id', ParseIntPipe) id: number) {
return this.hosts.remove(id, req.user.sub);
}
@Post('reorder')
reorder(@Request() req: any, @Body() body: { ids: number[] }) {
return this.hosts.reorder(req.user.sub, body.ids);
}
}

View File

@ -1,83 +0,0 @@
import { Injectable, NotFoundException, ForbiddenException } 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(userId: number, search?: string) {
const where: any = { userId };
if (search) {
where.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, userId?: number) {
const host = await this.prisma.host.findUnique({
where: { id },
include: { group: true, credential: true },
});
if (!host) throw new NotFoundException(`Host ${id} not found`);
if (userId !== undefined && host.userId !== userId) {
throw new ForbiddenException('Access denied');
}
return host;
}
create(userId: number, 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,
userId,
tags: dto.tags ?? [],
notes: dto.notes,
color: dto.color,
},
include: { group: true },
});
}
async update(id: number, userId: number, dto: UpdateHostDto) {
await this.findOne(id, userId);
return this.prisma.host.update({ where: { id }, data: dto });
}
async remove(id: number, userId: number) {
await this.findOne(id, userId);
return this.prisma.host.delete({ where: { id } });
}
async touchLastConnected(id: number) {
return this.prisma.host.update({
where: { id },
data: { lastConnectedAt: new Date() },
});
}
async reorder(userId: number, ids: number[]) {
// Verify all hosts belong to user
const hosts = await this.prisma.host.findMany({ where: { id: { in: ids }, userId } });
if (hosts.length !== ids.length) throw new ForbiddenException('Access denied');
const updates = ids.map((id, index) =>
this.prisma.host.update({ where: { id }, data: { sortOrder: index } }),
);
return this.prisma.$transaction(updates);
}
}

View File

@ -1,119 +0,0 @@
import helmet from 'helmet';
import * as cookieParser from 'cookie-parser';
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { WsAdapter } from '@nestjs/platform-ws';
import { AppModule } from './app.module';
import { WebSocketServer } from 'ws';
import { TerminalGateway } from './terminal/terminal.gateway';
import { SftpGateway } from './terminal/sftp.gateway';
import { RdpGateway } from './rdp/rdp.gateway';
// Crash handlers — catch whatever is killing the process
process.on('uncaughtException', (err) => {
console.error(`[FATAL] Uncaught Exception: ${err.message}\n${err.stack}`);
});
process.on('unhandledRejection', (reason: any) => {
console.error(`[FATAL] Unhandled Rejection: ${reason?.message || reason}\n${reason?.stack || ''}`);
});
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.use(cookieParser());
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "blob:"],
connectSrc: ["'self'", "ws:", "wss:"],
fontSrc: ["'self'", "data:"],
},
},
}));
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');
const server = app.getHttpServer();
const terminalGateway = app.get(TerminalGateway);
const sftpGateway = app.get(SftpGateway);
const rdpGateway = app.get(RdpGateway);
const terminalWss = new WebSocketServer({ noServer: true });
const sftpWss = new WebSocketServer({ noServer: true });
const rdpWss = new WebSocketServer({ noServer: true });
terminalWss.on('connection', (ws, req) => {
try {
console.log(`[WS] Terminal connection established`);
terminalGateway.handleConnection(ws, req);
} catch (err: any) {
console.error(`[FATAL] Terminal handleConnection crashed: ${err.message}\n${err.stack}`);
}
});
sftpWss.on('connection', (ws, req) => {
try {
console.log(`[WS] SFTP connection established`);
sftpGateway.handleConnection(ws, req);
} catch (err: any) {
console.error(`[FATAL] SFTP handleConnection crashed: ${err.message}\n${err.stack}`);
}
});
rdpWss.on('connection', (ws, req) => {
try {
console.log(`[WS] RDP connection established`);
rdpGateway.handleConnection(ws, req);
} catch (err: any) {
console.error(`[FATAL] RDP handleConnection crashed: ${err.message}\n${err.stack}`);
}
});
// Remove ALL existing upgrade listeners (WsAdapter's) so we handle upgrades first
const existingListeners = server.listeners('upgrade');
server.removeAllListeners('upgrade');
// Our handler runs first — routes terminal/sftp, passes everything else to WsAdapter
server.on('upgrade', (req: any, socket: any, head: any) => {
try {
const pathname = req.url?.split('?')[0];
console.log(`[HTTP-UPGRADE] path=${pathname} socket.destroyed=${socket.destroyed}`);
if (pathname === '/api/ws/terminal') {
terminalWss.handleUpgrade(req, socket, head, (ws) => {
console.log(`[WS] Terminal upgrade complete`);
terminalWss.emit('connection', ws, req);
});
} else if (pathname === '/api/ws/sftp') {
sftpWss.handleUpgrade(req, socket, head, (ws) => {
console.log(`[WS] SFTP upgrade complete`);
sftpWss.emit('connection', ws, req);
});
} else if (pathname === '/api/ws/rdp') {
rdpWss.handleUpgrade(req, socket, head, (ws) => {
console.log(`[WS] RDP upgrade complete`);
rdpWss.emit('connection', ws, req);
});
} else {
// Pass to WsAdapter's original handlers
for (const listener of existingListeners) {
listener.call(server, req, socket, head);
}
}
} catch (err: any) {
console.error(`[FATAL] Upgrade handler crashed: ${err.message}\n${err.stack}`);
socket.destroy();
}
});
}
bootstrap();

View File

@ -1,9 +0,0 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}

View File

@ -1,13 +0,0 @@
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();
}
}

View File

@ -1,275 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import * as net from 'net';
/**
* Guacamole wire protocol: instructions are comma-separated length-prefixed fields
* terminated by semicolons.
* Example: "4.size,4.1024,3.768;"
* Format per field: "<length>.<value>"
*/
@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);
/**
* Opens a raw TCP connection to guacd, completes the SELECT CONNECT handshake,
* and returns the live socket ready for bidirectional Guacamole instruction traffic.
*/
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<net.Socket> {
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 rdp — tells guacd which protocol to prepare
socket.write(this.encode('select', 'rdp'));
let buffer = '';
const onHandshake = (data: Buffer) => {
buffer += data.toString('utf-8');
// guacd responds with "args" listing the parameters it expects.
// Wait until we receive at least one complete instruction (ends with ';').
const semicolonIdx = buffer.indexOf(';');
if (semicolonIdx === -1) return;
// We've received the args instruction — remove handshake listener
socket.removeListener('data', onHandshake);
// Clear the connect timeout — handshake completed
socket.setTimeout(0);
// Parse the args instruction to get expected parameter names
const argsInstruction = buffer.substring(0, semicolonIdx + 1);
const argNames = this.decode(argsInstruction);
// First element is the opcode ("args"), rest are parameter names
if (argNames[0] === 'args') {
argNames.shift();
}
this.logger.log(`guacd expects ${argNames.length} args: ${argNames.join(', ')}`);
// Phase 2: Client capability instructions — MUST be sent before CONNECT.
// The Guacamole protocol requires: size, audio, video, image, timezone
// between receiving 'args' and sending 'connect'. Without these, guacd
// sees 0x0 resolution and immediately kills the connection.
const width = String(params.width);
const height = String(params.height);
const dpi = String(params.dpi || 96);
socket.write(this.encode('size', width, height, dpi));
socket.write(this.encode('audio'));
socket.write(this.encode('video'));
socket.write(this.encode('image', 'image/png', 'image/jpeg', 'image/webp'));
let tz: string;
try {
tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
} catch {
tz = 'UTC';
}
socket.write(this.encode('timezone', tz));
// Phase 3: CONNECT — send values in the exact order guacd expects
const connectInstruction = this.buildConnectInstruction(params, argNames);
this.logger.log(
`Sending CONNECT: host=${params.hostname}:${params.port} user=${params.username} domain=${params.domain || '(none)'} ` +
`security=${params.security || 'any'} size=${width}x${height}@${dpi}dpi ignoreCert=${params.ignoreCert !== false}`,
);
socket.write(connectInstruction);
resolve(socket);
};
socket.on('data', onHandshake);
});
socket.on('error', (err) => {
this.logger.error(`guacd connection error: ${err.message}`);
reject(err);
});
// 10-second timeout for the SELECT → args handshake
socket.setTimeout(10000, () => {
socket.destroy();
reject(new Error(`guacd handshake timeout connecting to ${this.host}:${this.port}`));
});
});
}
private buildConnectInstruction(
params: {
hostname: string;
port: number;
username: string;
password: string;
domain?: string;
width: number;
height: number;
dpi?: number;
security?: string;
colorDepth?: number;
ignoreCert?: boolean;
},
argNames: string[],
): string {
// Map our params to guacd's expected arg names
const paramMap: Record<string, string> = {
'hostname': params.hostname,
'port': String(params.port),
'username': params.username,
'password': params.password,
'domain': params.domain || '',
'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',
'server-layout': '',
'timezone': '',
'console': '',
'initial-program': '',
'client-name': 'Wraith',
'enable-full-window-drag': 'false',
'enable-desktop-composition': 'false',
'enable-menu-animations': 'false',
'disable-bitmap-caching': 'false',
'disable-offscreen-caching': 'false',
'disable-glyph-caching': 'false',
'preconnection-id': '',
'preconnection-blob': '',
'enable-sftp': 'false',
'sftp-hostname': '',
'sftp-port': '',
'sftp-username': '',
'sftp-password': '',
'sftp-private-key': '',
'sftp-passphrase': '',
'sftp-directory': '',
'sftp-root-directory': '',
'sftp-server-alive-interval': '',
'recording-path': '',
'recording-name': '',
'recording-exclude-output': '',
'recording-exclude-mouse': '',
'recording-include-keys': '',
'create-recording-path': '',
'remote-app': '',
'remote-app-dir': '',
'remote-app-args': '',
'gateway-hostname': '',
'gateway-port': '',
'gateway-domain': '',
'gateway-username': '',
'gateway-password': '',
'load-balance-info': '',
'normalize-clipboard': '',
'force-lossless': '',
'wol-send-packet': '',
'wol-mac-addr': '',
'wol-broadcast-addr': '',
'wol-udp-port': '',
'wol-wait-time': '',
};
// Build values array matching the exact order guacd expects
// VERSION_X_Y_Z args must be echoed back as-is
const values = argNames.map((name) => {
if (name.startsWith('VERSION_')) return name;
return paramMap[name] ?? '';
});
return this.encode('connect', ...values);
}
// Opcodes that clients are allowed to send to guacd.
// Server-originating opcodes (img, blob, end, ack, etc.) are intentionally excluded.
private static ALLOWED_CLIENT_OPCODES = new Set([
'key', 'mouse', 'size', 'clipboard', 'sync', 'disconnect', 'nop',
]);
/**
* Validate a raw Guacamole instruction received from a browser client.
*
* Checks:
* 1. The instruction matches the Guacamole wire format: <len>.<opcode>[,...]
* 2. The declared length matches the actual opcode length (prevents injection
* via length field manipulation).
* 3. The opcode is in the client-allowed allowlist.
*
* Returns true if the instruction is safe to forward to guacd.
*/
validateClientInstruction(instruction: string): boolean {
try {
// Must begin with a length-prefixed opcode: "<digits>.<opcode>"
const match = instruction.match(/^(\d+)\.([\w-]+)/);
if (!match) return false;
const declaredLen = parseInt(match[1], 10);
const opcode = match[2];
// The declared length must equal the actual opcode length to prevent
// smuggling a different opcode via a mismatched length prefix.
if (declaredLen !== opcode.length) return false;
return GuacamoleService.ALLOWED_CLIENT_OPCODES.has(opcode);
} catch {
return false;
}
}
/**
* Encode a Guacamole instruction.
* Each part is length-prefixed: "<len>.<value>"
* Parts are comma-separated, instruction ends with ';'
* Example: encode('size', '1024', '768') "4.size,4.1024,3.768;"
*/
encode(...parts: string[]): string {
return parts.map((p) => `${p.length}.${p}`).join(',') + ';';
}
/**
* Decode a Guacamole instruction string back to a string array.
*/
decode(instruction: string): string[] {
const parts: string[] = [];
let pos = 0;
// Strip trailing semicolon if present
const clean = instruction.endsWith(';')
? instruction.slice(0, -1)
: instruction;
while (pos < clean.length) {
const dotIndex = clean.indexOf('.', pos);
if (dotIndex === -1) break;
const len = parseInt(clean.substring(pos, dotIndex), 10);
if (isNaN(len)) break;
const value = clean.substring(dotIndex + 1, dotIndex + 1 + len);
parts.push(value);
// Move past: value + separator (comma or end)
pos = dotIndex + 1 + len + 1;
}
return parts;
}
}

View File

@ -1,162 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
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';
@Injectable()
export class RdpGateway {
private readonly logger = new Logger(RdpGateway.name);
// Maps browser WebSocket client → live guacd TCP socket
private clientSockets = new Map<any, net.Socket>();
private clientUsers = new Map<any, { sub: number; email: string }>();
constructor(
private guacamole: GuacamoleService,
private credentials: CredentialsService,
private hosts: HostsService,
private prisma: PrismaService,
private wsAuth: WsAuthGuard,
) {}
handleConnection(client: any, req: any) {
const user = this.wsAuth.validateClient(client, req);
if (!user) {
client.close(4001, 'Unauthorized');
return;
}
this.clientUsers.set(client, user);
this.logger.log(`RDP WS connected: ${user.email}`);
client.on('message', async (raw: Buffer) => {
try {
const msg = JSON.parse(raw.toString());
this.logger.log(`[RDP] Message: ${msg.type}`);
if (msg.type === 'connect') {
await this.handleConnect(client, msg);
} else if (msg.type === 'guac') {
// Validate before forwarding raw Guacamole instruction to guacd TCP socket
if (typeof msg.instruction !== 'string') {
this.send(client, { type: 'error', message: 'Invalid instruction' });
return;
}
if (msg.instruction.length > 65536) {
this.send(client, { type: 'error', message: 'Instruction too large' });
return;
}
if (!this.guacamole.validateClientInstruction(msg.instruction)) {
this.logger.warn(`[RDP] Blocked invalid Guacamole instruction: ${msg.instruction.substring(0, 80)}`);
this.send(client, { type: 'error', message: 'Invalid instruction' });
return;
}
const socket = this.clientSockets.get(client);
if (socket && !socket.destroyed) {
socket.write(msg.instruction);
}
}
} catch (err: any) {
this.logger.error(`RDP message error: ${err.message}`);
this.send(client, { type: 'error', message: err.message });
}
});
client.on('close', () => {
this.logger.log('RDP WS disconnected');
const socket = this.clientSockets.get(client);
if (socket) {
socket.destroy();
this.clientSockets.delete(client);
this.logger.log('guacd socket destroyed on WS close');
}
this.clientUsers.delete(client);
});
}
private async handleConnect(client: any, msg: any) {
const host = await this.hosts.findOne(msg.hostId);
// Decrypt credentials if attached to host
const cred = host.credentialId
? await this.credentials.decryptForConnection(host.credentialId)
: null;
this.logger.log(
`Opening RDP tunnel: ${host.hostname}:${host.port} for host "${host.name}" (user: ${cred?.username || 'none'}, hasPwd: ${!!cred?.password}, domain: ${cred?.domain || 'none'})`,
);
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,
ignoreCert: true,
});
this.clientSockets.set(client, socket);
// Pipe guacd → browser: buffer TCP stream into complete Guacamole instructions.
// TCP is a stream protocol — data events can contain partial instructions,
// multiple instructions, or any combination. We must only forward complete
// instructions (terminated by ';') to the browser.
let guacMsgCount = 0;
let tcpBuffer = '';
socket.on('data', (data: Buffer) => {
tcpBuffer += data.toString('utf-8');
// Extract all complete instructions from the buffer
let semicolonIdx: number;
while ((semicolonIdx = tcpBuffer.indexOf(';')) !== -1) {
const instruction = tcpBuffer.substring(0, semicolonIdx + 1);
tcpBuffer = tcpBuffer.substring(semicolonIdx + 1);
guacMsgCount++;
// Log first 10 instructions and any errors for diagnostics
if (guacMsgCount <= 10 || instruction.includes('error')) {
this.logger.log(
`[guacd→browser #${guacMsgCount}] ${instruction.substring(0, 300)}`,
);
}
if (client.readyState === 1 /* WebSocket.OPEN */) {
client.send(JSON.stringify({ type: 'guac', instruction }));
}
}
});
socket.on('close', () => {
this.logger.log(`guacd socket closed for host ${host.id}`);
this.send(client, { type: 'disconnected', reason: 'RDP session closed' });
this.clientSockets.delete(client);
});
socket.on('error', (err) => {
this.logger.error(`guacd socket error for host ${host.id}: ${err.message}`);
this.send(client, { type: 'error', message: err.message });
});
// Connection tracking
const wsUser = this.clientUsers.get(client);
this.hosts.touchLastConnected(host.id).catch(() => {});
this.prisma.connectionLog
.create({ data: { hostId: host.id, userId: wsUser!.sub, protocol: 'rdp' } })
.catch(() => {});
this.send(client, { type: 'connected', hostId: host.id, hostName: host.name });
}
private send(client: any, data: any) {
if (client.readyState === 1 /* WebSocket.OPEN */) {
client.send(JSON.stringify(data));
}
}
}

View File

@ -1,12 +0,0 @@
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 {}

View File

@ -1,19 +0,0 @@
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<string, string>) {
return this.settings.setMany(body);
}
}

View File

@ -1,10 +0,0 @@
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 {}

View File

@ -1,36 +0,0 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class SettingsService {
constructor(private prisma: PrismaService) {}
async getAll(): Promise<Record<string, string>> {
const settings = await this.prisma.setting.findMany();
return Object.fromEntries(settings.map((s) => [s.key, s.value]));
}
async get(key: string): Promise<string | null> {
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<string, string>) {
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);
}
}

View File

@ -1,249 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { WsAuthGuard } from '../auth/ws-auth.guard';
import { SshConnectionService } from './ssh-connection.service';
const MAX_EDIT_SIZE = 5 * 1024 * 1024; // 5MB
@Injectable()
export class SftpGateway {
private readonly logger = new Logger(SftpGateway.name);
// Maps WebSocket client → set of session IDs they own
private clientSessions = new Map<any, Set<string>>();
constructor(
private ssh: SshConnectionService,
private wsAuth: WsAuthGuard,
) {}
handleConnection(client: any, req: any) {
const user = this.wsAuth.validateClient(client, req);
if (!user) {
client.close(4001, 'Unauthorized');
return;
}
this.logger.log(`SFTP WS connected: ${user.email}`);
// Initialize an empty session ownership set for this client
this.clientSessions.set(client, new Set());
client.on('message', async (raw: Buffer) => {
try {
const msg = JSON.parse(raw.toString());
this.logger.log(`[SFTP] Message: ${msg.type} sessionId=${msg.sessionId} path=${msg.path || ''}`);
await this.handleMessage(client, msg);
} catch (err: any) {
this.logger.error(`[SFTP] Error: ${err.message}`);
this.send(client, { type: 'error', message: err.message });
}
});
client.on('close', () => {
this.clientSessions.delete(client);
});
}
private async handleMessage(client: any, msg: any) {
const { sessionId } = msg;
if (!sessionId) {
return this.send(client, { type: 'error', message: 'sessionId required' });
}
const ownedSessions = this.clientSessions.get(client);
// If this client has already claimed at least one session, enforce ownership
// for all subsequent requests. A client claims a sessionId on first successful
// SFTP channel open (see registration below).
if (ownedSessions && ownedSessions.size > 0 && !ownedSessions.has(sessionId)) {
this.logger.warn(`[SFTP] Session access denied: client does not own sessionId=${sessionId}`);
return this.send(client, { type: 'error', message: 'Session access denied' });
}
this.logger.log(`[SFTP] Getting SFTP channel for session ${sessionId}...`);
let sftp: any;
try {
sftp = await this.ssh.getSftpChannel(sessionId);
this.logger.log(`[SFTP] Got SFTP channel OK — type: ${typeof sftp}, constructor: ${sftp?.constructor?.name}`);
} catch (err: any) {
this.logger.error(`[SFTP] Failed to get SFTP channel: ${err.message}`);
return this.send(client, { type: 'error', message: `SFTP channel failed: ${err.message}` });
}
// Register this sessionId as owned by this client on first successful channel open
if (ownedSessions && !ownedSessions.has(sessionId)) {
ownedSessions.add(sessionId);
this.logger.log(`[SFTP] Registered sessionId=${sessionId} for client`);
}
// Listen for SFTP channel errors
sftp.on('error', (err: any) => {
this.logger.error(`[SFTP] Channel error event: ${err.message}`);
});
sftp.on('close', () => {
this.logger.warn(`[SFTP] Channel closed`);
});
switch (msg.type) {
case 'list': {
// Resolve '~' to the user's home directory via SFTP realpath('.')
const resolvePath = (path: string, cb: (resolved: string) => void) => {
if (path === '~') {
sftp.realpath('.', (err: any, absPath: string) => {
if (err) {
this.logger.warn(`[SFTP] realpath('.') failed, falling back to /: ${err.message}`);
cb('/');
} else {
cb(absPath);
}
});
} else {
cb(path);
}
};
resolvePath(msg.path, (resolvedPath) => {
this.logger.log(`[SFTP] readdir starting for path: "${resolvedPath}"`);
try {
sftp.readdir(resolvedPath, (err: any, list: any[]) => {
this.logger.log(`[SFTP] readdir callback fired, err=${err?.message || 'null'}, entries=${list?.length || 0}`);
if (err) return this.send(client, { type: 'error', message: err.message });
const entries = list.map((f: any) => ({
name: f.filename,
path: `${resolvedPath === '/' ? '' : resolvedPath}/${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.logger.log(`[SFTP] Sending list response with ${entries.length} entries, client.readyState=${client.readyState}`);
this.send(client, { type: 'list', path: resolvedPath, entries });
});
} catch (syncErr: any) {
this.logger.error(`[SFTP] readdir threw synchronously: ${syncErr.message}`);
this.send(client, { type: 'error', message: syncErr.message });
}
});
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 'upload': {
const buf = Buffer.from(msg.data, 'base64');
const stream = sftp.createWriteStream(msg.path);
stream.end(buf, () => {
this.logger.log(`[SFTP] Upload complete: ${msg.path} (${buf.length} bytes)`);
this.send(client, { type: 'uploaded', path: msg.path, size: buf.length });
});
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': {
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': {
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));
}
}
}

View File

@ -1,229 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { Client, ClientChannel, utils } from 'ssh2';
import { createHash } from 'crypto';
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<string, SshSession>();
private pendingClients = new Map<string, Client>(); // sessionId → client (before 'ready')
constructor(
private credentials: CredentialsService,
private hosts: HostsService,
private prisma: PrismaService,
) {}
async connect(
hostId: number,
userId: number,
onData: (data: string) => void,
onClose: (reason: string) => void,
onHostKeyVerify: (fingerprint: string, isNew: boolean) => Promise<boolean>,
options?: { enableCwdTracking?: boolean },
): Promise<string> {
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();
this.pendingClients.set(sessionId, client);
return new Promise((resolve, reject) => {
let settled = false;
client.on('ready', () => {
this.pendingClients.delete(sessionId);
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')));
// Shell integration: inject PROMPT_COMMAND (bash) / precmd (zsh) to emit
// OSC 7 escape sequences reporting the current working directory on every prompt.
// Leading space prevents the command from being saved to shell history.
// The frontend captures OSC 7 via xterm.js and syncs the SFTP sidebar.
// Only injected when the caller explicitly opts in via options.enableCwdTracking.
if (options?.enableCwdTracking) {
const shellIntegration =
` if [ -n "$ZSH_VERSION" ]; then` +
` __wraith_cwd(){ printf '\\e]7;file://%s%s\\a' "$HOST" "$PWD"; };` +
` precmd_functions+=(__wraith_cwd);` +
` elif [ -n "$BASH_VERSION" ]; then` +
` PROMPT_COMMAND='printf "\\033]7;file://%s%s\\a" "$HOSTNAME" "$PWD"';` +
` fi\n`;
stream.write(shellIntegration);
}
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, userId, protocol: host.protocol },
}).catch(() => {});
resolve(sessionId);
});
});
client.on('error', (err) => {
this.logger.error(`SSH error for host ${hostId}: ${err.message}`);
settled = true;
this.pendingClients.delete(sessionId);
this.disconnect(sessionId);
client.destroy();
onClose(err.message);
reject(err);
});
const connectConfig: any = {
host: host.hostname,
port: host.port,
username: cred?.username || 'root',
debug: (msg: string) => {
this.logger.log(`[SSH-DEBUG] ${msg}`);
},
hostVerifier: (key: Buffer, verify: (accept: boolean) => void) => {
const fingerprint = createHash('sha256').update(key).digest('base64');
const fp = `SHA256:${fingerprint}`;
if (host.hostFingerprint === fp) {
verify(true); // known host — key matches, accept silently
return;
}
if (host.hostFingerprint && host.hostFingerprint !== fp) {
// Key has CHANGED from what was stored — possible MITM attack. Block immediately.
this.logger.warn(
`[SSH] HOST KEY CHANGED for hostId=${hostId} — possible MITM attack. Connection blocked.`,
);
verify(false);
return;
}
// No stored fingerprint — first connection, ask the user via WebSocket
onHostKeyVerify(fp, true).then((accepted) => {
if (settled) return; // connection already timed out / errored — don't call back into ssh2
if (accepted) {
// Persist fingerprint so future connections auto-accept
this.prisma.host.update({
where: { id: hostId },
data: { hostFingerprint: fp },
}).catch(() => {});
}
verify(accepted);
});
},
};
if (cred?.sshKey) {
connectConfig.privateKey = cred.sshKey.privateKey;
if (cred.sshKey.passphrase) {
connectConfig.passphrase = cred.sshKey.passphrase;
}
this.logger.log(`[SSH] Using SSH key auth`);
} else if (cred?.password) {
connectConfig.password = cred.password;
this.logger.log(`[SSH] Using password auth for hostId=${hostId}`);
} else {
this.logger.warn(`[SSH] No auth method available for host ${hostId}`);
}
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) {
// Kill pending (not yet 'ready') connections
const pending = this.pendingClients.get(sessionId);
if (pending) {
pending.destroy();
this.pendingClients.delete(sessionId);
}
const session = this.sessions.get(sessionId);
if (session) {
session.stream?.close();
session.client.end();
this.sessions.delete(sessionId);
this.sftpChannels.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);
}
private sftpChannels = new Map<string, any>();
getSftpChannel(sessionId: string): Promise<any> {
const logger = this.logger;
const cached = this.sftpChannels.get(sessionId);
if (cached) {
return Promise.resolve(cached);
}
return new Promise((resolve, reject) => {
const session = this.sessions.get(sessionId);
if (!session) {
logger.error(`[SFTP] Session ${sessionId} not found in sessions map (${this.sessions.size} active sessions)`);
return reject(new Error('Session not found'));
}
logger.log(`[SFTP] Requesting SFTP subsystem on session ${sessionId}`);
session.client.sftp((err, sftp) => {
if (err) {
logger.error(`[SFTP] client.sftp() callback error: ${err.message}`);
return reject(err);
}
logger.log(`[SFTP] SFTP subsystem opened successfully for session ${sessionId}`);
sftp.on('close', () => {
this.sftpChannels.delete(sessionId);
logger.log(`[SFTP] Channel closed for session ${sessionId}`);
});
this.sftpChannels.set(sessionId, sftp);
resolve(sftp);
});
});
}
}

View File

@ -1,149 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { WsAuthGuard } from '../auth/ws-auth.guard';
import { SshConnectionService } from './ssh-connection.service';
@Injectable()
export class TerminalGateway {
private readonly logger = new Logger(TerminalGateway.name);
private clientSessions = new Map<any, string[]>(); // ws client → sessionIds
private clientUsers = new Map<any, { sub: number; email: string }>(); // ws client → user
private pendingHostKeyVerifications = new Map<string, { resolve: (accepted: boolean) => void }>(); // verifyId → resolver
constructor(
private ssh: SshConnectionService,
private wsAuth: WsAuthGuard,
) {}
handleConnection(client: any, req: any) {
this.logger.log(`[WS] handleConnection fired, req.url=${req?.url}, client.url=${client?.url}`);
const user = this.wsAuth.validateClient(client, req);
if (!user) {
this.logger.warn(`[WS] Auth failed — closing 4001`);
client.close(4001, 'Unauthorized');
return;
}
this.clientSessions.set(client, []);
this.clientUsers.set(client, user);
this.logger.log(`[WS] Terminal connected: ${user.email}`);
client.on('message', async (raw: Buffer) => {
try {
const msg = JSON.parse(raw.toString());
if (msg.type === 'data') {
this.logger.log(`[WS-TERMINAL] type=data sessionId=${msg.sessionId} bytes=${msg.data?.length || 0}`);
} else {
this.logger.log(`[WS-TERMINAL] ${JSON.stringify(msg).substring(0, 200)}`);
}
await this.handleMessage(client, msg);
} catch (err: any) {
this.logger.error(`[WS] handleMessage error: ${err.message}\n${err.stack}`);
this.send(client, { type: 'error', message: err.message });
}
});
client.on('close', () => {
this.logger.log(`[WS] Client disconnected`);
const sessions = this.clientSessions.get(client) || [];
sessions.forEach((sid) => this.ssh.disconnect(sid));
this.clientSessions.delete(client);
this.clientUsers.delete(client);
});
}
private async handleMessage(client: any, msg: any) {
switch (msg.type) {
case 'connect': {
let sessionId = '';
const wsUser = this.clientUsers.get(client);
try {
sessionId = await this.ssh.connect(
msg.hostId,
wsUser!.sub,
(data) => this.send(client, { type: 'data', sessionId, data }),
(reason) => this.send(client, { type: 'disconnected', sessionId, reason }),
(fingerprint: string, isNew: boolean) => {
// New host — ask the user; changed host keys are rejected in the service before reaching here
return new Promise<boolean>((resolve) => {
const verifyId = `${sessionId}-${Date.now()}`;
this.pendingHostKeyVerifications.set(verifyId, { resolve });
this.send(client, {
type: 'host-key-verify',
verifyId,
fingerprint,
isNew,
});
// Timeout after 30 seconds — reject if no response
setTimeout(() => {
if (this.pendingHostKeyVerifications.has(verifyId)) {
this.pendingHostKeyVerifications.delete(verifyId);
resolve(false);
}
}, 30000);
});
},
);
} catch (err: any) {
this.logger.error(`[WS] SSH connect failed: ${err.message}`);
this.send(client, { type: 'error', message: `Connection failed: ${err.message}` });
break;
}
const sessions = this.clientSessions.get(client) || [];
sessions.push(sessionId);
this.clientSessions.set(client, sessions);
this.send(client, { type: 'connected', sessionId });
break;
}
case 'data': {
// M-1: Verify the session belongs to this client before processing
const dataSessions = this.clientSessions.get(client);
if (!dataSessions || !dataSessions.includes(msg.sessionId)) {
this.send(client, { type: 'error', message: 'Session access denied' });
return;
}
if (msg.sessionId) {
this.ssh.write(msg.sessionId, msg.data);
}
break;
}
case 'resize': {
// M-1: Verify the session belongs to this client before processing
const resizeSessions = this.clientSessions.get(client);
if (!resizeSessions || !resizeSessions.includes(msg.sessionId)) {
this.send(client, { type: 'error', message: 'Session access denied' });
return;
}
if (msg.sessionId) {
this.ssh.resize(msg.sessionId, msg.cols, msg.rows);
}
break;
}
case 'disconnect': {
// M-1: Verify the session belongs to this client before processing
const disconnectSessions = this.clientSessions.get(client);
if (!disconnectSessions || !disconnectSessions.includes(msg.sessionId)) {
this.send(client, { type: 'error', message: 'Session access denied' });
return;
}
if (msg.sessionId) {
this.ssh.disconnect(msg.sessionId);
}
break;
}
case 'host-key-accept':
case 'host-key-reject': {
const pending = this.pendingHostKeyVerifications.get(msg.verifyId);
if (pending) {
pending.resolve(msg.type === 'host-key-accept');
this.pendingHostKeyVerifications.delete(msg.verifyId);
}
break;
}
}
}
private send(client: any, data: any) {
if (client.readyState === 1) { // WebSocket.OPEN
client.send(JSON.stringify(data));
}
}
}

View File

@ -1,14 +0,0 @@
import { Module } from '@nestjs/common';
import { SshConnectionService } from './ssh-connection.service';
import { TerminalGateway } from './terminal.gateway';
import { SftpGateway } from './sftp.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, SftpGateway],
exports: [SshConnectionService, TerminalGateway, SftpGateway],
})
export class TerminalModule {}

View File

@ -1,91 +0,0 @@
import { Controller, Get, Post, Put, Delete, Param, Body, Request, UseGuards, ParseIntPipe, Logger } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { AdminGuard } from '../auth/admin.guard';
import { CredentialsService } from './credentials.service';
import { EncryptionService } from './encryption.service';
import { PrismaService } from '../prisma/prisma.service';
import { CreateCredentialDto } from './dto/create-credential.dto';
import { UpdateCredentialDto } from './dto/update-credential.dto';
@UseGuards(JwtAuthGuard)
@Controller('credentials')
export class CredentialsController {
private readonly logger = new Logger(CredentialsController.name);
constructor(
private credentials: CredentialsService,
private encryption: EncryptionService,
private prisma: PrismaService,
) {}
@Get()
findAll(@Request() req: any) {
return this.credentials.findAll(req.user.sub);
}
@Get(':id')
findOne(@Request() req: any, @Param('id', ParseIntPipe) id: number) {
return this.credentials.findOne(id, req.user.sub);
}
@Post()
create(@Request() req: any, @Body() dto: CreateCredentialDto) {
return this.credentials.create(req.user.sub, dto);
}
@Put(':id')
update(@Request() req: any, @Param('id', ParseIntPipe) id: number, @Body() dto: UpdateCredentialDto) {
return this.credentials.update(id, req.user.sub, dto);
}
@Delete(':id')
remove(@Request() req: any, @Param('id', ParseIntPipe) id: number) {
return this.credentials.remove(id, req.user.sub);
}
/**
* Admin-only: Migrate all v1 (raw key) encrypted values to v2 (Argon2id).
* Safe to run multiple times skips records already on v2.
*/
@Post('migrate-v2')
@UseGuards(AdminGuard)
async migrateToV2() {
let credsMigrated = 0;
let keysMigrated = 0;
// Migrate credential passwords
const creds = await this.prisma.credential.findMany({
where: { encryptedValue: { not: null } },
select: { id: true, encryptedValue: true },
});
for (const cred of creds) {
if (cred.encryptedValue && this.encryption.isV1(cred.encryptedValue)) {
const upgraded = await this.encryption.upgradeToV2(cred.encryptedValue);
if (upgraded) {
await this.prisma.credential.update({ where: { id: cred.id }, data: { encryptedValue: upgraded } });
credsMigrated++;
}
}
}
// Migrate SSH key private keys and passphrases
const keys = await this.prisma.sshKey.findMany({
select: { id: true, encryptedPrivateKey: true, passphraseEncrypted: true },
});
for (const key of keys) {
const updates: any = {};
if (this.encryption.isV1(key.encryptedPrivateKey)) {
updates.encryptedPrivateKey = await this.encryption.upgradeToV2(key.encryptedPrivateKey);
}
if (key.passphraseEncrypted && this.encryption.isV1(key.passphraseEncrypted)) {
updates.passphraseEncrypted = await this.encryption.upgradeToV2(key.passphraseEncrypted);
}
if (Object.keys(updates).length) {
await this.prisma.sshKey.update({ where: { id: key.id }, data: updates });
keysMigrated++;
}
}
this.logger.log(`v2 migration complete: ${credsMigrated} credentials, ${keysMigrated} SSH keys upgraded`);
return { credsMigrated, keysMigrated };
}
}

View File

@ -1,178 +0,0 @@
// backend/src/vault/credentials.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { CredentialsService } from './credentials.service';
import { PrismaService } from '../prisma/prisma.service';
import { EncryptionService } from './encryption.service';
import { NotFoundException, ForbiddenException } from '@nestjs/common';
describe('CredentialsService', () => {
let service: CredentialsService;
let prisma: any;
let encryption: any;
beforeEach(async () => {
prisma = {
credential: {
findMany: jest.fn(),
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
};
encryption = {
encrypt: jest.fn().mockResolvedValue('v2:mock:encrypted'),
decrypt: jest.fn().mockResolvedValue('decrypted-password'),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
CredentialsService,
{ provide: PrismaService, useValue: prisma },
{ provide: EncryptionService, useValue: encryption },
],
}).compile();
service = module.get(CredentialsService);
});
describe('findAll', () => {
it('should query by userId and exclude encryptedValue from select', async () => {
prisma.credential.findMany.mockResolvedValue([{ id: 1, name: 'test' }]);
await service.findAll(1);
expect(prisma.credential.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId: 1 },
select: expect.objectContaining({ id: true, name: true }),
}),
);
// encryptedValue must never appear in the list response (H-10)
const call = prisma.credential.findMany.mock.calls[0][0];
expect(call.select.encryptedValue).toBeUndefined();
});
});
describe('findOne', () => {
it('should return credential for owner', async () => {
prisma.credential.findUnique.mockResolvedValue({ id: 1, userId: 1, name: 'cred', sshKey: null, hosts: [] });
const result = await service.findOne(1, 1);
expect(result.name).toBe('cred');
});
it('should throw ForbiddenException when userId does not match', async () => {
prisma.credential.findUnique.mockResolvedValue({ id: 1, userId: 2, name: 'cred', sshKey: null, hosts: [] });
await expect(service.findOne(1, 1)).rejects.toThrow(ForbiddenException);
});
it('should throw NotFoundException for missing credential', async () => {
prisma.credential.findUnique.mockResolvedValue(null);
await expect(service.findOne(99, 1)).rejects.toThrow(NotFoundException);
});
});
describe('create', () => {
it('should encrypt password before storage', async () => {
prisma.credential.create.mockResolvedValue({ id: 1 });
await service.create(1, {
name: 'test',
username: 'admin',
password: 'secret',
type: 'password' as any,
});
expect(encryption.encrypt).toHaveBeenCalledWith('secret');
expect(prisma.credential.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ encryptedValue: 'v2:mock:encrypted' }),
}),
);
});
it('should store null encryptedValue when no password provided', async () => {
prisma.credential.create.mockResolvedValue({ id: 2 });
await service.create(1, { name: 'ssh-only', type: 'ssh_key' as any, sshKeyId: 5 });
expect(encryption.encrypt).not.toHaveBeenCalled();
expect(prisma.credential.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ encryptedValue: null }),
}),
);
});
});
describe('decryptForConnection', () => {
it('should decrypt password credential', async () => {
prisma.credential.findUnique.mockResolvedValue({
id: 1,
username: 'admin',
domain: null,
encryptedValue: 'v2:encrypted',
sshKey: null,
sshKeyId: null,
});
const result = await service.decryptForConnection(1);
expect(result.password).toBe('decrypted-password');
expect(result.username).toBe('admin');
});
it('should decrypt SSH key credential with passphrase', async () => {
prisma.credential.findUnique.mockResolvedValue({
id: 1,
username: 'root',
domain: null,
encryptedValue: null,
sshKey: { encryptedPrivateKey: 'v2:key', passphraseEncrypted: 'v2:pass' },
sshKeyId: 5,
});
encryption.decrypt
.mockResolvedValueOnce('private-key-content')
.mockResolvedValueOnce('passphrase');
const result = await service.decryptForConnection(1);
expect(result.sshKey).toEqual({ privateKey: 'private-key-content', passphrase: 'passphrase' });
});
it('should throw NotFoundException for orphaned SSH key reference', async () => {
prisma.credential.findUnique.mockResolvedValue({
id: 1,
name: 'orphan',
username: 'root',
domain: null,
encryptedValue: null,
sshKey: null,
sshKeyId: 99,
});
await expect(service.decryptForConnection(1)).rejects.toThrow(NotFoundException);
});
it('should throw NotFoundException for credential with no auth method', async () => {
prisma.credential.findUnique.mockResolvedValue({
id: 1,
name: 'empty',
username: 'root',
domain: null,
encryptedValue: null,
sshKey: null,
sshKeyId: null,
});
await expect(service.decryptForConnection(1)).rejects.toThrow(NotFoundException);
});
it('should throw NotFoundException for missing credential', async () => {
prisma.credential.findUnique.mockResolvedValue(null);
await expect(service.decryptForConnection(99)).rejects.toThrow(NotFoundException);
});
});
describe('remove', () => {
it('should delete owned credential', async () => {
prisma.credential.findUnique.mockResolvedValue({ id: 1, userId: 1, sshKey: null, hosts: [] });
prisma.credential.delete.mockResolvedValue({ id: 1 });
await service.remove(1, 1);
expect(prisma.credential.delete).toHaveBeenCalledWith({ where: { id: 1 } });
});
it('should throw ForbiddenException when removing non-owned credential', async () => {
prisma.credential.findUnique.mockResolvedValue({ id: 1, userId: 2, sshKey: null, hosts: [] });
await expect(service.remove(1, 1)).rejects.toThrow(ForbiddenException);
});
});
});

View File

@ -1,115 +0,0 @@
import { Injectable, NotFoundException, ForbiddenException } 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(userId: number) {
// H-10: exclude encryptedValue from list response — frontend never needs it
return this.prisma.credential.findMany({
where: { userId },
select: {
id: true,
name: true,
username: true,
domain: true,
type: true,
sshKeyId: true,
sshKey: { select: { id: true, name: true, keyType: true, fingerprint: true } },
createdAt: true,
updatedAt: true,
},
orderBy: { name: 'asc' },
});
}
async findOne(id: number, userId?: 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`);
if (userId !== undefined && cred.userId !== userId) {
throw new ForbiddenException('Access denied');
}
return cred;
}
async create(userId: number, dto: CreateCredentialDto) {
const encryptedValue = dto.password ? await this.encryption.encrypt(dto.password) : null;
return this.prisma.credential.create({
data: {
name: dto.name,
username: dto.username,
domain: dto.domain,
type: dto.type,
userId,
encryptedValue,
sshKeyId: dto.sshKeyId,
},
});
}
async update(id: number, userId: number, dto: UpdateCredentialDto) {
await this.findOne(id, userId);
const data: any = { ...dto };
delete data.password;
if (dto.password) {
data.encryptedValue = await this.encryption.encrypt(dto.password);
}
return this.prisma.credential.update({ where: { id }, data });
}
async remove(id: number, userId: number) {
await this.findOne(id, userId);
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 = await this.encryption.decrypt(cred.encryptedValue);
}
let sshKey: { privateKey: string; passphrase: string | null } | null = null;
if (cred.sshKey) {
const privateKey = await this.encryption.decrypt(cred.sshKey.encryptedPrivateKey);
const passphrase = cred.sshKey.passphraseEncrypted
? await this.encryption.decrypt(cred.sshKey.passphraseEncrypted)
: null;
sshKey = { privateKey, passphrase };
} else if (cred.sshKeyId) {
// Orphaned reference — credential points to a deleted/missing SSH key
throw new NotFoundException(
`Credential "${cred.name}" references SSH key #${cred.sshKeyId} which no longer exists. Re-import the key or update the credential.`,
);
}
if (!password && !sshKey) {
throw new NotFoundException(
`Credential "${cred.name}" has no password or SSH key configured.`,
);
}
return { username: cred.username, domain: cred.domain, password, sshKey };
}
}

View File

@ -1,26 +0,0 @@
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;
}

View File

@ -1,17 +0,0 @@
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;
}

View File

@ -1,4 +0,0 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateCredentialDto } from './create-credential.dto';
export class UpdateCredentialDto extends PartialType(CreateCredentialDto) {}

View File

@ -1,11 +0,0 @@
import { IsString, IsOptional } from 'class-validator';
export class UpdateSshKeyDto {
@IsString()
@IsOptional()
name?: string;
@IsString()
@IsOptional()
passphrase?: string; // new passphrase (re-encrypted)
}

View File

@ -1,85 +0,0 @@
// backend/src/vault/encryption.service.spec.ts
// Set test encryption key before importing the service — constructor reads process.env.ENCRYPTION_KEY
process.env.ENCRYPTION_KEY = 'a'.repeat(64); // 64 hex chars = 32 bytes
import { EncryptionService } from './encryption.service';
describe('EncryptionService', () => {
let service: EncryptionService;
beforeAll(async () => {
service = new EncryptionService();
await service.onModuleInit();
});
describe('encrypt/decrypt round-trip', () => {
it('should encrypt and decrypt a string', async () => {
const plaintext = 'my-secret-password';
const encrypted = await service.encrypt(plaintext);
expect(encrypted).toMatch(/^v2:/);
const decrypted = await service.decrypt(encrypted);
expect(decrypted).toBe(plaintext);
});
it('should produce different ciphertexts for the same plaintext (random salt + IV)', async () => {
const plaintext = 'same-input';
const a = await service.encrypt(plaintext);
const b = await service.encrypt(plaintext);
expect(a).not.toBe(b);
});
it('should handle empty string', async () => {
const encrypted = await service.encrypt('');
const decrypted = await service.decrypt(encrypted);
expect(decrypted).toBe('');
});
it('should handle unicode and emoji', async () => {
const plaintext = '密码 пароль 🔐';
const encrypted = await service.encrypt(plaintext);
const decrypted = await service.decrypt(encrypted);
expect(decrypted).toBe(plaintext);
});
});
describe('v2 format structure', () => {
it('should produce a 5-part colon-delimited ciphertext', async () => {
const encrypted = await service.encrypt('test');
const parts = encrypted.split(':');
expect(parts[0]).toBe('v2');
expect(parts).toHaveLength(5); // v2:salt:iv:authTag:ciphertext
});
});
describe('isV1', () => {
it('should detect v1 format', () => {
expect(service.isV1('v1:abc123:def456:ghi789')).toBe(true);
});
it('should not classify v2 as v1', () => {
expect(service.isV1('v2:abc:def:ghi:jkl')).toBe(false);
});
});
describe('upgradeToV2', () => {
it('should return null for already-v2 ciphertext', async () => {
const v2 = await service.encrypt('test');
const result = await service.upgradeToV2(v2);
expect(result).toBeNull();
});
});
describe('error handling', () => {
it('should throw on unknown encryption version', async () => {
await expect(service.decrypt('v3:bad:data')).rejects.toThrow('Unknown encryption version');
});
it('should throw on tampered ciphertext (auth tag mismatch)', async () => {
const encrypted = await service.encrypt('test');
// Corrupt the last 4 hex chars of the ciphertext segment
const tampered = encrypted.slice(0, -4) + 'dead';
await expect(service.decrypt(tampered)).rejects.toThrow();
});
});
});

View File

@ -1,150 +0,0 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
import * as argon2 from 'argon2';
/**
* Vault Encryption Service
*
* v1: AES-256-GCM with raw ENCRYPTION_KEY (legacy, still decryptable)
* v2: AES-256-GCM with Argon2id-derived key (GPU-resistant)
*
* On encrypt, always produces v2. On decrypt, handles both v1 and v2.
* Use migrateToV2() to re-encrypt all v1 records.
*
* Argon2id parameters (OWASP recommended):
* memory: 64 MiB, iterations: 3, parallelism: 4
* salt: 16 random bytes (stored per-ciphertext)
*/
@Injectable()
export class EncryptionService implements OnModuleInit {
private readonly logger = new Logger(EncryptionService.name);
private readonly algorithm = 'aes-256-gcm';
// v1: raw key from env (kept for backwards compat decryption)
private readonly rawKey: Buffer;
// v2: Argon2id-derived key (computed once at startup with a fixed salt derived from ENCRYPTION_KEY)
// Per-record salts are used in the ciphertext format, but the master derived key uses the env key as input
private readonly masterPassword: Buffer;
// Argon2id tuning — OWASP recommendations for sensitive data
private readonly argon2Options = {
type: argon2.argon2id,
memoryCost: 65536, // 64 MiB
timeCost: 3, // 3 iterations
parallelism: 4, // 4 threads
hashLength: 32, // 256-bit derived key
};
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.rawKey = Buffer.from(hex.slice(0, 64), 'hex');
this.masterPassword = this.rawKey; // Used as Argon2 password input
}
async onModuleInit() {
// Warm up Argon2 by deriving a test key at startup — this also validates the config
try {
await this.deriveKey(randomBytes(16));
this.logger.log('Argon2id key derivation initialized (v2 encryption active)');
} catch (err: any) {
this.logger.error(`Argon2id initialization failed: ${err.message}`);
throw err;
}
}
/**
* Derive a 256-bit AES key from the master password + per-record salt using Argon2id
*/
private async deriveKey(salt: Buffer): Promise<Buffer> {
const hash = await argon2.hash(this.masterPassword, {
...this.argon2Options,
salt,
raw: true, // Return raw Buffer instead of encoded string
});
return hash;
}
/**
* Encrypt plaintext using AES-256-GCM with Argon2id-derived key (v2)
* Format: v2:<salt_hex>:<iv_hex>:<authTag_hex>:<ciphertext_hex>
*/
async encrypt(plaintext: string): Promise<string> {
const salt = randomBytes(16);
const derivedKey = await this.deriveKey(salt);
const iv = randomBytes(16);
const cipher = createCipheriv(this.algorithm, derivedKey, iv);
const encrypted = Buffer.concat([
cipher.update(plaintext, 'utf8'),
cipher.final(),
]);
const authTag = cipher.getAuthTag();
return `v2:${salt.toString('hex')}:${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted.toString('hex')}`;
}
/**
* Decrypt ciphertext handles both v1 (legacy raw key) and v2 (Argon2id)
*/
async decrypt(encrypted: string): Promise<string> {
const parts = encrypted.split(':');
const version = parts[0];
if (version === 'v2') {
// v2: Argon2id-derived key
const [, saltHex, ivHex, authTagHex, ciphertextHex] = parts;
const salt = Buffer.from(saltHex, 'hex');
const iv = Buffer.from(ivHex, 'hex');
const authTag = Buffer.from(authTagHex, 'hex');
const ciphertext = Buffer.from(ciphertextHex, 'hex');
const derivedKey = await this.deriveKey(salt);
const decipher = createDecipheriv(this.algorithm, derivedKey, iv);
decipher.setAuthTag(authTag);
return Buffer.concat([
decipher.update(ciphertext),
decipher.final(),
]).toString('utf8');
}
if (version === 'v1') {
// v1: raw ENCRYPTION_KEY (legacy backwards compat)
const [, ivHex, authTagHex, ciphertextHex] = parts;
const iv = Buffer.from(ivHex, 'hex');
const authTag = Buffer.from(authTagHex, 'hex');
const ciphertext = Buffer.from(ciphertextHex, 'hex');
const decipher = createDecipheriv(this.algorithm, this.rawKey, iv);
decipher.setAuthTag(authTag);
return Buffer.concat([
decipher.update(ciphertext),
decipher.final(),
]).toString('utf8');
}
throw new Error(`Unknown encryption version: ${version}`);
}
/**
* Check if a ciphertext is using the legacy v1 format
*/
isV1(encrypted: string): boolean {
return encrypted.startsWith('v1:');
}
/**
* Re-encrypt a v1 ciphertext to v2 (Argon2id). Returns the new ciphertext,
* or null if already v2.
*/
async upgradeToV2(encrypted: string): Promise<string | null> {
if (!this.isV1(encrypted)) return null;
const plaintext = await this.decrypt(encrypted);
return this.encrypt(plaintext);
}
}

View File

@ -1,36 +0,0 @@
import { Controller, Get, Post, Put, Delete, Param, Body, Request, 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(@Request() req: any) {
return this.sshKeys.findAll(req.user.sub);
}
@Get(':id')
findOne(@Request() req: any, @Param('id', ParseIntPipe) id: number) {
return this.sshKeys.findOne(id, req.user.sub);
}
@Post()
create(@Request() req: any, @Body() dto: CreateSshKeyDto) {
return this.sshKeys.create(req.user.sub, dto);
}
@Put(':id')
update(@Request() req: any, @Param('id', ParseIntPipe) id: number, @Body() dto: UpdateSshKeyDto) {
return this.sshKeys.update(id, req.user.sub, dto);
}
@Delete(':id')
remove(@Request() req: any, @Param('id', ParseIntPipe) id: number) {
return this.sshKeys.remove(id, req.user.sub);
}
}

View File

@ -1,147 +0,0 @@
// backend/src/vault/ssh-keys.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { SshKeysService } from './ssh-keys.service';
import { PrismaService } from '../prisma/prisma.service';
import { EncryptionService } from './encryption.service';
import { NotFoundException, ForbiddenException } from '@nestjs/common';
describe('SshKeysService', () => {
let service: SshKeysService;
let prisma: any;
let encryption: any;
beforeEach(async () => {
prisma = {
sshKey: {
findMany: jest.fn(),
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
};
encryption = {
encrypt: jest.fn().mockResolvedValue('v2:mock:encrypted'),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
SshKeysService,
{ provide: PrismaService, useValue: prisma },
{ provide: EncryptionService, useValue: encryption },
],
}).compile();
service = module.get(SshKeysService);
});
describe('findAll', () => {
it('should return keys without private key data', async () => {
prisma.sshKey.findMany.mockResolvedValue([{ id: 1, name: 'key1' }]);
await service.findAll(1);
const call = prisma.sshKey.findMany.mock.calls[0][0];
expect(call.select.encryptedPrivateKey).toBeUndefined();
expect(call.select.passphraseEncrypted).toBeUndefined();
});
});
describe('findOne', () => {
it('should return key for owner without encrypted private key field', async () => {
prisma.sshKey.findUnique.mockResolvedValue({
id: 1,
userId: 1,
name: 'key1',
keyType: 'ed25519',
fingerprint: 'SHA256:abc',
publicKey: null,
credentials: [],
createdAt: new Date(),
encryptedPrivateKey: 'v2:secret',
});
const result = await service.findOne(1, 1);
expect(result.name).toBe('key1');
// findOne strips encryptedPrivateKey from the returned object
expect((result as any).encryptedPrivateKey).toBeUndefined();
});
it('should throw ForbiddenException for non-owner', async () => {
prisma.sshKey.findUnique.mockResolvedValue({ id: 1, userId: 2, name: 'key1', credentials: [] });
await expect(service.findOne(1, 1)).rejects.toThrow(ForbiddenException);
});
it('should throw NotFoundException for missing key', async () => {
prisma.sshKey.findUnique.mockResolvedValue(null);
await expect(service.findOne(99, 1)).rejects.toThrow(NotFoundException);
});
});
describe('create', () => {
it('should encrypt both private key and passphrase', async () => {
prisma.sshKey.create.mockResolvedValue({ id: 1 });
await service.create(1, {
name: 'my-key',
privateKey: '-----BEGIN OPENSSH PRIVATE KEY-----\ndata\n-----END OPENSSH PRIVATE KEY-----',
passphrase: 'secret',
});
expect(encryption.encrypt).toHaveBeenCalledTimes(2);
});
it('should detect RSA key type', async () => {
prisma.sshKey.create.mockResolvedValue({ id: 1 });
await service.create(1, {
name: 'rsa-key',
privateKey: '-----BEGIN RSA PRIVATE KEY-----\ndata\n-----END RSA PRIVATE KEY-----',
});
expect(prisma.sshKey.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ keyType: 'rsa' }),
}),
);
});
it('should detect ed25519 key type from OPENSSH header', async () => {
prisma.sshKey.create.mockResolvedValue({ id: 1 });
await service.create(1, {
name: 'ed25519-key',
privateKey: '-----BEGIN OPENSSH PRIVATE KEY-----\ndata\n-----END OPENSSH PRIVATE KEY-----',
});
expect(prisma.sshKey.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ keyType: 'ed25519' }),
}),
);
});
it('should detect ECDSA key type', async () => {
prisma.sshKey.create.mockResolvedValue({ id: 1 });
await service.create(1, {
name: 'ecdsa-key',
privateKey: '-----BEGIN EC PRIVATE KEY-----\ndata\n-----END EC PRIVATE KEY-----',
});
expect(prisma.sshKey.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ keyType: 'ecdsa' }),
}),
);
});
});
describe('remove', () => {
it('should delete owned key', async () => {
prisma.sshKey.findUnique.mockResolvedValue({ id: 1, userId: 1 });
prisma.sshKey.delete.mockResolvedValue({ id: 1 });
await service.remove(1, 1);
expect(prisma.sshKey.delete).toHaveBeenCalledWith({ where: { id: 1 } });
});
it('should throw ForbiddenException for non-owner', async () => {
prisma.sshKey.findUnique.mockResolvedValue({ id: 1, userId: 2 });
await expect(service.remove(1, 1)).rejects.toThrow(ForbiddenException);
});
it('should throw NotFoundException for missing key', async () => {
prisma.sshKey.findUnique.mockResolvedValue(null);
await expect(service.remove(99, 1)).rejects.toThrow(NotFoundException);
});
});
});

View File

@ -1,107 +0,0 @@
import { Injectable, NotFoundException, ForbiddenException } 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 { createHash } from 'crypto';
@Injectable()
export class SshKeysService {
constructor(
private prisma: PrismaService,
private encryption: EncryptionService,
) {}
findAll(userId: number) {
return this.prisma.sshKey.findMany({
where: { userId },
select: { id: true, name: true, keyType: true, fingerprint: true, publicKey: true, createdAt: true },
orderBy: { name: 'asc' },
});
}
async findOne(id: number, userId?: 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`);
if (userId !== undefined && key.userId !== userId) {
throw new ForbiddenException('Access denied');
}
// 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(userId: number, 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 with Argon2id-derived key (v2)
const encryptedPrivateKey = await this.encryption.encrypt(dto.privateKey);
const passphraseEncrypted = dto.passphrase
? await this.encryption.encrypt(dto.passphrase)
: null;
return this.prisma.sshKey.create({
data: {
name: dto.name,
keyType,
fingerprint,
publicKey: dto.publicKey || null,
userId,
encryptedPrivateKey,
passphraseEncrypted,
},
});
}
async update(id: number, userId: number, dto: UpdateSshKeyDto) {
const key = await this.prisma.sshKey.findUnique({ where: { id } });
if (!key) throw new NotFoundException(`SSH key ${id} not found`);
if (key.userId !== userId) throw new ForbiddenException('Access denied');
const data: any = {};
if (dto.name) data.name = dto.name;
if (dto.passphrase !== undefined) {
data.passphraseEncrypted = dto.passphrase
? await this.encryption.encrypt(dto.passphrase)
: null;
}
return this.prisma.sshKey.update({ where: { id }, data });
}
async remove(id: number, userId: number) {
const key = await this.prisma.sshKey.findUnique({ where: { id } });
if (!key) throw new NotFoundException(`SSH key ${id} not found`);
if (key.userId !== userId) throw new ForbiddenException('Access denied');
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';
}
}
}

View File

@ -1,13 +0,0 @@
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 {}

View File

@ -1,4 +0,0 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*.spec.ts"]
}

View File

@ -1,22 +0,0 @@
{
"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/*"] }
}
}

View File

@ -1,36 +0,0 @@
services:
app:
build: .
ports: ["4210: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:

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

108
docs/test-buildout-spec.md Normal file
View File

@ -0,0 +1,108 @@
# Wraith Remote — Test Suite Build-Out Spec
**Date:** 2026-03-14
**Scope:** Level B — Full service layer coverage (~80-100 tests)
**Status:** Pinned — awaiting green light to execute
---
## Backend (Jest)
Jest is already configured in `backend/package.json`. Zero spec files exist today.
### Infrastructure
- Tests co-located with source: `*.spec.ts` next to `*.ts`
- Shared mock factories in `backend/src/__mocks__/` for Prisma, JwtService, EncryptionService
- `beforeEach` resets all mocks to prevent test bleed
### Test Files
| File | Tests | Priority |
|------|-------|----------|
| `auth.service.spec.ts` | Login (valid, invalid, non-existent user timing), bcrypt→argon2 migration, TOTP setup/verify/disable, TOTP secret encryption/decryption, password hashing, profile update, admin CRUD | ~20 |
| `encryption.service.spec.ts` | v2 encrypt/decrypt round-trip, v1 backwards compat decrypt, v1→v2 upgrade, isV1 detection, invalid version handling, key derivation warmup | ~8 |
| `credentials.service.spec.ts` | findAll excludes encryptedValue, findOne with ownership check, create with encryption, update with password change, remove, decryptForConnection (password, SSH key, orphaned key, no auth) | ~10 |
| `ssh-keys.service.spec.ts` | Create with encryption, findAll (no private key leak), findOne ownership, update passphrase, remove, key type detection, fingerprint generation | ~8 |
| `jwt-auth.guard.spec.ts` | Passes valid JWT, rejects missing/expired/invalid JWT | ~3 |
| `admin.guard.spec.ts` | Allows admin role, blocks non-admin, blocks missing user | ~3 |
| `ws-auth.guard.spec.ts` | Cookie-based auth, WS ticket auth (valid, expired, reused), legacy URL token fallback, no token rejection | ~6 |
| `auth.controller.spec.ts` | Login sets cookie, logout clears cookie, ws-ticket issuance and consumption, TOTP endpoints wired correctly | ~8 |
**Backend total: ~66 tests**
### Mocking Strategy
- **PrismaService:** Jest manual mock returning controlled data per test
- **EncryptionService:** Mock encrypt returns `v2:mock:...`, mock decrypt returns plaintext
- **JwtService:** Mock sign returns `mock-jwt-token`, mock verify returns payload
- **Argon2:** Real library (fast enough for unit tests, tests actual hashing behavior)
- **bcrypt:** Real library (needed to test migration path)
---
## Frontend (Vitest)
No test infrastructure exists today. Needs full setup.
### Infrastructure
- Install: `vitest`, `@vue/test-utils`, `@pinia/testing`, `happy-dom`
- Config: `frontend/vitest.config.ts` with happy-dom environment
- Global mock for `$fetch` (Nuxt auto-import) via setup file
- Global mock for `navigateTo` (Nuxt auto-import)
### Test Files
| File | Tests | Priority |
|------|-------|----------|
| `stores/auth.store.spec.ts` | Login success (stores user, no token in state), login TOTP flow, logout clears state + calls API, fetchProfile success/failure, getWsTicket, isAuthenticated/isAdmin getters | ~10 |
| `stores/connection.store.spec.ts` | fetchHosts, fetchTree, createHost, updateHost, deleteHost, group CRUD, no Authorization headers in requests | ~8 |
| `composables/useVault.spec.ts` | listKeys, importKey, deleteKey, listCredentials, createCredential, updateCredential, deleteCredential — all without Authorization headers | ~7 |
| `middleware/admin.spec.ts` | Redirects non-admin to /, allows admin through | ~3 |
| `plugins/auth.client.spec.ts` | Calls fetchProfile on init when user is null, skips when user exists | ~2 |
**Frontend total: ~30 tests**
---
## NPM Scripts
```json
// backend/package.json
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage"
// frontend/package.json
"test": "vitest run",
"test:watch": "vitest",
"test:cov": "vitest run --coverage"
```
---
## Pre-commit Hook (Husky)
- Install `husky` + `lint-staged` at repo root
- Pre-commit runs: backend Jest (changed files) + frontend Vitest (changed files)
- Fast feedback — only tests related to changed files run on commit
---
## Execution Plan
1. **Agent 1:** Backend test infra (mock factories) + auth service tests + auth controller tests + guard tests (~37 tests)
2. **Agent 2:** Backend vault tests — encryption, credentials, SSH keys (~26 tests)
3. **Agent 3:** Frontend test infra (Vitest setup) + store tests + middleware + plugin tests (~30 tests)
4. **XO (me):** Wire npm scripts, verify all pass, add Husky pre-commit hook, final integration check
---
## Future (Level C — when ready)
- Component tests with Vue Test Utils (render + interaction)
- Integration tests against a real test PostgreSQL (Docker test container)
- Gateway tests (WebSocket mocking for terminal, SFTP, RDP)
- E2E with Playwright
- CI pipeline (Gitea Actions)

View File

@ -1,12 +0,0 @@
<script setup lang="ts">
// Apply dark theme by default on initial load; settings page can toggle it
useHead({
htmlAttrs: { class: 'dark' },
})
</script>
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>

View File

@ -1,7 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html, body, #__nuxt {
@apply h-full bg-gray-900 text-gray-100;
}

View File

@ -1,108 +0,0 @@
<script setup lang="ts">
const props = defineProps<{
visible: boolean
group?: any | null
}>()
const emit = defineEmits<{
(e: 'update:visible', val: boolean): void
(e: 'saved'): void
}>()
const connections = useConnectionStore()
const form = ref({
name: '',
parentId: null as number | null,
})
const saving = ref(false)
const error = ref('')
const isEdit = computed(() => !!props.group?.id)
const title = computed(() => isEdit.value ? 'Edit Group' : 'New Group')
const parentOptions = computed(() => {
const options: { label: string; value: number | null }[] = [{ label: 'No Parent (top-level)', value: null }]
const addGroups = (groups: any[], prefix = '') => {
for (const g of groups) {
if (isEdit.value && g.id === props.group?.id) continue
options.push({ label: prefix + g.name, value: g.id })
if (g.children?.length) addGroups(g.children, prefix + g.name + ' / ')
}
}
addGroups(connections.groups)
return options
})
watch(() => props.visible, (v) => {
if (v) {
form.value = {
name: props.group?.name || '',
parentId: props.group?.parentId ?? null,
}
error.value = ''
}
})
function close() {
emit('update:visible', false)
}
async function save() {
error.value = ''
saving.value = true
try {
if (isEdit.value) {
await connections.updateGroup(props.group.id, form.value)
} else {
await connections.createGroup(form.value)
}
emit('saved')
close()
} catch (e: any) {
error.value = e.data?.message || 'Failed to save group'
} finally {
saving.value = false
}
}
</script>
<template>
<div v-if="visible" class="fixed inset-0 z-[9999] flex items-center justify-center">
<!-- Backdrop -->
<div class="absolute inset-0 bg-black/70" @click="close" />
<!-- Dialog -->
<div class="relative bg-gray-900 border border-gray-700 rounded-lg shadow-2xl w-[380px] max-h-[90vh] overflow-y-auto">
<!-- Header -->
<div class="flex items-center justify-between px-5 py-4 border-b border-gray-800">
<h3 class="text-lg font-semibold text-white">{{ title }}</h3>
<button @click="close" class="text-gray-500 hover:text-white text-xl leading-none">&times;</button>
</div>
<!-- Body -->
<div class="px-5 py-4 space-y-4">
<div>
<label class="block text-sm text-gray-400 mb-1">Group Name *</label>
<input v-model="form.name" type="text" placeholder="Production Servers" autofocus
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white focus:border-sky-500 focus:outline-none" />
</div>
<div>
<label class="block text-sm text-gray-400 mb-1">Parent Group</label>
<select v-model="form.parentId"
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white focus:border-sky-500 focus:outline-none">
<option v-for="opt in parentOptions" :key="String(opt.value)" :value="opt.value">{{ opt.label }}</option>
</select>
</div>
<p v-if="error" class="text-red-400 text-sm">{{ error }}</p>
</div>
<!-- Footer -->
<div class="flex justify-end gap-2 px-5 py-4 border-t border-gray-800">
<button @click="close" class="px-4 py-2 text-sm text-gray-400 hover:text-white rounded">Cancel</button>
<button @click="save" :disabled="saving || !form.name"
class="px-4 py-2 text-sm bg-sky-600 hover:bg-sky-700 text-white rounded disabled:opacity-50">
{{ saving ? 'Saving...' : 'Save' }}
</button>
</div>
</div>
</div>
</template>

View File

@ -1,128 +0,0 @@
<script setup lang="ts">
interface Host {
id: number
name: string
hostname: string
port: number
protocol: 'ssh' | 'rdp'
tags: string[]
notes: string | null
color: string | null
lastConnectedAt: string | null
group: { id: number; name: string } | null
}
const props = defineProps<{
host: Host
selected?: boolean
}>()
const emit = defineEmits<{
(e: 'edit'): void
(e: 'delete'): void
(e: 'connect'): void
}>()
function formatLastConnected(ts: string | null): string {
if (!ts) return 'Never'
const d = new Date(ts)
const now = new Date()
const diffMs = now.getTime() - d.getTime()
const diffDays = Math.floor(diffMs / 86400000)
if (diffDays === 0) return 'Today'
if (diffDays === 1) return 'Yesterday'
if (diffDays < 30) return `${diffDays}d ago`
return d.toLocaleDateString()
}
function recencyClass(ts: string | null): string {
if (!ts) return 'text-gray-600'
const diffDays = Math.floor((Date.now() - new Date(ts).getTime()) / 86400000)
if (diffDays === 0) return 'text-emerald-400'
if (diffDays <= 7) return 'text-amber-400'
return 'text-gray-600'
}
// Deterministic tag color from string hash
const TAG_COLORS = [
'bg-teal-900/40 text-teal-300 border-teal-700/50',
'bg-amber-900/40 text-amber-300 border-amber-700/50',
'bg-violet-900/40 text-violet-300 border-violet-700/50',
'bg-rose-900/40 text-rose-300 border-rose-700/50',
'bg-emerald-900/40 text-emerald-300 border-emerald-700/50',
'bg-sky-900/40 text-sky-300 border-sky-700/50',
'bg-orange-900/40 text-orange-300 border-orange-700/50',
'bg-indigo-900/40 text-indigo-300 border-indigo-700/50',
]
function tagColor(tag: string): string {
let hash = 0
for (let i = 0; i < tag.length; i++) hash = ((hash << 5) - hash + tag.charCodeAt(i)) | 0
return TAG_COLORS[Math.abs(hash) % TAG_COLORS.length]
}
function defaultStrip(protocol: string): string {
return protocol === 'rdp' ? '#a855f7' : '#5c7cfa'
}
</script>
<template>
<div
class="relative bg-gray-900 border rounded-lg p-4 hover:border-wraith-700 transition-colors group cursor-pointer"
:class="selected ? 'border-wraith-600 ring-1 ring-wraith-600/30' : 'border-gray-800'"
>
<!-- Color indicator strip always visible, defaults to protocol color -->
<div
class="absolute top-0 left-0 w-1 h-full rounded-l-lg"
:style="{ backgroundColor: host.color || defaultStrip(host.protocol) }"
/>
<!-- Header row -->
<div class="flex items-start justify-between gap-2 pl-2">
<div class="min-w-0 flex-1">
<h3 class="font-semibold text-white truncate">{{ host.name }}</h3>
<p class="text-sm text-gray-500 truncate">{{ host.hostname }}:{{ host.port }}</p>
</div>
<!-- Protocol badge -->
<span
class="text-xs font-medium px-2 py-0.5 rounded shrink-0"
:class="host.protocol === 'rdp'
? 'bg-purple-900/50 text-purple-300 border border-purple-800'
: 'bg-wraith-900/50 text-wraith-300 border border-wraith-800'"
>{{ host.protocol.toUpperCase() }}</span>
</div>
<!-- Group + last connected -->
<div class="mt-2 pl-2 flex items-center justify-between text-xs">
<span v-if="host.group" class="truncate text-gray-600">{{ host.group.name }}</span>
<span v-else class="italic text-gray-600">Ungrouped</span>
<span :class="recencyClass(host.lastConnectedAt)">{{ formatLastConnected(host.lastConnectedAt) }}</span>
</div>
<!-- Tags -->
<div v-if="host.tags?.length" class="mt-2 pl-2 flex flex-wrap gap-1">
<span
v-for="tag in host.tags"
:key="tag"
class="text-xs px-1.5 py-0.5 rounded border"
:class="tagColor(tag)"
>{{ tag }}</span>
</div>
<!-- Action buttons (show on hover) -->
<div
class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity flex gap-1"
@click.stop
>
<button
@click="emit('edit')"
class="text-xs bg-gray-800 hover:bg-gray-700 text-gray-400 hover:text-white px-2 py-1 rounded"
>Edit</button>
<button
@click="emit('delete')"
class="text-xs bg-gray-800 hover:bg-red-900 text-gray-400 hover:text-red-300 px-2 py-1 rounded"
>Delete</button>
</div>
</div>
</template>

View File

@ -1,249 +0,0 @@
<script setup lang="ts">
const props = defineProps<{
visible: boolean
host: any | null
}>()
const emit = defineEmits<{
(e: 'update:visible', val: boolean): void
(e: 'saved'): void
}>()
const connections = useConnectionStore()
const auth = useAuthStore()
const form = ref({
name: '',
hostname: '',
port: 22,
protocol: 'ssh' as 'ssh' | 'rdp',
groupId: null as number | null,
credentialId: null as number | null,
tags: [] as string[],
notes: '',
color: '',
})
const tagInput = ref('')
const saving = ref(false)
const error = ref('')
const isEdit = computed(() => !!props.host?.id)
const title = computed(() => isEdit.value ? 'Edit Host' : 'New Host')
const groupOptions = computed(() => {
const opts: { label: string; value: number | null }[] = [{ label: 'No Group', value: null }]
for (const g of connections.groups) {
opts.push({ label: g.name, value: g.id })
}
return opts
})
const credentials = ref<any[]>([])
const credentialOptions = computed(() => {
const opts: { label: string; value: number | null }[] = [{ label: 'No Credential', value: null }]
for (const c of credentials.value) {
opts.push({ label: c.name, value: c.id })
}
return opts
})
async function loadCredentials() {
try {
credentials.value = await $fetch('/api/credentials')
} catch {
credentials.value = []
}
}
watch(() => props.visible, (v) => {
if (v) {
loadCredentials()
if (props.host?.id) {
form.value = {
name: props.host.name || '',
hostname: props.host.hostname || '',
port: props.host.port || 22,
protocol: props.host.protocol || 'ssh',
groupId: props.host.groupId ?? null,
credentialId: props.host.credentialId ?? null,
tags: [...(props.host.tags || [])],
notes: props.host.notes || '',
color: props.host.color || '',
}
} else {
form.value = {
name: '',
hostname: '',
port: 22,
protocol: 'ssh',
groupId: props.host?.groupId ?? null,
credentialId: null,
tags: [],
notes: '',
color: '',
}
}
error.value = ''
}
})
watch(() => form.value.protocol, (proto) => {
if (proto === 'rdp' && form.value.port === 22) form.value.port = 3389
if (proto === 'ssh' && form.value.port === 3389) form.value.port = 22
})
function addTag() {
const t = tagInput.value.trim()
if (t && !form.value.tags.includes(t)) {
form.value.tags.push(t)
}
tagInput.value = ''
}
function removeTag(tag: string) {
form.value.tags = form.value.tags.filter((t) => t !== tag)
}
function close() {
emit('update:visible', false)
}
async function save() {
error.value = ''
saving.value = true
try {
const payload = {
...form.value,
port: Number(form.value.port),
color: form.value.color || undefined,
notes: form.value.notes || undefined,
}
if (isEdit.value) {
await connections.updateHost(props.host.id, payload)
} else {
await connections.createHost(payload)
}
emit('saved')
close()
} catch (e: any) {
error.value = e.data?.message || 'Failed to save host'
} finally {
saving.value = false
}
}
</script>
<template>
<div v-if="visible" class="fixed inset-0 z-[9999] flex items-center justify-center">
<!-- Backdrop -->
<div class="absolute inset-0 bg-black/70" @click="close" />
<!-- Dialog -->
<div class="relative bg-gray-900 border border-gray-700 rounded-lg shadow-xl w-[480px] max-h-[90vh] overflow-y-auto">
<!-- Header -->
<div class="flex items-center justify-between px-5 py-4 border-b border-gray-800">
<h3 class="text-lg font-semibold text-white">{{ title }}</h3>
<button @click="close" class="text-gray-500 hover:text-white text-xl leading-none">&times;</button>
</div>
<!-- Body -->
<div class="px-5 py-4 space-y-4">
<!-- Name -->
<div>
<label class="block text-sm text-gray-400 mb-1">Name *</label>
<input v-model="form.name" type="text" placeholder="My Server" autofocus
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white focus:border-sky-500 focus:outline-none" />
</div>
<!-- Hostname + Port -->
<div class="flex gap-3">
<div class="flex-1">
<label class="block text-sm text-gray-400 mb-1">Hostname / IP *</label>
<input v-model="form.hostname" type="text" placeholder="192.168.1.1"
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white focus:border-sky-500 focus:outline-none" />
</div>
<div class="w-24">
<label class="block text-sm text-gray-400 mb-1">Port</label>
<input v-model.number="form.port" type="number"
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white focus:border-sky-500 focus:outline-none" />
</div>
</div>
<!-- Protocol -->
<div>
<label class="block text-sm text-gray-400 mb-1">Protocol</label>
<select v-model="form.protocol"
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white focus:border-sky-500 focus:outline-none">
<option value="ssh">SSH</option>
<option value="rdp">RDP</option>
</select>
</div>
<!-- Group -->
<div>
<label class="block text-sm text-gray-400 mb-1">Group</label>
<select v-model="form.groupId"
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white focus:border-sky-500 focus:outline-none">
<option v-for="opt in groupOptions" :key="String(opt.value)" :value="opt.value">{{ opt.label }}</option>
</select>
</div>
<!-- Credential -->
<div>
<label class="block text-sm text-gray-400 mb-1">Credential</label>
<select v-model="form.credentialId"
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white focus:border-sky-500 focus:outline-none">
<option v-for="opt in credentialOptions" :key="String(opt.value)" :value="opt.value">{{ opt.label }}</option>
</select>
</div>
<!-- Color -->
<div>
<label class="block text-sm text-gray-400 mb-1">Color (optional)</label>
<div class="flex items-center gap-2">
<input v-model="form.color" type="color" class="h-8 w-12 rounded cursor-pointer bg-gray-800 border-0" />
<span class="text-xs text-gray-500">{{ form.color || 'None' }}</span>
<button v-if="form.color" @click="form.color = ''" class="text-xs text-gray-600 hover:text-red-400">Clear</button>
</div>
</div>
<!-- Tags -->
<div>
<label class="block text-sm text-gray-400 mb-1">Tags</label>
<div class="flex gap-2 mb-2">
<input v-model="tagInput" type="text" placeholder="Add tag..."
class="flex-1 px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white text-sm focus:border-sky-500 focus:outline-none"
@keydown.enter.prevent="addTag" />
<button @click="addTag" class="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 text-sm rounded text-gray-300">Add</button>
</div>
<div class="flex flex-wrap gap-1">
<span v-for="tag in form.tags" :key="tag"
class="inline-flex items-center gap-1 bg-gray-800 text-gray-300 text-xs px-2 py-1 rounded">
{{ tag }}
<button @click="removeTag(tag)" class="text-gray-500 hover:text-red-400">&times;</button>
</span>
</div>
</div>
<!-- Notes -->
<div>
<label class="block text-sm text-gray-400 mb-1">Notes</label>
<textarea v-model="form.notes" rows="3" placeholder="Optional notes..."
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white text-sm focus:border-sky-500 focus:outline-none resize-none" />
</div>
<!-- Error -->
<p v-if="error" class="text-red-400 text-sm">{{ error }}</p>
</div>
<!-- Footer -->
<div class="flex justify-end gap-2 px-5 py-4 border-t border-gray-800">
<button @click="close" class="px-4 py-2 text-sm text-gray-400 hover:text-white rounded">Cancel</button>
<button @click="save" :disabled="saving || !form.name || !form.hostname"
class="px-4 py-2 text-sm bg-sky-600 hover:bg-sky-700 text-white rounded disabled:opacity-50">
{{ saving ? 'Saving...' : 'Save' }}
</button>
</div>
</div>
</div>
</template>

View File

@ -1,143 +0,0 @@
<script setup lang="ts">
interface Host {
id: number
name: string
hostname: string
port: number
protocol: 'ssh' | 'rdp'
color: string | null
tags?: string[]
}
interface HostGroup {
id: number
name: string
parentId: number | null
children: HostGroup[]
hosts: Host[]
}
const props = defineProps<{
groups: HostGroup[]
}>()
const emit = defineEmits<{
(e: 'select-host', host: Host): void
(e: 'new-host', groupId?: number): void
(e: 'delete-group', groupId: number): void
}>()
const expanded = ref<Set<number>>(new Set())
function toggleGroup(id: number) {
if (expanded.value.has(id)) {
expanded.value.delete(id)
} else {
expanded.value.add(id)
}
}
function isExpanded(id: number) {
return expanded.value.has(id)
}
// Recursively count all hosts in a group and its children
function countHosts(group: HostGroup): number {
let count = group.hosts?.length || 0
for (const child of group.children || []) {
count += countHosts(child)
}
return count
}
// Tag color same algorithm as HostCard
const TAG_COLORS = [
'bg-teal-900/40 text-teal-300',
'bg-amber-900/40 text-amber-300',
'bg-violet-900/40 text-violet-300',
'bg-rose-900/40 text-rose-300',
'bg-emerald-900/40 text-emerald-300',
'bg-sky-900/40 text-sky-300',
'bg-orange-900/40 text-orange-300',
'bg-indigo-900/40 text-indigo-300',
]
function tagColor(tag: string): string {
let hash = 0
for (let i = 0; i < tag.length; i++) hash = ((hash << 5) - hash + tag.charCodeAt(i)) | 0
return TAG_COLORS[Math.abs(hash) % TAG_COLORS.length]
}
</script>
<template>
<div class="flex-1 overflow-y-auto text-sm">
<!-- Groups -->
<template v-for="group in groups" :key="group.id">
<div>
<!-- Group header -->
<div
class="flex items-center gap-1 px-3 py-1.5 cursor-pointer hover:bg-gray-800 text-gray-400 hover:text-gray-200 select-none group/grp border-l-2 border-wraith-500/30"
@click="toggleGroup(group.id)"
>
<span class="text-xs w-3">{{ isExpanded(group.id) ? '▾' : '▸' }}</span>
<span class="font-medium truncate flex-1">{{ group.name }}</span>
<span class="text-xs text-gray-600 tabular-nums mr-1">{{ countHosts(group) }}</span>
<button
@click.stop="emit('delete-group', group.id)"
class="text-xs text-gray-600 hover:text-red-400 px-0.5 opacity-0 group-hover/grp:opacity-100 transition-opacity"
title="Delete group"
>×</button>
<button
@click.stop="emit('new-host', group.id)"
class="text-xs text-gray-600 hover:text-wraith-400 px-0.5 opacity-0 group-hover/grp:opacity-100 transition-opacity"
title="Add host to group"
>+</button>
</div>
<!-- Group children (hosts + sub-groups) -->
<div v-if="isExpanded(group.id)" class="pl-3">
<!-- Sub-groups recursively -->
<HostTree
v-if="group.children?.length"
:groups="group.children"
@select-host="(h) => emit('select-host', h)"
@new-host="(gid) => emit('new-host', gid)"
@delete-group="(gid) => emit('delete-group', gid)"
/>
<!-- Hosts in this group -->
<div
v-for="host in group.hosts"
:key="host.id"
class="flex items-center gap-2 px-3 py-1.5 cursor-pointer hover:bg-gray-800 text-gray-300 hover:text-white group/host"
@click="emit('select-host', host)"
>
<span
class="w-2 h-2 rounded-full shrink-0"
:style="{ backgroundColor: host.color || (host.protocol === 'rdp' ? '#a855f7' : '#5c7cfa') }"
/>
<div class="min-w-0 flex-1">
<span class="truncate block">{{ host.name }}</span>
<div v-if="host.tags?.length" class="flex gap-1 mt-0.5 flex-wrap">
<span
v-for="tag in host.tags.slice(0, 3)"
:key="tag"
class="text-[10px] leading-tight px-1 py-px rounded"
:class="tagColor(tag)"
>{{ tag }}</span>
<span v-if="host.tags.length > 3" class="text-[10px] text-gray-600">+{{ host.tags.length - 3 }}</span>
</div>
</div>
<span
class="text-xs px-1 rounded shrink-0"
:class="host.protocol === 'rdp' ? 'text-purple-400 bg-purple-900/30' : 'text-wraith-400 bg-wraith-900/30'"
>{{ host.protocol.toUpperCase() }}</span>
</div>
</div>
</div>
</template>
<!-- Ungrouped hosts (shown at root level when no groups) -->
<div v-if="!groups.length" class="px-3 py-2 text-gray-600 text-xs">No groups yet</div>
</div>
</template>

View File

@ -1,46 +0,0 @@
<script setup lang="ts">
const input = ref('')
const protocol = ref<'ssh' | 'rdp'>('ssh')
const emit = defineEmits<{
connect: [{ hostname: string; port: number; username: string; protocol: 'ssh' | 'rdp' }]
}>()
function handleConnect() {
const raw = input.value.trim()
if (!raw) return
// Parse user@hostname:port format
let username = ''
let hostname = raw
let port = protocol.value === 'rdp' ? 3389 : 22
if (hostname.includes('@')) {
[username, hostname] = hostname.split('@')
}
if (hostname.includes(':')) {
const parts = hostname.split(':')
hostname = parts[0]
port = parseInt(parts[1], 10)
}
emit('connect', { hostname, port, username, protocol: protocol.value })
input.value = ''
}
</script>
<template>
<div class="flex items-center gap-2 px-3 py-2 bg-gray-800 border-b border-gray-700">
<select v-model="protocol" class="bg-gray-700 text-gray-300 text-xs rounded px-2 py-1.5 border-none">
<option value="ssh">SSH</option>
<option value="rdp">RDP</option>
</select>
<input
v-model="input"
@keydown.enter="handleConnect"
:placeholder="`user@hostname:${protocol === 'rdp' ? '3389' : '22'}`"
class="flex-1 bg-gray-900 text-white px-3 py-1.5 rounded text-sm border border-gray-700 focus:border-wraith-500 focus:outline-none"
/>
<button @click="handleConnect" class="text-sm text-wraith-400 hover:text-wraith-300 px-2">Connect</button>
</div>
</template>

View File

@ -1,64 +0,0 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { useRdp } from '~/composables/useRdp'
const props = defineProps<{
hostId: number
hostName: string
sessionId: string
color?: string | null
}>()
const container = ref<HTMLDivElement | null>(null)
const { connectRdp } = useRdp()
let rdpSession: Awaited<ReturnType<ReturnType<typeof useRdp>['connectRdp']>> | null = null
// Expose to parent (RdpToolbar uses these)
const sendClipboard = (text: string) => rdpSession?.sendClipboardText(text)
const disconnect = () => rdpSession?.disconnect()
defineExpose({ sendClipboard, disconnect })
onMounted(async () => {
if (!container.value) return
rdpSession = await connectRdp(
container.value,
props.hostId,
props.hostName,
props.color ?? null,
props.sessionId,
{
width: container.value.clientWidth,
height: container.value.clientHeight,
},
)
})
onBeforeUnmount(() => {
rdpSession?.disconnect()
rdpSession = null
})
</script>
<template>
<!-- Full-size container for the Guacamole display canvas.
The Guacamole client appends its own <canvas> element here. -->
<div
ref="container"
class="rdp-canvas-container"
/>
</template>
<style scoped>
.rdp-canvas-container {
@apply absolute inset-0 bg-gray-950 overflow-hidden cursor-default;
}
/* Guacamole manages its own display element sizing via display.scale().
Do NOT override width/height it breaks the internal rendering pipeline. */
.rdp-canvas-container :deep(canvas) {
display: block;
}
</style>

View File

@ -1,226 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
import { Monitor, Clipboard, Maximize2, Minimize2, X, Settings } from 'lucide-vue-next'
const props = defineProps<{
hostName: string
}>()
const emit = defineEmits<{
disconnect: []
sendClipboard: [text: string]
}>()
// Toolbar visibility auto-hide after idle
const toolbarVisible = ref(true)
let hideTimer: ReturnType<typeof setTimeout> | null = null
function showToolbar() {
toolbarVisible.value = true
if (hideTimer) clearTimeout(hideTimer)
hideTimer = setTimeout(() => {
toolbarVisible.value = false
}, 3000)
}
// Clipboard dialog
const clipboardOpen = ref(false)
const clipboardText = ref('')
function openClipboard() {
clipboardOpen.value = true
clipboardText.value = ''
}
function pasteClipboard() {
if (clipboardText.value) {
emit('sendClipboard', clipboardText.value)
}
clipboardOpen.value = false
}
// Fullscreen
const isFullscreen = ref(false)
async function toggleFullscreen() {
if (!document.fullscreenElement) {
await document.documentElement.requestFullscreen()
isFullscreen.value = true
} else {
await document.exitFullscreen()
isFullscreen.value = false
}
}
// Settings panel
const settingsOpen = ref(false)
// Disconnect
function disconnect() {
emit('disconnect')
}
</script>
<template>
<!-- Floating toolbar shows on mouse movement, auto-hides after 3s -->
<div
class="rdp-toolbar-wrapper"
@mousemove="showToolbar"
>
<Transition name="toolbar-slide">
<div
v-if="toolbarVisible"
class="rdp-toolbar"
>
<!-- Host name label -->
<div class="flex items-center gap-2 text-gray-300 text-sm font-medium min-w-0">
<Monitor class="w-4 h-4 text-wraith-400 shrink-0" />
<span class="truncate max-w-36">{{ props.hostName }}</span>
</div>
<div class="h-4 w-px bg-gray-600 mx-1" />
<!-- Clipboard -->
<button
class="toolbar-btn"
title="Send clipboard text"
@click="openClipboard"
>
<Clipboard class="w-4 h-4" />
</button>
<!-- Fullscreen toggle -->
<button
class="toolbar-btn"
:title="isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'"
@click="toggleFullscreen"
>
<Maximize2 v-if="!isFullscreen" class="w-4 h-4" />
<Minimize2 v-else class="w-4 h-4" />
</button>
<!-- Settings -->
<button
class="toolbar-btn"
title="RDP settings"
@click="settingsOpen = !settingsOpen"
>
<Settings class="w-4 h-4" />
</button>
<div class="h-4 w-px bg-gray-600 mx-1" />
<!-- Disconnect -->
<button
class="toolbar-btn toolbar-btn-danger"
title="Disconnect"
@click="disconnect"
>
<X class="w-4 h-4" />
</button>
</div>
</Transition>
<!-- Clipboard Dialog -->
<Teleport to="body">
<div
v-if="clipboardOpen"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
@click.self="clipboardOpen = false"
>
<div class="bg-gray-800 border border-gray-700 rounded-xl shadow-2xl w-full max-w-md p-6">
<h2 class="text-gray-100 font-semibold text-lg mb-4 flex items-center gap-2">
<Clipboard class="w-5 h-5 text-wraith-400" />
Send to clipboard
</h2>
<p class="text-gray-400 text-sm mb-3">
Type or paste text here. It will be sent to the remote session clipboard.
</p>
<textarea
v-model="clipboardText"
class="w-full bg-gray-900 border border-gray-600 rounded-lg text-gray-100 text-sm p-3 resize-none focus:outline-none focus:ring-2 focus:ring-wraith-500"
rows="5"
placeholder="Paste text to send..."
autofocus
/>
<div class="flex justify-end gap-3 mt-4">
<button
class="px-4 py-2 rounded-lg bg-gray-700 hover:bg-gray-600 text-gray-300 text-sm transition-colors"
@click="clipboardOpen = false"
>
Cancel
</button>
<button
class="px-4 py-2 rounded-lg bg-wraith-600 hover:bg-wraith-500 text-white text-sm font-medium transition-colors"
:disabled="!clipboardText"
@click="pasteClipboard"
>
Send
</button>
</div>
</div>
</div>
</Teleport>
<!-- Settings panel (minimal expand in later tasks) -->
<Teleport to="body">
<div
v-if="settingsOpen"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
@click.self="settingsOpen = false"
>
<div class="bg-gray-800 border border-gray-700 rounded-xl shadow-2xl w-full max-w-sm p-6">
<h2 class="text-gray-100 font-semibold text-lg mb-4 flex items-center gap-2">
<Settings class="w-5 h-5 text-wraith-400" />
RDP Settings
</h2>
<p class="text-gray-400 text-sm">
Advanced RDP settings (color depth, resize behavior) will be configurable here in a future release.
</p>
<div class="flex justify-end mt-6">
<button
class="px-4 py-2 rounded-lg bg-gray-700 hover:bg-gray-600 text-gray-300 text-sm transition-colors"
@click="settingsOpen = false"
>
Close
</button>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<style scoped>
.rdp-toolbar-wrapper {
@apply absolute inset-0 pointer-events-none z-20;
}
.rdp-toolbar {
@apply absolute top-4 left-1/2 -translate-x-1/2
flex items-center gap-1 px-3 py-1.5
bg-gray-900/90 backdrop-blur-sm
border border-gray-700 rounded-full shadow-xl
pointer-events-auto;
}
.toolbar-btn {
@apply p-1.5 rounded-full text-gray-400 hover:text-gray-100 hover:bg-gray-700
transition-colors duration-150;
}
.toolbar-btn-danger {
@apply hover:text-red-400 hover:bg-red-900/30;
}
/* Toolbar slide-down animation */
.toolbar-slide-enter-active,
.toolbar-slide-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.toolbar-slide-enter-from,
.toolbar-slide-leave-to {
opacity: 0;
transform: translateX(-50%) translateY(-8px);
}
</style>

View File

@ -1,87 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useSessionStore } from '~/stores/session.store'
const sessions = useSessionStore()
// Sessions with pending-XXX IDs are mounting TerminalInstance which will get a real UUID.
// SFTP sidebar only connects once we have a real (non-pending) backend session ID.
function isRealSession(id: string) {
return !id.startsWith('pending-')
}
// Per-session refs to RdpCanvas instances (keyed by session.id)
// Used to forward toolbar actions (clipboard, disconnect) to the canvas
const rdpCanvasRefs = ref<Record<string, any>>({})
function setRdpRef(sessionId: string, el: any) {
if (el) {
rdpCanvasRefs.value[sessionId] = el
} else {
delete rdpCanvasRefs.value[sessionId]
}
}
function handleRdpDisconnect(sessionId: string) {
rdpCanvasRefs.value[sessionId]?.disconnect()
}
function handleRdpClipboard(sessionId: string, text: string) {
rdpCanvasRefs.value[sessionId]?.sendClipboard(text)
}
</script>
<template>
<!-- Session container always in DOM when sessions exist -->
<div v-if="sessions.hasSessions" class="absolute inset-0 flex flex-col z-10" :class="sessions.showHome ? '' : 'bg-gray-950'">
<!-- Tab bar always visible when sessions exist -->
<TerminalTabs />
<!-- Session panels hidden when Home tab is active, underlying page shows through -->
<div v-show="!sessions.showHome" class="flex-1 overflow-hidden relative bg-gray-950">
<div
v-for="session in sessions.sessions"
:key="session.key"
v-show="session.id === sessions.activeSessionId"
class="absolute inset-0 flex"
>
<!-- SSH session: SFTP sidebar + terminal -->
<template v-if="session.protocol === 'ssh'">
<SftpSidebar
v-if="isRealSession(session.id)"
:session-id="session.id"
/>
<div class="flex-1 overflow-hidden">
<TerminalInstance
:session-id="session.id"
:host-id="session.hostId"
:host-name="session.hostName"
:color="session.color"
/>
</div>
</template>
<!-- RDP session: Guacamole canvas + floating toolbar -->
<template v-else-if="session.protocol === 'rdp'">
<div class="flex-1 overflow-hidden relative">
<RdpCanvas
:ref="(el) => setRdpRef(session.id, el)"
:host-id="session.hostId"
:host-name="session.hostName"
:session-id="session.id"
:color="session.color"
/>
<RdpToolbar
:host-name="session.hostName"
@disconnect="handleRdpDisconnect(session.id)"
@send-clipboard="(text) => handleRdpClipboard(session.id, text)"
/>
</div>
</template>
</div>
</div>
<!-- Transfer status bar -->
<TransferStatus v-show="!sessions.showHome" :transfers="[]" />
</div>
</template>

View File

@ -1,77 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useSessionStore } from '~/stores/session.store'
const sessions = useSessionStore()
function isRealSession(id: string) {
return !id.startsWith('pending-')
}
const rdpCanvasRefs = ref<Record<string, any>>({})
function setRdpRef(sessionId: string, el: any) {
if (el) {
rdpCanvasRefs.value[sessionId] = el
} else {
delete rdpCanvasRefs.value[sessionId]
}
}
function handleRdpDisconnect(sessionId: string) {
rdpCanvasRefs.value[sessionId]?.disconnect()
}
function handleRdpClipboard(sessionId: string, text: string) {
rdpCanvasRefs.value[sessionId]?.sendClipboard(text)
}
</script>
<template>
<div class="absolute inset-0 flex flex-col bg-gray-950">
<div class="flex-1 overflow-hidden relative">
<div
v-for="session in sessions.sessions"
:key="session.key"
v-show="session.id === sessions.activeSessionId"
class="absolute inset-0 flex"
>
<!-- SSH session: SFTP sidebar + terminal -->
<template v-if="session.protocol === 'ssh'">
<SftpSidebar
v-if="isRealSession(session.id)"
:session-id="session.id"
/>
<div class="flex-1 overflow-hidden">
<TerminalInstance
:session-id="session.id"
:host-id="session.hostId"
:host-name="session.hostName"
:color="session.color"
/>
</div>
</template>
<!-- RDP session: Guacamole canvas + floating toolbar -->
<template v-else-if="session.protocol === 'rdp'">
<div class="flex-1 overflow-hidden relative">
<RdpCanvas
:ref="(el) => setRdpRef(session.id, el)"
:host-id="session.hostId"
:host-name="session.hostName"
:session-id="session.id"
:color="session.color"
/>
<RdpToolbar
:host-name="session.hostName"
@disconnect="handleRdpDisconnect(session.id)"
@send-clipboard="(text) => handleRdpClipboard(session.id, text)"
/>
</div>
</template>
</div>
</div>
<TransferStatus :transfers="[]" />
</div>
</template>

View File

@ -1,45 +0,0 @@
<script setup lang="ts">
defineProps<{
session: {
id: string
hostName: string
protocol: 'ssh' | 'rdp'
color: string | null
active: boolean
}
isActive: boolean
}>()
const emit = defineEmits<{
(e: 'activate'): void
(e: 'close'): void
}>()
</script>
<template>
<button
@click="emit('activate')"
class="flex items-center gap-2 px-3 h-full text-sm shrink-0 border-r border-gray-800 transition-colors"
:class="isActive ? 'bg-gray-900 text-white' : 'bg-gray-950 text-gray-500 hover:text-gray-300 hover:bg-gray-900'"
>
<!-- Color dot -->
<span
class="w-2 h-2 rounded-full shrink-0"
:style="session.color ? `background-color: ${session.color}` : ''"
:class="!session.color ? 'bg-wraith-500' : ''"
/>
<!-- Protocol badge -->
<span
class="text-xs font-mono uppercase px-1 rounded"
:class="session.protocol === 'rdp' ? 'text-orange-400 bg-orange-950' : 'text-wraith-400 bg-wraith-950'"
>{{ session.protocol }}</span>
<!-- Host name -->
<span class="max-w-[120px] truncate">{{ session.hostName }}</span>
<!-- Close -->
<button
@click.stop="emit('close')"
class="ml-1 text-gray-600 hover:text-red-400 text-xs leading-none"
title="Close session"
>&times;</button>
</button>
</template>

View File

@ -1,163 +0,0 @@
<script setup lang="ts">
import { ref, watch, onMounted, onBeforeUnmount } from 'vue'
const props = defineProps<{
filePath: string
content: string
}>()
const emit = defineEmits<{
(e: 'save', path: string, content: string): void
(e: 'close'): void
}>()
let popupWindow: Window | null = null
let editor: any = null
let monaco: any = null
const isDirty = ref(false)
// Language detection from file extension
const langMap: Record<string, string> = {
ts: 'typescript', js: 'javascript', json: 'json', py: 'python',
sh: 'shell', bash: 'shell', yml: 'yaml', yaml: 'yaml',
md: 'markdown', html: 'html', css: 'css', xml: 'xml',
go: 'go', rs: 'rust', rb: 'ruby', php: 'php',
conf: 'ini', cfg: 'ini', ini: 'ini', toml: 'ini',
sql: 'sql', dockerfile: 'dockerfile',
}
watch(() => props.content, (val) => {
if (editor) {
editor.setValue(val)
isDirty.value = false
}
})
function handleSave() {
if (editor) {
emit('save', props.filePath, editor.getValue())
isDirty.value = false
updatePopupTitle()
}
}
function updatePopupTitle() {
if (popupWindow && !popupWindow.closed) {
const dirty = isDirty.value ? ' *' : ''
popupWindow.document.title = `${props.filePath.split('/').pop()}${dirty} — Wraith Editor`
}
}
onMounted(async () => {
const fileName = props.filePath.split('/').pop() || 'file'
const ext = fileName.split('.').pop() || ''
const lang = langMap[ext] || 'plaintext'
// Open popup window
const width = 900
const height = 700
const left = window.screenX + (window.innerWidth - width) / 2
const top = window.screenY + (window.innerHeight - height) / 2
popupWindow = window.open('', '_blank',
`width=${width},height=${height},left=${left},top=${top},menubar=no,toolbar=no,status=no,scrollbars=no`)
if (!popupWindow) {
// Popup blocked fall back to inline display
console.warn('[Editor] Popup blocked, falling back to inline')
await mountInlineEditor()
return
}
// Build popup HTML
popupWindow.document.write(`<!DOCTYPE html>
<html>
<head>
<title>${fileName} Wraith Editor</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #0a0a0f; overflow: hidden; display: flex; flex-direction: column; height: 100vh; font-family: -apple-system, sans-serif; }
#toolbar { height: 36px; background: #1a1a2e; border-bottom: 1px solid #2a2a3e; display: flex; align-items: center; justify-content: space-between; padding: 0 12px; flex-shrink: 0; }
#toolbar .file-path { color: #6b7280; font-size: 12px; font-family: 'JetBrains Mono', monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#toolbar .actions { display: flex; gap: 8px; align-items: center; }
#toolbar .dirty { color: #f59e0b; font-size: 12px; }
#toolbar button { padding: 4px 12px; border-radius: 4px; border: none; font-size: 12px; cursor: pointer; }
#toolbar .save-btn { background: #e94560; color: white; }
#toolbar .save-btn:hover { background: #c23152; }
#toolbar .save-btn:disabled { opacity: 0.4; cursor: default; }
#editor { flex: 1; }
</style>
</head>
<body>
<div id="toolbar">
<span class="file-path">${props.filePath}</span>
<div class="actions">
<span id="dirty-indicator" class="dirty" style="display:none">unsaved</span>
<button id="save-btn" class="save-btn" disabled>Save</button>
</div>
</div>
<div id="editor"></div>
</body>
</html>`)
popupWindow.document.close()
// Load Monaco in the popup
monaco = await import('monaco-editor')
const container = popupWindow.document.getElementById('editor')
if (!container) return
editor = monaco.editor.create(container, {
value: props.content,
language: lang,
theme: 'vs-dark',
fontSize: 13,
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
minimap: { enabled: true },
scrollBeyondLastLine: false,
automaticLayout: true,
})
editor.onDidChangeModelContent(() => {
isDirty.value = editor.getValue() !== props.content
const dirtyEl = popupWindow?.document.getElementById('dirty-indicator')
const saveBtn = popupWindow?.document.getElementById('save-btn') as HTMLButtonElement
if (dirtyEl) dirtyEl.style.display = isDirty.value ? '' : 'none'
if (saveBtn) saveBtn.disabled = !isDirty.value
updatePopupTitle()
})
// Ctrl+S in popup
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
handleSave()
})
// Save button click
const saveBtn = popupWindow.document.getElementById('save-btn')
saveBtn?.addEventListener('click', () => handleSave())
// Handle popup close
popupWindow.addEventListener('beforeunload', () => {
emit('close')
})
// Focus the editor
editor.focus()
})
async function mountInlineEditor() {
// Fallback: mount inline (popup was blocked)
// This will be handled by the template below
}
onBeforeUnmount(() => {
editor?.dispose()
if (popupWindow && !popupWindow.closed) {
popupWindow.close()
}
})
</script>
<template>
<!-- This component is invisible the editor lives in the popup window.
We emit 'close' immediately so the SFTP sidebar goes back to file tree view. -->
</template>

View File

@ -1,114 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
interface FileEntry {
name: string
path: string
size: number
isDirectory: boolean
permissions: string
modified: string
}
const props = defineProps<{
entries: FileEntry[]
currentPath: string
}>()
const emit = defineEmits<{
(e: 'navigate', path: string): void
(e: 'openFile', path: string): void
(e: 'download', path: string): void
(e: 'delete', path: string): void
(e: 'rename', oldPath: string): void
(e: 'mkdir'): void
}>()
const contextMenu = ref<{ visible: boolean; x: number; y: number; entry: FileEntry | null }>({
visible: false,
x: 0,
y: 0,
entry: null,
})
function handleClick(entry: FileEntry) {
if (entry.isDirectory) {
emit('navigate', entry.path)
} else {
emit('openFile', entry.path)
}
}
function showContext(event: MouseEvent, entry: FileEntry) {
event.preventDefault()
contextMenu.value = { visible: true, x: event.clientX, y: event.clientY, entry }
}
function closeContext() {
contextMenu.value.visible = false
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
</script>
<template>
<div class="h-full overflow-y-auto text-sm" @click="closeContext">
<div
v-for="entry in entries"
:key="entry.path"
class="flex items-center gap-2 px-3 py-1 hover:bg-gray-800 cursor-pointer group"
@click="handleClick(entry)"
@contextmenu="showContext($event, entry)"
>
<!-- Icon -->
<span class="shrink-0 text-base">
{{ entry.isDirectory ? '📁' : '📄' }}
</span>
<!-- Name -->
<span class="flex-1 truncate" :class="entry.isDirectory ? 'text-wraith-300' : 'text-gray-300'">
{{ entry.name }}
</span>
<!-- Size (files only) -->
<span v-if="!entry.isDirectory" class="text-gray-600 text-xs shrink-0">
{{ formatSize(entry.size) }}
</span>
</div>
<p v-if="entries.length === 0" class="text-gray-600 text-center py-4 text-xs">
Empty directory
</p>
</div>
<!-- Context menu -->
<Teleport to="body">
<div
v-if="contextMenu.visible"
class="fixed z-50 bg-gray-800 border border-gray-700 rounded shadow-xl py-1 min-w-[140px] text-sm"
:style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }"
@click.stop
>
<button
v-if="!contextMenu.entry?.isDirectory"
class="w-full text-left px-3 py-1.5 hover:bg-gray-700 text-gray-300"
@click="emit('openFile', contextMenu.entry!.path); closeContext()"
>Open / Edit</button>
<button
class="w-full text-left px-3 py-1.5 hover:bg-gray-700 text-gray-300"
@click="emit('download', contextMenu.entry!.path); closeContext()"
>Download</button>
<button
class="w-full text-left px-3 py-1.5 hover:bg-gray-700 text-gray-300"
@click="emit('rename', contextMenu.entry!.path); closeContext()"
>Rename</button>
<div class="border-t border-gray-700 my-1" />
<button
class="w-full text-left px-3 py-1.5 hover:bg-gray-700 text-red-400"
@click="emit('delete', contextMenu.entry!.path); closeContext()"
>Delete</button>
</div>
</Teleport>
</template>

View File

@ -1,404 +0,0 @@
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
import { useSftp } from '~/composables/useSftp'
import { useSessionStore } from '~/stores/session.store'
const props = defineProps<{
sessionId: string | null
}>()
const sessions = useSessionStore()
const sessionIdRef = computed(() => props.sessionId)
const {
entries, currentPath, fileContent,
connect, disconnect, list, readFile, writeFile, mkdir, rename, remove, download, upload,
} = useSftp(sessionIdRef)
// Track the terminal's current working directory from the session store
const sessionCwd = computed(() => {
if (!props.sessionId) return null
return sessions.sessions.find(s => s.id === props.sessionId)?.cwd ?? null
})
// Follow terminal CWD changes
watch(sessionCwd, (newCwd, oldCwd) => {
if (newCwd && newCwd !== oldCwd && newCwd !== currentPath.value) {
list(newCwd)
}
})
const width = ref(260)
const isDragging = ref(false)
const newFolderName = ref('')
const showNewFolderInput = ref(false)
const renameTarget = ref<string | null>(null)
const renameTo = ref('')
const fileInput = ref<HTMLInputElement | null>(null)
// Drag-and-drop state
const dragOver = ref(false)
// Fullscreen editor overlay state
const editorOverlay = ref(false)
const editorFilePath = ref('')
const editorContainer = ref<HTMLElement | null>(null)
let editorInstance: any = null
let editorMonaco: any = null
let editorOriginalContent = ''
const editorDirty = ref(false)
const editorSavedMsg = ref(false)
const editorLang = ref('plaintext')
function triggerUpload() {
fileInput.value?.click()
}
function handleFileSelected(event: Event) {
const input = event.target as HTMLInputElement
const files = input.files
if (!files?.length) return
uploadFiles(files)
input.value = ''
}
function uploadFiles(files: FileList | File[]) {
for (const file of files) {
const reader = new FileReader()
reader.onload = () => {
const base64 = (reader.result as string).split(',')[1]
const destPath = `${currentPath.value === '/' ? '' : currentPath.value}/${file.name}`
upload(destPath, base64)
}
reader.readAsDataURL(file)
}
}
// Drag-and-drop handlers
function handleDragOver(e: DragEvent) {
e.preventDefault()
e.stopPropagation()
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
dragOver.value = true
}
function handleDragLeave(e: DragEvent) {
e.preventDefault()
e.stopPropagation()
dragOver.value = false
}
function handleDrop(e: DragEvent) {
e.preventDefault()
e.stopPropagation()
dragOver.value = false
const files = e.dataTransfer?.files
if (files?.length) {
uploadFiles(files)
}
}
onMounted(() => {
connect()
list('~')
})
onBeforeUnmount(() => {
editorInstance?.dispose()
})
function navigateTo(path: string) {
list(path)
}
function goUp() {
const parts = currentPath.value.split('/').filter(Boolean)
parts.pop()
list(parts.length ? '/' + parts.join('/') : '/')
}
function handleOpenFile(path: string) {
readFile(path)
}
const langMap: Record<string, string> = {
ts: 'typescript', js: 'javascript', json: 'json', py: 'python',
sh: 'shell', bash: 'shell', yml: 'yaml', yaml: 'yaml',
md: 'markdown', html: 'html', css: 'css', xml: 'xml',
go: 'go', rs: 'rust', rb: 'ruby', php: 'php',
conf: 'ini', cfg: 'ini', ini: 'ini', toml: 'ini',
sql: 'sql', dockerfile: 'dockerfile',
}
// Watch for file content and open fullscreen editor
watch(fileContent, async (fc) => {
if (!fc) return
editorFilePath.value = fc.path
editorOriginalContent = fc.content
editorDirty.value = false
editorSavedMsg.value = false
const ext = fc.path.split('.').pop() || ''
editorLang.value = langMap[ext] || 'plaintext'
// Clear fileContent so the file tree stays visible underneath
fileContent.value = null
// Show overlay and mount Monaco
editorOverlay.value = true
await nextTick()
if (!editorContainer.value) return
if (!editorMonaco) {
editorMonaco = await import('monaco-editor')
}
// Dispose previous instance if any
editorInstance?.dispose()
editorInstance = editorMonaco.editor.create(editorContainer.value, {
value: editorOriginalContent,
language: editorLang.value,
theme: 'vs-dark',
fontSize: 13,
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
minimap: { enabled: true },
scrollBeyondLastLine: false,
automaticLayout: true,
})
editorInstance.onDidChangeModelContent(() => {
editorDirty.value = editorInstance.getValue() !== editorOriginalContent
})
// Ctrl+S to save
editorInstance.addCommand(
editorMonaco.KeyMod.CtrlCmd | editorMonaco.KeyCode.KeyS,
() => editorSave()
)
// Esc to close
editorInstance.addCommand(
editorMonaco.KeyCode.Escape,
() => editorClose()
)
editorInstance.focus()
})
function editorSave() {
if (!editorInstance) return
const val = editorInstance.getValue()
writeFile(editorFilePath.value, val)
editorOriginalContent = val
editorDirty.value = false
editorSavedMsg.value = true
setTimeout(() => { editorSavedMsg.value = false }, 2000)
}
function editorClose() {
if (editorDirty.value) {
if (!confirm('You have unsaved changes. Close anyway?')) return
}
editorOverlay.value = false
editorInstance?.dispose()
editorInstance = null
}
function handleDelete(path: string) {
if (confirm(`Delete ${path}?`)) {
remove(path)
list(currentPath.value)
}
}
function handleRename(oldPath: string) {
renameTarget.value = oldPath
renameTo.value = oldPath.split('/').pop() || ''
}
function confirmRename() {
if (!renameTarget.value || !renameTo.value) return
const dir = renameTarget.value.split('/').slice(0, -1).join('/')
rename(renameTarget.value, `${dir}/${renameTo.value}`)
renameTarget.value = null
list(currentPath.value)
}
function createFolder() {
if (!newFolderName.value) return
mkdir(`${currentPath.value === '/' ? '' : currentPath.value}/${newFolderName.value}`)
newFolderName.value = ''
showNewFolderInput.value = false
list(currentPath.value)
}
function startResize(event: MouseEvent) {
isDragging.value = true
const startX = event.clientX
const startWidth = width.value
const onMove = (e: MouseEvent) => {
width.value = Math.max(180, Math.min(480, startWidth + (e.clientX - startX)))
}
const onUp = () => {
isDragging.value = false
window.removeEventListener('mousemove', onMove)
window.removeEventListener('mouseup', onUp)
}
window.addEventListener('mousemove', onMove)
window.addEventListener('mouseup', onUp)
}
// Breadcrumb segments from currentPath
const breadcrumbs = computed(() => {
const parts = currentPath.value.split('/').filter(Boolean)
return [
{ name: '/', path: '/' },
...parts.map((part, i) => ({
name: part,
path: '/' + parts.slice(0, i + 1).join('/'),
})),
]
})
</script>
<template>
<div class="flex h-full" :style="{ width: width + 'px' }">
<div
class="flex flex-col h-full bg-gray-900 border-r border-gray-800 overflow-hidden flex-1"
@dragover="handleDragOver"
@dragleave="handleDragLeave"
@drop="handleDrop"
>
<!-- Toolbar -->
<div class="px-2 py-1.5 border-b border-gray-800 shrink-0">
<!-- Breadcrumbs -->
<div class="flex items-center gap-1 text-xs text-gray-500 overflow-x-auto mb-1">
<button
v-for="(crumb, i) in breadcrumbs"
:key="crumb.path"
class="hover:text-wraith-400 shrink-0"
@click="navigateTo(crumb.path)"
>{{ crumb.name }}</button>
<template v-if="breadcrumbs.length > 1">
<span v-for="(_, i) in breadcrumbs.slice(1)" :key="i" class="text-gray-700">/</span>
</template>
</div>
<!-- Actions -->
<div class="flex items-center gap-1">
<button
v-if="currentPath !== '/'"
@click="goUp"
class="text-xs text-gray-500 hover:text-gray-300 px-1.5 py-0.5 rounded hover:bg-gray-800"
title="Go up"
>Up</button>
<button
@click="list(currentPath)"
class="text-xs text-gray-500 hover:text-gray-300 px-1.5 py-0.5 rounded hover:bg-gray-800"
title="Refresh"
>Refresh</button>
<button
@click="showNewFolderInput = !showNewFolderInput"
class="text-xs text-gray-500 hover:text-wraith-400 px-1.5 py-0.5 rounded hover:bg-gray-800"
title="New folder"
>+ Folder</button>
<button
@click="triggerUpload"
class="text-xs text-gray-500 hover:text-wraith-400 px-1.5 py-0.5 rounded hover:bg-gray-800"
title="Upload file"
>Upload</button>
<input ref="fileInput" type="file" multiple class="hidden" @change="handleFileSelected" />
</div>
<!-- New folder input -->
<div v-if="showNewFolderInput" class="flex gap-1 mt-1">
<input
v-model="newFolderName"
class="flex-1 text-xs bg-gray-800 border border-gray-700 rounded px-1.5 py-0.5 text-white"
placeholder="Folder name"
@keyup.enter="createFolder"
autofocus
/>
<button @click="createFolder" class="text-xs text-wraith-400 hover:text-wraith-300">OK</button>
<button @click="showNewFolderInput = false" class="text-xs text-gray-500">x</button>
</div>
<!-- Rename input -->
<div v-if="renameTarget" class="flex gap-1 mt-1">
<input
v-model="renameTo"
class="flex-1 text-xs bg-gray-800 border border-gray-700 rounded px-1.5 py-0.5 text-white"
placeholder="New name"
@keyup.enter="confirmRename"
autofocus
/>
<button @click="confirmRename" class="text-xs text-wraith-400 hover:text-wraith-300">OK</button>
<button @click="renameTarget = null" class="text-xs text-gray-500">x</button>
</div>
</div>
<!-- File tree with drag-and-drop overlay -->
<div class="flex-1 min-h-0 overflow-hidden relative">
<!-- Drag overlay -->
<div
v-if="dragOver"
class="absolute inset-0 z-10 flex items-center justify-center bg-wraith-600/10 border-2 border-dashed border-wraith-500 rounded pointer-events-none"
>
<span class="text-wraith-400 text-sm font-medium">Drop files to upload</span>
</div>
<SftpFileTree
:entries="entries"
:current-path="currentPath"
@navigate="navigateTo"
@open-file="handleOpenFile"
@download="download"
@delete="handleDelete"
@rename="handleRename"
/>
</div>
</div>
<!-- Resize handle -->
<div
class="w-1 bg-transparent hover:bg-wraith-500 cursor-col-resize transition-colors shrink-0"
@mousedown="startResize"
/>
<!-- Fullscreen Monaco editor overlay -->
<Teleport to="body">
<div
v-if="editorOverlay"
class="fixed inset-0 z-[99999] flex flex-col bg-[#0a0a0f]"
>
<!-- Editor toolbar -->
<div class="h-9 bg-[#1a1a2e] border-b border-[#2a2a3e] flex items-center justify-between px-3 shrink-0">
<span class="text-xs text-gray-500 font-mono truncate max-w-[60%]" :title="editorFilePath">{{ editorFilePath }}</span>
<div class="flex items-center gap-2">
<span v-if="editorDirty" class="text-xs text-amber-400">unsaved</span>
<span v-if="editorSavedMsg" class="text-xs text-green-400">Saved!</span>
<button
@click="editorSave"
:disabled="!editorDirty"
class="px-3 py-1 rounded text-xs"
:class="editorDirty ? 'bg-[#e94560] hover:bg-[#c23152] text-white cursor-pointer' : 'bg-[#e94560]/40 text-white/40 cursor-default'"
>Save</button>
<button
@click="editorClose"
class="px-3 py-1 rounded text-xs bg-gray-700 hover:bg-gray-600 text-gray-300"
>Close</button>
</div>
</div>
<!-- Monaco mount point -->
<div ref="editorContainer" class="flex-1" />
<!-- Status bar -->
<div class="h-6 bg-[#1a1a2e] border-t border-[#2a2a3e] flex items-center px-3 shrink-0">
<span class="text-[11px] text-gray-500">{{ editorLang }}</span>
<span class="text-[11px] text-gray-600 ml-3">Ctrl+S save | Esc close</span>
</div>
</div>
</Teleport>
</div>
</template>

View File

@ -1,43 +0,0 @@
<script setup lang="ts">
interface Transfer {
id: string
path: string
total: number
bytes: number
direction: 'download' | 'upload'
}
defineProps<{
transfers: Transfer[]
}>()
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
function progressPercent(transfer: Transfer): number {
if (!transfer.total) return 0
return Math.round((transfer.bytes / transfer.total) * 100)
}
</script>
<template>
<div v-if="transfers.length" class="bg-gray-900 border-t border-gray-800 px-3 py-2 shrink-0">
<div v-for="transfer in transfers" :key="transfer.id" class="flex items-center gap-3 text-xs mb-1">
<span class="text-gray-500">{{ transfer.direction === 'download' ? '↓' : '↑' }}</span>
<span class="text-gray-400 truncate max-w-[200px] font-mono">{{ transfer.path.split('/').pop() }}</span>
<div class="flex-1 bg-gray-800 rounded-full h-1.5">
<div
class="bg-wraith-500 h-1.5 rounded-full transition-all"
:style="{ width: progressPercent(transfer) + '%' }"
/>
</div>
<span class="text-gray-500 shrink-0">
{{ formatSize(transfer.bytes) }} / {{ formatSize(transfer.total) }}
</span>
<span class="text-gray-600 shrink-0">{{ progressPercent(transfer) }}%</span>
</div>
</div>
</template>

View File

@ -1,74 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
const direction = ref<'horizontal' | 'vertical'>('horizontal')
const splitRatio = ref(50) // percentage for first pane
const isDragging = ref(false)
function toggleDirection() {
direction.value = direction.value === 'horizontal' ? 'vertical' : 'horizontal'
}
function startDrag(event: MouseEvent) {
isDragging.value = true
const container = (event.target as HTMLElement).closest('.split-pane-container') as HTMLElement
if (!container) return
const onMove = (e: MouseEvent) => {
if (!isDragging.value) return
const rect = container.getBoundingClientRect()
if (direction.value === 'horizontal') {
splitRatio.value = Math.min(80, Math.max(20, ((e.clientX - rect.left) / rect.width) * 100))
} else {
splitRatio.value = Math.min(80, Math.max(20, ((e.clientY - rect.top) / rect.height) * 100))
}
}
const onUp = () => {
isDragging.value = false
window.removeEventListener('mousemove', onMove)
window.removeEventListener('mouseup', onUp)
}
window.addEventListener('mousemove', onMove)
window.addEventListener('mouseup', onUp)
}
</script>
<template>
<div
class="split-pane-container w-full h-full flex"
:class="direction === 'horizontal' ? 'flex-row' : 'flex-col'"
>
<!-- First pane -->
<div
class="overflow-hidden"
:style="direction === 'horizontal'
? `width: ${splitRatio}%; flex-shrink: 0`
: `height: ${splitRatio}%; flex-shrink: 0`"
>
<slot name="first" />
</div>
<!-- Divider -->
<div
class="bg-gray-700 hover:bg-wraith-500 transition-colors cursor-col-resize shrink-0"
:class="direction === 'horizontal' ? 'w-1 cursor-col-resize' : 'h-1 cursor-row-resize'"
@mousedown="startDrag"
/>
<!-- Second pane -->
<div class="flex-1 overflow-hidden">
<slot name="second" />
</div>
<!-- Direction toggle button (slot for external control) -->
<slot name="controls">
<button
@click="toggleDirection"
class="absolute top-2 right-2 text-xs text-gray-600 hover:text-gray-400 z-10"
title="Toggle split direction"
>{{ direction === 'horizontal' ? '⇅' : '⇄' }}</button>
</slot>
</div>
</template>

View File

@ -1,46 +0,0 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { useTerminal } from '~/composables/useTerminal'
import { useSessionStore } from '~/stores/session.store'
const props = defineProps<{
sessionId: string // may be a pending-XXX id initially
hostId: number
hostName: string
color: string | null
}>()
const termContainer = ref<HTMLElement | null>(null)
const sessions = useSessionStore()
const { createTerminal, connectToHost, disconnect } = useTerminal()
let termInstance: ReturnType<typeof createTerminal> | null = null
// Track the real backend sessionId once connected (replaces pending-XXX)
let realSessionId: string | null = null
onMounted(() => {
if (!termContainer.value) return
termInstance = createTerminal(termContainer.value)
const { term, fitAddon } = termInstance
// Connect useTerminal will replace the pending session with the real backend sessionId
connectToHost(props.hostId, props.hostName, 'ssh', props.color, props.sessionId, term, fitAddon)
})
onBeforeUnmount(() => {
if (termInstance) {
termInstance.resizeObserver.disconnect()
termInstance.term.dispose()
}
if (realSessionId) {
disconnect(realSessionId)
}
})
</script>
<template>
<div class="w-full h-full bg-[#0a0a0f]">
<div ref="termContainer" class="w-full h-full" />
</div>
</template>

View File

@ -1,28 +0,0 @@
<script setup lang="ts">
import { useSessionStore } from '~/stores/session.store'
const sessions = useSessionStore()
</script>
<template>
<div class="flex h-8 bg-gray-950 border-b border-gray-800 overflow-x-auto shrink-0">
<!-- Home tab -->
<button
@click="sessions.goHome()"
class="flex items-center gap-1.5 px-3 h-full text-sm shrink-0 border-r border-gray-800 transition-colors"
:class="sessions.showHome ? 'bg-gray-900 text-white' : 'bg-gray-950 text-gray-500 hover:text-gray-300 hover:bg-gray-900'"
>
<svg class="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor"><path d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" /></svg>
<span>Home</span>
</button>
<!-- Session tabs -->
<SessionTab
v-for="session in sessions.sessions"
:key="session.key"
:session="session"
:is-active="session.id === sessions.activeSessionId && !sessions.showHome"
@activate="sessions.setActive(session.id)"
@close="sessions.removeSession(session.id)"
/>
</div>
</template>

View File

@ -1,163 +0,0 @@
<script setup lang="ts">
const props = defineProps<{
visible: boolean
credential: any | null
sshKeys: any[]
}>()
const emit = defineEmits<{
'update:visible': [boolean]
saved: []
}>()
const vault = useVault()
const form = ref({
name: '',
type: 'password' as 'password' | 'ssh_key',
username: '',
password: '',
sshKeyId: null as number | null,
domain: '',
})
const saving = ref(false)
const error = ref('')
const isEdit = computed(() => !!props.credential)
watch(() => props.visible, (val) => {
if (val) {
if (props.credential) {
form.value = {
name: props.credential.name || '',
type: props.credential.type || 'password',
username: props.credential.username || '',
password: '',
sshKeyId: props.credential.sshKeyId || null,
domain: props.credential.domain || '',
}
} else {
form.value = { name: '', type: 'password', username: '', password: '', sshKeyId: null, domain: '' }
}
error.value = ''
}
})
function close() {
emit('update:visible', false)
}
async function handleSubmit() {
if (!form.value.name.trim()) {
error.value = 'Name is required.'
return
}
saving.value = true
error.value = ''
try {
const payload: any = {
name: form.value.name.trim(),
type: form.value.type,
username: form.value.username.trim() || undefined,
domain: form.value.domain.trim() || undefined,
}
if (form.value.type === 'password' && form.value.password) {
payload.password = form.value.password
}
if (form.value.type === 'ssh_key' && form.value.sshKeyId) {
payload.sshKeyId = form.value.sshKeyId
}
if (isEdit.value) {
await vault.updateCredential(props.credential.id, payload)
} else {
await vault.createCredential(payload)
}
emit('saved')
close()
} catch (e: any) {
error.value = e?.data?.message || 'Save failed.'
} finally {
saving.value = false
}
}
</script>
<template>
<Teleport to="body">
<div v-if="visible" class="fixed inset-0 z-50 flex items-center justify-center">
<div class="absolute inset-0 bg-black/60" @click="close" />
<div class="relative bg-gray-800 rounded-lg border border-gray-700 w-full max-w-lg mx-4 p-6 shadow-xl">
<h3 class="text-lg font-semibold text-white mb-4">
{{ isEdit ? 'Edit Credential' : 'New Credential' }}
</h3>
<div v-if="error" class="mb-4 p-3 bg-red-900/50 border border-red-700 rounded text-red-300 text-sm">
{{ error }}
</div>
<div class="space-y-4">
<div>
<label class="block text-sm text-gray-400 mb-1">Name <span class="text-red-400">*</span></label>
<input v-model="form.name" type="text" placeholder="e.g. prod-server-admin"
class="w-full bg-gray-900 text-white px-3 py-2 rounded border border-gray-600 focus:border-wraith-500 focus:outline-none text-sm" />
</div>
<div>
<label class="block text-sm text-gray-400 mb-1">Type</label>
<select v-model="form.type"
class="w-full bg-gray-900 text-white px-3 py-2 rounded border border-gray-600 focus:border-wraith-500 focus:outline-none text-sm">
<option value="password">Password</option>
<option value="ssh_key">SSH Key</option>
</select>
</div>
<div>
<label class="block text-sm text-gray-400 mb-1">Username</label>
<input v-model="form.username" type="text" placeholder="e.g. admin"
class="w-full bg-gray-900 text-white px-3 py-2 rounded border border-gray-600 focus:border-wraith-500 focus:outline-none text-sm" />
</div>
<div v-if="form.type === 'password'">
<label class="block text-sm text-gray-400 mb-1">
Password <span v-if="isEdit" class="text-gray-600">(leave blank to keep current)</span>
</label>
<input v-model="form.password" type="password" placeholder="Enter password"
class="w-full bg-gray-900 text-white px-3 py-2 rounded border border-gray-600 focus:border-wraith-500 focus:outline-none text-sm" />
</div>
<div v-if="form.type === 'ssh_key'">
<label class="block text-sm text-gray-400 mb-1">SSH Key</label>
<select v-model="form.sshKeyId"
class="w-full bg-gray-900 text-white px-3 py-2 rounded border border-gray-600 focus:border-wraith-500 focus:outline-none text-sm">
<option :value="null"> Select a key </option>
<option v-for="key in sshKeys" :key="key.id" :value="key.id">
{{ key.name }} ({{ key.keyType || 'rsa' }})
</option>
</select>
<p v-if="sshKeys.length === 0" class="text-xs text-gray-500 mt-1">
No SSH keys in vault yet.
<NuxtLink to="/vault/keys" class="text-wraith-400 hover:underline">Import one first.</NuxtLink>
</p>
</div>
<div>
<label class="block text-sm text-gray-400 mb-1">Domain <span class="text-gray-600">(optional, for RDP)</span></label>
<input v-model="form.domain" type="text" placeholder="e.g. CORP"
class="w-full bg-gray-900 text-white px-3 py-2 rounded border border-gray-600 focus:border-wraith-500 focus:outline-none text-sm" />
</div>
</div>
<div class="flex justify-end gap-3 mt-6">
<button @click="close" class="px-4 py-2 text-sm text-gray-400 hover:text-white rounded border border-gray-600 hover:border-gray-400">
Cancel
</button>
<button @click="handleSubmit" :disabled="saving"
class="px-4 py-2 text-sm bg-wraith-600 hover:bg-wraith-700 text-white rounded disabled:opacity-50">
{{ saving ? 'Saving...' : (isEdit ? 'Update' : 'Create') }}
</button>
</div>
</div>
</div>
</Teleport>
</template>

View File

@ -1,125 +0,0 @@
<script setup lang="ts">
const props = defineProps<{
visible: boolean
}>()
const emit = defineEmits<{
'update:visible': [boolean]
imported: []
}>()
const vault = useVault()
const form = ref({
name: '',
privateKey: '',
publicKey: '',
passphrase: '',
})
const saving = ref(false)
const error = ref('')
function close() {
emit('update:visible', false)
form.value = { name: '', privateKey: '', publicKey: '', passphrase: '' }
error.value = ''
}
async function handleSubmit() {
if (!form.value.name.trim() || !form.value.privateKey.trim()) {
error.value = 'Name and private key are required.'
return
}
saving.value = true
error.value = ''
try {
await vault.importKey({
name: form.value.name.trim(),
privateKey: form.value.privateKey.trim(),
publicKey: form.value.publicKey.trim() || undefined,
passphrase: form.value.passphrase || undefined,
})
emit('imported')
close()
} catch (e: any) {
error.value = e?.data?.message || 'Import failed.'
} finally {
saving.value = false
}
}
function handleFileUpload(field: 'privateKey' | 'publicKey', event: Event) {
const file = (event.target as HTMLInputElement).files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = (e) => {
form.value[field] = e.target?.result as string
}
reader.readAsText(file)
}
</script>
<template>
<Teleport to="body">
<div v-if="visible" class="fixed inset-0 z-50 flex items-center justify-center">
<div class="absolute inset-0 bg-black/60" @click="close" />
<div class="relative bg-gray-800 rounded-lg border border-gray-700 w-full max-w-lg mx-4 p-6 shadow-xl">
<h3 class="text-lg font-semibold text-white mb-4">Import SSH Key</h3>
<div v-if="error" class="mb-4 p-3 bg-red-900/50 border border-red-700 rounded text-red-300 text-sm">
{{ error }}
</div>
<div class="space-y-4">
<div>
<label class="block text-sm text-gray-400 mb-1">Key Name <span class="text-red-400">*</span></label>
<input v-model="form.name" type="text" placeholder="e.g. my-server-key"
class="w-full bg-gray-900 text-white px-3 py-2 rounded border border-gray-600 focus:border-wraith-500 focus:outline-none text-sm" />
</div>
<div>
<label class="block text-sm text-gray-400 mb-1">Private Key <span class="text-red-400">*</span></label>
<textarea v-model="form.privateKey" rows="6" placeholder="-----BEGIN RSA PRIVATE KEY-----..."
class="w-full bg-gray-900 text-white px-3 py-2 rounded border border-gray-600 focus:border-wraith-500 focus:outline-none text-sm font-mono resize-none" />
<div class="mt-1 flex items-center gap-2">
<label class="text-xs text-gray-500 cursor-pointer hover:text-gray-300">
<input type="file" class="hidden" accept=".pem,.key,id_rsa,id_ed25519,id_ecdsa"
@change="handleFileUpload('privateKey', $event)" />
Upload from file
</label>
</div>
</div>
<div>
<label class="block text-sm text-gray-400 mb-1">Public Key <span class="text-gray-600">(optional)</span></label>
<textarea v-model="form.publicKey" rows="2" placeholder="ssh-rsa AAAA..."
class="w-full bg-gray-900 text-white px-3 py-2 rounded border border-gray-600 focus:border-wraith-500 focus:outline-none text-sm font-mono resize-none" />
<div class="mt-1">
<label class="text-xs text-gray-500 cursor-pointer hover:text-gray-300">
<input type="file" class="hidden" accept=".pub"
@change="handleFileUpload('publicKey', $event)" />
Upload from file
</label>
</div>
</div>
<div>
<label class="block text-sm text-gray-400 mb-1">Passphrase <span class="text-gray-600">(optional)</span></label>
<input v-model="form.passphrase" type="password" placeholder="Leave blank if unencrypted"
class="w-full bg-gray-900 text-white px-3 py-2 rounded border border-gray-600 focus:border-wraith-500 focus:outline-none text-sm" />
</div>
</div>
<div class="flex justify-end gap-3 mt-6">
<button @click="close" class="px-4 py-2 text-sm text-gray-400 hover:text-white rounded border border-gray-600 hover:border-gray-400">
Cancel
</button>
<button @click="handleSubmit" :disabled="saving"
class="px-4 py-2 text-sm bg-wraith-600 hover:bg-wraith-700 text-white rounded disabled:opacity-50">
{{ saving ? 'Importing...' : 'Import Key' }}
</button>
</div>
</div>
</div>
</Teleport>
</template>

View File

@ -1,285 +0,0 @@
import Guacamole from 'guacamole-common-js'
import { useAuthStore } from '~/stores/auth.store'
import { useSessionStore } from '~/stores/session.store'
/**
* Creates a Guacamole-compatible tunnel that speaks JSON over WebSocket.
* Does NOT extend Guacamole.Tunnel (its constructor assigns no-op instance
* properties that shadow subclass methods). Instead, creates a base tunnel
* and overwrites the methods directly.
*/
function createJsonWsTunnel(wsUrl: string, connectMsg: object) {
const tunnel = new Guacamole.Tunnel()
let ws: WebSocket | null = null
// Custom callbacks for our JSON protocol layer
let onConnected: ((hostId: number, hostName: string) => void) | null = null
let onDisconnected: ((reason: string) => void) | null = null
let onGatewayError: ((message: string) => void) | null = null
function dispatchInstructions(raw: string) {
if (!tunnel.oninstruction) return
let remaining = raw
while (remaining.length > 0) {
const semicolonIdx = remaining.indexOf(';')
if (semicolonIdx === -1) break
const instruction = remaining.substring(0, semicolonIdx)
remaining = remaining.substring(semicolonIdx + 1)
if (!instruction) continue
const parts: string[] = []
let pos = 0
while (pos < instruction.length) {
const dotIdx = instruction.indexOf('.', pos)
if (dotIdx === -1) break
const len = parseInt(instruction.substring(pos, dotIdx), 10)
if (isNaN(len)) break
parts.push(instruction.substring(dotIdx + 1, dotIdx + 1 + len))
pos = dotIdx + 1 + len + 1
}
if (parts.length > 0) {
tunnel.oninstruction(parts[0], parts.slice(1))
}
}
}
// Override the no-op instance methods
tunnel.connect = (_data?: string) => {
console.log('[RDP] Tunnel opening WebSocket:', wsUrl)
ws = new WebSocket(wsUrl)
ws.onopen = () => {
console.log('[RDP] WebSocket open, sending connect handshake')
ws!.send(JSON.stringify(connectMsg))
}
ws.onmessage = (event: MessageEvent) => {
const msg = JSON.parse(event.data as string)
switch (msg.type) {
case 'connected':
onConnected?.(msg.hostId, msg.hostName)
break
case 'guac':
dispatchInstructions(msg.instruction)
break
case 'error':
onGatewayError?.(msg.message)
tunnel.onerror?.(new Guacamole.Status(Guacamole.Status.Code.SERVER_ERROR, msg.message))
break
case 'disconnected':
onDisconnected?.(msg.reason)
break
}
}
ws.onclose = (event: CloseEvent) => {
console.log('[RDP] WebSocket closed, code:', event.code)
if (event.code === 4001) {
tunnel.onerror?.(new Guacamole.Status(Guacamole.Status.Code.CLIENT_FORBIDDEN, 'Unauthorized'))
}
onDisconnected?.('WebSocket closed')
}
ws.onerror = () => {
console.error('[RDP] WebSocket error')
tunnel.onerror?.(new Guacamole.Status(Guacamole.Status.Code.SERVER_ERROR, 'WebSocket error'))
}
}
tunnel.disconnect = () => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.close()
}
ws = null
}
tunnel.sendMessage = (...elements: any[]) => {
if (ws?.readyState === WebSocket.OPEN) {
const instruction = elements.map((e: any) => {
const s = String(e)
return `${s.length}.${s}`
}).join(',') + ';'
ws.send(JSON.stringify({ type: 'guac', instruction }))
}
}
return {
tunnel,
get onConnected() { return onConnected },
set onConnected(fn) { onConnected = fn },
get onDisconnected() { return onDisconnected },
set onDisconnected(fn) { onDisconnected = fn },
get onGatewayError() { return onGatewayError },
set onGatewayError(fn) { onGatewayError = fn },
}
}
// ─── Composable ────────────────────────────────────────────────────────────────
export function useRdp() {
const auth = useAuthStore()
const sessions = useSessionStore()
async function connectRdp(
container: HTMLElement,
hostId: number,
hostName: string,
color: string | null,
pendingSessionId: string,
options?: { width?: number; height?: number },
) {
// C-3: Use short-lived WS ticket instead of JWT in URL
const ticket = await auth.getWsTicket()
const proto = location.protocol === 'https:' ? 'wss' : 'ws'
const wsUrl = `${proto}://${location.host}/api/ws/rdp?ticket=${ticket}`
const width = options?.width || container.clientWidth || 1920
const height = options?.height || container.clientHeight || 1080
console.log(`[RDP] Connecting to ${wsUrl} for hostId=${hostId} (${width}x${height})`)
const wrapper = createJsonWsTunnel(wsUrl, {
type: 'connect',
hostId,
width,
height,
})
const client = new Guacamole.Client(wrapper.tunnel)
const sessionId = pendingSessionId
wrapper.onConnected = (_resolvedHostId: number, resolvedHostName: string) => {
console.log(`[RDP] Connected to ${resolvedHostName}`)
sessions.replaceSession(sessionId, {
key: sessionId,
id: sessionId,
hostId,
hostName: resolvedHostName || hostName,
protocol: 'rdp',
color,
active: true,
})
}
wrapper.onDisconnected = (reason: string) => {
console.log(`[RDP] Disconnected: ${reason}`)
sessions.removeSession(sessionId)
client.disconnect()
}
wrapper.onGatewayError = (message: string) => {
console.error('[RDP] Gateway error:', message)
}
// Handle Guacamole-level errors (NLA failure, auth failure, etc.)
client.onerror = (status: Guacamole.Status) => {
const code = status.code
const msg = status.message || 'Unknown error'
console.error(`[RDP] Guacamole error: code=${code} message=${msg}`)
// Surface error via gateway error callback so UI can display it
wrapper.onGatewayError?.(`RDP connection failed: ${msg}`)
}
// Track connection state transitions for diagnostics
client.onstatechange = (state: number) => {
const states: Record<number, string> = {
0: 'IDLE',
1: 'CONNECTING',
2: 'WAITING',
3: 'CONNECTED',
4: 'DISCONNECTING',
5: 'DISCONNECTED',
}
console.log(`[RDP] State: ${states[state] || state}`)
}
// Attach Guacamole display element to container
const display = client.getDisplay()
const displayEl = display.getElement()
container.appendChild(displayEl)
// Auto-scale the Guacamole display to fit the container
function fitDisplay() {
const dw = display.getWidth()
const dh = display.getHeight()
if (!dw || !dh) return
const scale = Math.min(container.clientWidth / dw, container.clientHeight / dh)
display.scale(scale)
}
// Re-fit when guacd sends a sync (display dimensions may have changed)
const origOnSync = display.onresize
display.onresize = (w: number, h: number) => {
origOnSync?.call(display, w, h)
fitDisplay()
}
// Re-fit when the browser container resizes
const resizeObserver = new ResizeObserver(() => fitDisplay())
resizeObserver.observe(container)
// Mouse input — bind to the display element
const mouse = new Guacamole.Mouse(displayEl)
mouse.onEach(
['mousedown', 'mousemove', 'mouseup'],
(e: Guacamole.Mouse.Event) => {
// Scale mouse coordinates back to remote display space
const scale = display.getScale()
const scaledState = new Guacamole.Mouse.State(
e.state.x / scale,
e.state.y / scale,
e.state.left,
e.state.middle,
e.state.right,
e.state.up,
e.state.down,
)
client.sendMouseState(scaledState)
},
)
// Keyboard input — attached to document for global capture, but yield to
// form elements (inputs, textareas, selects, contenteditable) so modals,
// toolbars, and other UI overlays can receive keystrokes normally.
const keyboard = new Guacamole.Keyboard(document)
function isTypingInFormElement(): boolean {
const el = document.activeElement as HTMLElement | null
if (!el) return false
const tag = el.tagName
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el.isContentEditable
}
keyboard.onkeydown = (keysym: number) => {
if (isTypingInFormElement()) return true // let browser handle it
client.sendKeyEvent(1, keysym)
return false
}
keyboard.onkeyup = (keysym: number) => {
if (isTypingInFormElement()) return true
client.sendKeyEvent(0, keysym)
return false
}
// Initiate the WebSocket connection
client.connect()
function disconnect() {
resizeObserver.disconnect()
keyboard.onkeydown = null
keyboard.onkeyup = null
client.disconnect()
sessions.removeSession(sessionId)
}
function sendClipboardText(text: string) {
const stream = client.createClipboardStream('text/plain')
const writer = new Guacamole.StringWriter(stream)
writer.sendText(text)
writer.sendEnd()
}
return { client, sessionId, disconnect, sendClipboardText }
}
return { connectRdp }
}

View File

@ -1,142 +0,0 @@
import { ref, watch, type Ref } from 'vue'
import { useAuthStore } from '~/stores/auth.store'
export function useSftp(sessionId: Ref<string | null>) {
const auth = useAuthStore()
let ws: WebSocket | null = null
const entries = ref<any[]>([])
const currentPath = ref('/')
const fileContent = ref<{ path: string; content: string } | null>(null)
const transfers = ref<any[]>([])
const activeDownloads = new Map<string, { path: string; chunks: string[]; total: number }>()
let pendingList: string | null = null
async function connect() {
// C-3: Use short-lived WS ticket instead of JWT in URL
const ticket = await auth.getWsTicket()
const wsUrl = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/api/ws/sftp?ticket=${ticket}`
ws = new WebSocket(wsUrl)
ws.onopen = () => {
console.log('[SFTP] WS open, sessionId=', sessionId.value, 'pendingList=', pendingList)
if (pendingList !== null) {
// Send directly — don't rely on send() guard since we know WS is open
const path = pendingList
pendingList = null
if (sessionId.value) {
ws!.send(JSON.stringify({ type: 'list', path, sessionId: sessionId.value }))
} else {
console.warn('[SFTP] No sessionId available yet, cannot list')
pendingList = path // put it back
}
}
}
ws.onmessage = (event) => {
console.log('[SFTP] WS message received, raw length:', event.data?.length, 'type:', typeof event.data)
const msg = JSON.parse(event.data)
console.log('[SFTP] Parsed message type:', msg.type, 'entries:', msg.entries?.length)
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
console.log('[SFTP] entries.value set, count:', entries.value.length)
break
case 'fileContent':
fileContent.value = { path: msg.path, content: msg.content }
break
case 'saved':
fileContent.value = null
list(currentPath.value)
break
case 'uploaded':
list(currentPath.value)
break
case 'downloadStart':
activeDownloads.set(msg.transferId, { path: msg.path, chunks: [], total: msg.total })
break
case 'downloadChunk':
const dl = activeDownloads.get(msg.transferId)
if (dl) dl.chunks.push(msg.data)
break
case 'downloadComplete': {
const transfer = activeDownloads.get(msg.transferId)
if (transfer) {
const binary = atob(transfer.chunks.join(''))
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
const blob = new Blob([bytes])
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = transfer.path.split('/').pop() || 'download'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
activeDownloads.delete(msg.transferId)
}
break
}
case 'error':
console.error('SFTP error:', msg.message)
break
}
}
ws.onerror = (event) => {
console.error('[SFTP] WS error event:', event)
}
ws.onclose = (event) => {
console.log('[SFTP] WS closed, code:', event.code, 'reason:', event.reason, 'wasClean:', event.wasClean)
}
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) {
if (ws && ws.readyState === WebSocket.OPEN) {
send({ type: 'list', path })
} else {
pendingList = 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 upload(path: string, dataBase64: string) { send({ type: 'upload', path, data: dataBase64 }) }
// If sessionId arrives after WS is already open, send any pending list
watch(sessionId, (newId) => {
if (newId && pendingList !== null && ws?.readyState === WebSocket.OPEN) {
const path = pendingList
pendingList = null
ws!.send(JSON.stringify({ type: 'list', path, sessionId: newId }))
}
})
function disconnect() {
ws?.close()
ws = null
}
return {
entries, currentPath, fileContent, transfers,
connect, disconnect, list, readFile, writeFile, mkdir, rename, remove, chmod, download, upload,
}
}

Some files were not shown because too many files have changed in this diff Show More