Merge branch 'worktree-agent-a866869e' into feat/phase1-foundation
# Conflicts: # go.mod # go.sum
This commit is contained in:
commit
714b92292d
1
.claude/worktrees/agent-a24062da
Submodule
1
.claude/worktrees/agent-a24062da
Submodule
@ -0,0 +1 @@
|
||||
Subproject commit 4c32694a52625252175a95aca70aa703f8e6d09e
|
||||
1
.claude/worktrees/agent-a32f4756
Submodule
1
.claude/worktrees/agent-a32f4756
Submodule
@ -0,0 +1 @@
|
||||
Subproject commit ab5a5c7ae2dd869c14189edff3976051981d3739
|
||||
1
.claude/worktrees/agent-a36e902e
Submodule
1
.claude/worktrees/agent-a36e902e
Submodule
@ -0,0 +1 @@
|
||||
Subproject commit e8ed0139b31cea4237b72c354cd11d835a06e505
|
||||
1
.claude/worktrees/agent-a866869e
Submodule
1
.claude/worktrees/agent-a866869e
Submodule
@ -0,0 +1 @@
|
||||
Subproject commit 5179f5ab7650c2a919c41e3d08310a6ba463ed70
|
||||
1
.claude/worktrees/agent-a9763668
Submodule
1
.claude/worktrees/agent-a9763668
Submodule
@ -0,0 +1 @@
|
||||
Subproject commit 4de47352cdf7bb5dbd8fce43d25ce8823f1b7f88
|
||||
1
.claude/worktrees/agent-adaf01c0
Submodule
1
.claude/worktrees/agent-adaf01c0
Submodule
@ -0,0 +1 @@
|
||||
Subproject commit 41613586c503b24033e25a8a5f1943ee3527b184
|
||||
@ -1,3 +0,0 @@
|
||||
DB_PASSWORD=changeme
|
||||
JWT_SECRET=generate-a-64-char-hex-string
|
||||
ENCRYPTION_KEY=generate-a-64-char-hex-string
|
||||
30
Dockerfile
30
Dockerfile
@ -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"]
|
||||
29
README.md
29
README.md
@ -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
|
||||
```
|
||||
284
Remote-Spec.md
284
Remote-Spec.md
@ -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.*
|
||||
@ -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
9614
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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"
|
||||
@ -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
|
||||
}
|
||||
@ -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());
|
||||
@ -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());
|
||||
@ -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;
|
||||
}
|
||||
@ -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 {}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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 {}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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' };
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -1,5 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
||||
@ -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 };
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 {}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -1,4 +0,0 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateGroupDto } from './create-group.dto';
|
||||
|
||||
export class UpdateGroupDto extends PartialType(CreateGroupDto) {}
|
||||
@ -1,4 +0,0 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateHostDto } from './create-host.dto';
|
||||
|
||||
export class UpdateHostDto extends PartialType(CreateHostDto) {}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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 } });
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
@ -1,9 +0,0 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 {}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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 {}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 {}
|
||||
@ -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 };
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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 };
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -1,4 +0,0 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateCredentialDto } from './create-credential.dto';
|
||||
|
||||
export class UpdateCredentialDto extends PartialType(CreateCredentialDto) {}
|
||||
@ -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)
|
||||
}
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 {}
|
||||
@ -1,4 +0,0 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*.spec.ts"]
|
||||
}
|
||||
@ -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/*"] }
|
||||
}
|
||||
}
|
||||
@ -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:
|
||||
1371
docs/plans/2026-03-14-test-suite-buildout.md
Normal file
1371
docs/plans/2026-03-14-test-suite-buildout.md
Normal file
File diff suppressed because it is too large
Load Diff
BIN
docs/screenshots/wraith-final.png
Normal file
BIN
docs/screenshots/wraith-final.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 80 KiB |
108
docs/test-buildout-spec.md
Normal file
108
docs/test-buildout-spec.md
Normal 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)
|
||||
@ -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>
|
||||
@ -1,7 +0,0 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
html, body, #__nuxt {
|
||||
@apply h-full bg-gray-900 text-gray-100;
|
||||
}
|
||||
@ -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">×</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>
|
||||
@ -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>
|
||||
@ -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">×</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">×</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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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"
|
||||
>×</button>
|
||||
</button>
|
||||
</template>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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 }
|
||||
}
|
||||
@ -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
Loading…
Reference in New Issue
Block a user