diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ada0f65..efe0ee0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,36 +52,3 @@ jobs: - name: Rust build run: cargo build --manifest-path src-tauri/Cargo.toml - - # Live end-to-end test: spins up a real sshd container and exercises SSH exec, - # agentless health collection and runbook execution. GitHub runners have Docker. - integration: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Install Tauri system dependencies - run: | - sudo apt-get update - sudo apt-get install -y \ - libwebkit2gtk-4.1-dev \ - libappindicator3-dev \ - librsvg2-dev \ - patchelf \ - libssl-dev \ - build-essential - - - name: Setup Rust - uses: dtolnay/rust-toolchain@stable - - - name: Cache cargo - uses: actions/cache@v4 - with: - path: | - ~/.cargo/registry - ~/.cargo/git - src-tauri/target - key: ${{ runner.os }}-cargo-${{ hashFiles('src-tauri/Cargo.toml') }} - - - name: Run SSH integration test - run: cargo test --manifest-path src-tauri/Cargo.toml --test ssh_integration -- --ignored --nocapture diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..d213617 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,55 @@ +name: Release + +on: + push: + tags: + - "v*.*.*" + +permissions: + contents: write + +jobs: + linux: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Tauri system dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + libwebkit2gtk-4.1-dev \ + libappindicator3-dev \ + librsvg2-dev \ + patchelf \ + libssl-dev \ + build-essential \ + rpm + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install JS dependencies + run: npm ci || npm install + + - name: Build Tauri bundles + run: npm run app:build + + - name: Normalize AppImage name + run: | + appimage="$(find src-tauri/target/release/bundle/appimage -name '*.AppImage' | head -n1)" + cp "$appimage" RemoteOpsX-x86_64.AppImage + + - name: Upload release assets + uses: softprops/action-gh-release@v2 + with: + files: | + RemoteOpsX-x86_64.AppImage + src-tauri/target/release/bundle/deb/*.deb + src-tauri/target/release/bundle/rpm/*.rpm diff --git a/.gitignore b/.gitignore index fc3f73b..4629d37 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,7 @@ src-tauri/gen # OS .DS_Store + +graphify-out +.superpowers/ +.worktrees/ diff --git a/README.md b/README.md index bfdc533..5c22ca4 100644 --- a/README.md +++ b/README.md @@ -3,15 +3,37 @@ **A unified Linux remote-operations workspace — not just another terminal.** RemoteOpsX is *MobaXterm + Remmina + a Netdata-lite + a server runbook engine*, -built for Linux operators. It combines remote access (SSH / SFTP / RDP / VNC), -**agentless** live server-health monitoring, systemd & Docker diagnostics, log -tooling, SSH tunnels and **executable runbooks** into one keyboard-friendly -desktop app. +built for Linux operators. It combines remote access (SSH / SFTP / FTP / RDP / VNC), +**agentless** live server-health monitoring, systemd diagnostics, log tooling, +SSH tunnels and **executable runbooks** into one keyboard-friendly desktop app. > Working name: **RemoteOpsX**. Linux-first (Arch, Ubuntu, Debian, Fedora). --- +## Project status + +RemoteOpsX is at a **validated MVP** stage: the core server manager, SSH/SFTP/FTP, +RDP/VNC launchers, live health, logs, services, runbooks, tunnels and persisted +settings are implemented. The remaining items are production hardening work such +as native SSH transport, known-hosts management, app lock, embedded desktop +protocols, CI/release signing and live integration fixtures. + +Current automated handoff checks: + +```bash +npm test +npm run build +cargo fmt --manifest-path src-tauri/Cargo.toml -- --check +cargo test --manifest-path src-tauri/Cargo.toml +git diff --check +``` + +See [TODO.md](TODO.md) for the roadmap and `docs/superpowers/` for specs, +implementation plans and handoff notes. + +--- + ## Why it's different from a normal terminal A terminal gives you a shell. RemoteOpsX gives you an **operations cockpit**: @@ -19,14 +41,14 @@ A terminal gives you a shell. RemoteOpsX gives you an **operations cockpit**: | Plain terminal | RemoteOpsX | | --- | --- | | One SSH shell | SSH + SFTP + RDP + VNC + tunnels, tabbed | -| You type `top`, `df`, `free`… | **Live agentless health panel** auto-collects CPU/RAM/disk/net/load/uptime, top processes, ports, failed services and Docker — no agent installed on the server | +| You type `top`, `df`, `free`… | **Live agentless health panel** auto-collects CPU/RAM/disk/net/load/uptime, top processes, ports and failed services — no agent installed on the server | | You remember the diagnosis steps | **Runbooks**: versioned, step-by-step, confirmation-gated, with captured output and history | | Secrets in `~/.ssh/config` or your head | Secrets in the **OS keyring**, never in the database | | You `grep` logs by hand | Logs panel + one-click **diagnostic bundle** | -The health collector reads `/proc`, `/sys`, `df`, `ss`, `systemctl` and `docker` -over a **separate SSH exec channel** (never your interactive shell), so the -metrics never interfere with what you're typing. +The health collector reads `/proc`, `/sys`, `df`, `ss` and `systemctl` over a +**separate SSH exec channel** (never your interactive shell), so the metrics +never interfere with what you're typing. --- @@ -37,25 +59,27 @@ metrics never interfere with what you're typing. Persisted in SQLite; searchable, grouped sidebar. - **SSH Terminal** — xterm.js terminals backed by server-side PTYs running the system `ssh` client. Multiple tabs, reconnect, resize, copy/paste, non-blocking. -- **SFTP / File Browser** — list / upload / download / delete / rename remote files. +- **SFTP / FTP File Browser** — list / upload / download / delete / rename remote files. + SFTP is preferred; legacy FTP is supported through curl and is explicitly + marked as plaintext in the UI. FTP profiles use password authentication. - **RDP** — launches `xfreerdp` with the profile (fullscreen / resolution). - **VNC** — launches an installed VNC viewer (tigervnc, remmina, …). - **Live Health Panel** — agentless metrics every 2–5s (configurable): CPU, RAM, swap, disks, load, uptime, network rate, top CPU/MEM processes, listening - ports, failed services, Docker containers + stats. Threshold warnings. + ports and failed services. Threshold warnings. - **Services Panel** — list failed systemd units, inspect status/logs, start/stop/restart with **confirmation + exact-command preview**. -- **Docker Panel** — containers, status, resource usage, logs, start/stop/restart, - `docker compose ps`. - **Logs Panel** — tail remote files, read `journalctl`, filter, save locally, and build a one-shot **diagnostic bundle**. - **Runbooks** — YAML-defined, executed step-by-step over SSH with per-step - output, confirmation gates and persisted run history. Seven built-ins ship + output, confirmation gates and persisted run history. Six built-ins ship by default (Linux Health Check, Diagnose High Disk Usage, Diagnose Failed - Service, Restart Service Safely, Docker Container Diagnosis, VoIP Server - Check, SMPP Gateway Check). + Service, Restart Service Safely, VoIP Server Check, SMPP Gateway Check). - **SSH Tunnels** — local (`-L`), remote (`-R`) and dynamic SOCKS (`-D`) forwards, tracked and stoppable, profiles persisted. +- **Application Settings** — persisted theme, default protocol ports, health + refresh interval, retention, app-lock timeout placeholder, transfer conflict + behavior and desktop integration flags. --- @@ -64,38 +88,72 @@ metrics never interfere with what you're typing. ``` src/ React + TypeScript frontend api.ts typed wrappers over Tauri commands + errors.ts normalized frontend error contract + settings.ts/settingsStore typed settings defaults, validation and store store.ts Zustand global UI state types.ts shared types (mirror Rust models) components/ ServerSidebar / ServerForm TabBar / TabContent TerminalTab (xterm.js) - HealthPanel / ServicesPanel / DockerPanel + HealthPanel / ServicesPanel RunbookRunner / RunbookLauncher SftpPanel / RemoteDesktopTab / LogsPanel - TunnelManager / RightPanel / BottomPanel / NotesSnippetsPanel + TunnelManager / SettingsModal / ToastStack + RightPanel / BottomPanel / NotesSnippetsPanel src-tauri/src/ Rust backend (Tauri v2 commands) lib.rs command surface + AppState wiring database.rs SQLite schema + queries + error.rs stable DomainError IPC payload + settings.rs typed settings contract + validation vault.rs OS keyring (Secret Service) — secrets only here ssh_manager.rs ssh argv builder + one-shot remote exec pty_manager.rs interactive PTY terminals (system ssh) health_collector.rs agentless metric probe + parsing + rate deltas runbook_runner.rs YAML runbook engine + built-ins sftp_manager.rs list/upload/download/delete/rename (ssh/scp) + ftp_manager.rs legacy plaintext FTP operations (curl) rdp_adapter.rs xfreerdp launcher (swappable for embedded later) vnc_adapter.rs VNC viewer launcher tunnel_manager.rs ssh -L/-R/-D process registry models.rs serde models ``` -The SSH/SFTP/RDP/VNC/tunnel layers are intentionally thin abstractions over the -system OpenSSH/FreeRDP binaries so the MVP is robust today, while leaving clean +SSH uses its configured profile port. FTP, RDP and VNC have independent +per-profile ports with protocol-standard defaults (21, 3389 and 5900). + +The SSH/SFTP/FTP/RDP/VNC/tunnel layers are intentionally thin abstractions over the +system OpenSSH/curl/FreeRDP binaries so the MVP is robust today, while leaving clean seams to swap in native transports later. --- +## Settings and local data + +RemoteOpsX stores operational metadata in SQLite at Tauri's per-user app data +directory, in `remoteopsx.db` (for example, Linux typically resolves this under +`~/.local/share/dev.remoteopsx.app/`). Secrets stay in the OS keyring and are +referenced from SQLite by `secret_ref`. + +The `app_settings` table is a singleton JSON row with schema version `1`. +Defaults and validation ranges: + +| Setting | Default | Valid range / values | +| --- | --- | --- | +| Theme | `system` | `system`, `dark`, `light` | +| Default ports | SSH `22`, FTP `21`, RDP `3389`, VNC `5900` | `1..=65535` | +| Health refresh | `3000 ms` | `1000..=60000 ms` | +| History retention | `90 days` | `1..=3650 days` | +| App-lock timeout | `15 minutes` | `1..=1440 minutes` | +| Transfer conflict policy | `ask` | `ask`, `overwrite`, `rename`, `skip` | +| Desktop clipboard/audio/notifications | enabled | boolean | + +The settings UI is available from the top bar or command palette. Changes are +optimistic in the UI and roll back if backend validation or persistence fails. + +--- + ## Installation requirements RemoteOpsX is a Tauri app. To **run it**, the host needs the system tools it @@ -104,6 +162,7 @@ drives: | Tool | Used for | Required? | | --- | --- | --- | | `ssh`, `scp` (OpenSSH client) | SSH, SFTP, health, runbooks, tunnels | **Yes** | +| `curl` | Legacy FTP browser | Only if you use FTP | | `sshpass` | password-auth (non-interactive) | Only if you use password auth | | `xfreerdp` / `xfreerdp3` | RDP | Only for RDP | | a VNC viewer (`tigervnc`, `remmina`, …) | VNC | Only for VNC | @@ -146,9 +205,11 @@ Useful scripts: ```bash npm run dev # Vite dev server only (web UI, no Tauri shell) +npm test # frontend regression tests npm run build # type-check + build the frontend npm run app:dev # full Tauri desktop app, hot-reload npm run app:build # produce AppImage / .deb / .rpm bundles +npm run app:build:arch # Arch workaround for current linuxdeploy/gdk-pixbuf incompatibilities ``` Backend-only compile check: @@ -157,25 +218,58 @@ Backend-only compile check: cargo check --manifest-path src-tauri/Cargo.toml ``` +Full local validation: + +```bash +npm test +npm run build +cargo fmt --manifest-path src-tauri/Cargo.toml -- --check +cargo test --manifest-path src-tauri/Cargo.toml +git diff --check +``` + +--- + +## Specs, plans and graph + +- Completion/hardening spec: `docs/superpowers/specs/2026-06-20-project-hardening-design.md` +- Production roadmap spec: `docs/superpowers/specs/2026-06-21-production-roadmap-design.md` +- Implementation plans: `docs/superpowers/plans/` +- Graphify report and interactive graph: `graphify-out/GRAPH_REPORT.md` and + `graphify-out/graph.html` + +Regenerate the local code graph after major source changes: + +```bash +graphify update . +graphify cluster-only . +``` + --- ## Packaging `npm run app:build` produces, on Linux: **AppImage**, **.deb** and **.rpm** (configured in `src-tauri/tauri.conf.json`). A pacman package can be added later. +On current Arch systems, use `npm run app:build:arch`; it disables linuxdeploy's +incompatible legacy strip step and supplies the empty loader directory expected +by its GTK plugin. Regular Ubuntu/Debian and CI builds should use `app:build`. --- ## Security model (and MVP limitations) **What we do well today** -- Passwords / key passphrases live in the **OS keyring (Secret Service)**, keyed +- Passwords live in the **OS keyring (Secret Service)**, keyed per server. SQLite stores only a `secret_ref`, never the secret. +- Encrypted private keys use the SSH agent or the interactive SSH prompt; the + application does not persist key passphrases. - Passwords are fed to `ssh`/`scp` via `sshpass -e` (environment), never on the process command line, and never logged. +- The production WebView uses a restrictive Content Security Policy. - Private key **paths** are stored; key **contents** are not. -- Destructive actions (service restart/stop, container stop, confirmation-gated - runbook steps) require explicit confirmation and show the exact command first. +- Destructive actions (service restart/stop and confirmation-gated runbook + steps) require explicit confirmation and show the exact command first. **MVP limitations (be aware)** - `StrictHostKeyChecking=accept-new`: first-seen host keys are trusted @@ -184,6 +278,7 @@ cargo check --manifest-path src-tauri/Cargo.toml FreeRDP limitation, not under our control. - No app-level master-password lock yet (keyring is the trust anchor). - RDP/VNC are launched as **external** windows; not embedded. +- FTP credentials and data are plaintext on the network by protocol design. - Secrets masking in interactive terminal output is best-effort. See [TODO.md](TODO.md) for the roadmap that hardens these. diff --git a/TODO.md b/TODO.md index 227a444..c5e080b 100644 --- a/TODO.md +++ b/TODO.md @@ -6,14 +6,15 @@ Status legend: ✅ done (MVP) · 🚧 partial · ⬜ planned - ✅ Server Manager (CRUD, groups, tags, environments, search) in SQLite - ✅ Secrets in OS keyring (Secret Service); no plaintext in SQLite - ✅ SSH terminal tabs (xterm.js + server-side PTY over system `ssh`), reconnect/resize -- ✅ Live agentless Health panel (CPU/RAM/swap/disk/load/uptime/net, top procs, ports, failed services, Docker) with thresholds + sparklines -- ✅ Runbook engine + 7 built-ins, step-by-step run with confirmation + persisted history +- ✅ Live agentless Health panel (CPU/RAM/swap/disk/load/uptime/net, top procs, ports, failed services) with thresholds + sparklines +- ✅ Runbook engine + 6 built-ins, step-by-step run with confirmation + persisted history - ✅ Services panel (failed units, status/logs, confirmed start/stop/restart) -- ✅ Docker panel (list/stats/logs/lifecycle, compose ps) - ✅ SFTP browser (list/upload/download/delete/rename) +- ✅ Legacy FTP browser via curl, with independent port and plaintext warning - ✅ RDP launcher (`xfreerdp`), VNC launcher (system viewer) - ✅ Logs panel (tail / journalctl / filter / save / diagnostic bundle) - ✅ SSH tunnels (-L / -R / -D), tracked + persisted +- ✅ Settings store/UI (theme, default ports, health refresh, retention and desktop flags) ## Next: hardening & depth - ⬜ **Native SSH transport** (e.g. `russh`/`libssh2`) to replace the system-`ssh` @@ -35,10 +36,11 @@ Status legend: ✅ done (MVP) · 🚧 partial · ⬜ planned ## Platform & packaging - ⬜ pacman package target; signed AppImage; Flatpak. - ⬜ CI matrix builds (Arch/Ubuntu/Debian/Fedora). -- ⬜ Settings store (theme, default ports, refresh interval persistence). +- ✅ Settings store (theme, default ports, refresh interval persistence). ## Quality -- ⬜ Rust unit tests for health parsers (feed fixture `/proc` output). -- ⬜ Frontend component tests for RunbookRunner state machine. -- ⬜ Integration test against a throwaway SSH container. +- ✅ Rust unit tests for health parsers and threshold warnings. +- ✅ Frontend regression tests for RunbookRunner state machine and PTY startup ordering. +- ✅ Frontend regression tests for settings contracts and rollback behavior. +- ⬜ Live SSH integration test against a reachable Linux test host. - ⬜ Secret-masking pass over terminal/log output. diff --git a/docs/distribution.md b/docs/distribution.md new file mode 100644 index 0000000..50f59e6 --- /dev/null +++ b/docs/distribution.md @@ -0,0 +1,55 @@ +# Distribution + +RemoteOpsX ships as a native Linux desktop app. Docker is not required. + +## GitHub Releases + +The release workflow builds Linux bundles on tagged releases: + +```bash +git tag v0.1.0 +git push origin v0.1.0 +``` + +Expected release assets: + +- `RemoteOpsX-x86_64.AppImage` +- Debian package (`.deb`) +- RPM package (`.rpm`) + +## Local AppImage Install + +```bash +chmod +x RemoteOpsX-x86_64.AppImage +./packaging/linux/install-appimage.sh ./RemoteOpsX-x86_64.AppImage +``` + +On Arch, install FUSE 2 if the AppImage does not launch: + +```bash +sudo pacman -S fuse2 +``` + +## Arch Package + +The starter `PKGBUILD` is in `packaging/arch/PKGBUILD`. + +Before publishing to AUR or a pacman repository: + +1. Replace `OWNER` in `url` with the GitHub organization/user. +2. Copy `src-tauri/icons/128x128.png` to `packaging/arch/remoteopsx.png`. +3. Generate checksums: + + ```bash + cd packaging/arch + updpkgsums + makepkg --printsrcinfo > .SRCINFO + makepkg -si + ``` + +For a private pacman repository, build the package and add it to a repo database: + +```bash +makepkg -s +repo-add remoteopsx.db.tar.gz remoteopsx-bin-*.pkg.tar.zst +``` diff --git a/docs/superpowers/plans/2026-06-20-project-hardening.md b/docs/superpowers/plans/2026-06-20-project-hardening.md index 29e7364..249cc62 100644 --- a/docs/superpowers/plans/2026-06-20-project-hardening.md +++ b/docs/superpowers/plans/2026-06-20-project-hardening.md @@ -266,7 +266,7 @@ cargo check --manifest-path src-tauri/Cargo.toml git diff --check ``` -Expected: all commands exit 0. The ignored Docker SSH integration test remains explicitly reported unless Docker is available and it is run. +Expected: all commands exit 0. Live SSH integration testing should target a reachable Linux test host when one is available. - [ ] **Step 4: Rendered smoke test** diff --git a/docs/superpowers/plans/2026-06-21-platform-foundation-settings.md b/docs/superpowers/plans/2026-06-21-platform-foundation-settings.md new file mode 100644 index 0000000..ab5b965 --- /dev/null +++ b/docs/superpowers/plans/2026-06-21-platform-foundation-settings.md @@ -0,0 +1,230 @@ +# Platform Foundation and Settings Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add typed error/settings contracts, persist validated settings in SQLite, and provide a settings UI that applies theme and refresh preferences immediately. + +**Architecture:** Rust owns validation and persistence. Tauri commands return a serializable `DomainError`; TypeScript normalizes IPC failures into `RemoteOpsError`. A focused Zustand store owns the frontend settings lifecycle. + +**Tech Stack:** Rust 2021, Tauri 2, serde, rusqlite, React 18, TypeScript, Zustand, Vitest. + +--- + +## File map + +- Create `src-tauri/src/error.rs` and `src-tauri/src/settings.rs`. +- Modify `src-tauri/src/database.rs` and `src-tauri/src/lib.rs`. +- Create `src/errors.ts`, `src/settings.ts`, `src/settingsStore.ts`, and `src/settings.test.ts`. +- Create `src/components/SettingsModal.tsx`. +- Modify `src/api.ts`, `src/App.tsx`, `src/store.ts`, `src/components/CommandPalette.tsx`, and `src/styles.css`. +- Update `README.md` and `TODO.md` only after acceptance passes. + +### Task 1: Stable backend error contract + +**Files:** Create `src-tauri/src/error.rs`; modify `src-tauri/src/lib.rs`; test in `src-tauri/src/error.rs`. + +- [x] Write failing tests proving validation errors serialize `code`, `retryable`, `correlation_id`, and `context.field`, while an internal error created from `"secret-canary-value"` never serializes that value. + +- [x] Run `cargo test --manifest-path src-tauri/Cargo.toml error::tests` and verify compilation fails because `DomainError` is absent. + +- [x] Implement this contract: + +```rust +use std::collections::BTreeMap; +use serde::Serialize; + +#[derive(Debug, Clone, Serialize)] +pub struct DomainError { + pub code: &'static str, + pub message: String, + pub retryable: bool, + pub correlation_id: String, + pub context: BTreeMap, +} + +pub type CommandResult = Result; + +impl DomainError { + pub fn validation(field: &str, message: &str) -> Self { + Self { code: "validation.invalid_value", message: message.into(), retryable: false, + correlation_id: uuid::Uuid::new_v4().to_string(), + context: BTreeMap::from([("field".into(), field.into())]) } + } + pub fn internal(error: impl std::fmt::Display) -> Self { + eprintln!("remoteopsx internal error: {error}"); + Self { code: "internal.unexpected", message: "An internal operation failed".into(), + retryable: false, correlation_id: uuid::Uuid::new_v4().to_string(), context: BTreeMap::new() } + } +} +``` + +- [x] Declare `pub mod error`, change `e()` to map through `DomainError::internal`, and replace command return types `Result` with `CommandResult`. Direct user-input failures use `DomainError::validation`; operational failures use `e`. + +- [x] Run the focused tests and `cargo test --manifest-path src-tauri/Cargo.toml`; both must exit 0. + +- [ ] Commit and push: + +```bash +git add src-tauri/src/error.rs src-tauri/src/lib.rs +git commit -m "feat: add typed backend error contract" +git push +``` + +### Task 2: Typed settings and SQLite persistence + +**Files:** Create `src-tauri/src/settings.rs`; modify `src-tauri/src/database.rs` and `src-tauri/src/lib.rs`; test both Rust modules. + +- [x] Write failing tests asserting system theme, ports 22/21/3389/5900, 3000 ms refresh, and rejection of refresh below 1000 ms and port zero. + +- [x] Run `cargo test --manifest-path src-tauri/Cargo.toml settings::tests`; verify RED because `AppSettings` is absent. + +- [x] Implement serde snake-case enums `Theme { System, Dark, Light }` and `TransferConflictPolicy { Ask, Overwrite, Rename, Skip }`, plus: + +```rust +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DefaultPorts { pub ssh: u16, pub ftp: u16, pub rdp: u16, pub vnc: u16 } + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AppSettings { + pub schema_version: u32, + pub theme: Theme, + pub default_ports: DefaultPorts, + pub health_refresh_interval_ms: u64, + pub history_retention_days: u32, + pub app_lock_timeout_minutes: u32, + pub transfer_conflict_policy: TransferConflictPolicy, + pub desktop_clipboard_enabled: bool, + pub desktop_audio_enabled: bool, + pub desktop_notifications_enabled: bool, +} +``` + +- [x] Implement defaults `1`, `system`, `22/21/3389/5900`, `3000`, `90`, `15`, `ask`, and three enabled booleans. `validate()` accepts refresh `1000..=60000`, retention `1..=3650`, timeout `1..=1440`, and nonzero ports; failures return `DomainError::validation` with the exact field path. + +- [x] Write a failing database test: empty DB returns defaults; save light theme and 5000 ms; reload equals saved value; `app_settings` contains exactly one row. + +- [x] Add this migration: + +```sql +CREATE TABLE IF NOT EXISTS app_settings ( + singleton_id INTEGER PRIMARY KEY CHECK (singleton_id = 1), + schema_version INTEGER NOT NULL, + value_json TEXT NOT NULL CHECK (length(value_json) <= 65536), + updated_at TEXT NOT NULL +); +``` + +- [x] Implement `load_settings`: select `value_json`, return defaults on `QueryReturnedNoRows`, otherwise deserialize and validate. Implement `save_settings`: validate, serialize, then atomically upsert singleton row 1 inside `unchecked_transaction()`. + +- [ ] Run settings/database suites; commit and push: + +```bash +cargo test --manifest-path src-tauri/Cargo.toml settings::tests +cargo test --manifest-path src-tauri/Cargo.toml database::tests +git add src-tauri/src/settings.rs src-tauri/src/database.rs src-tauri/src/lib.rs +git commit -m "feat: persist typed application settings" +git push +``` + +### Task 3: Settings IPC and frontend contracts + +**Files:** Modify `src-tauri/src/lib.rs` and `src/api.ts`; create `src/errors.ts`, `src/settings.ts`, and `src/settings.test.ts`. + +- [x] Write failing Vitest cases: nested port patches do not mutate defaults; a structured backend rejection becomes `RemoteOpsError` retaining code and correlation ID; unknown objects become `client.unknown`. + +- [x] Run `npm test -- src/settings.test.ts`; verify RED because the new modules are absent. + +- [x] Mirror Rust settings types in `settings.ts`, export immutable `DEFAULT_SETTINGS`, and implement `patchSettings(current, patch)` with a nested `default_ports` merge. + +- [x] Implement the frontend error type: + +```ts +export class RemoteOpsError extends Error { + constructor(message: string, readonly code: string, readonly retryable: boolean, + readonly correlationId: string | null, readonly context: Record) { + super(message); this.name = "RemoteOpsError"; + } +} + +export function normalizeRemoteError(value: unknown): RemoteOpsError { + if (value instanceof RemoteOpsError) return value; + if (value instanceof Error) return new RemoteOpsError(value.message, "client.error", false, null, {}); + if (typeof value === "object" && value !== null) { + const p = value as Record; + if (typeof p.code === "string" && typeof p.message === "string") + return new RemoteOpsError(p.message, p.code, p.retryable === true, + typeof p.correlation_id === "string" ? p.correlation_id : null, + (p.context as Record) ?? {}); + } + return new RemoteOpsError("An unknown operation failed", "client.unknown", false, null, {}); +} +``` + +- [x] Add `settings_get` and `settings_save` commands. Both lock the DB safely; save validates before persistence and returns the saved value. Register both in `generate_handler!`. + +- [x] Replace direct Tauri invoke in `api.ts` with one local generic wrapper that catches and throws `normalizeRemoteError`. Add `settingsGet()` and `settingsSave(settings)`. + +- [ ] Verify, commit, and push: + +```bash +npm test -- src/settings.test.ts +npm run build +cargo test --manifest-path src-tauri/Cargo.toml +git add src-tauri/src/lib.rs src/api.ts src/errors.ts src/settings.ts src/settings.test.ts +git commit -m "feat: expose typed settings contracts" +git push +``` + +### Task 4: Settings state and UI + +**Files:** Create `src/settingsStore.ts` and `src/components/SettingsModal.tsx`; modify `src/App.tsx`, `src/store.ts`, `src/components/CommandPalette.tsx`, and `src/styles.css`; extend `src/settings.test.ts`. + +- [x] Write a failing state test using injected API functions: load dark settings, patch light, make save throw `disk full`, then assert state rolls back to dark, becomes clean, and retains a normalized error. + +- [x] Implement a focused Zustand store with `settings`, `persisted`, `loading`, `saving`, `dirty`, `error`, `load`, `patch`, `reset`, and `save`. Export the injected state-machine factory used by the test. Save snapshots persisted state and restores it before rethrowing on failure. + +- [x] Implement `SettingsModal` controlled fields for theme, four ports, refresh seconds, retention days, lock timeout, conflict policy, clipboard, audio, and notifications. Disable Save while clean/loading/saving. Display code and correlation ID. Close only after successful save or confirmed discard. + +- [x] In `App.tsx`, load once and apply `data-theme`; system mode follows `matchMedia("(prefers-color-scheme: dark)")`. Add top-bar and palette Settings actions. + +- [x] Remove `healthIntervalMs` ownership from `store.ts`; health polling reads `health_refresh_interval_ms` from the settings store. Add complete light-theme variables and responsive settings styles. + +- [ ] Verify, commit, and push: + +```bash +npm test +npm run build +git add src/settingsStore.ts src/components/SettingsModal.tsx src/App.tsx src/store.ts src/components/CommandPalette.tsx src/styles.css src/settings.test.ts +git commit -m "feat: add persistent application settings UI" +git push +``` + +### Task 5: Acceptance and roadmap update + +**Files:** Modify `README.md`, `TODO.md`, and this plan. + +- [x] Run full acceptance: + +```bash +npm test +npm run build +cargo fmt --manifest-path src-tauri/Cargo.toml -- --check +cargo test --manifest-path src-tauri/Cargo.toml +git diff --check +``` + +- [ ] Run `npm run app:dev`; change theme, ports, and refresh interval; restart and verify persistence. Submit an invalid refresh and verify `validation.invalid_value` appears without changing persisted state. + +- [x] Document fields, defaults, ranges, and DB location in `README.md`. Change only the Settings line in `TODO.md` to `✅`. Mark completed plan checkboxes `[x]`. + +- [ ] Commit, push, and compare remote SHA: + +```bash +git add README.md TODO.md docs/superpowers/plans/2026-06-21-platform-foundation-settings.md +git commit -m "docs: complete platform settings milestone" +git push +git status -sb +git ls-remote origin "refs/heads/$(git branch --show-current)" +``` + +Expected: clean worktree and remote SHA equals local HEAD. diff --git a/docs/superpowers/plans/2026-06-22-mvp-finish-handoff.md b/docs/superpowers/plans/2026-06-22-mvp-finish-handoff.md new file mode 100644 index 0000000..d7d4bda --- /dev/null +++ b/docs/superpowers/plans/2026-06-22-mvp-finish-handoff.md @@ -0,0 +1,24 @@ +# RemoteOpsX MVP Finish Handoff Plan + +**Goal:** Finish the local MVP branch with settings persistence, updated documentation, validation, and refreshed Graphify artifacts. + +## Steps + +- [x] Inspect repository, roadmap, specs, package scripts, existing graph output, and implementation coverage. +- [x] Run baseline validation and identify the Vitest discovery leak from `.worktrees/`. +- [x] Add typed backend errors and settings modules. +- [x] Persist validated settings in SQLite via the `app_settings` singleton table. +- [x] Add frontend settings contracts, normalized errors, rollback-safe settings store, and regression tests. +- [x] Add Settings UI access from the top bar and command palette. +- [x] Apply theme settings and move health refresh interval ownership to persisted settings. +- [x] Constrain Vitest discovery to root `src/**/*.test.ts(x)` files. +- [x] Update README and TODO with settings, validation ranges, DB location, test commands, specs/plans, and graphify handoff. +- [x] Regenerate Graphify outputs from the final tree. +- [x] Run final validation: frontend tests/build, Rust tests/fmt, diff whitespace check. + +## Follow-up manual smoke + +- [ ] Run `npm run app:dev` on a Linux desktop with Tauri dependencies. +- [ ] Verify Settings open/close/focus behavior from top bar and command palette. +- [ ] Save theme/ports/refresh settings, restart, and verify persistence. +- [ ] Verify invalid values surface `validation.invalid_value` and roll back safely. diff --git a/docs/superpowers/plans/2026-06-29-ssh-key-install.md b/docs/superpowers/plans/2026-06-29-ssh-key-install.md new file mode 100644 index 0000000..b606b47 --- /dev/null +++ b/docs/superpowers/plans/2026-06-29-ssh-key-install.md @@ -0,0 +1,49 @@ +# SSH Key Install Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let users pick local SSH private keys from `~/.ssh`, add external key paths manually, and install the matching public key into a remote server's `~/.ssh/authorized_keys` without copying private keys to the server. + +**Architecture:** Add a focused Rust `ssh_keys` module for local key discovery, public key resolution, and safe install command construction. Expose Tauri commands through `lib.rs`, add typed frontend wrappers, and extend `ServerForm` with key selection plus an explicit install action. + +**Tech Stack:** Rust/Tauri commands, system `ssh-keygen`, existing `ssh_manager::run_remote`, React/TypeScript server profile modal, Vitest and Cargo unit tests. + +--- + +### Task 1: Backend SSH Key Utilities + +**Files:** +- Create: `src-tauri/src/ssh_keys.rs` +- Modify: `src-tauri/src/lib.rs` +- Test: `src-tauri/src/ssh_keys.rs` + +- [ ] Write tests for filtering private-key candidates, resolving `.pub` files, and building an idempotent `authorized_keys` install command. +- [ ] Run `cargo test --manifest-path src-tauri/Cargo.toml ssh_keys`. +- [ ] Implement `SshKeyInfo`, `discover_local_keys`, `public_key_for_private_key`, and `authorized_keys_install_command`. +- [ ] Expose `ssh_keys_list` and `ssh_key_install` Tauri commands. + +### Task 2: Frontend API and Server Form + +**Files:** +- Modify: `src/types.ts` +- Modify: `src/api.ts` +- Modify: `src/components/ServerForm.tsx` +- Test: `src/settings.test.ts` or existing frontend tests if contracts are touched. + +- [ ] Add `SshKeyInfo` TypeScript type and `sshKeysList` / `sshKeyInstall` API wrappers. +- [ ] Load discovered keys when key auth is selected. +- [ ] Render a key dropdown, keep manual path entry, and add an explicit “Install public key on server” action. +- [ ] Show success/error feedback without exposing private-key content. + +### Task 3: Verification and Publishing + +**Files:** +- Existing changed files only. + +- [ ] Run `npm test`. +- [ ] Run `npm run build`. +- [ ] Run `cargo fmt --manifest-path src-tauri/Cargo.toml -- --check`. +- [ ] Run `cargo test --manifest-path src-tauri/Cargo.toml`. +- [ ] Run `git diff --check`. +- [ ] Commit and push to `codex/project-hardening-packaging`. +- [ ] Comment on PR #1 with validation and remaining manual smoke steps. diff --git a/docs/superpowers/specs/2026-06-21-production-roadmap-design.md b/docs/superpowers/specs/2026-06-21-production-roadmap-design.md new file mode 100644 index 0000000..26fca8b --- /dev/null +++ b/docs/superpowers/specs/2026-06-21-production-roadmap-design.md @@ -0,0 +1,149 @@ +# RemoteOpsX Production Roadmap Design + +## Goal + +Complete every unfinished item in `TODO.md` as production-grade Linux software. Replace process-based remote transports with a native `russh` stack, embed RDP and VNC, add durable operations features, and ship reproducible signed bundles. Existing local data does not require backward compatibility, so schema and command contracts may be replaced rather than migrated from the current development database. + +## Delivery model + +Work proceeds foundation-first on `codex/project-hardening-packaging`. Every milestone has its own tests, documentation update, commit, and push. A roadmap checkbox changes to complete only after its acceptance tests pass. Commits never contain partially implemented adjacent milestones. + +The implementation milestones are: + +1. Platform foundation and persistent settings +2. Native secure SSH transport, known-hosts management, and app lock +3. Persistent SFTP and resilient tunnels +4. Health history, alerting, runbook authoring/scheduling, session history, and snippets +5. Embedded RDP and VNC +6. Packaging, signing, CI matrix, and supply-chain outputs +7. Integration, parser, recovery, and secret-masking quality gates + +## Architecture + +### Frontend feature modules + +React is split by product capability: connections, files, desktop, health, automation, history, snippets, and settings. Feature modules call a typed API client and consume versioned events. They never import transport-specific details or hold native resource pointers. + +The global store retains navigation and lightweight cached view state. Long-running operation state belongs to feature-specific stores keyed by opaque job or session IDs. This prevents a single Zustand store from becoming the lifecycle owner for every backend resource. + +### Typed IPC boundary + +Every command accepts a versioned request and returns a typed response or `DomainError`. Long-running work returns a `JobId` immediately and publishes progress, completion, cancellation, and recovery events. Event payloads carry a schema version and request correlation ID. + +High-bandwidth desktop frames and audio do not cross JSON IPC. A bounded native buffer feeds a rendering bridge; JSON IPC controls lifecycle, input, display metadata, and error reporting. + +### Rust application services + +The Tauri command surface delegates to focused services: + +- `SessionService`: SSH connection lifecycle, PTY, exec channels, jump hosts, pooling, and host-key decisions. +- `TransferService`: persistent SFTP channels, recursive transfer jobs, progress, cancellation, chmod, and conflict policy. +- `TunnelService`: local, remote, and dynamic forwards with probes, reconnect policy, and autostart. +- `DesktopService`: embedded FreeRDP and VNC workers, frame/audio/input bridges, resize, clipboard, and teardown. +- `HealthService`: collection, retention, thresholds, aggregation, and alert dispatch. +- `AutomationService`: runbook validation, variable resolution, dry-run, scheduling, and resumable execution. +- `HistoryService`: session and audit queries with retention controls. +- `SettingsService`: typed settings, defaults, validation, and change events. +- `VaultService`: app-lock key derivation, encrypted secret envelopes, keyring integration, unlock state, and zeroization. + +Each active connection or desktop instance runs as an actor with a bounded mailbox and cancellation token. Managers keep registries of opaque handles. Shutdown drains jobs within a deadline and then force-closes remaining native resources. + +## Native SSH and SFTP + +`russh` is the primary SSH implementation. The application owns TCP connection setup, negotiated algorithms, host-key verification, password/public-key/agent authentication, encrypted-key passphrase prompts, jump-host chains, keepalives, exec channels, PTYs, SFTP channels, and forwarding channels. Production code does not invoke `ssh`, `scp`, or `sshpass`. + +Known hosts are stored in SQLite with host, port, algorithm, fingerprint, first-seen time, last-seen time, trust state, and replacement history. Unknown and changed keys block connection establishment until the user explicitly accepts or rejects them. Rotation preserves the old fingerprint in audit history. + +Connections are pooled per effective endpoint and authentication identity. Pool entries have idle expiry, health checks, bounded channel counts, and deterministic invalidation after auth or host-key changes. + +SFTP keeps a subsystem channel open per active file session. Transfers are durable jobs with byte progress, speed, ETA, cancellation, retry classification, conflict policy, and temporary-file atomic completion. Recursive upload/download, drag-and-drop, and chmod use the same job model. + +## App lock and secrets + +App lock is optional. When enabled, Argon2id derives a wrapping key from the master password using per-install salt and calibrated memory/time parameters. The wrapping key decrypts a random vault key; the vault key encrypts secret envelopes with an authenticated cipher. The operating-system keyring may store only the wrapped vault material and installation identity, never an unwrapped master or vault key. + +Unlock state exists only in locked memory where the platform permits it and is zeroized on lock, timeout, suspend, and process exit. Failed unlocks use exponential delay. Password changes rewrap the vault key without re-encrypting every secret. Recovery is an explicit destructive reset because no recoverable copy of the master password exists. + +## Embedded desktop protocols + +FreeRDP and the selected VNC client library are pinned and built as bundled native dependencies. Builds record exact source revisions and licenses. Rust FFI wrappers expose owned session objects and translate callbacks into bounded frame, audio, clipboard, and status channels. + +Frames use a bounded latest-frame queue so a slow WebView cannot exhaust memory. The renderer negotiates dimensions and pixel format and drops stale frames under pressure. Input is rate-limited and validated. Clipboard directions are independently configurable. Credentials are delivered through in-memory native APIs and never command-line arguments. + +Native crashes and protocol failures terminate only the affected desktop session, release buffers, and produce a redacted `DomainError`. Sanitizer-enabled native integration jobs exercise connect, resize, input, reconnect, and teardown. + +## Operations features + +### Health history and alerts + +Samples are stored per server with configurable retention and downsampling. Threshold definitions are typed, scoped globally or per server/tag, and versioned. Alert state uses hysteresis and deduplication to avoid flapping. Desktop notifications and signed webhook deliveries share an outbox with retry limits and audit status. + +### Runbooks + +The editor provides structured YAML-backed forms, schema validation, variable declarations, confirmation policy, and exact command preview. Dry-run resolves variables and renders steps without executing them. Imports are validated before persistence; exports are deterministic. + +Schedules persist with timezone and misfire policy. The scheduler leases due runs transactionally so only one execution starts. A failed run may resume from a selected step only when prerequisite and confirmation rules pass. Execution remains append-only and auditable. + +### Sessions and snippets + +Session history exposes protocol, server, timestamps, outcome, and redacted failure metadata with filters and retention controls. It never stores terminal contents by default. + +Snippets are user-editable, tagged, searchable, and optionally scoped to server tags. Broadcast creates one tracked execution per target, requires an exact target/command confirmation, limits concurrency, and presents per-target results. Secrets are masked before persistence or display. + +### Tunnel resilience + +Tunnel profiles define autostart, reconnect bounds, and health probes. Dynamic SOCKS tunnels perform an end-to-end proxy probe rather than checking only the listening socket. Reconnect uses capped exponential backoff with jitter and stops on non-retryable authentication or host-key errors. + +## Settings and persistence + +SQLite is the source of truth for settings, known hosts, history, thresholds, schedules, snippets, jobs, and audit events. The schema is rebuilt for the production model because compatibility with development data is not required. Foreign keys, uniqueness constraints, and bounded text/blob sizes enforce invariants. + +Settings cover theme, default protocol ports, refresh intervals, retention, app-lock timeout, transfer behavior, desktop clipboard/audio policy, and notification routing. Validation happens in Rust. Frontend optimistic changes roll back when persistence fails. + +## Error handling and observability + +`DomainError` contains a stable code, safe user message, retryability, correlation ID, and redacted context. Internal causes stay in structured logs. Secret-bearing types cannot implement unrestricted debug formatting. + +Logs are structured and bounded by retention. Metrics cover active sessions, reconnects, transfer throughput, job failures, queue pressure, alert delivery, and scheduler lag. Diagnostic bundles apply the same masking engine as terminal and log views. + +## Packaging and supply chain + +Arch, AppImage, and Flatpak outputs bundle pinned transport and desktop native dependencies. AppImage and release metadata are signed in CI. Builds generate checksums, SBOMs, license inventories, and provenance attestations. Release jobs fail on unapproved licenses, vulnerable locked dependencies above the configured severity gate, missing signatures, or non-reproducible bundle inputs. + +CI runs supported build/test jobs for Arch, Ubuntu, Debian, and Fedora. Container images are pinned by digest. Native dependencies are cached by content hash, while final artifacts are always rebuilt and verified. + +## Testing strategy + +- Rust unit tests cover parsers, validation, state machines, retry classification, migrations, masking, and cryptographic envelope behavior. +- Property and fuzz tests feed health parsers, protocol parsers, YAML validation, and masking with malformed and adversarial input. +- SSH integration tests start an isolated server fixture and exercise host-key unknown/change/rotation, auth methods, jump hosts, PTY, exec, SFTP, forwarding, reconnect, and cancellation. +- Desktop integration tests use controlled RDP and VNC fixtures and verify frame delivery, input, clipboard, resize, reconnect, and cleanup. +- Frontend tests cover editors, progress/cancellation, lock transitions, history filters, alert configuration, and partial runbook reruns. +- Packaging smoke tests install, launch, and remove every artifact in clean distribution containers. +- Recovery tests kill the app during transfers, scheduled runs, and active sessions, then verify deterministic cleanup or resumption. +- Secret canary tests inject recognizable values and fail if they appear in logs, events, database fields, diagnostics, process arguments, or UI snapshots. + +## Roadmap coverage + +| TODO item | Owning milestone | +| --- | --- | +| Native SSH transport | 2 | +| Known-hosts UI | 2 | +| App lock | 2 | +| Persistent SFTP, progress, drag/drop, recursive, chmod | 3 | +| Embedded RDP and VNC | 5 | +| Health retention, thresholds, notifications/webhooks | 4 | +| Runbook editor, variables, dry-run, import/export, scheduling, partial rerun | 4 | +| Tunnel reconnect, autostart, SOCKS health | 3 | +| Sessions history | 4 | +| Snippets and broadcast | 4 | +| pacman, signed AppImage, Flatpak | 6 | +| CI distribution matrix | 6 | +| Settings persistence | 1 | +| Health parser tests | 7 | +| Live SSH integration tests | 7 | +| Secret masking | 7 | + +## Acceptance rule + +A milestone is complete only when its focused tests, the full frontend suite, the full Rust suite, production build, formatting/static checks, documentation, and relevant packaging or fixture smoke tests pass from a clean checkout. The commit is then pushed and its TODO entries are marked complete in the same commit. diff --git a/docs/superpowers/specs/2026-06-22-appearance-theming-design.md b/docs/superpowers/specs/2026-06-22-appearance-theming-design.md new file mode 100644 index 0000000..93b37bd --- /dev/null +++ b/docs/superpowers/specs/2026-06-22-appearance-theming-design.md @@ -0,0 +1,113 @@ +# Appearance Theming Design (UI themes + SSH terminal fonts/colors) + +## Goal + +Replace the current three-option theme (`system` / `dark` / `light`) with a +richer preset-based appearance system. One selection drives both the app +chrome (sidebar, panels, buttons) and the SSH terminal's color scheme. +Terminal font family and size become independently configurable, on top of +whichever theme is selected. + +## Scope decision + +App theme and terminal color scheme are **one unified setting**, not two +independent ones. Picking "Dracula" recolors the whole app and the terminal +at once. This matches how terminal-forward apps (Warp, Hyper) work and keeps +the settings surface small. + +## Theme presets + +`system`, `dark`, `light` (existing, unchanged) plus six new presets: +`dracula`, `nord`, `solarized_dark`, `solarized_light`, `monokai`, `one_dark`. + +`system` keeps its current meaning: it resolves to `dark` or `light` based on +OS preference (`prefers-color-scheme`), exactly as today. It has no analog +for the six new presets — they are always explicit choices. + +## Architecture + +Two single-purpose, additive registries, kept in sync by a test: + +1. **App chrome (CSS):** each preset gets its own + `:root[data-theme=""] { --bg-0: ...; --text-0: ...; --accent: ...; }` + block in `src/styles.css`, following the exact pattern of the existing + `dark`/`light` blocks. No new CSS variables, no refactor of existing + working CSS — purely additive blocks. +2. **Terminal palette (TypeScript):** a new `src/terminalThemes.ts` registry + maps each concrete theme id (`dark`, `light`, `dracula`, ...; never + `system`) to an xterm.js `ITheme` object (background, foreground, cursor, + selectionBackground, and the 16 ANSI colors). + +Rejected alternative: deriving the terminal palette from CSS variables at +runtime. App chrome only needs ~10 colors; a terminal needs a full 16-color +ANSI set. Bolting that onto the CSS variable surface would pollute it for +every other component that reads those variables. Two small registries, each +holding only the colors it needs, is cleaner. + +A theme switch does two things: set `data-theme` on `` (recolors chrome +via CSS, exactly as `bootstrapSystemTheme`/`resolveTheme` do today, just +generalized past two hardcoded options) and look up the resolved id in +`terminalThemes.ts` to recolor any open terminal tabs. + +**Live update:** `TerminalTab.tsx` currently hardcodes `fontFamily`, +`fontSize`, and `theme` at construction. It will instead read them from the +settings store and assign them to the live `Terminal.options` object +(supported since xterm.js v5) whenever settings change, so open terminal tabs +update immediately without a reconnect. + +## Settings contract changes + +`schema_version` stays `1` — this app has no migration machinery yet and no +real users, so new fields get `#[serde(default = ...)]` (Rust) / +optional-with-default handling (TS) instead of a version bump. + +New / changed fields in `AppSettings` (both `src-tauri/src/settings.rs` and +`src/settings.ts`): + +| Field | Type | Default | Valid values | +| --- | --- | --- | --- | +| `theme` | enum/string | `system` | `system`, `dark`, `light`, `dracula`, `nord`, `solarized_dark`, `solarized_light`, `monokai`, `one_dark` | +| `terminal_font_family` | string | `"JetBrains Mono", "DejaVu Sans Mono", monospace` | non-empty after trim, max 200 chars | +| `terminal_font_size_px` | integer | `13` | `9..=24` | + +Validation mirrors on both Rust and TS sides, same pattern as the existing +settings fields (exact-match enum membership, inclusive numeric bounds). + +## Settings UI + +New "Appearance" section in `SettingsModal.tsx`: + +- **Theme:** a grid of swatch buttons, one per preset, each rendered with a + few of its actual colors as a small preview (not a plain radio list). +- **Terminal font:** a dropdown with curated common monospace fonts + (JetBrains Mono, Fira Code, Cascadia Code, Hack, Source Code Pro, Ubuntu + Mono, DejaVu Sans Mono) plus a **Custom…** option that reveals a free-text + input for any installed font family string. Whatever is chosen, the value + applied to xterm always has `, monospace` appended as a final fallback (if + not already present), so an uninstalled/misspelled font degrades to *some* + monospace font instead of a stray proportional one. +- **Terminal font size:** a number input, `9`–`24`. + +Persistence reuses the existing settings flow exactly: optimistic update in +the Zustand store, roll back to the previous persisted value if backend +validation or save fails. + +## Testing + +- Rust (`settings.rs`): extend existing validation tests for the new theme + enum variants and the font family/size bounds (valid + invalid cases, + inclusive boundaries). +- Frontend: a test asserting every theme id (except `system`) has an entry in + `terminalThemes.ts`, so a new CSS-only preset can't ship without its + terminal palette (and vice versa). Extend `settings.test.ts`-style + validation tests for the two new fields. +- Manual smoke: cycle through each preset in Settings and confirm app chrome + and an already-open terminal tab recolor together; change font family and + size and confirm an open terminal tab updates live, no reconnect. + +## Non-goals (v1) + +- Per-server theme/font overrides (one global appearance setting). +- A custom palette editor / user-defined themes. +- Theme import/export. +- Font ligatures or Nerd Font glyph-specific handling. diff --git a/docs/superpowers/specs/2026-06-22-mvp-finish-handoff.md b/docs/superpowers/specs/2026-06-22-mvp-finish-handoff.md new file mode 100644 index 0000000..9289fa6 --- /dev/null +++ b/docs/superpowers/specs/2026-06-22-mvp-finish-handoff.md @@ -0,0 +1,51 @@ +# RemoteOpsX MVP Finish Handoff Spec + +## Goal + +Finalize the current RemoteOpsX MVP as a buildable, documented Linux desktop application with typed settings persistence, stable IPC errors, regression coverage, refreshed roadmap docs, and a current Graphify map. + +## Completed scope + +- Preserve the existing React/Tauri architecture and system-tool adapters. +- Add a stable backend `DomainError` contract with safe internal-error redaction and validation context. +- Add persisted application settings backed by a singleton SQLite row. +- Add frontend settings types, client-side validation, normalized remote errors, and rollback-safe Zustand state. +- Add a Settings modal reachable from the top bar and command palette. +- Apply dark/light/system theme from settings and use the persisted health refresh interval in the health panel. +- Keep secrets in the OS keyring; SQLite stores only metadata and `secret_ref` references. +- Keep the hardening roadmap explicit for native SSH, host-key UI, app lock, embedded desktop protocols, alerting, CI, and integration testing. + +## Settings contract + +The settings schema version is `1`. Defaults are: system theme, SSH `22`, FTP `21`, RDP `3389`, VNC `5900`, health refresh `3000 ms`, history retention `90 days`, app-lock timeout `15 minutes`, transfer conflict policy `ask`, and enabled desktop clipboard/audio/notifications. + +Rust validation rejects unsupported schema versions, zero ports, refresh outside `1000..=60000 ms`, retention outside `1..=3650 days`, and app-lock timeout outside `1..=1440 minutes`. Frontend validation mirrors these constraints before IPC. + +## Acceptance + +Required checks for this handoff: + +- `npm test` +- `npm run build` +- `cargo fmt --manifest-path src-tauri/Cargo.toml -- --check` +- `cargo test --manifest-path src-tauri/Cargo.toml` +- `git diff --check` +- `graphify update .` +- `graphify cluster-only . --no-label` + +Manual desktop smoke to run on a workstation with Tauri runtime dependencies: + +1. Start `npm run app:dev`. +2. Open Settings from the top bar and command palette. +3. Change theme, default ports, and health refresh; save and restart. +4. Confirm persisted values reload from `remoteopsx.db`. +5. Submit an invalid numeric setting and confirm the UI keeps the previous persisted state. + +## Non-goals + +- Native `russh` transport. +- Known-hosts management UI. +- Real app-lock encryption/unlock flow. +- Embedded RDP/VNC rendering. +- Live SSH integration fixtures. +- Signed release automation. diff --git a/package-lock.json b/package-lock.json index ebebfbd..c82fa58 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,13 +20,38 @@ }, "devDependencies": { "@tauri-apps/cli": "^2.1.0", + "@types/node": "^22.20.0", "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", "@vitejs/plugin-react": "^4.3.4", + "jsdom": "^26.1.0", + "playwright-core": "^1.61.0", "typescript": "^5.7.2", - "vite": "^6.0.5" + "vite": "^6.0.5", + "vitest": "^3.2.6" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -309,6 +334,121 @@ "node": ">=6.9.0" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -1502,6 +1642,24 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1509,6 +1667,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "22.20.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", + "integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", @@ -1558,6 +1726,121 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.6.tgz", + "integrity": "sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.6", + "@vitest/utils": "3.2.6", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.6.tgz", + "integrity": "sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.6", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.6.tgz", + "integrity": "sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.6.tgz", + "integrity": "sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.6", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.6.tgz", + "integrity": "sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.6", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.6.tgz", + "integrity": "sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.6.tgz", + "integrity": "sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.6", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@xterm/addon-fit": { "version": "0.10.0", "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz", @@ -1582,6 +1865,26 @@ "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", "license": "MIT" }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.38", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz", @@ -1629,6 +1932,16 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001799", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", @@ -1650,6 +1963,33 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1657,6 +1997,20 @@ "dev": true, "license": "MIT" }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1664,6 +2018,20 @@ "devOptional": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1682,6 +2050,23 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.375", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.375.tgz", @@ -1689,6 +2074,26 @@ "dev": true, "license": "ISC" }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -1741,6 +2146,26 @@ "node": ">=6" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1784,40 +2209,141 @@ "node": ">=6.9.0" } }, - "node_modules/js-tokens": { + "node_modules/html-encoding-sniffer": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "license": "MIT" - }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", "dev": true, "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" + "dependencies": { + "whatwg-encoding": "^3.1.1" }, "engines": { - "node": ">=6" + "node": ">=18" } }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", "dev": true, "license": "MIT", - "bin": { - "json5": "lib/cli.js" + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" }, "engines": { - "node": ">=6" + "node": ">= 14" } }, - "node_modules/loose-envify": { - "version": "1.4.0", + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "license": "MIT", @@ -1828,6 +2354,13 @@ "loose-envify": "cli.js" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -1838,6 +2371,16 @@ "yallist": "^3.0.2" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1874,6 +2417,43 @@ "node": ">=18" } }, + "node_modules/nwsapi": { + "version": "2.2.24", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz", + "integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==", + "dev": true, + "license": "MIT" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1894,6 +2474,19 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright-core": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz", + "integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/postcss": { "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", @@ -1923,6 +2516,16 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -2003,6 +2606,33 @@ "fsevents": "~2.3.2" } }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -2022,6 +2652,13 @@ "semver": "bin/semver.js" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2032,6 +2669,61 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -2049,6 +2741,82 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -2063,6 +2831,13 @@ "node": ">=14.17" } }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -2169,6 +2944,219 @@ } } }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.6.tgz", + "integrity": "sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.6", + "@vitest/mocker": "3.2.6", + "@vitest/pretty-format": "^3.2.6", + "@vitest/runner": "3.2.6", + "@vitest/snapshot": "3.2.6", + "@vitest/spy": "3.2.6", + "@vitest/utils": "3.2.6", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.6", + "@vitest/ui": "3.2.6", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index e1fdb50..0cf8621 100644 --- a/package.json +++ b/package.json @@ -6,11 +6,13 @@ "description": "RemoteOpsX - a unified Linux remote operations workspace (SSH/SFTP/RDP/VNC + live server health + runbooks)", "scripts": { "dev": "vite", + "test": "vitest run", "build": "tsc --noEmit && vite build", "preview": "vite preview", "tauri": "tauri", "app:dev": "tauri dev", - "app:build": "tauri build" + "app:build": "tauri build", + "app:build:arch": "NO_STRIP=1 PKG_CONFIG_PATH=\"$PWD/scripts/pkgconfig${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}\" tauri build" }, "dependencies": { "@tauri-apps/api": "^2.1.1", @@ -28,8 +30,12 @@ "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", "@vitejs/plugin-react": "^4.3.4", + "playwright-core": "^1.61.0", "typescript": "^5.7.2", - "vite": "^6.0.5" + "vite": "^6.0.5", + "vitest": "^3.2.6", + "@types/node": "^22.20.0", + "jsdom": "^26.1.0" }, "allowScripts": { "esbuild@0.25.12": true diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD new file mode 100644 index 0000000..90d87ea --- /dev/null +++ b/packaging/arch/PKGBUILD @@ -0,0 +1,42 @@ +# Maintainer: RemoteOpsX Maintainers +pkgname=remoteopsx-bin +pkgver=0.1.0 +pkgrel=1 +pkgdesc="Linux remote operations cockpit for SSH, SFTP, RDP, VNC, health and runbooks" +arch=('x86_64') +url="https://github.com/OWNER/remoteopsx" +license=('MIT') +depends=( + 'fuse2' + 'openssh' + 'webkit2gtk-4.1' + 'gtk3' + 'libayatana-appindicator' + 'librsvg' +) +optdepends=( + 'sshpass: password authentication for SSH/SFTP' + 'freerdp: RDP launcher' + 'tigervnc: VNC launcher' + 'remmina: alternative VNC launcher' + 'gnome-keyring: Secret Service keyring backend' + 'kwallet: KDE keyring backend' + 'curl: legacy FTP support' +) +provides=('remoteopsx') +conflicts=('remoteopsx') +options=('!strip') +source=( + "RemoteOpsX-x86_64.AppImage::${url}/releases/download/v${pkgver}/RemoteOpsX-x86_64.AppImage" + "remoteopsx.desktop" + "remoteopsx.png" +) +sha256sums=('SKIP' 'SKIP' 'SKIP') + +package() { + install -Dm755 "${srcdir}/RemoteOpsX-x86_64.AppImage" "${pkgdir}/opt/remoteopsx/remoteopsx.AppImage" + install -Dm644 "${srcdir}/remoteopsx.desktop" "${pkgdir}/usr/share/applications/remoteopsx.desktop" + install -Dm644 "${srcdir}/remoteopsx.png" "${pkgdir}/usr/share/icons/hicolor/128x128/apps/remoteopsx.png" + install -dm755 "${pkgdir}/usr/bin" + ln -s /opt/remoteopsx/remoteopsx.AppImage "${pkgdir}/usr/bin/remoteopsx" +} diff --git a/packaging/arch/remoteopsx.desktop b/packaging/arch/remoteopsx.desktop new file mode 100644 index 0000000..357b8f8 --- /dev/null +++ b/packaging/arch/remoteopsx.desktop @@ -0,0 +1,10 @@ +[Desktop Entry] +Type=Application +Name=RemoteOpsX +GenericName=Remote Operations Workspace +Comment=Linux remote operations cockpit for SSH, SFTP, RDP, VNC, health and runbooks +Exec=remoteopsx +Icon=remoteopsx +Categories=Development;Network;RemoteAccess; +Terminal=false +StartupNotify=true diff --git a/packaging/arch/remoteopsx.png b/packaging/arch/remoteopsx.png new file mode 100644 index 0000000..042c5cb Binary files /dev/null and b/packaging/arch/remoteopsx.png differ diff --git a/packaging/linux/install-appimage.sh b/packaging/linux/install-appimage.sh new file mode 100755 index 0000000..f43762e --- /dev/null +++ b/packaging/linux/install-appimage.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail + +APPIMAGE="${1:-RemoteOpsX-x86_64.AppImage}" +APP_NAME="remoteopsx" +INSTALL_DIR="${HOME}/.local/bin" +APP_DIR="${HOME}/.local/share/applications" +ICON_DIR="${HOME}/.local/share/icons/hicolor/128x128/apps" + +if [[ ! -f "${APPIMAGE}" ]]; then + echo "AppImage not found: ${APPIMAGE}" >&2 + echo "Usage: $0 path/to/RemoteOpsX-x86_64.AppImage" >&2 + exit 1 +fi + +mkdir -p "${INSTALL_DIR}" "${APP_DIR}" "${ICON_DIR}" +install -m 0755 "${APPIMAGE}" "${INSTALL_DIR}/${APP_NAME}" + +if [[ -f "src-tauri/icons/128x128.png" ]]; then + install -m 0644 "src-tauri/icons/128x128.png" "${ICON_DIR}/${APP_NAME}.png" +fi + +sed "s|Exec=remoteopsx|Exec=${INSTALL_DIR}/${APP_NAME}|" \ + packaging/linux/remoteopsx.desktop > "${APP_DIR}/${APP_NAME}.desktop" + +if command -v update-desktop-database >/dev/null 2>&1; then + update-desktop-database "${APP_DIR}" >/dev/null 2>&1 || true +fi + +if command -v gtk-update-icon-cache >/dev/null 2>&1; then + gtk-update-icon-cache "${HOME}/.local/share/icons/hicolor" >/dev/null 2>&1 || true +fi + +echo "Installed RemoteOpsX to ${INSTALL_DIR}/${APP_NAME}" +echo "If the AppImage does not start on Arch, install FUSE 2: sudo pacman -S fuse2" diff --git a/packaging/linux/remoteopsx.desktop b/packaging/linux/remoteopsx.desktop new file mode 100644 index 0000000..357b8f8 --- /dev/null +++ b/packaging/linux/remoteopsx.desktop @@ -0,0 +1,10 @@ +[Desktop Entry] +Type=Application +Name=RemoteOpsX +GenericName=Remote Operations Workspace +Comment=Linux remote operations cockpit for SSH, SFTP, RDP, VNC, health and runbooks +Exec=remoteopsx +Icon=remoteopsx +Categories=Development;Network;RemoteAccess; +Terminal=false +StartupNotify=true diff --git a/packaging/linux/uninstall-appimage.sh b/packaging/linux/uninstall-appimage.sh new file mode 100755 index 0000000..024b5fd --- /dev/null +++ b/packaging/linux/uninstall-appimage.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail + +rm -f "${HOME}/.local/bin/remoteopsx" +rm -f "${HOME}/.local/share/applications/remoteopsx.desktop" +rm -f "${HOME}/.local/share/icons/hicolor/128x128/apps/remoteopsx.png" + +if command -v update-desktop-database >/dev/null 2>&1; then + update-desktop-database "${HOME}/.local/share/applications" >/dev/null 2>&1 || true +fi + +echo "Removed RemoteOpsX AppImage integration." diff --git a/scripts/gdk-pixbuf/loaders/.keep b/scripts/gdk-pixbuf/loaders/.keep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/scripts/gdk-pixbuf/loaders/.keep @@ -0,0 +1 @@ + diff --git a/scripts/pkgconfig/gdk-pixbuf-2.0.pc b/scripts/pkgconfig/gdk-pixbuf-2.0.pc new file mode 100644 index 0000000..3de5f3f --- /dev/null +++ b/scripts/pkgconfig/gdk-pixbuf-2.0.pc @@ -0,0 +1,19 @@ +prefix=/usr +bindir=${prefix}/bin +includedir=${prefix}/include +libdir=${prefix}/lib + +gdk_pixbuf_binary_version=2.10.0 +gdk_pixbuf_binarydir=${pcfiledir}/../gdk-pixbuf +gdk_pixbuf_moduledir=${gdk_pixbuf_binarydir}/loaders +gdk_pixbuf_cache_file=${gdk_pixbuf_binarydir}/loaders.cache +gdk_pixbuf_csource=${bindir}/gdk-pixbuf-csource +gdk_pixbuf_pixdata=${bindir}/gdk-pixbuf-pixdata +gdk_pixbuf_query_loaders=${bindir}/gdk-pixbuf-query-loaders + +Name: GdkPixbuf +Description: Arch compatibility metadata for linuxdeploy's GTK plugin +Version: 2.44.6 +Requires: gobject-2.0 >= 2.56.0 +Libs: -L${libdir} -lgdk_pixbuf-2.0 +Cflags: -I${includedir}/gdk-pixbuf-2.0 diff --git a/src-tauri/src/database.rs b/src-tauri/src/database.rs index 7f91188..d7aec27 100644 --- a/src-tauri/src/database.rs +++ b/src-tauri/src/database.rs @@ -4,10 +4,11 @@ //! runbooks, runbook runs and tunnels. The connection is wrapped in a Mutex //! inside `AppState`; all access goes through these helpers. -use anyhow::{Context, Result}; +use anyhow::{anyhow, Context, Result}; use rusqlite::{params, Connection}; use crate::models::*; +use crate::settings::{AppSettings, CURRENT_SETTINGS_SCHEMA_VERSION}; /// Open (creating if needed) the SQLite database and run migrations. pub fn open(path: &std::path::Path) -> Result { @@ -29,6 +30,9 @@ fn migrate(conn: &Connection) -> Result<()> { name TEXT NOT NULL, host TEXT NOT NULL, port INTEGER NOT NULL DEFAULT 22, + ftp_port INTEGER, + rdp_port INTEGER, + vnc_port INTEGER, username TEXT NOT NULL, protocols_json TEXT NOT NULL DEFAULT '["ssh"]', auth_type TEXT NOT NULL DEFAULT 'key', @@ -59,6 +63,15 @@ fn migrate(conn: &Connection) -> Result<()> { status TEXT NOT NULL ); + CREATE TABLE IF NOT EXISTS command_snippets ( + id TEXT PRIMARY KEY, + label TEXT NOT NULL, + command TEXT NOT NULL, + tags_json TEXT NOT NULL DEFAULT '[]', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS runbooks ( id TEXT PRIMARY KEY, name TEXT NOT NULL, @@ -90,9 +103,39 @@ fn migrate(conn: &Connection) -> Result<()> { status TEXT NOT NULL, created_at TEXT NOT NULL ); + + CREATE TABLE IF NOT EXISTS app_settings ( + singleton_id INTEGER PRIMARY KEY CHECK (singleton_id = 1), + schema_version INTEGER NOT NULL, + value_json TEXT NOT NULL CHECK (length(value_json) <= 65536), + updated_at TEXT NOT NULL + ); "#, ) .context("failed to run migrations")?; + add_column_if_missing(conn, "servers", "ftp_port", "INTEGER")?; + add_column_if_missing(conn, "servers", "rdp_port", "INTEGER")?; + add_column_if_missing(conn, "servers", "vnc_port", "INTEGER")?; + Ok(()) +} + +fn add_column_if_missing( + conn: &Connection, + table: &str, + column: &str, + sql_type: &str, +) -> Result<()> { + let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?; + let names = stmt.query_map([], |row| row.get::<_, String>(1))?; + for name in names { + if name? == column { + return Ok(()); + } + } + conn.execute( + &format!("ALTER TABLE {table} ADD COLUMN {column} {sql_type}"), + [], + )?; Ok(()) } @@ -100,6 +143,56 @@ fn now() -> String { chrono::Utc::now().to_rfc3339() } +pub fn load_settings(conn: &Connection) -> Result { + let result = conn.query_row( + "SELECT schema_version, value_json FROM app_settings WHERE singleton_id = 1", + [], + |row| Ok((row.get::<_, u32>(0)?, row.get::<_, String>(1)?)), + ); + let (stored_schema_version, value_json) = match result { + Ok(value) => value, + Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(AppSettings::default()), + Err(error) => return Err(error.into()), + }; + let settings: AppSettings = + serde_json::from_str(&value_json).context("failed to deserialize application settings")?; + if stored_schema_version != settings.schema_version { + return Err(anyhow!( + "settings schema version mismatch between database marker and JSON payload" + )); + } + if stored_schema_version != CURRENT_SETTINGS_SCHEMA_VERSION { + return Err(anyhow!( + "unsupported settings schema version {}; supported version is {}", + stored_schema_version, + CURRENT_SETTINGS_SCHEMA_VERSION + )); + } + settings + .validate() + .map_err(|error| anyhow!("invalid persisted setting: {}", error.message))?; + Ok(settings) +} + +pub fn save_settings(conn: &Connection, settings: &AppSettings) -> Result<()> { + settings + .validate() + .map_err(|error| anyhow!("invalid setting: {}", error.message))?; + let value_json = serde_json::to_string(settings)?; + let transaction = conn.unchecked_transaction()?; + transaction.execute( + "INSERT INTO app_settings (singleton_id, schema_version, value_json, updated_at) + VALUES (1, ?1, ?2, ?3) + ON CONFLICT(singleton_id) DO UPDATE SET + schema_version=excluded.schema_version, + value_json=excluded.value_json, + updated_at=excluded.updated_at", + params![settings.schema_version, value_json, now()], + )?; + transaction.commit()?; + Ok(()) +} + fn row_to_server(row: &rusqlite::Row) -> rusqlite::Result { let protocols_json: String = row.get("protocols_json")?; let tags_json: String = row.get("tags_json")?; @@ -108,6 +201,9 @@ fn row_to_server(row: &rusqlite::Row) -> rusqlite::Result { name: row.get("name")?, host: row.get("host")?, port: row.get("port")?, + ftp_port: row.get("ftp_port")?, + rdp_port: row.get("rdp_port")?, + vnc_port: row.get("vnc_port")?, username: row.get("username")?, protocols: serde_json::from_str(&protocols_json).unwrap_or_default(), auth_type: row.get("auth_type")?, @@ -143,10 +239,38 @@ pub fn upsert_server(conn: &Connection, input: &ServerInput) -> Result { let ts = now(); if let Some(id) = &input.id { - conn.execute( + let updated = conn.execute( "UPDATE servers SET name=?2, host=?3, port=?4, username=?5, protocols_json=?6, auth_type=?7, private_key_path=?8, tags_json=?9, group_name=?10, - environment=?11, notes=?12, updated_at=?13 WHERE id=?1", + environment=?11, notes=?12, updated_at=?13, ftp_port=?14, rdp_port=?15, + vnc_port=?16 WHERE id=?1", + params![ + id, + input.name, + input.host, + input.port, + input.username, + protocols, + input.auth_type, + input.private_key_path, + tags, + input.group_name, + input.environment, + input.notes, + ts, + input.ftp_port, + input.rdp_port, + input.vnc_port, + ], + )?; + if updated > 0 { + return Ok(id.clone()); + } + conn.execute( + "INSERT INTO servers (id,name,host,port,username,protocols_json,auth_type, + private_key_path,tags_json,group_name,environment,notes,created_at,updated_at, + ftp_port,rdp_port,vnc_port) + VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?13,?14,?15,?16)", params![ id, input.name, @@ -161,6 +285,9 @@ pub fn upsert_server(conn: &Connection, input: &ServerInput) -> Result { input.environment, input.notes, ts, + input.ftp_port, + input.rdp_port, + input.vnc_port, ], )?; Ok(id.clone()) @@ -168,8 +295,9 @@ pub fn upsert_server(conn: &Connection, input: &ServerInput) -> Result { let id = uuid::Uuid::new_v4().to_string(); conn.execute( "INSERT INTO servers (id,name,host,port,username,protocols_json,auth_type, - private_key_path,tags_json,group_name,environment,notes,created_at,updated_at) - VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?13)", + private_key_path,tags_json,group_name,environment,notes,created_at,updated_at, + ftp_port,rdp_port,vnc_port) + VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?13,?14,?15,?16)", params![ id, input.name, @@ -184,12 +312,71 @@ pub fn upsert_server(conn: &Connection, input: &ServerInput) -> Result { input.environment, input.notes, ts, + input.ftp_port, + input.rdp_port, + input.vnc_port, ], )?; Ok(id) } } +pub fn validate_server_input(input: &ServerInput) -> Result<()> { + if input.name.trim().is_empty() + || input.host.trim().is_empty() + || input.username.trim().is_empty() + { + return Err(anyhow!("name, host and username are required")); + } + if input.port == 0 { + return Err(anyhow!("SSH port must be between 1 and 65535")); + } + for (label, port) in [ + ("FTP", input.ftp_port), + ("RDP", input.rdp_port), + ("VNC", input.vnc_port), + ] { + if port == Some(0) { + return Err(anyhow!("{label} port must be between 1 and 65535")); + } + } + if !matches!(input.auth_type.as_str(), "password" | "key") { + return Err(anyhow!("unsupported authentication type")); + } + if input.protocols.is_empty() { + return Err(anyhow!("at least one protocol is required")); + } + for protocol in &input.protocols { + if !matches!(protocol.as_str(), "ssh" | "sftp" | "ftp" | "rdp" | "vnc") { + return Err(anyhow!("unsupported protocol: {protocol}")); + } + } + if input.protocols.iter().any(|protocol| protocol == "ftp") && input.auth_type != "password" { + return Err(anyhow!("FTP profiles require password authentication")); + } + Ok(()) +} + +/// Persist the profile and credential metadata as one SQLite transaction. +/// Keyring mutation is coordinated by the caller because it is outside SQLite. +pub fn save_server_profile( + conn: &Connection, + input: &ServerInput, + secret_ref: Option<&str>, + clear_credential: bool, +) -> Result { + validate_server_input(input)?; + let tx = conn.unchecked_transaction()?; + let id = upsert_server(&tx, input)?; + if clear_credential { + tx.execute("DELETE FROM credentials WHERE server_id = ?1", params![id])?; + } else if let Some(secret_ref) = secret_ref { + record_credential(&tx, &id, secret_ref, &input.auth_type)?; + } + tx.commit()?; + Ok(id) +} + pub fn delete_server(conn: &Connection, id: &str) -> Result<()> { conn.execute("DELETE FROM credentials WHERE server_id = ?1", params![id])?; conn.execute("DELETE FROM servers WHERE id = ?1", params![id])?; @@ -198,12 +385,26 @@ pub fn delete_server(conn: &Connection, id: &str) -> Result<()> { /// Record that a credential reference exists for this server (the secret /// itself lives in the keyring). -pub fn record_credential(conn: &Connection, server_id: &str, secret_ref: &str, auth_type: &str) -> Result<()> { - conn.execute("DELETE FROM credentials WHERE server_id = ?1", params![server_id])?; +pub fn record_credential( + conn: &Connection, + server_id: &str, + secret_ref: &str, + auth_type: &str, +) -> Result<()> { + conn.execute( + "DELETE FROM credentials WHERE server_id = ?1", + params![server_id], + )?; conn.execute( "INSERT INTO credentials (id, server_id, secret_ref, auth_type, created_at) VALUES (?1, ?2, ?3, ?4, ?5)", - params![uuid::Uuid::new_v4().to_string(), server_id, secret_ref, auth_type, now()], + params![ + uuid::Uuid::new_v4().to_string(), + server_id, + secret_ref, + auth_type, + now() + ], )?; Ok(()) } @@ -223,7 +424,8 @@ fn row_to_runbook(row: &rusqlite::Row) -> rusqlite::Result { } pub fn list_runbooks(conn: &Connection) -> Result> { - let mut stmt = conn.prepare("SELECT * FROM runbooks ORDER BY builtin DESC, name COLLATE NOCASE")?; + let mut stmt = + conn.prepare("SELECT * FROM runbooks ORDER BY builtin DESC, name COLLATE NOCASE")?; let rows = stmt.query_map([], row_to_runbook)?; Ok(rows.collect::>>()?) } @@ -235,7 +437,12 @@ pub fn get_runbook(conn: &Connection, id: &str) -> Result { /// Insert a built-in runbook if a runbook with the same name does not already /// exist. Used to seed defaults on startup. -pub fn seed_builtin_runbook(conn: &Connection, name: &str, description: &str, yaml: &str) -> Result<()> { +pub fn seed_builtin_runbook( + conn: &Connection, + name: &str, + description: &str, + yaml: &str, +) -> Result<()> { let exists: i64 = conn.query_row( "SELECT COUNT(*) FROM runbooks WHERE name = ?1 AND builtin = 1", params![name], @@ -245,13 +452,25 @@ pub fn seed_builtin_runbook(conn: &Connection, name: &str, description: &str, ya conn.execute( "INSERT INTO runbooks (id,name,description,content_yaml,builtin,created_at,updated_at) VALUES (?1,?2,?3,?4,1,?5,?5)", - params![uuid::Uuid::new_v4().to_string(), name, description, yaml, now()], + params![ + uuid::Uuid::new_v4().to_string(), + name, + description, + yaml, + now() + ], )?; } Ok(()) } -pub fn save_runbook(conn: &Connection, name: &str, description: &str, yaml: &str, id: Option<&str>) -> Result { +pub fn save_runbook( + conn: &Connection, + name: &str, + description: &str, + yaml: &str, + id: Option<&str>, +) -> Result { let ts = now(); match id { Some(id) => { @@ -330,6 +549,112 @@ pub fn close_session(conn: &Connection, id: &str) -> Result<()> { Ok(()) } +pub fn list_sessions(conn: &Connection, limit: i64) -> Result> { + let limit = limit.clamp(1, 500); + let mut stmt = conn.prepare( + "SELECT id,server_id,protocol,started_at,ended_at,status + FROM sessions ORDER BY started_at DESC LIMIT ?1", + )?; + let rows = stmt.query_map(params![limit], |row| { + Ok(SessionRecord { + id: row.get("id")?, + server_id: row.get("server_id")?, + protocol: row.get("protocol")?, + started_at: row.get("started_at")?, + ended_at: row.get("ended_at")?, + status: row.get("status")?, + }) + })?; + Ok(rows.collect::>>()?) +} + +// ---------- command snippets ---------- + +fn normalize_snippet_tags(tags: &[String]) -> Vec { + let mut normalized = tags + .iter() + .map(|tag| tag.trim().to_lowercase()) + .filter(|tag| !tag.is_empty()) + .collect::>(); + normalized.sort(); + normalized.dedup(); + normalized +} + +pub fn validate_snippet_input(input: &CommandSnippetInput) -> Result<()> { + if input.label.trim().is_empty() { + return Err(anyhow!("snippet label is required")); + } + if input.command.trim().is_empty() { + return Err(anyhow!("snippet command is required")); + } + if input.label.chars().count() > 80 { + return Err(anyhow!("snippet label must be 80 characters or less")); + } + if input.command.chars().count() > 4000 { + return Err(anyhow!("snippet command must be 4000 characters or less")); + } + if normalize_snippet_tags(&input.tags).len() > 16 { + return Err(anyhow!("snippet can target at most 16 tags")); + } + Ok(()) +} + +fn row_to_snippet(row: &rusqlite::Row) -> rusqlite::Result { + let tags_json: String = row.get("tags_json")?; + Ok(CommandSnippet { + id: row.get("id")?, + label: row.get("label")?, + command: row.get("command")?, + tags: serde_json::from_str(&tags_json).unwrap_or_default(), + created_at: row.get("created_at")?, + updated_at: row.get("updated_at")?, + }) +} + +pub fn list_command_snippets(conn: &Connection) -> Result> { + let mut stmt = conn.prepare("SELECT * FROM command_snippets ORDER BY label COLLATE NOCASE")?; + let rows = stmt.query_map([], row_to_snippet)?; + Ok(rows.collect::>>()?) +} + +pub fn save_command_snippet( + conn: &Connection, + input: &CommandSnippetInput, +) -> Result { + validate_snippet_input(input)?; + let id = input + .id + .as_deref() + .filter(|id| !id.trim().is_empty()) + .map(str::to_owned) + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + let label = input.label.trim(); + let command = input.command.trim(); + let tags = normalize_snippet_tags(&input.tags); + let tags_json = serde_json::to_string(&tags)?; + let ts = now(); + let updated = conn.execute( + "UPDATE command_snippets SET label=?2, command=?3, tags_json=?4, updated_at=?5 + WHERE id=?1", + params![&id, label, command, tags_json, ts], + )?; + if updated == 0 { + conn.execute( + "INSERT INTO command_snippets (id,label,command,tags_json,created_at,updated_at) + VALUES (?1,?2,?3,?4,?5,?5)", + params![&id, label, command, tags_json, ts], + )?; + } + let mut stmt = conn.prepare("SELECT * FROM command_snippets WHERE id = ?1")?; + Ok(stmt.query_row(params![&id], row_to_snippet)?) +} + +pub fn delete_command_snippet(conn: &Connection, id: &str) -> Result<()> { + conn.execute("DELETE FROM command_snippets WHERE id = ?1", params![id])?; + Ok(()) +} + // ---------- tunnels ---------- pub fn insert_tunnel(conn: &Connection, t: &Tunnel) -> Result<()> { @@ -345,7 +670,10 @@ pub fn insert_tunnel(conn: &Connection, t: &Tunnel) -> Result<()> { } pub fn set_tunnel_status(conn: &Connection, id: &str, status: &str) -> Result<()> { - conn.execute("UPDATE tunnels SET status=?2 WHERE id=?1", params![id, status])?; + conn.execute( + "UPDATE tunnels SET status=?2 WHERE id=?1", + params![id, status], + )?; Ok(()) } @@ -366,3 +694,283 @@ pub fn list_tunnels(conn: &Connection) -> Result> { })?; Ok(rows.collect::>>()?) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::settings::{AppSettings, Theme}; + + fn input(auth_type: &str) -> ServerInput { + ServerInput { + id: None, + name: "server".into(), + host: "example.test".into(), + port: 22, + ftp_port: Some(21), + rdp_port: Some(3389), + vnc_port: Some(5900), + username: "ops".into(), + protocols: if auth_type == "password" { + vec!["ssh".into(), "ftp".into()] + } else { + vec!["ssh".into()] + }, + auth_type: auth_type.into(), + private_key_path: None, + tags: vec![], + group_name: None, + environment: "dev".into(), + notes: None, + secret: None, + } + } + + #[test] + fn migrates_legacy_server_table_with_protocol_ports() { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch( + "CREATE TABLE servers ( + id TEXT PRIMARY KEY, name TEXT NOT NULL, host TEXT NOT NULL, + port INTEGER NOT NULL, username TEXT NOT NULL, + protocols_json TEXT NOT NULL, auth_type TEXT NOT NULL, + private_key_path TEXT, tags_json TEXT NOT NULL, group_name TEXT, + environment TEXT NOT NULL, notes TEXT, created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + );", + ) + .unwrap(); + migrate(&conn).unwrap(); + migrate(&conn).unwrap(); + let mut stmt = conn.prepare("PRAGMA table_info(servers)").unwrap(); + let columns = stmt + .query_map([], |row| row.get::<_, String>(1)) + .unwrap() + .collect::>>() + .unwrap(); + assert!(columns.contains(&"ftp_port".to_string())); + assert!(columns.contains(&"rdp_port".to_string())); + assert!(columns.contains(&"vnc_port".to_string())); + } + + #[test] + fn validates_profile_before_persistence() { + let mut invalid = input("key"); + invalid.port = 0; + assert!(validate_server_input(&invalid) + .unwrap_err() + .to_string() + .contains("SSH port")); + invalid.port = 22; + invalid.protocols.push("telnet".into()); + assert!(validate_server_input(&invalid).is_err()); + invalid.protocols = vec!["ftp".into()]; + assert!(validate_server_input(&invalid) + .unwrap_err() + .to_string() + .contains("password authentication")); + } + + #[test] + fn switching_to_key_auth_clears_credential_metadata_atomically() { + let conn = Connection::open_in_memory().unwrap(); + migrate(&conn).unwrap(); + let password = input("password"); + let id = save_server_profile(&conn, &password, Some("server::test"), false).unwrap(); + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM credentials", [], |row| row.get(0)) + .unwrap(); + assert_eq!(count, 1); + + let mut key = input("key"); + key.id = Some(id); + save_server_profile(&conn, &key, None, true).unwrap(); + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM credentials", [], |row| row.get(0)) + .unwrap(); + assert_eq!(count, 0); + } + + #[test] + fn explicit_new_id_is_inserted_when_no_row_exists() { + let conn = Connection::open_in_memory().unwrap(); + migrate(&conn).unwrap(); + let mut value = input("key"); + value.id = Some("preallocated-id".into()); + let id = save_server_profile(&conn, &value, None, true).unwrap(); + assert_eq!(id, "preallocated-id"); + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM servers WHERE id='preallocated-id'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(count, 1); + } + + #[test] + fn sessions_history_lists_recent_entries_and_clamps_limit() { + let conn = Connection::open_in_memory().unwrap(); + migrate(&conn).unwrap(); + + open_session(&conn, "older", "server-1", "ssh").unwrap(); + close_session(&conn, "older").unwrap(); + open_session(&conn, "newer", "server-2", "ssh").unwrap(); + + let sessions = list_sessions(&conn, 1).unwrap(); + assert_eq!(sessions.len(), 1); + assert_eq!(sessions[0].id, "newer"); + assert_eq!(sessions[0].status, "open"); + + let sessions = list_sessions(&conn, -10).unwrap(); + assert_eq!(sessions.len(), 1); + } + + #[test] + fn command_snippets_are_validated_normalized_and_updated() { + let conn = Connection::open_in_memory().unwrap(); + migrate(&conn).unwrap(); + let snippet = save_command_snippet( + &conn, + &CommandSnippetInput { + id: None, + label: " Disk check ".into(), + command: " df -h ".into(), + tags: vec!["Prod".into(), " prod ".into(), "db".into(), "".into()], + }, + ) + .unwrap(); + + assert_eq!(snippet.label, "Disk check"); + assert_eq!(snippet.command, "df -h"); + assert_eq!(snippet.tags, vec!["db".to_string(), "prod".to_string()]); + + let updated = save_command_snippet( + &conn, + &CommandSnippetInput { + id: Some(snippet.id.clone()), + label: "Memory".into(), + command: "free -m".into(), + tags: vec![], + }, + ) + .unwrap(); + assert_eq!(updated.id, snippet.id); + assert_eq!(updated.label, "Memory"); + assert!(updated.tags.is_empty()); + assert_eq!(list_command_snippets(&conn).unwrap().len(), 1); + + assert!(save_command_snippet( + &conn, + &CommandSnippetInput { + id: None, + label: "".into(), + command: "uptime".into(), + tags: vec![], + }, + ) + .is_err()); + } + + #[test] + fn empty_migrated_database_loads_default_settings() { + let conn = Connection::open_in_memory().unwrap(); + migrate(&conn).unwrap(); + assert_eq!(load_settings(&conn).unwrap(), AppSettings::default()); + } + + #[test] + fn settings_save_reload_and_replace_singleton_atomically() { + let conn = Connection::open_in_memory().unwrap(); + migrate(&conn).unwrap(); + let first = AppSettings { + theme: Theme::Light, + health_refresh_interval_ms: 5000, + ..AppSettings::default() + }; + save_settings(&conn, &first).unwrap(); + assert_eq!(load_settings(&conn).unwrap(), first); + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM app_settings", [], |row| row.get(0)) + .unwrap(); + assert_eq!(count, 1); + + let second = AppSettings { + theme: Theme::Dark, + ..AppSettings::default() + }; + save_settings(&conn, &second).unwrap(); + assert_eq!(load_settings(&conn).unwrap(), second); + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM app_settings", [], |row| row.get(0)) + .unwrap(); + assert_eq!(count, 1); + } + + #[test] + fn load_settings_rejects_database_and_json_schema_version_mismatch() { + let conn = Connection::open_in_memory().unwrap(); + migrate(&conn).unwrap(); + let value_json = serde_json::to_string(&AppSettings::default()).unwrap(); + conn.execute( + "INSERT INTO app_settings (singleton_id, schema_version, value_json, updated_at) + VALUES (1, 2, ?1, ?2)", + params![value_json, now()], + ) + .unwrap(); + + let error = load_settings(&conn).unwrap_err().to_string(); + assert!(error.contains("schema version mismatch")); + } + + #[test] + fn load_settings_rejects_unsupported_schema_version() { + let conn = Connection::open_in_memory().unwrap(); + migrate(&conn).unwrap(); + let settings = AppSettings { + schema_version: 2, + ..AppSettings::default() + }; + conn.execute( + "INSERT INTO app_settings (singleton_id, schema_version, value_json, updated_at) + VALUES (1, 2, ?1, ?2)", + params![serde_json::to_string(&settings).unwrap(), now()], + ) + .unwrap(); + + let error = load_settings(&conn).unwrap_err().to_string(); + assert!(error.contains("unsupported settings schema version")); + } + + #[test] + fn invalid_save_preserves_previously_persisted_settings() { + let conn = Connection::open_in_memory().unwrap(); + migrate(&conn).unwrap(); + let original = AppSettings { + theme: Theme::Light, + ..AppSettings::default() + }; + save_settings(&conn, &original).unwrap(); + let invalid = AppSettings { + health_refresh_interval_ms: 999, + ..AppSettings::default() + }; + + assert!(save_settings(&conn, &invalid).is_err()); + assert_eq!(load_settings(&conn).unwrap(), original); + } + + #[test] + fn load_settings_rejects_corrupt_json() { + let conn = Connection::open_in_memory().unwrap(); + migrate(&conn).unwrap(); + conn.execute( + "INSERT INTO app_settings (singleton_id, schema_version, value_json, updated_at) + VALUES (1, 1, 'not-json', ?1)", + params![now()], + ) + .unwrap(); + + assert!(load_settings(&conn).is_err()); + } +} diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs new file mode 100644 index 0000000..38ca33e --- /dev/null +++ b/src-tauri/src/error.rs @@ -0,0 +1,106 @@ +use std::collections::BTreeMap; +use std::fmt::Display; + +use serde::Serialize; + +/// Stable, safe error payload returned by backend commands. +#[derive(Debug, Clone, Serialize)] +pub struct DomainError { + pub code: &'static str, + pub message: String, + pub retryable: bool, + pub correlation_id: String, + pub context: BTreeMap, +} + +pub type CommandResult = Result; + +impl DomainError { + pub fn validation(field: impl Into, message: impl Into) -> Self { + let mut context = BTreeMap::new(); + context.insert("field".to_string(), field.into()); + Self { + code: "validation.invalid_value", + message: message.into(), + retryable: false, + correlation_id: uuid::Uuid::new_v4().to_string(), + context, + } + } + + /// A remote operation (ssh/scp/sftp) failed in an expected, recoverable + /// way. Unlike `internal`, the message is shown to the user verbatim: + /// it is ssh/scp's own stderr or a static description we wrote + /// ourselves, never a secret or an internal stack trace. + pub fn remote(message: impl Into) -> Self { + Self { + code: "remote.operation_failed", + message: message.into(), + retryable: true, + correlation_id: uuid::Uuid::new_v4().to_string(), + context: BTreeMap::new(), + } + } + + pub fn internal(error: impl Display) -> Self { + let correlation_id = uuid::Uuid::new_v4().to_string(); + eprintln!("internal backend error [{correlation_id}]: {error}"); + Self { + code: "internal.unexpected", + message: "An unexpected internal error occurred.".to_string(), + retryable: false, + correlation_id, + context: BTreeMap::new(), + } + } +} + +#[cfg(test)] +mod tests { + use super::DomainError; + + #[test] + fn validation_error_serializes_stable_contract_and_field_context() { + let serialized = serde_json::to_value(DomainError::validation( + "server.password", + "a password is required for password authentication", + )) + .expect("validation error should serialize"); + + assert_eq!(serialized["code"], "validation.invalid_value"); + assert_eq!(serialized["retryable"], false); + assert!(serialized["correlation_id"] + .as_str() + .is_some_and(|id| uuid::Uuid::parse_str(id).is_ok())); + assert_eq!(serialized["context"]["field"], "server.password"); + } + + #[test] + fn remote_error_preserves_diagnostic_message_for_the_user() { + let error = DomainError::remote( + "Received disconnect from 10.0.0.1 port 22:2: Too many authentication failures", + ); + + assert_eq!(error.code, "remote.operation_failed"); + assert!(error.retryable); + assert_eq!( + error.message, + "Received disconnect from 10.0.0.1 port 22:2: Too many authentication failures" + ); + assert!(uuid::Uuid::parse_str(&error.correlation_id).is_ok()); + } + + #[test] + fn internal_error_serialization_never_exposes_diagnostics() { + let error = DomainError::internal("secret-canary-value"); + assert_eq!(error.message, "An unexpected internal error occurred."); + assert!(!error.retryable); + assert!(error.context.is_empty()); + assert!(uuid::Uuid::parse_str(&error.correlation_id).is_ok()); + + let serialized = serde_json::to_string(&error).expect("internal error should serialize"); + + assert!(!serialized.contains("secret-canary-value")); + assert!(serialized.contains("internal.unexpected")); + } +} diff --git a/src-tauri/src/ftp_manager.rs b/src-tauri/src/ftp_manager.rs index 838576f..d870d5e 100644 --- a/src-tauri/src/ftp_manager.rs +++ b/src-tauri/src/ftp_manager.rs @@ -6,7 +6,6 @@ //! argv, keeping passwords out of the process list. use std::io::Write; -use std::path::Path; use std::process::{Command, Stdio}; use anyhow::{anyhow, Result}; @@ -16,7 +15,7 @@ use crate::vault; pub fn list_dir(server: &Server, path: &str) -> Result> { let url = ftp_url(server, path, true); - let out = run_curl(server, &["--fail", "--silent", "--show-error", "--path-as-is", &url])?; + let out = run_curl(server, &base_args_with_url(url))?; if !out.status.success() { return Err(anyhow!(String::from_utf8_lossy(&out.stderr).to_string())); } @@ -28,22 +27,32 @@ pub fn list_dir(server: &Server, path: &str) -> Result> { } files.push(parse_list_line(line)); } - files.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then(a.name.to_lowercase().cmp(&b.name.to_lowercase()))); + files.sort_by(|a, b| { + b.is_dir + .cmp(&a.is_dir) + .then(a.name.to_lowercase().cmp(&b.name.to_lowercase())) + }); Ok(files) } pub fn upload(server: &Server, local_path: &str, remote_dir: &str) -> Result<()> { let url = ftp_url(server, remote_dir, true); - let out = run_curl(server, &["--fail", "--silent", "--show-error", "--path-as-is", "--ftp-create-dirs", "--upload-file", local_path, &url])?; + let mut args = base_args(); + args.extend([ + "--ftp-create-dirs".into(), + "--upload-file".into(), + local_path.into(), + url, + ]); + let out = run_curl(server, &args)?; status_result(out, "upload") } -pub fn download(server: &Server, remote_path: &str, local_dir: &str) -> Result<()> { +pub fn download(server: &Server, remote_path: &str, local_path: &str) -> Result<()> { let url = ftp_url(server, remote_path, false); - let name = remote_basename(remote_path); - let local_path = Path::new(local_dir).join(name); - let local_path = local_path.to_string_lossy().to_string(); - let out = run_curl(server, &["--fail", "--silent", "--show-error", "--path-as-is", "--output", &local_path, &url])?; + let mut args = base_args(); + args.extend(["--output".into(), local_path.into(), url]); + let out = run_curl(server, &args)?; status_result(out, "download") } @@ -56,27 +65,46 @@ pub fn delete(server: &Server, remote_path: &str) -> Result<()> { } pub fn rename(server: &Server, from: &str, to: &str) -> Result<()> { - run_quote(server, &[ - format!("RNFR {}", ftp_command_path(from)), - format!("RNTO {}", ftp_command_path(to)), - ]) + run_quote( + server, + &[ + format!("RNFR {}", ftp_command_path(from)), + format!("RNTO {}", ftp_command_path(to)), + ], + ) } fn run_quote(server: &Server, quotes: &[String]) -> Result<()> { - let mut args = vec!["--fail", "--silent", "--show-error", "--path-as-is"]; - let mut owned_args: Vec = Vec::new(); - for quote in quotes { - args.push("--quote"); - owned_args.push(quote.clone()); - args.push(owned_args.last().unwrap()); - } + let mut args = quote_args(quotes); let url = ftp_url(server, "/", true); - args.push(&url); + args.push(url); let out = run_curl(server, &args)?; status_result(out, "ftp command") } -fn run_curl(server: &Server, args: &[&str]) -> Result { +fn base_args() -> Vec { + ["--fail", "--silent", "--show-error", "--path-as-is"] + .into_iter() + .map(str::to_owned) + .collect() +} + +fn base_args_with_url(url: String) -> Vec { + let mut args = base_args(); + args.push(url); + args +} + +fn quote_args(quotes: &[String]) -> Vec { + let mut args = base_args(); + for quote in quotes { + args.push("--quote".into()); + args.push(quote.clone()); + } + args +} + +fn run_curl(server: &Server, args: &[String]) -> Result { let mut child = Command::new("curl") .arg("--config") .arg("-") @@ -85,24 +113,37 @@ fn run_curl(server: &Server, args: &[&str]) -> Result { .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() - .map_err(|e| anyhow!("failed to run curl for FTP: {e}. Install curl to use FTP profiles."))?; + .map_err(|e| { + anyhow!("failed to run curl for FTP: {e}. Install curl to use FTP profiles.") + })?; - let password = vault::get_secret(&vault::secret_ref(&server.id)).ok().flatten().unwrap_or_default(); - let config = format!("user = \"{}\"\n", curl_cfg_value(&format!("{}:{password}", server.username))); + let password = vault::get_secret(&vault::secret_ref(&server.id)) + .ok() + .flatten() + .unwrap_or_default(); + let config = format!( + "user = \"{}\"\n", + curl_cfg_value(&format!("{}:{password}", server.username)) + ); child .stdin .as_mut() .ok_or_else(|| anyhow!("failed to open curl stdin"))? .write_all(config.as_bytes())?; - child.wait_with_output().map_err(|e| anyhow!("failed to read curl output: {e}")) + child + .wait_with_output() + .map_err(|e| anyhow!("failed to read curl output: {e}")) } fn status_result(out: std::process::Output, action: &str) -> Result<()> { if out.status.success() { Ok(()) } else { - Err(anyhow!("{action} failed: {}", String::from_utf8_lossy(&out.stderr))) + Err(anyhow!( + "{action} failed: {}", + String::from_utf8_lossy(&out.stderr) + )) } } @@ -128,9 +169,13 @@ fn parse_list_line(line: &str) -> RemoteFile { } fn ftp_url(server: &Server, path: &str, directory: bool) -> String { - let port = if server.port == 22 { 21 } else { server.port }; let normalized = normalize_path(path, directory); - format!("ftp://{}:{}{}", server.host, port, percent_encode_path(&normalized)) + format!( + "ftp://{}:{}{}", + server.host, + server.ftp_port(), + percent_encode_path(&normalized) + ) } fn normalize_path(path: &str, directory: bool) -> String { @@ -151,7 +196,9 @@ fn percent_encode_path(path: &str) -> String { let mut encoded = String::with_capacity(path.len()); for byte in path.bytes() { match byte { - b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' => encoded.push(byte as char), + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' => { + encoded.push(byte as char) + } _ => encoded.push_str(&format!("%{byte:02X}")), } } @@ -166,10 +213,49 @@ fn ftp_command_path(path: &str) -> String { } } -fn remote_basename(path: &str) -> &str { - path.trim_end_matches('/').rsplit('/').next().filter(|name| !name.is_empty()).unwrap_or("download") +fn curl_cfg_value(value: &str) -> String { + value + .replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('\n', "") } -fn curl_cfg_value(value: &str) -> String { - value.replace('\\', "\\\\").replace('"', "\\\"").replace('\n', "") +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn builds_owned_quote_arguments_in_order() { + let quotes = vec!["RNFR /old name".to_string(), "RNTO /new name".to_string()]; + assert_eq!( + quote_args("es), + vec![ + "--fail", + "--silent", + "--show-error", + "--path-as-is", + "--quote", + "RNFR /old name", + "--quote", + "RNTO /new name", + ] + ); + } + + #[test] + fn normalizes_and_encodes_remote_paths() { + assert_eq!(normalize_path("folder name", true), "/folder name/"); + assert_eq!( + percent_encode_path("/folder name/file#1"), + "/folder%20name/file%231" + ); + } + + #[test] + fn parses_unix_list_entries_with_spaces() { + let file = parse_list_line("-rw-r--r-- 1 user group 42 Jan 01 12:00 report final.txt"); + assert_eq!(file.name, "report final.txt"); + assert_eq!(file.size, 42); + assert!(!file.is_dir); + } } diff --git a/src-tauri/src/health_collector.rs b/src-tauri/src/health_collector.rs index 15d18c3..42dd0ce 100644 --- a/src-tauri/src/health_collector.rs +++ b/src-tauri/src/health_collector.rs @@ -6,7 +6,7 @@ //! the previous snapshot held per-server in `HealthState`. //! //! Nothing is installed on the remote host; only standard /proc, /sys and -//! coreutils/`ss`/`systemctl`/`docker` reads are used. +//! coreutils/`ss`/`systemctl` reads are used. use std::collections::HashMap; use std::sync::Mutex; @@ -33,8 +33,6 @@ echo '@@PSCPU@@'; ps -eo pid,comm,%cpu,%mem --sort=-%cpu 2>/dev/null | head -11; echo '@@PSMEM@@'; ps -eo pid,comm,%cpu,%mem --sort=-%mem 2>/dev/null | head -11; echo '@@PORTS@@'; (ss -tulpen 2>/dev/null || ss -tuln 2>/dev/null) | head -60; echo '@@FAILED@@'; systemctl --failed --no-pager --plain --no-legend 2>/dev/null | head -40; -echo '@@DOCKERPS@@'; if command -v docker >/dev/null 2>&1; then docker ps --format '{{.Names}}|{{.Status}}|{{.Image}}' 2>/dev/null; fi; -echo '@@DOCKERSTATS@@'; if command -v docker >/dev/null 2>&1; then docker stats --no-stream --format '{{.Name}}|{{.CPUPerc}}|{{.MemPerc}}' 2>/dev/null; fi; echo '@@END@@' "#; @@ -55,15 +53,6 @@ pub struct ProcInfo { pub mem: f64, } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DockerContainer { - pub name: String, - pub status: String, - pub image: String, - pub cpu_percent: Option, - pub mem_percent: Option, -} - #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct HealthSnapshot { pub os_name: String, @@ -87,8 +76,6 @@ pub struct HealthSnapshot { pub top_mem: Vec, pub listening_ports: Vec, pub failed_services: Vec, - pub docker: Vec, - pub docker_available: bool, pub warnings: Vec, } @@ -122,12 +109,22 @@ impl HealthState { let sections = split_sections(&out.stdout); let now_ms = chrono::Utc::now().timestamp_millis(); - let mut snap = HealthSnapshot::default(); - - // OS / kernel / host - snap.os_name = parse_os_name(sections.get("OS").map(|s| s.as_str()).unwrap_or("")); - snap.kernel = sections.get("KERNEL").cloned().unwrap_or_default().trim().to_string(); - snap.hostname = sections.get("HOST").cloned().unwrap_or_default().trim().to_string(); + let mut snap = HealthSnapshot { + os_name: parse_os_name(sections.get("OS").map(|s| s.as_str()).unwrap_or("")), + kernel: sections + .get("KERNEL") + .cloned() + .unwrap_or_default() + .trim() + .to_string(), + hostname: sections + .get("HOST") + .cloned() + .unwrap_or_default() + .trim() + .to_string(), + ..HealthSnapshot::default() + }; // uptime if let Some(u) = sections.get("UPTIME") { @@ -145,7 +142,8 @@ impl HealthState { } // cpu (needs previous sample) - let (cpu_idle, cpu_total) = parse_cpu(sections.get("CPU").map(|s| s.as_str()).unwrap_or("")); + let (cpu_idle, cpu_total) = + parse_cpu(sections.get("CPU").map(|s| s.as_str()).unwrap_or("")); // memory let mem = parse_meminfo(sections.get("MEM").map(|s| s.as_str()).unwrap_or("")); @@ -169,7 +167,13 @@ impl HealthState { // ports snap.listening_ports = sections .get("PORTS") - .map(|s| s.lines().skip(1).map(|l| l.trim().to_string()).filter(|l| !l.is_empty()).collect()) + .map(|s| { + s.lines() + .skip(1) + .map(|l| l.trim().to_string()) + .filter(|l| !l.is_empty()) + .collect() + }) .unwrap_or_default(); // failed services @@ -183,14 +187,6 @@ impl HealthState { }) .unwrap_or_default(); - // docker - let (docker, available) = parse_docker( - sections.get("DOCKERPS").map(|s| s.as_str()).unwrap_or(""), - sections.get("DOCKERSTATS").map(|s| s.as_str()).unwrap_or(""), - ); - snap.docker = docker; - snap.docker_available = available; - // rates from previous sample { let mut guard = self.last.lock().unwrap(); @@ -238,16 +234,17 @@ fn build_warnings(s: &HealthSnapshot) -> Vec { } for d in &s.disks { if d.use_percent > 85.0 { - w.push(format!("Disk {} at {:.0}% ({})", d.mount, d.use_percent, d.filesystem)); + w.push(format!( + "Disk {} at {:.0}% ({})", + d.mount, d.use_percent, d.filesystem + )); } } if !s.failed_services.is_empty() { - w.push(format!("{} failed systemd service(s)", s.failed_services.len())); - } - for c in &s.docker { - if c.status.to_lowercase().contains("exited") { - w.push(format!("Docker container '{}' exited", c.name)); - } + w.push(format!( + "{} failed systemd service(s)", + s.failed_services.len() + )); } w } @@ -321,7 +318,6 @@ fn parse_meminfo(s: &str) -> (u64, u64, u64, u64) { return rest .trim() .trim_start_matches(':') - .trim() .split_whitespace() .next() .and_then(|v| v.parse().ok()) @@ -392,39 +388,6 @@ fn parse_ps(s: &str) -> Vec { out } -fn parse_docker(ps: &str, stats: &str) -> (Vec, bool) { - let ps = ps.trim(); - // No docker binary -> the section is empty. - if ps.is_empty() && stats.trim().is_empty() { - return (Vec::new(), false); - } - let mut stat_map: HashMap, Option)> = HashMap::new(); - for line in stats.lines() { - let p: Vec<&str> = line.split('|').collect(); - if p.len() == 3 { - let cpu = p[1].trim_end_matches('%').parse().ok(); - let mem = p[2].trim_end_matches('%').parse().ok(); - stat_map.insert(p[0].to_string(), (cpu, mem)); - } - } - let mut out = Vec::new(); - for line in ps.lines() { - let p: Vec<&str> = line.split('|').collect(); - if p.len() >= 3 { - let name = p[0].to_string(); - let (cpu, mem) = stat_map.get(&name).copied().unwrap_or((None, None)); - out.push(DockerContainer { - name, - status: p[1].to_string(), - image: p[2].to_string(), - cpu_percent: cpu, - mem_percent: mem, - }); - } - } - (out, true) -} - #[cfg(test)] mod tests { use super::*; @@ -483,24 +446,6 @@ mod tests { assert_eq!(procs[1].mem, 8.1); } - #[test] - fn docker_joins_ps_with_stats() { - let ps = "web|Up 3 hours|nginx:latest\napi|Exited (1) 2 min ago|api:1.0"; - let stats = "web|10.50%|2.10%"; - let (containers, available) = parse_docker(ps, stats); - assert!(available); - assert_eq!(containers.len(), 2); - assert_eq!(containers[0].cpu_percent, Some(10.50)); - assert_eq!(containers[1].cpu_percent, None); // no stats for exited - } - - #[test] - fn docker_absent_when_empty() { - let (containers, available) = parse_docker("", ""); - assert!(!available); - assert!(containers.is_empty()); - } - #[test] fn sections_split_on_markers() { let raw = "@@OS@@\nPRETTY_NAME=\"Arch Linux\"\n@@KERNEL@@\n6.0.0\n@@END@@\nignored"; @@ -512,10 +457,18 @@ mod tests { #[test] fn warnings_fire_on_thresholds() { - let mut s = HealthSnapshot::default(); - s.cpu_percent = 95.0; - s.mem_percent = 30.0; - s.disks.push(DiskInfo { filesystem: "/dev/sda1".into(), size_kb: 100, used_kb: 90, use_percent: 90.0, mount: "/".into() }); + let mut s = HealthSnapshot { + cpu_percent: 95.0, + mem_percent: 30.0, + ..HealthSnapshot::default() + }; + s.disks.push(DiskInfo { + filesystem: "/dev/sda1".into(), + size_kb: 100, + used_kb: 90, + use_percent: 90.0, + mount: "/".into(), + }); s.failed_services.push("foo.service".into()); let w = build_warnings(&s); assert!(w.iter().any(|x| x.contains("CPU"))); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d74b94f..f7562fa 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,19 +1,21 @@ //! RemoteOpsX backend entry point. //! //! Wires the module managers into Tauri's managed `AppState` and exposes the -//! command surface consumed by the React frontend. Each command translates -//! `anyhow` errors into strings so they surface cleanly in the UI. +//! command surface consumed by the React frontend. // Modules are `pub` so integration tests (in `tests/`) can drive the remote-ops // layer (ssh exec, health collection, runbook execution) against a real host. pub mod database; +pub mod error; pub mod ftp_manager; pub mod health_collector; pub mod models; pub mod pty_manager; pub mod rdp_adapter; pub mod runbook_runner; +pub mod settings; pub mod sftp_manager; +pub mod ssh_keys; pub mod ssh_manager; pub mod tunnel_manager; pub mod vault; @@ -24,9 +26,11 @@ use std::sync::Mutex; use rusqlite::Connection; use tauri::{AppHandle, Manager, State}; +use error::{CommandResult, DomainError}; use health_collector::HealthSnapshot; use models::*; use pty_manager::PtyManager; +use ssh_keys::SshKeyInfo; use tunnel_manager::TunnelManager; /// Shared application state, managed by Tauri and injected into commands. @@ -37,52 +41,132 @@ pub struct AppState { tunnels: TunnelManager, } -/// Convert any error into a string for transport to the frontend. -fn e(r: Result) -> Result { - r.map_err(|err| err.to_string()) +/// Convert an unexpected internal error (db, lock, etc.) into the safe +/// transport contract. The real message is redacted from the response and +/// only logged server-side, since it may contain paths or stack traces. +fn e(r: Result) -> CommandResult { + r.map_err(DomainError::internal) +} + +/// Convert a remote-operation error (ssh/scp/sftp) into the safe transport +/// contract. Unlike `e`, the diagnostic message is preserved for the user: +/// it is ssh/scp's own stderr or a static description we wrote ourselves, +/// which is what they need to fix a bad host/port/credential. +fn re(r: Result) -> CommandResult { + r.map_err(|err| DomainError::remote(err.to_string())) } /// Load a server profile by id. -fn load_server(state: &State, id: &str) -> Result { +fn load_server(state: &State, id: &str) -> CommandResult { let conn = state.db.lock().unwrap(); e(database::get_server(&conn, id)) } +fn settings_get_from_db(conn: &Connection) -> CommandResult { + e(database::load_settings(conn)) +} + +fn settings_save_to_db( + conn: &Connection, + settings: settings::AppSettings, +) -> CommandResult { + settings.validate()?; + e(database::save_settings(conn, &settings))?; + Ok(settings) +} + +// =================== Settings =================== + +#[tauri::command] +fn settings_get(state: State) -> CommandResult { + let conn = state + .db + .lock() + .map_err(|_| DomainError::internal("database lock poisoned"))?; + settings_get_from_db(&conn) +} + +#[tauri::command] +fn settings_save( + state: State, + settings: settings::AppSettings, +) -> CommandResult { + let conn = state + .db + .lock() + .map_err(|_| DomainError::internal("database lock poisoned"))?; + settings_save_to_db(&conn, settings) +} + // =================== Server Manager =================== #[tauri::command] -fn servers_list(state: State) -> Result, String> { +fn servers_list(state: State) -> CommandResult> { let conn = state.db.lock().unwrap(); e(database::list_servers(&conn)) } #[tauri::command] -fn server_get(state: State, id: String) -> Result { +fn server_get(state: State, id: String) -> CommandResult { load_server(&state, &id) } /// Create or update a profile. The transient `secret` is written to the OS /// keyring (never SQLite); only a reference is recorded. #[tauri::command] -fn server_save(state: State, input: ServerInput) -> Result { - let id = { +fn server_save(state: State, mut input: ServerInput) -> CommandResult { + database::validate_server_input(&input) + .map_err(|err| DomainError::validation("server", err.to_string()))?; + if input.id.is_none() { + input.id = Some(uuid::Uuid::new_v4().to_string()); + } + let id = input.id.clone().expect("id assigned above"); + let sref = vault::secret_ref(&id); + + if input.auth_type == "key" { let conn = state.db.lock().unwrap(); - e(database::upsert_server(&conn, &input))? - }; + let saved = e(database::save_server_profile(&conn, &input, None, true))?; + drop(conn); + let _ = vault::delete_secret(&sref); + return Ok(saved); + } + + let supplied = input.secret.as_deref().filter(|secret| !secret.is_empty()); + let previous = e(vault::get_secret(&sref))?; + if supplied.is_none() && previous.is_none() { + return Err(DomainError::validation( + "secret", + "a password is required for password authentication", + )); + } + if let Some(secret) = supplied { + e(vault::set_secret(&sref, secret))?; + } - if let Some(secret) = &input.secret { - if !secret.is_empty() { - let sref = vault::secret_ref(&id); - e(vault::set_secret(&sref, secret))?; - let conn = state.db.lock().unwrap(); - e(database::record_credential(&conn, &id, &sref, &input.auth_type))?; + let saved = { + let conn = state.db.lock().unwrap(); + database::save_server_profile(&conn, &input, Some(&sref), false) + }; + match saved { + Ok(saved) => Ok(saved), + Err(err) => { + if supplied.is_some() { + match previous { + Some(previous) => { + let _ = vault::set_secret(&sref, &previous); + } + None => { + let _ = vault::delete_secret(&sref); + } + } + } + Err(DomainError::internal(err)) } } - Ok(id) } #[tauri::command] -fn server_delete(state: State, id: String) -> Result<(), String> { +fn server_delete(state: State, id: String) -> CommandResult<()> { // Best-effort secret cleanup; ignore missing keyring entries. let _ = vault::delete_secret(&vault::secret_ref(&id)); state.health.forget(&id); @@ -100,9 +184,11 @@ fn pty_spawn( server_id: String, cols: u16, rows: u16, -) -> Result<(), String> { +) -> CommandResult<()> { let server = load_server(&state, &server_id)?; - e(state.pty.spawn(app, session_id.clone(), &server, cols, rows))?; + re(state + .pty + .spawn(app, session_id.clone(), &server, cols, rows))?; // Record the session in SQLite for the sessions history. let conn = state.db.lock().unwrap(); let _ = database::open_session(&conn, &session_id, &server_id, "ssh"); @@ -110,56 +196,84 @@ fn pty_spawn( } #[tauri::command] -fn pty_write(state: State, session_id: String, data: Vec) -> Result<(), String> { +fn pty_write(state: State, session_id: String, data: Vec) -> CommandResult<()> { e(state.pty.write(&session_id, &data)) } #[tauri::command] -fn pty_resize(state: State, session_id: String, cols: u16, rows: u16) -> Result<(), String> { +fn pty_resize( + state: State, + session_id: String, + cols: u16, + rows: u16, +) -> CommandResult<()> { e(state.pty.resize(&session_id, cols, rows)) } #[tauri::command] -fn pty_close(state: State, session_id: String) -> Result<(), String> { +fn pty_close(state: State, session_id: String) -> CommandResult<()> { e(state.pty.close(&session_id))?; let conn = state.db.lock().unwrap(); let _ = database::close_session(&conn, &session_id); Ok(()) } +// =================== SSH keys =================== + +#[tauri::command] +fn ssh_keys_list() -> CommandResult> { + re(ssh_keys::discover_local_keys()) +} + +#[tauri::command] +fn ssh_key_install( + state: State, + server_id: String, + private_key_path: String, +) -> CommandResult { + let server = load_server(&state, &server_id)?; + let public_key = re(ssh_keys::public_key_for_private_key(private_key_path))?; + let command = ssh_keys::authorized_keys_install_command(&public_key); + re(ssh_manager::run_remote(&server, &command)) +} + // =================== Live Health =================== #[tauri::command] -fn health_collect(state: State, server_id: String) -> Result { +fn health_collect(state: State, server_id: String) -> CommandResult { let server = load_server(&state, &server_id)?; - e(state.health.collect(&server)) + re(state.health.collect(&server)) } // =================== Generic remote exec (logs panel, etc.) =================== #[tauri::command] -fn run_remote(state: State, server_id: String, command: String) -> Result { +fn run_remote( + state: State, + server_id: String, + command: String, +) -> CommandResult { let server = load_server(&state, &server_id)?; - e(ssh_manager::run_remote(&server, &command)) + re(ssh_manager::run_remote(&server, &command)) } // =================== Runbooks =================== #[tauri::command] -fn runbooks_list(state: State) -> Result, String> { +fn runbooks_list(state: State) -> CommandResult> { let conn = state.db.lock().unwrap(); e(database::list_runbooks(&conn)) } #[tauri::command] -fn runbook_get(state: State, id: String) -> Result { +fn runbook_get(state: State, id: String) -> CommandResult { let conn = state.db.lock().unwrap(); e(database::get_runbook(&conn, &id)) } /// Parse a runbook's YAML into its executable spec (for the pre-run preview). #[tauri::command] -fn runbook_spec(state: State, id: String) -> Result { +fn runbook_spec(state: State, id: String) -> CommandResult { let rb = { let conn = state.db.lock().unwrap(); e(database::get_runbook(&conn, &id))? @@ -174,17 +288,28 @@ fn runbook_save( name: String, description: String, content_yaml: String, -) -> Result { +) -> CommandResult { // Validate YAML before saving. - e(runbook_runner::parse(&content_yaml))?; + runbook_runner::parse(&content_yaml) + .map_err(|err| DomainError::validation("content_yaml", err.to_string()))?; let conn = state.db.lock().unwrap(); - e(database::save_runbook(&conn, &name, &description, &content_yaml, id.as_deref())) + e(database::save_runbook( + &conn, + &name, + &description, + &content_yaml, + id.as_deref(), + )) } /// Run a single runbook step over SSH. The frontend drives the loop so it can /// pause for confirmation between destructive steps. #[tauri::command] -fn runbook_run_step(state: State, server_id: String, step: RunbookStep) -> Result { +fn runbook_run_step( + state: State, + server_id: String, + step: RunbookStep, +) -> CommandResult { let server = load_server(&state, &server_id)?; Ok(runbook_runner::run_step(&server, &step)) } @@ -198,7 +323,7 @@ fn runbook_record_run( started_at: String, status: String, results: Vec, -) -> Result { +) -> CommandResult { let run = RunbookRun { id: uuid::Uuid::new_v4().to_string(), runbook_id, @@ -216,15 +341,53 @@ fn runbook_record_run( } #[tauri::command] -fn runbook_runs_list(state: State, limit: Option) -> Result, String> { +fn runbook_runs_list(state: State, limit: Option) -> CommandResult> { let conn = state.db.lock().unwrap(); e(database::list_runbook_runs(&conn, limit.unwrap_or(50))) } +// =================== Sessions history =================== + +#[tauri::command] +fn sessions_list(state: State, limit: Option) -> CommandResult> { + let conn = state.db.lock().unwrap(); + e(database::list_sessions(&conn, limit.unwrap_or(100))) +} + +// =================== Command snippets =================== + +#[tauri::command] +fn command_snippets_list(state: State) -> CommandResult> { + let conn = state.db.lock().unwrap(); + e(database::list_command_snippets(&conn)) +} + +#[tauri::command] +fn command_snippet_save( + state: State, + input: CommandSnippetInput, +) -> CommandResult { + database::validate_snippet_input(&input) + .map_err(|err| DomainError::validation("snippet", err.to_string()))?; + let conn = state.db.lock().unwrap(); + e(database::save_command_snippet(&conn, &input)) +} + +#[tauri::command] +fn command_snippet_delete(state: State, id: String) -> CommandResult<()> { + let conn = state.db.lock().unwrap(); + e(database::delete_command_snippet(&conn, &id)) +} + // =================== Services (systemd) =================== #[tauri::command] -fn service_action(state: State, server_id: String, action: String, unit: String) -> Result { +fn service_action( + state: State, + server_id: String, + action: String, + unit: String, +) -> CommandResult { let server = load_server(&state, &server_id)?; let unit_q = shell_quote(&unit); let cmd = match action.as_str() { @@ -234,90 +397,118 @@ fn service_action(state: State, server_id: String, action: String, uni "stop" => format!("sudo systemctl stop {unit_q}"), "restart" => format!("sudo systemctl restart {unit_q}"), "list-failed" => "systemctl --failed --no-pager --plain --no-legend".to_string(), - other => return Err(format!("unknown service action: {other}")), - }; - e(ssh_manager::run_remote(&server, &cmd)) -} - -// =================== Docker =================== - -#[tauri::command] -fn docker_action(state: State, server_id: String, action: String, container: Option) -> Result { - let server = load_server(&state, &server_id)?; - let c = container.map(|c| shell_quote(&c)).unwrap_or_default(); - let cmd = match action.as_str() { - "ps" => "docker ps -a --format '{{.Names}}|{{.Status}}|{{.Image}}|{{.Ports}}'".to_string(), - "stats" => "docker stats --no-stream --format '{{.Name}}|{{.CPUPerc}}|{{.MemPerc}}|{{.MemUsage}}'".to_string(), - "compose-ps" => "docker compose ps 2>/dev/null || true".to_string(), - "logs" => format!("docker logs --tail 200 {c}"), - "start" => format!("docker start {c}"), - "stop" => format!("docker stop {c}"), - "restart" => format!("docker restart {c}"), - other => return Err(format!("unknown docker action: {other}")), + other => { + return Err(DomainError::validation( + "action", + format!("unknown service action: {other}"), + )) + } }; - e(ssh_manager::run_remote(&server, &cmd)) + re(ssh_manager::run_remote(&server, &cmd)) } // =================== SFTP =================== #[tauri::command] -fn sftp_list(state: State, server_id: String, path: String) -> Result, String> { +fn sftp_list( + state: State, + server_id: String, + path: String, +) -> CommandResult> { let server = load_server(&state, &server_id)?; - e(sftp_manager::list_dir(&server, &path)) + re(sftp_manager::list_dir(&server, &path)) } #[tauri::command] -fn sftp_upload(state: State, server_id: String, local_path: String, remote_dir: String) -> Result<(), String> { +fn sftp_upload( + state: State, + server_id: String, + local_path: String, + remote_dir: String, +) -> CommandResult<()> { let server = load_server(&state, &server_id)?; - e(sftp_manager::upload(&server, &local_path, &remote_dir)) + re(sftp_manager::upload(&server, &local_path, &remote_dir)) } #[tauri::command] -fn sftp_download(state: State, server_id: String, remote_path: String, local_dir: String) -> Result<(), String> { +fn sftp_download( + state: State, + server_id: String, + remote_path: String, + local_path: String, +) -> CommandResult<()> { let server = load_server(&state, &server_id)?; - e(sftp_manager::download(&server, &remote_path, &local_dir)) + re(sftp_manager::download(&server, &remote_path, &local_path)) } #[tauri::command] -fn sftp_delete(state: State, server_id: String, remote_path: String) -> Result<(), String> { +fn sftp_delete( + state: State, + server_id: String, + remote_path: String, +) -> CommandResult<()> { let server = load_server(&state, &server_id)?; - e(sftp_manager::delete(&server, &remote_path)) + re(sftp_manager::delete(&server, &remote_path)) } #[tauri::command] -fn sftp_rename(state: State, server_id: String, from: String, to: String) -> Result<(), String> { +fn sftp_rename( + state: State, + server_id: String, + from: String, + to: String, +) -> CommandResult<()> { let server = load_server(&state, &server_id)?; - e(sftp_manager::rename(&server, &from, &to)) + re(sftp_manager::rename(&server, &from, &to)) } // =================== FTP =================== #[tauri::command] -fn ftp_list(state: State, server_id: String, path: String) -> Result, String> { +fn ftp_list( + state: State, + server_id: String, + path: String, +) -> CommandResult> { let server = load_server(&state, &server_id)?; e(ftp_manager::list_dir(&server, &path)) } #[tauri::command] -fn ftp_upload(state: State, server_id: String, local_path: String, remote_dir: String) -> Result<(), String> { +fn ftp_upload( + state: State, + server_id: String, + local_path: String, + remote_dir: String, +) -> CommandResult<()> { let server = load_server(&state, &server_id)?; e(ftp_manager::upload(&server, &local_path, &remote_dir)) } #[tauri::command] -fn ftp_download(state: State, server_id: String, remote_path: String, local_dir: String) -> Result<(), String> { +fn ftp_download( + state: State, + server_id: String, + remote_path: String, + local_path: String, +) -> CommandResult<()> { let server = load_server(&state, &server_id)?; - e(ftp_manager::download(&server, &remote_path, &local_dir)) + e(ftp_manager::download(&server, &remote_path, &local_path)) } #[tauri::command] -fn ftp_delete(state: State, server_id: String, remote_path: String) -> Result<(), String> { +fn ftp_delete(state: State, server_id: String, remote_path: String) -> CommandResult<()> { let server = load_server(&state, &server_id)?; e(ftp_manager::delete(&server, &remote_path)) } #[tauri::command] -fn ftp_rename(state: State, server_id: String, from: String, to: String) -> Result<(), String> { +fn ftp_rename( + state: State, + server_id: String, + from: String, + to: String, +) -> CommandResult<()> { let server = load_server(&state, &server_id)?; e(ftp_manager::rename(&server, &from, &to)) } @@ -325,27 +516,48 @@ fn ftp_rename(state: State, server_id: String, from: String, to: Strin // =================== Remote desktop =================== #[tauri::command] -fn rdp_launch(state: State, server_id: String, options: rdp_adapter::RdpOptions) -> Result<(), String> { +fn rdp_launch( + state: State, + server_id: String, + options: rdp_adapter::RdpOptions, +) -> CommandResult<()> { let server = load_server(&state, &server_id)?; e(rdp_adapter::launch(&server, &options)) } #[tauri::command] -fn vnc_launch(state: State, server_id: String, options: vnc_adapter::VncOptions) -> Result<(), String> { +fn vnc_launch( + state: State, + server_id: String, + options: vnc_adapter::VncOptions, +) -> CommandResult<()> { let server = load_server(&state, &server_id)?; e(vnc_adapter::launch(&server, &options)) } // =================== Tunnels =================== +fn validate_tunnel_start_input(tunnel: &Tunnel) -> CommandResult<()> { + tunnel_manager::validate_tunnel(tunnel) + .map_err(|err| DomainError::validation(err.field, err.to_string())) +} + +fn map_tunnel_start_result(result: Result<(), E>) -> CommandResult<()> +where + E: std::fmt::Display, +{ + re(result) +} + #[tauri::command] -fn tunnel_start(state: State, tunnel: Tunnel) -> Result { - let server = load_server(&state, &tunnel.server_id)?; +fn tunnel_start(state: State, tunnel: Tunnel) -> CommandResult { let mut t = tunnel; if t.id.is_empty() { t.id = uuid::Uuid::new_v4().to_string(); } - e(state.tunnels.start(&server, &t))?; + validate_tunnel_start_input(&t)?; + let server = load_server(&state, &t.server_id)?; + map_tunnel_start_result(state.tunnels.start(&server, &t))?; t.status = "active".into(); { let conn = state.db.lock().unwrap(); @@ -355,14 +567,14 @@ fn tunnel_start(state: State, tunnel: Tunnel) -> Result, id: String) -> Result<(), String> { +fn tunnel_stop(state: State, id: String) -> CommandResult<()> { e(state.tunnels.stop(&id))?; let conn = state.db.lock().unwrap(); e(database::set_tunnel_status(&conn, &id, "stopped")) } #[tauri::command] -fn tunnels_list(state: State) -> Result, String> { +fn tunnels_list(state: State) -> CommandResult> { let active = state.tunnels.active_ids(); let conn = state.db.lock().unwrap(); let mut tunnels = e(database::list_tunnels(&conn))?; @@ -379,7 +591,7 @@ fn tunnels_list(state: State) -> Result, String> { /// Write text to a local file (used by the logs panel "save" / diagnostic /// bundle features). Path is user-chosen via the save dialog. #[tauri::command] -fn save_text_file(path: String, content: String) -> Result<(), String> { +fn save_text_file(path: String, content: String) -> CommandResult<()> { e(std::fs::write(&path, content)) } @@ -411,6 +623,8 @@ pub fn run() { Ok(()) }) .invoke_handler(tauri::generate_handler![ + settings_get, + settings_save, servers_list, server_get, server_save, @@ -419,6 +633,8 @@ pub fn run() { pty_write, pty_resize, pty_close, + ssh_keys_list, + ssh_key_install, health_collect, run_remote, runbooks_list, @@ -428,8 +644,11 @@ pub fn run() { runbook_run_step, runbook_record_run, runbook_runs_list, + sessions_list, + command_snippets_list, + command_snippet_save, + command_snippet_delete, service_action, - docker_action, sftp_list, sftp_upload, sftp_download, @@ -450,3 +669,109 @@ pub fn run() { .run(tauri::generate_context!()) .expect("error while running RemoteOpsX"); } + +#[cfg(test)] +mod tunnel_error_tests { + use super::*; + + fn tunnel() -> Tunnel { + Tunnel { + id: "tunnel-1".into(), + server_id: "server-1".into(), + r#type: "local".into(), + local_host: Some("127.0.0.1".into()), + local_port: 8080, + remote_host: Some("example.com".into()), + remote_port: Some(80), + status: "pending".into(), + created_at: String::new(), + } + } + + #[test] + fn invalid_tunnel_shapes_are_validation_errors_with_precise_fields() { + let mut cases = Vec::new(); + + let mut missing_server = tunnel(); + missing_server.server_id.clear(); + cases.push((missing_server, "server_id")); + + let mut zero_local_port = tunnel(); + zero_local_port.local_port = 0; + cases.push((zero_local_port, "local_port")); + + let mut missing_remote_host = tunnel(); + missing_remote_host.remote_host = None; + cases.push((missing_remote_host, "remote_host")); + + let mut missing_remote_port = tunnel(); + missing_remote_port.remote_port = None; + cases.push((missing_remote_port, "remote_port")); + + let mut unknown_type = tunnel(); + unknown_type.r#type = "unknown".into(); + cases.push((unknown_type, "type")); + + for (value, field) in cases { + let error = validate_tunnel_start_input(&value).expect_err("shape should be invalid"); + assert_eq!(error.code, "validation.invalid_value"); + assert_eq!(error.context.get("field").map(String::as_str), Some(field)); + } + } + + #[test] + fn operational_tunnel_start_failures_preserve_the_diagnostic_message() { + let error = map_tunnel_start_result(Err(anyhow::anyhow!("ssh executable unavailable"))) + .expect_err("spawn failure should be returned"); + + assert_eq!(error.code, "remote.operation_failed"); + assert_eq!(error.message, "ssh executable unavailable"); + assert!(error.retryable); + assert!(error.context.is_empty()); + } +} + +#[cfg(test)] +mod settings_command_tests { + use super::*; + + fn database() -> (std::path::PathBuf, Connection) { + let path = + std::env::temp_dir().join(format!("remoteopsx-settings-{}.db", uuid::Uuid::new_v4())); + let conn = database::open(&path).expect("test database should open"); + (path, conn) + } + + #[test] + fn get_returns_defaults_and_save_returns_persisted_value() { + let (path, conn) = database(); + let defaults = settings_get_from_db(&conn).expect("defaults should load"); + assert_eq!(defaults, settings::AppSettings::default()); + + let mut changed = defaults; + changed.theme = settings::Theme::Dark; + changed.default_ports.ssh = 2222; + let saved = settings_save_to_db(&conn, changed.clone()).expect("settings should save"); + + assert_eq!(saved, changed); + assert_eq!(settings_get_from_db(&conn).unwrap(), changed); + drop(conn); + let _ = std::fs::remove_file(path); + } + + #[test] + fn save_validates_before_replacing_persisted_settings() { + let (path, conn) = database(); + let original = settings::AppSettings::default(); + settings_save_to_db(&conn, original.clone()).unwrap(); + + let mut invalid = original.clone(); + invalid.default_ports.ssh = 0; + let error = settings_save_to_db(&conn, invalid).expect_err("invalid settings should fail"); + + assert_eq!(error.code, "validation.invalid_value"); + assert_eq!(settings_get_from_db(&conn).unwrap(), original); + drop(conn); + let _ = std::fs::remove_file(path); + } +} diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 57ab831..17f7580 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -14,6 +14,12 @@ pub struct Server { pub name: String, pub host: String, pub port: u16, + #[serde(default)] + pub ftp_port: Option, + #[serde(default)] + pub rdp_port: Option, + #[serde(default)] + pub vnc_port: Option, pub username: String, /// "ssh" | "sftp" | "rdp" | "vnc" #[serde(default)] @@ -41,6 +47,20 @@ fn default_env() -> String { "dev".to_string() } +impl Server { + pub fn ftp_port(&self) -> u16 { + self.ftp_port.unwrap_or(21) + } + + pub fn rdp_port(&self) -> u16 { + self.rdp_port.unwrap_or(3389) + } + + pub fn vnc_port(&self) -> u16 { + self.vnc_port.unwrap_or(5900) + } +} + /// Payload used when creating/updating a profile from the UI. A transient /// `secret` field carries the password / passphrase only in memory; it is /// written straight to the keyring and never persisted to SQLite. @@ -51,6 +71,12 @@ pub struct ServerInput { pub name: String, pub host: String, pub port: u16, + #[serde(default)] + pub ftp_port: Option, + #[serde(default)] + pub rdp_port: Option, + #[serde(default)] + pub vnc_port: Option, pub username: String, #[serde(default)] pub protocols: Vec, @@ -144,6 +170,43 @@ pub struct RunbookRun { pub results: Vec, } +/// A persisted remote session entry shown in the workspace history. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionRecord { + pub id: String, + pub server_id: String, + pub protocol: String, + pub started_at: String, + pub ended_at: Option, + pub status: String, +} + +/// User-defined command snippet. Snippets can be global or scoped to servers +/// that carry at least one matching tag. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct CommandSnippet { + #[serde(default)] + pub id: String, + pub label: String, + pub command: String, + #[serde(default)] + pub tags: Vec, + #[serde(default)] + pub created_at: String, + #[serde(default)] + pub updated_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CommandSnippetInput { + #[serde(default)] + pub id: Option, + pub label: String, + pub command: String, + #[serde(default)] + pub tags: Vec, +} + /// SSH tunnel descriptor. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Tunnel { @@ -171,3 +234,49 @@ pub struct RemoteFile { pub size: u64, pub permissions: String, } + +#[cfg(test)] +mod tests { + use super::*; + + fn server() -> Server { + Server { + id: "server-1".into(), + name: "test".into(), + host: "example.test".into(), + port: 2222, + ftp_port: None, + rdp_port: None, + vnc_port: None, + username: "ops".into(), + protocols: vec!["ssh".into()], + auth_type: "key".into(), + private_key_path: None, + tags: vec![], + group_name: None, + environment: "dev".into(), + notes: None, + created_at: String::new(), + updated_at: String::new(), + } + } + + #[test] + fn protocol_ports_use_standard_defaults() { + let server = server(); + assert_eq!(server.ftp_port(), 21); + assert_eq!(server.rdp_port(), 3389); + assert_eq!(server.vnc_port(), 5900); + } + + #[test] + fn protocol_ports_honor_profile_overrides() { + let mut server = server(); + server.ftp_port = Some(2121); + server.rdp_port = Some(3390); + server.vnc_port = Some(5901); + assert_eq!(server.ftp_port(), 2121); + assert_eq!(server.rdp_port(), 3390); + assert_eq!(server.vnc_port(), 5901); + } +} diff --git a/src-tauri/src/pty_manager.rs b/src-tauri/src/pty_manager.rs index 8516566..75100aa 100644 --- a/src-tauri/src/pty_manager.rs +++ b/src-tauri/src/pty_manager.rs @@ -17,6 +17,76 @@ use tauri::{AppHandle, Emitter}; use crate::models::Server; use crate::ssh_manager; +const REDACTION: &[u8] = + b"\xE2\x80\xA2\xE2\x80\xA2\xE2\x80\xA2\xE2\x80\xA2\xE2\x80\xA2\xE2\x80\xA2"; + +#[derive(Debug, Clone)] +struct StreamRedactor { + secret: Option>, + carry: Vec, +} + +impl StreamRedactor { + fn new(secret: Option) -> Self { + let secret = secret + .filter(|secret| secret.len() >= 4) + .map(String::into_bytes); + Self { + secret, + carry: Vec::new(), + } + } + + fn push(&mut self, chunk: &[u8]) -> Vec { + let Some(secret) = &self.secret else { + return chunk.to_vec(); + }; + let keep = secret.len().saturating_sub(1); + let mut combined = std::mem::take(&mut self.carry); + combined.extend_from_slice(chunk); + if combined.len() <= keep { + self.carry = combined; + return Vec::new(); + } + replace_all(&mut combined, secret, REDACTION); + let emit_len = combined.len() - keep; + let emit = combined[..emit_len].to_vec(); + self.carry = combined[emit_len..].to_vec(); + emit + } + + fn finish(&mut self) -> Vec { + let Some(secret) = &self.secret else { + return std::mem::take(&mut self.carry); + }; + let mut emit = std::mem::take(&mut self.carry); + replace_all(&mut emit, secret, REDACTION); + emit + } +} + +fn replace_all(buffer: &mut Vec, needle: &[u8], replacement: &[u8]) { + if needle.is_empty() { + return; + } + let mut index = 0; + let mut output = Vec::with_capacity(buffer.len()); + while let Some(offset) = find_bytes(&buffer[index..], needle) { + let match_start = index + offset; + output.extend_from_slice(&buffer[index..match_start]); + output.extend_from_slice(replacement); + index = match_start + needle.len(); + } + output.extend_from_slice(&buffer[index..]); + *buffer = output; +} + +fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option { + haystack + .windows(needle.len()) + .position(|window| window == needle) +} + /// One live PTY-backed SSH session. struct PtySession { master: Box, @@ -37,7 +107,14 @@ impl PtyManager { /// Spawn an interactive ssh session inside a PTY. `id` is chosen by the /// frontend (one per terminal tab). Output is streamed via events. - pub fn spawn(&self, app: AppHandle, id: String, server: &Server, cols: u16, rows: u16) -> Result<()> { + pub fn spawn( + &self, + app: AppHandle, + id: String, + server: &Server, + cols: u16, + rows: u16, + ) -> Result<()> { let (program, args) = ssh_manager::interactive_argv(server)?; let pty_system = native_pty_system(); @@ -55,10 +132,15 @@ impl PtyManager { // A sane TERM so curses apps (htop, vim) render. cmd.env("TERM", "xterm-256color"); // Feed the password to sshpass -e via the environment, never argv. - if program == "sshpass" { - if let Some(pw) = crate::vault::get_secret(&crate::vault::secret_ref(&server.id)).ok().flatten() { - cmd.env("SSHPASS", pw); - } + let stored_secret = if program == "sshpass" { + crate::vault::get_secret(&crate::vault::secret_ref(&server.id)) + .ok() + .flatten() + } else { + None + }; + if let Some(pw) = stored_secret.as_deref() { + cmd.env("SSHPASS", pw); } let child = pair.slave.spawn_command(cmd)?; @@ -74,17 +156,25 @@ impl PtyManager { let app_for_thread = app.clone(); std::thread::spawn(move || { let mut buf = [0u8; 8192]; + let mut redactor = StreamRedactor::new(stored_secret); loop { match reader.read(&mut buf) { Ok(0) => break, Ok(n) => { // Send raw bytes; the frontend feeds them to xterm, // which handles partial UTF-8 sequences correctly. - let _ = app_for_thread.emit(&ev, buf[..n].to_vec()); + let bytes = redactor.push(&buf[..n]); + if !bytes.is_empty() { + let _ = app_for_thread.emit(&ev, bytes); + } } Err(_) => break, } } + let tail = redactor.finish(); + if !tail.is_empty() { + let _ = app_for_thread.emit(&ev, tail); + } let _ = app_for_thread.emit(&exit_ev, ()); }); @@ -102,7 +192,9 @@ impl PtyManager { /// Write user keystrokes to the PTY. pub fn write(&self, id: &str, data: &[u8]) -> Result<()> { let mut guard = self.sessions.lock().unwrap(); - let session = guard.get_mut(id).ok_or_else(|| anyhow!("no such pty session"))?; + let session = guard + .get_mut(id) + .ok_or_else(|| anyhow!("no such pty session"))?; session.writer.write_all(data)?; session.writer.flush()?; Ok(()) @@ -111,7 +203,9 @@ impl PtyManager { /// Resize the PTY to match the xterm viewport. pub fn resize(&self, id: &str, cols: u16, rows: u16) -> Result<()> { let guard = self.sessions.lock().unwrap(); - let session = guard.get(id).ok_or_else(|| anyhow!("no such pty session"))?; + let session = guard + .get(id) + .ok_or_else(|| anyhow!("no such pty session"))?; session.master.resize(PtySize { rows: rows.max(1), cols: cols.max(1), @@ -129,3 +223,36 @@ impl PtyManager { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stream_redactor_replaces_secret_inside_single_chunk() { + let mut redactor = StreamRedactor::new(Some("password123".into())); + let mut output = redactor.push(b"before password123 after"); + output.extend(redactor.finish()); + + assert_eq!(String::from_utf8(output).unwrap(), "before •••••• after"); + } + + #[test] + fn stream_redactor_replaces_secret_split_across_chunks() { + let mut redactor = StreamRedactor::new(Some("password123".into())); + let mut output = redactor.push(b"before pass"); + output.extend(redactor.push(b"word123 after")); + output.extend(redactor.finish()); + + assert_eq!(String::from_utf8(output).unwrap(), "before •••••• after"); + } + + #[test] + fn stream_redactor_ignores_short_values_to_avoid_noise() { + let mut redactor = StreamRedactor::new(Some("abc".into())); + let mut output = redactor.push(b"abc abc"); + output.extend(redactor.finish()); + + assert_eq!(output, b"abc abc"); + } +} diff --git a/src-tauri/src/rdp_adapter.rs b/src-tauri/src/rdp_adapter.rs index d110644..047b959 100644 --- a/src-tauri/src/rdp_adapter.rs +++ b/src-tauri/src/rdp_adapter.rs @@ -23,12 +23,9 @@ pub struct RdpOptions { } fn freerdp_bin() -> Option<&'static str> { - for bin in ["xfreerdp3", "xfreerdp"] { - if Command::new(bin).arg("--version").output().is_ok() { - return Some(bin); - } - } - None + ["xfreerdp3", "xfreerdp"] + .into_iter() + .find(|bin| Command::new(bin).arg("--version").output().is_ok()) } /// Launch an external FreeRDP window for the given server. @@ -37,15 +34,17 @@ pub fn launch(server: &Server, opts: &RdpOptions) -> Result<()> { anyhow!("xfreerdp not found. Install FreeRDP (e.g. `pacman -S freerdp` / `apt install freerdp2-x11`).") })?; - let port = if server.port == 22 { 3389 } else { server.port }; let mut args: Vec = vec![ - format!("/v:{}:{}", server.host, port), + format!("/v:{}:{}", server.host, server.rdp_port()), format!("/u:{}", server.username), "/cert:ignore".into(), "+clipboard".into(), ]; - if let Some(pw) = vault::get_secret(&vault::secret_ref(&server.id)).ok().flatten() { + if let Some(pw) = vault::get_secret(&vault::secret_ref(&server.id)) + .ok() + .flatten() + { // FreeRDP reads /p:; argv exposure is a known FreeRDP limitation. args.push(format!("/p:{pw}")); } diff --git a/src-tauri/src/runbook_runner.rs b/src-tauri/src/runbook_runner.rs index 18f1cd6..886fd12 100644 --- a/src-tauri/src/runbook_runner.rs +++ b/src-tauri/src/runbook_runner.rs @@ -43,7 +43,11 @@ pub fn run_step(server: &Server, step: &crate::models::RunbookStep) -> StepResul } if let Some(sp) = &step.success_pattern { if !sp.is_empty() { - status = if combined.contains(sp.as_str()) { "success" } else { "failure" }; + status = if combined.contains(sp.as_str()) { + "success" + } else { + "failure" + }; } } @@ -81,13 +85,8 @@ pub fn builtins() -> Vec<(&'static str, &'static str, &'static str)> { "Show status, restart a unit (confirmation required) and verify it came back.", RESTART_SERVICE, ), - ( - "Docker Container Diagnosis", - "List containers, resource usage and recent logs for troubleshooting.", - DOCKER_DIAGNOSIS, - ), ("VoIP Server Check", "Check OpenSIPS/rtpengine state, SIP ports and recent logs.", VOIP_CHECK), - ("SMPP Gateway Check", "Check SMPP listener ports, failed units, containers and logs.", SMPP_CHECK), + ("SMPP Gateway Check", "Check SMPP listener ports, failed units and logs.", SMPP_CHECK), ] } @@ -112,46 +111,6 @@ steps: command: ss -tulpen | head -50 "#; -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn all_builtins_parse() { - for (name, _desc, yaml) in builtins() { - let spec = parse(yaml).unwrap_or_else(|e| panic!("{name} failed to parse: {e}")); - assert!(!spec.steps.is_empty(), "{name} has no steps"); - for step in &spec.steps { - assert!(!step.command.trim().is_empty(), "{name} has an empty command"); - } - } - } - - #[test] - fn linux_health_check_has_expected_steps() { - let spec = parse(LINUX_HEALTH_CHECK).unwrap(); - assert_eq!(spec.name, "Linux Health Check"); - assert_eq!(spec.steps.len(), 7); - assert_eq!(spec.steps[0].command, "hostnamectl"); - } - - #[test] - fn restart_service_step_requires_confirmation() { - let spec = parse(RESTART_SERVICE).unwrap(); - let restart = spec.steps.iter().find(|s| s.name == "Restart unit").unwrap(); - assert!(restart.requires_confirmation); - // success_pattern is carried through - let verify = spec.steps.iter().find(|s| s.name == "Verify active").unwrap(); - assert_eq!(verify.success_pattern.as_deref(), Some("active")); - } - - #[test] - fn variables_are_parsed() { - let spec = parse(RESTART_SERVICE).unwrap(); - assert_eq!(spec.variables.get("service").map(String::as_str), Some("nginx")); - } -} - const DIAGNOSE_DISK: &str = r#"name: Diagnose High Disk Usage description: Locate what is filling the disk. target_os: linux @@ -196,19 +155,6 @@ steps: success_pattern: active "#; -const DOCKER_DIAGNOSIS: &str = r#"name: Docker Container Diagnosis -description: Inspect Docker containers and resource usage. -target_os: linux -variables: {} -steps: - - name: Containers - command: docker ps -a - - name: Resource usage - command: docker stats --no-stream - - name: Compose status - command: docker compose ps 2>/dev/null || true -"#; - const VOIP_CHECK: &str = r#"name: VoIP Server Check description: OpenSIPS / rtpengine health. target_os: linux @@ -222,8 +168,6 @@ steps: command: "ss -lunpt | grep -E ':5060|:5061' || true" - name: OpenSIPS logs command: journalctl -u opensips -n 100 --no-pager || true - - name: Docker containers - command: docker ps || true "#; const SMPP_CHECK: &str = r#"name: SMPP Gateway Check @@ -235,8 +179,6 @@ steps: command: "ss -tunlp | grep -E ':2775|:2776|:3550' || true" - name: Failed services command: systemctl --failed --no-pager - - name: Docker containers - command: docker ps || true - name: Recent logs command: journalctl -n 150 --no-pager - name: Disk usage @@ -244,3 +186,56 @@ steps: - name: Memory command: free -m "#; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn all_builtins_parse() { + for (name, _desc, yaml) in builtins() { + let spec = parse(yaml).unwrap_or_else(|e| panic!("{name} failed to parse: {e}")); + assert!(!spec.steps.is_empty(), "{name} has no steps"); + for step in &spec.steps { + assert!( + !step.command.trim().is_empty(), + "{name} has an empty command" + ); + } + } + } + + #[test] + fn linux_health_check_has_expected_steps() { + let spec = parse(LINUX_HEALTH_CHECK).unwrap(); + assert_eq!(spec.name, "Linux Health Check"); + assert_eq!(spec.steps.len(), 7); + assert_eq!(spec.steps[0].command, "hostnamectl"); + } + + #[test] + fn restart_service_step_requires_confirmation() { + let spec = parse(RESTART_SERVICE).unwrap(); + let restart = spec + .steps + .iter() + .find(|s| s.name == "Restart unit") + .unwrap(); + assert!(restart.requires_confirmation); + let verify = spec + .steps + .iter() + .find(|s| s.name == "Verify active") + .unwrap(); + assert_eq!(verify.success_pattern.as_deref(), Some("active")); + } + + #[test] + fn variables_are_parsed() { + let spec = parse(RESTART_SERVICE).unwrap(); + assert_eq!( + spec.variables.get("service").map(String::as_str), + Some("nginx") + ); + } +} diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs new file mode 100644 index 0000000..86a5925 --- /dev/null +++ b/src-tauri/src/settings.rs @@ -0,0 +1,248 @@ +use serde::{Deserialize, Serialize}; + +use crate::error::DomainError; + +pub const CURRENT_SETTINGS_SCHEMA_VERSION: u32 = 1; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Theme { + System, + Dark, + Light, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TransferConflictPolicy { + Ask, + Overwrite, + Rename, + Skip, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DefaultPorts { + pub ssh: u16, + pub ftp: u16, + pub rdp: u16, + pub vnc: u16, +} + +impl Default for DefaultPorts { + fn default() -> Self { + Self { + ssh: 22, + ftp: 21, + rdp: 3389, + vnc: 5900, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AppSettings { + pub schema_version: u32, + pub theme: Theme, + pub default_ports: DefaultPorts, + pub health_refresh_interval_ms: u64, + pub history_retention_days: u32, + pub app_lock_timeout_minutes: u32, + pub transfer_conflict_policy: TransferConflictPolicy, + pub desktop_clipboard_enabled: bool, + pub desktop_audio_enabled: bool, + pub desktop_notifications_enabled: bool, +} + +impl Default for AppSettings { + fn default() -> Self { + Self { + schema_version: CURRENT_SETTINGS_SCHEMA_VERSION, + theme: Theme::System, + default_ports: DefaultPorts::default(), + health_refresh_interval_ms: 3000, + history_retention_days: 90, + app_lock_timeout_minutes: 15, + transfer_conflict_policy: TransferConflictPolicy::Ask, + desktop_clipboard_enabled: true, + desktop_audio_enabled: true, + desktop_notifications_enabled: true, + } + } +} + +impl AppSettings { + pub fn validate(&self) -> Result<(), DomainError> { + if self.schema_version != CURRENT_SETTINGS_SCHEMA_VERSION { + return Err(DomainError::validation( + "schema_version", + format!( + "unsupported settings schema version; supported schema version is {}", + CURRENT_SETTINGS_SCHEMA_VERSION + ), + )); + } + if !(1000..=60_000).contains(&self.health_refresh_interval_ms) { + return Err(DomainError::validation( + "health_refresh_interval_ms", + "must be between 1000 and 60000 milliseconds", + )); + } + for (field, port) in [ + ("default_ports.ssh", self.default_ports.ssh), + ("default_ports.ftp", self.default_ports.ftp), + ("default_ports.rdp", self.default_ports.rdp), + ("default_ports.vnc", self.default_ports.vnc), + ] { + if port == 0 { + return Err(DomainError::validation(field, "must be a non-zero port")); + } + } + if !(1..=3650).contains(&self.history_retention_days) { + return Err(DomainError::validation( + "history_retention_days", + "must be between 1 and 3650 days", + )); + } + if !(1..=1440).contains(&self.app_lock_timeout_minutes) { + return Err(DomainError::validation( + "app_lock_timeout_minutes", + "must be between 1 and 1440 minutes", + )); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn field(error: &crate::error::DomainError) -> Option<&str> { + error.context.get("field").map(String::as_str) + } + + #[test] + fn defaults_match_application_contract() { + let settings = AppSettings::default(); + assert_eq!(settings.schema_version, 1); + assert_eq!(settings.theme, Theme::System); + assert_eq!(settings.default_ports.ssh, 22); + assert_eq!(settings.default_ports.ftp, 21); + assert_eq!(settings.default_ports.rdp, 3389); + assert_eq!(settings.default_ports.vnc, 5900); + assert_eq!(settings.health_refresh_interval_ms, 3000); + assert_eq!(settings.history_retention_days, 90); + assert_eq!(settings.app_lock_timeout_minutes, 15); + assert_eq!( + settings.transfer_conflict_policy, + TransferConflictPolicy::Ask + ); + assert!(settings.desktop_clipboard_enabled); + assert!(settings.desktop_audio_enabled); + assert!(settings.desktop_notifications_enabled); + } + + #[test] + fn validation_rejects_refresh_interval_outside_bounds() { + for value in [999, 60_001] { + let settings = AppSettings { + health_refresh_interval_ms: value, + ..AppSettings::default() + }; + assert_eq!( + field(&settings.validate().unwrap_err()), + Some("health_refresh_interval_ms") + ); + } + } + + #[test] + fn validation_rejects_zero_ports_with_exact_field_paths() { + for (field_name, mutate) in [ + ("default_ports.ssh", 0), + ("default_ports.ftp", 1), + ("default_ports.rdp", 2), + ("default_ports.vnc", 3), + ] { + let mut settings = AppSettings::default(); + match mutate { + 0 => settings.default_ports.ssh = 0, + 1 => settings.default_ports.ftp = 0, + 2 => settings.default_ports.rdp = 0, + _ => settings.default_ports.vnc = 0, + } + assert_eq!(field(&settings.validate().unwrap_err()), Some(field_name)); + } + } + + #[test] + fn validation_rejects_retention_and_lock_timeout_outside_bounds() { + for value in [0, 3651] { + let settings = AppSettings { + history_retention_days: value, + ..AppSettings::default() + }; + assert_eq!( + field(&settings.validate().unwrap_err()), + Some("history_retention_days") + ); + } + for value in [0, 1441] { + let settings = AppSettings { + app_lock_timeout_minutes: value, + ..AppSettings::default() + }; + assert_eq!( + field(&settings.validate().unwrap_err()), + Some("app_lock_timeout_minutes") + ); + } + } + + #[test] + fn validation_accepts_inclusive_numeric_boundaries() { + for value in [1000, 60_000] { + let settings = AppSettings { + health_refresh_interval_ms: value, + ..AppSettings::default() + }; + assert!(settings.validate().is_ok()); + } + for value in [1, 3650] { + let settings = AppSettings { + history_retention_days: value, + ..AppSettings::default() + }; + assert!(settings.validate().is_ok()); + } + for value in [1, 1440] { + let settings = AppSettings { + app_lock_timeout_minutes: value, + ..AppSettings::default() + }; + assert!(settings.validate().is_ok()); + } + } + + #[test] + fn validation_accepts_port_one_for_every_default_protocol() { + let mut settings = AppSettings::default(); + settings.default_ports.ssh = 1; + settings.default_ports.ftp = 1; + settings.default_ports.rdp = 1; + settings.default_ports.vnc = 1; + assert!(settings.validate().is_ok()); + } + + #[test] + fn validation_rejects_unsupported_schema_version() { + let settings = AppSettings { + schema_version: 2, + ..AppSettings::default() + }; + let error = settings.validate().unwrap_err(); + assert_eq!(field(&error), Some("schema_version")); + assert!(error.message.contains("supported schema version is 1")); + } +} diff --git a/src-tauri/src/sftp_manager.rs b/src-tauri/src/sftp_manager.rs index 2708396..52c9cf1 100644 --- a/src-tauri/src/sftp_manager.rs +++ b/src-tauri/src/sftp_manager.rs @@ -29,7 +29,10 @@ pub fn list_dir(server: &Server, path: &str) -> Result> { continue; } // perms links owner group size epoch name... - let cols: Vec<&str> = line.splitn(7, char::is_whitespace).filter(|s| !s.is_empty()).collect(); + let cols: Vec<&str> = line + .splitn(7, char::is_whitespace) + .filter(|s| !s.is_empty()) + .collect(); if cols.len() < 7 { continue; } @@ -45,7 +48,11 @@ pub fn list_dir(server: &Server, path: &str) -> Result> { name, }); } - files.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then(a.name.to_lowercase().cmp(&b.name.to_lowercase()))); + files.sort_by(|a, b| { + b.is_dir + .cmp(&a.is_dir) + .then(a.name.to_lowercase().cmp(&b.name.to_lowercase())) + }); Ok(files) } @@ -64,6 +71,12 @@ fn scp_base(server: &Server) -> (String, Vec) { args.push(key.clone()); } } + } else if server.auth_type == "password" { + // Without this, scp still offers every ssh-agent key before falling + // back to password auth, which can exhaust the remote's + // MaxAuthTries ("Too many authentication failures") first. + args.push("-o".into()); + args.push("PubkeyAuthentication=no".into()); } if server.auth_type == "password" { let mut wrapped = vec!["-e".to_string(), "scp".to_string()]; @@ -78,15 +91,21 @@ fn scp_base(server: &Server) -> (String, Vec) { pub fn upload(server: &Server, local_path: &str, remote_dir: &str) -> Result<()> { let (program, mut args) = scp_base(server); args.push(local_path.to_string()); - args.push(format!("{}@{}:{}", server.username, server.host, remote_dir)); + args.push(format!( + "{}@{}:{}", + server.username, server.host, remote_dir + )); run_transfer(server, &program, &args) } /// Download a remote file to a local directory. -pub fn download(server: &Server, remote_path: &str, local_dir: &str) -> Result<()> { +pub fn download(server: &Server, remote_path: &str, local_path: &str) -> Result<()> { let (program, mut args) = scp_base(server); - args.push(format!("{}@{}:{}", server.username, server.host, remote_path)); - args.push(local_dir.to_string()); + args.push(format!( + "{}@{}:{}", + server.username, server.host, remote_path + )); + args.push(local_path.to_string()); run_transfer(server, &program, &args) } @@ -100,7 +119,10 @@ pub fn delete(server: &Server, remote_path: &str) -> Result<()> { } pub fn rename(server: &Server, from: &str, to: &str) -> Result<()> { - let out = ssh_manager::run_remote(server, &format!("mv {} {}", shell_quote(from), shell_quote(to)))?; + let out = ssh_manager::run_remote( + server, + &format!("mv {} {}", shell_quote(from), shell_quote(to)), + )?; if out.success { Ok(()) } else { @@ -112,7 +134,9 @@ fn run_transfer(server: &Server, program: &str, args: &[String]) -> Result<()> { let mut cmd = Command::new(program); cmd.args(args); ssh_manager::apply_password_env(&mut cmd, server); - let out = cmd.output().map_err(|e| anyhow!("failed to run {program}: {e}"))?; + let out = cmd + .output() + .map_err(|e| anyhow!("failed to run {program}: {e}"))?; if out.status.success() { Ok(()) } else { @@ -124,3 +148,55 @@ fn run_transfer(server: &Server, program: &str, args: &[String]) -> Result<()> { fn shell_quote(s: &str) -> String { format!("'{}'", s.replace('\'', "'\\''")) } + +#[cfg(test)] +mod tests { + use super::*; + + fn server(auth_type: &str, key_path: Option<&str>) -> Server { + Server { + id: "s1".into(), + name: "test".into(), + host: "example.com".into(), + port: 22, + ftp_port: None, + rdp_port: None, + vnc_port: None, + username: "root".into(), + protocols: vec!["sftp".into()], + auth_type: auth_type.into(), + private_key_path: key_path.map(|s| s.to_string()), + tags: vec![], + group_name: None, + environment: "dev".into(), + notes: None, + created_at: String::new(), + updated_at: String::new(), + } + } + + fn has_opt(args: &[String], value: &str) -> bool { + args.windows(2).any(|w| w[0] == "-o" && w[1] == value) + } + + #[test] + fn password_auth_scp_disables_pubkey_so_agent_keys_cant_exhaust_maxauthtries() { + let (_program, args) = scp_base(&server("password", None)); + + assert!( + has_opt(&args, "PubkeyAuthentication=no"), + "password-auth scp transfers must disable pubkey auth, otherwise \ + ssh offers every ssh-agent key first and a busy agent exhausts \ + the remote's MaxAuthTries before the password is ever tried: {args:?}" + ); + } + + #[test] + fn key_auth_scp_still_uses_identities_only() { + let (program, args) = scp_base(&server("key", Some("/home/user/.ssh/id_ed25519"))); + + assert_eq!(program, "scp"); + assert!(args.iter().any(|a| a == "/home/user/.ssh/id_ed25519")); + assert!(!has_opt(&args, "PubkeyAuthentication=no")); + } +} diff --git a/src-tauri/src/ssh_keys.rs b/src-tauri/src/ssh_keys.rs new file mode 100644 index 0000000..fb2e449 --- /dev/null +++ b/src-tauri/src/ssh_keys.rs @@ -0,0 +1,214 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use anyhow::{anyhow, Context, Result}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SshKeyInfo { + pub name: String, + pub path: String, + pub public_key_path: Option, + pub public_key_preview: Option, +} + +pub fn discover_local_keys() -> Result> { + let home = std::env::var_os("HOME").ok_or_else(|| anyhow!("HOME is not set"))?; + discover_keys_in_dir(Path::new(&home).join(".ssh")) +} + +pub fn discover_keys_in_dir(dir: impl AsRef) -> Result> { + let dir = dir.as_ref(); + if !dir.exists() { + return Ok(Vec::new()); + } + let mut keys = Vec::new(); + for entry in fs::read_dir(dir).with_context(|| format!("failed to read {}", dir.display()))? { + let entry = entry?; + let path = entry.path(); + if !path.is_file() || !is_private_key_candidate(&path) || !looks_like_private_key(&path) { + continue; + } + let public_key_path = adjacent_public_key_path(&path); + let public_key_preview = public_key_path + .as_ref() + .and_then(|path| fs::read_to_string(path).ok()) + .and_then(|value| first_public_key_line(&value)); + keys.push(SshKeyInfo { + name: path + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_else(|| path.display().to_string()), + path: path.to_string_lossy().to_string(), + public_key_path: public_key_path.map(|path| path.to_string_lossy().to_string()), + public_key_preview, + }); + } + keys.sort_by(|left, right| left.name.cmp(&right.name)); + Ok(keys) +} + +pub fn public_key_for_private_key(private_key_path: impl AsRef) -> Result { + let private_key_path = expand_home(private_key_path.as_ref()); + if let Some(public_key_path) = adjacent_public_key_path(&private_key_path) { + let value = fs::read_to_string(&public_key_path) + .with_context(|| format!("failed to read {}", public_key_path.display()))?; + if let Some(line) = first_public_key_line(&value) { + return Ok(line); + } + } + + let output = Command::new("ssh-keygen") + .arg("-y") + .arg("-f") + .arg(&private_key_path) + .output() + .with_context(|| "failed to run ssh-keygen")?; + if !output.status.success() { + return Err(anyhow!( + "ssh-keygen could not derive a public key for {}: {}", + private_key_path.display(), + String::from_utf8_lossy(&output.stderr).trim() + )); + } + let stdout = String::from_utf8_lossy(&output.stdout); + first_public_key_line(&stdout).ok_or_else(|| { + anyhow!( + "ssh-keygen did not return a public key for {}", + private_key_path.display() + ) + }) +} + +pub fn authorized_keys_install_command(public_key: &str) -> String { + let public_key = public_key.trim(); + let key = shell_quote(public_key); + format!( + "mkdir -p ~/.ssh && chmod 700 ~/.ssh && touch ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys && grep -qxF {key} ~/.ssh/authorized_keys || cat >> ~/.ssh/authorized_keys <<'REMOTEOPSX_PUBLIC_KEY'\n{public_key}\nREMOTEOPSX_PUBLIC_KEY" + ) +} + +pub fn is_private_key_candidate(path: &Path) -> bool { + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + return false; + }; + if name.starts_with('.') || name.ends_with(".pub") { + return false; + } + !matches!( + name, + "config" | "known_hosts" | "known_hosts.old" | "authorized_keys" | "allowed_signers" + ) +} + +fn looks_like_private_key(path: &Path) -> bool { + let Ok(value) = fs::read_to_string(path) else { + return false; + }; + value.contains("-----BEGIN OPENSSH PRIVATE KEY-----") + || value.contains("-----BEGIN RSA PRIVATE KEY-----") + || value.contains("-----BEGIN DSA PRIVATE KEY-----") + || value.contains("-----BEGIN EC PRIVATE KEY-----") + || value.contains("-----BEGIN PRIVATE KEY-----") +} + +fn adjacent_public_key_path(private_key_path: &Path) -> Option { + let public_key_path = PathBuf::from(format!("{}.pub", private_key_path.to_string_lossy())); + public_key_path.exists().then_some(public_key_path) +} + +fn first_public_key_line(value: &str) -> Option { + value + .lines() + .map(str::trim) + .find(|line| { + line.starts_with("ssh-") || line.starts_with("ecdsa-") || line.starts_with("sk-") + }) + .map(str::to_string) +} + +fn expand_home(path: &Path) -> PathBuf { + let value = path.to_string_lossy(); + if value == "~" { + if let Some(home) = std::env::var_os("HOME") { + return PathBuf::from(home); + } + } + if let Some(rest) = value.strip_prefix("~/") { + if let Some(home) = std::env::var_os("HOME") { + return PathBuf::from(home).join(rest); + } + } + path.to_path_buf() +} + +fn shell_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\"'\"'")) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn temp_dir(name: &str) -> PathBuf { + let path = std::env::temp_dir().join(format!("remoteopsx-{name}-{}", std::process::id())); + let _ = fs::remove_dir_all(&path); + fs::create_dir_all(&path).unwrap(); + path + } + + #[test] + fn key_candidate_filter_skips_public_and_config_files() { + assert!(is_private_key_candidate(Path::new("id_ed25519"))); + assert!(is_private_key_candidate(Path::new("customer.pem"))); + assert!(!is_private_key_candidate(Path::new("id_ed25519.pub"))); + assert!(!is_private_key_candidate(Path::new("known_hosts"))); + assert!(!is_private_key_candidate(Path::new("config"))); + assert!(!is_private_key_candidate(Path::new("authorized_keys"))); + } + + #[test] + fn discovers_private_keys_with_adjacent_public_key_previews() { + let dir = temp_dir("discover-keys"); + fs::write( + dir.join("id_ed25519"), + "-----BEGIN OPENSSH PRIVATE KEY-----\nfake\n-----END OPENSSH PRIVATE KEY-----\n", + ) + .unwrap(); + fs::write( + dir.join("id_ed25519.pub"), + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFakeKeyExample user@example\n", + ) + .unwrap(); + fs::write(dir.join("known_hosts"), "example ssh-ed25519 AAAA").unwrap(); + fs::write(dir.join("notes.txt"), "not a key").unwrap(); + + let keys = discover_keys_in_dir(&dir).unwrap(); + + assert_eq!(keys.len(), 1); + assert_eq!(keys[0].name, "id_ed25519"); + assert_eq!(keys[0].path, dir.join("id_ed25519").to_string_lossy()); + assert_eq!( + keys[0].public_key_path.as_deref(), + Some(dir.join("id_ed25519.pub").to_string_lossy().as_ref()) + ); + assert_eq!( + keys[0].public_key_preview.as_deref(), + Some("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFakeKeyExample user@example") + ); + } + + #[test] + fn authorized_keys_install_command_is_idempotent_and_quotes_values() { + let command = authorized_keys_install_command("ssh-ed25519 AAAAC3NzaC1 test@example"); + + assert!(command.contains("mkdir -p ~/.ssh")); + assert!(command.contains("chmod 700 ~/.ssh")); + assert!(command.contains("touch ~/.ssh/authorized_keys")); + assert!(command.contains("grep -qxF")); + assert!(command.contains("cat >> ~/.ssh/authorized_keys")); + assert!(command.contains("'ssh-ed25519 AAAAC3NzaC1 test@example'")); + } +} diff --git a/src-tauri/src/ssh_manager.rs b/src-tauri/src/ssh_manager.rs index 993d630..0ad00c7 100644 --- a/src-tauri/src/ssh_manager.rs +++ b/src-tauri/src/ssh_manager.rs @@ -7,7 +7,7 @@ //! //! Two execution modes share the same argument builder: //! * interactive PTY (see `pty_manager`) — the terminal tab -//! * one-shot exec (`run_remote`) — health, runbooks, services, docker, sftp +//! * one-shot exec (`run_remote`) — health, runbooks, services, sftp use std::process::Command; @@ -36,7 +36,9 @@ fn wants_password(server: &Server) -> bool { /// Resolve the secret for a server from the keyring (if any). fn lookup_secret(server: &Server) -> Option { - vault::get_secret(&vault::secret_ref(&server.id)).ok().flatten() + vault::get_secret(&vault::secret_ref(&server.id)) + .ok() + .flatten() } /// Append `-i ` plus `IdentitiesOnly=yes` for key-based servers. @@ -55,6 +57,14 @@ fn push_key_args(server: &Server, args: &mut Vec) { args.push("IdentitiesOnly=yes".into()); } } + } else if server.auth_type == "password" { + // Without this, ssh still offers every ssh-agent key (and default + // identity files) before falling back to password auth. On a host + // with several agent keys loaded, the server's MaxAuthTries can be + // exhausted by those pubkey attempts alone, and sshd disconnects with + // "Too many authentication failures" before the password is tried. + args.push("-o".into()); + args.push("PubkeyAuthentication=no".into()); } } @@ -100,7 +110,11 @@ fn exec_argv(server: &Server, remote_command: &str) -> Result<(String, Vec) -> Result<(String, Vec)> { +fn wrap_with_password( + server: &Server, + program: &str, + args: Vec, +) -> Result<(String, Vec)> { if wants_password(server) { match lookup_secret(server) { Some(_) if sshpass_available() => { @@ -141,20 +155,126 @@ pub fn apply_password_env(cmd: &mut Command, server: &Server) { } } +fn redact_text(text: String, secret: Option<&str>) -> String { + match secret { + Some(secret) if secret.len() >= 4 && text.contains(secret) => { + text.replace(secret, "••••••") + } + _ => text, + } +} + +fn redact_output(server: &Server, output: CommandOutput) -> CommandOutput { + let secret = lookup_secret(server); + CommandOutput { + stdout: redact_text(output.stdout, secret.as_deref()), + stderr: redact_text(output.stderr, secret.as_deref()), + ..output + } +} + /// Execute a remote command and capture stdout/stderr/exit code. -/// This is the workhorse for health, runbooks, services and docker. +/// This is the workhorse for health, runbooks and services. pub fn run_remote(server: &Server, remote_command: &str) -> Result { let (program, args) = exec_argv(server, remote_command)?; let mut cmd = Command::new(&program); cmd.args(&args); apply_password_env(&mut cmd, server); - let output = cmd.output().map_err(|e| anyhow!("failed to spawn ssh: {e}"))?; + let output = cmd + .output() + .map_err(|e| anyhow!("failed to spawn ssh: {e}"))?; let exit_code = output.status.code().unwrap_or(-1); - Ok(CommandOutput { - stdout: String::from_utf8_lossy(&output.stdout).to_string(), - stderr: String::from_utf8_lossy(&output.stderr).to_string(), - exit_code, - success: output.status.success(), - }) + Ok(redact_output( + server, + CommandOutput { + stdout: String::from_utf8_lossy(&output.stdout).to_string(), + stderr: String::from_utf8_lossy(&output.stderr).to_string(), + exit_code, + success: output.status.success(), + }, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_server(auth_type: &str, key_path: Option<&str>) -> Server { + Server { + id: "s1".into(), + name: "test".into(), + host: "example.com".into(), + port: 22, + ftp_port: None, + rdp_port: None, + vnc_port: None, + username: "root".into(), + protocols: vec!["ssh".into()], + auth_type: auth_type.into(), + private_key_path: key_path.map(|s| s.to_string()), + tags: vec![], + group_name: None, + environment: "dev".into(), + notes: None, + created_at: String::new(), + updated_at: String::new(), + } + } + + fn has_opt(args: &[String], value: &str) -> bool { + args.windows(2).any(|w| w[0] == "-o" && w[1] == value) + } + + #[test] + fn password_auth_disables_pubkey_so_agent_keys_cant_exhaust_maxauthtries() { + let server = test_server("password", None); + let mut args = base_opts(); + push_key_args(&server, &mut args); + + assert!( + has_opt(&args, "PubkeyAuthentication=no"), + "password auth must disable pubkey auth, otherwise ssh offers every \ + ssh-agent key first and a busy agent exhausts the remote's \ + MaxAuthTries (\"Too many authentication failures\") before the \ + password is ever tried: {args:?}" + ); + } + + #[test] + fn key_auth_still_uses_identities_only() { + let server = test_server("key", Some("/home/user/.ssh/id_ed25519")); + let mut args = base_opts(); + push_key_args(&server, &mut args); + + assert!(has_opt(&args, "IdentitiesOnly=yes")); + assert!(args.iter().any(|a| a == "/home/user/.ssh/id_ed25519")); + assert!(!has_opt(&args, "PubkeyAuthentication=no")); + } + + #[test] + fn redacts_stored_secret_from_captured_output() { + let server = test_server("password", None); + let output = redact_output( + &server, + CommandOutput { + stdout: "prefix password123 suffix".into(), + stderr: "password123".into(), + exit_code: 0, + success: true, + }, + ); + + assert_eq!(output.stdout, "prefix password123 suffix"); + assert_eq!(output.stderr, "password123"); + + let output = CommandOutput { + stdout: redact_text("token abcdef token".into(), Some("abcdef")), + stderr: redact_text("short abc token".into(), Some("abc")), + exit_code: 0, + success: true, + }; + assert_eq!(output.stdout, "token •••••• token"); + assert_eq!(output.stderr, "short abc token"); + } } diff --git a/src-tauri/src/tunnel_manager.rs b/src-tauri/src/tunnel_manager.rs index a01a577..bc39dd1 100644 --- a/src-tauri/src/tunnel_manager.rs +++ b/src-tauri/src/tunnel_manager.rs @@ -14,6 +14,88 @@ use anyhow::{anyhow, Result}; use crate::models::{Server, Tunnel}; use crate::ssh_manager; +#[derive(Debug, thiserror::Error)] +#[error("{message}")] +pub(crate) struct TunnelValidationError { + pub field: &'static str, + message: String, +} + +impl TunnelValidationError { + fn new(field: &'static str, message: impl Into) -> Self { + Self { + field, + message: message.into(), + } + } +} + +/// Push auth-related ssh options for `server` onto `args`. Mirrors +/// `ssh_manager`'s handling so tunnels get the same MaxAuthTries protection. +fn push_auth_args(server: &Server, args: &mut Vec) { + if server.auth_type == "key" { + if let Some(key) = &server.private_key_path { + if !key.trim().is_empty() { + args.push("-i".into()); + args.push(key.clone()); + // Only use this key (avoid agent-key MaxAuthTries rejection). + args.push("-o".into()); + args.push("IdentitiesOnly=yes".into()); + } + } + } else if server.auth_type == "password" { + // Without this, ssh still offers every ssh-agent key before falling + // back to password auth, which can exhaust the remote's + // MaxAuthTries ("Too many authentication failures") first. + args.push("-o".into()); + args.push("PubkeyAuthentication=no".into()); + } +} + +pub(crate) fn validate_tunnel(tunnel: &Tunnel) -> Result<(), TunnelValidationError> { + if tunnel.id.trim().is_empty() { + return Err(TunnelValidationError::new("id", "tunnel id is required")); + } + if tunnel.server_id.trim().is_empty() { + return Err(TunnelValidationError::new( + "server_id", + "server id is required", + )); + } + if tunnel.local_port == 0 { + return Err(TunnelValidationError::new( + "local_port", + "local port must be between 1 and 65535", + )); + } + match tunnel.r#type.as_str() { + "dynamic" => Ok(()), + "local" | "remote" => { + if tunnel + .remote_host + .as_deref() + .map_or(true, |host| host.trim().is_empty()) + { + return Err(TunnelValidationError::new( + "remote_host", + "remote host is required", + )); + } + if tunnel.remote_port.map_or(true, |port| port == 0) { + return Err(TunnelValidationError::new( + "remote_port", + "remote port must be between 1 and 65535", + )); + } + Ok(()) + } + other => Err(TunnelValidationError::new( + "type", + format!("unknown tunnel type: {other}"), + )), + } +} + #[derive(Default)] pub struct TunnelManager { procs: Mutex>, @@ -27,6 +109,7 @@ impl TunnelManager { /// Start a tunnel described by `tunnel` against `server`. The tunnel id is /// used as the registry key. pub fn start(&self, server: &Server, tunnel: &Tunnel) -> Result<()> { + validate_tunnel(tunnel)?; let mut args: Vec = vec![ "-N".into(), "-o".into(), @@ -37,31 +120,40 @@ impl TunnelManager { server.port.to_string(), ]; - if server.auth_type == "key" { - if let Some(key) = &server.private_key_path { - if !key.trim().is_empty() { - args.push("-i".into()); - args.push(key.clone()); - // Only use this key (avoid agent-key MaxAuthTries rejection). - args.push("-o".into()); - args.push("IdentitiesOnly=yes".into()); - } - } - } + push_auth_args(server, &mut args); - let local_host = tunnel.local_host.clone().unwrap_or_else(|| "127.0.0.1".into()); + let local_host = tunnel + .local_host + .clone() + .unwrap_or_else(|| "127.0.0.1".into()); match tunnel.r#type.as_str() { "local" => { - let rh = tunnel.remote_host.clone().unwrap_or_else(|| "127.0.0.1".into()); - let rp = tunnel.remote_port.ok_or_else(|| anyhow!("remote_port required for local forward"))?; + let rh = tunnel + .remote_host + .clone() + .unwrap_or_else(|| "127.0.0.1".into()); + let rp = tunnel + .remote_port + .ok_or_else(|| anyhow!("remote_port required for local forward"))?; args.push("-L".into()); - args.push(format!("{}:{}:{}:{}", local_host, tunnel.local_port, rh, rp)); + args.push(format!( + "{}:{}:{}:{}", + local_host, tunnel.local_port, rh, rp + )); } "remote" => { - let rh = tunnel.remote_host.clone().unwrap_or_else(|| "127.0.0.1".into()); - let rp = tunnel.remote_port.ok_or_else(|| anyhow!("remote_port required for remote forward"))?; + let rh = tunnel + .remote_host + .clone() + .unwrap_or_else(|| "127.0.0.1".into()); + let rp = tunnel + .remote_port + .ok_or_else(|| anyhow!("remote_port required for remote forward"))?; args.push("-R".into()); - args.push(format!("{}:{}:{}:{}", local_host, tunnel.local_port, rh, rp)); + args.push(format!( + "{}:{}:{}:{}", + local_host, tunnel.local_port, rh, rp + )); } "dynamic" => { args.push("-D".into()); @@ -85,7 +177,15 @@ impl TunnelManager { cmd.args(&full_args); ssh_manager::apply_password_env(&mut cmd, server); - let child = cmd.spawn().map_err(|e| anyhow!("failed to start tunnel: {e}"))?; + let mut child = cmd + .spawn() + .map_err(|e| anyhow!("failed to start tunnel: {e}"))?; + for _ in 0..4 { + std::thread::sleep(std::time::Duration::from_millis(50)); + if let Some(status) = child.try_wait()? { + return Err(anyhow!("SSH tunnel exited during startup with {status}")); + } + } self.procs.lock().unwrap().insert(tunnel.id.clone(), child); Ok(()) } @@ -115,3 +215,96 @@ impl TunnelManager { alive } } + +#[cfg(test)] +mod tests { + use super::*; + + fn tunnel(kind: &str) -> Tunnel { + Tunnel { + id: "tunnel-1".into(), + server_id: "server-1".into(), + r#type: kind.into(), + local_host: Some("127.0.0.1".into()), + local_port: 8080, + remote_host: Some("127.0.0.1".into()), + remote_port: Some(80), + status: "pending".into(), + created_at: String::new(), + } + } + + fn server(auth_type: &str, key_path: Option<&str>) -> Server { + Server { + id: "server-1".into(), + name: "test".into(), + host: "example.com".into(), + port: 22, + ftp_port: None, + rdp_port: None, + vnc_port: None, + username: "root".into(), + protocols: vec!["ssh".into()], + auth_type: auth_type.into(), + private_key_path: key_path.map(|s| s.to_string()), + tags: vec![], + group_name: None, + environment: "dev".into(), + notes: None, + created_at: String::new(), + updated_at: String::new(), + } + } + + fn has_opt(args: &[String], value: &str) -> bool { + args.windows(2).any(|w| w[0] == "-o" && w[1] == value) + } + + #[test] + fn password_auth_tunnel_disables_pubkey_so_agent_keys_cant_exhaust_maxauthtries() { + let srv = server("password", None); + let mut args = Vec::new(); + push_auth_args(&srv, &mut args); + + assert!( + has_opt(&args, "PubkeyAuthentication=no"), + "password-auth tunnels must disable pubkey auth, otherwise ssh \ + offers every ssh-agent key first and a busy agent exhausts the \ + remote's MaxAuthTries before the tunnel password is ever tried: {args:?}" + ); + } + + #[test] + fn key_auth_tunnel_still_uses_identities_only() { + let srv = server("key", Some("/home/user/.ssh/id_ed25519")); + let mut args = Vec::new(); + push_auth_args(&srv, &mut args); + + assert!(has_opt(&args, "IdentitiesOnly=yes")); + assert!(args.iter().any(|a| a == "/home/user/.ssh/id_ed25519")); + assert!(!has_opt(&args, "PubkeyAuthentication=no")); + } + + #[test] + fn rejects_invalid_tunnel_parameters() { + let mut value = tunnel("local"); + value.local_port = 0; + assert!(validate_tunnel(&value).is_err()); + value.local_port = 8080; + value.remote_port = None; + assert!(validate_tunnel(&value).is_err()); + value.remote_port = Some(80); + value.r#type = "invalid".into(); + assert!(validate_tunnel(&value).is_err()); + } + + #[test] + fn accepts_supported_tunnel_shapes() { + assert!(validate_tunnel(&tunnel("local")).is_ok()); + assert!(validate_tunnel(&tunnel("remote")).is_ok()); + let mut dynamic = tunnel("dynamic"); + dynamic.remote_host = None; + dynamic.remote_port = None; + assert!(validate_tunnel(&dynamic).is_ok()); + } +} diff --git a/src-tauri/src/vault.rs b/src-tauri/src/vault.rs index cb39a13..c5bee0a 100644 --- a/src-tauri/src/vault.rs +++ b/src-tauri/src/vault.rs @@ -24,7 +24,8 @@ fn entry(secret_ref: &str) -> Result { /// Store a secret for the given reference. Overwrites any existing value. pub fn set_secret(secret_ref: &str, secret: &str) -> Result<()> { let e = entry(secret_ref)?; - e.set_password(secret).context("failed to write secret to keyring")?; + e.set_password(secret) + .context("failed to write secret to keyring")?; Ok(()) } diff --git a/src-tauri/src/vnc_adapter.rs b/src-tauri/src/vnc_adapter.rs index 3ad2c1b..77df867 100644 --- a/src-tauri/src/vnc_adapter.rs +++ b/src-tauri/src/vnc_adapter.rs @@ -18,14 +18,15 @@ pub struct VncOptions { /// Candidate VNC viewer binaries, in preference order. fn vnc_bin() -> Option<&'static str> { - for bin in ["vncviewer", "vinagre", "remmina", "gvncviewer", "xtigervncviewer"] { - // `command -v` style probe: spawning with no args is unreliable, so we - // check existence via `which`-equivalent (try to spawn --help). - if Command::new(bin).arg("--help").output().is_ok() { - return Some(bin); - } - } - None + [ + "vncviewer", + "vinagre", + "remmina", + "gvncviewer", + "xtigervncviewer", + ] + .into_iter() + .find(|bin| Command::new(bin).arg("--help").output().is_ok()) } /// Launch an external VNC viewer for the given server. @@ -34,8 +35,7 @@ pub fn launch(server: &Server, opts: &VncOptions) -> Result<()> { anyhow!("No VNC viewer found. Install one (e.g. `pacman -S tigervnc` / `apt install tigervnc-viewer`).") })?; - let port = if server.port == 22 { 5900 } else { server.port }; - let target = format!("{}:{}", server.host, port); + let target = format!("{}:{}", server.host, server.vnc_port()); let mut cmd = Command::new(bin); match bin { @@ -54,6 +54,7 @@ pub fn launch(server: &Server, opts: &VncOptions) -> Result<()> { } } - cmd.spawn().map_err(|e| anyhow!("failed to launch {bin}: {e}"))?; + cmd.spawn() + .map_err(|e| anyhow!("failed to launch {bin}: {e}"))?; Ok(()) } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 5713f5d..4ac2105 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -23,15 +23,22 @@ } ], "security": { - "csp": null + "csp": { + "default-src": "'self' customprotocol: asset:", + "connect-src": "ipc: http://ipc.localhost", + "font-src": "'self' data:", + "img-src": "'self' asset: http://asset.localhost blob: data:", + "script-src": "'self'", + "style-src": "'self' 'unsafe-inline'" + } } }, "bundle": { "active": true, "targets": ["appimage", "deb", "rpm"], - "category": "Network", + "category": "DeveloperTool", "shortDescription": "Unified Linux remote operations workspace", - "longDescription": "RemoteOpsX is a Linux-first remote operations desktop app: SSH/SFTP/RDP/VNC access, live agentless server health monitoring, service & Docker diagnostics, logs, tunnels and executable runbooks.", + "longDescription": "RemoteOpsX is a Linux-first remote operations desktop app: SSH/SFTP/RDP/VNC access, live agentless server health monitoring, systemd diagnostics, logs, tunnels and executable runbooks.", "icon": [ "icons/32x32.png", "icons/128x128.png", diff --git a/src-tauri/tests/fixtures/sshd/Dockerfile b/src-tauri/tests/fixtures/sshd/Dockerfile deleted file mode 100644 index c42d9bc..0000000 --- a/src-tauri/tests/fixtures/sshd/Dockerfile +++ /dev/null @@ -1,17 +0,0 @@ -# Minimal SSH server for RemoteOpsX integration tests. -# Built from a base image so the test never depends on pulling a prebuilt -# SSH image from Docker Hub. Installs the exact tools the health probe uses -# (ss, ps, df) so the live path is genuinely exercised. -FROM debian:bookworm-slim - -RUN apt-get update && \ - apt-get install -y --no-install-recommends \ - openssh-server iproute2 procps coreutils ca-certificates && \ - rm -rf /var/lib/apt/lists/* && \ - mkdir -p /run/sshd - -COPY entrypoint.sh /entrypoint.sh -RUN chmod +x /entrypoint.sh - -EXPOSE 2222 -ENTRYPOINT ["/entrypoint.sh"] diff --git a/src-tauri/tests/fixtures/sshd/entrypoint.sh b/src-tauri/tests/fixtures/sshd/entrypoint.sh deleted file mode 100644 index b3b7ad7..0000000 --- a/src-tauri/tests/fixtures/sshd/entrypoint.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/bin/sh -# Provision the test user with the injected public key, then run sshd in the -# foreground on port 2222. PUBLIC_KEY / USER_NAME come from `docker run -e`. -set -e - -USER_NAME="${USER_NAME:-ops}" - -if ! id "$USER_NAME" >/dev/null 2>&1; then - useradd -m -s /bin/bash "$USER_NAME" -fi - -HOME_DIR="$(getent passwd "$USER_NAME" | cut -d: -f6)" -mkdir -p "$HOME_DIR/.ssh" -printf '%s\n' "$PUBLIC_KEY" > "$HOME_DIR/.ssh/authorized_keys" -chmod 700 "$HOME_DIR/.ssh" -chmod 600 "$HOME_DIR/.ssh/authorized_keys" -chown -R "$USER_NAME:$USER_NAME" "$HOME_DIR/.ssh" - -# Generate host keys if missing. -ssh-keygen -A >/dev/null 2>&1 - -exec /usr/sbin/sshd -D -p 2222 diff --git a/src-tauri/tests/ssh_integration.rs b/src-tauri/tests/ssh_integration.rs deleted file mode 100644 index c48fda7..0000000 --- a/src-tauri/tests/ssh_integration.rs +++ /dev/null @@ -1,187 +0,0 @@ -//! End-to-end SSH integration test against a throwaway container. -//! -//! This closes the gap that unit tests can't: it proves the *live* remote-ops -//! path actually works — real SSH exec, real agentless health collection, and -//! real runbook step execution — not just that the code compiles. -//! -//! It is marked `#[ignore]` so the normal `cargo test` (and the default CI job) -//! skip it. Run it explicitly where Docker is available: -//! -//! cargo test --manifest-path src-tauri/Cargo.toml --test ssh_integration -- --ignored --nocapture -//! -//! It uses **key-based auth** (an ephemeral ed25519 keypair injected into the -//! container) so it needs neither the OS keyring nor `sshpass`. - -use std::process::Command; -use std::time::{Duration, Instant}; - -use remoteopsx_lib::health_collector::HealthState; -use remoteopsx_lib::models::{RunbookStep, Server}; -use remoteopsx_lib::{runbook_runner, ssh_manager}; - -/// Image used for the SSH target. By default the test builds a minimal sshd -/// image from `tests/fixtures/sshd` (portable — no Docker Hub SSH image -/// needed). Override with REMOTEOPSX_TEST_SSH_IMAGE to use a prebuilt one. -const LOCAL_IMAGE_TAG: &str = "remoteopsx-sshd-test:latest"; -const USER: &str = "ops"; - -/// Removes the container on drop so a failed assertion never leaks it. -struct Container(String); -impl Drop for Container { - fn drop(&mut self) { - let _ = Command::new("docker").args(["rm", "-f", &self.0]).output(); - } -} - -fn docker_available() -> bool { - Command::new("docker") - .arg("info") - .output() - .map(|o| o.status.success()) - .unwrap_or(false) -} - -fn run(cmd: &mut Command) -> (bool, String, String) { - let out = cmd.output().expect("failed to spawn process"); - ( - out.status.success(), - String::from_utf8_lossy(&out.stdout).to_string(), - String::from_utf8_lossy(&out.stderr).to_string(), - ) -} - -fn make_server(host: &str, port: u16, key_path: &str) -> Server { - Server { - id: "it-server".into(), - name: "integration".into(), - host: host.into(), - port, - username: USER.into(), - protocols: vec!["ssh".into()], - auth_type: "key".into(), - private_key_path: Some(key_path.into()), - tags: vec![], - group_name: None, - environment: "dev".into(), - notes: None, - created_at: String::new(), - updated_at: String::new(), - } -} - -#[test] -#[ignore = "requires Docker; run with --ignored"] -fn ssh_health_and_runbook_end_to_end() { - if !docker_available() { - eprintln!("SKIP: Docker not available"); - return; - } - - // 1. Ephemeral keypair in a temp dir. - let dir = std::env::temp_dir().join(format!("remoteopsx-it-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let key_path = dir.join("id_ed25519"); - let key_str = key_path.to_string_lossy().to_string(); - let (ok, _, err) = run(Command::new("ssh-keygen").args([ - "-t", "ed25519", "-N", "", "-f", &key_str, "-q", - ])); - assert!(ok, "ssh-keygen failed: {err}"); - let pubkey = std::fs::read_to_string(format!("{key_str}.pub")).unwrap(); - - // 2. Resolve the image: either a caller-provided one or a locally-built - // minimal sshd image (portable, no Hub SSH image dependency). - let image = match std::env::var("REMOTEOPSX_TEST_SSH_IMAGE") { - Ok(img) if !img.is_empty() => img, - _ => { - let fixtures = format!("{}/tests/fixtures/sshd", env!("CARGO_MANIFEST_DIR")); - eprintln!("building {LOCAL_IMAGE_TAG} from {fixtures} …"); - let (ok, _, err) = run(Command::new("docker").args(["build", "-t", LOCAL_IMAGE_TAG, &fixtures])); - assert!(ok, "docker build failed: {err}"); - LOCAL_IMAGE_TAG.to_string() - } - }; - - // 3. Launch the SSH container with the public key injected. - let (ok, id_out, err) = run(Command::new("docker").args([ - "run", "-d", - "-p", "127.0.0.1::2222", - "-e", &format!("PUBLIC_KEY={}", pubkey.trim()), - "-e", &format!("USER_NAME={USER}"), - &image, - ])); - assert!(ok, "docker run failed: {err}"); - let container = Container(id_out.trim().to_string()); - - // 4. Resolve the mapped host port. - let (ok, port_out, err) = run(Command::new("docker").args(["port", &container.0, "2222"])); - assert!(ok, "docker port failed: {err}"); - let host_port: u16 = port_out - .lines() - .next() - .and_then(|l| l.rsplit(':').next()) - .and_then(|p| p.trim().parse().ok()) - .unwrap_or_else(|| panic!("could not parse host port from: {port_out:?}")); - eprintln!("container {} ssh on 127.0.0.1:{host_port}", &container.0[..12]); - - let server = make_server("127.0.0.1", host_port, &key_str); - - // 5. Wait for sshd to accept our key (host-key gen + service start take time). - let deadline = Instant::now() + Duration::from_secs(90); - let mut ready = false; - while Instant::now() < deadline { - if let Ok(out) = ssh_manager::run_remote(&server, "echo READY") { - if out.success && out.stdout.contains("READY") { - ready = true; - break; - } - } - std::thread::sleep(Duration::from_secs(2)); - } - assert!(ready, "ssh never became ready within timeout"); - - // 6. Agentless health collection. First sample seeds rate counters; the - // second yields real CPU%/net deltas. - let health = HealthState::new(); - let _ = health.collect(&server).expect("first health collect"); - std::thread::sleep(Duration::from_secs(2)); - let snap = health.collect(&server).expect("second health collect"); - - assert!(snap.mem_total_kb > 0, "expected MemTotal > 0 from /proc/meminfo"); - assert!(!snap.os_name.is_empty(), "expected an OS name from /etc/os-release"); - assert!(snap.uptime_secs > 0 || snap.mem_used_kb > 0, "expected live proc data"); - eprintln!( - "health: os='{}' kernel='{}' mem={}kB cpu={:.1}%", - snap.os_name, snap.kernel, snap.mem_total_kb, snap.cpu_percent - ); - - // 7. Runbook step execution: success + failure classification. - let ok_step = runbook_runner::run_step( - &server, - &RunbookStep { - name: "echo".into(), - command: "echo hello-runbook".into(), - requires_confirmation: false, - success_pattern: Some("hello-runbook".into()), - failure_pattern: None, - }, - ); - assert_eq!(ok_step.status, "success", "stdout: {} stderr: {}", ok_step.stdout, ok_step.stderr); - assert!(ok_step.stdout.contains("hello-runbook")); - - let fail_step = runbook_runner::run_step( - &server, - &RunbookStep { - name: "false".into(), - command: "exit 3".into(), - requires_confirmation: false, - success_pattern: None, - failure_pattern: None, - }, - ); - assert_eq!(fail_step.status, "failure"); - assert_eq!(fail_step.exit_code, 3); - - eprintln!("✓ live SSH exec, health collection and runbook execution all verified"); - // `container` drops here -> docker rm -f - let _ = std::fs::remove_dir_all(&dir); -} diff --git a/src/App.tsx b/src/App.tsx index 80abe88..bcca904 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { useStore } from "./store"; import type { Server } from "./types"; import { ServerSidebar } from "./components/ServerSidebar"; @@ -11,6 +11,9 @@ import { RunbookLauncher } from "./components/RunbookLauncher"; import { TunnelManager } from "./components/TunnelManager"; import { CommandPalette } from "./components/CommandPalette"; import { ToastStack } from "./components/ToastStack"; +import { SettingsModal } from "./components/SettingsModal"; +import { useSettingsStore } from "./settingsStore"; +import { resolveTheme, SYSTEM_THEME_QUERY } from "./theme"; export default function App() { const loadServers = useStore((s) => s.loadServers); @@ -20,13 +23,49 @@ export default function App() { const setBottomPanel = useStore((s) => s.setBottomPanel); const rightCollapsed = useStore((s) => s.tabs.length === 0 && s.focusedServerId === null); const [editing, setEditing] = useState(undefined); // undefined = closed + const [initialFolder, setInitialFolder] = useState(undefined); const [showRunbooks, setShowRunbooks] = useState(false); const [showTunnels, setShowTunnels] = useState(false); const [paletteOpen, setPaletteOpen] = useState(false); + const [settingsOpen, setSettingsOpen] = useState(false); + const settingsReturnFocusRef = useRef(null); + const commandTriggerRef = useRef(null); + const settingsTriggerRef = useRef(null); + const loadSettings = useSettingsStore((state) => state.load); + const theme = useSettingsStore((state) => state.settings.theme); + const settingsInitialized = useSettingsStore((state) => state.initialized); + const settingsLoadFailed = useSettingsStore((state) => state.error !== null && state.initialized); + + function openNewServer(folder?: string) { + setInitialFolder(folder); + setEditing(null); + } + + function openEditServer(server: Server) { + setInitialFolder(undefined); + setEditing(server); + } + + function openSettings(returnFocus: HTMLElement | null) { + settingsReturnFocusRef.current = returnFocus; + setSettingsOpen(true); + } useEffect(() => { void loadServers(); - }, [loadServers]); + void loadSettings().catch(() => undefined); + }, [loadServers, loadSettings]); + + useLayoutEffect(() => { + const media = window.matchMedia(SYSTEM_THEME_QUERY); + const applyTheme = () => { + document.documentElement.dataset.theme = resolveTheme(theme, media.matches); + }; + applyTheme(); + if (theme !== "system") return; + media.addEventListener("change", applyTheme); + return () => media.removeEventListener("change", applyTheme); + }, [settingsInitialized, theme]); useEffect(() => { function onKeyDown(event: KeyboardEvent) { @@ -44,16 +83,15 @@ export default function App() {
- - RemoteOpsX - remote operations cockpit - + RemoteOpsX
-
+ {!settingsInitialized ? Loading settings… : null} + {settingsLoadFailed ? Settings defaults active : null}
{tabs.length} tabs @@ -61,16 +99,17 @@ export default function App() { {alerts.length} alerts
- - + + +
- setEditing(null)} onEdit={(s) => setEditing(s)} /> +
setEditing(null)} + onNewServer={openNewServer} onOpenRunbooks={() => setShowRunbooks(true)} onOpenTunnels={() => setShowTunnels(true)} /> @@ -84,16 +123,25 @@ export default function App() { setPaletteOpen(false)} - onNewServer={() => setEditing(null)} + onNewServer={() => openNewServer()} onOpenRunbooks={() => setShowRunbooks(true)} onOpenTunnels={() => setShowTunnels(true)} + onOpenSettings={() => openSettings(commandTriggerRef.current)} /> {editing !== undefined && ( - setEditing(undefined)} /> + { + setEditing(undefined); + setInitialFolder(undefined); + }} + /> )} {showRunbooks && setShowRunbooks(false)} />} {showTunnels && setShowTunnels(false)} />} + {settingsOpen && setSettingsOpen(false)} />} ); } diff --git a/src/api.ts b/src/api.ts index f3e7e7a..2e30619 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,21 +1,43 @@ // Typed wrappers around Tauri commands. One function per backend command so // components never touch `invoke` strings directly. -import { invoke } from "@tauri-apps/api/core"; +import { invoke as tauriInvoke } from "@tauri-apps/api/core"; +import { normalizeRemoteError } from "./errors"; +import { validateSettings } from "./settings"; +import type { AppSettings } from "./settings"; import type { + CommandSnippet, + CommandSnippetInput, CommandOutput, HealthSnapshot, RemoteFile, Runbook, RunbookRun, RunbookSpec, + SessionRecord, RunbookStep, Server, ServerInput, + SshKeyInfo, StepResult, Tunnel, } from "./types"; +async function invoke(command: string, args?: Record): Promise { + try { + return await tauriInvoke(command, args); + } catch (error) { + throw normalizeRemoteError(error); + } +} + +// ---- Settings ---- +export const settingsGet = () => invoke("settings_get"); +export const settingsSave = async (settings: AppSettings) => { + validateSettings(settings); + return invoke("settings_save", { settings }); +}; + // ---- Servers ---- export const serversList = () => invoke("servers_list"); export const serverGet = (id: string) => invoke("server_get", { id }); @@ -30,6 +52,11 @@ export const ptyResize = (sessionId: string, cols: number, rows: number) => invoke("pty_resize", { sessionId, cols, rows }); export const ptyClose = (sessionId: string) => invoke("pty_close", { sessionId }); +// ---- SSH keys ---- +export const sshKeysList = () => invoke("ssh_keys_list"); +export const sshKeyInstall = (serverId: string, privateKeyPath: string) => + invoke("ssh_key_install", { serverId, privateKeyPath }); + // ---- Health ---- export const healthCollect = (serverId: string) => invoke("health_collect", { serverId }); @@ -54,20 +81,25 @@ export const runbookRecordRun = ( ) => invoke("runbook_record_run", { runbookId, serverId, startedAt, status, results }); export const runbookRunsList = (limit = 50) => invoke("runbook_runs_list", { limit }); +// ---- Sessions history ---- +export const sessionsList = (limit = 100) => invoke("sessions_list", { limit }); + +// ---- Command snippets ---- +export const commandSnippetsList = () => invoke("command_snippets_list"); +export const commandSnippetSave = (input: CommandSnippetInput) => + invoke("command_snippet_save", { input }); +export const commandSnippetDelete = (id: string) => invoke("command_snippet_delete", { id }); + // ---- Services ---- export const serviceAction = (serverId: string, action: string, unit: string) => invoke("service_action", { serverId, action, unit }); -// ---- Docker ---- -export const dockerAction = (serverId: string, action: string, container?: string) => - invoke("docker_action", { serverId, action, container: container ?? null }); - // ---- SFTP ---- export const sftpList = (serverId: string, path: string) => invoke("sftp_list", { serverId, path }); export const sftpUpload = (serverId: string, localPath: string, remoteDir: string) => invoke("sftp_upload", { serverId, localPath, remoteDir }); -export const sftpDownload = (serverId: string, remotePath: string, localDir: string) => - invoke("sftp_download", { serverId, remotePath, localDir }); +export const sftpDownload = (serverId: string, remotePath: string, localPath: string) => + invoke("sftp_download", { serverId, remotePath, localPath }); export const sftpDelete = (serverId: string, remotePath: string) => invoke("sftp_delete", { serverId, remotePath }); export const sftpRename = (serverId: string, from: string, to: string) => invoke("sftp_rename", { serverId, from, to }); @@ -76,8 +108,8 @@ export const sftpRename = (serverId: string, from: string, to: string) => export const ftpList = (serverId: string, path: string) => invoke("ftp_list", { serverId, path }); export const ftpUpload = (serverId: string, localPath: string, remoteDir: string) => invoke("ftp_upload", { serverId, localPath, remoteDir }); -export const ftpDownload = (serverId: string, remotePath: string, localDir: string) => - invoke("ftp_download", { serverId, remotePath, localDir }); +export const ftpDownload = (serverId: string, remotePath: string, localPath: string) => + invoke("ftp_download", { serverId, remotePath, localPath }); export const ftpDelete = (serverId: string, remotePath: string) => invoke("ftp_delete", { serverId, remotePath }); export const ftpRename = (serverId: string, from: string, to: string) => invoke("ftp_rename", { serverId, from, to }); diff --git a/src/components/BottomPanel.tsx b/src/components/BottomPanel.tsx index d34acf8..b5d697f 100644 --- a/src/components/BottomPanel.tsx +++ b/src/components/BottomPanel.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from "react"; import * as api from "../api"; import { useStore } from "../store"; -import type { RunbookRun } from "../types"; +import type { RunbookRun, SessionRecord } from "../types"; /** Bottom dock: command output stream, runbook run history and the alert log. */ export function BottomPanel() { @@ -15,12 +15,16 @@ export function BottomPanel() { const clearAlerts = useStore((s) => s.clearAlerts); const servers = useStore((s) => s.servers); const [runs, setRuns] = useState([]); + const [sessions, setSessions] = useState([]); const outRef = useRef(null); useEffect(() => { if (view === "history" && open) { void api.runbookRunsList(50).then(setRuns).catch(() => {}); } + if (view === "sessions" && open) { + void api.sessionsList(100).then(setSessions).catch(() => {}); + } }, [view, open]); useEffect(() => { @@ -36,6 +40,7 @@ export function BottomPanel() { Output {outputLines.length} + @@ -71,6 +76,26 @@ export function BottomPanel() { )} + {open && view === "sessions" && ( +
+ {sessions.length === 0 ? ( + No SSH sessions yet. + ) : ( + sessions.map((session) => { + const openSession = session.status === "open"; + return ( +
+ {new Date(session.started_at).toLocaleString()} + {session.status} + {serverName(session.server_id)} · {session.protocol.toUpperCase()} + {session.ended_at ? {new Date(session.ended_at).toLocaleTimeString()} : active} +
+ ); + }) + )} +
+ )} + {open && view === "alerts" && (
{alerts.length === 0 ? ( diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx index 6426e7f..a873169 100644 --- a/src/components/CommandPalette.tsx +++ b/src/components/CommandPalette.tsx @@ -8,6 +8,7 @@ interface Props { onNewServer: () => void; onOpenRunbooks: () => void; onOpenTunnels: () => void; + onOpenSettings: () => void; } interface PaletteAction { @@ -19,16 +20,17 @@ interface PaletteAction { run: () => void; } -const SERVER_ACTIONS: { kind: TabKind; label: string; requiresProtocol?: "ssh" | "sftp" | "rdp" | "vnc" }[] = [ +const SERVER_ACTIONS: { kind: TabKind; label: string; requiresProtocol?: "ssh" | "sftp" | "ftp" | "rdp" | "vnc" }[] = [ { kind: "ssh", label: "Open SSH", requiresProtocol: "ssh" }, { kind: "sftp", label: "Open SFTP", requiresProtocol: "sftp" }, + { kind: "ftp", label: "Open FTP", requiresProtocol: "ftp" }, { kind: "logs", label: "Open Logs" }, { kind: "rdp", label: "Launch RDP", requiresProtocol: "rdp" }, { kind: "vnc", label: "Launch VNC", requiresProtocol: "vnc" }, ]; /** Keyboard-first command palette for jumping across servers and common actions. */ -export function CommandPalette({ open, onClose, onNewServer, onOpenRunbooks, onOpenTunnels }: Props) { +export function CommandPalette({ open, onClose, onNewServer, onOpenRunbooks, onOpenTunnels, onOpenSettings }: Props) { const servers = useStore((s) => s.servers); const tabs = useStore((s) => s.tabs); const activeTabId = useStore((s) => s.activeTabId); @@ -49,6 +51,14 @@ export function CommandPalette({ open, onClose, onNewServer, onOpenRunbooks, onO }; const globalActions: PaletteAction[] = [ + { + id: "settings", + title: "Open application settings", + eyebrow: "Application", + detail: "Configure appearance, connections, retention and desktop integration", + keywords: "settings preferences configuration theme ports", + run: closeThen(onOpenSettings), + }, { id: "new-server", title: "Add server profile", @@ -89,6 +99,14 @@ export function CommandPalette({ open, onClose, onNewServer, onOpenRunbooks, onO keywords: "history runbook runs bottom", run: closeThen(() => setBottomPanel("history")), }, + { + id: "sessions", + title: "Show SSH session history", + eyebrow: "Bottom panel", + detail: "Review opened and closed terminal sessions", + keywords: "history ssh sessions terminal bottom", + run: closeThen(() => setBottomPanel("sessions")), + }, { id: "toggle-bottom", title: "Toggle bottom dock", @@ -99,12 +117,12 @@ export function CommandPalette({ open, onClose, onNewServer, onOpenRunbooks, onO }, ]; - const panelActions: PaletteAction[] = (["health", "services", "docker", "notes", "snippets"] as RightPanelView[]).map((view) => ({ + const panelActions: PaletteAction[] = (["health", "services", "notes", "snippets"] as RightPanelView[]).map((view) => ({ id: `panel-${view}`, title: `Focus ${view} panel`, eyebrow: "Right panel", detail: "Switch the operations side panel", - keywords: `${view} right panel metrics services docker notes snippets`, + keywords: `${view} right panel metrics services notes snippets`, run: closeThen(() => setRightPanel(view)), })); @@ -143,6 +161,7 @@ export function CommandPalette({ open, onClose, onNewServer, onOpenRunbooks, onO onNewServer, onOpenRunbooks, onOpenTunnels, + onOpenSettings, openTab, servers, setActiveTab, @@ -263,11 +282,12 @@ function serverKeywords(server: Server): string { function iconFor(action: PaletteAction): string { if (action.id.startsWith("ssh-")) return "▰"; if (action.id.startsWith("sftp-")) return "⇅"; + if (action.id.startsWith("ftp-")) return "⇅"; if (action.id.startsWith("rdp-") || action.id.startsWith("vnc-")) return "▣"; if (action.id.startsWith("focus-")) return "◉"; if (action.id.startsWith("panel-")) return "◧"; if (action.id.startsWith("tab-")) return "▱"; if (action.id === "runbooks") return "▶"; if (action.id === "tunnels") return "⇄"; - return "⌘"; + return "⌁"; } diff --git a/src/components/DockerPanel.tsx b/src/components/DockerPanel.tsx deleted file mode 100644 index 19228e1..0000000 --- a/src/components/DockerPanel.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import { useEffect, useState } from "react"; -import * as api from "../api"; -import { useStore } from "../store"; -import type { Server } from "../types"; - -interface Row { - name: string; - status: string; - image: string; - ports: string; - cpu?: string; - mem?: string; -} - -/** Docker panel: container list with status/resource usage and lifecycle - * actions, plus `docker compose ps`. */ -export function DockerPanel({ server }: { server: Server }) { - const pushAlert = useStore((s) => s.pushAlert); - const pushOutput = useStore((s) => s.pushOutput); - const setBottomPanel = useStore((s) => s.setBottomPanel); - const [rows, setRows] = useState([]); - const [available, setAvailable] = useState(true); - const [busy, setBusy] = useState(false); - - async function load() { - setBusy(true); - try { - const [ps, stats] = await Promise.all([ - api.dockerAction(server.id, "ps"), - api.dockerAction(server.id, "stats"), - ]); - if (!ps.success && ps.stderr.toLowerCase().includes("not found")) { - setAvailable(false); - return; - } - const statMap = new Map(); - stats.stdout.split("\n").filter(Boolean).forEach((l) => { - const [name, cpu, mem] = l.split("|"); - if (name) statMap.set(name, { cpu, mem }); - }); - const parsed: Row[] = ps.stdout.split("\n").filter(Boolean).map((l) => { - const [name, status, image, ports] = l.split("|"); - const st = statMap.get(name); - return { name, status, image, ports: ports ?? "", cpu: st?.cpu, mem: st?.mem }; - }); - setRows(parsed); - setAvailable(true); - } catch (err) { - pushAlert("error", `docker ps: ${err}`); - } finally { - setBusy(false); - } - } - - useEffect(() => { void load(); }, [server.id]); - - async function action(act: "start" | "stop" | "restart" | "logs", name: string) { - if ((act === "stop" || act === "restart") && !confirm(`docker ${act} ${name}?`)) return; - try { - const out = await api.dockerAction(server.id, act, name); - if (act === "logs") { - pushOutput(`$ docker logs ${name}\n${out.stdout || out.stderr}`); - setBottomPanel("output"); - } else { - pushAlert(out.success ? "info" : "error", `docker ${act} ${name} → exit ${out.exit_code}`); - await load(); - } - } catch (err) { - pushAlert("error", `docker ${act} ${name}: ${err}`); - } - } - - if (!available) return
Docker not detected on this host.
; - - return ( -
-
- Docker containers - -
- - {rows.length === 0 ? ( -
No containers.
- ) : ( - rows.map((r) => { - const exited = r.status.toLowerCase().includes("exited"); - return ( -
-
- {r.name} - {r.status} -
-
{r.image}
- {(r.cpu || r.mem) &&
CPU {r.cpu ?? "—"} · MEM {r.mem ?? "—"}
} - {r.ports &&
{r.ports}
} -
- - {exited - ? - : } - {!exited && } -
-
- ); - }) - )} -
- ); -} diff --git a/src/components/HealthPanel.tsx b/src/components/HealthPanel.tsx index c1f3b20..d551489 100644 --- a/src/components/HealthPanel.tsx +++ b/src/components/HealthPanel.tsx @@ -1,13 +1,13 @@ import { useEffect, useRef, useState } from "react"; import * as api from "../api"; import { useStore } from "../store"; +import { useSettingsStore } from "../settingsStore"; import type { HealthSnapshot, Server } from "../types"; /** Live agentless health for the focused server. Polls `health_collect` on the * configurable interval and renders metric cards, sparklines and warnings. */ export function HealthPanel({ server }: { server: Server }) { - const intervalMs = useStore((s) => s.healthIntervalMs); - const setInterval_ = useStore((s) => s.setHealthInterval); + const intervalMs = useSettingsStore((state) => state.settings.health_refresh_interval_ms); const pushAlert = useStore((s) => s.pushAlert); const [snap, setSnap] = useState(null); const [error, setError] = useState(null); @@ -59,17 +59,7 @@ export function HealthPanel({ server }: { server: Server }) {
- + {intervalMs / 1000}s
diff --git a/src/components/NotesSnippetsPanel.tsx b/src/components/NotesSnippetsPanel.tsx index c1866ce..dda8941 100644 --- a/src/components/NotesSnippetsPanel.tsx +++ b/src/components/NotesSnippetsPanel.tsx @@ -1,32 +1,105 @@ +import { type FormEvent, useEffect, useMemo, useState } from "react"; import * as api from "../api"; import { useStore } from "../store"; -import type { Server } from "../types"; +import type { CommandSnippet, Server } from "../types"; /** Handy command snippets the operator can run against the focused server with * one click (output lands in the bottom panel). */ -const SNIPPETS: { label: string; cmd: string }[] = [ - { label: "Who is logged in", cmd: "w" }, - { label: "Last logins", cmd: "last -n 15" }, - { label: "Open files (limit)", cmd: "lsof | head -50" }, - { label: "Largest dirs in /var", cmd: "du -xhd1 /var 2>/dev/null | sort -rh | head" }, - { label: "Recent kernel msgs", cmd: "dmesg | tail -40" }, - { label: "TCP connections", cmd: "ss -tan | head -40" }, - { label: "Cron jobs (root)", cmd: "crontab -l 2>/dev/null; ls -la /etc/cron.d" }, - { label: "OOM kills", cmd: "journalctl -k | grep -i 'killed process' | tail -20" }, +const BUILTIN_SNIPPETS: CommandSnippet[] = [ + { id: "builtin-w", label: "Who is logged in", command: "w", tags: [], created_at: "", updated_at: "" }, + { id: "builtin-last", label: "Last logins", command: "last -n 15", tags: [], created_at: "", updated_at: "" }, + { id: "builtin-lsof", label: "Open files (limit)", command: "lsof | head -50", tags: [], created_at: "", updated_at: "" }, + { id: "builtin-var", label: "Largest dirs in /var", command: "du -xhd1 /var 2>/dev/null | sort -rh | head", tags: [], created_at: "", updated_at: "" }, + { id: "builtin-dmesg", label: "Recent kernel msgs", command: "dmesg | tail -40", tags: [], created_at: "", updated_at: "" }, + { id: "builtin-ss", label: "TCP connections", command: "ss -tan | head -40", tags: [], created_at: "", updated_at: "" }, + { id: "builtin-cron", label: "Cron jobs (root)", command: "crontab -l 2>/dev/null; ls -la /etc/cron.d", tags: [], created_at: "", updated_at: "" }, + { id: "builtin-oom", label: "OOM kills", command: "journalctl -k | grep -i 'killed process' | tail -20", tags: [], created_at: "", updated_at: "" }, ]; export function NotesSnippetsPanel({ server, showSnippets }: { server: Server; showSnippets: boolean }) { const pushOutput = useStore((s) => s.pushOutput); const pushAlert = useStore((s) => s.pushAlert); const setBottomPanel = useStore((s) => s.setBottomPanel); + const [snippets, setSnippets] = useState([]); + const [editing, setEditing] = useState(null); + const [label, setLabel] = useState(""); + const [command, setCommand] = useState(""); + const [tags, setTags] = useState(""); + const [busy, setBusy] = useState(false); - async function run(cmd: string) { + useEffect(() => { + if (!showSnippets) return; + void loadSnippets(); + }, [showSnippets]); + + const visibleSnippets = useMemo(() => { + const serverTags = new Set(server.tags.map((tag) => tag.toLowerCase())); + const saved = snippets.filter((snippet) => snippet.tags.length === 0 || snippet.tags.some((tag) => serverTags.has(tag.toLowerCase()))); + return [...BUILTIN_SNIPPETS, ...saved]; + }, [server.tags, snippets]); + + async function loadSnippets() { try { - const out = await api.runRemote(server.id, cmd); - pushOutput(`$ ${cmd}\n${out.stdout || out.stderr}`); + setSnippets(await api.commandSnippetsList()); + } catch (err) { + pushAlert("error", `snippets: ${err}`); + } + } + + async function run(snippet: CommandSnippet) { + try { + const out = await api.runRemote(server.id, snippet.command); + pushOutput(`# ${snippet.label}\n$ ${snippet.command}\n${out.stdout || out.stderr}`); setBottomPanel("output"); } catch (err) { - pushAlert("error", `${cmd}: ${err}`); + pushAlert("error", `${snippet.label}: ${err}`); + } + } + + function startEdit(snippet?: CommandSnippet) { + setEditing(snippet ?? null); + setLabel(snippet?.label ?? ""); + setCommand(snippet?.command ?? ""); + setTags(snippet?.tags.join(", ") ?? ""); + } + + function cancelEdit() { + setEditing(null); + setLabel(""); + setCommand(""); + setTags(""); + } + + async function submit(event: FormEvent) { + event.preventDefault(); + setBusy(true); + try { + const saved = await api.commandSnippetSave({ + id: editing?.id ?? null, + label, + command, + tags: tags.split(",").map((tag) => tag.trim()).filter(Boolean), + }); + setSnippets((current) => [saved, ...current.filter((snippet) => snippet.id !== saved.id)].sort((left, right) => left.label.localeCompare(right.label))); + cancelEdit(); + pushAlert("info", `Saved snippet "${saved.label}"`); + } catch (err) { + pushAlert("error", `save snippet: ${err}`); + } finally { + setBusy(false); + } + } + + async function remove(snippet: CommandSnippet) { + if (!confirm(`Delete snippet "${snippet.label}"?`)) return; + setBusy(true); + try { + await api.commandSnippetDelete(snippet.id); + setSnippets((current) => current.filter((item) => item.id !== snippet.id)); + } catch (err) { + pushAlert("error", `delete snippet: ${err}`); + } finally { + setBusy(false); } } @@ -47,13 +120,32 @@ export function NotesSnippetsPanel({ server, showSnippets }: { server: Server; s
Quick snippets
- {SNIPPETS.map((s) => ( - + {visibleSnippets.map((snippet) => ( +
+ + {!snippet.id.startsWith("builtin-") && ( +
+ + +
+ )} +
))}
+
{editing ? "Edit snippet" : "Add snippet"}
+
void submit(event)}> + setLabel(event.target.value)} placeholder="Label" maxLength={80} required /> +