From b8857585fabac1069e4245a9c96ef3a48bd82b2e Mon Sep 17 00:00:00 2001 From: darwvin-dev Date: Sun, 21 Jun 2026 21:03:32 +0330 Subject: [PATCH 1/9] harden remote operations and add packaging --- .github/workflows/ci.yml | 33 -- .github/workflows/release.yml | 55 +++ README.md | 53 +- TODO.md | 10 +- docs/distribution.md | 55 +++ .../plans/2026-06-20-project-hardening.md | 2 +- package-lock.json | 466 +++++++++++++++++- package.json | 8 +- packaging/arch/PKGBUILD | 42 ++ packaging/arch/remoteopsx.desktop | 10 + packaging/arch/remoteopsx.png | Bin 0 -> 7058 bytes packaging/linux/install-appimage.sh | 35 ++ packaging/linux/remoteopsx.desktop | 10 + packaging/linux/uninstall-appimage.sh | 12 + scripts/gdk-pixbuf/loaders/.keep | 1 + scripts/pkgconfig/gdk-pixbuf-2.0.pc | 19 + src-tauri/src/database.rs | 294 ++++++++++- src-tauri/src/ftp_manager.rs | 154 ++++-- src-tauri/src/health_collector.rs | 139 ++---- src-tauri/src/lib.rs | 197 ++++++-- src-tauri/src/models.rs | 72 +++ src-tauri/src/pty_manager.rs | 22 +- src-tauri/src/rdp_adapter.rs | 17 +- src-tauri/src/runbook_runner.rs | 123 +++-- src-tauri/src/sftp_manager.rs | 34 +- src-tauri/src/ssh_manager.rs | 18 +- src-tauri/src/tunnel_manager.rs | 112 ++++- src-tauri/src/vault.rs | 3 +- src-tauri/src/vnc_adapter.rs | 23 +- src-tauri/tauri.conf.json | 13 +- src-tauri/tests/fixtures/sshd/Dockerfile | 17 - src-tauri/tests/fixtures/sshd/entrypoint.sh | 22 - src-tauri/tests/ssh_integration.rs | 187 ------- src/App.tsx | 28 +- src/api.ts | 12 +- src/components/CommandPalette.tsx | 10 +- src/components/DockerPanel.tsx | 109 ---- src/components/RemoteDesktopTab.tsx | 7 +- src/components/RightPanel.tsx | 4 - src/components/RunbookLauncher.tsx | 2 +- src/components/RunbookRunner.tsx | 261 +++++----- src/components/ServerForm.tsx | 264 ++++++++-- src/components/ServerSidebar.tsx | 24 +- src/components/SftpPanel.tsx | 6 +- src/components/TabContent.tsx | 14 +- src/components/TerminalTab.tsx | 50 +- src/runbookMachine.test.ts | 69 +++ src/runbookMachine.ts | 114 +++++ src/store.ts | 2 +- src/styles.css | 99 +++- src/terminalSession.test.ts | 44 ++ src/terminalSession.ts | 27 + src/types.ts | 20 +- vite.config.ts | 11 + 54 files changed, 2478 insertions(+), 957 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 docs/distribution.md create mode 100644 packaging/arch/PKGBUILD create mode 100644 packaging/arch/remoteopsx.desktop create mode 100644 packaging/arch/remoteopsx.png create mode 100755 packaging/linux/install-appimage.sh create mode 100644 packaging/linux/remoteopsx.desktop create mode 100755 packaging/linux/uninstall-appimage.sh create mode 100644 scripts/gdk-pixbuf/loaders/.keep create mode 100644 scripts/pkgconfig/gdk-pixbuf-2.0.pc delete mode 100644 src-tauri/tests/fixtures/sshd/Dockerfile delete mode 100644 src-tauri/tests/fixtures/sshd/entrypoint.sh delete mode 100644 src-tauri/tests/ssh_integration.rs delete mode 100644 src/components/DockerPanel.tsx create mode 100644 src/runbookMachine.test.ts create mode 100644 src/runbookMachine.ts create mode 100644 src/terminalSession.test.ts create mode 100644 src/terminalSession.ts 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/README.md b/README.md index bfdc533..34b8670 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,9 @@ **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). @@ -19,14 +18,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,23 +36,22 @@ 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. @@ -70,7 +68,7 @@ src/ React + TypeScript frontend ServerSidebar / ServerForm TabBar / TabContent TerminalTab (xterm.js) - HealthPanel / ServicesPanel / DockerPanel + HealthPanel / ServicesPanel RunbookRunner / RunbookLauncher SftpPanel / RemoteDesktopTab / LogsPanel TunnelManager / RightPanel / BottomPanel / NotesSnippetsPanel @@ -84,14 +82,18 @@ src-tauri/src/ Rust backend (Tauri v2 commands) 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. --- @@ -104,6 +106,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 | @@ -149,6 +152,7 @@ npm run dev # Vite dev server only (web UI, no Tauri shell) 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: @@ -163,19 +167,25 @@ cargo check --manifest-path src-tauri/Cargo.toml `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 +194,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..04993c8 100644 --- a/TODO.md +++ b/TODO.md @@ -6,11 +6,11 @@ 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 @@ -39,6 +39,6 @@ Status legend: ✅ done (MVP) · 🚧 partial · ⬜ planned ## 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. +- ✅ Frontend regression tests for RunbookRunner state machine and PTY startup ordering. +- ⬜ 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/package-lock.json b/package-lock.json index ebebfbd..4017eea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,8 +23,10 @@ "@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" } }, "node_modules/@babel/code-frame": { @@ -1502,6 +1504,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", @@ -1558,6 +1578,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 +1717,16 @@ "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", "license": "MIT" }, + "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 +1774,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 +1805,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", @@ -1682,6 +1864,16 @@ } } }, + "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 +1881,13 @@ "dev": true, "license": "ISC" }, + "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 +1940,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", @@ -1828,6 +2047,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 +2064,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 +2110,23 @@ "node": ">=18" } }, + "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 +2147,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", @@ -2022,6 +2288,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 +2305,54 @@ "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/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 +2370,36 @@ "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/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -2169,6 +2520,119 @@ } } }, + "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/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/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..18d2b30 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,10 @@ "@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" }, "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 0000000000000000000000000000000000000000..042c5cb1865fba9a24f31966bfc076117d52a6b3 GIT binary patch literal 7058 zcmb`Mbx_pd_wPT;vP*Y&Nk~hlbV)Y|(jn5YNG!2{NQodJA*mo8g2a-GA}Cz~(gKo8 zN#|bt{_fnl_s;zOy7x2l`J9>a%zK{C6X(oxUQfJ%o(2&;_eKqqo$EA0EBSe`CkCQ<-hh#00u;#U`U0-pcX1I*3S*&k-_>GR)fGFfNu zhDPtFrK)5SG`qVH7;0jAi#eR$(Bn!H!!>nV=I`IJ_tVjb@K)uqu`1>*$L?v)z@s-LO7Pic$}q@0Z=N+tPcz-UhV7 zX?kfq-oWG9ZwthACDzEoKASoI5o-Or~j7zOe{l&e@zmELJ#;r$7(QJ$2tkrAa9eUKqlzBUr+56Wi{knjK zsu2i=eEz8b+db8@2HQ)IM1Vt^&fZ077Mj-_1sv8(Fy;`-)F!>6VC4tn9(kf z(hXB}$e4e}w#y25=Z-fM2v=v(e!1E(?W(%~>AQ+haJrT`NE%2UsUY@?3981rR!X5j zN1rdngacCM*Hj_pH)0m?hLz+tFCW zWpU-c^BFQYE>%)+)mY@ITp@kt00U{JU-({z z25Ye9hD!1cP;-B5->jV+R&rWrTO8ygZ4%=}QfQF8NnMEG@QCUu5}+Xo$KnmxGQo83 zbx9otiW<&2w4SS5Uf={%xZPJtz`U#~TIv$*#BtiQ3|UZcMZB&PIwl6RQYA$AXLJSI zMrAp3x7tx<;V`42Pt`k$`tWH2zopsR-P}wN*}xyJH5UTqJ!({TU*N~1T*I4{?v7OWb;@22 zohx4V7l_9=*{Mxkh3c+O7Kv80)i9GBmbJFyGqYznty&t}MRt-+XyyCBbvz@_vhVs~ z-d6jHVVHlicP{ZTZFl9Vsx3S&$NGc%WBQ)qN}4&O$UzbFaWz4^$cy2MQMYG>PUg%_ zy{+4CDGw+|R%IuGU;4^0xmN2z%c&m7Z~K+`sC_u?h>f;p9ur@;99?Zy=f-Y(Q$iyG zTAtjtx^lF6RAX#}G`B`MLN8v4c306mdv23o6?w_oum;GS~Vv&Vyl^?Tj8m_0rU?D){$+^h%jc*Jn zy=quA5%$V1q4a@yw7BHouPyG8E;=bqK+$0bVIF|%(PP-o+~s6hV~?Fos>JWZcDrA4 zMYA+OT2@Px2l%9W^w>|WGPvS=>mpIY4Hr@o0tmlKIqLwl>a?Zy>_E)v-q>U^z&vj?QXERmFla%J3{qAWbW{%b z`5Ak5mM>b$Ak}OU-eaO-&?S{qLoMhn`|hXi$AZ2&I3lkZ4)S`RGnB^QAy_{})VXTQ z!8}YbZf!X8olRd&pHt4LT?W1f_TA_o`FUNEAFvi$1%qj3zV4op@#JhCrI6$wzt^Hv znsQ_oYFxJ4UA6p+i!KX37Iee1s*wsxx+dHr5_xtczt(Aau8+j6IxEqXXVs>Sq~XDh z`B8V^jKyjpQAZLPS5ni6j<%3}h71SG=o!aD+t2lOuC$k_mpf3iKjtnJDAz(B+k!`tE zLam94jo7D0ZGqBhBHI4EQg>TqVA+RTtGjb8)tx_)ZzZM z^JCpHw{R&4Iz!-+(TS6x0k0}uWn?{&tH8B*WQ3{_B%JY5(|Uy)7Mno|dh zpyjx*S*haDvqqbGeZ&rNCC4w$C)Y-2gzVQ$VR@x_@ljBL3dWyPDU=kl30pp5A{*ZG z;HXe_r8Lalonp%JL->a6qSRr)5s;KoB z;Ir@dEFL?v#-U&G`Zt_(046>adBgdpnkD2ku z_iPRh&}J=n)Xnc0AqwD6j41R#fMglsUgE*dQuVd|XN39f(j+((f%!yZm`dccG8Cmm zPQo%|1Fs1X{XAH9RCEiju+6J8Vu8S;Y&2nXv*MA?7NptekjOmJCH`kcUO?-Pik~1N zloXvTn0t4|yvCw}n+YIQ{g-`)&|SW`<3a;7ybFcB6Y;-pJNEi%f!H7Fit7C#_^3Zk z#A$fw2j!LfShp)bFq-Fe#MIoxrz^VT~!!%X8bGo z(g$WAG9n!OF$M{0-jFl%?jUY`Sc4lUrdV{yiJH zNcp22$W_YM>@5&&*ZVY!*97dc8lnBfWq(!YrDdSUWG;C+pyZ!cZ&OHOX$ok+@q8bh zHzfok4P4yp>yoF0L8N=RZXJvmr(B_5dxBai^%B9X@@ysgf3{#aZSSZr1BrP!OHmtU zvyNY1;$6+g5&fe0>pqIJ#;`|F68rS6`8suBJFdq)hvXu*v1O~GMlEc=-CEAWy9MS2 zO@E>REjo%*e|LxDF2XqrxWnPi`Yhy<667MjO@n% zB@{n0(#hy|#dJRQmv22q%t4-0C$S^0! zqSo!^O?6RycIZLCG>qK-c~??HwdC)#w>!h1a-tUBKY#&lT#YdV(*QC;|`R^6#I zlJQ5K#lY*%6#sYgM54{NtW%*`zZ`E&vT=fm2wW$qtESf=_`=+`mc7`D!DLc~0rqx> z#0LF_SB|Ixg`W3*g_JGe5eg8_V!w?5=7l#-vCvzwEZY{&#aY}r2}b5_HUr7%cx?Ar zIW5q)D_mMv8VW$YTvJy|xx0DJMwJpBwr5d)&G5Ey_6=(?s_Aj8>|YFuxyb8%_qL!u zj$UZPFrufy zaE9LWXL~4I@_(UiJb=l&mx@CcR^=hWw&D=68>R~10kW49>dbryt6-JX!K`5=TS8|9 z=T9Oxa5ud$Gm-M@4CF+jsWP z_pr~}YOrqT&kq@czM7UB~!#LvUz z6(K2m)^;yJBg|ZY@{{F${+M=UwouVPtCs;3p><9}j#I=wO!ezesWlV2*hi>5lU4;a zXDAnvvPVVQ^wgL@(`wDzE?p!66((|3H{G2&iE$-PXDdq-=Ye518;+4Z{rCPB?YHT! zekSqge`ru_Z2$B2_^kTvvY$4EywVRSr@BG5mu2v8or!?oa+GUIAybL`c&aLgE^QVZ zYi}wv{&^}3A>aAtw5d^e7*b0|B?Zp{c)W9K;IL(}MEv8wyyq+cyW6CaQqEQLiXXDt zG=#9p+Q+U79dn<-+}a}#9NdBb@U0$eqEk@z6bQOFl3%?~PEtPcEcAn~k8ct*{t`69 zGGWtSUx4s^oDJZb54T4+6xNm22BhU9GJXbHjx$rcdURPr5hp3=%MPZP+k@$m2?5*| zW9u_N+z>&{p}JUQzlNEMORmlEjxkJ-2}hO=@S;tTR7xKsV7)oV9k85uBmt{mjDAJq z?><0Ux7~l0?tI}@$n8M0MbE1k9dFA1SsvE%@%D)f4LaDG-7xq1bhY=sJr?SKfUw_^ zsw8h6bxNi&UxoP7yidz3uOyfXp*+~2ipD5`=OQ$!KFj#ghVVbVZj>kIZA|v8GFFt2 zbW`TwxjV6Tt)M6NnH);Bsj{vX-)dBkrCc~K2KrgqrFZemN1~PspOm@BMMM27LyO%; zI~R}xFtV9ipGZ|>xp(%tmR!IqTYs}NWO2;h5A+cyJBe4-JzwOfi4M@W%ls|!a7=?{ zM*@5P#@bN|S6=XG)oEDDR6tij`T$2&by7hNb>jay4M}!v@99AJ&3i>Q> z=ABgoy?WNDqrW`(fTjMhFIWDyVn8hiY{kdIs3#L@b^k`$4S&iZo-J9yR_B=I*!W2@ zEU)<)zue?$Q#DKN}a3m5dc?+@BhtQU9;K?TkF&i*9|ZZihwS^M|3B9Q_T<|(VAHGz+;+;Dui4B;@gr?Qxr zY$Z$+&D>Av$3|O>lB~+{J!@+M8y|v>_Gjq5pWxNJVb4kWC3h_L07S|knL7U^;(WZE zh?{<$CBv&w^r?oJGcn-ai2edrv)R|%1~OB>J-6$=|EzX`qKqR?97m!n+@}g1O{KqY zEtFUEHS?r&f(%UplRb1k?{K`pL-l98$t9rSl+3DT=}xeeRhU#uk!;%ZvS#)6iMD(Q z+rEn&^2&?=WIm$AvuWh5+rerWPBYR5d;L&YE&WfzM%UD$o-w~vt90K`qn&tD$R@uX z=tlofJV!(kSc0tr^Anao&md0o4J$IM@-(xy`}*z}t0i!9kLptbJsL zuN(_@BILD_G;xLDu>RL+cF)i`tGl%gzNE@}W-!eUCX_b!(KXW}-U~s;H;ecpN>FV4 z=X$#~p^^EB(7x!P-xhuIxAVkw?tXLqd8jAc7kPf+-sZz!p%*%;J5k4_0fj~UiW^$gM4L_f6g4s%$jkO3*ApXy zSa&6&D9Dc1eh?dB|Kb4!@RF=oSqR`~2alA!A@%4mw9$HcS5TIgC+L#-D7tPcqIxNU z$};Zi(4!8y?}Qz62RAElIxJbn7ka>2UA8_deBNrznC0A%#TZYkGil zeE7aUcmre=qAe(-QD?=b7LCyZCfA@Yk%5m+&*cSB%(k#ZSGKzxE=TiE#+^Vv&iFYx z%y#S{&$G;*;y3q~Y59U4%p~!3jN&0`>W40|5@cLXG+7A#Cwa5xU$U%)wE3#fxUma8 z@~pdp%Q!y-VlrL0yk$wA4iO@y|7q{ofm^o;k}$N=Z(pt+gx`veIKGnm4=>}Pd-dSe z#6U03aaw{IKIOcB(DFy*GE+{Y>zQ9+8)Ll$c3b>EVxgFBlDS?ezuTP9s_cb64f8jP zwy`>Q^15e`$LFM6$KaL@e=zyxb)kG{T7rzS*YPQnKQ$uEaclrdq*(FYnOwQKxQGI{ zb(i}uTIssBxXb4fy%EzvWPS>LY1xc?3nZL<7JBjEOY%wyKzpyF+Pvr>o+C{%AgaAw zwprjD3nQ?d8EO7-&Y5}Bb@A858S1dYeS$lJXwhEU4QC&4<0!ClE5&#h-aAHveo>slHE;Yg?$r=;aKx7tT+AQyJ1yCB)CZ@c8zMe0i7eL>FFG1CvZ&5^Y4`jl z7RAn%SMux%CC+)dkYKKCbSB|VtR4O0)yFokOZqBaLs^P-aP2Hb*ly%%)z2U2e9eQm zn!kINI0#>D%SOY8u`dQ&$K@doX-4!m zvjnNo=3LnzY*pPFLkaT!HdaB%U?G=feAH%!_0Prfl~b?_f( z8!qb5|&J*;kchw0z7O=e*q@QeQT3XaK6{RI-$wo8{Svcj>W q?Zy0W3HCo=?*GoZ|BudFg*peJH8}>7+PfPoKub+ewMN-4>VE(QpB*{? literal 0 HcmV?d00001 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..c277dd1 100644 --- a/src-tauri/src/database.rs +++ b/src-tauri/src/database.rs @@ -4,7 +4,7 @@ //! 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::*; @@ -29,6 +29,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', @@ -93,6 +96,29 @@ fn migrate(conn: &Connection) -> Result<()> { "#, ) .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(()) } @@ -108,6 +134,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 +172,11 @@ 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, @@ -161,6 +191,36 @@ pub fn upsert_server(conn: &Connection, input: &ServerInput) -> Result { 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, + 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, ], )?; Ok(id.clone()) @@ -168,8 +228,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 +245,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 +318,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 +357,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 +370,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 +385,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) => { @@ -345,7 +497,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 +521,116 @@ pub fn list_tunnels(conn: &Connection) -> Result> { })?; Ok(rows.collect::>>()?) } + +#[cfg(test)] +mod tests { + use super::*; + + 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); + } +} 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..2fca5b0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -64,21 +64,51 @@ fn server_get(state: State, id: String) -> Result { /// 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) -> Result { + e(database::validate_server_input(&input))?; + 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); + } - 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 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("a password is required for password authentication".into()); + } + if let Some(secret) = supplied { + e(vault::set_secret(&sref, secret))?; + } + + 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(err.to_string()) } } - Ok(id) } #[tauri::command] @@ -102,7 +132,9 @@ fn pty_spawn( rows: u16, ) -> Result<(), String> { let server = load_server(&state, &server_id)?; - e(state.pty.spawn(app, session_id.clone(), &server, cols, rows))?; + e(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"); @@ -115,7 +147,12 @@ fn pty_write(state: State, session_id: String, data: Vec) -> Resul } #[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, +) -> Result<(), String> { e(state.pty.resize(&session_id, cols, rows)) } @@ -138,7 +175,11 @@ fn health_collect(state: State, server_id: String) -> Result, server_id: String, command: String) -> Result { +fn run_remote( + state: State, + server_id: String, + command: String, +) -> Result { let server = load_server(&state, &server_id)?; e(ssh_manager::run_remote(&server, &command)) } @@ -178,13 +219,23 @@ fn runbook_save( // Validate YAML before saving. e(runbook_runner::parse(&content_yaml))?; 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, +) -> Result { let server = load_server(&state, &server_id)?; Ok(runbook_runner::run_step(&server, &step)) } @@ -216,7 +267,10 @@ fn runbook_record_run( } #[tauri::command] -fn runbook_runs_list(state: State, limit: Option) -> Result, String> { +fn runbook_runs_list( + state: State, + limit: Option, +) -> Result, String> { let conn = state.db.lock().unwrap(); e(database::list_runbook_runs(&conn, limit.unwrap_or(50))) } @@ -224,7 +278,12 @@ fn runbook_runs_list(state: State, limit: Option) -> Result, server_id: String, action: String, unit: String) -> Result { +fn service_action( + state: State, + server_id: String, + action: String, + unit: String, +) -> Result { let server = load_server(&state, &server_id)?; let unit_q = shell_quote(&unit); let cmd = match action.as_str() { @@ -239,53 +298,57 @@ fn service_action(state: State, server_id: String, action: String, uni 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}")), - }; - e(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, +) -> Result, String> { let server = load_server(&state, &server_id)?; e(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, +) -> Result<(), String> { let server = load_server(&state, &server_id)?; e(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, +) -> Result<(), String> { let server = load_server(&state, &server_id)?; - e(sftp_manager::download(&server, &remote_path, &local_dir)) + e(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, +) -> Result<(), String> { let server = load_server(&state, &server_id)?; e(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, +) -> Result<(), String> { let server = load_server(&state, &server_id)?; e(sftp_manager::rename(&server, &from, &to)) } @@ -293,31 +356,54 @@ fn sftp_rename(state: State, server_id: String, from: String, to: Stri // =================== 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, +) -> Result, String> { 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, +) -> Result<(), String> { 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, +) -> Result<(), String> { 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, +) -> Result<(), String> { 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, +) -> Result<(), String> { let server = load_server(&state, &server_id)?; e(ftp_manager::rename(&server, &from, &to)) } @@ -325,13 +411,21 @@ 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, +) -> Result<(), String> { 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, +) -> Result<(), String> { let server = load_server(&state, &server_id)?; e(vnc_adapter::launch(&server, &options)) } @@ -429,7 +523,6 @@ pub fn run() { runbook_record_run, runbook_runs_list, service_action, - docker_action, sftp_list, sftp_upload, sftp_download, diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 57ab831..fe4d2b1 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, @@ -171,3 +197,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..b9b7788 100644 --- a/src-tauri/src/pty_manager.rs +++ b/src-tauri/src/pty_manager.rs @@ -37,7 +37,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(); @@ -56,7 +63,10 @@ impl PtyManager { 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() { + if let Some(pw) = crate::vault::get_secret(&crate::vault::secret_ref(&server.id)) + .ok() + .flatten() + { cmd.env("SSHPASS", pw); } } @@ -102,7 +112,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 +123,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), 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/sftp_manager.rs b/src-tauri/src/sftp_manager.rs index 2708396..4f10bc1 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) } @@ -78,15 +85,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 +113,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 +128,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 { diff --git a/src-tauri/src/ssh_manager.rs b/src-tauri/src/ssh_manager.rs index 993d630..0c60d89 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. @@ -100,7 +102,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() => { @@ -142,14 +148,16 @@ pub fn apply_password_env(cmd: &mut Command, server: &Server) { } /// 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(), diff --git a/src-tauri/src/tunnel_manager.rs b/src-tauri/src/tunnel_manager.rs index a01a577..fc1eeea 100644 --- a/src-tauri/src/tunnel_manager.rs +++ b/src-tauri/src/tunnel_manager.rs @@ -14,6 +14,32 @@ use anyhow::{anyhow, Result}; use crate::models::{Server, Tunnel}; use crate::ssh_manager; +fn validate_tunnel(tunnel: &Tunnel) -> Result<()> { + if tunnel.id.trim().is_empty() { + return Err(anyhow!("tunnel id is required")); + } + if tunnel.local_port == 0 { + return Err(anyhow!("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(anyhow!("remote host is required")); + } + if tunnel.remote_port.map_or(true, |port| port == 0) { + return Err(anyhow!("remote port must be between 1 and 65535")); + } + Ok(()) + } + other => Err(anyhow!("unknown tunnel type: {other}")), + } +} + #[derive(Default)] pub struct TunnelManager { procs: Mutex>, @@ -27,6 +53,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(), @@ -49,19 +76,38 @@ impl TunnelManager { } } - 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 +131,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 +169,45 @@ 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(), + } + } + + #[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..1a4524f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,10 +20,21 @@ 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); + function openNewServer(folder?: string) { + setInitialFolder(folder); + setEditing(null); + } + + function openEditServer(server: Server) { + setInitialFolder(undefined); + setEditing(server); + } + useEffect(() => { void loadServers(); }, [loadServers]); @@ -51,7 +62,7 @@ export default function App() {
@@ -65,12 +76,12 @@ export default function App() { - setEditing(null)} onEdit={(s) => setEditing(s)} /> +
setEditing(null)} + onNewServer={openNewServer} onOpenRunbooks={() => setShowRunbooks(true)} onOpenTunnels={() => setShowTunnels(true)} /> @@ -84,13 +95,20 @@ export default function App() { setPaletteOpen(false)} - onNewServer={() => setEditing(null)} + onNewServer={() => openNewServer()} onOpenRunbooks={() => setShowRunbooks(true)} onOpenTunnels={() => setShowTunnels(true)} /> {editing !== undefined && ( - setEditing(undefined)} /> + { + setEditing(undefined); + setInitialFolder(undefined); + }} + /> )} {showRunbooks && setShowRunbooks(false)} />} {showTunnels && setShowTunnels(false)} />} diff --git a/src/api.ts b/src/api.ts index f3e7e7a..2cfe420 100644 --- a/src/api.ts +++ b/src/api.ts @@ -58,16 +58,12 @@ export const runbookRunsList = (limit = 50) => invoke("runbook_run 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 +72,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/CommandPalette.tsx b/src/components/CommandPalette.tsx index 6426e7f..cd685fd 100644 --- a/src/components/CommandPalette.tsx +++ b/src/components/CommandPalette.tsx @@ -19,9 +19,10 @@ 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" }, @@ -99,12 +100,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)), })); @@ -263,11 +264,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/RemoteDesktopTab.tsx b/src/components/RemoteDesktopTab.tsx index 7e4643e..a76a119 100644 --- a/src/components/RemoteDesktopTab.tsx +++ b/src/components/RemoteDesktopTab.tsx @@ -3,7 +3,7 @@ import * as api from "../api"; import { useStore } from "../store"; import type { Server } from "../types"; -/** RDP / VNC launcher tab. MVP launches the external system client +/** RDP / VNC launcher tab. Launches the external system client * (xfreerdp / vncviewer) while the session record stays in RemoteOpsX. * The adapter is structured so an embedded canvas can replace this later. */ export function RemoteDesktopTab({ kind, server }: { kind: "rdp" | "vnc"; server: Server }) { @@ -12,8 +12,7 @@ export function RemoteDesktopTab({ kind, server }: { kind: "rdp" | "vnc"; server const [resolution, setResolution] = useState("1600x900"); const [launched, setLaunched] = useState(false); - const defaultPort = kind === "rdp" ? 3389 : 5900; - const port = server.port === 22 ? defaultPort : server.port; + const port = kind === "rdp" ? (server.rdp_port ?? 3389) : (server.vnc_port ?? 5900); async function launch() { try { @@ -63,7 +62,7 @@ export function RemoteDesktopTab({ kind, server }: { kind: "rdp" | "vnc"; server

- MVP launches the system {kind === "rdp" ? "FreeRDP" : "VNC"} client as a separate window. Ensure{" "} + RemoteOpsX launches the system {kind === "rdp" ? "FreeRDP" : "VNC"} client as a separate window. Ensure{" "} {kind === "rdp" ? "xfreerdp / xfreerdp3" : "a VNC viewer (tigervnc, remmina…)"}{" "} is installed. Embedded {kind.toUpperCase()} rendering is on the roadmap.

diff --git a/src/components/RightPanel.tsx b/src/components/RightPanel.tsx index 3ec1765..51b1df9 100644 --- a/src/components/RightPanel.tsx +++ b/src/components/RightPanel.tsx @@ -1,13 +1,11 @@ import { useStore } from "../store"; import { HealthPanel } from "./HealthPanel"; import { ServicesPanel } from "./ServicesPanel"; -import { DockerPanel } from "./DockerPanel"; import { NotesSnippetsPanel } from "./NotesSnippetsPanel"; const VIEWS = [ { key: "health", label: "Health", icon: "◆" }, { key: "services", label: "Services", icon: "●" }, - { key: "docker", label: "Docker", icon: "▣" }, { key: "notes", label: "Notes", icon: "✦" }, { key: "snippets", label: "Snippets", icon: "⌁" }, ] as const; @@ -54,8 +52,6 @@ export function RightPanel() { ) : view === "services" ? ( - ) : view === "docker" ? ( - ) : ( )} diff --git a/src/components/RunbookLauncher.tsx b/src/components/RunbookLauncher.tsx index 262c8ac..ded85cf 100644 --- a/src/components/RunbookLauncher.tsx +++ b/src/components/RunbookLauncher.tsx @@ -51,7 +51,7 @@ export function RunbookLauncher({ onClose }: { onClose: () => void }) {
- setQuery(event.target.value)} placeholder="health, disk, docker, voip…" /> + setQuery(event.target.value)} placeholder="health, disk, voip…" />
diff --git a/src/components/RunbookRunner.tsx b/src/components/RunbookRunner.tsx index bf266c7..4224599 100644 --- a/src/components/RunbookRunner.tsx +++ b/src/components/RunbookRunner.tsx @@ -1,113 +1,98 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import * as api from "../api"; import { useStore } from "../store"; -import type { RunbookSpec, RunbookStep, Server, StepResult } from "../types"; - -interface StepView extends RunbookStep { - // resolved command after variable substitution - resolved: string; - state: "pending" | "running" | "success" | "failure" | "skipped"; - result?: StepResult; - expanded: boolean; -} - -/** Runbook execution view: preview steps, confirm destructive ones, run them - * one-by-one over SSH, capture per-step output and persist the run. */ +import { + confirmStep, + createRun, + nextAction, + recordResult, + skipStep, + type RunState, +} from "../runbookMachine"; +import type { RunbookSpec, Server, StepResult } from "../types"; + +/** Executes one durable frontend run state across confirmation boundaries. */ export function RunbookRunner({ runbookId, server }: { runbookId: string; server: Server }) { - const pushAlert = useStore((s) => s.pushAlert); + const pushAlert = useStore((state) => state.pushAlert); const [spec, setSpec] = useState(null); const [vars, setVars] = useState>({}); - const [steps, setSteps] = useState([]); - const [running, setRunning] = useState(false); - const [confirmIdx, setConfirmIdx] = useState(null); - const [done, setDone] = useState(false); - const completedSteps = steps.filter((step) => step.state === "success" || step.state === "failure" || step.state === "skipped").length; - const progressPct = steps.length ? (completedSteps / steps.length) * 100 : 0; + const [run, setRun] = useState(null); + const [expanded, setExpanded] = useState>(() => new Set()); + const recordedRun = useRef(null); useEffect(() => { - void api.runbookSpec(runbookId).then((sp) => { - setSpec(sp); - setVars(sp.variables ?? {}); - resetSteps(sp, sp.variables ?? {}); - }).catch((err) => pushAlert("error", `load runbook: ${err}`)); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [runbookId]); - - function substitute(cmd: string, v: Record): string { - return cmd.replace(/\{\{\s*(\w+)\s*\}\}/g, (_, k) => v[k] ?? `{{${k}}}`); - } + let cancelled = false; + void api.runbookSpec(runbookId).then((loaded) => { + if (cancelled) return; + setSpec(loaded); + setVars(loaded.variables ?? {}); + setRun(null); + }).catch((error) => pushAlert("error", `load runbook: ${error}`)); + return () => { cancelled = true; }; + }, [pushAlert, runbookId]); - function resetSteps(sp: RunbookSpec, v: Record) { - setSteps(sp.steps.map((s) => ({ - ...s, - resolved: substitute(s.command, v), - state: "pending", - expanded: false, - }))); - setDone(false); - } - - function setStep(i: number, patch: Partial) { - setSteps((cur) => cur.map((s, idx) => (idx === i ? { ...s, ...patch } : s))); - } - - // Execute steps sequentially, pausing at confirmation gates. - async function runFrom(startIdx: number) { - setRunning(true); - const startedAt = new Date().toISOString(); - const collected: StepResult[] = []; - let overall: "success" | "failure" = "success"; + useEffect(() => { + if (!run || run.phase !== "running") return; + const advanced = nextAction(run); + setRun(advanced.state); + }, [run]); - for (let i = startIdx; i < steps.length; i++) { - const step = steps[i]; - if (step.requires_confirmation && i !== startIdx - 1) { - // gate: stop and ask for confirmation, unless we just confirmed this one - if (confirmIdx !== i) { - setConfirmIdx(i); - setRunning(false); - return; // resumes when user confirms - } - } - setStep(i, { state: "running", expanded: true }); - const resolved = substitute(step.command, vars); - try { - const res = await api.runbookRunStep(server.id, { ...step, command: resolved }); - collected.push(res); - setStep(i, { state: res.status, result: res }); - if (res.status === "failure") overall = "failure"; - } catch (err) { - const res: StepResult = { - name: step.name, command: resolved, stdout: "", stderr: String(err), exit_code: -1, status: "failure", - }; - collected.push(res); - setStep(i, { state: "failure", result: res }); - overall = "failure"; - } - setConfirmIdx(null); - } + useEffect(() => { + if (!run || run.phase !== "executing") return; + const step = run.steps[run.cursor]; + if (!step) return; + let cancelled = false; + void api.runbookRunStep(server.id, step).then((result) => { + if (!cancelled) setRun((current) => current ? recordResult(current, result) : current); + }).catch((error) => { + if (cancelled) return; + const result: StepResult = { + name: step.name, + command: step.command, + stdout: "", + stderr: String(error), + exit_code: -1, + status: "failure", + }; + setRun((current) => current ? recordResult(current, result) : current); + }); + return () => { cancelled = true; }; + }, [run, server.id]); - setRunning(false); - setDone(true); - try { - await api.runbookRecordRun(runbookId, server.id, startedAt, overall, collected); - pushAlert(overall === "success" ? "info" : "warn", `Runbook "${spec?.name}" finished: ${overall}`, server.id); - } catch (err) { - pushAlert("error", `record run: ${err}`); - } - } + useEffect(() => { + if (!run || run.phase !== "complete" || recordedRun.current === run.startedAt) return; + recordedRun.current = run.startedAt; + void api.runbookRecordRun(runbookId, server.id, run.startedAt, run.overall, run.results) + .then(() => pushAlert( + run.overall === "success" ? "info" : "warn", + `Runbook "${spec?.name}" finished: ${run.overall}`, + server.id, + )) + .catch((error) => pushAlert("error", `record run: ${error}`)); + }, [pushAlert, run, runbookId, server.id, spec?.name]); + + const previewSteps = useMemo( + () => spec ? createRun(spec.steps, vars, "preview").steps : [], + [spec, vars], + ); + const steps = run?.steps ?? previewSteps; + const completedSteps = steps.filter((step) => ["success", "failure", "skipped"].includes(step.state)).length; + const progressPct = steps.length ? (completedSteps / steps.length) * 100 : 0; + const active = run?.phase === "running" || run?.phase === "executing"; function start() { - if (spec) resetSteps(spec, vars); - // allow state to flush before running - setTimeout(() => void runFrom(0), 0); + if (!spec) return; + recordedRun.current = null; + setExpanded(new Set()); + setRun(createRun(spec.steps, vars)); } - function confirmAndContinue() { - const i = confirmIdx; - if (i === null) return; - // Mark as confirmed by setting confirmIdx to i then resuming from i. - setConfirmIdx(i); - setTimeout(() => void runFrom(i), 0); + function toggleExpanded(index: number) { + setExpanded((current) => { + const next = new Set(current); + if (next.has(index)) next.delete(index); else next.add(index); + return next; + }); } if (!spec) return
Loading runbook…
; @@ -119,68 +104,68 @@ export function RunbookRunner({ runbookId, server }: { runbookId: string; server

{spec.name}

{spec.description} · target {server.name}

-
- -
+
{completedSteps}/{steps.length} - {running ? "Running steps" : done ? "Run complete" : "Ready to execute"} -
-
- + {active ? "Running steps" : run?.phase === "complete" ? "Run complete" : "Ready to execute"}
+
{Object.keys(vars).length > 0 && (
Variables
- {Object.entries(vars).map(([k, v]) => ( -
- - setVars((cur) => ({ ...cur, [k]: e.target.value }))} /> + {Object.entries(vars).map(([key, value]) => ( +
+ + setVars((current) => ({ ...current, [key]: event.target.value }))} />
))}
)} - {steps.map((s, i) => ( -
-
setStep(i, { expanded: !s.expanded })}> - - {s.state === "success" ? "✓" : s.state === "failure" ? "✕" : s.state === "running" ? "•" : i + 1} - - - {s.name} - {s.requires_confirmation && confirm} - - {s.resolved} -
- - {confirmIdx === i && ( -
- This step requires confirmation. It will run: -
{s.resolved}
-
- - + {steps.map((step, index) => { + const isExpanded = expanded.has(index) || step.state === "running"; + const needsConfirmation = run?.pendingConfirmation === index; + return ( +
+ + + {needsConfirmation && run && ( +
+ This step requires confirmation. It will run: +
{step.command}
+
+ + +
-
- )} + )} - {s.expanded && s.result && ( -
-
{s.result.stdout || ""}{s.result.stderr ? `\n\x1b[stderr]\n${s.result.stderr}` : ""}{`\n— exit ${s.result.exit_code}`}
-
- )} -
- ))} + {isExpanded && step.result && ( +
+
{step.result.stdout}{step.result.stderr ? `\n[stderr]\n${step.result.stderr}` : ""}{`\n— exit ${step.result.exit_code}`}
+
+ )} +
+ ); + })}
); } diff --git a/src/components/ServerForm.tsx b/src/components/ServerForm.tsx index c417e8c..de765bc 100644 --- a/src/components/ServerForm.tsx +++ b/src/components/ServerForm.tsx @@ -1,45 +1,116 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useStore } from "../store"; import * as api from "../api"; import type { AuthType, Environment, Protocol, Server, ServerInput } from "../types"; interface Props { server: Server | null; // null = create new + initialFolder?: string; onClose: () => void; } -const ALL_PROTOCOLS: Protocol[] = ["ssh", "sftp", "rdp", "vnc"]; +const FOLDER_NONE = "__none__"; +const FOLDER_NEW = "__new__"; + +const ENVIRONMENT_OPTIONS: { value: Environment; label: string; description: string }[] = [ + { value: "production", label: "Production", description: "Critical systems" }, + { value: "staging", label: "Staging", description: "Pre-production" }, + { value: "dev", label: "Dev", description: "Lab and test hosts" }, +]; + +const AUTH_OPTIONS: { value: AuthType; label: string; description: string }[] = [ + { value: "key", label: "Private key", description: "Best for daily SSH/SFTP work" }, + { value: "password", label: "Password", description: "Needed for FTP or password-only hosts" }, +]; + +const PROTOCOL_OPTIONS: { value: Protocol; label: string; detail: string; icon: string }[] = [ + { value: "ssh", label: "SSH", detail: "Terminal, health, runbooks", icon: "▰" }, + { value: "sftp", label: "SFTP", detail: "Encrypted file browser", icon: "⇅" }, + { value: "ftp", label: "FTP", detail: "Plaintext legacy file access", icon: "⇆" }, + { value: "rdp", label: "RDP", detail: "Launch FreeRDP", icon: "▣" }, + { value: "vnc", label: "VNC", detail: "Launch VNC viewer", icon: "◫" }, +]; /** Modal to create / edit a server profile. The secret field is write-only: * it is sent to the keyring on save and never read back into the UI. */ -export function ServerForm({ server, onClose }: Props) { +export function ServerForm({ server, initialFolder, onClose }: Props) { + const servers = useStore((store) => store.servers); const loadServers = useStore((s) => s.loadServers); const pushAlert = useStore((s) => s.pushAlert); + const existingFolders = useMemo(() => { + const folders = new Set(); + for (const profile of servers) { + const folder = profile.group_name?.trim(); + if (folder) folders.add(folder); + } + return [...folders].sort((left, right) => left.localeCompare(right)); + }, [servers]); + const defaultFolder = (server?.group_name ?? initialFolder ?? "").trim(); const [name, setName] = useState(server?.name ?? ""); const [host, setHost] = useState(server?.host ?? ""); const [port, setPort] = useState(server?.port ?? 22); + const [ftpPort, setFtpPort] = useState(server?.ftp_port ?? 21); + const [rdpPort, setRdpPort] = useState(server?.rdp_port ?? 3389); + const [vncPort, setVncPort] = useState(server?.vnc_port ?? 5900); const [username, setUsername] = useState(server?.username ?? ""); const [protocols, setProtocols] = useState(server?.protocols ?? ["ssh"]); const [authType, setAuthType] = useState(server?.auth_type ?? "key"); const [keyPath, setKeyPath] = useState(server?.private_key_path ?? ""); const [secret, setSecret] = useState(""); const [tags, setTags] = useState((server?.tags ?? []).join(", ")); - const [group, setGroup] = useState(server?.group_name ?? ""); + const [folderChoice, setFolderChoice] = useState(defaultFolder || FOLDER_NONE); + const [folderName, setFolderName] = useState(defaultFolder); const [environment, setEnvironment] = useState(server?.environment ?? "dev"); const [notes, setNotes] = useState(server?.notes ?? ""); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); - function toggleProtocol(p: Protocol) { - setProtocols((cur) => (cur.includes(p) ? cur.filter((x) => x !== p) : [...cur, p])); + const folderOptions = useMemo(() => { + if ( + folderChoice !== FOLDER_NONE && + folderChoice !== FOLDER_NEW && + !existingFolders.includes(folderChoice) + ) { + return [folderChoice, ...existingFolders]; + } + return existingFolders; + }, [existingFolders, folderChoice]); + + function toggleProtocol(protocol: Protocol) { + setProtocols((current) => { + if (current.includes(protocol)) { + return current.filter((enabledProtocol) => enabledProtocol !== protocol); + } + if (protocol === "ftp") setAuthType("password"); + return [...current, protocol]; + }); } function applyDefaults(nextAuthType: AuthType) { setAuthType(nextAuthType); - if (nextAuthType === "password" && !protocols.includes("ssh")) { - setProtocols((current) => [...current, "ssh"]); + if (nextAuthType === "key") setSecret(""); + } + + function selectFolder(nextChoice: string) { + setFolderChoice(nextChoice); + if (nextChoice === FOLDER_NONE) { + setFolderName(""); + return; + } + if (nextChoice === FOLDER_NEW) { + setFolderName(""); + return; + } + setFolderName(nextChoice); + } + + function validatePort(label: string, value: number) { + if (!Number.isInteger(Number(value)) || value < 1 || value > 65535) { + setError(`${label} must be between 1 and 65535.`); + return false; } + return true; } async function save() { @@ -52,17 +123,38 @@ export function ServerForm({ server, onClose }: Props) { setError("Pick at least one protocol."); return; } + if (protocols.includes("ftp") && authType !== "password") { + setError("FTP requires password authentication because the protocol does not support SSH keys."); + return; + } + if (authType === "password" && !server && !secret.trim()) { + setError("Enter the password to store in the OS keyring."); + return; + } + if (folderChoice === FOLDER_NEW && !folderName.trim()) { + setError("Enter a folder name or choose No folder."); + return; + } + if (!validatePort("SSH port", Number(port))) return; + if (protocols.includes("ftp") && !validatePort("FTP port", Number(ftpPort))) return; + if (protocols.includes("rdp") && !validatePort("RDP port", Number(rdpPort))) return; + if (protocols.includes("vnc") && !validatePort("VNC port", Number(vncPort))) return; + + const normalizedFolder = folderChoice === FOLDER_NONE ? "" : folderName.trim(); const input: ServerInput = { id: server?.id, name: name.trim(), host: host.trim(), port: Number(port) || 22, + ftp_port: protocols.includes("ftp") ? Number(ftpPort) || 21 : null, + rdp_port: protocols.includes("rdp") ? Number(rdpPort) || 3389 : null, + vnc_port: protocols.includes("vnc") ? Number(vncPort) || 5900 : null, username: username.trim(), protocols, auth_type: authType, private_key_path: keyPath.trim() || null, tags: tags.split(",").map((t) => t.trim()).filter(Boolean), - group_name: group.trim() || null, + group_name: normalizedFolder || null, environment, notes: notes.trim() || null, secret: secret ? secret : null, @@ -82,7 +174,7 @@ export function ServerForm({ server, onClose }: Props) { return (
e.target === e.currentTarget && onClose()}> -
+
{server ? "Edit profile" : "New profile"} @@ -93,66 +185,146 @@ export function ServerForm({ server, onClose }: Props) {
Identity -

Give the host a recognizable name and environment for fast filtering.

+

Name the host, place it in a folder and mark its environment for safer day-to-day operations.

-
-
setName(e.target.value)} placeholder="prod-db-1" />
-
setGroup(e.target.value)} placeholder="Production" />
+ +
- - + + setName(event.target.value)} placeholder="prod-db-1" /> + The label shown in the sidebar and command palette. +
+
+ +
+ + {folderChoice === FOLDER_NEW && ( + setFolderName(event.target.value)} + placeholder="e.g. Production / EU" + autoFocus + /> + )} +
+ Folders are shared by all profiles and appear in the sidebar.
-
setHost(e.target.value)} placeholder="10.0.0.5 / host.example.com" />
-
setPort(Number(e.target.value))} />
-
setUsername(e.target.value)} placeholder="root" />
+
+ + setHost(event.target.value)} placeholder="10.0.0.5 or host.example.com" /> +
+
+ + setUsername(event.target.value)} placeholder="root" /> +
+
+ + setPort(Number(event.target.value))} /> +
+
+ +
+ +
+ {ENVIRONMENT_OPTIONS.map((option) => ( + + ))} +
Access -

