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
This commit is contained in:
2026-04-15 00:00:00 +01:00
commit 35bc9c75b1
36 changed files with 6226 additions and 0 deletions
+38
View File
@@ -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
+59
View File
@@ -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"
+399
View File
@@ -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 `<data-dir>/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:<uuid>:password`, `profile:<uuid>: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.
---
<sub>Built with ❤ using [Wails](https://wails.io/), [xterm.js](https://xtermjs.org/), and [Go](https://go.dev/).</sub>
Executable
+97
View File
@@ -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
+39
View File
@@ -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)
}
}
+9
View File
@@ -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
+18
View File
@@ -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"
}
}
+105
View File
@@ -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();
}
+83
View File
@@ -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.');
+124
View File
@@ -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);
}
}
+300
View File
@@ -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<string>}
*/
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 = `
<h2 class="modal-title">${escapeHtml(title)}</h2>
<button class="modal-close" aria-label="Close">&times;</button>
`;
modal.appendChild(header);
// Body
const body = createElement('div', 'modal-body');
body.innerHTML = `
<p class="secret-message">${escapeHtml(message)}</p>
<div class="form-group">
<input type="password" class="form-input secret-input"
autocomplete="off" spellcheck="false" placeholder="Enter value…" />
</div>
`;
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<string>} 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 = `
<h2 class="modal-title">${escapeHtml(title)}</h2>
<button class="modal-close" aria-label="Close">&times;</button>
`;
modal.appendChild(header);
const body = createElement('div', 'modal-body');
let formHtml = `
<p class="secret-message">${escapeHtml(description)}</p>
<div class="form-group">
<label class="form-label">Master password</label>
<input type="password" class="form-input master-password"
autocomplete="off" spellcheck="false" placeholder="Password…" />
</div>
`;
if (isNew) {
formHtml += `
<div class="form-group">
<label class="form-label">Confirm password</label>
<input type="password" class="form-input master-confirm"
autocomplete="off" spellcheck="false" placeholder="Confirm…" />
</div>
<p class="form-hint secret-error" style="display:none;"></p>
`;
}
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<boolean>} 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 = `
<h2 class="modal-title">Unknown Host Key</h2>
<button class="modal-close" aria-label="Close">&times;</button>
`;
modal.appendChild(header);
const body = createElement('div', 'modal-body');
body.innerHTML = `
<p class="secret-message">
The authenticity of host <strong>${escapeHtml(host)}</strong> cannot be established.
</p>
<div class="hostkey-details">
<div class="hostkey-row">
<span class="hostkey-label">Key type</span>
<code class="hostkey-value">${escapeHtml(keyType)}</code>
</div>
<div class="hostkey-row">
<span class="hostkey-label">Fingerprint</span>
<code class="hostkey-value">${escapeHtml(fingerprint)}</code>
</div>
</div>
<p class="secret-message">
Are you sure you want to continue connecting?
</p>
`;
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;
}
}
}
+335
View File
@@ -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 = `
<p class="sidebar-empty-text">No profiles found</p>
`;
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<Object>} 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 = `
<span class="sidebar-section-chevron ${isCollapsed ? 'collapsed' : ''}">${isCollapsed ? '▸' : '▾'}</span>
<span class="sidebar-section-title">${escapeHtml(title)}</span>
<span class="sidebar-section-count">${profiles.length}</span>
`;
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<Object>}
* @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);
}
}
+225
View File
@@ -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<Object>} */
this.profiles = [];
/** @type {Map<string, Object>} 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<string, Set<Function>>} 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<Object>} 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<Object>}
*/
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);
}
}
+546
View File
@@ -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;
});
}
}
+286
View File
@@ -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<string, Object>}
*/
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',
};
}
}
+229
View File
@@ -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<Element>}
*/
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 = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;',
};
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';
}
}
+41
View File
@@ -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
)
+106
View File
@@ -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=
+117
View File
@@ -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")
}
+100
View File
@@ -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)
}
+87
View File
@@ -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
}
+98
View File
@@ -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
}
+102
View File
@@ -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
}
+195
View File
@@ -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"`
}
+107
View File
@@ -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
}
}
+239
View File
@@ -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...))
}
}
+66
View File
@@ -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
}
+37
View File
@@ -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
}
+345
View File
@@ -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
}
+310
View File
@@ -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...))
}
}
@@ -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'));
+10
View File
@@ -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
+661
View File
@@ -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(&current); 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)
}
+352
View File
@@ -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...))
}
}
+279
View File
@@ -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
}
+19
View File
@@ -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"
}
}