Compare commits
No commits in common. "v0.1.0" and "main" have entirely different histories.
@ -1,21 +1,6 @@
|
||||
# =============================================================================
|
||||
# Wraith — Build & Sign Release
|
||||
# Wraith — Build & Sign Release (Tauri v2)
|
||||
# =============================================================================
|
||||
# Builds the Wails v3 desktop app for Windows amd64, cross-compiles FreeRDP3
|
||||
# from source via MinGW, signs everything with Azure Key Vault EV cert,
|
||||
# then uploads to SeaweedFS.
|
||||
#
|
||||
# Trigger: push a tag matching v* (e.g. v1.0.0) or run manually.
|
||||
#
|
||||
# Required secrets:
|
||||
# AZURE_TENANT_ID — Azure AD tenant
|
||||
# AZURE_CLIENT_ID — Service principal client ID
|
||||
# AZURE_CLIENT_SECRET — Service principal secret
|
||||
# AZURE_KEY_VAULT_URL — e.g. https://my-vault.vault.azure.net
|
||||
# AZURE_CERT_NAME — Certificate/key name in the vault
|
||||
# GIT_TOKEN — PAT for cloning private repo
|
||||
# =============================================================================
|
||||
|
||||
name: Build & Sign Wraith
|
||||
|
||||
on:
|
||||
@ -24,243 +9,189 @@ on:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
EXTRA_PATH: C:\Program Files (x86)\NSIS;C:\Program Files\Eclipse Adoptium\jre-21.0.10.7-hotspot\bin;C:\Users\vantz\.cargo\bin;C:\Users\vantz\.rustup\toolchains\stable-x86_64-pc-windows-msvc\bin;C:\Program Files\nodejs
|
||||
|
||||
jobs:
|
||||
build-and-sign:
|
||||
name: Build Windows + Sign
|
||||
runs-on: linux
|
||||
runs-on: windows
|
||||
steps:
|
||||
# ---------------------------------------------------------------
|
||||
# Checkout
|
||||
# ---------------------------------------------------------------
|
||||
- name: Checkout code
|
||||
shell: powershell
|
||||
run: |
|
||||
git clone --depth 1 --branch ${{ github.ref_name }} \
|
||||
https://${{ secrets.GIT_TOKEN }}@git.command.vigilcyber.com/vstockwell/wraith.git .
|
||||
git clone --depth 1 --branch ${{ github.ref_name }} https://${{ secrets.GIT_TOKEN }}@git.command.vigilcyber.com/vstockwell/wraith.git .
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Extract version from tag
|
||||
# ---------------------------------------------------------------
|
||||
- name: Get version from tag
|
||||
id: version
|
||||
- name: Configure Rust
|
||||
shell: powershell
|
||||
run: |
|
||||
TAG=$(echo "${{ github.ref_name }}" | sed 's/^v//')
|
||||
echo "version=${TAG}" >> $GITHUB_OUTPUT
|
||||
echo "Building version: ${TAG}"
|
||||
$env:Path = "$env:EXTRA_PATH;$env:Path"
|
||||
$ErrorActionPreference = "Continue"
|
||||
rustup default stable
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Install toolchain
|
||||
# ---------------------------------------------------------------
|
||||
- name: Install build dependencies
|
||||
- name: Verify toolchain
|
||||
shell: powershell
|
||||
run: |
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq \
|
||||
mingw-w64 mingw-w64-tools binutils-mingw-w64 \
|
||||
cmake ninja-build nasm meson \
|
||||
default-jre-headless \
|
||||
python3 awscli
|
||||
|
||||
# Node.js
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
|
||||
apt-get install -y -qq nodejs
|
||||
fi
|
||||
|
||||
echo "=== Toolchain versions ==="
|
||||
go version
|
||||
$env:Path = "$env:EXTRA_PATH;$env:Path"
|
||||
node --version
|
||||
x86_64-w64-mingw32-gcc --version | head -1
|
||||
cmake --version | head -1
|
||||
rustc --version
|
||||
cargo --version
|
||||
java --version
|
||||
|
||||
# ===============================================================
|
||||
# FreeRDP3 — Cross-compile from source via MinGW
|
||||
# ===============================================================
|
||||
- name: Build FreeRDP3 for Windows (MinGW cross-compile)
|
||||
- name: Patch version from git tag
|
||||
shell: powershell
|
||||
run: |
|
||||
FREERDP_VERSION="3.24.0"
|
||||
echo "=== Building FreeRDP ${FREERDP_VERSION} for Windows amd64 via MinGW ==="
|
||||
$ver = ("${{ github.ref_name }}" -replace '^v','')
|
||||
$conf = Get-Content src-tauri\tauri.conf.json -Raw
|
||||
$conf = $conf -replace '"version":\s*"[^"]*"', "`"version`": `"$ver`""
|
||||
[System.IO.File]::WriteAllText((Join-Path (Get-Location) "src-tauri\tauri.conf.json"), $conf)
|
||||
Write-Host "Patched tauri.conf.json version to $ver"
|
||||
|
||||
# Download FreeRDP source
|
||||
curl -sSL -o /tmp/freerdp.tar.gz \
|
||||
"https://github.com/FreeRDP/FreeRDP/archive/refs/tags/${FREERDP_VERSION}.tar.gz"
|
||||
tar -xzf /tmp/freerdp.tar.gz -C /tmp
|
||||
cd /tmp/FreeRDP-${FREERDP_VERSION}
|
||||
|
||||
# Create MinGW toolchain file
|
||||
cat > /tmp/mingw-toolchain.cmake << 'TCEOF'
|
||||
set(CMAKE_SYSTEM_NAME Windows)
|
||||
set(CMAKE_SYSTEM_PROCESSOR AMD64)
|
||||
set(CMAKE_C_COMPILER x86_64-w64-mingw32-gcc)
|
||||
set(CMAKE_CXX_COMPILER x86_64-w64-mingw32-g++)
|
||||
set(CMAKE_RC_COMPILER x86_64-w64-mingw32-windres)
|
||||
set(CMAKE_FIND_ROOT_PATH /usr/x86_64-w64-mingw32)
|
||||
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
|
||||
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
|
||||
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
|
||||
TCEOF
|
||||
|
||||
# Configure — minimal client-only build (no server, no extras)
|
||||
cmake -B build -G Ninja \
|
||||
-DCMAKE_TOOLCHAIN_FILE=/tmp/mingw-toolchain.cmake \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_INSTALL_PREFIX=/tmp/freerdp-install \
|
||||
-DBUILD_SHARED_LIBS=ON \
|
||||
-DWITH_CLIENT=ON \
|
||||
-DWITH_SERVER=OFF \
|
||||
-DWITH_SHADOW=OFF \
|
||||
-DWITH_PROXY=OFF \
|
||||
-DWITH_SAMPLE=OFF \
|
||||
-DWITH_PLATFORM_SERVER=OFF \
|
||||
-DWITH_WINPR_TOOLS=OFF \
|
||||
-DWITH_FFMPEG=OFF \
|
||||
-DWITH_SWSCALE=OFF \
|
||||
-DWITH_CAIRO=OFF \
|
||||
-DWITH_CUPS=OFF \
|
||||
-DWITH_PULSE=OFF \
|
||||
-DWITH_ALSA=OFF \
|
||||
-DWITH_OSS=OFF \
|
||||
-DWITH_WAYLAND=OFF \
|
||||
-DWITH_X11=OFF \
|
||||
-DCHANNEL_URBDRC=OFF \
|
||||
-DWITH_OPENH264=OFF
|
||||
|
||||
# Build
|
||||
cmake --build build --parallel $(nproc)
|
||||
cmake --install build
|
||||
|
||||
echo "=== FreeRDP3 DLLs built ==="
|
||||
ls -la /tmp/freerdp-install/bin/*.dll 2>/dev/null || ls -la /tmp/freerdp-install/lib/*.dll 2>/dev/null || echo "Checking build output..."
|
||||
find /tmp/freerdp-install -name "*.dll" -type f
|
||||
|
||||
- name: Stage FreeRDP3 DLLs
|
||||
- name: Install dependencies and build frontend
|
||||
shell: powershell
|
||||
run: |
|
||||
mkdir -p dist
|
||||
|
||||
# Copy all FreeRDP DLLs (MinGW produces lib-prefixed names)
|
||||
find /tmp/freerdp-install -name "*.dll" -type f -exec cp {} dist/ \;
|
||||
|
||||
echo "=== Staged DLLs ==="
|
||||
ls -la dist/*.dll 2>/dev/null || echo "No DLLs found — FreeRDP build may have failed"
|
||||
|
||||
# ===============================================================
|
||||
# Build Wraith
|
||||
# ===============================================================
|
||||
- name: Build frontend
|
||||
run: |
|
||||
cd frontend
|
||||
$env:Path = "$env:EXTRA_PATH;$env:Path"
|
||||
npm ci
|
||||
npm run build
|
||||
echo "Frontend build complete:"
|
||||
ls -la dist/
|
||||
|
||||
- name: Build wraith.exe (Windows amd64)
|
||||
- name: Install Tauri CLI
|
||||
shell: powershell
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
echo "=== Cross-compiling wraith.exe for Windows amd64 ==="
|
||||
$env:Path = "$env:EXTRA_PATH;$env:Path"
|
||||
cargo install tauri-cli --version "^2"
|
||||
|
||||
GOOS=windows GOARCH=amd64 CGO_ENABLED=0 \
|
||||
go build \
|
||||
-ldflags="-s -w -X main.version=${VERSION}" \
|
||||
-o dist/wraith.exe \
|
||||
.
|
||||
|
||||
ls -la dist/wraith.exe
|
||||
|
||||
# ===============================================================
|
||||
# Code signing — jsign + Azure Key Vault (EV cert)
|
||||
# ===============================================================
|
||||
- name: Install jsign
|
||||
- name: Build Tauri app (with update signing)
|
||||
shell: powershell
|
||||
run: |
|
||||
JSIGN_VERSION="7.0"
|
||||
curl -sSL -o /usr/local/bin/jsign.jar \
|
||||
"https://github.com/ebourg/jsign/releases/download/${JSIGN_VERSION}/jsign-${JSIGN_VERSION}.jar"
|
||||
$env:Path = "$env:EXTRA_PATH;$env:Path"
|
||||
$env:TAURI_SIGNING_PRIVATE_KEY = "${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}"
|
||||
$env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD = "${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}"
|
||||
cargo tauri build
|
||||
Write-Host "=== Build output ==="
|
||||
Get-ChildItem -Recurse src-tauri\target\release\bundle\nsis\*
|
||||
|
||||
- name: Get Azure Key Vault access token
|
||||
id: azure-token
|
||||
- name: Build and package MCP bridge binary
|
||||
shell: powershell
|
||||
run: |
|
||||
TOKEN=$(curl -s -X POST \
|
||||
"https://login.microsoftonline.com/${{ secrets.AZURE_TENANT_ID }}/oauth2/v2.0/token" \
|
||||
-d "client_id=${{ secrets.AZURE_CLIENT_ID }}" \
|
||||
-d "client_secret=${{ secrets.AZURE_CLIENT_SECRET }}" \
|
||||
-d "scope=https://vault.azure.net/.default" \
|
||||
-d "grant_type=client_credentials" \
|
||||
| python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])")
|
||||
echo "::add-mask::${TOKEN}"
|
||||
echo "token=${TOKEN}" >> $GITHUB_OUTPUT
|
||||
$env:Path = "$env:EXTRA_PATH;$env:Path"
|
||||
cd src-tauri
|
||||
cargo build --release --bin wraith-mcp-bridge
|
||||
Write-Host "Bridge binary built:"
|
||||
Get-ChildItem target\release\wraith-mcp-bridge.exe
|
||||
|
||||
- name: Sign all Windows binaries
|
||||
- name: Download jsign
|
||||
shell: powershell
|
||||
run: |
|
||||
echo "=== Signing all .exe and .dll files with EV certificate ==="
|
||||
for binary in dist/*.exe dist/*.dll; do
|
||||
[ -f "$binary" ] || continue
|
||||
echo "Signing: $binary"
|
||||
java -jar /usr/local/bin/jsign.jar \
|
||||
--storetype AZUREKEYVAULT \
|
||||
--keystore "${{ secrets.AZURE_KEY_VAULT_URL }}" \
|
||||
--storepass "${{ steps.azure-token.outputs.token }}" \
|
||||
--alias "${{ secrets.AZURE_CERT_NAME }}" \
|
||||
--tsaurl http://timestamp.digicert.com \
|
||||
--tsmode RFC3161 \
|
||||
"$binary"
|
||||
echo "Signed: $binary"
|
||||
done
|
||||
Invoke-WebRequest -Uri "https://github.com/ebourg/jsign/releases/download/7.0/jsign-7.0.jar" -OutFile jsign.jar
|
||||
|
||||
# ===============================================================
|
||||
# Version manifest
|
||||
# ===============================================================
|
||||
- name: Create version.json
|
||||
- name: Get Azure token
|
||||
shell: powershell
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
EXE_SHA=$(sha256sum dist/wraith.exe | awk '{print $1}')
|
||||
$body = @{
|
||||
client_id = "${{ secrets.AZURE_CLIENT_ID }}"
|
||||
client_secret = "${{ secrets.AZURE_CLIENT_SECRET }}"
|
||||
scope = "https://vault.azure.net/.default"
|
||||
grant_type = "client_credentials"
|
||||
}
|
||||
$resp = Invoke-RestMethod -Uri "https://login.microsoftonline.com/${{ secrets.AZURE_TENANT_ID }}/oauth2/v2.0/token" -Method POST -Body $body
|
||||
$token = $resp.access_token
|
||||
echo "::add-mask::$token"
|
||||
[System.IO.File]::WriteAllText("$env:TEMP\aztoken.txt", $token)
|
||||
|
||||
# Build DLL manifest
|
||||
DLL_ENTRIES=""
|
||||
for dll in dist/*.dll; do
|
||||
[ -f "$dll" ] || continue
|
||||
DLL_NAME=$(basename "$dll")
|
||||
DLL_SHA=$(sha256sum "$dll" | awk '{print $1}')
|
||||
DLL_ENTRIES="${DLL_ENTRIES} \"${DLL_NAME}\": \"${DLL_SHA}\",
|
||||
"
|
||||
done
|
||||
- name: Sign binaries
|
||||
shell: powershell
|
||||
run: |
|
||||
$env:Path = "$env:EXTRA_PATH;$env:Path"
|
||||
$token = [System.IO.File]::ReadAllText("$env:TEMP\aztoken.txt")
|
||||
# Sign NSIS installers + MCP bridge binary
|
||||
$binaries = @()
|
||||
$binaries += Get-ChildItem -Recurse src-tauri\target\release\bundle\nsis\*.exe
|
||||
$binaries += Get-Item src-tauri\target\release\wraith-mcp-bridge.exe -ErrorAction SilentlyContinue
|
||||
foreach ($binary in $binaries) {
|
||||
Write-Host "Signing: $($binary.FullName)"
|
||||
java -jar jsign.jar --storetype AZUREKEYVAULT --keystore "${{ secrets.AZURE_KEY_VAULT_URL }}" --storepass $token --alias "${{ secrets.AZURE_CERT_NAME }}" --tsaurl http://timestamp.digicert.com --tsmode RFC3161 $binary.FullName
|
||||
Write-Host "Signed: $($binary.Name)"
|
||||
}
|
||||
Remove-Item "$env:TEMP\aztoken.txt" -ErrorAction SilentlyContinue
|
||||
|
||||
cat > dist/version.json << EOF
|
||||
{
|
||||
"version": "${VERSION}",
|
||||
"filename": "wraith.exe",
|
||||
"sha256": "${EXE_SHA}",
|
||||
"platform": "windows",
|
||||
"architecture": "amd64",
|
||||
"released": "$(date -u +"%Y-%m-%dT%H:%M:%SZ")",
|
||||
"signed": true,
|
||||
"dlls": {
|
||||
${DLL_ENTRIES} "_note": "All DLLs are EV code-signed"
|
||||
- name: Upload all artifacts to SeaweedFS
|
||||
shell: powershell
|
||||
run: |
|
||||
$ver = ("${{ github.ref_name }}" -replace '^v','')
|
||||
$s3 = "https://files.command.vigilcyber.com/wraith"
|
||||
|
||||
# Upload installer
|
||||
$installers = Get-ChildItem -Recurse src-tauri\target\release\bundle\nsis\*.exe
|
||||
foreach ($file in $installers) {
|
||||
Write-Host "Uploading: $($file.Name)"
|
||||
Invoke-RestMethod -Uri "$s3/$ver/$($file.Name)" -Method PUT -ContentType "application/octet-stream" -InFile $file.FullName
|
||||
# Also upload as 'latest' for direct download links
|
||||
Invoke-RestMethod -Uri "$s3/latest/$($file.Name)" -Method PUT -ContentType "application/octet-stream" -InFile $file.FullName
|
||||
}
|
||||
|
||||
# Upload MCP bridge binary
|
||||
$bridge = "src-tauri\target\release\wraith-mcp-bridge.exe"
|
||||
if (Test-Path $bridge) {
|
||||
Write-Host "Uploading: wraith-mcp-bridge.exe"
|
||||
Invoke-RestMethod -Uri "$s3/$ver/wraith-mcp-bridge.exe" -Method PUT -ContentType "application/octet-stream" -InFile $bridge
|
||||
Invoke-RestMethod -Uri "$s3/latest/wraith-mcp-bridge.exe" -Method PUT -ContentType "application/octet-stream" -InFile $bridge
|
||||
}
|
||||
|
||||
# Upload .nsis.zip for Tauri auto-updater
|
||||
$zipFile = Get-ChildItem -Recurse src-tauri\target\release\bundle\nsis\*.nsis.zip | Select-Object -First 1
|
||||
if ($zipFile) {
|
||||
Write-Host "Uploading: $($zipFile.Name)"
|
||||
Invoke-RestMethod -Uri "$s3/$ver/$($zipFile.Name)" -Method PUT -ContentType "application/octet-stream" -InFile $zipFile.FullName
|
||||
}
|
||||
|
||||
# Upload version.json metadata
|
||||
$installer = $installers | Select-Object -First 1
|
||||
if ($installer) {
|
||||
$hash = (Get-FileHash $installer.FullName -Algorithm SHA256).Hash.ToLower()
|
||||
@{ version = $ver; filename = $installer.Name; sha256 = $hash; platform = "windows"; architecture = "amd64"; released = (Get-Date -Format "yyyy-MM-ddTHH:mm:ssZ"); signed = $true } | ConvertTo-Json | Out-File version.json -Encoding utf8
|
||||
Invoke-RestMethod -Uri "$s3/$ver/version.json" -Method PUT -ContentType "application/json" -InFile version.json
|
||||
Invoke-RestMethod -Uri "$s3/latest/version.json" -Method PUT -ContentType "application/json" -InFile version.json
|
||||
}
|
||||
|
||||
Write-Host "=== SeaweedFS upload complete ==="
|
||||
|
||||
- name: Generate and upload update.json for Tauri updater
|
||||
shell: powershell
|
||||
run: |
|
||||
$ver = ("${{ github.ref_name }}" -replace '^v','')
|
||||
$s3 = "https://files.command.vigilcyber.com/wraith"
|
||||
|
||||
$sigFile = Get-ChildItem -Recurse src-tauri\target\release\bundle\nsis\*.nsis.zip.sig | Select-Object -First 1
|
||||
$zipFile = Get-ChildItem -Recurse src-tauri\target\release\bundle\nsis\*.nsis.zip | Select-Object -First 1
|
||||
|
||||
if ($sigFile -and $zipFile) {
|
||||
$signature = Get-Content $sigFile.FullName -Raw
|
||||
$downloadUrl = "$s3/$ver/$($zipFile.Name)"
|
||||
|
||||
$updateJson = @{
|
||||
version = "v$ver"
|
||||
notes = "Wraith Desktop v$ver"
|
||||
pub_date = (Get-Date -Format "yyyy-MM-ddTHH:mm:ssZ")
|
||||
platforms = @{
|
||||
"windows-x86_64" = @{
|
||||
signature = $signature.Trim()
|
||||
url = $downloadUrl
|
||||
}
|
||||
}
|
||||
EOF
|
||||
} | ConvertTo-Json -Depth 4
|
||||
|
||||
echo "=== version.json ==="
|
||||
cat dist/version.json
|
||||
$updateJson | Out-File update.json -Encoding utf8
|
||||
Write-Host "update.json content:"
|
||||
Get-Content update.json
|
||||
|
||||
# ===============================================================
|
||||
# Upload release artifacts
|
||||
# ===============================================================
|
||||
- name: Upload release artifacts
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
ENDPOINT="https://files.command.vigilcyber.com"
|
||||
# Upload to root (Tauri updater endpoint)
|
||||
Invoke-RestMethod -Uri "$s3/update.json" -Method PUT -ContentType "application/json" -InFile update.json
|
||||
# Also versioned copy
|
||||
Invoke-RestMethod -Uri "$s3/$ver/update.json" -Method PUT -ContentType "application/json" -InFile update.json
|
||||
|
||||
echo "=== Uploading Wraith ${VERSION} ==="
|
||||
Write-Host "=== Update manifest uploaded ==="
|
||||
} else {
|
||||
Write-Host 'WARNING - No .sig file found, update signing may have failed'
|
||||
}
|
||||
|
||||
# Versioned path
|
||||
aws s3 cp dist/ "s3://agents/wraith/${VERSION}/windows/amd64/" \
|
||||
--recursive --endpoint-url "$ENDPOINT" --no-sign-request
|
||||
|
||||
# Latest path
|
||||
aws s3 sync dist/ "s3://agents/wraith/latest/windows/amd64/" \
|
||||
--delete --endpoint-url "$ENDPOINT" --no-sign-request
|
||||
|
||||
echo "=== Upload complete ==="
|
||||
echo "Versioned: ${ENDPOINT}/agents/wraith/${VERSION}/windows/amd64/"
|
||||
echo "Latest: ${ENDPOINT}/agents/wraith/latest/windows/amd64/"
|
||||
echo ""
|
||||
echo "=== Contents ==="
|
||||
ls -la dist/
|
||||
|
||||
32
.gitignore
vendored
32
.gitignore
vendored
@ -1,29 +1,7 @@
|
||||
# Go
|
||||
bin/
|
||||
node_modules/
|
||||
dist/
|
||||
*.exe
|
||||
|
||||
# Frontend
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
frontend/bindings/
|
||||
|
||||
# Wails
|
||||
build/bin/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# OS
|
||||
src-tauri/target/
|
||||
src-tauri/binaries/
|
||||
*.log
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# App data
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
|
||||
# Superpowers
|
||||
.superpowers/
|
||||
.claude/
|
||||
.claude/worktrees/
|
||||
|
||||
63
AGENTS.md
Normal file
63
AGENTS.md
Normal file
@ -0,0 +1,63 @@
|
||||
# AGENTS.md — Wraith Desktop v2
|
||||
|
||||
## Agent Roster
|
||||
|
||||
Three tiers. Use the right tool for the job.
|
||||
|
||||
### Architect (Opus)
|
||||
|
||||
**Role:** Strategy, COAs, root cause analysis, architectural decisions.
|
||||
**When to use:** Design questions, complex debugging, trade-off analysis, cross-module planning.
|
||||
**How to use:** Plans, not code. The Architect reasons about the problem and presents options. The Commander decides. Then Specialists execute.
|
||||
|
||||
### Specialist (Sonnet)
|
||||
|
||||
**Role:** Full-stack execution. Writes code, fixes bugs, builds features.
|
||||
**When to use:** Implementation tasks with clear requirements. Feature builds, bug fixes, refactoring, test writing.
|
||||
**How to use:** `subagent_type: general-purpose, model: sonnet`. Give precise briefs with file paths, expected behavior, and acceptance criteria.
|
||||
|
||||
### Scout (Sonnet/Haiku)
|
||||
|
||||
**Role:** Recon, context mapping, read-only exploration.
|
||||
**When to use:** Before any implementation. Understanding code structure, finding patterns, mapping dependencies.
|
||||
**How to use:** `subagent_type: Explore, model: sonnet` for thorough exploration. Haiku for quick file lookups. Scouts NEVER modify files.
|
||||
|
||||
## Dispatch Rules
|
||||
|
||||
- **Simple bug fix (1-2 files):** Do it yourself. Don't burn an agent on a one-liner.
|
||||
- **Feature build (3+ files):** Dispatch a Specialist with a complete brief.
|
||||
- **Unknown territory:** Scout first, then Specialist.
|
||||
- **Architecture decision:** Architect agent OR present COAs to the Commander directly.
|
||||
- **Mechanical bulk work (renaming, formatting, repetitive edits):** Sonnet Specialist. Don't waste Opus on mechanical tasks.
|
||||
- **Security-critical code (vault, crypto, auth):** Opus Architect reviews. Sonnet Specialist implements. Both touch the code.
|
||||
|
||||
## Cross-Project Context
|
||||
|
||||
Wraith is part of the Vigilsynth portfolio alongside:
|
||||
|
||||
- **Vigilance HQ** (`../vigilance-hq`) — MSP operations platform. Vue 3 + Express.js. 1,216+ commits. Production.
|
||||
- **Vigilance Command** (`../vigilance-command-v2`) — Security OS. NestJS + Rust agent. 16 modules. Active development.
|
||||
- **Vigilance Complete** (`../vigilance-complete`) — The merge. HQ + Command unified.
|
||||
|
||||
The Commander manages multiple AI XOs across all repos simultaneously. Context from one repo may inform work in another. When the Commander references HQ patterns, Command architecture, or Vigilsynth strategy, that's cross-project context — use it.
|
||||
|
||||
## Gemini CLI
|
||||
|
||||
Gemini CLI is available in the Commander's environment. Gemini specializes in:
|
||||
- Architecture and protocol design
|
||||
- Library/crate research and evaluation
|
||||
- Deep code audits against specifications
|
||||
- Optimization identification
|
||||
|
||||
The Commander may direct Gemini to work on Wraith alongside or instead of Claude. Both AIs follow the same CLAUDE.md doctrine. The Commander routes tasks to whichever AI is best suited.
|
||||
|
||||
## The Go Reference
|
||||
|
||||
The Go version of Wraith lives at `../wraith-go-archive`. It is the reference implementation:
|
||||
- SSH terminal with xterm.js worked
|
||||
- SFTP sidebar with CWD following worked
|
||||
- Connection manager with groups and search worked
|
||||
- Credential vault with Argon2id encryption worked
|
||||
- Multi-tab sessions worked
|
||||
|
||||
When building features, Scouts should read the Go version first. Specialists should match or exceed Go's capabilities. Don't reinvent what was already solved — port it better.
|
||||
137
CLAUDE.md
Normal file
137
CLAUDE.md
Normal file
@ -0,0 +1,137 @@
|
||||
# CLAUDE.md — Wraith Desktop v2
|
||||
|
||||
## Project Overview
|
||||
|
||||
Wraith is a native desktop SSH/SFTP/RDP client — a MobaXTerm killer. Rust backend (Tauri v2) + Vue 3 frontend (WebView2). Single binary, no Docker, no sidecar processes. Built to replace every commercial remote access tool on a technician's desktop.
|
||||
|
||||
**Name:** Wraith — exists everywhere, all at once.
|
||||
|
||||
**Current Status:** Active development. SSH connects, terminal renders. RDP via ironrdp in progress. SFTP sidebar functional. Vault encrypted with Argon2id + AES-256-GCM.
|
||||
|
||||
## Who You Are Here
|
||||
|
||||
You are the Wraith XO. The Commander built this from a working Go/Wails v3 prototype that had a buggy terminal and slow performance. Your job is to make the Rust/Tauri rewrite exceed the Go version in every way — faster, cleaner, more capable.
|
||||
|
||||
**Operate with autonomy, personality, and spine.** The Commander doesn't write code. He leads, you execute. He built the doctrine across Vigilance HQ (1,216+ commits, 22 clients in production) and Vigilance Command (16-module security OS, pure Rust agent). The same methodology that built those platforms applies here. Read the V4_WORKFLOW. Follow it. Trust it.
|
||||
|
||||
**Don't be timid.** The Go version worked. Users connected to servers, transferred files, managed sessions. Your Rust version needs to match that and surpass it. If something is broken, fix it. If something is missing, build it. If you need to make an architectural decision, present COAs — don't ask "should I proceed?"
|
||||
|
||||
**The Go version is your reference implementation.** It lives at `../wraith-go-archive`. The SSH terminal worked. The SFTP sidebar worked. The connection manager worked. The vault worked. When in doubt about what a feature should do, read the Go code. It's the spec that ran in production.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Runtime:** Tauri v2 (stable)
|
||||
- **Backend:** Rust with `russh` (SSH/SFTP), `ironrdp` (RDP), `rusqlite` (SQLite), `aes-gcm` + `argon2` (vault), `dashmap` (concurrent session registry)
|
||||
- **Frontend:** Vue 3 (Composition API, `<script setup>`), TypeScript, Vite, Pinia, Tailwind CSS v4, xterm.js 6, CodeMirror 6
|
||||
- **Distribution:** Tauri bundler (NSIS installer), auto-updater with code signing
|
||||
- **License:** 100% commercial-safe. Zero GPL contamination. Every dependency MIT/Apache-2.0/BSD.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src-tauri/ # Rust backend
|
||||
src/
|
||||
main.rs # Entry point
|
||||
lib.rs # App state, module declarations, Tauri setup
|
||||
ssh/ # SSH service (russh), host keys (TOFU), CWD tracker
|
||||
sftp/ # SFTP operations (russh-sftp)
|
||||
rdp/ # RDP service (ironrdp), scancode mapping
|
||||
vault/ # Encryption (Argon2id + AES-256-GCM)
|
||||
db/ # SQLite (rusqlite), migrations
|
||||
connections/ # Connection CRUD, groups, search
|
||||
credentials/ # Credential CRUD, encrypted storage
|
||||
settings/ # Key-value settings
|
||||
theme/ # Terminal themes (7 built-in)
|
||||
session/ # Session manager (DashMap)
|
||||
workspace/ # Workspace snapshots, crash recovery
|
||||
commands/ # Tauri command wrappers
|
||||
|
||||
src/ # Vue 3 frontend
|
||||
layouts/ # MainLayout, UnlockLayout
|
||||
components/ # UI components
|
||||
composables/ # useTerminal, useSftp, useRdp, useTransfers
|
||||
stores/ # Pinia stores (app, session, connection)
|
||||
assets/ # CSS, images
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm install # Install frontend deps
|
||||
npm run dev # Vite dev server only
|
||||
cargo tauri dev # Full app (Rust + frontend)
|
||||
cargo tauri build # Production build
|
||||
cd src-tauri && cargo test # Run Rust tests (95 tests)
|
||||
cd src-tauri && cargo build # Build Rust only
|
||||
```
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
- **Sessions use DashMap** — lock-free concurrent access, no deadlocks during tab detach
|
||||
- **Drop trait for cleanup** — SSH/SFTP/RDP connections close automatically when sessions drop
|
||||
- **CWD following via exec channel** — polls `pwd` on a separate SSH channel every 2 seconds. Never touches the terminal data stream. This avoids ANSI escape sequence corruption.
|
||||
- **RDP runs in dedicated thread** — ironrdp's trait objects aren't Send, so each RDP session gets its own tokio runtime in a std::thread
|
||||
- **xterm.js font handling** — `document.fonts.ready.then(() => fitAddon.fit())` prevents cell width miscalculation
|
||||
- **Tauri v2 ACL** — The `capabilities/default.json` file MUST grant `core:default`, `event:default`, and `shell:allow-open`. Without these, the frontend cannot listen for events or invoke commands. This was the root cause of the blank screen bug — missing `url: "index.html"` and `label: "main"` in `tauri.conf.json`, plus empty capabilities.
|
||||
|
||||
## V4_WORKFLOW — Standard Operating Procedure
|
||||
|
||||
**Phase 1: RECON** — Read all relevant files before proposing changes. Understand patterns, dependencies, blast radius. When touching Rust, check the Go version at `../wraith-go-archive` for how it was done before.
|
||||
|
||||
**Phase 2: PLAN** — Present approach for approval. **Never make executive decisions autonomously** — surface trade-offs as COAs (Courses of Action).
|
||||
|
||||
**Phase 3: EXECUTE** — Implement approved changes. Commit and push. Format: `type: Short description`
|
||||
|
||||
**Phase 4: SITREP** — Report: SITUATION, ACTIONS TAKEN, RESULT, NEXT.
|
||||
|
||||
## Standing Orders
|
||||
|
||||
- **Commit and push after every meaningful change.** The Commander tests in real-time. Unpushed commits are invisible.
|
||||
- Use military terminology, be direct and precise
|
||||
- Present trade-offs as COAs with pros/cons — let the Commander decide
|
||||
- **Don't ask "should I proceed?" when the answer is obviously yes.** Read the room. If the Commander gave you a task, execute it.
|
||||
- **If something is broken, fix it.** Don't document it and move on. Fix it.
|
||||
- **Tauri v2 ACL is mandatory.** Every new Tauri command or event MUST be added to `capabilities/default.json` or it will silently fail.
|
||||
- **Check the Go version first.** Before building any feature, read how `../wraith-go-archive` did it. Don't reinvent what was already solved.
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
1. **No terminal stream processing.** The Go version's CWD tracker parsed OSC 7 from the terminal output and corrupted ANSI sequences. Never again. CWD tracking uses a separate exec channel that polls `pwd` independently.
|
||||
2. **Tauri v2 over Wails v3.** Wails v3 is alpha with breaking changes. Tauri v2 is stable with built-in multi-window, auto-updater, and active community.
|
||||
3. **ironrdp over FreeRDP FFI.** Pure Rust, no DLL dependency, memory safe. FreeRDP is the fallback discussion if ironrdp can't hit performance targets.
|
||||
4. **Fresh vault, no Go migration.** 6 connections — faster to re-enter than engineer format compatibility.
|
||||
5. **macOS data directory.** Use `~/Library/Application Support/Wraith` on macOS, not Linux-style `~/.local/share`. Respect platform conventions.
|
||||
|
||||
## Lessons Learned
|
||||
|
||||
1. **Tauri v2 capabilities are not optional.** The blank screen bug that stumped the first XO was a missing `"url": "index.html"` in `tauri.conf.json` and an empty `capabilities/` directory. Tauri v2's security model blocks ALL frontend event listeners and IPC calls without explicit permissions. Every new feature that uses `emit()`, `listen()`, or `invoke()` must have a corresponding entry in `capabilities/default.json`. If the frontend silently does nothing, check capabilities first.
|
||||
|
||||
2. **The Go version is the spec.** When in doubt about what a feature should do, how it should behave, or what edge cases to handle — read the Go code at `../wraith-go-archive`. It ran. Users used it. The terminal worked, the SFTP worked, the vault worked. Don't guess. Read.
|
||||
|
||||
3. **Rust backend command names must match frontend invoke names exactly.** If the frontend calls `invoke('disconnect_ssh')` but the backend exports `disconnect_session`, nothing happens. No error. Silent failure. When adding Tauri commands, grep the frontend for the exact invoke string.
|
||||
|
||||
4. **DashMap is the session registry.** Don't replace it with Mutex<HashMap>. DashMap provides lock-free concurrent access. Multiple tabs can operate on different sessions simultaneously without deadlocking. The Drop trait on sessions ensures cleanup when tabs close.
|
||||
|
||||
5. **xterm.js must wait for fonts.** `document.fonts.ready.then(() => fitAddon.fit())` — if you fit the terminal before fonts load, cell widths are wrong and text overlaps. This is a browser-level race condition that every terminal app hits.
|
||||
|
||||
## Lineage
|
||||
|
||||
This is a ground-up Rust rewrite of `wraith` (Go/Wails v3). The Go version is at `../wraith-go-archive`. The original design spec is at `docs/superpowers/specs/2026-03-17-wraith-desktop-design.md` in the Go repo. The enterprise feature roadmap is at `../wraith-go-archive/docs/FUTURE-FEATURES.md`.
|
||||
|
||||
## Future Vision
|
||||
|
||||
Wraith Personal is the foundation. Wraith Enterprise adds:
|
||||
- PostgreSQL backend (replaces SQLite)
|
||||
- Shared credentials from Vigilance Intel vault (Argon2id, per-tenant encryption)
|
||||
- Entra ID SSO via Vigilance Clearance
|
||||
- Client-scoped access (MSP multi-tenancy)
|
||||
- Session recording to Vigilance Signal (SIEM)
|
||||
- AI copilot panel (Gemini + Claude toggle) with tool access to SSH/SFTP/RDP sessions
|
||||
- Split panes, jump hosts, port forwarding manager
|
||||
- Command-level audit logging for compliance
|
||||
|
||||
The enterprise upgrade path connects to the Vigilance ecosystem — same vault, same identity, same audit trail. Wraith becomes the technician's daily driver that authenticates against Command Clearance, pulls credentials from Intel, and logs sessions to Signal.
|
||||
|
||||
## Parent Organization
|
||||
|
||||
**Vigilsynth** is the parent company. Wraith is a product alongside Vigilance HQ and Vigilance Command. The same development methodology (Commander/XO model, AI-assisted development, CLAUDE.md doctrine) applies across all repos. The Commander manages multiple AI XOs across multiple projects simultaneously.
|
||||
21
LICENSE
21
LICENSE
@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Vantz Stockwell
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
242
README.md
242
README.md
@ -1,242 +0,0 @@
|
||||
<p align="center">
|
||||
<img src="images/wraith-logo.png" alt="Wraith" width="128" />
|
||||
</p>
|
||||
|
||||
<h1 align="center">Wraith</h1>
|
||||
|
||||
<p align="center">
|
||||
Native desktop SSH + RDP + SFTP client — a MobaXTerm replacement built with Go and Vue.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<!-- badges -->
|
||||
<img alt="Go" src="https://img.shields.io/badge/Go-1.22+-00ADD8?logo=go&logoColor=white" />
|
||||
<img alt="Wails v3" src="https://img.shields.io/badge/Wails-v3-red" />
|
||||
<img alt="Vue 3" src="https://img.shields.io/badge/Vue-3-4FC08D?logo=vuedotjs&logoColor=white" />
|
||||
<img alt="License" src="https://img.shields.io/badge/License-MIT-blue" />
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- **Multi-tabbed SSH terminal** with xterm.js + WebGL rendering
|
||||
- **SFTP sidebar** on every SSH session (MobaXTerm's killer feature) -- same SSH connection, separate channel
|
||||
- **RDP** via FreeRDP3 dynamic linking (purego, no CGO)
|
||||
- **Encrypted vault** -- master password derived with Argon2id, secrets sealed with AES-256-GCM
|
||||
- **Connection manager** with hierarchical groups, tags, color labels, and full-text search
|
||||
- **7 built-in terminal themes** -- Dracula, Nord, Monokai, One Dark, Solarized Dark, Gruvbox Dark, MobaXTerm Classic
|
||||
- **Tab detach / reattach** -- sessions live in the Go backend; tabs can be torn off into separate windows and reattached without dropping the connection
|
||||
- **MobaXTerm import** -- plugin interface for `.mobaconf` and other formats
|
||||
- **Command palette** (Ctrl+K) for quick connection search and actions
|
||||
- **Single binary** -- ships as `wraith.exe` + `freerdp3.dll`, no Docker, no database server
|
||||
|
||||
## Tech Stack
|
||||
|
||||
### Backend (Go)
|
||||
|
||||
| Component | Technology | Purpose |
|
||||
|-----------|-----------|---------|
|
||||
| Framework | Wails v3 | Desktop shell, multi-window, type-safe Go-to-JS bindings |
|
||||
| SSH | `golang.org/x/crypto/ssh` | SSH client, PTY, key/password auth |
|
||||
| SFTP | `github.com/pkg/sftp` | Remote filesystem over SSH channel |
|
||||
| RDP | FreeRDP3 via `purego` | RDP protocol, bitmap rendering |
|
||||
| Database | SQLite via `modernc.org/sqlite` (pure Go) | Connections, credentials, settings, themes |
|
||||
| Encryption | AES-256-GCM + Argon2id | Vault encryption at rest |
|
||||
|
||||
### Frontend (Vue 3 in WebView2)
|
||||
|
||||
| Component | Technology | Purpose |
|
||||
|-----------|-----------|---------|
|
||||
| Framework | Vue 3 (Composition API) | UI framework |
|
||||
| Terminal | xterm.js 5.x + WebGL addon | SSH terminal emulator |
|
||||
| CSS | Tailwind CSS 4 | Utility-first styling |
|
||||
| Components | Naive UI | Tree, tabs, modals, dialogs |
|
||||
| State | Pinia | Reactive stores for sessions, connections, app state |
|
||||
| Build | Vite 6 | Frontend build tooling |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
| Tool | Version | Install |
|
||||
|------|---------|---------|
|
||||
| Go | 1.22+ | [go.dev/dl](https://go.dev/dl/) |
|
||||
| Node.js | 20+ | [nodejs.org](https://nodejs.org/) |
|
||||
| Wails CLI | v3 | `go install github.com/wailsapp/wails/v3/cmd/wails3@latest` |
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Clone
|
||||
git clone https://github.com/vstockwell/wraith.git
|
||||
cd wraith
|
||||
|
||||
# Install frontend dependencies
|
||||
cd frontend && npm install && cd ..
|
||||
|
||||
# Run in dev mode (hot-reload frontend + Go backend)
|
||||
wails3 dev
|
||||
```
|
||||
|
||||
The app opens a 1400x900 window. On first launch you will be prompted to create a master password for the vault.
|
||||
|
||||
## Building
|
||||
|
||||
```bash
|
||||
# Production build for Windows
|
||||
wails3 build
|
||||
|
||||
# Output: build/bin/wraith.exe
|
||||
```
|
||||
|
||||
The build embeds the compiled frontend (`frontend/dist`) into the Go binary via `//go:embed`. Ship `wraith.exe` alongside `freerdp3.dll` for RDP support.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
wraith/
|
||||
main.go # Entry point -- Wails app setup, service registration
|
||||
go.mod # Go module (github.com/vstockwell/wraith)
|
||||
internal/
|
||||
app/
|
||||
app.go # WraithApp -- wires all services, vault create/unlock
|
||||
db/
|
||||
sqlite.go # SQLite open with WAL mode, busy timeout, FK enforcement
|
||||
migrations.go # Embedded SQL migration runner
|
||||
migrations/
|
||||
001_initial.sql # Schema: groups, connections, credentials, ssh_keys,
|
||||
# themes, host_keys, connection_history, settings
|
||||
vault/
|
||||
service.go # Argon2id key derivation + AES-256-GCM encrypt/decrypt
|
||||
connections/
|
||||
service.go # Connection and Group CRUD (hierarchical tree)
|
||||
search.go # Full-text search + tag filtering via json_each()
|
||||
settings/
|
||||
service.go # Key-value settings store (vault salt, preferences)
|
||||
theme/
|
||||
builtins.go # 7 built-in theme definitions
|
||||
service.go # Theme CRUD + idempotent seeding
|
||||
session/
|
||||
session.go # SessionInfo struct + state machine (connecting/connected/detached)
|
||||
manager.go # Concurrent session manager -- create, detach, reattach, 32-session cap
|
||||
plugin/
|
||||
interfaces.go # ProtocolHandler + Importer + Session interfaces
|
||||
registry.go # Plugin registry -- register/lookup protocol handlers and importers
|
||||
frontend/
|
||||
package.json # Vue 3, Pinia, Naive UI, Tailwind CSS, Vite
|
||||
vite.config.ts # Vite + Vue + Tailwind plugin config
|
||||
src/
|
||||
main.ts # App bootstrap -- createApp, Pinia, mount
|
||||
App.vue # Root component
|
||||
layouts/
|
||||
MainLayout.vue # Sidebar + tab bar + session area + status bar
|
||||
UnlockLayout.vue # Master password entry screen
|
||||
components/
|
||||
sidebar/
|
||||
ConnectionTree.vue # Hierarchical connection/group tree
|
||||
SidebarToggle.vue # Collapse/expand sidebar
|
||||
session/
|
||||
TabBar.vue # Draggable session tabs
|
||||
SessionContainer.vue # Active session viewport
|
||||
common/
|
||||
StatusBar.vue # Bottom status bar
|
||||
stores/
|
||||
app.store.ts # Global app state (sidebar, vault status)
|
||||
connection.store.ts # Connection + group state
|
||||
session.store.ts # Active sessions state
|
||||
images/
|
||||
wraith-logo.png # Application logo
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Go Backend Wails v3 Bindings Vue 3 Frontend
|
||||
(services + business logic) (type-safe Go <-> JS) (WebView2)
|
||||
|
|
||||
WraithApp ─────────────────────────┼──────────────> Pinia Stores
|
||||
|-- VaultService | |-- app.store
|
||||
|-- ConnectionService | |-- connection.store
|
||||
|-- ThemeService | |-- session.store
|
||||
|-- SettingsService | |
|
||||
|-- SessionManager | Vue Components
|
||||
|-- PluginRegistry | |-- MainLayout
|
||||
| |-- ConnectionTree
|
||||
SQLite (WAL mode) | |-- TabBar
|
||||
wraith.db | |-- SessionContainer
|
||||
%APPDATA%\Wraith\ | |-- StatusBar
|
||||
```
|
||||
|
||||
**How it fits together:**
|
||||
|
||||
1. `main.go` creates a `WraithApp` and registers Go services as Wails bindings.
|
||||
2. Wails generates type-safe JavaScript bindings so the Vue frontend can call Go methods directly.
|
||||
3. The Vue frontend uses Pinia stores to manage reactive state, calling into Go services for all data operations.
|
||||
4. All secrets (passwords, SSH keys) are encrypted with AES-256-GCM before being written to SQLite. The encryption key is derived from the master password using Argon2id and is never persisted.
|
||||
5. Sessions are managed by the Go `SessionManager` -- they are decoupled from windows, enabling tab detach/reattach without dropping connections.
|
||||
|
||||
**Data storage:** SQLite with WAL mode at `%APPDATA%\Wraith\wraith.db` (Windows) or `~/.local/share/wraith/wraith.db` (Linux/macOS dev). Foreign keys enforced, 5-second busy timeout.
|
||||
|
||||
## Plugin Development
|
||||
|
||||
Wraith uses a plugin registry with two interfaces: `ProtocolHandler` for new connection protocols and `Importer` for loading connections from external tools.
|
||||
|
||||
### Implementing a ProtocolHandler
|
||||
|
||||
```go
|
||||
package myplugin
|
||||
|
||||
import "github.com/vstockwell/wraith/internal/plugin"
|
||||
|
||||
type MyProtocol struct{}
|
||||
|
||||
func (p *MyProtocol) Name() string { return "myproto" }
|
||||
|
||||
func (p *MyProtocol) Connect(config map[string]interface{}) (plugin.Session, error) {
|
||||
// Establish connection, return a Session
|
||||
}
|
||||
|
||||
func (p *MyProtocol) Disconnect(sessionID string) error {
|
||||
// Clean up resources
|
||||
}
|
||||
```
|
||||
|
||||
### Implementing an Importer
|
||||
|
||||
```go
|
||||
package myplugin
|
||||
|
||||
import "github.com/vstockwell/wraith/internal/plugin"
|
||||
|
||||
type MyImporter struct{}
|
||||
|
||||
func (i *MyImporter) Name() string { return "myformat" }
|
||||
func (i *MyImporter) FileExtensions() []string { return []string{".myconf"} }
|
||||
func (i *MyImporter) Parse(data []byte) (*plugin.ImportResult, error) {
|
||||
// Parse file bytes into ImportResult (groups, connections, host keys, theme)
|
||||
}
|
||||
```
|
||||
|
||||
### Registering Plugins
|
||||
|
||||
Register handlers and importers with the plugin registry during app initialization:
|
||||
|
||||
```go
|
||||
app.Plugins.RegisterProtocol(&myplugin.MyProtocol{})
|
||||
app.Plugins.RegisterImporter(&myplugin.MyImporter{})
|
||||
```
|
||||
|
||||
The `ImportResult` struct supports groups, connections, host keys, and an optional theme -- everything needed to migrate from another tool in a single import.
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch (`git checkout -b feat/my-feature`)
|
||||
3. Make your changes
|
||||
4. Run tests: `go test ./...`
|
||||
5. Run frontend checks: `cd frontend && npm run build`
|
||||
6. Commit and push your branch
|
||||
7. Open a Pull Request
|
||||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE) -- Copyright (c) 2026 Vantz Stockwell
|
||||
@ -1,54 +0,0 @@
|
||||
# Vigilance Remote — Future Features
|
||||
|
||||
Remaining spec items not yet built. Foundation is solid — all items below are additive, no rearchitecting required.
|
||||
|
||||
---
|
||||
|
||||
## Priority 1 — Power User
|
||||
|
||||
1. **Split panes** — Horizontal and vertical splits within a single tab (xterm.js instances in a flex grid)
|
||||
2. **Session recording/playback** — asciinema-compatible casts for SSH, Guacamole native for RDP. Replay in browser. Audit trail for MSP compliance.
|
||||
3. **Saved snippets/macros** — Quick-execute saved commands/scripts. Click to paste into active terminal.
|
||||
|
||||
## Priority 2 — MSP / Enterprise
|
||||
|
||||
4. **Jump hosts / bastion** — Configure SSH proxy/jump hosts for reaching targets behind firewalls (ProxyJump chain support)
|
||||
5. **Port forwarding manager** — Graphical SSH tunnel manager: local, remote, and dynamic forwarding
|
||||
6. **Entra ID SSO** — One-click Microsoft Entra ID integration (same pattern as Vigilance HQ)
|
||||
7. **Client-scoped access** — MSP multi-tenancy: technicians see only the hosts for clients they're assigned to
|
||||
8. **Shared connections** — Admins define connection templates. Technicians connect without seeing credentials.
|
||||
|
||||
## Priority 3 — Audit & Compliance
|
||||
|
||||
9. **Command-level audit logging** — Every command, file transfer logged with user, timestamp, duration (currently connection-level only)
|
||||
10. **Session sharing** — Share a live terminal session with a colleague (read-only or collaborative)
|
||||
|
||||
## Priority 4 — File Transfer
|
||||
|
||||
11. **Dual-pane SFTP** — Optional second SFTP panel for server-to-server file operations (drag between panels)
|
||||
12. **Transfer queue** — Background upload/download queue with progress bars, pause/resume, retry
|
||||
|
||||
## Priority 5 — RDP Enhancements
|
||||
|
||||
13. **Multi-monitor RDP** — Support for multiple virtual displays
|
||||
14. **RDP file transfer** — Upload/download via Guacamole's built-in drive redirection
|
||||
|
||||
## Priority 6 — Auth Hardening
|
||||
|
||||
15. **FIDO2 / hardware key auth** — WebAuthn support for login and SSH
|
||||
16. **SSH agent forwarding** — Forward local SSH agent to remote host
|
||||
|
||||
---
|
||||
|
||||
## Already Built (exceeds spec)
|
||||
|
||||
- SSH terminal (xterm.js + ssh2 + WebSocket proxy + WebGL)
|
||||
- RDP (guacd + guacamole-common-js + display.scale())
|
||||
- SFTP sidebar (auto-open, CWD following via OSC 7, drag-and-drop upload)
|
||||
- Monaco file editor (fullscreen overlay with syntax highlighting)
|
||||
- Connection manager (hosts, groups, quick connect, search, tags, colors)
|
||||
- Credential vault (AES-256-GCM + **Argon2id key derivation**)
|
||||
- Multi-tab sessions + Home navigation
|
||||
- Terminal theming (6+ themes with visual picker)
|
||||
- Multi-user with admin/user roles + per-user data isolation
|
||||
- User management admin UI
|
||||
67
docs/GO_MIGRATION.md
Normal file
67
docs/GO_MIGRATION.md
Normal file
@ -0,0 +1,67 @@
|
||||
# Go → Rust Migration Checklist
|
||||
|
||||
## Pre-Migration (Before Deploying Wraith v2)
|
||||
|
||||
- [ ] Test Wraith v2 on Windows — SSH to all 6 hosts, SFTP browse/upload/download, RDP to Hyper-V
|
||||
- [ ] Verify vault creation + credential storage works
|
||||
- [ ] Verify auto-updater finds releases
|
||||
- [ ] Test code signing on installer
|
||||
- [ ] Set up 6 connections manually (no import needed)
|
||||
|
||||
## Migration Steps
|
||||
|
||||
### 1. Create Gitea Repo
|
||||
```bash
|
||||
# Create wraith repo on Gitea (or rename current wraith to wraith-go-legacy)
|
||||
# Push wraith codebase
|
||||
cd /path/to/wraith
|
||||
git remote add origin ssh://git.command.vigilcyber.com:3021/vstockwell/wraith.git
|
||||
git push -u origin main
|
||||
```
|
||||
|
||||
### 2. Configure CI Secrets
|
||||
Add these secrets to the wraith repo on Gitea:
|
||||
- `AZURE_TENANT_ID`
|
||||
- `AZURE_CLIENT_ID`
|
||||
- `AZURE_CLIENT_SECRET`
|
||||
- `AZURE_KEY_VAULT_URL`
|
||||
- `AZURE_CERT_NAME`
|
||||
- `GIT_TOKEN`
|
||||
- `TAURI_SIGNING_PRIVATE_KEY` (generate with `npx tauri signer generate`)
|
||||
- `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`
|
||||
|
||||
### 3. First Release
|
||||
```bash
|
||||
git tag v1.0.0
|
||||
git push origin v1.0.0
|
||||
```
|
||||
|
||||
### 4. Uninstall Go Version
|
||||
- Use a software uninstaller (the Go NSIS uninstaller is broken)
|
||||
- Or manually delete: `C:\Program Files\Wraith\` and `%APPDATA%\Wraith\`
|
||||
- Note: deleting `%APPDATA%\Wraith\wraith.db` removes Go version credentials (you're re-entering them anyway)
|
||||
|
||||
### 5. Archive Go Repository
|
||||
On Gitea:
|
||||
1. Rename `wraith` repo to `wraith-go-legacy`
|
||||
2. Set repo to archived/read-only
|
||||
3. Rename `wraith` to `wraith`
|
||||
4. Update auto-updater endpoint in tauri.conf.json to new repo path
|
||||
5. Delete old Gitea packages (Go-built versions)
|
||||
|
||||
### 6. Clean Up Local
|
||||
```bash
|
||||
# Remove old Go repo (keep backup if desired)
|
||||
mv ~/repos/wraith ~/repos/wraith-go-legacy-backup
|
||||
mv ~/repos/wraith ~/repos/wraith
|
||||
```
|
||||
|
||||
## What Was NOT Migrated
|
||||
|
||||
| Component | Reason |
|
||||
|---|---|
|
||||
| AI Copilot (8 Go files + 4 Vue files) | Claude Code over SSH replaces this |
|
||||
| MobaXTerm importer | 6 connections — entered by hand |
|
||||
| Plugin system | Not needed |
|
||||
| Go test suite (90 tests) | Replaced by 52 Rust tests |
|
||||
| wraith.db data | Fresh vault, fresh credentials |
|
||||
@ -1,369 +0,0 @@
|
||||
# Wraith Remote — Security Audit Report
|
||||
|
||||
**Date:** 2026-03-14
|
||||
**Auditor:** Claude (Opus 4.6) — secure-code-guardian + security-reviewer + ISO 27001 frameworks
|
||||
**Scope:** Full-stack — Auth, Vault, WebSocket/SSH/SFTP/RDP, Frontend, Infrastructure, ISO 27001 gap assessment
|
||||
**Codebase:** RDP-SSH-Client (Nuxt 3 + NestJS + guacd)
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
**54 unique findings** across 4 audit domains after deduplication.
|
||||
|
||||
| Severity | Count |
|
||||
|----------|-------|
|
||||
| CRITICAL | 8 |
|
||||
| HIGH | 16 |
|
||||
| MEDIUM | 18 |
|
||||
| LOW | 12 |
|
||||
|
||||
The platform has a solid encryption foundation (Argon2id vault is well-implemented) but has significant gaps in transport security, session management, infrastructure hardening, and real-time channel authorization. The most urgent issues are **unauthenticated guacd exposure**, **JWT in localStorage/URLs**, and **missing session ownership checks on WebSocket channels**.
|
||||
|
||||
---
|
||||
|
||||
## CRITICAL Findings (8)
|
||||
|
||||
### C-1. guacd exposed on all interfaces via `network_mode: host`
|
||||
**Location:** `docker-compose.yml:23`
|
||||
**Domain:** Infrastructure
|
||||
|
||||
guacd runs with `network_mode: host` and binds to `0.0.0.0:4822`. guacd is an **unauthenticated** service — anyone who can reach port 4822 can initiate RDP/VNC connections to any host reachable from the Docker host. This completely bypasses all application-level authentication.
|
||||
|
||||
**Impact:** Full unauthenticated RDP/VNC access to every target host in the environment.
|
||||
|
||||
**Fix:** Remove `network_mode: host`. Place guacd on the internal Docker network. Bind to `127.0.0.1`. The app container connects via the Docker service name `guacd` over the internal network.
|
||||
|
||||
---
|
||||
|
||||
### C-2. JWT stored in localStorage (XSS → full account takeover)
|
||||
**Location:** `frontend/stores/auth.store.ts:19,42`
|
||||
**Domain:** Auth / Frontend
|
||||
|
||||
`wraith_token` JWT stored in `localStorage`. Any XSS payload, browser extension, or injected script can read it. The token has a 7-day lifetime with no revocation mechanism — a stolen token is valid for up to a week with no way to invalidate it.
|
||||
|
||||
**Impact:** Single XSS vulnerability → 7-day persistent access to the victim's account, including all SSH/RDP sessions and stored credentials.
|
||||
|
||||
**Fix:** Issue JWT via `Set-Cookie: httpOnly; Secure; SameSite=Strict`. Remove all `localStorage` token operations. Browser automatically attaches the cookie to every request.
|
||||
|
||||
---
|
||||
|
||||
### C-3. JWT passed in WebSocket URL query parameters
|
||||
**Location:** `backend/src/auth/ws-auth.guard.ts:11-13`, all three WS gateways
|
||||
**Domain:** Auth / WebSocket
|
||||
|
||||
All WebSocket connections (`/api/ws/terminal`, `/api/ws/sftp`, `/api/ws/rdp`) accept JWT via `?token=<jwt>` in the URL. Query parameters are logged by: web server access logs, browser history, Referrer headers, network proxies, and the application itself (`main.ts:75` logs `req.url`).
|
||||
|
||||
**Impact:** JWT exposure in every log and monitoring system in the path. Combined with C-2, this creates multiple extraction vectors for a 7-day-lived credential.
|
||||
|
||||
**Fix:** Issue short-lived (30-second) single-use WebSocket tickets via an authenticated REST endpoint. Frontend exchanges JWT for a ticket, connects WS with `?ticket=<nonce>`. Server validates and destroys the ticket on use.
|
||||
|
||||
---
|
||||
|
||||
### C-4. No HTTPS/TLS anywhere in the stack
|
||||
**Location:** `docker-compose.yml`, `backend/src/main.ts`
|
||||
**Domain:** Infrastructure
|
||||
|
||||
No TLS termination configured. No nginx reverse proxy. No `helmet()` middleware. The application serves over plain HTTP. JWT tokens, SSH passwords, and TOTP codes all transit in cleartext.
|
||||
|
||||
**Impact:** Any network observer (same Wi-Fi, ISP, network tap) can intercept credentials, tokens, and terminal data.
|
||||
|
||||
**Fix:** Add nginx with TLS termination in front of the app. Install `helmet()` in NestJS for security headers (HSTS, X-Frame-Options, X-Content-Type-Options). Enforce HTTPS-only.
|
||||
|
||||
---
|
||||
|
||||
### C-5. SSH host key verification auto-accepts all keys (MITM blind spot)
|
||||
**Location:** `terminal.gateway.ts:61`, `ssh-connection.service.ts:98-119`
|
||||
**Domain:** SSH
|
||||
|
||||
`hostVerifier` callback returns `true` unconditionally. New fingerprints are silently accepted. **Changed** fingerprints (active MITM) are also silently accepted and overwrite the stored fingerprint.
|
||||
|
||||
**Impact:** Man-in-the-middle attacker between the Wraith server and SSH target is completely invisible. Attacker gets the decrypted credentials and a live shell.
|
||||
|
||||
**Fix:** Block connections to hosts with changed fingerprints. Require explicit user acceptance via a WS round-trip for new hosts. Never auto-accept changed fingerprints.
|
||||
|
||||
---
|
||||
|
||||
### C-6. SFTP gateway has no session ownership check (horizontal privilege escalation)
|
||||
**Location:** `sftp.gateway.ts:36-215`
|
||||
**Domain:** SFTP / Authorization
|
||||
|
||||
`SftpGateway.handleMessage()` looks up sessions by caller-supplied `sessionId` without verifying the requesting WebSocket client owns that session. User B can supply User A's `sessionId` and get full filesystem access on User A's server.
|
||||
|
||||
**Impact:** Any authenticated user can read/write/delete files on any other user's active SSH session.
|
||||
|
||||
**Fix:** Maintain a `clientSessions` map in `SftpGateway` (same pattern as `TerminalGateway`). Verify session ownership before every SFTP operation.
|
||||
|
||||
---
|
||||
|
||||
### C-7. Raw Guacamole instructions forwarded to guacd without validation
|
||||
**Location:** `rdp.gateway.ts:43-47`
|
||||
**Domain:** RDP
|
||||
|
||||
When `msg.type === 'guac'`, raw `msg.instruction` is written directly to the guacd TCP socket. Zero parsing, validation, or opcode whitelisting. The Guacamole protocol supports `file`, `put`, `pipe`, `disconnect`, and other instructions.
|
||||
|
||||
**Impact:** Authenticated user can inject arbitrary Guacamole protocol instructions — write files via guacd file transfer, crash guacd via malformed instructions, or cause protocol desync.
|
||||
|
||||
**Fix:** Parse incoming instructions via `guacamole.service.ts` `decode()`. Whitelist permitted opcodes (`input`, `mouse`, `key`, `size`, `sync`, `disconnect`). Enforce per-message size limit. Reject anything that doesn't parse.
|
||||
|
||||
---
|
||||
|
||||
### C-8. PostgreSQL port exposed to host network
|
||||
**Location:** `docker-compose.yml:27`
|
||||
**Domain:** Infrastructure
|
||||
|
||||
`ports: ["4211:5432"]` maps PostgreSQL to the host. Without a host-level firewall rule, the database is network-accessible. Contains encrypted credentials, SSH private keys, TOTP secrets, password hashes.
|
||||
|
||||
**Impact:** Direct database access from the network. Even with password auth, the attack surface is unnecessary.
|
||||
|
||||
**Fix:** Remove the `ports` mapping. Only the app container needs DB access via the internal Docker network. Use `docker exec` for admin access.
|
||||
|
||||
---
|
||||
|
||||
## HIGH Findings (16)
|
||||
|
||||
### H-1. 7-day JWT with no revocation mechanism
|
||||
**Location:** `backend/src/auth/auth.module.ts:14`
|
||||
**Domain:** Auth
|
||||
|
||||
JWTs signed with 7-day expiry. No token blocklist, no session table, no refresh token pattern. Admin password reset, TOTP reset, and role changes do not invalidate existing tokens.
|
||||
|
||||
**Fix:** Short-lived access token (15min) + refresh token in httpOnly cookie. Or: Redis-backed blocklist checked on every request.
|
||||
|
||||
### H-2. RDP certificate verification hardcoded to disabled
|
||||
**Location:** `rdp.gateway.ts:90`, `guacamole.service.ts:142`
|
||||
**Domain:** RDP
|
||||
|
||||
`ignoreCert: true` hardcoded unconditionally. Every RDP connection accepts any certificate — MITM attacks are invisible.
|
||||
|
||||
**Fix:** Store `ignoreCert` as a per-host setting (default `false`). Surface a UI warning when enabled.
|
||||
|
||||
### H-3. TOTP secret stored as plaintext in database
|
||||
**Location:** `users` table, `totp_secret` column
|
||||
**Domain:** Auth / Vault
|
||||
|
||||
TOTP secrets stored unencrypted. If the database is compromised (C-8 makes this plausible), attacker can generate valid TOTP codes for every user with 2FA enabled, completely defeating the second factor.
|
||||
|
||||
**Fix:** Encrypt TOTP secrets using the vault's `EncryptionService` (Argon2id v2) before storage. Decrypt only when validating a TOTP code.
|
||||
|
||||
### H-4. SSH private key material logged in cleartext
|
||||
**Location:** `ssh-connection.service.ts:126-129`
|
||||
**Domain:** SSH / Logging
|
||||
|
||||
First 40 characters of decrypted private key, key length, and passphrase existence boolean logged to stdout. Docker routes stdout to `docker logs`, which may be shipped to external log aggregation.
|
||||
|
||||
**Fix:** Remove lines 126-129 entirely. Log only key type and fingerprint.
|
||||
|
||||
### H-5. Terminal keystroke data logged (passwords in sudo prompts)
|
||||
**Location:** `terminal.gateway.ts:31`
|
||||
**Domain:** WebSocket / Logging
|
||||
|
||||
`JSON.stringify(msg).substring(0, 200)` logs raw terminal keystrokes including passwords typed at `sudo` prompts. 200-char truncation still captures most passwords.
|
||||
|
||||
**Fix:** For `msg.type === 'data'`, log only `{ type: 'data', sessionId, bytes: msg.data?.length }`.
|
||||
|
||||
### H-6. No rate limiting on authentication endpoints
|
||||
**Location:** Entire backend — no throttler installed
|
||||
**Domain:** Auth / Infrastructure
|
||||
|
||||
No `@nestjs/throttler`, no `express-rate-limit`. Login endpoint accepts unlimited attempts. Combined with 6-character minimum password = viable online brute-force.
|
||||
|
||||
**Fix:** Install `@nestjs/throttler`. Apply tight limit on auth endpoints (10 req/min/IP). Add account lockout after repeated failures.
|
||||
|
||||
### H-7. Container runs as root
|
||||
**Location:** `Dockerfile:19-28`
|
||||
**Domain:** Infrastructure
|
||||
|
||||
Final Docker stage runs as `root`. Any code execution vulnerability (path traversal, injection) gives root access inside the container.
|
||||
|
||||
**Fix:** Add `RUN addgroup -S wraith && adduser -S wraith -G wraith` and `USER wraith` before `CMD`.
|
||||
|
||||
### H-8. Timing attack on login (bcrypt comparison)
|
||||
**Location:** `auth.service.ts` login handler
|
||||
**Domain:** Auth
|
||||
|
||||
Failed login for non-existent user returns faster than for existing user (skips bcrypt comparison). Enables username enumeration via timing analysis.
|
||||
|
||||
**Fix:** Always run `bcrypt.compare()` against a dummy hash when user not found, ensuring constant-time response.
|
||||
|
||||
### H-9. bcrypt cost factor is 10 (below modern recommendations)
|
||||
**Location:** `auth.service.ts`
|
||||
**Domain:** Auth
|
||||
|
||||
bcrypt cost 10 = ~100ms on modern hardware. OWASP recommends 12+ for password hashing.
|
||||
|
||||
**Fix:** Increase to `bcrypt.hash(password, 12)`. Existing hashes auto-upgrade on next login.
|
||||
|
||||
### H-10. `findAll` credentials endpoint leaks encrypted blobs
|
||||
**Location:** `credentials.service.ts` / `credentials.controller.ts`
|
||||
**Domain:** Vault
|
||||
|
||||
The `GET /api/credentials` response includes `encryptedValue` fields. While encrypted, exposing ciphertext over the API gives attackers material for offline analysis and is unnecessary — the frontend never needs the encrypted blob.
|
||||
|
||||
**Fix:** Add `select` clause to exclude `encryptedValue` from list responses.
|
||||
|
||||
### H-11. No upload size limit on SFTP
|
||||
**Location:** `sftp.gateway.ts:130-138`
|
||||
**Domain:** SFTP
|
||||
|
||||
`upload` handler does `Buffer.from(msg.data, 'base64')` with no size check. An authenticated user can send multi-gigabyte payloads, exhausting server memory.
|
||||
|
||||
**Fix:** Check `msg.data.length` before `Buffer.from()`. Enforce max (e.g., 50MB base64 = ~37MB file). Set `maxPayload` on WebSocket server config.
|
||||
|
||||
### H-12. No write size limit on SFTP file editor
|
||||
**Location:** `sftp.gateway.ts:122-128`
|
||||
**Domain:** SFTP
|
||||
|
||||
`write` handler (save from Monaco editor) has no size check. `MAX_EDIT_SIZE` exists but is only applied to `read`.
|
||||
|
||||
**Fix:** Apply `MAX_EDIT_SIZE` check on the write path.
|
||||
|
||||
### H-13. Shell integration injected into remote sessions without consent
|
||||
**Location:** `ssh-connection.service.ts:59-65`
|
||||
**Domain:** SSH
|
||||
|
||||
`PROMPT_COMMAND` / `precmd_functions` modification injected into every SSH shell for CWD tracking. Users are not informed. If this injection were modified (supply chain, code change), it would execute on every connected host.
|
||||
|
||||
**Fix:** Make opt-in. Document the behavior. Scope injection to minimum needed.
|
||||
|
||||
### H-14. Password auth credentials logged with username and host
|
||||
**Location:** `ssh-connection.service.ts:146`
|
||||
**Domain:** SSH / Logging
|
||||
|
||||
Logs `username@host:port` for every password-authenticated connection. Creates a persistent record correlating users to targets.
|
||||
|
||||
**Fix:** Log at DEBUG only. Use `hostId` instead of hostname.
|
||||
|
||||
### H-15. guacd routing via `host.docker.internal` bypasses container isolation
|
||||
**Location:** `docker-compose.yml:9`
|
||||
**Domain:** Infrastructure
|
||||
|
||||
App-to-guacd traffic routes out of the container network, through the host, and back. Unnecessary external routing path.
|
||||
|
||||
**Fix:** After fixing C-1, both services on the same Docker network. Use service name `guacd` as hostname.
|
||||
|
||||
### H-16. Client-side-only admin guard
|
||||
**Location:** `frontend/pages/admin/users.vue:4-6`
|
||||
**Domain:** Frontend
|
||||
|
||||
`if (!auth.isAdmin) navigateTo('/')` is a UI redirect, not access control. Can be bypassed during hydration gaps.
|
||||
|
||||
**Fix:** Backend `AdminGuard` handles the real enforcement. Add proper route middleware (`definePageMeta({ middleware: 'admin' })`) for consistent frontend behavior.
|
||||
|
||||
---
|
||||
|
||||
## MEDIUM Findings (18)
|
||||
|
||||
| # | Finding | Location | Domain |
|
||||
|---|---------|----------|--------|
|
||||
| M-1 | Terminal gateway no session ownership check on `data`/`resize`/`disconnect` | `terminal.gateway.ts:76-79` | WebSocket |
|
||||
| M-2 | TOTP replay possible (no used-code tracking) | `auth.service.ts` | Auth |
|
||||
| M-3 | Email change has no verification step | `users.controller.ts` | Auth |
|
||||
| M-4 | Email uniqueness not enforced at DB level | `users` table | Auth |
|
||||
| M-5 | Password minimum length is 6 chars (NIST says 8+, OWASP says 12+) | Frontend + backend DTOs | Auth |
|
||||
| M-6 | JWT_SECRET has no startup validation | `auth.module.ts` | Auth |
|
||||
| M-7 | TOTP secret returned in setup response (exposure window) | `auth.controller.ts` | Auth |
|
||||
| M-8 | Mass assignment via object spread in update endpoints | Multiple controllers | API |
|
||||
| M-9 | CORS config may not behave as expected in production | `main.ts:24-27` | Infrastructure |
|
||||
| M-10 | Weak `.env.example` defaults (`DB_PASSWORD=changeme`) | `.env.example` | Infrastructure |
|
||||
| M-11 | Seed script runs on every container start | `Dockerfile:28` | Infrastructure |
|
||||
| M-12 | File paths logged for every SFTP operation | `sftp.gateway.ts:27` | Logging |
|
||||
| M-13 | SFTP `delete` falls through from `unlink` to `rmdir` silently | `sftp.gateway.ts:154-165` | SFTP |
|
||||
| M-14 | Unbounded TCP buffer for guacd stream (no max size) | `rdp.gateway.ts:100-101` | RDP |
|
||||
| M-15 | Connection log `updateMany` closes sibling sessions | `ssh-connection.service.ts:178-181` | SSH |
|
||||
| M-16 | RDP `security`/`width`/`height`/`dpi` params not validated | `rdp.gateway.ts:85-89` | RDP |
|
||||
| M-17 | Frontend file upload has no client-side size validation | `SftpSidebar.vue:64-73` | Frontend |
|
||||
| M-18 | Error messages from server reflected to UI verbatim | `login.vue:64` | Frontend |
|
||||
|
||||
---
|
||||
|
||||
## LOW Findings (12)
|
||||
|
||||
| # | Finding | Location | Domain |
|
||||
|---|---------|----------|--------|
|
||||
| L-1 | No Content Security Policy header | `main.ts` | Frontend |
|
||||
| L-2 | No WebSocket connection limit per user | `terminal.gateway.ts:8` | WebSocket |
|
||||
| L-3 | Internal error messages forwarded to WS clients | `terminal.gateway.ts:34-35`, `rdp.gateway.ts:51` | WebSocket |
|
||||
| L-4 | Server timezone leaked in Guacamole CONNECT | `guacamole.service.ts:81-85` | RDP |
|
||||
| L-5 | SFTP event listeners re-registered on every message | `sftp.gateway.ts:53-58` | SFTP |
|
||||
| L-6 | Default SSH username falls back to `root` | `ssh-connection.service.ts:92` | SSH |
|
||||
| L-7 | Weak seed password for default admin | `seed.js` | Infrastructure |
|
||||
| L-8 | SSH fingerprint derived from private key (should use public) | `ssh-keys.service.ts` | Vault |
|
||||
| L-9 | `console.error` used instead of structured logger | Multiple files | Logging |
|
||||
| L-10 | `confirm()` used for SFTP delete | `SftpSidebar.vue:210` | Frontend |
|
||||
| L-11 | Settings mirrored to localStorage unnecessarily | `default.vue:25-27` | Frontend |
|
||||
| L-12 | No DTO validation on admin password reset | `auth.controller.ts` | Auth |
|
||||
|
||||
---
|
||||
|
||||
## ISO 27001:2022 Gap Assessment
|
||||
|
||||
| Control | Status | Gap |
|
||||
|---------|--------|-----|
|
||||
| **A.5 — Security Policies** | MISSING | No security policies, incident response plan, or vulnerability disclosure process |
|
||||
| **A.6 — Security Roles** | MISSING | No defined security responsibilities or RACI for incidents |
|
||||
| **A.8.1 — Asset Management** | MISSING | No data classification scheme (SSH keys, TOTP secrets, credentials treated uniformly) |
|
||||
| **A.8.5 — Access Control** | PARTIAL | Auth exists but: no brute-force protection, no account lockout, no session revocation, only 2 roles (admin/user) with no least-privilege granularity |
|
||||
| **A.8.9 — Configuration Mgmt** | FAIL | guacd on host network, DB port exposed, no security headers |
|
||||
| **A.8.15 — Logging** | FAIL | No structured audit log. Sensitive data IN logs. No failed login tracking |
|
||||
| **A.8.16 — Monitoring** | MISSING | No anomaly detection, no alerting on repeated auth failures |
|
||||
| **A.8.24 — Cryptography** | PARTIAL | Vault encryption is excellent (Argon2id). But: no TLS, tokens in URLs, TOTP unencrypted, keys in logs |
|
||||
| **A.8.25 — Secure Development** | MISSING | No SAST, no dependency scanning, no security testing |
|
||||
| **A.8.28 — Secure Coding** | MISSING | No documented coding standard, no input validation framework |
|
||||
|
||||
---
|
||||
|
||||
## Prioritized Remediation Roadmap
|
||||
|
||||
### Phase 1 — Stop the Bleeding (do this week)
|
||||
|
||||
| Priority | Finding | Effort | Impact |
|
||||
|----------|---------|--------|--------|
|
||||
| 1 | **C-1:** Fix guacd `network_mode: host` | 30 min | Closes unauthenticated backdoor to entire infrastructure |
|
||||
| 2 | **C-8:** Remove PostgreSQL port exposure | 5 min | Closes direct DB access from network |
|
||||
| 3 | **C-6:** Add session ownership to SFTP gateway | 1 hr | Blocks cross-user file access |
|
||||
| 4 | **H-4:** Remove private key logging | 15 min | Stop bleeding secrets to logs |
|
||||
| 5 | **H-5:** Stop logging terminal keystroke data | 15 min | Stop logging passwords |
|
||||
| 6 | **H-11:** Add upload size limit | 15 min | Block memory exhaustion DoS |
|
||||
|
||||
### Phase 2 — Auth Hardening (next sprint)
|
||||
|
||||
| Priority | Finding | Effort | Impact |
|
||||
|----------|---------|--------|--------|
|
||||
| 7 | **C-2 + C-3:** Move JWT to httpOnly cookie + WS ticket auth | 4 hr | Eliminates primary token theft vectors |
|
||||
| 8 | **C-4:** Add TLS termination (nginx + Let's Encrypt) | 2 hr | Encrypts all traffic |
|
||||
| 9 | **H-1:** Short-lived access + refresh token | 3 hr | Limits exposure window of stolen tokens |
|
||||
| 10 | **H-6:** Rate limiting on auth endpoints | 1 hr | Blocks brute-force |
|
||||
| 11 | **H-3:** Encrypt TOTP secrets in DB | 1 hr | Protects 2FA if DB compromised |
|
||||
| 12 | **M-5:** Increase password minimum to 12 chars | 15 min | NIST/OWASP compliance |
|
||||
|
||||
### Phase 3 — Channel Hardening (following sprint)
|
||||
|
||||
| Priority | Finding | Effort | Impact |
|
||||
|----------|---------|--------|--------|
|
||||
| 13 | **C-5:** SSH host key verification (block changed fingerprints) | 3 hr | Blocks MITM on SSH |
|
||||
| 14 | **C-7:** Guacamole instruction validation + opcode whitelist | 2 hr | Blocks protocol injection |
|
||||
| 15 | **H-2:** RDP cert validation (per-host configurable) | 2 hr | Blocks MITM on RDP |
|
||||
| 16 | **M-1:** Terminal gateway session ownership check | 30 min | Blocks cross-user terminal access |
|
||||
| 17 | **H-7:** Run container as non-root | 30 min | Limits blast radius of any RCE |
|
||||
|
||||
### Phase 4 — Hardening & Compliance (ongoing)
|
||||
|
||||
Everything in MEDIUM and LOW, plus ISO 27001 documentation gaps. Most are incremental improvements that can be addressed as part of normal development.
|
||||
|
||||
---
|
||||
|
||||
## What's Actually Good
|
||||
|
||||
Credit where due — these areas are solid:
|
||||
|
||||
- **Vault encryption (Argon2id v2)** — OWASP-recommended parameters, per-record salts, backwards-compatible versioning, migration endpoint. This is production-grade.
|
||||
- **Credential isolation** — `decryptForConnection()` is internal-only, never exposed over API. Correct pattern.
|
||||
- **Per-user data isolation** — Users can only see their own credentials and SSH keys (ownership checks in vault services).
|
||||
- **TOTP 2FA implementation** — Correct TOTP flow with QR code generation (aside from the plaintext storage issue).
|
||||
- **Password hashing** — bcrypt is correct choice (cost factor should increase, but the algorithm is right).
|
||||
- **Admin guards on backend** — `AdminGuard` properly enforces server-side. Not just frontend checks.
|
||||
|
||||
---
|
||||
|
||||
*Report generated by 4 parallel audit agents covering Auth/JWT/Session, Vault/Encryption/DB, WebSocket/SSH/SFTP/RDP, and Frontend/Infrastructure/ISO 27001. Deduplicated from 70+ raw findings to 54 unique issues.*
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
|
Before Width: | Height: | Size: 632 KiB |
BIN
docs/moba1.png
BIN
docs/moba1.png
Binary file not shown.
|
Before Width: | Height: | Size: 132 KiB |
BIN
docs/moba2.png
BIN
docs/moba2.png
Binary file not shown.
|
Before Width: | Height: | Size: 115 KiB |
File diff suppressed because it is too large
Load Diff
BIN
docs/screenshots/stats-and-status-bars.png
Normal file
BIN
docs/screenshots/stats-and-status-bars.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 80 KiB |
340
docs/specs/wraith-terminal-mcp-spec.md
Normal file
340
docs/specs/wraith-terminal-mcp-spec.md
Normal file
@ -0,0 +1,340 @@
|
||||
# Wraith Terminal MCP — Design Specification
|
||||
|
||||
**Date:** March 25, 2026
|
||||
**Status:** Draft
|
||||
**Author:** Gargoyle (HQ XO)
|
||||
|
||||
---
|
||||
|
||||
## 1. Problem
|
||||
|
||||
The AI copilot panel in Wraith runs CLI tools (Claude, Gemini, Codex) in a local PTY. The AI can chat with the user, but it cannot independently interact with active SSH/RDP sessions. The technician has to manually copy-paste terminal output into the AI and relay commands back.
|
||||
|
||||
The goal: let the AI **drive** the terminal. Read output. Execute commands. Take screenshots. React to errors. All through a standardized protocol.
|
||||
|
||||
---
|
||||
|
||||
## 2. Solution: Wraith Terminal MCP Server
|
||||
|
||||
Implement an MCP (Model Context Protocol) server inside Wraith's Rust backend that exposes active sessions as tools and resources. The AI CLI running in the copilot panel connects to this MCP server and gains programmatic access to every open session.
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
AI CLI (claude/gemini)
|
||||
|
|
||||
+-- MCP Client (built into the CLI)
|
||||
|
|
||||
+-- connects to localhost:PORT or Unix socket
|
||||
|
|
||||
v
|
||||
Wraith MCP Server (Rust, runs inside Tauri)
|
||||
|
|
||||
+-- Tool: terminal_execute(session_id, command)
|
||||
+-- Tool: terminal_read(session_id, lines?)
|
||||
+-- Tool: terminal_screenshot(session_id) [RDP only]
|
||||
+-- Tool: sftp_list(session_id, path)
|
||||
+-- Tool: sftp_read(session_id, path)
|
||||
+-- Tool: sftp_write(session_id, path, content)
|
||||
+-- Resource: sessions://list
|
||||
+-- Resource: sessions://{id}/info
|
||||
+-- Resource: sessions://{id}/scrollback
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. MCP Server Implementation
|
||||
|
||||
### 3.1 Transport
|
||||
|
||||
Two options for how the AI CLI connects to the MCP server:
|
||||
|
||||
**Option A: stdio (Recommended for v1)**
|
||||
- The copilot panel spawns the AI CLI with `--mcp-server` flag pointing to a Wraith helper binary
|
||||
- The helper binary communicates with Wraith's Tauri backend via Tauri commands
|
||||
- Simple, no port management, no firewall issues
|
||||
- Pattern: AI CLI → stdio → wraith-mcp-bridge → Tauri invoke → session data
|
||||
|
||||
**Option B: HTTP/SSE (Future)**
|
||||
- Wraith runs an HTTP server on localhost:random-port
|
||||
- AI CLI connects via `--mcp-server http://localhost:PORT`
|
||||
- More flexible (multiple AI CLIs can connect), but requires port management
|
||||
- Pattern: AI CLI → HTTP → Wraith MCP HTTP handler → session data
|
||||
|
||||
### 3.2 Rust Implementation
|
||||
|
||||
```
|
||||
src-tauri/src/mcp/
|
||||
mod.rs — MCP server lifecycle, transport handling
|
||||
tools.rs — Tool definitions (terminal_execute, screenshot, etc.)
|
||||
resources.rs — Resource definitions (session list, scrollback)
|
||||
bridge.rs — Bridge between MCP protocol and existing services
|
||||
```
|
||||
|
||||
The MCP server reuses existing services:
|
||||
- `SshService` — for terminal_execute, terminal_read on SSH sessions
|
||||
- `RdpService` — for terminal_screenshot on RDP sessions
|
||||
- `SftpService` — for sftp_list, sftp_read, sftp_write
|
||||
- `PtyService` — for local shell access
|
||||
- `SessionStore` (DashMap) — for session enumeration
|
||||
|
||||
---
|
||||
|
||||
## 4. Tools
|
||||
|
||||
### 4.1 terminal_execute
|
||||
|
||||
Execute a command in an active SSH or local PTY session and return the output.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "terminal_execute",
|
||||
"description": "Execute a command in a terminal session and return output",
|
||||
"parameters": {
|
||||
"session_id": "string — the active session ID",
|
||||
"command": "string — the command to run (newline appended automatically)",
|
||||
"timeout_ms": "number — max wait for output (default: 5000)"
|
||||
},
|
||||
"returns": "string — captured terminal output after command execution"
|
||||
}
|
||||
```
|
||||
|
||||
**Implementation:** Write command + `\n` to the session's writer. Start capturing output from the session's reader. Wait for a shell prompt pattern or timeout. Return captured bytes as UTF-8 string.
|
||||
|
||||
**Challenge:** Detecting when command output is "done" — shell prompt detection is fragile. Options:
|
||||
- **Marker approach:** Send `echo __WRAITH_DONE__` after the command, capture until marker appears
|
||||
- **Timeout approach:** Wait N ms after last output byte, assume done
|
||||
- **Prompt regex:** Configurable prompt pattern (default: `$ `, `# `, `> `, `PS>`)
|
||||
|
||||
Recommend: marker approach for SSH, timeout approach for PTY (since local shells have predictable prompt timing).
|
||||
|
||||
### 4.2 terminal_read
|
||||
|
||||
Read the current scrollback or recent output from a session without executing anything.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "terminal_read",
|
||||
"description": "Read recent terminal output from a session",
|
||||
"parameters": {
|
||||
"session_id": "string",
|
||||
"lines": "number — last N lines (default: 50)"
|
||||
},
|
||||
"returns": "string — terminal scrollback content (ANSI stripped)"
|
||||
}
|
||||
```
|
||||
|
||||
**Implementation:** Maintain a circular buffer of recent output per session (last 10KB). On read, return the last N lines with ANSI escape codes stripped.
|
||||
|
||||
**Note:** The buffer exists in the Rust backend, not xterm.js. The AI doesn't need to scrape the DOM — it reads from the same data stream that feeds the terminal.
|
||||
|
||||
### 4.3 terminal_screenshot
|
||||
|
||||
Capture the current frame of an RDP session as a base64-encoded PNG.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "terminal_screenshot",
|
||||
"description": "Capture a screenshot of an RDP session",
|
||||
"parameters": {
|
||||
"session_id": "string — must be an RDP session"
|
||||
},
|
||||
"returns": "string — base64-encoded PNG image"
|
||||
}
|
||||
```
|
||||
|
||||
**Implementation:** The RDP frame buffer is already maintained by `RdpService`. Encode the current frame as PNG (using the `image` crate), base64 encode, return. The AI CLI passes this to the multimodal AI provider for visual analysis.
|
||||
|
||||
**Use case:** "Screenshot the error on screen. What can you tell me about it?"
|
||||
|
||||
### 4.4 sftp_list
|
||||
|
||||
List files in a directory on the remote host via the session's SFTP channel.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "sftp_list",
|
||||
"description": "List files in a remote directory",
|
||||
"parameters": {
|
||||
"session_id": "string",
|
||||
"path": "string — remote directory path"
|
||||
},
|
||||
"returns": "array of { name, size, modified, is_dir }"
|
||||
}
|
||||
```
|
||||
|
||||
### 4.5 sftp_read
|
||||
|
||||
Read a file from the remote host.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "sftp_read",
|
||||
"description": "Read a file from the remote host",
|
||||
"parameters": {
|
||||
"session_id": "string",
|
||||
"path": "string — remote file path",
|
||||
"max_bytes": "number — limit (default: 1MB)"
|
||||
},
|
||||
"returns": "string — file content (UTF-8) or base64 for binary"
|
||||
}
|
||||
```
|
||||
|
||||
### 4.6 sftp_write
|
||||
|
||||
Write a file to the remote host.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "sftp_write",
|
||||
"description": "Write content to a file on the remote host",
|
||||
"parameters": {
|
||||
"session_id": "string",
|
||||
"path": "string — remote file path",
|
||||
"content": "string — file content"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Resources
|
||||
|
||||
### 5.1 sessions://list
|
||||
|
||||
Returns all active sessions with their type, connection info, and status.
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "ssh-abc123",
|
||||
"type": "ssh",
|
||||
"name": "prod-web-01",
|
||||
"host": "10.0.1.50",
|
||||
"username": "admin",
|
||||
"status": "connected"
|
||||
},
|
||||
{
|
||||
"id": "rdp-def456",
|
||||
"type": "rdp",
|
||||
"name": "dc-01",
|
||||
"host": "10.0.1.10",
|
||||
"status": "connected"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### 5.2 sessions://{id}/info
|
||||
|
||||
Detailed info about a specific session — connection parameters, uptime, bytes transferred.
|
||||
|
||||
### 5.3 sessions://{id}/scrollback
|
||||
|
||||
Full scrollback buffer for a terminal session (last 10KB, ANSI stripped).
|
||||
|
||||
---
|
||||
|
||||
## 6. Security
|
||||
|
||||
- **MCP server only binds to localhost** — no remote access, no network exposure
|
||||
- **Session access inherits Wraith's auth** — if the user is logged into Wraith, the MCP server trusts the connection
|
||||
- **No credential exposure** — the MCP tools execute commands through existing authenticated sessions. The AI never sees passwords or SSH keys.
|
||||
- **Audit trail** — every MCP tool invocation logged with timestamp, session ID, command, and result size
|
||||
- **Read-only option** — sessions can be marked read-only in connection settings, preventing terminal_execute and sftp_write
|
||||
|
||||
---
|
||||
|
||||
## 7. AI CLI Integration
|
||||
|
||||
### 7.1 Claude Code
|
||||
|
||||
Claude Code already supports MCP servers via `--mcp-server` flag or `.claude/settings.json`. Configuration:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"wraith": {
|
||||
"command": "wraith-mcp-bridge",
|
||||
"args": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `wraith-mcp-bridge` is a small binary that Wraith ships alongside the main app. It communicates with the running Wraith instance via Tauri's IPC.
|
||||
|
||||
### 7.2 Gemini CLI
|
||||
|
||||
Gemini CLI supports MCP servers similarly. Same bridge binary, same configuration pattern.
|
||||
|
||||
### 7.3 Auto-Configuration
|
||||
|
||||
When the copilot panel launches an AI CLI, Wraith can auto-inject the MCP server configuration via environment variables or command-line flags, so the user doesn't have to manually configure anything.
|
||||
|
||||
```rust
|
||||
// When spawning the AI CLI in the PTY:
|
||||
let mut cmd = CommandBuilder::new(shell_path);
|
||||
cmd.env("CLAUDE_MCP_SERVERS", r#"{"wraith":{"command":"wraith-mcp-bridge"}}"#);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Data Flow Example
|
||||
|
||||
**User says to Claude in copilot panel:** "Check disk space on the server I'm connected to"
|
||||
|
||||
1. Claude's MCP client calls `sessions://list` → gets `[{id: "ssh-abc", name: "prod-web-01", ...}]`
|
||||
2. Claude calls `terminal_execute(session_id: "ssh-abc", command: "df -h")`
|
||||
3. Wraith MCP bridge → Tauri invoke → SshService.write("ssh-abc", "df -h\n")
|
||||
4. Wraith captures output until prompt marker
|
||||
5. Returns: `/dev/sda1 50G 45G 5G 90% /`
|
||||
6. Claude analyzes: "Your root partition is at 90%. You should clean up /var/log or expand the disk."
|
||||
|
||||
**User says:** "Screenshot the RDP session, what's that error?"
|
||||
|
||||
1. Claude calls `terminal_screenshot(session_id: "rdp-def")`
|
||||
2. Wraith MCP bridge → RdpService.get_frame("rdp-def") → PNG encode → base64
|
||||
3. Returns 200KB base64 PNG
|
||||
4. Claude (multimodal) analyzes the image: "That's a Windows Event Viewer showing Event ID 1001 — application crash in outlook.exe. The faulting module is mso.dll. This is a known Office corruption issue. Run `sfc /scannow` or repair Office from Control Panel."
|
||||
|
||||
---
|
||||
|
||||
## 9. Implementation Phases
|
||||
|
||||
### Phase 1: Bridge + Basic Tools (MVP)
|
||||
- `wraith-mcp-bridge` binary (stdio transport)
|
||||
- `terminal_execute` tool (marker-based output capture)
|
||||
- `terminal_read` tool (scrollback buffer)
|
||||
- `sessions://list` resource
|
||||
- Auto-configuration when spawning AI CLI
|
||||
|
||||
### Phase 2: SFTP + Screenshot
|
||||
- `sftp_list`, `sftp_read`, `sftp_write` tools
|
||||
- `terminal_screenshot` tool (RDP frame capture)
|
||||
- Session info resource
|
||||
|
||||
### Phase 3: Advanced
|
||||
- HTTP/SSE transport for multi-client access
|
||||
- Read-only session enforcement
|
||||
- Audit trail logging
|
||||
- AI-initiated session creation ("Connect me to prod-web-01")
|
||||
|
||||
---
|
||||
|
||||
## 10. Dependencies
|
||||
|
||||
| Component | Crate/Tool | License |
|
||||
|---|---|---|
|
||||
| MCP protocol | Custom implementation (JSON-RPC over stdio) | Proprietary |
|
||||
| PNG encoding | `image` crate | MIT/Apache-2.0 |
|
||||
| Base64 | `base64` crate (already in deps) | MIT/Apache-2.0 |
|
||||
| ANSI stripping | `strip-ansi-escapes` crate | MIT/Apache-2.0 |
|
||||
| Bridge binary | Rust, ships alongside Wraith | Proprietary |
|
||||
|
||||
---
|
||||
|
||||
## 11. Black Binder Note
|
||||
|
||||
An MCP server embedded in a remote access client that gives AI tools programmatic access to live SSH, RDP, and SFTP sessions is, to the company's knowledge, a novel integration. No competing SSH/RDP client ships with an MCP server that allows AI assistants to interact with active remote sessions.
|
||||
|
||||
The combination of terminal command execution, RDP screenshot analysis, and SFTP file operations through a standardized AI tool protocol represents a new category of AI-augmented remote access.
|
||||
@ -1,129 +0,0 @@
|
||||
# Spike: Multi-Window Support in Wails v3
|
||||
|
||||
**Status:** Research-based (not yet validated on Windows)
|
||||
**Date:** 2026-03-17
|
||||
**Target platform:** Windows (developing on macOS)
|
||||
**Wails version:** v3.0.0-alpha.74
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Wraith needs to support detached sessions — users should be able to pop out
|
||||
an SSH or RDP session into its own window while the main connection manager
|
||||
remains open. This spike evaluates three approaches, ranked by preference.
|
||||
|
||||
---
|
||||
|
||||
## Plan A: Wails v3 Native Multi-Window
|
||||
|
||||
**Status: LIKELY WORKS** based on API documentation.
|
||||
|
||||
### How it works
|
||||
|
||||
- `app.Window.NewWithOptions()` creates a new OS-level window at runtime.
|
||||
- Each window can load a different URL or frontend route (e.g.,
|
||||
`/session/rdp/3` in one window, `/` in the main window).
|
||||
- All windows share the same Go backend services — no IPC or inter-process
|
||||
marshalling required. Bindings registered on the application are callable
|
||||
from any window.
|
||||
- Window lifecycle events (`OnClose`, `OnFocus`, etc.) are available for
|
||||
cleanup.
|
||||
|
||||
### Example (pseudocode)
|
||||
|
||||
```go
|
||||
win, err := app.Window.NewWithOptions(application.WindowOptions{
|
||||
Title: "RDP — server-01",
|
||||
Width: 1280,
|
||||
Height: 720,
|
||||
URL: "/session/rdp/3",
|
||||
})
|
||||
```
|
||||
|
||||
### Risks
|
||||
|
||||
| Risk | Severity | Mitigation |
|
||||
|------|----------|------------|
|
||||
| Alpha API — method signatures may change before v3 stable | Medium | Pin to a known-good alpha tag; wrap calls behind an internal interface so migration is a single-file change. |
|
||||
| Platform-specific quirks on Windows (DPI, focus, taskbar grouping) | Low | Test on Windows during Phase 2. Wails uses webview2 on Windows which is mature. |
|
||||
| Window count limits or resource leaks | Low | Cap concurrent detached windows (e.g., 8). Ensure `OnClose` releases resources. |
|
||||
|
||||
---
|
||||
|
||||
## Plan B: Floating Panels (CSS-based)
|
||||
|
||||
**Status: FALLBACK** — no external dependency, purely frontend.
|
||||
|
||||
### How it works
|
||||
|
||||
- Detached sessions render as draggable, resizable `position: fixed` panels
|
||||
within the main Wails window.
|
||||
- Each panel contains its own Vue component instance (terminal emulator or
|
||||
RDP canvas).
|
||||
- Panels can be minimised, maximised within the viewport, or snapped to
|
||||
edges.
|
||||
|
||||
### Pros
|
||||
|
||||
- Zero dependency on Wails multi-window API.
|
||||
- Works on any platform without additional testing.
|
||||
- Simpler state management — everything lives in one window context.
|
||||
|
||||
### Cons
|
||||
|
||||
- Sessions share the same viewport — limited screen real estate.
|
||||
- Cannot span multiple monitors.
|
||||
- Feels less native than real OS windows.
|
||||
|
||||
### Implementation cost
|
||||
|
||||
Small. Requires a `<FloatingPanel>` wrapper component with drag/resize
|
||||
handlers. Libraries like `vue3-draggable-resizable` exist but a lightweight
|
||||
custom implementation (~150 LOC) is preferable to avoid dependency churn.
|
||||
|
||||
---
|
||||
|
||||
## Plan C: Browser Mode
|
||||
|
||||
**Status: EMERGENCY** — last resort if both Plan A and Plan B are inadequate.
|
||||
|
||||
### How it works
|
||||
|
||||
- Wails v3 supports a server mode where the frontend is served over HTTP on
|
||||
`localhost`.
|
||||
- Detached sessions open in the user's default browser via
|
||||
`open(url, '_blank')` or `runtime.BrowserOpenURL()`.
|
||||
- The browser tab communicates with Go services through the same HTTP
|
||||
endpoint.
|
||||
|
||||
### Pros
|
||||
|
||||
- Guaranteed to work — it is just a web page.
|
||||
- Users can arrange tabs freely across monitors.
|
||||
|
||||
### Cons
|
||||
|
||||
- Breaks the desktop-app experience.
|
||||
- Browser tabs lack access to Wails runtime bindings; all communication must
|
||||
go through HTTP/WebSocket, requiring a parallel transport layer.
|
||||
- Security surface increases — localhost HTTP server is accessible to other
|
||||
local processes.
|
||||
|
||||
---
|
||||
|
||||
## Recommendation
|
||||
|
||||
**Start with Plan A.** The Wails v3 `NewWithOptions` API is documented and
|
||||
consistent with how other multi-window desktop frameworks (Electron,
|
||||
Tauri v2) work. The alpha stability risk is mitigated by wrapping calls
|
||||
behind an internal interface.
|
||||
|
||||
If Plan A fails during Windows validation, **Plan B requires only frontend
|
||||
CSS changes** — no backend work is wasted. Plan C is reserved for scenarios
|
||||
where neither A nor B is viable.
|
||||
|
||||
## Next Step
|
||||
|
||||
Validate Plan A on Windows during Phase 2 when SSH sessions exist and there
|
||||
is a real payload to render in a second window.
|
||||
@ -1,171 +0,0 @@
|
||||
# Spike: RDP Frame Transport Mechanisms
|
||||
|
||||
**Status:** Research-based (not yet benchmarked)
|
||||
**Date:** 2026-03-17
|
||||
**Target platform:** Windows (developing on macOS)
|
||||
**Wails version:** v3.0.0-alpha.74
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
When Wraith connects to a remote desktop via FreeRDP, the Go backend
|
||||
receives raw bitmap frames that must be delivered to the frontend for
|
||||
rendering on an HTML `<canvas>`. This spike evaluates three transport
|
||||
approaches, estimating throughput for a 1920x1080 session at 30 fps.
|
||||
|
||||
---
|
||||
|
||||
## Approach 1: Local HTTP Endpoint
|
||||
|
||||
### How it works
|
||||
|
||||
1. Go spins up a local HTTP server on a random high port
|
||||
(`net.Listen("tcp", "127.0.0.1:0")`).
|
||||
2. Each frame is JPEG-encoded and served at a predictable URL
|
||||
(e.g., `http://127.0.0.1:{port}/frame?session=3`).
|
||||
3. The frontend fetches frames via `fetch()`, `<img>` tag, or
|
||||
`ReadableStream` for chunked delivery.
|
||||
|
||||
### Throughput estimate
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| 1080p RGBA raw | ~8 MB/frame |
|
||||
| 1080p JPEG (quality 80) | ~100-200 KB/frame |
|
||||
| At 30 fps (JPEG) | ~3-6 MB/s |
|
||||
| Loopback bandwidth | >1 GB/s |
|
||||
|
||||
Loopback HTTP can handle this with headroom to spare.
|
||||
|
||||
### Pros
|
||||
|
||||
- No base64 overhead — binary JPEG bytes transfer directly.
|
||||
- Standard HTTP semantics; easy to debug with browser DevTools.
|
||||
- Can use `Transfer-Encoding: chunked` or Server-Sent Events for
|
||||
push-based delivery.
|
||||
- Can serve multiple sessions on the same server with different paths.
|
||||
|
||||
### Cons
|
||||
|
||||
- Requires an extra listening port on localhost.
|
||||
- Potential firewall or endpoint-security issues on locked-down Windows
|
||||
enterprise machines.
|
||||
- Slightly more complex setup (port allocation, CORS headers for Wails
|
||||
webview origin).
|
||||
|
||||
---
|
||||
|
||||
## Approach 2: Wails Bindings (Base64)
|
||||
|
||||
### How it works
|
||||
|
||||
1. Go encodes each frame as a JPEG, then base64-encodes the result.
|
||||
2. A Wails-bound method (`SessionService.GetFrame(sessionID)`) returns the
|
||||
base64 string.
|
||||
3. The frontend decodes the string, creates an `ImageBitmap` or sets it as a
|
||||
data URI, and draws it on a `<canvas>`.
|
||||
|
||||
### Throughput estimate
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| 1080p JPEG (quality 80) | ~100-200 KB/frame |
|
||||
| Base64 of JPEG (+33%) | ~133-270 KB/frame |
|
||||
| At 30 fps | ~4-8 MB/s of string data |
|
||||
| Wails IPC overhead | Negligible for this payload size |
|
||||
|
||||
This is feasible. Modern JavaScript engines handle base64 decoding at
|
||||
several hundred MB/s.
|
||||
|
||||
### Pros
|
||||
|
||||
- No extra ports — everything flows through the existing Wails IPC channel.
|
||||
- Works out of the box with Wails bindings; no additional infrastructure.
|
||||
- No firewall concerns.
|
||||
|
||||
### Cons
|
||||
|
||||
- 33% base64 size overhead on every frame.
|
||||
- CPU cost of `base64.StdEncoding.EncodeToString()` in Go and `atob()` in
|
||||
JS on every frame (though both are fast).
|
||||
- Polling-based unless combined with Wails events to signal frame
|
||||
availability.
|
||||
- May bottleneck at very high resolutions (4K) or high FPS (60+).
|
||||
|
||||
---
|
||||
|
||||
## Approach 3: Wails Events (Streaming)
|
||||
|
||||
### How it works
|
||||
|
||||
1. Go emits each frame as a Wails event:
|
||||
`app.EmitEvent("frame:3", base64JpegString)`.
|
||||
2. The frontend subscribes: `wails.Events.On("frame:3", handler)`.
|
||||
3. The handler decodes and renders on canvas.
|
||||
|
||||
### Throughput estimate
|
||||
|
||||
Same as Approach 2 — the payload is identical (base64 JPEG). The difference
|
||||
is delivery mechanism (push vs. pull).
|
||||
|
||||
### Pros
|
||||
|
||||
- Push-based — the frontend receives frames as soon as they are available
|
||||
with no polling delay.
|
||||
- Natural Wails pattern; aligns with how other real-time data (connection
|
||||
status, notifications) already flows.
|
||||
|
||||
### Cons
|
||||
|
||||
- Same 33% base64 overhead as Approach 2.
|
||||
- Wails event bus may not be optimised for high-frequency, large-payload
|
||||
events. This is unvalidated.
|
||||
- Harder to apply backpressure — if the frontend cannot keep up, events
|
||||
queue without flow control.
|
||||
|
||||
---
|
||||
|
||||
## Throughput Summary
|
||||
|
||||
| Approach | Payload/frame | 30 fps throughput | Extra infra |
|
||||
|----------|--------------|-------------------|-------------|
|
||||
| 1 — Local HTTP | ~150 KB (binary JPEG) | ~4.5 MB/s | Localhost port |
|
||||
| 2 — Wails bindings | ~200 KB (base64 JPEG) | ~6 MB/s | None |
|
||||
| 3 — Wails events | ~200 KB (base64 JPEG) | ~6 MB/s | None |
|
||||
|
||||
All three approaches are within comfortable limits for 1080p at 30 fps.
|
||||
The differentiator is operational simplicity, not raw throughput.
|
||||
|
||||
---
|
||||
|
||||
## Recommendation
|
||||
|
||||
**Start with Approach 2 (base64 JPEG via Wails bindings).**
|
||||
|
||||
Rationale:
|
||||
|
||||
1. JPEG compression brings 1080p frames down to ~200 KB, making the 33%
|
||||
base64 overhead manageable (~6 MB/s at 30 fps).
|
||||
2. No extra ports or firewall concerns — important for enterprise Windows
|
||||
environments where Wraith will be deployed.
|
||||
3. Simple implementation: one Go method, one frontend call per frame.
|
||||
4. If polling latency is a problem, upgrade to Approach 3 (events) with
|
||||
minimal code change — the payload encoding is identical.
|
||||
|
||||
**If benchmarking reveals issues** (dropped frames, high CPU from
|
||||
encoding), fall back to Approach 1 (local HTTP) which eliminates base64
|
||||
overhead entirely. The migration path is straightforward: replace the
|
||||
`fetch(dataUri)` call with `fetch(httpUrl)`.
|
||||
|
||||
---
|
||||
|
||||
## Next Step
|
||||
|
||||
Benchmark during Phase 3 when FreeRDP integration is in progress and real
|
||||
frame data is available. Key metrics to capture:
|
||||
|
||||
- End-to-end frame latency (Go encode to canvas paint)
|
||||
- CPU utilisation on both Go and browser sides
|
||||
- Frame drop rate at 30 fps and 60 fps
|
||||
- Memory pressure from base64 string allocation/GC
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,487 +0,0 @@
|
||||
# Wraith Desktop — Phase 2: SSH + SFTP 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:** Connect to remote hosts via SSH with a real terminal (xterm.js), SFTP sidebar with file operations and CWD following, multi-tab sessions, and CodeMirror 6 file editor.
|
||||
|
||||
**Architecture:** Go SSH service wraps `x/crypto/ssh` for connections, PTY requests, and shell I/O. SFTP service wraps `pkg/sftp` riding the same SSH connection. Data flows: xterm.js ↔ Wails bindings ↔ Go SSH pipes. CWD tracking via OSC 7 shell injection.
|
||||
|
||||
**Tech Stack:** `golang.org/x/crypto/ssh`, `github.com/pkg/sftp`, xterm.js 5.x + WebGL addon + fit addon + search addon, CodeMirror 6
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-03-17-wraith-desktop-design.md` (Sections 6, 10, 11)
|
||||
|
||||
---
|
||||
|
||||
## File Structure (New/Modified)
|
||||
|
||||
```
|
||||
internal/
|
||||
ssh/
|
||||
service.go # SSH dial, PTY, shell, I/O goroutines
|
||||
service_test.go # Connection config tests (unit, no real SSH)
|
||||
hostkey.go # Host key verification + storage
|
||||
hostkey_test.go
|
||||
cwd.go # OSC 7 parser for CWD tracking
|
||||
cwd_test.go
|
||||
sftp/
|
||||
service.go # SFTP operations (list, upload, download, etc.)
|
||||
service_test.go
|
||||
credentials/
|
||||
service.go # Credential CRUD (encrypted passwords + SSH keys)
|
||||
service_test.go
|
||||
app/
|
||||
app.go # Add SSH/SFTP/Credential services
|
||||
frontend/
|
||||
src/
|
||||
components/
|
||||
terminal/
|
||||
TerminalView.vue # xterm.js instance wrapper
|
||||
sftp/
|
||||
FileTree.vue # Remote filesystem tree
|
||||
TransferProgress.vue # Upload/download progress
|
||||
editor/
|
||||
EditorWindow.vue # CodeMirror 6 (placeholder for multi-window)
|
||||
composables/
|
||||
useTerminal.ts # xterm.js lifecycle + Wails binding bridge
|
||||
useSftp.ts # SFTP operations via Wails bindings
|
||||
stores/
|
||||
session.store.ts # Update with real session management
|
||||
assets/
|
||||
css/
|
||||
terminal.css # xterm.js overrides
|
||||
package.json # Add xterm.js, codemirror deps
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 1: SSH Service — Connect, PTY, Shell I/O
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/ssh/service.go`
|
||||
- Create: `internal/ssh/service_test.go`
|
||||
|
||||
The SSH service manages connections and exposes methods to Wails:
|
||||
|
||||
```go
|
||||
// SSHService methods (exposed to frontend via Wails bindings):
|
||||
// Connect(connectionID int64) (string, error) → returns sessionID
|
||||
// Write(sessionID string, data string) error → write to stdin
|
||||
// Resize(sessionID string, cols, rows int) error → window change
|
||||
// Disconnect(sessionID string) error → close session
|
||||
//
|
||||
// Events emitted to frontend via Wails events:
|
||||
// "ssh:data:{sessionID}" → terminal output (stdout)
|
||||
// "ssh:connected:{sessionID}" → connection established
|
||||
// "ssh:disconnected:{sessionID}" → connection closed
|
||||
// "ssh:error:{sessionID}" → error message
|
||||
```
|
||||
|
||||
Key implementation details:
|
||||
- Each SSH session runs two goroutines: one reading stdout→Wails events, one for keepalive
|
||||
- Sessions stored in a `map[string]*SSHSession` with mutex
|
||||
- `SSHSession` holds: `*ssh.Client`, `*ssh.Session`, stdin `io.WriteCloser`, connection metadata
|
||||
- PTY requested as `xterm-256color` with initial size from frontend
|
||||
- Auth method determined by credential type (password, SSH key, keyboard-interactive)
|
||||
- Host key verification delegates to `hostkey.go`
|
||||
|
||||
Tests (unit only — no real SSH server):
|
||||
- TestSSHServiceCreation
|
||||
- TestBuildAuthMethods (password → ssh.Password, key → ssh.PublicKeys)
|
||||
- TestSessionTracking (create, get, remove)
|
||||
|
||||
- [ ] **Step 1:** Write tests
|
||||
- [ ] **Step 2:** Implement service
|
||||
- [ ] **Step 3:** Run tests, verify pass
|
||||
- [ ] **Step 4:** Commit: `feat: SSH service — connect, PTY, shell I/O with goroutine pipes`
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Host Key Verification
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/ssh/hostkey.go`
|
||||
- Create: `internal/ssh/hostkey_test.go`
|
||||
|
||||
Host key verification stores/checks fingerprints in the `host_keys` SQLite table:
|
||||
- New host → emit `ssh:hostkey-verify` event to frontend with fingerprint, wait for accept/reject
|
||||
- Known host, matching fingerprint → proceed silently
|
||||
- Known host, CHANGED fingerprint → emit warning event, block connection
|
||||
|
||||
For Phase 2, implement the storage and verification logic. The frontend prompt (accept/reject dialog) will be wired in the frontend task.
|
||||
|
||||
Tests:
|
||||
- TestStoreHostKey
|
||||
- TestVerifyKnownHost (match → ok)
|
||||
- TestVerifyChangedHost (mismatch → error)
|
||||
- TestVerifyNewHost (not found → returns "new")
|
||||
|
||||
- [ ] **Step 1:** Write tests
|
||||
- [ ] **Step 2:** Implement hostkey.go
|
||||
- [ ] **Step 3:** Run tests, verify pass
|
||||
- [ ] **Step 4:** Commit: `feat: SSH host key verification — store, verify, detect changes`
|
||||
|
||||
---
|
||||
|
||||
## Task 3: CWD Tracker (OSC 7 Parser)
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/ssh/cwd.go`
|
||||
- Create: `internal/ssh/cwd_test.go`
|
||||
|
||||
Parses OSC 7 escape sequences from terminal output to track the remote working directory:
|
||||
|
||||
```
|
||||
Input: "some output\033]7;file://hostname/home/user\033\\more output"
|
||||
Output: stripped="some output more output", cwd="/home/user"
|
||||
```
|
||||
|
||||
The CWD tracker:
|
||||
1. Scans byte stream for `\033]7;` prefix
|
||||
2. Extracts URL between prefix and `\033\\` (or `\007`) terminator
|
||||
3. Parses `file://hostname/path` to extract just the path
|
||||
4. Strips the OSC 7 sequence from the output before forwarding to xterm.js
|
||||
5. Returns the new CWD path when detected
|
||||
|
||||
Shell injection command (injected after PTY is established):
|
||||
```bash
|
||||
# bash
|
||||
PROMPT_COMMAND='printf "\033]7;file://%s%s\033\\" "$(hostname)" "$PWD"'
|
||||
# zsh
|
||||
precmd() { printf "\033]7;file://%s%s\033\\" "$(hostname)" "$PWD" }
|
||||
# fish
|
||||
function fish_prompt; printf "\033]7;file://%s%s\033\\" (hostname) "$PWD"; end
|
||||
```
|
||||
|
||||
Tests:
|
||||
- TestParseOSC7Basic
|
||||
- TestParseOSC7WithBEL (terminated by \007 instead of ST)
|
||||
- TestParseOSC7NoMatch (no OSC 7 in output)
|
||||
- TestParseOSC7MultipleInStream
|
||||
- TestStripOSC7FromOutput
|
||||
|
||||
- [ ] **Step 1:** Write tests
|
||||
- [ ] **Step 2:** Implement cwd.go
|
||||
- [ ] **Step 3:** Run tests, verify pass
|
||||
- [ ] **Step 4:** Commit: `feat: OSC 7 CWD tracker — parse and strip directory change sequences`
|
||||
|
||||
---
|
||||
|
||||
## Task 4: SFTP Service
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/sftp/service.go`
|
||||
- Create: `internal/sftp/service_test.go`
|
||||
|
||||
SFTP service wraps `pkg/sftp` and exposes file operations to the frontend:
|
||||
|
||||
```go
|
||||
// SFTPService methods (exposed via Wails bindings):
|
||||
// OpenSFTP(sessionID string) error → start SFTP on existing SSH connection
|
||||
// List(sessionID string, path string) ([]FileEntry, error) → directory listing
|
||||
// ReadFile(sessionID string, path string) (string, error) → read file content (max 5MB)
|
||||
// WriteFile(sessionID string, path string, content string) error → write file
|
||||
// Upload(sessionID string, remotePath string, localPath string) error
|
||||
// Download(sessionID string, remotePath string) (string, error) → returns local temp path
|
||||
// Mkdir(sessionID string, path string) error
|
||||
// Delete(sessionID string, path string) error
|
||||
// Rename(sessionID string, oldPath, newPath string) error
|
||||
// Stat(sessionID string, path string) (*FileEntry, error)
|
||||
```
|
||||
|
||||
`FileEntry` type:
|
||||
```go
|
||||
type FileEntry struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Size int64 `json:"size"`
|
||||
IsDir bool `json:"isDir"`
|
||||
Permissions string `json:"permissions"`
|
||||
ModTime string `json:"modTime"`
|
||||
Owner string `json:"owner"`
|
||||
}
|
||||
```
|
||||
|
||||
SFTP client is created from the existing `*ssh.Client` (same connection, separate channel). Stored alongside the SSH session.
|
||||
|
||||
Tests (unit — mock the sftp.Client interface):
|
||||
- TestFileEntryFromFileInfo
|
||||
- TestListSortsDirectoriesFirst
|
||||
- TestReadFileRejectsLargeFiles (>5MB)
|
||||
|
||||
- [ ] **Step 1:** Write tests
|
||||
- [ ] **Step 2:** Implement service
|
||||
- [ ] **Step 3:** Add `pkg/sftp` dependency
|
||||
- [ ] **Step 4:** Run tests, verify pass
|
||||
- [ ] **Step 5:** Commit: `feat: SFTP service — list, read, write, upload, download, mkdir, delete`
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Credential Service (Encrypted SSH Keys + Passwords)
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/credentials/service.go`
|
||||
- Create: `internal/credentials/service_test.go`
|
||||
|
||||
CRUD for credentials and SSH keys with vault encryption:
|
||||
|
||||
```go
|
||||
// CredentialService methods:
|
||||
// CreatePassword(name, username, password, domain string) (*Credential, error)
|
||||
// CreateSSHKey(name string, privateKey, passphrase []byte) (*SSHKey, error)
|
||||
// GetCredential(id int64) (*Credential, error)
|
||||
// ListCredentials() ([]Credential, error)
|
||||
// DecryptPassword(id int64) (string, error) → decrypt for connection use only
|
||||
// DecryptSSHKey(id int64) ([]byte, string, error) → returns (privateKey, passphrase, error)
|
||||
// DeleteCredential(id int64) error
|
||||
// ImportSSHKeyFile(name, filePath string) (*SSHKey, error) → read .pem file, detect type, store
|
||||
```
|
||||
|
||||
All sensitive data encrypted via VaultService before storage. Decryption only happens at connection time.
|
||||
|
||||
Tests:
|
||||
- TestCreatePasswordCredential
|
||||
- TestCreateSSHKeyCredential
|
||||
- TestDecryptPassword (round-trip through vault)
|
||||
- TestDecryptSSHKey (round-trip)
|
||||
- TestListCredentialsExcludesEncryptedValues
|
||||
- TestDetectKeyType (RSA, Ed25519, ECDSA)
|
||||
|
||||
- [ ] **Step 1:** Write tests
|
||||
- [ ] **Step 2:** Implement service
|
||||
- [ ] **Step 3:** Run tests, verify pass
|
||||
- [ ] **Step 4:** Commit: `feat: credential service — encrypted password and SSH key storage`
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Wire SSH/SFTP/Credentials into App
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/app/app.go`
|
||||
- Modify: `main.go`
|
||||
|
||||
Add SSHService, SFTPService, and CredentialService to WraithApp. Register as Wails services.
|
||||
|
||||
- [ ] **Step 1:** Update app.go to create and expose new services
|
||||
- [ ] **Step 2:** Update main.go to register them
|
||||
- [ ] **Step 3:** Verify compilation: `go vet ./...`
|
||||
- [ ] **Step 4:** Run all tests: `go test ./... -count=1`
|
||||
- [ ] **Step 5:** Commit: `feat: wire SSH, SFTP, and credential services into Wails app`
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Frontend — xterm.js Terminal
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/package.json` — add xterm.js + addons
|
||||
- Create: `frontend/src/components/terminal/TerminalView.vue`
|
||||
- Create: `frontend/src/composables/useTerminal.ts`
|
||||
- Create: `frontend/src/assets/css/terminal.css`
|
||||
- Modify: `frontend/src/components/session/SessionContainer.vue`
|
||||
- Modify: `frontend/src/stores/session.store.ts`
|
||||
|
||||
Install xterm.js dependencies:
|
||||
```
|
||||
@xterm/xterm
|
||||
@xterm/addon-fit
|
||||
@xterm/addon-webgl
|
||||
@xterm/addon-search
|
||||
@xterm/addon-web-links
|
||||
```
|
||||
|
||||
`useTerminal` composable:
|
||||
- Creates xterm.js Terminal instance with theme from connection settings
|
||||
- Attaches fit, WebGL, search, web-links addons
|
||||
- Binds `terminal.onData` → Wails `SSHService.Write(sessionId, data)`
|
||||
- Listens for Wails events `ssh:data:{sessionId}` → `terminal.write(data)`
|
||||
- Handles resize via fit addon → Wails `SSHService.Resize(sessionId, cols, rows)`
|
||||
- Cleanup on unmount
|
||||
|
||||
`TerminalView.vue`:
|
||||
- Receives `sessionId` prop
|
||||
- Mounts xterm.js into a div ref
|
||||
- Applies theme colors from the active theme
|
||||
- Handles focus management
|
||||
|
||||
`SessionContainer.vue` update:
|
||||
- Replace placeholder with real TerminalView for SSH sessions
|
||||
- Use `v-show` (not `v-if`) to keep terminals alive across tab switches
|
||||
|
||||
- [ ] **Step 1:** Install xterm.js deps: `cd frontend && npm install @xterm/xterm @xterm/addon-fit @xterm/addon-webgl @xterm/addon-search @xterm/addon-web-links`
|
||||
- [ ] **Step 2:** Create terminal.css (xterm.js container styling)
|
||||
- [ ] **Step 3:** Create useTerminal.ts composable
|
||||
- [ ] **Step 4:** Create TerminalView.vue component
|
||||
- [ ] **Step 5:** Update SessionContainer.vue to render TerminalView
|
||||
- [ ] **Step 6:** Update session.store.ts with real Wails binding calls
|
||||
- [ ] **Step 7:** Build frontend: `npm run build`
|
||||
- [ ] **Step 8:** Commit: `feat: xterm.js terminal with WebGL rendering and Wails binding bridge`
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Frontend — SFTP Sidebar
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/components/sftp/FileTree.vue`
|
||||
- Create: `frontend/src/components/sftp/TransferProgress.vue`
|
||||
- Create: `frontend/src/composables/useSftp.ts`
|
||||
- Modify: `frontend/src/layouts/MainLayout.vue` — SFTP sidebar rendering
|
||||
- Modify: `frontend/src/components/sidebar/SidebarToggle.vue` — enable SFTP tab
|
||||
|
||||
`useSftp` composable:
|
||||
- `listDirectory(sessionId, path)` → calls Wails SFTPService.List
|
||||
- `uploadFile(sessionId, remotePath, file)` → chunked upload with progress
|
||||
- `downloadFile(sessionId, remotePath)` → triggers browser download
|
||||
- `deleteFile(sessionId, path)` → with confirmation
|
||||
- `createDirectory(sessionId, path)`
|
||||
- `renameFile(sessionId, old, new)`
|
||||
- Tracks current path, file list, loading state, transfer progress
|
||||
|
||||
`FileTree.vue`:
|
||||
- Renders file/directory tree (lazy-loaded on expand)
|
||||
- Path bar at top showing current directory
|
||||
- Toolbar: upload, download, new file, new folder, refresh, delete
|
||||
- File entries show: icon (folder/file), name, size, modified date
|
||||
- Double-click file → open in editor (Task 9)
|
||||
- Drag-and-drop upload zone
|
||||
- "Follow terminal folder" toggle at bottom
|
||||
|
||||
`TransferProgress.vue`:
|
||||
- Shows active uploads/downloads with progress bars
|
||||
- File name, percentage, speed, ETA
|
||||
|
||||
- [ ] **Step 1:** Create useSftp.ts composable
|
||||
- [ ] **Step 2:** Create FileTree.vue component
|
||||
- [ ] **Step 3:** Create TransferProgress.vue component
|
||||
- [ ] **Step 4:** Update MainLayout.vue to render SFTP sidebar when toggled
|
||||
- [ ] **Step 5:** Enable SFTP toggle in SidebarToggle.vue
|
||||
- [ ] **Step 6:** Build frontend: `npm run build`
|
||||
- [ ] **Step 7:** Commit: `feat: SFTP sidebar — file tree, upload/download, CWD following`
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Frontend — Host Key Dialog + Connection Flow
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/components/common/HostKeyDialog.vue`
|
||||
- Modify: `frontend/src/components/sidebar/ConnectionTree.vue` — double-click to connect
|
||||
- Modify: `frontend/src/stores/session.store.ts` — real connection flow
|
||||
|
||||
Wire up the full connection flow:
|
||||
1. User double-clicks connection in sidebar
|
||||
2. Session store calls Wails `SSHService.Connect(connectionId)`
|
||||
3. If host key verification needed → show HostKeyDialog
|
||||
4. On success → create tab, mount TerminalView, open SFTP sidebar
|
||||
5. On error → show error toast
|
||||
|
||||
`HostKeyDialog.vue`:
|
||||
- Modal showing: hostname, key type, fingerprint
|
||||
- "New host" vs "CHANGED host key (WARNING)" modes
|
||||
- Accept / Reject buttons
|
||||
- "Always accept for this host" checkbox
|
||||
|
||||
- [ ] **Step 1:** Create HostKeyDialog.vue
|
||||
- [ ] **Step 2:** Update ConnectionTree.vue with double-click handler
|
||||
- [ ] **Step 3:** Update session.store.ts with connection flow
|
||||
- [ ] **Step 4:** Build frontend: `npm run build`
|
||||
- [ ] **Step 5:** Commit: `feat: connection flow — host key dialog, double-click to connect`
|
||||
|
||||
---
|
||||
|
||||
## Task 10: Frontend — CodeMirror 6 Editor (Placeholder)
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/package.json` — add CodeMirror deps
|
||||
- Create: `frontend/src/components/editor/EditorWindow.vue`
|
||||
|
||||
Install CodeMirror 6:
|
||||
```
|
||||
codemirror
|
||||
@codemirror/lang-javascript
|
||||
@codemirror/lang-json
|
||||
@codemirror/lang-html
|
||||
@codemirror/lang-css
|
||||
@codemirror/lang-python
|
||||
@codemirror/lang-markdown
|
||||
@codemirror/theme-one-dark
|
||||
```
|
||||
|
||||
`EditorWindow.vue`:
|
||||
- Renders CodeMirror 6 editor with dark theme
|
||||
- Receives file content, path, and sessionId as props
|
||||
- Syntax highlighting based on file extension
|
||||
- Save button → calls Wails SFTPService.WriteFile
|
||||
- Unsaved changes detection
|
||||
- For Phase 2: renders inline (not separate window — multi-window is Phase 4)
|
||||
|
||||
- [ ] **Step 1:** Install CodeMirror deps
|
||||
- [ ] **Step 2:** Create EditorWindow.vue
|
||||
- [ ] **Step 3:** Wire file click in FileTree.vue to open EditorWindow
|
||||
- [ ] **Step 4:** Build frontend: `npm run build`
|
||||
- [ ] **Step 5:** Commit: `feat: CodeMirror 6 editor — syntax highlighting, dark theme, SFTP save`
|
||||
|
||||
---
|
||||
|
||||
## Task 11: Workspace Snapshot Persistence
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/app/workspace.go`
|
||||
- Create: `internal/app/workspace_test.go`
|
||||
|
||||
Implements workspace snapshot saving/restoring per the spec:
|
||||
|
||||
```go
|
||||
// SaveWorkspace() error — serialize current tab layout to settings
|
||||
// LoadWorkspace() (*WorkspaceSnapshot, error) — read last saved layout
|
||||
// Auto-save every 30 seconds via goroutine
|
||||
// Save on clean shutdown
|
||||
```
|
||||
|
||||
WorkspaceSnapshot JSON:
|
||||
```json
|
||||
{
|
||||
"tabs": [
|
||||
{"connectionId": 1, "protocol": "ssh", "position": 0},
|
||||
{"connectionId": 5, "protocol": "rdp", "position": 1}
|
||||
],
|
||||
"sidebarWidth": 240,
|
||||
"sidebarMode": "connections",
|
||||
"activeTab": 0
|
||||
}
|
||||
```
|
||||
|
||||
Tests:
|
||||
- TestSaveAndLoadWorkspace
|
||||
- TestEmptyWorkspace
|
||||
|
||||
- [ ] **Step 1:** Write tests
|
||||
- [ ] **Step 2:** Implement workspace.go
|
||||
- [ ] **Step 3:** Run tests, verify pass
|
||||
- [ ] **Step 4:** Commit: `feat: workspace snapshot persistence — auto-save layout every 30s`
|
||||
|
||||
---
|
||||
|
||||
## Task 12: Integration Test + Final Verification
|
||||
|
||||
- [ ] **Step 1:** Run all Go tests: `go test ./... -count=1`
|
||||
- [ ] **Step 2:** Build frontend: `cd frontend && npm run build`
|
||||
- [ ] **Step 3:** Verify Go compiles with embedded frontend: `go vet ./...`
|
||||
- [ ] **Step 4:** Count tests and lines of code
|
||||
- [ ] **Step 5:** Commit any fixes: `chore: Phase 2 complete — SSH + SFTP with terminal and file operations`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 Completion Checklist
|
||||
|
||||
- [ ] SSH service: connect, PTY, shell I/O with goroutine pipes
|
||||
- [ ] Host key verification: store, verify, detect changes
|
||||
- [ ] OSC 7 CWD tracker: parse and strip directory change sequences
|
||||
- [ ] SFTP service: list, read, write, upload, download, mkdir, delete
|
||||
- [ ] Credential service: encrypted password + SSH key storage
|
||||
- [ ] All new services wired into Wails app
|
||||
- [ ] xterm.js terminal with WebGL rendering
|
||||
- [ ] SFTP file tree sidebar with upload/download
|
||||
- [ ] Host key verification dialog
|
||||
- [ ] Double-click connection to connect flow
|
||||
- [ ] CodeMirror 6 inline editor with SFTP save
|
||||
- [ ] Workspace snapshot persistence
|
||||
- [ ] All Go tests passing
|
||||
- [ ] Frontend builds clean
|
||||
780
docs/superpowers/plans/2026-03-24-local-pty-copilot.md
Normal file
780
docs/superpowers/plans/2026-03-24-local-pty-copilot.md
Normal file
@ -0,0 +1,780 @@
|
||||
# Local PTY Copilot Panel — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace the Gemini API stub with a local PTY terminal in the sidebar where users run CLI tools (claude, gemini, codex) directly.
|
||||
|
||||
**Architecture:** New `PtyService` module mirrors `SshService` patterns — DashMap session registry, `portable-pty` for cross-platform PTY spawn, `spawn_blocking` output loop emitting Tauri events. Frontend reuses existing `useTerminal` composable with a new `backend` parameter. Gemini stub deleted entirely.
|
||||
|
||||
**Tech Stack:** portable-pty (Rust PTY), xterm.js (existing), Tauri v2 events (existing)
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-03-24-local-pty-copilot-design.md`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add portable-pty dependency
|
||||
|
||||
**Files:**
|
||||
- Modify: `src-tauri/Cargo.toml`
|
||||
|
||||
- [ ] **Step 1: Add portable-pty to Cargo.toml**
|
||||
|
||||
Add under the existing dependencies:
|
||||
|
||||
```toml
|
||||
portable-pty = "0.8"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify it resolves**
|
||||
|
||||
Run: `cd src-tauri && cargo check`
|
||||
Expected: compiles with no errors
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src-tauri/Cargo.toml src-tauri/Cargo.lock
|
||||
git commit -m "deps: add portable-pty for local PTY support"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Create PtyService backend module
|
||||
|
||||
**Files:**
|
||||
- Create: `src-tauri/src/pty/mod.rs`
|
||||
|
||||
- [ ] **Step 1: Create the pty module with PtyService, PtySession, ShellInfo, list_shells**
|
||||
|
||||
```rust
|
||||
//! Local PTY service — spawns shells for the AI copilot panel.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use base64::Engine;
|
||||
use dashmap::DashMap;
|
||||
use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize};
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
pub struct ShellInfo {
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
pub struct PtySession {
|
||||
pub id: String,
|
||||
pub shell_path: String,
|
||||
writer: Mutex<Box<dyn Write + Send>>,
|
||||
master: Mutex<Box<dyn MasterPty + Send>>,
|
||||
child: Mutex<Box<dyn Child + Send + Sync>>,
|
||||
}
|
||||
|
||||
pub struct PtyService {
|
||||
sessions: DashMap<String, Arc<PtySession>>,
|
||||
}
|
||||
|
||||
impl PtyService {
|
||||
pub fn new() -> Self {
|
||||
Self { sessions: DashMap::new() }
|
||||
}
|
||||
|
||||
/// Detect available shells on the system.
|
||||
pub fn list_shells(&self) -> Vec<ShellInfo> {
|
||||
let mut shells = Vec::new();
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
// Check $SHELL first (user's default)
|
||||
if let Ok(user_shell) = std::env::var("SHELL") {
|
||||
if std::path::Path::new(&user_shell).exists() {
|
||||
let name = std::path::Path::new(&user_shell)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("shell")
|
||||
.to_string();
|
||||
shells.push(ShellInfo { name, path: user_shell });
|
||||
}
|
||||
}
|
||||
for (name, path) in [("bash", "/bin/bash"), ("zsh", "/bin/zsh"), ("sh", "/bin/sh")] {
|
||||
if std::path::Path::new(path).exists() && !shells.iter().any(|s| s.path == path) {
|
||||
shells.push(ShellInfo { name: name.to_string(), path: path.to_string() });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
shells.push(ShellInfo { name: "PowerShell".to_string(), path: "powershell.exe".to_string() });
|
||||
shells.push(ShellInfo { name: "CMD".to_string(), path: "cmd.exe".to_string() });
|
||||
for git_bash in [
|
||||
r"C:\Program Files\Git\bin\bash.exe",
|
||||
r"C:\Program Files (x86)\Git\bin\bash.exe",
|
||||
] {
|
||||
if std::path::Path::new(git_bash).exists() {
|
||||
shells.push(ShellInfo { name: "Git Bash".to_string(), path: git_bash.to_string() });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
shells
|
||||
}
|
||||
|
||||
/// Spawn a local shell and start reading its output.
|
||||
pub fn spawn(
|
||||
&self,
|
||||
shell_path: &str,
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
app_handle: AppHandle,
|
||||
) -> Result<String, String> {
|
||||
let session_id = uuid::Uuid::new_v4().to_string();
|
||||
let pty_system = native_pty_system();
|
||||
|
||||
let pair = pty_system
|
||||
.openpty(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 })
|
||||
.map_err(|e| format!("Failed to open PTY: {}", e))?;
|
||||
|
||||
let mut cmd = CommandBuilder::new(shell_path);
|
||||
// Inherit parent environment so PATH includes CLI tools
|
||||
// CommandBuilder inherits env by default — no action needed
|
||||
|
||||
let child = pair.slave
|
||||
.spawn_command(cmd)
|
||||
.map_err(|e| format!("Failed to spawn shell '{}': {}", shell_path, e))?;
|
||||
|
||||
let reader = pair.master
|
||||
.try_clone_reader()
|
||||
.map_err(|e| format!("Failed to clone PTY reader: {}", e))?;
|
||||
|
||||
let writer = pair.master
|
||||
.take_writer()
|
||||
.map_err(|e| format!("Failed to take PTY writer: {}", e))?;
|
||||
|
||||
let session = Arc::new(PtySession {
|
||||
id: session_id.clone(),
|
||||
shell_path: shell_path.to_string(),
|
||||
writer: Mutex::new(writer),
|
||||
master: Mutex::new(pair.master),
|
||||
child: Mutex::new(child),
|
||||
});
|
||||
|
||||
self.sessions.insert(session_id.clone(), session);
|
||||
|
||||
// Output reader loop — runs in a blocking thread because
|
||||
// portable-pty's reader is synchronous (std::io::Read).
|
||||
let sid = session_id.clone();
|
||||
let app = app_handle;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut reader = std::io::BufReader::new(reader);
|
||||
let mut buf = [0u8; 4096];
|
||||
loop {
|
||||
match reader.read(&mut buf) {
|
||||
Ok(0) => {
|
||||
let _ = app.emit(&format!("pty:close:{}", sid), ());
|
||||
break;
|
||||
}
|
||||
Ok(n) => {
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(&buf[..n]);
|
||||
let _ = app.emit(&format!("pty:data:{}", sid), encoded);
|
||||
}
|
||||
Err(_) => {
|
||||
let _ = app.emit(&format!("pty:close:{}", sid), ());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(session_id)
|
||||
}
|
||||
|
||||
/// Write data to a PTY session's stdin.
|
||||
pub fn write(&self, session_id: &str, data: &[u8]) -> Result<(), String> {
|
||||
let session = self.sessions.get(session_id)
|
||||
.ok_or_else(|| format!("PTY session {} not found", session_id))?;
|
||||
let mut writer = session.writer.lock()
|
||||
.map_err(|e| format!("Failed to lock PTY writer: {}", e))?;
|
||||
writer.write_all(data)
|
||||
.map_err(|e| format!("Failed to write to PTY {}: {}", session_id, e))
|
||||
}
|
||||
|
||||
/// Resize a PTY session.
|
||||
pub fn resize(&self, session_id: &str, cols: u16, rows: u16) -> Result<(), String> {
|
||||
let session = self.sessions.get(session_id)
|
||||
.ok_or_else(|| format!("PTY session {} not found", session_id))?;
|
||||
let master = session.master.lock()
|
||||
.map_err(|e| format!("Failed to lock PTY master: {}", e))?;
|
||||
master.resize(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 })
|
||||
.map_err(|e| format!("Failed to resize PTY {}: {}", session_id, e))
|
||||
}
|
||||
|
||||
/// Kill and remove a PTY session.
|
||||
pub fn disconnect(&self, session_id: &str) -> Result<(), String> {
|
||||
let (_, session) = self.sessions.remove(session_id)
|
||||
.ok_or_else(|| format!("PTY session {} not found", session_id))?;
|
||||
if let Ok(mut child) = session.child.lock() {
|
||||
let _ = child.kill();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify it compiles**
|
||||
|
||||
Add `pub mod pty;` to `src-tauri/src/lib.rs` temporarily (just the module declaration, full AppState wiring comes in Task 4).
|
||||
|
||||
Run: `cd src-tauri && cargo check`
|
||||
Expected: compiles (warnings about unused code are fine here)
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src-tauri/src/pty/mod.rs src-tauri/src/lib.rs
|
||||
git commit -m "feat: PtyService — local PTY spawn, write, resize, disconnect"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Create PTY Tauri commands
|
||||
|
||||
**Files:**
|
||||
- Create: `src-tauri/src/commands/pty_commands.rs`
|
||||
- Modify: `src-tauri/src/commands/mod.rs`
|
||||
|
||||
- [ ] **Step 1: Create pty_commands.rs**
|
||||
|
||||
```rust
|
||||
//! Tauri commands for local PTY session management.
|
||||
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
use crate::pty::ShellInfo;
|
||||
use crate::AppState;
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_available_shells(state: State<'_, AppState>) -> Vec<ShellInfo> {
|
||||
state.pty.list_shells()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn spawn_local_shell(
|
||||
shell_path: String,
|
||||
cols: u32,
|
||||
rows: u32,
|
||||
app_handle: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<String, String> {
|
||||
state.pty.spawn(&shell_path, cols as u16, rows as u16, app_handle)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn pty_write(
|
||||
session_id: String,
|
||||
data: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
state.pty.write(&session_id, data.as_bytes())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn pty_resize(
|
||||
session_id: String,
|
||||
cols: u32,
|
||||
rows: u32,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
state.pty.resize(&session_id, cols as u16, rows as u16)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn disconnect_pty(
|
||||
session_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
state.pty.disconnect(&session_id)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `pub mod pty_commands;` to `src-tauri/src/commands/mod.rs`**
|
||||
|
||||
Replace the `ai_commands` line:
|
||||
|
||||
```rust
|
||||
pub mod vault;
|
||||
pub mod settings;
|
||||
pub mod connections;
|
||||
pub mod credentials;
|
||||
pub mod ssh_commands;
|
||||
pub mod sftp_commands;
|
||||
pub mod rdp_commands;
|
||||
pub mod theme_commands;
|
||||
pub mod pty_commands;
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src-tauri/src/commands/pty_commands.rs src-tauri/src/commands/mod.rs
|
||||
git commit -m "feat: PTY Tauri commands — spawn, write, resize, disconnect, list shells"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Wire PtyService into AppState and delete Gemini stub
|
||||
|
||||
**Files:**
|
||||
- Modify: `src-tauri/src/lib.rs`
|
||||
- Delete: `src-tauri/src/ai/mod.rs`
|
||||
- Delete: `src-tauri/src/commands/ai_commands.rs`
|
||||
|
||||
- [ ] **Step 1: Update lib.rs**
|
||||
|
||||
Full replacement of `lib.rs`:
|
||||
|
||||
Changes:
|
||||
1. Replace `pub mod ai;` with `pub mod pty;`
|
||||
2. Replace `use` for ai with `use pty::PtyService;`
|
||||
3. Replace `gemini: Mutex<Option<ai::GeminiClient>>` with `pub pty: PtyService`
|
||||
4. Replace `gemini: Mutex::new(None)` with `pty: PtyService::new()`
|
||||
5. Replace AI command registrations with PTY command registrations in `generate_handler!`
|
||||
|
||||
The `generate_handler!` line 110 should change from:
|
||||
```
|
||||
commands::ai_commands::set_gemini_auth, commands::ai_commands::gemini_chat, commands::ai_commands::is_gemini_authenticated,
|
||||
```
|
||||
to:
|
||||
```
|
||||
commands::pty_commands::list_available_shells, commands::pty_commands::spawn_local_shell, commands::pty_commands::pty_write, commands::pty_commands::pty_resize, commands::pty_commands::disconnect_pty,
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Delete Gemini files**
|
||||
|
||||
```bash
|
||||
rm src-tauri/src/ai/mod.rs
|
||||
rmdir src-tauri/src/ai
|
||||
rm src-tauri/src/commands/ai_commands.rs
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify build**
|
||||
|
||||
Run: `cd src-tauri && cargo build`
|
||||
Expected: compiles with zero warnings
|
||||
|
||||
- [ ] **Step 4: Run tests**
|
||||
|
||||
Run: `cd src-tauri && cargo test`
|
||||
Expected: 82 tests pass (existing tests unaffected)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "refactor: replace Gemini stub with PtyService in AppState"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Parameterize useTerminal composable
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/composables/useTerminal.ts`
|
||||
|
||||
- [ ] **Step 1: Add backend parameter**
|
||||
|
||||
Change the function signature from:
|
||||
```typescript
|
||||
export function useTerminal(sessionId: string): UseTerminalReturn {
|
||||
```
|
||||
to:
|
||||
```typescript
|
||||
export function useTerminal(sessionId: string, backend: 'ssh' | 'pty' = 'ssh'): UseTerminalReturn {
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Derive command/event names from backend**
|
||||
|
||||
Add at the top of the function body (after the addons, before the Terminal constructor):
|
||||
```typescript
|
||||
const writeCmd = backend === 'ssh' ? 'ssh_write' : 'pty_write';
|
||||
const resizeCmd = backend === 'ssh' ? 'ssh_resize' : 'pty_resize';
|
||||
const dataEvent = backend === 'ssh' ? `ssh:data:${sessionId}` : `pty:data:${sessionId}`;
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Set convertEol based on backend**
|
||||
|
||||
In the Terminal constructor options, change:
|
||||
```typescript
|
||||
convertEol: true,
|
||||
```
|
||||
to:
|
||||
```typescript
|
||||
convertEol: backend === 'ssh',
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Replace hardcoded command names**
|
||||
|
||||
Replace all `invoke("ssh_write"` with `invoke(writeCmd` (3 occurrences: onData handler, right-click paste handler).
|
||||
|
||||
Replace `invoke("ssh_resize"` with `invoke(resizeCmd` (1 occurrence: onResize handler).
|
||||
|
||||
Replace `` `ssh:data:${sessionId}` `` with `dataEvent` (1 occurrence: listen call in mount).
|
||||
|
||||
Replace error log strings: `"SSH write error:"` → `"Write error:"`, `"SSH resize error:"` → `"Resize error:"`.
|
||||
|
||||
- [ ] **Step 5: Verify existing SSH path still works**
|
||||
|
||||
Run: `npx vue-tsc --noEmit` — should compile clean. Existing callers pass no second argument, so they default to `'ssh'`.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/composables/useTerminal.ts
|
||||
git commit -m "refactor: parameterize useTerminal for ssh/pty backends"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Create CopilotPanel.vue
|
||||
|
||||
**Files:**
|
||||
- Create: `src/components/ai/CopilotPanel.vue`
|
||||
|
||||
- [ ] **Step 1: Create the component**
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="flex flex-col h-full bg-[var(--wraith-bg-secondary)] border-l border-[var(--wraith-border)] w-80">
|
||||
<!-- Header -->
|
||||
<div class="p-3 border-b border-[var(--wraith-border)] flex items-center justify-between gap-2">
|
||||
<span class="text-xs font-bold tracking-widest text-[var(--wraith-accent-blue)]">AI COPILOT</span>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<select
|
||||
v-model="selectedShell"
|
||||
class="bg-[var(--wraith-bg-tertiary)] border border-[var(--wraith-border)] rounded px-1.5 py-0.5 text-[10px] text-[var(--wraith-text-secondary)] outline-none"
|
||||
:disabled="connected"
|
||||
>
|
||||
<option v-for="shell in shells" :key="shell.path" :value="shell.path">
|
||||
{{ shell.name }}
|
||||
</option>
|
||||
</select>
|
||||
<button
|
||||
v-if="!connected"
|
||||
class="px-2 py-0.5 text-[10px] font-bold rounded bg-[var(--wraith-accent-blue)] text-black cursor-pointer"
|
||||
:disabled="!selectedShell"
|
||||
@click="launch"
|
||||
>
|
||||
Launch
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="px-2 py-0.5 text-[10px] font-bold rounded bg-[var(--wraith-accent-red,#f85149)] text-white cursor-pointer"
|
||||
@click="kill"
|
||||
>
|
||||
Kill
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Terminal area -->
|
||||
<div v-if="connected" ref="containerRef" class="flex-1 min-h-0" />
|
||||
|
||||
<!-- Session ended prompt -->
|
||||
<div v-else-if="sessionEnded" class="flex-1 flex flex-col items-center justify-center gap-3 p-4">
|
||||
<p class="text-xs text-[var(--wraith-text-muted)]">Session ended</p>
|
||||
<button
|
||||
class="px-3 py-1.5 text-xs rounded bg-[var(--wraith-accent-blue)] text-black font-bold cursor-pointer"
|
||||
@click="launch"
|
||||
>
|
||||
Relaunch
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-else class="flex-1 flex flex-col items-center justify-center gap-2 p-4">
|
||||
<p class="text-xs text-[var(--wraith-text-muted)] text-center">
|
||||
Select a shell and click Launch to start a local terminal.
|
||||
</p>
|
||||
<p class="text-[10px] text-[var(--wraith-text-muted)] text-center">
|
||||
Run <code class="text-[var(--wraith-accent-blue)]">claude</code>,
|
||||
<code class="text-[var(--wraith-accent-blue)]">gemini</code>, or
|
||||
<code class="text-[var(--wraith-accent-blue)]">codex</code> here.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick, onMounted, onBeforeUnmount } from "vue";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||
import { useTerminal } from "@/composables/useTerminal";
|
||||
|
||||
interface ShellInfo { name: string; path: string; }
|
||||
|
||||
const shells = ref<ShellInfo[]>([]);
|
||||
const selectedShell = ref("");
|
||||
const connected = ref(false);
|
||||
const sessionEnded = ref(false);
|
||||
const containerRef = ref<HTMLElement | null>(null);
|
||||
|
||||
let sessionId = "";
|
||||
let terminalInstance: ReturnType<typeof useTerminal> | null = null;
|
||||
let closeUnlisten: UnlistenFn | null = null;
|
||||
|
||||
async function loadShells(): Promise<void> {
|
||||
try {
|
||||
shells.value = await invoke<ShellInfo[]>("list_available_shells");
|
||||
if (shells.value.length > 0 && !selectedShell.value) {
|
||||
selectedShell.value = shells.value[0].path;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to list shells:", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function launch(): Promise<void> {
|
||||
if (!selectedShell.value) return;
|
||||
sessionEnded.value = false;
|
||||
|
||||
// Use defaults until terminal is mounted and measured
|
||||
const cols = 80;
|
||||
const rows = 24;
|
||||
|
||||
try {
|
||||
sessionId = await invoke<string>("spawn_local_shell", {
|
||||
shellPath: selectedShell.value,
|
||||
cols,
|
||||
rows,
|
||||
});
|
||||
connected.value = true;
|
||||
|
||||
// Wait for DOM update so containerRef is available
|
||||
await nextTick();
|
||||
|
||||
if (containerRef.value) {
|
||||
terminalInstance = useTerminal(sessionId, "pty");
|
||||
terminalInstance.mount(containerRef.value);
|
||||
|
||||
// Fit after mount to get real dimensions, then resize the PTY
|
||||
setTimeout(() => {
|
||||
if (terminalInstance) {
|
||||
terminalInstance.fit();
|
||||
const term = terminalInstance.terminal;
|
||||
invoke("pty_resize", {
|
||||
sessionId,
|
||||
cols: term.cols,
|
||||
rows: term.rows,
|
||||
}).catch(() => {});
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
|
||||
// Listen for shell exit
|
||||
closeUnlisten = await listen(`pty:close:${sessionId}`, () => {
|
||||
cleanup();
|
||||
sessionEnded.value = true;
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to spawn shell:", err);
|
||||
connected.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function kill(): void {
|
||||
if (sessionId) {
|
||||
invoke("disconnect_pty", { sessionId }).catch(() => {});
|
||||
}
|
||||
cleanup();
|
||||
}
|
||||
|
||||
function cleanup(): void {
|
||||
if (terminalInstance) {
|
||||
terminalInstance.destroy();
|
||||
terminalInstance = null;
|
||||
}
|
||||
if (closeUnlisten) {
|
||||
closeUnlisten();
|
||||
closeUnlisten = null;
|
||||
}
|
||||
connected.value = false;
|
||||
sessionId = "";
|
||||
}
|
||||
|
||||
onMounted(loadShells);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (connected.value) kill();
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify TypeScript compiles**
|
||||
|
||||
Run: `npx vue-tsc --noEmit`
|
||||
Expected: no errors
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/components/ai/CopilotPanel.vue
|
||||
git commit -m "feat: CopilotPanel — local PTY terminal in AI sidebar"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Update MainLayout and delete GeminiPanel
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/layouts/MainLayout.vue`
|
||||
- Delete: `src/components/ai/GeminiPanel.vue`
|
||||
|
||||
- [ ] **Step 1: Update MainLayout imports and template**
|
||||
|
||||
In `MainLayout.vue`:
|
||||
|
||||
Replace the import (line 205):
|
||||
```typescript
|
||||
import GeminiPanel from "@/components/ai/GeminiPanel.vue";
|
||||
```
|
||||
with:
|
||||
```typescript
|
||||
import CopilotPanel from "@/components/ai/CopilotPanel.vue";
|
||||
```
|
||||
|
||||
Replace the template usage (line 168):
|
||||
```html
|
||||
<GeminiPanel v-if="geminiVisible" />
|
||||
```
|
||||
with:
|
||||
```html
|
||||
<CopilotPanel v-if="copilotVisible" />
|
||||
```
|
||||
|
||||
Rename the ref and all references (line 219, 71, 73, 293):
|
||||
- `geminiVisible` → `copilotVisible`
|
||||
- Update the toolbar button title: `"Gemini XO (Ctrl+Shift+G)"` → `"AI Copilot (Ctrl+Shift+G)"`
|
||||
|
||||
- [ ] **Step 2: Delete GeminiPanel.vue**
|
||||
|
||||
```bash
|
||||
rm src/components/ai/GeminiPanel.vue
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify frontend compiles**
|
||||
|
||||
Run: `npx vue-tsc --noEmit`
|
||||
Expected: no errors
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "feat: swap GeminiPanel for CopilotPanel in MainLayout"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Add PTY tests
|
||||
|
||||
**Files:**
|
||||
- Modify: `src-tauri/src/pty/mod.rs`
|
||||
|
||||
- [ ] **Step 1: Add test module to pty/mod.rs**
|
||||
|
||||
Append to the bottom of `src-tauri/src/pty/mod.rs`:
|
||||
|
||||
```rust
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn list_shells_returns_at_least_one() {
|
||||
let svc = PtyService::new();
|
||||
let shells = svc.list_shells();
|
||||
assert!(!shells.is_empty(), "should find at least one shell");
|
||||
for shell in &shells {
|
||||
assert!(!shell.name.is_empty());
|
||||
assert!(!shell.path.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_shells_no_duplicates() {
|
||||
let svc = PtyService::new();
|
||||
let shells = svc.list_shells();
|
||||
let paths: Vec<&str> = shells.iter().map(|s| s.path.as_str()).collect();
|
||||
let mut unique = paths.clone();
|
||||
unique.sort();
|
||||
unique.dedup();
|
||||
assert_eq!(paths.len(), unique.len(), "shell list should not contain duplicates");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disconnect_nonexistent_session_errors() {
|
||||
let svc = PtyService::new();
|
||||
assert!(svc.disconnect("nonexistent").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_nonexistent_session_errors() {
|
||||
let svc = PtyService::new();
|
||||
assert!(svc.write("nonexistent", b"hello").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resize_nonexistent_session_errors() {
|
||||
let svc = PtyService::new();
|
||||
assert!(svc.resize("nonexistent", 80, 24).is_err());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests**
|
||||
|
||||
Run: `cd src-tauri && cargo test`
|
||||
Expected: 87+ tests pass (82 existing + 5 new), zero warnings
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src-tauri/src/pty/mod.rs
|
||||
git commit -m "test: PtyService unit tests — shell detection, error paths"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Final build, verify, tag
|
||||
|
||||
**Files:** None (verification only)
|
||||
|
||||
- [ ] **Step 1: Full Rust build with zero warnings**
|
||||
|
||||
Run: `cd src-tauri && cargo build`
|
||||
Expected: zero warnings, zero errors
|
||||
|
||||
- [ ] **Step 2: Full test suite**
|
||||
|
||||
Run: `cd src-tauri && cargo test`
|
||||
Expected: 87+ tests, all passing
|
||||
|
||||
- [ ] **Step 3: Frontend type check**
|
||||
|
||||
Run: `npx vue-tsc --noEmit`
|
||||
Expected: no errors
|
||||
|
||||
- [ ] **Step 4: Push and tag**
|
||||
|
||||
```bash
|
||||
git push
|
||||
git tag v1.2.5
|
||||
git push origin v1.2.5
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Update CLAUDE.md test count**
|
||||
|
||||
Update the test count in CLAUDE.md to reflect the new total. Commit and push (do NOT re-tag).
|
||||
@ -1,614 +0,0 @@
|
||||
# Wraith — Lean Build Spec
|
||||
|
||||
> **Date:** 2026-03-12
|
||||
> **Purpose:** Self-hosted MobaXterm replacement — SSH + SFTP + RDP in a browser
|
||||
> **Stack:** Nuxt 3 (Vue 3 SPA) + NestJS 10 + PostgreSQL 16 + guacd
|
||||
> **Target:** Single-user personal tool with bolt-on multi-user path
|
||||
> **Reference:** `Remote-Spec.md` (full feature spec — this is the lean cut)
|
||||
|
||||
---
|
||||
|
||||
## 1. What This Is
|
||||
|
||||
A self-hosted web application that replaces MobaXterm. SSH terminal with SFTP sidebar (MobaXterm's killer feature), RDP via Guacamole, connection manager with hierarchical groups, and an encrypted vault for SSH keys and passwords. Runs in any browser, deployed as a Docker stack.
|
||||
|
||||
**What this is NOT:** An MSP product, a SaaS platform, a team collaboration tool. It's a personal remote access workstation that happens to be web-based. Multi-user is a future bolt-on, not a design constraint.
|
||||
|
||||
**Name:** Wraith — exists everywhere, all at once.
|
||||
|
||||
---
|
||||
|
||||
## 2. Five Modules
|
||||
|
||||
### 2.1 SSH Terminal
|
||||
|
||||
**Frontend:** xterm.js 5.x with addons:
|
||||
- `@xterm/addon-fit` — auto-resize to container
|
||||
- `@xterm/addon-search` — Ctrl+F scrollback search
|
||||
- `@xterm/addon-web-links` — clickable URLs
|
||||
- `@xterm/addon-webgl` — GPU-accelerated rendering
|
||||
|
||||
**Backend:** NestJS WebSocket gateway + `ssh2` (npm). Browser opens WebSocket to NestJS, NestJS opens SSH connection to target using credentials from the vault. Bidirectional data pipe: terminal input → ssh2 stdin, ssh2 stdout → terminal output.
|
||||
|
||||
**Features:**
|
||||
- Multi-tab sessions with host name labels and color-coding by group
|
||||
- Horizontal and vertical split panes within a single tab (multiple xterm.js instances in a flex grid)
|
||||
- Terminal theming: dark/light modes, custom color schemes, font selection, font size
|
||||
- Configurable scrollback buffer size (default 10,000 lines, configurable in settings)
|
||||
- Copy/paste: Ctrl+Shift+C/V, right-click context menu
|
||||
- Search in scrollback: Ctrl+F via xterm.js SearchAddon
|
||||
- Auto-reconnect on connection drop with configurable retry
|
||||
|
||||
**Authentication flow:**
|
||||
1. User clicks host in connection manager
|
||||
2. Backend looks up host → finds associated credential (key or password)
|
||||
3. If SSH key: decrypt private key from vault, optionally decrypt passphrase, pass to ssh2
|
||||
4. If password: decrypt from vault, pass to ssh2
|
||||
5. ssh2 performs host key verification (see Section 8: Host Key Verification)
|
||||
6. ssh2 connects, WebSocket bridge established
|
||||
|
||||
### 2.2 SFTP Sidebar
|
||||
|
||||
The MobaXterm feature. When an SSH session connects, a sidebar automatically opens showing the remote filesystem.
|
||||
|
||||
**Layout:** Resizable left sidebar panel (tree view) + main terminal panel. Sidebar can be collapsed/hidden per session.
|
||||
|
||||
**Backend:** Uses the same ssh2 connection as the terminal (ssh2's SFTP subsystem). No separate connection needed — SFTP rides the existing SSH channel. All SFTP commands include a `sessionId` to target the correct ssh2 connection when multiple tabs are open.
|
||||
|
||||
**File operations:**
|
||||
- Browse remote filesystem as a tree (lazy-loaded — fetch children on expand)
|
||||
- Upload: drag-and-drop from desktop onto sidebar, or click upload button. Chunked transfer with progress bar.
|
||||
- Download: click file → browser download, or right-click → Download
|
||||
- Rename, delete, chmod, mkdir via right-click context menu
|
||||
- File size, permissions, modified date shown in tree or detail view
|
||||
|
||||
**File editing:**
|
||||
- Click a text file → opens in embedded Monaco Editor (VS Code's editor component)
|
||||
- File size guard: files over 5MB are refused for inline editing (download instead)
|
||||
- Syntax highlighting based on file extension
|
||||
- Save button pushes content back to remote via SFTP
|
||||
- Unsaved changes warning on close
|
||||
|
||||
**Transfer status:** Bottom status bar showing active transfers with progress, speed, ETA. Queue-based — multiple uploads/downloads run sequentially with status indicators.
|
||||
|
||||
### 2.3 RDP (Remote Desktop)
|
||||
|
||||
**Architecture:** Browser → WebSocket → NestJS Guacamole tunnel → guacd (Docker) → RDP target
|
||||
|
||||
**Frontend:** `guacamole-common-js` — renders remote desktop on HTML5 Canvas. Keyboard, mouse, and touch input forwarded to remote.
|
||||
|
||||
**Backend:** NestJS WebSocket gateway that speaks Guacamole wire protocol to the `guacd` daemon over TCP. The gateway translates between the browser's WebSocket and guacd's TCP socket.
|
||||
|
||||
**guacd:** Apache Guacamole daemon running as `guacamole/guacd` Docker image. Handles the actual RDP protocol translation. Battle-tested, Apache-licensed.
|
||||
|
||||
**Features:**
|
||||
- Clipboard sync: bidirectional between browser and remote desktop
|
||||
- Auto-resolution: detect browser window/tab size, send to RDP server
|
||||
- Connection settings: color depth (16/24/32-bit), security mode (NLA/TLS/RDP), console session, admin mode
|
||||
- Audio: remote audio playback in browser (Guacamole native)
|
||||
- Full-screen mode: F11 or toolbar button
|
||||
|
||||
**Authentication:** RDP credentials (username + password + domain) stored encrypted in vault, associated with host. Decrypted at connect time and passed to guacd.
|
||||
|
||||
### 2.4 Connection Manager
|
||||
|
||||
The home screen. A searchable, organized view of all saved hosts.
|
||||
|
||||
**Host properties:**
|
||||
```
|
||||
name — display name (e.g., "RSM File Server")
|
||||
hostname — IP or FQDN
|
||||
port — default 22 (SSH) or 3389 (RDP)
|
||||
protocol — ssh | rdp
|
||||
group_id — FK to host_groups (nullable for ungrouped)
|
||||
credential_id — FK to credentials (nullable for quick-connect-style)
|
||||
tags — text[] array for categorization
|
||||
notes — free text (markdown rendered)
|
||||
color — hex color for visual grouping
|
||||
lastConnectedAt — timestamp of most recent connection
|
||||
```
|
||||
|
||||
**Host groups:** Hierarchical folders with `parent_id` self-reference. E.g., "RSM > Servers", "Home Lab > VMs". Collapsible tree in the sidebar.
|
||||
|
||||
**Quick connect:** Top bar input — type `user@hostname:port` and hit Enter to connect without saving. Protocol auto-detected (or toggle SSH/RDP).
|
||||
|
||||
**Search:** Full-text across host name, hostname, tags, notes, group name. Instant filter as you type.
|
||||
|
||||
**Recent connections:** Hosts sorted by `lastConnectedAt` shown as a quick-access section above the full host tree.
|
||||
|
||||
**UI pattern:** Left sidebar = group tree + host list. Main area = active sessions rendered as persistent tab components within the layout (NOT separate routes — terminal/RDP instances persist across tab switches). Double-click host or press Enter to connect. Drag hosts between groups.
|
||||
|
||||
### 2.5 Key Vault
|
||||
|
||||
Encrypted storage for SSH private keys and passwords.
|
||||
|
||||
**SSH keys:**
|
||||
```
|
||||
name — display name (e.g., "RSM Production Key")
|
||||
public_key — plaintext (safe to store)
|
||||
encrypted_private_key — AES-256-GCM encrypted blob
|
||||
passphrase_encrypted — AES-256-GCM encrypted (nullable — not all keys have passphrases)
|
||||
fingerprint — SHA-256 fingerprint for display
|
||||
key_type — rsa | ed25519 | ecdsa (detected on import)
|
||||
```
|
||||
|
||||
**Import flow:**
|
||||
1. Click "Import Key" in vault management
|
||||
2. Paste key content or upload `.pem`/`.pub`/id_rsa file
|
||||
3. If key has passphrase, prompt for it (stored encrypted)
|
||||
4. Key encrypted with AES-256-GCM using `ENCRYPTION_KEY` env var
|
||||
5. Public key extracted and stored separately (for display/export)
|
||||
|
||||
**Credentials (passwords and key references):**
|
||||
```
|
||||
name — display name (e.g., "RSM root cred")
|
||||
username — plaintext username (not sensitive)
|
||||
domain — for RDP (e.g., "CONTOSO")
|
||||
type — password | ssh_key (enum CredentialType)
|
||||
encrypted_value — AES-256-GCM encrypted password (for type=password)
|
||||
ssh_key_id — FK to ssh_keys (for type=ssh_key)
|
||||
```
|
||||
|
||||
Credentials are shared entities — hosts reference credentials via `credential_id` FK on the host. Multiple hosts can share the same credential. The relationship is Host → Credential (many-to-one), not Credential → Host.
|
||||
|
||||
**Encryption pattern:** Same as Vigilance HQ — `ENCRYPTION_KEY` env var (32+ byte hex), AES-256-GCM, random IV per encryption, `v1:` version prefix on ciphertext for future key rotation.
|
||||
|
||||
---
|
||||
|
||||
## 3. Technology Stack
|
||||
|
||||
### Frontend
|
||||
|
||||
| Component | Technology | Purpose |
|
||||
|---|---|---|
|
||||
| Framework | Nuxt 3 (Vue 3, SPA mode `ssr: false`) | App shell, routing, auto-imports |
|
||||
| Terminal | xterm.js 5.x + addons | SSH terminal emulator |
|
||||
| RDP client | guacamole-common-js | RDP canvas rendering |
|
||||
| Code editor | Monaco Editor | SFTP file editing |
|
||||
| UI library | PrimeVue 4 | DataTable, Dialog, Tree, Toolbar, etc. |
|
||||
| State | Pinia | Connection state, session management |
|
||||
| CSS | Tailwind CSS | Utility-first styling |
|
||||
| Icons | Lucide Vue | Consistent iconography |
|
||||
|
||||
> **Why SPA, not SSR:** xterm.js, Monaco, and guacamole-common-js are all browser-only. Every session page would need `<ClientOnly>` wrappers. No SEO benefit for a self-hosted tool behind auth. SPA mode avoids hydration mismatches entirely while keeping Nuxt's routing, auto-imports, and module ecosystem.
|
||||
|
||||
### Backend
|
||||
|
||||
| Component | Technology | Purpose |
|
||||
|---|---|---|
|
||||
| Framework | NestJS 10 | REST API + WebSocket gateways |
|
||||
| SSH proxy | ssh2 (npm) | SSH + SFTP connections |
|
||||
| RDP proxy | Custom Guacamole tunnel | NestJS ↔ guacd TCP bridge |
|
||||
| Database | PostgreSQL 16 | Hosts, credentials, keys, settings |
|
||||
| ORM | Prisma | Schema-as-code, type-safe queries |
|
||||
| Encryption | Node.js crypto (AES-256-GCM) | Vault encryption at rest |
|
||||
| Auth | JWT + bcrypt | Single-user local login |
|
||||
| WebSocket | @nestjs/websockets (ws) | Terminal and RDP data channels |
|
||||
|
||||
### Infrastructure (Docker Compose)
|
||||
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
ports: ["3000:3000"]
|
||||
environment:
|
||||
DATABASE_URL: postgresql://wraith:${DB_PASSWORD}@postgres:5432/wraith
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
|
||||
GUACD_HOST: guacd
|
||||
GUACD_PORT: "4822"
|
||||
depends_on: [postgres, guacd]
|
||||
|
||||
guacd:
|
||||
image: guacamole/guacd
|
||||
restart: always
|
||||
# Internal only — app connects via Docker DNS hostname "guacd" on port 4822
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
volumes: [pgdata:/var/lib/postgresql/data]
|
||||
environment:
|
||||
POSTGRES_DB: wraith
|
||||
POSTGRES_USER: wraith
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
```
|
||||
|
||||
> **No Redis:** JWT auth is stateless. Single NestJS process means no pub/sub fanout needed. If horizontal scaling becomes relevant later, Redis is a straightforward add. Not burning ops complexity on it now.
|
||||
|
||||
**Required `.env` vars:**
|
||||
```
|
||||
DB_PASSWORD=<strong-random-password>
|
||||
JWT_SECRET=<random-256-bit-hex>
|
||||
ENCRYPTION_KEY=<random-256-bit-hex>
|
||||
```
|
||||
|
||||
Production deployment: Nginx reverse proxy on the Docker host with SSL termination and WebSocket upgrade support (`proxy_set_header Upgrade $http_upgrade`).
|
||||
|
||||
---
|
||||
|
||||
## 4. Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Browser (Any device) │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ xterm.js │ │ SFTP Sidebar │ │ guac-client │ │
|
||||
│ │ (SSH term) │ │ (file tree) │ │ (RDP canvas) │ │
|
||||
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
|
||||
│ │ WebSocket │ WebSocket │ WebSocket │
|
||||
└─────────┼──────────────────┼─────────────────┼──────────────┘
|
||||
│ │ │
|
||||
┌─────────┼──────────────────┼─────────────────┼──────────────┐
|
||||
│ NestJS Backend (Docker: app) │
|
||||
│ ┌──────▼───────┐ ┌──────▼───────┐ ┌──────▼───────┐ │
|
||||
│ │ SSH Gateway │ │ SFTP Gateway │ │ Guac Tunnel │ │
|
||||
│ │ (ssh2) │ │ (ssh2 sftp) │ │ (TCP→guacd) │ │
|
||||
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
|
||||
│ │ SSH │ SFTP │ Guac Protocol │
|
||||
│ ┌──────▼────────────────────────┐ ┌──────▼───────┐ │
|
||||
│ │ Vault Service │ │ guacd │ │
|
||||
│ │ (decrypt keys/passwords) │ │ (Docker) │ │
|
||||
│ └──────┬────────────────────────┘ └──────┬───────┘ │
|
||||
│ │ Prisma │ RDP │
|
||||
│ ┌──────▼───────┐ │ │
|
||||
│ │ PostgreSQL │ │ │
|
||||
│ │ (Docker) │ │ │
|
||||
│ └──────────────┘ │ │
|
||||
└──────────────────────────────────────────────┼──────────────┘
|
||||
│
|
||||
┌─────────────────┐ ┌──────▼───────┐
|
||||
│ SSH Targets │ │ RDP Targets │
|
||||
│ (Linux/Unix) │ │ (Windows) │
|
||||
└─────────────────┘ └──────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Database Schema (Prisma)
|
||||
|
||||
```prisma
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
email String @unique
|
||||
passwordHash String @map("password_hash")
|
||||
displayName String? @map("display_name")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
model HostGroup {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
parentId Int? @map("parent_id")
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
parent HostGroup? @relation("GroupTree", fields: [parentId], references: [id], onDelete: SetNull)
|
||||
children HostGroup[] @relation("GroupTree")
|
||||
hosts Host[]
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@map("host_groups")
|
||||
}
|
||||
|
||||
model Host {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
hostname String
|
||||
port Int @default(22)
|
||||
protocol Protocol @default(ssh)
|
||||
groupId Int? @map("group_id")
|
||||
credentialId Int? @map("credential_id")
|
||||
tags String[] @default([])
|
||||
notes String?
|
||||
color String? @db.VarChar(7)
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
hostFingerprint String? @map("host_fingerprint")
|
||||
lastConnectedAt DateTime? @map("last_connected_at")
|
||||
group HostGroup? @relation(fields: [groupId], references: [id], onDelete: SetNull)
|
||||
credential Credential? @relation(fields: [credentialId], references: [id], onDelete: SetNull)
|
||||
connectionLogs ConnectionLog[]
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@map("hosts")
|
||||
}
|
||||
|
||||
model Credential {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
username String?
|
||||
domain String?
|
||||
type CredentialType
|
||||
encryptedValue String? @map("encrypted_value")
|
||||
sshKeyId Int? @map("ssh_key_id")
|
||||
sshKey SshKey? @relation(fields: [sshKeyId], references: [id], onDelete: SetNull)
|
||||
hosts Host[]
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@map("credentials")
|
||||
}
|
||||
|
||||
model SshKey {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
keyType String @map("key_type") @db.VarChar(20)
|
||||
fingerprint String?
|
||||
publicKey String? @map("public_key")
|
||||
encryptedPrivateKey String @map("encrypted_private_key")
|
||||
passphraseEncrypted String? @map("passphrase_encrypted")
|
||||
credentials Credential[]
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@map("ssh_keys")
|
||||
}
|
||||
|
||||
model ConnectionLog {
|
||||
id Int @id @default(autoincrement())
|
||||
hostId Int @map("host_id")
|
||||
protocol Protocol
|
||||
connectedAt DateTime @default(now()) @map("connected_at")
|
||||
disconnectedAt DateTime? @map("disconnected_at")
|
||||
host Host @relation(fields: [hostId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("connection_logs")
|
||||
}
|
||||
|
||||
model Setting {
|
||||
key String @id
|
||||
value String
|
||||
|
||||
@@map("settings")
|
||||
}
|
||||
|
||||
enum Protocol {
|
||||
ssh
|
||||
rdp
|
||||
}
|
||||
|
||||
enum CredentialType {
|
||||
password
|
||||
ssh_key
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Frontend Structure
|
||||
|
||||
```
|
||||
frontend/
|
||||
nuxt.config.ts # ssr: false (SPA mode)
|
||||
layouts/
|
||||
default.vue # Main layout: sidebar + persistent tab container
|
||||
auth.vue # Login page layout
|
||||
pages/
|
||||
index.vue # Connection manager (home screen) + active session tabs
|
||||
login.vue # Single-user login
|
||||
vault/
|
||||
index.vue # Key vault management
|
||||
keys.vue # SSH key list + import
|
||||
credentials.vue # Password credentials
|
||||
settings.vue # App settings (theme, terminal defaults, scrollback)
|
||||
components/
|
||||
connections/
|
||||
HostTree.vue # Sidebar host group tree
|
||||
HostCard.vue # Host entry in list
|
||||
HostEditDialog.vue # Add/edit host modal
|
||||
GroupEditDialog.vue # Add/edit group modal
|
||||
QuickConnect.vue # Top bar quick connect input
|
||||
session/
|
||||
SessionContainer.vue # Persistent container — holds all active sessions, manages tab switching
|
||||
SessionTab.vue # Single session (SSH terminal + SFTP sidebar, or RDP canvas)
|
||||
terminal/
|
||||
TerminalInstance.vue # Single xterm.js instance
|
||||
TerminalTabs.vue # Tab bar for multiple sessions
|
||||
SplitPane.vue # Split pane container
|
||||
sftp/
|
||||
SftpSidebar.vue # SFTP file tree sidebar
|
||||
FileTree.vue # Remote filesystem tree
|
||||
FileEditor.vue # Monaco editor for text files
|
||||
TransferStatus.vue # Upload/download progress
|
||||
rdp/
|
||||
RdpCanvas.vue # Guacamole client wrapper
|
||||
RdpToolbar.vue # Clipboard, fullscreen, settings
|
||||
vault/
|
||||
KeyImportDialog.vue # SSH key import modal
|
||||
CredentialForm.vue # Password credential form
|
||||
composables/
|
||||
useTerminal.ts # xterm.js lifecycle + WebSocket
|
||||
useSftp.ts # SFTP operations via WebSocket
|
||||
useRdp.ts # Guacamole client lifecycle
|
||||
useVault.ts # Key/credential CRUD
|
||||
useConnections.ts # Host CRUD + search
|
||||
stores/
|
||||
auth.store.ts # Login state, JWT (stored in memory/localStorage, sent via Authorization header)
|
||||
session.store.ts # Active sessions, tabs — sessions persist across tab switches
|
||||
connection.store.ts # Hosts, groups, search
|
||||
```
|
||||
|
||||
> **Session architecture:** Active sessions are NOT page routes. They render as persistent tab components inside `SessionContainer.vue` within the main `index.vue` layout. Switching tabs toggles `v-show` visibility (not `v-if` destruction), so xterm.js and guacamole-common-js instances stay alive. The vault and settings pages are separate routes — navigating away from the main page does NOT destroy active sessions (the SessionContainer lives in the `default.vue` layout).
|
||||
|
||||
---
|
||||
|
||||
## 7. Backend Structure
|
||||
|
||||
```
|
||||
backend/src/
|
||||
main.ts # Bootstrap, global prefix, validation pipe
|
||||
app.module.ts # Root module
|
||||
prisma/
|
||||
prisma.service.ts # Prisma client lifecycle
|
||||
prisma.module.ts # Global Prisma module
|
||||
auth/
|
||||
auth.module.ts
|
||||
auth.service.ts # Login, JWT issue/verify
|
||||
auth.controller.ts # POST /login, GET /profile
|
||||
jwt.strategy.ts # Passport JWT strategy
|
||||
jwt-auth.guard.ts # Route guard (REST)
|
||||
ws-auth.guard.ts # WebSocket auth guard (validates JWT from handshake)
|
||||
connections/
|
||||
connections.module.ts
|
||||
hosts.service.ts # Host CRUD + lastConnectedAt updates
|
||||
hosts.controller.ts # REST: /hosts
|
||||
groups.service.ts # Group CRUD (hierarchical)
|
||||
groups.controller.ts # REST: /groups
|
||||
vault/
|
||||
vault.module.ts
|
||||
encryption.service.ts # AES-256-GCM encrypt/decrypt
|
||||
credentials.service.ts # Credential CRUD + decrypt-on-demand
|
||||
credentials.controller.ts # REST: /credentials
|
||||
ssh-keys.service.ts # SSH key import/CRUD
|
||||
ssh-keys.controller.ts # REST: /ssh-keys
|
||||
terminal/
|
||||
terminal.module.ts
|
||||
terminal.gateway.ts # WebSocket gateway: SSH proxy via ssh2
|
||||
sftp.gateway.ts # WebSocket gateway: SFTP operations
|
||||
ssh-connection.service.ts # ssh2 connection management + pooling
|
||||
rdp/
|
||||
rdp.module.ts
|
||||
rdp.gateway.ts # WebSocket gateway: Guacamole tunnel
|
||||
guacamole.service.ts # TCP connection to guacd, protocol translation
|
||||
settings/
|
||||
settings.module.ts
|
||||
settings.service.ts # Key/value settings CRUD
|
||||
settings.controller.ts # REST: /settings
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Key Implementation Details
|
||||
|
||||
### WebSocket Authentication
|
||||
|
||||
All WebSocket gateways validate JWT before processing any commands. The token is sent in the WebSocket handshake:
|
||||
|
||||
```typescript
|
||||
// Client: connect with JWT
|
||||
const ws = new WebSocket(`wss://host/terminal?token=${jwt}`)
|
||||
|
||||
// Server: ws-auth.guard.ts validates in handleConnection
|
||||
// Rejects connection if token is invalid/expired
|
||||
```
|
||||
|
||||
JWT is stored in Pinia state (memory) and localStorage for persistence. Sent via `Authorization: Bearer` header for REST, query parameter for WebSocket handshake. No cookies used for auth — CSRF protection not required.
|
||||
|
||||
### WebSocket Protocol (SSH)
|
||||
|
||||
```
|
||||
Client → Server:
|
||||
{ type: 'connect', hostId: 123 } # Initiate SSH connection
|
||||
{ type: 'data', data: '...' } # Terminal input (keystrokes)
|
||||
{ type: 'resize', cols: 120, rows: 40 } # Terminal resize
|
||||
|
||||
Server → Client:
|
||||
{ type: 'connected', sessionId: 'uuid' } # SSH connection established
|
||||
{ type: 'data', data: '...' } # Terminal output
|
||||
{ type: 'host-key-verify', fingerprint: 'SHA256:...', isNew: true } # First connection — needs approval
|
||||
{ type: 'error', message: '...' } # Connection error
|
||||
{ type: 'disconnected', reason: '...' } # Connection closed
|
||||
|
||||
Client → Server (host key response):
|
||||
{ type: 'host-key-accept' } # User approved — save fingerprint to host record
|
||||
{ type: 'host-key-reject' } # User rejected — abort connection
|
||||
```
|
||||
|
||||
### WebSocket Protocol (SFTP)
|
||||
|
||||
All SFTP commands include `sessionId` to target the correct ssh2 connection:
|
||||
|
||||
```
|
||||
Client → Server:
|
||||
{ type: 'list', sessionId: 'uuid', path: '/home/user' } # List directory
|
||||
{ type: 'read', sessionId: 'uuid', path: '/etc/nginx/nginx.conf' } # Read file (max 5MB)
|
||||
{ type: 'write', sessionId: 'uuid', path: '/etc/nginx/nginx.conf', data } # Write file
|
||||
{ type: 'upload', sessionId: 'uuid', path: '/tmp/file.tar.gz', chunk } # Upload chunk
|
||||
{ type: 'download', sessionId: 'uuid', path: '/var/log/syslog' } # Start download
|
||||
{ type: 'mkdir', sessionId: 'uuid', path: '/home/user/newdir' } # Create directory
|
||||
{ type: 'rename', sessionId: 'uuid', oldPath, newPath } # Rename/move
|
||||
{ type: 'delete', sessionId: 'uuid', path: '/tmp/junk.log' } # Delete file
|
||||
{ type: 'chmod', sessionId: 'uuid', path, mode: '755' } # Change permissions
|
||||
{ type: 'stat', sessionId: 'uuid', path: '/home/user' } # Get file info
|
||||
|
||||
Server → Client:
|
||||
{ type: 'list', path, entries: [...] } # Directory listing
|
||||
{ type: 'fileContent', path, content, encoding } # File content
|
||||
{ type: 'progress', transferId, bytes, total } # Transfer progress
|
||||
{ type: 'error', message } # Operation error
|
||||
```
|
||||
|
||||
### Host Key Verification
|
||||
|
||||
SSH host key verification follows standard `known_hosts` behavior:
|
||||
|
||||
1. **First connection:** ssh2 receives server's public key fingerprint. Gateway sends `host-key-verify` message to browser with `isNew: true`. User sees a dialog showing the fingerprint and chooses to accept or reject.
|
||||
2. **Accept:** Fingerprint saved to `Host.hostFingerprint` in database. Connection proceeds.
|
||||
3. **Subsequent connections:** ssh2 receives fingerprint, compared against stored `Host.hostFingerprint`. If match, connect silently. If mismatch, gateway sends `host-key-verify` with `isNew: false` and `previousFingerprint` — user warned of possible MITM.
|
||||
4. **Reject:** Connection aborted, no fingerprint stored.
|
||||
|
||||
### Guacamole Tunnel (RDP)
|
||||
|
||||
NestJS acts as a tunnel between the browser's WebSocket and guacd's TCP socket:
|
||||
|
||||
1. Browser sends `{ type: 'connect', hostId: 456 }`
|
||||
2. NestJS looks up host → decrypts RDP credentials
|
||||
3. NestJS opens TCP socket to guacd at `${GUACD_HOST}:${GUACD_PORT}` (default: `guacd:4822`)
|
||||
4. NestJS sends Guacamole handshake: `select`, `size`, `audio`, `video`, `image` instructions
|
||||
5. NestJS sends `connect` instruction with RDP params (hostname, port, username, password, security, color-depth)
|
||||
6. Bidirectional pipe: browser WebSocket ↔ NestJS ↔ guacd TCP
|
||||
7. guacd handles actual RDP protocol to target Windows machine
|
||||
|
||||
The `guacamole-common-js` client library handles rendering the Guacamole instruction stream to Canvas in the browser.
|
||||
|
||||
### Encryption Service
|
||||
|
||||
Identical pattern to Vigilance HQ:
|
||||
|
||||
```typescript
|
||||
encrypt(plaintext: string): string
|
||||
→ random 16-byte IV
|
||||
→ AES-256-GCM cipher with ENCRYPTION_KEY
|
||||
→ return `v1:${iv.hex}:${authTag.hex}:${ciphertext.hex}`
|
||||
|
||||
decrypt(encrypted: string): string
|
||||
→ parse version prefix, IV, authTag, ciphertext
|
||||
→ AES-256-GCM decipher
|
||||
→ return plaintext
|
||||
```
|
||||
|
||||
`ENCRYPTION_KEY` is a 32-byte hex string from environment. `v1:` prefix allows future key rotation without re-encrypting all stored values.
|
||||
|
||||
---
|
||||
|
||||
## 9. Multi-User Bolt-On Path
|
||||
|
||||
When the time comes to add JT or Victor:
|
||||
|
||||
1. Add rows to `users` table
|
||||
2. Add `userId` FK to `hosts`, `host_groups`, `credentials`, and `ssh_keys` tables (nullable — null = shared with all users)
|
||||
3. Add `shared_with` field or a `host_permissions` join table
|
||||
4. Add basic role: `admin` | `user` on `users` table
|
||||
5. Filter host list by ownership/sharing in queries
|
||||
6. Optional: Entra ID SSO (same pattern as HQ and RSM)
|
||||
|
||||
**Zero architectural changes.** The connection manager, vault, terminal, SFTP, and RDP modules don't change. You just add a filter layer on who can see what.
|
||||
|
||||
---
|
||||
|
||||
## 10. Build Phases
|
||||
|
||||
| Phase | Deliverables |
|
||||
|---|---|
|
||||
| **1: Foundation** | Docker Compose, NestJS scaffold, Prisma schema, encryption service, Nuxt 3 SPA shell, auth (single-user login), connection manager CRUD, host groups |
|
||||
| **2: SSH + SFTP** | xterm.js terminal, ssh2 WebSocket proxy, host key verification, multi-tab, split panes, SFTP sidebar with file tree, upload/download, Monaco editor |
|
||||
| **3: RDP** | guacd integration, Guacamole tunnel, RDP canvas rendering, clipboard sync, connection settings |
|
||||
| **4: Polish** | SSH key import UI, vault management page, theming, quick connect, search, settings page, connection history/recent hosts |
|
||||
|
||||
> **Note on encryption timing:** The encryption service and credential CRUD (encrypted) are in Phase 1, not Phase 4. SSH connections in Phase 2 need to decrypt credentials — plaintext storage is never acceptable, even temporarily. Phase 4's vault work is the management UI (import dialogs, key list view), not the encryption layer itself.
|
||||
@ -1,982 +0,0 @@
|
||||
# Wraith Desktop — Design Spec
|
||||
|
||||
> **Date:** 2026-03-17
|
||||
> **Purpose:** Native Windows desktop replacement for MobaXTerm — SSH + SFTP + RDP in a single binary
|
||||
> **Stack:** Go + Wails v3 (Vue 3 frontend, WebView2) + SQLite + FreeRDP3 (purego)
|
||||
> **Target:** Personal tool for daily MSP/sysadmin work — Windows only
|
||||
> **Name:** Wraith — exists everywhere, all at once.
|
||||
|
||||
---
|
||||
|
||||
## 1. What This Is
|
||||
|
||||
A Windows desktop application that replaces MobaXTerm. Multi-tabbed SSH terminal with SFTP sidebar (MobaXTerm's killer feature), RDP via FreeRDP3 dynamic linking, connection manager with hierarchical groups, and an encrypted vault for SSH keys and passwords. Ships as `wraith.exe` + `freerdp3.dll`. No Docker, no database server, no sidecar processes.
|
||||
|
||||
**What this is NOT:** A web app, a SaaS platform, a team tool. It's a personal remote access workstation built as a native desktop binary.
|
||||
|
||||
**Prior art:** This is a ground-up rebuild of Wraith, which was previously a self-hosted web application (Nuxt 3 + NestJS + guacd + PostgreSQL). The web version proved the feature set; this version delivers it as a proper desktop tool.
|
||||
|
||||
---
|
||||
|
||||
## 2. Technology Stack
|
||||
|
||||
### Backend (Go)
|
||||
|
||||
| Component | Technology | Purpose |
|
||||
|---|---|---|
|
||||
| Framework | Wails v3 (alpha) | Desktop app shell, multi-window, Go↔JS bindings |
|
||||
| SSH | `golang.org/x/crypto/ssh` | SSH client connections, PTY, auth |
|
||||
| SFTP | `github.com/pkg/sftp` | Remote filesystem operations over SSH |
|
||||
| RDP | FreeRDP3 via `purego` / `syscall.NewLazyDLL` | RDP protocol, bitmap rendering |
|
||||
| Database | SQLite via `modernc.org/sqlite` (pure Go) | Connections, credentials, settings |
|
||||
| Encryption | `crypto/aes` + `crypto/cipher` (GCM) | Vault encryption at rest |
|
||||
| Key derivation | `golang.org/x/crypto/argon2` | Master password → encryption key |
|
||||
|
||||
### Frontend (Vue 3 in WebView2)
|
||||
|
||||
| Component | Technology | Purpose |
|
||||
|---|---|---|
|
||||
| Framework | Vue 3 (Composition API) | UI framework |
|
||||
| Terminal | xterm.js 5.x + WebGL addon | SSH terminal emulator |
|
||||
| File editor | CodeMirror 6 | Remote file editing (separate window) |
|
||||
| CSS | Tailwind CSS | Utility-first styling |
|
||||
| Components | Naive UI | Tree, tabs, modals, dialogs, inputs |
|
||||
| State | Pinia | Reactive stores for sessions, connections, app state |
|
||||
| Build | Vite | Frontend build tooling |
|
||||
|
||||
### Distribution
|
||||
|
||||
| Artifact | Notes |
|
||||
|---|---|
|
||||
| `wraith.exe` | Single Go binary, ~8-10MB |
|
||||
| `freerdp3.dll` | FreeRDP3 dynamic library, shipped alongside |
|
||||
| Data | `%APPDATA%\Wraith\wraith.db` (SQLite) |
|
||||
| Installer | NSIS via Wails build |
|
||||
|
||||
---
|
||||
|
||||
## 3. Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ Wails v3 Application (wraith.exe) │
|
||||
│ │
|
||||
│ ┌─ Go Backend ──────────────────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ ┌──────────────┐ ┌──────────────┐ ┌───────────────────┐ │ │
|
||||
│ │ │ SSH Service │ │ SFTP Service │ │ RDP Service │ │ │
|
||||
│ │ │ x/crypto/ssh │ │ pkg/sftp │ │ purego→freerdp3 │ │ │
|
||||
│ │ └──────┬───────┘ └──────┬───────┘ └────────┬──────────┘ │ │
|
||||
│ │ │ │ │ │ │
|
||||
│ │ ┌──────▼─────────────────▼────────────────────▼──────────┐ │ │
|
||||
│ │ │ Session Manager │ │ │
|
||||
│ │ │ • Tracks all active SSH/RDP sessions │ │ │
|
||||
│ │ │ • Routes I/O between frontend and protocol backends │ │ │
|
||||
│ │ │ • Supports tab detach/reattach (session ≠ window) │ │ │
|
||||
│ │ └────────────────────────┬───────────────────────────────┘ │ │
|
||||
│ │ │ │ │
|
||||
│ │ ┌────────────────────────▼───────────────────────────────┐ │ │
|
||||
│ │ │ Vault Service │ │ │
|
||||
│ │ │ • Master password → Argon2id → AES-256-GCM key │ │ │
|
||||
│ │ │ • SQLite storage (%APPDATA%\Wraith\wraith.db) │ │ │
|
||||
│ │ │ • Encrypts: SSH keys, passwords, RDP credentials │ │ │
|
||||
│ │ └────────────────────────────────────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │ │
|
||||
│ │ │ Connection │ │ Import │ │ Host Key │ │ │
|
||||
│ │ │ Manager │ │ .mobaconf │ │ Store │ │ │
|
||||
│ │ └──────────────┘ └──────────────┘ └──────────────────┘ │ │
|
||||
│ └───────────────────────────────────────────────────────────────┘ │
|
||||
│ ▲ │
|
||||
│ Wails v3 Bindings (type-safe Go↔JS) │
|
||||
│ ▼ │
|
||||
│ ┌─ Vue 3 Frontend (WebView2) ───────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ ┌──────────┐ ┌───────────┐ ┌──────────┐ ┌───────────────┐ │ │
|
||||
│ │ │ xterm.js │ │ SFTP Tree │ │ RDP │ │ CodeMirror 6 │ │ │
|
||||
│ │ │ +WebGL │ │ Sidebar │ │ Canvas │ │ (sep window) │ │ │
|
||||
│ │ └──────────┘ └───────────┘ └──────────┘ └───────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ ┌───────────────────────────────────────────────────────┐ │ │
|
||||
│ │ │ Tab Bar (detachable) + Connection Sidebar │ │ │
|
||||
│ │ │ Command Palette (Ctrl+K) | Dark theme │ │ │
|
||||
│ │ │ Tailwind CSS + Naive UI │ │ │
|
||||
│ │ └───────────────────────────────────────────────────────┘ │ │
|
||||
│ └───────────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
│ │ │
|
||||
SSH (port 22) SFTP (over SSH) RDP (port 3389)
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
Linux/macOS hosts Remote filesystems Windows hosts
|
||||
```
|
||||
|
||||
### Key Architectural Decisions
|
||||
|
||||
**Sessions ≠ Windows.** SSH and RDP sessions live as objects in the Go Session Manager. The frontend is a view. Detaching a tab spawns a new Wails window pointing at the same backend session. Re-attaching destroys the window and re-renders the session in the original tab. The session itself never drops.
|
||||
|
||||
**Wails v3 multi-window risk mitigation:** This is the project's biggest technical risk. The detach/reattach model depends on Wails v3's alpha `application.NewWebviewWindow()` API. Three fallback plans, validated in priority order during Phase 1:
|
||||
|
||||
- **Plan A (target):** Wails v3 `NewWebviewWindow()` — true native multi-window. Spike this in Phase 1 with a minimal two-window prototype before committing.
|
||||
- **Plan B:** Single Wails window with internal "floating panel" detach — session renders in a draggable, resizable overlay within the main window. Not true OS windows, but close enough. No external dependency.
|
||||
- **Plan C:** Wails v3 server mode — detached sessions open in the default browser at `localhost:{port}/session/{id}`. Functional but breaks the native feel.
|
||||
|
||||
If Plan A fails, we fall to Plan B (which is entirely within our control). Plan C is the emergency fallback. **This must be validated in Phase 1, not discovered in Phase 4.**
|
||||
|
||||
**Single binary + DLL.** No Docker, no sidecar processes. SQLite is embedded (pure Go driver). FreeRDP3 is the only external dependency, loaded dynamically via `purego`.
|
||||
|
||||
**SFTP rides SSH.** SFTP opens a separate SSH channel on the same `x/crypto/ssh` connection as the terminal. No separate TCP connection is needed. `pkg/sftp.NewClient()` takes an `*ssh.Client` (not the shell `*ssh.Session`) and opens its own subsystem channel internally. The terminal shell session and SFTP operate as independent channels multiplexed over the same connection.
|
||||
|
||||
**RDP via pixel buffer.** FreeRDP3 is loaded via `purego` (dynamic linking, no CGO). FreeRDP writes decoded bitmap frames into a shared Go pixel buffer. The Go backend serves frame data to the frontend via a local HTTP endpoint (`localhost:{random_port}/frame`) that returns raw RGBA data. The frontend renders frames on a `<canvas>` element using `requestAnimationFrame`. Performance target: 1080p @ 30fps using Bitmap Update callbacks. The local HTTP approach is the default; if benchmarking reveals issues, Wails binding with base64-encoded frames is the fallback.
|
||||
|
||||
---
|
||||
|
||||
## 4. Data Model (SQLite)
|
||||
|
||||
```sql
|
||||
-- Connection groups (hierarchical folders)
|
||||
CREATE TABLE groups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
parent_id INTEGER REFERENCES groups(id) ON DELETE SET NULL,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
icon TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Saved connections
|
||||
CREATE TABLE connections (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
hostname TEXT NOT NULL,
|
||||
port INTEGER NOT NULL DEFAULT 22,
|
||||
protocol TEXT NOT NULL CHECK(protocol IN ('ssh','rdp')),
|
||||
group_id INTEGER REFERENCES groups(id) ON DELETE SET NULL,
|
||||
credential_id INTEGER REFERENCES credentials(id) ON DELETE SET NULL,
|
||||
color TEXT,
|
||||
tags TEXT DEFAULT '[]', -- JSON array: ["Prod","Linux","Client-RSM"]
|
||||
notes TEXT,
|
||||
options TEXT DEFAULT '{}', -- JSON: protocol-specific settings
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
last_connected DATETIME,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Credentials (password or SSH key reference)
|
||||
CREATE TABLE credentials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
username TEXT,
|
||||
domain TEXT,
|
||||
type TEXT NOT NULL CHECK(type IN ('password','ssh_key')),
|
||||
encrypted_value TEXT,
|
||||
ssh_key_id INTEGER REFERENCES ssh_keys(id) ON DELETE SET NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- SSH private keys (encrypted at rest)
|
||||
CREATE TABLE ssh_keys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
key_type TEXT,
|
||||
fingerprint TEXT,
|
||||
public_key TEXT,
|
||||
encrypted_private_key TEXT NOT NULL,
|
||||
passphrase_encrypted TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Terminal themes (16-color ANSI + fg/bg/cursor)
|
||||
CREATE TABLE themes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
foreground TEXT NOT NULL,
|
||||
background TEXT NOT NULL,
|
||||
cursor TEXT NOT NULL,
|
||||
black TEXT NOT NULL,
|
||||
red TEXT NOT NULL,
|
||||
green TEXT NOT NULL,
|
||||
yellow TEXT NOT NULL,
|
||||
blue TEXT NOT NULL,
|
||||
magenta TEXT NOT NULL,
|
||||
cyan TEXT NOT NULL,
|
||||
white TEXT NOT NULL,
|
||||
bright_black TEXT NOT NULL,
|
||||
bright_red TEXT NOT NULL,
|
||||
bright_green TEXT NOT NULL,
|
||||
bright_yellow TEXT NOT NULL,
|
||||
bright_blue TEXT NOT NULL,
|
||||
bright_magenta TEXT NOT NULL,
|
||||
bright_cyan TEXT NOT NULL,
|
||||
bright_white TEXT NOT NULL,
|
||||
selection_bg TEXT,
|
||||
selection_fg TEXT,
|
||||
is_builtin BOOLEAN DEFAULT 0
|
||||
);
|
||||
|
||||
-- Connection history (for recent connections + frequency sorting)
|
||||
CREATE TABLE connection_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
connection_id INTEGER NOT NULL REFERENCES connections(id) ON DELETE CASCADE,
|
||||
protocol TEXT NOT NULL,
|
||||
connected_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
disconnected_at DATETIME,
|
||||
duration_secs INTEGER
|
||||
);
|
||||
|
||||
-- Known SSH host keys
|
||||
CREATE TABLE host_keys (
|
||||
hostname TEXT NOT NULL,
|
||||
port INTEGER NOT NULL,
|
||||
key_type TEXT NOT NULL,
|
||||
fingerprint TEXT NOT NULL,
|
||||
raw_key TEXT,
|
||||
first_seen DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (hostname, port, key_type)
|
||||
);
|
||||
|
||||
-- App settings (key-value)
|
||||
CREATE TABLE settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
**`connections.options`** — JSON blob for protocol-specific settings. SSH: keepalive interval, preferred auth method, shell integration toggle. RDP: color depth, security mode (NLA/TLS/RDP), console session, audio redirection, display resolution. Keeps the schema clean and extensible as we discover edge cases without adding nullable columns.
|
||||
|
||||
**`connections.tags`** — JSON array searchable via SQLite's `json_each()`. Enables filtering across groups (type "Prod" in search, see only production hosts regardless of which group they're in).
|
||||
|
||||
**Connections → Credentials** is many-to-one. Multiple hosts can share the same credential.
|
||||
|
||||
**SQLite WAL mode:** Enable Write-Ahead Logging (`PRAGMA journal_mode=WAL`) on database open in `db/sqlite.go`. WAL mode allows concurrent reads during writes, preventing "database is locked" errors when the frontend queries connections while the backend is writing session history or updating `last_connected` timestamps. Also set `PRAGMA busy_timeout=5000` as a safety net.
|
||||
|
||||
**Host keys** are keyed by `(hostname, port, key_type)`. Supports multiple key types per host. Separated from connections so host key verification works independently of saved connections (e.g., quick connect).
|
||||
|
||||
---
|
||||
|
||||
## 5. UI Layout
|
||||
|
||||
### Visual Identity
|
||||
|
||||
Dark theme inspired by the Wraith brand: deep dark backgrounds (#0d1117), blue accent (#58a6ff), green for SSH indicators (#3fb950), blue for RDP indicators (#1f6feb). The aesthetic is "operator command center" — atmospheric, moody, professional. Reference: `docs/karens-wraith-layout.png` for the target mood.
|
||||
|
||||
Logo: `images/wraith-logo.png` — ghost with "$" symbol, used in the title bar and app icon.
|
||||
|
||||
**The "alive" feel:** Tabs use a 0.5s CSS `transition` on `background-color` and `border-color` when switching between active and backgrounded states. The active tab's background subtly brightens; backgrounded tabs dim. This creates a fluid, "breathing" quality as you switch between sessions — the Wraith is present without being loud. Same 0.5s transition applies to sidebar item hover states and toolbar button interactions. No animations on the terminal itself — that would be distracting.
|
||||
|
||||
### Main Window Layout
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ [👻 WRAITH v1.0] File View Tools Settings Help │ ← Title/Menu bar
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ [⚡ Quick connect...] [+SSH] [+RDP] 4 sessions 🔒 ⚙ │ ← Toolbar
|
||||
├────────────┬────────────────────────────────────────────────┤
|
||||
│ │ [Asgard ●] [Docker ●] [Predator ●] [VM01 ●]+ │ ← Tab bar
|
||||
│ SIDEBAR │────────────────────────────────────────────────│
|
||||
│ │ │
|
||||
│ Toggles: │ Terminal / RDP Canvas │
|
||||
│ 📂 Conn │ │
|
||||
│ 📁 SFTP │ (xterm.js or <canvas>) │
|
||||
│ │ │
|
||||
│ Search │ Primary workspace area │
|
||||
│ Tags │ Takes dominant space │
|
||||
│ Groups │ │
|
||||
│ Tree │ │
|
||||
│ │ │
|
||||
├────────────┴────────────────────────────────────────────────┤
|
||||
│ SSH · root@asgard:22 ⚠️ ↑1.2K ↓3.4K Dark+ UTF-8 120×40 │ ← Status bar
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Sidebar Behavior
|
||||
|
||||
The left sidebar is a **single panel that toggles context** between Connections and SFTP (same as MobaXTerm, not two panels):
|
||||
|
||||
- **Connections view:** Search bar, tag filter pills, recent connections, hierarchical group tree with connection entries. Green dots for SSH, blue dots for RDP. "Connected" indicator on active sessions. Right-click context menu for edit, delete, duplicate, move to group.
|
||||
- **SFTP view:** Activates when an SSH session connects. Path bar showing current remote directory. Toolbar with upload, download, new file, new folder, refresh, delete. File tree with name, size, modified date. "Follow terminal folder" toggle at bottom.
|
||||
|
||||
The sidebar is resizable. Minimum width ~200px, collapsible to icon-only rail.
|
||||
|
||||
### Tab Bar
|
||||
|
||||
- Color-coded dots: green = SSH, blue = RDP
|
||||
- Protocol icon on each tab
|
||||
- Environment badges: optional colored pills (PROD, ROOT, DEV) derived from connection tags
|
||||
- Root session warning: tabs connected as root get a subtle warm accent
|
||||
- Close button (×) on each tab
|
||||
- Pop-out icon (↗) on hover for tab detach
|
||||
- Overflow: chevron dropdown for hidden tabs when 10+ are open (not multi-line rows)
|
||||
- Drag to reorder tabs
|
||||
- Drag out of tab bar to detach into new window
|
||||
- "+" button to open new session
|
||||
|
||||
### Tab Detach/Reattach
|
||||
|
||||
- **Detach:** Drag tab out of bar OR click ↗ icon → spawns new Wails window with that session still alive. Original tab shows "Session detached — [Reattach]" placeholder.
|
||||
- **Reattach:** Click "Reattach" button in placeholder OR close the detached window → session snaps back into the tab bar.
|
||||
- Works for both SSH and RDP sessions.
|
||||
- The session lives in the Go backend, not the window. Detaching is just moving the view.
|
||||
|
||||
### Command Palette (Ctrl+K)
|
||||
|
||||
Modal overlay with fuzzy search across:
|
||||
- Connection names, hostnames, group names, tags
|
||||
- Actions: "New SSH", "New RDP", "Open Vault", "Settings", "Import MobaXTerm"
|
||||
- Active sessions: "Switch to Asgard", "Disconnect Docker"
|
||||
- Keyboard-first — arrow keys to navigate, Enter to select, Esc to close
|
||||
|
||||
### Status Bar
|
||||
|
||||
- Left: Protocol, user@host:port, privilege warning (⚠️ when root), transfer speed
|
||||
- Right: Active theme name, encoding (UTF-8), terminal dimensions (cols×rows)
|
||||
- Active session count in toolbar area
|
||||
|
||||
### Terminal Theming
|
||||
|
||||
Built-in themes: Dracula, Nord, Monokai, Solarized Dark, One Dark, Gruvbox, plus a "MobaXTerm Classic" theme matching the colors from the user's `.mobaconf` export.
|
||||
|
||||
Custom theme creation via settings. Full 16-color ANSI palette + foreground/background/cursor maps directly to xterm.js `ITheme` objects.
|
||||
|
||||
Per-connection theme override via `connections.options` JSON field.
|
||||
|
||||
### Keyboard Shortcuts
|
||||
|
||||
| Shortcut | Action |
|
||||
|---|---|
|
||||
| Ctrl+K | Command palette |
|
||||
| Ctrl+T | New SSH session (from quick connect) |
|
||||
| Ctrl+W | Close current tab |
|
||||
| Ctrl+Tab | Next tab |
|
||||
| Ctrl+Shift+Tab | Previous tab |
|
||||
| Ctrl+1-9 | Switch to tab N |
|
||||
| Ctrl+B | Toggle sidebar |
|
||||
| Ctrl+Shift+D | Detach current tab |
|
||||
| F11 | Fullscreen |
|
||||
| Ctrl+Shift+C | Copy (terminal) |
|
||||
| Ctrl+Shift+V | Paste (terminal) |
|
||||
| Ctrl+F | Search in terminal scrollback |
|
||||
|
||||
### CodeMirror 6 Editor (Separate Window)
|
||||
|
||||
- Opens as a new Wails window when clicking a text file in the SFTP sidebar
|
||||
- File size guard: files over 5MB refused for inline editing (offered download instead)
|
||||
- Syntax highlighting based on file extension
|
||||
- Save button writes content back to remote via SFTP
|
||||
- Unsaved changes warning on close
|
||||
- Window title: `filename — host — Wraith Editor`
|
||||
|
||||
---
|
||||
|
||||
## 6. SSH + SFTP Flow
|
||||
|
||||
### SSH Connection
|
||||
|
||||
```
|
||||
User double-clicks "Asgard" in connection sidebar
|
||||
→ Go: ConnectionManager.Connect(connectionId)
|
||||
→ Go: VaultService.DecryptCredential(credentialId) → auth method
|
||||
→ Go: SSHService.Dial(hostname, port, authConfig)
|
||||
→ x/crypto/ssh.Dial() with host key callback
|
||||
→ If new host key: emit event to frontend, user accepts/rejects
|
||||
→ If changed host key: BLOCK connection, warn user (no silent accept)
|
||||
→ If accepted: store in host_keys table
|
||||
→ Go: SessionManager.Create(sshClient, connectionId) → sessionId
|
||||
→ Go: SSHService.RequestPTY(session, "xterm-256color", cols, rows)
|
||||
→ Go: SSHService.Shell(session) → stdin/stdout pipes
|
||||
→ Frontend: xterm.js instance created, bound to sessionId
|
||||
→ Wails bindings: bidirectional data flow
|
||||
→ xterm.js onData → Go SSHService.Write(sessionId, bytes)
|
||||
→ Go SSHService.Read(sessionId) → Wails event → xterm.js write
|
||||
```
|
||||
|
||||
### SSH Authentication
|
||||
|
||||
Supports three auth methods:
|
||||
|
||||
1. **SSH Key:** Decrypt private key from vault. If key has passphrase, decrypt that too. Pass to `ssh.PublicKeys()` signer.
|
||||
2. **Password:** Decrypt password from vault. Pass to `ssh.Password()`.
|
||||
3. **Keyboard-Interactive:** For servers with 2FA/MFA prompts. `ssh.KeyboardInteractive()` callback relays challenge prompts to the frontend, user responds in a dialog. Common in MSP environments with PAM-based MFA.
|
||||
|
||||
Auth methods are tried in order: key → password → keyboard-interactive. The credential type determines which are attempted first, but keyboard-interactive is always available as a fallback for servers that require it.
|
||||
|
||||
### Terminal Resize
|
||||
|
||||
```
|
||||
Frontend: xterm.js fit addon detects container resize
|
||||
→ Wails binding: SSHService.Resize(sessionId, cols, rows)
|
||||
→ Go: session.WindowChange(rows, cols)
|
||||
```
|
||||
|
||||
### SFTP Sidebar
|
||||
|
||||
```
|
||||
SSH connection established:
|
||||
→ Go: SFTPService.Open(sshClient) → pkg/sftp.NewClient(sshClient)
|
||||
→ Go: SFTPService.List(sessionId, homeDir) → directory listing
|
||||
→ Frontend: sidebar switches to SFTP view, renders file tree
|
||||
```
|
||||
|
||||
SFTP uses the **same SSH connection** as the terminal (SFTP subsystem). No separate connection needed.
|
||||
|
||||
**File operations:** All SFTP commands route through Go via Wails bindings, targeting the correct `pkg/sftp` client by sessionId.
|
||||
|
||||
| Operation | Go function | Notes |
|
||||
|---|---|---|
|
||||
| List directory | `sftp.ReadDir(path)` | Lazy-loaded on tree expand |
|
||||
| Upload | `sftp.Create(path)` + chunked write | Drag-and-drop from Windows Explorer |
|
||||
| Download | `sftp.Open(path)` + read | Browser-style save dialog |
|
||||
| Delete | `sftp.Remove(path)` / `sftp.RemoveAll(path)` | Confirmation prompt |
|
||||
| Rename/Move | `sftp.Rename(old, new)` | |
|
||||
| Mkdir | `sftp.Mkdir(path)` | |
|
||||
| Chmod | `sftp.Chmod(path, mode)` | |
|
||||
| Read file | `sftp.Open(path)` → content | Opens in CodeMirror window |
|
||||
| Write file | `sftp.Create(path)` ← content | Save from CodeMirror |
|
||||
|
||||
### CWD Following
|
||||
|
||||
```
|
||||
SSH session starts:
|
||||
→ Go: injects shell hook after PTY is established:
|
||||
PROMPT_COMMAND='printf "\033]7;file://%s%s\033\\" "$(hostname)" "$PWD"'
|
||||
(or precmd for zsh)
|
||||
→ Go: SSHService reads stdout, scans for OSC 7 escape sequences
|
||||
→ Go: strips OSC 7 before forwarding to xterm.js (user never sees it)
|
||||
→ Go: emits CWD change event with new path
|
||||
→ Frontend: if "Follow terminal folder" is enabled, calls SFTPService.List(newPath)
|
||||
→ Frontend: SFTP tree navigates to new directory
|
||||
```
|
||||
|
||||
"Follow terminal folder" is a per-session toggle (checkbox at bottom of SFTP sidebar), enabled by default.
|
||||
|
||||
**Shell detection:** The OSC 7 injection assumes a bash-like shell (`PROMPT_COMMAND`) or zsh (`precmd`). For fish, the equivalent is `function fish_prompt; printf "\033]7;file://%s%s\033\\" (hostname) "$PWD"; end`. If shell detection fails (unknown shell, restricted shell, non-interactive session), CWD following is silently disabled — the SFTP sidebar stays at the initial home directory and requires manual navigation.
|
||||
|
||||
### Upload Flow
|
||||
|
||||
```
|
||||
User drags file from Windows Explorer onto SFTP sidebar:
|
||||
→ Frontend: reads file via File API, sends chunks to Go
|
||||
→ Go: SFTPService.Upload(sessionId, remotePath, fileData)
|
||||
→ Go: sftp.Create(remotePath) → write chunks → close
|
||||
→ Progress events emitted back to frontend
|
||||
→ SFTP tree refreshes on completion
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. RDP Flow
|
||||
|
||||
### Architecture
|
||||
|
||||
FreeRDP3 is loaded via `purego` (or `syscall.NewLazyDLL`) at runtime. No CGO, no C compiler needed. The Go binary loads `freerdp3.dll` from the application directory.
|
||||
|
||||
### Connection
|
||||
|
||||
```
|
||||
User double-clicks "CLT-VMHOST01" in connection sidebar:
|
||||
→ Go: ConnectionManager.Connect(connectionId)
|
||||
→ Go: VaultService.DecryptCredential(credentialId) → username, password, domain
|
||||
→ Go: RDPService.Connect(host, port, username, password, domain, options)
|
||||
→ purego: freerdp_new() → configure settings → freerdp_connect()
|
||||
→ Register BitmapUpdate callback
|
||||
→ Go: allocate pixel buffer (width × height × 4 bytes RGBA)
|
||||
→ FreeRDP: decoded bitmap frames written into pixel buffer
|
||||
→ Go: frame data served to frontend
|
||||
→ Frontend: <canvas> renders frames via requestAnimationFrame (30fps target)
|
||||
```
|
||||
|
||||
### Frame Delivery
|
||||
|
||||
FreeRDP writes decoded frame data into a shared Go pixel buffer. The frontend retrieves frame data via one of:
|
||||
|
||||
- **Local HTTP endpoint:** `localhost:{random_port}/frame` returns raw RGBA or PNG
|
||||
- **Blob URL:** Go encodes frame, passes via Wails binding as base64
|
||||
- **Optimal approach TBD during implementation** — benchmark both
|
||||
|
||||
Performance target: **1080p @ 30fps**. Focus on Bitmap Update callbacks. No H.264 pipeline needed — raw bitmap updates with basic RLE compression is sufficient for remote management work.
|
||||
|
||||
### Input Handling
|
||||
|
||||
```
|
||||
Frontend: mouse/keyboard events captured on <canvas> element
|
||||
→ Wails binding → Go: RDPService.SendMouseEvent(sessionId, x, y, flags)
|
||||
→ Wails binding → Go: RDPService.SendKeyEvent(sessionId, keycode, pressed)
|
||||
→ Go: translate JS virtual keycodes to RDP scancodes via lookup table
|
||||
→ Go: purego calls freerdp_input_send_mouse_event / freerdp_input_send_keyboard_event
|
||||
```
|
||||
|
||||
**Scancode mapping:** JavaScript `KeyboardEvent.code` values (e.g., "KeyA", "ShiftLeft") must be translated to RDP hardware scancodes that FreeRDP expects. A static lookup table in `internal/rdp/input.go` maps JS key codes → RDP scancodes. This is a known complexity in web-based RDP — the table must handle extended keys (e.g., right Alt, numpad) and platform-specific quirks. Reference: FreeRDP's `scancode.h` for the canonical scancode list.
|
||||
|
||||
**System key pass-through:** The Windows key and Alt+Tab require special handling. By default, these keys are captured by the local OS. A per-connection toggle in `connections.options` (`"grabKeyboard": true`) controls whether system keys are forwarded to the remote host or stay local. When enabled, the RDP canvas captures all keyboard input including Win key, Alt+Tab, Ctrl+Alt+Del (via a toolbar button). Power users toggling between remote and local need this to be fast and obvious — surface it as an icon in the RDP toolbar.
|
||||
|
||||
### Clipboard Sync
|
||||
|
||||
```
|
||||
Remote → Local:
|
||||
→ Go: FreeRDP clipboard channel callback fires
|
||||
→ Go: emits clipboard event to frontend
|
||||
→ Frontend: writes to system clipboard via Wails API
|
||||
|
||||
Local → Remote:
|
||||
→ Frontend: detects clipboard change (or user pastes)
|
||||
→ Wails binding → Go: RDPService.SendClipboard(sessionId, data)
|
||||
→ Go: writes to FreeRDP clipboard channel
|
||||
```
|
||||
|
||||
### RDP Connection Options
|
||||
|
||||
Stored in `connections.options` JSON field:
|
||||
|
||||
```json
|
||||
{
|
||||
"colorDepth": 32,
|
||||
"security": "nla",
|
||||
"consoleSession": false,
|
||||
"audioRedirect": false,
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"scaleFactor": 100
|
||||
}
|
||||
```
|
||||
|
||||
**HiDPI / display scaling:** On Windows with display scaling (e.g., 150% on a 4K monitor), the RDP session resolution must account for the scale factor. `scaleFactor` in connection options controls whether to send the physical pixel resolution or the scaled logical resolution to FreeRDP. Default behavior: detect the current Windows DPI setting and scale the RDP resolution accordingly. Override via the `scaleFactor` option (100 = no scaling, 150 = 150%).
|
||||
|
||||
---
|
||||
|
||||
## 8. Vault + Encryption
|
||||
|
||||
### Master Password Flow
|
||||
|
||||
```
|
||||
App launch:
|
||||
→ Master password prompt (modal, cannot be bypassed)
|
||||
→ If first launch:
|
||||
→ Generate random 32-byte salt
|
||||
→ Store salt in settings table (key: "vault_salt")
|
||||
→ Derive key: Argon2id(password, salt, t=3, m=65536, p=4, keyLen=32)
|
||||
→ Encrypt a known test value ("wraith-vault-check") with derived key
|
||||
→ Store encrypted test value in settings (key: "vault_check")
|
||||
→ If returning:
|
||||
→ Read salt and encrypted test value from settings
|
||||
→ Derive key with same parameters
|
||||
→ Attempt to decrypt test value
|
||||
→ If decryption succeeds → vault unlocked
|
||||
→ If fails → wrong password, prompt again
|
||||
→ Derived key held in memory only, never written to disk
|
||||
→ Key zeroed from memory on app close
|
||||
```
|
||||
|
||||
### Encryption Functions
|
||||
|
||||
```
|
||||
Encrypt(plaintext string) → string:
|
||||
→ Generate random 12-byte IV (crypto/rand)
|
||||
→ AES-256-GCM Seal(): returns ciphertext with authTag appended (Go's native format)
|
||||
→ Return "v1:{iv_hex}:{sealed_hex}"
|
||||
→ (sealed = ciphertext || authTag, as produced by cipher.AEAD.Seal())
|
||||
|
||||
Decrypt(blob string) → string:
|
||||
→ Parse version prefix, IV (12B), sealed data
|
||||
→ AES-256-GCM Open(): decrypts and verifies authTag (Go's native format)
|
||||
→ Return plaintext
|
||||
```
|
||||
|
||||
The `v1:` version prefix enables future key rotation without re-encrypting all stored values.
|
||||
|
||||
### What Gets Encrypted
|
||||
|
||||
| Data | Encrypted | Reason |
|
||||
|---|---|---|
|
||||
| SSH private keys | Yes | Sensitive key material |
|
||||
| SSH key passphrases | Yes | Passphrase is a secret |
|
||||
| Password credentials | Yes | Passwords are secrets |
|
||||
| RDP passwords | Yes | Via credential reference |
|
||||
| Hostnames, ports, usernames | No | Not secrets, needed for display |
|
||||
| Public keys, fingerprints | No | Public by definition |
|
||||
| Group names, tags, notes | No | Not secrets |
|
||||
| Settings, themes | No | User preferences |
|
||||
|
||||
### Argon2id Parameters
|
||||
|
||||
| Parameter | Value | Rationale |
|
||||
|---|---|---|
|
||||
| Time cost (t) | 3 | OWASP recommended minimum |
|
||||
| Memory cost (m) | 65536 (64MB) | Resists GPU attacks |
|
||||
| Parallelism (p) | 4 | Matches typical core count |
|
||||
| Key length | 32 bytes (256-bit) | AES-256 key size |
|
||||
| Salt | 32 bytes, random | Unique per installation |
|
||||
|
||||
### Future: Windows DPAPI Integration (Post-MVP)
|
||||
|
||||
The current vault is secure and portable (works on any Windows machine, backup the `.db` file and go). Post-MVP, an optional DPAPI layer could wrap the derived AES key with Windows Data Protection API, tying the vault to the current Windows user account. This would enable:
|
||||
|
||||
- Transparent unlock when logged into Windows (no master password prompt)
|
||||
- Hardware-backed key protection on machines with TPM
|
||||
- Enterprise trust (DPAPI is a known quantity for IT departments)
|
||||
|
||||
Implementation: the Argon2id-derived key gets wrapped with `CryptProtectData()` and stored. On unlock, DPAPI unwraps the key. Master password remains the fallback for portability (moving the database to another machine). This is designed-for but not built in MVP — the `v1:` encryption prefix enables adding a `v2:` scheme without re-encrypting existing data.
|
||||
|
||||
---
|
||||
|
||||
## 9. MobaXTerm Importer
|
||||
|
||||
### Config Format
|
||||
|
||||
MobaXTerm exports configuration as `.mobaconf` files — INI format with `%`-delimited session strings.
|
||||
|
||||
```ini
|
||||
[Bookmarks_1]
|
||||
SubRep=AAA Vantz's Stuff # Group name
|
||||
ImgNum=41 # Icon index
|
||||
*Asgard=#109#0%192.168.1.4%22%vstockwell%... # SSH session
|
||||
CLT-VMHOST01=#91#4%100.64.1.204%3389%... # RDP session
|
||||
|
||||
[SSH_Hostkeys]
|
||||
ssh-ed25519@22:192.168.1.4=0x29ac... # Known host keys
|
||||
|
||||
[Colors]
|
||||
ForegroundColour=236,236,236 # Terminal colors
|
||||
BackgroundColour=36,36,36
|
||||
|
||||
[Passwords]
|
||||
vstockwell@192.168.1.214=_@9jajOXK... # Encrypted (can't import)
|
||||
```
|
||||
|
||||
### Session String Parsing
|
||||
|
||||
| Protocol | Type code | Fields (%-delimited) |
|
||||
|---|---|---|
|
||||
| SSH | `#109#` | host, port, username, ..., SSH key path, ..., colors |
|
||||
| RDP | `#91#` | host, port, username, ..., color depth, security |
|
||||
|
||||
### Import Flow
|
||||
|
||||
```
|
||||
1. User: File → Import → Select .mobaconf file
|
||||
2. Go: parse INI sections
|
||||
3. Go: extract groups from [Bookmarks_N] SubRep values
|
||||
4. Go: parse session strings → connections
|
||||
5. Go: parse [SSH_Hostkeys] → host_keys table
|
||||
6. Go: parse [Colors] + [Font] → create "MobaXTerm Import" theme
|
||||
7. Frontend: show preview dialog:
|
||||
"Found: 18 connections, 1 group, 4 host keys, 1 color theme"
|
||||
8. User confirms import
|
||||
9. Go: create groups, connections, host keys, theme in SQLite
|
||||
10. Frontend: report results:
|
||||
"Imported! 5 connections reference SSH keys — re-import key files.
|
||||
3 connections had stored passwords — re-enter in Wraith vault."
|
||||
```
|
||||
|
||||
### What Gets Imported
|
||||
|
||||
| Data | Imported | Notes |
|
||||
|---|---|---|
|
||||
| Connection names | Yes | |
|
||||
| Groups (folder hierarchy) | Yes | From SubRep values |
|
||||
| Hostnames, ports | Yes | |
|
||||
| Usernames | Yes | |
|
||||
| Protocol (SSH/RDP) | Yes | From type code #109# / #91# |
|
||||
| SSH key file paths | As notes | User must re-import actual key files |
|
||||
| Host keys | Yes | To host_keys table |
|
||||
| Terminal colors | Yes | As a new theme |
|
||||
| Font preferences | Yes | To settings |
|
||||
| Encrypted passwords | No | MobaXTerm-encrypted, can't decrypt |
|
||||
|
||||
---
|
||||
|
||||
## 10. Frontend Structure
|
||||
|
||||
```
|
||||
frontend/
|
||||
src/
|
||||
App.vue # Root: master password → main layout
|
||||
layouts/
|
||||
MainLayout.vue # Sidebar + tab container + status bar
|
||||
UnlockLayout.vue # Master password prompt
|
||||
components/
|
||||
sidebar/
|
||||
ConnectionTree.vue # Group tree with connection entries
|
||||
SftpBrowser.vue # SFTP file tree + toolbar
|
||||
SidebarToggle.vue # Connections ↔ SFTP toggle
|
||||
session/
|
||||
SessionContainer.vue # Holds all active sessions (v-show, not v-if)
|
||||
TabBar.vue # Draggable, detachable tab bar
|
||||
TabBadge.vue # PROD/ROOT/DEV environment pills
|
||||
terminal/
|
||||
TerminalView.vue # xterm.js instance wrapper
|
||||
ThemePicker.vue # Terminal color scheme selector
|
||||
rdp/
|
||||
RdpView.vue # Canvas-based RDP renderer
|
||||
RdpToolbar.vue # Clipboard, fullscreen controls
|
||||
sftp/
|
||||
FileTree.vue # Remote filesystem tree (lazy-loaded)
|
||||
TransferProgress.vue # Upload/download progress indicator
|
||||
vault/
|
||||
VaultManager.vue # SSH keys + credentials management
|
||||
KeyImportDialog.vue # SSH key import modal
|
||||
CredentialForm.vue # Password/key credential form
|
||||
common/
|
||||
CommandPalette.vue # Ctrl+K fuzzy search overlay
|
||||
QuickConnect.vue # Quick connect input
|
||||
StatusBar.vue # Bottom status bar
|
||||
composables/
|
||||
useSession.ts # Session lifecycle + tab management
|
||||
useTerminal.ts # xterm.js + Wails binding bridge
|
||||
useSftp.ts # SFTP operations via Wails bindings
|
||||
useRdp.ts # RDP canvas rendering + input capture
|
||||
useVault.ts # Key/credential CRUD
|
||||
useConnections.ts # Connection CRUD + search + tags
|
||||
useTheme.ts # Terminal theme management
|
||||
useCommandPalette.ts # Command palette search + actions
|
||||
stores/
|
||||
session.store.ts # Active sessions, tab order, detach state
|
||||
connection.store.ts # Connections, groups, search state
|
||||
app.store.ts # Global state: unlocked, settings, active theme
|
||||
```
|
||||
|
||||
**Session architecture:** Active sessions render as persistent components inside `SessionContainer.vue`. Switching tabs toggles `v-show` visibility (not `v-if` destruction), so xterm.js and RDP canvas instances stay alive across tab switches. This is critical — destroying and recreating terminal instances would lose scrollback and session state.
|
||||
|
||||
---
|
||||
|
||||
## 11. Go Backend Structure
|
||||
|
||||
```
|
||||
internal/
|
||||
app/
|
||||
app.go # Wails app setup, window management
|
||||
menu.go # Application menu definitions
|
||||
session/
|
||||
manager.go # Session lifecycle, tab detach/reattach
|
||||
session.go # Session struct (SSH or RDP, backend state)
|
||||
ssh/
|
||||
service.go # SSH dial, PTY, shell, I/O pipes
|
||||
hostkey.go # Host key verification + storage
|
||||
cwd.go # OSC 7 parsing for CWD tracking
|
||||
sftp/
|
||||
service.go # SFTP operations (list, upload, download, etc.)
|
||||
rdp/
|
||||
service.go # RDP session management
|
||||
freerdp.go # purego bindings to freerdp3.dll
|
||||
pixelbuffer.go # Shared frame buffer management
|
||||
input.go # Mouse/keyboard event translation
|
||||
vault/
|
||||
service.go # Encrypt/decrypt, master password, key derivation
|
||||
vault_test.go # Encryption round-trip tests
|
||||
connections/
|
||||
service.go # Connection CRUD, group management
|
||||
search.go # Full-text search + tag filtering
|
||||
importer/
|
||||
mobaconf.go # MobaXTerm .mobaconf parser
|
||||
mobaconf_test.go # Parser tests with real config samples
|
||||
db/
|
||||
sqlite.go # SQLite connection, migrations
|
||||
migrations/ # SQL migration files
|
||||
settings/
|
||||
service.go # Key-value settings CRUD
|
||||
theme/
|
||||
service.go # Theme CRUD, built-in theme definitions
|
||||
builtins.go # Dracula, Nord, Monokai, etc.
|
||||
plugin/
|
||||
interfaces.go # Plugin interfaces (ProtocolHandler, Importer, etc.)
|
||||
registry.go # Plugin registration and lifecycle
|
||||
```
|
||||
|
||||
### Plugin Interface
|
||||
|
||||
Wraith exposes Go interfaces that community developers can implement to extend functionality:
|
||||
|
||||
```go
|
||||
// ProtocolHandler — add support for new protocols (VNC, Telnet, etc.)
|
||||
type ProtocolHandler interface {
|
||||
Name() string
|
||||
Connect(config ConnectionConfig) (Session, error)
|
||||
Disconnect(sessionId string) error
|
||||
}
|
||||
|
||||
// Importer — add support for importing from other tools
|
||||
type Importer interface {
|
||||
Name() string
|
||||
FileExtensions() []string
|
||||
Parse(data []byte) (*ImportResult, error)
|
||||
}
|
||||
```
|
||||
|
||||
Plugins are compiled into the binary (not runtime-loaded). Community developers fork the repo, implement the interface, register it in `plugin/registry.go`, and build. This keeps distribution simple (single binary) while enabling extensibility.
|
||||
|
||||
---
|
||||
|
||||
## 12. MVP Scope
|
||||
|
||||
### In MVP (launch-blocking)
|
||||
|
||||
| Feature | Priority | Phase | Notes |
|
||||
|---|---|---|---|
|
||||
| Wails v3 scaffold + SQLite + vault | P0 | 1 | Foundation — nothing works without this |
|
||||
| Connection manager sidebar | P0 | 1 | Groups, tree, search, tags |
|
||||
| SSH terminal (xterm.js) | P0 | 2 | Multi-tab, 8+ concurrent sessions |
|
||||
| SFTP sidebar | P0 | 2 | Auto-open, CWD following, file ops |
|
||||
| Credential vault UI | P0 | 2 | SSH key import, credential management |
|
||||
| Host key verification | P0 | 2 | Accept/reject new, block changed |
|
||||
| RDP in tabs | P0 | 3 | FreeRDP3/purego, embedded canvas |
|
||||
| MobaXTerm importer | P1 | 4 | Parse .mobaconf, first-run detection |
|
||||
| Terminal theming | P1 | 4 | 6+ built-in themes, custom themes |
|
||||
| Tab detach/reattach | P1 | 4 | Drag out, pop-out icon, reattach button |
|
||||
| CodeMirror 6 editor | P1 | 4 | Separate window, syntax highlighting |
|
||||
| Command palette (Ctrl+K) | P1 | 4 | Fuzzy search connections + actions |
|
||||
| Session context awareness | P1 | 4 | Root warning, user@host in status bar |
|
||||
| Tab badges | P1 | 4 | Protocol icon, environment tags |
|
||||
| Quick connect | P1 | 4 | user@host:port in toolbar |
|
||||
| Plugin interface | P1 | 1 | Define interfaces, implement in later phases |
|
||||
| README.md | P1 | 1 | Developer docs, architecture, contribution guide |
|
||||
|
||||
### Post-MVP
|
||||
|
||||
| Feature | Notes |
|
||||
|---|---|
|
||||
| Split panes | Horizontal/vertical splits within a tab |
|
||||
| Session recording/playback | asciinema-compatible |
|
||||
| Jump host / bastion proxy | ProxyJump chain support |
|
||||
| Port forwarding manager | Local, remote, dynamic SSH tunnels |
|
||||
| Saved snippets/macros | Quick-execute command library |
|
||||
| Tab grouping/stacking | Browser-style tab groups |
|
||||
| Live latency monitoring | Ping/packet loss in status bar |
|
||||
| Dual-pane SFTP | Server-to-server file operations |
|
||||
| Auto-detect environment | Parse hostname for prod/dev/staging classification |
|
||||
| Subtle glow effects | "Wraith" personality — energy on active sessions |
|
||||
| Dynamic plugin loading | Drop-in plugins without recompilation (longer-term) |
|
||||
| Windows DPAPI vault | Optional OS-backed encryption layer for transparent unlock |
|
||||
| **Claude Code plugin** | **First official plugin — see below** |
|
||||
|
||||
### Post-MVP Plugin: Claude Code Integration
|
||||
|
||||
The first plugin built on the Wraith plugin interface. Embeds Claude Code directly into Wraith as a sidebar panel or tab, with full access to the active session's context.
|
||||
|
||||
**Authentication:** User authenticates with their Anthropic API key or Claude account (stored encrypted in the vault alongside SSH keys and passwords). Key is decrypted on demand, never persisted in plaintext.
|
||||
|
||||
**Core capabilities:**
|
||||
- **Terminal integration:** Claude Code runs in a dedicated Wraith tab (xterm.js instance). It can see the active SSH session's terminal output and type commands into it — same as a human operator switching tabs.
|
||||
- **SFTP-aware file access:** Claude Code can read and write files on the remote host via the active SFTP session. "Read `/etc/nginx/nginx.conf`" pulls the file through SFTP, Claude analyzes/modifies it, and writes it back. No need for Claude to SSH separately — it rides the existing Wraith session.
|
||||
- **CodeMirror handoff:** Claude can open files in the CodeMirror editor window, make changes, and save back to the remote host. The user sees the edits happening in real-time.
|
||||
- **Context awareness:** Claude sees which host you're connected to, the current working directory (via CWD tracking), and recent terminal output. "Fix the nginx config on this server" just works because Claude already knows where "this" is.
|
||||
|
||||
**UX flow:**
|
||||
1. User opens Claude Code panel (sidebar tab or dedicated session tab)
|
||||
2. Types a prompt: "Check why nginx is returning 502 on this server"
|
||||
3. Claude reads recent terminal output, pulls nginx config via SFTP, analyzes logs
|
||||
4. Claude proposes a fix, user approves, Claude writes the file via SFTP
|
||||
5. Claude types `nginx -t && systemctl reload nginx` into the terminal
|
||||
|
||||
**Plugin interface usage:** This plugin implements `ProtocolHandler` (for the Claude Code tab) and extends the SFTP/terminal services to allow programmatic read/write. It proves the plugin architecture works and becomes the reference implementation for community plugin developers.
|
||||
|
||||
---
|
||||
|
||||
## 13. Build Phases
|
||||
|
||||
### Error Handling + Logging Strategy
|
||||
|
||||
**Structured logging:** Use `log/slog` (Go 1.21+ standard library) with JSON output. Log levels: DEBUG, INFO, WARN, ERROR. Log to `%APPDATA%\Wraith\wraith.log` with daily rotation (keep 7 days).
|
||||
|
||||
**Connection drops:** When an SSH/RDP connection drops unexpectedly:
|
||||
1. Session Manager marks session as `disconnected`
|
||||
2. Frontend tab shows "Connection lost — [Reconnect] [Close]"
|
||||
3. Auto-reconnect is opt-in (configurable per connection via `options` JSON)
|
||||
4. If auto-reconnect is enabled, retry 3 times with exponential backoff (1s, 2s, 4s)
|
||||
|
||||
**Error surfacing:** Errors from Go backend are emitted as Wails events with a severity level. Frontend shows:
|
||||
- Transient errors (network timeout) → toast notification, auto-dismiss
|
||||
- Actionable errors (auth failure) → modal with explanation and action button
|
||||
- Fatal errors (vault corruption) → full-screen error with instructions
|
||||
|
||||
**Sensitive data in logs:** Never log passwords, private keys, or decrypted credentials. Log only: connection IDs, hostnames, session IDs, error types.
|
||||
|
||||
### Crash Recovery + Workspace Restore
|
||||
|
||||
When the app crashes, the system reboots, or Wails dies, SSH/RDP sessions are gone — there's no way to recover a dropped TCP connection. But the **workspace layout** can be restored.
|
||||
|
||||
**Workspace snapshots:** The Session Manager periodically writes a workspace snapshot to SQLite (every 30 seconds and on clean shutdown):
|
||||
|
||||
```json
|
||||
{
|
||||
"tabs": [
|
||||
{"connectionId": 1, "protocol": "ssh", "position": 0, "detached": false},
|
||||
{"connectionId": 5, "protocol": "rdp", "position": 1, "detached": false},
|
||||
{"connectionId": 3, "protocol": "ssh", "position": 2, "detached": true, "windowBounds": {...}}
|
||||
],
|
||||
"sidebarWidth": 240,
|
||||
"sidebarMode": "connections",
|
||||
"activeTab": 0
|
||||
}
|
||||
```
|
||||
|
||||
**On restart after crash:**
|
||||
1. Detect unclean shutdown (snapshot exists but no `clean_shutdown` flag)
|
||||
2. Show: "Wraith closed unexpectedly. Restore previous workspace? [Restore] [Start Fresh]"
|
||||
3. If Restore: recreate tab layout, attempt to reconnect each session
|
||||
4. Tabs that fail to reconnect show "Connection lost — [Retry] [Close]"
|
||||
|
||||
Users care about continuity more than perfection. Even if every session dies, restoring the layout and offering one-click reconnect is a massive UX win.
|
||||
|
||||
### Resource Management
|
||||
|
||||
With 20+ SSH sessions and multiple RDP sessions, resource awareness is critical:
|
||||
|
||||
**Memory budget:** Each SSH session costs ~2-5MB (PTY buffer + SFTP client). Each RDP session costs ~8-12MB (pixel buffer at 1080p). Target: stable at 20 SSH + 3 RDP (~100-120MB total backend memory).
|
||||
|
||||
**Session limits:**
|
||||
- Default max: 32 concurrent sessions (SSH + RDP combined)
|
||||
- Configurable via settings
|
||||
- When limit reached: "Maximum sessions reached. Close a session to open a new one."
|
||||
|
||||
**Inactive session handling:**
|
||||
- Sessions idle for 30+ minutes get a subtle "idle" indicator on the tab (dimmed text)
|
||||
- SSH keepalive (`ServerAliveInterval` equivalent) prevents server-side timeouts — configurable per connection via `options.keepAliveInterval` (default: 60 seconds)
|
||||
- No automatic session suspension — users control their sessions explicitly
|
||||
- SFTP idle connections are closed after 10 minutes of inactivity and silently reopened on next file operation
|
||||
|
||||
**Monitoring:** Expose a "Sessions" panel in Settings showing per-session memory usage, connection duration, and idle time. Simple table, not a dashboard.
|
||||
|
||||
---
|
||||
|
||||
## 14. Licensing + Open Source
|
||||
|
||||
**License:** MIT. All dependencies must be MIT, Apache 2.0, BSD, or ISC compatible. **No GPL/AGPL dependencies.**
|
||||
|
||||
Dependency license audit is part of Phase 1. Key libraries and their licenses:
|
||||
- `golang.org/x/crypto` — BSD-3-Clause ✓
|
||||
- `github.com/pkg/sftp` — BSD-2-Clause ✓
|
||||
- `github.com/ebitengine/purego` — Apache 2.0 ✓
|
||||
- `modernc.org/sqlite` — BSD-3-Clause ✓
|
||||
- FreeRDP3 — Apache 2.0 ✓ (dynamically linked, no license contamination)
|
||||
- xterm.js — MIT ✓
|
||||
- Vue 3 — MIT ✓
|
||||
- Naive UI — MIT ✓
|
||||
- Tailwind CSS — MIT ✓
|
||||
- CodeMirror 6 — MIT ✓
|
||||
|
||||
**Plugin architecture:** The Go backend exposes a plugin interface so community developers can extend Wraith with custom protocol handlers, importers, or sidebar panels. Plugins are Go packages that implement defined interfaces and are compiled into the binary (no runtime plugin loading — keeps the binary simple and portable).
|
||||
|
||||
**README.md:** Comprehensive developer-facing documentation covering: architecture overview, build instructions, project structure walkthrough, plugin development guide, contribution guidelines, and the design philosophy. Written as part of Phase 1.
|
||||
|
||||
---
|
||||
|
||||
## 15. First-Run Experience
|
||||
|
||||
On first launch:
|
||||
1. Master password creation dialog (set + confirm)
|
||||
2. Detect if `.mobaconf` files exist in common locations (`%APPDATA%\MobaXterm\`, user's Documents folder)
|
||||
3. If found: prompt "We found a MobaXTerm configuration. Import your sessions?" with file path shown
|
||||
4. If not found: offer "Import from MobaXTerm" button + "Start fresh"
|
||||
5. After import (or skip): land on the empty connection manager with a "Create your first connection" prompt
|
||||
|
||||
---
|
||||
|
||||
## 16. Build Phases
|
||||
|
||||
| Phase | Deliverables |
|
||||
|---|---|
|
||||
| **1: Foundation** | Wails v3 scaffold (including multi-window spike — validate Plan A/B/C), SQLite schema + migrations (WAL mode), vault service (master password, Argon2id, AES-256-GCM), connection CRUD, group tree, Vue 3 shell with sidebar + tab container, dark theme, Naive UI integration, plugin interface definitions, README.md, license audit, **RDP frame transport spike** (benchmark HTTP vs base64 with a test canvas — don't wait until Phase 3) |
|
||||
| **2: SSH + SFTP** | SSH service (x/crypto/ssh), PTY + shell, xterm.js terminal rendering, multi-tab sessions, SFTP sidebar (pkg/sftp), file tree, upload/download, CWD following (OSC 7), CodeMirror 6 editor in separate window, workspace snapshot persistence |
|
||||
| **3: RDP** | FreeRDP3 purego bindings, pixel buffer, canvas rendering (using proven transport from Phase 1 spike), mouse/keyboard input mapping (including scancode table + system key pass-through), clipboard sync, connection options |
|
||||
| **4: Polish** | Command palette, tab detach/reattach, terminal theming (built-in + custom), MobaXTerm importer (with first-run detection), tab badges, session context awareness, quick connect, host key management UI, settings page, crash recovery / workspace restore, resource management panel, NSIS installer |
|
||||
186
docs/superpowers/specs/2026-03-24-local-pty-copilot-design.md
Normal file
186
docs/superpowers/specs/2026-03-24-local-pty-copilot-design.md
Normal file
@ -0,0 +1,186 @@
|
||||
# Local PTY Copilot Panel — Design Spec
|
||||
|
||||
**Date:** 2026-03-24
|
||||
**Status:** Approved
|
||||
**Author:** Claude Opus 4.6 (XO)
|
||||
|
||||
## Problem
|
||||
|
||||
The AI panel is a Gemini API stub (~130 lines backend, ~124 lines frontend) with no OAuth, no conversation history, no tool use. The Commander pays $200/mo Claude Max, $20/mo Gemini, $20/mo ChatGPT — all of which include CLI tool access (Claude Code, Gemini CLI, Codex CLI). These CLIs are designed for terminals. Wraith has a terminal. Ship a local PTY in the sidebar and let the user run whichever CLI they want.
|
||||
|
||||
## Solution
|
||||
|
||||
Replace the Gemini stub with a local PTY terminal in the sidebar panel. Reuse the existing xterm.js infrastructure. User picks a shell (bash, sh, zsh, PowerShell, Git Bash), the panel spawns it locally, and they run `claude`, `gemini`, `codex`, or anything else.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Backend — `src-tauri/src/pty/mod.rs`
|
||||
|
||||
New module following the same patterns as `SshService`:
|
||||
|
||||
```
|
||||
PtyService
|
||||
sessions: DashMap<String, Arc<PtySession>>
|
||||
|
||||
PtySession
|
||||
id: String
|
||||
writer: Mutex<Box<dyn Write + Send>> // from master.take_writer()
|
||||
master: Mutex<Box<dyn MasterPty + Send>> // kept for resize()
|
||||
child: Mutex<Box<dyn Child + Send + Sync>> // for kill/wait
|
||||
shell_path: String
|
||||
|
||||
PtyService methods:
|
||||
spawn(shell_path, cols, rows, app_handle) -> Result<String, String>
|
||||
write(session_id, data) -> Result<(), String>
|
||||
resize(session_id, cols, rows) -> Result<(), String>
|
||||
disconnect(session_id) -> Result<(), String>
|
||||
list_shells() -> Vec<ShellInfo>
|
||||
```
|
||||
|
||||
Note: `writer` and `master` require `Mutex` wrappers because `portable-pty` trait objects are `Send` but not `Sync`, and the `DashMap` requires `Sync` on stored values.
|
||||
|
||||
**PTY crate:** `portable-pty` — cross-platform (Unix PTY, Windows ConPTY). MIT licensed. Part of the wezterm project.
|
||||
|
||||
**Shell detection** (`list_shells`):
|
||||
- Unix: check existence of `/bin/bash`, `/bin/sh`, `/bin/zsh`, `$SHELL`
|
||||
- Windows: `powershell.exe`, `cmd.exe`, plus scan for Git Bash at common paths (`C:\Program Files\Git\bin\bash.exe`, `C:\Program Files (x86)\Git\bin\bash.exe`)
|
||||
- Return `Vec<ShellInfo>` with `{ name, path }` pairs
|
||||
|
||||
**Output loop** — spawned per session via `spawn_blocking` (not async — `portable-pty` reader is synchronous `std::io::Read`):
|
||||
```rust
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut reader = BufReader::new(pty_reader);
|
||||
let mut buf = [0u8; 4096];
|
||||
loop {
|
||||
match reader.read(&mut buf) {
|
||||
Ok(0) => { app.emit("pty:close:{id}", ()); break; }
|
||||
Ok(n) => { app.emit("pty:data:{id}", base64(&buf[..n])); }
|
||||
Err(_) => break;
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
`AppHandle::emit()` is synchronous in Tauri v2, so it works from a blocking thread context without issues.
|
||||
|
||||
**Environment:** `CommandBuilder` inherits the parent process environment by default. This is required so that `PATH` includes the user's CLI tools (`claude`, `gemini`, `codex`). No env filtering should be applied.
|
||||
|
||||
### Backend — Tauri Commands (`src-tauri/src/commands/pty_commands.rs`)
|
||||
|
||||
```rust
|
||||
spawn_local_shell(shell_path: String, cols: u32, rows: u32) -> Result<String, String>
|
||||
pty_write(session_id: String, data: String) -> Result<(), String>
|
||||
pty_resize(session_id: String, cols: u32, rows: u32) -> Result<(), String>
|
||||
disconnect_pty(session_id: String) -> Result<(), String>
|
||||
list_available_shells() -> Result<Vec<ShellInfo>, String>
|
||||
```
|
||||
|
||||
All registered in `lib.rs` invoke handler. All added to `capabilities/default.json`.
|
||||
|
||||
### Backend — AppState Changes
|
||||
|
||||
```rust
|
||||
pub struct AppState {
|
||||
// ... existing fields ...
|
||||
pub pty: PtyService, // ADD
|
||||
// pub gemini: Mutex<...>, // DELETE
|
||||
}
|
||||
```
|
||||
|
||||
### Frontend — `src/components/ai/CopilotPanel.vue`
|
||||
|
||||
Replaces `GeminiPanel.vue`. Structure:
|
||||
|
||||
1. **Header bar:** "AI Copilot" title + shell selector dropdown + spawn/kill buttons
|
||||
2. **Terminal area:** xterm.js instance via `useTerminal` composable (adapted for PTY events)
|
||||
3. **State:** shell list (from `list_available_shells`), active session ID, connected flag
|
||||
4. **Close handling:** Listen for `pty:close:{session_id}` events to update `connected` state and show "Session ended — Relaunch?" UI. This differs from the SSH path where tab closure handles cleanup.
|
||||
|
||||
Shell selector is a `<select>` dropdown populated on mount. "Launch" button calls `spawn_local_shell`. Terminal mounts when session starts.
|
||||
|
||||
**Initial terminal size:** On spawn, measure terminal dimensions via `fitAddon.fit()` before invoking `spawn_local_shell`. Pass the measured cols/rows. If the terminal is not yet mounted, use defaults (80x24) and immediately resize after mount.
|
||||
|
||||
### Frontend — `useTerminal` Adaptation
|
||||
|
||||
Current `useTerminal.ts` hardcodes `ssh_write`, `ssh_resize`, and `ssh:data:` events.
|
||||
|
||||
**Chosen approach:** Parameterize the composable to accept a "backend type":
|
||||
```typescript
|
||||
export function useTerminal(sessionId: string, backend: 'ssh' | 'pty' = 'ssh')
|
||||
```
|
||||
|
||||
- `backend === 'ssh'` → `invoke("ssh_write")`, `listen("ssh:data:{id}")`, `convertEol: true`
|
||||
- `backend === 'pty'` → `invoke("pty_write")`, `listen("pty:data:{id}")`, `convertEol: false`
|
||||
|
||||
Same xterm.js instance, same resize observer, same clipboard, same base64 decode. Only the invoke target, event prefix, and EOL conversion change.
|
||||
|
||||
**Important:** The local PTY driver already translates LF to CRLF. The SSH path needs `convertEol: true` because raw SSH streams may send bare LF. Setting `convertEol: true` on the PTY path would produce double newlines.
|
||||
|
||||
### Cleanup — Delete Gemini Stub
|
||||
|
||||
Remove entirely:
|
||||
- `src-tauri/src/ai/mod.rs`
|
||||
- `src-tauri/src/commands/ai_commands.rs`
|
||||
- `src/components/ai/GeminiPanel.vue`
|
||||
- `AppState.gemini` field and `Mutex<Option<ai::GeminiClient>>` in `lib.rs`
|
||||
- AI command registrations from invoke handler
|
||||
- `pub mod ai;` from `lib.rs`
|
||||
|
||||
Keep `reqwest` in `Cargo.toml` — the RDP stack (`ironrdp-tokio`, `sspi`) depends on it transitively and may require the `json` feature flag our direct dependency enables.
|
||||
|
||||
### Data Flow
|
||||
|
||||
```
|
||||
User types in copilot panel
|
||||
→ useTerminal.onData(data)
|
||||
→ invoke("pty_write", { sessionId, data })
|
||||
→ PtyService.write() → writer.write_all(data)
|
||||
→ PTY stdin → shell process
|
||||
|
||||
Shell output → PTY stdout
|
||||
→ output reader loop (spawn_blocking)
|
||||
→ app.emit("pty:data:{id}", base64(bytes))
|
||||
→ useTerminal listener → base64 decode → xterm.js.write()
|
||||
|
||||
Shell exits (user types "exit" or CLI tool quits)
|
||||
→ reader returns Ok(0)
|
||||
→ app.emit("pty:close:{id}", ())
|
||||
→ CopilotPanel listens → updates connected state → shows relaunch UI
|
||||
```
|
||||
|
||||
### Tauri ACL
|
||||
|
||||
No changes needed to `capabilities/default.json` — `core:default` covers command invocation, `core:event:default` covers event listening. The PTY commands are registered via `generate_handler!`.
|
||||
|
||||
### Testing
|
||||
|
||||
**Rust tests:**
|
||||
- `list_shells()` returns at least one shell on any platform
|
||||
- `spawn()` + `write("echo hello\n")` + read output contains "hello"
|
||||
- `resize()` doesn't error on active session
|
||||
- `disconnect()` removes session from registry
|
||||
- `disconnect()` on nonexistent session returns error
|
||||
|
||||
**Frontend:** `useTerminal` composable already tested via SSH path. The `backend` parameter is a simple branch — no separate test needed.
|
||||
|
||||
### Dependencies
|
||||
|
||||
**Add:**
|
||||
- `portable-pty` — cross-platform PTY (MIT license, part of wezterm project)
|
||||
|
||||
**No removals** — `reqwest`, `md5`, `pem`, and other existing deps serve SSH and RDP functionality.
|
||||
|
||||
### Migration
|
||||
|
||||
No data migration needed. The Gemini stub stores nothing persistent — no DB tables, no settings, no vault entries. Clean delete.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
1. Commander opens Wraith, presses Ctrl+Shift+G
|
||||
2. Shell dropdown shows detected shells (bash on macOS, PowerShell + Git Bash on Windows)
|
||||
3. Selects shell, clicks Launch
|
||||
4. Full interactive terminal appears in sidebar
|
||||
5. Types `claude` (or `gemini` or `codex`) — CLI launches, works normally
|
||||
6. Resize sidebar → terminal reflows
|
||||
7. Close panel or kill session → PTY process terminates cleanly
|
||||
8. Shell exits → panel shows "Session ended — Relaunch?" prompt
|
||||
@ -1,108 +0,0 @@
|
||||
# Wraith Remote — Test Suite Build-Out Spec
|
||||
|
||||
**Date:** 2026-03-14
|
||||
**Scope:** Level B — Full service layer coverage (~80-100 tests)
|
||||
**Status:** Pinned — awaiting green light to execute
|
||||
|
||||
---
|
||||
|
||||
## Backend (Jest)
|
||||
|
||||
Jest is already configured in `backend/package.json`. Zero spec files exist today.
|
||||
|
||||
### Infrastructure
|
||||
|
||||
- Tests co-located with source: `*.spec.ts` next to `*.ts`
|
||||
- Shared mock factories in `backend/src/__mocks__/` for Prisma, JwtService, EncryptionService
|
||||
- `beforeEach` resets all mocks to prevent test bleed
|
||||
|
||||
### Test Files
|
||||
|
||||
| File | Tests | Priority |
|
||||
|------|-------|----------|
|
||||
| `auth.service.spec.ts` | Login (valid, invalid, non-existent user timing), bcrypt→argon2 migration, TOTP setup/verify/disable, TOTP secret encryption/decryption, password hashing, profile update, admin CRUD | ~20 |
|
||||
| `encryption.service.spec.ts` | v2 encrypt/decrypt round-trip, v1 backwards compat decrypt, v1→v2 upgrade, isV1 detection, invalid version handling, key derivation warmup | ~8 |
|
||||
| `credentials.service.spec.ts` | findAll excludes encryptedValue, findOne with ownership check, create with encryption, update with password change, remove, decryptForConnection (password, SSH key, orphaned key, no auth) | ~10 |
|
||||
| `ssh-keys.service.spec.ts` | Create with encryption, findAll (no private key leak), findOne ownership, update passphrase, remove, key type detection, fingerprint generation | ~8 |
|
||||
| `jwt-auth.guard.spec.ts` | Passes valid JWT, rejects missing/expired/invalid JWT | ~3 |
|
||||
| `admin.guard.spec.ts` | Allows admin role, blocks non-admin, blocks missing user | ~3 |
|
||||
| `ws-auth.guard.spec.ts` | Cookie-based auth, WS ticket auth (valid, expired, reused), legacy URL token fallback, no token rejection | ~6 |
|
||||
| `auth.controller.spec.ts` | Login sets cookie, logout clears cookie, ws-ticket issuance and consumption, TOTP endpoints wired correctly | ~8 |
|
||||
|
||||
**Backend total: ~66 tests**
|
||||
|
||||
### Mocking Strategy
|
||||
|
||||
- **PrismaService:** Jest manual mock returning controlled data per test
|
||||
- **EncryptionService:** Mock encrypt returns `v2:mock:...`, mock decrypt returns plaintext
|
||||
- **JwtService:** Mock sign returns `mock-jwt-token`, mock verify returns payload
|
||||
- **Argon2:** Real library (fast enough for unit tests, tests actual hashing behavior)
|
||||
- **bcrypt:** Real library (needed to test migration path)
|
||||
|
||||
---
|
||||
|
||||
## Frontend (Vitest)
|
||||
|
||||
No test infrastructure exists today. Needs full setup.
|
||||
|
||||
### Infrastructure
|
||||
|
||||
- Install: `vitest`, `@vue/test-utils`, `@pinia/testing`, `happy-dom`
|
||||
- Config: `frontend/vitest.config.ts` with happy-dom environment
|
||||
- Global mock for `$fetch` (Nuxt auto-import) via setup file
|
||||
- Global mock for `navigateTo` (Nuxt auto-import)
|
||||
|
||||
### Test Files
|
||||
|
||||
| File | Tests | Priority |
|
||||
|------|-------|----------|
|
||||
| `stores/auth.store.spec.ts` | Login success (stores user, no token in state), login TOTP flow, logout clears state + calls API, fetchProfile success/failure, getWsTicket, isAuthenticated/isAdmin getters | ~10 |
|
||||
| `stores/connection.store.spec.ts` | fetchHosts, fetchTree, createHost, updateHost, deleteHost, group CRUD, no Authorization headers in requests | ~8 |
|
||||
| `composables/useVault.spec.ts` | listKeys, importKey, deleteKey, listCredentials, createCredential, updateCredential, deleteCredential — all without Authorization headers | ~7 |
|
||||
| `middleware/admin.spec.ts` | Redirects non-admin to /, allows admin through | ~3 |
|
||||
| `plugins/auth.client.spec.ts` | Calls fetchProfile on init when user is null, skips when user exists | ~2 |
|
||||
|
||||
**Frontend total: ~30 tests**
|
||||
|
||||
---
|
||||
|
||||
## NPM Scripts
|
||||
|
||||
```json
|
||||
// backend/package.json
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage"
|
||||
|
||||
// frontend/package.json
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:cov": "vitest run --coverage"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pre-commit Hook (Husky)
|
||||
|
||||
- Install `husky` + `lint-staged` at repo root
|
||||
- Pre-commit runs: backend Jest (changed files) + frontend Vitest (changed files)
|
||||
- Fast feedback — only tests related to changed files run on commit
|
||||
|
||||
---
|
||||
|
||||
## Execution Plan
|
||||
|
||||
1. **Agent 1:** Backend test infra (mock factories) + auth service tests + auth controller tests + guard tests (~37 tests)
|
||||
2. **Agent 2:** Backend vault tests — encryption, credentials, SSH keys (~26 tests)
|
||||
3. **Agent 3:** Frontend test infra (Vitest setup) + store tests + middleware + plugin tests (~30 tests)
|
||||
4. **XO (me):** Wire npm scripts, verify all pass, add Husky pre-commit hook, final integration check
|
||||
|
||||
---
|
||||
|
||||
## Future (Level C — when ready)
|
||||
|
||||
- Component tests with Vue Test Utils (render + interaction)
|
||||
- Integration tests against a real test PostgreSQL (Docker test container)
|
||||
- Gateway tests (WebSocket mocking for terminal, SFTP, RDP)
|
||||
- E2E with Playwright
|
||||
- CI pipeline (Gitea Actions)
|
||||
@ -1,12 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Wraith</title>
|
||||
</head>
|
||||
<body class="bg-[#0d1117] text-[#e0e0e0]">
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,39 +0,0 @@
|
||||
{
|
||||
"name": "wraith-frontend",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"build:dev": "vue-tsc --noEmit && vite build --minify false --mode development",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-javascript": "^6.2.5",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@codemirror/lang-markdown": "^6.5.0",
|
||||
"@codemirror/lang-python": "^6.2.1",
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.40.0",
|
||||
"@wailsio/runtime": "latest",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-search": "^0.16.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/addon-webgl": "^0.19.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"codemirror": "^6.0.2",
|
||||
"naive-ui": "^2.40.0",
|
||||
"pinia": "^2.2.0",
|
||||
"vue": "^3.5.0",
|
||||
"vue-router": "^4.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@vitejs/plugin-vue": "^5.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.5.0",
|
||||
"vite": "^6.0.0",
|
||||
"vue-tsc": "^2.0.0"
|
||||
}
|
||||
}
|
||||
@ -1,30 +0,0 @@
|
||||
<template>
|
||||
<div class="h-screen w-screen bg-[var(--wraith-bg-primary)]">
|
||||
<!-- Loading state -->
|
||||
<div v-if="appStore.isLoading" class="h-full flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<h1 class="text-3xl font-bold text-[var(--wraith-accent-blue)]">WRAITH</h1>
|
||||
<p class="text-[var(--wraith-text-secondary)] mt-2">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Unlock screen -->
|
||||
<UnlockLayout v-else-if="!appStore.isUnlocked" />
|
||||
|
||||
<!-- Main application -->
|
||||
<MainLayout v-else />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from "vue";
|
||||
import { useAppStore } from "@/stores/app.store";
|
||||
import UnlockLayout from "@/layouts/UnlockLayout.vue";
|
||||
import MainLayout from "@/layouts/MainLayout.vue";
|
||||
|
||||
const appStore = useAppStore();
|
||||
|
||||
onMounted(() => {
|
||||
appStore.checkFirstRun();
|
||||
});
|
||||
</script>
|
||||
@ -1,29 +0,0 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--wraith-bg-primary: #0d1117;
|
||||
--wraith-bg-secondary: #161b22;
|
||||
--wraith-bg-tertiary: #21262d;
|
||||
--wraith-border: #30363d;
|
||||
--wraith-text-primary: #e0e0e0;
|
||||
--wraith-text-secondary: #8b949e;
|
||||
--wraith-text-muted: #484f58;
|
||||
--wraith-accent-blue: #58a6ff;
|
||||
--wraith-accent-green: #3fb950;
|
||||
--wraith-accent-red: #f85149;
|
||||
--wraith-accent-yellow: #e3b341;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||
background: var(--wraith-bg-primary);
|
||||
color: var(--wraith-text-primary);
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar { width: 8px; }
|
||||
::-webkit-scrollbar-track { background: var(--wraith-bg-primary); }
|
||||
::-webkit-scrollbar-thumb { background: var(--wraith-border); border-radius: 4px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--wraith-text-muted); }
|
||||
@ -1,97 +0,0 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="visible"
|
||||
class="fixed inset-0 z-50"
|
||||
@click="close"
|
||||
@contextmenu.prevent="close"
|
||||
>
|
||||
<div
|
||||
ref="menuRef"
|
||||
class="fixed bg-[#161b22] border border-[#30363d] rounded-lg shadow-2xl py-1 min-w-[160px] overflow-hidden"
|
||||
:style="{ left: position.x + 'px', top: position.y + 'px' }"
|
||||
@click.stop
|
||||
>
|
||||
<template v-for="(item, idx) in items" :key="idx">
|
||||
<!-- Separator -->
|
||||
<div v-if="item.separator" class="my-1 border-t border-[#30363d]" />
|
||||
|
||||
<!-- Menu item -->
|
||||
<button
|
||||
v-else
|
||||
class="w-full flex items-center gap-2.5 px-3 py-1.5 text-xs text-left transition-colors cursor-pointer"
|
||||
:class="item.danger
|
||||
? 'text-[var(--wraith-accent-red)] hover:bg-[var(--wraith-accent-red)]/10'
|
||||
: 'text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)]'
|
||||
"
|
||||
@click="handleClick(item)"
|
||||
>
|
||||
<span v-if="item.icon" class="w-4 h-4 flex items-center justify-center shrink-0" v-html="item.icon" />
|
||||
<span class="flex-1">{{ item.label }}</span>
|
||||
<span v-if="item.shortcut" class="text-[10px] text-[var(--wraith-text-muted)]">{{ item.shortcut }}</span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick } from "vue";
|
||||
|
||||
export interface ContextMenuItem {
|
||||
label?: string;
|
||||
icon?: string;
|
||||
shortcut?: string;
|
||||
danger?: boolean;
|
||||
separator?: boolean;
|
||||
action?: () => void;
|
||||
}
|
||||
|
||||
const visible = ref(false);
|
||||
const position = ref({ x: 0, y: 0 });
|
||||
const items = ref<ContextMenuItem[]>([]);
|
||||
const menuRef = ref<HTMLDivElement | null>(null);
|
||||
|
||||
function open(event: MouseEvent, menuItems: ContextMenuItem[]): void {
|
||||
items.value = menuItems;
|
||||
visible.value = true;
|
||||
|
||||
// Position at cursor, adjusting if near viewport edges
|
||||
nextTick(() => {
|
||||
const menu = menuRef.value;
|
||||
if (!menu) {
|
||||
position.value = { x: event.clientX, y: event.clientY };
|
||||
return;
|
||||
}
|
||||
|
||||
let x = event.clientX;
|
||||
let y = event.clientY;
|
||||
|
||||
const menuWidth = menu.offsetWidth;
|
||||
const menuHeight = menu.offsetHeight;
|
||||
|
||||
if (x + menuWidth > window.innerWidth) {
|
||||
x = window.innerWidth - menuWidth - 4;
|
||||
}
|
||||
if (y + menuHeight > window.innerHeight) {
|
||||
y = window.innerHeight - menuHeight - 4;
|
||||
}
|
||||
|
||||
position.value = { x, y };
|
||||
});
|
||||
}
|
||||
|
||||
function close(): void {
|
||||
visible.value = false;
|
||||
}
|
||||
|
||||
function handleClick(item: ContextMenuItem): void {
|
||||
if (item.action) {
|
||||
item.action();
|
||||
}
|
||||
close();
|
||||
}
|
||||
|
||||
defineExpose({ open, close, visible });
|
||||
</script>
|
||||
@ -1,247 +0,0 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="visible"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center"
|
||||
@click.self="close"
|
||||
@keydown.esc="close"
|
||||
>
|
||||
<!-- Backdrop -->
|
||||
<div class="absolute inset-0 bg-black/50" @click="close" />
|
||||
|
||||
<!-- Dialog -->
|
||||
<div class="relative w-full max-w-md bg-[#161b22] border border-[#30363d] rounded-lg shadow-2xl overflow-hidden">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b border-[#30363d]">
|
||||
<h3 class="text-sm font-semibold text-[var(--wraith-text-primary)]">Import Configuration</h3>
|
||||
<button
|
||||
class="text-[var(--wraith-text-muted)] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
|
||||
@click="close"
|
||||
>
|
||||
<svg class="w-4 h-4" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.749.749 0 1 1 1.06 1.06L9.06 8l3.22 3.22a.749.749 0 1 1-1.06 1.06L8 9.06l-3.22 3.22a.749.749 0 1 1-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="px-4 py-4">
|
||||
<!-- Step 1: File selection -->
|
||||
<template v-if="step === 'select'">
|
||||
<p class="text-sm text-[var(--wraith-text-secondary)] mb-4">
|
||||
Select a MobaXTerm <code class="text-[var(--wraith-accent-blue)]">.mobaconf</code> file to import connections and settings.
|
||||
</p>
|
||||
<div
|
||||
class="border-2 border-dashed border-[#30363d] rounded-lg p-8 text-center hover:border-[var(--wraith-accent-blue)] transition-colors cursor-pointer"
|
||||
@click="selectFile"
|
||||
@dragover.prevent
|
||||
@drop.prevent="handleDrop"
|
||||
>
|
||||
<svg class="w-8 h-8 mx-auto text-[var(--wraith-text-muted)] mb-3" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M2.75 14A1.75 1.75 0 0 1 1 12.25v-2.5a.75.75 0 0 1 1.5 0v2.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25v-2.5a.75.75 0 0 1 1.5 0v2.5A1.75 1.75 0 0 1 13.25 14ZM11.78 4.72a.749.749 0 1 1-1.06 1.06L8.75 3.81V9.5a.75.75 0 0 1-1.5 0V3.81L5.28 5.78a.749.749 0 1 1-1.06-1.06l3.25-3.25a.749.749 0 0 1 1.06 0l3.25 3.25Z" />
|
||||
</svg>
|
||||
<p class="text-sm text-[var(--wraith-text-secondary)]">
|
||||
Click to select or drag and drop
|
||||
</p>
|
||||
<p class="text-xs text-[var(--wraith-text-muted)] mt-1">
|
||||
Supports .mobaconf files
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept=".mobaconf"
|
||||
class="hidden"
|
||||
@change="handleFileSelect"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Step 2: Preview -->
|
||||
<template v-else-if="step === 'preview'">
|
||||
<div class="space-y-3">
|
||||
<p class="text-sm text-[var(--wraith-text-primary)] font-medium">
|
||||
{{ fileName }}
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div class="bg-[#0d1117] rounded-lg p-3 text-center">
|
||||
<div class="text-lg font-bold text-[var(--wraith-accent-blue)]">{{ preview.connections }}</div>
|
||||
<div class="text-[10px] text-[var(--wraith-text-muted)] mt-0.5">Connections</div>
|
||||
</div>
|
||||
<div class="bg-[#0d1117] rounded-lg p-3 text-center">
|
||||
<div class="text-lg font-bold text-[var(--wraith-accent-yellow)]">{{ preview.groups }}</div>
|
||||
<div class="text-[10px] text-[var(--wraith-text-muted)] mt-0.5">Groups</div>
|
||||
</div>
|
||||
<div class="bg-[#0d1117] rounded-lg p-3 text-center">
|
||||
<div class="text-lg font-bold text-[var(--wraith-accent-green)]">{{ preview.hostKeys }}</div>
|
||||
<div class="text-[10px] text-[var(--wraith-text-muted)] mt-0.5">Host Keys</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="preview.hasTheme" class="flex items-center gap-2 text-xs text-[var(--wraith-text-secondary)] bg-[#0d1117] rounded-lg px-3 py-2">
|
||||
<svg class="w-3.5 h-3.5 text-[var(--wraith-accent-green)]" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z" />
|
||||
</svg>
|
||||
Terminal theme included
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-[var(--wraith-text-muted)]">
|
||||
Passwords are not imported (MobaXTerm uses proprietary encryption).
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Step 3: Complete -->
|
||||
<template v-else-if="step === 'complete'">
|
||||
<div class="text-center py-4">
|
||||
<svg class="w-12 h-12 mx-auto text-[var(--wraith-accent-green)] mb-3" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M8 16A8 8 0 1 1 8 0a8 8 0 0 1 0 16Zm3.78-9.72a.751.751 0 0 0-.018-1.042.751.751 0 0 0-1.042-.018L6.75 9.19 5.28 7.72a.751.751 0 0 0-1.042.018.751.751 0 0 0-.018 1.042l2 2a.75.75 0 0 0 1.06 0l4.5-4.5Z" />
|
||||
</svg>
|
||||
<h4 class="text-sm font-semibold text-[var(--wraith-text-primary)] mb-1">Import Complete</h4>
|
||||
<p class="text-xs text-[var(--wraith-text-secondary)]">
|
||||
Successfully imported {{ preview.connections }} connections and {{ preview.groups }} groups.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-end gap-2 px-4 py-3 border-t border-[#30363d]">
|
||||
<button
|
||||
v-if="step !== 'complete'"
|
||||
class="px-3 py-1.5 text-xs text-[var(--wraith-text-secondary)] hover:text-[var(--wraith-text-primary)] rounded border border-[#30363d] hover:bg-[#30363d] transition-colors cursor-pointer"
|
||||
@click="close"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
v-if="step === 'preview'"
|
||||
class="px-3 py-1.5 text-xs text-white bg-[#238636] hover:bg-[#2ea043] rounded transition-colors cursor-pointer"
|
||||
@click="doImport"
|
||||
>
|
||||
Import
|
||||
</button>
|
||||
<button
|
||||
v-if="step === 'complete'"
|
||||
class="px-3 py-1.5 text-xs text-white bg-[#1f6feb] hover:bg-[#388bfd] rounded transition-colors cursor-pointer"
|
||||
@click="close"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
|
||||
type Step = "select" | "preview" | "complete";
|
||||
|
||||
const visible = ref(false);
|
||||
const step = ref<Step>("select");
|
||||
const fileName = ref("");
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const preview = ref({
|
||||
connections: 0,
|
||||
groups: 0,
|
||||
hostKeys: 0,
|
||||
hasTheme: false,
|
||||
});
|
||||
|
||||
function open(): void {
|
||||
visible.value = true;
|
||||
step.value = "select";
|
||||
fileName.value = "";
|
||||
}
|
||||
|
||||
function close(): void {
|
||||
visible.value = false;
|
||||
}
|
||||
|
||||
function selectFile(): void {
|
||||
fileInput.value?.click();
|
||||
}
|
||||
|
||||
function handleFileSelect(event: Event): void {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const file = target.files?.[0];
|
||||
if (file) {
|
||||
processFile(file);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDrop(event: DragEvent): void {
|
||||
const file = event.dataTransfer?.files?.[0];
|
||||
if (file) {
|
||||
processFile(file);
|
||||
}
|
||||
}
|
||||
|
||||
async function processFile(file: File): Promise<void> {
|
||||
fileName.value = file.name;
|
||||
|
||||
// TODO: Replace with Wails binding — ImporterService.Preview(fileData)
|
||||
// For now, read and mock-parse the file to show a preview
|
||||
const text = await file.text();
|
||||
|
||||
// Simple mock parse to count items
|
||||
const lines = text.split("\n");
|
||||
let groups = 0;
|
||||
let connections = 0;
|
||||
let hostKeys = 0;
|
||||
let hasTheme = false;
|
||||
let inBookmarks = false;
|
||||
let inHostKeys = false;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith("[Bookmarks")) {
|
||||
inBookmarks = true;
|
||||
inHostKeys = false;
|
||||
continue;
|
||||
}
|
||||
if (trimmed === "[SSH_Hostkeys]") {
|
||||
inBookmarks = false;
|
||||
inHostKeys = true;
|
||||
continue;
|
||||
}
|
||||
if (trimmed === "[Colors]") {
|
||||
hasTheme = true;
|
||||
inBookmarks = false;
|
||||
inHostKeys = false;
|
||||
continue;
|
||||
}
|
||||
if (trimmed.startsWith("[")) {
|
||||
inBookmarks = false;
|
||||
inHostKeys = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inBookmarks) {
|
||||
if (trimmed.startsWith("SubRep=") && trimmed !== "SubRep=") {
|
||||
groups++;
|
||||
} else if (trimmed.includes("#109#") || trimmed.includes("#91#")) {
|
||||
connections++;
|
||||
}
|
||||
}
|
||||
|
||||
if (inHostKeys && trimmed.includes("@") && trimmed.includes("=")) {
|
||||
hostKeys++;
|
||||
}
|
||||
}
|
||||
|
||||
preview.value = { connections, groups, hostKeys, hasTheme };
|
||||
step.value = "preview";
|
||||
}
|
||||
|
||||
function doImport(): void {
|
||||
// TODO: Replace with Wails binding — ImporterService.Import(fileData)
|
||||
// For now, just show success
|
||||
step.value = "complete";
|
||||
}
|
||||
|
||||
defineExpose({ open, close, visible });
|
||||
</script>
|
||||
@ -1,292 +0,0 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="visible"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center"
|
||||
@click.self="close"
|
||||
@keydown.esc="close"
|
||||
>
|
||||
<!-- Backdrop -->
|
||||
<div class="absolute inset-0 bg-black/50" @click="close" />
|
||||
|
||||
<!-- Dialog -->
|
||||
<div class="relative w-full max-w-md bg-[#161b22] border border-[#30363d] rounded-lg shadow-2xl overflow-hidden">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b border-[#30363d]">
|
||||
<h3 class="text-sm font-semibold text-[var(--wraith-text-primary)]">
|
||||
{{ isEditing ? "Edit Connection" : "New Connection" }}
|
||||
</h3>
|
||||
<button
|
||||
class="text-[var(--wraith-text-muted)] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
|
||||
@click="close"
|
||||
>
|
||||
<svg class="w-4 h-4" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.749.749 0 1 1 1.06 1.06L9.06 8l3.22 3.22a.749.749 0 1 1-1.06 1.06L8 9.06l-3.22 3.22a.749.749 0 1 1-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="px-4 py-4 space-y-3 max-h-[60vh] overflow-y-auto">
|
||||
<!-- Name -->
|
||||
<div>
|
||||
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1">Name</label>
|
||||
<input
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
placeholder="My Server"
|
||||
class="w-full px-3 py-2 text-sm rounded bg-[#0d1117] border border-[#30363d] text-[var(--wraith-text-primary)] placeholder-[var(--wraith-text-muted)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Hostname & Port -->
|
||||
<div class="flex gap-3">
|
||||
<div class="flex-1">
|
||||
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1">Hostname</label>
|
||||
<input
|
||||
v-model="form.hostname"
|
||||
type="text"
|
||||
placeholder="192.168.1.1"
|
||||
class="w-full px-3 py-2 text-sm rounded bg-[#0d1117] border border-[#30363d] text-[var(--wraith-text-primary)] placeholder-[var(--wraith-text-muted)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div class="w-24">
|
||||
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1">Port</label>
|
||||
<input
|
||||
v-model.number="form.port"
|
||||
type="number"
|
||||
class="w-full px-3 py-2 text-sm rounded bg-[#0d1117] border border-[#30363d] text-[var(--wraith-text-primary)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Protocol -->
|
||||
<div>
|
||||
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1">Protocol</label>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="flex-1 py-2 text-sm rounded border transition-colors cursor-pointer"
|
||||
:class="form.protocol === 'ssh'
|
||||
? 'bg-[#3fb950]/10 border-[#3fb950] text-[#3fb950]'
|
||||
: 'bg-[#0d1117] border-[#30363d] text-[var(--wraith-text-muted)] hover:border-[var(--wraith-text-secondary)]'
|
||||
"
|
||||
@click="setProtocol('ssh')"
|
||||
>
|
||||
SSH
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 py-2 text-sm rounded border transition-colors cursor-pointer"
|
||||
:class="form.protocol === 'rdp'
|
||||
? 'bg-[#1f6feb]/10 border-[#1f6feb] text-[#1f6feb]'
|
||||
: 'bg-[#0d1117] border-[#30363d] text-[var(--wraith-text-muted)] hover:border-[var(--wraith-text-secondary)]'
|
||||
"
|
||||
@click="setProtocol('rdp')"
|
||||
>
|
||||
RDP
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Group -->
|
||||
<div>
|
||||
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1">Group</label>
|
||||
<select
|
||||
v-model="form.groupId"
|
||||
class="w-full px-3 py-2 text-sm rounded bg-[#0d1117] border border-[#30363d] text-[var(--wraith-text-primary)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors cursor-pointer"
|
||||
>
|
||||
<option :value="null">No Group</option>
|
||||
<option v-for="group in connectionStore.groups" :key="group.id" :value="group.id">
|
||||
{{ group.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
<div>
|
||||
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1">Tags (comma-separated)</label>
|
||||
<input
|
||||
v-model="tagsInput"
|
||||
type="text"
|
||||
placeholder="Prod, Linux, Web"
|
||||
class="w-full px-3 py-2 text-sm rounded bg-[#0d1117] border border-[#30363d] text-[var(--wraith-text-primary)] placeholder-[var(--wraith-text-muted)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Color -->
|
||||
<div>
|
||||
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1">Color Label</label>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-for="color in colorOptions"
|
||||
:key="color.value"
|
||||
class="w-6 h-6 rounded-full border-2 transition-transform cursor-pointer hover:scale-110"
|
||||
:class="form.color === color.value ? 'border-white scale-110' : 'border-transparent'"
|
||||
:style="{ backgroundColor: color.hex }"
|
||||
:title="color.label"
|
||||
@click="form.color = color.value"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notes -->
|
||||
<div>
|
||||
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1">Notes</label>
|
||||
<textarea
|
||||
v-model="form.notes"
|
||||
rows="3"
|
||||
placeholder="Optional notes about this connection..."
|
||||
class="w-full px-3 py-2 text-sm rounded bg-[#0d1117] border border-[#30363d] text-[var(--wraith-text-primary)] placeholder-[var(--wraith-text-muted)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-end gap-2 px-4 py-3 border-t border-[#30363d]">
|
||||
<button
|
||||
class="px-3 py-1.5 text-xs text-[var(--wraith-text-secondary)] hover:text-[var(--wraith-text-primary)] rounded border border-[#30363d] hover:bg-[#30363d] transition-colors cursor-pointer"
|
||||
@click="close"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="px-3 py-1.5 text-xs text-white bg-[#238636] hover:bg-[#2ea043] rounded transition-colors cursor-pointer"
|
||||
:class="{ 'opacity-50 cursor-not-allowed': !isValid }"
|
||||
:disabled="!isValid"
|
||||
@click="save"
|
||||
>
|
||||
{{ isEditing ? "Save Changes" : "Create" }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from "vue";
|
||||
import { useConnectionStore, type Connection } from "@/stores/connection.store";
|
||||
|
||||
interface ConnectionForm {
|
||||
name: string;
|
||||
hostname: string;
|
||||
port: number;
|
||||
protocol: "ssh" | "rdp";
|
||||
groupId: number | null;
|
||||
color: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
const connectionStore = useConnectionStore();
|
||||
|
||||
const visible = ref(false);
|
||||
const isEditing = ref(false);
|
||||
const editingId = ref<number | null>(null);
|
||||
const tagsInput = ref("");
|
||||
|
||||
const form = ref<ConnectionForm>({
|
||||
name: "",
|
||||
hostname: "",
|
||||
port: 22,
|
||||
protocol: "ssh",
|
||||
groupId: null,
|
||||
color: "",
|
||||
notes: "",
|
||||
});
|
||||
|
||||
const colorOptions = [
|
||||
{ value: "", label: "None", hex: "#30363d" },
|
||||
{ value: "red", label: "Red", hex: "#f85149" },
|
||||
{ value: "orange", label: "Orange", hex: "#d29922" },
|
||||
{ value: "green", label: "Green", hex: "#3fb950" },
|
||||
{ value: "blue", label: "Blue", hex: "#58a6ff" },
|
||||
{ value: "purple", label: "Purple", hex: "#bc8cff" },
|
||||
{ value: "pink", label: "Pink", hex: "#f778ba" },
|
||||
];
|
||||
|
||||
const isValid = computed(() => {
|
||||
return form.value.name.trim() !== "" && form.value.hostname.trim() !== "" && form.value.port > 0;
|
||||
});
|
||||
|
||||
function setProtocol(protocol: "ssh" | "rdp"): void {
|
||||
form.value.protocol = protocol;
|
||||
if (protocol === "ssh" && form.value.port === 3389) {
|
||||
form.value.port = 22;
|
||||
} else if (protocol === "rdp" && form.value.port === 22) {
|
||||
form.value.port = 3389;
|
||||
}
|
||||
}
|
||||
|
||||
function openNew(groupId?: number): void {
|
||||
isEditing.value = false;
|
||||
editingId.value = null;
|
||||
form.value = {
|
||||
name: "",
|
||||
hostname: "",
|
||||
port: 22,
|
||||
protocol: "ssh",
|
||||
groupId: groupId ?? null,
|
||||
color: "",
|
||||
notes: "",
|
||||
};
|
||||
tagsInput.value = "";
|
||||
visible.value = true;
|
||||
}
|
||||
|
||||
function openEdit(conn: Connection): void {
|
||||
isEditing.value = true;
|
||||
editingId.value = conn.id;
|
||||
form.value = {
|
||||
name: conn.name,
|
||||
hostname: conn.hostname,
|
||||
port: conn.port,
|
||||
protocol: conn.protocol,
|
||||
groupId: conn.groupId,
|
||||
color: "",
|
||||
notes: "",
|
||||
};
|
||||
tagsInput.value = conn.tags?.join(", ") ?? "";
|
||||
visible.value = true;
|
||||
}
|
||||
|
||||
function close(): void {
|
||||
visible.value = false;
|
||||
}
|
||||
|
||||
function save(): void {
|
||||
if (!isValid.value) return;
|
||||
|
||||
const tags = tagsInput.value
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter((t) => t.length > 0);
|
||||
|
||||
if (isEditing.value && editingId.value !== null) {
|
||||
// TODO: Replace with Wails binding — ConnectionService.UpdateConnection(id, input)
|
||||
const conn = connectionStore.connections.find((c) => c.id === editingId.value);
|
||||
if (conn) {
|
||||
conn.name = form.value.name;
|
||||
conn.hostname = form.value.hostname;
|
||||
conn.port = form.value.port;
|
||||
conn.protocol = form.value.protocol;
|
||||
conn.groupId = form.value.groupId ?? conn.groupId;
|
||||
conn.tags = tags;
|
||||
}
|
||||
} else {
|
||||
// TODO: Replace with Wails binding — ConnectionService.CreateConnection(input)
|
||||
const newId = Math.max(...connectionStore.connections.map((c) => c.id), 0) + 1;
|
||||
connectionStore.connections.push({
|
||||
id: newId,
|
||||
name: form.value.name,
|
||||
hostname: form.value.hostname,
|
||||
port: form.value.port,
|
||||
protocol: form.value.protocol,
|
||||
groupId: form.value.groupId ?? 1,
|
||||
tags,
|
||||
});
|
||||
}
|
||||
|
||||
close();
|
||||
}
|
||||
|
||||
defineExpose({ openNew, openEdit, close, visible });
|
||||
</script>
|
||||
@ -1,382 +0,0 @@
|
||||
<template>
|
||||
<div class="rdp-container" ref="containerRef">
|
||||
<!-- Toolbar -->
|
||||
<div class="rdp-toolbar">
|
||||
<div class="rdp-toolbar-left">
|
||||
<span class="rdp-toolbar-label">RDP</span>
|
||||
<span class="rdp-toolbar-session">{{ sessionId }}</span>
|
||||
</div>
|
||||
<div class="rdp-toolbar-right">
|
||||
<button
|
||||
class="rdp-toolbar-btn"
|
||||
:class="{ active: keyboardGrabbed }"
|
||||
title="Toggle keyboard capture"
|
||||
@click="handleToggleKeyboard"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M1 4a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V4zm1 0v7h12V4H2z"/>
|
||||
<path d="M3 6h2v1H3V6zm3 0h2v1H6V6zm3 0h2v1H9V6zm3 0h1v1h-1V6zM3 8h1v1H3V8zm2 0h6v1H5V8zm7 0h1v1h-1V8z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="rdp-toolbar-btn"
|
||||
:class="{ active: clipboardSync }"
|
||||
title="Toggle clipboard sync"
|
||||
@click="handleToggleClipboard"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/>
|
||||
<path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="rdp-toolbar-btn"
|
||||
title="Fullscreen"
|
||||
@click="handleFullscreen"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M1.5 1a.5.5 0 0 0-.5.5v4a.5.5 0 0 1-1 0v-4A1.5 1.5 0 0 1 1.5 0h4a.5.5 0 0 1 0 1h-4zM10 .5a.5.5 0 0 1 .5-.5h4A1.5 1.5 0 0 1 16 1.5v4a.5.5 0 0 1-1 0v-4a.5.5 0 0 0-.5-.5h-4a.5.5 0 0 1-.5-.5zM.5 10a.5.5 0 0 1 .5.5v4a.5.5 0 0 0 .5.5h4a.5.5 0 0 1 0 1h-4A1.5 1.5 0 0 1 0 14.5v-4a.5.5 0 0 1 .5-.5zm15 0a.5.5 0 0 1 .5.5v4a1.5 1.5 0 0 1-1.5 1.5h-4a.5.5 0 0 1 0-1h4a.5.5 0 0 0 .5-.5v-4a.5.5 0 0 1 .5-.5z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Canvas -->
|
||||
<div class="rdp-canvas-wrapper" ref="canvasWrapper">
|
||||
<canvas
|
||||
ref="canvasRef"
|
||||
class="rdp-canvas"
|
||||
tabindex="0"
|
||||
@mousedown="handleMouseDown"
|
||||
@mouseup="handleMouseUp"
|
||||
@mousemove="handleMouseMove"
|
||||
@wheel.prevent="handleWheel"
|
||||
@contextmenu.prevent
|
||||
@keydown.prevent="handleKeyDown"
|
||||
@keyup.prevent="handleKeyUp"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Connection status overlay -->
|
||||
<div v-if="!connected" class="rdp-overlay">
|
||||
<div class="rdp-overlay-content">
|
||||
<div class="rdp-spinner" />
|
||||
<p>Connecting to RDP session...</p>
|
||||
<p class="rdp-overlay-sub">Session: {{ sessionId }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, watch } from "vue";
|
||||
import { useRdp, MouseFlag } from "@/composables/useRdp";
|
||||
|
||||
const props = defineProps<{
|
||||
sessionId: string;
|
||||
isActive: boolean;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}>();
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null);
|
||||
const canvasWrapper = ref<HTMLElement | null>(null);
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
||||
|
||||
const rdpWidth = props.width ?? 1920;
|
||||
const rdpHeight = props.height ?? 1080;
|
||||
|
||||
const {
|
||||
connected,
|
||||
keyboardGrabbed,
|
||||
clipboardSync,
|
||||
sendMouse,
|
||||
sendKey,
|
||||
startFrameLoop,
|
||||
stopFrameLoop,
|
||||
toggleKeyboardGrab,
|
||||
toggleClipboardSync,
|
||||
} = useRdp();
|
||||
|
||||
/**
|
||||
* Convert canvas-relative mouse coordinates to RDP coordinates,
|
||||
* accounting for CSS scaling of the canvas.
|
||||
*/
|
||||
function toRdpCoords(
|
||||
e: MouseEvent,
|
||||
): { x: number; y: number } | null {
|
||||
const canvas = canvasRef.value;
|
||||
if (!canvas) return null;
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scaleX = rdpWidth / rect.width;
|
||||
const scaleY = rdpHeight / rect.height;
|
||||
|
||||
return {
|
||||
x: Math.floor((e.clientX - rect.left) * scaleX),
|
||||
y: Math.floor((e.clientY - rect.top) * scaleY),
|
||||
};
|
||||
}
|
||||
|
||||
function handleMouseDown(e: MouseEvent): void {
|
||||
const coords = toRdpCoords(e);
|
||||
if (!coords) return;
|
||||
|
||||
let buttonFlag = 0;
|
||||
switch (e.button) {
|
||||
case 0:
|
||||
buttonFlag = MouseFlag.Button1;
|
||||
break;
|
||||
case 1:
|
||||
buttonFlag = MouseFlag.Button3;
|
||||
break; // middle
|
||||
case 2:
|
||||
buttonFlag = MouseFlag.Button2;
|
||||
break;
|
||||
}
|
||||
|
||||
sendMouse(
|
||||
props.sessionId,
|
||||
coords.x,
|
||||
coords.y,
|
||||
buttonFlag | MouseFlag.Down,
|
||||
);
|
||||
}
|
||||
|
||||
function handleMouseUp(e: MouseEvent): void {
|
||||
const coords = toRdpCoords(e);
|
||||
if (!coords) return;
|
||||
|
||||
let buttonFlag = 0;
|
||||
switch (e.button) {
|
||||
case 0:
|
||||
buttonFlag = MouseFlag.Button1;
|
||||
break;
|
||||
case 1:
|
||||
buttonFlag = MouseFlag.Button3;
|
||||
break;
|
||||
case 2:
|
||||
buttonFlag = MouseFlag.Button2;
|
||||
break;
|
||||
}
|
||||
|
||||
sendMouse(props.sessionId, coords.x, coords.y, buttonFlag);
|
||||
}
|
||||
|
||||
function handleMouseMove(e: MouseEvent): void {
|
||||
const coords = toRdpCoords(e);
|
||||
if (!coords) return;
|
||||
sendMouse(props.sessionId, coords.x, coords.y, MouseFlag.Move);
|
||||
}
|
||||
|
||||
function handleWheel(e: WheelEvent): void {
|
||||
const coords = toRdpCoords(e);
|
||||
if (!coords) return;
|
||||
|
||||
let flags = MouseFlag.Wheel;
|
||||
if (e.deltaY > 0) {
|
||||
flags |= MouseFlag.WheelNeg;
|
||||
}
|
||||
|
||||
sendMouse(props.sessionId, coords.x, coords.y, flags);
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent): void {
|
||||
if (!keyboardGrabbed.value) return;
|
||||
sendKey(props.sessionId, e.code, true);
|
||||
}
|
||||
|
||||
function handleKeyUp(e: KeyboardEvent): void {
|
||||
if (!keyboardGrabbed.value) return;
|
||||
sendKey(props.sessionId, e.code, false);
|
||||
}
|
||||
|
||||
function handleToggleKeyboard(): void {
|
||||
toggleKeyboardGrab();
|
||||
// Focus canvas when grabbing keyboard
|
||||
if (keyboardGrabbed.value && canvasRef.value) {
|
||||
canvasRef.value.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function handleToggleClipboard(): void {
|
||||
toggleClipboardSync();
|
||||
}
|
||||
|
||||
function handleFullscreen(): void {
|
||||
const wrapper = canvasWrapper.value;
|
||||
if (!wrapper) return;
|
||||
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen();
|
||||
} else {
|
||||
wrapper.requestFullscreen();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (canvasRef.value) {
|
||||
startFrameLoop(
|
||||
props.sessionId,
|
||||
canvasRef.value,
|
||||
rdpWidth,
|
||||
rdpHeight,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopFrameLoop();
|
||||
});
|
||||
|
||||
// Focus canvas when this tab becomes active
|
||||
watch(
|
||||
() => props.isActive,
|
||||
(active) => {
|
||||
if (active && keyboardGrabbed.value && canvasRef.value) {
|
||||
setTimeout(() => {
|
||||
canvasRef.value?.focus();
|
||||
}, 0);
|
||||
}
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.rdp-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--wraith-bg-primary, #0d1117);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.rdp-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 32px;
|
||||
padding: 0 8px;
|
||||
background: var(--wraith-bg-secondary, #161b22);
|
||||
border-bottom: 1px solid var(--wraith-border, #30363d);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rdp-toolbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.rdp-toolbar-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--wraith-accent, #58a6ff);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.rdp-toolbar-session {
|
||||
font-size: 11px;
|
||||
color: var(--wraith-text-muted, #484f58);
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rdp-toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.rdp-toolbar-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--wraith-text-secondary, #8b949e);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.rdp-toolbar-btn:hover {
|
||||
background: var(--wraith-bg-tertiary, #21262d);
|
||||
color: var(--wraith-text-primary, #e6edf3);
|
||||
}
|
||||
|
||||
.rdp-toolbar-btn.active {
|
||||
background: var(--wraith-accent, #58a6ff);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.rdp-canvas-wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.rdp-canvas {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
cursor: default;
|
||||
outline: none;
|
||||
image-rendering: auto;
|
||||
}
|
||||
|
||||
.rdp-canvas:focus {
|
||||
outline: 2px solid var(--wraith-accent, #58a6ff);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.rdp-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(13, 17, 23, 0.85);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.rdp-overlay-content {
|
||||
text-align: center;
|
||||
color: var(--wraith-text-secondary, #8b949e);
|
||||
}
|
||||
|
||||
.rdp-overlay-content p {
|
||||
margin: 8px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.rdp-overlay-sub {
|
||||
font-size: 12px !important;
|
||||
color: var(--wraith-text-muted, #484f58);
|
||||
}
|
||||
|
||||
.rdp-spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
margin: 0 auto 16px;
|
||||
border: 3px solid var(--wraith-border, #30363d);
|
||||
border-top-color: var(--wraith-accent, #58a6ff);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,61 +0,0 @@
|
||||
<template>
|
||||
<div class="flex-1 flex flex-col bg-[var(--wraith-bg-primary)] min-h-0 relative">
|
||||
<!-- Terminal views — v-show keeps them alive across tab switches -->
|
||||
<div
|
||||
v-for="session in sshSessions"
|
||||
:key="session.id"
|
||||
v-show="session.id === sessionStore.activeSessionId"
|
||||
class="absolute inset-0"
|
||||
>
|
||||
<TerminalView
|
||||
:session-id="session.id"
|
||||
:is-active="session.id === sessionStore.activeSessionId"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- RDP views — v-show keeps them alive across tab switches -->
|
||||
<div
|
||||
v-for="session in rdpSessions"
|
||||
:key="session.id"
|
||||
v-show="session.id === sessionStore.activeSessionId"
|
||||
class="absolute inset-0"
|
||||
>
|
||||
<RdpView
|
||||
:session-id="session.id"
|
||||
:is-active="session.id === sessionStore.activeSessionId"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- No session placeholder -->
|
||||
<div
|
||||
v-if="!sessionStore.activeSession"
|
||||
class="flex-1 flex items-center justify-center"
|
||||
>
|
||||
<div class="text-center">
|
||||
<p class="text-[var(--wraith-text-muted)] text-sm">
|
||||
No active session
|
||||
</p>
|
||||
<p class="text-[var(--wraith-text-muted)] text-xs mt-1">
|
||||
Double-click a connection to start a session
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useSessionStore } from "@/stores/session.store";
|
||||
import TerminalView from "@/components/terminal/TerminalView.vue";
|
||||
import RdpView from "@/components/rdp/RdpView.vue";
|
||||
|
||||
const sessionStore = useSessionStore();
|
||||
|
||||
const sshSessions = computed(() =>
|
||||
sessionStore.sessions.filter((s) => s.protocol === "ssh"),
|
||||
);
|
||||
|
||||
const rdpSessions = computed(() =>
|
||||
sessionStore.sessions.filter((s) => s.protocol === "rdp"),
|
||||
);
|
||||
</script>
|
||||
@ -1,113 +0,0 @@
|
||||
<template>
|
||||
<div class="flex items-center bg-[var(--wraith-bg-secondary)] border-b border-[var(--wraith-border)] h-9 shrink-0">
|
||||
<!-- Tabs -->
|
||||
<div class="flex items-center overflow-x-auto min-w-0">
|
||||
<button
|
||||
v-for="session in sessionStore.sessions"
|
||||
:key="session.id"
|
||||
class="group flex items-center gap-2 px-3 h-9 text-xs whitespace-nowrap border-r border-[var(--wraith-border)] transition-all duration-500 cursor-pointer shrink-0"
|
||||
:class="[
|
||||
session.id === sessionStore.activeSessionId
|
||||
? 'bg-[var(--wraith-bg-primary)] text-[var(--wraith-text-primary)] border-b-2 border-b-[var(--wraith-accent-blue)]'
|
||||
: 'text-[var(--wraith-text-muted)] hover:text-[var(--wraith-text-secondary)] hover:bg-[var(--wraith-bg-tertiary)]',
|
||||
isRootUser(session) ? 'border-t-2 border-t-[#f8514966]' : '',
|
||||
]"
|
||||
@click="sessionStore.activateSession(session.id)"
|
||||
>
|
||||
<!-- Protocol icon -->
|
||||
<span class="shrink-0">
|
||||
<!-- SSH terminal icon -->
|
||||
<svg
|
||||
v-if="session.protocol === 'ssh'"
|
||||
class="w-3.5 h-3.5 text-[#3fb950]"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M0 2.75C0 1.784.784 1 1.75 1h12.5c.966 0 1.75.784 1.75 1.75v10.5A1.75 1.75 0 0 1 14.25 15H1.75A1.75 1.75 0 0 1 0 13.25Zm1.75-.25a.25.25 0 0 0-.25.25v10.5c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25V2.75a.25.25 0 0 0-.25-.25ZM7.25 8a.749.749 0 0 1-.22.53l-2.25 2.25a.749.749 0 1 1-1.06-1.06L5.44 8 3.72 6.28a.749.749 0 1 1 1.06-1.06l2.25 2.25c.141.14.22.331.22.53Zm1.5 1.5h3a.75.75 0 0 1 0 1.5h-3a.75.75 0 0 1 0-1.5Z" />
|
||||
</svg>
|
||||
<!-- RDP monitor icon -->
|
||||
<svg
|
||||
v-else
|
||||
class="w-3.5 h-3.5 text-[#1f6feb]"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M1.75 2.5h12.5a.25.25 0 0 1 .25.25v7.5a.25.25 0 0 1-.25.25H1.75a.25.25 0 0 1-.25-.25v-7.5a.25.25 0 0 1 .25-.25ZM14.25 1H1.75A1.75 1.75 0 0 0 0 2.75v7.5C0 11.216.784 12 1.75 12h4.388l-.533 1.5H4a.75.75 0 0 0 0 1.5h8a.75.75 0 0 0 0-1.5h-1.605l-.533-1.5h4.388A1.75 1.75 0 0 0 16 10.25v-7.5A1.75 1.75 0 0 0 14.25 1ZM9.112 13.5H6.888l.533-1.5h1.158l.533 1.5Z" />
|
||||
</svg>
|
||||
</span>
|
||||
|
||||
<span>{{ session.name }}</span>
|
||||
|
||||
<!-- Environment tag badges -->
|
||||
<template v-if="getSessionTags(session).length > 0">
|
||||
<span
|
||||
v-for="tag in getSessionTags(session)"
|
||||
:key="tag"
|
||||
class="px-1 py-0.5 text-[9px] font-semibold rounded leading-none"
|
||||
:class="tagClass(tag)"
|
||||
>
|
||||
{{ tag }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!-- Close button -->
|
||||
<span
|
||||
class="ml-1 opacity-0 group-hover:opacity-100 hover:text-[var(--wraith-accent-red)] transition-opacity"
|
||||
@click.stop="sessionStore.closeSession(session.id)"
|
||||
>
|
||||
×
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- New tab button -->
|
||||
<button
|
||||
class="flex items-center justify-center w-9 h-9 text-[var(--wraith-text-muted)] hover:text-[var(--wraith-text-primary)] hover:bg-[var(--wraith-bg-tertiary)] transition-colors cursor-pointer shrink-0"
|
||||
title="New session"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useSessionStore, type Session } from "@/stores/session.store";
|
||||
import { useConnectionStore } from "@/stores/connection.store";
|
||||
|
||||
const sessionStore = useSessionStore();
|
||||
const connectionStore = useConnectionStore();
|
||||
|
||||
/** Get tags for a session's underlying connection. */
|
||||
function getSessionTags(session: Session): string[] {
|
||||
const conn = connectionStore.connections.find((c) => c.id === session.connectionId);
|
||||
return conn?.tags ?? [];
|
||||
}
|
||||
|
||||
/** Check if the connection for this session uses the root user. */
|
||||
function isRootUser(session: Session): boolean {
|
||||
const conn = connectionStore.connections.find((c) => c.id === session.connectionId);
|
||||
if (!conn) return false;
|
||||
// TODO: Get actual username from the credential or session
|
||||
// For now, check mock data — root user detection will come from the session/credential store
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Return Tailwind classes for environment tag badges. */
|
||||
function tagClass(tag: string): string {
|
||||
const t = tag.toUpperCase();
|
||||
if (t === "PROD" || t === "PRODUCTION") {
|
||||
return "bg-[#da3633]/20 text-[#f85149]";
|
||||
}
|
||||
if (t === "DEV" || t === "DEVELOPMENT") {
|
||||
return "bg-[#238636]/20 text-[#3fb950]";
|
||||
}
|
||||
if (t === "STAGING" || t === "STG") {
|
||||
return "bg-[#9e6a03]/20 text-[#d29922]";
|
||||
}
|
||||
if (t === "TEST" || t === "QA") {
|
||||
return "bg-[#1f6feb]/20 text-[#58a6ff]";
|
||||
}
|
||||
// Default for other tags
|
||||
return "bg-[var(--wraith-bg-tertiary)] text-[var(--wraith-text-muted)]";
|
||||
}
|
||||
</script>
|
||||
@ -1,165 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-full text-xs">
|
||||
<!-- Path bar -->
|
||||
<div class="flex items-center gap-1 px-3 py-1.5 border-b border-[var(--wraith-border)] bg-[var(--wraith-bg-tertiary)]">
|
||||
<button
|
||||
class="text-[var(--wraith-text-muted)] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer shrink-0"
|
||||
title="Go up"
|
||||
@click="goUp"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M3.22 9.78a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1-1.06 1.06L8 6.06 4.28 9.78a.75.75 0 0 1-1.06 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
<span class="text-[var(--wraith-text-secondary)] truncate font-mono text-[10px]">
|
||||
{{ currentPath }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="flex items-center gap-1 px-2 py-1 border-b border-[var(--wraith-border)]">
|
||||
<button
|
||||
class="p-1 text-[var(--wraith-text-muted)] hover:text-[var(--wraith-accent-blue)] transition-colors cursor-pointer rounded hover:bg-[var(--wraith-bg-tertiary)]"
|
||||
title="Upload file"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M2.75 14A1.75 1.75 0 0 1 1 12.25v-2.5a.75.75 0 0 1 1.5 0v2.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25v-2.5a.75.75 0 0 1 1.5 0v2.5A1.75 1.75 0 0 1 13.25 14H2.75z" />
|
||||
<path d="M11.78 4.72a.75.75 0 0 1-1.06 1.06L8.75 3.81V9.5a.75.75 0 0 1-1.5 0V3.81L5.28 5.78a.75.75 0 0 1-1.06-1.06l3.25-3.25a.75.75 0 0 1 1.06 0l3.25 3.25z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="p-1 text-[var(--wraith-text-muted)] hover:text-[var(--wraith-accent-blue)] transition-colors cursor-pointer rounded hover:bg-[var(--wraith-bg-tertiary)]"
|
||||
title="Download file"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M2.75 14A1.75 1.75 0 0 1 1 12.25v-2.5a.75.75 0 0 1 1.5 0v2.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25v-2.5a.75.75 0 0 1 1.5 0v2.5A1.75 1.75 0 0 1 13.25 14H2.75z" />
|
||||
<path d="M7.25 7.689V2a.75.75 0 0 1 1.5 0v5.689l1.97-1.969a.749.749 0 1 1 1.06 1.06l-3.25 3.25a.749.749 0 0 1-1.06 0L4.22 6.78a.749.749 0 1 1 1.06-1.06l1.97 1.969z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="p-1 text-[var(--wraith-text-muted)] hover:text-[var(--wraith-accent-yellow)] transition-colors cursor-pointer rounded hover:bg-[var(--wraith-bg-tertiary)]"
|
||||
title="New folder"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3H7.5a.25.25 0 0 1-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1H1.75zM8.75 8v1.75a.75.75 0 0 1-1.5 0V8H5.5a.75.75 0 0 1 0-1.5h1.75V4.75a.75.75 0 0 1 1.5 0V6.5h1.75a.75.75 0 0 1 0 1.5H8.75z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="p-1 text-[var(--wraith-text-muted)] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer rounded hover:bg-[var(--wraith-bg-tertiary)]"
|
||||
title="Refresh"
|
||||
@click="refresh"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M8 2.5a5.487 5.487 0 0 0-4.131 1.869l1.204 1.204A.25.25 0 0 1 4.896 6H1.25A.25.25 0 0 1 1 5.75V2.104a.25.25 0 0 1 .427-.177l1.38 1.38A7.001 7.001 0 0 1 14.95 7.16a.75.75 0 0 1-1.49.178A5.501 5.501 0 0 0 8 2.5zM1.705 8.005a.75.75 0 0 1 .834.656 5.501 5.501 0 0 0 9.592 2.97l-1.204-1.204a.25.25 0 0 1 .177-.427h3.646a.25.25 0 0 1 .25.25v3.646a.25.25 0 0 1-.427.177l-1.38-1.38A7.001 7.001 0 0 1 1.05 8.84a.75.75 0 0 1 .656-.834z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="p-1 text-[var(--wraith-text-muted)] hover:text-[var(--wraith-accent-red)] transition-colors cursor-pointer rounded hover:bg-[var(--wraith-bg-tertiary)]"
|
||||
title="Delete"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M11 1.75V3h2.25a.75.75 0 0 1 0 1.5H2.75a.75.75 0 0 1 0-1.5H5V1.75C5 .784 5.784 0 6.75 0h2.5C10.216 0 11 .784 11 1.75zM6.5 1.75v1.25h3V1.75a.25.25 0 0 0-.25-.25h-2.5a.25.25 0 0 0-.25.25zM4.997 6.178a.75.75 0 1 0-1.493.144l.685 7.107A2.25 2.25 0 0 0 6.427 15.5h3.146a2.25 2.25 0 0 0 2.238-2.071l.685-7.107a.75.75 0 1 0-1.493-.144l-.685 7.107a.75.75 0 0 1-.746.715H6.427a.75.75 0 0 1-.746-.715l-.684-7.107z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- File list -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<!-- Loading -->
|
||||
<div v-if="isLoading" class="flex items-center justify-center py-8">
|
||||
<span class="text-[var(--wraith-text-muted)]">Loading...</span>
|
||||
</div>
|
||||
|
||||
<!-- Empty -->
|
||||
<div v-else-if="entries.length === 0" class="flex items-center justify-center py-8">
|
||||
<span class="text-[var(--wraith-text-muted)]">Empty directory</span>
|
||||
</div>
|
||||
|
||||
<!-- Entries -->
|
||||
<template v-else>
|
||||
<button
|
||||
v-for="entry in entries"
|
||||
:key="entry.path"
|
||||
class="w-full flex items-center gap-2 px-3 py-1.5 hover:bg-[var(--wraith-bg-tertiary)] transition-colors cursor-pointer group"
|
||||
@dblclick="handleEntryDblClick(entry)"
|
||||
>
|
||||
<!-- Icon -->
|
||||
<svg
|
||||
v-if="entry.isDir"
|
||||
class="w-3.5 h-3.5 text-[var(--wraith-accent-yellow)] shrink-0"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3H7.5a.25.25 0 0 1-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1H1.75z" />
|
||||
</svg>
|
||||
<svg
|
||||
v-else
|
||||
class="w-3.5 h-3.5 text-[var(--wraith-text-muted)] shrink-0"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M3.75 1.5a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h8.5a.25.25 0 0 0 .25-.25V6H9.75A1.75 1.75 0 0 1 8 4.25V1.5H3.75zm5.75.56v2.19c0 .138.112.25.25.25h2.19L9.5 2.06zM2 1.75C2 .784 2.784 0 3.75 0h5.086c.464 0 .909.184 1.237.513l3.414 3.414c.329.328.513.773.513 1.237v8.086A1.75 1.75 0 0 1 12.25 15h-8.5A1.75 1.75 0 0 1 2 13.25V1.75z" />
|
||||
</svg>
|
||||
|
||||
<!-- Name -->
|
||||
<span class="text-[var(--wraith-text-primary)] truncate">{{ entry.name }}</span>
|
||||
|
||||
<!-- Size (files only) -->
|
||||
<span
|
||||
v-if="!entry.isDir"
|
||||
class="ml-auto text-[var(--wraith-text-muted)] text-[10px] shrink-0"
|
||||
>
|
||||
{{ humanizeSize(entry.size) }}
|
||||
</span>
|
||||
|
||||
<!-- Modified date -->
|
||||
<span class="text-[var(--wraith-text-muted)] text-[10px] shrink-0 w-[68px] text-right">
|
||||
{{ entry.modTime }}
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Follow terminal toggle -->
|
||||
<div class="flex items-center gap-2 px-3 py-1.5 border-t border-[var(--wraith-border)]">
|
||||
<label class="flex items-center gap-1.5 cursor-pointer text-[var(--wraith-text-muted)] hover:text-[var(--wraith-text-secondary)] transition-colors">
|
||||
<input
|
||||
v-model="followTerminal"
|
||||
type="checkbox"
|
||||
class="w-3 h-3 accent-[var(--wraith-accent-blue)] cursor-pointer"
|
||||
/>
|
||||
<span>Follow terminal folder</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useSftp, type FileEntry } from "@/composables/useSftp";
|
||||
|
||||
const props = defineProps<{
|
||||
sessionId: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
openFile: [entry: FileEntry];
|
||||
}>();
|
||||
|
||||
const { currentPath, entries, isLoading, followTerminal, navigateTo, goUp, refresh } = useSftp(props.sessionId);
|
||||
|
||||
function handleEntryDblClick(entry: FileEntry): void {
|
||||
if (entry.isDir) {
|
||||
navigateTo(entry.path);
|
||||
} else {
|
||||
emit("openFile", entry);
|
||||
}
|
||||
}
|
||||
|
||||
function humanizeSize(bytes: number): string {
|
||||
if (bytes === 0) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
const size = (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0);
|
||||
return `${size} ${units[i]}`;
|
||||
}
|
||||
</script>
|
||||
@ -1,45 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="terminal-container"
|
||||
@focus="handleFocus"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from "vue";
|
||||
import { useTerminal } from "@/composables/useTerminal";
|
||||
import "@/assets/css/terminal.css";
|
||||
|
||||
const props = defineProps<{
|
||||
sessionId: string;
|
||||
isActive: boolean;
|
||||
}>();
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null);
|
||||
const { terminal, mount, fit } = useTerminal(props.sessionId);
|
||||
|
||||
onMounted(() => {
|
||||
if (containerRef.value) {
|
||||
mount(containerRef.value);
|
||||
}
|
||||
});
|
||||
|
||||
// Re-fit and focus terminal when this tab becomes active
|
||||
watch(
|
||||
() => props.isActive,
|
||||
(active) => {
|
||||
if (active) {
|
||||
// nextTick is not needed — fit and focus happen after the DOM update
|
||||
setTimeout(() => {
|
||||
fit();
|
||||
terminal.focus();
|
||||
}, 0);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
function handleFocus(): void {
|
||||
terminal.focus();
|
||||
}
|
||||
</script>
|
||||
@ -1,379 +0,0 @@
|
||||
import { ref, onBeforeUnmount } from "vue";
|
||||
|
||||
/**
|
||||
* RDP mouse event flags — match the Go constants in internal/rdp/input.go
|
||||
*/
|
||||
export const MouseFlag = {
|
||||
Move: 0x0800,
|
||||
Button1: 0x1000, // Left
|
||||
Button2: 0x2000, // Right
|
||||
Button3: 0x4000, // Middle
|
||||
Down: 0x8000,
|
||||
Wheel: 0x0200,
|
||||
WheelNeg: 0x0100,
|
||||
HWheel: 0x0400,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* JavaScript KeyboardEvent.code → RDP scancode mapping.
|
||||
* Mirrors the Go ScancodeMap in internal/rdp/input.go.
|
||||
*/
|
||||
export const ScancodeMap: Record<string, number> = {
|
||||
// Row 0: Escape + Function keys
|
||||
Escape: 0x0001,
|
||||
F1: 0x003b,
|
||||
F2: 0x003c,
|
||||
F3: 0x003d,
|
||||
F4: 0x003e,
|
||||
F5: 0x003f,
|
||||
F6: 0x0040,
|
||||
F7: 0x0041,
|
||||
F8: 0x0042,
|
||||
F9: 0x0043,
|
||||
F10: 0x0044,
|
||||
F11: 0x0057,
|
||||
F12: 0x0058,
|
||||
|
||||
// Row 1: Number row
|
||||
Backquote: 0x0029,
|
||||
Digit1: 0x0002,
|
||||
Digit2: 0x0003,
|
||||
Digit3: 0x0004,
|
||||
Digit4: 0x0005,
|
||||
Digit5: 0x0006,
|
||||
Digit6: 0x0007,
|
||||
Digit7: 0x0008,
|
||||
Digit8: 0x0009,
|
||||
Digit9: 0x000a,
|
||||
Digit0: 0x000b,
|
||||
Minus: 0x000c,
|
||||
Equal: 0x000d,
|
||||
Backspace: 0x000e,
|
||||
|
||||
// Row 2: QWERTY
|
||||
Tab: 0x000f,
|
||||
KeyQ: 0x0010,
|
||||
KeyW: 0x0011,
|
||||
KeyE: 0x0012,
|
||||
KeyR: 0x0013,
|
||||
KeyT: 0x0014,
|
||||
KeyY: 0x0015,
|
||||
KeyU: 0x0016,
|
||||
KeyI: 0x0017,
|
||||
KeyO: 0x0018,
|
||||
KeyP: 0x0019,
|
||||
BracketLeft: 0x001a,
|
||||
BracketRight: 0x001b,
|
||||
Backslash: 0x002b,
|
||||
|
||||
// Row 3: Home row
|
||||
CapsLock: 0x003a,
|
||||
KeyA: 0x001e,
|
||||
KeyS: 0x001f,
|
||||
KeyD: 0x0020,
|
||||
KeyF: 0x0021,
|
||||
KeyG: 0x0022,
|
||||
KeyH: 0x0023,
|
||||
KeyJ: 0x0024,
|
||||
KeyK: 0x0025,
|
||||
KeyL: 0x0026,
|
||||
Semicolon: 0x0027,
|
||||
Quote: 0x0028,
|
||||
Enter: 0x001c,
|
||||
|
||||
// Row 4: Bottom row
|
||||
ShiftLeft: 0x002a,
|
||||
KeyZ: 0x002c,
|
||||
KeyX: 0x002d,
|
||||
KeyC: 0x002e,
|
||||
KeyV: 0x002f,
|
||||
KeyB: 0x0030,
|
||||
KeyN: 0x0031,
|
||||
KeyM: 0x0032,
|
||||
Comma: 0x0033,
|
||||
Period: 0x0034,
|
||||
Slash: 0x0035,
|
||||
ShiftRight: 0x0036,
|
||||
|
||||
// Row 5: Bottom modifiers + space
|
||||
ControlLeft: 0x001d,
|
||||
MetaLeft: 0xe05b,
|
||||
AltLeft: 0x0038,
|
||||
Space: 0x0039,
|
||||
AltRight: 0xe038,
|
||||
MetaRight: 0xe05c,
|
||||
ContextMenu: 0xe05d,
|
||||
ControlRight: 0xe01d,
|
||||
|
||||
// Navigation cluster
|
||||
PrintScreen: 0xe037,
|
||||
ScrollLock: 0x0046,
|
||||
Pause: 0x0045,
|
||||
Insert: 0xe052,
|
||||
Home: 0xe047,
|
||||
PageUp: 0xe049,
|
||||
Delete: 0xe053,
|
||||
End: 0xe04f,
|
||||
PageDown: 0xe051,
|
||||
|
||||
// Arrow keys
|
||||
ArrowUp: 0xe048,
|
||||
ArrowLeft: 0xe04b,
|
||||
ArrowDown: 0xe050,
|
||||
ArrowRight: 0xe04d,
|
||||
|
||||
// Numpad
|
||||
NumLock: 0x0045,
|
||||
NumpadDivide: 0xe035,
|
||||
NumpadMultiply: 0x0037,
|
||||
NumpadSubtract: 0x004a,
|
||||
Numpad7: 0x0047,
|
||||
Numpad8: 0x0048,
|
||||
Numpad9: 0x0049,
|
||||
NumpadAdd: 0x004e,
|
||||
Numpad4: 0x004b,
|
||||
Numpad5: 0x004c,
|
||||
Numpad6: 0x004d,
|
||||
Numpad1: 0x004f,
|
||||
Numpad2: 0x0050,
|
||||
Numpad3: 0x0051,
|
||||
NumpadEnter: 0xe01c,
|
||||
Numpad0: 0x0052,
|
||||
NumpadDecimal: 0x0053,
|
||||
};
|
||||
|
||||
/**
|
||||
* Look up the RDP scancode for a JS KeyboardEvent.code string.
|
||||
*/
|
||||
export function jsKeyToScancode(code: string): number | null {
|
||||
return ScancodeMap[code] ?? null;
|
||||
}
|
||||
|
||||
export interface UseRdpReturn {
|
||||
/** Whether the RDP session is connected (mock: always true after init) */
|
||||
connected: ReturnType<typeof ref<boolean>>;
|
||||
/** Whether keyboard capture is enabled */
|
||||
keyboardGrabbed: ReturnType<typeof ref<boolean>>;
|
||||
/** Whether clipboard sync is enabled */
|
||||
clipboardSync: ReturnType<typeof ref<boolean>>;
|
||||
/** Fetch the current frame as RGBA ImageData */
|
||||
fetchFrame: (sessionId: string) => Promise<ImageData | null>;
|
||||
/** Send a mouse event to the backend */
|
||||
sendMouse: (
|
||||
sessionId: string,
|
||||
x: number,
|
||||
y: number,
|
||||
flags: number,
|
||||
) => void;
|
||||
/** Send a key event to the backend */
|
||||
sendKey: (sessionId: string, code: string, pressed: boolean) => void;
|
||||
/** Send clipboard text to the remote session */
|
||||
sendClipboard: (sessionId: string, text: string) => void;
|
||||
/** Start the frame rendering loop */
|
||||
startFrameLoop: (
|
||||
sessionId: string,
|
||||
canvas: HTMLCanvasElement,
|
||||
width: number,
|
||||
height: number,
|
||||
) => void;
|
||||
/** Stop the frame rendering loop */
|
||||
stopFrameLoop: () => void;
|
||||
/** Toggle keyboard grab */
|
||||
toggleKeyboardGrab: () => void;
|
||||
/** Toggle clipboard sync */
|
||||
toggleClipboardSync: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable that manages an RDP session's rendering and input.
|
||||
*
|
||||
* All backend calls are currently stubs that will be replaced with
|
||||
* Wails bindings once the Go RDP service is wired up.
|
||||
*/
|
||||
export function useRdp(): UseRdpReturn {
|
||||
const connected = ref(false);
|
||||
const keyboardGrabbed = ref(false);
|
||||
const clipboardSync = ref(false);
|
||||
|
||||
let animFrameId: number | null = null;
|
||||
let frameCount = 0;
|
||||
|
||||
/**
|
||||
* Fetch the current frame from the backend.
|
||||
* TODO: Replace with Wails binding — RDPService.GetFrame(sessionId)
|
||||
* Mock: generates a gradient test pattern.
|
||||
*/
|
||||
async function fetchFrame(
|
||||
sessionId: string,
|
||||
width = 1920,
|
||||
height = 1080,
|
||||
): Promise<ImageData | null> {
|
||||
void sessionId;
|
||||
|
||||
// Mock: generate a test frame with animated gradient
|
||||
const imageData = new ImageData(width, height);
|
||||
const data = imageData.data;
|
||||
const t = Date.now() / 1000;
|
||||
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const i = (y * width + x) * 4;
|
||||
const nx = x / width;
|
||||
const ny = y / height;
|
||||
const diag = (nx + ny) / 2;
|
||||
|
||||
data[i + 0] = Math.floor(20 + diag * 40); // R
|
||||
data[i + 1] = Math.floor(25 + (1 - diag) * 30); // G
|
||||
data[i + 2] = Math.floor(80 + diag * 100); // B
|
||||
data[i + 3] = 255; // A
|
||||
|
||||
// Grid lines every 100px
|
||||
if (x % 100 === 0 || y % 100 === 0) {
|
||||
data[i + 0] = Math.min(data[i + 0] + 20, 255);
|
||||
data[i + 1] = Math.min(data[i + 1] + 20, 255);
|
||||
data[i + 2] = Math.min(data[i + 2] + 20, 255);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Animated pulsing circle at center
|
||||
const cx = width / 2;
|
||||
const cy = height / 2;
|
||||
const radius = 40 + 20 * Math.sin(t * 2);
|
||||
|
||||
for (let dy = -70; dy <= 70; dy++) {
|
||||
for (let dx = -70; dx <= 70; dx++) {
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
if (dist <= radius && dist >= radius - 4) {
|
||||
const px = Math.floor(cx + dx);
|
||||
const py = Math.floor(cy + dy);
|
||||
if (px >= 0 && px < width && py >= 0 && py < height) {
|
||||
const i = (py * width + px) * 4;
|
||||
data[i + 0] = 88;
|
||||
data[i + 1] = 166;
|
||||
data[i + 2] = 255;
|
||||
data[i + 3] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return imageData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a mouse event.
|
||||
* TODO: Replace with Wails binding — RDPService.SendMouse(sessionId, x, y, flags)
|
||||
*/
|
||||
function sendMouse(
|
||||
sessionId: string,
|
||||
x: number,
|
||||
y: number,
|
||||
flags: number,
|
||||
): void {
|
||||
void sessionId;
|
||||
void x;
|
||||
void y;
|
||||
void flags;
|
||||
// Mock: no-op — will call Wails binding when wired
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a key event, mapping JS code to RDP scancode.
|
||||
* TODO: Replace with Wails binding — RDPService.SendKey(sessionId, scancode, pressed)
|
||||
*/
|
||||
function sendKey(
|
||||
sessionId: string,
|
||||
code: string,
|
||||
pressed: boolean,
|
||||
): void {
|
||||
const scancode = jsKeyToScancode(code);
|
||||
if (scancode === null) return;
|
||||
|
||||
void sessionId;
|
||||
void pressed;
|
||||
// Mock: no-op — will call Wails binding when wired
|
||||
}
|
||||
|
||||
/**
|
||||
* Send clipboard text to the remote session.
|
||||
* TODO: Replace with Wails binding — RDPService.SendClipboard(sessionId, text)
|
||||
*/
|
||||
function sendClipboard(sessionId: string, text: string): void {
|
||||
void sessionId;
|
||||
void text;
|
||||
// Mock: no-op
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the rendering loop. Fetches frames and draws them on the canvas
|
||||
* using requestAnimationFrame.
|
||||
*/
|
||||
function startFrameLoop(
|
||||
sessionId: string,
|
||||
canvas: HTMLCanvasElement,
|
||||
width: number,
|
||||
height: number,
|
||||
): void {
|
||||
connected.value = true;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
function renderLoop(): void {
|
||||
frameCount++;
|
||||
|
||||
// Throttle to ~30fps (skip every other frame at 60fps rAF)
|
||||
if (frameCount % 2 === 0) {
|
||||
fetchFrame(sessionId, width, height).then((imageData) => {
|
||||
if (imageData && ctx) {
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
animFrameId = requestAnimationFrame(renderLoop);
|
||||
}
|
||||
|
||||
animFrameId = requestAnimationFrame(renderLoop);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the rendering loop.
|
||||
*/
|
||||
function stopFrameLoop(): void {
|
||||
if (animFrameId !== null) {
|
||||
cancelAnimationFrame(animFrameId);
|
||||
animFrameId = null;
|
||||
}
|
||||
connected.value = false;
|
||||
}
|
||||
|
||||
function toggleKeyboardGrab(): void {
|
||||
keyboardGrabbed.value = !keyboardGrabbed.value;
|
||||
}
|
||||
|
||||
function toggleClipboardSync(): void {
|
||||
clipboardSync.value = !clipboardSync.value;
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopFrameLoop();
|
||||
});
|
||||
|
||||
return {
|
||||
connected,
|
||||
keyboardGrabbed,
|
||||
clipboardSync,
|
||||
fetchFrame,
|
||||
sendMouse,
|
||||
sendKey,
|
||||
sendClipboard,
|
||||
startFrameLoop,
|
||||
stopFrameLoop,
|
||||
toggleKeyboardGrab,
|
||||
toggleClipboardSync,
|
||||
};
|
||||
}
|
||||
@ -1,100 +0,0 @@
|
||||
import { ref, type Ref } from "vue";
|
||||
|
||||
export interface FileEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
size: number;
|
||||
isDir: boolean;
|
||||
permissions: string;
|
||||
modTime: string;
|
||||
}
|
||||
|
||||
export interface UseSftpReturn {
|
||||
currentPath: Ref<string>;
|
||||
entries: Ref<FileEntry[]>;
|
||||
isLoading: Ref<boolean>;
|
||||
followTerminal: Ref<boolean>;
|
||||
navigateTo: (path: string) => Promise<void>;
|
||||
goUp: () => Promise<void>;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** Mock directory listings used until Wails SFTP bindings are connected. */
|
||||
const mockDirectories: Record<string, FileEntry[]> = {
|
||||
"/home/user": [
|
||||
{ name: "docs", path: "/home/user/docs", size: 0, isDir: true, permissions: "drwxr-xr-x", modTime: "2026-03-17" },
|
||||
{ name: "projects", path: "/home/user/projects", size: 0, isDir: true, permissions: "drwxr-xr-x", modTime: "2026-03-16" },
|
||||
{ name: ".ssh", path: "/home/user/.ssh", size: 0, isDir: true, permissions: "drwx------", modTime: "2026-03-10" },
|
||||
{ name: ".bashrc", path: "/home/user/.bashrc", size: 3771, isDir: false, permissions: "-rw-r--r--", modTime: "2026-03-15" },
|
||||
{ name: "deploy.sh", path: "/home/user/deploy.sh", size: 1024, isDir: false, permissions: "-rwxr-xr-x", modTime: "2026-03-16" },
|
||||
{ name: ".profile", path: "/home/user/.profile", size: 807, isDir: false, permissions: "-rw-r--r--", modTime: "2026-03-10" },
|
||||
],
|
||||
"/home/user/docs": [
|
||||
{ name: "readme.md", path: "/home/user/docs/readme.md", size: 2048, isDir: false, permissions: "-rw-r--r--", modTime: "2026-03-17" },
|
||||
{ name: "notes.txt", path: "/home/user/docs/notes.txt", size: 512, isDir: false, permissions: "-rw-r--r--", modTime: "2026-03-14" },
|
||||
],
|
||||
"/home/user/projects": [
|
||||
{ name: "app", path: "/home/user/projects/app", size: 0, isDir: true, permissions: "drwxr-xr-x", modTime: "2026-03-16" },
|
||||
{ name: "Makefile", path: "/home/user/projects/Makefile", size: 256, isDir: false, permissions: "-rw-r--r--", modTime: "2026-03-16" },
|
||||
],
|
||||
"/home/user/.ssh": [
|
||||
{ name: "authorized_keys", path: "/home/user/.ssh/authorized_keys", size: 743, isDir: false, permissions: "-rw-------", modTime: "2026-03-10" },
|
||||
{ name: "config", path: "/home/user/.ssh/config", size: 128, isDir: false, permissions: "-rw-------", modTime: "2026-03-10" },
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Composable that manages SFTP file browsing state.
|
||||
*
|
||||
* Uses mock data until Wails SFTPService bindings are connected.
|
||||
*/
|
||||
export function useSftp(_sessionId: string): UseSftpReturn {
|
||||
const currentPath = ref("/home/user");
|
||||
const entries = ref<FileEntry[]>([]);
|
||||
const isLoading = ref(false);
|
||||
const followTerminal = ref(false);
|
||||
|
||||
async function listDirectory(path: string): Promise<FileEntry[]> {
|
||||
// TODO: Replace with Wails binding call — SFTPService.List(sessionId, path)
|
||||
// Simulate network delay
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
return mockDirectories[path] ?? [];
|
||||
}
|
||||
|
||||
async function navigateTo(path: string): Promise<void> {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
currentPath.value = path;
|
||||
entries.value = await listDirectory(path);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function goUp(): Promise<void> {
|
||||
const parts = currentPath.value.split("/").filter(Boolean);
|
||||
if (parts.length <= 1) {
|
||||
await navigateTo("/");
|
||||
return;
|
||||
}
|
||||
parts.pop();
|
||||
await navigateTo("/" + parts.join("/"));
|
||||
}
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
await navigateTo(currentPath.value);
|
||||
}
|
||||
|
||||
// Load initial directory
|
||||
navigateTo(currentPath.value);
|
||||
|
||||
return {
|
||||
currentPath,
|
||||
entries,
|
||||
isLoading,
|
||||
followTerminal,
|
||||
navigateTo,
|
||||
goUp,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
@ -1,123 +0,0 @@
|
||||
import { ref, onBeforeUnmount } from "vue";
|
||||
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 "@xterm/xterm/css/xterm.css";
|
||||
|
||||
/** MobaXTerm Classic–inspired terminal theme colors. */
|
||||
const defaultTheme = {
|
||||
background: "#0d1117",
|
||||
foreground: "#e0e0e0",
|
||||
cursor: "#58a6ff",
|
||||
cursorAccent: "#0d1117",
|
||||
selectionBackground: "rgba(88, 166, 255, 0.3)",
|
||||
selectionForeground: "#ffffff",
|
||||
black: "#0d1117",
|
||||
red: "#f85149",
|
||||
green: "#3fb950",
|
||||
yellow: "#e3b341",
|
||||
blue: "#58a6ff",
|
||||
magenta: "#bc8cff",
|
||||
cyan: "#39c5cf",
|
||||
white: "#e0e0e0",
|
||||
brightBlack: "#484f58",
|
||||
brightRed: "#ff7b72",
|
||||
brightGreen: "#56d364",
|
||||
brightYellow: "#e3b341",
|
||||
brightBlue: "#79c0ff",
|
||||
brightMagenta: "#d2a8ff",
|
||||
brightCyan: "#56d4dd",
|
||||
brightWhite: "#f0f6fc",
|
||||
};
|
||||
|
||||
export interface UseTerminalReturn {
|
||||
terminal: Terminal;
|
||||
fitAddon: FitAddon;
|
||||
mount: (container: HTMLElement) => void;
|
||||
destroy: () => void;
|
||||
write: (data: string) => void;
|
||||
fit: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable that manages an xterm.js Terminal lifecycle.
|
||||
*
|
||||
* Creates the terminal with fit, search, and web-links addons.
|
||||
* Data input and resize events are wired as TODOs for Wails bindings.
|
||||
*/
|
||||
export function useTerminal(sessionId: string): UseTerminalReturn {
|
||||
const fitAddon = new FitAddon();
|
||||
const searchAddon = new SearchAddon();
|
||||
const webLinksAddon = new WebLinksAddon();
|
||||
|
||||
const terminal = new Terminal({
|
||||
theme: defaultTheme,
|
||||
fontFamily: "'JetBrains Mono', 'Cascadia Code', 'Fira Code', Menlo, Monaco, monospace",
|
||||
fontSize: 14,
|
||||
lineHeight: 1.2,
|
||||
cursorBlink: true,
|
||||
cursorStyle: "block",
|
||||
scrollback: 10000,
|
||||
allowProposedApi: true,
|
||||
convertEol: true,
|
||||
});
|
||||
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.loadAddon(searchAddon);
|
||||
terminal.loadAddon(webLinksAddon);
|
||||
|
||||
// Capture typed data and forward to the SSH backend
|
||||
terminal.onData((_data: string) => {
|
||||
// TODO: Replace with Wails binding call — SSHService.Write(sessionId, data)
|
||||
// For now, echo typed data back to the terminal for visual feedback
|
||||
void sessionId;
|
||||
});
|
||||
|
||||
// Handle terminal resize events
|
||||
terminal.onResize((_size: { cols: number; rows: number }) => {
|
||||
// TODO: Replace with Wails binding call — SSHService.Resize(sessionId, cols, rows)
|
||||
void sessionId;
|
||||
});
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
function mount(container: HTMLElement): void {
|
||||
terminal.open(container);
|
||||
fitAddon.fit();
|
||||
|
||||
// Auto-fit when the container resizes
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
fitAddon.fit();
|
||||
});
|
||||
resizeObserver.observe(container);
|
||||
|
||||
// Write a placeholder welcome message (mock — replaced by real SSH output)
|
||||
terminal.writeln("\x1b[1;34m Wraith Terminal\x1b[0m");
|
||||
terminal.writeln("\x1b[90m Session: " + sessionId + "\x1b[0m");
|
||||
terminal.writeln("\x1b[90m Waiting for SSH connection...\x1b[0m");
|
||||
terminal.writeln("");
|
||||
}
|
||||
|
||||
function destroy(): void {
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect();
|
||||
resizeObserver = null;
|
||||
}
|
||||
terminal.dispose();
|
||||
}
|
||||
|
||||
function write(data: string): void {
|
||||
terminal.write(data);
|
||||
}
|
||||
|
||||
function fit(): void {
|
||||
fitAddon.fit();
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
destroy();
|
||||
});
|
||||
|
||||
return { terminal, fitAddon, mount, destroy, write, fit };
|
||||
}
|
||||
7
frontend/src/env.d.ts
vendored
7
frontend/src/env.d.ts
vendored
@ -1,7 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module "*.vue" {
|
||||
import type { DefineComponent } from "vue";
|
||||
const component: DefineComponent<object, object, unknown>;
|
||||
export default component;
|
||||
}
|
||||
@ -1,264 +0,0 @@
|
||||
<template>
|
||||
<div class="h-screen w-screen flex flex-col overflow-hidden">
|
||||
<!-- Toolbar -->
|
||||
<div
|
||||
class="h-10 flex items-center justify-between px-4 bg-[var(--wraith-bg-secondary)] border-b border-[var(--wraith-border)] shrink-0"
|
||||
style="--wails-draggable: drag"
|
||||
>
|
||||
<span class="text-sm font-bold tracking-widest text-[var(--wraith-accent-blue)]">
|
||||
WRAITH
|
||||
</span>
|
||||
|
||||
<!-- Quick Connect -->
|
||||
<div class="flex-1 max-w-xs mx-4" style="--wails-draggable: no-drag">
|
||||
<input
|
||||
v-model="quickConnectInput"
|
||||
type="text"
|
||||
placeholder="Quick connect: user@host:port"
|
||||
class="w-full px-2.5 py-1 text-xs rounded bg-[var(--wraith-bg-tertiary)] border border-[var(--wraith-border)] text-[var(--wraith-text-primary)] placeholder-[var(--wraith-text-muted)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors"
|
||||
@keydown.enter="handleQuickConnect"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 text-xs text-[var(--wraith-text-secondary)]" style="--wails-draggable: no-drag">
|
||||
<span>{{ sessionStore.sessionCount }} session{{ sessionStore.sessionCount !== 1 ? "s" : "" }}</span>
|
||||
<button
|
||||
class="hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
|
||||
title="Command palette (Ctrl+K)"
|
||||
@click="commandPalette?.toggle()"
|
||||
>
|
||||
<svg class="w-4 h-4" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M11.5 7a4.5 4.5 0 1 1-9 0 4.5 4.5 0 0 1 9 0zm-.82 4.74a6 6 0 1 1 1.06-1.06l3.04 3.04a.75.75 0 1 1-1.06 1.06l-3.04-3.04z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
|
||||
title="Lock vault"
|
||||
@click="appStore.lock()"
|
||||
>
|
||||
🔒
|
||||
</button>
|
||||
<button
|
||||
class="hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
|
||||
title="Settings"
|
||||
>
|
||||
⚙
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main content area -->
|
||||
<div class="flex flex-1 min-h-0">
|
||||
<!-- Sidebar -->
|
||||
<div
|
||||
class="flex flex-col bg-[var(--wraith-bg-secondary)] border-r border-[var(--wraith-border)] shrink-0"
|
||||
:style="{ width: sidebarWidth + 'px' }"
|
||||
>
|
||||
<SidebarToggle v-model="sidebarTab" />
|
||||
|
||||
<!-- Search (connections mode only) -->
|
||||
<div v-if="sidebarTab === 'connections'" class="px-3 py-2">
|
||||
<input
|
||||
v-model="connectionStore.searchQuery"
|
||||
type="text"
|
||||
placeholder="Search connections..."
|
||||
class="w-full px-2.5 py-1.5 text-xs rounded bg-[var(--wraith-bg-tertiary)] border border-[var(--wraith-border)] text-[var(--wraith-text-primary)] placeholder-[var(--wraith-text-muted)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar content -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<!-- Connection tree -->
|
||||
<ConnectionTree v-if="sidebarTab === 'connections'" />
|
||||
|
||||
<!-- SFTP file tree -->
|
||||
<template v-else-if="sidebarTab === 'sftp'">
|
||||
<FileTree
|
||||
v-if="sessionStore.activeSession && sessionStore.activeSession.protocol === 'ssh'"
|
||||
:session-id="sessionStore.activeSession.id"
|
||||
@open-file="handleOpenFile"
|
||||
/>
|
||||
<div v-else class="flex items-center justify-center py-8 px-3">
|
||||
<p class="text-[var(--wraith-text-muted)] text-xs text-center">
|
||||
Connect to an SSH session to browse files
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Transfer progress (SFTP mode only) -->
|
||||
<TransferProgress v-if="sidebarTab === 'sftp'" />
|
||||
</div>
|
||||
|
||||
<!-- Content area -->
|
||||
<div class="flex-1 flex flex-col min-w-0">
|
||||
<!-- Tab bar -->
|
||||
<TabBar />
|
||||
|
||||
<!-- Editor panel (if a file is open) -->
|
||||
<EditorWindow
|
||||
v-if="editorFile"
|
||||
:content="editorFile.content"
|
||||
:file-path="editorFile.path"
|
||||
:session-id="editorFile.sessionId"
|
||||
@close="editorFile = null"
|
||||
/>
|
||||
|
||||
<!-- Session area -->
|
||||
<SessionContainer />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status bar -->
|
||||
<StatusBar ref="statusBar" @open-theme-picker="themePicker?.open()" />
|
||||
|
||||
<!-- Command Palette (Ctrl+K) -->
|
||||
<CommandPalette ref="commandPalette" @open-import="importDialog?.open()" />
|
||||
|
||||
<!-- Theme Picker -->
|
||||
<ThemePicker ref="themePicker" @select="handleThemeSelect" />
|
||||
|
||||
<!-- Import Dialog -->
|
||||
<ImportDialog ref="importDialog" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from "vue";
|
||||
import { useAppStore } from "@/stores/app.store";
|
||||
import { useConnectionStore } from "@/stores/connection.store";
|
||||
import { useSessionStore } from "@/stores/session.store";
|
||||
import SidebarToggle from "@/components/sidebar/SidebarToggle.vue";
|
||||
import ConnectionTree from "@/components/sidebar/ConnectionTree.vue";
|
||||
import FileTree from "@/components/sftp/FileTree.vue";
|
||||
import TransferProgress from "@/components/sftp/TransferProgress.vue";
|
||||
import TabBar from "@/components/session/TabBar.vue";
|
||||
import SessionContainer from "@/components/session/SessionContainer.vue";
|
||||
import StatusBar from "@/components/common/StatusBar.vue";
|
||||
import EditorWindow from "@/components/editor/EditorWindow.vue";
|
||||
import CommandPalette from "@/components/common/CommandPalette.vue";
|
||||
import ThemePicker from "@/components/common/ThemePicker.vue";
|
||||
import ImportDialog from "@/components/common/ImportDialog.vue";
|
||||
import type { ThemeDefinition } from "@/components/common/ThemePicker.vue";
|
||||
import type { SidebarTab } from "@/components/sidebar/SidebarToggle.vue";
|
||||
import type { FileEntry } from "@/composables/useSftp";
|
||||
|
||||
const appStore = useAppStore();
|
||||
const connectionStore = useConnectionStore();
|
||||
const sessionStore = useSessionStore();
|
||||
|
||||
const sidebarWidth = ref(240);
|
||||
const sidebarTab = ref<SidebarTab>("connections");
|
||||
const quickConnectInput = ref("");
|
||||
|
||||
/** Currently open file in the editor panel (null = no file open). */
|
||||
const editorFile = ref<{ content: string; path: string; sessionId: string } | null>(null);
|
||||
|
||||
const commandPalette = ref<InstanceType<typeof CommandPalette> | null>(null);
|
||||
const themePicker = ref<InstanceType<typeof ThemePicker> | null>(null);
|
||||
const importDialog = ref<InstanceType<typeof ImportDialog> | null>(null);
|
||||
const statusBar = ref<InstanceType<typeof StatusBar> | null>(null);
|
||||
|
||||
/** Handle file open from SFTP sidebar -- loads mock content for now. */
|
||||
function handleOpenFile(entry: FileEntry): void {
|
||||
if (!sessionStore.activeSession) return;
|
||||
|
||||
// TODO: Replace with Wails binding call -- SFTPService.ReadFile(sessionId, entry.path)
|
||||
// Mock file content for development
|
||||
const mockContent = `# ${entry.name}\n\n` +
|
||||
`# File: ${entry.path}\n` +
|
||||
`# Size: ${entry.size} bytes\n` +
|
||||
`# Permissions: ${entry.permissions}\n` +
|
||||
`# Modified: ${entry.modTime}\n\n` +
|
||||
`# TODO: Content will be loaded from SFTPService.ReadFile()\n`;
|
||||
|
||||
editorFile.value = {
|
||||
content: mockContent,
|
||||
path: entry.path,
|
||||
sessionId: sessionStore.activeSession.id,
|
||||
};
|
||||
}
|
||||
|
||||
/** Handle theme selection from the ThemePicker. */
|
||||
function handleThemeSelect(theme: ThemeDefinition): void {
|
||||
statusBar.value?.setThemeName(theme.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick Connect: parse user@host:port and open a session.
|
||||
* Default protocol: SSH, default port: 22.
|
||||
* If port is 3389, use RDP.
|
||||
*/
|
||||
function handleQuickConnect(): void {
|
||||
const raw = quickConnectInput.value.trim();
|
||||
if (!raw) return;
|
||||
|
||||
let username = "";
|
||||
let hostname = "";
|
||||
let port = 22;
|
||||
let protocol: "ssh" | "rdp" = "ssh";
|
||||
|
||||
let hostPart = raw;
|
||||
|
||||
// Extract username if present (user@...)
|
||||
const atIdx = raw.indexOf("@");
|
||||
if (atIdx > 0) {
|
||||
username = raw.substring(0, atIdx);
|
||||
hostPart = raw.substring(atIdx + 1);
|
||||
}
|
||||
|
||||
// Extract port if present (...:port)
|
||||
const colonIdx = hostPart.lastIndexOf(":");
|
||||
if (colonIdx > 0) {
|
||||
const portStr = hostPart.substring(colonIdx + 1);
|
||||
const parsedPort = parseInt(portStr, 10);
|
||||
if (!isNaN(parsedPort) && parsedPort > 0 && parsedPort <= 65535) {
|
||||
port = parsedPort;
|
||||
hostPart = hostPart.substring(0, colonIdx);
|
||||
}
|
||||
}
|
||||
|
||||
hostname = hostPart;
|
||||
if (!hostname) return;
|
||||
|
||||
// Auto-detect RDP by port
|
||||
if (port === 3389) {
|
||||
protocol = "rdp";
|
||||
}
|
||||
|
||||
// Create a temporary connection and session
|
||||
// TODO: Replace with Wails binding — create ephemeral session via SSHService.Connect / RDPService.Connect
|
||||
const tempId = Math.max(...connectionStore.connections.map((c) => c.id), 0) + 1;
|
||||
const name = username ? `${username}@${hostname}` : hostname;
|
||||
|
||||
connectionStore.connections.push({
|
||||
id: tempId,
|
||||
name,
|
||||
hostname,
|
||||
port,
|
||||
protocol,
|
||||
groupId: 1,
|
||||
tags: [],
|
||||
});
|
||||
|
||||
sessionStore.connect(tempId);
|
||||
quickConnectInput.value = "";
|
||||
}
|
||||
|
||||
/** Global keyboard shortcut handler. */
|
||||
function handleKeydown(event: KeyboardEvent): void {
|
||||
// Ctrl+K or Cmd+K — open command palette
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === "k") {
|
||||
event.preventDefault();
|
||||
commandPalette.value?.toggle();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener("keydown", handleKeydown);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener("keydown", handleKeydown);
|
||||
});
|
||||
</script>
|
||||
@ -1,122 +0,0 @@
|
||||
<template>
|
||||
<div class="h-screen w-screen flex items-center justify-center bg-[var(--wraith-bg-primary)]">
|
||||
<div class="w-full max-w-sm px-6">
|
||||
<!-- Branding -->
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-4xl font-bold tracking-widest text-[var(--wraith-accent-blue)]">
|
||||
WRAITH
|
||||
</h1>
|
||||
<p class="text-[var(--wraith-text-secondary)] mt-2 text-sm">
|
||||
{{ appStore.isFirstRun ? "Create a master password" : "Enter your master password" }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Card -->
|
||||
<form
|
||||
class="bg-[var(--wraith-bg-secondary)] border border-[var(--wraith-border)] rounded-lg p-6 space-y-4"
|
||||
@submit.prevent="handleSubmit"
|
||||
>
|
||||
<!-- Error -->
|
||||
<div
|
||||
v-if="appStore.error"
|
||||
class="text-sm text-[var(--wraith-accent-red)] bg-[var(--wraith-accent-red)]/10 border border-[var(--wraith-accent-red)]/20 rounded px-3 py-2"
|
||||
>
|
||||
{{ appStore.error }}
|
||||
</div>
|
||||
|
||||
<!-- Password -->
|
||||
<div>
|
||||
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1.5">
|
||||
Master Password
|
||||
</label>
|
||||
<input
|
||||
ref="passwordInput"
|
||||
v-model="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
placeholder="Enter password..."
|
||||
class="w-full px-3 py-2 text-sm rounded bg-[var(--wraith-bg-tertiary)] border border-[var(--wraith-border)] text-[var(--wraith-text-primary)] placeholder-[var(--wraith-text-muted)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors"
|
||||
@input="appStore.clearError()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Confirm password (first run only) -->
|
||||
<div v-if="appStore.isFirstRun">
|
||||
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1.5">
|
||||
Confirm Password
|
||||
</label>
|
||||
<input
|
||||
v-model="confirmPassword"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
placeholder="Confirm password..."
|
||||
class="w-full px-3 py-2 text-sm rounded bg-[var(--wraith-bg-tertiary)] border border-[var(--wraith-border)] text-[var(--wraith-text-primary)] placeholder-[var(--wraith-text-muted)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors"
|
||||
@input="appStore.clearError()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Submit -->
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="submitting"
|
||||
class="w-full py-2 text-sm font-medium rounded bg-[var(--wraith-accent-blue)] text-white hover:opacity-90 disabled:opacity-50 transition-opacity cursor-pointer disabled:cursor-not-allowed"
|
||||
>
|
||||
<span v-if="submitting">{{ appStore.isFirstRun ? "Creating..." : "Unlocking..." }}</span>
|
||||
<span v-else>{{ appStore.isFirstRun ? "Create Vault" : "Unlock" }}</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Version -->
|
||||
<p class="text-center text-xs text-[var(--wraith-text-muted)] mt-4">
|
||||
v1.0.0-alpha
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue";
|
||||
import { useAppStore } from "@/stores/app.store";
|
||||
|
||||
const appStore = useAppStore();
|
||||
|
||||
const password = ref("");
|
||||
const confirmPassword = ref("");
|
||||
const submitting = ref(false);
|
||||
const passwordInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
onMounted(() => {
|
||||
passwordInput.value?.focus();
|
||||
});
|
||||
|
||||
async function handleSubmit(): Promise<void> {
|
||||
if (!password.value) {
|
||||
appStore.error = "Password is required";
|
||||
return;
|
||||
}
|
||||
|
||||
if (appStore.isFirstRun) {
|
||||
if (password.value.length < 8) {
|
||||
appStore.error = "Password must be at least 8 characters";
|
||||
return;
|
||||
}
|
||||
if (password.value !== confirmPassword.value) {
|
||||
appStore.error = "Passwords do not match";
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
try {
|
||||
if (appStore.isFirstRun) {
|
||||
await appStore.createVault(password.value);
|
||||
} else {
|
||||
await appStore.unlock(password.value);
|
||||
}
|
||||
} catch {
|
||||
// Error is set in the store
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@ -1,77 +0,0 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { ref } from "vue";
|
||||
|
||||
/**
|
||||
* Wraith application store.
|
||||
* Manages unlock state, first-run detection, and vault operations.
|
||||
*
|
||||
* Once Wails v3 bindings are generated, the mock calls below will be
|
||||
* replaced with actual WraithApp.IsFirstRun(), CreateVault(), Unlock(), etc.
|
||||
*/
|
||||
export const useAppStore = defineStore("app", () => {
|
||||
const isUnlocked = ref(false);
|
||||
const isFirstRun = ref(true);
|
||||
const isLoading = ref(true);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
/** Check whether the vault has been created before. */
|
||||
async function checkFirstRun(): Promise<void> {
|
||||
try {
|
||||
// TODO: replace with Wails binding — WraithApp.IsFirstRun()
|
||||
isFirstRun.value = true;
|
||||
} catch {
|
||||
isFirstRun.value = true;
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Create a new vault with the given master password. */
|
||||
async function createVault(password: string): Promise<void> {
|
||||
error.value = null;
|
||||
try {
|
||||
// TODO: replace with Wails binding — WraithApp.CreateVault(password)
|
||||
void password;
|
||||
isFirstRun.value = false;
|
||||
isUnlocked.value = true;
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : "Failed to create vault";
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/** Unlock an existing vault with the master password. */
|
||||
async function unlock(password: string): Promise<void> {
|
||||
error.value = null;
|
||||
try {
|
||||
// TODO: replace with Wails binding — WraithApp.Unlock(password)
|
||||
void password;
|
||||
isUnlocked.value = true;
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : "Invalid master password";
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/** Lock the vault (return to unlock screen). */
|
||||
function lock(): void {
|
||||
isUnlocked.value = false;
|
||||
}
|
||||
|
||||
/** Clear the current error message. */
|
||||
function clearError(): void {
|
||||
error.value = null;
|
||||
}
|
||||
|
||||
return {
|
||||
isUnlocked,
|
||||
isFirstRun,
|
||||
isLoading,
|
||||
error,
|
||||
checkFirstRun,
|
||||
createVault,
|
||||
unlock,
|
||||
lock,
|
||||
clearError,
|
||||
};
|
||||
});
|
||||
@ -1,95 +0,0 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { ref, computed } from "vue";
|
||||
|
||||
export interface Connection {
|
||||
id: number;
|
||||
name: string;
|
||||
hostname: string;
|
||||
port: number;
|
||||
protocol: "ssh" | "rdp";
|
||||
groupId: number;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface Group {
|
||||
id: number;
|
||||
name: string;
|
||||
parentId: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connection store.
|
||||
* Manages connections, groups, and search state.
|
||||
*
|
||||
* Uses mock data until Wails bindings are connected.
|
||||
*/
|
||||
export const useConnectionStore = defineStore("connection", () => {
|
||||
const connections = ref<Connection[]>([
|
||||
{ id: 1, name: "Asgard", hostname: "192.168.1.4", port: 22, protocol: "ssh", groupId: 1, tags: ["Prod"] },
|
||||
{ id: 2, name: "Docker", hostname: "155.254.29.221", port: 22, protocol: "ssh", groupId: 1, tags: ["Prod"] },
|
||||
{ id: 3, name: "Predator Mac", hostname: "192.168.1.214", port: 22, protocol: "ssh", groupId: 1 },
|
||||
{ id: 4, name: "CLT-VMHOST01", hostname: "100.64.1.204", port: 3389, protocol: "rdp", groupId: 1 },
|
||||
{ id: 5, name: "ITFlow", hostname: "192.154.253.106", port: 22, protocol: "ssh", groupId: 2 },
|
||||
{ id: 6, name: "Mautic", hostname: "192.154.253.112", port: 22, protocol: "ssh", groupId: 2 },
|
||||
]);
|
||||
|
||||
const groups = ref<Group[]>([
|
||||
{ id: 1, name: "Vantz's Stuff", parentId: null },
|
||||
{ id: 2, name: "MSPNerd", parentId: null },
|
||||
]);
|
||||
|
||||
const searchQuery = ref("");
|
||||
|
||||
/** Filter connections by search query. */
|
||||
const filteredConnections = computed(() => {
|
||||
const q = searchQuery.value.toLowerCase().trim();
|
||||
if (!q) return connections.value;
|
||||
return connections.value.filter(
|
||||
(c) =>
|
||||
c.name.toLowerCase().includes(q) ||
|
||||
c.hostname.toLowerCase().includes(q) ||
|
||||
c.tags?.some((t) => t.toLowerCase().includes(q)),
|
||||
);
|
||||
});
|
||||
|
||||
/** Get connections belonging to a specific group. */
|
||||
function connectionsByGroup(groupId: number): Connection[] {
|
||||
const q = searchQuery.value.toLowerCase().trim();
|
||||
const groupConns = connections.value.filter((c) => c.groupId === groupId);
|
||||
if (!q) return groupConns;
|
||||
return groupConns.filter(
|
||||
(c) =>
|
||||
c.name.toLowerCase().includes(q) ||
|
||||
c.hostname.toLowerCase().includes(q) ||
|
||||
c.tags?.some((t) => t.toLowerCase().includes(q)),
|
||||
);
|
||||
}
|
||||
|
||||
/** Check if a group has any matching connections (for search filtering). */
|
||||
function groupHasResults(groupId: number): boolean {
|
||||
return connectionsByGroup(groupId).length > 0;
|
||||
}
|
||||
|
||||
/** Load connections from backend (mock for now). */
|
||||
async function loadConnections(): Promise<void> {
|
||||
// TODO: replace with Wails binding — ConnectionService.ListConnections()
|
||||
// connections.value = await ConnectionService.ListConnections();
|
||||
}
|
||||
|
||||
/** Load groups from backend (mock for now). */
|
||||
async function loadGroups(): Promise<void> {
|
||||
// TODO: replace with Wails binding — ConnectionService.ListGroups()
|
||||
// groups.value = await ConnectionService.ListGroups();
|
||||
}
|
||||
|
||||
return {
|
||||
connections,
|
||||
groups,
|
||||
searchQuery,
|
||||
filteredConnections,
|
||||
connectionsByGroup,
|
||||
groupHasResults,
|
||||
loadConnections,
|
||||
loadGroups,
|
||||
};
|
||||
});
|
||||
@ -1,96 +0,0 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { ref, computed } from "vue";
|
||||
import { useConnectionStore } from "@/stores/connection.store";
|
||||
|
||||
export interface Session {
|
||||
id: string;
|
||||
connectionId: number;
|
||||
name: string;
|
||||
protocol: "ssh" | "rdp";
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Session store.
|
||||
* Manages active sessions and tab order.
|
||||
*
|
||||
* Sessions are populated by the Go SessionManager once plugins are wired up.
|
||||
* For now, mock sessions are used to render the tab bar.
|
||||
*/
|
||||
export const useSessionStore = defineStore("session", () => {
|
||||
const sessions = ref<Session[]>([]);
|
||||
|
||||
const activeSessionId = ref<string | null>(null);
|
||||
|
||||
const activeSession = computed(() =>
|
||||
sessions.value.find((s) => s.id === activeSessionId.value) ?? null,
|
||||
);
|
||||
|
||||
const sessionCount = computed(() => sessions.value.length);
|
||||
|
||||
/** Switch to a session tab. */
|
||||
function activateSession(id: string): void {
|
||||
activeSessionId.value = id;
|
||||
}
|
||||
|
||||
/** Close a session tab. */
|
||||
function closeSession(id: string): void {
|
||||
const idx = sessions.value.findIndex((s) => s.id === id);
|
||||
if (idx === -1) return;
|
||||
|
||||
sessions.value.splice(idx, 1);
|
||||
|
||||
// If we closed the active session, activate an adjacent one
|
||||
if (activeSessionId.value === id) {
|
||||
if (sessions.value.length === 0) {
|
||||
activeSessionId.value = null;
|
||||
} else {
|
||||
const nextIdx = Math.min(idx, sessions.value.length - 1);
|
||||
activeSessionId.value = sessions.value[nextIdx].id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Add a new session (placeholder — will be called from connection double-click). */
|
||||
function addSession(connectionId: number, name: string, protocol: "ssh" | "rdp"): void {
|
||||
const id = `s${Date.now()}`;
|
||||
sessions.value.push({ id, connectionId, name, protocol, active: false });
|
||||
activeSessionId.value = id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to a server by connection ID.
|
||||
* Creates a new session tab and sets it active.
|
||||
*
|
||||
* TODO: Replace with Wails binding call — SSHService.Connect(hostname, port, ...)
|
||||
* For now, creates a mock session using the connection's name.
|
||||
*/
|
||||
function connect(connectionId: number): void {
|
||||
const connectionStore = useConnectionStore();
|
||||
const conn = connectionStore.connections.find((c) => c.id === connectionId);
|
||||
if (!conn) return;
|
||||
|
||||
// Check if there's already an active session for this connection
|
||||
const existing = sessions.value.find((s) => s.connectionId === connectionId);
|
||||
if (existing) {
|
||||
activeSessionId.value = existing.id;
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: Replace with Wails binding call:
|
||||
// const sessionId = await SSHService.Connect(conn.hostname, conn.port, username, authMethods, cols, rows)
|
||||
// For now, create a mock session
|
||||
addSession(connectionId, conn.name, conn.protocol);
|
||||
}
|
||||
|
||||
return {
|
||||
sessions,
|
||||
activeSessionId,
|
||||
activeSession,
|
||||
sessionCount,
|
||||
activateSession,
|
||||
closeSession,
|
||||
addSession,
|
||||
connect,
|
||||
};
|
||||
});
|
||||
@ -1,19 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"]
|
||||
}
|
||||
@ -1,13 +0,0 @@
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { resolve } from "path";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": resolve(__dirname, "src"),
|
||||
},
|
||||
},
|
||||
});
|
||||
58
go.mod
58
go.mod
@ -1,58 +0,0 @@
|
||||
module github.com/vstockwell/wraith
|
||||
|
||||
go 1.26.1
|
||||
|
||||
require (
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/pkg/sftp v1.13.10
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.74
|
||||
golang.org/x/crypto v0.49.0
|
||||
modernc.org/sqlite v1.46.2
|
||||
)
|
||||
|
||||
require (
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/ProtonMail/go-crypto v1.3.0 // indirect
|
||||
github.com/adrg/xdg v0.5.3 // indirect
|
||||
github.com/bep/debounce v1.2.1 // indirect
|
||||
github.com/cloudflare/circl v1.6.3 // indirect
|
||||
github.com/coder/websocket v1.8.14 // indirect
|
||||
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/ebitengine/purego v0.9.1 // indirect
|
||||
github.com/emirpasic/gods v1.18.1 // indirect
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
|
||||
github.com/go-git/go-billy/v5 v5.7.0 // indirect
|
||||
github.com/go-git/go-git/v5 v5.16.4 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
|
||||
github.com/kevinburke/ssh_config v1.4.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/kr/fs v0.1.0 // indirect
|
||||
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
|
||||
github.com/leaanthony/u v1.1.1 // indirect
|
||||
github.com/lmittmann/tint v1.1.2 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/pjbgf/sha1cd v0.5.0 // indirect
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/samber/lo v1.52.0 // indirect
|
||||
github.com/sergi/go-diff v1.4.0 // indirect
|
||||
github.com/skeema/knownhosts v1.3.2 // indirect
|
||||
github.com/wailsapp/go-webview2 v1.0.23 // indirect
|
||||
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||
modernc.org/libc v1.70.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
197
go.sum
197
go.sum
@ -1,197 +0,0 @@
|
||||
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
|
||||
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
|
||||
github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
|
||||
github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
||||
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
|
||||
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
||||
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
|
||||
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
|
||||
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
|
||||
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
|
||||
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
|
||||
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
||||
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
|
||||
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
||||
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
|
||||
github.com/go-git/go-billy/v5 v5.7.0 h1:83lBUJhGWhYp0ngzCMSgllhUSuoHP1iEWYjsPl9nwqM=
|
||||
github.com/go-git/go-billy/v5 v5.7.0/go.mod h1:/1IUejTKH8xipsAcdfcSAlUlo2J7lkYV8GTKxAT/L3E=
|
||||
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
|
||||
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
|
||||
github.com/go-git/go-git/v5 v5.16.4 h1:7ajIEZHZJULcyJebDLo99bGgS0jRrOxzZG4uCk2Yb2Y=
|
||||
github.com/go-git/go-git/v5 v5.16.4/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
|
||||
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU=
|
||||
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
|
||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ=
|
||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
|
||||
github.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PWkxoFkQ=
|
||||
github.com/kevinburke/ssh_config v1.4.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
|
||||
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
|
||||
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
|
||||
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
|
||||
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
|
||||
github.com/lmittmann/tint v1.1.2 h1:2CQzrL6rslrsyjqLDwD11bZ5OpLBPU+g3G/r5LSfS8w=
|
||||
github.com/lmittmann/tint v1.1.2/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
|
||||
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
|
||||
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
|
||||
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
|
||||
github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
|
||||
github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU=
|
||||
github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw=
|
||||
github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
|
||||
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
|
||||
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||
github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg=
|
||||
github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/wailsapp/go-webview2 v1.0.23 h1:jmv8qhz1lHibCc79bMM/a/FqOnnzOGEisLav+a0b9P0=
|
||||
github.com/wailsapp/go-webview2 v1.0.23/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.74 h1:wRm1EiDQtxDisXk46NtpiBH90STwfKp36NrTDwOEdxw=
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.74/go.mod h1:4saK4A4K9970X+X7RkMwP2lyGbLogcUz54wVeq4C/V8=
|
||||
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
|
||||
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
|
||||
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
|
||||
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
|
||||
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.46.2 h1:gkXQ6R0+AjxFC/fTDaeIVLbNLNrRoOK7YYVz5BKhTcE=
|
||||
modernc.org/sqlite v1.46.2/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 30 KiB |
25
index.html
Normal file
25
index.html
Normal file
@ -0,0 +1,25 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Wraith</title>
|
||||
<!-- Prevent any flash of light background before Vue mounts -->
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #0d1117;
|
||||
height: 100%;
|
||||
}
|
||||
#app {
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,205 +0,0 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/vstockwell/wraith/internal/connections"
|
||||
"github.com/vstockwell/wraith/internal/credentials"
|
||||
"github.com/vstockwell/wraith/internal/db"
|
||||
"github.com/vstockwell/wraith/internal/plugin"
|
||||
"github.com/vstockwell/wraith/internal/rdp"
|
||||
"github.com/vstockwell/wraith/internal/session"
|
||||
"github.com/vstockwell/wraith/internal/settings"
|
||||
"github.com/vstockwell/wraith/internal/sftp"
|
||||
"github.com/vstockwell/wraith/internal/ssh"
|
||||
"github.com/vstockwell/wraith/internal/theme"
|
||||
"github.com/vstockwell/wraith/internal/vault"
|
||||
)
|
||||
|
||||
// WraithApp is the main application struct that wires together all services
|
||||
// and exposes vault management methods to the frontend via Wails bindings.
|
||||
type WraithApp struct {
|
||||
db *sql.DB
|
||||
Vault *vault.VaultService
|
||||
Settings *settings.SettingsService
|
||||
Connections *connections.ConnectionService
|
||||
Themes *theme.ThemeService
|
||||
Sessions *session.Manager
|
||||
Plugins *plugin.Registry
|
||||
SSH *ssh.SSHService
|
||||
SFTP *sftp.SFTPService
|
||||
RDP *rdp.RDPService
|
||||
Credentials *credentials.CredentialService
|
||||
unlocked bool
|
||||
}
|
||||
|
||||
// New creates and initializes the WraithApp, opening the database, running
|
||||
// migrations, creating all services, and seeding built-in themes.
|
||||
func New() (*WraithApp, error) {
|
||||
dataDir := dataDirectory()
|
||||
dbPath := filepath.Join(dataDir, "wraith.db")
|
||||
|
||||
slog.Info("opening database", "path", dbPath)
|
||||
database, err := db.Open(dbPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open database: %w", err)
|
||||
}
|
||||
|
||||
if err := db.Migrate(database); err != nil {
|
||||
return nil, fmt.Errorf("run migrations: %w", err)
|
||||
}
|
||||
|
||||
settingsSvc := settings.NewSettingsService(database)
|
||||
connSvc := connections.NewConnectionService(database)
|
||||
themeSvc := theme.NewThemeService(database)
|
||||
sessionMgr := session.NewManager()
|
||||
pluginReg := plugin.NewRegistry()
|
||||
|
||||
// No-op output handler — Wails event emission will be wired at runtime.
|
||||
sshSvc := ssh.NewSSHService(database, func(sessionID string, data []byte) {
|
||||
// TODO: Emit Wails event "ssh:output" with sessionID + data
|
||||
_ = sessionID
|
||||
_ = data
|
||||
})
|
||||
sftpSvc := sftp.NewSFTPService()
|
||||
|
||||
// RDP service with platform-aware backend factory.
|
||||
// On Windows the factory returns a FreeRDPBackend backed by libfreerdp3.dll;
|
||||
// on other platforms it falls back to MockBackend for development.
|
||||
rdpSvc := rdp.NewRDPService(func() rdp.RDPBackend {
|
||||
return rdp.NewProductionBackend()
|
||||
})
|
||||
|
||||
// CredentialService requires the vault to be unlocked, so it starts nil.
|
||||
// It is created lazily after the vault is unlocked via initCredentials().
|
||||
|
||||
// Seed built-in themes on every startup (INSERT OR IGNORE keeps it idempotent)
|
||||
if err := themeSvc.SeedBuiltins(); err != nil {
|
||||
slog.Warn("failed to seed themes", "error", err)
|
||||
}
|
||||
|
||||
return &WraithApp{
|
||||
db: database,
|
||||
Settings: settingsSvc,
|
||||
Connections: connSvc,
|
||||
Themes: themeSvc,
|
||||
Sessions: sessionMgr,
|
||||
Plugins: pluginReg,
|
||||
SSH: sshSvc,
|
||||
SFTP: sftpSvc,
|
||||
RDP: rdpSvc,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// dataDirectory returns the path where Wraith stores its data.
|
||||
// On Windows with APPDATA set, it uses %APPDATA%\Wraith.
|
||||
// On macOS/Linux with XDG_DATA_HOME or HOME, it uses the appropriate path.
|
||||
// Falls back to the current working directory for development.
|
||||
func dataDirectory() string {
|
||||
// Windows
|
||||
if appData := os.Getenv("APPDATA"); appData != "" {
|
||||
return filepath.Join(appData, "Wraith")
|
||||
}
|
||||
|
||||
// macOS / Linux: use XDG_DATA_HOME or fallback to ~/.local/share
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
if xdg := os.Getenv("XDG_DATA_HOME"); xdg != "" {
|
||||
return filepath.Join(xdg, "wraith")
|
||||
}
|
||||
return filepath.Join(home, ".local", "share", "wraith")
|
||||
}
|
||||
|
||||
// Dev fallback
|
||||
return "."
|
||||
}
|
||||
|
||||
// IsFirstRun checks whether the vault has been set up by looking for vault_salt in settings.
|
||||
func (a *WraithApp) IsFirstRun() bool {
|
||||
salt, _ := a.Settings.Get("vault_salt")
|
||||
return salt == ""
|
||||
}
|
||||
|
||||
// CreateVault sets up the vault with a master password. It generates a salt,
|
||||
// derives an encryption key, and stores the salt and a check value in settings.
|
||||
func (a *WraithApp) CreateVault(password string) error {
|
||||
salt, err := vault.GenerateSalt()
|
||||
if err != nil {
|
||||
return fmt.Errorf("generate salt: %w", err)
|
||||
}
|
||||
|
||||
key := vault.DeriveKey(password, salt)
|
||||
a.Vault = vault.NewVaultService(key)
|
||||
|
||||
// Store salt as hex in settings
|
||||
if err := a.Settings.Set("vault_salt", hex.EncodeToString(salt)); err != nil {
|
||||
return fmt.Errorf("store salt: %w", err)
|
||||
}
|
||||
|
||||
// Encrypt a known check value — used to verify the password on unlock
|
||||
check, err := a.Vault.Encrypt("wraith-vault-check")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encrypt check value: %w", err)
|
||||
}
|
||||
if err := a.Settings.Set("vault_check", check); err != nil {
|
||||
return fmt.Errorf("store check value: %w", err)
|
||||
}
|
||||
|
||||
a.unlocked = true
|
||||
a.initCredentials()
|
||||
slog.Info("vault created successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Unlock verifies the master password against the stored check value and
|
||||
// initializes the vault service for decryption.
|
||||
func (a *WraithApp) Unlock(password string) error {
|
||||
saltHex, err := a.Settings.Get("vault_salt")
|
||||
if err != nil || saltHex == "" {
|
||||
return fmt.Errorf("vault not set up — call CreateVault first")
|
||||
}
|
||||
|
||||
salt, err := hex.DecodeString(saltHex)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode salt: %w", err)
|
||||
}
|
||||
|
||||
key := vault.DeriveKey(password, salt)
|
||||
vs := vault.NewVaultService(key)
|
||||
|
||||
// Verify by decrypting the stored check value
|
||||
checkEncrypted, err := a.Settings.Get("vault_check")
|
||||
if err != nil || checkEncrypted == "" {
|
||||
return fmt.Errorf("vault check value missing")
|
||||
}
|
||||
|
||||
checkPlain, err := vs.Decrypt(checkEncrypted)
|
||||
if err != nil {
|
||||
return fmt.Errorf("incorrect master password")
|
||||
}
|
||||
if checkPlain != "wraith-vault-check" {
|
||||
return fmt.Errorf("incorrect master password")
|
||||
}
|
||||
|
||||
a.Vault = vs
|
||||
a.unlocked = true
|
||||
a.initCredentials()
|
||||
slog.Info("vault unlocked successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsUnlocked returns whether the vault is currently unlocked.
|
||||
func (a *WraithApp) IsUnlocked() bool {
|
||||
return a.unlocked
|
||||
}
|
||||
|
||||
// initCredentials creates the CredentialService after the vault is unlocked.
|
||||
func (a *WraithApp) initCredentials() {
|
||||
if a.Vault != nil {
|
||||
a.Credentials = credentials.NewCredentialService(a.db, a.Vault)
|
||||
}
|
||||
}
|
||||
@ -1,66 +0,0 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/vstockwell/wraith/internal/settings"
|
||||
)
|
||||
|
||||
type WorkspaceSnapshot struct {
|
||||
Tabs []WorkspaceTab `json:"tabs"`
|
||||
SidebarWidth int `json:"sidebarWidth"`
|
||||
SidebarMode string `json:"sidebarMode"`
|
||||
ActiveTab int `json:"activeTab"`
|
||||
}
|
||||
|
||||
type WorkspaceTab struct {
|
||||
ConnectionID int64 `json:"connectionId"`
|
||||
Protocol string `json:"protocol"`
|
||||
Position int `json:"position"`
|
||||
}
|
||||
|
||||
type WorkspaceService struct {
|
||||
settings *settings.SettingsService
|
||||
}
|
||||
|
||||
func NewWorkspaceService(s *settings.SettingsService) *WorkspaceService {
|
||||
return &WorkspaceService{settings: s}
|
||||
}
|
||||
|
||||
// Save serializes the workspace snapshot to settings
|
||||
func (w *WorkspaceService) Save(snapshot *WorkspaceSnapshot) error {
|
||||
data, err := json.Marshal(snapshot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return w.settings.Set("workspace_snapshot", string(data))
|
||||
}
|
||||
|
||||
// Load reads the last saved workspace snapshot
|
||||
func (w *WorkspaceService) Load() (*WorkspaceSnapshot, error) {
|
||||
data, err := w.settings.Get("workspace_snapshot")
|
||||
if err != nil || data == "" {
|
||||
return nil, nil // no saved workspace
|
||||
}
|
||||
var snapshot WorkspaceSnapshot
|
||||
if err := json.Unmarshal([]byte(data), &snapshot); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &snapshot, nil
|
||||
}
|
||||
|
||||
// MarkCleanShutdown saves a flag indicating clean exit
|
||||
func (w *WorkspaceService) MarkCleanShutdown() error {
|
||||
return w.settings.Set("clean_shutdown", "true")
|
||||
}
|
||||
|
||||
// WasCleanShutdown checks if last exit was clean
|
||||
func (w *WorkspaceService) WasCleanShutdown() bool {
|
||||
val, _ := w.settings.Get("clean_shutdown")
|
||||
return val == "true"
|
||||
}
|
||||
|
||||
// ClearCleanShutdown removes the clean shutdown flag (called on startup)
|
||||
func (w *WorkspaceService) ClearCleanShutdown() error {
|
||||
return w.settings.Delete("clean_shutdown")
|
||||
}
|
||||
@ -1,74 +0,0 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/vstockwell/wraith/internal/db"
|
||||
"github.com/vstockwell/wraith/internal/settings"
|
||||
)
|
||||
|
||||
func setupWorkspaceService(t *testing.T) *WorkspaceService {
|
||||
t.Helper()
|
||||
d, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Migrate(d); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { d.Close() })
|
||||
return NewWorkspaceService(settings.NewSettingsService(d))
|
||||
}
|
||||
|
||||
func TestSaveAndLoadWorkspace(t *testing.T) {
|
||||
svc := setupWorkspaceService(t)
|
||||
snapshot := &WorkspaceSnapshot{
|
||||
Tabs: []WorkspaceTab{
|
||||
{ConnectionID: 1, Protocol: "ssh", Position: 0},
|
||||
{ConnectionID: 5, Protocol: "rdp", Position: 1},
|
||||
},
|
||||
SidebarWidth: 240,
|
||||
SidebarMode: "connections",
|
||||
ActiveTab: 0,
|
||||
}
|
||||
if err := svc.Save(snapshot); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err := svc.Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(loaded.Tabs) != 2 {
|
||||
t.Errorf("len(Tabs) = %d, want 2", len(loaded.Tabs))
|
||||
}
|
||||
if loaded.SidebarWidth != 240 {
|
||||
t.Errorf("SidebarWidth = %d, want 240", loaded.SidebarWidth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadEmptyWorkspace(t *testing.T) {
|
||||
svc := setupWorkspaceService(t)
|
||||
loaded, err := svc.Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded != nil {
|
||||
t.Error("should return nil for empty workspace")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanShutdownFlag(t *testing.T) {
|
||||
svc := setupWorkspaceService(t)
|
||||
if svc.WasCleanShutdown() {
|
||||
t.Error("should not be clean initially")
|
||||
}
|
||||
svc.MarkCleanShutdown()
|
||||
if !svc.WasCleanShutdown() {
|
||||
t.Error("should be clean after marking")
|
||||
}
|
||||
svc.ClearCleanShutdown()
|
||||
if svc.WasCleanShutdown() {
|
||||
t.Error("should not be clean after clearing")
|
||||
}
|
||||
}
|
||||
@ -1,40 +0,0 @@
|
||||
package connections
|
||||
|
||||
import "fmt"
|
||||
|
||||
func (s *ConnectionService) Search(query string) ([]Connection, error) {
|
||||
like := "%" + query + "%"
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, name, hostname, port, protocol, group_id, credential_id,
|
||||
COALESCE(color,''), tags, COALESCE(notes,''), COALESCE(options,'{}'),
|
||||
sort_order, last_connected, created_at, updated_at
|
||||
FROM connections
|
||||
WHERE name LIKE ? COLLATE NOCASE
|
||||
OR hostname LIKE ? COLLATE NOCASE
|
||||
OR tags LIKE ? COLLATE NOCASE
|
||||
OR notes LIKE ? COLLATE NOCASE
|
||||
ORDER BY last_connected DESC NULLS LAST, name`,
|
||||
like, like, like, like,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search connections: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanConnections(rows)
|
||||
}
|
||||
|
||||
func (s *ConnectionService) FilterByTag(tag string) ([]Connection, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT c.id, c.name, c.hostname, c.port, c.protocol, c.group_id, c.credential_id,
|
||||
COALESCE(c.color,''), c.tags, COALESCE(c.notes,''), COALESCE(c.options,'{}'),
|
||||
c.sort_order, c.last_connected, c.created_at, c.updated_at
|
||||
FROM connections c, json_each(c.tags) AS t
|
||||
WHERE t.value = ?
|
||||
ORDER BY c.name`, tag,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("filter by tag: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanConnections(rows)
|
||||
}
|
||||
@ -1,53 +0,0 @@
|
||||
package connections
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSearchByName(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
svc.CreateConnection(CreateConnectionInput{Name: "Asgard", Hostname: "192.168.1.4", Port: 22, Protocol: "ssh"})
|
||||
svc.CreateConnection(CreateConnectionInput{Name: "Docker", Hostname: "155.254.29.221", Port: 22, Protocol: "ssh"})
|
||||
|
||||
results, err := svc.Search("asg")
|
||||
if err != nil {
|
||||
t.Fatalf("Search() error: %v", err)
|
||||
}
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("len(results) = %d, want 1", len(results))
|
||||
}
|
||||
if results[0].Name != "Asgard" {
|
||||
t.Errorf("Name = %q, want %q", results[0].Name, "Asgard")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchByHostname(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
svc.CreateConnection(CreateConnectionInput{Name: "Asgard", Hostname: "192.168.1.4", Port: 22, Protocol: "ssh"})
|
||||
|
||||
results, _ := svc.Search("192.168")
|
||||
if len(results) != 1 {
|
||||
t.Errorf("len(results) = %d, want 1", len(results))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchByTag(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
svc.CreateConnection(CreateConnectionInput{Name: "ProdServer", Hostname: "10.0.0.1", Port: 22, Protocol: "ssh", Tags: []string{"Prod", "Linux"}})
|
||||
svc.CreateConnection(CreateConnectionInput{Name: "DevServer", Hostname: "10.0.0.2", Port: 22, Protocol: "ssh", Tags: []string{"Dev", "Linux"}})
|
||||
|
||||
results, _ := svc.Search("Prod")
|
||||
if len(results) != 1 {
|
||||
t.Errorf("len(results) = %d, want 1", len(results))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterByTag(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
svc.CreateConnection(CreateConnectionInput{Name: "A", Hostname: "10.0.0.1", Port: 22, Protocol: "ssh", Tags: []string{"Prod"}})
|
||||
svc.CreateConnection(CreateConnectionInput{Name: "B", Hostname: "10.0.0.2", Port: 22, Protocol: "ssh", Tags: []string{"Dev"}})
|
||||
svc.CreateConnection(CreateConnectionInput{Name: "C", Hostname: "10.0.0.3", Port: 22, Protocol: "ssh", Tags: []string{"Prod", "Linux"}})
|
||||
|
||||
results, _ := svc.FilterByTag("Prod")
|
||||
if len(results) != 2 {
|
||||
t.Errorf("len(results) = %d, want 2", len(results))
|
||||
}
|
||||
}
|
||||
@ -1,361 +0,0 @@
|
||||
package connections
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Group struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ParentID *int64 `json:"parentId"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
Icon string `json:"icon"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
Children []Group `json:"children,omitempty"`
|
||||
}
|
||||
|
||||
type Connection struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Hostname string `json:"hostname"`
|
||||
Port int `json:"port"`
|
||||
Protocol string `json:"protocol"`
|
||||
GroupID *int64 `json:"groupId"`
|
||||
CredentialID *int64 `json:"credentialId"`
|
||||
Color string `json:"color"`
|
||||
Tags []string `json:"tags"`
|
||||
Notes string `json:"notes"`
|
||||
Options string `json:"options"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
LastConnected *time.Time `json:"lastConnected"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type CreateConnectionInput struct {
|
||||
Name string `json:"name"`
|
||||
Hostname string `json:"hostname"`
|
||||
Port int `json:"port"`
|
||||
Protocol string `json:"protocol"`
|
||||
GroupID *int64 `json:"groupId"`
|
||||
CredentialID *int64 `json:"credentialId"`
|
||||
Color string `json:"color"`
|
||||
Tags []string `json:"tags"`
|
||||
Notes string `json:"notes"`
|
||||
Options string `json:"options"`
|
||||
}
|
||||
|
||||
type UpdateConnectionInput struct {
|
||||
Name *string `json:"name"`
|
||||
Hostname *string `json:"hostname"`
|
||||
Port *int `json:"port"`
|
||||
GroupID *int64 `json:"groupId"`
|
||||
CredentialID *int64 `json:"credentialId"`
|
||||
Color *string `json:"color"`
|
||||
Tags []string `json:"tags"`
|
||||
Notes *string `json:"notes"`
|
||||
Options *string `json:"options"`
|
||||
}
|
||||
|
||||
type ConnectionService struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewConnectionService(db *sql.DB) *ConnectionService {
|
||||
return &ConnectionService{db: db}
|
||||
}
|
||||
|
||||
// ---------- Group CRUD ----------
|
||||
|
||||
func (s *ConnectionService) CreateGroup(name string, parentID *int64) (*Group, error) {
|
||||
result, err := s.db.Exec(
|
||||
"INSERT INTO groups (name, parent_id) VALUES (?, ?)",
|
||||
name, parentID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create group: %w", err)
|
||||
}
|
||||
id, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get group id: %w", err)
|
||||
}
|
||||
|
||||
var g Group
|
||||
var icon sql.NullString
|
||||
err = s.db.QueryRow(
|
||||
"SELECT id, name, parent_id, sort_order, icon, created_at FROM groups WHERE id = ?", id,
|
||||
).Scan(&g.ID, &g.Name, &g.ParentID, &g.SortOrder, &icon, &g.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get created group: %w", err)
|
||||
}
|
||||
if icon.Valid {
|
||||
g.Icon = icon.String
|
||||
}
|
||||
return &g, nil
|
||||
}
|
||||
|
||||
func (s *ConnectionService) ListGroups() ([]Group, error) {
|
||||
rows, err := s.db.Query(
|
||||
"SELECT id, name, parent_id, sort_order, icon, created_at FROM groups ORDER BY sort_order, name",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list groups: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
groupMap := make(map[int64]*Group)
|
||||
var allGroups []*Group
|
||||
|
||||
for rows.Next() {
|
||||
var g Group
|
||||
var icon sql.NullString
|
||||
if err := rows.Scan(&g.ID, &g.Name, &g.ParentID, &g.SortOrder, &icon, &g.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan group: %w", err)
|
||||
}
|
||||
if icon.Valid {
|
||||
g.Icon = icon.String
|
||||
}
|
||||
groupMap[g.ID] = &g
|
||||
allGroups = append(allGroups, &g)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate groups: %w", err)
|
||||
}
|
||||
|
||||
// Build tree: attach children to parents, collect roots
|
||||
var roots []Group
|
||||
for _, g := range allGroups {
|
||||
if g.ParentID != nil {
|
||||
if parent, ok := groupMap[*g.ParentID]; ok {
|
||||
parent.Children = append(parent.Children, *g)
|
||||
}
|
||||
} else {
|
||||
roots = append(roots, *g)
|
||||
}
|
||||
}
|
||||
|
||||
// Re-attach children to root copies (since we copied into roots)
|
||||
for i := range roots {
|
||||
if orig, ok := groupMap[roots[i].ID]; ok {
|
||||
roots[i].Children = orig.Children
|
||||
}
|
||||
}
|
||||
|
||||
if roots == nil {
|
||||
roots = []Group{}
|
||||
}
|
||||
return roots, nil
|
||||
}
|
||||
|
||||
func (s *ConnectionService) DeleteGroup(id int64) error {
|
||||
_, err := s.db.Exec("DELETE FROM groups WHERE id = ?", id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete group: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- Connection CRUD ----------
|
||||
|
||||
func (s *ConnectionService) CreateConnection(input CreateConnectionInput) (*Connection, error) {
|
||||
tags, err := json.Marshal(input.Tags)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal tags: %w", err)
|
||||
}
|
||||
if input.Tags == nil {
|
||||
tags = []byte("[]")
|
||||
}
|
||||
|
||||
options := input.Options
|
||||
if options == "" {
|
||||
options = "{}"
|
||||
}
|
||||
|
||||
result, err := s.db.Exec(
|
||||
`INSERT INTO connections (name, hostname, port, protocol, group_id, credential_id, color, tags, notes, options)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
input.Name, input.Hostname, input.Port, input.Protocol,
|
||||
input.GroupID, input.CredentialID, input.Color,
|
||||
string(tags), input.Notes, options,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create connection: %w", err)
|
||||
}
|
||||
id, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get connection id: %w", err)
|
||||
}
|
||||
return s.GetConnection(id)
|
||||
}
|
||||
|
||||
func (s *ConnectionService) GetConnection(id int64) (*Connection, error) {
|
||||
row := s.db.QueryRow(
|
||||
`SELECT id, name, hostname, port, protocol, group_id, credential_id,
|
||||
color, tags, notes, options, sort_order, last_connected, created_at, updated_at
|
||||
FROM connections WHERE id = ?`, id,
|
||||
)
|
||||
|
||||
var c Connection
|
||||
var tagsJSON string
|
||||
var color, notes, options sql.NullString
|
||||
var lastConnected sql.NullTime
|
||||
|
||||
err := row.Scan(
|
||||
&c.ID, &c.Name, &c.Hostname, &c.Port, &c.Protocol,
|
||||
&c.GroupID, &c.CredentialID,
|
||||
&color, &tagsJSON, ¬es, &options,
|
||||
&c.SortOrder, &lastConnected, &c.CreatedAt, &c.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get connection: %w", err)
|
||||
}
|
||||
|
||||
if color.Valid {
|
||||
c.Color = color.String
|
||||
}
|
||||
if notes.Valid {
|
||||
c.Notes = notes.String
|
||||
}
|
||||
if options.Valid {
|
||||
c.Options = options.String
|
||||
}
|
||||
if lastConnected.Valid {
|
||||
c.LastConnected = &lastConnected.Time
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(tagsJSON), &c.Tags); err != nil {
|
||||
c.Tags = []string{}
|
||||
}
|
||||
if c.Tags == nil {
|
||||
c.Tags = []string{}
|
||||
}
|
||||
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func (s *ConnectionService) ListConnections() ([]Connection, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, name, hostname, port, protocol, group_id, credential_id,
|
||||
color, tags, notes, options, sort_order, last_connected, created_at, updated_at
|
||||
FROM connections ORDER BY sort_order, name`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list connections: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return scanConnections(rows)
|
||||
}
|
||||
|
||||
// scanConnections is a shared helper used by ListConnections and (later) Search.
|
||||
func scanConnections(rows *sql.Rows) ([]Connection, error) {
|
||||
var conns []Connection
|
||||
|
||||
for rows.Next() {
|
||||
var c Connection
|
||||
var tagsJSON string
|
||||
var color, notes, options sql.NullString
|
||||
var lastConnected sql.NullTime
|
||||
|
||||
if err := rows.Scan(
|
||||
&c.ID, &c.Name, &c.Hostname, &c.Port, &c.Protocol,
|
||||
&c.GroupID, &c.CredentialID,
|
||||
&color, &tagsJSON, ¬es, &options,
|
||||
&c.SortOrder, &lastConnected, &c.CreatedAt, &c.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan connection: %w", err)
|
||||
}
|
||||
|
||||
if color.Valid {
|
||||
c.Color = color.String
|
||||
}
|
||||
if notes.Valid {
|
||||
c.Notes = notes.String
|
||||
}
|
||||
if options.Valid {
|
||||
c.Options = options.String
|
||||
}
|
||||
if lastConnected.Valid {
|
||||
c.LastConnected = &lastConnected.Time
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(tagsJSON), &c.Tags); err != nil {
|
||||
c.Tags = []string{}
|
||||
}
|
||||
if c.Tags == nil {
|
||||
c.Tags = []string{}
|
||||
}
|
||||
|
||||
conns = append(conns, c)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate connections: %w", err)
|
||||
}
|
||||
|
||||
if conns == nil {
|
||||
conns = []Connection{}
|
||||
}
|
||||
return conns, nil
|
||||
}
|
||||
|
||||
func (s *ConnectionService) UpdateConnection(id int64, input UpdateConnectionInput) (*Connection, error) {
|
||||
setClauses := []string{"updated_at = CURRENT_TIMESTAMP"}
|
||||
args := []interface{}{}
|
||||
|
||||
if input.Name != nil {
|
||||
setClauses = append(setClauses, "name = ?")
|
||||
args = append(args, *input.Name)
|
||||
}
|
||||
if input.Hostname != nil {
|
||||
setClauses = append(setClauses, "hostname = ?")
|
||||
args = append(args, *input.Hostname)
|
||||
}
|
||||
if input.Port != nil {
|
||||
setClauses = append(setClauses, "port = ?")
|
||||
args = append(args, *input.Port)
|
||||
}
|
||||
if input.GroupID != nil {
|
||||
setClauses = append(setClauses, "group_id = ?")
|
||||
args = append(args, *input.GroupID)
|
||||
}
|
||||
if input.CredentialID != nil {
|
||||
setClauses = append(setClauses, "credential_id = ?")
|
||||
args = append(args, *input.CredentialID)
|
||||
}
|
||||
if input.Tags != nil {
|
||||
tags, _ := json.Marshal(input.Tags)
|
||||
setClauses = append(setClauses, "tags = ?")
|
||||
args = append(args, string(tags))
|
||||
}
|
||||
if input.Notes != nil {
|
||||
setClauses = append(setClauses, "notes = ?")
|
||||
args = append(args, *input.Notes)
|
||||
}
|
||||
if input.Color != nil {
|
||||
setClauses = append(setClauses, "color = ?")
|
||||
args = append(args, *input.Color)
|
||||
}
|
||||
if input.Options != nil {
|
||||
setClauses = append(setClauses, "options = ?")
|
||||
args = append(args, *input.Options)
|
||||
}
|
||||
|
||||
args = append(args, id)
|
||||
query := fmt.Sprintf("UPDATE connections SET %s WHERE id = ?", strings.Join(setClauses, ", "))
|
||||
if _, err := s.db.Exec(query, args...); err != nil {
|
||||
return nil, fmt.Errorf("update connection: %w", err)
|
||||
}
|
||||
return s.GetConnection(id)
|
||||
}
|
||||
|
||||
func (s *ConnectionService) DeleteConnection(id int64) error {
|
||||
_, err := s.db.Exec("DELETE FROM connections WHERE id = ?", id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete connection: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@ -1,234 +0,0 @@
|
||||
package connections
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/vstockwell/wraith/internal/db"
|
||||
)
|
||||
|
||||
func strPtr(s string) *string { return &s }
|
||||
|
||||
func setupTestService(t *testing.T) *ConnectionService {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
database, err := db.Open(filepath.Join(dir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("db.Open() error: %v", err)
|
||||
}
|
||||
if err := db.Migrate(database); err != nil {
|
||||
t.Fatalf("db.Migrate() error: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { database.Close() })
|
||||
return NewConnectionService(database)
|
||||
}
|
||||
|
||||
func TestCreateGroup(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
|
||||
g, err := svc.CreateGroup("Servers", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateGroup() error: %v", err)
|
||||
}
|
||||
if g.ID == 0 {
|
||||
t.Error("expected non-zero ID")
|
||||
}
|
||||
if g.Name != "Servers" {
|
||||
t.Errorf("Name = %q, want %q", g.Name, "Servers")
|
||||
}
|
||||
if g.ParentID != nil {
|
||||
t.Errorf("ParentID = %v, want nil", g.ParentID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateSubGroup(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
|
||||
parent, err := svc.CreateGroup("Servers", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateGroup(parent) error: %v", err)
|
||||
}
|
||||
|
||||
child, err := svc.CreateGroup("Production", &parent.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateGroup(child) error: %v", err)
|
||||
}
|
||||
if child.ParentID == nil {
|
||||
t.Fatal("expected non-nil ParentID")
|
||||
}
|
||||
if *child.ParentID != parent.ID {
|
||||
t.Errorf("ParentID = %d, want %d", *child.ParentID, parent.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListGroups(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
|
||||
parent, err := svc.CreateGroup("Servers", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateGroup(parent) error: %v", err)
|
||||
}
|
||||
if _, err := svc.CreateGroup("Production", &parent.ID); err != nil {
|
||||
t.Fatalf("CreateGroup(child) error: %v", err)
|
||||
}
|
||||
|
||||
groups, err := svc.ListGroups()
|
||||
if err != nil {
|
||||
t.Fatalf("ListGroups() error: %v", err)
|
||||
}
|
||||
if len(groups) != 1 {
|
||||
t.Fatalf("len(groups) = %d, want 1 (only root groups)", len(groups))
|
||||
}
|
||||
if groups[0].Name != "Servers" {
|
||||
t.Errorf("groups[0].Name = %q, want %q", groups[0].Name, "Servers")
|
||||
}
|
||||
if len(groups[0].Children) != 1 {
|
||||
t.Fatalf("len(children) = %d, want 1", len(groups[0].Children))
|
||||
}
|
||||
if groups[0].Children[0].Name != "Production" {
|
||||
t.Errorf("child name = %q, want %q", groups[0].Children[0].Name, "Production")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteGroup(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
|
||||
g, err := svc.CreateGroup("ToDelete", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateGroup() error: %v", err)
|
||||
}
|
||||
|
||||
if err := svc.DeleteGroup(g.ID); err != nil {
|
||||
t.Fatalf("DeleteGroup() error: %v", err)
|
||||
}
|
||||
|
||||
groups, err := svc.ListGroups()
|
||||
if err != nil {
|
||||
t.Fatalf("ListGroups() error: %v", err)
|
||||
}
|
||||
if len(groups) != 0 {
|
||||
t.Errorf("len(groups) = %d, want 0 after delete", len(groups))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateConnection(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
|
||||
conn, err := svc.CreateConnection(CreateConnectionInput{
|
||||
Name: "Web Server",
|
||||
Hostname: "10.0.0.1",
|
||||
Port: 22,
|
||||
Protocol: "ssh",
|
||||
Tags: []string{"Prod", "Linux"},
|
||||
Options: `{"keepAliveInterval": 60}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateConnection() error: %v", err)
|
||||
}
|
||||
if conn.ID == 0 {
|
||||
t.Error("expected non-zero ID")
|
||||
}
|
||||
if conn.Name != "Web Server" {
|
||||
t.Errorf("Name = %q, want %q", conn.Name, "Web Server")
|
||||
}
|
||||
if len(conn.Tags) != 2 {
|
||||
t.Fatalf("len(Tags) = %d, want 2", len(conn.Tags))
|
||||
}
|
||||
if conn.Tags[0] != "Prod" || conn.Tags[1] != "Linux" {
|
||||
t.Errorf("Tags = %v, want [Prod Linux]", conn.Tags)
|
||||
}
|
||||
if conn.Options != `{"keepAliveInterval": 60}` {
|
||||
t.Errorf("Options = %q, want JSON blob", conn.Options)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListConnections(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
|
||||
if _, err := svc.CreateConnection(CreateConnectionInput{
|
||||
Name: "Server A",
|
||||
Hostname: "10.0.0.1",
|
||||
Port: 22,
|
||||
Protocol: "ssh",
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateConnection(A) error: %v", err)
|
||||
}
|
||||
if _, err := svc.CreateConnection(CreateConnectionInput{
|
||||
Name: "Server B",
|
||||
Hostname: "10.0.0.2",
|
||||
Port: 3389,
|
||||
Protocol: "rdp",
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateConnection(B) error: %v", err)
|
||||
}
|
||||
|
||||
conns, err := svc.ListConnections()
|
||||
if err != nil {
|
||||
t.Fatalf("ListConnections() error: %v", err)
|
||||
}
|
||||
if len(conns) != 2 {
|
||||
t.Fatalf("len(conns) = %d, want 2", len(conns))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateConnection(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
|
||||
conn, err := svc.CreateConnection(CreateConnectionInput{
|
||||
Name: "Old Name",
|
||||
Hostname: "10.0.0.1",
|
||||
Port: 22,
|
||||
Protocol: "ssh",
|
||||
Tags: []string{"Dev"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateConnection() error: %v", err)
|
||||
}
|
||||
|
||||
updated, err := svc.UpdateConnection(conn.ID, UpdateConnectionInput{
|
||||
Name: strPtr("New Name"),
|
||||
Tags: []string{"Prod", "Linux"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateConnection() error: %v", err)
|
||||
}
|
||||
if updated.Name != "New Name" {
|
||||
t.Errorf("Name = %q, want %q", updated.Name, "New Name")
|
||||
}
|
||||
if len(updated.Tags) != 2 {
|
||||
t.Fatalf("len(Tags) = %d, want 2", len(updated.Tags))
|
||||
}
|
||||
if updated.Tags[0] != "Prod" {
|
||||
t.Errorf("Tags[0] = %q, want %q", updated.Tags[0], "Prod")
|
||||
}
|
||||
// Hostname should remain unchanged
|
||||
if updated.Hostname != "10.0.0.1" {
|
||||
t.Errorf("Hostname = %q, want %q (unchanged)", updated.Hostname, "10.0.0.1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteConnection(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
|
||||
conn, err := svc.CreateConnection(CreateConnectionInput{
|
||||
Name: "ToDelete",
|
||||
Hostname: "10.0.0.1",
|
||||
Port: 22,
|
||||
Protocol: "ssh",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateConnection() error: %v", err)
|
||||
}
|
||||
|
||||
if err := svc.DeleteConnection(conn.ID); err != nil {
|
||||
t.Fatalf("DeleteConnection() error: %v", err)
|
||||
}
|
||||
|
||||
conns, err := svc.ListConnections()
|
||||
if err != nil {
|
||||
t.Fatalf("ListConnections() error: %v", err)
|
||||
}
|
||||
if len(conns) != 0 {
|
||||
t.Errorf("len(conns) = %d, want 0 after delete", len(conns))
|
||||
}
|
||||
}
|
||||
@ -1,408 +0,0 @@
|
||||
package credentials
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
|
||||
"github.com/vstockwell/wraith/internal/vault"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// Credential represents a stored credential (password or SSH key reference).
|
||||
type Credential struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Username string `json:"username"`
|
||||
Domain string `json:"domain"`
|
||||
Type string `json:"type"` // "password" or "ssh_key"
|
||||
SSHKeyID *int64 `json:"sshKeyId"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// SSHKey represents a stored SSH key.
|
||||
type SSHKey struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
KeyType string `json:"keyType"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
PublicKey string `json:"publicKey"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
// CredentialService provides CRUD for credentials with vault encryption.
|
||||
type CredentialService struct {
|
||||
db *sql.DB
|
||||
vault *vault.VaultService
|
||||
}
|
||||
|
||||
// NewCredentialService creates a new CredentialService.
|
||||
func NewCredentialService(db *sql.DB, vault *vault.VaultService) *CredentialService {
|
||||
return &CredentialService{db: db, vault: vault}
|
||||
}
|
||||
|
||||
// CreatePassword creates a password credential (password encrypted via vault).
|
||||
func (s *CredentialService) CreatePassword(name, username, password, domain string) (*Credential, error) {
|
||||
encrypted, err := s.vault.Encrypt(password)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encrypt password: %w", err)
|
||||
}
|
||||
|
||||
result, err := s.db.Exec(
|
||||
`INSERT INTO credentials (name, username, domain, type, encrypted_value)
|
||||
VALUES (?, ?, ?, 'password', ?)`,
|
||||
name, username, domain, encrypted,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert credential: %w", err)
|
||||
}
|
||||
|
||||
id, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get credential id: %w", err)
|
||||
}
|
||||
|
||||
return s.getCredential(id)
|
||||
}
|
||||
|
||||
// CreateSSHKey imports an SSH key (private key encrypted via vault).
|
||||
func (s *CredentialService) CreateSSHKey(name string, privateKeyPEM []byte, passphrase string) (*SSHKey, error) {
|
||||
// Parse the private key to detect type and extract public key
|
||||
keyType := DetectKeyType(privateKeyPEM)
|
||||
|
||||
// Parse the key to get the public key for fingerprinting.
|
||||
// Try without passphrase first (handles unencrypted keys even when a
|
||||
// passphrase is provided for storage), then fall back to using the
|
||||
// passphrase for encrypted PEM keys.
|
||||
var signer ssh.Signer
|
||||
var err error
|
||||
signer, err = ssh.ParsePrivateKey(privateKeyPEM)
|
||||
if err != nil && passphrase != "" {
|
||||
signer, err = ssh.ParsePrivateKeyWithPassphrase(privateKeyPEM, []byte(passphrase))
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse private key: %w", err)
|
||||
}
|
||||
|
||||
pubKey := signer.PublicKey()
|
||||
fingerprint := ssh.FingerprintSHA256(pubKey)
|
||||
publicKeyStr := string(ssh.MarshalAuthorizedKey(pubKey))
|
||||
|
||||
// Encrypt private key via vault
|
||||
encryptedKey, err := s.vault.Encrypt(string(privateKeyPEM))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encrypt private key: %w", err)
|
||||
}
|
||||
|
||||
// Encrypt passphrase via vault (if provided)
|
||||
var encryptedPassphrase sql.NullString
|
||||
if passphrase != "" {
|
||||
ep, err := s.vault.Encrypt(passphrase)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encrypt passphrase: %w", err)
|
||||
}
|
||||
encryptedPassphrase = sql.NullString{String: ep, Valid: true}
|
||||
}
|
||||
|
||||
result, err := s.db.Exec(
|
||||
`INSERT INTO ssh_keys (name, key_type, fingerprint, public_key, encrypted_private_key, passphrase_encrypted)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
name, keyType, fingerprint, publicKeyStr, encryptedKey, encryptedPassphrase,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert ssh key: %w", err)
|
||||
}
|
||||
|
||||
id, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get ssh key id: %w", err)
|
||||
}
|
||||
|
||||
return s.getSSHKey(id)
|
||||
}
|
||||
|
||||
// ListCredentials returns all credentials WITHOUT encrypted values.
|
||||
func (s *CredentialService) ListCredentials() ([]Credential, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, name, username, domain, type, ssh_key_id, created_at, updated_at
|
||||
FROM credentials ORDER BY name`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list credentials: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var creds []Credential
|
||||
for rows.Next() {
|
||||
var c Credential
|
||||
var username, domain sql.NullString
|
||||
if err := rows.Scan(&c.ID, &c.Name, &username, &domain, &c.Type, &c.SSHKeyID, &c.CreatedAt, &c.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan credential: %w", err)
|
||||
}
|
||||
if username.Valid {
|
||||
c.Username = username.String
|
||||
}
|
||||
if domain.Valid {
|
||||
c.Domain = domain.String
|
||||
}
|
||||
creds = append(creds, c)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate credentials: %w", err)
|
||||
}
|
||||
|
||||
if creds == nil {
|
||||
creds = []Credential{}
|
||||
}
|
||||
return creds, nil
|
||||
}
|
||||
|
||||
// ListSSHKeys returns all SSH keys WITHOUT private key data.
|
||||
func (s *CredentialService) ListSSHKeys() ([]SSHKey, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, name, key_type, fingerprint, public_key, created_at
|
||||
FROM ssh_keys ORDER BY name`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list ssh keys: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var keys []SSHKey
|
||||
for rows.Next() {
|
||||
var k SSHKey
|
||||
var keyType, fingerprint, publicKey sql.NullString
|
||||
if err := rows.Scan(&k.ID, &k.Name, &keyType, &fingerprint, &publicKey, &k.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan ssh key: %w", err)
|
||||
}
|
||||
if keyType.Valid {
|
||||
k.KeyType = keyType.String
|
||||
}
|
||||
if fingerprint.Valid {
|
||||
k.Fingerprint = fingerprint.String
|
||||
}
|
||||
if publicKey.Valid {
|
||||
k.PublicKey = publicKey.String
|
||||
}
|
||||
keys = append(keys, k)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate ssh keys: %w", err)
|
||||
}
|
||||
|
||||
if keys == nil {
|
||||
keys = []SSHKey{}
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
// DecryptPassword returns the decrypted password for a credential.
|
||||
func (s *CredentialService) DecryptPassword(credentialID int64) (string, error) {
|
||||
var encrypted sql.NullString
|
||||
err := s.db.QueryRow(
|
||||
"SELECT encrypted_value FROM credentials WHERE id = ? AND type = 'password'",
|
||||
credentialID,
|
||||
).Scan(&encrypted)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get encrypted password: %w", err)
|
||||
}
|
||||
if !encrypted.Valid {
|
||||
return "", fmt.Errorf("no encrypted value for credential %d", credentialID)
|
||||
}
|
||||
|
||||
password, err := s.vault.Decrypt(encrypted.String)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("decrypt password: %w", err)
|
||||
}
|
||||
return password, nil
|
||||
}
|
||||
|
||||
// DecryptSSHKey returns the decrypted private key + passphrase.
|
||||
func (s *CredentialService) DecryptSSHKey(sshKeyID int64) (privateKey []byte, passphrase string, err error) {
|
||||
var encryptedKey string
|
||||
var encryptedPassphrase sql.NullString
|
||||
err = s.db.QueryRow(
|
||||
"SELECT encrypted_private_key, passphrase_encrypted FROM ssh_keys WHERE id = ?",
|
||||
sshKeyID,
|
||||
).Scan(&encryptedKey, &encryptedPassphrase)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("get encrypted ssh key: %w", err)
|
||||
}
|
||||
|
||||
decryptedKey, err := s.vault.Decrypt(encryptedKey)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("decrypt private key: %w", err)
|
||||
}
|
||||
|
||||
if encryptedPassphrase.Valid {
|
||||
passphrase, err = s.vault.Decrypt(encryptedPassphrase.String)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("decrypt passphrase: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return []byte(decryptedKey), passphrase, nil
|
||||
}
|
||||
|
||||
// DeleteCredential removes a credential.
|
||||
func (s *CredentialService) DeleteCredential(id int64) error {
|
||||
_, err := s.db.Exec("DELETE FROM credentials WHERE id = ?", id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete credential: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteSSHKey removes an SSH key.
|
||||
func (s *CredentialService) DeleteSSHKey(id int64) error {
|
||||
_, err := s.db.Exec("DELETE FROM ssh_keys WHERE id = ?", id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete ssh key: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DetectKeyType parses a PEM key and returns its type (rsa, ed25519, ecdsa).
|
||||
func DetectKeyType(pemData []byte) string {
|
||||
block, _ := pem.Decode(pemData)
|
||||
if block == nil {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// Try OpenSSH format first (ssh.MarshalPrivateKey produces OPENSSH PRIVATE KEY blocks)
|
||||
if block.Type == "OPENSSH PRIVATE KEY" {
|
||||
return detectOpenSSHKeyType(block.Bytes)
|
||||
}
|
||||
|
||||
// Try PKCS8 format
|
||||
if key, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil {
|
||||
switch key.(type) {
|
||||
case *rsa.PrivateKey:
|
||||
return "rsa"
|
||||
case ed25519.PrivateKey:
|
||||
return "ed25519"
|
||||
case *ecdsa.PrivateKey:
|
||||
return "ecdsa"
|
||||
}
|
||||
}
|
||||
|
||||
// Try RSA PKCS1
|
||||
if _, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
|
||||
return "rsa"
|
||||
}
|
||||
|
||||
// Try EC
|
||||
if _, err := x509.ParseECPrivateKey(block.Bytes); err == nil {
|
||||
return "ecdsa"
|
||||
}
|
||||
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// detectOpenSSHKeyType parses the OpenSSH private key format to determine key type.
|
||||
func detectOpenSSHKeyType(data []byte) string {
|
||||
// OpenSSH private key format: "openssh-key-v1\0" magic, then fields.
|
||||
// We parse the key using ssh package to determine the type.
|
||||
// Re-encode to PEM to use ssh.ParsePrivateKey which gives us the signer.
|
||||
pemBlock := &pem.Block{
|
||||
Type: "OPENSSH PRIVATE KEY",
|
||||
Bytes: data,
|
||||
}
|
||||
pemBytes := pem.EncodeToMemory(pemBlock)
|
||||
|
||||
signer, err := ssh.ParsePrivateKey(pemBytes)
|
||||
if err != nil {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
return classifyPublicKey(signer.PublicKey())
|
||||
}
|
||||
|
||||
// classifyPublicKey determines the key type from an ssh.PublicKey.
|
||||
func classifyPublicKey(pub ssh.PublicKey) string {
|
||||
keyType := pub.Type()
|
||||
switch keyType {
|
||||
case "ssh-rsa":
|
||||
return "rsa"
|
||||
case "ssh-ed25519":
|
||||
return "ed25519"
|
||||
case "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521":
|
||||
return "ecdsa"
|
||||
default:
|
||||
return keyType
|
||||
}
|
||||
}
|
||||
|
||||
// getCredential retrieves a single credential by ID.
|
||||
func (s *CredentialService) getCredential(id int64) (*Credential, error) {
|
||||
var c Credential
|
||||
var username, domain sql.NullString
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, name, username, domain, type, ssh_key_id, created_at, updated_at
|
||||
FROM credentials WHERE id = ?`, id,
|
||||
).Scan(&c.ID, &c.Name, &username, &domain, &c.Type, &c.SSHKeyID, &c.CreatedAt, &c.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get credential: %w", err)
|
||||
}
|
||||
if username.Valid {
|
||||
c.Username = username.String
|
||||
}
|
||||
if domain.Valid {
|
||||
c.Domain = domain.String
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// getSSHKey retrieves a single SSH key by ID (without private key data).
|
||||
func (s *CredentialService) getSSHKey(id int64) (*SSHKey, error) {
|
||||
var k SSHKey
|
||||
var keyType, fingerprint, publicKey sql.NullString
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, name, key_type, fingerprint, public_key, created_at
|
||||
FROM ssh_keys WHERE id = ?`, id,
|
||||
).Scan(&k.ID, &k.Name, &keyType, &fingerprint, &publicKey, &k.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get ssh key: %w", err)
|
||||
}
|
||||
if keyType.Valid {
|
||||
k.KeyType = keyType.String
|
||||
}
|
||||
if fingerprint.Valid {
|
||||
k.Fingerprint = fingerprint.String
|
||||
}
|
||||
if publicKey.Valid {
|
||||
k.PublicKey = publicKey.String
|
||||
}
|
||||
return &k, nil
|
||||
}
|
||||
|
||||
// generateFingerprint generates an SSH fingerprint string from a public key.
|
||||
func generateFingerprint(pubKey ssh.PublicKey) string {
|
||||
return ssh.FingerprintSHA256(pubKey)
|
||||
}
|
||||
|
||||
// marshalPublicKey returns the authorized_keys format of an SSH public key.
|
||||
func marshalPublicKey(pubKey ssh.PublicKey) string {
|
||||
return base64.StdEncoding.EncodeToString(pubKey.Marshal())
|
||||
}
|
||||
|
||||
// ecdsaCurveName returns the name for an ECDSA curve.
|
||||
func ecdsaCurveName(curve elliptic.Curve) string {
|
||||
switch curve {
|
||||
case elliptic.P256():
|
||||
return "nistp256"
|
||||
case elliptic.P384():
|
||||
return "nistp384"
|
||||
case elliptic.P521():
|
||||
return "nistp521"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
@ -1,176 +0,0 @@
|
||||
package credentials
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/pem"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/vstockwell/wraith/internal/db"
|
||||
"github.com/vstockwell/wraith/internal/vault"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
func setupCredentialService(t *testing.T) *CredentialService {
|
||||
t.Helper()
|
||||
d, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Migrate(d); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { d.Close() })
|
||||
|
||||
salt := []byte("test-salt-exactly-32-bytes-long!")
|
||||
key := vault.DeriveKey("testpassword", salt)
|
||||
vs := vault.NewVaultService(key)
|
||||
|
||||
return NewCredentialService(d, vs)
|
||||
}
|
||||
|
||||
func TestCreatePasswordCredential(t *testing.T) {
|
||||
svc := setupCredentialService(t)
|
||||
cred, err := svc.CreatePassword("Test Cred", "admin", "secret123", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cred.Name != "Test Cred" {
|
||||
t.Error("wrong name")
|
||||
}
|
||||
if cred.Type != "password" {
|
||||
t.Error("wrong type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptPassword(t *testing.T) {
|
||||
svc := setupCredentialService(t)
|
||||
cred, _ := svc.CreatePassword("Test", "admin", "mypassword", "")
|
||||
password, err := svc.DecryptPassword(cred.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if password != "mypassword" {
|
||||
t.Errorf("got %q, want mypassword", password)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListCredentialsExcludesSecrets(t *testing.T) {
|
||||
svc := setupCredentialService(t)
|
||||
svc.CreatePassword("Cred1", "user1", "pass1", "")
|
||||
svc.CreatePassword("Cred2", "user2", "pass2", "")
|
||||
creds, err := svc.ListCredentials()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(creds) != 2 {
|
||||
t.Errorf("got %d, want 2", len(creds))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateSSHKey(t *testing.T) {
|
||||
svc := setupCredentialService(t)
|
||||
// Generate a test key
|
||||
_, priv, _ := ed25519.GenerateKey(rand.Reader)
|
||||
pemBlock, _ := ssh.MarshalPrivateKey(priv, "")
|
||||
keyPEM := pem.EncodeToMemory(pemBlock)
|
||||
|
||||
key, err := svc.CreateSSHKey("My Key", keyPEM, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if key.KeyType != "ed25519" {
|
||||
t.Errorf("KeyType = %q, want ed25519", key.KeyType)
|
||||
}
|
||||
if key.Fingerprint == "" {
|
||||
t.Error("fingerprint should not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptSSHKey(t *testing.T) {
|
||||
svc := setupCredentialService(t)
|
||||
_, priv, _ := ed25519.GenerateKey(rand.Reader)
|
||||
pemBlock, _ := ssh.MarshalPrivateKey(priv, "")
|
||||
keyPEM := pem.EncodeToMemory(pemBlock)
|
||||
|
||||
key, _ := svc.CreateSSHKey("My Key", keyPEM, "testpass")
|
||||
decryptedKey, passphrase, err := svc.DecryptSSHKey(key.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(decryptedKey) == 0 {
|
||||
t.Error("decrypted key should not be empty")
|
||||
}
|
||||
if passphrase != "testpass" {
|
||||
t.Errorf("passphrase = %q, want testpass", passphrase)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectKeyType(t *testing.T) {
|
||||
_, priv, _ := ed25519.GenerateKey(rand.Reader)
|
||||
pemBlock, _ := ssh.MarshalPrivateKey(priv, "")
|
||||
keyPEM := pem.EncodeToMemory(pemBlock)
|
||||
if got := DetectKeyType(keyPEM); got != "ed25519" {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteCredential(t *testing.T) {
|
||||
svc := setupCredentialService(t)
|
||||
cred, _ := svc.CreatePassword("ToDelete", "user", "pass", "")
|
||||
err := svc.DeleteCredential(cred.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
creds, _ := svc.ListCredentials()
|
||||
if len(creds) != 0 {
|
||||
t.Errorf("got %d credentials, want 0", len(creds))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteSSHKey(t *testing.T) {
|
||||
svc := setupCredentialService(t)
|
||||
_, priv, _ := ed25519.GenerateKey(rand.Reader)
|
||||
pemBlock, _ := ssh.MarshalPrivateKey(priv, "")
|
||||
keyPEM := pem.EncodeToMemory(pemBlock)
|
||||
|
||||
key, _ := svc.CreateSSHKey("ToDelete", keyPEM, "")
|
||||
err := svc.DeleteSSHKey(key.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keys, _ := svc.ListSSHKeys()
|
||||
if len(keys) != 0 {
|
||||
t.Errorf("got %d keys, want 0", len(keys))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListSSHKeys(t *testing.T) {
|
||||
svc := setupCredentialService(t)
|
||||
_, priv, _ := ed25519.GenerateKey(rand.Reader)
|
||||
pemBlock, _ := ssh.MarshalPrivateKey(priv, "")
|
||||
keyPEM := pem.EncodeToMemory(pemBlock)
|
||||
|
||||
svc.CreateSSHKey("Key1", keyPEM, "")
|
||||
svc.CreateSSHKey("Key2", keyPEM, "")
|
||||
|
||||
keys, err := svc.ListSSHKeys()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(keys) != 2 {
|
||||
t.Errorf("got %d keys, want 2", len(keys))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePasswordWithDomain(t *testing.T) {
|
||||
svc := setupCredentialService(t)
|
||||
cred, err := svc.CreatePassword("Domain Cred", "admin", "secret", "example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cred.Domain != "example.com" {
|
||||
t.Errorf("domain = %q, want example.com", cred.Domain)
|
||||
}
|
||||
}
|
||||
@ -1,34 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"embed"
|
||||
"fmt"
|
||||
"sort"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationFiles embed.FS
|
||||
|
||||
func Migrate(db *sql.DB) error {
|
||||
entries, err := migrationFiles.ReadDir("migrations")
|
||||
if err != nil {
|
||||
return fmt.Errorf("read migrations: %w", err)
|
||||
}
|
||||
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
return entries[i].Name() < entries[j].Name()
|
||||
})
|
||||
|
||||
for _, entry := range entries {
|
||||
content, err := migrationFiles.ReadFile("migrations/" + entry.Name())
|
||||
if err != nil {
|
||||
return fmt.Errorf("read migration %s: %w", entry.Name(), err)
|
||||
}
|
||||
if _, err := db.Exec(string(content)); err != nil {
|
||||
return fmt.Errorf("execute migration %s: %w", entry.Name(), err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@ -1,101 +0,0 @@
|
||||
-- 001_initial.sql
|
||||
CREATE TABLE IF NOT EXISTS groups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
parent_id INTEGER REFERENCES groups(id) ON DELETE SET NULL,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
icon TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ssh_keys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
key_type TEXT,
|
||||
fingerprint TEXT,
|
||||
public_key TEXT,
|
||||
encrypted_private_key TEXT NOT NULL,
|
||||
passphrase_encrypted TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS credentials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
username TEXT,
|
||||
domain TEXT,
|
||||
type TEXT NOT NULL CHECK(type IN ('password','ssh_key')),
|
||||
encrypted_value TEXT,
|
||||
ssh_key_id INTEGER REFERENCES ssh_keys(id) ON DELETE SET NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS connections (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
hostname TEXT NOT NULL,
|
||||
port INTEGER NOT NULL DEFAULT 22,
|
||||
protocol TEXT NOT NULL CHECK(protocol IN ('ssh','rdp')),
|
||||
group_id INTEGER REFERENCES groups(id) ON DELETE SET NULL,
|
||||
credential_id INTEGER REFERENCES credentials(id) ON DELETE SET NULL,
|
||||
color TEXT,
|
||||
tags TEXT DEFAULT '[]',
|
||||
notes TEXT,
|
||||
options TEXT DEFAULT '{}',
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
last_connected DATETIME,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS themes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
foreground TEXT NOT NULL,
|
||||
background TEXT NOT NULL,
|
||||
cursor TEXT NOT NULL,
|
||||
black TEXT NOT NULL,
|
||||
red TEXT NOT NULL,
|
||||
green TEXT NOT NULL,
|
||||
yellow TEXT NOT NULL,
|
||||
blue TEXT NOT NULL,
|
||||
magenta TEXT NOT NULL,
|
||||
cyan TEXT NOT NULL,
|
||||
white TEXT NOT NULL,
|
||||
bright_black TEXT NOT NULL,
|
||||
bright_red TEXT NOT NULL,
|
||||
bright_green TEXT NOT NULL,
|
||||
bright_yellow TEXT NOT NULL,
|
||||
bright_blue TEXT NOT NULL,
|
||||
bright_magenta TEXT NOT NULL,
|
||||
bright_cyan TEXT NOT NULL,
|
||||
bright_white TEXT NOT NULL,
|
||||
selection_bg TEXT,
|
||||
selection_fg TEXT,
|
||||
is_builtin BOOLEAN DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS connection_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
connection_id INTEGER NOT NULL REFERENCES connections(id) ON DELETE CASCADE,
|
||||
protocol TEXT NOT NULL,
|
||||
connected_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
disconnected_at DATETIME,
|
||||
duration_secs INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS host_keys (
|
||||
hostname TEXT NOT NULL,
|
||||
port INTEGER NOT NULL,
|
||||
key_type TEXT NOT NULL,
|
||||
fingerprint TEXT NOT NULL,
|
||||
raw_key TEXT,
|
||||
first_seen DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (hostname, port, key_type)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
@ -1,39 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
func Open(dbPath string) (*sql.DB, error) {
|
||||
dir := filepath.Dir(dbPath)
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return nil, fmt.Errorf("create db directory: %w", err)
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open database: %w", err)
|
||||
}
|
||||
|
||||
if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("set WAL mode: %w", err)
|
||||
}
|
||||
|
||||
if _, err := db.Exec("PRAGMA busy_timeout=5000"); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("set busy_timeout: %w", err)
|
||||
}
|
||||
|
||||
if _, err := db.Exec("PRAGMA foreign_keys=ON"); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("enable foreign keys: %w", err)
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
@ -1,85 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOpenCreatesDatabase(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "test.db")
|
||||
|
||||
db, err := Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Open() error: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
||||
t.Fatal("database file was not created")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenSetsWALMode(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
db, err := Open(filepath.Join(dir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("Open() error: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
var mode string
|
||||
err = db.QueryRow("PRAGMA journal_mode").Scan(&mode)
|
||||
if err != nil {
|
||||
t.Fatalf("PRAGMA query error: %v", err)
|
||||
}
|
||||
if mode != "wal" {
|
||||
t.Errorf("journal_mode = %q, want %q", mode, "wal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenSetsBusyTimeout(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
db, err := Open(filepath.Join(dir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("Open() error: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
var timeout int
|
||||
err = db.QueryRow("PRAGMA busy_timeout").Scan(&timeout)
|
||||
if err != nil {
|
||||
t.Fatalf("PRAGMA query error: %v", err)
|
||||
}
|
||||
if timeout != 5000 {
|
||||
t.Errorf("busy_timeout = %d, want %d", timeout, 5000)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateCreatesAllTables(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
db, err := Open(filepath.Join(dir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("Open() error: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if err := Migrate(db); err != nil {
|
||||
t.Fatalf("Migrate() error: %v", err)
|
||||
}
|
||||
|
||||
expectedTables := []string{
|
||||
"groups", "connections", "credentials", "ssh_keys",
|
||||
"themes", "connection_history", "host_keys", "settings",
|
||||
}
|
||||
for _, table := range expectedTables {
|
||||
var name string
|
||||
err := db.QueryRow(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name=?", table,
|
||||
).Scan(&name)
|
||||
if err != nil {
|
||||
t.Errorf("table %q not found: %v", table, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,322 +0,0 @@
|
||||
package importer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/vstockwell/wraith/internal/plugin"
|
||||
)
|
||||
|
||||
// MobaConfImporter parses MobaXTerm .mobaconf configuration files and extracts
|
||||
// connections, groups, host keys, and terminal theme settings.
|
||||
type MobaConfImporter struct{}
|
||||
|
||||
func (m *MobaConfImporter) Name() string { return "MobaXTerm" }
|
||||
func (m *MobaConfImporter) FileExtensions() []string { return []string{".mobaconf"} }
|
||||
|
||||
// Parse reads a .mobaconf file and returns the extracted import result.
|
||||
func (m *MobaConfImporter) Parse(data []byte) (*plugin.ImportResult, error) {
|
||||
result := &plugin.ImportResult{
|
||||
Groups: []plugin.ImportGroup{},
|
||||
Connections: []plugin.ImportConnection{},
|
||||
HostKeys: []plugin.ImportHostKey{},
|
||||
}
|
||||
|
||||
sections := parseSections(string(data))
|
||||
|
||||
// Parse [Bookmarks_N] sections
|
||||
for name, lines := range sections {
|
||||
if !strings.HasPrefix(name, "Bookmarks") {
|
||||
continue
|
||||
}
|
||||
|
||||
groupName := ""
|
||||
for _, line := range lines {
|
||||
key, value := splitKV(line)
|
||||
if key == "SubRep" && value != "" {
|
||||
groupName = value
|
||||
result.Groups = append(result.Groups, plugin.ImportGroup{
|
||||
Name: groupName,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Parse session lines within the bookmarks section
|
||||
for _, line := range lines {
|
||||
key, value := splitKV(line)
|
||||
if key == "SubRep" || key == "ImgNum" || key == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Session lines start with a name (possibly prefixed with *)
|
||||
// and contain = followed by the session definition
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
sessionName := key
|
||||
// Strip leading * from session names (marks favorites)
|
||||
sessionName = strings.TrimPrefix(sessionName, "*")
|
||||
|
||||
conn := parseSessionLine(sessionName, value)
|
||||
if conn != nil {
|
||||
conn.GroupName = groupName
|
||||
result.Connections = append(result.Connections, *conn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse [SSH_Hostkeys] section
|
||||
if hostKeyLines, ok := sections["SSH_Hostkeys"]; ok {
|
||||
for _, line := range hostKeyLines {
|
||||
key, value := splitKV(line)
|
||||
if key == "" || value == "" {
|
||||
continue
|
||||
}
|
||||
hk := parseHostKeyLine(key, value)
|
||||
if hk != nil {
|
||||
result.HostKeys = append(result.HostKeys, *hk)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse [Colors] section
|
||||
if colorLines, ok := sections["Colors"]; ok {
|
||||
theme := parseColorSection(colorLines)
|
||||
if theme != nil {
|
||||
result.Theme = theme
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// parseSections splits .mobaconf INI content into named sections.
|
||||
// Returns a map of section name -> lines within that section.
|
||||
func parseSections(content string) map[string][]string {
|
||||
sections := make(map[string][]string)
|
||||
currentSection := ""
|
||||
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
line = strings.TrimRight(line, "\r")
|
||||
|
||||
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
|
||||
currentSection = line[1 : len(line)-1]
|
||||
if _, exists := sections[currentSection]; !exists {
|
||||
sections[currentSection] = []string{}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if currentSection != "" && line != "" {
|
||||
sections[currentSection] = append(sections[currentSection], line)
|
||||
}
|
||||
}
|
||||
|
||||
return sections
|
||||
}
|
||||
|
||||
// splitKV splits a line on the first = sign into key and value.
|
||||
func splitKV(line string) (string, string) {
|
||||
idx := strings.Index(line, "=")
|
||||
if idx < 0 {
|
||||
return line, ""
|
||||
}
|
||||
return strings.TrimSpace(line[:idx]), strings.TrimSpace(line[idx+1:])
|
||||
}
|
||||
|
||||
// parseSessionLine parses a MobaXTerm session definition string.
|
||||
// Format: #type#flags%field1%field2%...
|
||||
// SSH (#109#): fields[0]=host, fields[1]=port, fields[2]=username
|
||||
// RDP (#91#): fields[0]=host, fields[1]=port, fields[2]=username
|
||||
func parseSessionLine(name, value string) *plugin.ImportConnection {
|
||||
// Find the protocol type marker
|
||||
hashIdx := strings.Index(value, "#")
|
||||
if hashIdx < 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Extract the type number between # markers
|
||||
rest := value[hashIdx+1:]
|
||||
secondHash := strings.Index(rest, "#")
|
||||
if secondHash < 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
typeStr := rest[:secondHash]
|
||||
afterType := rest[secondHash+1:]
|
||||
|
||||
// The remainder after the type and flags section: flags%field1%field2%...
|
||||
// Split on the first % to separate flags from fields, then split the rest
|
||||
parts := strings.Split(afterType, "%")
|
||||
if len(parts) < 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// parts[0] = flags (numeric), parts[1:] = fields
|
||||
fields := parts[1:]
|
||||
|
||||
conn := &plugin.ImportConnection{
|
||||
Name: name,
|
||||
}
|
||||
|
||||
switch typeStr {
|
||||
case "109": // SSH
|
||||
conn.Protocol = "ssh"
|
||||
if len(fields) >= 1 {
|
||||
conn.Hostname = fields[0]
|
||||
}
|
||||
if len(fields) >= 2 {
|
||||
port, err := strconv.Atoi(fields[1])
|
||||
if err == nil && port > 0 {
|
||||
conn.Port = port
|
||||
} else {
|
||||
conn.Port = 22
|
||||
}
|
||||
} else {
|
||||
conn.Port = 22
|
||||
}
|
||||
if len(fields) >= 3 {
|
||||
conn.Username = fields[2]
|
||||
}
|
||||
case "91": // RDP
|
||||
conn.Protocol = "rdp"
|
||||
if len(fields) >= 1 {
|
||||
conn.Hostname = fields[0]
|
||||
}
|
||||
if len(fields) >= 2 {
|
||||
port, err := strconv.Atoi(fields[1])
|
||||
if err == nil && port > 0 {
|
||||
conn.Port = port
|
||||
} else {
|
||||
conn.Port = 3389
|
||||
}
|
||||
} else {
|
||||
conn.Port = 3389
|
||||
}
|
||||
if len(fields) >= 3 {
|
||||
conn.Username = fields[2]
|
||||
}
|
||||
default:
|
||||
// Unknown protocol type — skip
|
||||
return nil
|
||||
}
|
||||
|
||||
if conn.Hostname == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return conn
|
||||
}
|
||||
|
||||
// parseHostKeyLine parses a line from the [SSH_Hostkeys] section.
|
||||
// Key format: keytype@port:hostname
|
||||
// Value: the fingerprint data
|
||||
func parseHostKeyLine(key, value string) *plugin.ImportHostKey {
|
||||
// Parse key format: keytype@port:hostname
|
||||
atIdx := strings.Index(key, "@")
|
||||
if atIdx < 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
keyType := key[:atIdx]
|
||||
rest := key[atIdx+1:]
|
||||
|
||||
colonIdx := strings.Index(rest, ":")
|
||||
if colonIdx < 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
portStr := rest[:colonIdx]
|
||||
hostname := rest[colonIdx+1:]
|
||||
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &plugin.ImportHostKey{
|
||||
Hostname: hostname,
|
||||
Port: port,
|
||||
KeyType: keyType,
|
||||
Fingerprint: value,
|
||||
}
|
||||
}
|
||||
|
||||
// parseColorSection extracts a terminal theme from the [Colors] section.
|
||||
func parseColorSection(lines []string) *plugin.ImportTheme {
|
||||
colorMap := make(map[string]string)
|
||||
for _, line := range lines {
|
||||
key, value := splitKV(line)
|
||||
if key != "" && value != "" {
|
||||
colorMap[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
// Need at least foreground and background to form a theme
|
||||
fg, hasFg := colorMap["ForegroundColour"]
|
||||
bg, hasBg := colorMap["BackgroundColour"]
|
||||
if !hasFg || !hasBg {
|
||||
return nil
|
||||
}
|
||||
|
||||
theme := &plugin.ImportTheme{
|
||||
Name: "MobaXTerm Import",
|
||||
Foreground: rgbToHex(fg),
|
||||
Background: rgbToHex(bg),
|
||||
}
|
||||
|
||||
if cursor, ok := colorMap["CursorColour"]; ok {
|
||||
theme.Cursor = rgbToHex(cursor)
|
||||
}
|
||||
|
||||
// Map MobaXTerm color names to the 16-color array positions
|
||||
// Standard ANSI order: black, red, green, yellow, blue, magenta, cyan, white,
|
||||
// then bright variants in the same order
|
||||
colorNames := [16][2]string{
|
||||
{"Black", ""},
|
||||
{"Red", ""},
|
||||
{"Green", ""},
|
||||
{"Yellow", ""},
|
||||
{"Blue", ""},
|
||||
{"Magenta", ""},
|
||||
{"Cyan", ""},
|
||||
{"White", ""},
|
||||
{"BoldBlack", ""},
|
||||
{"BoldRed", ""},
|
||||
{"BoldGreen", ""},
|
||||
{"BoldYellow", ""},
|
||||
{"BoldBlue", ""},
|
||||
{"BoldMagenta", ""},
|
||||
{"BoldCyan", ""},
|
||||
{"BoldWhite", ""},
|
||||
}
|
||||
|
||||
for i, cn := range colorNames {
|
||||
name := cn[0]
|
||||
if val, ok := colorMap[name]; ok {
|
||||
theme.Colors[i] = rgbToHex(val)
|
||||
}
|
||||
}
|
||||
|
||||
return theme
|
||||
}
|
||||
|
||||
// rgbToHex converts a "R,G,B" string to "#RRGGBB" hex format.
|
||||
func rgbToHex(rgb string) string {
|
||||
parts := strings.Split(rgb, ",")
|
||||
if len(parts) != 3 {
|
||||
return rgb // Return as-is if not in expected format
|
||||
}
|
||||
|
||||
r, err1 := strconv.Atoi(strings.TrimSpace(parts[0]))
|
||||
g, err2 := strconv.Atoi(strings.TrimSpace(parts[1]))
|
||||
b, err3 := strconv.Atoi(strings.TrimSpace(parts[2]))
|
||||
|
||||
if err1 != nil || err2 != nil || err3 != nil {
|
||||
return rgb
|
||||
}
|
||||
|
||||
return fmt.Sprintf("#%02x%02x%02x", r, g, b)
|
||||
}
|
||||
@ -1,236 +0,0 @@
|
||||
package importer
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseMobaConf(t *testing.T) {
|
||||
data, err := os.ReadFile("../../docs/config-export.mobaconf")
|
||||
if err != nil {
|
||||
t.Skip("config file not found")
|
||||
}
|
||||
|
||||
imp := &MobaConfImporter{}
|
||||
result, err := imp.Parse(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(result.Groups) == 0 {
|
||||
t.Error("should parse groups")
|
||||
}
|
||||
|
||||
if len(result.Connections) == 0 {
|
||||
t.Error("should parse connections")
|
||||
}
|
||||
|
||||
// Check that we found the expected group
|
||||
foundGroup := false
|
||||
for _, g := range result.Groups {
|
||||
if g.Name == "AAA Vantz's Stuff" {
|
||||
foundGroup = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundGroup {
|
||||
t.Error("should find 'AAA Vantz's Stuff' group")
|
||||
}
|
||||
|
||||
// Check for known SSH connections
|
||||
foundAsgard := false
|
||||
foundDocker := false
|
||||
for _, c := range result.Connections {
|
||||
if c.Name == "Asgard" {
|
||||
foundAsgard = true
|
||||
if c.Hostname != "192.168.1.4" {
|
||||
t.Errorf("Asgard hostname = %q, want 192.168.1.4", c.Hostname)
|
||||
}
|
||||
if c.Port != 22 {
|
||||
t.Errorf("Asgard port = %d, want 22", c.Port)
|
||||
}
|
||||
if c.Protocol != "ssh" {
|
||||
t.Errorf("Asgard protocol = %q, want ssh", c.Protocol)
|
||||
}
|
||||
if c.Username != "vstockwell" {
|
||||
t.Errorf("Asgard username = %q, want vstockwell", c.Username)
|
||||
}
|
||||
}
|
||||
if c.Name == "Docker" {
|
||||
foundDocker = true
|
||||
if c.Hostname != "155.254.29.221" {
|
||||
t.Errorf("Docker hostname = %q, want 155.254.29.221", c.Hostname)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundAsgard {
|
||||
t.Error("should find Asgard connection")
|
||||
}
|
||||
if !foundDocker {
|
||||
t.Error("should find Docker connection")
|
||||
}
|
||||
|
||||
// Check for RDP connections
|
||||
foundRDP := false
|
||||
for _, c := range result.Connections {
|
||||
if c.Name == "CLT-VMHOST01" {
|
||||
foundRDP = true
|
||||
if c.Protocol != "rdp" {
|
||||
t.Errorf("CLT-VMHOST01 protocol = %q, want rdp", c.Protocol)
|
||||
}
|
||||
if c.Hostname != "100.64.1.204" {
|
||||
t.Errorf("CLT-VMHOST01 hostname = %q, want 100.64.1.204", c.Hostname)
|
||||
}
|
||||
if c.Port != 3389 {
|
||||
t.Errorf("CLT-VMHOST01 port = %d, want 3389", c.Port)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundRDP {
|
||||
t.Error("should find CLT-VMHOST01 RDP connection")
|
||||
}
|
||||
|
||||
// Check host keys were parsed
|
||||
if len(result.HostKeys) == 0 {
|
||||
t.Error("should parse host keys")
|
||||
}
|
||||
|
||||
// Check theme was parsed
|
||||
if result.Theme == nil {
|
||||
t.Error("should parse theme from Colors section")
|
||||
} else {
|
||||
if result.Theme.Foreground != "#ececec" {
|
||||
t.Errorf("theme foreground = %q, want #ececec", result.Theme.Foreground)
|
||||
}
|
||||
if result.Theme.Background != "#242424" {
|
||||
t.Errorf("theme background = %q, want #242424", result.Theme.Background)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSSHSession(t *testing.T) {
|
||||
line := `#109#0%192.168.1.4%22%vstockwell%%-1%-1%%%22%%0%0%0%V:\ssh-key%%-1%0%0%0%%1080%%0%0%1%%0%%%%0%-1%-1%0`
|
||||
conn := parseSessionLine("Asgard", line)
|
||||
if conn == nil {
|
||||
t.Fatal("should parse SSH session")
|
||||
}
|
||||
if conn.Hostname != "192.168.1.4" {
|
||||
t.Errorf("hostname = %q, want 192.168.1.4", conn.Hostname)
|
||||
}
|
||||
if conn.Port != 22 {
|
||||
t.Errorf("port = %d, want 22", conn.Port)
|
||||
}
|
||||
if conn.Protocol != "ssh" {
|
||||
t.Errorf("protocol = %q, want ssh", conn.Protocol)
|
||||
}
|
||||
if conn.Username != "vstockwell" {
|
||||
t.Errorf("username = %q, want vstockwell", conn.Username)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRDPSession(t *testing.T) {
|
||||
line := `#91#4%100.64.1.204%3389%%-1%0%0%0%-1%0%0%-1`
|
||||
conn := parseSessionLine("CLT-VMHOST01", line)
|
||||
if conn == nil {
|
||||
t.Fatal("should parse RDP session")
|
||||
}
|
||||
if conn.Hostname != "100.64.1.204" {
|
||||
t.Errorf("hostname = %q, want 100.64.1.204", conn.Hostname)
|
||||
}
|
||||
if conn.Port != 3389 {
|
||||
t.Errorf("port = %d, want 3389", conn.Port)
|
||||
}
|
||||
if conn.Protocol != "rdp" {
|
||||
t.Errorf("protocol = %q, want rdp", conn.Protocol)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSSHSessionWithUsername(t *testing.T) {
|
||||
line := `#109#0%192.168.1.105%22%root%%-1%-1%%%%%0%0%0%%%-1%0%0%0%%1080%%0%0%1`
|
||||
conn := parseSessionLine("Node 1(top)", line)
|
||||
if conn == nil {
|
||||
t.Fatal("should parse SSH session")
|
||||
}
|
||||
if conn.Username != "root" {
|
||||
t.Errorf("username = %q, want root", conn.Username)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRDPSessionWithUsername(t *testing.T) {
|
||||
line := `#91#4%192.154.253.107%3389%administrator%0%0%0%0%-1%0%0%-1`
|
||||
conn := parseSessionLine("Win Game Host", line)
|
||||
if conn == nil {
|
||||
t.Fatal("should parse RDP session")
|
||||
}
|
||||
if conn.Username != "administrator" {
|
||||
t.Errorf("username = %q, want administrator", conn.Username)
|
||||
}
|
||||
if conn.Hostname != "192.154.253.107" {
|
||||
t.Errorf("hostname = %q, want 192.154.253.107", conn.Hostname)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRgbToHex(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"236,236,236", "#ececec"},
|
||||
{"0,0,0", "#000000"},
|
||||
{"255,255,255", "#ffffff"},
|
||||
{"36,36,36", "#242424"},
|
||||
{"128,128,128", "#808080"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := rgbToHex(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("rgbToHex(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHostKeyLine(t *testing.T) {
|
||||
hk := parseHostKeyLine(
|
||||
"ssh-ed25519@22:192.168.1.4",
|
||||
"0x29ac3a21e1d5166c45aed41398d71cc889b683d01e1a019bf23cb2e1ce1c8276,0x2a8e2417caf686ac4b219cc3b94cd726fb49d2559bd8725ac2281b842845582b",
|
||||
)
|
||||
if hk == nil {
|
||||
t.Fatal("should parse host key")
|
||||
}
|
||||
if hk.Hostname != "192.168.1.4" {
|
||||
t.Errorf("hostname = %q, want 192.168.1.4", hk.Hostname)
|
||||
}
|
||||
if hk.Port != 22 {
|
||||
t.Errorf("port = %d, want 22", hk.Port)
|
||||
}
|
||||
if hk.KeyType != "ssh-ed25519" {
|
||||
t.Errorf("keyType = %q, want ssh-ed25519", hk.KeyType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImporterInterface(t *testing.T) {
|
||||
imp := &MobaConfImporter{}
|
||||
if imp.Name() != "MobaXTerm" {
|
||||
t.Errorf("Name() = %q, want MobaXTerm", imp.Name())
|
||||
}
|
||||
exts := imp.FileExtensions()
|
||||
if len(exts) != 1 || exts[0] != ".mobaconf" {
|
||||
t.Errorf("FileExtensions() = %v, want [.mobaconf]", exts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseUnknownProtocol(t *testing.T) {
|
||||
line := `#999#0%hostname%22%user`
|
||||
conn := parseSessionLine("Unknown", line)
|
||||
if conn != nil {
|
||||
t.Error("should return nil for unknown protocol type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEmptySessionLine(t *testing.T) {
|
||||
conn := parseSessionLine("Empty", "")
|
||||
if conn != nil {
|
||||
t.Error("should return nil for empty session line")
|
||||
}
|
||||
}
|
||||
@ -1,57 +0,0 @@
|
||||
package plugin
|
||||
|
||||
type ProtocolHandler interface {
|
||||
Name() string
|
||||
Connect(config map[string]interface{}) (Session, error)
|
||||
Disconnect(sessionID string) error
|
||||
}
|
||||
|
||||
type Session interface {
|
||||
ID() string
|
||||
Protocol() string
|
||||
Write(data []byte) error
|
||||
Close() error
|
||||
}
|
||||
|
||||
type Importer interface {
|
||||
Name() string
|
||||
FileExtensions() []string
|
||||
Parse(data []byte) (*ImportResult, error)
|
||||
}
|
||||
|
||||
type ImportResult struct {
|
||||
Groups []ImportGroup `json:"groups"`
|
||||
Connections []ImportConnection `json:"connections"`
|
||||
HostKeys []ImportHostKey `json:"hostKeys"`
|
||||
Theme *ImportTheme `json:"theme,omitempty"`
|
||||
}
|
||||
|
||||
type ImportGroup struct {
|
||||
Name string `json:"name"`
|
||||
ParentName string `json:"parentName,omitempty"`
|
||||
}
|
||||
|
||||
type ImportConnection struct {
|
||||
Name string `json:"name"`
|
||||
Hostname string `json:"hostname"`
|
||||
Port int `json:"port"`
|
||||
Protocol string `json:"protocol"`
|
||||
Username string `json:"username"`
|
||||
GroupName string `json:"groupName"`
|
||||
Notes string `json:"notes"`
|
||||
}
|
||||
|
||||
type ImportHostKey struct {
|
||||
Hostname string `json:"hostname"`
|
||||
Port int `json:"port"`
|
||||
KeyType string `json:"keyType"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
}
|
||||
|
||||
type ImportTheme struct {
|
||||
Name string `json:"name"`
|
||||
Foreground string `json:"foreground"`
|
||||
Background string `json:"background"`
|
||||
Cursor string `json:"cursor"`
|
||||
Colors [16]string `json:"colors"`
|
||||
}
|
||||
@ -1,47 +0,0 @@
|
||||
package plugin
|
||||
|
||||
import "fmt"
|
||||
|
||||
type Registry struct {
|
||||
protocols map[string]ProtocolHandler
|
||||
importers map[string]Importer
|
||||
}
|
||||
|
||||
func NewRegistry() *Registry {
|
||||
return &Registry{
|
||||
protocols: make(map[string]ProtocolHandler),
|
||||
importers: make(map[string]Importer),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Registry) RegisterProtocol(handler ProtocolHandler) {
|
||||
r.protocols[handler.Name()] = handler
|
||||
}
|
||||
|
||||
func (r *Registry) RegisterImporter(imp Importer) {
|
||||
r.importers[imp.Name()] = imp
|
||||
}
|
||||
|
||||
func (r *Registry) GetProtocol(name string) (ProtocolHandler, error) {
|
||||
h, ok := r.protocols[name]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("protocol handler %q not registered", name)
|
||||
}
|
||||
return h, nil
|
||||
}
|
||||
|
||||
func (r *Registry) GetImporter(name string) (Importer, error) {
|
||||
imp, ok := r.importers[name]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("importer %q not registered", name)
|
||||
}
|
||||
return imp, nil
|
||||
}
|
||||
|
||||
func (r *Registry) ListProtocols() []string {
|
||||
names := make([]string, 0, len(r.protocols))
|
||||
for name := range r.protocols {
|
||||
names = append(names, name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
@ -1,14 +0,0 @@
|
||||
package rdp
|
||||
|
||||
import "runtime"
|
||||
|
||||
// NewProductionBackend returns the appropriate RDP backend for the current
|
||||
// platform. On Windows it returns a FreeRDPBackend that loads freerdp3.dll
|
||||
// at runtime via syscall. On other platforms it falls back to MockBackend
|
||||
// so the application can still be developed and tested without FreeRDP.
|
||||
func NewProductionBackend() RDPBackend {
|
||||
if runtime.GOOS == "windows" {
|
||||
return NewFreeRDPBackend()
|
||||
}
|
||||
return NewMockBackend()
|
||||
}
|
||||
@ -1,45 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package rdp
|
||||
|
||||
import "fmt"
|
||||
|
||||
// FreeRDPBackend is a stub on non-Windows platforms. The real implementation
|
||||
// lives in freerdp_windows.go and loads FreeRDP3 DLLs via syscall at runtime.
|
||||
type FreeRDPBackend struct{}
|
||||
|
||||
// NewFreeRDPBackend creates a stub backend that returns errors on all operations.
|
||||
func NewFreeRDPBackend() *FreeRDPBackend {
|
||||
return &FreeRDPBackend{}
|
||||
}
|
||||
|
||||
func (f *FreeRDPBackend) Connect(config RDPConfig) error {
|
||||
return fmt.Errorf("FreeRDP backend is only available on Windows — use MockBackend for development")
|
||||
}
|
||||
|
||||
func (f *FreeRDPBackend) Disconnect() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *FreeRDPBackend) SendMouseEvent(x, y int, flags uint32) error {
|
||||
return fmt.Errorf("FreeRDP backend is not available on this platform")
|
||||
}
|
||||
|
||||
func (f *FreeRDPBackend) SendKeyEvent(scancode uint32, pressed bool) error {
|
||||
return fmt.Errorf("FreeRDP backend is not available on this platform")
|
||||
}
|
||||
|
||||
func (f *FreeRDPBackend) SendClipboard(data string) error {
|
||||
return fmt.Errorf("FreeRDP backend is not available on this platform")
|
||||
}
|
||||
|
||||
func (f *FreeRDPBackend) GetFrame() ([]byte, error) {
|
||||
return nil, fmt.Errorf("FreeRDP backend is not available on this platform")
|
||||
}
|
||||
|
||||
func (f *FreeRDPBackend) IsConnected() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// Ensure FreeRDPBackend satisfies the RDPBackend interface at compile time.
|
||||
var _ RDPBackend = (*FreeRDPBackend)(nil)
|
||||
@ -1,289 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package rdp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
var (
|
||||
libfreerdp = syscall.NewLazyDLL("libfreerdp3.dll")
|
||||
libfreerdpClient = syscall.NewLazyDLL("libfreerdp-client3.dll")
|
||||
|
||||
// Instance lifecycle
|
||||
procFreerdpNew = libfreerdp.NewProc("freerdp_new")
|
||||
procFreerdpFree = libfreerdp.NewProc("freerdp_free")
|
||||
procFreerdpConnect = libfreerdp.NewProc("freerdp_connect")
|
||||
procFreerdpDisconnect = libfreerdp.NewProc("freerdp_disconnect")
|
||||
|
||||
// Settings
|
||||
procSettingsSetString = libfreerdp.NewProc("freerdp_settings_set_string")
|
||||
procSettingsSetUint32 = libfreerdp.NewProc("freerdp_settings_set_uint32")
|
||||
procSettingsSetBool = libfreerdp.NewProc("freerdp_settings_set_bool")
|
||||
|
||||
// Input
|
||||
procInputSendMouse = libfreerdp.NewProc("freerdp_input_send_mouse_event")
|
||||
procInputSendKeyboard = libfreerdp.NewProc("freerdp_input_send_keyboard_event")
|
||||
|
||||
// Event loop
|
||||
procCheckEventHandles = libfreerdp.NewProc("freerdp_check_event_handles")
|
||||
|
||||
// Client helpers
|
||||
procClientNew = libfreerdpClient.NewProc("freerdp_client_context_new")
|
||||
procClientFree = libfreerdpClient.NewProc("freerdp_client_context_free")
|
||||
)
|
||||
|
||||
// FreeRDP settings IDs (from FreeRDP3 freerdp/settings.h)
|
||||
const (
|
||||
FreeRDP_ServerHostname = 20
|
||||
FreeRDP_ServerPort = 21
|
||||
FreeRDP_Username = 22
|
||||
FreeRDP_Password = 23
|
||||
FreeRDP_Domain = 24
|
||||
FreeRDP_DesktopWidth = 1025
|
||||
FreeRDP_DesktopHeight = 1026
|
||||
FreeRDP_ColorDepth = 1027
|
||||
FreeRDP_FullscreenMode = 1028
|
||||
FreeRDP_IgnoreCertificate = 4556
|
||||
FreeRDP_AuthenticationOnly = 4554
|
||||
FreeRDP_NlaSecurity = 4560
|
||||
FreeRDP_TlsSecurity = 4561
|
||||
FreeRDP_RdpSecurity = 4562
|
||||
)
|
||||
|
||||
// Keyboard event flags for FreeRDP input calls.
|
||||
const (
|
||||
KBD_FLAGS_EXTENDED = 0x0100
|
||||
KBD_FLAGS_DOWN = 0x4000
|
||||
KBD_FLAGS_RELEASE = 0x8000
|
||||
)
|
||||
|
||||
// FreeRDPBackend implements RDPBackend using the FreeRDP3 library loaded at
|
||||
// runtime via syscall.NewLazyDLL. This avoids any CGO dependency and allows
|
||||
// cross-compilation from Linux while the DLLs are resolved at runtime on
|
||||
// Windows.
|
||||
type FreeRDPBackend struct {
|
||||
instance uintptr // freerdp*
|
||||
settings uintptr // rdpSettings*
|
||||
input uintptr // rdpInput*
|
||||
buffer *PixelBuffer
|
||||
connected bool
|
||||
config RDPConfig
|
||||
mu sync.Mutex
|
||||
stopCh chan struct{}
|
||||
}
|
||||
|
||||
// NewFreeRDPBackend creates a new FreeRDP-backed RDP backend. The underlying
|
||||
// DLLs are not loaded until Connect is called.
|
||||
func NewFreeRDPBackend() *FreeRDPBackend {
|
||||
return &FreeRDPBackend{
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Connect establishes an RDP session using FreeRDP3. It creates a new FreeRDP
|
||||
// instance, configures connection settings, and starts the event loop.
|
||||
func (f *FreeRDPBackend) Connect(config RDPConfig) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
if f.connected {
|
||||
return fmt.Errorf("already connected")
|
||||
}
|
||||
|
||||
f.config = config
|
||||
|
||||
// Create a bare FreeRDP instance.
|
||||
ret, _, err := procFreerdpNew.Call()
|
||||
if ret == 0 {
|
||||
return fmt.Errorf("freerdp_new failed: %v", err)
|
||||
}
|
||||
f.instance = ret
|
||||
|
||||
// Configure connection settings via the settings accessor functions.
|
||||
f.setString(FreeRDP_ServerHostname, config.Hostname)
|
||||
f.setUint32(FreeRDP_ServerPort, uint32(config.Port))
|
||||
f.setString(FreeRDP_Username, config.Username)
|
||||
f.setString(FreeRDP_Password, config.Password)
|
||||
if config.Domain != "" {
|
||||
f.setString(FreeRDP_Domain, config.Domain)
|
||||
}
|
||||
|
||||
// Display settings with sensible defaults.
|
||||
width := config.Width
|
||||
if width == 0 {
|
||||
width = 1920
|
||||
}
|
||||
height := config.Height
|
||||
if height == 0 {
|
||||
height = 1080
|
||||
}
|
||||
f.setUint32(FreeRDP_DesktopWidth, uint32(width))
|
||||
f.setUint32(FreeRDP_DesktopHeight, uint32(height))
|
||||
|
||||
colorDepth := config.ColorDepth
|
||||
if colorDepth == 0 {
|
||||
colorDepth = 32
|
||||
}
|
||||
f.setUint32(FreeRDP_ColorDepth, uint32(colorDepth))
|
||||
|
||||
// Security mode selection.
|
||||
switch config.Security {
|
||||
case "nla":
|
||||
f.setBool(FreeRDP_NlaSecurity, true)
|
||||
case "tls":
|
||||
f.setBool(FreeRDP_TlsSecurity, true)
|
||||
case "rdp":
|
||||
f.setBool(FreeRDP_RdpSecurity, true)
|
||||
default:
|
||||
f.setBool(FreeRDP_NlaSecurity, true)
|
||||
}
|
||||
|
||||
// Accept all server certificates. Per-host pinning is a future enhancement.
|
||||
f.setBool(FreeRDP_IgnoreCertificate, true)
|
||||
|
||||
// Allocate the pixel buffer for frame capture.
|
||||
f.buffer = NewPixelBuffer(width, height)
|
||||
|
||||
// TODO: Register PostConnect callback to set up bitmap update handler.
|
||||
// TODO: Register BitmapUpdate callback to write frames into f.buffer.
|
||||
|
||||
// Initiate the RDP connection.
|
||||
ret, _, err = procFreerdpConnect.Call(f.instance)
|
||||
if ret == 0 {
|
||||
procFreerdpFree.Call(f.instance)
|
||||
f.instance = 0
|
||||
return fmt.Errorf("freerdp_connect failed: %v", err)
|
||||
}
|
||||
|
||||
f.connected = true
|
||||
|
||||
// Start the event processing loop in a background goroutine.
|
||||
go f.eventLoop()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// eventLoop polls FreeRDP for incoming events at roughly 60 fps. It runs
|
||||
// until the stop channel is closed or the connection is marked as inactive.
|
||||
func (f *FreeRDPBackend) eventLoop() {
|
||||
for {
|
||||
select {
|
||||
case <-f.stopCh:
|
||||
return
|
||||
default:
|
||||
f.mu.Lock()
|
||||
if !f.connected {
|
||||
f.mu.Unlock()
|
||||
return
|
||||
}
|
||||
procCheckEventHandles.Call(f.instance)
|
||||
f.mu.Unlock()
|
||||
time.Sleep(16 * time.Millisecond) // ~60 fps
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Disconnect tears down the RDP session and frees the FreeRDP instance.
|
||||
func (f *FreeRDPBackend) Disconnect() error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
if !f.connected {
|
||||
return nil
|
||||
}
|
||||
|
||||
close(f.stopCh)
|
||||
f.connected = false
|
||||
|
||||
procFreerdpDisconnect.Call(f.instance)
|
||||
procFreerdpFree.Call(f.instance)
|
||||
f.instance = 0
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendMouseEvent forwards a mouse event to the remote RDP session.
|
||||
func (f *FreeRDPBackend) SendMouseEvent(x, y int, flags uint32) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
if !f.connected || f.input == 0 {
|
||||
return fmt.Errorf("not connected")
|
||||
}
|
||||
procInputSendMouse.Call(f.input, uintptr(flags), uintptr(x), uintptr(y))
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendKeyEvent forwards a keyboard event to the remote RDP session. The
|
||||
// scancode uses the same format as the ScancodeMap in input.go — extended
|
||||
// keys have the 0xE0 prefix in the high byte.
|
||||
func (f *FreeRDPBackend) SendKeyEvent(scancode uint32, pressed bool) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
if !f.connected || f.input == 0 {
|
||||
return fmt.Errorf("not connected")
|
||||
}
|
||||
|
||||
var flags uint32
|
||||
if pressed {
|
||||
flags = KBD_FLAGS_DOWN
|
||||
} else {
|
||||
flags = KBD_FLAGS_RELEASE
|
||||
}
|
||||
if scancode > 0xFF {
|
||||
flags |= KBD_FLAGS_EXTENDED
|
||||
}
|
||||
procInputSendKeyboard.Call(f.input, uintptr(flags), uintptr(scancode&0xFF))
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendClipboard sends clipboard text to the remote session.
|
||||
// TODO: Implement via the FreeRDP cliprdr channel.
|
||||
func (f *FreeRDPBackend) SendClipboard(data string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetFrame returns the current full-frame RGBA pixel buffer. The frame is
|
||||
// populated by bitmap update callbacks registered during PostConnect.
|
||||
func (f *FreeRDPBackend) GetFrame() ([]byte, error) {
|
||||
if f.buffer == nil {
|
||||
return nil, fmt.Errorf("no frame buffer")
|
||||
}
|
||||
return f.buffer.GetFrame(), nil
|
||||
}
|
||||
|
||||
// IsConnected reports whether the backend has an active RDP connection.
|
||||
func (f *FreeRDPBackend) IsConnected() bool {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.connected
|
||||
}
|
||||
|
||||
// setString sets a string setting on the FreeRDP instance.
|
||||
func (f *FreeRDPBackend) setString(id int, value string) {
|
||||
b, _ := syscall.BytePtrFromString(value)
|
||||
procSettingsSetString.Call(f.settings, uintptr(id), uintptr(unsafe.Pointer(b)))
|
||||
}
|
||||
|
||||
// setUint32 sets a uint32 setting on the FreeRDP instance.
|
||||
func (f *FreeRDPBackend) setUint32(id int, value uint32) {
|
||||
procSettingsSetUint32.Call(f.settings, uintptr(id), uintptr(value))
|
||||
}
|
||||
|
||||
// setBool sets a boolean setting on the FreeRDP instance.
|
||||
func (f *FreeRDPBackend) setBool(id int, value bool) {
|
||||
v := uintptr(0)
|
||||
if value {
|
||||
v = 1
|
||||
}
|
||||
procSettingsSetBool.Call(f.settings, uintptr(id), v)
|
||||
}
|
||||
|
||||
// Ensure FreeRDPBackend satisfies the RDPBackend interface at compile time.
|
||||
var _ RDPBackend = (*FreeRDPBackend)(nil)
|
||||
@ -1,189 +0,0 @@
|
||||
package rdp
|
||||
|
||||
// RDP mouse event flags — these match the MS-RDPBCGR specification.
|
||||
const (
|
||||
MouseFlagMove uint32 = 0x0800 // Mouse moved (no button change)
|
||||
MouseFlagButton1 uint32 = 0x1000 // Left button
|
||||
MouseFlagButton2 uint32 = 0x2000 // Right button
|
||||
MouseFlagButton3 uint32 = 0x4000 // Middle button
|
||||
MouseFlagDown uint32 = 0x8000 // Button pressed (absence = released)
|
||||
|
||||
// Extended mouse flags for wheel events
|
||||
MouseFlagWheel uint32 = 0x0200 // Vertical wheel rotation
|
||||
MouseFlagWheelNeg uint32 = 0x0100 // Negative wheel direction (scroll down)
|
||||
MouseFlagHWheel uint32 = 0x0400 // Horizontal wheel rotation
|
||||
)
|
||||
|
||||
// ScancodeMap maps JavaScript KeyboardEvent.code strings to RDP hardware
|
||||
// scancodes (Set 1 / XT scan codes). This covers the standard US 104-key
|
||||
// layout. Extended keys (those with a 0xE0 prefix on the wire) have the
|
||||
// high byte set to 0xE0.
|
||||
//
|
||||
// Reference: USB HID to PS/2 scancode mapping + MS-RDPBCGR 2.2.8.1.1.3.1.1.1
|
||||
var ScancodeMap = map[string]uint32{
|
||||
// ── Row 0: Escape + Function keys ──────────────────────────────
|
||||
"Escape": 0x0001,
|
||||
"F1": 0x003B,
|
||||
"F2": 0x003C,
|
||||
"F3": 0x003D,
|
||||
"F4": 0x003E,
|
||||
"F5": 0x003F,
|
||||
"F6": 0x0040,
|
||||
"F7": 0x0041,
|
||||
"F8": 0x0042,
|
||||
"F9": 0x0043,
|
||||
"F10": 0x0044,
|
||||
"F11": 0x0057,
|
||||
"F12": 0x0058,
|
||||
|
||||
// ── Row 1: Number row ──────────────────────────────────────────
|
||||
"Backquote": 0x0029, // ` ~
|
||||
"Digit1": 0x0002,
|
||||
"Digit2": 0x0003,
|
||||
"Digit3": 0x0004,
|
||||
"Digit4": 0x0005,
|
||||
"Digit5": 0x0006,
|
||||
"Digit6": 0x0007,
|
||||
"Digit7": 0x0008,
|
||||
"Digit8": 0x0009,
|
||||
"Digit9": 0x000A,
|
||||
"Digit0": 0x000B,
|
||||
"Minus": 0x000C, // - _
|
||||
"Equal": 0x000D, // = +
|
||||
"Backspace": 0x000E,
|
||||
|
||||
// ── Row 2: QWERTY row ─────────────────────────────────────────
|
||||
"Tab": 0x000F,
|
||||
"KeyQ": 0x0010,
|
||||
"KeyW": 0x0011,
|
||||
"KeyE": 0x0012,
|
||||
"KeyR": 0x0013,
|
||||
"KeyT": 0x0014,
|
||||
"KeyY": 0x0015,
|
||||
"KeyU": 0x0016,
|
||||
"KeyI": 0x0017,
|
||||
"KeyO": 0x0018,
|
||||
"KeyP": 0x0019,
|
||||
"BracketLeft": 0x001A, // [ {
|
||||
"BracketRight":0x001B, // ] }
|
||||
"Backslash": 0x002B, // \ |
|
||||
|
||||
// ── Row 3: Home row ───────────────────────────────────────────
|
||||
"CapsLock": 0x003A,
|
||||
"KeyA": 0x001E,
|
||||
"KeyS": 0x001F,
|
||||
"KeyD": 0x0020,
|
||||
"KeyF": 0x0021,
|
||||
"KeyG": 0x0022,
|
||||
"KeyH": 0x0023,
|
||||
"KeyJ": 0x0024,
|
||||
"KeyK": 0x0025,
|
||||
"KeyL": 0x0026,
|
||||
"Semicolon": 0x0027, // ; :
|
||||
"Quote": 0x0028, // ' "
|
||||
"Enter": 0x001C,
|
||||
|
||||
// ── Row 4: Bottom row ─────────────────────────────────────────
|
||||
"ShiftLeft": 0x002A,
|
||||
"KeyZ": 0x002C,
|
||||
"KeyX": 0x002D,
|
||||
"KeyC": 0x002E,
|
||||
"KeyV": 0x002F,
|
||||
"KeyB": 0x0030,
|
||||
"KeyN": 0x0031,
|
||||
"KeyM": 0x0032,
|
||||
"Comma": 0x0033, // , <
|
||||
"Period": 0x0034, // . >
|
||||
"Slash": 0x0035, // / ?
|
||||
"ShiftRight": 0x0036,
|
||||
|
||||
// ── Row 5: Bottom modifiers + space ───────────────────────────
|
||||
"ControlLeft": 0x001D,
|
||||
"MetaLeft": 0xE05B, // Left Windows / Super
|
||||
"AltLeft": 0x0038,
|
||||
"Space": 0x0039,
|
||||
"AltRight": 0xE038, // Right Alt (extended)
|
||||
"MetaRight": 0xE05C, // Right Windows / Super
|
||||
"ContextMenu": 0xE05D, // Application / Menu key
|
||||
"ControlRight": 0xE01D, // Right Ctrl (extended)
|
||||
|
||||
// ── Navigation cluster ────────────────────────────────────────
|
||||
"PrintScreen": 0xE037,
|
||||
"ScrollLock": 0x0046,
|
||||
"Pause": 0x0045, // Note: Pause has special handling on wire
|
||||
"Insert": 0xE052,
|
||||
"Home": 0xE047,
|
||||
"PageUp": 0xE049,
|
||||
"Delete": 0xE053,
|
||||
"End": 0xE04F,
|
||||
"PageDown": 0xE051,
|
||||
|
||||
// ── Arrow keys ────────────────────────────────────────────────
|
||||
"ArrowUp": 0xE048,
|
||||
"ArrowLeft": 0xE04B,
|
||||
"ArrowDown": 0xE050,
|
||||
"ArrowRight": 0xE04D,
|
||||
|
||||
// ── Numpad ────────────────────────────────────────────────────
|
||||
"NumLock": 0x0045,
|
||||
"NumpadDivide": 0xE035,
|
||||
"NumpadMultiply":0x0037,
|
||||
"NumpadSubtract":0x004A,
|
||||
"Numpad7": 0x0047,
|
||||
"Numpad8": 0x0048,
|
||||
"Numpad9": 0x0049,
|
||||
"NumpadAdd": 0x004E,
|
||||
"Numpad4": 0x004B,
|
||||
"Numpad5": 0x004C,
|
||||
"Numpad6": 0x004D,
|
||||
"Numpad1": 0x004F,
|
||||
"Numpad2": 0x0050,
|
||||
"Numpad3": 0x0051,
|
||||
"NumpadEnter": 0xE01C,
|
||||
"Numpad0": 0x0052,
|
||||
"NumpadDecimal": 0x0053,
|
||||
|
||||
// ── Multimedia / browser keys (common on 104+ key layouts) ───
|
||||
"BrowserBack": 0xE06A,
|
||||
"BrowserForward": 0xE069,
|
||||
"BrowserRefresh": 0xE067,
|
||||
"BrowserStop": 0xE068,
|
||||
"BrowserSearch": 0xE065,
|
||||
"BrowserFavorites":0xE066,
|
||||
"BrowserHome": 0xE032,
|
||||
"VolumeMute": 0xE020,
|
||||
"VolumeDown": 0xE02E,
|
||||
"VolumeUp": 0xE030,
|
||||
"MediaTrackNext": 0xE019,
|
||||
"MediaTrackPrevious":0xE010,
|
||||
"MediaStop": 0xE024,
|
||||
"MediaPlayPause": 0xE022,
|
||||
"LaunchMail": 0xE06C,
|
||||
"LaunchApp1": 0xE06B,
|
||||
"LaunchApp2": 0xE021,
|
||||
|
||||
// ── International keys ────────────────────────────────────────
|
||||
"IntlBackslash": 0x0056, // key between left Shift and Z on ISO keyboards
|
||||
"IntlYen": 0x007D, // Yen key on Japanese keyboards
|
||||
"IntlRo": 0x0073, // Ro key on Japanese keyboards
|
||||
}
|
||||
|
||||
// JSKeyToScancode translates a JavaScript KeyboardEvent.code string to an
|
||||
// RDP hardware scancode. Returns the scancode and true if a mapping exists,
|
||||
// or 0 and false for unmapped keys.
|
||||
func JSKeyToScancode(jsCode string) (uint32, bool) {
|
||||
sc, ok := ScancodeMap[jsCode]
|
||||
return sc, ok
|
||||
}
|
||||
|
||||
// IsExtendedKey returns true if the scancode has the 0xE0 extended prefix.
|
||||
// Extended keys require a two-byte sequence on the RDP wire.
|
||||
func IsExtendedKey(scancode uint32) bool {
|
||||
return (scancode & 0xFF00) == 0xE000
|
||||
}
|
||||
|
||||
// ScancodeValue returns the low byte of the scancode (the actual scan code
|
||||
// value without the extended prefix).
|
||||
func ScancodeValue(scancode uint32) uint8 {
|
||||
return uint8(scancode & 0xFF)
|
||||
}
|
||||
@ -1,227 +0,0 @@
|
||||
package rdp
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestScancodeMapping(t *testing.T) {
|
||||
tests := []struct {
|
||||
jsCode string
|
||||
expected uint32
|
||||
}{
|
||||
{"Escape", 0x0001},
|
||||
{"Digit1", 0x0002},
|
||||
{"Digit0", 0x000B},
|
||||
{"KeyA", 0x001E},
|
||||
{"KeyZ", 0x002C},
|
||||
{"Enter", 0x001C},
|
||||
{"Space", 0x0039},
|
||||
{"Tab", 0x000F},
|
||||
{"Backspace", 0x000E},
|
||||
{"ShiftLeft", 0x002A},
|
||||
{"ShiftRight", 0x0036},
|
||||
{"ControlLeft", 0x001D},
|
||||
{"ControlRight", 0xE01D},
|
||||
{"AltLeft", 0x0038},
|
||||
{"AltRight", 0xE038},
|
||||
{"CapsLock", 0x003A},
|
||||
{"F1", 0x003B},
|
||||
{"F12", 0x0058},
|
||||
{"ArrowUp", 0xE048},
|
||||
{"ArrowDown", 0xE050},
|
||||
{"ArrowLeft", 0xE04B},
|
||||
{"ArrowRight", 0xE04D},
|
||||
{"Insert", 0xE052},
|
||||
{"Delete", 0xE053},
|
||||
{"Home", 0xE047},
|
||||
{"End", 0xE04F},
|
||||
{"PageUp", 0xE049},
|
||||
{"PageDown", 0xE051},
|
||||
{"NumLock", 0x0045},
|
||||
{"Numpad0", 0x0052},
|
||||
{"Numpad9", 0x0049},
|
||||
{"NumpadEnter", 0xE01C},
|
||||
{"NumpadAdd", 0x004E},
|
||||
{"NumpadSubtract", 0x004A},
|
||||
{"NumpadMultiply", 0x0037},
|
||||
{"NumpadDivide", 0xE035},
|
||||
{"NumpadDecimal", 0x0053},
|
||||
{"MetaLeft", 0xE05B},
|
||||
{"MetaRight", 0xE05C},
|
||||
{"ContextMenu", 0xE05D},
|
||||
{"PrintScreen", 0xE037},
|
||||
{"ScrollLock", 0x0046},
|
||||
{"Backquote", 0x0029},
|
||||
{"Minus", 0x000C},
|
||||
{"Equal", 0x000D},
|
||||
{"BracketLeft", 0x001A},
|
||||
{"BracketRight", 0x001B},
|
||||
{"Backslash", 0x002B},
|
||||
{"Semicolon", 0x0027},
|
||||
{"Quote", 0x0028},
|
||||
{"Comma", 0x0033},
|
||||
{"Period", 0x0034},
|
||||
{"Slash", 0x0035},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.jsCode, func(t *testing.T) {
|
||||
sc, ok := JSKeyToScancode(tt.jsCode)
|
||||
if !ok {
|
||||
t.Fatalf("JSKeyToScancode(%q) returned false", tt.jsCode)
|
||||
}
|
||||
if sc != tt.expected {
|
||||
t.Errorf("JSKeyToScancode(%q) = 0x%04X, want 0x%04X", tt.jsCode, sc, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllLetterKeys(t *testing.T) {
|
||||
// Verify all 26 letter keys are mapped
|
||||
letters := "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
for _, ch := range letters {
|
||||
code := "Key" + string(ch)
|
||||
_, ok := JSKeyToScancode(code)
|
||||
if !ok {
|
||||
t.Errorf("missing mapping for %s", code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllDigitKeys(t *testing.T) {
|
||||
for i := 0; i <= 9; i++ {
|
||||
code := "Digit" + string(rune('0'+i))
|
||||
_, ok := JSKeyToScancode(code)
|
||||
if !ok {
|
||||
t.Errorf("missing mapping for %s", code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllFunctionKeys(t *testing.T) {
|
||||
fKeys := []string{"F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "F10", "F11", "F12"}
|
||||
for _, key := range fKeys {
|
||||
_, ok := JSKeyToScancode(key)
|
||||
if !ok {
|
||||
t.Errorf("missing mapping for %s", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllNumpadKeys(t *testing.T) {
|
||||
numpadKeys := []string{
|
||||
"Numpad0", "Numpad1", "Numpad2", "Numpad3", "Numpad4",
|
||||
"Numpad5", "Numpad6", "Numpad7", "Numpad8", "Numpad9",
|
||||
"NumpadAdd", "NumpadSubtract", "NumpadMultiply", "NumpadDivide",
|
||||
"NumpadDecimal", "NumpadEnter", "NumLock",
|
||||
}
|
||||
for _, key := range numpadKeys {
|
||||
_, ok := JSKeyToScancode(key)
|
||||
if !ok {
|
||||
t.Errorf("missing mapping for %s", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownKey(t *testing.T) {
|
||||
sc, ok := JSKeyToScancode("FakeKey123")
|
||||
if ok {
|
||||
t.Errorf("unknown key returned ok=true with scancode 0x%04X", sc)
|
||||
}
|
||||
if sc != 0 {
|
||||
t.Errorf("unknown key scancode = 0x%04X, want 0", sc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsExtendedKey(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
scancode uint32
|
||||
extended bool
|
||||
}{
|
||||
{"Escape (not extended)", 0x0001, false},
|
||||
{"Enter (not extended)", 0x001C, false},
|
||||
{"Right Ctrl (extended)", 0xE01D, true},
|
||||
{"Right Alt (extended)", 0xE038, true},
|
||||
{"ArrowUp (extended)", 0xE048, true},
|
||||
{"Insert (extended)", 0xE052, true},
|
||||
{"NumpadEnter (extended)", 0xE01C, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := IsExtendedKey(tt.scancode)
|
||||
if got != tt.extended {
|
||||
t.Errorf("IsExtendedKey(0x%04X) = %v, want %v", tt.scancode, got, tt.extended)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestScancodeValue(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
scancode uint32
|
||||
value uint8
|
||||
}{
|
||||
{"Escape", 0x0001, 0x01},
|
||||
{"Right Ctrl", 0xE01D, 0x1D},
|
||||
{"ArrowUp", 0xE048, 0x48},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ScancodeValue(tt.scancode)
|
||||
if got != tt.value {
|
||||
t.Errorf("ScancodeValue(0x%04X) = 0x%02X, want 0x%02X", tt.scancode, got, tt.value)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMouseFlags(t *testing.T) {
|
||||
// Verify the flag constants have the right values
|
||||
if MouseFlagMove != 0x0800 {
|
||||
t.Errorf("MouseFlagMove = 0x%04X, want 0x0800", MouseFlagMove)
|
||||
}
|
||||
if MouseFlagButton1 != 0x1000 {
|
||||
t.Errorf("MouseFlagButton1 = 0x%04X, want 0x1000", MouseFlagButton1)
|
||||
}
|
||||
if MouseFlagButton2 != 0x2000 {
|
||||
t.Errorf("MouseFlagButton2 = 0x%04X, want 0x2000", MouseFlagButton2)
|
||||
}
|
||||
if MouseFlagButton3 != 0x4000 {
|
||||
t.Errorf("MouseFlagButton3 = 0x%04X, want 0x4000", MouseFlagButton3)
|
||||
}
|
||||
if MouseFlagDown != 0x8000 {
|
||||
t.Errorf("MouseFlagDown = 0x%04X, want 0x8000", MouseFlagDown)
|
||||
}
|
||||
|
||||
// Test combining flags — left click down
|
||||
flags := MouseFlagButton1 | MouseFlagDown
|
||||
if flags != 0x9000 {
|
||||
t.Errorf("left click down = 0x%04X, want 0x9000", flags)
|
||||
}
|
||||
|
||||
// Test combining flags — right click up (no Down flag)
|
||||
flags = MouseFlagButton2
|
||||
if flags != 0x2000 {
|
||||
t.Errorf("right click up = 0x%04X, want 0x2000", flags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScancodeMapCompleteness(t *testing.T) {
|
||||
// Minimum expected mappings for a standard 104-key US keyboard:
|
||||
// 26 letters + 10 digits + 12 F-keys + escape + tab + caps + 2 shifts +
|
||||
// 2 ctrls + 2 alts + 2 metas + space + enter + backspace +
|
||||
// numrow punctuation (backquote, minus, equal) +
|
||||
// bracket pair + backslash + semicolon + quote + comma + period + slash +
|
||||
// 4 arrows + insert + delete + home + end + pageup + pagedown +
|
||||
// printscreen + scrolllock + pause +
|
||||
// numlock + numpad 0-9 + numpad operators (5) + numpad enter + numpad decimal +
|
||||
// context menu
|
||||
// = 26+10+12+1+1+1+2+2+2+2+1+1+1+3+2+1+1+1+1+1+1+4+6+3+1+10+5+1+1+1 = ~104
|
||||
minExpected := 90 // conservative lower bound for core keys
|
||||
if len(ScancodeMap) < minExpected {
|
||||
t.Errorf("ScancodeMap has %d entries, expected at least %d", len(ScancodeMap), minExpected)
|
||||
}
|
||||
}
|
||||
@ -1,313 +0,0 @@
|
||||
package rdp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MockBackend implements the RDPBackend interface for development and testing.
|
||||
// Instead of connecting to a real RDP server, it generates animated test frames
|
||||
// with a gradient pattern, moving shapes, and a timestamp overlay.
|
||||
type MockBackend struct {
|
||||
connected bool
|
||||
config RDPConfig
|
||||
buffer *PixelBuffer
|
||||
mu sync.Mutex
|
||||
startTime time.Time
|
||||
clipboard string
|
||||
}
|
||||
|
||||
// NewMockBackend creates a new MockBackend instance.
|
||||
func NewMockBackend() *MockBackend {
|
||||
return &MockBackend{}
|
||||
}
|
||||
|
||||
// Connect initializes the mock backend with the given configuration and starts
|
||||
// generating test frames.
|
||||
func (m *MockBackend) Connect(config RDPConfig) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if m.connected {
|
||||
return fmt.Errorf("already connected")
|
||||
}
|
||||
|
||||
width := config.Width
|
||||
height := config.Height
|
||||
if width <= 0 {
|
||||
width = 1920
|
||||
}
|
||||
if height <= 0 {
|
||||
height = 1080
|
||||
}
|
||||
|
||||
m.config = config
|
||||
m.config.Width = width
|
||||
m.config.Height = height
|
||||
m.buffer = NewPixelBuffer(width, height)
|
||||
m.connected = true
|
||||
m.startTime = time.Now()
|
||||
|
||||
// Generate the initial test frame
|
||||
m.generateFrame()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Disconnect shuts down the mock backend.
|
||||
func (m *MockBackend) Disconnect() error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if !m.connected {
|
||||
return fmt.Errorf("not connected")
|
||||
}
|
||||
|
||||
m.connected = false
|
||||
m.buffer = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendMouseEvent records a mouse event (no-op for mock).
|
||||
func (m *MockBackend) SendMouseEvent(x, y int, flags uint32) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if !m.connected {
|
||||
return fmt.Errorf("not connected")
|
||||
}
|
||||
|
||||
// In the mock, we draw a cursor indicator at the mouse position
|
||||
if m.buffer != nil && flags&MouseFlagMove != 0 {
|
||||
m.drawCursor(x, y)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendKeyEvent records a key event (no-op for mock).
|
||||
func (m *MockBackend) SendKeyEvent(scancode uint32, pressed bool) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if !m.connected {
|
||||
return fmt.Errorf("not connected")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendClipboard stores clipboard text (mock implementation).
|
||||
func (m *MockBackend) SendClipboard(data string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if !m.connected {
|
||||
return fmt.Errorf("not connected")
|
||||
}
|
||||
|
||||
m.clipboard = data
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetFrame returns the current test frame. Each call regenerates the frame
|
||||
// with an updated animation state so the renderer can verify dynamic updates.
|
||||
func (m *MockBackend) GetFrame() ([]byte, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if !m.connected {
|
||||
return nil, fmt.Errorf("not connected")
|
||||
}
|
||||
|
||||
m.generateFrame()
|
||||
return m.buffer.GetFrame(), nil
|
||||
}
|
||||
|
||||
// IsConnected reports whether the mock backend is connected.
|
||||
func (m *MockBackend) IsConnected() bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.connected
|
||||
}
|
||||
|
||||
// generateFrame creates a visually interesting test frame with:
|
||||
// - A blue-to-purple diagonal gradient background
|
||||
// - An animated bouncing rectangle
|
||||
// - A grid overlay for alignment verification
|
||||
// - Session info text area
|
||||
func (m *MockBackend) generateFrame() {
|
||||
w := m.config.Width
|
||||
h := m.config.Height
|
||||
elapsed := time.Since(m.startTime).Seconds()
|
||||
|
||||
data := make([]byte, w*h*4)
|
||||
|
||||
// ── Background: diagonal gradient from dark blue to dark purple ──
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
offset := (y*w + x) * 4
|
||||
// Normalized coordinates
|
||||
nx := float64(x) / float64(w)
|
||||
ny := float64(y) / float64(h)
|
||||
diag := (nx + ny) / 2.0
|
||||
|
||||
r := uint8(20 + diag*40) // 20–60
|
||||
g := uint8(25 + (1.0-diag)*30) // 25–55
|
||||
b := uint8(80 + diag*100) // 80–180
|
||||
data[offset+0] = r
|
||||
data[offset+1] = g
|
||||
data[offset+2] = b
|
||||
data[offset+3] = 255
|
||||
}
|
||||
}
|
||||
|
||||
// ── Grid overlay: subtle lines every 100 pixels ──
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
if x%100 == 0 || y%100 == 0 {
|
||||
offset := (y*w + x) * 4
|
||||
data[offset+0] = min8(data[offset+0]+20, 255)
|
||||
data[offset+1] = min8(data[offset+1]+20, 255)
|
||||
data[offset+2] = min8(data[offset+2]+20, 255)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Animated bouncing rectangle ──
|
||||
rectW, rectH := 200, 120
|
||||
// Bounce horizontally and vertically using sine/cosine
|
||||
cx := int(float64(w-rectW) * (0.5 + 0.4*math.Sin(elapsed*0.7)))
|
||||
cy := int(float64(h-rectH) * (0.5 + 0.4*math.Cos(elapsed*0.5)))
|
||||
|
||||
// Color cycles through hues
|
||||
hue := math.Mod(elapsed*30, 360)
|
||||
rr, gg, bb := hueToRGB(hue)
|
||||
|
||||
for ry := 0; ry < rectH; ry++ {
|
||||
for rx := 0; rx < rectW; rx++ {
|
||||
px := cx + rx
|
||||
py := cy + ry
|
||||
if px >= 0 && px < w && py >= 0 && py < h {
|
||||
offset := (py*w + px) * 4
|
||||
// Border: 3px white outline
|
||||
if rx < 3 || rx >= rectW-3 || ry < 3 || ry >= rectH-3 {
|
||||
data[offset+0] = 255
|
||||
data[offset+1] = 255
|
||||
data[offset+2] = 255
|
||||
data[offset+3] = 255
|
||||
} else {
|
||||
data[offset+0] = rr
|
||||
data[offset+1] = gg
|
||||
data[offset+2] = bb
|
||||
data[offset+3] = 220
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Info panel in top-left corner ──
|
||||
panelW, panelH := 320, 80
|
||||
for py := 10; py < 10+panelH && py < h; py++ {
|
||||
for px := 10; px < 10+panelW && px < w; px++ {
|
||||
offset := (py*w + px) * 4
|
||||
data[offset+0] = 0
|
||||
data[offset+1] = 0
|
||||
data[offset+2] = 0
|
||||
data[offset+3] = 180
|
||||
}
|
||||
}
|
||||
|
||||
// ── Secondary animated element: pulsing circle ──
|
||||
circleX := w / 2
|
||||
circleY := h / 2
|
||||
radius := 40.0 + 20.0*math.Sin(elapsed*2.0)
|
||||
|
||||
for dy := -70; dy <= 70; dy++ {
|
||||
for dx := -70; dx <= 70; dx++ {
|
||||
dist := math.Sqrt(float64(dx*dx + dy*dy))
|
||||
if dist <= radius && dist >= radius-4 {
|
||||
px := circleX + dx
|
||||
py := circleY + dy
|
||||
if px >= 0 && px < w && py >= 0 && py < h {
|
||||
offset := (py*w + px) * 4
|
||||
data[offset+0] = 88
|
||||
data[offset+1] = 166
|
||||
data[offset+2] = 255
|
||||
data[offset+3] = 255
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply as a full-frame update
|
||||
m.buffer.Update(0, 0, w, h, data)
|
||||
}
|
||||
|
||||
// drawCursor draws a small crosshair at the given position.
|
||||
func (m *MockBackend) drawCursor(cx, cy int) {
|
||||
size := 10
|
||||
w := m.config.Width
|
||||
h := m.config.Height
|
||||
|
||||
cursorData := make([]byte, (size*2+1)*(size*2+1)*4)
|
||||
idx := 0
|
||||
for dy := -size; dy <= size; dy++ {
|
||||
for dx := -size; dx <= size; dx++ {
|
||||
if dx == 0 || dy == 0 {
|
||||
cursorData[idx+0] = 255
|
||||
cursorData[idx+1] = 255
|
||||
cursorData[idx+2] = 0
|
||||
cursorData[idx+3] = 200
|
||||
}
|
||||
idx += 4
|
||||
}
|
||||
}
|
||||
|
||||
startX := cx - size
|
||||
startY := cy - size
|
||||
if startX < 0 {
|
||||
startX = 0
|
||||
}
|
||||
if startY < 0 {
|
||||
startY = 0
|
||||
}
|
||||
_ = w
|
||||
_ = h
|
||||
|
||||
m.buffer.Update(startX, startY, size*2+1, size*2+1, cursorData)
|
||||
}
|
||||
|
||||
// hueToRGB converts a hue angle (0-360) to RGB values.
|
||||
func hueToRGB(hue float64) (uint8, uint8, uint8) {
|
||||
h := math.Mod(hue, 360) / 60
|
||||
c := 200.0 // chroma
|
||||
x := c * (1 - math.Abs(math.Mod(h, 2)-1))
|
||||
|
||||
var r, g, b float64
|
||||
switch {
|
||||
case h < 1:
|
||||
r, g, b = c, x, 0
|
||||
case h < 2:
|
||||
r, g, b = x, c, 0
|
||||
case h < 3:
|
||||
r, g, b = 0, c, x
|
||||
case h < 4:
|
||||
r, g, b = 0, x, c
|
||||
case h < 5:
|
||||
r, g, b = x, 0, c
|
||||
default:
|
||||
r, g, b = c, 0, x
|
||||
}
|
||||
|
||||
return uint8(r + 55), uint8(g + 55), uint8(b + 55)
|
||||
}
|
||||
|
||||
// min8 returns the smaller of two uint8 values.
|
||||
func min8(a, b uint8) uint8 {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@ -1,103 +0,0 @@
|
||||
package rdp
|
||||
|
||||
import "sync"
|
||||
|
||||
// PixelBuffer holds a shared RGBA pixel buffer that is updated by the RDP
|
||||
// backend (via partial region updates) and read by the frame-serving path.
|
||||
type PixelBuffer struct {
|
||||
Width int
|
||||
Height int
|
||||
Data []byte // RGBA pixel data, len = Width * Height * 4
|
||||
mu sync.RWMutex
|
||||
dirty bool
|
||||
}
|
||||
|
||||
// NewPixelBuffer allocates a buffer for the given resolution.
|
||||
// The buffer is initialized to all zeros (transparent black).
|
||||
func NewPixelBuffer(width, height int) *PixelBuffer {
|
||||
return &PixelBuffer{
|
||||
Width: width,
|
||||
Height: height,
|
||||
Data: make([]byte, width*height*4),
|
||||
}
|
||||
}
|
||||
|
||||
// Update applies a partial region update to the pixel buffer.
|
||||
// The data slice must contain w*h*4 bytes of RGBA pixel data.
|
||||
// Pixels outside the buffer bounds are silently clipped.
|
||||
func (p *PixelBuffer) Update(x, y, w, h int, data []byte) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
for row := 0; row < h; row++ {
|
||||
destY := y + row
|
||||
if destY < 0 || destY >= p.Height {
|
||||
continue
|
||||
}
|
||||
|
||||
srcOffset := row * w * 4
|
||||
destOffset := (destY*p.Width + x) * 4
|
||||
|
||||
// Calculate how many pixels we can copy on this row
|
||||
copyWidth := w
|
||||
if x < 0 {
|
||||
srcOffset += (-x) * 4
|
||||
copyWidth += x // reduce by the clipped amount
|
||||
destOffset = destY * p.Width * 4
|
||||
}
|
||||
if x+copyWidth > p.Width {
|
||||
copyWidth = p.Width - x
|
||||
if x < 0 {
|
||||
copyWidth = p.Width
|
||||
}
|
||||
}
|
||||
if copyWidth <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
srcEnd := srcOffset + copyWidth*4
|
||||
if srcEnd > len(data) {
|
||||
srcEnd = len(data)
|
||||
}
|
||||
if srcOffset >= len(data) {
|
||||
continue
|
||||
}
|
||||
|
||||
destEnd := destOffset + copyWidth*4
|
||||
if destEnd > len(p.Data) {
|
||||
destEnd = len(p.Data)
|
||||
}
|
||||
|
||||
copy(p.Data[destOffset:destEnd], data[srcOffset:srcEnd])
|
||||
}
|
||||
|
||||
p.dirty = true
|
||||
}
|
||||
|
||||
// GetFrame returns a copy of the full pixel buffer.
|
||||
// The caller receives an independent copy that will not be affected
|
||||
// by subsequent updates.
|
||||
func (p *PixelBuffer) GetFrame() []byte {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
|
||||
frame := make([]byte, len(p.Data))
|
||||
copy(frame, p.Data)
|
||||
return frame
|
||||
}
|
||||
|
||||
// IsDirty reports whether the buffer has been updated since the last
|
||||
// ClearDirty call.
|
||||
func (p *PixelBuffer) IsDirty() bool {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return p.dirty
|
||||
}
|
||||
|
||||
// ClearDirty resets the dirty flag. Typically called after a frame has
|
||||
// been sent to the frontend.
|
||||
func (p *PixelBuffer) ClearDirty() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.dirty = false
|
||||
}
|
||||
@ -1,172 +0,0 @@
|
||||
package rdp
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNewPixelBuffer(t *testing.T) {
|
||||
pb := NewPixelBuffer(100, 50)
|
||||
|
||||
if pb.Width != 100 {
|
||||
t.Errorf("Width = %d, want 100", pb.Width)
|
||||
}
|
||||
if pb.Height != 50 {
|
||||
t.Errorf("Height = %d, want 50", pb.Height)
|
||||
}
|
||||
|
||||
expectedLen := 100 * 50 * 4
|
||||
if len(pb.Data) != expectedLen {
|
||||
t.Errorf("Data length = %d, want %d", len(pb.Data), expectedLen)
|
||||
}
|
||||
|
||||
// All pixels should be initialized to zero
|
||||
for i, b := range pb.Data {
|
||||
if b != 0 {
|
||||
t.Errorf("Data[%d] = %d, want 0", i, b)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewPixelBufferSmall(t *testing.T) {
|
||||
pb := NewPixelBuffer(1, 1)
|
||||
if len(pb.Data) != 4 {
|
||||
t.Errorf("1x1 buffer Data length = %d, want 4", len(pb.Data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateRegion(t *testing.T) {
|
||||
pb := NewPixelBuffer(10, 10) // 10x10 pixels
|
||||
|
||||
// Create a 3x2 red patch
|
||||
patch := make([]byte, 3*2*4) // 3 wide, 2 tall
|
||||
for i := 0; i < 3*2; i++ {
|
||||
patch[i*4+0] = 255 // R
|
||||
patch[i*4+1] = 0 // G
|
||||
patch[i*4+2] = 0 // B
|
||||
patch[i*4+3] = 255 // A
|
||||
}
|
||||
|
||||
// Apply at position (2, 3)
|
||||
pb.Update(2, 3, 3, 2, patch)
|
||||
|
||||
// Check that pixel (2,3) is red
|
||||
offset := (3*10 + 2) * 4
|
||||
if pb.Data[offset+0] != 255 || pb.Data[offset+1] != 0 || pb.Data[offset+2] != 0 || pb.Data[offset+3] != 255 {
|
||||
t.Errorf("pixel (2,3) = [%d,%d,%d,%d], want [255,0,0,255]",
|
||||
pb.Data[offset+0], pb.Data[offset+1], pb.Data[offset+2], pb.Data[offset+3])
|
||||
}
|
||||
|
||||
// Check that pixel (4,4) is red (last pixel of patch)
|
||||
offset = (4*10 + 4) * 4
|
||||
if pb.Data[offset+0] != 255 || pb.Data[offset+1] != 0 || pb.Data[offset+2] != 0 || pb.Data[offset+3] != 255 {
|
||||
t.Errorf("pixel (4,4) = [%d,%d,%d,%d], want [255,0,0,255]",
|
||||
pb.Data[offset+0], pb.Data[offset+1], pb.Data[offset+2], pb.Data[offset+3])
|
||||
}
|
||||
|
||||
// Check that pixel (1,3) is still black (just outside patch)
|
||||
offset = (3*10 + 1) * 4
|
||||
if pb.Data[offset+0] != 0 || pb.Data[offset+3] != 0 {
|
||||
t.Errorf("pixel (1,3) should be untouched, got [%d,%d,%d,%d]",
|
||||
pb.Data[offset+0], pb.Data[offset+1], pb.Data[offset+2], pb.Data[offset+3])
|
||||
}
|
||||
|
||||
// Check that pixel (5,3) is still black (just outside patch)
|
||||
offset = (3*10 + 5) * 4
|
||||
if pb.Data[offset+0] != 0 || pb.Data[offset+3] != 0 {
|
||||
t.Errorf("pixel (5,3) should be untouched, got [%d,%d,%d,%d]",
|
||||
pb.Data[offset+0], pb.Data[offset+1], pb.Data[offset+2], pb.Data[offset+3])
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateRegionClipping(t *testing.T) {
|
||||
pb := NewPixelBuffer(10, 10)
|
||||
|
||||
// Create a 5x5 green patch and place it at (8, 8), so it overflows
|
||||
patch := make([]byte, 5*5*4)
|
||||
for i := 0; i < 5*5; i++ {
|
||||
patch[i*4+0] = 0
|
||||
patch[i*4+1] = 255
|
||||
patch[i*4+2] = 0
|
||||
patch[i*4+3] = 255
|
||||
}
|
||||
|
||||
// Should not panic — overflowing regions are clipped
|
||||
pb.Update(8, 8, 5, 5, patch)
|
||||
|
||||
// Pixel (8,8) should be green
|
||||
offset := (8*10 + 8) * 4
|
||||
if pb.Data[offset+1] != 255 {
|
||||
t.Errorf("pixel (8,8) G = %d, want 255", pb.Data[offset+1])
|
||||
}
|
||||
|
||||
// Pixel (9,9) should also be green (last valid pixel)
|
||||
offset = (9*10 + 9) * 4
|
||||
if pb.Data[offset+1] != 255 {
|
||||
t.Errorf("pixel (9,9) G = %d, want 255", pb.Data[offset+1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirtyFlag(t *testing.T) {
|
||||
pb := NewPixelBuffer(10, 10)
|
||||
|
||||
if pb.IsDirty() {
|
||||
t.Error("new buffer should not be dirty")
|
||||
}
|
||||
|
||||
// Update a region
|
||||
patch := make([]byte, 4) // 1x1 pixel
|
||||
patch[0] = 255
|
||||
patch[3] = 255
|
||||
pb.Update(0, 0, 1, 1, patch)
|
||||
|
||||
if !pb.IsDirty() {
|
||||
t.Error("buffer should be dirty after update")
|
||||
}
|
||||
|
||||
pb.ClearDirty()
|
||||
|
||||
if pb.IsDirty() {
|
||||
t.Error("buffer should not be dirty after ClearDirty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFrameReturnsCopy(t *testing.T) {
|
||||
pb := NewPixelBuffer(2, 2)
|
||||
|
||||
// Set first pixel to white
|
||||
patch := []byte{255, 255, 255, 255}
|
||||
pb.Update(0, 0, 1, 1, patch)
|
||||
|
||||
frame1 := pb.GetFrame()
|
||||
|
||||
// Modify the returned frame
|
||||
frame1[0] = 0
|
||||
|
||||
// Get another frame — it should still have the original value
|
||||
frame2 := pb.GetFrame()
|
||||
if frame2[0] != 255 {
|
||||
t.Errorf("GetFrame did not return an independent copy: got %d, want 255", frame2[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFullFrameUpdate(t *testing.T) {
|
||||
pb := NewPixelBuffer(4, 4)
|
||||
|
||||
// Create a full-frame update with all blue pixels
|
||||
fullFrame := make([]byte, 4*4*4)
|
||||
for i := 0; i < 4*4; i++ {
|
||||
fullFrame[i*4+0] = 0
|
||||
fullFrame[i*4+1] = 0
|
||||
fullFrame[i*4+2] = 255
|
||||
fullFrame[i*4+3] = 255
|
||||
}
|
||||
|
||||
pb.Update(0, 0, 4, 4, fullFrame)
|
||||
|
||||
frame := pb.GetFrame()
|
||||
for i := 0; i < 4*4; i++ {
|
||||
if frame[i*4+2] != 255 {
|
||||
t.Errorf("pixel %d blue channel = %d, want 255", i, frame[i*4+2])
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,172 +0,0 @@
|
||||
package rdp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// RDPSession represents an active RDP connection with its associated state.
|
||||
type RDPSession struct {
|
||||
ID string `json:"id"`
|
||||
Config RDPConfig `json:"config"`
|
||||
Backend RDPBackend `json:"-"`
|
||||
Buffer *PixelBuffer `json:"-"`
|
||||
ConnID int64 `json:"connectionId"`
|
||||
Connected time.Time `json:"connected"`
|
||||
}
|
||||
|
||||
// RDPService manages multiple RDP sessions. It uses a backend factory to
|
||||
// create new RDPBackend instances — during development the factory returns
|
||||
// MockBackend instances; in production it will return FreeRDP-backed ones.
|
||||
type RDPService struct {
|
||||
sessions map[string]*RDPSession
|
||||
mu sync.RWMutex
|
||||
backendFactory func() RDPBackend
|
||||
}
|
||||
|
||||
// NewRDPService creates a new service with the given backend factory.
|
||||
// The factory is called once per Connect to create a fresh backend.
|
||||
func NewRDPService(factory func() RDPBackend) *RDPService {
|
||||
return &RDPService{
|
||||
sessions: make(map[string]*RDPSession),
|
||||
backendFactory: factory,
|
||||
}
|
||||
}
|
||||
|
||||
// Connect creates a new RDP session using the provided configuration.
|
||||
// Returns the session ID on success.
|
||||
func (s *RDPService) Connect(config RDPConfig, connectionID int64) (string, error) {
|
||||
// Apply defaults
|
||||
if config.Port <= 0 {
|
||||
config.Port = 3389
|
||||
}
|
||||
if config.Width <= 0 {
|
||||
config.Width = 1920
|
||||
}
|
||||
if config.Height <= 0 {
|
||||
config.Height = 1080
|
||||
}
|
||||
if config.ColorDepth <= 0 {
|
||||
config.ColorDepth = 32
|
||||
}
|
||||
if config.Security == "" {
|
||||
config.Security = "nla"
|
||||
}
|
||||
|
||||
backend := s.backendFactory()
|
||||
if err := backend.Connect(config); err != nil {
|
||||
return "", fmt.Errorf("rdp connect to %s:%d: %w", config.Hostname, config.Port, err)
|
||||
}
|
||||
|
||||
sessionID := uuid.NewString()
|
||||
session := &RDPSession{
|
||||
ID: sessionID,
|
||||
Config: config,
|
||||
Backend: backend,
|
||||
Buffer: NewPixelBuffer(config.Width, config.Height),
|
||||
ConnID: connectionID,
|
||||
Connected: time.Now(),
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.sessions[sessionID] = session
|
||||
s.mu.Unlock()
|
||||
|
||||
return sessionID, nil
|
||||
}
|
||||
|
||||
// Disconnect tears down the RDP session and removes it from tracking.
|
||||
func (s *RDPService) Disconnect(sessionID string) error {
|
||||
s.mu.Lock()
|
||||
session, ok := s.sessions[sessionID]
|
||||
if !ok {
|
||||
s.mu.Unlock()
|
||||
return fmt.Errorf("session %s not found", sessionID)
|
||||
}
|
||||
delete(s.sessions, sessionID)
|
||||
s.mu.Unlock()
|
||||
|
||||
return session.Backend.Disconnect()
|
||||
}
|
||||
|
||||
// SendMouse forwards a mouse event to the session's backend.
|
||||
func (s *RDPService) SendMouse(sessionID string, x, y int, flags uint32) error {
|
||||
session, err := s.getSession(sessionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return session.Backend.SendMouseEvent(x, y, flags)
|
||||
}
|
||||
|
||||
// SendKey forwards a key event to the session's backend.
|
||||
func (s *RDPService) SendKey(sessionID string, scancode uint32, pressed bool) error {
|
||||
session, err := s.getSession(sessionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return session.Backend.SendKeyEvent(scancode, pressed)
|
||||
}
|
||||
|
||||
// SendClipboard forwards clipboard text to the session's backend.
|
||||
func (s *RDPService) SendClipboard(sessionID string, data string) error {
|
||||
session, err := s.getSession(sessionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return session.Backend.SendClipboard(data)
|
||||
}
|
||||
|
||||
// GetFrame returns the current RGBA pixel buffer for the given session.
|
||||
func (s *RDPService) GetFrame(sessionID string) ([]byte, error) {
|
||||
session, err := s.getSession(sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return session.Backend.GetFrame()
|
||||
}
|
||||
|
||||
// GetSessionInfo returns the session metadata without the backend or buffer.
|
||||
// This is safe to serialize to JSON for the frontend.
|
||||
func (s *RDPService) GetSessionInfo(sessionID string) (*RDPSession, error) {
|
||||
session, err := s.getSession(sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// ListSessions returns all active RDP sessions.
|
||||
func (s *RDPService) ListSessions() []*RDPSession {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
list := make([]*RDPSession, 0, len(s.sessions))
|
||||
for _, sess := range s.sessions {
|
||||
list = append(list, sess)
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
// IsConnected returns whether a specific session is still connected.
|
||||
func (s *RDPService) IsConnected(sessionID string) bool {
|
||||
session, err := s.getSession(sessionID)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return session.Backend.IsConnected()
|
||||
}
|
||||
|
||||
// getSession is an internal helper that retrieves a session by ID.
|
||||
func (s *RDPService) getSession(sessionID string) (*RDPSession, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
session, ok := s.sessions[sessionID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("session %s not found", sessionID)
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
@ -1,255 +0,0 @@
|
||||
package rdp
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNewRDPService(t *testing.T) {
|
||||
svc := NewRDPService(func() RDPBackend { return NewMockBackend() })
|
||||
if svc == nil {
|
||||
t.Fatal("NewRDPService returned nil")
|
||||
}
|
||||
if len(svc.ListSessions()) != 0 {
|
||||
t.Error("new service should have no sessions")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectWithMockBackend(t *testing.T) {
|
||||
svc := NewRDPService(func() RDPBackend { return NewMockBackend() })
|
||||
|
||||
config := RDPConfig{
|
||||
Hostname: "test-host",
|
||||
Port: 3389,
|
||||
Username: "admin",
|
||||
Width: 1024,
|
||||
Height: 768,
|
||||
}
|
||||
|
||||
sessionID, err := svc.Connect(config, 42)
|
||||
if err != nil {
|
||||
t.Fatalf("Connect error: %v", err)
|
||||
}
|
||||
if sessionID == "" {
|
||||
t.Fatal("Connect returned empty session ID")
|
||||
}
|
||||
|
||||
// Verify session is tracked
|
||||
sessions := svc.ListSessions()
|
||||
if len(sessions) != 1 {
|
||||
t.Fatalf("expected 1 session, got %d", len(sessions))
|
||||
}
|
||||
if sessions[0].ID != sessionID {
|
||||
t.Errorf("session ID = %q, want %q", sessions[0].ID, sessionID)
|
||||
}
|
||||
if sessions[0].ConnID != 42 {
|
||||
t.Errorf("ConnID = %d, want 42", sessions[0].ConnID)
|
||||
}
|
||||
if sessions[0].Config.Hostname != "test-host" {
|
||||
t.Errorf("Hostname = %q, want %q", sessions[0].Config.Hostname, "test-host")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectDefaults(t *testing.T) {
|
||||
svc := NewRDPService(func() RDPBackend { return NewMockBackend() })
|
||||
|
||||
// Connect with zero values — should get defaults
|
||||
config := RDPConfig{Hostname: "host"}
|
||||
sessionID, err := svc.Connect(config, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("Connect error: %v", err)
|
||||
}
|
||||
|
||||
info, err := svc.GetSessionInfo(sessionID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSessionInfo error: %v", err)
|
||||
}
|
||||
|
||||
if info.Config.Port != 3389 {
|
||||
t.Errorf("default Port = %d, want 3389", info.Config.Port)
|
||||
}
|
||||
if info.Config.Width != 1920 {
|
||||
t.Errorf("default Width = %d, want 1920", info.Config.Width)
|
||||
}
|
||||
if info.Config.Height != 1080 {
|
||||
t.Errorf("default Height = %d, want 1080", info.Config.Height)
|
||||
}
|
||||
if info.Config.ColorDepth != 32 {
|
||||
t.Errorf("default ColorDepth = %d, want 32", info.Config.ColorDepth)
|
||||
}
|
||||
if info.Config.Security != "nla" {
|
||||
t.Errorf("default Security = %q, want %q", info.Config.Security, "nla")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisconnect(t *testing.T) {
|
||||
svc := NewRDPService(func() RDPBackend { return NewMockBackend() })
|
||||
|
||||
config := RDPConfig{Hostname: "host", Width: 800, Height: 600}
|
||||
sessionID, err := svc.Connect(config, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("Connect error: %v", err)
|
||||
}
|
||||
|
||||
if err := svc.Disconnect(sessionID); err != nil {
|
||||
t.Fatalf("Disconnect error: %v", err)
|
||||
}
|
||||
|
||||
if len(svc.ListSessions()) != 0 {
|
||||
t.Error("expected 0 sessions after disconnect")
|
||||
}
|
||||
|
||||
// Verify double-disconnect returns error
|
||||
if err := svc.Disconnect(sessionID); err == nil {
|
||||
t.Error("expected error on double disconnect")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisconnectNotFound(t *testing.T) {
|
||||
svc := NewRDPService(func() RDPBackend { return NewMockBackend() })
|
||||
if err := svc.Disconnect("nonexistent"); err == nil {
|
||||
t.Error("expected error for nonexistent session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionTracking(t *testing.T) {
|
||||
svc := NewRDPService(func() RDPBackend { return NewMockBackend() })
|
||||
|
||||
config := RDPConfig{Hostname: "host", Width: 640, Height: 480}
|
||||
|
||||
id1, _ := svc.Connect(config, 1)
|
||||
id2, _ := svc.Connect(config, 2)
|
||||
id3, _ := svc.Connect(config, 3)
|
||||
|
||||
if len(svc.ListSessions()) != 3 {
|
||||
t.Fatalf("expected 3 sessions, got %d", len(svc.ListSessions()))
|
||||
}
|
||||
|
||||
// Disconnect middle session
|
||||
if err := svc.Disconnect(id2); err != nil {
|
||||
t.Fatalf("Disconnect error: %v", err)
|
||||
}
|
||||
|
||||
sessions := svc.ListSessions()
|
||||
if len(sessions) != 2 {
|
||||
t.Fatalf("expected 2 sessions, got %d", len(sessions))
|
||||
}
|
||||
|
||||
// Verify remaining sessions
|
||||
ids := make(map[string]bool)
|
||||
for _, s := range sessions {
|
||||
ids[s.ID] = true
|
||||
}
|
||||
if !ids[id1] || !ids[id3] {
|
||||
t.Error("remaining sessions should be id1 and id3")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendMouse(t *testing.T) {
|
||||
svc := NewRDPService(func() RDPBackend { return NewMockBackend() })
|
||||
|
||||
config := RDPConfig{Hostname: "host", Width: 800, Height: 600}
|
||||
sessionID, _ := svc.Connect(config, 1)
|
||||
|
||||
if err := svc.SendMouse(sessionID, 100, 200, MouseFlagMove); err != nil {
|
||||
t.Errorf("SendMouse error: %v", err)
|
||||
}
|
||||
|
||||
if err := svc.SendMouse(sessionID, 100, 200, MouseFlagButton1|MouseFlagDown); err != nil {
|
||||
t.Errorf("SendMouse click error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendMouseNotFound(t *testing.T) {
|
||||
svc := NewRDPService(func() RDPBackend { return NewMockBackend() })
|
||||
if err := svc.SendMouse("nonexistent", 0, 0, 0); err == nil {
|
||||
t.Error("expected error for nonexistent session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendKey(t *testing.T) {
|
||||
svc := NewRDPService(func() RDPBackend { return NewMockBackend() })
|
||||
|
||||
config := RDPConfig{Hostname: "host", Width: 800, Height: 600}
|
||||
sessionID, _ := svc.Connect(config, 1)
|
||||
|
||||
// Send key down
|
||||
sc, _ := JSKeyToScancode("KeyA")
|
||||
if err := svc.SendKey(sessionID, sc, true); err != nil {
|
||||
t.Errorf("SendKey down error: %v", err)
|
||||
}
|
||||
|
||||
// Send key up
|
||||
if err := svc.SendKey(sessionID, sc, false); err != nil {
|
||||
t.Errorf("SendKey up error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendKeyNotFound(t *testing.T) {
|
||||
svc := NewRDPService(func() RDPBackend { return NewMockBackend() })
|
||||
if err := svc.SendKey("nonexistent", 0x001E, true); err == nil {
|
||||
t.Error("expected error for nonexistent session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendClipboard(t *testing.T) {
|
||||
svc := NewRDPService(func() RDPBackend { return NewMockBackend() })
|
||||
|
||||
config := RDPConfig{Hostname: "host", Width: 800, Height: 600}
|
||||
sessionID, _ := svc.Connect(config, 1)
|
||||
|
||||
if err := svc.SendClipboard(sessionID, "hello clipboard"); err != nil {
|
||||
t.Errorf("SendClipboard error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFrame(t *testing.T) {
|
||||
svc := NewRDPService(func() RDPBackend { return NewMockBackend() })
|
||||
|
||||
config := RDPConfig{Hostname: "host", Width: 100, Height: 100}
|
||||
sessionID, _ := svc.Connect(config, 1)
|
||||
|
||||
frame, err := svc.GetFrame(sessionID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetFrame error: %v", err)
|
||||
}
|
||||
|
||||
expectedLen := 100 * 100 * 4
|
||||
if len(frame) != expectedLen {
|
||||
t.Errorf("frame length = %d, want %d", len(frame), expectedLen)
|
||||
}
|
||||
|
||||
// Verify the frame is not all zeros (mock generates colored content)
|
||||
allZero := true
|
||||
for _, b := range frame {
|
||||
if b != 0 {
|
||||
allZero = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allZero {
|
||||
t.Error("mock frame should not be all zeros")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFrameNotFound(t *testing.T) {
|
||||
svc := NewRDPService(func() RDPBackend { return NewMockBackend() })
|
||||
_, err := svc.GetFrame("nonexistent")
|
||||
if err == nil {
|
||||
t.Error("expected error for nonexistent session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsConnected(t *testing.T) {
|
||||
svc := NewRDPService(func() RDPBackend { return NewMockBackend() })
|
||||
|
||||
config := RDPConfig{Hostname: "host", Width: 800, Height: 600}
|
||||
sessionID, _ := svc.Connect(config, 1)
|
||||
|
||||
if !svc.IsConnected(sessionID) {
|
||||
t.Error("session should be connected")
|
||||
}
|
||||
|
||||
svc.Disconnect(sessionID)
|
||||
|
||||
if svc.IsConnected(sessionID) {
|
||||
t.Error("session should not be connected after disconnect")
|
||||
}
|
||||
}
|
||||
@ -1,52 +0,0 @@
|
||||
package rdp
|
||||
|
||||
// RDPConfig holds the parameters needed to establish an RDP connection.
|
||||
type RDPConfig struct {
|
||||
Hostname string // Remote host address
|
||||
Port int // RDP port (default 3389)
|
||||
Username string
|
||||
Password string
|
||||
Domain string // Windows domain (optional)
|
||||
Width int // Desktop width in pixels
|
||||
Height int // Desktop height in pixels
|
||||
ColorDepth int // 16, 24, or 32
|
||||
Security string // "nla", "tls", or "rdp"
|
||||
}
|
||||
|
||||
// FrameUpdate represents a partial screen update from the RDP server.
|
||||
// The data is in RGBA format (4 bytes per pixel).
|
||||
type FrameUpdate struct {
|
||||
X int // top-left X of the updated region
|
||||
Y int // top-left Y of the updated region
|
||||
Width int // width of the updated region in pixels
|
||||
Height int // height of the updated region in pixels
|
||||
Data []byte // RGBA pixel data, len = Width * Height * 4
|
||||
}
|
||||
|
||||
// RDPBackend abstracts the FreeRDP implementation so that a mock can be
|
||||
// used during development on platforms where FreeRDP is not available.
|
||||
type RDPBackend interface {
|
||||
// Connect establishes an RDP session with the given configuration.
|
||||
Connect(config RDPConfig) error
|
||||
|
||||
// Disconnect tears down the RDP session.
|
||||
Disconnect() error
|
||||
|
||||
// SendMouseEvent sends a mouse event with the given position and flags.
|
||||
// Flags use the RDP mouse event flag constants (MouseFlag*).
|
||||
SendMouseEvent(x, y int, flags uint32) error
|
||||
|
||||
// SendKeyEvent sends a keyboard event for the given RDP scancode.
|
||||
// pressed=true for key down, false for key up.
|
||||
SendKeyEvent(scancode uint32, pressed bool) error
|
||||
|
||||
// SendClipboard sends clipboard text to the remote session.
|
||||
SendClipboard(data string) error
|
||||
|
||||
// GetFrame returns the current full-frame RGBA pixel buffer.
|
||||
// The returned slice length is Width * Height * 4.
|
||||
GetFrame() ([]byte, error)
|
||||
|
||||
// IsConnected reports whether the backend has an active connection.
|
||||
IsConnected() bool
|
||||
}
|
||||
@ -1,96 +0,0 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const MaxSessions = 32
|
||||
|
||||
type Manager struct {
|
||||
mu sync.RWMutex
|
||||
sessions map[string]*SessionInfo
|
||||
}
|
||||
|
||||
func NewManager() *Manager {
|
||||
return &Manager{
|
||||
sessions: make(map[string]*SessionInfo),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) Create(connectionID int64, protocol string) (*SessionInfo, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if len(m.sessions) >= MaxSessions {
|
||||
return nil, fmt.Errorf("maximum sessions (%d) reached", MaxSessions)
|
||||
}
|
||||
|
||||
s := &SessionInfo{
|
||||
ID: uuid.NewString(),
|
||||
ConnectionID: connectionID,
|
||||
Protocol: protocol,
|
||||
State: StateConnecting,
|
||||
TabPosition: len(m.sessions),
|
||||
}
|
||||
m.sessions[s.ID] = s
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (m *Manager) Get(id string) (*SessionInfo, bool) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
s, ok := m.sessions[id]
|
||||
return s, ok
|
||||
}
|
||||
|
||||
func (m *Manager) List() []*SessionInfo {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
list := make([]*SessionInfo, 0, len(m.sessions))
|
||||
for _, s := range m.sessions {
|
||||
list = append(list, s)
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
func (m *Manager) SetState(id string, state SessionState) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
s, ok := m.sessions[id]
|
||||
if !ok {
|
||||
return fmt.Errorf("session %s not found", id)
|
||||
}
|
||||
s.State = state
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) Detach(id string) error {
|
||||
return m.SetState(id, StateDetached)
|
||||
}
|
||||
|
||||
func (m *Manager) Reattach(id, windowID string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
s, ok := m.sessions[id]
|
||||
if !ok {
|
||||
return fmt.Errorf("session %s not found", id)
|
||||
}
|
||||
s.State = StateConnected
|
||||
s.WindowID = windowID
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) Remove(id string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.sessions, id)
|
||||
}
|
||||
|
||||
func (m *Manager) Count() int {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return len(m.sessions)
|
||||
}
|
||||
@ -1,64 +0,0 @@
|
||||
package session
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCreateSession(t *testing.T) {
|
||||
m := NewManager()
|
||||
s, err := m.Create(1, "ssh")
|
||||
if err != nil {
|
||||
t.Fatalf("Create() error: %v", err)
|
||||
}
|
||||
if s.ID == "" {
|
||||
t.Error("session ID should not be empty")
|
||||
}
|
||||
if s.State != StateConnecting {
|
||||
t.Errorf("State = %q, want %q", s.State, StateConnecting)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaxSessions(t *testing.T) {
|
||||
m := NewManager()
|
||||
for i := 0; i < MaxSessions; i++ {
|
||||
_, err := m.Create(int64(i), "ssh")
|
||||
if err != nil {
|
||||
t.Fatalf("Create() error at %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
_, err := m.Create(999, "ssh")
|
||||
if err == nil {
|
||||
t.Error("Create() should fail at max sessions")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetachReattach(t *testing.T) {
|
||||
m := NewManager()
|
||||
s, _ := m.Create(1, "ssh")
|
||||
m.SetState(s.ID, StateConnected)
|
||||
|
||||
if err := m.Detach(s.ID); err != nil {
|
||||
t.Fatalf("Detach() error: %v", err)
|
||||
}
|
||||
|
||||
got, _ := m.Get(s.ID)
|
||||
if got.State != StateDetached {
|
||||
t.Errorf("State = %q, want %q", got.State, StateDetached)
|
||||
}
|
||||
|
||||
if err := m.Reattach(s.ID, "window-1"); err != nil {
|
||||
t.Fatalf("Reattach() error: %v", err)
|
||||
}
|
||||
|
||||
got, _ = m.Get(s.ID)
|
||||
if got.State != StateConnected {
|
||||
t.Errorf("State = %q, want %q", got.State, StateConnected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveSession(t *testing.T) {
|
||||
m := NewManager()
|
||||
s, _ := m.Create(1, "ssh")
|
||||
m.Remove(s.ID)
|
||||
if m.Count() != 0 {
|
||||
t.Error("session should have been removed")
|
||||
}
|
||||
}
|
||||
@ -1,22 +0,0 @@
|
||||
package session
|
||||
|
||||
import "time"
|
||||
|
||||
type SessionState string
|
||||
|
||||
const (
|
||||
StateConnecting SessionState = "connecting"
|
||||
StateConnected SessionState = "connected"
|
||||
StateDisconnected SessionState = "disconnected"
|
||||
StateDetached SessionState = "detached"
|
||||
)
|
||||
|
||||
type SessionInfo struct {
|
||||
ID string `json:"id"`
|
||||
ConnectionID int64 `json:"connectionId"`
|
||||
Protocol string `json:"protocol"`
|
||||
State SessionState `json:"state"`
|
||||
WindowID string `json:"windowId"`
|
||||
TabPosition int `json:"tabPosition"`
|
||||
ConnectedAt time.Time `json:"connectedAt"`
|
||||
}
|
||||
@ -1,41 +0,0 @@
|
||||
package settings
|
||||
|
||||
import "database/sql"
|
||||
|
||||
type SettingsService struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewSettingsService(db *sql.DB) *SettingsService {
|
||||
return &SettingsService{db: db}
|
||||
}
|
||||
|
||||
func (s *SettingsService) Get(key string) (string, error) {
|
||||
var value string
|
||||
err := s.db.QueryRow("SELECT value FROM settings WHERE key = ?", key).Scan(&value)
|
||||
if err == sql.ErrNoRows {
|
||||
return "", nil
|
||||
}
|
||||
return value, err
|
||||
}
|
||||
|
||||
func (s *SettingsService) GetDefault(key, defaultValue string) string {
|
||||
val, err := s.Get(key)
|
||||
if err != nil || val == "" {
|
||||
return defaultValue
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
func (s *SettingsService) Set(key, value string) error {
|
||||
_, err := s.db.Exec(
|
||||
"INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?",
|
||||
key, value, value,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SettingsService) Delete(key string) error {
|
||||
_, err := s.db.Exec("DELETE FROM settings WHERE key = ?", key)
|
||||
return err
|
||||
}
|
||||
@ -1,70 +0,0 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/vstockwell/wraith/internal/db"
|
||||
)
|
||||
|
||||
func setupTestDB(t *testing.T) *SettingsService {
|
||||
t.Helper()
|
||||
d, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Migrate(d); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { d.Close() })
|
||||
return NewSettingsService(d)
|
||||
}
|
||||
|
||||
func TestSetAndGet(t *testing.T) {
|
||||
s := setupTestDB(t)
|
||||
|
||||
if err := s.Set("theme", "dracula"); err != nil {
|
||||
t.Fatalf("Set() error: %v", err)
|
||||
}
|
||||
|
||||
val, err := s.Get("theme")
|
||||
if err != nil {
|
||||
t.Fatalf("Get() error: %v", err)
|
||||
}
|
||||
if val != "dracula" {
|
||||
t.Errorf("Get() = %q, want %q", val, "dracula")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMissing(t *testing.T) {
|
||||
s := setupTestDB(t)
|
||||
|
||||
val, err := s.Get("nonexistent")
|
||||
if err != nil {
|
||||
t.Fatalf("Get() error: %v", err)
|
||||
}
|
||||
if val != "" {
|
||||
t.Errorf("Get() = %q, want empty string", val)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetOverwrites(t *testing.T) {
|
||||
s := setupTestDB(t)
|
||||
|
||||
s.Set("key", "value1")
|
||||
s.Set("key", "value2")
|
||||
|
||||
val, _ := s.Get("key")
|
||||
if val != "value2" {
|
||||
t.Errorf("Get() = %q, want %q", val, "value2")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetWithDefault(t *testing.T) {
|
||||
s := setupTestDB(t)
|
||||
|
||||
val := s.GetDefault("missing", "fallback")
|
||||
if val != "fallback" {
|
||||
t.Errorf("GetDefault() = %q, want %q", val, "fallback")
|
||||
}
|
||||
}
|
||||
@ -1,238 +0,0 @@
|
||||
package sftp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/pkg/sftp"
|
||||
)
|
||||
|
||||
const MaxEditFileSize = 5 * 1024 * 1024 // 5MB
|
||||
|
||||
// FileEntry represents a file or directory in a remote filesystem.
|
||||
type FileEntry struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Size int64 `json:"size"`
|
||||
IsDir bool `json:"isDir"`
|
||||
Permissions string `json:"permissions"`
|
||||
ModTime string `json:"modTime"`
|
||||
}
|
||||
|
||||
// SFTPService manages SFTP clients keyed by session ID.
|
||||
type SFTPService struct {
|
||||
clients map[string]*sftp.Client
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewSFTPService creates a new SFTPService.
|
||||
func NewSFTPService() *SFTPService {
|
||||
return &SFTPService{
|
||||
clients: make(map[string]*sftp.Client),
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterClient stores an SFTP client for a session.
|
||||
func (s *SFTPService) RegisterClient(sessionID string, client *sftp.Client) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.clients[sessionID] = client
|
||||
}
|
||||
|
||||
// RemoveClient removes and closes an SFTP client.
|
||||
func (s *SFTPService) RemoveClient(sessionID string) {
|
||||
s.mu.Lock()
|
||||
client, ok := s.clients[sessionID]
|
||||
if ok {
|
||||
delete(s.clients, sessionID)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
if ok && client != nil {
|
||||
client.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// getClient returns the SFTP client for a session or an error if not found.
|
||||
func (s *SFTPService) getClient(sessionID string) (*sftp.Client, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
client, ok := s.clients[sessionID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("no SFTP client for session %s", sessionID)
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// SortEntries sorts file entries with directories first, then alphabetically by name.
|
||||
func SortEntries(entries []FileEntry) {
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
if entries[i].IsDir != entries[j].IsDir {
|
||||
return entries[i].IsDir
|
||||
}
|
||||
return strings.ToLower(entries[i].Name) < strings.ToLower(entries[j].Name)
|
||||
})
|
||||
}
|
||||
|
||||
// fileInfoToEntry converts an os.FileInfo and its path into a FileEntry.
|
||||
func fileInfoToEntry(info os.FileInfo, path string) FileEntry {
|
||||
return FileEntry{
|
||||
Name: info.Name(),
|
||||
Path: path,
|
||||
Size: info.Size(),
|
||||
IsDir: info.IsDir(),
|
||||
Permissions: info.Mode().Perm().String(),
|
||||
ModTime: info.ModTime().UTC().Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
}
|
||||
|
||||
// List returns directory contents sorted (dirs first, then files alphabetically).
|
||||
func (s *SFTPService) List(sessionID string, path string) ([]FileEntry, error) {
|
||||
client, err := s.getClient(sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
infos, err := client.ReadDir(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read directory %s: %w", path, err)
|
||||
}
|
||||
|
||||
entries := make([]FileEntry, 0, len(infos))
|
||||
for _, info := range infos {
|
||||
entryPath := path
|
||||
if !strings.HasSuffix(entryPath, "/") {
|
||||
entryPath += "/"
|
||||
}
|
||||
entryPath += info.Name()
|
||||
entries = append(entries, fileInfoToEntry(info, entryPath))
|
||||
}
|
||||
|
||||
SortEntries(entries)
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// ReadFile reads a file (max 5MB). Returns content as string.
|
||||
func (s *SFTPService) ReadFile(sessionID string, path string) (string, error) {
|
||||
client, err := s.getClient(sessionID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
info, err := client.Stat(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("stat %s: %w", path, err)
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
return "", fmt.Errorf("%s is a directory", path)
|
||||
}
|
||||
|
||||
if info.Size() > MaxEditFileSize {
|
||||
return "", fmt.Errorf("file %s is %d bytes, exceeds max edit size of %d bytes", path, info.Size(), MaxEditFileSize)
|
||||
}
|
||||
|
||||
f, err := client.Open(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("open %s: %w", path, err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
data, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read %s: %w", path, err)
|
||||
}
|
||||
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// WriteFile writes content to a file.
|
||||
func (s *SFTPService) WriteFile(sessionID string, path string, content string) error {
|
||||
client, err := s.getClient(sessionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
f, err := client.Create(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create %s: %w", path, err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if _, err := f.Write([]byte(content)); err != nil {
|
||||
return fmt.Errorf("write %s: %w", path, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Mkdir creates a directory.
|
||||
func (s *SFTPService) Mkdir(sessionID string, path string) error {
|
||||
client, err := s.getClient(sessionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := client.Mkdir(path); err != nil {
|
||||
return fmt.Errorf("mkdir %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes a file or empty directory.
|
||||
func (s *SFTPService) Delete(sessionID string, path string) error {
|
||||
client, err := s.getClient(sessionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
info, err := client.Stat(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("stat %s: %w", path, err)
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
if err := client.RemoveDirectory(path); err != nil {
|
||||
return fmt.Errorf("remove directory %s: %w", path, err)
|
||||
}
|
||||
} else {
|
||||
if err := client.Remove(path); err != nil {
|
||||
return fmt.Errorf("remove %s: %w", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Rename renames/moves a file.
|
||||
func (s *SFTPService) Rename(sessionID string, oldPath, newPath string) error {
|
||||
client, err := s.getClient(sessionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := client.Rename(oldPath, newPath); err != nil {
|
||||
return fmt.Errorf("rename %s to %s: %w", oldPath, newPath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stat returns info about a file/directory.
|
||||
func (s *SFTPService) Stat(sessionID string, path string) (*FileEntry, error) {
|
||||
client, err := s.getClient(sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
info, err := client.Stat(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stat %s: %w", path, err)
|
||||
}
|
||||
|
||||
entry := fileInfoToEntry(info, path)
|
||||
return &entry, nil
|
||||
}
|
||||
@ -1,119 +0,0 @@
|
||||
package sftp
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewSFTPService(t *testing.T) {
|
||||
svc := NewSFTPService()
|
||||
if svc == nil {
|
||||
t.Fatal("nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListWithoutClient(t *testing.T) {
|
||||
svc := NewSFTPService()
|
||||
_, err := svc.List("nonexistent", "/")
|
||||
if err == nil {
|
||||
t.Error("should error without client")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadFileWithoutClient(t *testing.T) {
|
||||
svc := NewSFTPService()
|
||||
_, err := svc.ReadFile("nonexistent", "/etc/hosts")
|
||||
if err == nil {
|
||||
t.Error("should error without client")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFileWithoutClient(t *testing.T) {
|
||||
svc := NewSFTPService()
|
||||
err := svc.WriteFile("nonexistent", "/tmp/test", "data")
|
||||
if err == nil {
|
||||
t.Error("should error without client")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMkdirWithoutClient(t *testing.T) {
|
||||
svc := NewSFTPService()
|
||||
err := svc.Mkdir("nonexistent", "/tmp/newdir")
|
||||
if err == nil {
|
||||
t.Error("should error without client")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteWithoutClient(t *testing.T) {
|
||||
svc := NewSFTPService()
|
||||
err := svc.Delete("nonexistent", "/tmp/file")
|
||||
if err == nil {
|
||||
t.Error("should error without client")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenameWithoutClient(t *testing.T) {
|
||||
svc := NewSFTPService()
|
||||
err := svc.Rename("nonexistent", "/old", "/new")
|
||||
if err == nil {
|
||||
t.Error("should error without client")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatWithoutClient(t *testing.T) {
|
||||
svc := NewSFTPService()
|
||||
_, err := svc.Stat("nonexistent", "/tmp")
|
||||
if err == nil {
|
||||
t.Error("should error without client")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileEntrySorting(t *testing.T) {
|
||||
// Test that SortEntries puts dirs first, then alpha
|
||||
entries := []FileEntry{
|
||||
{Name: "zebra.txt", IsDir: false},
|
||||
{Name: "alpha", IsDir: true},
|
||||
{Name: "beta.conf", IsDir: false},
|
||||
{Name: "omega", IsDir: true},
|
||||
}
|
||||
SortEntries(entries)
|
||||
if entries[0].Name != "alpha" {
|
||||
t.Errorf("[0] = %s, want alpha", entries[0].Name)
|
||||
}
|
||||
if entries[1].Name != "omega" {
|
||||
t.Errorf("[1] = %s, want omega", entries[1].Name)
|
||||
}
|
||||
if entries[2].Name != "beta.conf" {
|
||||
t.Errorf("[2] = %s, want beta.conf", entries[2].Name)
|
||||
}
|
||||
if entries[3].Name != "zebra.txt" {
|
||||
t.Errorf("[3] = %s, want zebra.txt", entries[3].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSortEntriesEmpty(t *testing.T) {
|
||||
entries := []FileEntry{}
|
||||
SortEntries(entries)
|
||||
if len(entries) != 0 {
|
||||
t.Errorf("expected empty slice, got %d entries", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSortEntriesCaseInsensitive(t *testing.T) {
|
||||
entries := []FileEntry{
|
||||
{Name: "Zebra", IsDir: false},
|
||||
{Name: "alpha", IsDir: false},
|
||||
}
|
||||
SortEntries(entries)
|
||||
if entries[0].Name != "alpha" {
|
||||
t.Errorf("[0] = %s, want alpha", entries[0].Name)
|
||||
}
|
||||
if entries[1].Name != "Zebra" {
|
||||
t.Errorf("[1] = %s, want Zebra", entries[1].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaxEditFileSize(t *testing.T) {
|
||||
if MaxEditFileSize != 5*1024*1024 {
|
||||
t.Errorf("MaxEditFileSize = %d, want %d", MaxEditFileSize, 5*1024*1024)
|
||||
}
|
||||
}
|
||||
@ -1,128 +0,0 @@
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// CWDTracker parses OSC 7 escape sequences from terminal output to track the
|
||||
// remote working directory.
|
||||
type CWDTracker struct {
|
||||
currentPath string
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewCWDTracker creates a new CWDTracker.
|
||||
func NewCWDTracker() *CWDTracker {
|
||||
return &CWDTracker{}
|
||||
}
|
||||
|
||||
// osc7Prefix is the escape sequence that starts an OSC 7 directive.
|
||||
var osc7Prefix = []byte("\033]7;")
|
||||
|
||||
// stTerminator is the ST (String Terminator) escape: ESC + backslash.
|
||||
var stTerminator = []byte("\033\\")
|
||||
|
||||
// belTerminator is the BEL character, an alternative OSC terminator.
|
||||
var belTerminator = []byte{0x07}
|
||||
|
||||
// ProcessOutput scans data for OSC 7 sequences of the form:
|
||||
//
|
||||
// \033]7;file://hostname/path\033\\ (ST terminator)
|
||||
// \033]7;file://hostname/path\007 (BEL terminator)
|
||||
//
|
||||
// It returns cleaned output with all OSC 7 sequences stripped and the new CWD
|
||||
// path (or "" if no OSC 7 was found).
|
||||
func (t *CWDTracker) ProcessOutput(data []byte) (cleaned []byte, newCWD string) {
|
||||
var result []byte
|
||||
remaining := data
|
||||
var lastCWD string
|
||||
|
||||
for {
|
||||
idx := bytes.Index(remaining, osc7Prefix)
|
||||
if idx == -1 {
|
||||
result = append(result, remaining...)
|
||||
break
|
||||
}
|
||||
|
||||
// Append everything before the OSC 7 sequence.
|
||||
result = append(result, remaining[:idx]...)
|
||||
|
||||
// Find the end of the OSC 7 payload (after the prefix).
|
||||
afterPrefix := remaining[idx+len(osc7Prefix):]
|
||||
|
||||
// Try ST terminator first (\033\\), then BEL (\007).
|
||||
endIdx := -1
|
||||
terminatorLen := 0
|
||||
|
||||
if stIdx := bytes.Index(afterPrefix, stTerminator); stIdx != -1 {
|
||||
endIdx = stIdx
|
||||
terminatorLen = len(stTerminator)
|
||||
}
|
||||
if belIdx := bytes.Index(afterPrefix, belTerminator); belIdx != -1 {
|
||||
if endIdx == -1 || belIdx < endIdx {
|
||||
endIdx = belIdx
|
||||
terminatorLen = len(belTerminator)
|
||||
}
|
||||
}
|
||||
|
||||
if endIdx == -1 {
|
||||
// No terminator found; treat the rest as literal output.
|
||||
result = append(result, remaining[idx:]...)
|
||||
break
|
||||
}
|
||||
|
||||
// Extract the URI payload between prefix and terminator.
|
||||
payload := string(afterPrefix[:endIdx])
|
||||
if path := extractPathFromOSC7(payload); path != "" {
|
||||
lastCWD = path
|
||||
}
|
||||
|
||||
remaining = afterPrefix[endIdx+terminatorLen:]
|
||||
}
|
||||
|
||||
if lastCWD != "" {
|
||||
t.mu.Lock()
|
||||
t.currentPath = lastCWD
|
||||
t.mu.Unlock()
|
||||
}
|
||||
|
||||
return result, lastCWD
|
||||
}
|
||||
|
||||
// GetCWD returns the current tracked working directory.
|
||||
func (t *CWDTracker) GetCWD() string {
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
return t.currentPath
|
||||
}
|
||||
|
||||
// extractPathFromOSC7 parses a file:// URI and returns the path component.
|
||||
func extractPathFromOSC7(uri string) string {
|
||||
u, err := url.Parse(uri)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
if u.Scheme != "file" {
|
||||
return ""
|
||||
}
|
||||
return u.Path
|
||||
}
|
||||
|
||||
// ShellIntegrationCommand returns the shell command to inject for CWD tracking
|
||||
// via OSC 7. The returned command sets up a prompt hook that emits the OSC 7
|
||||
// escape sequence after every command.
|
||||
func ShellIntegrationCommand(shellType string) string {
|
||||
switch shellType {
|
||||
case "bash":
|
||||
return fmt.Sprintf(`PROMPT_COMMAND='printf "\033]7;file://%%s%%s\033\\" "$HOSTNAME" "$PWD"'`)
|
||||
case "zsh":
|
||||
return fmt.Sprintf(`precmd() { printf '\033]7;file://%%s%%s\033\\' "$HOST" "$PWD" }`)
|
||||
case "fish":
|
||||
return `function __wraith_osc7 --on-event fish_prompt; printf '\033]7;file://%s%s\033\\' (hostname) (pwd); end`
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@ -1,72 +0,0 @@
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestProcessOutputBasicOSC7(t *testing.T) {
|
||||
tracker := NewCWDTracker()
|
||||
input := []byte("hello\033]7;file://myhost/home/user\033\\world")
|
||||
cleaned, cwd := tracker.ProcessOutput(input)
|
||||
if string(cleaned) != "helloworld" {
|
||||
t.Errorf("cleaned = %q", string(cleaned))
|
||||
}
|
||||
if cwd != "/home/user" {
|
||||
t.Errorf("cwd = %q", cwd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessOutputBELTerminator(t *testing.T) {
|
||||
tracker := NewCWDTracker()
|
||||
input := []byte("output\033]7;file://host/tmp\007more")
|
||||
cleaned, cwd := tracker.ProcessOutput(input)
|
||||
if string(cleaned) != "outputmore" {
|
||||
t.Errorf("cleaned = %q", string(cleaned))
|
||||
}
|
||||
if cwd != "/tmp" {
|
||||
t.Errorf("cwd = %q", cwd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessOutputNoOSC7(t *testing.T) {
|
||||
tracker := NewCWDTracker()
|
||||
input := []byte("just normal output")
|
||||
cleaned, cwd := tracker.ProcessOutput(input)
|
||||
if string(cleaned) != "just normal output" {
|
||||
t.Errorf("cleaned = %q", string(cleaned))
|
||||
}
|
||||
if cwd != "" {
|
||||
t.Errorf("cwd should be empty, got %q", cwd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessOutputMultipleOSC7(t *testing.T) {
|
||||
tracker := NewCWDTracker()
|
||||
input := []byte("\033]7;file://h/dir1\033\\text\033]7;file://h/dir2\033\\end")
|
||||
cleaned, cwd := tracker.ProcessOutput(input)
|
||||
if string(cleaned) != "textend" {
|
||||
t.Errorf("cleaned = %q", string(cleaned))
|
||||
}
|
||||
if cwd != "/dir2" {
|
||||
t.Errorf("cwd = %q, want /dir2", cwd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCWDPersists(t *testing.T) {
|
||||
tracker := NewCWDTracker()
|
||||
tracker.ProcessOutput([]byte("\033]7;file://h/home/user\033\\"))
|
||||
if tracker.GetCWD() != "/home/user" {
|
||||
t.Errorf("GetCWD = %q", tracker.GetCWD())
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellIntegrationCommand(t *testing.T) {
|
||||
cmd := ShellIntegrationCommand("bash")
|
||||
if cmd == "" {
|
||||
t.Error("bash command should not be empty")
|
||||
}
|
||||
cmd = ShellIntegrationCommand("zsh")
|
||||
if cmd == "" {
|
||||
t.Error("zsh command should not be empty")
|
||||
}
|
||||
}
|
||||
@ -1,89 +0,0 @@
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// HostKeyResult represents the result of a host key verification.
|
||||
type HostKeyResult int
|
||||
|
||||
const (
|
||||
HostKeyNew HostKeyResult = iota // never seen this host before
|
||||
HostKeyMatch // fingerprint matches stored
|
||||
HostKeyChanged // fingerprint CHANGED — possible MITM
|
||||
)
|
||||
|
||||
// HostKeyStore stores and verifies SSH host key fingerprints in the host_keys SQLite table.
|
||||
type HostKeyStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewHostKeyStore creates a new HostKeyStore backed by the given database.
|
||||
func NewHostKeyStore(db *sql.DB) *HostKeyStore {
|
||||
return &HostKeyStore{db: db}
|
||||
}
|
||||
|
||||
// Verify checks whether the given fingerprint matches any stored host key for the
|
||||
// specified hostname, port, and key type. It returns HostKeyNew if no key is stored,
|
||||
// HostKeyMatch if the fingerprint matches, or HostKeyChanged if it differs.
|
||||
func (s *HostKeyStore) Verify(hostname string, port int, keyType string, fingerprint string) (HostKeyResult, error) {
|
||||
var storedFingerprint string
|
||||
err := s.db.QueryRow(
|
||||
"SELECT fingerprint FROM host_keys WHERE hostname = ? AND port = ? AND key_type = ?",
|
||||
hostname, port, keyType,
|
||||
).Scan(&storedFingerprint)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return HostKeyNew, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("query host key: %w", err)
|
||||
}
|
||||
|
||||
if storedFingerprint == fingerprint {
|
||||
return HostKeyMatch, nil
|
||||
}
|
||||
return HostKeyChanged, nil
|
||||
}
|
||||
|
||||
// Store inserts or replaces a host key fingerprint for the given hostname, port, and key type.
|
||||
func (s *HostKeyStore) Store(hostname string, port int, keyType string, fingerprint string, rawKey string) error {
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO host_keys (hostname, port, key_type, fingerprint, raw_key)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT (hostname, port, key_type)
|
||||
DO UPDATE SET fingerprint = excluded.fingerprint, raw_key = excluded.raw_key`,
|
||||
hostname, port, keyType, fingerprint, rawKey,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store host key: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes all stored host keys for the given hostname and port.
|
||||
func (s *HostKeyStore) Delete(hostname string, port int) error {
|
||||
_, err := s.db.Exec(
|
||||
"DELETE FROM host_keys WHERE hostname = ? AND port = ?",
|
||||
hostname, port,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete host key: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetFingerprint returns the stored fingerprint for the given hostname and port.
|
||||
// It returns an empty string and sql.ErrNoRows if no key is stored.
|
||||
func (s *HostKeyStore) GetFingerprint(hostname string, port int) (string, error) {
|
||||
var fingerprint string
|
||||
err := s.db.QueryRow(
|
||||
"SELECT fingerprint FROM host_keys WHERE hostname = ? AND port = ?",
|
||||
hostname, port,
|
||||
).Scan(&fingerprint)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get fingerprint: %w", err)
|
||||
}
|
||||
return fingerprint, nil
|
||||
}
|
||||
@ -1,77 +0,0 @@
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/vstockwell/wraith/internal/db"
|
||||
)
|
||||
|
||||
func setupHostKeyStore(t *testing.T) *HostKeyStore {
|
||||
t.Helper()
|
||||
d, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Migrate(d); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { d.Close() })
|
||||
return NewHostKeyStore(d)
|
||||
}
|
||||
|
||||
func TestVerifyNewHost(t *testing.T) {
|
||||
store := setupHostKeyStore(t)
|
||||
result, err := store.Verify("192.168.1.4", 22, "ssh-ed25519", "SHA256:abc123")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result != HostKeyNew {
|
||||
t.Errorf("got %d, want HostKeyNew", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreAndVerifyMatch(t *testing.T) {
|
||||
store := setupHostKeyStore(t)
|
||||
if err := store.Store("192.168.1.4", 22, "ssh-ed25519", "SHA256:abc123", "AAAA..."); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := store.Verify("192.168.1.4", 22, "ssh-ed25519", "SHA256:abc123")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result != HostKeyMatch {
|
||||
t.Errorf("got %d, want HostKeyMatch", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyChangedKey(t *testing.T) {
|
||||
store := setupHostKeyStore(t)
|
||||
if err := store.Store("192.168.1.4", 22, "ssh-ed25519", "SHA256:abc123", "AAAA..."); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := store.Verify("192.168.1.4", 22, "ssh-ed25519", "SHA256:DIFFERENT")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result != HostKeyChanged {
|
||||
t.Errorf("got %d, want HostKeyChanged", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteHostKey(t *testing.T) {
|
||||
store := setupHostKeyStore(t)
|
||||
if err := store.Store("192.168.1.4", 22, "ssh-ed25519", "SHA256:abc123", "AAAA..."); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.Delete("192.168.1.4", 22); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := store.Verify("192.168.1.4", 22, "ssh-ed25519", "SHA256:abc123")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result != HostKeyNew {
|
||||
t.Errorf("after delete, got %d, want HostKeyNew", result)
|
||||
}
|
||||
}
|
||||
@ -1,259 +0,0 @@
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"io"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// OutputHandler is called when data is read from an SSH session's stdout.
|
||||
// In production this will emit Wails events; for testing, a simple callback.
|
||||
type OutputHandler func(sessionID string, data []byte)
|
||||
|
||||
// SSHSession represents an active SSH connection with its PTY shell session.
|
||||
type SSHSession struct {
|
||||
ID string
|
||||
Client *ssh.Client
|
||||
Session *ssh.Session
|
||||
Stdin io.WriteCloser
|
||||
ConnID int64
|
||||
Hostname string
|
||||
Port int
|
||||
Username string
|
||||
Connected time.Time
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// SSHService manages SSH connections and their associated sessions.
|
||||
type SSHService struct {
|
||||
sessions map[string]*SSHSession
|
||||
mu sync.RWMutex
|
||||
db *sql.DB
|
||||
outputHandler OutputHandler
|
||||
}
|
||||
|
||||
// NewSSHService creates a new SSHService. The outputHandler is called when data
|
||||
// arrives from a session's stdout. Pass nil if output handling is not needed.
|
||||
func NewSSHService(db *sql.DB, outputHandler OutputHandler) *SSHService {
|
||||
return &SSHService{
|
||||
sessions: make(map[string]*SSHSession),
|
||||
db: db,
|
||||
outputHandler: outputHandler,
|
||||
}
|
||||
}
|
||||
|
||||
// Connect dials an SSH server, opens a session with a PTY and shell, and
|
||||
// launches a goroutine to read stdout. Returns the session ID.
|
||||
func (s *SSHService) Connect(hostname string, port int, username string, authMethods []ssh.AuthMethod, cols, rows int) (string, error) {
|
||||
addr := fmt.Sprintf("%s:%d", hostname, port)
|
||||
|
||||
config := &ssh.ClientConfig{
|
||||
User: username,
|
||||
Auth: authMethods,
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
Timeout: 15 * time.Second,
|
||||
}
|
||||
|
||||
client, err := ssh.Dial("tcp", addr, config)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("ssh dial %s: %w", addr, err)
|
||||
}
|
||||
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
client.Close()
|
||||
return "", fmt.Errorf("new session: %w", err)
|
||||
}
|
||||
|
||||
modes := ssh.TerminalModes{
|
||||
ssh.ECHO: 1,
|
||||
ssh.TTY_OP_ISPEED: 14400,
|
||||
ssh.TTY_OP_OSPEED: 14400,
|
||||
}
|
||||
|
||||
if err := session.RequestPty("xterm-256color", rows, cols, modes); err != nil {
|
||||
session.Close()
|
||||
client.Close()
|
||||
return "", fmt.Errorf("request pty: %w", err)
|
||||
}
|
||||
|
||||
stdin, err := session.StdinPipe()
|
||||
if err != nil {
|
||||
session.Close()
|
||||
client.Close()
|
||||
return "", fmt.Errorf("stdin pipe: %w", err)
|
||||
}
|
||||
|
||||
stdout, err := session.StdoutPipe()
|
||||
if err != nil {
|
||||
session.Close()
|
||||
client.Close()
|
||||
return "", fmt.Errorf("stdout pipe: %w", err)
|
||||
}
|
||||
|
||||
if err := session.Shell(); err != nil {
|
||||
session.Close()
|
||||
client.Close()
|
||||
return "", fmt.Errorf("start shell: %w", err)
|
||||
}
|
||||
|
||||
sessionID := uuid.NewString()
|
||||
sshSession := &SSHSession{
|
||||
ID: sessionID,
|
||||
Client: client,
|
||||
Session: session,
|
||||
Stdin: stdin,
|
||||
Hostname: hostname,
|
||||
Port: port,
|
||||
Username: username,
|
||||
Connected: time.Now(),
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.sessions[sessionID] = sshSession
|
||||
s.mu.Unlock()
|
||||
|
||||
// Launch goroutine to read stdout and forward data via the output handler
|
||||
go s.readLoop(sessionID, stdout)
|
||||
|
||||
return sessionID, nil
|
||||
}
|
||||
|
||||
// readLoop continuously reads from the session stdout and calls the output
|
||||
// handler with data. It stops when the reader returns an error (typically EOF
|
||||
// when the session closes).
|
||||
func (s *SSHService) readLoop(sessionID string, reader io.Reader) {
|
||||
buf := make([]byte, 32*1024)
|
||||
for {
|
||||
n, err := reader.Read(buf)
|
||||
if n > 0 && s.outputHandler != nil {
|
||||
data := make([]byte, n)
|
||||
copy(data, buf[:n])
|
||||
s.outputHandler(sessionID, data)
|
||||
}
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write sends data to the session's stdin.
|
||||
func (s *SSHService) Write(sessionID string, data string) error {
|
||||
s.mu.RLock()
|
||||
sess, ok := s.sessions[sessionID]
|
||||
s.mu.RUnlock()
|
||||
|
||||
if !ok {
|
||||
return fmt.Errorf("session %s not found", sessionID)
|
||||
}
|
||||
|
||||
sess.mu.Lock()
|
||||
defer sess.mu.Unlock()
|
||||
|
||||
if sess.Stdin == nil {
|
||||
return fmt.Errorf("session %s stdin is closed", sessionID)
|
||||
}
|
||||
|
||||
_, err := sess.Stdin.Write([]byte(data))
|
||||
if err != nil {
|
||||
return fmt.Errorf("write to session %s: %w", sessionID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Resize sends a window-change request to the remote PTY.
|
||||
func (s *SSHService) Resize(sessionID string, cols, rows int) error {
|
||||
s.mu.RLock()
|
||||
sess, ok := s.sessions[sessionID]
|
||||
s.mu.RUnlock()
|
||||
|
||||
if !ok {
|
||||
return fmt.Errorf("session %s not found", sessionID)
|
||||
}
|
||||
|
||||
sess.mu.Lock()
|
||||
defer sess.mu.Unlock()
|
||||
|
||||
if sess.Session == nil {
|
||||
return fmt.Errorf("session %s is closed", sessionID)
|
||||
}
|
||||
|
||||
if err := sess.Session.WindowChange(rows, cols); err != nil {
|
||||
return fmt.Errorf("resize session %s: %w", sessionID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Disconnect closes the SSH session and client, and removes it from tracking.
|
||||
func (s *SSHService) Disconnect(sessionID string) error {
|
||||
s.mu.Lock()
|
||||
sess, ok := s.sessions[sessionID]
|
||||
if !ok {
|
||||
s.mu.Unlock()
|
||||
return fmt.Errorf("session %s not found", sessionID)
|
||||
}
|
||||
delete(s.sessions, sessionID)
|
||||
s.mu.Unlock()
|
||||
|
||||
sess.mu.Lock()
|
||||
defer sess.mu.Unlock()
|
||||
|
||||
if sess.Stdin != nil {
|
||||
sess.Stdin.Close()
|
||||
}
|
||||
if sess.Session != nil {
|
||||
sess.Session.Close()
|
||||
}
|
||||
if sess.Client != nil {
|
||||
sess.Client.Close()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSession returns the SSHSession for the given ID, or false if not found.
|
||||
func (s *SSHService) GetSession(sessionID string) (*SSHSession, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
sess, ok := s.sessions[sessionID]
|
||||
return sess, ok
|
||||
}
|
||||
|
||||
// ListSessions returns all active SSH sessions.
|
||||
func (s *SSHService) ListSessions() []*SSHSession {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
list := make([]*SSHSession, 0, len(s.sessions))
|
||||
for _, sess := range s.sessions {
|
||||
list = append(list, sess)
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
// BuildPasswordAuth creates an ssh.AuthMethod for password authentication.
|
||||
func (s *SSHService) BuildPasswordAuth(password string) ssh.AuthMethod {
|
||||
return ssh.Password(password)
|
||||
}
|
||||
|
||||
// BuildKeyAuth creates an ssh.AuthMethod from a PEM-encoded private key.
|
||||
// If the key is encrypted, pass the passphrase; otherwise pass an empty string.
|
||||
func (s *SSHService) BuildKeyAuth(privateKey []byte, passphrase string) (ssh.AuthMethod, error) {
|
||||
var signer ssh.Signer
|
||||
var err error
|
||||
|
||||
if passphrase != "" {
|
||||
signer, err = ssh.ParsePrivateKeyWithPassphrase(privateKey, []byte(passphrase))
|
||||
} else {
|
||||
signer, err = ssh.ParsePrivateKey(privateKey)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse private key: %w", err)
|
||||
}
|
||||
|
||||
return ssh.PublicKeys(signer), nil
|
||||
}
|
||||
@ -1,148 +0,0 @@
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/pem"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
func TestNewSSHService(t *testing.T) {
|
||||
svc := NewSSHService(nil, nil)
|
||||
if svc == nil {
|
||||
t.Fatal("NewSSHService returned nil")
|
||||
}
|
||||
if len(svc.ListSessions()) != 0 {
|
||||
t.Error("new service should have no sessions")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPasswordAuth(t *testing.T) {
|
||||
svc := NewSSHService(nil, nil)
|
||||
auth := svc.BuildPasswordAuth("mypassword")
|
||||
if auth == nil {
|
||||
t.Error("BuildPasswordAuth returned nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildKeyAuth(t *testing.T) {
|
||||
svc := NewSSHService(nil, nil)
|
||||
|
||||
// Generate a test Ed25519 key
|
||||
_, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKey error: %v", err)
|
||||
}
|
||||
pemBlock, err := ssh.MarshalPrivateKey(priv, "")
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalPrivateKey error: %v", err)
|
||||
}
|
||||
keyBytes := pem.EncodeToMemory(pemBlock)
|
||||
|
||||
auth, err := svc.BuildKeyAuth(keyBytes, "")
|
||||
if err != nil {
|
||||
t.Fatalf("BuildKeyAuth error: %v", err)
|
||||
}
|
||||
if auth == nil {
|
||||
t.Error("BuildKeyAuth returned nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildKeyAuthInvalidKey(t *testing.T) {
|
||||
svc := NewSSHService(nil, nil)
|
||||
_, err := svc.BuildKeyAuth([]byte("not a key"), "")
|
||||
if err == nil {
|
||||
t.Error("BuildKeyAuth should fail with invalid key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionTracking(t *testing.T) {
|
||||
svc := NewSSHService(nil, nil)
|
||||
|
||||
// Manually add a session to test tracking
|
||||
svc.mu.Lock()
|
||||
svc.sessions["test-123"] = &SSHSession{
|
||||
ID: "test-123",
|
||||
Hostname: "192.168.1.4",
|
||||
Port: 22,
|
||||
Username: "vstockwell",
|
||||
Connected: time.Now(),
|
||||
}
|
||||
svc.mu.Unlock()
|
||||
|
||||
s, ok := svc.GetSession("test-123")
|
||||
if !ok {
|
||||
t.Fatal("session not found")
|
||||
}
|
||||
if s.Hostname != "192.168.1.4" {
|
||||
t.Errorf("Hostname = %q, want %q", s.Hostname, "192.168.1.4")
|
||||
}
|
||||
|
||||
sessions := svc.ListSessions()
|
||||
if len(sessions) != 1 {
|
||||
t.Errorf("ListSessions() = %d, want 1", len(sessions))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSessionNotFound(t *testing.T) {
|
||||
svc := NewSSHService(nil, nil)
|
||||
_, ok := svc.GetSession("nonexistent")
|
||||
if ok {
|
||||
t.Error("GetSession should return false for nonexistent session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteNotFound(t *testing.T) {
|
||||
svc := NewSSHService(nil, nil)
|
||||
err := svc.Write("nonexistent", "data")
|
||||
if err == nil {
|
||||
t.Error("Write should fail for nonexistent session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResizeNotFound(t *testing.T) {
|
||||
svc := NewSSHService(nil, nil)
|
||||
err := svc.Resize("nonexistent", 80, 24)
|
||||
if err == nil {
|
||||
t.Error("Resize should fail for nonexistent session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisconnectNotFound(t *testing.T) {
|
||||
svc := NewSSHService(nil, nil)
|
||||
err := svc.Disconnect("nonexistent")
|
||||
if err == nil {
|
||||
t.Error("Disconnect should fail for nonexistent session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisconnectRemovesSession(t *testing.T) {
|
||||
svc := NewSSHService(nil, nil)
|
||||
|
||||
// Manually add a session with nil Client/Session/Stdin (no real connection)
|
||||
svc.mu.Lock()
|
||||
svc.sessions["test-dc"] = &SSHSession{
|
||||
ID: "test-dc",
|
||||
Hostname: "10.0.0.1",
|
||||
Port: 22,
|
||||
Username: "admin",
|
||||
Connected: time.Now(),
|
||||
}
|
||||
svc.mu.Unlock()
|
||||
|
||||
if err := svc.Disconnect("test-dc"); err != nil {
|
||||
t.Fatalf("Disconnect error: %v", err)
|
||||
}
|
||||
|
||||
_, ok := svc.GetSession("test-dc")
|
||||
if ok {
|
||||
t.Error("session should be removed after Disconnect")
|
||||
}
|
||||
|
||||
if len(svc.ListSessions()) != 0 {
|
||||
t.Error("ListSessions should be empty after Disconnect")
|
||||
}
|
||||
}
|
||||
@ -1,94 +0,0 @@
|
||||
package theme
|
||||
|
||||
type Theme struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Foreground string `json:"foreground"`
|
||||
Background string `json:"background"`
|
||||
Cursor string `json:"cursor"`
|
||||
Black string `json:"black"`
|
||||
Red string `json:"red"`
|
||||
Green string `json:"green"`
|
||||
Yellow string `json:"yellow"`
|
||||
Blue string `json:"blue"`
|
||||
Magenta string `json:"magenta"`
|
||||
Cyan string `json:"cyan"`
|
||||
White string `json:"white"`
|
||||
BrightBlack string `json:"brightBlack"`
|
||||
BrightRed string `json:"brightRed"`
|
||||
BrightGreen string `json:"brightGreen"`
|
||||
BrightYellow string `json:"brightYellow"`
|
||||
BrightBlue string `json:"brightBlue"`
|
||||
BrightMagenta string `json:"brightMagenta"`
|
||||
BrightCyan string `json:"brightCyan"`
|
||||
BrightWhite string `json:"brightWhite"`
|
||||
SelectionBg string `json:"selectionBg,omitempty"`
|
||||
SelectionFg string `json:"selectionFg,omitempty"`
|
||||
IsBuiltin bool `json:"isBuiltin"`
|
||||
}
|
||||
|
||||
var BuiltinThemes = []Theme{
|
||||
{
|
||||
Name: "Dracula", IsBuiltin: true,
|
||||
Foreground: "#f8f8f2", Background: "#282a36", Cursor: "#f8f8f2",
|
||||
Black: "#21222c", Red: "#ff5555", Green: "#50fa7b", Yellow: "#f1fa8c",
|
||||
Blue: "#bd93f9", Magenta: "#ff79c6", Cyan: "#8be9fd", White: "#f8f8f2",
|
||||
BrightBlack: "#6272a4", BrightRed: "#ff6e6e", BrightGreen: "#69ff94",
|
||||
BrightYellow: "#ffffa5", BrightBlue: "#d6acff", BrightMagenta: "#ff92df",
|
||||
BrightCyan: "#a4ffff", BrightWhite: "#ffffff",
|
||||
},
|
||||
{
|
||||
Name: "Nord", IsBuiltin: true,
|
||||
Foreground: "#d8dee9", Background: "#2e3440", Cursor: "#d8dee9",
|
||||
Black: "#3b4252", Red: "#bf616a", Green: "#a3be8c", Yellow: "#ebcb8b",
|
||||
Blue: "#81a1c1", Magenta: "#b48ead", Cyan: "#88c0d0", White: "#e5e9f0",
|
||||
BrightBlack: "#4c566a", BrightRed: "#bf616a", BrightGreen: "#a3be8c",
|
||||
BrightYellow: "#ebcb8b", BrightBlue: "#81a1c1", BrightMagenta: "#b48ead",
|
||||
BrightCyan: "#8fbcbb", BrightWhite: "#eceff4",
|
||||
},
|
||||
{
|
||||
Name: "Monokai", IsBuiltin: true,
|
||||
Foreground: "#f8f8f2", Background: "#272822", Cursor: "#f8f8f0",
|
||||
Black: "#272822", Red: "#f92672", Green: "#a6e22e", Yellow: "#f4bf75",
|
||||
Blue: "#66d9ef", Magenta: "#ae81ff", Cyan: "#a1efe4", White: "#f8f8f2",
|
||||
BrightBlack: "#75715e", BrightRed: "#f92672", BrightGreen: "#a6e22e",
|
||||
BrightYellow: "#f4bf75", BrightBlue: "#66d9ef", BrightMagenta: "#ae81ff",
|
||||
BrightCyan: "#a1efe4", BrightWhite: "#f9f8f5",
|
||||
},
|
||||
{
|
||||
Name: "One Dark", IsBuiltin: true,
|
||||
Foreground: "#abb2bf", Background: "#282c34", Cursor: "#528bff",
|
||||
Black: "#282c34", Red: "#e06c75", Green: "#98c379", Yellow: "#e5c07b",
|
||||
Blue: "#61afef", Magenta: "#c678dd", Cyan: "#56b6c2", White: "#abb2bf",
|
||||
BrightBlack: "#545862", BrightRed: "#e06c75", BrightGreen: "#98c379",
|
||||
BrightYellow: "#e5c07b", BrightBlue: "#61afef", BrightMagenta: "#c678dd",
|
||||
BrightCyan: "#56b6c2", BrightWhite: "#c8ccd4",
|
||||
},
|
||||
{
|
||||
Name: "Solarized Dark", IsBuiltin: true,
|
||||
Foreground: "#839496", Background: "#002b36", Cursor: "#839496",
|
||||
Black: "#073642", Red: "#dc322f", Green: "#859900", Yellow: "#b58900",
|
||||
Blue: "#268bd2", Magenta: "#d33682", Cyan: "#2aa198", White: "#eee8d5",
|
||||
BrightBlack: "#002b36", BrightRed: "#cb4b16", BrightGreen: "#586e75",
|
||||
BrightYellow: "#657b83", BrightBlue: "#839496", BrightMagenta: "#6c71c4",
|
||||
BrightCyan: "#93a1a1", BrightWhite: "#fdf6e3",
|
||||
},
|
||||
{
|
||||
Name: "Gruvbox Dark", IsBuiltin: true,
|
||||
Foreground: "#ebdbb2", Background: "#282828", Cursor: "#ebdbb2",
|
||||
Black: "#282828", Red: "#cc241d", Green: "#98971a", Yellow: "#d79921",
|
||||
Blue: "#458588", Magenta: "#b16286", Cyan: "#689d6a", White: "#a89984",
|
||||
BrightBlack: "#928374", BrightRed: "#fb4934", BrightGreen: "#b8bb26",
|
||||
BrightYellow: "#fabd2f", BrightBlue: "#83a598", BrightMagenta: "#d3869b",
|
||||
BrightCyan: "#8ec07c", BrightWhite: "#ebdbb2",
|
||||
},
|
||||
{
|
||||
Name: "MobaXTerm Classic", IsBuiltin: true,
|
||||
Foreground: "#ececec", Background: "#242424", Cursor: "#b4b4c0",
|
||||
Black: "#000000", Red: "#aa4244", Green: "#7e8d53", Yellow: "#e4b46d",
|
||||
Blue: "#6e9aba", Magenta: "#9e5085", Cyan: "#80d5cf", White: "#cccccc",
|
||||
BrightBlack: "#808080", BrightRed: "#cc7b7d", BrightGreen: "#a5b17c",
|
||||
BrightYellow: "#ecc995", BrightBlue: "#96b6cd", BrightMagenta: "#c083ac",
|
||||
BrightCyan: "#a9e2de", BrightWhite: "#cccccc",
|
||||
},
|
||||
}
|
||||
@ -1,85 +0,0 @@
|
||||
package theme
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type ThemeService struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewThemeService(db *sql.DB) *ThemeService {
|
||||
return &ThemeService{db: db}
|
||||
}
|
||||
|
||||
func (s *ThemeService) SeedBuiltins() error {
|
||||
for _, t := range BuiltinThemes {
|
||||
_, err := s.db.Exec(
|
||||
`INSERT OR IGNORE INTO themes (name, foreground, background, cursor,
|
||||
black, red, green, yellow, blue, magenta, cyan, white,
|
||||
bright_black, bright_red, bright_green, bright_yellow, bright_blue,
|
||||
bright_magenta, bright_cyan, bright_white, selection_bg, selection_fg, is_builtin)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1)`,
|
||||
t.Name, t.Foreground, t.Background, t.Cursor,
|
||||
t.Black, t.Red, t.Green, t.Yellow, t.Blue, t.Magenta, t.Cyan, t.White,
|
||||
t.BrightBlack, t.BrightRed, t.BrightGreen, t.BrightYellow, t.BrightBlue,
|
||||
t.BrightMagenta, t.BrightCyan, t.BrightWhite, t.SelectionBg, t.SelectionFg,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("seed theme %s: %w", t.Name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ThemeService) List() ([]Theme, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, name, foreground, background, cursor,
|
||||
black, red, green, yellow, blue, magenta, cyan, white,
|
||||
bright_black, bright_red, bright_green, bright_yellow, bright_blue,
|
||||
bright_magenta, bright_cyan, bright_white,
|
||||
COALESCE(selection_bg,''), COALESCE(selection_fg,''), is_builtin
|
||||
FROM themes ORDER BY is_builtin DESC, name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var themes []Theme
|
||||
for rows.Next() {
|
||||
var t Theme
|
||||
if err := rows.Scan(&t.ID, &t.Name, &t.Foreground, &t.Background, &t.Cursor,
|
||||
&t.Black, &t.Red, &t.Green, &t.Yellow, &t.Blue, &t.Magenta, &t.Cyan, &t.White,
|
||||
&t.BrightBlack, &t.BrightRed, &t.BrightGreen, &t.BrightYellow, &t.BrightBlue,
|
||||
&t.BrightMagenta, &t.BrightCyan, &t.BrightWhite,
|
||||
&t.SelectionBg, &t.SelectionFg, &t.IsBuiltin); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
themes = append(themes, t)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return themes, nil
|
||||
}
|
||||
|
||||
func (s *ThemeService) GetByName(name string) (*Theme, error) {
|
||||
var t Theme
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, name, foreground, background, cursor,
|
||||
black, red, green, yellow, blue, magenta, cyan, white,
|
||||
bright_black, bright_red, bright_green, bright_yellow, bright_blue,
|
||||
bright_magenta, bright_cyan, bright_white,
|
||||
COALESCE(selection_bg,''), COALESCE(selection_fg,''), is_builtin
|
||||
FROM themes WHERE name = ?`, name,
|
||||
).Scan(&t.ID, &t.Name, &t.Foreground, &t.Background, &t.Cursor,
|
||||
&t.Black, &t.Red, &t.Green, &t.Yellow, &t.Blue, &t.Magenta, &t.Cyan, &t.White,
|
||||
&t.BrightBlack, &t.BrightRed, &t.BrightGreen, &t.BrightYellow, &t.BrightBlue,
|
||||
&t.BrightMagenta, &t.BrightCyan, &t.BrightWhite,
|
||||
&t.SelectionBg, &t.SelectionFg, &t.IsBuiltin)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get theme %s: %w", name, err)
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
@ -1,60 +0,0 @@
|
||||
package theme
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/vstockwell/wraith/internal/db"
|
||||
)
|
||||
|
||||
func setupTestDB(t *testing.T) *ThemeService {
|
||||
t.Helper()
|
||||
d, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Migrate(d); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { d.Close() })
|
||||
return NewThemeService(d)
|
||||
}
|
||||
|
||||
func TestSeedBuiltins(t *testing.T) {
|
||||
svc := setupTestDB(t)
|
||||
if err := svc.SeedBuiltins(); err != nil {
|
||||
t.Fatalf("SeedBuiltins() error: %v", err)
|
||||
}
|
||||
|
||||
themes, err := svc.List()
|
||||
if err != nil {
|
||||
t.Fatalf("List() error: %v", err)
|
||||
}
|
||||
if len(themes) != len(BuiltinThemes) {
|
||||
t.Errorf("len(themes) = %d, want %d", len(themes), len(BuiltinThemes))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedBuiltinsIdempotent(t *testing.T) {
|
||||
svc := setupTestDB(t)
|
||||
svc.SeedBuiltins()
|
||||
svc.SeedBuiltins()
|
||||
|
||||
themes, _ := svc.List()
|
||||
if len(themes) != len(BuiltinThemes) {
|
||||
t.Errorf("len(themes) = %d after double seed, want %d", len(themes), len(BuiltinThemes))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetByName(t *testing.T) {
|
||||
svc := setupTestDB(t)
|
||||
svc.SeedBuiltins()
|
||||
|
||||
theme, err := svc.GetByName("Dracula")
|
||||
if err != nil {
|
||||
t.Fatalf("GetByName() error: %v", err)
|
||||
}
|
||||
if theme.Background != "#282a36" {
|
||||
t.Errorf("Background = %q, want %q", theme.Background, "#282a36")
|
||||
}
|
||||
}
|
||||
@ -1,95 +0,0 @@
|
||||
package vault
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
)
|
||||
|
||||
const (
|
||||
argonTime = 3
|
||||
argonMemory = 64 * 1024
|
||||
argonThreads = 4
|
||||
argonKeyLen = 32
|
||||
)
|
||||
|
||||
func DeriveKey(password string, salt []byte) []byte {
|
||||
return argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
|
||||
}
|
||||
|
||||
func GenerateSalt() ([]byte, error) {
|
||||
salt := make([]byte, 32)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return nil, fmt.Errorf("generate salt: %w", err)
|
||||
}
|
||||
return salt, nil
|
||||
}
|
||||
|
||||
type VaultService struct {
|
||||
key []byte
|
||||
}
|
||||
|
||||
func NewVaultService(key []byte) *VaultService {
|
||||
return &VaultService{key: key}
|
||||
}
|
||||
|
||||
func (v *VaultService) Encrypt(plaintext string) (string, error) {
|
||||
block, err := aes.NewCipher(v.key)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create cipher: %w", err)
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create GCM: %w", err)
|
||||
}
|
||||
|
||||
iv := make([]byte, gcm.NonceSize())
|
||||
if _, err := rand.Read(iv); err != nil {
|
||||
return "", fmt.Errorf("generate IV: %w", err)
|
||||
}
|
||||
|
||||
sealed := gcm.Seal(nil, iv, []byte(plaintext), nil)
|
||||
|
||||
return fmt.Sprintf("v1:%s:%s", hex.EncodeToString(iv), hex.EncodeToString(sealed)), nil
|
||||
}
|
||||
|
||||
func (v *VaultService) Decrypt(encrypted string) (string, error) {
|
||||
parts := strings.SplitN(encrypted, ":", 3)
|
||||
if len(parts) != 3 || parts[0] != "v1" {
|
||||
return "", errors.New("invalid encrypted format: expected v1:{iv}:{sealed}")
|
||||
}
|
||||
|
||||
iv, err := hex.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("decode IV: %w", err)
|
||||
}
|
||||
|
||||
sealed, err := hex.DecodeString(parts[2])
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("decode sealed data: %w", err)
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(v.key)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create cipher: %w", err)
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create GCM: %w", err)
|
||||
}
|
||||
|
||||
plaintext, err := gcm.Open(nil, iv, sealed, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("decrypt: %w", err)
|
||||
}
|
||||
|
||||
return string(plaintext), nil
|
||||
}
|
||||
@ -1,104 +0,0 @@
|
||||
package vault
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDeriveKeyConsistent(t *testing.T) {
|
||||
salt := []byte("test-salt-exactly-32-bytes-long!")
|
||||
key1 := DeriveKey("mypassword", salt)
|
||||
key2 := DeriveKey("mypassword", salt)
|
||||
|
||||
if len(key1) != 32 {
|
||||
t.Errorf("key length = %d, want 32", len(key1))
|
||||
}
|
||||
if string(key1) != string(key2) {
|
||||
t.Error("same password+salt produced different keys")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveKeyDifferentPasswords(t *testing.T) {
|
||||
salt := []byte("test-salt-exactly-32-bytes-long!")
|
||||
key1 := DeriveKey("password1", salt)
|
||||
key2 := DeriveKey("password2", salt)
|
||||
|
||||
if string(key1) == string(key2) {
|
||||
t.Error("different passwords produced same key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptDecryptRoundTrip(t *testing.T) {
|
||||
key := DeriveKey("testpassword", []byte("test-salt-exactly-32-bytes-long!"))
|
||||
vs := NewVaultService(key)
|
||||
|
||||
plaintext := "super-secret-ssh-key-data"
|
||||
encrypted, err := vs.Encrypt(plaintext)
|
||||
if err != nil {
|
||||
t.Fatalf("Encrypt() error: %v", err)
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(encrypted, "v1:") {
|
||||
t.Errorf("encrypted does not start with v1: prefix: %q", encrypted[:10])
|
||||
}
|
||||
|
||||
decrypted, err := vs.Decrypt(encrypted)
|
||||
if err != nil {
|
||||
t.Fatalf("Decrypt() error: %v", err)
|
||||
}
|
||||
|
||||
if decrypted != plaintext {
|
||||
t.Errorf("Decrypt() = %q, want %q", decrypted, plaintext)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptProducesDifferentCiphertexts(t *testing.T) {
|
||||
key := DeriveKey("testpassword", []byte("test-salt-exactly-32-bytes-long!"))
|
||||
vs := NewVaultService(key)
|
||||
|
||||
enc1, _ := vs.Encrypt("same-data")
|
||||
enc2, _ := vs.Encrypt("same-data")
|
||||
|
||||
if enc1 == enc2 {
|
||||
t.Error("two encryptions of same data produced identical ciphertext (IV reuse)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptWrongKey(t *testing.T) {
|
||||
key1 := DeriveKey("password1", []byte("test-salt-exactly-32-bytes-long!"))
|
||||
key2 := DeriveKey("password2", []byte("test-salt-exactly-32-bytes-long!"))
|
||||
|
||||
vs1 := NewVaultService(key1)
|
||||
vs2 := NewVaultService(key2)
|
||||
|
||||
encrypted, _ := vs1.Encrypt("secret")
|
||||
_, err := vs2.Decrypt(encrypted)
|
||||
if err == nil {
|
||||
t.Error("Decrypt() with wrong key should return error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptInvalidFormat(t *testing.T) {
|
||||
key := DeriveKey("test", []byte("test-salt-exactly-32-bytes-long!"))
|
||||
vs := NewVaultService(key)
|
||||
|
||||
_, err := vs.Decrypt("not-valid-format")
|
||||
if err == nil {
|
||||
t.Error("Decrypt() with invalid format should return error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateSalt(t *testing.T) {
|
||||
salt1, err := GenerateSalt()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateSalt() error: %v", err)
|
||||
}
|
||||
if len(salt1) != 32 {
|
||||
t.Errorf("salt length = %d, want 32", len(salt1))
|
||||
}
|
||||
|
||||
salt2, _ := GenerateSalt()
|
||||
if string(salt1) == string(salt2) {
|
||||
t.Error("two calls to GenerateSalt produced identical salt")
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user