Choose every workflow this profile should expose in the sidebar.

+

Choose every workflow this profile should expose. FTP is only for legacy hosts.

-
- {ALL_PROTOCOLS.map((p) => ( - +
+ {PROTOCOL_OPTIONS.map((option) => ( + ))}
+ {protocols.includes("ftp") && ( +
+ FTP is plaintext and forces password authentication. Prefer SFTP whenever possible. +
+ )}
-
-
- - + {(protocols.includes("ftp") || protocols.includes("rdp") || protocols.includes("vnc")) && ( +
+ {protocols.includes("ftp") &&
setFtpPort(Number(e.target.value))} />
} + {protocols.includes("rdp") &&
setRdpPort(Number(e.target.value))} />
} + {protocols.includes("vnc") &&
setVncPort(Number(e.target.value))} />
} +
+ )} + +
+ +
+ {AUTH_OPTIONS.map((option) => ( + + ))}
+
+ + {authType === "password" ? (
- + setSecret(e.target.value)} - placeholder={server ? "•••• (unchanged)" : authType === "key" ? "optional" : "stored in OS keyring"} + onChange={(event) => setSecret(event.target.value)} + placeholder={server ? "•••• (unchanged)" : "stored in OS keyring"} /> + Saved to the OS keyring. It is never written into SQLite.
-
- - {authType === "key" && ( + ) : (
- setKeyPath(e.target.value)} placeholder="~/.ssh/id_ed25519" /> + setKeyPath(event.target.value)} placeholder="~/.ssh/id_ed25519" /> + Encrypted keys use your SSH agent or the interactive SSH prompt.
)} @@ -162,12 +334,12 @@ export function ServerForm({ server, onClose }: Props) {
- setTags(e.target.value)} placeholder="db, postgres, eu-west" /> + setTags(event.target.value)} placeholder="db, postgres, eu-west" />
-