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