3932 lines
114 KiB
Markdown
3932 lines
114 KiB
Markdown
# Wraith Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Build Wraith — a self-hosted MobaXterm replacement providing SSH+SFTP+RDP in a browser, deployed as a Docker stack.
|
|
|
|
**Architecture:** Monorepo with `backend/` (NestJS 10, Prisma, ssh2, guacamole tunnel) and `frontend/` (Nuxt 3 SPA, xterm.js, Monaco, guacamole-common-js, PrimeVue 4). Single Docker container serves both API and static frontend. WebSocket gateways handle real-time terminal/SFTP/RDP data channels.
|
|
|
|
**Tech Stack:** NestJS 10, Prisma 6, PostgreSQL 16, ssh2, guacd, Nuxt 3 (SPA mode), xterm.js 5, Monaco Editor, guacamole-common-js, PrimeVue 4, Tailwind CSS, Pinia
|
|
|
|
**Spec:** `docs/superpowers/specs/2026-03-12-vigilance-remote-lean-design.md`
|
|
|
|
---
|
|
|
|
## File Structure
|
|
|
|
```
|
|
wraith/
|
|
├── docker-compose.yml
|
|
├── Dockerfile
|
|
├── .env.example
|
|
├── .gitignore
|
|
├── README.md
|
|
├── backend/
|
|
│ ├── package.json
|
|
│ ├── tsconfig.json
|
|
│ ├── tsconfig.build.json
|
|
│ ├── nest-cli.json
|
|
│ ├── prisma/
|
|
│ │ ├── schema.prisma
|
|
│ │ └── seed.ts
|
|
│ ├── src/
|
|
│ │ ├── main.ts
|
|
│ │ ├── app.module.ts
|
|
│ │ ├── prisma/
|
|
│ │ │ ├── prisma.service.ts
|
|
│ │ │ └── prisma.module.ts
|
|
│ │ ├── auth/
|
|
│ │ │ ├── auth.module.ts
|
|
│ │ │ ├── auth.service.ts
|
|
│ │ │ ├── auth.controller.ts
|
|
│ │ │ ├── jwt.strategy.ts
|
|
│ │ │ ├── jwt-auth.guard.ts
|
|
│ │ │ ├── ws-auth.guard.ts
|
|
│ │ │ └── dto/
|
|
│ │ │ └── login.dto.ts
|
|
│ │ ├── vault/
|
|
│ │ │ ├── vault.module.ts
|
|
│ │ │ ├── encryption.service.ts
|
|
│ │ │ ├── credentials.service.ts
|
|
│ │ │ ├── credentials.controller.ts
|
|
│ │ │ ├── ssh-keys.service.ts
|
|
│ │ │ ├── ssh-keys.controller.ts
|
|
│ │ │ └── dto/
|
|
│ │ │ ├── create-credential.dto.ts
|
|
│ │ │ ├── update-credential.dto.ts
|
|
│ │ │ ├── create-ssh-key.dto.ts
|
|
│ │ │ └── update-ssh-key.dto.ts
|
|
│ │ ├── connections/
|
|
│ │ │ ├── connections.module.ts
|
|
│ │ │ ├── hosts.service.ts
|
|
│ │ │ ├── hosts.controller.ts
|
|
│ │ │ ├── groups.service.ts
|
|
│ │ │ ├── groups.controller.ts
|
|
│ │ │ └── dto/
|
|
│ │ │ ├── create-host.dto.ts
|
|
│ │ │ ├── update-host.dto.ts
|
|
│ │ │ ├── create-group.dto.ts
|
|
│ │ │ └── update-group.dto.ts
|
|
│ │ ├── terminal/
|
|
│ │ │ ├── terminal.module.ts
|
|
│ │ │ ├── terminal.gateway.ts
|
|
│ │ │ ├── sftp.gateway.ts
|
|
│ │ │ └── ssh-connection.service.ts
|
|
│ │ ├── rdp/
|
|
│ │ │ ├── rdp.module.ts
|
|
│ │ │ ├── rdp.gateway.ts
|
|
│ │ │ └── guacamole.service.ts
|
|
│ │ └── settings/
|
|
│ │ ├── settings.module.ts
|
|
│ │ ├── settings.service.ts
|
|
│ │ └── settings.controller.ts
|
|
│ └── test/
|
|
│ ├── encryption.service.spec.ts
|
|
│ └── auth.service.spec.ts
|
|
├── frontend/
|
|
│ ├── package.json
|
|
│ ├── nuxt.config.ts
|
|
│ ├── tailwind.config.ts
|
|
│ ├── app.vue
|
|
│ ├── assets/
|
|
│ │ └── css/
|
|
│ │ └── main.css
|
|
│ ├── layouts/
|
|
│ │ ├── default.vue
|
|
│ │ └── auth.vue
|
|
│ ├── pages/
|
|
│ │ ├── index.vue
|
|
│ │ ├── login.vue
|
|
│ │ ├── vault/
|
|
│ │ │ ├── index.vue
|
|
│ │ │ ├── keys.vue
|
|
│ │ │ └── credentials.vue
|
|
│ │ └── settings.vue
|
|
│ ├── components/
|
|
│ │ ├── connections/
|
|
│ │ │ ├── HostTree.vue
|
|
│ │ │ ├── HostCard.vue
|
|
│ │ │ ├── HostEditDialog.vue
|
|
│ │ │ ├── GroupEditDialog.vue
|
|
│ │ │ └── QuickConnect.vue
|
|
│ │ ├── session/
|
|
│ │ │ ├── SessionContainer.vue
|
|
│ │ │ └── SessionTab.vue
|
|
│ │ ├── terminal/
|
|
│ │ │ ├── TerminalInstance.vue
|
|
│ │ │ ├── TerminalTabs.vue
|
|
│ │ │ └── SplitPane.vue
|
|
│ │ ├── sftp/
|
|
│ │ │ ├── SftpSidebar.vue
|
|
│ │ │ ├── FileTree.vue
|
|
│ │ │ ├── FileEditor.vue
|
|
│ │ │ └── TransferStatus.vue
|
|
│ │ ├── rdp/
|
|
│ │ │ ├── RdpCanvas.vue
|
|
│ │ │ └── RdpToolbar.vue
|
|
│ │ └── vault/
|
|
│ │ ├── KeyImportDialog.vue
|
|
│ │ └── CredentialForm.vue
|
|
│ ├── composables/
|
|
│ │ ├── useTerminal.ts
|
|
│ │ ├── useSftp.ts
|
|
│ │ ├── useRdp.ts
|
|
│ │ ├── useVault.ts
|
|
│ │ └── useConnections.ts
|
|
│ └── stores/
|
|
│ ├── auth.store.ts
|
|
│ ├── session.store.ts
|
|
│ └── connection.store.ts
|
|
└── docs/
|
|
└── superpowers/
|
|
├── specs/
|
|
│ └── 2026-03-12-vigilance-remote-lean-design.md
|
|
└── plans/
|
|
└── 2026-03-12-wraith-build.md
|
|
```
|
|
|
|
---
|
|
|
|
## Chunk 1: Foundation (Phase 1)
|
|
|
|
### Task 1: Project Scaffold + Docker
|
|
|
|
**Files:**
|
|
- Create: `.gitignore`, `.env.example`, `docker-compose.yml`, `Dockerfile`, `README.md`
|
|
- Create: `backend/package.json`, `backend/tsconfig.json`, `backend/tsconfig.build.json`, `backend/nest-cli.json`
|
|
- Create: `frontend/package.json`, `frontend/nuxt.config.ts`, `frontend/tailwind.config.ts`
|
|
|
|
- [ ] **Step 1: Initialize git repo**
|
|
|
|
```bash
|
|
cd /Users/vstockwell/repos/RDP-SSH-Client
|
|
git init
|
|
```
|
|
|
|
- [ ] **Step 2: Create `.gitignore`**
|
|
|
|
```gitignore
|
|
node_modules/
|
|
dist/
|
|
.output/
|
|
.nuxt/
|
|
.env
|
|
*.log
|
|
.DS_Store
|
|
backend/prisma/*.db
|
|
```
|
|
|
|
- [ ] **Step 3: Create `.env.example`**
|
|
|
|
```env
|
|
DB_PASSWORD=changeme
|
|
JWT_SECRET=generate-a-64-char-hex-string
|
|
ENCRYPTION_KEY=generate-a-64-char-hex-string
|
|
```
|
|
|
|
- [ ] **Step 4: Create `docker-compose.yml`**
|
|
|
|
```yaml
|
|
services:
|
|
app:
|
|
build: .
|
|
ports: ["3000:3000"]
|
|
environment:
|
|
DATABASE_URL: postgresql://wraith:${DB_PASSWORD}@postgres:5432/wraith
|
|
JWT_SECRET: ${JWT_SECRET}
|
|
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
|
|
GUACD_HOST: guacd
|
|
GUACD_PORT: "4822"
|
|
depends_on:
|
|
postgres:
|
|
condition: service_healthy
|
|
guacd:
|
|
condition: service_started
|
|
restart: unless-stopped
|
|
|
|
guacd:
|
|
image: guacamole/guacd
|
|
restart: always
|
|
|
|
postgres:
|
|
image: postgres:16-alpine
|
|
volumes: [pgdata:/var/lib/postgresql/data]
|
|
environment:
|
|
POSTGRES_DB: wraith
|
|
POSTGRES_USER: wraith
|
|
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "pg_isready -U wraith"]
|
|
interval: 5s
|
|
timeout: 3s
|
|
retries: 5
|
|
|
|
volumes:
|
|
pgdata:
|
|
```
|
|
|
|
- [ ] **Step 5: Create `Dockerfile`**
|
|
|
|
Multi-stage build: frontend → static, backend → NestJS, production → serve both.
|
|
|
|
```dockerfile
|
|
# Stage 1: Frontend build
|
|
FROM node:20-alpine AS frontend
|
|
WORKDIR /app/frontend
|
|
COPY frontend/package*.json ./
|
|
RUN npm ci
|
|
COPY frontend/ ./
|
|
RUN npx nuxi generate
|
|
|
|
# Stage 2: Backend build
|
|
FROM node:20-alpine AS backend
|
|
WORKDIR /app/backend
|
|
COPY backend/package*.json ./
|
|
RUN npm ci
|
|
COPY backend/ ./
|
|
RUN npx prisma generate
|
|
RUN npm run build
|
|
|
|
# Stage 3: Production
|
|
FROM node:20-alpine
|
|
WORKDIR /app
|
|
COPY --from=backend /app/backend/dist ./dist
|
|
COPY --from=backend /app/backend/node_modules ./node_modules
|
|
COPY --from=backend /app/backend/package.json ./
|
|
COPY --from=backend /app/backend/prisma ./prisma
|
|
COPY --from=frontend /app/frontend/.output/public ./public
|
|
EXPOSE 3000
|
|
CMD ["node", "dist/main.js"]
|
|
```
|
|
|
|
- [ ] **Step 6: Create `backend/package.json`**
|
|
|
|
```json
|
|
{
|
|
"name": "wraith-backend",
|
|
"version": "0.1.0",
|
|
"private": true,
|
|
"scripts": {
|
|
"build": "nest build",
|
|
"start": "node dist/main.js",
|
|
"dev": "nest start --watch",
|
|
"test": "jest",
|
|
"test:watch": "jest --watch",
|
|
"prisma:migrate": "prisma migrate dev",
|
|
"prisma:generate": "prisma generate",
|
|
"prisma:seed": "ts-node prisma/seed.ts"
|
|
},
|
|
"dependencies": {
|
|
"@nestjs/common": "^10.0.0",
|
|
"@nestjs/core": "^10.0.0",
|
|
"@nestjs/jwt": "^10.0.0",
|
|
"@nestjs/passport": "^10.0.0",
|
|
"@nestjs/platform-express": "^10.0.0",
|
|
"@nestjs/platform-ws": "^10.0.0",
|
|
"@nestjs/serve-static": "^4.0.0",
|
|
"@nestjs/websockets": "^10.0.0",
|
|
"@prisma/client": "^6.0.0",
|
|
"bcrypt": "^5.1.0",
|
|
"class-transformer": "^0.5.1",
|
|
"class-validator": "^0.14.0",
|
|
"passport": "^0.7.0",
|
|
"passport-jwt": "^4.0.1",
|
|
"reflect-metadata": "^0.2.0",
|
|
"rxjs": "^7.8.0",
|
|
"ssh2": "^1.15.0",
|
|
"ws": "^8.16.0"
|
|
},
|
|
"devDependencies": {
|
|
"@nestjs/cli": "^10.0.0",
|
|
"@nestjs/testing": "^10.0.0",
|
|
"@types/bcrypt": "^5.0.0",
|
|
"@types/node": "^20.0.0",
|
|
"@types/passport-jwt": "^4.0.0",
|
|
"@types/ssh2": "^1.15.0",
|
|
"@types/ws": "^8.5.0",
|
|
"jest": "^29.0.0",
|
|
"prisma": "^6.0.0",
|
|
"ts-jest": "^29.0.0",
|
|
"ts-node": "^10.9.0",
|
|
"typescript": "^5.3.0"
|
|
},
|
|
"jest": {
|
|
"moduleFileExtensions": ["js", "json", "ts"],
|
|
"rootDir": ".",
|
|
"testRegex": ".*\\.spec\\.ts$",
|
|
"transform": { "^.+\\.ts$": "ts-jest" },
|
|
"testEnvironment": "node",
|
|
"moduleNameMapper": {
|
|
"^@/(.*)$": "<rootDir>/src/$1"
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 7: Create `backend/tsconfig.json` and `backend/tsconfig.build.json`**
|
|
|
|
`tsconfig.json`:
|
|
```json
|
|
{
|
|
"compilerOptions": {
|
|
"module": "commonjs",
|
|
"declaration": true,
|
|
"removeComments": true,
|
|
"emitDecoratorMetadata": true,
|
|
"experimentalDecorators": true,
|
|
"allowSyntheticDefaultImports": true,
|
|
"target": "ES2021",
|
|
"sourceMap": true,
|
|
"outDir": "./dist",
|
|
"baseUrl": "./",
|
|
"incremental": true,
|
|
"skipLibCheck": true,
|
|
"strictNullChecks": true,
|
|
"noImplicitAny": true,
|
|
"strictBindCallApply": true,
|
|
"forceConsistentCasingInFileNames": true,
|
|
"noFallthroughCasesInSwitch": true,
|
|
"paths": { "@/*": ["src/*"] }
|
|
}
|
|
}
|
|
```
|
|
|
|
`tsconfig.build.json`:
|
|
```json
|
|
{
|
|
"extends": "./tsconfig.json",
|
|
"exclude": ["node_modules", "test", "dist", "**/*.spec.ts"]
|
|
}
|
|
```
|
|
|
|
`nest-cli.json`:
|
|
```json
|
|
{
|
|
"$schema": "https://json.schemastore.org/nest-cli",
|
|
"collection": "@nestjs/schematics",
|
|
"sourceRoot": "src",
|
|
"compilerOptions": {
|
|
"deleteOutDir": true
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 8: Create `frontend/package.json`**
|
|
|
|
```json
|
|
{
|
|
"name": "wraith-frontend",
|
|
"version": "0.1.0",
|
|
"private": true,
|
|
"scripts": {
|
|
"dev": "nuxi dev",
|
|
"build": "nuxi generate",
|
|
"preview": "nuxi preview"
|
|
},
|
|
"dependencies": {
|
|
"@pinia/nuxt": "^0.5.0",
|
|
"@primevue/themes": "^4.0.0",
|
|
"guacamole-common-js": "^1.5.0",
|
|
"lucide-vue-next": "^0.300.0",
|
|
"monaco-editor": "^0.45.0",
|
|
"pinia": "^2.1.0",
|
|
"primevue": "^4.0.0",
|
|
"@xterm/xterm": "^5.4.0",
|
|
"@xterm/addon-fit": "^0.10.0",
|
|
"@xterm/addon-search": "^0.15.0",
|
|
"@xterm/addon-web-links": "^0.11.0",
|
|
"@xterm/addon-webgl": "^0.18.0"
|
|
},
|
|
"devDependencies": {
|
|
"@nuxtjs/tailwindcss": "^6.0.0",
|
|
"@primevue/nuxt-module": "^4.0.0",
|
|
"nuxt": "^3.10.0",
|
|
"typescript": "^5.3.0"
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 9: Create `frontend/nuxt.config.ts`**
|
|
|
|
```typescript
|
|
export default defineNuxtConfig({
|
|
ssr: false,
|
|
devtools: { enabled: false },
|
|
modules: [
|
|
'@pinia/nuxt',
|
|
'@nuxtjs/tailwindcss',
|
|
'@primevue/nuxt-module',
|
|
],
|
|
css: ['~/assets/css/main.css'],
|
|
primevue: {
|
|
options: {
|
|
theme: 'none',
|
|
},
|
|
},
|
|
runtimeConfig: {
|
|
public: {
|
|
apiBase: process.env.API_BASE || 'http://localhost:3000',
|
|
},
|
|
},
|
|
devServer: {
|
|
port: 3001,
|
|
},
|
|
nitro: {
|
|
devProxy: {
|
|
'/api': { target: 'http://localhost:3000/api', ws: true },
|
|
'/ws': { target: 'ws://localhost:3000/ws', ws: true },
|
|
},
|
|
},
|
|
})
|
|
```
|
|
|
|
- [ ] **Step 10: Create `frontend/tailwind.config.ts`**
|
|
|
|
```typescript
|
|
import type { Config } from 'tailwindcss'
|
|
|
|
export default {
|
|
content: [
|
|
'./components/**/*.vue',
|
|
'./layouts/**/*.vue',
|
|
'./pages/**/*.vue',
|
|
'./composables/**/*.ts',
|
|
'./app.vue',
|
|
],
|
|
darkMode: 'class',
|
|
theme: {
|
|
extend: {
|
|
colors: {
|
|
wraith: {
|
|
50: '#f0f4ff',
|
|
100: '#dbe4ff',
|
|
200: '#bac8ff',
|
|
300: '#91a7ff',
|
|
400: '#748ffc',
|
|
500: '#5c7cfa',
|
|
600: '#4c6ef5',
|
|
700: '#4263eb',
|
|
800: '#3b5bdb',
|
|
900: '#364fc7',
|
|
950: '#1e3a8a',
|
|
},
|
|
},
|
|
},
|
|
},
|
|
plugins: [],
|
|
} satisfies Config
|
|
```
|
|
|
|
- [ ] **Step 11: Create frontend shell files**
|
|
|
|
`frontend/app.vue`:
|
|
```vue
|
|
<template>
|
|
<NuxtLayout>
|
|
<NuxtPage />
|
|
</NuxtLayout>
|
|
</template>
|
|
```
|
|
|
|
`frontend/assets/css/main.css`:
|
|
```css
|
|
@tailwind base;
|
|
@tailwind components;
|
|
@tailwind utilities;
|
|
|
|
html, body, #__nuxt {
|
|
@apply h-full bg-gray-900 text-gray-100;
|
|
}
|
|
```
|
|
|
|
`frontend/layouts/auth.vue`:
|
|
```vue
|
|
<template>
|
|
<div class="min-h-screen flex items-center justify-center bg-gray-950">
|
|
<slot />
|
|
</div>
|
|
</template>
|
|
```
|
|
|
|
- [ ] **Step 12: Commit scaffold**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "feat: project scaffold — Docker, NestJS, Nuxt 3, Prisma config"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: Prisma Schema + Backend Bootstrap
|
|
|
|
**Files:**
|
|
- Create: `backend/prisma/schema.prisma`
|
|
- Create: `backend/src/main.ts`, `backend/src/app.module.ts`
|
|
- Create: `backend/src/prisma/prisma.service.ts`, `backend/src/prisma/prisma.module.ts`
|
|
|
|
- [ ] **Step 1: Create `backend/prisma/schema.prisma`**
|
|
|
|
Full schema from spec Section 5 — 7 models, 2 enums. Copy verbatim from spec:
|
|
|
|
```prisma
|
|
generator client {
|
|
provider = "prisma-client-js"
|
|
}
|
|
|
|
datasource db {
|
|
provider = "postgresql"
|
|
url = env("DATABASE_URL")
|
|
}
|
|
|
|
model User {
|
|
id Int @id @default(autoincrement())
|
|
email String @unique
|
|
passwordHash String @map("password_hash")
|
|
displayName String? @map("display_name")
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
updatedAt DateTime @updatedAt @map("updated_at")
|
|
|
|
@@map("users")
|
|
}
|
|
|
|
model HostGroup {
|
|
id Int @id @default(autoincrement())
|
|
name String
|
|
parentId Int? @map("parent_id")
|
|
sortOrder Int @default(0) @map("sort_order")
|
|
parent HostGroup? @relation("GroupTree", fields: [parentId], references: [id], onDelete: SetNull)
|
|
children HostGroup[] @relation("GroupTree")
|
|
hosts Host[]
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
updatedAt DateTime @updatedAt @map("updated_at")
|
|
|
|
@@map("host_groups")
|
|
}
|
|
|
|
model Host {
|
|
id Int @id @default(autoincrement())
|
|
name String
|
|
hostname String
|
|
port Int @default(22)
|
|
protocol Protocol @default(ssh)
|
|
groupId Int? @map("group_id")
|
|
credentialId Int? @map("credential_id")
|
|
tags String[] @default([])
|
|
notes String?
|
|
color String? @db.VarChar(7)
|
|
sortOrder Int @default(0) @map("sort_order")
|
|
hostFingerprint String? @map("host_fingerprint")
|
|
lastConnectedAt DateTime? @map("last_connected_at")
|
|
group HostGroup? @relation(fields: [groupId], references: [id], onDelete: SetNull)
|
|
credential Credential? @relation(fields: [credentialId], references: [id], onDelete: SetNull)
|
|
connectionLogs ConnectionLog[]
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
updatedAt DateTime @updatedAt @map("updated_at")
|
|
|
|
@@map("hosts")
|
|
}
|
|
|
|
model Credential {
|
|
id Int @id @default(autoincrement())
|
|
name String
|
|
username String?
|
|
domain String?
|
|
type CredentialType
|
|
encryptedValue String? @map("encrypted_value")
|
|
sshKeyId Int? @map("ssh_key_id")
|
|
sshKey SshKey? @relation(fields: [sshKeyId], references: [id], onDelete: SetNull)
|
|
hosts Host[]
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
updatedAt DateTime @updatedAt @map("updated_at")
|
|
|
|
@@map("credentials")
|
|
}
|
|
|
|
model SshKey {
|
|
id Int @id @default(autoincrement())
|
|
name String
|
|
keyType String @map("key_type") @db.VarChar(20)
|
|
fingerprint String?
|
|
publicKey String? @map("public_key")
|
|
encryptedPrivateKey String @map("encrypted_private_key")
|
|
passphraseEncrypted String? @map("passphrase_encrypted")
|
|
credentials Credential[]
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
updatedAt DateTime @updatedAt @map("updated_at")
|
|
|
|
@@map("ssh_keys")
|
|
}
|
|
|
|
model ConnectionLog {
|
|
id Int @id @default(autoincrement())
|
|
hostId Int @map("host_id")
|
|
protocol Protocol
|
|
connectedAt DateTime @default(now()) @map("connected_at")
|
|
disconnectedAt DateTime? @map("disconnected_at")
|
|
host Host @relation(fields: [hostId], references: [id], onDelete: Cascade)
|
|
|
|
@@map("connection_logs")
|
|
}
|
|
|
|
model Setting {
|
|
key String @id
|
|
value String
|
|
|
|
@@map("settings")
|
|
}
|
|
|
|
enum Protocol {
|
|
ssh
|
|
rdp
|
|
}
|
|
|
|
enum CredentialType {
|
|
password
|
|
ssh_key
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Create Prisma service + module**
|
|
|
|
`backend/src/prisma/prisma.service.ts`:
|
|
```typescript
|
|
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
|
import { PrismaClient } from '@prisma/client';
|
|
|
|
@Injectable()
|
|
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
|
async onModuleInit() {
|
|
await this.$connect();
|
|
}
|
|
|
|
async onModuleDestroy() {
|
|
await this.$disconnect();
|
|
}
|
|
}
|
|
```
|
|
|
|
`backend/src/prisma/prisma.module.ts`:
|
|
```typescript
|
|
import { Global, Module } from '@nestjs/common';
|
|
import { PrismaService } from './prisma.service';
|
|
|
|
@Global()
|
|
@Module({
|
|
providers: [PrismaService],
|
|
exports: [PrismaService],
|
|
})
|
|
export class PrismaModule {}
|
|
```
|
|
|
|
- [ ] **Step 3: Create `backend/src/main.ts`**
|
|
|
|
```typescript
|
|
import { NestFactory } from '@nestjs/core';
|
|
import { ValidationPipe } from '@nestjs/common';
|
|
import { WsAdapter } from '@nestjs/platform-ws';
|
|
import { AppModule } from './app.module';
|
|
|
|
async function bootstrap() {
|
|
const app = await NestFactory.create(AppModule);
|
|
app.setGlobalPrefix('api');
|
|
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
|
|
app.useWebSocketAdapter(new WsAdapter(app));
|
|
app.enableCors({
|
|
origin: process.env.NODE_ENV === 'production' ? false : 'http://localhost:3001',
|
|
credentials: true,
|
|
});
|
|
await app.listen(3000);
|
|
console.log('Wraith backend running on port 3000');
|
|
}
|
|
bootstrap();
|
|
```
|
|
|
|
- [ ] **Step 4: Create `backend/src/app.module.ts`** (initial — will grow as modules are added)
|
|
|
|
```typescript
|
|
import { Module } from '@nestjs/common';
|
|
import { ServeStaticModule } from '@nestjs/serve-static';
|
|
import { join } from 'path';
|
|
import { PrismaModule } from './prisma/prisma.module';
|
|
|
|
@Module({
|
|
imports: [
|
|
PrismaModule,
|
|
ServeStaticModule.forRoot({
|
|
rootPath: join(__dirname, '..', 'public'),
|
|
exclude: ['/api/(.*)'],
|
|
}),
|
|
],
|
|
})
|
|
export class AppModule {}
|
|
```
|
|
|
|
- [ ] **Step 5: Install backend dependencies and generate Prisma client**
|
|
|
|
```bash
|
|
cd backend && npm install
|
|
npx prisma generate
|
|
```
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "feat: Prisma schema (7 models) + NestJS bootstrap"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: Encryption Service
|
|
|
|
**Files:**
|
|
- Create: `backend/src/vault/encryption.service.ts`
|
|
- Create: `backend/src/vault/vault.module.ts`
|
|
- Create: `backend/test/encryption.service.spec.ts`
|
|
|
|
This is a critical security component. Full code required.
|
|
|
|
- [ ] **Step 1: Write encryption service tests**
|
|
|
|
`backend/test/encryption.service.spec.ts`:
|
|
```typescript
|
|
import { EncryptionService } from '../src/vault/encryption.service';
|
|
|
|
describe('EncryptionService', () => {
|
|
let service: EncryptionService;
|
|
|
|
beforeEach(() => {
|
|
// 32-byte key as 64-char hex string
|
|
process.env.ENCRYPTION_KEY = 'a'.repeat(64);
|
|
service = new EncryptionService();
|
|
});
|
|
|
|
it('encrypts and decrypts a string', () => {
|
|
const plaintext = 'my-secret-password';
|
|
const encrypted = service.encrypt(plaintext);
|
|
expect(encrypted).not.toEqual(plaintext);
|
|
expect(encrypted.startsWith('v1:')).toBe(true);
|
|
expect(service.decrypt(encrypted)).toEqual(plaintext);
|
|
});
|
|
|
|
it('produces different ciphertext for same plaintext (random IV)', () => {
|
|
const plaintext = 'same-input';
|
|
const a = service.encrypt(plaintext);
|
|
const b = service.encrypt(plaintext);
|
|
expect(a).not.toEqual(b);
|
|
expect(service.decrypt(a)).toEqual(plaintext);
|
|
expect(service.decrypt(b)).toEqual(plaintext);
|
|
});
|
|
|
|
it('throws on tampered ciphertext', () => {
|
|
const encrypted = service.encrypt('test');
|
|
const parts = encrypted.split(':');
|
|
parts[3] = 'ff' + parts[3].slice(2); // tamper ciphertext
|
|
expect(() => service.decrypt(parts.join(':'))).toThrow();
|
|
});
|
|
|
|
it('handles empty string', () => {
|
|
const encrypted = service.encrypt('');
|
|
expect(service.decrypt(encrypted)).toEqual('');
|
|
});
|
|
|
|
it('handles unicode', () => {
|
|
const plaintext = 'p@$$w0rd-日本語-🔑';
|
|
const encrypted = service.encrypt(plaintext);
|
|
expect(service.decrypt(encrypted)).toEqual(plaintext);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run tests — verify they fail**
|
|
|
|
```bash
|
|
cd backend && npx jest test/encryption.service.spec.ts
|
|
```
|
|
|
|
Expected: FAIL — module not found.
|
|
|
|
- [ ] **Step 3: Implement encryption service**
|
|
|
|
`backend/src/vault/encryption.service.ts`:
|
|
```typescript
|
|
import { Injectable } from '@nestjs/common';
|
|
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
|
|
|
|
@Injectable()
|
|
export class EncryptionService {
|
|
private readonly algorithm = 'aes-256-gcm';
|
|
private readonly key: Buffer;
|
|
|
|
constructor() {
|
|
const hex = process.env.ENCRYPTION_KEY;
|
|
if (!hex || hex.length < 64) {
|
|
throw new Error('ENCRYPTION_KEY must be a 64-char hex string (32 bytes)');
|
|
}
|
|
this.key = Buffer.from(hex.slice(0, 64), 'hex');
|
|
}
|
|
|
|
encrypt(plaintext: string): string {
|
|
const iv = randomBytes(16);
|
|
const cipher = createCipheriv(this.algorithm, this.key, iv);
|
|
const encrypted = Buffer.concat([
|
|
cipher.update(plaintext, 'utf8'),
|
|
cipher.final(),
|
|
]);
|
|
const authTag = cipher.getAuthTag();
|
|
return `v1:${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted.toString('hex')}`;
|
|
}
|
|
|
|
decrypt(encrypted: string): string {
|
|
const [version, ivHex, authTagHex, ciphertextHex] = encrypted.split(':');
|
|
if (version !== 'v1') throw new Error(`Unknown encryption version: ${version}`);
|
|
const iv = Buffer.from(ivHex, 'hex');
|
|
const authTag = Buffer.from(authTagHex, 'hex');
|
|
const ciphertext = Buffer.from(ciphertextHex, 'hex');
|
|
const decipher = createDecipheriv(this.algorithm, this.key, iv);
|
|
decipher.setAuthTag(authTag);
|
|
return Buffer.concat([
|
|
decipher.update(ciphertext),
|
|
decipher.final(),
|
|
]).toString('utf8');
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Create vault module**
|
|
|
|
`backend/src/vault/vault.module.ts`:
|
|
```typescript
|
|
import { Module } from '@nestjs/common';
|
|
import { EncryptionService } from './encryption.service';
|
|
|
|
@Module({
|
|
providers: [EncryptionService],
|
|
exports: [EncryptionService],
|
|
})
|
|
export class VaultModule {}
|
|
```
|
|
|
|
- [ ] **Step 5: Run tests — verify they pass**
|
|
|
|
```bash
|
|
cd backend && npx jest test/encryption.service.spec.ts --verbose
|
|
```
|
|
|
|
Expected: 5 tests PASS.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "feat: AES-256-GCM encryption service with tests"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: Auth Module
|
|
|
|
**Files:**
|
|
- Create: `backend/src/auth/auth.module.ts`, `auth.service.ts`, `auth.controller.ts`
|
|
- Create: `backend/src/auth/jwt.strategy.ts`, `jwt-auth.guard.ts`, `ws-auth.guard.ts`
|
|
- Create: `backend/src/auth/dto/login.dto.ts`
|
|
- Create: `backend/test/auth.service.spec.ts`
|
|
- Create: `backend/prisma/seed.ts`
|
|
|
|
- [ ] **Step 1: Write auth service tests**
|
|
|
|
`backend/test/auth.service.spec.ts`:
|
|
```typescript
|
|
import { JwtService } from '@nestjs/jwt';
|
|
import { AuthService } from '../src/auth/auth.service';
|
|
import * as bcrypt from 'bcrypt';
|
|
|
|
describe('AuthService', () => {
|
|
let service: AuthService;
|
|
const mockPrisma = {
|
|
user: {
|
|
findUnique: jest.fn(),
|
|
count: jest.fn(),
|
|
create: jest.fn(),
|
|
},
|
|
};
|
|
const mockJwt = {
|
|
sign: jest.fn().mockReturnValue('mock-jwt-token'),
|
|
};
|
|
|
|
beforeEach(() => {
|
|
service = new AuthService(mockPrisma as any, mockJwt as any);
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
it('returns token for valid credentials', async () => {
|
|
const hash = await bcrypt.hash('password123', 10);
|
|
mockPrisma.user.findUnique.mockResolvedValue({
|
|
id: 1,
|
|
email: 'admin@wraith.local',
|
|
passwordHash: hash,
|
|
displayName: 'Admin',
|
|
});
|
|
|
|
const result = await service.login('admin@wraith.local', 'password123');
|
|
expect(result).toEqual({
|
|
access_token: 'mock-jwt-token',
|
|
user: { id: 1, email: 'admin@wraith.local', displayName: 'Admin' },
|
|
});
|
|
});
|
|
|
|
it('throws on wrong password', async () => {
|
|
const hash = await bcrypt.hash('correct', 10);
|
|
mockPrisma.user.findUnique.mockResolvedValue({
|
|
id: 1,
|
|
email: 'admin@wraith.local',
|
|
passwordHash: hash,
|
|
});
|
|
|
|
await expect(service.login('admin@wraith.local', 'wrong'))
|
|
.rejects.toThrow('Invalid credentials');
|
|
});
|
|
|
|
it('throws on unknown user', async () => {
|
|
mockPrisma.user.findUnique.mockResolvedValue(null);
|
|
await expect(service.login('nobody@wraith.local', 'pass'))
|
|
.rejects.toThrow('Invalid credentials');
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run tests — verify fail**
|
|
|
|
- [ ] **Step 3: Implement auth service**
|
|
|
|
`backend/src/auth/auth.service.ts`:
|
|
```typescript
|
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
|
import { JwtService } from '@nestjs/jwt';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
import * as bcrypt from 'bcrypt';
|
|
|
|
@Injectable()
|
|
export class AuthService {
|
|
constructor(
|
|
private prisma: PrismaService,
|
|
private jwt: JwtService,
|
|
) {}
|
|
|
|
async login(email: string, password: string) {
|
|
const user = await this.prisma.user.findUnique({ where: { email } });
|
|
if (!user) throw new UnauthorizedException('Invalid credentials');
|
|
|
|
const valid = await bcrypt.compare(password, user.passwordHash);
|
|
if (!valid) throw new UnauthorizedException('Invalid credentials');
|
|
|
|
const payload = { sub: user.id, email: user.email };
|
|
return {
|
|
access_token: this.jwt.sign(payload),
|
|
user: { id: user.id, email: user.email, displayName: user.displayName },
|
|
};
|
|
}
|
|
|
|
async getProfile(userId: number) {
|
|
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
|
if (!user) throw new UnauthorizedException();
|
|
return { id: user.id, email: user.email, displayName: user.displayName };
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Implement auth controller**
|
|
|
|
`backend/src/auth/auth.controller.ts`:
|
|
```typescript
|
|
import { Controller, Post, Get, Body, Request, UseGuards } from '@nestjs/common';
|
|
import { AuthService } from './auth.service';
|
|
import { JwtAuthGuard } from './jwt-auth.guard';
|
|
import { LoginDto } from './dto/login.dto';
|
|
|
|
@Controller('auth')
|
|
export class AuthController {
|
|
constructor(private auth: AuthService) {}
|
|
|
|
@Post('login')
|
|
login(@Body() dto: LoginDto) {
|
|
return this.auth.login(dto.email, dto.password);
|
|
}
|
|
|
|
@UseGuards(JwtAuthGuard)
|
|
@Get('profile')
|
|
getProfile(@Request() req: any) {
|
|
return this.auth.getProfile(req.user.sub);
|
|
}
|
|
}
|
|
```
|
|
|
|
`backend/src/auth/dto/login.dto.ts`:
|
|
```typescript
|
|
import { IsEmail, IsString, MinLength } from 'class-validator';
|
|
|
|
export class LoginDto {
|
|
@IsEmail()
|
|
email: string;
|
|
|
|
@IsString()
|
|
@MinLength(1)
|
|
password: string;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Implement JWT strategy + guards**
|
|
|
|
`backend/src/auth/jwt.strategy.ts`:
|
|
```typescript
|
|
import { Injectable } from '@nestjs/common';
|
|
import { PassportStrategy } from '@nestjs/passport';
|
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
|
|
|
@Injectable()
|
|
export class JwtStrategy extends PassportStrategy(Strategy) {
|
|
constructor() {
|
|
super({
|
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
|
ignoreExpiration: false,
|
|
secretOrKey: process.env.JWT_SECRET,
|
|
});
|
|
}
|
|
|
|
validate(payload: { sub: number; email: string }) {
|
|
return { sub: payload.sub, email: payload.email };
|
|
}
|
|
}
|
|
```
|
|
|
|
`backend/src/auth/jwt-auth.guard.ts`:
|
|
```typescript
|
|
import { Injectable } from '@nestjs/common';
|
|
import { AuthGuard } from '@nestjs/passport';
|
|
|
|
@Injectable()
|
|
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
|
```
|
|
|
|
`backend/src/auth/ws-auth.guard.ts`:
|
|
```typescript
|
|
import { Injectable } from '@nestjs/common';
|
|
import { JwtService } from '@nestjs/jwt';
|
|
import { WsException } from '@nestjs/websockets';
|
|
|
|
@Injectable()
|
|
export class WsAuthGuard {
|
|
constructor(private jwt: JwtService) {}
|
|
|
|
validateClient(client: any): { sub: number; email: string } | null {
|
|
try {
|
|
const url = new URL(client.url || client._url, 'http://localhost');
|
|
const token = url.searchParams.get('token');
|
|
if (!token) throw new WsException('No token');
|
|
return this.jwt.verify(token);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 6: Implement auth module**
|
|
|
|
`backend/src/auth/auth.module.ts`:
|
|
```typescript
|
|
import { Module } from '@nestjs/common';
|
|
import { JwtModule } from '@nestjs/jwt';
|
|
import { PassportModule } from '@nestjs/passport';
|
|
import { AuthService } from './auth.service';
|
|
import { AuthController } from './auth.controller';
|
|
import { JwtStrategy } from './jwt.strategy';
|
|
import { WsAuthGuard } from './ws-auth.guard';
|
|
|
|
@Module({
|
|
imports: [
|
|
PassportModule,
|
|
JwtModule.register({
|
|
secret: process.env.JWT_SECRET,
|
|
signOptions: { expiresIn: '7d' },
|
|
}),
|
|
],
|
|
providers: [AuthService, JwtStrategy, WsAuthGuard],
|
|
controllers: [AuthController],
|
|
exports: [WsAuthGuard, JwtModule],
|
|
})
|
|
export class AuthModule {}
|
|
```
|
|
|
|
- [ ] **Step 7: Create seed script**
|
|
|
|
`backend/prisma/seed.ts`:
|
|
```typescript
|
|
import { PrismaClient } from '@prisma/client';
|
|
import * as bcrypt from 'bcrypt';
|
|
|
|
const prisma = new PrismaClient();
|
|
|
|
async function main() {
|
|
const hash = await bcrypt.hash('wraith', 10);
|
|
await prisma.user.upsert({
|
|
where: { email: 'admin@wraith.local' },
|
|
update: {},
|
|
create: {
|
|
email: 'admin@wraith.local',
|
|
passwordHash: hash,
|
|
displayName: 'Admin',
|
|
},
|
|
});
|
|
console.log('Seed complete: admin@wraith.local / wraith');
|
|
}
|
|
|
|
main()
|
|
.catch(console.error)
|
|
.finally(() => prisma.$disconnect());
|
|
```
|
|
|
|
Add to `backend/package.json`:
|
|
```json
|
|
"prisma": {
|
|
"seed": "ts-node prisma/seed.ts"
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 8: Update app.module.ts — register AuthModule + VaultModule**
|
|
|
|
```typescript
|
|
import { Module } from '@nestjs/common';
|
|
import { ServeStaticModule } from '@nestjs/serve-static';
|
|
import { join } from 'path';
|
|
import { PrismaModule } from './prisma/prisma.module';
|
|
import { AuthModule } from './auth/auth.module';
|
|
import { VaultModule } from './vault/vault.module';
|
|
|
|
@Module({
|
|
imports: [
|
|
PrismaModule,
|
|
AuthModule,
|
|
VaultModule,
|
|
ServeStaticModule.forRoot({
|
|
rootPath: join(__dirname, '..', 'public'),
|
|
exclude: ['/api/(.*)'],
|
|
}),
|
|
],
|
|
})
|
|
export class AppModule {}
|
|
```
|
|
|
|
- [ ] **Step 9: Run tests — verify pass**
|
|
|
|
```bash
|
|
cd backend && npx jest --verbose
|
|
```
|
|
|
|
Expected: all encryption + auth tests pass.
|
|
|
|
- [ ] **Step 10: Commit**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "feat: auth module — JWT login, guards, seed user"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: Connection Manager Backend
|
|
|
|
**Files:**
|
|
- Create: `backend/src/connections/connections.module.ts`
|
|
- Create: `backend/src/connections/hosts.service.ts`, `hosts.controller.ts`
|
|
- Create: `backend/src/connections/groups.service.ts`, `groups.controller.ts`
|
|
- Create: `backend/src/connections/dto/*.ts`
|
|
|
|
- [ ] **Step 1: Create DTOs**
|
|
|
|
`backend/src/connections/dto/create-host.dto.ts`:
|
|
```typescript
|
|
import { IsString, IsInt, IsOptional, IsEnum, IsArray, Min, Max } from 'class-validator';
|
|
import { Protocol } from '@prisma/client';
|
|
|
|
export class CreateHostDto {
|
|
@IsString()
|
|
name: string;
|
|
|
|
@IsString()
|
|
hostname: string;
|
|
|
|
@IsInt()
|
|
@Min(1)
|
|
@Max(65535)
|
|
@IsOptional()
|
|
port?: number;
|
|
|
|
@IsEnum(Protocol)
|
|
@IsOptional()
|
|
protocol?: Protocol;
|
|
|
|
@IsInt()
|
|
@IsOptional()
|
|
groupId?: number;
|
|
|
|
@IsInt()
|
|
@IsOptional()
|
|
credentialId?: number;
|
|
|
|
@IsArray()
|
|
@IsString({ each: true })
|
|
@IsOptional()
|
|
tags?: string[];
|
|
|
|
@IsString()
|
|
@IsOptional()
|
|
notes?: string;
|
|
|
|
@IsString()
|
|
@IsOptional()
|
|
color?: string;
|
|
}
|
|
```
|
|
|
|
`backend/src/connections/dto/update-host.dto.ts`:
|
|
```typescript
|
|
import { PartialType } from '@nestjs/mapped-types';
|
|
import { CreateHostDto } from './create-host.dto';
|
|
|
|
export class UpdateHostDto extends PartialType(CreateHostDto) {}
|
|
```
|
|
|
|
`backend/src/connections/dto/create-group.dto.ts`:
|
|
```typescript
|
|
import { IsString, IsInt, IsOptional } from 'class-validator';
|
|
|
|
export class CreateGroupDto {
|
|
@IsString()
|
|
name: string;
|
|
|
|
@IsInt()
|
|
@IsOptional()
|
|
parentId?: number;
|
|
|
|
@IsInt()
|
|
@IsOptional()
|
|
sortOrder?: number;
|
|
}
|
|
```
|
|
|
|
`backend/src/connections/dto/update-group.dto.ts`:
|
|
```typescript
|
|
import { PartialType } from '@nestjs/mapped-types';
|
|
import { CreateGroupDto } from './create-group.dto';
|
|
|
|
export class UpdateGroupDto extends PartialType(CreateGroupDto) {}
|
|
```
|
|
|
|
- [ ] **Step 2: Implement hosts service**
|
|
|
|
`backend/src/connections/hosts.service.ts`:
|
|
```typescript
|
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
import { CreateHostDto } from './dto/create-host.dto';
|
|
import { UpdateHostDto } from './dto/update-host.dto';
|
|
|
|
@Injectable()
|
|
export class HostsService {
|
|
constructor(private prisma: PrismaService) {}
|
|
|
|
findAll(search?: string) {
|
|
const where = search
|
|
? {
|
|
OR: [
|
|
{ name: { contains: search, mode: 'insensitive' as const } },
|
|
{ hostname: { contains: search, mode: 'insensitive' as const } },
|
|
{ tags: { has: search } },
|
|
],
|
|
}
|
|
: {};
|
|
return this.prisma.host.findMany({
|
|
where,
|
|
include: { group: true, credential: { select: { id: true, name: true, type: true } } },
|
|
orderBy: [{ lastConnectedAt: { sort: 'desc', nulls: 'last' } }, { sortOrder: 'asc' }],
|
|
});
|
|
}
|
|
|
|
async findOne(id: number) {
|
|
const host = await this.prisma.host.findUnique({
|
|
where: { id },
|
|
include: { group: true, credential: true },
|
|
});
|
|
if (!host) throw new NotFoundException(`Host ${id} not found`);
|
|
return host;
|
|
}
|
|
|
|
create(dto: CreateHostDto) {
|
|
return this.prisma.host.create({
|
|
data: {
|
|
name: dto.name,
|
|
hostname: dto.hostname,
|
|
port: dto.port ?? (dto.protocol === 'rdp' ? 3389 : 22),
|
|
protocol: dto.protocol ?? 'ssh',
|
|
groupId: dto.groupId,
|
|
credentialId: dto.credentialId,
|
|
tags: dto.tags ?? [],
|
|
notes: dto.notes,
|
|
color: dto.color,
|
|
},
|
|
include: { group: true },
|
|
});
|
|
}
|
|
|
|
async update(id: number, dto: UpdateHostDto) {
|
|
await this.findOne(id); // throws if not found
|
|
return this.prisma.host.update({ where: { id }, data: dto });
|
|
}
|
|
|
|
async remove(id: number) {
|
|
await this.findOne(id);
|
|
return this.prisma.host.delete({ where: { id } });
|
|
}
|
|
|
|
async touchLastConnected(id: number) {
|
|
return this.prisma.host.update({
|
|
where: { id },
|
|
data: { lastConnectedAt: new Date() },
|
|
});
|
|
}
|
|
|
|
async reorder(ids: number[]) {
|
|
const updates = ids.map((id, index) =>
|
|
this.prisma.host.update({ where: { id }, data: { sortOrder: index } }),
|
|
);
|
|
return this.prisma.$transaction(updates);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Implement hosts controller**
|
|
|
|
`backend/src/connections/hosts.controller.ts`:
|
|
```typescript
|
|
import { Controller, Get, Post, Put, Delete, Param, Body, Query, UseGuards, ParseIntPipe } from '@nestjs/common';
|
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
|
import { HostsService } from './hosts.service';
|
|
import { CreateHostDto } from './dto/create-host.dto';
|
|
import { UpdateHostDto } from './dto/update-host.dto';
|
|
|
|
@UseGuards(JwtAuthGuard)
|
|
@Controller('hosts')
|
|
export class HostsController {
|
|
constructor(private hosts: HostsService) {}
|
|
|
|
@Get()
|
|
findAll(@Query('search') search?: string) {
|
|
return this.hosts.findAll(search);
|
|
}
|
|
|
|
@Get(':id')
|
|
findOne(@Param('id', ParseIntPipe) id: number) {
|
|
return this.hosts.findOne(id);
|
|
}
|
|
|
|
@Post()
|
|
create(@Body() dto: CreateHostDto) {
|
|
return this.hosts.create(dto);
|
|
}
|
|
|
|
@Put(':id')
|
|
update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateHostDto) {
|
|
return this.hosts.update(id, dto);
|
|
}
|
|
|
|
@Delete(':id')
|
|
remove(@Param('id', ParseIntPipe) id: number) {
|
|
return this.hosts.remove(id);
|
|
}
|
|
|
|
@Post('reorder')
|
|
reorder(@Body() body: { ids: number[] }) {
|
|
return this.hosts.reorder(body.ids);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Implement groups service + controller**
|
|
|
|
`backend/src/connections/groups.service.ts`:
|
|
```typescript
|
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
import { CreateGroupDto } from './dto/create-group.dto';
|
|
import { UpdateGroupDto } from './dto/update-group.dto';
|
|
|
|
@Injectable()
|
|
export class GroupsService {
|
|
constructor(private prisma: PrismaService) {}
|
|
|
|
findAll() {
|
|
return this.prisma.hostGroup.findMany({
|
|
include: { children: true, hosts: { select: { id: true, name: true, protocol: true } } },
|
|
orderBy: { sortOrder: 'asc' },
|
|
});
|
|
}
|
|
|
|
findTree() {
|
|
return this.prisma.hostGroup.findMany({
|
|
where: { parentId: null },
|
|
include: {
|
|
hosts: { orderBy: { sortOrder: 'asc' } },
|
|
children: {
|
|
include: {
|
|
hosts: { orderBy: { sortOrder: 'asc' } },
|
|
children: {
|
|
include: {
|
|
hosts: { orderBy: { sortOrder: 'asc' } },
|
|
},
|
|
},
|
|
},
|
|
orderBy: { sortOrder: 'asc' },
|
|
},
|
|
},
|
|
orderBy: { sortOrder: 'asc' },
|
|
});
|
|
}
|
|
|
|
async findOne(id: number) {
|
|
const group = await this.prisma.hostGroup.findUnique({
|
|
where: { id },
|
|
include: { hosts: true, children: true },
|
|
});
|
|
if (!group) throw new NotFoundException(`Group ${id} not found`);
|
|
return group;
|
|
}
|
|
|
|
create(dto: CreateGroupDto) {
|
|
return this.prisma.hostGroup.create({ data: dto });
|
|
}
|
|
|
|
async update(id: number, dto: UpdateGroupDto) {
|
|
await this.findOne(id);
|
|
return this.prisma.hostGroup.update({ where: { id }, data: dto });
|
|
}
|
|
|
|
async remove(id: number) {
|
|
await this.findOne(id);
|
|
return this.prisma.hostGroup.delete({ where: { id } });
|
|
}
|
|
}
|
|
```
|
|
|
|
`backend/src/connections/groups.controller.ts`:
|
|
```typescript
|
|
import { Controller, Get, Post, Put, Delete, Param, Body, UseGuards, ParseIntPipe } from '@nestjs/common';
|
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
|
import { GroupsService } from './groups.service';
|
|
import { CreateGroupDto } from './dto/create-group.dto';
|
|
import { UpdateGroupDto } from './dto/update-group.dto';
|
|
|
|
@UseGuards(JwtAuthGuard)
|
|
@Controller('groups')
|
|
export class GroupsController {
|
|
constructor(private groups: GroupsService) {}
|
|
|
|
@Get()
|
|
findAll() {
|
|
return this.groups.findAll();
|
|
}
|
|
|
|
@Get('tree')
|
|
findTree() {
|
|
return this.groups.findTree();
|
|
}
|
|
|
|
@Get(':id')
|
|
findOne(@Param('id', ParseIntPipe) id: number) {
|
|
return this.groups.findOne(id);
|
|
}
|
|
|
|
@Post()
|
|
create(@Body() dto: CreateGroupDto) {
|
|
return this.groups.create(dto);
|
|
}
|
|
|
|
@Put(':id')
|
|
update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateGroupDto) {
|
|
return this.groups.update(id, dto);
|
|
}
|
|
|
|
@Delete(':id')
|
|
remove(@Param('id', ParseIntPipe) id: number) {
|
|
return this.groups.remove(id);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Create connections module + register in app.module**
|
|
|
|
`backend/src/connections/connections.module.ts`:
|
|
```typescript
|
|
import { Module } from '@nestjs/common';
|
|
import { HostsService } from './hosts.service';
|
|
import { HostsController } from './hosts.controller';
|
|
import { GroupsService } from './groups.service';
|
|
import { GroupsController } from './groups.controller';
|
|
|
|
@Module({
|
|
providers: [HostsService, GroupsService],
|
|
controllers: [HostsController, GroupsController],
|
|
exports: [HostsService],
|
|
})
|
|
export class ConnectionsModule {}
|
|
```
|
|
|
|
Update `app.module.ts` imports to add `ConnectionsModule`.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "feat: connection manager — hosts + groups CRUD with search"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6: Vault Backend (Credentials + SSH Keys)
|
|
|
|
**Files:**
|
|
- Create: `backend/src/vault/credentials.service.ts`, `credentials.controller.ts`
|
|
- Create: `backend/src/vault/ssh-keys.service.ts`, `ssh-keys.controller.ts`
|
|
- Create: `backend/src/vault/dto/*.ts`
|
|
- Modify: `backend/src/vault/vault.module.ts`
|
|
|
|
- [ ] **Step 1: Create DTOs**
|
|
|
|
`backend/src/vault/dto/create-credential.dto.ts`:
|
|
```typescript
|
|
import { IsString, IsOptional, IsEnum, IsInt } from 'class-validator';
|
|
import { CredentialType } from '@prisma/client';
|
|
|
|
export class CreateCredentialDto {
|
|
@IsString()
|
|
name: string;
|
|
|
|
@IsString()
|
|
@IsOptional()
|
|
username?: string;
|
|
|
|
@IsString()
|
|
@IsOptional()
|
|
domain?: string;
|
|
|
|
@IsEnum(CredentialType)
|
|
type: CredentialType;
|
|
|
|
@IsString()
|
|
@IsOptional()
|
|
password?: string; // plaintext — encrypted before storage
|
|
|
|
@IsInt()
|
|
@IsOptional()
|
|
sshKeyId?: number;
|
|
}
|
|
```
|
|
|
|
`backend/src/vault/dto/update-credential.dto.ts`:
|
|
```typescript
|
|
import { PartialType } from '@nestjs/mapped-types';
|
|
import { CreateCredentialDto } from './create-credential.dto';
|
|
|
|
export class UpdateCredentialDto extends PartialType(CreateCredentialDto) {}
|
|
```
|
|
|
|
`backend/src/vault/dto/create-ssh-key.dto.ts`:
|
|
```typescript
|
|
import { IsString, IsOptional } from 'class-validator';
|
|
|
|
export class CreateSshKeyDto {
|
|
@IsString()
|
|
name: string;
|
|
|
|
@IsString()
|
|
privateKey: string; // plaintext — encrypted before storage
|
|
|
|
@IsString()
|
|
@IsOptional()
|
|
passphrase?: string; // plaintext — encrypted before storage
|
|
|
|
@IsString()
|
|
@IsOptional()
|
|
publicKey?: string;
|
|
}
|
|
```
|
|
|
|
`backend/src/vault/dto/update-ssh-key.dto.ts`:
|
|
```typescript
|
|
import { IsString, IsOptional } from 'class-validator';
|
|
|
|
export class UpdateSshKeyDto {
|
|
@IsString()
|
|
@IsOptional()
|
|
name?: string;
|
|
|
|
@IsString()
|
|
@IsOptional()
|
|
passphrase?: string; // new passphrase (re-encrypted)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Implement credentials service**
|
|
|
|
`backend/src/vault/credentials.service.ts`:
|
|
```typescript
|
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
import { EncryptionService } from './encryption.service';
|
|
import { CreateCredentialDto } from './dto/create-credential.dto';
|
|
import { UpdateCredentialDto } from './dto/update-credential.dto';
|
|
|
|
@Injectable()
|
|
export class CredentialsService {
|
|
constructor(
|
|
private prisma: PrismaService,
|
|
private encryption: EncryptionService,
|
|
) {}
|
|
|
|
findAll() {
|
|
return this.prisma.credential.findMany({
|
|
include: { sshKey: { select: { id: true, name: true, keyType: true, fingerprint: true } } },
|
|
orderBy: { name: 'asc' },
|
|
});
|
|
}
|
|
|
|
async findOne(id: number) {
|
|
const cred = await this.prisma.credential.findUnique({
|
|
where: { id },
|
|
include: { sshKey: true, hosts: { select: { id: true, name: true } } },
|
|
});
|
|
if (!cred) throw new NotFoundException(`Credential ${id} not found`);
|
|
return cred;
|
|
}
|
|
|
|
create(dto: CreateCredentialDto) {
|
|
const encryptedValue = dto.password ? this.encryption.encrypt(dto.password) : null;
|
|
return this.prisma.credential.create({
|
|
data: {
|
|
name: dto.name,
|
|
username: dto.username,
|
|
domain: dto.domain,
|
|
type: dto.type,
|
|
encryptedValue,
|
|
sshKeyId: dto.sshKeyId,
|
|
},
|
|
});
|
|
}
|
|
|
|
async update(id: number, dto: UpdateCredentialDto) {
|
|
await this.findOne(id);
|
|
const data: any = { ...dto };
|
|
delete data.password;
|
|
if (dto.password) {
|
|
data.encryptedValue = this.encryption.encrypt(dto.password);
|
|
}
|
|
return this.prisma.credential.update({ where: { id }, data });
|
|
}
|
|
|
|
async remove(id: number) {
|
|
await this.findOne(id);
|
|
return this.prisma.credential.delete({ where: { id } });
|
|
}
|
|
|
|
/** Decrypt credential for use in SSH/RDP connections. Never expose over API. */
|
|
async decryptForConnection(id: number): Promise<{
|
|
username: string | null;
|
|
domain: string | null;
|
|
password: string | null;
|
|
sshKey: { privateKey: string; passphrase: string | null } | null;
|
|
}> {
|
|
const cred = await this.prisma.credential.findUnique({
|
|
where: { id },
|
|
include: { sshKey: true },
|
|
});
|
|
if (!cred) throw new NotFoundException(`Credential ${id} not found`);
|
|
|
|
let password: string | null = null;
|
|
if (cred.encryptedValue) {
|
|
password = this.encryption.decrypt(cred.encryptedValue);
|
|
}
|
|
|
|
let sshKey: { privateKey: string; passphrase: string | null } | null = null;
|
|
if (cred.sshKey) {
|
|
const privateKey = this.encryption.decrypt(cred.sshKey.encryptedPrivateKey);
|
|
const passphrase = cred.sshKey.passphraseEncrypted
|
|
? this.encryption.decrypt(cred.sshKey.passphraseEncrypted)
|
|
: null;
|
|
sshKey = { privateKey, passphrase };
|
|
}
|
|
|
|
return { username: cred.username, domain: cred.domain, password, sshKey };
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Implement credentials controller**
|
|
|
|
`backend/src/vault/credentials.controller.ts`:
|
|
```typescript
|
|
import { Controller, Get, Post, Put, Delete, Param, Body, UseGuards, ParseIntPipe } from '@nestjs/common';
|
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
|
import { CredentialsService } from './credentials.service';
|
|
import { CreateCredentialDto } from './dto/create-credential.dto';
|
|
import { UpdateCredentialDto } from './dto/update-credential.dto';
|
|
|
|
@UseGuards(JwtAuthGuard)
|
|
@Controller('credentials')
|
|
export class CredentialsController {
|
|
constructor(private credentials: CredentialsService) {}
|
|
|
|
@Get()
|
|
findAll() {
|
|
return this.credentials.findAll();
|
|
}
|
|
|
|
@Get(':id')
|
|
findOne(@Param('id', ParseIntPipe) id: number) {
|
|
return this.credentials.findOne(id);
|
|
}
|
|
|
|
@Post()
|
|
create(@Body() dto: CreateCredentialDto) {
|
|
return this.credentials.create(dto);
|
|
}
|
|
|
|
@Put(':id')
|
|
update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateCredentialDto) {
|
|
return this.credentials.update(id, dto);
|
|
}
|
|
|
|
@Delete(':id')
|
|
remove(@Param('id', ParseIntPipe) id: number) {
|
|
return this.credentials.remove(id);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Implement SSH keys service**
|
|
|
|
`backend/src/vault/ssh-keys.service.ts`:
|
|
```typescript
|
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
import { EncryptionService } from './encryption.service';
|
|
import { CreateSshKeyDto } from './dto/create-ssh-key.dto';
|
|
import { UpdateSshKeyDto } from './dto/update-ssh-key.dto';
|
|
import { createPublicKey, createHash } from 'crypto';
|
|
import { utils as ssh2Utils } from 'ssh2';
|
|
|
|
@Injectable()
|
|
export class SshKeysService {
|
|
constructor(
|
|
private prisma: PrismaService,
|
|
private encryption: EncryptionService,
|
|
) {}
|
|
|
|
findAll() {
|
|
return this.prisma.sshKey.findMany({
|
|
select: { id: true, name: true, keyType: true, fingerprint: true, publicKey: true, createdAt: true },
|
|
orderBy: { name: 'asc' },
|
|
});
|
|
}
|
|
|
|
async findOne(id: number) {
|
|
const key = await this.prisma.sshKey.findUnique({
|
|
where: { id },
|
|
include: { credentials: { select: { id: true, name: true } } },
|
|
});
|
|
if (!key) throw new NotFoundException(`SSH key ${id} not found`);
|
|
// Never return encrypted private key over API
|
|
return {
|
|
id: key.id,
|
|
name: key.name,
|
|
keyType: key.keyType,
|
|
fingerprint: key.fingerprint,
|
|
publicKey: key.publicKey,
|
|
credentials: key.credentials,
|
|
createdAt: key.createdAt,
|
|
};
|
|
}
|
|
|
|
async create(dto: CreateSshKeyDto) {
|
|
// Detect key type from private key content
|
|
const keyType = this.detectKeyType(dto.privateKey);
|
|
|
|
// Generate fingerprint from public key if provided, else from private key
|
|
const fingerprint = this.generateFingerprint(dto.publicKey || dto.privateKey);
|
|
|
|
// Encrypt sensitive data
|
|
const encryptedPrivateKey = this.encryption.encrypt(dto.privateKey);
|
|
const passphraseEncrypted = dto.passphrase
|
|
? this.encryption.encrypt(dto.passphrase)
|
|
: null;
|
|
|
|
return this.prisma.sshKey.create({
|
|
data: {
|
|
name: dto.name,
|
|
keyType,
|
|
fingerprint,
|
|
publicKey: dto.publicKey || null,
|
|
encryptedPrivateKey,
|
|
passphraseEncrypted,
|
|
},
|
|
});
|
|
}
|
|
|
|
async update(id: number, dto: UpdateSshKeyDto) {
|
|
const key = await this.prisma.sshKey.findUnique({ where: { id } });
|
|
if (!key) throw new NotFoundException(`SSH key ${id} not found`);
|
|
|
|
const data: any = {};
|
|
if (dto.name) data.name = dto.name;
|
|
if (dto.passphrase !== undefined) {
|
|
data.passphraseEncrypted = dto.passphrase
|
|
? this.encryption.encrypt(dto.passphrase)
|
|
: null;
|
|
}
|
|
return this.prisma.sshKey.update({ where: { id }, data });
|
|
}
|
|
|
|
async remove(id: number) {
|
|
const key = await this.prisma.sshKey.findUnique({ where: { id } });
|
|
if (!key) throw new NotFoundException(`SSH key ${id} not found`);
|
|
return this.prisma.sshKey.delete({ where: { id } });
|
|
}
|
|
|
|
private detectKeyType(privateKey: string): string {
|
|
if (privateKey.includes('RSA')) return 'rsa';
|
|
if (privateKey.includes('EC')) return 'ecdsa';
|
|
if (privateKey.includes('OPENSSH')) return 'ed25519'; // OpenSSH format, likely ed25519
|
|
return 'unknown';
|
|
}
|
|
|
|
private generateFingerprint(keyContent: string): string {
|
|
try {
|
|
const hash = createHash('sha256').update(keyContent.trim()).digest('base64');
|
|
return `SHA256:${hash}`;
|
|
} catch {
|
|
return 'unknown';
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Implement SSH keys controller**
|
|
|
|
`backend/src/vault/ssh-keys.controller.ts`:
|
|
```typescript
|
|
import { Controller, Get, Post, Put, Delete, Param, Body, UseGuards, ParseIntPipe } from '@nestjs/common';
|
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
|
import { SshKeysService } from './ssh-keys.service';
|
|
import { CreateSshKeyDto } from './dto/create-ssh-key.dto';
|
|
import { UpdateSshKeyDto } from './dto/update-ssh-key.dto';
|
|
|
|
@UseGuards(JwtAuthGuard)
|
|
@Controller('ssh-keys')
|
|
export class SshKeysController {
|
|
constructor(private sshKeys: SshKeysService) {}
|
|
|
|
@Get()
|
|
findAll() {
|
|
return this.sshKeys.findAll();
|
|
}
|
|
|
|
@Get(':id')
|
|
findOne(@Param('id', ParseIntPipe) id: number) {
|
|
return this.sshKeys.findOne(id);
|
|
}
|
|
|
|
@Post()
|
|
create(@Body() dto: CreateSshKeyDto) {
|
|
return this.sshKeys.create(dto);
|
|
}
|
|
|
|
@Put(':id')
|
|
update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateSshKeyDto) {
|
|
return this.sshKeys.update(id, dto);
|
|
}
|
|
|
|
@Delete(':id')
|
|
remove(@Param('id', ParseIntPipe) id: number) {
|
|
return this.sshKeys.remove(id);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 6: Update vault.module.ts**
|
|
|
|
```typescript
|
|
import { Module } from '@nestjs/common';
|
|
import { EncryptionService } from './encryption.service';
|
|
import { CredentialsService } from './credentials.service';
|
|
import { CredentialsController } from './credentials.controller';
|
|
import { SshKeysService } from './ssh-keys.service';
|
|
import { SshKeysController } from './ssh-keys.controller';
|
|
|
|
@Module({
|
|
providers: [EncryptionService, CredentialsService, SshKeysService],
|
|
controllers: [CredentialsController, SshKeysController],
|
|
exports: [EncryptionService, CredentialsService, SshKeysService],
|
|
})
|
|
export class VaultModule {}
|
|
```
|
|
|
|
Update `app.module.ts` — VaultModule is already imported.
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "feat: vault — encrypted credentials + SSH key management"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 7: Settings Backend
|
|
|
|
**Files:**
|
|
- Create: `backend/src/settings/settings.module.ts`, `settings.service.ts`, `settings.controller.ts`
|
|
|
|
- [ ] **Step 1: Implement settings service + controller**
|
|
|
|
`backend/src/settings/settings.service.ts`:
|
|
```typescript
|
|
import { Injectable } from '@nestjs/common';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
|
|
@Injectable()
|
|
export class SettingsService {
|
|
constructor(private prisma: PrismaService) {}
|
|
|
|
async getAll(): Promise<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);
|
|
}
|
|
}
|
|
```
|
|
|
|
`backend/src/settings/settings.controller.ts`:
|
|
```typescript
|
|
import { Controller, Get, Put, Body, UseGuards } from '@nestjs/common';
|
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
|
import { SettingsService } from './settings.service';
|
|
|
|
@UseGuards(JwtAuthGuard)
|
|
@Controller('settings')
|
|
export class SettingsController {
|
|
constructor(private settings: SettingsService) {}
|
|
|
|
@Get()
|
|
getAll() {
|
|
return this.settings.getAll();
|
|
}
|
|
|
|
@Put()
|
|
update(@Body() body: Record<string, string>) {
|
|
return this.settings.setMany(body);
|
|
}
|
|
}
|
|
```
|
|
|
|
`backend/src/settings/settings.module.ts`:
|
|
```typescript
|
|
import { Module } from '@nestjs/common';
|
|
import { SettingsService } from './settings.service';
|
|
import { SettingsController } from './settings.controller';
|
|
|
|
@Module({
|
|
providers: [SettingsService],
|
|
controllers: [SettingsController],
|
|
exports: [SettingsService],
|
|
})
|
|
export class SettingsModule {}
|
|
```
|
|
|
|
Register `SettingsModule` in `app.module.ts`.
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "feat: settings — key/value store with CRUD API"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 8: Frontend — Auth + Connection Manager UI
|
|
|
|
**Files:**
|
|
- Create: `frontend/stores/auth.store.ts`, `frontend/stores/connection.store.ts`
|
|
- Create: `frontend/pages/login.vue`, `frontend/pages/index.vue`
|
|
- Create: `frontend/layouts/default.vue`
|
|
- Create: `frontend/components/connections/HostTree.vue`, `HostCard.vue`, `HostEditDialog.vue`, `GroupEditDialog.vue`
|
|
|
|
- [ ] **Step 1: Create auth store**
|
|
|
|
`frontend/stores/auth.store.ts`:
|
|
```typescript
|
|
import { defineStore } from 'pinia'
|
|
|
|
interface User {
|
|
id: number
|
|
email: string
|
|
displayName: string | null
|
|
}
|
|
|
|
export const useAuthStore = defineStore('auth', {
|
|
state: () => ({
|
|
token: localStorage.getItem('wraith_token') || '',
|
|
user: null as User | null,
|
|
}),
|
|
getters: {
|
|
isAuthenticated: (state) => !!state.token,
|
|
},
|
|
actions: {
|
|
async login(email: string, password: string) {
|
|
const res = await $fetch<{ access_token: string; user: User }>('/api/auth/login', {
|
|
method: 'POST',
|
|
body: { email, password },
|
|
})
|
|
this.token = res.access_token
|
|
this.user = res.user
|
|
localStorage.setItem('wraith_token', res.access_token)
|
|
},
|
|
logout() {
|
|
this.token = ''
|
|
this.user = null
|
|
localStorage.removeItem('wraith_token')
|
|
navigateTo('/login')
|
|
},
|
|
async fetchProfile() {
|
|
if (!this.token) return
|
|
try {
|
|
this.user = await $fetch('/api/auth/profile', {
|
|
headers: { Authorization: `Bearer ${this.token}` },
|
|
})
|
|
} catch {
|
|
this.logout()
|
|
}
|
|
},
|
|
},
|
|
})
|
|
```
|
|
|
|
- [ ] **Step 2: Create connection store**
|
|
|
|
`frontend/stores/connection.store.ts`:
|
|
```typescript
|
|
import { defineStore } from 'pinia'
|
|
import { useAuthStore } from './auth.store'
|
|
|
|
interface Host {
|
|
id: number
|
|
name: string
|
|
hostname: string
|
|
port: number
|
|
protocol: 'ssh' | 'rdp'
|
|
groupId: number | null
|
|
credentialId: number | null
|
|
tags: string[]
|
|
notes: string | null
|
|
color: string | null
|
|
lastConnectedAt: string | null
|
|
group: { id: number; name: string } | null
|
|
}
|
|
|
|
interface HostGroup {
|
|
id: number
|
|
name: string
|
|
parentId: number | null
|
|
children: HostGroup[]
|
|
hosts: Host[]
|
|
}
|
|
|
|
export const useConnectionStore = defineStore('connections', {
|
|
state: () => ({
|
|
hosts: [] as Host[],
|
|
groups: [] as HostGroup[],
|
|
search: '',
|
|
loading: false,
|
|
}),
|
|
actions: {
|
|
headers() {
|
|
const auth = useAuthStore()
|
|
return { Authorization: `Bearer ${auth.token}` }
|
|
},
|
|
async fetchHosts() {
|
|
this.loading = true
|
|
try {
|
|
this.hosts = await $fetch('/api/hosts', { headers: this.headers() })
|
|
} finally {
|
|
this.loading = false
|
|
}
|
|
},
|
|
async fetchTree() {
|
|
this.groups = await $fetch('/api/groups/tree', { headers: this.headers() })
|
|
},
|
|
async createHost(data: Partial<Host>) {
|
|
const host = await $fetch<Host>('/api/hosts', {
|
|
method: 'POST',
|
|
body: data,
|
|
headers: this.headers(),
|
|
})
|
|
await this.fetchHosts()
|
|
return host
|
|
},
|
|
async updateHost(id: number, data: Partial<Host>) {
|
|
await $fetch(`/api/hosts/${id}`, {
|
|
method: 'PUT',
|
|
body: data,
|
|
headers: this.headers(),
|
|
})
|
|
await this.fetchHosts()
|
|
},
|
|
async deleteHost(id: number) {
|
|
await $fetch(`/api/hosts/${id}`, {
|
|
method: 'DELETE',
|
|
headers: this.headers(),
|
|
})
|
|
await this.fetchHosts()
|
|
},
|
|
async createGroup(data: { name: string; parentId?: number }) {
|
|
await $fetch('/api/groups', {
|
|
method: 'POST',
|
|
body: data,
|
|
headers: this.headers(),
|
|
})
|
|
await this.fetchTree()
|
|
},
|
|
async updateGroup(id: number, data: { name?: string; parentId?: number }) {
|
|
await $fetch(`/api/groups/${id}`, {
|
|
method: 'PUT',
|
|
body: data,
|
|
headers: this.headers(),
|
|
})
|
|
await this.fetchTree()
|
|
},
|
|
async deleteGroup(id: number) {
|
|
await $fetch(`/api/groups/${id}`, {
|
|
method: 'DELETE',
|
|
headers: this.headers(),
|
|
})
|
|
await this.fetchTree()
|
|
},
|
|
},
|
|
})
|
|
```
|
|
|
|
- [ ] **Step 3: Create login page**
|
|
|
|
`frontend/pages/login.vue`:
|
|
```vue
|
|
<script setup lang="ts">
|
|
definePageMeta({ layout: 'auth' })
|
|
|
|
const auth = useAuthStore()
|
|
const email = ref('admin@wraith.local')
|
|
const password = ref('')
|
|
const error = ref('')
|
|
const loading = ref(false)
|
|
|
|
async function handleLogin() {
|
|
error.value = ''
|
|
loading.value = true
|
|
try {
|
|
await auth.login(email.value, password.value)
|
|
navigateTo('/')
|
|
} catch (e: any) {
|
|
error.value = e.data?.message || 'Login failed'
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="w-full max-w-sm">
|
|
<div class="text-center mb-8">
|
|
<h1 class="text-3xl font-bold text-white tracking-wide">WRAITH</h1>
|
|
<p class="text-gray-500 mt-1 text-sm">Remote Access Terminal</p>
|
|
</div>
|
|
<form @submit.prevent="handleLogin" class="space-y-4 bg-gray-900 p-6 rounded-lg border border-gray-800">
|
|
<div>
|
|
<label class="block text-sm text-gray-400 mb-1">Email</label>
|
|
<input v-model="email" type="email" required autofocus
|
|
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white focus:border-wraith-500 focus:outline-none" />
|
|
</div>
|
|
<div>
|
|
<label class="block text-sm text-gray-400 mb-1">Password</label>
|
|
<input v-model="password" type="password" required
|
|
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white focus:border-wraith-500 focus:outline-none" />
|
|
</div>
|
|
<p v-if="error" class="text-red-400 text-sm">{{ error }}</p>
|
|
<button type="submit" :disabled="loading"
|
|
class="w-full py-2 bg-wraith-600 hover:bg-wraith-700 text-white rounded font-medium disabled:opacity-50">
|
|
{{ loading ? 'Signing in...' : 'Sign In' }}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</template>
|
|
```
|
|
|
|
- [ ] **Step 4: Create default layout + main index page (connection manager)**
|
|
|
|
`frontend/layouts/default.vue` — main layout with sidebar for host tree and top bar. Active sessions render as persistent tabs via `SessionContainer.vue` (built in Phase 2). For now, just the connection manager shell:
|
|
|
|
```vue
|
|
<script setup lang="ts">
|
|
const auth = useAuthStore()
|
|
|
|
// Redirect to login if not authenticated
|
|
if (!auth.isAuthenticated) {
|
|
navigateTo('/login')
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="h-screen flex flex-col bg-gray-950 text-gray-100">
|
|
<!-- Top bar -->
|
|
<header class="h-12 flex items-center justify-between px-4 bg-gray-900 border-b border-gray-800 shrink-0">
|
|
<div class="flex items-center gap-3">
|
|
<h1 class="text-lg font-bold tracking-wider text-wraith-400">WRAITH</h1>
|
|
</div>
|
|
<div class="flex items-center gap-3">
|
|
<NuxtLink to="/vault" class="text-sm text-gray-400 hover:text-white">Vault</NuxtLink>
|
|
<NuxtLink to="/settings" class="text-sm text-gray-400 hover:text-white">Settings</NuxtLink>
|
|
<button @click="auth.logout()" class="text-sm text-gray-500 hover:text-red-400">Logout</button>
|
|
</div>
|
|
</header>
|
|
<!-- Main content -->
|
|
<div class="flex-1 flex overflow-hidden">
|
|
<slot />
|
|
</div>
|
|
</div>
|
|
</template>
|
|
```
|
|
|
|
`frontend/pages/index.vue` — connection manager home page:
|
|
```vue
|
|
<script setup lang="ts">
|
|
const connections = useConnectionStore()
|
|
const showHostDialog = ref(false)
|
|
const editingHost = ref<any>(null)
|
|
const showGroupDialog = ref(false)
|
|
|
|
onMounted(async () => {
|
|
await Promise.all([connections.fetchHosts(), connections.fetchTree()])
|
|
})
|
|
|
|
function openNewHost(groupId?: number) {
|
|
editingHost.value = groupId ? { groupId } : null
|
|
showHostDialog.value = true
|
|
}
|
|
|
|
function openEditHost(host: any) {
|
|
editingHost.value = host
|
|
showHostDialog.value = true
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="flex w-full">
|
|
<!-- Sidebar: Group tree -->
|
|
<aside class="w-64 bg-gray-900 border-r border-gray-800 flex flex-col overflow-y-auto shrink-0">
|
|
<div class="p-3 flex items-center justify-between">
|
|
<span class="text-sm font-medium text-gray-400">Connections</span>
|
|
<div class="flex gap-1">
|
|
<button @click="showGroupDialog = true" class="text-xs text-gray-500 hover:text-wraith-400" title="New Group">+ Group</button>
|
|
<button @click="openNewHost()" class="text-xs text-gray-500 hover:text-wraith-400" title="New Host">+ Host</button>
|
|
</div>
|
|
</div>
|
|
<HostTree :groups="connections.groups" @select-host="openEditHost" @new-host="openNewHost" />
|
|
</aside>
|
|
|
|
<!-- Main: host list or active sessions (sessions added in Phase 2) -->
|
|
<main class="flex-1 p-4 overflow-y-auto">
|
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
|
<HostCard
|
|
v-for="host in connections.hosts"
|
|
:key="host.id"
|
|
:host="host"
|
|
@edit="openEditHost(host)"
|
|
@delete="connections.deleteHost(host.id)"
|
|
/>
|
|
</div>
|
|
<p v-if="!connections.hosts.length && !connections.loading" class="text-gray-600 text-center mt-12">
|
|
No hosts yet. Click "+ Host" to add your first connection.
|
|
</p>
|
|
</main>
|
|
|
|
<!-- Dialogs -->
|
|
<HostEditDialog v-model:visible="showHostDialog" :host="editingHost" @saved="connections.fetchHosts()" />
|
|
<GroupEditDialog v-model:visible="showGroupDialog" @saved="connections.fetchTree()" />
|
|
</div>
|
|
</template>
|
|
```
|
|
|
|
- [ ] **Step 5: Create HostTree, HostCard, HostEditDialog, GroupEditDialog components**
|
|
|
|
These are standard PrimeVue-driven components. Each component:
|
|
|
|
`frontend/components/connections/HostTree.vue` — recursive tree using PrimeVue `Tree` or hand-rolled recursive list. Displays groups with expand/collapse, hosts as leaf nodes. Emits `select-host` and `new-host` events.
|
|
|
|
`frontend/components/connections/HostCard.vue` — card showing host name, hostname:port, protocol badge (SSH/RDP), color indicator, last connected timestamp. Click to connect (Phase 2), edit button, delete button.
|
|
|
|
`frontend/components/connections/HostEditDialog.vue` — PrimeVue Dialog with form fields matching CreateHostDto. Protocol selector, group dropdown, credential dropdown, tags input, notes textarea, color picker.
|
|
|
|
`frontend/components/connections/GroupEditDialog.vue` — PrimeVue Dialog with name field and parent group dropdown.
|
|
|
|
Each component should be implemented following standard Vue 3 Composition API patterns with PrimeVue components. Use `$fetch` with auth headers for API calls.
|
|
|
|
- [ ] **Step 6: Install frontend dependencies and verify dev server starts**
|
|
|
|
```bash
|
|
cd frontend && npm install
|
|
npx nuxi dev
|
|
```
|
|
|
|
Verify: login page renders, login succeeds (requires backend running with DB), connection manager loads.
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "feat: frontend — auth flow, connection manager UI, host tree"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 9: First Docker Compose Up
|
|
|
|
- [ ] **Step 1: Create `.env` from `.env.example` with real values**
|
|
|
|
```bash
|
|
cp .env.example .env
|
|
# Generate secrets:
|
|
echo "DB_PASSWORD=$(openssl rand -hex 16)" >> .env
|
|
echo "JWT_SECRET=$(openssl rand -hex 32)" >> .env
|
|
echo "ENCRYPTION_KEY=$(openssl rand -hex 32)" >> .env
|
|
```
|
|
|
|
- [ ] **Step 2: Run Prisma migration against Docker postgres**
|
|
|
|
```bash
|
|
cd backend
|
|
DATABASE_URL=postgresql://wraith:$(grep DB_PASSWORD ../.env | cut -d= -f2)@localhost:5432/wraith npx prisma migrate dev --name init
|
|
```
|
|
|
|
- [ ] **Step 3: Seed the database**
|
|
|
|
```bash
|
|
DATABASE_URL=... npx prisma db seed
|
|
```
|
|
|
|
- [ ] **Step 4: Verify Docker Compose up**
|
|
|
|
```bash
|
|
docker compose up -d
|
|
docker logs -f wraith-app
|
|
```
|
|
|
|
Expected: NestJS starts, serves API on port 3000, frontend loads in browser.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "feat: Phase 1 complete — foundation layer verified"
|
|
```
|
|
|
|
---
|
|
|
|
## Chunk 2: SSH + SFTP (Phase 2)
|
|
|
|
### Task 10: SSH Connection Service + Terminal Gateway
|
|
|
|
**Files:**
|
|
- Create: `backend/src/terminal/terminal.module.ts`
|
|
- Create: `backend/src/terminal/ssh-connection.service.ts`
|
|
- Create: `backend/src/terminal/terminal.gateway.ts`
|
|
|
|
This is the core of Wraith. The SSH connection service manages ssh2 connections, the terminal gateway bridges WebSocket to ssh2.
|
|
|
|
- [ ] **Step 1: Implement SSH connection service**
|
|
|
|
`backend/src/terminal/ssh-connection.service.ts`:
|
|
```typescript
|
|
import { Injectable, Logger } from '@nestjs/common';
|
|
import { Client, ClientChannel } from 'ssh2';
|
|
import { 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>();
|
|
|
|
constructor(
|
|
private credentials: CredentialsService,
|
|
private hosts: HostsService,
|
|
private prisma: PrismaService,
|
|
) {}
|
|
|
|
async connect(
|
|
hostId: number,
|
|
onData: (data: string) => void,
|
|
onClose: (reason: string) => void,
|
|
onHostKeyVerify: (fingerprint: string, isNew: boolean) => Promise<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();
|
|
|
|
return new Promise((resolve, reject) => {
|
|
client.on('ready', () => {
|
|
client.shell({ term: 'xterm-256color' }, (err, stream) => {
|
|
if (err) {
|
|
client.end();
|
|
return reject(err);
|
|
}
|
|
const session: SshSession = { id: sessionId, hostId, client, stream };
|
|
this.sessions.set(sessionId, session);
|
|
|
|
stream.on('data', (data: Buffer) => onData(data.toString('utf-8')));
|
|
stream.on('close', () => {
|
|
this.disconnect(sessionId);
|
|
onClose('Session ended');
|
|
});
|
|
|
|
// Update lastConnectedAt and create connection log
|
|
this.hosts.touchLastConnected(hostId);
|
|
this.prisma.connectionLog.create({
|
|
data: { hostId, protocol: host.protocol },
|
|
}).catch(() => {});
|
|
|
|
resolve(sessionId);
|
|
});
|
|
});
|
|
|
|
client.on('error', (err) => {
|
|
this.logger.error(`SSH error for host ${hostId}: ${err.message}`);
|
|
this.disconnect(sessionId);
|
|
onClose(err.message);
|
|
reject(err);
|
|
});
|
|
|
|
const connectConfig: any = {
|
|
host: host.hostname,
|
|
port: host.port,
|
|
username: cred?.username || 'root',
|
|
hostVerifier: (key: Buffer, verify: (accept: boolean) => void) => {
|
|
const fingerprint = createHash('sha256').update(key).digest('base64');
|
|
const fp = `SHA256:${fingerprint}`;
|
|
|
|
if (host.hostFingerprint === fp) {
|
|
verify(true); // known host — accept silently
|
|
return;
|
|
}
|
|
|
|
// Unknown or changed fingerprint — ask the user via WebSocket
|
|
const isNew = !host.hostFingerprint;
|
|
onHostKeyVerify(fp, isNew).then((accepted) => {
|
|
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;
|
|
}
|
|
} else if (cred?.password) {
|
|
connectConfig.password = cred.password;
|
|
}
|
|
|
|
client.connect(connectConfig);
|
|
});
|
|
}
|
|
|
|
write(sessionId: string, data: string) {
|
|
const session = this.sessions.get(sessionId);
|
|
if (session?.stream) {
|
|
session.stream.write(data);
|
|
}
|
|
}
|
|
|
|
resize(sessionId: string, cols: number, rows: number) {
|
|
const session = this.sessions.get(sessionId);
|
|
if (session?.stream) {
|
|
session.stream.setWindow(rows, cols, 0, 0);
|
|
}
|
|
}
|
|
|
|
disconnect(sessionId: string) {
|
|
const session = this.sessions.get(sessionId);
|
|
if (session) {
|
|
session.stream?.close();
|
|
session.client.end();
|
|
this.sessions.delete(sessionId);
|
|
|
|
// Update connection log with disconnect time
|
|
this.prisma.connectionLog.updateMany({
|
|
where: { hostId: session.hostId, disconnectedAt: null },
|
|
data: { disconnectedAt: new Date() },
|
|
}).catch(() => {});
|
|
}
|
|
}
|
|
|
|
getSession(sessionId: string): SshSession | undefined {
|
|
return this.sessions.get(sessionId);
|
|
}
|
|
|
|
getSftpChannel(sessionId: string): Promise<any> {
|
|
return new Promise((resolve, reject) => {
|
|
const session = this.sessions.get(sessionId);
|
|
if (!session) return reject(new Error('Session not found'));
|
|
session.client.sftp((err, sftp) => {
|
|
if (err) return reject(err);
|
|
resolve(sftp);
|
|
});
|
|
});
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Implement terminal gateway**
|
|
|
|
`backend/src/terminal/terminal.gateway.ts`:
|
|
```typescript
|
|
import { WebSocketGateway, WebSocketServer, OnGatewayConnection, OnGatewayDisconnect } from '@nestjs/websockets';
|
|
import { Logger } from '@nestjs/common';
|
|
import { Server } from 'ws';
|
|
import { WsAuthGuard } from '../auth/ws-auth.guard';
|
|
import { SshConnectionService } from './ssh-connection.service';
|
|
|
|
@WebSocketGateway({ path: '/ws/terminal' })
|
|
export class TerminalGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
|
@WebSocketServer() server: Server;
|
|
private readonly logger = new Logger(TerminalGateway.name);
|
|
private clientSessions = new Map<any, string[]>(); // ws client → sessionIds
|
|
|
|
constructor(
|
|
private ssh: SshConnectionService,
|
|
private wsAuth: WsAuthGuard,
|
|
) {}
|
|
|
|
handleConnection(client: any) {
|
|
const user = this.wsAuth.validateClient(client);
|
|
if (!user) {
|
|
client.close(4001, 'Unauthorized');
|
|
return;
|
|
}
|
|
this.clientSessions.set(client, []);
|
|
this.logger.log(`Terminal WS connected: ${user.email}`);
|
|
|
|
client.on('message', async (raw: Buffer) => {
|
|
try {
|
|
const msg = JSON.parse(raw.toString());
|
|
await this.handleMessage(client, msg);
|
|
} catch (err: any) {
|
|
this.send(client, { type: 'error', message: err.message });
|
|
}
|
|
});
|
|
}
|
|
|
|
handleDisconnect(client: any) {
|
|
const sessions = this.clientSessions.get(client) || [];
|
|
sessions.forEach((sid) => this.ssh.disconnect(sid));
|
|
this.clientSessions.delete(client);
|
|
}
|
|
|
|
private async handleMessage(client: any, msg: any) {
|
|
switch (msg.type) {
|
|
case 'connect': {
|
|
const sessionId = await this.ssh.connect(
|
|
msg.hostId,
|
|
(data) => this.send(client, { type: 'data', sessionId, data }),
|
|
(reason) => this.send(client, { type: 'disconnected', sessionId, reason }),
|
|
async (fingerprint, isNew) => {
|
|
// Send verification request to client
|
|
this.send(client, { type: 'host-key-verify', fingerprint, isNew });
|
|
return true; // auto-accept for now, full flow in Task 12
|
|
},
|
|
);
|
|
const sessions = this.clientSessions.get(client) || [];
|
|
sessions.push(sessionId);
|
|
this.clientSessions.set(client, sessions);
|
|
this.send(client, { type: 'connected', sessionId });
|
|
break;
|
|
}
|
|
case 'data': {
|
|
if (msg.sessionId) {
|
|
this.ssh.write(msg.sessionId, msg.data);
|
|
}
|
|
break;
|
|
}
|
|
case 'resize': {
|
|
if (msg.sessionId) {
|
|
this.ssh.resize(msg.sessionId, msg.cols, msg.rows);
|
|
}
|
|
break;
|
|
}
|
|
case 'disconnect': {
|
|
if (msg.sessionId) {
|
|
this.ssh.disconnect(msg.sessionId);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
private send(client: any, data: any) {
|
|
if (client.readyState === 1) { // WebSocket.OPEN
|
|
client.send(JSON.stringify(data));
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Create terminal module**
|
|
|
|
`backend/src/terminal/terminal.module.ts`:
|
|
```typescript
|
|
import { Module } from '@nestjs/common';
|
|
import { SshConnectionService } from './ssh-connection.service';
|
|
import { TerminalGateway } from './terminal.gateway';
|
|
import { VaultModule } from '../vault/vault.module';
|
|
import { ConnectionsModule } from '../connections/connections.module';
|
|
import { AuthModule } from '../auth/auth.module';
|
|
|
|
@Module({
|
|
imports: [VaultModule, ConnectionsModule, AuthModule],
|
|
providers: [SshConnectionService, TerminalGateway],
|
|
exports: [SshConnectionService],
|
|
})
|
|
export class TerminalModule {}
|
|
```
|
|
|
|
Register `TerminalModule` in `app.module.ts`.
|
|
|
|
- [ ] **Step 4: Add `uuid` dependency**
|
|
|
|
```bash
|
|
cd backend && npm install uuid && npm install -D @types/uuid
|
|
```
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "feat: SSH terminal gateway — ssh2 proxy over WebSocket"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 11: SFTP Gateway
|
|
|
|
**Files:**
|
|
- Create: `backend/src/terminal/sftp.gateway.ts`
|
|
- Modify: `backend/src/terminal/terminal.module.ts`
|
|
|
|
- [ ] **Step 1: Implement SFTP gateway**
|
|
|
|
`backend/src/terminal/sftp.gateway.ts`:
|
|
```typescript
|
|
import { WebSocketGateway, WebSocketServer, OnGatewayConnection, OnGatewayDisconnect } from '@nestjs/websockets';
|
|
import { Logger } from '@nestjs/common';
|
|
import { Server } from 'ws';
|
|
import { WsAuthGuard } from '../auth/ws-auth.guard';
|
|
import { SshConnectionService } from './ssh-connection.service';
|
|
|
|
const MAX_EDIT_SIZE = 5 * 1024 * 1024; // 5MB
|
|
|
|
@WebSocketGateway({ path: '/ws/sftp' })
|
|
export class SftpGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
|
@WebSocketServer() server: Server;
|
|
private readonly logger = new Logger(SftpGateway.name);
|
|
|
|
constructor(
|
|
private ssh: SshConnectionService,
|
|
private wsAuth: WsAuthGuard,
|
|
) {}
|
|
|
|
handleConnection(client: any) {
|
|
const user = this.wsAuth.validateClient(client);
|
|
if (!user) {
|
|
client.close(4001, 'Unauthorized');
|
|
return;
|
|
}
|
|
this.logger.log(`SFTP WS connected: ${user.email}`);
|
|
|
|
client.on('message', async (raw: Buffer) => {
|
|
try {
|
|
const msg = JSON.parse(raw.toString());
|
|
await this.handleMessage(client, msg);
|
|
} catch (err: any) {
|
|
this.send(client, { type: 'error', message: err.message });
|
|
}
|
|
});
|
|
}
|
|
|
|
handleDisconnect() {}
|
|
|
|
private async handleMessage(client: any, msg: any) {
|
|
const { sessionId } = msg;
|
|
if (!sessionId) {
|
|
return this.send(client, { type: 'error', message: 'sessionId required' });
|
|
}
|
|
|
|
const sftp = await this.ssh.getSftpChannel(sessionId);
|
|
|
|
switch (msg.type) {
|
|
case 'list': {
|
|
sftp.readdir(msg.path, (err: any, list: any[]) => {
|
|
if (err) return this.send(client, { type: 'error', message: err.message });
|
|
const entries = list.map((f: any) => ({
|
|
name: f.filename,
|
|
path: `${msg.path === '/' ? '' : msg.path}/${f.filename}`,
|
|
size: f.attrs.size,
|
|
isDirectory: (f.attrs.mode & 0o40000) !== 0,
|
|
permissions: (f.attrs.mode & 0o7777).toString(8),
|
|
modified: new Date(f.attrs.mtime * 1000).toISOString(),
|
|
}));
|
|
this.send(client, { type: 'list', path: msg.path, entries });
|
|
});
|
|
break;
|
|
}
|
|
case 'read': {
|
|
sftp.stat(msg.path, (err: any, stats: any) => {
|
|
if (err) return this.send(client, { type: 'error', message: err.message });
|
|
if (stats.size > MAX_EDIT_SIZE) {
|
|
return this.send(client, {
|
|
type: 'error',
|
|
message: `File too large for editing (${(stats.size / 1024 / 1024).toFixed(1)}MB, max 5MB). Download instead.`,
|
|
});
|
|
}
|
|
const chunks: Buffer[] = [];
|
|
const stream = sftp.createReadStream(msg.path);
|
|
stream.on('data', (chunk: Buffer) => chunks.push(chunk));
|
|
stream.on('end', () => {
|
|
const content = Buffer.concat(chunks).toString('utf-8');
|
|
this.send(client, { type: 'fileContent', path: msg.path, content, encoding: 'utf-8' });
|
|
});
|
|
stream.on('error', (e: any) => this.send(client, { type: 'error', message: e.message }));
|
|
});
|
|
break;
|
|
}
|
|
case 'write': {
|
|
const stream = sftp.createWriteStream(msg.path);
|
|
stream.end(Buffer.from(msg.data, 'utf-8'), () => {
|
|
this.send(client, { type: 'saved', path: msg.path });
|
|
});
|
|
stream.on('error', (e: any) => this.send(client, { type: 'error', message: e.message }));
|
|
break;
|
|
}
|
|
case 'mkdir': {
|
|
sftp.mkdir(msg.path, (err: any) => {
|
|
if (err) return this.send(client, { type: 'error', message: err.message });
|
|
this.send(client, { type: 'created', path: msg.path });
|
|
});
|
|
break;
|
|
}
|
|
case 'rename': {
|
|
sftp.rename(msg.oldPath, msg.newPath, (err: any) => {
|
|
if (err) return this.send(client, { type: 'error', message: err.message });
|
|
this.send(client, { type: 'renamed', oldPath: msg.oldPath, newPath: msg.newPath });
|
|
});
|
|
break;
|
|
}
|
|
case 'delete': {
|
|
// Try unlink (file), fallback to rmdir (directory)
|
|
sftp.unlink(msg.path, (err: any) => {
|
|
if (err) {
|
|
sftp.rmdir(msg.path, (err2: any) => {
|
|
if (err2) return this.send(client, { type: 'error', message: err2.message });
|
|
this.send(client, { type: 'deleted', path: msg.path });
|
|
});
|
|
} else {
|
|
this.send(client, { type: 'deleted', path: msg.path });
|
|
}
|
|
});
|
|
break;
|
|
}
|
|
case 'chmod': {
|
|
const mode = parseInt(msg.mode, 8);
|
|
sftp.chmod(msg.path, mode, (err: any) => {
|
|
if (err) return this.send(client, { type: 'error', message: err.message });
|
|
this.send(client, { type: 'chmodDone', path: msg.path, mode: msg.mode });
|
|
});
|
|
break;
|
|
}
|
|
case 'stat': {
|
|
sftp.stat(msg.path, (err: any, stats: any) => {
|
|
if (err) return this.send(client, { type: 'error', message: err.message });
|
|
this.send(client, {
|
|
type: 'stat',
|
|
path: msg.path,
|
|
size: stats.size,
|
|
isDirectory: (stats.mode & 0o40000) !== 0,
|
|
permissions: (stats.mode & 0o7777).toString(8),
|
|
modified: new Date(stats.mtime * 1000).toISOString(),
|
|
accessed: new Date(stats.atime * 1000).toISOString(),
|
|
});
|
|
});
|
|
break;
|
|
}
|
|
case 'download': {
|
|
// Stream file data to client in chunks
|
|
const readStream = sftp.createReadStream(msg.path);
|
|
sftp.stat(msg.path, (err: any, stats: any) => {
|
|
if (err) return this.send(client, { type: 'error', message: err.message });
|
|
const transferId = `dl-${Date.now()}`;
|
|
let sent = 0;
|
|
this.send(client, { type: 'downloadStart', transferId, path: msg.path, total: stats.size });
|
|
readStream.on('data', (chunk: Buffer) => {
|
|
sent += chunk.length;
|
|
client.send(JSON.stringify({
|
|
type: 'downloadChunk',
|
|
transferId,
|
|
data: chunk.toString('base64'),
|
|
progress: { bytes: sent, total: stats.size },
|
|
}));
|
|
});
|
|
readStream.on('end', () => {
|
|
this.send(client, { type: 'downloadComplete', transferId });
|
|
});
|
|
readStream.on('error', (e: any) => {
|
|
this.send(client, { type: 'error', message: e.message });
|
|
});
|
|
});
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
private send(client: any, data: any) {
|
|
if (client.readyState === 1) {
|
|
client.send(JSON.stringify(data));
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Register SftpGateway in terminal.module.ts**
|
|
|
|
Add `SftpGateway` to providers array.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "feat: SFTP gateway — file operations over WebSocket"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 12: Frontend — Terminal + SFTP
|
|
|
|
**Files:**
|
|
- Create: `frontend/composables/useTerminal.ts`, `frontend/composables/useSftp.ts`
|
|
- Create: `frontend/stores/session.store.ts`
|
|
- Create: `frontend/components/session/SessionContainer.vue`, `SessionTab.vue`
|
|
- Create: `frontend/components/terminal/TerminalInstance.vue`, `TerminalTabs.vue`, `SplitPane.vue`
|
|
- Create: `frontend/components/sftp/SftpSidebar.vue`, `FileTree.vue`, `FileEditor.vue`, `TransferStatus.vue`
|
|
- Modify: `frontend/layouts/default.vue`, `frontend/pages/index.vue`
|
|
|
|
- [ ] **Step 1: Create session store**
|
|
|
|
`frontend/stores/session.store.ts`:
|
|
```typescript
|
|
import { defineStore } from 'pinia'
|
|
|
|
interface Session {
|
|
id: string // uuid from backend
|
|
hostId: number
|
|
hostName: string
|
|
protocol: 'ssh' | 'rdp'
|
|
color: string | null
|
|
active: boolean
|
|
}
|
|
|
|
export const useSessionStore = defineStore('sessions', {
|
|
state: () => ({
|
|
sessions: [] as Session[],
|
|
activeSessionId: null as string | null,
|
|
}),
|
|
getters: {
|
|
activeSession: (state) => state.sessions.find(s => s.id === state.activeSessionId),
|
|
hasSessions: (state) => state.sessions.length > 0,
|
|
},
|
|
actions: {
|
|
addSession(session: Session) {
|
|
this.sessions.push(session)
|
|
this.activeSessionId = session.id
|
|
},
|
|
removeSession(id: string) {
|
|
this.sessions = this.sessions.filter(s => s.id !== id)
|
|
if (this.activeSessionId === id) {
|
|
this.activeSessionId = this.sessions.length ? this.sessions[this.sessions.length - 1].id : null
|
|
}
|
|
},
|
|
setActive(id: string) {
|
|
this.activeSessionId = id
|
|
},
|
|
},
|
|
})
|
|
```
|
|
|
|
- [ ] **Step 2: Create useTerminal composable**
|
|
|
|
`frontend/composables/useTerminal.ts`:
|
|
```typescript
|
|
import { Terminal } from '@xterm/xterm'
|
|
import { FitAddon } from '@xterm/addon-fit'
|
|
import { SearchAddon } from '@xterm/addon-search'
|
|
import { WebLinksAddon } from '@xterm/addon-web-links'
|
|
import { WebglAddon } from '@xterm/addon-webgl'
|
|
import { useAuthStore } from '~/stores/auth.store'
|
|
import { useSessionStore } from '~/stores/session.store'
|
|
|
|
export function useTerminal() {
|
|
const auth = useAuthStore()
|
|
const sessions = useSessionStore()
|
|
let ws: WebSocket | null = null
|
|
|
|
function createTerminal(container: HTMLElement, options?: Partial<{ fontSize: number; scrollback: number }>) {
|
|
const term = new Terminal({
|
|
cursorBlink: true,
|
|
fontSize: options?.fontSize || 14,
|
|
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace",
|
|
scrollback: options?.scrollback || 10000,
|
|
theme: {
|
|
background: '#0a0a0f',
|
|
foreground: '#e4e4ef',
|
|
cursor: '#5c7cfa',
|
|
selectionBackground: '#364fc744',
|
|
},
|
|
})
|
|
|
|
const fitAddon = new FitAddon()
|
|
const searchAddon = new SearchAddon()
|
|
term.loadAddon(fitAddon)
|
|
term.loadAddon(searchAddon)
|
|
term.loadAddon(new WebLinksAddon())
|
|
|
|
term.open(container)
|
|
|
|
try {
|
|
term.loadAddon(new WebglAddon())
|
|
} catch {
|
|
// WebGL not available, fall back to canvas
|
|
}
|
|
|
|
fitAddon.fit()
|
|
const resizeObserver = new ResizeObserver(() => fitAddon.fit())
|
|
resizeObserver.observe(container)
|
|
|
|
return { term, fitAddon, searchAddon, resizeObserver }
|
|
}
|
|
|
|
function connectToHost(hostId: number, hostName: string, protocol: 'ssh', color: string | null, term: Terminal, fitAddon: FitAddon) {
|
|
const wsUrl = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/terminal?token=${auth.token}`
|
|
ws = new WebSocket(wsUrl)
|
|
|
|
ws.onopen = () => {
|
|
ws!.send(JSON.stringify({ type: 'connect', hostId }))
|
|
}
|
|
|
|
ws.onmessage = (event) => {
|
|
const msg = JSON.parse(event.data)
|
|
switch (msg.type) {
|
|
case 'connected':
|
|
sessions.addSession({ id: msg.sessionId, hostId, hostName, protocol, color, active: true })
|
|
// Send initial terminal size
|
|
ws!.send(JSON.stringify({ type: 'resize', sessionId: msg.sessionId, cols: term.cols, rows: term.rows }))
|
|
break
|
|
case 'data':
|
|
term.write(msg.data)
|
|
break
|
|
case 'disconnected':
|
|
sessions.removeSession(msg.sessionId)
|
|
break
|
|
case 'host-key-verify':
|
|
// Auto-accept for now — full UX in polish phase
|
|
ws!.send(JSON.stringify({ type: 'host-key-accept' }))
|
|
break
|
|
case 'error':
|
|
term.write(`\r\n\x1b[31mError: ${msg.message}\x1b[0m\r\n`)
|
|
break
|
|
}
|
|
}
|
|
|
|
ws.onclose = () => {
|
|
term.write('\r\n\x1b[33mConnection closed.\x1b[0m\r\n')
|
|
}
|
|
|
|
// Terminal input → WebSocket
|
|
term.onData((data) => {
|
|
if (ws?.readyState === WebSocket.OPEN) {
|
|
const sessionId = sessions.activeSession?.id
|
|
if (sessionId) {
|
|
ws.send(JSON.stringify({ type: 'data', sessionId, data }))
|
|
}
|
|
}
|
|
})
|
|
|
|
// Terminal resize → WebSocket
|
|
term.onResize(({ cols, rows }) => {
|
|
if (ws?.readyState === WebSocket.OPEN) {
|
|
const sessionId = sessions.activeSession?.id
|
|
if (sessionId) {
|
|
ws.send(JSON.stringify({ type: 'resize', sessionId, cols, rows }))
|
|
}
|
|
}
|
|
})
|
|
|
|
return ws
|
|
}
|
|
|
|
function disconnect(sessionId: string) {
|
|
if (ws?.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify({ type: 'disconnect', sessionId }))
|
|
}
|
|
sessions.removeSession(sessionId)
|
|
}
|
|
|
|
return { createTerminal, connectToHost, disconnect }
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Create useSftp composable**
|
|
|
|
`frontend/composables/useSftp.ts`:
|
|
```typescript
|
|
import { useAuthStore } from '~/stores/auth.store'
|
|
|
|
export function useSftp(sessionId: Ref<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[]>([])
|
|
|
|
function connect() {
|
|
const wsUrl = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/sftp?token=${auth.token}`
|
|
ws = new WebSocket(wsUrl)
|
|
|
|
ws.onmessage = (event) => {
|
|
const msg = JSON.parse(event.data)
|
|
switch (msg.type) {
|
|
case 'list':
|
|
entries.value = msg.entries.sort((a: any, b: any) => {
|
|
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1
|
|
return a.name.localeCompare(b.name)
|
|
})
|
|
currentPath.value = msg.path
|
|
break
|
|
case 'fileContent':
|
|
fileContent.value = { path: msg.path, content: msg.content }
|
|
break
|
|
case 'saved':
|
|
fileContent.value = null
|
|
list(currentPath.value)
|
|
break
|
|
case 'progress':
|
|
// Update transfer progress
|
|
break
|
|
case 'error':
|
|
console.error('SFTP error:', msg.message)
|
|
break
|
|
}
|
|
}
|
|
|
|
return ws
|
|
}
|
|
|
|
function send(msg: any) {
|
|
if (ws?.readyState === WebSocket.OPEN && sessionId.value) {
|
|
ws.send(JSON.stringify({ ...msg, sessionId: sessionId.value }))
|
|
}
|
|
}
|
|
|
|
function list(path: string) { send({ type: 'list', path }) }
|
|
function readFile(path: string) { send({ type: 'read', path }) }
|
|
function writeFile(path: string, data: string) { send({ type: 'write', path, data }) }
|
|
function mkdir(path: string) { send({ type: 'mkdir', path }) }
|
|
function rename(oldPath: string, newPath: string) { send({ type: 'rename', oldPath, newPath }) }
|
|
function remove(path: string) { send({ type: 'delete', path }) }
|
|
function chmod(path: string, mode: string) { send({ type: 'chmod', path, mode }) }
|
|
function download(path: string) { send({ type: 'download', path }) }
|
|
|
|
function disconnect() {
|
|
ws?.close()
|
|
ws = null
|
|
}
|
|
|
|
return {
|
|
entries, currentPath, fileContent, transfers,
|
|
connect, disconnect, list, readFile, writeFile, mkdir, rename, remove, chmod, download,
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Create SessionContainer, SessionTab, TerminalInstance components**
|
|
|
|
`frontend/components/session/SessionContainer.vue` — persistent container in default layout. Renders all active sessions, shows/hides via `v-show` based on `activeSessionId`. Tab bar on top. Each SSH session renders `TerminalInstance` + `SftpSidebar`. Each RDP session renders `RdpCanvas` (Phase 3).
|
|
|
|
`frontend/components/session/SessionTab.vue` — individual tab in the tab bar. Shows host name, color dot, protocol icon, close button.
|
|
|
|
`frontend/components/terminal/TerminalInstance.vue` — wraps xterm.js in a Vue component. Uses `useTerminal()` composable. Mounts terminal to a div ref. Handles resize via ResizeObserver. Props: `sessionId`, `hostId`. Imports xterm.js CSS.
|
|
|
|
`frontend/components/terminal/TerminalTabs.vue` — tab bar component showing all active sessions.
|
|
|
|
`frontend/components/terminal/SplitPane.vue` — flex container that allows horizontal/vertical splitting of terminal instances within a session. Uses CSS `flex-direction` toggle and a draggable divider.
|
|
|
|
- [ ] **Step 5: Create SFTP components**
|
|
|
|
`frontend/components/sftp/SftpSidebar.vue` — resizable panel on the left of the terminal. Uses `useSftp()` composable. Shows `FileTree` component. Top bar with path breadcrumbs and action buttons (upload, new folder, refresh).
|
|
|
|
`frontend/components/sftp/FileTree.vue` — recursive tree of remote filesystem entries. Directories are expandable (lazy-load children on click). Files are clickable (open in FileEditor if text, download if binary). Right-click context menu for rename/delete/chmod/download.
|
|
|
|
`frontend/components/sftp/FileEditor.vue` — wraps Monaco Editor. Opens when a text file is clicked in the tree. Shows file path, save button, close button. Unsaved changes warning on close.
|
|
|
|
`frontend/components/sftp/TransferStatus.vue` — bottom bar showing active uploads/downloads with progress bars, file names, speed, ETA.
|
|
|
|
- [ ] **Step 6: Update default.vue layout — add SessionContainer**
|
|
|
|
The `SessionContainer` should live in the default layout so it persists across page navigation. When sessions exist, the session area takes over the main content area. The connection manager sidebar remains for launching new connections.
|
|
|
|
- [ ] **Step 7: Update index.vue — connect-on-click**
|
|
|
|
When a host card is clicked (not the edit button), call `useTerminal().connectToHost()` to open a new SSH or RDP session. Add an "open" action to HostCard.
|
|
|
|
- [ ] **Step 8: Install xterm.js CSS**
|
|
|
|
Add to `frontend/nuxt.config.ts` CSS array: `'@xterm/xterm/css/xterm.css'`
|
|
|
|
- [ ] **Step 9: Commit**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "feat: Phase 2 — SSH terminal + SFTP sidebar in browser"
|
|
```
|
|
|
|
---
|
|
|
|
## Chunk 3: RDP + Polish (Phases 3-4)
|
|
|
|
### Task 13: RDP Backend — Guacamole Service + Gateway
|
|
|
|
**Files:**
|
|
- Create: `backend/src/rdp/rdp.module.ts`
|
|
- Create: `backend/src/rdp/guacamole.service.ts`
|
|
- Create: `backend/src/rdp/rdp.gateway.ts`
|
|
- Modify: `backend/src/app.module.ts`
|
|
|
|
- [ ] **Step 1: Implement Guacamole service**
|
|
|
|
`backend/src/rdp/guacamole.service.ts`:
|
|
|
|
The Guacamole service opens a raw TCP socket to guacd and speaks the Guacamole wire protocol. This is NOT an HTTP integration — it's a custom TCP client that translates between the Guacamole instruction format and JSON WebSocket messages.
|
|
|
|
```typescript
|
|
import { Injectable, Logger } from '@nestjs/common';
|
|
import * as net from 'net';
|
|
|
|
/**
|
|
* Guacamole wire protocol: instructions are comma-separated fields
|
|
* terminated by semicolons. Each field is length-prefixed.
|
|
* Example: "4.size,4.1024,3.768;"
|
|
*/
|
|
|
|
@Injectable()
|
|
export class GuacamoleService {
|
|
private readonly logger = new Logger(GuacamoleService.name);
|
|
private readonly host = process.env.GUACD_HOST || 'guacd';
|
|
private readonly port = parseInt(process.env.GUACD_PORT || '4822', 10);
|
|
|
|
async connect(params: {
|
|
hostname: string;
|
|
port: number;
|
|
username: string;
|
|
password: string;
|
|
domain?: string;
|
|
width: number;
|
|
height: number;
|
|
dpi?: number;
|
|
security?: string;
|
|
colorDepth?: number;
|
|
ignoreCert?: boolean;
|
|
}): Promise<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 protocol
|
|
socket.write(this.encode('select', 'rdp'));
|
|
|
|
let buffer = '';
|
|
const onData = (data: Buffer) => {
|
|
buffer += data.toString('utf-8');
|
|
|
|
// Wait for "args" instruction from guacd
|
|
if (buffer.includes(';')) {
|
|
socket.removeListener('data', onData);
|
|
|
|
// Phase 2: CONNECT with RDP parameters
|
|
const connectArgs = this.buildRdpArgs(params);
|
|
socket.write(connectArgs);
|
|
|
|
resolve(socket);
|
|
}
|
|
};
|
|
socket.on('data', onData);
|
|
});
|
|
|
|
socket.on('error', (err) => {
|
|
this.logger.error(`guacd connection error: ${err.message}`);
|
|
reject(err);
|
|
});
|
|
|
|
socket.setTimeout(10000, () => {
|
|
socket.destroy();
|
|
reject(new Error('guacd connection timeout'));
|
|
});
|
|
});
|
|
}
|
|
|
|
private buildRdpArgs(params: any): string {
|
|
const args: Record<string, string> = {
|
|
hostname: params.hostname,
|
|
port: String(params.port),
|
|
username: params.username,
|
|
password: params.password,
|
|
width: String(params.width),
|
|
height: String(params.height),
|
|
dpi: String(params.dpi || 96),
|
|
security: params.security || 'any',
|
|
'color-depth': String(params.colorDepth || 24),
|
|
'ignore-cert': params.ignoreCert !== false ? 'true' : 'false',
|
|
'disable-audio': 'false',
|
|
'enable-wallpaper': 'false',
|
|
'enable-theming': 'true',
|
|
'enable-font-smoothing': 'true',
|
|
'resize-method': 'reconnect',
|
|
};
|
|
if (params.domain) args.domain = params.domain;
|
|
|
|
// Build connect instruction with all args
|
|
const values = Object.values(args);
|
|
return this.encode('connect', ...values);
|
|
}
|
|
|
|
/** Encode a Guacamole instruction: "opcode,arg1,arg2,...;" with length prefixes */
|
|
encode(...parts: string[]): string {
|
|
return parts.map((p) => `${p.length}.${p}`).join(',') + ';';
|
|
}
|
|
|
|
/** Decode a Guacamole instruction back to string array */
|
|
decode(instruction: string): string[] {
|
|
const parts: string[] = [];
|
|
let pos = 0;
|
|
while (pos < instruction.length) {
|
|
const dotIndex = instruction.indexOf('.', pos);
|
|
if (dotIndex === -1) break;
|
|
const len = parseInt(instruction.substring(pos, dotIndex), 10);
|
|
const value = instruction.substring(dotIndex + 1, dotIndex + 1 + len);
|
|
parts.push(value);
|
|
pos = dotIndex + 1 + len + 1; // skip comma or semicolon
|
|
}
|
|
return parts;
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Implement RDP gateway**
|
|
|
|
`backend/src/rdp/rdp.gateway.ts`:
|
|
```typescript
|
|
import { WebSocketGateway, WebSocketServer, OnGatewayConnection, OnGatewayDisconnect } from '@nestjs/websockets';
|
|
import { Logger } from '@nestjs/common';
|
|
import { Server } from 'ws';
|
|
import * as net from 'net';
|
|
import { WsAuthGuard } from '../auth/ws-auth.guard';
|
|
import { GuacamoleService } from './guacamole.service';
|
|
import { CredentialsService } from '../vault/credentials.service';
|
|
import { HostsService } from '../connections/hosts.service';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
|
|
@WebSocketGateway({ path: '/ws/rdp' })
|
|
export class RdpGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
|
@WebSocketServer() server: Server;
|
|
private readonly logger = new Logger(RdpGateway.name);
|
|
private clientSockets = new Map<any, net.Socket>(); // ws client → guacd socket
|
|
|
|
constructor(
|
|
private guacamole: GuacamoleService,
|
|
private credentials: CredentialsService,
|
|
private hosts: HostsService,
|
|
private prisma: PrismaService,
|
|
private wsAuth: WsAuthGuard,
|
|
) {}
|
|
|
|
handleConnection(client: any) {
|
|
const user = this.wsAuth.validateClient(client);
|
|
if (!user) {
|
|
client.close(4001, 'Unauthorized');
|
|
return;
|
|
}
|
|
this.logger.log(`RDP WS connected: ${user.email}`);
|
|
|
|
client.on('message', async (raw: Buffer) => {
|
|
try {
|
|
const msg = JSON.parse(raw.toString());
|
|
if (msg.type === 'connect') {
|
|
await this.handleConnect(client, msg);
|
|
} else if (msg.type === 'guac') {
|
|
// Forward Guacamole instruction to guacd
|
|
const socket = this.clientSockets.get(client);
|
|
if (socket) {
|
|
socket.write(msg.instruction);
|
|
}
|
|
}
|
|
} catch (err: any) {
|
|
this.send(client, { type: 'error', message: err.message });
|
|
}
|
|
});
|
|
}
|
|
|
|
handleDisconnect(client: any) {
|
|
const socket = this.clientSockets.get(client);
|
|
if (socket) {
|
|
socket.destroy();
|
|
this.clientSockets.delete(client);
|
|
}
|
|
}
|
|
|
|
private async handleConnect(client: any, msg: any) {
|
|
const host = await this.hosts.findOne(msg.hostId);
|
|
const cred = host.credentialId
|
|
? await this.credentials.decryptForConnection(host.credentialId)
|
|
: null;
|
|
|
|
const socket = await this.guacamole.connect({
|
|
hostname: host.hostname,
|
|
port: host.port,
|
|
username: cred?.username || '',
|
|
password: cred?.password || '',
|
|
domain: cred?.domain || undefined,
|
|
width: msg.width || 1920,
|
|
height: msg.height || 1080,
|
|
dpi: msg.dpi || 96,
|
|
security: msg.security || 'any',
|
|
colorDepth: msg.colorDepth || 24,
|
|
});
|
|
|
|
this.clientSockets.set(client, socket);
|
|
|
|
// Forward guacd data to browser
|
|
socket.on('data', (data: Buffer) => {
|
|
if (client.readyState === 1) {
|
|
client.send(JSON.stringify({ type: 'guac', instruction: data.toString('utf-8') }));
|
|
}
|
|
});
|
|
|
|
socket.on('close', () => {
|
|
this.send(client, { type: 'disconnected', reason: 'RDP session closed' });
|
|
this.clientSockets.delete(client);
|
|
});
|
|
|
|
socket.on('error', (err) => {
|
|
this.send(client, { type: 'error', message: err.message });
|
|
});
|
|
|
|
// Update connection tracking
|
|
this.hosts.touchLastConnected(host.id);
|
|
this.prisma.connectionLog.create({
|
|
data: { hostId: host.id, protocol: 'rdp' },
|
|
}).catch(() => {});
|
|
|
|
this.send(client, { type: 'connected', hostId: host.id });
|
|
}
|
|
|
|
private send(client: any, data: any) {
|
|
if (client.readyState === 1) {
|
|
client.send(JSON.stringify(data));
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Create RDP module**
|
|
|
|
`backend/src/rdp/rdp.module.ts`:
|
|
```typescript
|
|
import { Module } from '@nestjs/common';
|
|
import { GuacamoleService } from './guacamole.service';
|
|
import { RdpGateway } from './rdp.gateway';
|
|
import { VaultModule } from '../vault/vault.module';
|
|
import { ConnectionsModule } from '../connections/connections.module';
|
|
import { AuthModule } from '../auth/auth.module';
|
|
|
|
@Module({
|
|
imports: [VaultModule, ConnectionsModule, AuthModule],
|
|
providers: [GuacamoleService, RdpGateway],
|
|
})
|
|
export class RdpModule {}
|
|
```
|
|
|
|
Register `RdpModule` in `app.module.ts`.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "feat: RDP gateway — Guacamole tunnel to guacd over WebSocket"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 14: RDP Frontend
|
|
|
|
**Files:**
|
|
- Create: `frontend/composables/useRdp.ts`
|
|
- Create: `frontend/components/rdp/RdpCanvas.vue`, `RdpToolbar.vue`
|
|
|
|
- [ ] **Step 1: Create useRdp composable**
|
|
|
|
`frontend/composables/useRdp.ts`:
|
|
```typescript
|
|
import Guacamole from 'guacamole-common-js'
|
|
import { useAuthStore } from '~/stores/auth.store'
|
|
import { useSessionStore } from '~/stores/session.store'
|
|
|
|
export function useRdp() {
|
|
const auth = useAuthStore()
|
|
const sessions = useSessionStore()
|
|
|
|
function connectRdp(
|
|
container: HTMLElement,
|
|
hostId: number,
|
|
hostName: string,
|
|
color: string | null,
|
|
options?: { width?: number; height?: number },
|
|
) {
|
|
const wsUrl = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/rdp?token=${auth.token}`
|
|
const ws = new WebSocket(wsUrl)
|
|
|
|
// Guacamole tunnel wrapping our WebSocket
|
|
const tunnel = new Guacamole.WebSocketTunnel(wsUrl)
|
|
// We need to handle this custom since we have a JSON wrapper
|
|
|
|
let client: Guacamole.Client | null = null
|
|
|
|
ws.onopen = () => {
|
|
ws.send(JSON.stringify({
|
|
type: 'connect',
|
|
hostId,
|
|
width: options?.width || container.clientWidth,
|
|
height: options?.height || container.clientHeight,
|
|
}))
|
|
}
|
|
|
|
ws.onmessage = (event) => {
|
|
const msg = JSON.parse(event.data)
|
|
switch (msg.type) {
|
|
case 'connected': {
|
|
// Initialize Guacamole client with custom tunnel
|
|
client = new Guacamole.Client(tunnel)
|
|
const display = client.getDisplay().getElement()
|
|
container.appendChild(display)
|
|
|
|
// Set up input
|
|
const mouse = new Guacamole.Mouse(display)
|
|
mouse.onEach(['mousedown', 'mousemove', 'mouseup'], (e: any) => {
|
|
client?.sendMouseState(e.state)
|
|
})
|
|
|
|
const keyboard = new Guacamole.Keyboard(document)
|
|
keyboard.onkeydown = (keysym: number) => client?.sendKeyEvent(1, keysym)
|
|
keyboard.onkeyup = (keysym: number) => client?.sendKeyEvent(0, keysym)
|
|
|
|
sessions.addSession({
|
|
id: `rdp-${hostId}-${Date.now()}`,
|
|
hostId,
|
|
hostName,
|
|
protocol: 'rdp',
|
|
color,
|
|
active: true,
|
|
})
|
|
break
|
|
}
|
|
case 'guac': {
|
|
// Forward Guacamole instruction to client
|
|
if (client) {
|
|
tunnel.oninstruction?.(msg.instruction)
|
|
}
|
|
break
|
|
}
|
|
case 'error':
|
|
console.error('RDP error:', msg.message)
|
|
break
|
|
case 'disconnected':
|
|
client?.disconnect()
|
|
break
|
|
}
|
|
}
|
|
|
|
return { ws, getClient: () => client }
|
|
}
|
|
|
|
return { connectRdp }
|
|
}
|
|
```
|
|
|
|
**Note:** The guacamole-common-js integration may need adjustment during implementation. The standard `WebSocketTunnel` expects raw Guacamole protocol over WebSocket, but our gateway wraps instructions in JSON. Two approaches:
|
|
1. Implement a custom `Guacamole.Tunnel` that speaks JSON over WebSocket
|
|
2. Switch the RDP gateway to pass raw Guacamole instructions without JSON wrapping
|
|
|
|
The implementer should evaluate both approaches during Phase 3 and choose whichever produces cleaner code. The custom tunnel approach is likely simpler.
|
|
|
|
- [ ] **Step 2: Create RdpCanvas and RdpToolbar components**
|
|
|
|
`frontend/components/rdp/RdpCanvas.vue` — wraps the Guacamole display element. Uses `useRdp()` composable. Full-size container that resizes with the parent. Props: `sessionId`, `hostId`.
|
|
|
|
`frontend/components/rdp/RdpToolbar.vue` — floating toolbar overlay for RDP sessions. Buttons: clipboard (text input dialog for paste), fullscreen toggle (HTML5 Fullscreen API), disconnect, settings dropdown (color depth, resize behavior).
|
|
|
|
- [ ] **Step 3: Update SessionContainer to handle RDP sessions**
|
|
|
|
When a session has `protocol === 'rdp'`, render `RdpCanvas` instead of `TerminalInstance + SftpSidebar`.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "feat: Phase 3 — RDP via Guacamole in browser"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 15: Vault Management UI
|
|
|
|
**Files:**
|
|
- Create: `frontend/composables/useVault.ts`
|
|
- Create: `frontend/pages/vault/index.vue`, `vault/keys.vue`, `vault/credentials.vue`
|
|
- Create: `frontend/components/vault/KeyImportDialog.vue`, `CredentialForm.vue`
|
|
|
|
- [ ] **Step 1: Create useVault composable**
|
|
|
|
`frontend/composables/useVault.ts`:
|
|
```typescript
|
|
import { useAuthStore } from '~/stores/auth.store'
|
|
|
|
export function useVault() {
|
|
const auth = useAuthStore()
|
|
const headers = () => ({ Authorization: `Bearer ${auth.token}` })
|
|
|
|
// SSH Keys
|
|
async function listKeys() {
|
|
return $fetch('/api/ssh-keys', { headers: headers() })
|
|
}
|
|
async function importKey(data: { name: string; privateKey: string; passphrase?: string; publicKey?: string }) {
|
|
return $fetch('/api/ssh-keys', { method: 'POST', body: data, headers: headers() })
|
|
}
|
|
async function deleteKey(id: number) {
|
|
return $fetch(`/api/ssh-keys/${id}`, { method: 'DELETE', headers: headers() })
|
|
}
|
|
|
|
// Credentials
|
|
async function listCredentials() {
|
|
return $fetch('/api/credentials', { headers: headers() })
|
|
}
|
|
async function createCredential(data: any) {
|
|
return $fetch('/api/credentials', { method: 'POST', body: data, headers: headers() })
|
|
}
|
|
async function updateCredential(id: number, data: any) {
|
|
return $fetch(`/api/credentials/${id}`, { method: 'PUT', body: data, headers: headers() })
|
|
}
|
|
async function deleteCredential(id: number) {
|
|
return $fetch(`/api/credentials/${id}`, { method: 'DELETE', headers: headers() })
|
|
}
|
|
|
|
return {
|
|
listKeys, importKey, deleteKey,
|
|
listCredentials, createCredential, updateCredential, deleteCredential,
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Create vault pages**
|
|
|
|
`frontend/pages/vault/index.vue` — overview page with quick stats (number of keys, number of credentials). Links to keys and credentials sub-pages.
|
|
|
|
`frontend/pages/vault/keys.vue` — list of SSH keys (name, type, fingerprint, created date). "Import Key" button opens `KeyImportDialog`. Delete button per key with confirmation.
|
|
|
|
`frontend/pages/vault/credentials.vue` — list of credentials (name, username, type badge, associated hosts). "New Credential" button opens form. Edit and delete per credential.
|
|
|
|
- [ ] **Step 3: Create KeyImportDialog**
|
|
|
|
`frontend/components/vault/KeyImportDialog.vue` — PrimeVue Dialog. Fields: name (text), private key (textarea or file upload), public key (textarea or file upload, optional), passphrase (password input, optional). File upload accepts `.pem`, `.pub`, `id_rsa`, `id_ed25519`.
|
|
|
|
- [ ] **Step 4: Create CredentialForm**
|
|
|
|
`frontend/components/vault/CredentialForm.vue` — form component used in both create and edit modes. Fields: name, type dropdown (password/ssh_key), username, password (shown if type=password), SSH key dropdown (shown if type=ssh_key, populated from keys list), domain (optional, for RDP).
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "feat: vault management UI — SSH key import + credential CRUD"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 16: Quick Connect + Search + Connection History
|
|
|
|
**Files:**
|
|
- Create: `frontend/components/connections/QuickConnect.vue`
|
|
- Modify: `frontend/pages/index.vue` — add quick connect bar, search, recent connections section
|
|
|
|
- [ ] **Step 1: Create QuickConnect component**
|
|
|
|
`frontend/components/connections/QuickConnect.vue`:
|
|
```vue
|
|
<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>
|
|
```
|
|
|
|
- [ ] **Step 2: Add search filter to connection manager**
|
|
|
|
Update `frontend/pages/index.vue`: add a search input above the host grid. Filter `connections.hosts` by search term (name, hostname, tags) client-side. Add a "Recent" section above the full list showing hosts sorted by `lastConnectedAt`.
|
|
|
|
- [ ] **Step 3: Wire QuickConnect to terminal/RDP**
|
|
|
|
When QuickConnect emits a connection, create a temporary (unsaved) connection and open a terminal or RDP session. If the user wants to save it afterward, show a "Save this connection?" prompt.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "feat: quick connect, search, recent connections"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 17: Settings Page + Theming
|
|
|
|
**Files:**
|
|
- Create: `frontend/pages/settings.vue`
|
|
- Modify: `frontend/layouts/default.vue` — add dark/light toggle
|
|
|
|
- [ ] **Step 1: Create settings page**
|
|
|
|
`frontend/pages/settings.vue`:
|
|
```vue
|
|
<script setup lang="ts">
|
|
const auth = useAuthStore()
|
|
const settings = ref<Record<string, string>>({})
|
|
const loading = ref(false)
|
|
const saved = ref(false)
|
|
|
|
onMounted(async () => {
|
|
settings.value = await $fetch('/api/settings', {
|
|
headers: { Authorization: `Bearer ${auth.token}` },
|
|
})
|
|
})
|
|
|
|
async function save() {
|
|
loading.value = true
|
|
await $fetch('/api/settings', {
|
|
method: 'PUT',
|
|
body: settings.value,
|
|
headers: { Authorization: `Bearer ${auth.token}` },
|
|
})
|
|
loading.value = false
|
|
saved.value = true
|
|
setTimeout(() => saved.value = false, 2000)
|
|
}
|
|
|
|
const theme = computed({
|
|
get: () => settings.value.theme || 'dark',
|
|
set: (v: string) => { settings.value.theme = v },
|
|
})
|
|
|
|
const scrollback = computed({
|
|
get: () => settings.value.scrollbackLines || '10000',
|
|
set: (v: string) => { settings.value.scrollbackLines = v },
|
|
})
|
|
|
|
const fontSize = computed({
|
|
get: () => settings.value.fontSize || '14',
|
|
set: (v: string) => { settings.value.fontSize = v },
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="max-w-2xl mx-auto p-6">
|
|
<h2 class="text-xl font-bold text-white mb-6">Settings</h2>
|
|
|
|
<div class="space-y-6">
|
|
<div>
|
|
<label class="block text-sm text-gray-400 mb-1">Theme</label>
|
|
<select v-model="theme" class="bg-gray-800 text-white px-3 py-2 rounded border border-gray-700">
|
|
<option value="dark">Dark</option>
|
|
<option value="light">Light</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label class="block text-sm text-gray-400 mb-1">Terminal Font Size</label>
|
|
<input v-model="fontSize" type="number" min="8" max="32"
|
|
class="bg-gray-800 text-white px-3 py-2 rounded border border-gray-700 w-24" />
|
|
</div>
|
|
|
|
<div>
|
|
<label class="block text-sm text-gray-400 mb-1">Scrollback Lines</label>
|
|
<input v-model="scrollback" type="number" min="1000" max="100000" step="1000"
|
|
class="bg-gray-800 text-white px-3 py-2 rounded border border-gray-700 w-32" />
|
|
</div>
|
|
|
|
<button @click="save" :disabled="loading"
|
|
class="px-4 py-2 bg-wraith-600 hover:bg-wraith-700 text-white rounded">
|
|
{{ saved ? 'Saved!' : loading ? 'Saving...' : 'Save Settings' }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
```
|
|
|
|
- [ ] **Step 2: Wire theme toggle**
|
|
|
|
Apply `dark` class to `<html>` element based on theme setting. Persist via the settings API. Update Tailwind classes throughout to support both modes using `dark:` prefix.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "feat: Phase 4 — settings, theming, polish"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 18: Final Integration + Docker Verify
|
|
|
|
- [ ] **Step 1: Update app.module.ts — register all modules**
|
|
|
|
Ensure `app.module.ts` imports: `PrismaModule`, `AuthModule`, `VaultModule`, `ConnectionsModule`, `TerminalModule`, `RdpModule`, `SettingsModule`, `ServeStaticModule`.
|
|
|
|
- [ ] **Step 2: Full Docker build and test**
|
|
|
|
```bash
|
|
docker compose build
|
|
docker compose up -d
|
|
docker logs -f wraith-app
|
|
```
|
|
|
|
Verify:
|
|
- Login works (admin@wraith.local / wraith)
|
|
- Connection manager loads, can create hosts and groups
|
|
- SSH terminal connects to a test host
|
|
- SFTP sidebar shows remote files
|
|
- RDP connects to a Windows target (if available)
|
|
- Vault: can import SSH key, create credential, associate with host
|
|
- Settings: theme, font size, scrollback persist
|
|
|
|
- [ ] **Step 3: Commit + tag**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "feat: Wraith v0.1.0 — SSH + SFTP + RDP in a browser"
|
|
git tag v0.1.0
|
|
```
|
|
|
|
---
|
|
|
|
## Dependency Graph
|
|
|
|
```
|
|
Task 1 (scaffold) ──┬──→ Task 2 (prisma + bootstrap) ──→ Task 3 (encryption) ──→ Task 4 (auth)
|
|
│ ↓
|
|
│ Task 6 (vault backend)
|
|
│ ↓
|
|
└──→ Task 8 (frontend shell) ←── Task 5 (connections backend)
|
|
↓
|
|
Task 9 (docker compose up)
|
|
↓
|
|
┌──────────────────────────────────────────────┐
|
|
↓ ↓
|
|
Task 10 (SSH gateway) ──→ Task 11 (SFTP gateway) ──→ Task 12 (frontend terminal+sftp)
|
|
│
|
|
↓
|
|
Task 13 (RDP backend) ──→ Task 14 (RDP frontend)
|
|
↓
|
|
Task 15 (vault UI) ──→ Task 16 (quick connect + search) ──→ Task 17 (settings)
|
|
↓
|
|
Task 18 (final integration)
|
|
```
|
|
|
|
**Parallelizable groups:**
|
|
- Tasks 3 + 5 (after Task 2)
|
|
- Tasks 6 + 7 (after Task 3)
|
|
- Tasks 10-12 and Tasks 13-14 (after Task 9)
|
|
- Tasks 15, 16, 17 (after Task 12)
|