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:
+38
@@ -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
|
||||
@@ -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"
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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.');
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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">×</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">×</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">×</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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
};
|
||||
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';
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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...))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'));
|
||||
@@ -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
|
||||
@@ -0,0 +1,661 @@
|
||||
// Package storage provides a SQLite-backed persistence layer for profiles,
|
||||
// known hosts, audit entries, and application preferences.
|
||||
package storage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
|
||||
"github.com/copilot-ssh-client/app/internal/models"
|
||||
"github.com/copilot-ssh-client/app/internal/storage/migrations"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Store wraps a SQLite database and exposes typed CRUD methods for
|
||||
// all application entities.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewStore opens (or creates) the SQLite database at dbPath, applies any
|
||||
// pending migrations, and returns an initialised Store.
|
||||
func NewStore(dbPath string) (*Store, error) {
|
||||
db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_busy_timeout=5000")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: failed to open database: %w", err)
|
||||
}
|
||||
if err := db.Ping(); err != nil {
|
||||
return nil, fmt.Errorf("storage: database not reachable: %w", err)
|
||||
}
|
||||
|
||||
s := &Store{db: db}
|
||||
if err := s.migrate(); err != nil {
|
||||
return nil, fmt.Errorf("storage: migration failed: %w", err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Close shuts down the database connection.
|
||||
func (s *Store) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Migrations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// migrate reads embedded SQL files and applies them in order.
|
||||
func (s *Store) migrate() error {
|
||||
// Ensure the schema_version table exists before querying it.
|
||||
_, err := s.db.Exec(`CREATE TABLE IF NOT EXISTS schema_version (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at TEXT NOT NULL
|
||||
)`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create schema_version table: %w", err)
|
||||
}
|
||||
|
||||
var current int
|
||||
row := s.db.QueryRow("SELECT COALESCE(MAX(version), 0) FROM schema_version")
|
||||
if err := row.Scan(¤t); err != nil {
|
||||
return fmt.Errorf("failed to read schema version: %w", err)
|
||||
}
|
||||
|
||||
entries, err := migrations.MigrationFS.ReadDir(".")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read migration files: %w", err)
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := entry.Name()
|
||||
if !strings.HasSuffix(name, ".sql") {
|
||||
continue
|
||||
}
|
||||
// Extract version number from filename prefix (e.g. "001_initial.sql" → 1).
|
||||
var ver int
|
||||
if _, err := fmt.Sscanf(name, "%03d_", &ver); err != nil {
|
||||
continue
|
||||
}
|
||||
if ver <= current {
|
||||
continue
|
||||
}
|
||||
|
||||
data, err := migrations.MigrationFS.ReadFile(name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read migration %s: %w", name, err)
|
||||
}
|
||||
if _, err := s.db.Exec(string(data)); err != nil {
|
||||
return fmt.Errorf("failed to apply migration %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Profile operations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// CreateProfile inserts a new profile into the database.
|
||||
func (s *Store) CreateProfile(p *models.Profile) error {
|
||||
tagsJSON, _ := json.Marshal(p.Tags)
|
||||
sshJSON, _ := json.Marshal(p.SSH)
|
||||
telnetJSON, _ := json.Marshal(p.Telnet)
|
||||
rawJSON, _ := json.Marshal(p.RAW)
|
||||
|
||||
_, err := s.db.Exec(`INSERT INTO profiles (
|
||||
id, display_name, description, protocol, host, port, username,
|
||||
tags, folder, environment, notes, favourite, last_connected,
|
||||
connect_timeout, read_timeout,
|
||||
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
|
||||
ssh_config, telnet_config, raw_config,
|
||||
created_at, updated_at
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
p.ID, p.DisplayName, p.Description, string(p.Protocol),
|
||||
p.Host, p.Port, p.Username,
|
||||
string(tagsJSON), p.Folder, p.Environment, p.Notes,
|
||||
boolToInt(p.Favourite), timeToStr(p.LastConnected),
|
||||
p.ConnectTimeout, p.ReadTimeout,
|
||||
p.TerminalFontFamily, p.TerminalFontSize, p.TerminalScrollback, p.TerminalCursorStyle,
|
||||
nullableJSON(p.SSH, sshJSON), nullableJSON(p.Telnet, telnetJSON), nullableJSON(p.RAW, rawJSON),
|
||||
p.CreatedAt.Format(time.RFC3339), p.UpdatedAt.Format(time.RFC3339),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage: failed to create profile: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetProfile retrieves a single profile by ID.
|
||||
func (s *Store) GetProfile(id string) (*models.Profile, error) {
|
||||
row := s.db.QueryRow(`SELECT
|
||||
id, display_name, description, protocol, host, port, username,
|
||||
tags, folder, environment, notes, favourite, last_connected,
|
||||
connect_timeout, read_timeout,
|
||||
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
|
||||
ssh_config, telnet_config, raw_config,
|
||||
created_at, updated_at
|
||||
FROM profiles WHERE id = ?`, id)
|
||||
|
||||
p, err := scanProfile(row)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("storage: profile %q not found", id)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: failed to get profile: %w", err)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// UpdateProfile overwrites an existing profile.
|
||||
func (s *Store) UpdateProfile(p *models.Profile) error {
|
||||
tagsJSON, _ := json.Marshal(p.Tags)
|
||||
sshJSON, _ := json.Marshal(p.SSH)
|
||||
telnetJSON, _ := json.Marshal(p.Telnet)
|
||||
rawJSON, _ := json.Marshal(p.RAW)
|
||||
|
||||
res, err := s.db.Exec(`UPDATE profiles SET
|
||||
display_name=?, description=?, protocol=?, host=?, port=?, username=?,
|
||||
tags=?, folder=?, environment=?, notes=?, favourite=?, last_connected=?,
|
||||
connect_timeout=?, read_timeout=?,
|
||||
terminal_font_family=?, terminal_font_size=?, terminal_scrollback=?, terminal_cursor_style=?,
|
||||
ssh_config=?, telnet_config=?, raw_config=?,
|
||||
updated_at=?
|
||||
WHERE id=?`,
|
||||
p.DisplayName, p.Description, string(p.Protocol),
|
||||
p.Host, p.Port, p.Username,
|
||||
string(tagsJSON), p.Folder, p.Environment, p.Notes,
|
||||
boolToInt(p.Favourite), timeToStr(p.LastConnected),
|
||||
p.ConnectTimeout, p.ReadTimeout,
|
||||
p.TerminalFontFamily, p.TerminalFontSize, p.TerminalScrollback, p.TerminalCursorStyle,
|
||||
nullableJSON(p.SSH, sshJSON), nullableJSON(p.Telnet, telnetJSON), nullableJSON(p.RAW, rawJSON),
|
||||
p.UpdatedAt.Format(time.RFC3339),
|
||||
p.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage: failed to update profile: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("storage: profile %q not found", p.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteProfile removes a profile by ID.
|
||||
func (s *Store) DeleteProfile(id string) error {
|
||||
res, err := s.db.Exec("DELETE FROM profiles WHERE id = ?", id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage: failed to delete profile: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("storage: profile %q not found", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListProfiles returns all profiles ordered by display name.
|
||||
func (s *Store) ListProfiles() ([]*models.Profile, error) {
|
||||
rows, err := s.db.Query(`SELECT
|
||||
id, display_name, description, protocol, host, port, username,
|
||||
tags, folder, environment, notes, favourite, last_connected,
|
||||
connect_timeout, read_timeout,
|
||||
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
|
||||
ssh_config, telnet_config, raw_config,
|
||||
created_at, updated_at
|
||||
FROM profiles ORDER BY display_name`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: failed to list profiles: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanProfiles(rows)
|
||||
}
|
||||
|
||||
// SearchProfiles returns profiles whose display name, host, or tags contain
|
||||
// the query string (case-insensitive).
|
||||
func (s *Store) SearchProfiles(query string) ([]*models.Profile, error) {
|
||||
q := "%" + query + "%"
|
||||
rows, err := s.db.Query(`SELECT
|
||||
id, display_name, description, protocol, host, port, username,
|
||||
tags, folder, environment, notes, favourite, last_connected,
|
||||
connect_timeout, read_timeout,
|
||||
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
|
||||
ssh_config, telnet_config, raw_config,
|
||||
created_at, updated_at
|
||||
FROM profiles
|
||||
WHERE display_name LIKE ? OR host LIKE ? OR tags LIKE ?
|
||||
ORDER BY display_name`, q, q, q)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: failed to search profiles: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanProfiles(rows)
|
||||
}
|
||||
|
||||
// ListProfilesByProtocol returns profiles filtered by protocol.
|
||||
func (s *Store) ListProfilesByProtocol(protocol string) ([]*models.Profile, error) {
|
||||
rows, err := s.db.Query(`SELECT
|
||||
id, display_name, description, protocol, host, port, username,
|
||||
tags, folder, environment, notes, favourite, last_connected,
|
||||
connect_timeout, read_timeout,
|
||||
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
|
||||
ssh_config, telnet_config, raw_config,
|
||||
created_at, updated_at
|
||||
FROM profiles WHERE protocol = ? ORDER BY display_name`, protocol)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: failed to list profiles by protocol: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanProfiles(rows)
|
||||
}
|
||||
|
||||
// ListProfilesByTag returns profiles that contain the specified tag.
|
||||
func (s *Store) ListProfilesByTag(tag string) ([]*models.Profile, error) {
|
||||
q := `%"` + tag + `"%`
|
||||
rows, err := s.db.Query(`SELECT
|
||||
id, display_name, description, protocol, host, port, username,
|
||||
tags, folder, environment, notes, favourite, last_connected,
|
||||
connect_timeout, read_timeout,
|
||||
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
|
||||
ssh_config, telnet_config, raw_config,
|
||||
created_at, updated_at
|
||||
FROM profiles WHERE tags LIKE ? ORDER BY display_name`, q)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: failed to list profiles by tag: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanProfiles(rows)
|
||||
}
|
||||
|
||||
// ListProfilesByFolder returns profiles in the specified folder.
|
||||
func (s *Store) ListProfilesByFolder(folder string) ([]*models.Profile, error) {
|
||||
rows, err := s.db.Query(`SELECT
|
||||
id, display_name, description, protocol, host, port, username,
|
||||
tags, folder, environment, notes, favourite, last_connected,
|
||||
connect_timeout, read_timeout,
|
||||
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
|
||||
ssh_config, telnet_config, raw_config,
|
||||
created_at, updated_at
|
||||
FROM profiles WHERE folder = ? ORDER BY display_name`, folder)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: failed to list profiles by folder: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanProfiles(rows)
|
||||
}
|
||||
|
||||
// ListFavourites returns all profiles marked as favourites.
|
||||
func (s *Store) ListFavourites() ([]*models.Profile, error) {
|
||||
rows, err := s.db.Query(`SELECT
|
||||
id, display_name, description, protocol, host, port, username,
|
||||
tags, folder, environment, notes, favourite, last_connected,
|
||||
connect_timeout, read_timeout,
|
||||
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
|
||||
ssh_config, telnet_config, raw_config,
|
||||
created_at, updated_at
|
||||
FROM profiles WHERE favourite = 1 ORDER BY display_name`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: failed to list favourites: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanProfiles(rows)
|
||||
}
|
||||
|
||||
// SetFavourite updates the favourite flag on a profile.
|
||||
func (s *Store) SetFavourite(id string, favourite bool) error {
|
||||
res, err := s.db.Exec("UPDATE profiles SET favourite = ?, updated_at = ? WHERE id = ?",
|
||||
boolToInt(favourite), time.Now().Format(time.RFC3339), id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage: failed to update favourite: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("storage: profile %q not found", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Known hosts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// GetKnownHost retrieves a known host entry by host, port, and key type.
|
||||
func (s *Store) GetKnownHost(host string, port int) (*models.KnownHost, error) {
|
||||
row := s.db.QueryRow(`SELECT id, host, port, key_type, fingerprint, public_key, first_seen, last_seen, trusted
|
||||
FROM known_hosts WHERE host = ? AND port = ?`, host, port)
|
||||
|
||||
kh := &models.KnownHost{}
|
||||
var firstSeen, lastSeen string
|
||||
var trusted int
|
||||
if err := row.Scan(&kh.ID, &kh.Host, &kh.Port, &kh.KeyType, &kh.Fingerprint,
|
||||
&kh.PublicKey, &firstSeen, &lastSeen, &trusted); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("storage: failed to get known host: %w", err)
|
||||
}
|
||||
kh.FirstSeen, _ = time.Parse(time.RFC3339, firstSeen)
|
||||
kh.LastSeen, _ = time.Parse(time.RFC3339, lastSeen)
|
||||
kh.Trusted = trusted == 1
|
||||
return kh, nil
|
||||
}
|
||||
|
||||
// SaveKnownHost inserts or updates a known host entry.
|
||||
func (s *Store) SaveKnownHost(kh *models.KnownHost) error {
|
||||
if kh.ID == "" {
|
||||
kh.ID = uuid.New().String()
|
||||
}
|
||||
_, err := s.db.Exec(`INSERT INTO known_hosts (id, host, port, key_type, fingerprint, public_key, first_seen, last_seen, trusted)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(host, port, key_type) DO UPDATE SET
|
||||
fingerprint=excluded.fingerprint, public_key=excluded.public_key,
|
||||
last_seen=excluded.last_seen, trusted=excluded.trusted`,
|
||||
kh.ID, kh.Host, kh.Port, kh.KeyType, kh.Fingerprint, kh.PublicKey,
|
||||
kh.FirstSeen.Format(time.RFC3339), kh.LastSeen.Format(time.RFC3339),
|
||||
boolToInt(kh.Trusted))
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage: failed to save known host: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListKnownHosts returns all stored known host entries.
|
||||
func (s *Store) ListKnownHosts() ([]*models.KnownHost, error) {
|
||||
rows, err := s.db.Query(`SELECT id, host, port, key_type, fingerprint, public_key, first_seen, last_seen, trusted
|
||||
FROM known_hosts ORDER BY host, port`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: failed to list known hosts: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var hosts []*models.KnownHost
|
||||
for rows.Next() {
|
||||
kh := &models.KnownHost{}
|
||||
var firstSeen, lastSeen string
|
||||
var trusted int
|
||||
if err := rows.Scan(&kh.ID, &kh.Host, &kh.Port, &kh.KeyType, &kh.Fingerprint,
|
||||
&kh.PublicKey, &firstSeen, &lastSeen, &trusted); err != nil {
|
||||
return nil, fmt.Errorf("storage: failed to scan known host: %w", err)
|
||||
}
|
||||
kh.FirstSeen, _ = time.Parse(time.RFC3339, firstSeen)
|
||||
kh.LastSeen, _ = time.Parse(time.RFC3339, lastSeen)
|
||||
kh.Trusted = trusted == 1
|
||||
hosts = append(hosts, kh)
|
||||
}
|
||||
return hosts, rows.Err()
|
||||
}
|
||||
|
||||
// DeleteKnownHost removes a known host entry by ID.
|
||||
func (s *Store) DeleteKnownHost(id string) error {
|
||||
res, err := s.db.Exec("DELETE FROM known_hosts WHERE id = ?", id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage: failed to delete known host: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("storage: known host %q not found", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Audit log
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// AddAuditEntry inserts an audit log entry.
|
||||
func (s *Store) AddAuditEntry(entry *models.AuditEntry) error {
|
||||
_, err := s.db.Exec(`INSERT INTO audit_log (id, timestamp, action, profile_id, session_id, details)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
entry.ID, entry.Timestamp.Format(time.RFC3339), entry.Action,
|
||||
entry.ProfileID, entry.SessionID, entry.Details)
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage: failed to add audit entry: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListAuditEntries returns the most recent audit entries, limited to count.
|
||||
func (s *Store) ListAuditEntries(limit int) ([]*models.AuditEntry, error) {
|
||||
rows, err := s.db.Query(`SELECT id, timestamp, action, profile_id, session_id, details
|
||||
FROM audit_log ORDER BY timestamp DESC LIMIT ?`, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: failed to list audit entries: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var entries []*models.AuditEntry
|
||||
for rows.Next() {
|
||||
e := &models.AuditEntry{}
|
||||
var ts string
|
||||
if err := rows.Scan(&e.ID, &ts, &e.Action, &e.ProfileID, &e.SessionID, &e.Details); err != nil {
|
||||
return nil, fmt.Errorf("storage: failed to scan audit entry: %w", err)
|
||||
}
|
||||
e.Timestamp, _ = time.Parse(time.RFC3339, ts)
|
||||
entries = append(entries, e)
|
||||
}
|
||||
return entries, rows.Err()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Preferences (key-value)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// GetPreferences reads all preference keys and deserialises them into an
|
||||
// AppPreferences struct.
|
||||
func (s *Store) GetPreferences() (*models.AppPreferences, error) {
|
||||
rows, err := s.db.Query("SELECT key, value FROM preferences")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: failed to read preferences: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
kv := make(map[string]string)
|
||||
for rows.Next() {
|
||||
var k, v string
|
||||
if err := rows.Scan(&k, &v); err != nil {
|
||||
return nil, fmt.Errorf("storage: failed to scan preference: %w", err)
|
||||
}
|
||||
kv[k] = v
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If no preferences stored yet, return nil to signal defaults should be used.
|
||||
if len(kv) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
data, err := json.Marshal(kv)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: failed to marshal preference map: %w", err)
|
||||
}
|
||||
|
||||
prefs := &models.AppPreferences{}
|
||||
if err := json.Unmarshal(data, prefs); err != nil {
|
||||
return nil, fmt.Errorf("storage: failed to unmarshal preferences: %w", err)
|
||||
}
|
||||
return prefs, nil
|
||||
}
|
||||
|
||||
// SavePreferences serialises the AppPreferences into key-value pairs and
|
||||
// persists them to the preferences table.
|
||||
func (s *Store) SavePreferences(prefs *models.AppPreferences) error {
|
||||
data, err := json.Marshal(prefs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage: failed to marshal preferences: %w", err)
|
||||
}
|
||||
|
||||
var kv map[string]interface{}
|
||||
if err := json.Unmarshal(data, &kv); err != nil {
|
||||
return fmt.Errorf("storage: failed to unmarshal preferences map: %w", err)
|
||||
}
|
||||
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage: failed to begin transaction: %w", err)
|
||||
}
|
||||
|
||||
for k, v := range kv {
|
||||
valStr := fmt.Sprintf("%v", v)
|
||||
if _, err := tx.Exec(`INSERT INTO preferences (key, value) VALUES (?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`, k, valStr); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("storage: failed to save preference %q: %w", k, err)
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// scanProfile reads a single profile row from the given scanner.
|
||||
func scanProfile(row *sql.Row) (*models.Profile, error) {
|
||||
p := &models.Profile{}
|
||||
var (
|
||||
protocol, tagsJSON string
|
||||
sshJSON, telnetJSON, rawJSON sql.NullString
|
||||
lastConn sql.NullString
|
||||
favourite int
|
||||
createdAt, updatedAt string
|
||||
)
|
||||
|
||||
err := row.Scan(
|
||||
&p.ID, &p.DisplayName, &p.Description, &protocol,
|
||||
&p.Host, &p.Port, &p.Username,
|
||||
&tagsJSON, &p.Folder, &p.Environment, &p.Notes,
|
||||
&favourite, &lastConn,
|
||||
&p.ConnectTimeout, &p.ReadTimeout,
|
||||
&p.TerminalFontFamily, &p.TerminalFontSize, &p.TerminalScrollback, &p.TerminalCursorStyle,
|
||||
&sshJSON, &telnetJSON, &rawJSON,
|
||||
&createdAt, &updatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
p.Protocol = models.Protocol(protocol)
|
||||
p.Favourite = favourite == 1
|
||||
p.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||
p.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
|
||||
if lastConn.Valid && lastConn.String != "" {
|
||||
t, err := time.Parse(time.RFC3339, lastConn.String)
|
||||
if err == nil {
|
||||
p.LastConnected = &t
|
||||
}
|
||||
}
|
||||
|
||||
_ = json.Unmarshal([]byte(tagsJSON), &p.Tags)
|
||||
if p.Tags == nil {
|
||||
p.Tags = []string{}
|
||||
}
|
||||
|
||||
if sshJSON.Valid && sshJSON.String != "" {
|
||||
p.SSH = &models.SSHConfig{}
|
||||
_ = json.Unmarshal([]byte(sshJSON.String), p.SSH)
|
||||
}
|
||||
if telnetJSON.Valid && telnetJSON.String != "" {
|
||||
p.Telnet = &models.TelnetConfig{}
|
||||
_ = json.Unmarshal([]byte(telnetJSON.String), p.Telnet)
|
||||
}
|
||||
if rawJSON.Valid && rawJSON.String != "" {
|
||||
p.RAW = &models.RAWConfig{}
|
||||
_ = json.Unmarshal([]byte(rawJSON.String), p.RAW)
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// scanProfiles iterates over rows and returns a slice of profiles.
|
||||
func scanProfiles(rows *sql.Rows) ([]*models.Profile, error) {
|
||||
var result []*models.Profile
|
||||
for rows.Next() {
|
||||
p := &models.Profile{}
|
||||
var (
|
||||
protocol, tagsJSON string
|
||||
sshJSON, telnetJSON, rawJSON sql.NullString
|
||||
lastConn sql.NullString
|
||||
favourite int
|
||||
createdAt, updatedAt string
|
||||
)
|
||||
|
||||
err := rows.Scan(
|
||||
&p.ID, &p.DisplayName, &p.Description, &protocol,
|
||||
&p.Host, &p.Port, &p.Username,
|
||||
&tagsJSON, &p.Folder, &p.Environment, &p.Notes,
|
||||
&favourite, &lastConn,
|
||||
&p.ConnectTimeout, &p.ReadTimeout,
|
||||
&p.TerminalFontFamily, &p.TerminalFontSize, &p.TerminalScrollback, &p.TerminalCursorStyle,
|
||||
&sshJSON, &telnetJSON, &rawJSON,
|
||||
&createdAt, &updatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: failed to scan profile row: %w", err)
|
||||
}
|
||||
|
||||
p.Protocol = models.Protocol(protocol)
|
||||
p.Favourite = favourite == 1
|
||||
p.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||
p.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
|
||||
if lastConn.Valid && lastConn.String != "" {
|
||||
t, err := time.Parse(time.RFC3339, lastConn.String)
|
||||
if err == nil {
|
||||
p.LastConnected = &t
|
||||
}
|
||||
}
|
||||
|
||||
_ = json.Unmarshal([]byte(tagsJSON), &p.Tags)
|
||||
if p.Tags == nil {
|
||||
p.Tags = []string{}
|
||||
}
|
||||
|
||||
if sshJSON.Valid && sshJSON.String != "" {
|
||||
p.SSH = &models.SSHConfig{}
|
||||
_ = json.Unmarshal([]byte(sshJSON.String), p.SSH)
|
||||
}
|
||||
if telnetJSON.Valid && telnetJSON.String != "" {
|
||||
p.Telnet = &models.TelnetConfig{}
|
||||
_ = json.Unmarshal([]byte(telnetJSON.String), p.Telnet)
|
||||
}
|
||||
if rawJSON.Valid && rawJSON.String != "" {
|
||||
p.RAW = &models.RAWConfig{}
|
||||
_ = json.Unmarshal([]byte(rawJSON.String), p.RAW)
|
||||
}
|
||||
|
||||
result = append(result, p)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func boolToInt(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func timeToStr(t *time.Time) interface{} {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
return t.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
// nullableJSON returns nil when the source object pointer is nil,
|
||||
// otherwise the marshalled JSON bytes as a string.
|
||||
func nullableJSON(ptr interface{}, data []byte) interface{} {
|
||||
if ptr == nil || string(data) == "null" {
|
||||
return nil
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
@@ -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...))
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user