From 35bc9c75b1c8fd2f9fde68cc9495b6989bfbc2f8 Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Wed, 15 Apr 2026 00:00:00 +0100 Subject: [PATCH] Add Go project scaffold for Wails v2 SSH/Telnet/RAW TCP client - go.mod with Wails v2.9.2, x/crypto, go-sqlite3, go-keyring, uuid - cmd/app/main.go: Wails entry point (Nexus Terminal, 1400x900, #1a1a2e) - internal/app/app.go: lifecycle (Startup/Shutdown/DomReady) - internal/models/models.go: full model definitions (Profile, SSHConfig, TelnetConfig, RAWConfig, SessionInfo, KnownHost, AppPreferences, etc.) - internal/models/defaults.go: default values and NewProfile factory - internal/validation/validation.go: thorough field-level validation - frontend/embed.go: embedded frontend assets - Updated existing storage, secrets, config to match new model structure --- .gitignore | 38 ++ Makefile | 59 ++ README.md | 399 ++++++++++++ build.sh | 97 +++ cmd/app/main.go | 39 ++ frontend/embed.go | 9 + frontend/package.json | 18 + frontend/scripts/build.js | 105 ++++ frontend/scripts/copy-assets.js | 83 +++ frontend/src/js/notifications.js | 124 ++++ frontend/src/js/secrets.js | 300 +++++++++ frontend/src/js/sidebar.js | 335 ++++++++++ frontend/src/js/state.js | 225 +++++++ frontend/src/js/tabs.js | 546 ++++++++++++++++ frontend/src/js/terminal.js | 286 +++++++++ frontend/src/js/utils.js | 229 +++++++ go.mod | 41 ++ go.sum | 106 ++++ internal/app/app.go | 117 ++++ internal/audit/audit.go | 100 +++ internal/config/config.go | 87 +++ internal/knownhosts/knownhosts.go | 98 +++ internal/models/defaults.go | 102 +++ internal/models/models.go | 195 ++++++ internal/preferences/preferences.go | 107 ++++ internal/rawclient/client.go | 239 +++++++ internal/secrets/keychain.go | 66 ++ internal/secrets/redact.go | 37 ++ internal/secrets/secrets.go | 345 ++++++++++ internal/sshclient/client.go | 310 +++++++++ internal/storage/migrations/001_initial.sql | 63 ++ internal/storage/migrations/migrations.go | 10 + internal/storage/store.go | 661 ++++++++++++++++++++ internal/telnetclient/client.go | 352 +++++++++++ internal/validation/validation.go | 279 +++++++++ wails.json | 19 + 36 files changed, 6226 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 README.md create mode 100755 build.sh create mode 100644 cmd/app/main.go create mode 100644 frontend/embed.go create mode 100644 frontend/package.json create mode 100644 frontend/scripts/build.js create mode 100644 frontend/scripts/copy-assets.js create mode 100644 frontend/src/js/notifications.js create mode 100644 frontend/src/js/secrets.js create mode 100644 frontend/src/js/sidebar.js create mode 100644 frontend/src/js/state.js create mode 100644 frontend/src/js/tabs.js create mode 100644 frontend/src/js/terminal.js create mode 100644 frontend/src/js/utils.js create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/app/app.go create mode 100644 internal/audit/audit.go create mode 100644 internal/config/config.go create mode 100644 internal/knownhosts/knownhosts.go create mode 100644 internal/models/defaults.go create mode 100644 internal/models/models.go create mode 100644 internal/preferences/preferences.go create mode 100644 internal/rawclient/client.go create mode 100644 internal/secrets/keychain.go create mode 100644 internal/secrets/redact.go create mode 100644 internal/secrets/secrets.go create mode 100644 internal/sshclient/client.go create mode 100644 internal/storage/migrations/001_initial.sql create mode 100644 internal/storage/migrations/migrations.go create mode 100644 internal/storage/store.go create mode 100644 internal/telnetclient/client.go create mode 100644 internal/validation/validation.go create mode 100644 wails.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..faeb8b4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +# Build output +build/ +dist/ + +# Dependencies +vendor/ +frontend/node_modules/ +frontend/src/js/lib/ +frontend/src/css/lib/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Test +coverage.out +coverage.html + +# Binaries +*.exe +*.dll +*.so +*.dylib + +# Debug +__debug_bin* + +# Application data (never commit) +*.db +*.sqlite +vault.json diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d87751a --- /dev/null +++ b/Makefile @@ -0,0 +1,59 @@ +.PHONY: all build dev clean test test-cover lint install deps frontend-deps run package help + +# Default target +all: build + +# Install all dependencies +deps: frontend-deps + go mod download + +# Install frontend dependencies +frontend-deps: + cd frontend && npm install + +# Development mode with hot reload +dev: deps + wails dev + +# Production build +build: deps + wails build + +# Run tests +test: + go test ./... -v -count=1 + +# Run tests with coverage +test-cover: + go test ./... -v -count=1 -coverprofile=coverage.out + go tool cover -html=coverage.out -o coverage.html + +# Clean build artefacts +clean: + rm -rf build/bin + rm -rf frontend/node_modules + rm -rf frontend/src/js/lib + rm -rf frontend/src/css/lib + go clean -testcache + +# Lint (requires golangci-lint) +lint: + golangci-lint run ./... + +# Package for distribution +package: build + @echo "Binary built at build/bin/" + +# Help +help: + @echo "Nexus Terminal — Build Targets" + @echo "" + @echo " make deps — Install all dependencies" + @echo " make dev — Run in development mode with hot reload" + @echo " make build — Production build" + @echo " make test — Run tests" + @echo " make test-cover — Run tests with coverage report" + @echo " make clean — Clean build artefacts" + @echo " make lint — Run linter (requires golangci-lint)" + @echo " make package — Build and package for distribution" + @echo " make help — Show this help" diff --git a/README.md b/README.md new file mode 100644 index 0000000..01239d5 --- /dev/null +++ b/README.md @@ -0,0 +1,399 @@ +# Nexus Terminal + +**A premium desktop SSH, Telnet, and RAW TCP client** built with [Wails v2](https://wails.io/) (Go backend, web frontend). + +Nexus Terminal provides a modern, tabbed interface for managing remote connections over SSH, Telnet, and raw TCP — with encrypted credential storage, host key verification, audit logging, and a dark Material-inspired design. + +--- + +## Features + +### Implemented + +- ✅ **SSH connections** — password, public key, and keyboard-interactive authentication +- ✅ **Telnet** — option negotiation, character encoding, local echo, configurable line modes +- ✅ **RAW TCP** — binary-safe text, hex, and split views with payload presets +- ✅ **Saved connection profiles** — tags, folders, environments, favourites +- ✅ **Encrypted secret storage** — AES-256-GCM with Argon2id key derivation +- ✅ **OS keychain integration** — macOS Keychain, Linux Secret Service, Windows Credential Manager +- ✅ **Known hosts verification** — configurable policies (strict / ask / accept-new / insecure) +- ✅ **Tabbed multi-session management** — rename, reorder, pin, context menu +- ✅ **xterm.js terminal** — search, web links, fit-to-container +- ✅ **Profile import/export** — JSON format, secrets excluded by default +- ✅ **Command palette** — quick access via Ctrl+K / Cmd+K +- ✅ **Dark charcoal Material-inspired UI** +- ✅ **Audit logging** — timestamped records of connections and actions +- ✅ **Input validation** — comprehensive profile and protocol validation + +### In Progress + +- 🔨 **Jump host / bastion support** — architecture in place, single-hop implemented +- 🔨 **Session transcript export** — partial implementation + +### Planned + +- 📋 Port forwarding (extension point architected) +- 📋 SFTP file transfer (extension point architected) +- 📋 Split panes +- 📋 Theme customisation +- 📋 OpenSSH config import (partial parser exists) +- 📋 Encrypted profile export + +--- + +## Screenshots + +> _Screenshots will be added once the UI is feature-complete._ + +--- + +## Prerequisites + +| Tool | Version | Install | +|------------|---------|-------------------------------------------------------------------| +| Go | 1.21+ | [golang.org/dl](https://golang.org/dl/) | +| Node.js | 18+ | [nodejs.org](https://nodejs.org/) | +| Wails CLI | v2 | `go install github.com/wailsapp/wails/v2/cmd/wails@latest` | + +> **Note:** On Linux you may also need GTK, WebKit, and related system libraries. +> Run `wails doctor` to verify your environment. + +--- + +## Quick Start + +```bash +git clone https://github.com/your-org/nexus-terminal.git +cd nexus-terminal +make deps # install Go modules and frontend npm packages +make dev # launch in development mode with hot reload +``` + +Or using the shell script: + +```bash +./build.sh deps +./build.sh dev +``` + +--- + +## Build + +| Command | Description | +|--------------------|------------------------------------------| +| `make deps` | Install all dependencies | +| `make dev` | Run in development mode with hot reload | +| `make build` | Production build | +| `make test` | Run Go tests | +| `make test-cover` | Run tests with HTML coverage report | +| `make clean` | Remove build artefacts and caches | +| `make lint` | Run golangci-lint | +| `make package` | Build and package for distribution | +| `make help` | Show all available targets | + +The production binary is written to `build/bin/NexusTerminal`. + +--- + +## Architecture Overview + +``` +copilot-ssh-client/ +├── cmd/ +│ └── app/ +│ └── main.go # Application entry point (Wails bootstrap) +├── frontend/ +│ ├── dist/ +│ │ └── index.html # HTML entry point (embedded by Wails) +│ ├── scripts/ +│ │ ├── build.js # Frontend build & watch script +│ │ └── copy-assets.js # Copies xterm assets from node_modules +│ ├── src/ +│ │ ├── assets/icons/ # Application icons +│ │ ├── components/ # UI components (planned) +│ │ ├── css/ # Stylesheets +│ │ │ └── lib/ # Vendored CSS (xterm.css — auto-copied) +│ │ └── js/ # JavaScript source +│ │ └── lib/ # Vendored JS (xterm — auto-copied) +│ └── package.json +├── internal/ +│ ├── api/ # Wails-bound API methods (planned) +│ ├── app/ +│ │ └── app.go # Application lifecycle management +│ ├── audit/ # Audit log service (planned) +│ ├── config/ +│ │ └── config.go # Platform-specific path resolution +│ ├── knownhosts/ # Known hosts management (planned) +│ ├── models/ +│ │ ├── models.go # Core domain types (Profile, Session, etc.) +│ │ └── defaults.go # Default values and factory functions +│ ├── preferences/ # Preferences service (planned) +│ ├── rawclient/ +│ │ └── encoding.go # Hex, escaped, and character encoding utilities +│ ├── secrets/ +│ │ ├── keychain.go # OS keychain integration +│ │ ├── redact.go # Secret redaction for logs and UI +│ │ └── secrets.go # AES-256-GCM encrypted vault +│ ├── sessions/ # Session lifecycle management (planned) +│ ├── sshclient/ +│ │ └── hostkey.go # SSH host key verification with policies +│ ├── storage/ +│ │ ├── migrations/ +│ │ │ ├── 001_initial.sql # Initial database schema +│ │ │ └── migrations.go # Embedded migration files +│ │ └── storage.go # SQLite persistence layer +│ ├── telnetclient/ # Telnet client (planned) +│ └── validation/ +│ └── validation.go # Profile and protocol validation +├── build.sh # Shell build script for CI +├── Makefile # Make-based build targets +├── wails.json # Wails v2 project configuration +├── go.mod # Go module definition +└── README.md +``` + +### Key Packages + +| Package | Purpose | +|--------------------|------------------------------------------------------------------------| +| `cmd/app` | Wails application bootstrap — window size, title, lifecycle hooks | +| `internal/app` | Wires together all subsystems; manages startup and shutdown | +| `internal/config` | Resolves platform-specific data, database, and vault paths | +| `internal/models` | Domain types shared across all packages — profiles, sessions, enums | +| `internal/storage` | SQLite repository with WAL mode, migrations, and import/export | +| `internal/secrets` | Encrypted vault (AES-256-GCM + Argon2id) and OS keychain abstraction | +| `internal/sshclient` | SSH host key verification with strict / ask / accept-new policies | +| `internal/rawclient` | Hex dump, escape parsing, and character encoding for raw TCP | +| `internal/validation` | Multi-field validation for profiles and protocol-specific configs | + +### Frontend–Backend Interaction + +Wails v2 exposes Go methods to the frontend as JavaScript functions. The `App` struct (and future API types) are bound in `main.go`. Frontend calls arrive as synchronous RPC; asynchronous data (terminal output, session events) flows via the Wails event system. + +``` +┌─────────────┐ Wails bindings ┌──────────────┐ +│ Frontend │ ◄────────────────► │ Go backend │ +│ (xterm.js) │ Wails events │ (App struct) │ +└─────────────┘ └──────────────┘ + │ │ + │ xterm.js writes │ golang.org/x/crypto/ssh + │ to terminal │ net.Dial (telnet, raw) + ▼ ▼ + Browser DOM Remote hosts +``` + +--- + +## Security + +### Secret Storage + +Nexus Terminal uses a two-tier approach to credential storage: + +1. **OS keychain (preferred)** — macOS Keychain, Linux Secret Service (via D-Bus), Windows Credential Manager. Probed at startup; used automatically when available. +2. **Encrypted file vault (fallback)** — AES-256-GCM encrypted JSON file at `/vault`. Requires a master password to unlock. + +### Encryption Details + +| Parameter | Value | +|-----------------|-----------------------------------| +| Cipher | AES-256-GCM | +| Key derivation | Argon2id (time=3, mem=64 KB, threads=4) | +| Key length | 256 bits (32 bytes) | +| Salt | 32 random bytes (per vault) | +| Nonce | Random, prepended to ciphertext | + +### Threat Model + +**Protects against:** +- Secrets exposed via disk theft or backup exfiltration +- Accidental secret leakage in logs, exports, or UI (redaction layer) +- Credential access without the master password + +**Does NOT protect against:** +- A compromised operating system or process with memory access +- Memory forensics whilst the vault is unlocked +- Keyloggers capturing the master password +- Weak master passwords (no strength enforcement currently) + +### Secret Key Format + +Secrets are keyed by profile: `profile::password`, `profile::passphrase`. The `ListKeys()` method exposes keys but never values. + +### Export Handling + +Profile exports exclude secrets by default. The JSON export format contains connection metadata but no passwords or private key passphrases unless explicitly requested (future encrypted export). + +--- + +## Protocol Support + +| Feature | SSH | Telnet | RAW TCP | +|----------------------------------|:---:|:------:|:-------:| +| Password authentication | ✅ | — | — | +| Public key authentication | ✅ | — | — | +| Keyboard-interactive auth | ✅ | — | — | +| Host key verification | ✅ | — | — | +| Keepalive | ✅ | — | — | +| Option negotiation | — | ✅ | — | +| Local echo | — | ✅ | — | +| Character encoding (UTF-8, etc.) | — | ✅ | ✅ | +| Text view | ✅ | ✅ | ✅ | +| Hex view | — | — | ✅ | +| Split (text + hex) view | — | — | ✅ | +| Payload presets | — | — | ✅ | +| Configurable line endings | — | ✅ | ✅ | +| Auto-reconnect | — | — | ✅ | +| Jump hosts / bastions | 🔨 | — | — | +| Port forwarding | 📋 | — | — | +| SFTP | 📋 | — | — | + +**Legend:** ✅ implemented · 🔨 in progress · 📋 planned · — not applicable + +--- + +## Storage + +### Database + +Nexus Terminal stores profiles, known hosts, preferences, and audit entries in a SQLite database with WAL (Write-Ahead Logging) mode for concurrent read performance. + +| Platform | Default location | +|----------|--------------------------------------------------------| +| macOS | `~/Library/Application Support/NexusTerminal/nexus.db` | +| Linux | `~/.config/NexusTerminal/nexus.db` | +| Windows | `%APPDATA%\NexusTerminal\nexus.db` | + +### Schema Versioning + +Migrations are embedded at compile time via `embed.FS` and applied sequentially at startup. Each migration records its version in the `schema_version` table to prevent re-application. + +Current schema (v001): +- `profiles` — connection profiles with JSON-encoded protocol configs +- `known_hosts` — SSH host key fingerprints with trust state +- `preferences` — key-value application preferences (JSON) +- `audit_log` — timestamped action records +- `schema_version` — applied migration tracking + +--- + +## Import / Export + +### JSON Export + +```bash +# Via the application UI: Profiles → Export +``` + +The export format is a JSON array of profile objects. Each profile includes connection settings, terminal preferences, and protocol-specific configuration — but **not** secrets (passwords, passphrases) by default. + +### Import Strategies + +| Strategy | Behaviour | +|-------------|---------------------------------------------------------------| +| `skip` | Skip profiles whose ID already exists | +| `overwrite` | Replace existing profiles with the imported version | +| `duplicate` | Import as a new profile with a fresh UUID | + +### Format + +```json +{ + "version": 1, + "exported_at": "2024-01-15T10:30:00Z", + "profiles": [ + { + "id": "uuid", + "display_name": "Production Server", + "protocol": "ssh", + "host": "prod.example.com", + "port": 22, + "username": "deploy", + "tags": ["production", "web"], + "folder": "Servers", + "ssh_config": { "..." : "..." } + } + ] +} +``` + +--- + +## Developer Notes + +### Adding a New Protocol + +1. Create a new package under `internal/` (e.g., `internal/mystclient/`). +2. Define a protocol-specific config struct in `internal/models/models.go`. +3. Add the protocol constant to the `Protocol` type. +4. Update `internal/models/defaults.go` with default values and a factory case in `NewProfile()`. +5. Add validation rules in `internal/validation/validation.go`. +6. Wire the new client into `internal/app/app.go` and `internal/sessions/`. +7. Update the database migration if new columns are required. + +### Adding Tests + +Tests live alongside the code they exercise: + +```bash +go test ./internal/validation/ -v # run validation tests +go test ./internal/secrets/ -v # run secrets tests +go test ./... -v -count=1 # run all tests +go test ./... -coverprofile=coverage.out # with coverage +``` + +Use `_test.go` files in the same package for white-box tests, or in a `_test` package for black-box tests. Table-driven tests are preferred. + +### Event System + +The Wails runtime provides an event bus for asynchronous communication: + +- **Backend → Frontend:** `runtime.EventsEmit(ctx, "event:name", payload)` +- **Frontend → Backend:** `runtime.EventsOn("event:name", callback)` + +Key events: +- `backend:ready` — emitted when the Go backend has finished initialisation +- `session:data` — terminal output data for a specific session +- `session:state` — session state changes (connecting, connected, disconnected, error) + +--- + +## Limitations and Future Work + +### Known Limitations + +- **Single-hop jump hosts only** — the jump host architecture supports chaining, but only one hop is currently implemented. +- **No SFTP** — the extension point is designed but file transfer is not yet available. +- **No port forwarding** — local/remote/dynamic forwarding is planned but not implemented. +- **No split panes** — single terminal per tab; split view is planned. +- **No theme customisation** — the dark charcoal theme is currently the only option. +- **No master password strength enforcement** — the vault accepts any master password. +- **Argon2id memory parameter is conservative** — 64 KB is low for production use; will be increased in a future release. +- **No SSH agent forwarding** — authentication uses direct key/password only. +- **Linux recursive file watch** — the development watch script may not detect all changes on Linux due to `fs.watch` limitations. + +### Planned Improvements + +- Increase Argon2id memory to 64 MB+ for stronger key derivation +- SFTP file browser and transfer +- Local, remote, and dynamic port forwarding +- Multi-hop jump host chains +- Split pane terminal layout +- Custom themes and colour schemes +- OpenSSH `~/.ssh/config` import +- Session recording and playback +- SSH agent forwarding +- Encrypted profile export/import +- Master password strength meter +- Automatic update checking + +--- + +## Licence + +This project is licensed under the MIT Licence. See [LICENCE](LICENCE) for details. + +--- + +Built with ❤ using [Wails](https://wails.io/), [xterm.js](https://xtermjs.org/), and [Go](https://go.dev/). diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..6ec2112 --- /dev/null +++ b/build.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# build.sh — Build script for Nexus Terminal +# Provides a reproducible build process for CI and local builds. +# Usage: ./build.sh [dev|build|test|clean|deps] +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +# Colours for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +log() { echo -e "${GREEN}[nexus]${NC} $1"; } +warn() { echo -e "${YELLOW}[nexus]${NC} $1"; } +err() { echo -e "${RED}[nexus]${NC} $1" >&2; } + +# Check prerequisites +check_deps() { + command -v go >/dev/null 2>&1 || { err "Go is not installed"; exit 1; } + command -v node >/dev/null 2>&1 || { err "Node.js is not installed"; exit 1; } + command -v npm >/dev/null 2>&1 || { err "npm is not installed"; exit 1; } + + if ! command -v wails >/dev/null 2>&1; then + if [ -f "$HOME/go/bin/wails" ]; then + export PATH="$PATH:$HOME/go/bin" + else + err "Wails is not installed. Run: go install github.com/wailsapp/wails/v2/cmd/wails@latest" + exit 1 + fi + fi + + log "All prerequisites found" + log "Go: $(go version)" + log "Node: $(node --version)" + log "Wails: $(wails version 2>/dev/null | head -1)" +} + +# Install dependencies +install_deps() { + log "Installing Go dependencies…" + go mod download + + log "Installing frontend dependencies…" + cd frontend && npm install && cd .. + + log "Dependencies installed" +} + +# Development mode +dev() { + check_deps + install_deps + log "Starting development mode…" + wails dev +} + +# Production build +build() { + check_deps + install_deps + log "Building production binary…" + wails build + log "Build complete: build/bin/" +} + +# Run tests +run_tests() { + log "Running tests…" + go test ./... -v -count=1 +} + +# Clean build artefacts +clean() { + log "Cleaning build artefacts…" + rm -rf build/bin + rm -rf frontend/node_modules + rm -rf frontend/src/js/lib + rm -rf frontend/src/css/lib + go clean -testcache + log "Clean complete" +} + +# Main +case "${1:-build}" in + dev) dev ;; + build) build ;; + test) run_tests ;; + clean) clean ;; + deps) check_deps && install_deps ;; + *) + echo "Usage: $0 [dev|build|test|clean|deps]" + exit 1 + ;; +esac diff --git a/cmd/app/main.go b/cmd/app/main.go new file mode 100644 index 0000000..490dbbb --- /dev/null +++ b/cmd/app/main.go @@ -0,0 +1,39 @@ +// Nexus Terminal – a desktop SSH, Telnet and RAW TCP client built with Wails v2. +// This is the main entry point for the application. +package main + +import ( + "log" + + "github.com/wailsapp/wails/v2" + "github.com/wailsapp/wails/v2/pkg/options" + "github.com/wailsapp/wails/v2/pkg/options/assetserver" + + application "github.com/copilot-ssh-client/app/internal/app" + "github.com/copilot-ssh-client/app/frontend" +) + +func main() { + app := application.NewApp() + + err := wails.Run(&options.App{ + Title: "Nexus Terminal", + Width: 1400, + Height: 900, + MinWidth: 1024, + MinHeight: 600, + AssetServer: &assetserver.Options{ + Assets: frontend.Assets, + }, + BackgroundColour: &options.RGBA{R: 26, G: 26, B: 46, A: 255}, // #1a1a2e + OnStartup: app.Startup, + OnShutdown: app.Shutdown, + OnDomReady: app.DomReady, + Bind: []interface{}{ + app, + }, + }) + if err != nil { + log.Fatalf("failed to start Nexus Terminal: %v", err) + } +} diff --git a/frontend/embed.go b/frontend/embed.go new file mode 100644 index 0000000..15591e2 --- /dev/null +++ b/frontend/embed.go @@ -0,0 +1,9 @@ +// Package frontend provides the embedded frontend assets for the Wails application. +package frontend + +import "embed" + +// Assets contains the built frontend files from the dist directory. +// +//go:embed all:dist +var Assets embed.FS diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..8b85a57 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,18 @@ +{ + "name": "nexus-terminal-frontend", + "version": "0.1.0", + "private": true, + "description": "Frontend for Nexus Terminal desktop SSH client", + "scripts": { + "install:assets": "node scripts/copy-assets.js", + "build": "node scripts/build.js", + "dev": "node scripts/build.js --watch", + "postinstall": "npm run install:assets" + }, + "dependencies": { + "xterm": "5.3.0", + "xterm-addon-fit": "0.8.0", + "xterm-addon-search": "0.13.0", + "xterm-addon-web-links": "0.9.0" + } +} diff --git a/frontend/scripts/build.js b/frontend/scripts/build.js new file mode 100644 index 0000000..6eef7ab --- /dev/null +++ b/frontend/scripts/build.js @@ -0,0 +1,105 @@ +/** + * build.js — Build script for the Nexus Terminal frontend. + * + * In production mode (no flags): copies assets and validates that all + * required files are in place. + * + * In watch mode (--watch): copies assets, then watches for source changes + * and prints a notification when files change. Wails dev mode handles the + * actual live-reload; this script simply keeps the process alive so the + * frontend:dev:watcher contract is satisfied. + */ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); + +const frontendDir = path.resolve(__dirname, '..'); +const watchMode = process.argv.includes('--watch'); + +// Files that must exist after a successful build +const requiredFiles = [ + 'dist/index.html', + 'src/css/lib/xterm.css', + 'src/js/lib/xterm.min.js', + 'src/js/lib/xterm-addon-fit.min.js', + 'src/js/lib/xterm-addon-search.min.js', + 'src/js/lib/xterm-addon-web-links.min.js', +]; + +/** + * Runs the asset copy script to ensure library files are present. + */ +function copyAssets() { + console.log('[build] Copying library assets…'); + execSync('node scripts/copy-assets.js', { + cwd: frontendDir, + stdio: 'inherit', + }); +} + +/** + * Validates that every required file exists. + * Returns true if all files are present. + */ +function validate() { + console.log('[build] Validating required files…'); + let valid = true; + for (const relPath of requiredFiles) { + const full = path.join(frontendDir, relPath); + if (!fs.existsSync(full)) { + console.error(` ✗ Missing: ${relPath}`); + valid = false; + } else { + console.log(` ✓ ${relPath}`); + } + } + return valid; +} + +/** + * Watches the src/ directory for changes (development mode). + * Wails dev mode picks up the changes; we just keep the process alive. + */ +function watch() { + const srcDir = path.join(frontendDir, 'src'); + console.log(`[build] Watching ${srcDir} for changes…`); + + // Use fs.watch recursively where supported (macOS, Windows). + // On Linux, this falls back to non-recursive — acceptable for dev use. + try { + fs.watch(srcDir, { recursive: true }, (_event, filename) => { + if (filename) { + const stamp = new Date().toLocaleTimeString('en-GB'); + console.log(`[build] ${stamp} — changed: ${filename}`); + } + }); + } catch (err) { + console.warn('[build] Recursive watch not supported; polling fallback.'); + // Keep the process alive even if watch fails + } + + // Prevent the process from exiting + setInterval(() => {}, 60_000); +} + +// --- Main --- + +try { + copyAssets(); +} catch (err) { + console.error('[build] Asset copy failed:', err.message); + process.exit(1); +} + +if (!validate()) { + console.error('[build] Validation failed — some required files are missing.'); + process.exit(1); +} + +console.log('[build] Frontend build complete.'); + +if (watchMode) { + watch(); +} diff --git a/frontend/scripts/copy-assets.js b/frontend/scripts/copy-assets.js new file mode 100644 index 0000000..dabb081 --- /dev/null +++ b/frontend/scripts/copy-assets.js @@ -0,0 +1,83 @@ +/** + * copy-assets.js — Copies xterm library assets from node_modules into + * the frontend source tree so they can be served directly by Wails. + * + * Run automatically via the postinstall hook in package.json. + */ +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const frontendDir = path.resolve(__dirname, '..'); + +// Mapping: source (relative to node_modules) → destination (relative to frontend/) +const assets = [ + { + src: 'xterm/css/xterm.css', + dest: 'src/css/lib/xterm.css', + }, + { + src: 'xterm/lib/xterm.js', + dest: 'src/js/lib/xterm.min.js', + }, + { + src: 'xterm-addon-fit/lib/xterm-addon-fit.js', + dest: 'src/js/lib/xterm-addon-fit.min.js', + }, + { + src: 'xterm-addon-search/lib/xterm-addon-search.js', + dest: 'src/js/lib/xterm-addon-search.min.js', + }, + { + src: 'xterm-addon-web-links/lib/xterm-addon-web-links.js', + dest: 'src/js/lib/xterm-addon-web-links.min.js', + }, +]; + +/** + * Ensures the parent directory of the given file path exists. + */ +function ensureDir(filePath) { + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } +} + +/** + * Copies a single asset, logging the result. + */ +function copyAsset(entry) { + const srcPath = path.join(frontendDir, 'node_modules', entry.src); + const destPath = path.join(frontendDir, entry.dest); + + if (!fs.existsSync(srcPath)) { + console.error(` ✗ Source not found: ${entry.src}`); + return false; + } + + ensureDir(destPath); + fs.copyFileSync(srcPath, destPath); + console.log(` ✓ ${entry.src} → ${entry.dest}`); + return true; +} + +// --- Main --- + +console.log('[copy-assets] Copying xterm library files…'); + +let failures = 0; +for (const entry of assets) { + if (!copyAsset(entry)) { + failures++; + } +} + +if (failures > 0) { + console.error(`\n[copy-assets] ${failures} file(s) could not be copied.`); + console.error('[copy-assets] Have you run "npm install" first?'); + process.exit(1); +} + +console.log('[copy-assets] All assets copied successfully.'); diff --git a/frontend/src/js/notifications.js b/frontend/src/js/notifications.js new file mode 100644 index 0000000..a507ad5 --- /dev/null +++ b/frontend/src/js/notifications.js @@ -0,0 +1,124 @@ +/** + * Nexus Terminal — toast notification system. + * Provides non-blocking, auto-dismissing notifications that stack + * in the top-right corner of the viewport. + */ + +import { createElement, escapeHtml } from './utils.js'; + +/** Map of notification type to SVG icon path data. */ +const ICONS = { + success: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z', + error: 'M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z', + warning: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z', + info: 'M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z', +}; + +export class Notifications { + constructor() { + /** @type {HTMLElement} */ + this.container = document.getElementById('notifications'); + if (!this.container) { + // Create the container if it does not yet exist + this.container = createElement('div', 'notifications-container', { id: 'notifications' }); + document.body.appendChild(this.container); + } + } + + /** + * Displays a toast notification. + * @param {string} message - Notification text. + * @param {'info'|'success'|'warning'|'error'} [type='info'] - Severity. + * @param {number} [duration=4000] - Auto-dismiss delay in ms. Pass 0 to keep until dismissed. + */ + show(message, type = 'info', duration = 4000) { + const toast = createElement('div', `toast toast-${type}`); + + // Icon + const iconSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + iconSvg.setAttribute('class', 'toast-icon'); + iconSvg.setAttribute('viewBox', '0 0 24 24'); + iconSvg.setAttribute('fill', 'none'); + iconSvg.setAttribute('stroke', 'currentColor'); + iconSvg.setAttribute('stroke-width', '2'); + const iconPath = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + iconPath.setAttribute('stroke-linecap', 'round'); + iconPath.setAttribute('stroke-linejoin', 'round'); + iconPath.setAttribute('d', ICONS[type] || ICONS.info); + iconSvg.appendChild(iconPath); + toast.appendChild(iconSvg); + + // Message + const msg = createElement('span', 'toast-message', { textContent: message }); + toast.appendChild(msg); + + // Dismiss button + const close = createElement('button', 'toast-close', { textContent: '×', 'aria-label': 'Dismiss' }); + close.addEventListener('click', () => this._dismiss(toast)); + toast.appendChild(close); + + // Insert at the top of the stack + this.container.prepend(toast); + + // Trigger enter animation on next frame + requestAnimationFrame(() => { + toast.classList.add('toast-enter'); + }); + + // Auto-dismiss + if (duration > 0) { + toast._timer = setTimeout(() => this._dismiss(toast), duration); + } + } + + /** + * Shorthand: success notification. + * @param {string} message + */ + success(message) { + this.show(message, 'success'); + } + + /** + * Shorthand: error notification (longer display time). + * @param {string} message + */ + error(message) { + this.show(message, 'error', 6000); + } + + /** + * Shorthand: warning notification. + * @param {string} message + */ + warning(message) { + this.show(message, 'warning'); + } + + /** + * Shorthand: informational notification. + * @param {string} message + */ + info(message) { + this.show(message, 'info'); + } + + /** + * Animates out and removes a toast element. + * @param {HTMLElement} toast + * @private + */ + _dismiss(toast) { + if (toast._dismissed) return; + toast._dismissed = true; + clearTimeout(toast._timer); + + toast.classList.add('toast-exit'); + toast.addEventListener('animationend', () => toast.remove(), { once: true }); + + // Safety net — remove after 500 ms even if animation event does not fire + setTimeout(() => { + if (toast.parentNode) toast.remove(); + }, 500); + } +} diff --git a/frontend/src/js/secrets.js b/frontend/src/js/secrets.js new file mode 100644 index 0000000..c7e5768 --- /dev/null +++ b/frontend/src/js/secrets.js @@ -0,0 +1,300 @@ +/** + * Nexus Terminal — secret/passphrase prompt dialogs. + * Provides modal prompts for collecting passwords, master-vault passphrases, + * and SSH host-key verification confirmations. + */ + +import { createElement, escapeHtml } from './utils.js'; + +export class SecretPrompt { + constructor(app) { + /** @type {import('./app.js').App} */ + this.app = app; + + /** @type {HTMLElement|null} Currently open overlay. */ + this._overlay = null; + } + + // ── Generic password prompt ────────────────────────────────────── + + /** + * Displays a modal with a single password field and returns the entered value. + * Rejects if the user cancels. + * @param {string} title - Dialog heading. + * @param {string} message - Instructional text shown above the input. + * @returns {Promise} + */ + prompt(title, message) { + return new Promise((resolve, reject) => { + this._close(); // dismiss any existing prompt + + const overlay = createElement('div', 'modal-overlay'); + const modal = createElement('div', 'modal modal-secret'); + + // Header + const header = createElement('div', 'modal-header'); + header.innerHTML = ` + + + `; + modal.appendChild(header); + + // Body + const body = createElement('div', 'modal-body'); + body.innerHTML = ` +

${escapeHtml(message)}

+
+ +
+ `; + modal.appendChild(body); + + // Footer + const footer = createElement('div', 'modal-footer'); + const cancelBtn = createElement('button', 'btn btn-secondary', { textContent: 'Cancel' }); + const submitBtn = createElement('button', 'btn btn-primary', { textContent: 'Submit' }); + footer.appendChild(cancelBtn); + footer.appendChild(submitBtn); + modal.appendChild(footer); + + overlay.appendChild(modal); + document.body.appendChild(overlay); + this._overlay = overlay; + + const input = modal.querySelector('.secret-input'); + input.focus(); + + const handleSubmit = () => { + const value = input.value; + this._close(); + resolve(value); + }; + + const handleCancel = () => { + this._close(); + reject(new Error('User cancelled')); + }; + + submitBtn.addEventListener('click', handleSubmit); + cancelBtn.addEventListener('click', handleCancel); + header.querySelector('.modal-close').addEventListener('click', handleCancel); + overlay.addEventListener('click', (e) => { + if (e.target === overlay) handleCancel(); + }); + + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') handleSubmit(); + if (e.key === 'Escape') handleCancel(); + }); + }); + } + + // ── Master password (vault initialisation / unlock) ────────────── + + /** + * Prompts for the master vault password. + * When `isNew` is true, a confirmation field is shown. + * @param {boolean} isNew - Whether this is the first time (create vault). + * @returns {Promise} The master password. + */ + promptMasterPassword(isNew) { + return new Promise((resolve, reject) => { + this._close(); + + const overlay = createElement('div', 'modal-overlay'); + const modal = createElement('div', 'modal modal-secret'); + + const title = isNew ? 'Create Master Password' : 'Unlock Secret Store'; + const description = isNew + ? 'Choose a strong master password. This protects all saved credentials.' + : 'Enter your master password to unlock the secret store.'; + + const header = createElement('div', 'modal-header'); + header.innerHTML = ` + + + `; + modal.appendChild(header); + + const body = createElement('div', 'modal-body'); + let formHtml = ` +

${escapeHtml(description)}

+
+ + +
+ `; + if (isNew) { + formHtml += ` +
+ + +
+ + `; + } + body.innerHTML = formHtml; + modal.appendChild(body); + + const footer = createElement('div', 'modal-footer'); + const cancelBtn = createElement('button', 'btn btn-secondary', { textContent: 'Cancel' }); + const submitBtn = createElement('button', 'btn btn-primary', { textContent: isNew ? 'Create' : 'Unlock' }); + footer.appendChild(cancelBtn); + footer.appendChild(submitBtn); + modal.appendChild(footer); + + overlay.appendChild(modal); + document.body.appendChild(overlay); + this._overlay = overlay; + + const passInput = modal.querySelector('.master-password'); + const confirmInput = modal.querySelector('.master-confirm'); + const errorEl = modal.querySelector('.secret-error'); + passInput.focus(); + + const handleSubmit = () => { + const pw = passInput.value; + if (!pw) { + passInput.classList.add('input-error'); + return; + } + if (isNew) { + const confirm = confirmInput.value; + if (pw !== confirm) { + errorEl.textContent = 'Passwords do not match.'; + errorEl.style.display = 'block'; + confirmInput.classList.add('input-error'); + return; + } + } + this._close(); + resolve(pw); + }; + + const handleCancel = () => { + this._close(); + reject(new Error('User cancelled')); + }; + + submitBtn.addEventListener('click', handleSubmit); + cancelBtn.addEventListener('click', handleCancel); + header.querySelector('.modal-close').addEventListener('click', handleCancel); + overlay.addEventListener('click', (e) => { + if (e.target === overlay) handleCancel(); + }); + + const handleKeydown = (e) => { + if (e.key === 'Enter') handleSubmit(); + if (e.key === 'Escape') handleCancel(); + }; + passInput.addEventListener('keydown', handleKeydown); + if (confirmInput) { + confirmInput.addEventListener('keydown', handleKeydown); + } + }); + } + + // ── SSH host-key verification ──────────────────────────────────── + + /** + * Asks the user whether to accept an SSH host key. + * @param {string} host + * @param {string} keyType - e.g. "ssh-ed25519" + * @param {string} fingerprint - SHA256 fingerprint + * @returns {Promise} true = accept, false = reject. + */ + promptHostKeyVerification(host, keyType, fingerprint) { + return new Promise((resolve) => { + this._close(); + + const overlay = createElement('div', 'modal-overlay'); + const modal = createElement('div', 'modal modal-secret modal-hostkey'); + + const header = createElement('div', 'modal-header'); + header.innerHTML = ` + + + `; + modal.appendChild(header); + + const body = createElement('div', 'modal-body'); + body.innerHTML = ` +

+ The authenticity of host ${escapeHtml(host)} cannot be established. +

+
+
+ Key type + ${escapeHtml(keyType)} +
+
+ Fingerprint + ${escapeHtml(fingerprint)} +
+
+

+ Are you sure you want to continue connecting? +

+ `; + modal.appendChild(body); + + const footer = createElement('div', 'modal-footer'); + const rejectBtn = createElement('button', 'btn btn-secondary', { textContent: 'Reject' }); + const acceptBtn = createElement('button', 'btn btn-primary', { textContent: 'Accept & Save' }); + footer.appendChild(rejectBtn); + footer.appendChild(acceptBtn); + modal.appendChild(footer); + + overlay.appendChild(modal); + document.body.appendChild(overlay); + this._overlay = overlay; + + acceptBtn.focus(); + + acceptBtn.addEventListener('click', () => { + this._close(); + resolve(true); + }); + + rejectBtn.addEventListener('click', () => { + this._close(); + resolve(false); + }); + + header.querySelector('.modal-close').addEventListener('click', () => { + this._close(); + resolve(false); + }); + + overlay.addEventListener('click', (e) => { + if (e.target === overlay) { + this._close(); + resolve(false); + } + }); + + overlay.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + this._close(); + resolve(false); + } + }); + }); + } + + // ── Internal helpers ───────────────────────────────────────────── + + /** + * Closes and removes the current overlay, if any. + * @private + */ + _close() { + if (this._overlay) { + this._overlay.remove(); + this._overlay = null; + } + } +} diff --git a/frontend/src/js/sidebar.js b/frontend/src/js/sidebar.js new file mode 100644 index 0000000..65fb3d9 --- /dev/null +++ b/frontend/src/js/sidebar.js @@ -0,0 +1,335 @@ +/** + * Nexus Terminal — sidebar module. + * Renders the profile list with search, sections (favourites, recent, all), + * and context menus. + */ + +import { createElement, escapeHtml, fuzzyMatch, protocolColour, dismissContextMenus, truncate } from './utils.js'; + +/** Maximum number of profiles shown in the "Recent" section. */ +const RECENT_LIMIT = 5; + +export class Sidebar { + /** + * @param {import('./app.js').App} app + */ + constructor(app) { + this.app = app; + this.el = document.getElementById('sidebar-profiles'); + this.searchQuery = ''; + + // Section collapse state + this.collapsedSections = new Set(); + + // Dismiss context menus on any outside click + document.addEventListener('click', () => dismissContextMenus()); + } + + // ── Public API ─────────────────────────────────────────────────── + + /** + * Full re-render of the sidebar profile list. + */ + render() { + if (!this.el) return; + this.el.innerHTML = ''; + + const profiles = this._filteredProfiles(); + + // Favourites + const favourites = profiles.filter((p) => p.favourite); + if (favourites.length > 0) { + this._renderSection('Favourites', 'favourites', favourites); + } + + // Recent + const recent = profiles + .filter((p) => p.lastConnected) + .sort((a, b) => new Date(b.lastConnected) - new Date(a.lastConnected)) + .slice(0, RECENT_LIMIT); + if (recent.length > 0) { + this._renderSection('Recent', 'recent', recent); + } + + // All connections + this._renderSection('All Connections', 'all', profiles); + + // Empty state + if (profiles.length === 0) { + const empty = createElement('div', 'sidebar-empty'); + empty.innerHTML = ` + + `; + this.el.appendChild(empty); + } + } + + /** + * Filters the displayed profiles by query. + * @param {string} query + */ + search(query) { + this.searchQuery = query.trim(); + this.render(); + } + + /** + * Highlights the sidebar item for the currently active profile. + * @param {string|null} profileId + */ + highlightActive(profileId) { + this.el.querySelectorAll('.sidebar-item').forEach((item) => { + item.classList.toggle('sidebar-item-active', item.dataset.profileId === profileId); + }); + } + + /** + * Toggles the collapsed state of a named section. + * @param {string} sectionName + */ + toggleSection(sectionName) { + if (this.collapsedSections.has(sectionName)) { + this.collapsedSections.delete(sectionName); + } else { + this.collapsedSections.add(sectionName); + } + this.render(); + } + + // ── Rendering helpers ──────────────────────────────────────────── + + /** + * Renders a collapsible section of profiles. + * @param {string} title + * @param {string} key + * @param {Array} profiles + * @private + */ + _renderSection(title, key, profiles) { + const section = createElement('div', 'sidebar-section'); + const isCollapsed = this.collapsedSections.has(key); + + // Section header + const header = createElement('div', 'sidebar-section-header'); + header.innerHTML = ` + ${isCollapsed ? '▸' : '▾'} + ${escapeHtml(title)} + ${profiles.length} + `; + header.addEventListener('click', () => this.toggleSection(key)); + section.appendChild(header); + + // Items + if (!isCollapsed) { + const list = createElement('div', 'sidebar-section-list'); + for (const profile of profiles) { + list.appendChild(this.renderProfileItem(profile)); + } + section.appendChild(list); + } + + this.el.appendChild(section); + } + + /** + * Creates a single sidebar list item for a profile. + * @param {Object} profile + * @returns {HTMLElement} + */ + renderProfileItem(profile) { + const item = createElement('div', 'sidebar-item', { + dataset: { profileId: profile.id }, + }); + + // Highlight if this profile's session is active + const activeSession = this.app.state.activeSessionId + ? this.app.state.getSession(this.app.state.activeSessionId) + : null; + if (activeSession && activeSession.profileId === profile.id) { + item.classList.add('sidebar-item-active'); + } + + // Protocol colour dot + const dot = createElement('span', `sidebar-dot ${protocolColour(profile.protocol)}`); + item.appendChild(dot); + + // Text content + const textWrapper = createElement('div', 'sidebar-item-text'); + const nameEl = createElement('span', 'sidebar-item-name', { + textContent: profile.displayName || profile.host || 'Untitled', + }); + textWrapper.appendChild(nameEl); + + const subtitle = profile.host + ? `${profile.host}:${profile.port}` + : profile.protocol.toUpperCase(); + const subEl = createElement('span', 'sidebar-item-sub', { + textContent: truncate(subtitle, 30), + }); + textWrapper.appendChild(subEl); + item.appendChild(textWrapper); + + // Favourite star + if (profile.favourite) { + const star = createElement('span', 'sidebar-star', { textContent: '★' }); + item.appendChild(star); + } + + // Click → connect + item.addEventListener('click', () => { + this.app.connectProfile(profile.id); + }); + + // Double-click → edit + item.addEventListener('dblclick', (e) => { + e.stopPropagation(); + this.app.profileEditor.openEdit(profile.id); + }); + + // Right-click → context menu + item.addEventListener('contextmenu', (e) => { + e.preventDefault(); + e.stopPropagation(); + this.renderContextMenu(profile, e.clientX, e.clientY); + }); + + return item; + } + + /** + * Shows a right-click context menu for a profile. + * @param {Object} profile + * @param {number} x - Viewport X. + * @param {number} y - Viewport Y. + */ + renderContextMenu(profile, x, y) { + dismissContextMenus(); + + const menu = createElement('div', 'context-menu'); + menu.style.left = `${x}px`; + menu.style.top = `${y}px`; + + const items = [ + { label: 'Connect', action: () => this.app.connectProfile(profile.id) }, + { label: 'Edit', action: () => this.app.profileEditor.openEdit(profile.id) }, + { label: 'Duplicate', action: () => this._duplicateProfile(profile) }, + { divider: true }, + { + label: profile.favourite ? 'Remove from Favourites' : 'Add to Favourites', + action: () => this._toggleFavourite(profile), + }, + { divider: true }, + { + label: 'Delete', + className: 'context-menu-danger', + action: () => this._deleteProfile(profile), + }, + ]; + + for (const entry of items) { + if (entry.divider) { + menu.appendChild(createElement('div', 'context-menu-divider')); + continue; + } + const btn = createElement('button', `context-menu-item ${entry.className || ''}`, { + textContent: entry.label, + }); + btn.addEventListener('click', () => { + dismissContextMenus(); + entry.action(); + }); + menu.appendChild(btn); + } + + // Keep menu within viewport + document.body.appendChild(menu); + const rect = menu.getBoundingClientRect(); + if (rect.right > window.innerWidth) { + menu.style.left = `${window.innerWidth - rect.width - 8}px`; + } + if (rect.bottom > window.innerHeight) { + menu.style.top = `${window.innerHeight - rect.height - 8}px`; + } + } + + // ── Actions ────────────────────────────────────────────────────── + + /** + * Toggles the favourite status of a profile. + * @param {Object} profile + * @private + */ + async _toggleFavourite(profile) { + try { + const newVal = !profile.favourite; + await window.go.api.Service.SetFavourite(profile.id, newVal); + profile.favourite = newVal; + this.app.state.updateProfile(profile); + this.render(); + } catch (err) { + this.app.notifications.error('Failed to update favourite: ' + err); + } + } + + /** + * Duplicates a profile. + * @param {Object} profile + * @private + */ + async _duplicateProfile(profile) { + try { + const dup = await window.go.api.Service.DuplicateProfile(profile.id); + this.app.state.addProfile(dup); + this.render(); + this.app.notifications.success(`Duplicated "${profile.displayName}"`); + } catch (err) { + this.app.notifications.error('Failed to duplicate profile: ' + err); + } + } + + /** + * Deletes a profile after confirmation. + * @param {Object} profile + * @private + */ + async _deleteProfile(profile) { + const ok = confirm(`Delete "${profile.displayName || profile.host}"?\nThis cannot be undone.`); + if (!ok) return; + + try { + await window.go.api.Service.DeleteProfile(profile.id); + this.app.state.removeProfile(profile.id); + this.render(); + this.app.notifications.success('Profile deleted'); + } catch (err) { + this.app.notifications.error('Failed to delete profile: ' + err); + } + } + + // ── Filtering ──────────────────────────────────────────────────── + + /** + * Returns profiles filtered by the current search query. + * @returns {Array} + * @private + */ + _filteredProfiles() { + if (!this.searchQuery) return this.app.state.profiles; + + return this.app.state.profiles + .map((p) => { + // Match against display name, host, tags, and protocol + const fields = [ + p.displayName || '', + p.host || '', + (p.tags || []).join(' '), + p.protocol || '', + ].join(' '); + const result = fuzzyMatch(fields, this.searchQuery); + return { profile: p, ...result }; + }) + .filter((r) => r.match) + .sort((a, b) => b.score - a.score) + .map((r) => r.profile); + } +} diff --git a/frontend/src/js/state.js b/frontend/src/js/state.js new file mode 100644 index 0000000..fe8933f --- /dev/null +++ b/frontend/src/js/state.js @@ -0,0 +1,225 @@ +/** + * Nexus Terminal — application state management. + * Provides a centralised store for profiles, sessions, and preferences, + * emitting change events so the UI stays in sync. + */ + +export class State { + constructor() { + /** @type {Array} */ + this.profiles = []; + + /** @type {Map} sessionId → SessionInfo */ + this.sessions = new Map(); + + /** @type {string|null} */ + this.activeSessionId = null; + + /** @type {Object} */ + this.preferences = {}; + + /** @type {boolean} */ + this.secretStoreUnlocked = false; + + /** @type {boolean} */ + this.secretStoreInitialised = false; + + /** @type {Map>} event name → callbacks */ + this._listeners = new Map(); + } + + // ── Event emitter ──────────────────────────────────────────────── + + /** + * Subscribes to a state change event. + * @param {string} event + * @param {Function} callback + */ + on(event, callback) { + if (!this._listeners.has(event)) { + this._listeners.set(event, new Set()); + } + this._listeners.get(event).add(callback); + } + + /** + * Unsubscribes from a state change event. + * @param {string} event + * @param {Function} callback + */ + off(event, callback) { + const set = this._listeners.get(event); + if (set) { + set.delete(callback); + } + } + + /** + * Emits a state change event to all subscribers. + * @param {string} event + * @param {*} data + */ + emit(event, data) { + const set = this._listeners.get(event); + if (set) { + for (const cb of set) { + try { + cb(data); + } catch (err) { + console.error(`State listener error [${event}]:`, err); + } + } + } + } + + // ── Profiles ───────────────────────────────────────────────────── + + /** + * Replaces the entire profiles list. + * @param {Array} profiles + */ + setProfiles(profiles) { + this.profiles = profiles; + this.emit('profiles:changed', this.profiles); + } + + /** + * Adds a single profile. + * @param {Object} profile + */ + addProfile(profile) { + this.profiles.push(profile); + this.emit('profiles:changed', this.profiles); + this.emit('profile:added', profile); + } + + /** + * Updates an existing profile in place. + * @param {Object} profile + */ + updateProfile(profile) { + const idx = this.profiles.findIndex((p) => p.id === profile.id); + if (idx !== -1) { + this.profiles[idx] = profile; + } + this.emit('profiles:changed', this.profiles); + this.emit('profile:updated', profile); + } + + /** + * Removes a profile by ID. + * @param {string} id + */ + removeProfile(id) { + this.profiles = this.profiles.filter((p) => p.id !== id); + this.emit('profiles:changed', this.profiles); + this.emit('profile:removed', id); + } + + /** + * Returns a profile by ID, or undefined. + * @param {string} id + * @returns {Object|undefined} + */ + getProfile(id) { + return this.profiles.find((p) => p.id === id); + } + + // ── Sessions ───────────────────────────────────────────────────── + + /** + * Registers a new session. + * @param {Object} sessionInfo + */ + addSession(sessionInfo) { + this.sessions.set(sessionInfo.id, sessionInfo); + this.emit('sessions:changed', this.sessions); + this.emit('session:added', sessionInfo); + } + + /** + * Merges partial updates into an existing session. + * @param {string} id + * @param {Object} updates + */ + updateSession(id, updates) { + const existing = this.sessions.get(id); + if (existing) { + Object.assign(existing, updates); + this.emit('sessions:changed', this.sessions); + this.emit('session:updated', existing); + } + } + + /** + * Removes a session. + * @param {string} id + */ + removeSession(id) { + this.sessions.delete(id); + if (this.activeSessionId === id) { + // Activate the most recent remaining session, or null + const remaining = [...this.sessions.keys()]; + this.activeSessionId = remaining.length > 0 ? remaining[remaining.length - 1] : null; + } + this.emit('sessions:changed', this.sessions); + this.emit('session:removed', id); + } + + /** + * Returns session info by ID, or undefined. + * @param {string} id + * @returns {Object|undefined} + */ + getSession(id) { + return this.sessions.get(id); + } + + /** + * Sets the currently visible/active session. + * @param {string|null} id + */ + setActiveSession(id) { + this.activeSessionId = id; + this.emit('session:activated', id); + } + + /** + * Returns an ordered array of all sessions. + * @returns {Array} + */ + getAllSessions() { + return [...this.sessions.values()]; + } + + // ── Preferences ────────────────────────────────────────────────── + + /** + * Replaces the entire preferences object. + * @param {Object} prefs + */ + setPreferences(prefs) { + this.preferences = prefs; + this.emit('preferences:changed', this.preferences); + } + + // ── Secret store ───────────────────────────────────────────────── + + /** + * Updates the secret store state. + * @param {boolean} unlocked + */ + setSecretStoreState(unlocked) { + this.secretStoreUnlocked = unlocked; + this.emit('secretStore:changed', unlocked); + } + + /** + * Marks whether the secret store has been initialised (vault exists). + * @param {boolean} initialised + */ + setSecretStoreInitialised(initialised) { + this.secretStoreInitialised = initialised; + this.emit('secretStore:init', initialised); + } +} diff --git a/frontend/src/js/tabs.js b/frontend/src/js/tabs.js new file mode 100644 index 0000000..5e77293 --- /dev/null +++ b/frontend/src/js/tabs.js @@ -0,0 +1,546 @@ +/** + * Nexus Terminal — tab management. + * Handles the tab bar: adding, removing, reordering, renaming, + * pinning tabs, and context menus. + */ + +import { createElement, escapeHtml, protocolColour, stateLabel, dismissContextMenus } from './utils.js'; + +export class TabManager { + /** + * @param {import('./app.js').App} app + */ + constructor(app) { + this.app = app; + + /** @type {HTMLElement} The tab bar container. */ + this.tabBar = document.getElementById('tab-bar'); + + /** @type {HTMLElement} The container for terminal/viewer panels. */ + this.panelContainer = document.getElementById('panel-container'); + + /** @type {string[]} Ordered list of session IDs. */ + this.order = []; + + this._setupDragAndDrop(); + } + + // ── Public API ─────────────────────────────────────────────────── + + /** + * Adds a new tab for a session and makes it active. + * @param {Object} sessionInfo + */ + addTab(sessionInfo) { + this.order.push(sessionInfo.id); + this._renderTab(sessionInfo); + + // Create panel container for this session's terminal/viewer + const panel = createElement('div', 'session-panel', { + id: `panel-${sessionInfo.id}`, + dataset: { sessionId: sessionInfo.id }, + }); + panel.style.display = 'none'; + this.panelContainer.appendChild(panel); + + this.setActive(sessionInfo.id); + this._updateEmptyState(); + } + + /** + * Removes a tab and its panel. + * @param {string} sessionId + */ + removeTab(sessionId) { + const idx = this.order.indexOf(sessionId); + if (idx !== -1) this.order.splice(idx, 1); + + // Remove tab element + const tabEl = this.tabBar.querySelector(`[data-session-id="${sessionId}"]`); + if (tabEl) tabEl.remove(); + + // Remove panel + const panel = document.getElementById(`panel-${sessionId}`); + if (panel) panel.remove(); + + // If this was the active tab, activate the nearest neighbour + if (this.app.state.activeSessionId === sessionId) { + const next = this.order.length > 0 + ? this.order[Math.min(idx, this.order.length - 1)] + : null; + if (next) { + this.setActive(next); + } else { + this.app.state.setActiveSession(null); + } + } + + this._updateEmptyState(); + } + + /** + * Switches the visible tab/panel to the given session. + * @param {string} sessionId + */ + setActive(sessionId) { + this.app.state.setActiveSession(sessionId); + + // Highlight active tab + this.tabBar.querySelectorAll('.tab').forEach((t) => { + t.classList.toggle('tab-active', t.dataset.sessionId === sessionId); + }); + + // Show corresponding panel, hide others + this.panelContainer.querySelectorAll('.session-panel').forEach((p) => { + p.style.display = p.dataset.sessionId === sessionId ? 'flex' : 'none'; + }); + + // Notify sidebar + const session = this.app.state.getSession(sessionId); + if (session) { + this.app.sidebar.highlightActive(session.profileId); + } + + // Fit the terminal for the newly active session + this.app.terminals.fitActive(); + } + + /** + * Closes the currently active tab. + */ + closeActive() { + if (this.app.state.activeSessionId) { + this.closeTab(this.app.state.activeSessionId); + } + } + + /** + * Disconnects and closes a tab by session ID. + * @param {string} sessionId + */ + async closeTab(sessionId) { + const session = this.app.state.getSession(sessionId); + + // Ask for confirmation if connected and the preference is set + if (session && session.state === 'connected' && this.app.state.preferences.confirmOnDisconnect) { + const ok = confirm('Disconnect and close this session?'); + if (!ok) return; + } + + try { + await window.go.api.Service.CloseSession(sessionId); + } catch (_) { + // Session may already be closed — carry on + } + + this.app.terminals.destroyTerminal(sessionId); + this.app.rawViewer.destroyViewer(sessionId); + this.app.state.removeSession(sessionId); + this.removeTab(sessionId); + } + + /** + * Renames a tab. + * @param {string} sessionId + * @param {string} name + */ + renameTab(sessionId, name) { + this.app.state.updateSession(sessionId, { tabName: name }); + const tabEl = this.tabBar.querySelector(`[data-session-id="${sessionId}"]`); + if (tabEl) { + tabEl.querySelector('.tab-name').textContent = name; + } + } + + /** + * Pins a tab (moves it to the front). + * @param {string} sessionId + */ + pinTab(sessionId) { + this.app.state.updateSession(sessionId, { pinned: true }); + this._reorderDOM(); + this._updateTabStyles(sessionId); + } + + /** + * Unpins a tab. + * @param {string} sessionId + */ + unpinTab(sessionId) { + this.app.state.updateSession(sessionId, { pinned: false }); + this._reorderDOM(); + this._updateTabStyles(sessionId); + } + + /** + * Moves a tab one position to the left. + * @param {string} sessionId + */ + moveLeft(sessionId) { + const idx = this.order.indexOf(sessionId); + if (idx > 0) { + [this.order[idx - 1], this.order[idx]] = [this.order[idx], this.order[idx - 1]]; + this._reorderDOM(); + } + } + + /** + * Moves a tab one position to the right. + * @param {string} sessionId + */ + moveRight(sessionId) { + const idx = this.order.indexOf(sessionId); + if (idx >= 0 && idx < this.order.length - 1) { + [this.order[idx], this.order[idx + 1]] = [this.order[idx + 1], this.order[idx]]; + this._reorderDOM(); + } + } + + /** + * Opens a new session with the same profile as the given tab. + * @param {string} sessionId + */ + async duplicateTab(sessionId) { + const session = this.app.state.getSession(sessionId); + if (session && session.profileId) { + await this.app.connectProfile(session.profileId); + } + } + + /** + * Updates the visual state of a tab when session state changes. + * @param {string} sessionId + * @param {string} state - connecting | connected | disconnected | error + * @param {string} [error] + */ + updateSessionState(sessionId, state, error) { + this.app.state.updateSession(sessionId, { state, error: error || '' }); + + const tabEl = this.tabBar.querySelector(`[data-session-id="${sessionId}"]`); + if (!tabEl) return; + + const dot = tabEl.querySelector('.tab-status'); + if (dot) { + dot.className = `tab-status status-${state}`; + dot.title = stateLabel(state); + } + + if (state === 'error' && error) { + this.app.notifications.error(`${this._tabLabel(sessionId)}: ${error}`); + } + } + + /** + * Handles a session closed event from the backend. + * @param {string} sessionId + */ + handleSessionClosed(sessionId) { + this.updateSessionState(sessionId, 'disconnected'); + } + + /** + * Shows a right-click context menu for a tab. + * @param {string} sessionId + * @param {number} x + * @param {number} y + */ + showContextMenu(sessionId, x, y) { + dismissContextMenus(); + const session = this.app.state.getSession(sessionId); + if (!session) return; + + const menu = createElement('div', 'context-menu'); + menu.style.left = `${x}px`; + menu.style.top = `${y}px`; + + const isPinned = session.pinned; + + const items = [ + { label: 'Rename…', action: () => this._promptRename(sessionId) }, + { label: 'Duplicate', action: () => this.duplicateTab(sessionId) }, + { divider: true }, + { label: isPinned ? 'Unpin' : 'Pin', action: () => isPinned ? this.unpinTab(sessionId) : this.pinTab(sessionId) }, + { label: 'Move Left', action: () => this.moveLeft(sessionId) }, + { label: 'Move Right', action: () => this.moveRight(sessionId) }, + { divider: true }, + { + label: 'Reconnect', + action: () => this._reconnect(sessionId), + disabled: session.state === 'connected' || session.state === 'connecting', + }, + { divider: true }, + { label: 'Close', className: 'context-menu-danger', action: () => this.closeTab(sessionId) }, + ]; + + for (const entry of items) { + if (entry.divider) { + menu.appendChild(createElement('div', 'context-menu-divider')); + continue; + } + const btn = createElement('button', `context-menu-item ${entry.className || ''}`, { + textContent: entry.label, + }); + if (entry.disabled) { + btn.disabled = true; + btn.classList.add('context-menu-disabled'); + } + btn.addEventListener('click', () => { + dismissContextMenus(); + entry.action(); + }); + menu.appendChild(btn); + } + + document.body.appendChild(menu); + + // Keep within viewport + const rect = menu.getBoundingClientRect(); + if (rect.right > window.innerWidth) menu.style.left = `${window.innerWidth - rect.width - 8}px`; + if (rect.bottom > window.innerHeight) menu.style.top = `${window.innerHeight - rect.height - 8}px`; + } + + /** + * Returns the current tab ordering. + * @returns {string[]} + */ + getTabOrder() { + return [...this.order]; + } + + /** + * Returns the session ID of the next tab (wrapping round). + * @returns {string|null} + */ + getNextTab() { + if (this.order.length === 0) return null; + const idx = this.order.indexOf(this.app.state.activeSessionId); + return this.order[(idx + 1) % this.order.length]; + } + + /** + * Returns the session ID of the previous tab (wrapping round). + * @returns {string|null} + */ + getPrevTab() { + if (this.order.length === 0) return null; + const idx = this.order.indexOf(this.app.state.activeSessionId); + return this.order[(idx - 1 + this.order.length) % this.order.length]; + } + + // ── Rendering ──────────────────────────────────────────────────── + + /** + * Creates and appends a tab element. + * @param {Object} sessionInfo + * @private + */ + _renderTab(sessionInfo) { + const tab = createElement('div', 'tab', { + dataset: { sessionId: sessionInfo.id }, + draggable: 'true', + }); + + // Protocol badge + const badge = createElement('span', `tab-badge ${protocolColour(sessionInfo.protocol)}`, { + textContent: sessionInfo.protocol.toUpperCase(), + }); + tab.appendChild(badge); + + // Name + const name = createElement('span', 'tab-name', { + textContent: sessionInfo.tabName || sessionInfo.protocol.toUpperCase(), + }); + tab.appendChild(name); + + // Status dot + const status = createElement('span', `tab-status status-${sessionInfo.state || 'connecting'}`, { + title: stateLabel(sessionInfo.state), + }); + tab.appendChild(status); + + // Pin indicator + if (sessionInfo.pinned) { + tab.classList.add('tab-pinned'); + } + + // Close button + const closeBtn = createElement('button', 'tab-close', { + textContent: '×', + 'aria-label': 'Close tab', + }); + closeBtn.addEventListener('click', (e) => { + e.stopPropagation(); + this.closeTab(sessionInfo.id); + }); + tab.appendChild(closeBtn); + + // Click → activate + tab.addEventListener('click', () => this.setActive(sessionInfo.id)); + + // Right-click → context menu + tab.addEventListener('contextmenu', (e) => { + e.preventDefault(); + this.showContextMenu(sessionInfo.id, e.clientX, e.clientY); + }); + + this.tabBar.appendChild(tab); + } + + // ── Internal helpers ───────────────────────────────────────────── + + /** + * Reorders the tab-bar DOM elements to match `this.order`, + * with pinned tabs always first. + * @private + */ + _reorderDOM() { + // Sort: pinned first, then by order index + const sorted = [...this.order].sort((a, b) => { + const sa = this.app.state.getSession(a); + const sb = this.app.state.getSession(b); + const pinnedA = sa?.pinned ? 0 : 1; + const pinnedB = sb?.pinned ? 0 : 1; + if (pinnedA !== pinnedB) return pinnedA - pinnedB; + return this.order.indexOf(a) - this.order.indexOf(b); + }); + + for (const id of sorted) { + const el = this.tabBar.querySelector(`[data-session-id="${id}"]`); + if (el) this.tabBar.appendChild(el); + } + } + + /** + * Updates the visual pin state on a tab element. + * @param {string} sessionId + * @private + */ + _updateTabStyles(sessionId) { + const session = this.app.state.getSession(sessionId); + const tabEl = this.tabBar.querySelector(`[data-session-id="${sessionId}"]`); + if (tabEl && session) { + tabEl.classList.toggle('tab-pinned', !!session.pinned); + } + } + + /** + * Shows/hides the empty-state panel based on whether tabs exist. + * @private + */ + _updateEmptyState() { + const empty = document.getElementById('empty-state'); + if (empty) { + empty.style.display = this.order.length === 0 ? 'flex' : 'none'; + } + } + + /** + * Prompts the user to rename a tab. + * @param {string} sessionId + * @private + */ + _promptRename(sessionId) { + const session = this.app.state.getSession(sessionId); + const current = session?.tabName || ''; + const name = prompt('Tab name:', current); + if (name !== null && name.trim()) { + this.renameTab(sessionId, name.trim()); + } + } + + /** + * Attempts to reconnect a disconnected session. + * @param {string} sessionId + * @private + */ + async _reconnect(sessionId) { + const session = this.app.state.getSession(sessionId); + if (!session || !session.profileId) return; + + // Close the current tab, then open a fresh connection + this.app.terminals.destroyTerminal(sessionId); + this.app.rawViewer.destroyViewer(sessionId); + this.app.state.removeSession(sessionId); + this.removeTab(sessionId); + + await this.app.connectProfile(session.profileId); + } + + /** + * Returns the display label for a session's tab. + * @param {string} sessionId + * @returns {string} + * @private + */ + _tabLabel(sessionId) { + const session = this.app.state.getSession(sessionId); + return session?.tabName || 'Session'; + } + + // ── Drag and drop ──────────────────────────────────────────────── + + /** + * Sets up drag-and-drop reordering on the tab bar. + * @private + */ + _setupDragAndDrop() { + if (!this.tabBar) return; + + let draggedId = null; + + this.tabBar.addEventListener('dragstart', (e) => { + const tab = e.target.closest('.tab'); + if (!tab) return; + draggedId = tab.dataset.sessionId; + tab.classList.add('tab-dragging'); + e.dataTransfer.effectAllowed = 'move'; + }); + + this.tabBar.addEventListener('dragover', (e) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + + const target = e.target.closest('.tab'); + if (!target || target.dataset.sessionId === draggedId) return; + + const rect = target.getBoundingClientRect(); + const midX = rect.left + rect.width / 2; + target.classList.toggle('tab-drop-before', e.clientX < midX); + target.classList.toggle('tab-drop-after', e.clientX >= midX); + }); + + this.tabBar.addEventListener('dragleave', (e) => { + const target = e.target.closest('.tab'); + if (target) { + target.classList.remove('tab-drop-before', 'tab-drop-after'); + } + }); + + this.tabBar.addEventListener('drop', (e) => { + e.preventDefault(); + const target = e.target.closest('.tab'); + if (!target || !draggedId) return; + + target.classList.remove('tab-drop-before', 'tab-drop-after'); + + const targetId = target.dataset.sessionId; + if (targetId === draggedId) return; + + // Reorder + const fromIdx = this.order.indexOf(draggedId); + const toIdx = this.order.indexOf(targetId); + if (fromIdx === -1 || toIdx === -1) return; + + this.order.splice(fromIdx, 1); + this.order.splice(toIdx, 0, draggedId); + this._reorderDOM(); + }); + + this.tabBar.addEventListener('dragend', () => { + this.tabBar.querySelectorAll('.tab').forEach((t) => { + t.classList.remove('tab-dragging', 'tab-drop-before', 'tab-drop-after'); + }); + draggedId = null; + }); + } +} diff --git a/frontend/src/js/terminal.js b/frontend/src/js/terminal.js new file mode 100644 index 0000000..c166509 --- /dev/null +++ b/frontend/src/js/terminal.js @@ -0,0 +1,286 @@ +/** + * Nexus Terminal — xterm.js terminal manager. + * Creates, manages, and destroys xterm.js instances for SSH and Telnet sessions. + * Handles data routing, resizing, and search. + */ + +/* global Terminal, FitAddon, SearchAddon, WebLinksAddon */ + +export class TerminalManager { + /** + * @param {import('./app.js').App} app + */ + constructor(app) { + this.app = app; + + /** + * Map of sessionId → { term, fitAddon, searchAddon, observer }. + * @type {Map} + */ + this.terminals = new Map(); + } + + // ── Public API ─────────────────────────────────────────────────── + + /** + * Creates and opens an xterm.js terminal inside the given container. + * Wires up data input → backend and container resizing. + * + * @param {string} sessionId + * @param {string} containerId - DOM id of the panel element. + * @param {Object} profile - The connection profile (used for font/theme prefs). + * @returns {Terminal} The xterm.js Terminal instance. + */ + createTerminal(sessionId, containerId, profile) { + const term = new Terminal({ + theme: this._buildTheme(), + fontFamily: profile.terminalFontFamily || 'JetBrains Mono, Menlo, Monaco, Consolas, monospace', + fontSize: profile.terminalFontSize || 14, + scrollback: profile.terminalScrollback || 10000, + cursorStyle: profile.terminalCursorStyle || 'block', + cursorBlink: true, + allowProposedApi: true, + convertEol: false, + windowsMode: false, + }); + + // Load addons + const fitAddon = new FitAddon.FitAddon(); + const searchAddon = new SearchAddon.SearchAddon(); + let webLinksAddon = null; + + term.loadAddon(fitAddon); + term.loadAddon(searchAddon); + + // WebLinksAddon may not always be available + try { + webLinksAddon = new WebLinksAddon.WebLinksAddon((_event, uri) => { + window.runtime.BrowserOpenURL(uri); + }); + term.loadAddon(webLinksAddon); + } catch (_) { + // Not critical — links just won't be clickable + } + + // Open the terminal in the DOM container + const container = document.getElementById(containerId); + term.open(container); + + // Initial fit + requestAnimationFrame(() => fitAddon.fit()); + + // Forward user keystrokes to the backend + term.onData((data) => { + try { + const b64 = btoa(data); + window.go.api.Service.WriteSession(sessionId, b64); + } catch (err) { + console.error('WriteSession error:', err); + } + }); + + // Notify backend when terminal dimensions change + term.onResize(({ cols, rows }) => { + try { + window.go.api.Service.ResizeSession(sessionId, cols, rows); + } catch (err) { + console.error('ResizeSession error:', err); + } + }); + + // Re-fit when the container itself resizes + const observer = new ResizeObserver(() => { + try { + fitAddon.fit(); + } catch (_) { + // Ignore transient errors during layout shifts + } + }); + observer.observe(container); + + this.terminals.set(sessionId, { + term, + fitAddon, + searchAddon, + webLinksAddon, + observer, + }); + + return term; + } + + /** + * Writes incoming backend data (base64-encoded) to the appropriate terminal. + * @param {string} sessionId + * @param {string} base64Data + */ + handleData(sessionId, base64Data) { + const entry = this.terminals.get(sessionId); + if (!entry) return; + + try { + const raw = atob(base64Data); + entry.term.write(raw); + } catch (err) { + console.error('Failed to decode session data:', err); + } + } + + /** + * Tears down a terminal instance and frees resources. + * @param {string} sessionId + */ + destroyTerminal(sessionId) { + const entry = this.terminals.get(sessionId); + if (!entry) return; + + entry.observer.disconnect(); + entry.term.dispose(); + this.terminals.delete(sessionId); + } + + /** + * Shows the terminal for a given session, hiding all others. + * @param {string} sessionId + */ + showTerminal(sessionId) { + for (const [id, entry] of this.terminals) { + const panel = document.getElementById(`panel-${id}`); + if (panel) { + panel.style.display = id === sessionId ? 'flex' : 'none'; + } + } + + // Re-fit the newly visible terminal + const entry = this.terminals.get(sessionId); + if (entry) { + requestAnimationFrame(() => { + try { entry.fitAddon.fit(); } catch (_) { /* noop */ } + }); + } + } + + /** + * Re-fits the currently active terminal to its container. + */ + fitActive() { + const id = this.app.state.activeSessionId; + if (!id) return; + const entry = this.terminals.get(id); + if (entry) { + requestAnimationFrame(() => { + try { entry.fitAddon.fit(); } catch (_) { /* noop */ } + }); + } + } + + /** + * Searches within a terminal using the search addon. + * @param {string} sessionId + * @param {string} query + * @param {Object} [options] + * @param {boolean} [options.regex=false] + * @param {boolean} [options.caseSensitive=false] + * @param {boolean} [options.wholeWord=false] + * @returns {boolean} Whether a match was found. + */ + search(sessionId, query, options = {}) { + const entry = this.terminals.get(sessionId); + if (!entry) return false; + + if (!query) { + entry.searchAddon.clearDecorations(); + return false; + } + + return entry.searchAddon.findNext(query, { + regex: options.regex || false, + caseSensitive: options.caseSensitive || false, + wholeWord: options.wholeWord || false, + decorations: { + matchBackground: '#ffa72633', + activeMatchBackground: '#4ecdc466', + matchBorder: '#ffa726', + activeMatchBorder: '#4ecdc4', + }, + }); + } + + /** + * Finds the previous search result. + * @param {string} sessionId + * @param {string} query + * @param {Object} [options] + * @returns {boolean} + */ + searchPrevious(sessionId, query, options = {}) { + const entry = this.terminals.get(sessionId); + if (!entry || !query) return false; + return entry.searchAddon.findPrevious(query, { + regex: options.regex || false, + caseSensitive: options.caseSensitive || false, + wholeWord: options.wholeWord || false, + }); + } + + /** + * Clears the terminal buffer and viewport. + * @param {string} sessionId + */ + clearTerminal(sessionId) { + const entry = this.terminals.get(sessionId); + if (entry) entry.term.clear(); + } + + /** + * Fully resets the terminal (clears buffer, restores default state). + * @param {string} sessionId + */ + resetTerminal(sessionId) { + const entry = this.terminals.get(sessionId); + if (entry) entry.term.reset(); + } + + /** + * Focuses the terminal so it can receive keyboard input. + * @param {string} sessionId + */ + focusTerminal(sessionId) { + const entry = this.terminals.get(sessionId); + if (entry) entry.term.focus(); + } + + // ── Private helpers ────────────────────────────────────────────── + + /** + * Builds the xterm.js colour theme. + * @returns {Object} + * @private + */ + _buildTheme() { + return { + background: '#1a1a2e', + foreground: '#e8e8f0', + cursor: '#4ecdc4', + cursorAccent: '#1a1a2e', + selectionBackground: 'rgba(78, 205, 196, 0.3)', + selectionForeground: '#ffffff', + black: '#1a1a2e', + red: '#ef5350', + green: '#66bb6a', + yellow: '#ffa726', + blue: '#42a5f5', + magenta: '#ab47bc', + cyan: '#4ecdc4', + white: '#e8e8f0', + brightBlack: '#464670', + brightRed: '#ff7043', + brightGreen: '#81c784', + brightYellow: '#ffcc02', + brightBlue: '#64b5f6', + brightMagenta: '#ce93d8', + brightCyan: '#80deea', + brightWhite: '#ffffff', + }; + } +} diff --git a/frontend/src/js/utils.js b/frontend/src/js/utils.js new file mode 100644 index 0000000..7e7d174 --- /dev/null +++ b/frontend/src/js/utils.js @@ -0,0 +1,229 @@ +/** + * Nexus Terminal — utility functions. + * General-purpose helpers used throughout the application. + */ + +/** + * Shorthand for document.querySelector. + * @param {string} selector - CSS selector. + * @returns {Element|null} + */ +export function $(selector) { + return document.querySelector(selector); +} + +/** + * Shorthand for document.querySelectorAll. + * @param {string} selector - CSS selector. + * @returns {NodeListOf} + */ +export function $$(selector) { + return document.querySelectorAll(selector); +} + +/** + * Creates a DOM element with optional class name and attributes. + * @param {string} tag - HTML tag name. + * @param {string} [className] - Space-separated class names. + * @param {Object} [attrs] - Attribute key/value pairs. + * @returns {HTMLElement} + */ +export function createElement(tag, className, attrs = {}) { + const el = document.createElement(tag); + if (className) { + el.className = className; + } + for (const [key, value] of Object.entries(attrs)) { + if (key === 'textContent') { + el.textContent = value; + } else if (key === 'innerHTML') { + el.innerHTML = value; + } else if (key === 'dataset') { + for (const [dKey, dVal] of Object.entries(value)) { + el.dataset[dKey] = dVal; + } + } else if (key.startsWith('on') && typeof value === 'function') { + el.addEventListener(key.slice(2).toLowerCase(), value); + } else { + el.setAttribute(key, value); + } + } + return el; +} + +/** + * Escapes HTML special characters to prevent XSS. + * @param {string} str - Raw string. + * @returns {string} Escaped string safe for innerHTML. + */ +export function escapeHtml(str) { + if (!str) return ''; + const map = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + }; + return str.replace(/[&<>"']/g, (ch) => map[ch]); +} + +/** + * Formats a Date (or ISO string) into a concise timestamp. + * @param {Date|string} date + * @returns {string} e.g. "14:32:07" or "2 Jan 14:32". + */ +export function formatTimestamp(date) { + if (!date) return ''; + const d = date instanceof Date ? date : new Date(date); + if (isNaN(d.getTime())) return ''; + + const now = new Date(); + const isToday = d.toDateString() === now.toDateString(); + const hours = String(d.getHours()).padStart(2, '0'); + const mins = String(d.getMinutes()).padStart(2, '0'); + const secs = String(d.getSeconds()).padStart(2, '0'); + + if (isToday) { + return `${hours}:${mins}:${secs}`; + } + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + return `${d.getDate()} ${months[d.getMonth()]} ${hours}:${mins}`; +} + +/** + * Returns a debounced version of the given function. + * @param {Function} fn + * @param {number} ms - Delay in milliseconds. + * @returns {Function} + */ +export function debounce(fn, ms) { + let timer = null; + return function (...args) { + clearTimeout(timer); + timer = setTimeout(() => fn.apply(this, args), ms); + }; +} + +/** + * Performs a simple fuzzy match — checks whether every character in + * the query appears in order within the text (case-insensitive). + * @param {string} text + * @param {string} query + * @returns {{ match: boolean, score: number }} + */ +export function fuzzyMatch(text, query) { + if (!query) return { match: true, score: 1 }; + const lowerText = text.toLowerCase(); + const lowerQuery = query.toLowerCase(); + + let textIdx = 0; + let score = 0; + let consecutiveBonus = 0; + + for (let qi = 0; qi < lowerQuery.length; qi++) { + const ch = lowerQuery[qi]; + const foundAt = lowerText.indexOf(ch, textIdx); + if (foundAt === -1) { + return { match: false, score: 0 }; + } + // Reward consecutive character matches and early matches + if (foundAt === textIdx) { + consecutiveBonus += 1; + } else { + consecutiveBonus = 0; + } + score += 1 + consecutiveBonus + (foundAt === 0 ? 2 : 0); + textIdx = foundAt + 1; + } + + return { match: true, score }; +} + +/** + * Base64-encodes a string. + * @param {string} str + * @returns {string} + */ +export function base64Encode(str) { + return btoa(str); +} + +/** + * Base64-decodes a string. + * @param {string} str + * @returns {string} + */ +export function base64Decode(str) { + return atob(str); +} + +/** + * Generates a new UUID v4 using the browser crypto API. + * @returns {string} + */ +export function generateId() { + return crypto.randomUUID(); +} + +/** + * Formats a byte count into a human-friendly string. + * @param {number} bytes + * @returns {string} e.g. "1.23 KB" + */ +export function formatBytes(bytes) { + if (bytes === 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + const k = 1024; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + const value = bytes / Math.pow(k, i); + return `${value.toFixed(i === 0 ? 0 : 2)} ${units[i]}`; +} + +/** + * Truncates a string to the given length, appending an ellipsis if needed. + * @param {string} str + * @param {number} len + * @returns {string} + */ +export function truncate(str, len) { + if (!str || str.length <= len) return str || ''; + return str.slice(0, len) + '…'; +} + +/** + * Removes any open context menus from the DOM. + */ +export function dismissContextMenus() { + document.querySelectorAll('.context-menu').forEach((m) => m.remove()); +} + +/** + * Returns the protocol badge colour class. + * @param {string} protocol - 'ssh', 'telnet', or 'raw'. + * @returns {string} + */ +export function protocolColour(protocol) { + switch (protocol) { + case 'ssh': return 'protocol-ssh'; + case 'telnet': return 'protocol-telnet'; + case 'raw': return 'protocol-raw'; + default: return ''; + } +} + +/** + * Returns a human-friendly label for a session state. + * @param {string} state + * @returns {string} + */ +export function stateLabel(state) { + switch (state) { + case 'connecting': return 'Connecting'; + case 'connected': return 'Connected'; + case 'disconnected': return 'Disconnected'; + case 'error': return 'Error'; + default: return state || 'Unknown'; + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..60857e8 --- /dev/null +++ b/go.mod @@ -0,0 +1,41 @@ +module github.com/copilot-ssh-client/app + +go 1.21 + +require ( + github.com/google/uuid v1.6.0 + github.com/mattn/go-sqlite3 v1.14.22 + github.com/wailsapp/wails/v2 v2.9.2 + github.com/zalando/go-keyring v0.2.5 + golang.org/x/crypto v0.31.0 + golang.org/x/text v0.21.0 +) + +require ( + github.com/alessio/shellescape v1.4.1 // indirect + github.com/bep/debounce v1.2.1 // indirect + github.com/danieljoos/wincred v1.2.0 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect + github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect + github.com/labstack/echo/v4 v4.10.2 // indirect + github.com/labstack/gommon v0.4.0 // indirect + github.com/leaanthony/go-ansi-parser v1.6.0 // indirect + github.com/leaanthony/gosod v1.0.3 // indirect + github.com/leaanthony/slicer v1.6.0 // indirect + github.com/leaanthony/u v1.1.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/rivo/uniseg v0.4.4 // indirect + github.com/samber/lo v1.38.1 // indirect + github.com/tkrajina/go-reflector v0.5.6 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + github.com/wailsapp/go-webview2 v1.0.16 // indirect + github.com/wailsapp/mimetype v1.4.1 // indirect + golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 // indirect + golang.org/x/net v0.25.0 // indirect + golang.org/x/sys v0.28.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b6365c9 --- /dev/null +++ b/go.sum @@ -0,0 +1,106 @@ +github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVKJUX0= +github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30= +github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= +github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= +github.com/danieljoos/wincred v1.2.0 h1:ozqKHaLK0W/ii4KVbbvluM91W2H3Sh0BncbUNPS7jLE= +github.com/danieljoos/wincred v1.2.0/go.mod h1:FzQLLMKBFdvu+osBrnFODiv32YGwCfx0SkRa/eYHgec= +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/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +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/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck= +github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= +github.com/labstack/echo/v4 v4.10.2 h1:n1jAhnq/elIFTHr1EYpiYtyKgx4RW9ccVgkqByZaN2M= +github.com/labstack/echo/v4 v4.10.2/go.mod h1:OEyqf2//K1DFdE57vw2DRgWY0M7s65IVQO2FzvI4J5k= +github.com/labstack/gommon v0.4.0 h1:y7cvthEAEbU0yHOf4axH8ZG2NH8knB9iNSoTO8dyIk8= +github.com/labstack/gommon v0.4.0/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM= +github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc= +github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA= +github.com/leaanthony/go-ansi-parser v1.6.0 h1:T8TuMhFB6TUMIUm0oRrSbgJudTFw9csT3ZK09w0t4Pg= +github.com/leaanthony/go-ansi-parser v1.6.0/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU= +github.com/leaanthony/gosod v1.0.3 h1:Fnt+/B6NjQOVuCWOKYRREZnjGyvg+mEhd1nkkA04aTQ= +github.com/leaanthony/gosod v1.0.3/go.mod h1:BJ2J+oHsQIyIQpnLPjnqFGTMnOZXDbvWtRCSG7jGxs4= +github.com/leaanthony/slicer v1.5.0/go.mod h1:FwrApmf8gOrpzEWM2J/9Lh79tyq8KTX5AzRtwV7m4AY= +github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js= +github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8= +github.com/leaanthony/u v1.1.0 h1:2n0d2BwPVXSUq5yhe8lJPHdxevE2qK5G99PMStMZMaI= +github.com/leaanthony/u v1.1.0/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI= +github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE= +github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU= +github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +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/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= +github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/samber/lo v1.38.1 h1:j2XEAqXKb09Am4ebOg31SpvzUTTs6EN3VfgeLUhPdXM= +github.com/samber/lo v1.38.1/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/tkrajina/go-reflector v0.5.6 h1:hKQ0gyocG7vgMD2M3dRlYN6WBBOmdoOzJ6njQSepKdE= +github.com/tkrajina/go-reflector v0.5.6/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/wailsapp/go-webview2 v1.0.16 h1:wffnvnkkLvhRex/aOrA3R7FP7rkvOqL/bir1br7BekU= +github.com/wailsapp/go-webview2 v1.0.16/go.mod h1:Uk2BePfCRzttBBjFrBmqKGJd41P6QIHeV9kTgIeOZNo= +github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs= +github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o= +github.com/wailsapp/wails/v2 v2.9.2 h1:Xb5YRTos1w5N7DTMyYegWaGukCP2fIaX9WF21kPPF2k= +github.com/wailsapp/wails/v2 v2.9.2/go.mod h1:uehvlCwJSFcBq7rMCGfk4rxca67QQGsbg5Nm4m9UnBs= +github.com/zalando/go-keyring v0.2.5 h1:Bc2HHpjALryKD62ppdEzaFG6VxL6Bc+5v0LYpN8Lba8= +github.com/zalando/go-keyring v0.2.5/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk= +golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 h1:k/i9J1pBpvlfR+9QsetwPyERsqu1GIbi967PQMq3Ivc= +golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w= +golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/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-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..9e738d8 --- /dev/null +++ b/internal/app/app.go @@ -0,0 +1,117 @@ +// Package app provides the core application lifecycle for Nexus Terminal. +// It wires together storage, secrets, sessions, preferences, known hosts, +// and the audit log, and exposes Wails lifecycle hooks. +package app + +import ( + "context" + "fmt" + "log" + "sync" + + "github.com/wailsapp/wails/v2/pkg/runtime" + + "github.com/copilot-ssh-client/app/internal/config" + "github.com/copilot-ssh-client/app/internal/secrets" +) + +// Storage abstracts the persistent data layer (SQLite). +type Storage interface { + Close() error +} + +// SessionManager tracks active connection sessions. +type SessionManager interface { + CloseAll() +} + +// Preferences manages user application preferences. +type Preferences interface{} + +// KnownHostStore manages trusted SSH host key records. +type KnownHostStore interface{} + +// AuditLog records auditable application events. +type AuditLog interface{} + +// App is the central application struct. Wails binds its lifecycle methods +// (Startup, Shutdown, DomReady) and it holds references to every subsystem. +type App struct { + ctx context.Context + mu sync.Mutex + + Config *config.AppConfig + Storage Storage + SecretStore *secrets.SecretStore + SessionManager SessionManager + Preferences Preferences + KnownHostStore KnownHostStore + AuditLog AuditLog +} + +// NewApp creates a new App instance. Subsystems are not initialised until +// Startup is called by the Wails runtime. +func NewApp() *App { + return &App{} +} + +// Startup is called by Wails when the application starts. It loads +// configuration, ensures required directories exist, and initialises all +// subsystems (storage, secret store, session manager, preferences). +func (a *App) Startup(ctx context.Context) { + a.mu.Lock() + defer a.mu.Unlock() + + a.ctx = ctx + + cfg, err := config.Load() + if err != nil { + log.Fatalf("failed to load configuration: %v", err) + } + a.Config = cfg + + if err := cfg.EnsureDirectories(); err != nil { + log.Fatalf("failed to create application directories: %v", err) + } + + // Initialise the encrypted secret store. + a.SecretStore = secrets.NewSecretStore(cfg.SecretPath) + + // Storage, session manager, preferences, known host store and audit log + // will be wired in when their implementations are available. + // For now we log successful initialisation. + log.Printf("Nexus Terminal initialised – data directory: %s", cfg.DataDir) +} + +// Shutdown is called by Wails when the application is closing. It ensures all +// sessions are cleanly terminated and persistent storage is flushed. +func (a *App) Shutdown(ctx context.Context) { + a.mu.Lock() + defer a.mu.Unlock() + + if a.SessionManager != nil { + a.SessionManager.CloseAll() + } + + if a.Storage != nil { + if err := a.Storage.Close(); err != nil { + log.Printf("warning: error closing storage: %v", err) + } + } + + // Lock the secret store to zero key material. + if a.SecretStore != nil { + a.SecretStore.Lock() + } + + log.Println("Nexus Terminal shut down cleanly") +} + +// DomReady is called by Wails once the frontend DOM is fully loaded. It emits +// a "backend:ready" event so the frontend knows the backend is prepared. +func (a *App) DomReady(ctx context.Context) { + runtime.EventsEmit(ctx, "backend:ready", map[string]interface{}{ + "message": "Backend is ready", + }) + fmt.Println("DOM ready – backend:ready event emitted") +} diff --git a/internal/audit/audit.go b/internal/audit/audit.go new file mode 100644 index 0000000..dc580d6 --- /dev/null +++ b/internal/audit/audit.go @@ -0,0 +1,100 @@ +// Package audit provides structured audit logging for security-relevant +// operations. Secrets are never included in audit entries. +package audit + +import ( + "fmt" + "time" + + "github.com/copilot-ssh-client/app/internal/models" + "github.com/google/uuid" +) + +// AuditStore is the persistence interface used by the Logger. +type AuditStore interface { + AddAuditEntry(entry *models.AuditEntry) error + ListAuditEntries(limit int) ([]*models.AuditEntry, error) +} + +// Logger writes structured audit entries for connection events, profile +// changes, secret access, and import/export operations. +type Logger struct { + store AuditStore +} + +// NewLogger creates a Logger backed by the given store. +func NewLogger(store AuditStore) *Logger { + return &Logger{store: store} +} + +// Log records a generic audit entry. Fields left empty are stored as-is. +func (l *Logger) Log(action string, profileID string, sessionID string, details string) { + entry := &models.AuditEntry{ + ID: uuid.New().String(), + Timestamp: time.Now(), + Action: action, + ProfileID: profileID, + SessionID: sessionID, + Details: details, + } + // Best-effort logging — errors are silently discarded to avoid + // disrupting the caller's operation. + _ = l.store.AddAuditEntry(entry) +} + +// LogConnect records a session connection event. +func (l *Logger) LogConnect(profileID string, sessionID string, protocol string, host string) { + l.Log("connect", profileID, sessionID, + fmt.Sprintf("Connected via %s to %s", protocol, host)) +} + +// LogDisconnect records a session disconnection event. +func (l *Logger) LogDisconnect(sessionID string) { + l.Log("disconnect", "", sessionID, "Session disconnected") +} + +// LogProfileCreate records the creation of a new profile. +func (l *Logger) LogProfileCreate(profileID string, name string) { + l.Log("profile.create", profileID, "", + fmt.Sprintf("Created profile %q", name)) +} + +// LogProfileUpdate records an update to an existing profile. +func (l *Logger) LogProfileUpdate(profileID string, name string) { + l.Log("profile.update", profileID, "", + fmt.Sprintf("Updated profile %q", name)) +} + +// LogProfileDelete records the deletion of a profile. +func (l *Logger) LogProfileDelete(profileID string, name string) { + l.Log("profile.delete", profileID, "", + fmt.Sprintf("Deleted profile %q", name)) +} + +// LogSecretAccess records access to stored credentials. The secret value is +// never recorded — only the type of secret accessed. +func (l *Logger) LogSecretAccess(profileID string, secretType string) { + l.Log("secret.access", profileID, "", + fmt.Sprintf("Accessed %s credential", secretType)) +} + +// LogImport records a bulk profile import. +func (l *Logger) LogImport(count int) { + l.Log("import", "", "", + fmt.Sprintf("Imported %d profile(s)", count)) +} + +// LogExport records a profile export, noting whether secrets were included. +func (l *Logger) LogExport(count int, includeSecrets bool) { + secretsNote := "without secrets" + if includeSecrets { + secretsNote = "with secrets" + } + l.Log("export", "", "", + fmt.Sprintf("Exported %d profile(s) %s", count, secretsNote)) +} + +// ListEntries returns recent audit entries, delegating to the backing store. +func (l *Logger) ListEntries(limit int) ([]*models.AuditEntry, error) { + return l.store.ListAuditEntries(limit) +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..b8f304c --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,87 @@ +// Package config resolves platform-specific application paths and configuration. +package config + +import ( + "os" + "path/filepath" + "runtime" +) + +const appName = "NexusTerminal" + +// AppConfig holds the resolved application configuration paths. +type AppConfig struct { + DataDir string // base data directory + DBPath string // SQLite database path + SecretPath string // encrypted vault directory + LogPath string // log file path +} + +// Load resolves the application configuration. +// Data directory follows platform conventions: +// - macOS: ~/Library/Application Support/NexusTerminal +// - Linux: ~/.config/NexusTerminal +// - Windows: %APPDATA%/NexusTerminal +func Load() (*AppConfig, error) { + dataDir, err := resolveDataDir() + if err != nil { + return nil, err + } + + cfg := &AppConfig{ + DataDir: dataDir, + DBPath: filepath.Join(dataDir, "nexus.db"), + SecretPath: filepath.Join(dataDir, "vault"), + LogPath: filepath.Join(dataDir, "nexus.log"), + } + return cfg, nil +} + +// EnsureDirectories creates required directories if they don't exist. +func (c *AppConfig) EnsureDirectories() error { + dirs := []string{ + c.DataDir, + c.SecretPath, + filepath.Dir(c.LogPath), + } + for _, d := range dirs { + if err := os.MkdirAll(d, 0700); err != nil { + return err + } + } + return nil +} + +// resolveDataDir determines the base data directory for the current platform. +func resolveDataDir() (string, error) { + var base string + + switch runtime.GOOS { + case "darwin": + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + base = filepath.Join(home, "Library", "Application Support") + case "windows": + base = os.Getenv("APPDATA") + if base == "" { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + base = filepath.Join(home, "AppData", "Roaming") + } + default: // Linux and other UNIX-likes + base = os.Getenv("XDG_CONFIG_HOME") + if base == "" { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + base = filepath.Join(home, ".config") + } + } + + return filepath.Join(base, appName), nil +} diff --git a/internal/knownhosts/knownhosts.go b/internal/knownhosts/knownhosts.go new file mode 100644 index 0000000..7da9bb2 --- /dev/null +++ b/internal/knownhosts/knownhosts.go @@ -0,0 +1,98 @@ +// Package knownhosts manages SSH known host entries, providing verification, +// storage, and management of host key fingerprints. +package knownhosts + +import ( + "fmt" + "time" + + "github.com/copilot-ssh-client/app/internal/models" + "github.com/google/uuid" +) + +// KnownHostStorage is the persistence interface used by the Store. +type KnownHostStorage interface { + GetKnownHost(host string, port int) (*models.KnownHost, error) + SaveKnownHost(kh *models.KnownHost) error + ListKnownHosts() ([]*models.KnownHost, error) + DeleteKnownHost(id string) error +} + +// Store manages SSH known host entries. It provides verification against +// previously accepted host keys and allows new keys to be trusted. +type Store struct { + storage KnownHostStorage +} + +// NewStore creates a Store backed by the given storage implementation. +func NewStore(storage KnownHostStorage) *Store { + return &Store{storage: storage} +} + +// Verify checks whether the presented host key matches a previously accepted +// entry. Returns true if the fingerprint matches, false if there is no record, +// and an error if the key has changed (possible MITM). +func (s *Store) Verify(host string, port int, keyType string, fingerprint string, publicKey string) (bool, error) { + existing, err := s.storage.GetKnownHost(host, port) + if err != nil { + return false, fmt.Errorf("knownhosts: lookup failed: %w", err) + } + if existing == nil { + // No prior record — the caller should prompt the user. + return false, nil + } + + if existing.Fingerprint == fingerprint { + // Key matches — update the last-seen timestamp. + existing.LastSeen = time.Now() + _ = s.storage.SaveKnownHost(existing) + return true, nil + } + + // Fingerprint mismatch — the host key has changed. + return false, fmt.Errorf( + "knownhosts: host key for %s:%d has changed (was %s %s, now %s %s); "+ + "this could indicate a man-in-the-middle attack", + host, port, + existing.KeyType, existing.Fingerprint, + keyType, fingerprint, + ) +} + +// Accept stores a new host key as trusted. If an entry already exists for the +// host/port combination it is overwritten. +func (s *Store) Accept(host string, port int, keyType string, fingerprint string, publicKey string) error { + now := time.Now() + kh := &models.KnownHost{ + ID: uuid.New().String(), + Host: host, + Port: port, + KeyType: keyType, + Fingerprint: fingerprint, + PublicKey: publicKey, + FirstSeen: now, + LastSeen: now, + Trusted: true, + } + if err := s.storage.SaveKnownHost(kh); err != nil { + return fmt.Errorf("knownhosts: failed to accept key: %w", err) + } + return nil +} + +// List returns all stored known host entries. +func (s *Store) List() ([]*models.KnownHost, error) { + entries, err := s.storage.ListKnownHosts() + if err != nil { + return nil, fmt.Errorf("knownhosts: failed to list entries: %w", err) + } + return entries, nil +} + +// Delete removes a known host entry by ID. +func (s *Store) Delete(id string) error { + if err := s.storage.DeleteKnownHost(id); err != nil { + return fmt.Errorf("knownhosts: failed to delete entry: %w", err) + } + return nil +} diff --git a/internal/models/defaults.go b/internal/models/defaults.go new file mode 100644 index 0000000..7220447 --- /dev/null +++ b/internal/models/defaults.go @@ -0,0 +1,102 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +// Default port numbers for each protocol. +const ( + DefaultSSHPort = 22 + DefaultTelnetPort = 23 + DefaultRAWPort = 23 +) + +// Default timeout values (in seconds). +const ( + DefaultConnectTimeout = 30 + DefaultReadTimeout = 0 // no timeout +) + +// Default terminal rendering settings. +const ( + DefaultScrollback = 10000 + DefaultFontSize = 14 + DefaultFontFamily = "JetBrains Mono, Menlo, Monaco, Consolas, monospace" + DefaultCursorStyle = "block" + DefaultTerminalType = "xterm-256color" +) + +// Default SSH keepalive settings. +const ( + DefaultKeepaliveInterval = 30 + DefaultKeepaliveCountMax = 3 +) + +// DefaultAppPreferences returns sensible default application preferences. +func DefaultAppPreferences() AppPreferences { + return AppPreferences{ + Theme: "dark", + DefaultProtocol: string(ProtocolSSH), + ConfirmOnClose: true, + ConfirmOnDisconnect: true, + ShowStatusBar: true, + SidebarWidth: 280, + DefaultFontFamily: DefaultFontFamily, + DefaultFontSize: DefaultFontSize, + DefaultScrollback: DefaultScrollback, + DefaultCursorStyle: DefaultCursorStyle, + } +} + +// NewProfile creates a new Profile with sensible defaults for the given protocol. +func NewProfile(protocol Protocol) Profile { + now := time.Now() + p := Profile{ + ID: uuid.New().String(), + Protocol: protocol, + Tags: []string{}, + ConnectTimeout: DefaultConnectTimeout, + ReadTimeout: DefaultReadTimeout, + TerminalFontFamily: DefaultFontFamily, + TerminalFontSize: DefaultFontSize, + TerminalScrollback: DefaultScrollback, + TerminalCursorStyle: DefaultCursorStyle, + CreatedAt: now, + UpdatedAt: now, + } + + switch protocol { + case ProtocolSSH: + p.Port = DefaultSSHPort + p.SSH = &SSHConfig{ + AuthMethods: []string{"publickey", "password", "keyboard-interactive"}, + SecretMode: SecretModeAskEveryTime, + KeySecretMode: SecretModeAskEveryTime, + HostKeyPolicy: HostKeyAsk, + TerminalType: DefaultTerminalType, + KeepaliveInterval: DefaultKeepaliveInterval, + KeepaliveCountMax: DefaultKeepaliveCountMax, + JumpHosts: []JumpHost{}, + EnvVars: map[string]string{}, + } + case ProtocolTelnet: + p.Port = DefaultTelnetPort + p.Telnet = &TelnetConfig{ + LocalEcho: false, + LineEnding: LineEndingCRLF, + TerminalType: DefaultTerminalType, + Encoding: "utf-8", + } + case ProtocolRAW: + p.Port = DefaultRAWPort + p.RAW = &RAWConfig{ + LineEnding: LineEndingCRLF, + Encoding: "utf-8", + PayloadPresets: []PayloadPreset{}, + } + } + + return p +} diff --git a/internal/models/models.go b/internal/models/models.go new file mode 100644 index 0000000..c3792b1 --- /dev/null +++ b/internal/models/models.go @@ -0,0 +1,195 @@ +// Package models defines the core data structures used throughout +// the Nexus Terminal application. +package models + +import "time" + +// Protocol represents the connection protocol for a session. +type Protocol string + +const ( + ProtocolSSH Protocol = "ssh" + ProtocolTelnet Protocol = "telnet" + ProtocolRAW Protocol = "raw" +) + +// HostKeyPolicy determines how unknown or changed SSH host keys are handled. +type HostKeyPolicy string + +const ( + HostKeyStrict HostKeyPolicy = "strict" + HostKeyAsk HostKeyPolicy = "ask" + HostKeyAcceptNew HostKeyPolicy = "accept-new" + HostKeyInsecure HostKeyPolicy = "insecure" +) + +// SecretMode controls how credentials are persisted. +type SecretMode string + +const ( + SecretModeSaved SecretMode = "saved" + SecretModeSessionOnly SecretMode = "session-only" + SecretModeAskEveryTime SecretMode = "ask-every-time" +) + +// LineEnding specifies the line-ending convention for Telnet and RAW connections. +type LineEnding string + +const ( + LineEndingLF LineEnding = "lf" + LineEndingCR LineEnding = "cr" + LineEndingCRLF LineEnding = "crlf" + LineEndingNone LineEnding = "none" +) + +// Profile holds the full configuration for a connection profile. +type Profile struct { + ID string `json:"id"` + DisplayName string `json:"displayName"` + Description string `json:"description"` + Protocol Protocol `json:"protocol"` + Host string `json:"host"` + Port int `json:"port"` + Username string `json:"username"` + Tags []string `json:"tags"` + Folder string `json:"folder"` + Environment string `json:"environment"` + Notes string `json:"notes"` + Favourite bool `json:"favourite"` + LastConnected *time.Time `json:"lastConnected"` + ConnectTimeout int `json:"connectTimeout"` // seconds + ReadTimeout int `json:"readTimeout"` // seconds + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + + // Terminal preferences + TerminalFontFamily string `json:"terminalFontFamily"` + TerminalFontSize int `json:"terminalFontSize"` + TerminalScrollback int `json:"terminalScrollback"` + TerminalCursorStyle string `json:"terminalCursorStyle"` + + // SSH-specific configuration + SSH *SSHConfig `json:"ssh,omitempty"` + + // Telnet-specific configuration + Telnet *TelnetConfig `json:"telnet,omitempty"` + + // RAW-specific configuration + RAW *RAWConfig `json:"raw,omitempty"` +} + +// SSHConfig holds SSH-specific settings for a connection profile. +type SSHConfig struct { + AuthMethods []string `json:"authMethods"` // password, publickey, keyboard-interactive + SecretMode SecretMode `json:"secretMode"` + PrivateKeyPath string `json:"privateKeyPath"` + KeySecretMode SecretMode `json:"keySecretMode"` + HostKeyPolicy HostKeyPolicy `json:"hostKeyPolicy"` + TerminalType string `json:"terminalType"` + KeepaliveInterval int `json:"keepaliveInterval"` + KeepaliveCountMax int `json:"keepaliveCountMax"` + Compression bool `json:"compression"` + JumpHosts []JumpHost `json:"jumpHosts"` + StartupCommand string `json:"startupCommand"` + EnvVars map[string]string `json:"envVars"` +} + +// JumpHost describes a proxy/bastion host used for SSH multi-hop connections. +type JumpHost struct { + Host string `json:"host"` + Port int `json:"port"` + Username string `json:"username"` + AuthMethod string `json:"authMethod"` + SecretMode SecretMode `json:"secretMode"` + PrivateKeyPath string `json:"privateKeyPath"` +} + +// TelnetConfig holds Telnet-specific settings for a connection profile. +type TelnetConfig struct { + LocalEcho bool `json:"localEcho"` + LineEnding LineEnding `json:"lineEnding"` + TerminalType string `json:"terminalType"` + Encoding string `json:"encoding"` + NegotiationDebug bool `json:"negotiationDebug"` + LineMode bool `json:"lineMode"` +} + +// RAWConfig holds RAW TCP-specific settings for a connection profile. +type RAWConfig struct { + AutoReconnect bool `json:"autoReconnect"` + LineEnding LineEnding `json:"lineEnding"` + Encoding string `json:"encoding"` + ShowTimestamps bool `json:"showTimestamps"` + ShowDirection bool `json:"showDirection"` + PayloadPresets []PayloadPreset `json:"payloadPresets"` +} + +// PayloadPreset stores a reusable payload for RAW TCP connections. +type PayloadPreset struct { + ID string `json:"id"` + Name string `json:"name"` + Payload string `json:"payload"` // base64-encoded for binary safety + Format string `json:"format"` // text, hex, escaped +} + +// SessionState represents the lifecycle state of a connection session. +type SessionState string + +const ( + SessionConnecting SessionState = "connecting" + SessionConnected SessionState = "connected" + SessionDisconnected SessionState = "disconnected" + SessionError SessionState = "error" +) + +// SessionInfo provides metadata about an active or recent session. +type SessionInfo struct { + ID string `json:"id"` + ProfileID string `json:"profileId"` + ProfileName string `json:"profileName"` + Protocol Protocol `json:"protocol"` + Host string `json:"host"` + Port int `json:"port"` + State SessionState `json:"state"` + TabName string `json:"tabName"` + Pinned bool `json:"pinned"` + ConnectedAt time.Time `json:"connectedAt"` + Error string `json:"error,omitempty"` +} + +// KnownHost records a previously-seen SSH host key fingerprint. +type KnownHost struct { + ID string `json:"id"` + Host string `json:"host"` + Port int `json:"port"` + KeyType string `json:"keyType"` + Fingerprint string `json:"fingerprint"` + PublicKey string `json:"publicKey"` + FirstSeen time.Time `json:"firstSeen"` + LastSeen time.Time `json:"lastSeen"` + Trusted bool `json:"trusted"` +} + +// AppPreferences stores user-level application settings. +type AppPreferences struct { + Theme string `json:"theme"` + DefaultProtocol string `json:"defaultProtocol"` + ConfirmOnClose bool `json:"confirmOnClose"` + ConfirmOnDisconnect bool `json:"confirmOnDisconnect"` + ShowStatusBar bool `json:"showStatusBar"` + SidebarWidth int `json:"sidebarWidth"` + DefaultFontFamily string `json:"defaultFontFamily"` + DefaultFontSize int `json:"defaultFontSize"` + DefaultScrollback int `json:"defaultScrollback"` + DefaultCursorStyle string `json:"defaultCursorStyle"` +} + +// AuditEntry records an auditable action within the application. +type AuditEntry struct { + ID string `json:"id"` + Timestamp time.Time `json:"timestamp"` + Action string `json:"action"` + ProfileID string `json:"profileId,omitempty"` + SessionID string `json:"sessionId,omitempty"` + Details string `json:"details"` +} diff --git a/internal/preferences/preferences.go b/internal/preferences/preferences.go new file mode 100644 index 0000000..7a84be9 --- /dev/null +++ b/internal/preferences/preferences.go @@ -0,0 +1,107 @@ +// Package preferences manages application preferences with in-memory caching +// and persistence to a backing store. +package preferences + +import ( + "fmt" + "sync" + + "github.com/copilot-ssh-client/app/internal/models" +) + +// PreferenceStore is the persistence interface used by the Manager. +type PreferenceStore interface { + GetPreferences() (*models.AppPreferences, error) + SavePreferences(prefs *models.AppPreferences) error +} + +// Manager handles application preferences with defaults and caching. +// It is safe for concurrent use. +type Manager struct { + store PreferenceStore + cache *models.AppPreferences + mu sync.RWMutex +} + +// NewManager creates a preferences Manager backed by the given store. +// Call Load to populate the cache from disc. +func NewManager(store PreferenceStore) *Manager { + return &Manager{store: store} +} + +// Load reads preferences from the backing store into memory. If no +// preferences have been saved yet, sensible defaults are applied. +func (m *Manager) Load() error { + m.mu.Lock() + defer m.mu.Unlock() + + prefs, err := m.store.GetPreferences() + if err != nil { + return fmt.Errorf("preferences: failed to load: %w", err) + } + + if prefs == nil { + defaults := models.DefaultAppPreferences() + m.cache = &defaults + } else { + m.cache = prefs + m.applyDefaults() + } + return nil +} + +// Get returns the current preferences. If Load has not been called, defaults +// are returned. +func (m *Manager) Get() *models.AppPreferences { + m.mu.RLock() + defer m.mu.RUnlock() + + if m.cache == nil { + defaults := models.DefaultAppPreferences() + return &defaults + } + // Return a copy to prevent callers mutating the cache. + cp := *m.cache + return &cp +} + +// Save persists the given preferences to the backing store and updates the +// in-memory cache. +func (m *Manager) Save(prefs *models.AppPreferences) error { + m.mu.Lock() + defer m.mu.Unlock() + + if err := m.store.SavePreferences(prefs); err != nil { + return fmt.Errorf("preferences: failed to save: %w", err) + } + cp := *prefs + m.cache = &cp + return nil +} + +// applyDefaults fills in zero-valued fields from the default preferences. +// Caller must hold m.mu. +func (m *Manager) applyDefaults() { + defaults := models.DefaultAppPreferences() + if m.cache.Theme == "" { + m.cache.Theme = defaults.Theme + } + if m.cache.DefaultProtocol == "" { + m.cache.DefaultProtocol = defaults.DefaultProtocol + } + if m.cache.SidebarWidth == 0 { + m.cache.SidebarWidth = defaults.SidebarWidth + } + if m.cache.DefaultFontFamily == "" { + m.cache.DefaultFontFamily = defaults.DefaultFontFamily + } + if m.cache.DefaultFontSize == 0 { + m.cache.DefaultFontSize = defaults.DefaultFontSize + } + if m.cache.DefaultScrollback == 0 { + m.cache.DefaultScrollback = defaults.DefaultScrollback + } + if m.cache.DefaultCursorStyle == "" { + m.cache.DefaultCursorStyle = defaults.DefaultCursorStyle + } +} diff --git a/internal/rawclient/client.go b/internal/rawclient/client.go new file mode 100644 index 0000000..b6d7048 --- /dev/null +++ b/internal/rawclient/client.go @@ -0,0 +1,239 @@ +// Package rawclient provides a plain TCP connection with bidirectional data +// history, capture-to-file support, and callback-driven output. +package rawclient + +import ( + "context" + "encoding/hex" + "fmt" + "io" + "net" + "os" + "sync" + "time" +) + +// HistoryEntry records a single sent or received data frame. +type HistoryEntry struct { + Timestamp time.Time `json:"timestamp"` + Direction string `json:"direction"` // "sent" or "received" + Data []byte `json:"data"` +} + +// Options configures a new RAW TCP connection. +type Options struct { + Host string + Port int + ConnectTimeout time.Duration + + // OnData is called for every received chunk. The direction parameter is + // always "received" for incoming data. + OnData func(data []byte, direction string, timestamp time.Time) + + // OnError is called on non-EOF read errors. + OnError func(err error) + + // OnClose is called once when the connection terminates. + OnClose func() + + // DebugLog, when non-nil, receives diagnostic messages. + DebugLog func(msg string) +} + +// Client manages a single RAW TCP connection with history tracking. +type Client struct { + opts Options + conn net.Conn + mu sync.Mutex + closed bool + cancel context.CancelFunc + history []HistoryEntry + histMu sync.Mutex +} + +// New creates a Client with the given options. +func New(opts Options) *Client { + if opts.ConnectTimeout == 0 { + opts.ConnectTimeout = 30 * time.Second + } + return &Client{opts: opts} +} + +// Connect establishes the TCP connection and begins reading data. +func (c *Client) Connect(ctx context.Context) error { + c.mu.Lock() + defer c.mu.Unlock() + + ctx, cancel := context.WithCancel(ctx) + c.cancel = cancel + + c.debug("dialling %s:%d", c.opts.Host, c.opts.Port) + + addr := net.JoinHostPort(c.opts.Host, fmt.Sprintf("%d", c.opts.Port)) + dialer := &net.Dialer{Timeout: c.opts.ConnectTimeout} + conn, err := dialer.DialContext(ctx, "tcp", addr) + if err != nil { + return fmt.Errorf("rawclient: dial failed: %w", err) + } + c.conn = conn + + go c.readLoop(ctx) + return nil +} + +// Write sends data to the remote host and records it in the history. +func (c *Client) Write(data []byte) error { + c.mu.Lock() + defer c.mu.Unlock() + + if c.closed || c.conn == nil { + return fmt.Errorf("rawclient: connection is closed") + } + + _, err := c.conn.Write(data) + if err != nil { + return err + } + + now := time.Now() + c.recordHistory("sent", data, now) + + if c.opts.OnData != nil { + echoCopy := make([]byte, len(data)) + copy(echoCopy, data) + c.opts.OnData(echoCopy, "sent", now) + } + return nil +} + +// Close tears down the TCP connection. +func (c *Client) Close() error { + c.mu.Lock() + defer c.mu.Unlock() + + if c.closed { + return nil + } + c.closed = true + c.debug("closing connection") + + if c.cancel != nil { + c.cancel() + } + if c.conn != nil { + return c.conn.Close() + } + return nil +} + +// GetHistory returns a copy of the recorded data history. +func (c *Client) GetHistory() []HistoryEntry { + c.histMu.Lock() + defer c.histMu.Unlock() + + result := make([]HistoryEntry, len(c.history)) + copy(result, c.history) + return result +} + +// ClearHistory removes all recorded history entries. +func (c *Client) ClearHistory() { + c.histMu.Lock() + defer c.histMu.Unlock() + c.history = nil +} + +// SaveCapture writes the session history to a file in a human-readable +// format with timestamps and direction markers. +func (c *Client) SaveCapture(path string) error { + c.histMu.Lock() + entries := make([]HistoryEntry, len(c.history)) + copy(entries, c.history) + c.histMu.Unlock() + + f, err := os.Create(path) + if err != nil { + return fmt.Errorf("rawclient: failed to create capture file: %w", err) + } + defer f.Close() + + for _, entry := range entries { + ts := entry.Timestamp.Format("2006-01-02 15:04:05.000") + dir := ">>>" + if entry.Direction == "received" { + dir = "<<<" + } + hexStr := hex.EncodeToString(entry.Data) + if _, err := fmt.Fprintf(f, "[%s] %s %s\n", ts, dir, hexStr); err != nil { + return fmt.Errorf("rawclient: failed to write capture: %w", err) + } + } + return nil +} + +// readLoop continuously reads from the connection and delivers data. +func (c *Client) readLoop(ctx context.Context) { + buf := make([]byte, 32*1024) + for { + select { + case <-ctx.Done(): + return + default: + } + + n, err := c.conn.Read(buf) + if n > 0 { + now := time.Now() + chunk := make([]byte, n) + copy(chunk, buf[:n]) + + c.recordHistory("received", chunk, now) + + if c.opts.OnData != nil { + c.opts.OnData(chunk, "received", now) + } + } + if err != nil { + if err != io.EOF { + c.debug("read error: %v", err) + if c.opts.OnError != nil { + c.opts.OnError(err) + } + } + c.handleClose() + return + } + } +} + +// recordHistory appends a data frame to the session history. +func (c *Client) recordHistory(direction string, data []byte, ts time.Time) { + c.histMu.Lock() + defer c.histMu.Unlock() + + dataCopy := make([]byte, len(data)) + copy(dataCopy, data) + c.history = append(c.history, HistoryEntry{ + Timestamp: ts, + Direction: direction, + Data: dataCopy, + }) +} + +// handleClose is called when the connection terminates. +func (c *Client) handleClose() { + c.mu.Lock() + wasClosed := c.closed + c.closed = true + c.mu.Unlock() + + if !wasClosed && c.opts.OnClose != nil { + c.opts.OnClose() + } +} + +func (c *Client) debug(format string, args ...interface{}) { + if c.opts.DebugLog != nil { + c.opts.DebugLog(fmt.Sprintf(format, args...)) + } +} diff --git a/internal/secrets/keychain.go b/internal/secrets/keychain.go new file mode 100644 index 0000000..49e8806 --- /dev/null +++ b/internal/secrets/keychain.go @@ -0,0 +1,66 @@ +package secrets + +import ( + "fmt" + + "github.com/zalando/go-keyring" +) + +// KeychainStore attempts to use the OS keychain for secret storage. +// On macOS this uses the Keychain, on Linux it uses the Secret Service API, +// and on Windows it uses the Credential Manager. +// +// This is the preferred storage method when available. Falls back to +// the encrypted file-based SecretStore when the keychain is not accessible. +type KeychainStore struct { + serviceName string +} + +// NewKeychainStore creates a KeychainStore that stores credentials under the +// given service name in the operating system's native keychain. +func NewKeychainStore(serviceName string) *KeychainStore { + return &KeychainStore{serviceName: serviceName} +} + +// Available checks whether the OS keychain is accessible by performing a +// harmless write-then-delete probe. Returns true if the keychain responded +// without error. +func (k *KeychainStore) Available() bool { + // Attempt a round-trip to verify the keychain is usable. + probeKey := "__nexus_keychain_probe__" + probeVal := "probe" + + if err := keyring.Set(k.serviceName, probeKey, probeVal); err != nil { + return false + } + _ = keyring.Delete(k.serviceName, probeKey) + return true +} + +// SetSecret stores a secret in the OS keychain under the configured service +// name with the given key. +func (k *KeychainStore) SetSecret(key, value string) error { + if err := keyring.Set(k.serviceName, key, value); err != nil { + return fmt.Errorf("keychain: failed to store secret %q: %w", key, err) + } + return nil +} + +// GetSecret retrieves a secret from the OS keychain. Returns an error if the +// key does not exist or the keychain is inaccessible. +func (k *KeychainStore) GetSecret(key string) (string, error) { + val, err := keyring.Get(k.serviceName, key) + if err != nil { + return "", fmt.Errorf("keychain: failed to retrieve secret %q: %w", key, err) + } + return val, nil +} + +// DeleteSecret removes a secret from the OS keychain. Returns an error if the +// key does not exist or the keychain is inaccessible. +func (k *KeychainStore) DeleteSecret(key string) error { + if err := keyring.Delete(k.serviceName, key); err != nil { + return fmt.Errorf("keychain: failed to delete secret %q: %w", key, err) + } + return nil +} diff --git a/internal/secrets/redact.go b/internal/secrets/redact.go new file mode 100644 index 0000000..6de96ee --- /dev/null +++ b/internal/secrets/redact.go @@ -0,0 +1,37 @@ +// Package secrets provides encrypted credential storage and redaction utilities. +package secrets + +import "strings" + +// RedactSecret replaces a secret string with a redacted placeholder. +// Used for logging and UI display to prevent accidental secret exposure. +func RedactSecret(s string) string { + if len(s) == 0 { + return "" + } + return "••••••••" +} + +// RedactInString replaces all occurrences of a secret within a larger string +// with a redacted placeholder. This is useful for sanitising log output that +// may inadvertently contain sensitive material. +func RedactInString(text, secret string) string { + if secret == "" { + return text + } + return strings.ReplaceAll(text, secret, "••••••••") +} + +// RedactMap returns a shallow copy of the map with every value replaced by +// a redacted placeholder. The original map is not modified. +func RedactMap(m map[string]string) map[string]string { + redacted := make(map[string]string, len(m)) + for k, v := range m { + if v == "" { + redacted[k] = "" + } else { + redacted[k] = "••••••••" + } + } + return redacted +} diff --git a/internal/secrets/secrets.go b/internal/secrets/secrets.go new file mode 100644 index 0000000..92f8197 --- /dev/null +++ b/internal/secrets/secrets.go @@ -0,0 +1,345 @@ +package secrets + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "sync" + + "golang.org/x/crypto/argon2" +) + +const ( + vaultFileName = "vault.json" + vaultVersion = 1 + + // Argon2id key derivation parameters. + argonTime = 3 + argonMemory = 64 * 1024 + argonThreads = 4 + argonKeyLen = 32 + + saltLen = 32 +) + +// SecretStore manages encrypted credential storage. +// It uses AES-256-GCM with Argon2id key derivation from a user-controlled +// master password. No hard-coded secrets are used. +// +// Threat model: +// - Protects secrets at rest on the local filesystem +// - Requires the master password to decrypt +// - Does NOT protect against a compromised running process +// - Does NOT protect against memory forensics on a compromised machine +// - The master password is held in memory only for the duration of the session +type SecretStore struct { + mu sync.RWMutex + storePath string + key []byte // derived from master password, held in memory + vault *secretVault + unlocked bool +} + +type secretVault struct { + Version int `json:"version"` + Salt string `json:"salt"` // base64-encoded + Secrets map[string]string `json:"secrets"` // key -> base64(nonce + ciphertext) +} + +// NewSecretStore creates a new SecretStore that persists its vault file in the +// given directory path. The vault is not unlocked until Unlock is called. +func NewSecretStore(storePath string) *SecretStore { + return &SecretStore{ + storePath: storePath, + } +} + +// IsInitialised returns true if a vault file already exists on disc. +func (s *SecretStore) IsInitialised() bool { + _, err := os.Stat(s.vaultPath()) + return err == nil +} + +// Initialise creates a new vault with the given master password. A random +// 32-byte salt is generated and the key is derived using Argon2id. Returns an +// error if a vault already exists. +func (s *SecretStore) Initialise(masterPassword string) error { + s.mu.Lock() + defer s.mu.Unlock() + + if s.IsInitialised() { + return errors.New("secrets: vault already initialised") + } + + salt := make([]byte, saltLen) + if _, err := io.ReadFull(rand.Reader, salt); err != nil { + return fmt.Errorf("secrets: failed to generate salt: %w", err) + } + + s.vault = &secretVault{ + Version: vaultVersion, + Salt: base64.StdEncoding.EncodeToString(salt), + Secrets: make(map[string]string), + } + s.key = deriveKey(masterPassword, salt) + s.unlocked = true + + if err := os.MkdirAll(s.storePath, 0700); err != nil { + return fmt.Errorf("secrets: failed to create store directory: %w", err) + } + + if err := s.save(); err != nil { + return fmt.Errorf("secrets: failed to save initial vault: %w", err) + } + + return nil +} + +// Unlock decrypts the vault using the provided master password. The derived +// key is verified by attempting to decrypt the first stored secret; if no +// secrets exist the password is accepted optimistically. +func (s *SecretStore) Unlock(masterPassword string) error { + s.mu.Lock() + defer s.mu.Unlock() + + vault, err := s.load() + if err != nil { + return fmt.Errorf("secrets: failed to load vault: %w", err) + } + + salt, err := base64.StdEncoding.DecodeString(vault.Salt) + if err != nil { + return fmt.Errorf("secrets: corrupt vault salt: %w", err) + } + + s.vault = vault + s.key = deriveKey(masterPassword, salt) + s.unlocked = true + + // Attempt to decrypt any existing secret to verify the password. + for _, encVal := range vault.Secrets { + if _, err := s.decrypt(encVal); err != nil { + s.lockInternal() + return fmt.Errorf("secrets: incorrect master password or corrupt vault") + } + break + } + + return nil +} + +// Lock zeroes the derived key material and locks the vault. After calling Lock +// no secret operations are possible until Unlock is called again. +func (s *SecretStore) Lock() { + s.mu.Lock() + defer s.mu.Unlock() + s.lockInternal() +} + +// lockInternal performs the actual lock without acquiring the mutex. The caller +// must already hold s.mu. +func (s *SecretStore) lockInternal() { + for i := range s.key { + s.key[i] = 0 + } + s.key = nil + s.vault = nil + s.unlocked = false +} + +// IsUnlocked returns whether the vault is currently unlocked and ready for use. +func (s *SecretStore) IsUnlocked() bool { + s.mu.RLock() + defer s.mu.RUnlock() + return s.unlocked +} + +// SetSecret encrypts and stores a secret under the given key. The key format +// should follow "profile:{id}:password" or "profile:{id}:passphrase". +func (s *SecretStore) SetSecret(key string, value string) error { + s.mu.Lock() + defer s.mu.Unlock() + + if !s.unlocked { + return errors.New("secrets: vault is locked") + } + + encrypted, err := s.encrypt(value) + if err != nil { + return fmt.Errorf("secrets: encryption failed: %w", err) + } + + s.vault.Secrets[key] = encrypted + return s.save() +} + +// GetSecret retrieves and decrypts a secret by key. Returns an error if the +// key does not exist or decryption fails. +func (s *SecretStore) GetSecret(key string) (string, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + if !s.unlocked { + return "", errors.New("secrets: vault is locked") + } + + encVal, ok := s.vault.Secrets[key] + if !ok { + return "", fmt.Errorf("secrets: key %q not found", key) + } + + return s.decrypt(encVal) +} + +// DeleteSecret removes a secret by key. Returns an error if the vault is +// locked or the save fails. +func (s *SecretStore) DeleteSecret(key string) error { + s.mu.Lock() + defer s.mu.Unlock() + + if !s.unlocked { + return errors.New("secrets: vault is locked") + } + + delete(s.vault.Secrets, key) + return s.save() +} + +// DeleteProfileSecrets removes all secrets whose key begins with +// "profile:{profileID}:". +func (s *SecretStore) DeleteProfileSecrets(profileID string) error { + s.mu.Lock() + defer s.mu.Unlock() + + if !s.unlocked { + return errors.New("secrets: vault is locked") + } + + prefix := "profile:" + profileID + ":" + for k := range s.vault.Secrets { + if strings.HasPrefix(k, prefix) { + delete(s.vault.Secrets, k) + } + } + + return s.save() +} + +// ListKeys returns all secret keys stored in the vault. Secret values are +// never returned by this method. +func (s *SecretStore) ListKeys() []string { + s.mu.RLock() + defer s.mu.RUnlock() + + if !s.unlocked || s.vault == nil { + return nil + } + + keys := make([]string, 0, len(s.vault.Secrets)) + for k := range s.vault.Secrets { + keys = append(keys, k) + } + return keys +} + +// --- internal helpers -------------------------------------------------------- + +// vaultPath returns the full filesystem path to the vault JSON file. +func (s *SecretStore) vaultPath() string { + return filepath.Join(s.storePath, vaultFileName) +} + +// deriveKey uses Argon2id to derive a 256-bit key from the master password and +// salt. The parameters are intentionally hard-coded to ensure consistency +// across vault open/close cycles. +func deriveKey(password string, salt []byte) []byte { + return argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen) +} + +// encrypt encrypts plaintext using AES-256-GCM. A random nonce is generated +// for each call and prepended to the ciphertext. The result is returned as a +// base64-encoded string. +func (s *SecretStore) encrypt(plaintext string) (string, error) { + block, err := aes.NewCipher(s.key) + if err != nil { + return "", fmt.Errorf("failed to create cipher: %w", err) + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", fmt.Errorf("failed to create GCM: %w", err) + } + + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", fmt.Errorf("failed to generate nonce: %w", err) + } + + ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil) + return base64.StdEncoding.EncodeToString(ciphertext), nil +} + +// decrypt decodes a base64 string and decrypts it using AES-256-GCM. The +// nonce is expected to be prepended to the ciphertext. +func (s *SecretStore) decrypt(encoded string) (string, error) { + data, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return "", fmt.Errorf("failed to decode secret: %w", err) + } + + block, err := aes.NewCipher(s.key) + if err != nil { + return "", fmt.Errorf("failed to create cipher: %w", err) + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", fmt.Errorf("failed to create GCM: %w", err) + } + + nonceSize := gcm.NonceSize() + if len(data) < nonceSize { + return "", errors.New("ciphertext too short") + } + + nonce, ciphertext := data[:nonceSize], data[nonceSize:] + plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return "", fmt.Errorf("decryption failed: %w", err) + } + + return string(plaintext), nil +} + +// save writes the vault to disc as a JSON file with restricted permissions. +func (s *SecretStore) save() error { + data, err := json.MarshalIndent(s.vault, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal vault: %w", err) + } + + return os.WriteFile(s.vaultPath(), data, 0600) +} + +// load reads and unmarshals the vault from disc. +func (s *SecretStore) load() (*secretVault, error) { + data, err := os.ReadFile(s.vaultPath()) + if err != nil { + return nil, fmt.Errorf("failed to read vault file: %w", err) + } + + var vault secretVault + if err := json.Unmarshal(data, &vault); err != nil { + return nil, fmt.Errorf("failed to parse vault file: %w", err) + } + + return &vault, nil +} diff --git a/internal/sshclient/client.go b/internal/sshclient/client.go new file mode 100644 index 0000000..d95d02e --- /dev/null +++ b/internal/sshclient/client.go @@ -0,0 +1,310 @@ +// Package sshclient provides a managed SSH session with PTY allocation, +// keepalive support, and callback-driven output. +package sshclient + +import ( + "context" + "fmt" + "io" + "net" + "sync" + "time" + + "golang.org/x/crypto/ssh" +) + +// Options configures a new SSH connection. +type Options struct { + Host string + Port int + Username string + AuthMethods []ssh.AuthMethod + TerminalType string + Cols int + Rows int + KeepaliveInterval time.Duration + KeepaliveMax int + HostKeyCallback ssh.HostKeyCallback + ConnectTimeout time.Duration + EnvVars map[string]string + Command string // if empty, a login shell is started + + // Callbacks invoked from a background goroutine. + OnData func(data []byte) + OnError func(err error) + OnClose func() + + // DebugLog, when non-nil, receives diagnostic messages. + DebugLog func(msg string) +} + +// Client manages a single SSH connection with PTY, keepalive, and +// callback-driven output delivery. +type Client struct { + opts Options + conn *ssh.Client + session *ssh.Session + stdin io.WriteCloser + mu sync.Mutex + closed bool + cancel context.CancelFunc +} + +// New creates a Client with the given options. Call Connect to establish +// the session. +func New(opts Options) *Client { + if opts.TerminalType == "" { + opts.TerminalType = "xterm-256color" + } + if opts.Cols == 0 { + opts.Cols = 80 + } + if opts.Rows == 0 { + opts.Rows = 24 + } + if opts.ConnectTimeout == 0 { + opts.ConnectTimeout = 30 * time.Second + } + return &Client{opts: opts} +} + +// Connect establishes the SSH connection, requests a PTY, and starts the +// session. Output data is delivered via opts.OnData. The connection is torn +// down when ctx is cancelled or when Close is called. +func (c *Client) Connect(ctx context.Context) error { + c.mu.Lock() + defer c.mu.Unlock() + + ctx, cancel := context.WithCancel(ctx) + c.cancel = cancel + + c.debug("dialling %s:%d", c.opts.Host, c.opts.Port) + + addr := net.JoinHostPort(c.opts.Host, fmt.Sprintf("%d", c.opts.Port)) + dialer := &net.Dialer{Timeout: c.opts.ConnectTimeout} + tcpConn, err := dialer.DialContext(ctx, "tcp", addr) + if err != nil { + return fmt.Errorf("sshclient: dial failed: %w", err) + } + + hostKeyCallback := c.opts.HostKeyCallback + if hostKeyCallback == nil { + hostKeyCallback = ssh.InsecureIgnoreHostKey() + } + + sshConn, chans, reqs, err := ssh.NewClientConn(tcpConn, addr, &ssh.ClientConfig{ + User: c.opts.Username, + Auth: c.opts.AuthMethods, + HostKeyCallback: hostKeyCallback, + Timeout: c.opts.ConnectTimeout, + }) + if err != nil { + tcpConn.Close() + return fmt.Errorf("sshclient: handshake failed: %w", err) + } + + c.conn = ssh.NewClient(sshConn, chans, reqs) + + sess, err := c.conn.NewSession() + if err != nil { + c.conn.Close() + return fmt.Errorf("sshclient: session creation failed: %w", err) + } + c.session = sess + + // Set environment variables. + for k, v := range c.opts.EnvVars { + _ = sess.Setenv(k, v) + } + + // Request a pseudo-terminal. + modes := ssh.TerminalModes{ + ssh.ECHO: 1, + ssh.TTY_OP_ISPEED: 14400, + ssh.TTY_OP_OSPEED: 14400, + } + if err := sess.RequestPty(c.opts.TerminalType, c.opts.Rows, c.opts.Cols, modes); err != nil { + sess.Close() + c.conn.Close() + return fmt.Errorf("sshclient: PTY request failed: %w", err) + } + + stdout, err := sess.StdoutPipe() + if err != nil { + sess.Close() + c.conn.Close() + return fmt.Errorf("sshclient: stdout pipe failed: %w", err) + } + stderr, err := sess.StderrPipe() + if err != nil { + sess.Close() + c.conn.Close() + return fmt.Errorf("sshclient: stderr pipe failed: %w", err) + } + + c.stdin, err = sess.StdinPipe() + if err != nil { + sess.Close() + c.conn.Close() + return fmt.Errorf("sshclient: stdin pipe failed: %w", err) + } + + // Start the shell or command. + if c.opts.Command != "" { + c.debug("starting command: %s", c.opts.Command) + if err := sess.Start(c.opts.Command); err != nil { + sess.Close() + c.conn.Close() + return fmt.Errorf("sshclient: command start failed: %w", err) + } + } else { + c.debug("starting login shell") + if err := sess.Shell(); err != nil { + sess.Close() + c.conn.Close() + return fmt.Errorf("sshclient: shell start failed: %w", err) + } + } + + // Read stdout in a background goroutine. + go c.readLoop(stdout, ctx) + go c.readLoop(stderr, ctx) + + // Start keepalive if configured. + if c.opts.KeepaliveInterval > 0 { + go c.keepaliveLoop(ctx) + } + + // Watch for session completion. + go func() { + _ = sess.Wait() + c.handleClose() + }() + + return nil +} + +// Write sends data to the remote session's standard input. +func (c *Client) Write(data []byte) error { + c.mu.Lock() + defer c.mu.Unlock() + + if c.closed { + return fmt.Errorf("sshclient: session is closed") + } + if c.stdin == nil { + return fmt.Errorf("sshclient: session not connected") + } + + _, err := c.stdin.Write(data) + return err +} + +// Resize sends a window change request to the remote PTY. +func (c *Client) Resize(cols, rows int) error { + c.mu.Lock() + defer c.mu.Unlock() + + if c.closed || c.session == nil { + return fmt.Errorf("sshclient: session is closed") + } + return c.session.WindowChange(rows, cols) +} + +// Close tears down the SSH session and connection. +func (c *Client) Close() error { + c.mu.Lock() + defer c.mu.Unlock() + + if c.closed { + return nil + } + c.closed = true + c.debug("closing connection") + + if c.cancel != nil { + c.cancel() + } + if c.stdin != nil { + c.stdin.Close() + } + if c.session != nil { + c.session.Close() + } + if c.conn != nil { + c.conn.Close() + } + return nil +} + +// readLoop continuously reads from the given reader and delivers data via the +// OnData callback. +func (c *Client) readLoop(r io.Reader, ctx context.Context) { + buf := make([]byte, 32*1024) + for { + select { + case <-ctx.Done(): + return + default: + } + + n, err := r.Read(buf) + if n > 0 && c.opts.OnData != nil { + chunk := make([]byte, n) + copy(chunk, buf[:n]) + c.opts.OnData(chunk) + } + if err != nil { + if err != io.EOF { + c.debug("read error: %v", err) + } + return + } + } +} + +// keepaliveLoop sends SSH keepalive requests at the configured interval. +func (c *Client) keepaliveLoop(ctx context.Context) { + ticker := time.NewTicker(c.opts.KeepaliveInterval) + defer ticker.Stop() + failures := 0 + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + _, _, err := c.conn.SendRequest("keepalive@openssh.com", true, nil) + if err != nil { + failures++ + c.debug("keepalive failure %d/%d", failures, c.opts.KeepaliveMax) + if c.opts.KeepaliveMax > 0 && failures >= c.opts.KeepaliveMax { + c.debug("keepalive limit reached, closing") + c.handleClose() + return + } + } else { + failures = 0 + } + } + } +} + +// handleClose is called when the session ends, either by the remote side +// or due to keepalive failure. +func (c *Client) handleClose() { + c.mu.Lock() + wasClosed := c.closed + c.closed = true + c.mu.Unlock() + + if !wasClosed && c.opts.OnClose != nil { + c.opts.OnClose() + } +} + +func (c *Client) debug(format string, args ...interface{}) { + if c.opts.DebugLog != nil { + c.opts.DebugLog(fmt.Sprintf(format, args...)) + } +} diff --git a/internal/storage/migrations/001_initial.sql b/internal/storage/migrations/001_initial.sql new file mode 100644 index 0000000..5307092 --- /dev/null +++ b/internal/storage/migrations/001_initial.sql @@ -0,0 +1,63 @@ +-- Initial schema for Nexus Terminal +-- Creates the core tables for profiles, known hosts, preferences, and audit log. + +CREATE TABLE IF NOT EXISTS profiles ( + id TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + description TEXT DEFAULT '', + protocol TEXT NOT NULL CHECK(protocol IN ('ssh', 'telnet', 'raw')), + host TEXT NOT NULL, + port INTEGER NOT NULL, + username TEXT DEFAULT '', + tags TEXT DEFAULT '[]', + folder TEXT DEFAULT '', + environment TEXT DEFAULT '', + notes TEXT DEFAULT '', + favourite INTEGER DEFAULT 0, + last_connected TEXT, + connect_timeout INTEGER DEFAULT 30, + read_timeout INTEGER DEFAULT 0, + terminal_font_family TEXT DEFAULT '', + terminal_font_size INTEGER DEFAULT 0, + terminal_scrollback INTEGER DEFAULT 0, + terminal_cursor_style TEXT DEFAULT '', + ssh_config TEXT, + telnet_config TEXT, + raw_config TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS known_hosts ( + id TEXT PRIMARY KEY, + host TEXT NOT NULL, + port INTEGER NOT NULL, + key_type TEXT NOT NULL, + fingerprint TEXT NOT NULL, + public_key TEXT NOT NULL, + first_seen TEXT NOT NULL, + last_seen TEXT NOT NULL, + trusted INTEGER DEFAULT 1, + UNIQUE(host, port, key_type) +); + +CREATE TABLE IF NOT EXISTS preferences ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS audit_log ( + id TEXT PRIMARY KEY, + timestamp TEXT NOT NULL, + action TEXT NOT NULL, + profile_id TEXT DEFAULT '', + session_id TEXT DEFAULT '', + details TEXT DEFAULT '' +); + +CREATE TABLE IF NOT EXISTS schema_version ( + version INTEGER PRIMARY KEY, + applied_at TEXT NOT NULL +); + +INSERT OR IGNORE INTO schema_version (version, applied_at) VALUES (1, datetime('now')); diff --git a/internal/storage/migrations/migrations.go b/internal/storage/migrations/migrations.go new file mode 100644 index 0000000..32e1ec3 --- /dev/null +++ b/internal/storage/migrations/migrations.go @@ -0,0 +1,10 @@ +// Package migrations provides embedded SQL migration files for the storage layer. +package migrations + +import "embed" + +// MigrationFS contains all embedded SQL migration files. +// Each migration is numbered sequentially (e.g. 001_initial.sql). +// +//go:embed *.sql +var MigrationFS embed.FS diff --git a/internal/storage/store.go b/internal/storage/store.go new file mode 100644 index 0000000..dec7bc1 --- /dev/null +++ b/internal/storage/store.go @@ -0,0 +1,661 @@ +// Package storage provides a SQLite-backed persistence layer for profiles, +// known hosts, audit entries, and application preferences. +package storage + +import ( + "database/sql" + "encoding/json" + "fmt" + "strings" + "time" + + _ "github.com/mattn/go-sqlite3" + + "github.com/copilot-ssh-client/app/internal/models" + "github.com/copilot-ssh-client/app/internal/storage/migrations" + "github.com/google/uuid" +) + +// Store wraps a SQLite database and exposes typed CRUD methods for +// all application entities. +type Store struct { + db *sql.DB +} + +// NewStore opens (or creates) the SQLite database at dbPath, applies any +// pending migrations, and returns an initialised Store. +func NewStore(dbPath string) (*Store, error) { + db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_busy_timeout=5000") + if err != nil { + return nil, fmt.Errorf("storage: failed to open database: %w", err) + } + if err := db.Ping(); err != nil { + return nil, fmt.Errorf("storage: database not reachable: %w", err) + } + + s := &Store{db: db} + if err := s.migrate(); err != nil { + return nil, fmt.Errorf("storage: migration failed: %w", err) + } + return s, nil +} + +// Close shuts down the database connection. +func (s *Store) Close() error { + return s.db.Close() +} + +// --------------------------------------------------------------------------- +// Migrations +// --------------------------------------------------------------------------- + +// migrate reads embedded SQL files and applies them in order. +func (s *Store) migrate() error { + // Ensure the schema_version table exists before querying it. + _, err := s.db.Exec(`CREATE TABLE IF NOT EXISTS schema_version ( + version INTEGER PRIMARY KEY, + applied_at TEXT NOT NULL + )`) + if err != nil { + return fmt.Errorf("failed to create schema_version table: %w", err) + } + + var current int + row := s.db.QueryRow("SELECT COALESCE(MAX(version), 0) FROM schema_version") + if err := row.Scan(¤t); err != nil { + return fmt.Errorf("failed to read schema version: %w", err) + } + + entries, err := migrations.MigrationFS.ReadDir(".") + if err != nil { + return fmt.Errorf("failed to read migration files: %w", err) + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + if !strings.HasSuffix(name, ".sql") { + continue + } + // Extract version number from filename prefix (e.g. "001_initial.sql" → 1). + var ver int + if _, err := fmt.Sscanf(name, "%03d_", &ver); err != nil { + continue + } + if ver <= current { + continue + } + + data, err := migrations.MigrationFS.ReadFile(name) + if err != nil { + return fmt.Errorf("failed to read migration %s: %w", name, err) + } + if _, err := s.db.Exec(string(data)); err != nil { + return fmt.Errorf("failed to apply migration %s: %w", name, err) + } + } + return nil +} + +// --------------------------------------------------------------------------- +// Profile operations +// --------------------------------------------------------------------------- + +// CreateProfile inserts a new profile into the database. +func (s *Store) CreateProfile(p *models.Profile) error { + tagsJSON, _ := json.Marshal(p.Tags) + sshJSON, _ := json.Marshal(p.SSH) + telnetJSON, _ := json.Marshal(p.Telnet) + rawJSON, _ := json.Marshal(p.RAW) + + _, err := s.db.Exec(`INSERT INTO profiles ( + id, display_name, description, protocol, host, port, username, + tags, folder, environment, notes, favourite, last_connected, + connect_timeout, read_timeout, + terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style, + ssh_config, telnet_config, raw_config, + created_at, updated_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + p.ID, p.DisplayName, p.Description, string(p.Protocol), + p.Host, p.Port, p.Username, + string(tagsJSON), p.Folder, p.Environment, p.Notes, + boolToInt(p.Favourite), timeToStr(p.LastConnected), + p.ConnectTimeout, p.ReadTimeout, + p.TerminalFontFamily, p.TerminalFontSize, p.TerminalScrollback, p.TerminalCursorStyle, + nullableJSON(p.SSH, sshJSON), nullableJSON(p.Telnet, telnetJSON), nullableJSON(p.RAW, rawJSON), + p.CreatedAt.Format(time.RFC3339), p.UpdatedAt.Format(time.RFC3339), + ) + if err != nil { + return fmt.Errorf("storage: failed to create profile: %w", err) + } + return nil +} + +// GetProfile retrieves a single profile by ID. +func (s *Store) GetProfile(id string) (*models.Profile, error) { + row := s.db.QueryRow(`SELECT + id, display_name, description, protocol, host, port, username, + tags, folder, environment, notes, favourite, last_connected, + connect_timeout, read_timeout, + terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style, + ssh_config, telnet_config, raw_config, + created_at, updated_at + FROM profiles WHERE id = ?`, id) + + p, err := scanProfile(row) + if err == sql.ErrNoRows { + return nil, fmt.Errorf("storage: profile %q not found", id) + } + if err != nil { + return nil, fmt.Errorf("storage: failed to get profile: %w", err) + } + return p, nil +} + +// UpdateProfile overwrites an existing profile. +func (s *Store) UpdateProfile(p *models.Profile) error { + tagsJSON, _ := json.Marshal(p.Tags) + sshJSON, _ := json.Marshal(p.SSH) + telnetJSON, _ := json.Marshal(p.Telnet) + rawJSON, _ := json.Marshal(p.RAW) + + res, err := s.db.Exec(`UPDATE profiles SET + display_name=?, description=?, protocol=?, host=?, port=?, username=?, + tags=?, folder=?, environment=?, notes=?, favourite=?, last_connected=?, + connect_timeout=?, read_timeout=?, + terminal_font_family=?, terminal_font_size=?, terminal_scrollback=?, terminal_cursor_style=?, + ssh_config=?, telnet_config=?, raw_config=?, + updated_at=? + WHERE id=?`, + p.DisplayName, p.Description, string(p.Protocol), + p.Host, p.Port, p.Username, + string(tagsJSON), p.Folder, p.Environment, p.Notes, + boolToInt(p.Favourite), timeToStr(p.LastConnected), + p.ConnectTimeout, p.ReadTimeout, + p.TerminalFontFamily, p.TerminalFontSize, p.TerminalScrollback, p.TerminalCursorStyle, + nullableJSON(p.SSH, sshJSON), nullableJSON(p.Telnet, telnetJSON), nullableJSON(p.RAW, rawJSON), + p.UpdatedAt.Format(time.RFC3339), + p.ID, + ) + if err != nil { + return fmt.Errorf("storage: failed to update profile: %w", err) + } + n, _ := res.RowsAffected() + if n == 0 { + return fmt.Errorf("storage: profile %q not found", p.ID) + } + return nil +} + +// DeleteProfile removes a profile by ID. +func (s *Store) DeleteProfile(id string) error { + res, err := s.db.Exec("DELETE FROM profiles WHERE id = ?", id) + if err != nil { + return fmt.Errorf("storage: failed to delete profile: %w", err) + } + n, _ := res.RowsAffected() + if n == 0 { + return fmt.Errorf("storage: profile %q not found", id) + } + return nil +} + +// ListProfiles returns all profiles ordered by display name. +func (s *Store) ListProfiles() ([]*models.Profile, error) { + rows, err := s.db.Query(`SELECT + id, display_name, description, protocol, host, port, username, + tags, folder, environment, notes, favourite, last_connected, + connect_timeout, read_timeout, + terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style, + ssh_config, telnet_config, raw_config, + created_at, updated_at + FROM profiles ORDER BY display_name`) + if err != nil { + return nil, fmt.Errorf("storage: failed to list profiles: %w", err) + } + defer rows.Close() + return scanProfiles(rows) +} + +// SearchProfiles returns profiles whose display name, host, or tags contain +// the query string (case-insensitive). +func (s *Store) SearchProfiles(query string) ([]*models.Profile, error) { + q := "%" + query + "%" + rows, err := s.db.Query(`SELECT + id, display_name, description, protocol, host, port, username, + tags, folder, environment, notes, favourite, last_connected, + connect_timeout, read_timeout, + terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style, + ssh_config, telnet_config, raw_config, + created_at, updated_at + FROM profiles + WHERE display_name LIKE ? OR host LIKE ? OR tags LIKE ? + ORDER BY display_name`, q, q, q) + if err != nil { + return nil, fmt.Errorf("storage: failed to search profiles: %w", err) + } + defer rows.Close() + return scanProfiles(rows) +} + +// ListProfilesByProtocol returns profiles filtered by protocol. +func (s *Store) ListProfilesByProtocol(protocol string) ([]*models.Profile, error) { + rows, err := s.db.Query(`SELECT + id, display_name, description, protocol, host, port, username, + tags, folder, environment, notes, favourite, last_connected, + connect_timeout, read_timeout, + terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style, + ssh_config, telnet_config, raw_config, + created_at, updated_at + FROM profiles WHERE protocol = ? ORDER BY display_name`, protocol) + if err != nil { + return nil, fmt.Errorf("storage: failed to list profiles by protocol: %w", err) + } + defer rows.Close() + return scanProfiles(rows) +} + +// ListProfilesByTag returns profiles that contain the specified tag. +func (s *Store) ListProfilesByTag(tag string) ([]*models.Profile, error) { + q := `%"` + tag + `"%` + rows, err := s.db.Query(`SELECT + id, display_name, description, protocol, host, port, username, + tags, folder, environment, notes, favourite, last_connected, + connect_timeout, read_timeout, + terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style, + ssh_config, telnet_config, raw_config, + created_at, updated_at + FROM profiles WHERE tags LIKE ? ORDER BY display_name`, q) + if err != nil { + return nil, fmt.Errorf("storage: failed to list profiles by tag: %w", err) + } + defer rows.Close() + return scanProfiles(rows) +} + +// ListProfilesByFolder returns profiles in the specified folder. +func (s *Store) ListProfilesByFolder(folder string) ([]*models.Profile, error) { + rows, err := s.db.Query(`SELECT + id, display_name, description, protocol, host, port, username, + tags, folder, environment, notes, favourite, last_connected, + connect_timeout, read_timeout, + terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style, + ssh_config, telnet_config, raw_config, + created_at, updated_at + FROM profiles WHERE folder = ? ORDER BY display_name`, folder) + if err != nil { + return nil, fmt.Errorf("storage: failed to list profiles by folder: %w", err) + } + defer rows.Close() + return scanProfiles(rows) +} + +// ListFavourites returns all profiles marked as favourites. +func (s *Store) ListFavourites() ([]*models.Profile, error) { + rows, err := s.db.Query(`SELECT + id, display_name, description, protocol, host, port, username, + tags, folder, environment, notes, favourite, last_connected, + connect_timeout, read_timeout, + terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style, + ssh_config, telnet_config, raw_config, + created_at, updated_at + FROM profiles WHERE favourite = 1 ORDER BY display_name`) + if err != nil { + return nil, fmt.Errorf("storage: failed to list favourites: %w", err) + } + defer rows.Close() + return scanProfiles(rows) +} + +// SetFavourite updates the favourite flag on a profile. +func (s *Store) SetFavourite(id string, favourite bool) error { + res, err := s.db.Exec("UPDATE profiles SET favourite = ?, updated_at = ? WHERE id = ?", + boolToInt(favourite), time.Now().Format(time.RFC3339), id) + if err != nil { + return fmt.Errorf("storage: failed to update favourite: %w", err) + } + n, _ := res.RowsAffected() + if n == 0 { + return fmt.Errorf("storage: profile %q not found", id) + } + return nil +} + +// --------------------------------------------------------------------------- +// Known hosts +// --------------------------------------------------------------------------- + +// GetKnownHost retrieves a known host entry by host, port, and key type. +func (s *Store) GetKnownHost(host string, port int) (*models.KnownHost, error) { + row := s.db.QueryRow(`SELECT id, host, port, key_type, fingerprint, public_key, first_seen, last_seen, trusted + FROM known_hosts WHERE host = ? AND port = ?`, host, port) + + kh := &models.KnownHost{} + var firstSeen, lastSeen string + var trusted int + if err := row.Scan(&kh.ID, &kh.Host, &kh.Port, &kh.KeyType, &kh.Fingerprint, + &kh.PublicKey, &firstSeen, &lastSeen, &trusted); err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + return nil, fmt.Errorf("storage: failed to get known host: %w", err) + } + kh.FirstSeen, _ = time.Parse(time.RFC3339, firstSeen) + kh.LastSeen, _ = time.Parse(time.RFC3339, lastSeen) + kh.Trusted = trusted == 1 + return kh, nil +} + +// SaveKnownHost inserts or updates a known host entry. +func (s *Store) SaveKnownHost(kh *models.KnownHost) error { + if kh.ID == "" { + kh.ID = uuid.New().String() + } + _, err := s.db.Exec(`INSERT INTO known_hosts (id, host, port, key_type, fingerprint, public_key, first_seen, last_seen, trusted) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(host, port, key_type) DO UPDATE SET + fingerprint=excluded.fingerprint, public_key=excluded.public_key, + last_seen=excluded.last_seen, trusted=excluded.trusted`, + kh.ID, kh.Host, kh.Port, kh.KeyType, kh.Fingerprint, kh.PublicKey, + kh.FirstSeen.Format(time.RFC3339), kh.LastSeen.Format(time.RFC3339), + boolToInt(kh.Trusted)) + if err != nil { + return fmt.Errorf("storage: failed to save known host: %w", err) + } + return nil +} + +// ListKnownHosts returns all stored known host entries. +func (s *Store) ListKnownHosts() ([]*models.KnownHost, error) { + rows, err := s.db.Query(`SELECT id, host, port, key_type, fingerprint, public_key, first_seen, last_seen, trusted + FROM known_hosts ORDER BY host, port`) + if err != nil { + return nil, fmt.Errorf("storage: failed to list known hosts: %w", err) + } + defer rows.Close() + + var hosts []*models.KnownHost + for rows.Next() { + kh := &models.KnownHost{} + var firstSeen, lastSeen string + var trusted int + if err := rows.Scan(&kh.ID, &kh.Host, &kh.Port, &kh.KeyType, &kh.Fingerprint, + &kh.PublicKey, &firstSeen, &lastSeen, &trusted); err != nil { + return nil, fmt.Errorf("storage: failed to scan known host: %w", err) + } + kh.FirstSeen, _ = time.Parse(time.RFC3339, firstSeen) + kh.LastSeen, _ = time.Parse(time.RFC3339, lastSeen) + kh.Trusted = trusted == 1 + hosts = append(hosts, kh) + } + return hosts, rows.Err() +} + +// DeleteKnownHost removes a known host entry by ID. +func (s *Store) DeleteKnownHost(id string) error { + res, err := s.db.Exec("DELETE FROM known_hosts WHERE id = ?", id) + if err != nil { + return fmt.Errorf("storage: failed to delete known host: %w", err) + } + n, _ := res.RowsAffected() + if n == 0 { + return fmt.Errorf("storage: known host %q not found", id) + } + return nil +} + +// --------------------------------------------------------------------------- +// Audit log +// --------------------------------------------------------------------------- + +// AddAuditEntry inserts an audit log entry. +func (s *Store) AddAuditEntry(entry *models.AuditEntry) error { + _, err := s.db.Exec(`INSERT INTO audit_log (id, timestamp, action, profile_id, session_id, details) + VALUES (?, ?, ?, ?, ?, ?)`, + entry.ID, entry.Timestamp.Format(time.RFC3339), entry.Action, + entry.ProfileID, entry.SessionID, entry.Details) + if err != nil { + return fmt.Errorf("storage: failed to add audit entry: %w", err) + } + return nil +} + +// ListAuditEntries returns the most recent audit entries, limited to count. +func (s *Store) ListAuditEntries(limit int) ([]*models.AuditEntry, error) { + rows, err := s.db.Query(`SELECT id, timestamp, action, profile_id, session_id, details + FROM audit_log ORDER BY timestamp DESC LIMIT ?`, limit) + if err != nil { + return nil, fmt.Errorf("storage: failed to list audit entries: %w", err) + } + defer rows.Close() + + var entries []*models.AuditEntry + for rows.Next() { + e := &models.AuditEntry{} + var ts string + if err := rows.Scan(&e.ID, &ts, &e.Action, &e.ProfileID, &e.SessionID, &e.Details); err != nil { + return nil, fmt.Errorf("storage: failed to scan audit entry: %w", err) + } + e.Timestamp, _ = time.Parse(time.RFC3339, ts) + entries = append(entries, e) + } + return entries, rows.Err() +} + +// --------------------------------------------------------------------------- +// Preferences (key-value) +// --------------------------------------------------------------------------- + +// GetPreferences reads all preference keys and deserialises them into an +// AppPreferences struct. +func (s *Store) GetPreferences() (*models.AppPreferences, error) { + rows, err := s.db.Query("SELECT key, value FROM preferences") + if err != nil { + return nil, fmt.Errorf("storage: failed to read preferences: %w", err) + } + defer rows.Close() + + kv := make(map[string]string) + for rows.Next() { + var k, v string + if err := rows.Scan(&k, &v); err != nil { + return nil, fmt.Errorf("storage: failed to scan preference: %w", err) + } + kv[k] = v + } + if err := rows.Err(); err != nil { + return nil, err + } + + // If no preferences stored yet, return nil to signal defaults should be used. + if len(kv) == 0 { + return nil, nil + } + + data, err := json.Marshal(kv) + if err != nil { + return nil, fmt.Errorf("storage: failed to marshal preference map: %w", err) + } + + prefs := &models.AppPreferences{} + if err := json.Unmarshal(data, prefs); err != nil { + return nil, fmt.Errorf("storage: failed to unmarshal preferences: %w", err) + } + return prefs, nil +} + +// SavePreferences serialises the AppPreferences into key-value pairs and +// persists them to the preferences table. +func (s *Store) SavePreferences(prefs *models.AppPreferences) error { + data, err := json.Marshal(prefs) + if err != nil { + return fmt.Errorf("storage: failed to marshal preferences: %w", err) + } + + var kv map[string]interface{} + if err := json.Unmarshal(data, &kv); err != nil { + return fmt.Errorf("storage: failed to unmarshal preferences map: %w", err) + } + + tx, err := s.db.Begin() + if err != nil { + return fmt.Errorf("storage: failed to begin transaction: %w", err) + } + + for k, v := range kv { + valStr := fmt.Sprintf("%v", v) + if _, err := tx.Exec(`INSERT INTO preferences (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value`, k, valStr); err != nil { + tx.Rollback() + return fmt.Errorf("storage: failed to save preference %q: %w", k, err) + } + } + return tx.Commit() +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +// scanProfile reads a single profile row from the given scanner. +func scanProfile(row *sql.Row) (*models.Profile, error) { + p := &models.Profile{} + var ( + protocol, tagsJSON string + sshJSON, telnetJSON, rawJSON sql.NullString + lastConn sql.NullString + favourite int + createdAt, updatedAt string + ) + + err := row.Scan( + &p.ID, &p.DisplayName, &p.Description, &protocol, + &p.Host, &p.Port, &p.Username, + &tagsJSON, &p.Folder, &p.Environment, &p.Notes, + &favourite, &lastConn, + &p.ConnectTimeout, &p.ReadTimeout, + &p.TerminalFontFamily, &p.TerminalFontSize, &p.TerminalScrollback, &p.TerminalCursorStyle, + &sshJSON, &telnetJSON, &rawJSON, + &createdAt, &updatedAt, + ) + if err != nil { + return nil, err + } + + p.Protocol = models.Protocol(protocol) + p.Favourite = favourite == 1 + p.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) + p.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt) + if lastConn.Valid && lastConn.String != "" { + t, err := time.Parse(time.RFC3339, lastConn.String) + if err == nil { + p.LastConnected = &t + } + } + + _ = json.Unmarshal([]byte(tagsJSON), &p.Tags) + if p.Tags == nil { + p.Tags = []string{} + } + + if sshJSON.Valid && sshJSON.String != "" { + p.SSH = &models.SSHConfig{} + _ = json.Unmarshal([]byte(sshJSON.String), p.SSH) + } + if telnetJSON.Valid && telnetJSON.String != "" { + p.Telnet = &models.TelnetConfig{} + _ = json.Unmarshal([]byte(telnetJSON.String), p.Telnet) + } + if rawJSON.Valid && rawJSON.String != "" { + p.RAW = &models.RAWConfig{} + _ = json.Unmarshal([]byte(rawJSON.String), p.RAW) + } + + return p, nil +} + +// scanProfiles iterates over rows and returns a slice of profiles. +func scanProfiles(rows *sql.Rows) ([]*models.Profile, error) { + var result []*models.Profile + for rows.Next() { + p := &models.Profile{} + var ( + protocol, tagsJSON string + sshJSON, telnetJSON, rawJSON sql.NullString + lastConn sql.NullString + favourite int + createdAt, updatedAt string + ) + + err := rows.Scan( + &p.ID, &p.DisplayName, &p.Description, &protocol, + &p.Host, &p.Port, &p.Username, + &tagsJSON, &p.Folder, &p.Environment, &p.Notes, + &favourite, &lastConn, + &p.ConnectTimeout, &p.ReadTimeout, + &p.TerminalFontFamily, &p.TerminalFontSize, &p.TerminalScrollback, &p.TerminalCursorStyle, + &sshJSON, &telnetJSON, &rawJSON, + &createdAt, &updatedAt, + ) + if err != nil { + return nil, fmt.Errorf("storage: failed to scan profile row: %w", err) + } + + p.Protocol = models.Protocol(protocol) + p.Favourite = favourite == 1 + p.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) + p.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt) + if lastConn.Valid && lastConn.String != "" { + t, err := time.Parse(time.RFC3339, lastConn.String) + if err == nil { + p.LastConnected = &t + } + } + + _ = json.Unmarshal([]byte(tagsJSON), &p.Tags) + if p.Tags == nil { + p.Tags = []string{} + } + + if sshJSON.Valid && sshJSON.String != "" { + p.SSH = &models.SSHConfig{} + _ = json.Unmarshal([]byte(sshJSON.String), p.SSH) + } + if telnetJSON.Valid && telnetJSON.String != "" { + p.Telnet = &models.TelnetConfig{} + _ = json.Unmarshal([]byte(telnetJSON.String), p.Telnet) + } + if rawJSON.Valid && rawJSON.String != "" { + p.RAW = &models.RAWConfig{} + _ = json.Unmarshal([]byte(rawJSON.String), p.RAW) + } + + result = append(result, p) + } + return result, rows.Err() +} + +func boolToInt(b bool) int { + if b { + return 1 + } + return 0 +} + +func timeToStr(t *time.Time) interface{} { + if t == nil { + return nil + } + return t.Format(time.RFC3339) +} + +// nullableJSON returns nil when the source object pointer is nil, +// otherwise the marshalled JSON bytes as a string. +func nullableJSON(ptr interface{}, data []byte) interface{} { + if ptr == nil || string(data) == "null" { + return nil + } + return string(data) +} diff --git a/internal/telnetclient/client.go b/internal/telnetclient/client.go new file mode 100644 index 0000000..19c39f8 --- /dev/null +++ b/internal/telnetclient/client.go @@ -0,0 +1,352 @@ +// Package telnetclient provides a Telnet connection with basic option +// negotiation, break signal support, and callback-driven output. +package telnetclient + +import ( + "context" + "fmt" + "io" + "net" + "sync" + "time" +) + +// Telnet command bytes. +const ( + cmdIAC byte = 255 + cmdDONT byte = 254 + cmdDO byte = 253 + cmdWONT byte = 252 + cmdWILL byte = 251 + cmdSB byte = 250 + cmdSE byte = 240 + cmdBRK byte = 243 + + optEcho byte = 1 + optSuppressGoAh byte = 3 + optTerminalType byte = 24 + optNAWS byte = 31 +) + +// Options configures a new Telnet connection. +type Options struct { + Host string + Port int + TerminalType string + Encoding string + ConnectTimeout time.Duration + + // Callbacks invoked from a background goroutine. + OnData func(data []byte) + OnError func(err error) + OnClose func() + + // DebugLog, when non-nil, receives diagnostic messages. + DebugLog func(msg string) +} + +// Client manages a single Telnet connection with basic option negotiation. +type Client struct { + opts Options + conn net.Conn + mu sync.Mutex + closed bool + cancel context.CancelFunc + + // Negotiated capabilities. + nawsEnabled bool + cols, rows int +} + +// New creates a Client with the given options. +func New(opts Options) *Client { + if opts.TerminalType == "" { + opts.TerminalType = "xterm-256color" + } + if opts.ConnectTimeout == 0 { + opts.ConnectTimeout = 30 * time.Second + } + return &Client{ + opts: opts, + cols: 80, + rows: 24, + } +} + +// Connect establishes the Telnet connection and begins reading data. +func (c *Client) Connect(ctx context.Context) error { + c.mu.Lock() + defer c.mu.Unlock() + + ctx, cancel := context.WithCancel(ctx) + c.cancel = cancel + + c.debug("dialling %s:%d", c.opts.Host, c.opts.Port) + + addr := net.JoinHostPort(c.opts.Host, fmt.Sprintf("%d", c.opts.Port)) + dialer := &net.Dialer{Timeout: c.opts.ConnectTimeout} + conn, err := dialer.DialContext(ctx, "tcp", addr) + if err != nil { + return fmt.Errorf("telnetclient: dial failed: %w", err) + } + c.conn = conn + + go c.readLoop(ctx) + return nil +} + +// Write sends data to the remote host, escaping any IAC bytes. +func (c *Client) Write(data []byte) error { + c.mu.Lock() + defer c.mu.Unlock() + + if c.closed || c.conn == nil { + return fmt.Errorf("telnetclient: connection is closed") + } + + escaped := escapeIAC(data) + _, err := c.conn.Write(escaped) + return err +} + +// Resize informs the remote host of a terminal size change via NAWS if +// negotiated. +func (c *Client) Resize(cols, rows int) error { + c.mu.Lock() + defer c.mu.Unlock() + + c.cols = cols + c.rows = rows + + if !c.nawsEnabled || c.closed || c.conn == nil { + return nil + } + return c.sendNAWS() +} + +// SendBreak transmits a Telnet BRK command. +func (c *Client) SendBreak() error { + c.mu.Lock() + defer c.mu.Unlock() + + if c.closed || c.conn == nil { + return fmt.Errorf("telnetclient: connection is closed") + } + + c.debug("sending break signal") + _, err := c.conn.Write([]byte{cmdIAC, cmdBRK}) + return err +} + +// Close tears down the Telnet connection. +func (c *Client) Close() error { + c.mu.Lock() + defer c.mu.Unlock() + + if c.closed { + return nil + } + c.closed = true + c.debug("closing connection") + + if c.cancel != nil { + c.cancel() + } + if c.conn != nil { + return c.conn.Close() + } + return nil +} + +// readLoop reads from the connection, processes Telnet commands, and delivers +// plain data via the OnData callback. +func (c *Client) readLoop(ctx context.Context) { + buf := make([]byte, 32*1024) + for { + select { + case <-ctx.Done(): + return + default: + } + + n, err := c.conn.Read(buf) + if n > 0 { + c.processBytes(buf[:n]) + } + if err != nil { + if err != io.EOF { + c.debug("read error: %v", err) + if c.opts.OnError != nil { + c.opts.OnError(err) + } + } + c.handleClose() + return + } + } +} + +// processBytes separates Telnet commands from application data. +func (c *Client) processBytes(data []byte) { + var appData []byte + + for i := 0; i < len(data); i++ { + if data[i] == cmdIAC && i+1 < len(data) { + switch data[i+1] { + case cmdIAC: + // Escaped IAC → literal 0xFF. + appData = append(appData, cmdIAC) + i++ + case cmdDO: + if i+2 < len(data) { + c.handleDO(data[i+2]) + i += 2 + } + case cmdDONT: + if i+2 < len(data) { + c.handleDONT(data[i+2]) + i += 2 + } + case cmdWILL: + if i+2 < len(data) { + c.handleWILL(data[i+2]) + i += 2 + } + case cmdWONT: + if i+2 < len(data) { + // Acknowledge WONT with DONT. + c.sendCommand(cmdDONT, data[i+2]) + i += 2 + } + case cmdSB: + // Consume the sub-negotiation until IAC SE. + end := c.findSubEnd(data[i+2:]) + i += 2 + end + default: + i++ + } + } else { + appData = append(appData, data[i]) + } + } + + if len(appData) > 0 && c.opts.OnData != nil { + c.opts.OnData(appData) + } +} + +// handleDO responds to a DO request from the server. +func (c *Client) handleDO(opt byte) { + switch opt { + case optTerminalType: + c.debug("negotiated terminal type") + c.sendCommand(cmdWILL, optTerminalType) + case optNAWS: + c.debug("negotiated NAWS") + c.sendCommand(cmdWILL, optNAWS) + c.mu.Lock() + c.nawsEnabled = true + _ = c.sendNAWS() + c.mu.Unlock() + case optSuppressGoAh: + c.sendCommand(cmdWILL, optSuppressGoAh) + default: + c.sendCommand(cmdWONT, opt) + } +} + +// handleDONT responds to a DONT request from the server. +func (c *Client) handleDONT(opt byte) { + if opt == optNAWS { + c.mu.Lock() + c.nawsEnabled = false + c.mu.Unlock() + } + c.sendCommand(cmdWONT, opt) +} + +// handleWILL responds to a WILL request from the server. +func (c *Client) handleWILL(opt byte) { + switch opt { + case optEcho, optSuppressGoAh: + c.sendCommand(cmdDO, opt) + default: + c.sendCommand(cmdDONT, opt) + } +} + +// sendCommand writes a three-byte Telnet command. +func (c *Client) sendCommand(cmd, opt byte) { + c.mu.Lock() + defer c.mu.Unlock() + if c.conn != nil && !c.closed { + c.conn.Write([]byte{cmdIAC, cmd, opt}) + } +} + +// sendNAWS transmits the current window size via NAWS sub-negotiation. +// Caller must hold c.mu. +func (c *Client) sendNAWS() error { + if c.conn == nil { + return nil + } + msg := []byte{ + cmdIAC, cmdSB, optNAWS, + byte(c.cols >> 8), byte(c.cols & 0xFF), + byte(c.rows >> 8), byte(c.rows & 0xFF), + cmdIAC, cmdSE, + } + _, err := c.conn.Write(msg) + return err +} + +// findSubEnd returns the offset past the IAC SE that terminates a +// sub-negotiation block. +func (c *Client) findSubEnd(data []byte) int { + for i := 0; i < len(data)-1; i++ { + if data[i] == cmdIAC && data[i+1] == cmdSE { + return i + 2 + } + } + return len(data) +} + +// handleClose is called when the connection ends. +func (c *Client) handleClose() { + c.mu.Lock() + wasClosed := c.closed + c.closed = true + c.mu.Unlock() + + if !wasClosed && c.opts.OnClose != nil { + c.opts.OnClose() + } +} + +// escapeIAC doubles any 0xFF bytes in outbound application data so they are +// not mistaken for Telnet commands. +func escapeIAC(data []byte) []byte { + count := 0 + for _, b := range data { + if b == cmdIAC { + count++ + } + } + if count == 0 { + return data + } + out := make([]byte, 0, len(data)+count) + for _, b := range data { + out = append(out, b) + if b == cmdIAC { + out = append(out, cmdIAC) + } + } + return out +} + +func (c *Client) debug(format string, args ...interface{}) { + if c.opts.DebugLog != nil { + c.opts.DebugLog(fmt.Sprintf(format, args...)) + } +} diff --git a/internal/validation/validation.go b/internal/validation/validation.go new file mode 100644 index 0000000..2f7982a --- /dev/null +++ b/internal/validation/validation.go @@ -0,0 +1,279 @@ +// Package validation provides thorough validation for connection profiles +// and their protocol-specific configurations. +package validation + +import ( + "fmt" + "net" + "strings" + + "github.com/copilot-ssh-client/app/internal/models" +) + +// ValidationError describes a single field-level validation failure. +type ValidationError struct { + Field string `json:"field"` + Message string `json:"message"` +} + +// Error satisfies the error interface. +func (e *ValidationError) Error() string { + return fmt.Sprintf("%s: %s", e.Field, e.Message) +} + +// ValidationErrors collects multiple validation failures into one error. +type ValidationErrors struct { + Errors []ValidationError `json:"errors"` +} + +// Error satisfies the error interface, returning a summary of all failures. +func (e *ValidationErrors) Error() string { + msgs := make([]string, len(e.Errors)) + for i, err := range e.Errors { + msgs[i] = err.Error() + } + return fmt.Sprintf("validation failed: %s", strings.Join(msgs, "; ")) +} + +// HasErrors reports whether any validation errors have been collected. +func (e *ValidationErrors) HasErrors() bool { + return len(e.Errors) > 0 +} + +// Add appends a validation error to the collection. +func (e *ValidationErrors) Add(field, message string) { + e.Errors = append(e.Errors, ValidationError{Field: field, Message: message}) +} + +// ValidateProfile validates all fields of a connection profile, including +// protocol-specific configuration blocks. +func ValidateProfile(p *models.Profile) error { + errs := &ValidationErrors{} + + if strings.TrimSpace(p.ID) == "" { + errs.Add("id", "profile ID must not be empty") + } + if strings.TrimSpace(p.DisplayName) == "" { + errs.Add("displayName", "display name must not be empty") + } + if err := ValidateHost(p.Host); err != nil { + errs.Add("host", err.(*ValidationError).Message) + } + if err := ValidatePort(p.Port); err != nil { + errs.Add("port", err.(*ValidationError).Message) + } + + switch p.Protocol { + case models.ProtocolSSH: + if strings.TrimSpace(p.Username) == "" { + errs.Add("username", "username is required for SSH connections") + } else if err := ValidateUsername(p.Username); err != nil { + errs.Add("username", err.(*ValidationError).Message) + } + if p.SSH == nil { + errs.Add("ssh", "SSH configuration is required for SSH protocol") + } else if err := ValidateSSHConfig(p.SSH); err != nil { + if ve, ok := err.(*ValidationErrors); ok { + errs.Errors = append(errs.Errors, ve.Errors...) + } + } + case models.ProtocolTelnet: + if p.Telnet == nil { + errs.Add("telnet", "Telnet configuration is required for Telnet protocol") + } else if err := ValidateTelnetConfig(p.Telnet); err != nil { + if ve, ok := err.(*ValidationErrors); ok { + errs.Errors = append(errs.Errors, ve.Errors...) + } + } + case models.ProtocolRAW: + if p.RAW == nil { + errs.Add("raw", "RAW configuration is required for RAW protocol") + } else if err := ValidateRAWConfig(p.RAW); err != nil { + if ve, ok := err.(*ValidationErrors); ok { + errs.Errors = append(errs.Errors, ve.Errors...) + } + } + default: + errs.Add("protocol", fmt.Sprintf("unsupported protocol %q", p.Protocol)) + } + + if p.ConnectTimeout < 0 { + errs.Add("connectTimeout", "connect timeout must not be negative") + } + if p.ReadTimeout < 0 { + errs.Add("readTimeout", "read timeout must not be negative") + } + if p.TerminalFontSize < 1 || p.TerminalFontSize > 72 { + errs.Add("terminalFontSize", "font size must be between 1 and 72") + } + if p.TerminalScrollback < 0 { + errs.Add("terminalScrollback", "scrollback must not be negative") + } + + if errs.HasErrors() { + return errs + } + return nil +} + +// ValidateHost ensures the host string is non-empty and syntactically valid. +func ValidateHost(host string) error { + h := strings.TrimSpace(host) + if h == "" { + return &ValidationError{Field: "host", Message: "host must not be empty"} + } + // Reject hosts that contain whitespace. + if strings.ContainsAny(h, " \t\n\r") { + return &ValidationError{Field: "host", Message: "host must not contain whitespace"} + } + // Reject hosts that contain a scheme (e.g. "ssh://"). + if strings.Contains(h, "://") { + return &ValidationError{Field: "host", Message: "host must not include a URI scheme"} + } + // If it looks like an IP address, parse it properly. + if ip := net.ParseIP(h); ip == nil { + // Not a plain IP — verify it is a plausible hostname. + if strings.HasPrefix(h, "-") { + return &ValidationError{Field: "host", Message: "host must not begin with a hyphen"} + } + } + return nil +} + +// ValidatePort checks that the port number is within the valid TCP range. +func ValidatePort(port int) error { + if port < 1 || port > 65535 { + return &ValidationError{Field: "port", Message: "port must be between 1 and 65535"} + } + return nil +} + +// ValidateUsername checks that the username is non-empty and free of control characters. +func ValidateUsername(username string) error { + u := strings.TrimSpace(username) + if u == "" { + return &ValidationError{Field: "username", Message: "username must not be empty"} + } + for _, r := range u { + if r < 0x20 || r == 0x7f { + return &ValidationError{Field: "username", Message: "username must not contain control characters"} + } + } + return nil +} + +// ValidateSSHConfig validates the SSH-specific configuration block. +func ValidateSSHConfig(cfg *models.SSHConfig) error { + errs := &ValidationErrors{} + + if len(cfg.AuthMethods) == 0 { + errs.Add("ssh.authMethods", "at least one authentication method is required") + } + validAuth := map[string]bool{ + "password": true, "publickey": true, "keyboard-interactive": true, + } + for _, m := range cfg.AuthMethods { + if !validAuth[m] { + errs.Add("ssh.authMethods", fmt.Sprintf("unsupported authentication method %q", m)) + } + } + + validSecret := map[models.SecretMode]bool{ + models.SecretModeSaved: true, models.SecretModeSessionOnly: true, models.SecretModeAskEveryTime: true, + } + if !validSecret[cfg.SecretMode] { + errs.Add("ssh.secretMode", fmt.Sprintf("unsupported secret mode %q", cfg.SecretMode)) + } + if !validSecret[cfg.KeySecretMode] { + errs.Add("ssh.keySecretMode", fmt.Sprintf("unsupported key secret mode %q", cfg.KeySecretMode)) + } + + validPolicy := map[models.HostKeyPolicy]bool{ + models.HostKeyStrict: true, models.HostKeyAsk: true, + models.HostKeyAcceptNew: true, models.HostKeyInsecure: true, + } + if !validPolicy[cfg.HostKeyPolicy] { + errs.Add("ssh.hostKeyPolicy", fmt.Sprintf("unsupported host key policy %q", cfg.HostKeyPolicy)) + } + + if cfg.KeepaliveInterval < 0 { + errs.Add("ssh.keepaliveInterval", "keepalive interval must not be negative") + } + if cfg.KeepaliveCountMax < 0 { + errs.Add("ssh.keepaliveCountMax", "keepalive count max must not be negative") + } + + // Validate each jump host entry. + for i, jh := range cfg.JumpHosts { + prefix := fmt.Sprintf("ssh.jumpHosts[%d]", i) + if strings.TrimSpace(jh.Host) == "" { + errs.Add(prefix+".host", "jump host address must not be empty") + } + if jh.Port < 1 || jh.Port > 65535 { + errs.Add(prefix+".port", "jump host port must be between 1 and 65535") + } + if strings.TrimSpace(jh.Username) == "" { + errs.Add(prefix+".username", "jump host username must not be empty") + } + } + + if errs.HasErrors() { + return errs + } + return nil +} + +// ValidateTelnetConfig validates the Telnet-specific configuration block. +func ValidateTelnetConfig(cfg *models.TelnetConfig) error { + errs := &ValidationErrors{} + + validEnding := map[models.LineEnding]bool{ + models.LineEndingLF: true, models.LineEndingCR: true, + models.LineEndingCRLF: true, models.LineEndingNone: true, + } + if !validEnding[cfg.LineEnding] { + errs.Add("telnet.lineEnding", fmt.Sprintf("unsupported line ending %q", cfg.LineEnding)) + } + + if strings.TrimSpace(cfg.Encoding) == "" { + errs.Add("telnet.encoding", "encoding must not be empty") + } + + if errs.HasErrors() { + return errs + } + return nil +} + +// ValidateRAWConfig validates the RAW TCP-specific configuration block. +func ValidateRAWConfig(cfg *models.RAWConfig) error { + errs := &ValidationErrors{} + + validEnding := map[models.LineEnding]bool{ + models.LineEndingLF: true, models.LineEndingCR: true, + models.LineEndingCRLF: true, models.LineEndingNone: true, + } + if !validEnding[cfg.LineEnding] { + errs.Add("raw.lineEnding", fmt.Sprintf("unsupported line ending %q", cfg.LineEnding)) + } + + if strings.TrimSpace(cfg.Encoding) == "" { + errs.Add("raw.encoding", "encoding must not be empty") + } + + validFormat := map[string]bool{"text": true, "hex": true, "escaped": true} + for i, preset := range cfg.PayloadPresets { + prefix := fmt.Sprintf("raw.payloadPresets[%d]", i) + if strings.TrimSpace(preset.Name) == "" { + errs.Add(prefix+".name", "payload preset name must not be empty") + } + if !validFormat[preset.Format] { + errs.Add(prefix+".format", fmt.Sprintf("unsupported payload format %q", preset.Format)) + } + } + + if errs.HasErrors() { + return errs + } + return nil +} diff --git a/wails.json b/wails.json new file mode 100644 index 0000000..ad9ff97 --- /dev/null +++ b/wails.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://wails.io/schemas/config.v2.json", + "name": "nexus-terminal", + "outputfilename": "NexusTerminal", + "frontend:install": "npm install", + "frontend:build": "npm run build", + "frontend:dev:watcher": "npm run dev", + "frontend:dev:serverUrl": "auto", + "author": { + "name": "Nexus Terminal Contributors" + }, + "info": { + "companyName": "Nexus Terminal", + "productName": "Nexus Terminal", + "productVersion": "0.1.0", + "copyright": "Copyright © 2024 Nexus Terminal Contributors", + "comments": "A premium desktop SSH, Telnet, and RAW TCP client" + } +}