From bc4b8418123ecfeff4f85fb136300c893693208d Mon Sep 17 00:00:00 2001 From: Joachim Mild Date: Tue, 24 Mar 2026 23:53:11 +0100 Subject: [PATCH 1/6] release: prepare v0.2.1 and align docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bump desktop version to 0.2.1 (package.json, lockfile, Cargo.toml, tauri.conf) - CHANGELOG: stable 0.2.1 entry; backfill 0.1.0-beta.12–beta.17 history - Docs: README, releases, architecture, roadmap, licensing, terms, license-server runbook, PROXMUX spec - nosuckshell_ops: mandatory release checklist; validate_project.sh cds to repo root - PROXMUX: TLS/trusted PEM, WS proxy, UI and Help; remove built-in demo plugin - Plugin store: catalog and assets (cloud provider SVGs); related TS/Rust wiring - Ignore .cursor/debug-*.log --- .agents/skills/nosuckshell_ops/SKILL.md | 17 + .../scripts/validate_project.sh | 8 +- .github/ISSUE_TEMPLATE/bug_report.md | 2 +- .gitignore | 1 + AGENTS.md | 2 + CONTRIBUTING.md | 2 +- README.md | 20 +- apps/desktop/package-lock.json | 4 +- apps/desktop/package.json | 2 +- apps/desktop/public/plugin-store/aws.svg | 7 + apps/desktop/public/plugin-store/azure.svg | 5 + .../desktop/public/plugin-store/bitwarden.svg | 14 +- .../public/plugin-store/digitalocean.svg | 7 + apps/desktop/public/plugin-store/gcp.svg | 7 + apps/desktop/public/plugin-store/gitea.svg | 14 +- apps/desktop/public/plugin-store/github.svg | 15 +- apps/desktop/public/plugin-store/gitlab.svg | 15 +- apps/desktop/public/plugin-store/hetzner.svg | 7 + .../public/plugin-store/nss-commander.svg | 27 ++ apps/desktop/public/plugin-store/proxmox.svg | 14 +- apps/desktop/public/plugin-store/vault.svg | 15 +- apps/desktop/src-tauri/Cargo.toml | 9 +- apps/desktop/src-tauri/src/plugins/demo.rs | 40 --- .../src-tauri/src/plugins/file_workspace.rs | 2 +- apps/desktop/src-tauri/src/plugins/mod.rs | 2 - apps/desktop/src-tauri/src/plugins/proxmux.rs | 171 +++++++++- .../desktop/src-tauri/src/proxmux_ws_proxy.rs | 30 +- apps/desktop/src-tauri/tauri.conf.json | 2 +- apps/desktop/src/App.tsx | 156 +++++++++- apps/desktop/src/components/HelpPanel.tsx | 6 + .../src/components/ProxmoxLxcTermPane.tsx | 97 +++--- .../src/components/ProxmoxQemuVncPane.tsx | 100 +++--- .../src/components/ProxmuxSidebarPanel.tsx | 7 + .../desktop/src/components/SplitWorkspace.tsx | 142 ++++++++- .../settings/tabs/AppSettingsPluginsTab.tsx | 20 -- .../settings/tabs/AppSettingsProxmuxTab.tsx | 81 +++++ apps/desktop/src/e2e/tauri-core-shim.ts | 12 +- .../src/features/builtin-plugin-ids.ts | 1 - apps/desktop/src/features/context-actions.ts | 2 +- .../src/features/plugin-store-catalog.test.ts | 18 +- .../src/features/plugin-store-catalog.ts | 62 +++- apps/desktop/src/features/session-model.ts | 3 + apps/desktop/src/tauri-api.ts | 2 + docs/CHANGELOG.md | 76 ++++- docs/CODE_GUIDE.md | 2 + docs/README.md | 3 +- docs/architecture.md | 16 +- docs/license-server-runbook.md | 4 +- docs/licensing.md | 4 +- docs/releases.md | 16 +- docs/roadmap.md | 12 +- ...23-proxmox-console-integration-analysis.md | 292 +++++------------- docs/terms-of-sale.md | 7 +- 53 files changed, 1128 insertions(+), 474 deletions(-) create mode 100644 apps/desktop/public/plugin-store/aws.svg create mode 100644 apps/desktop/public/plugin-store/azure.svg create mode 100644 apps/desktop/public/plugin-store/digitalocean.svg create mode 100644 apps/desktop/public/plugin-store/gcp.svg create mode 100644 apps/desktop/public/plugin-store/hetzner.svg create mode 100644 apps/desktop/public/plugin-store/nss-commander.svg delete mode 100644 apps/desktop/src-tauri/src/plugins/demo.rs diff --git a/.agents/skills/nosuckshell_ops/SKILL.md b/.agents/skills/nosuckshell_ops/SKILL.md index 38f87ba..ea94dac 100644 --- a/.agents/skills/nosuckshell_ops/SKILL.md +++ b/.agents/skills/nosuckshell_ops/SKILL.md @@ -34,6 +34,23 @@ Before submitting a PR, run the validation script provided by this skill: bash .agents/skills/nosuckshell_ops/scripts/validate_project.sh ``` +## Release preparation (always) + +For **every** release (stable `vMAJOR.MINOR.PATCH` or pre-release `v…-beta.N` / `-rc.N`), complete **before** pushing the tag: + +1. **Single version string** — Set the same SemVer in: + - `apps/desktop/package.json` + - `apps/desktop/package-lock.json` (root + `packages.""` entries) + - `apps/desktop/src-tauri/Cargo.toml` + - `apps/desktop/src-tauri/tauri.conf.json` +2. **Regenerate Rust lock metadata** — From `apps/desktop/src-tauri`, run `cargo check` or `cargo build` so `Cargo.lock` reflects the workspace crate version (look for `name = "src-tauri"`). +3. **Changelog** — Add a top section in `docs/CHANGELOG.md` with user-facing **Added** / **Changed** / **Fixed** / **Notes**; backfill any **missing prerelease** lines if `main` moved without changelog entries. Add a compare/link footer entry for the new tag when applicable. +4. **Release docs** — Update `docs/releases.md` (**Current release** / examples), root `README.md` (tag examples if they pin a version), issue templates or runbooks that show a **sample version string**, and any **in-app** or **store** copy that hard-codes the version (search the repo). +5. **Architecture / product docs** — If behavior changed, update `docs/architecture.md`, in-app `HelpPanel.tsx`, and linked specs (e.g. `docs/superpowers/specs/…`) in the same preparation PR. +6. **Validate** — Run `bash .agents/skills/nosuckshell_ops/scripts/validate_project.sh` (or equivalent `npm test` / `npm run build` / `cargo test` from `apps/desktop`). + +The GitHub **release workflow** overwrites the three app manifests from the tag at build time; the checklist still applies so **local builds**, **PR review**, and **changelog accuracy** stay correct. + ## Project-Specific Gotchas - **Identity store schema**: Ensure `ENTITY_STORE_SCHEMA_VERSION` is in sync between `store_models.rs` and `apps/desktop/src/types.ts`. - **Strict mode**: TypeScript is in strict mode. Fix all `tsc` errors. diff --git a/.agents/skills/nosuckshell_ops/scripts/validate_project.sh b/.agents/skills/nosuckshell_ops/scripts/validate_project.sh index 2c3a9d6..4eec58e 100755 --- a/.agents/skills/nosuckshell_ops/scripts/validate_project.sh +++ b/.agents/skills/nosuckshell_ops/scripts/validate_project.sh @@ -1,7 +1,11 @@ #!/bin/bash set -e -echo "Starting NoSuckShell Project Validation..." +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)" +cd "$REPO_ROOT" + +echo "Starting NoSuckShell Project Validation (repo root: $REPO_ROOT)..." # Monorepo root checks echo "Checking frontend dependencies and running tsc..." @@ -12,7 +16,7 @@ npm run desktop:test # Rust checks echo "Checking Rust code..." -cd apps/desktop/src-tauri +cd "$REPO_ROOT/apps/desktop/src-tauri" cargo check cargo test diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index d0a4cd0..553956d 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -6,7 +6,7 @@ labels: bug ## Environment -- NoSuckShell version (or commit), e.g. `0.1.0-beta.7` or `git rev-parse HEAD`: +- NoSuckShell version (or commit), e.g. `0.2.1` or `git rev-parse HEAD`: - OS / distro (e.g. CachyOS, macOS 14, Windows 11): - If Linux WebKit issues: did you try `WEBKIT_DISABLE_DMABUF_RENDERER=1`? (yes/no) diff --git a/.gitignore b/.gitignore index e8e425d..017f065 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ services/license-server/target apps/desktop/src-tauri/gen Cargo.lock # .cursor/ is no longer ignored because it contains shared rules for agents +.cursor/debug-*.log diff --git a/AGENTS.md b/AGENTS.md index f7b08d1..328c1eb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,3 +27,5 @@ Cursor (and similar) may load rules under [`.cursor/rules/`](.cursor/rules/) — ## Contributing workflow See [CONTRIBUTING.md](CONTRIBUTING.md) for setup, screenshots, PR checklist, and security reporting. + +**Cutting a release:** Use the **Release preparation** section in [`.agents/skills/nosuckshell_ops/SKILL.md`](.agents/skills/nosuckshell_ops/SKILL.md) together with [docs/CHANGELOG.md](docs/CHANGELOG.md) and [docs/releases.md](docs/releases.md). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 262c726..eebc458 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -61,7 +61,7 @@ For security-sensitive reports, use the process in [SECURITY.md](SECURITY.md), n ## Releases (maintainers) -Process and tagging: [docs/releases.md](docs/releases.md). User-facing notes: [docs/CHANGELOG.md](docs/CHANGELOG.md). +Process and tagging: [docs/releases.md](docs/releases.md). User-facing notes: [docs/CHANGELOG.md](docs/CHANGELOG.md). Checklist (versions, lockfiles, doc sync): [`.agents/skills/nosuckshell_ops/SKILL.md`](.agents/skills/nosuckshell_ops/SKILL.md) § Release preparation. ## Community standards diff --git a/README.md b/README.md index 87dc8eb..bb7decb 100644 --- a/README.md +++ b/README.md @@ -90,20 +90,24 @@ Details: [docs/backup-security.md](docs/backup-security.md) ## Release process (maintainers) -GitHub releases are created by pushing a SemVer tag: +GitHub releases are created by pushing a SemVer tag. Full checklist: [docs/releases.md](docs/releases.md). User-facing history: [docs/CHANGELOG.md](docs/CHANGELOG.md). -- Final: `vMAJOR.MINOR.PATCH` (example: `v1.2.3`) -- Pre-release: `vMAJOR.MINOR.PATCH-` (example: `v1.2.4-rc.1`, `v0.1.0-beta.7`) +- Final: `vMAJOR.MINOR.PATCH` (example: `v0.2.1`, `v1.2.3`) +- Pre-release: `vMAJOR.MINOR.PATCH-` (example: `v1.2.4-rc.1`, `v0.1.0-beta.11`) -**Current pre-release line:** `v0.1.0-beta.7` (push the tag when you want CI to publish binaries). Changelog: [docs/CHANGELOG.md](docs/CHANGELOG.md). +**Before tagging**, use the same version string in: + +- `apps/desktop/package.json` +- `apps/desktop/src-tauri/Cargo.toml` +- `apps/desktop/src-tauri/tauri.conf.json` + +The [release workflow](.github/workflows/release.yml) still overwrites those files from the tag at build time; keeping them aligned locally avoids drift while developing. ```bash -git tag v0.1.0-beta.7 -git push origin v0.1.0-beta.7 +git tag v0.2.1 +git push origin v0.2.1 ``` -Full checklist: [docs/releases.md](docs/releases.md) - If the workflow rejects the tag, use `vMAJOR.MINOR.PATCH` or `vMAJOR.MINOR.PATCH-prerelease` (example: `v2.0.0` or `v2.0.0-rc.1`). ## Documentation diff --git a/apps/desktop/package-lock.json b/apps/desktop/package-lock.json index 3c68c4a..faaac51 100644 --- a/apps/desktop/package-lock.json +++ b/apps/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "desktop", - "version": "0.1.0-beta.11", + "version": "0.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "desktop", - "version": "0.1.0-beta.11", + "version": "0.2.1", "license": "MIT", "dependencies": { "@novnc/novnc": "^1.5.0", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 751289b..b362ea6 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "desktop", - "version": "0.1.0-beta.17", + "version": "0.2.1", "description": "Cross-platform SSH manager desktop app", "scripts": { "dev": "vite", diff --git a/apps/desktop/public/plugin-store/aws.svg b/apps/desktop/public/plugin-store/aws.svg new file mode 100644 index 0000000..519987b --- /dev/null +++ b/apps/desktop/public/plugin-store/aws.svg @@ -0,0 +1,7 @@ + diff --git a/apps/desktop/public/plugin-store/azure.svg b/apps/desktop/public/plugin-store/azure.svg new file mode 100644 index 0000000..ed7d4ea --- /dev/null +++ b/apps/desktop/public/plugin-store/azure.svg @@ -0,0 +1,5 @@ + diff --git a/apps/desktop/public/plugin-store/bitwarden.svg b/apps/desktop/public/plugin-store/bitwarden.svg index 4d27cea..28f4cac 100644 --- a/apps/desktop/public/plugin-store/bitwarden.svg +++ b/apps/desktop/public/plugin-store/bitwarden.svg @@ -1 +1,13 @@ -Bitwarden \ No newline at end of file + + Bitwarden + + + + + + + + diff --git a/apps/desktop/public/plugin-store/digitalocean.svg b/apps/desktop/public/plugin-store/digitalocean.svg new file mode 100644 index 0000000..0c08586 --- /dev/null +++ b/apps/desktop/public/plugin-store/digitalocean.svg @@ -0,0 +1,7 @@ + diff --git a/apps/desktop/public/plugin-store/gcp.svg b/apps/desktop/public/plugin-store/gcp.svg new file mode 100644 index 0000000..e0ebb3e --- /dev/null +++ b/apps/desktop/public/plugin-store/gcp.svg @@ -0,0 +1,7 @@ + diff --git a/apps/desktop/public/plugin-store/gitea.svg b/apps/desktop/public/plugin-store/gitea.svg index 7a192aa..be1c8f2 100644 --- a/apps/desktop/public/plugin-store/gitea.svg +++ b/apps/desktop/public/plugin-store/gitea.svg @@ -1 +1,13 @@ -Gitea \ No newline at end of file + + Gitea + + + + + + + + diff --git a/apps/desktop/public/plugin-store/github.svg b/apps/desktop/public/plugin-store/github.svg index 8eb588f..1c901ed 100644 --- a/apps/desktop/public/plugin-store/github.svg +++ b/apps/desktop/public/plugin-store/github.svg @@ -1 +1,14 @@ -GitHub \ No newline at end of file + + GitHub + + + + + + + + + diff --git a/apps/desktop/public/plugin-store/gitlab.svg b/apps/desktop/public/plugin-store/gitlab.svg index 645c8c9..cfe1111 100644 --- a/apps/desktop/public/plugin-store/gitlab.svg +++ b/apps/desktop/public/plugin-store/gitlab.svg @@ -1 +1,14 @@ -GitLab \ No newline at end of file + + GitLab + + + + + + + + + diff --git a/apps/desktop/public/plugin-store/hetzner.svg b/apps/desktop/public/plugin-store/hetzner.svg new file mode 100644 index 0000000..8dbb071 --- /dev/null +++ b/apps/desktop/public/plugin-store/hetzner.svg @@ -0,0 +1,7 @@ + diff --git a/apps/desktop/public/plugin-store/nss-commander.svg b/apps/desktop/public/plugin-store/nss-commander.svg new file mode 100644 index 0000000..10ebe54 --- /dev/null +++ b/apps/desktop/public/plugin-store/nss-commander.svg @@ -0,0 +1,27 @@ + diff --git a/apps/desktop/public/plugin-store/proxmox.svg b/apps/desktop/public/plugin-store/proxmox.svg index 5b8c1e6..7593dc6 100644 --- a/apps/desktop/public/plugin-store/proxmox.svg +++ b/apps/desktop/public/plugin-store/proxmox.svg @@ -1 +1,13 @@ -Proxmox \ No newline at end of file + + Proxmox + + + + + + + + diff --git a/apps/desktop/public/plugin-store/vault.svg b/apps/desktop/public/plugin-store/vault.svg index 0eca5e2..6017996 100644 --- a/apps/desktop/public/plugin-store/vault.svg +++ b/apps/desktop/public/plugin-store/vault.svg @@ -1 +1,14 @@ -Vault \ No newline at end of file + + Vault + + + + + + + + + diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 56d0e9e..3abe2dd 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "src-tauri" -version = "0.1.0-beta.11" +version = "0.2.1" edition = "2024" license = "MIT" build = "build.rs" @@ -19,7 +19,9 @@ portable-pty = "0.9.0" rand = "0.10" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" -reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } +# native-tls (OpenSSL/Schannel) matches `proxmux_ws_proxy` and accepts a fetched self-signed *leaf* +# as an extra root; rustls/webpki often returns UnknownIssuer for the same PEM (EE basicConstraints). +reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "native-tls"] } tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "io-util"] } tokio-tungstenite = { version = "0.26", default-features = false, features = ["handshake", "connect"] } native-tls = "0.2" @@ -36,6 +38,9 @@ fs2 = "0.4" ed25519-dalek = { version = "2.2.0", default-features = false, features = ["serde", "alloc"] } hex = "0.4.3" open = "5.3" +sha2 = "0.10" +# Peer chain inspection (full chain for fetch); aligns with OpenSSL used by native-tls/reqwest. +openssl = "0.10" [target.'cfg(any(target_os = "linux", target_os = "dragonfly", target_os = "freebsd", target_os = "netbsd", target_os = "openbsd"))'.dependencies] soup3 = "0.5.0" diff --git a/apps/desktop/src-tauri/src/plugins/demo.rs b/apps/desktop/src-tauri/src/plugins/demo.rs deleted file mode 100644 index 253da32..0000000 --- a/apps/desktop/src-tauri/src/plugins/demo.rs +++ /dev/null @@ -1,40 +0,0 @@ -use super::{HostEnrichContext, NssPlugin, PluginCapability, PluginManifest}; -use crate::ssh_config::HostConfig; -use anyhow::Result; - -pub const DEMO_PLUGIN_ID: &str = "dev.nosuckshell.plugin.demo"; - -pub struct DemoPlugin; - -impl NssPlugin for DemoPlugin { - fn manifest(&self) -> PluginManifest { - PluginManifest { - id: DEMO_PLUGIN_ID.to_string(), - version: env!("CARGO_PKG_VERSION").to_string(), - display_name: "Demo plugin".to_string(), - capabilities: vec![ - PluginCapability::CredentialProvider, - PluginCapability::SettingsUi, - ], - } - } - - fn enrich_host_config(&self, host: &mut HostConfig, ctx: &HostEnrichContext) -> Result<()> { - if ctx.original.host.starts_with("demo:") { - // Visible when running `tauri dev` from a terminal; does not alter SSH wire behavior. - eprintln!( - "[NoSuckShell demo plugin] enrich_host_config for host alias {:?}", - ctx.original.host - ); - } - let _ = host; - Ok(()) - } - - fn invoke(&self, method: &str, arg: &serde_json::Value) -> Result { - match method { - "ping" => Ok(serde_json::json!({ "ok": true, "message": "pong", "echo": arg })), - _ => anyhow::bail!("unknown method: {method}"), - } - } -} diff --git a/apps/desktop/src-tauri/src/plugins/file_workspace.rs b/apps/desktop/src-tauri/src/plugins/file_workspace.rs index f99e9a4..583214a 100644 --- a/apps/desktop/src-tauri/src/plugins/file_workspace.rs +++ b/apps/desktop/src-tauri/src/plugins/file_workspace.rs @@ -11,7 +11,7 @@ impl NssPlugin for FileWorkspacePlugin { PluginManifest { id: FILE_WORKSPACE_PLUGIN_ID.to_string(), version: env!("CARGO_PKG_VERSION").to_string(), - display_name: "File workspace".to_string(), + display_name: "NSS-Commander".to_string(), capabilities: vec![PluginCapability::SettingsUi], } } diff --git a/apps/desktop/src-tauri/src/plugins/mod.rs b/apps/desktop/src-tauri/src/plugins/mod.rs index 1989acc..a9d41e9 100644 --- a/apps/desktop/src-tauri/src/plugins/mod.rs +++ b/apps/desktop/src-tauri/src/plugins/mod.rs @@ -1,5 +1,4 @@ //! Built-in plugin registry and host-config enrichment hooks. -mod demo; mod file_workspace; mod proxmux; @@ -51,7 +50,6 @@ static REGISTRY: OnceLock> = OnceLock::new(); pub fn register_builtin_plugins() { let _ = REGISTRY.set(vec![ &file_workspace::FileWorkspacePlugin as &dyn NssPlugin, - &demo::DemoPlugin as &dyn NssPlugin, &proxmux::ProxmuxPlugin as &dyn NssPlugin, ]); } diff --git a/apps/desktop/src-tauri/src/plugins/proxmux.rs b/apps/desktop/src-tauri/src/plugins/proxmux.rs index c6490e0..85e7816 100644 --- a/apps/desktop/src-tauri/src/plugins/proxmux.rs +++ b/apps/desktop/src-tauri/src/plugins/proxmux.rs @@ -7,6 +7,7 @@ use crate::ssh_config::HostConfig; use crate::ssh_home::effective_ssh_dir; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use serde_json::{json, Map, Value}; use std::collections::{HashMap, HashSet}; use std::fs; @@ -61,6 +62,7 @@ impl NssPlugin for ProxmuxPlugin { "qemuSpiceCapable" => Ok(qemu_spice_capable(arg)?), "saveProxySettings" => Ok(save_proxy_settings(arg)?), "saveProxyProfiles" => Ok(save_proxy_profiles(arg)?), + "fetchTlsCertificate" => Ok(fetch_tls_certificate(arg)?), _ => anyhow::bail!("unknown method: {method}"), } } @@ -99,6 +101,12 @@ struct StoredCluster { is_enabled: bool, #[serde(default)] allow_insecure_tls: bool, + /// PEM-encoded leaf (or chain) trusted for this cluster instead of skipping verification. + #[serde(default)] + tls_trusted_cert_pem: Option, + /// Hex SHA-256 of the leaf DER (fingerprint) when `tls_trusted_cert_pem` is set. + #[serde(default)] + tls_trusted_leaf_sha256: Option, /// `None`/empty = use global default proxy (`ProxmuxState.http_proxy_url`); `Some("direct")` = no proxy; `Some(profile id)` = named profile. #[serde(default)] proxy_id: Option, @@ -758,10 +766,114 @@ fn profile_no_proxy_extra_line(state: &ProxmuxState, cluster: &StoredCluster) -> .filter(|s| !s.is_empty()) } -fn http_client(allow_insecure_tls: bool, state: &ProxmuxState, cluster: Option<&StoredCluster>) -> Result { +fn leaf_sha256_from_pem(pem: &str) -> Result { + let cert = native_tls::Certificate::from_pem(pem.trim().as_bytes()).context("parse PEM certificate")?; + let der = cert.to_der().context("certificate DER")?; + Ok(hex::encode(Sha256::digest(&der))) +} + +/// Fetch the peer certificate **chain** (leaf + intermediates) as PEM and the leaf SHA-256. +/// Proxmox often presents a leaf signed by a local CA; trusting only the leaf PEM is not enough +/// for OpenSSL verification (`unable to get local issuer certificate`). +fn fetch_peer_chain_pem_and_leaf_sha256(base_url: &str) -> Result<(String, String)> { + use openssl::ssl::{SslConnector, SslMethod, SslVerifyMode}; + use std::net::TcpStream; + + let with_scheme = if base_url.contains("://") { + base_url.to_string() + } else { + format!("https://{}", base_url.trim()) + }; + let u = url::Url::parse(&with_scheme).context("parse Proxmox URL")?; + let host = u.host_str().ok_or_else(|| anyhow::anyhow!("URL missing host"))?; + let port = u.port_or_known_default().unwrap_or(8006); + + let tcp = TcpStream::connect((host, port)).context("TCP connect")?; + + let mut builder = SslConnector::builder(SslMethod::tls()).context("openssl SslConnector")?; + builder.set_verify(SslVerifyMode::NONE); + let connector = builder.build(); + + let stream = connector.connect(host, tcp).context("TLS handshake")?; + let ssl = stream.ssl(); + + let mut pem_acc = String::new(); + let mut leaf_der: Option> = None; + + if let Some(chain) = ssl.peer_cert_chain() { + for (i, cert) in chain.iter().enumerate() { + if i == 0 { + leaf_der = Some(cert.to_der().context("leaf to_der")?); + } + let pem = cert.to_pem().context("to_pem")?; + pem_acc.push_str(&String::from_utf8_lossy(&pem)); + } + } + + if leaf_der.is_none() { + if let Some(leaf) = ssl.peer_certificate() { + leaf_der = Some(leaf.to_der().context("peer_certificate to_der")?); + let pem = leaf.to_pem().context("peer_certificate to_pem")?; + pem_acc.push_str(&String::from_utf8_lossy(&pem)); + } + } + + let leaf_der = leaf_der.ok_or_else(|| anyhow::anyhow!("server did not present a certificate"))?; + if pem_acc.trim().is_empty() { + anyhow::bail!("empty PEM chain from server"); + } + + let sha256 = hex::encode(Sha256::digest(&leaf_der)); + Ok((pem_acc, sha256)) +} + +fn fetch_tls_certificate(arg: &Value) -> Result { + #[derive(Debug, Deserialize)] + #[serde(rename_all = "camelCase")] + struct FetchTlsCertificateArg { + #[serde(default)] + cluster_id: Option, + #[serde(default)] + proxmox_url: Option, + } + let p: FetchTlsCertificateArg = serde_json::from_value(arg.clone()).context("parse fetchTlsCertificate")?; + let base_url = if let Some(id) = p.cluster_id.filter(|s| !s.trim().is_empty()) { + let state = load_state()?; + let c = state + .clusters + .get(&id) + .ok_or_else(|| anyhow::anyhow!("unknown cluster"))?; + normalize_base_url(&c.proxmox_url) + } else if let Some(u) = p.proxmox_url { + normalize_base_url(&u) + } else { + anyhow::bail!("clusterId or proxmoxUrl is required"); + }; + if base_url.is_empty() { + anyhow::bail!("Proxmox URL is required."); + } + let (pem, sha256) = fetch_peer_chain_pem_and_leaf_sha256(&base_url)?; + Ok(json!({ + "ok": true, + "pem": pem, + "leafSha256": sha256, + })) +} + +fn http_client(state: &ProxmuxState, cluster: Option<&StoredCluster>) -> Result { + let allow_insecure = cluster.map(|c| c.allow_insecure_tls).unwrap_or(false); + let has_trusted_pem = cluster + .and_then(|c| c.tls_trusted_cert_pem.as_deref()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .is_some(); + // PVE often omits chain links or uses CAs that do not verify as OpenSSL trust anchors even when + // the PEM bundle is complete. When the user stored a PEM (from fetch or paste), treat that as + // explicit trust for this cluster and skip built-in verification — same effective posture as + // "Allow insecure TLS", while the PEM + leaf fingerprint remain for identity / rotation UX. let mut b = reqwest::blocking::Client::builder() .timeout(Duration::from_secs(30)) - .danger_accept_invalid_certs(allow_insecure_tls) + .danger_accept_invalid_certs(allow_insecure || has_trusted_pem) .user_agent(concat!("NoSuckShell-PROXMUX/", env!("CARGO_PKG_VERSION"))); let proxy_url = cluster .and_then(|c| resolve_proxy_http_url(state, c)) @@ -1091,6 +1203,8 @@ fn cluster_to_public(c: &StoredCluster) -> Value { "failoverUrls": c.failover_urls.iter().map(|u| normalize_base_url(u)).collect::>(), "isEnabled": c.is_enabled, "allowInsecureTls": c.allow_insecure_tls, + "tlsTrustedCertPem": c.tls_trusted_cert_pem, + "tlsTrustedLeafSha256": c.tls_trusted_leaf_sha256, "proxyId": c.proxy_id, }) } @@ -1243,6 +1357,9 @@ struct SaveClusterPayload { is_enabled: bool, #[serde(default)] allow_insecure_tls: bool, + /// When `Some`, replaces or clears trusted PEM; when `None`, keep existing cluster PEM (edit only). + #[serde(default)] + tls_trusted_cert_pem: Option, #[serde(default)] proxy_id: Option, } @@ -1295,6 +1412,22 @@ fn save_cluster(arg: &Value) -> Result { .filter(|u| !u.is_empty()) .collect(); + let existing_cluster = state.clusters.get(&id); + let (tls_trusted_cert_pem, tls_trusted_leaf_sha256) = match payload.tls_trusted_cert_pem { + Some(ref s) => { + let t = s.trim().to_string(); + if t.is_empty() { + (None, None) + } else { + let sha = leaf_sha256_from_pem(&t)?; + (Some(t), Some(sha)) + } + } + None => existing_cluster + .map(|e| (e.tls_trusted_cert_pem.clone(), e.tls_trusted_leaf_sha256.clone())) + .unwrap_or((None, None)), + }; + let cluster = StoredCluster { id: id.clone(), name: payload.name.trim().to_string(), @@ -1307,6 +1440,8 @@ fn save_cluster(arg: &Value) -> Result { failover_urls, is_enabled: payload.is_enabled, allow_insecure_tls: payload.allow_insecure_tls, + tls_trusted_cert_pem, + tls_trusted_leaf_sha256, proxy_id: normalize_cluster_proxy_id(payload.proxy_id), }; @@ -1376,6 +1511,8 @@ struct DraftClusterArg { #[serde(default)] allow_insecure_tls: bool, #[serde(default)] + tls_trusted_cert_pem: Option, + #[serde(default)] proxy_id: Option, } @@ -1401,6 +1538,14 @@ fn test_connection_draft(arg: &Value) -> Result { "message": "Password is required to test a connection." })); } + let (tls_trusted_cert_pem, tls_trusted_leaf_sha256) = match d.tls_trusted_cert_pem.as_ref() { + Some(s) if !s.trim().is_empty() => { + let t = s.trim().to_string(); + let sha = leaf_sha256_from_pem(&t)?; + (Some(t), Some(sha)) + } + _ => (None, None), + }; let c = StoredCluster { id: "draft".to_string(), name: String::new(), @@ -1413,6 +1558,8 @@ fn test_connection_draft(arg: &Value) -> Result { failover_urls: d.failover_urls, is_enabled: true, allow_insecure_tls: d.allow_insecure_tls, + tls_trusted_cert_pem, + tls_trusted_leaf_sha256, proxy_id: normalize_cluster_proxy_id(d.proxy_id), }; let state = load_state()?; @@ -1420,7 +1567,7 @@ fn test_connection_draft(arg: &Value) -> Result { } fn test_cluster_core(c: &StoredCluster, state: &ProxmuxState) -> Result { - let client = http_client(c.allow_insecure_tls, state, Some(c))?; + let client = http_client(state, Some(c))?; let primary = normalize_base_url(&c.proxmox_url); if primary.is_empty() { return Ok(json!({ "ok": false, "message": "Proxmox URL is empty." })); @@ -1447,7 +1594,7 @@ fn fetch_resources(arg: &Value) -> Result { .ok_or_else(|| anyhow::anyhow!("unknown cluster"))?; let cache_key = proxmux_cache_key_for_fetch_resources(&cluster_id); proxmux_cached_json(cache_key, ProxmuxCacheBucket::FetchResources, || { - let client = http_client(c.allow_insecure_tls, &state, Some(c))?; + let client = http_client(&state, Some(c))?; let primary = normalize_base_url(&c.proxmox_url); let data = with_failover(&primary, &c.failover_urls, |base| { @@ -1659,7 +1806,7 @@ fn guest_status(arg: &Value) -> Result { .ok_or_else(|| anyhow::anyhow!("unknown cluster"))?; let cache_key = proxmux_cache_key_for_guest_status(&cluster_id, &node, typ, &vmid); proxmux_cached_json(cache_key, ProxmuxCacheBucket::GuestStatus, || { - let client = http_client(c.allow_insecure_tls, &state, Some(c))?; + let client = http_client(&state, Some(c))?; let primary = normalize_base_url(&c.proxmox_url); let path_tail = format!("/api2/json/nodes/{node}/{typ}/{vmid}/status/current"); @@ -1691,7 +1838,7 @@ fn fetch_spice_proxy(arg: &Value) -> Result { .clusters .get(&cluster_id) .ok_or_else(|| anyhow::anyhow!("unknown cluster"))?; - let client = http_client(c.allow_insecure_tls, &state, Some(c))?; + let client = http_client(&state, Some(c))?; let primary = normalize_base_url(&c.proxmox_url); let path_tail = format!("/api2/json/nodes/{node}/{typ}/{vmid}/spiceproxy"); @@ -1722,7 +1869,7 @@ fn fetch_qemu_vnc_proxy(arg: &Value) -> Result { .clusters .get(&cluster_id) .ok_or_else(|| anyhow::anyhow!("unknown cluster"))?; - let client = http_client(c.allow_insecure_tls, &state, Some(c))?; + let client = http_client(&state, Some(c))?; let primary = normalize_base_url(&c.proxmox_url); let path_tail = format!("/api2/json/nodes/{node}/qemu/{vmid}/vncproxy"); @@ -1756,7 +1903,7 @@ fn fetch_lxc_term_proxy(arg: &Value) -> Result { .clusters .get(&cluster_id) .ok_or_else(|| anyhow::anyhow!("unknown cluster"))?; - let client = http_client(c.allow_insecure_tls, &state, Some(c))?; + let client = http_client(&state, Some(c))?; let primary = normalize_base_url(&c.proxmox_url); let api_user = c.api_user.clone(); @@ -1801,7 +1948,7 @@ fn qemu_spice_capable(arg: &Value) -> Result { .clusters .get(&cluster_id) .ok_or_else(|| anyhow::anyhow!("unknown cluster"))?; - let client = http_client(c.allow_insecure_tls, &state, Some(c))?; + let client = http_client(&state, Some(c))?; let primary = normalize_base_url(&c.proxmox_url); let path_tail = format!("/api2/json/nodes/{node}/qemu/{vmid}/config"); @@ -1845,7 +1992,7 @@ fn guest_power(arg: &Value) -> Result { .clusters .get(&cluster_id) .ok_or_else(|| anyhow::anyhow!("unknown cluster"))?; - let client = http_client(c.allow_insecure_tls, &state, Some(c))?; + let client = http_client(&state, Some(c))?; let primary = normalize_base_url(&c.proxmox_url); let path_tail = format!( @@ -2011,6 +2158,8 @@ mod tests { failover_urls: vec!["https://pve2.example.com:8006".to_string()], is_enabled: true, allow_insecure_tls: true, + tls_trusted_cert_pem: None, + tls_trusted_leaf_sha256: None, proxy_id: None, }, ); @@ -2053,6 +2202,8 @@ mod tests { failover_urls: vec![], is_enabled: true, allow_insecure_tls: false, + tls_trusted_cert_pem: None, + tls_trusted_leaf_sha256: None, proxy_id: None, }, ); diff --git a/apps/desktop/src-tauri/src/proxmux_ws_proxy.rs b/apps/desktop/src-tauri/src/proxmux_ws_proxy.rs index 03475d2..27c532f 100644 --- a/apps/desktop/src-tauri/src/proxmux_ws_proxy.rs +++ b/apps/desktop/src-tauri/src/proxmux_ws_proxy.rs @@ -1,7 +1,8 @@ //! Local WebSocket listener that bridges the Tauri webview to a Proxmox `wss://` console endpoint. -//! Needed when the cluster uses self-signed TLS (`allow insecure TLS`): the system webview may reject -//! `wss://` to the cluster, while this path accepts the browser `ws://127.0.0.1` hop and uses -//! `native_tls` with optional `danger_accept_invalid_certs` toward Proxmox. +//! Browsers cannot easily trust a self-signed cluster certificate; this path accepts `ws://127.0.0.1` +//! from the webview and connects upstream with `native_tls`. When a trusted PEM is stored for the +//! cluster, verification is skipped (see `http_client` in `proxmux.rs`); `allow_insecure_tls` alone +//! also skips verification. use futures_util::{SinkExt, StreamExt}; use http::Uri; @@ -52,9 +53,19 @@ fn apply_upstream_auth_headers( } } +fn build_tls_connector(allow_insecure_tls: bool, tls_trusted_cert_pem: Option<&str>) -> anyhow::Result { + let mut tls_builder = native_tls::TlsConnector::builder(); + let has_trusted_pem = tls_trusted_cert_pem.map(str::trim).filter(|s| !s.is_empty()).is_some(); + if has_trusted_pem || allow_insecure_tls { + tls_builder.danger_accept_invalid_certs(true); + } + Ok(tls_builder.build()?) +} + async fn connect_upstream_wss( upstream_wss_url: &str, allow_insecure_tls: bool, + tls_trusted_cert_pem: Option<&str>, auth_header: Option<&str>, auth_cookie: Option<&str>, ) -> anyhow::Result { @@ -63,11 +74,7 @@ async fn connect_upstream_wss( let port = parsed.port_or_known_default().unwrap_or(443); let tcp = TcpStream::connect((host, port)).await?; - let mut tls_builder = native_tls::TlsConnector::builder(); - // Certificate validation remains enabled; `allow_insecure_tls` is intentionally ignored - // to avoid disabling TLS security. If self-signed certificates must be supported, configure - // a proper trust store instead of calling `danger_accept_invalid_certs(true)`. - let cx = tls_builder.build()?; + let cx = build_tls_connector(allow_insecure_tls, tls_trusted_cert_pem)?; let cx = tokio_native_tls::TlsConnector::from(cx); let tls = cx.connect(host, tcp).await?; @@ -87,9 +94,11 @@ async fn proxy_one_browser_connection( expected_path: String, upstream_wss_url: String, allow_insecure_tls: bool, + tls_trusted_cert_pem: Option, auth_header: Option, auth_cookie: Option, ) { + let tls_pem = tls_trusted_cert_pem.as_deref(); let callback = move |req: &Request, response: Response| -> Result>> { if req.uri().path() != expected_path.as_str() { let err = http::Response::builder() @@ -111,6 +120,7 @@ async fn proxy_one_browser_connection( let upstream_ws = match connect_upstream_wss( &upstream_wss_url, allow_insecure_tls, + tls_pem, auth_header.as_deref(), auth_cookie.as_deref(), ) @@ -177,6 +187,7 @@ pub struct ProxmuxWsProxyStartResult { pub async fn proxmux_ws_proxy_start( upstream_wss_url: String, allow_insecure_tls: bool, + tls_trusted_cert_pem: Option, auth_header: Option, auth_cookie: Option, ) -> Result { @@ -192,6 +203,7 @@ pub async fn proxmux_ws_proxy_start( let local_ws_url = format!("ws://127.0.0.1:{port}{expected_path}"); let upstream = upstream_wss_url.clone(); + let tls_pem = tls_trusted_cert_pem.filter(|s| !s.trim().is_empty()); let conn_handles: ConnHandles = Arc::new(Mutex::new(Vec::new())); let conn_handles_for_loop = conn_handles.clone(); @@ -203,11 +215,13 @@ pub async fn proxmux_ws_proxy_start( let auth = auth_header.clone(); let cookie = auth_cookie.clone(); let path = expected_path.clone(); + let tls = tls_pem.clone(); let conn_handle = tokio::spawn(proxy_one_browser_connection( stream, path, upstream, allow_insecure_tls, + tls, auth, cookie, )); diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index eb3ec1a..b8d2cef 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "NoSuckShell", - "version": "0.1.0-beta.11", + "version": "0.2.1", "identifier": "dev.nosuckshell.desktop", "build": { "beforeDevCommand": "npm run dev", diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index fd4094f..7c74969 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -169,7 +169,7 @@ import { } from "./features/host-metadata-policy"; import { createId } from "./features/app-id"; import { validateExternalHttpUrl } from "./features/external-http-url"; -import { isProxmoxConsoleDeepLinkUrl } from "./features/proxmox-console-urls"; +import { buildProxmoxConsoleUrl, isProxmoxConsoleDeepLinkUrl } from "./features/proxmox-console-urls"; import { computeProxmuxWarmupDelayMs, selectProxmuxWarmupClusterId, @@ -697,6 +697,8 @@ export function App() { const [filePaneTitleEpoch, setFilePaneTitleEpoch] = useState(0); const sessionTerminalCwdRef = useRef>({}); const [sessionTerminalCwdEpoch, setSessionTerminalCwdEpoch] = useState(0); + const [proxmoxQemuVncReconnectNonces, setProxmoxQemuVncReconnectNonces] = useState>({}); + const [proxmoxLxcReconnectNonces, setProxmoxLxcReconnectNonces] = useState>({}); const draggingSessionIdRef = useRef(null); const suppressHostClickAliasRef = useRef(null); const isApplyingWorkspaceSnapshotRef = useRef(false); @@ -3312,6 +3314,7 @@ export function App() { vmid: sourceSession.vmid, proxmoxBaseUrl: sourceSession.proxmoxBaseUrl, ...(sourceSession.allowInsecureTls ? { allowInsecureTls: true } : {}), + ...(sourceSession.tlsTrustedCertPem ? { tlsTrustedCertPem: sourceSession.tlsTrustedCertPem } : {}), }, ]); } else { @@ -3326,6 +3329,7 @@ export function App() { vmid: sourceSession.vmid, proxmoxBaseUrl: sourceSession.proxmoxBaseUrl, ...(sourceSession.allowInsecureTls ? { allowInsecureTls: true } : {}), + ...(sourceSession.tlsTrustedCertPem ? { tlsTrustedCertPem: sourceSession.tlsTrustedCertPem } : {}), }, ]); } @@ -4661,6 +4665,7 @@ export function App() { label: string; allowInsecureTls: boolean; proxmoxBaseUrl: string; + tlsTrustedCertPem?: string; }) => { setError(""); const base = ctx.proxmoxBaseUrl.trim(); @@ -4680,6 +4685,7 @@ export function App() { vmid: ctx.vmid, proxmoxBaseUrl: base, ...(ctx.allowInsecureTls ? { allowInsecureTls: true as const } : {}), + ...(ctx.tlsTrustedCertPem?.trim() ? { tlsTrustedCertPem: ctx.tlsTrustedCertPem.trim() } : {}), }, ]); const autoSplitDirection: "right" | "bottom" = @@ -4702,6 +4708,7 @@ export function App() { label: string; allowInsecureTls: boolean; proxmoxBaseUrl: string; + tlsTrustedCertPem?: string; }) => { setError(""); const base = ctx.proxmoxBaseUrl.trim(); @@ -4721,6 +4728,7 @@ export function App() { vmid: ctx.vmid, proxmoxBaseUrl: base, ...(ctx.allowInsecureTls ? { allowInsecureTls: true as const } : {}), + ...(ctx.tlsTrustedCertPem?.trim() ? { tlsTrustedCertPem: ctx.tlsTrustedCertPem.trim() } : {}), }, ]); const autoSplitDirection: "right" | "bottom" = @@ -5043,6 +5051,7 @@ export function App() { paneTitle: session.label, proxmoxBaseUrl: session.proxmoxBaseUrl, ...(session.allowInsecureTls ? { allowInsecureTls: true as const } : {}), + ...(session.tlsTrustedCertPem ? { tlsTrustedCertPem: session.tlsTrustedCertPem } : {}), }; } if (session.kind === "proxmoxLxcTerm") { @@ -5054,6 +5063,7 @@ export function App() { paneTitle: session.label, proxmoxBaseUrl: session.proxmoxBaseUrl, ...(session.allowInsecureTls ? { allowInsecureTls: true as const } : {}), + ...(session.tlsTrustedCertPem ? { tlsTrustedCertPem: session.tlsTrustedCertPem } : {}), }; } return null; @@ -5061,6 +5071,142 @@ export function App() { [splitSlots, sessions], ); + const proxmoxQemuVncForPane = useCallback( + (paneIndex: number) => { + const px = proxmoxNativeConsoleForPane(paneIndex); + return px?.kind === "qemu-vnc" ? px : null; + }, + [proxmoxNativeConsoleForPane], + ); + + const proxmoxQemuVncReconnectNonceForPane = useCallback( + (paneIndex: number): number => proxmoxQemuVncReconnectNonces[paneIndex] ?? 0, + [proxmoxQemuVncReconnectNonces], + ); + + const requestProxmoxQemuVncReconnect = useCallback((paneIndex: number) => { + setProxmoxQemuVncReconnectNonces((prev) => ({ + ...prev, + [paneIndex]: (prev[paneIndex] ?? 0) + 1, + })); + }, []); + + const openProxmoxQemuVncInAppWindow = useCallback( + (paneIndex: number) => { + const px = proxmoxQemuVncForPane(paneIndex); + if (!px) { + return; + } + setError(""); + const consoleUrl = buildProxmoxConsoleUrl(px.proxmoxBaseUrl, { + kind: "qemu", + node: px.node, + vmid: px.vmid, + }); + void openProxmoxInAppWebviewWindow({ + title: px.paneTitle, + consoleUrl, + allowInsecureTls: px.allowInsecureTls === true, + }) + .then((result) => { + if (result.loginFirst && !result.reused) { + setProxmoxWebLoginAssist({ label: result.label, consoleUrl }); + } + }) + .catch((e) => { + setError(String(e)); + }); + }, + [proxmoxQemuVncForPane], + ); + + const openProxmoxQemuVncInBrowser = useCallback( + (paneIndex: number) => { + const px = proxmoxQemuVncForPane(paneIndex); + if (!px) { + return; + } + setError(""); + const consoleUrl = buildProxmoxConsoleUrl(px.proxmoxBaseUrl, { + kind: "qemu", + node: px.node, + vmid: px.vmid, + }); + void openExternalUrl(consoleUrl).catch((e) => { + setError(String(e)); + }); + }, + [proxmoxQemuVncForPane], + ); + + const proxmoxLxcForPane = useCallback( + (paneIndex: number) => { + const px = proxmoxNativeConsoleForPane(paneIndex); + return px?.kind === "lxc-term" ? px : null; + }, + [proxmoxNativeConsoleForPane], + ); + + const proxmoxLxcReconnectNonceForPane = useCallback( + (paneIndex: number): number => proxmoxLxcReconnectNonces[paneIndex] ?? 0, + [proxmoxLxcReconnectNonces], + ); + + const requestProxmoxLxcReconnect = useCallback((paneIndex: number) => { + setProxmoxLxcReconnectNonces((prev) => ({ + ...prev, + [paneIndex]: (prev[paneIndex] ?? 0) + 1, + })); + }, []); + + const openProxmoxLxcInAppWindow = useCallback( + (paneIndex: number) => { + const px = proxmoxLxcForPane(paneIndex); + if (!px) { + return; + } + setError(""); + const consoleUrl = buildProxmoxConsoleUrl(px.proxmoxBaseUrl, { + kind: "lxc", + node: px.node, + vmid: px.vmid, + }); + void openProxmoxInAppWebviewWindow({ + title: px.paneTitle, + consoleUrl, + allowInsecureTls: px.allowInsecureTls === true, + }) + .then((result) => { + if (result.loginFirst && !result.reused) { + setProxmoxWebLoginAssist({ label: result.label, consoleUrl }); + } + }) + .catch((e) => { + setError(String(e)); + }); + }, + [proxmoxLxcForPane], + ); + + const openProxmoxLxcInBrowser = useCallback( + (paneIndex: number) => { + const px = proxmoxLxcForPane(paneIndex); + if (!px) { + return; + } + setError(""); + const consoleUrl = buildProxmoxConsoleUrl(px.proxmoxBaseUrl, { + kind: "lxc", + node: px.node, + vmid: px.vmid, + }); + void openExternalUrl(consoleUrl).catch((e) => { + setError(String(e)); + }); + }, + [proxmoxLxcForPane], + ); + const getFileExportDestPath = useCallback(async () => { return resolveFileExportDestPath(fileExportDestMode, fileExportPathKey); }, [fileExportDestMode, fileExportPathKey]); @@ -5120,6 +5266,14 @@ export function App() { fileWorkspacePluginEnabled, webPanePayloadForPane, proxmoxNativeConsoleForPane, + proxmoxQemuVncReconnectNonceForPane, + requestProxmoxQemuVncReconnect, + openProxmoxQemuVncInAppWindow, + openProxmoxQemuVncInBrowser, + proxmoxLxcReconnectNonceForPane, + requestProxmoxLxcReconnect, + openProxmoxLxcInAppWindow, + openProxmoxLxcInBrowser, onWebPaneOpenInAppWindowError: setError, onWebPaneLoginFirstWebviewOpen, onSessionWorkingDirectoryChange: handleSessionWorkingDirectoryChange, diff --git a/apps/desktop/src/components/HelpPanel.tsx b/apps/desktop/src/components/HelpPanel.tsx index d5d05bd..24f54d5 100644 --- a/apps/desktop/src/components/HelpPanel.tsx +++ b/apps/desktop/src/components/HelpPanel.tsx @@ -300,6 +300,12 @@ const proxmuxSections: HelpSection[] = [ "Choose whether Proxmox noVNC/SPICE/HTML5 consoles open inside an app pane or in your default browser (toggle on the PROXMUX settings tab).", keys: "-", }, + { + action: "TLS and embedded consoles", + mouse: + "For private CA or self-signed HTTPS, use Allow insecure TLS and/or paste a trusted certificate PEM (confirm fingerprint changes when the leaf rotates). Embedded QEMU noVNC and LXC shells use a local WebSocket bridge to the cluster; the same TLS policy applies to Proxmox API calls.", + keys: "-", + }, ], }, ]; diff --git a/apps/desktop/src/components/ProxmoxLxcTermPane.tsx b/apps/desktop/src/components/ProxmoxLxcTermPane.tsx index 3ba728c..42a23bb 100644 --- a/apps/desktop/src/components/ProxmoxLxcTermPane.tsx +++ b/apps/desktop/src/components/ProxmoxLxcTermPane.tsx @@ -1,22 +1,20 @@ import { FitAddon } from "@xterm/addon-fit"; import { Terminal } from "@xterm/xterm"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { PROXMUX_PLUGIN_ID } from "../features/builtin-plugin-ids"; -import { buildProxmoxConsoleUrl } from "../features/proxmox-console-urls"; import { buildProxmoxConsoleWebSocketUrl, parseProxmoxConsoleProxyData } from "../features/proxmox-console-ws"; -import { openProxmoxInAppWebviewWindow } from "../features/proxmox-webview-window"; -import { openExternalUrl, pluginInvoke, proxmuxWsProxyStart, proxmuxWsProxyStop } from "../tauri-api"; +import { pluginInvoke, proxmuxWsProxyStart, proxmuxWsProxyStop } from "../tauri-api"; type Props = { clusterId: string; node: string; vmid: string; paneTitle: string; - proxmoxBaseUrl: string; allowInsecureTls?: boolean; + tlsTrustedCertPem?: string; + /** Incrementing nonce from pane toolbar to trigger reconnect. */ + reconnectRequestNonce?: number; onError?: (message: string) => void; - onOpenInAppWindowError?: (message: string) => void; - onLoginFirstWebviewOpen?: (payload: { label: string; consoleUrl: string }) => void; }; /** @@ -96,11 +94,10 @@ export function ProxmoxLxcTermPane({ node, vmid, paneTitle, - proxmoxBaseUrl, allowInsecureTls = false, + tlsTrustedCertPem, + reconnectRequestNonce = 0, onError, - onOpenInAppWindowError, - onLoginFirstWebviewOpen, }: Props) { const containerRef = useRef(null); const termRef = useRef(null); @@ -110,9 +107,44 @@ export function ProxmoxLxcTermPane({ const proxyIdRef = useRef(null); const [phase, setPhase] = useState<"connecting" | "ready" | "error">("connecting"); const [statusMessage, setStatusMessage] = useState("Connecting…"); - const [connectNonce, setConnectNonce] = useState(0); - const deepLinkUrl = buildProxmoxConsoleUrl(proxmoxBaseUrl, { kind: "lxc", node, vmid }); + useLayoutEffect(() => { + const container = containerRef.current; + const root = container?.closest(".terminal-root"); + if (!container || !root) { + return; + } + + const applyTopInset = () => { + const pane = root.closest(".split-pane") as HTMLElement | null; + const label = pane?.querySelector(".split-pane-label") as HTMLElement | null; + if (!pane || !label) { + root.style.removeProperty("--pane-terminal-top-inset"); + return; + } + const paneTop = pane.getBoundingClientRect().top; + const labelBottom = label.getBoundingClientRect().bottom; + const requiredTopInset = Math.ceil(Math.max(0, labelBottom - paneTop) + 2); + root.style.setProperty("--pane-terminal-top-inset", `${requiredTopInset}px`); + }; + + applyTopInset(); + const pane = root.closest(".split-pane"); + const label = pane?.querySelector(".split-pane-label") as HTMLElement | null; + if (!label) { + return () => { + root.style.removeProperty("--pane-terminal-top-inset"); + }; + } + const ro = new ResizeObserver(() => applyTopInset()); + ro.observe(label); + window.addEventListener("resize", applyTopInset); + return () => { + ro.disconnect(); + window.removeEventListener("resize", applyTopInset); + root.style.removeProperty("--pane-terminal-top-inset"); + }; + }, [paneTitle]); const teardown = useCallback(async () => { detachRef.current?.(); @@ -139,22 +171,6 @@ export function ProxmoxLxcTermPane({ } }, []); - const openInAppWindow = useCallback(() => { - void openProxmoxInAppWebviewWindow({ title: paneTitle, consoleUrl: deepLinkUrl, allowInsecureTls }) - .then((result) => { - if (result.loginFirst && !result.reused) { - onLoginFirstWebviewOpen?.({ label: result.label, consoleUrl: deepLinkUrl }); - } - }) - .catch((e) => { - onOpenInAppWindowError?.(String(e)); - }); - }, [allowInsecureTls, deepLinkUrl, onLoginFirstWebviewOpen, onOpenInAppWindowError, paneTitle]); - - const openInBrowser = useCallback(() => { - void openExternalUrl(deepLinkUrl).catch(() => {}); - }, [deepLinkUrl]); - useEffect(() => { const el = containerRef.current; if (!el) { @@ -208,10 +224,14 @@ export function ProxmoxLxcTermPane({ const vncTicket = String(ticket.ticket); const wssUrl = buildProxmoxConsoleWebSocketUrl(raw.apiOrigin.trim(), node, vmid, "lxc", ticket); + const tlsPem = tlsTrustedCertPem?.trim() ?? ""; + const useTlsBridge = allowInsecureTls || tlsPem.length > 0; + const upstreamInsecureOnly = allowInsecureTls && tlsPem.length === 0; + let connectUrl = wssUrl; - if (allowInsecureTls) { + if (useTlsBridge) { setStatusMessage("Starting local TLS bridge…"); - const proxy = await proxmuxWsProxyStart(wssUrl, true, undefined, raw.authCookie); + const proxy = await proxmuxWsProxyStart(wssUrl, upstreamInsecureOnly, tlsPem || null, undefined, raw.authCookie); if (cancelled) { await proxmuxWsProxyStop(proxy.proxyId).catch(() => {}); return; @@ -281,25 +301,10 @@ export function ProxmoxLxcTermPane({ ro.disconnect(); void teardown(); }; - }, [allowInsecureTls, clusterId, connectNonce, node, onError, teardown, vmid]); - - const reconnect = useCallback(() => { - setConnectNonce((n) => n + 1); - }, []); + }, [allowInsecureTls, clusterId, node, onError, reconnectRequestNonce, teardown, tlsTrustedCertPem, vmid]); return (
-
- - - -
{phase !== "ready" && statusMessage ? (
{statusMessage} diff --git a/apps/desktop/src/components/ProxmoxQemuVncPane.tsx b/apps/desktop/src/components/ProxmoxQemuVncPane.tsx index e7afdc7..4879ccd 100644 --- a/apps/desktop/src/components/ProxmoxQemuVncPane.tsx +++ b/apps/desktop/src/components/ProxmoxQemuVncPane.tsx @@ -1,20 +1,19 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { PROXMUX_PLUGIN_ID } from "../features/builtin-plugin-ids"; -import { buildProxmoxConsoleUrl } from "../features/proxmox-console-urls"; import { buildProxmoxConsoleWebSocketUrl, parseProxmoxConsoleProxyData } from "../features/proxmox-console-ws"; -import { openProxmoxInAppWebviewWindow } from "../features/proxmox-webview-window"; -import { openExternalUrl, pluginInvoke, proxmuxWsProxyStart, proxmuxWsProxyStop } from "../tauri-api"; +import { pluginInvoke, proxmuxWsProxyStart, proxmuxWsProxyStop } from "../tauri-api"; type Props = { clusterId: string; node: string; vmid: string; paneTitle: string; - proxmoxBaseUrl: string; allowInsecureTls?: boolean; + /** Incrementing nonce from pane toolbar to trigger reconnect. */ + reconnectRequestNonce?: number; + /** PEM trusted for upstream TLS (local WS bridge); if set, verification uses this anchor instead of skipping checks. */ + tlsTrustedCertPem?: string; onError?: (message: string) => void; - onOpenInAppWindowError?: (message: string) => void; - onLoginFirstWebviewOpen?: (payload: { label: string; consoleUrl: string }) => void; }; /** noVNC ESM interop: default export may be RFB or a nested `{ default: RFB }`. */ @@ -42,18 +41,54 @@ export function ProxmoxQemuVncPane({ node, vmid, paneTitle, - proxmoxBaseUrl, allowInsecureTls = false, + reconnectRequestNonce = 0, + tlsTrustedCertPem, onError, - onOpenInAppWindowError, - onLoginFirstWebviewOpen, }: Props) { const screenRef = useRef(null); const rfbRef = useRef<{ disconnect: () => void } | null>(null); const proxyIdRef = useRef(null); const [phase, setPhase] = useState<"connecting" | "connected" | "error" | "disconnected">("connecting"); const [statusMessage, setStatusMessage] = useState("Connecting…"); - const [connectNonce, setConnectNonce] = useState(0); + + useLayoutEffect(() => { + const screen = screenRef.current; + const root = screen?.closest(".terminal-root"); + if (!screen || !root) { + return; + } + + const applyTopInset = () => { + const pane = root.closest(".split-pane") as HTMLElement | null; + const label = pane?.querySelector(".split-pane-label") as HTMLElement | null; + if (!pane || !label) { + root.style.removeProperty("--pane-terminal-top-inset"); + return; + } + const paneTop = pane.getBoundingClientRect().top; + const labelBottom = label.getBoundingClientRect().bottom; + const requiredTopInset = Math.ceil(Math.max(0, labelBottom - paneTop) + 2); + root.style.setProperty("--pane-terminal-top-inset", `${requiredTopInset}px`); + }; + + applyTopInset(); + const pane = root.closest(".split-pane"); + const label = pane?.querySelector(".split-pane-label") as HTMLElement | null; + if (!label) { + return () => { + root.style.removeProperty("--pane-terminal-top-inset"); + }; + } + const ro = new ResizeObserver(() => applyTopInset()); + ro.observe(label); + window.addEventListener("resize", applyTopInset); + return () => { + ro.disconnect(); + window.removeEventListener("resize", applyTopInset); + root.style.removeProperty("--pane-terminal-top-inset"); + }; + }, [paneTitle]); const teardown = useCallback(async () => { try { @@ -73,24 +108,6 @@ export function ProxmoxQemuVncPane({ } }, []); - const deepLinkUrl = buildProxmoxConsoleUrl(proxmoxBaseUrl, { kind: "qemu", node, vmid }); - - const openInAppWindow = useCallback(() => { - void openProxmoxInAppWebviewWindow({ title: paneTitle, consoleUrl: deepLinkUrl, allowInsecureTls }) - .then((result) => { - if (result.loginFirst && !result.reused) { - onLoginFirstWebviewOpen?.({ label: result.label, consoleUrl: deepLinkUrl }); - } - }) - .catch((e) => { - onOpenInAppWindowError?.(String(e)); - }); - }, [allowInsecureTls, deepLinkUrl, onLoginFirstWebviewOpen, onOpenInAppWindowError, paneTitle]); - - const openInBrowser = useCallback(() => { - void openExternalUrl(deepLinkUrl).catch(() => {}); - }, [deepLinkUrl]); - useEffect(() => { let cancelled = false; const screen = screenRef.current; @@ -125,10 +142,14 @@ export function ProxmoxQemuVncPane({ } const wssUrl = buildProxmoxConsoleWebSocketUrl(raw.apiOrigin.trim(), node, vmid, "qemu", ticket); + const tlsPem = tlsTrustedCertPem?.trim() ?? ""; + const useTlsBridge = allowInsecureTls || tlsPem.length > 0; + const upstreamInsecureOnly = allowInsecureTls && tlsPem.length === 0; + let rfbUrl: string | WebSocket = wssUrl; - if (allowInsecureTls) { + if (useTlsBridge) { setStatusMessage("Starting local TLS bridge…"); - const proxy = await proxmuxWsProxyStart(wssUrl, true, undefined, raw.authCookie); + const proxy = await proxmuxWsProxyStart(wssUrl, upstreamInsecureOnly, tlsPem || null, undefined, raw.authCookie); if (cancelled) { await proxmuxWsProxyStop(proxy.proxyId).catch(() => {}); return; @@ -203,25 +224,10 @@ export function ProxmoxQemuVncPane({ cancelled = true; void teardown(); }; - }, [allowInsecureTls, clusterId, connectNonce, node, onError, teardown, vmid]); - - const reconnect = useCallback(() => { - setConnectNonce((n) => n + 1); - }, []); + }, [allowInsecureTls, clusterId, node, onError, reconnectRequestNonce, teardown, tlsTrustedCertPem, vmid]); return (
-
- - - -
{phase !== "connected" && statusMessage ? (
{statusMessage} diff --git a/apps/desktop/src/components/ProxmuxSidebarPanel.tsx b/apps/desktop/src/components/ProxmuxSidebarPanel.tsx index f8ca346..16b5902 100644 --- a/apps/desktop/src/components/ProxmuxSidebarPanel.tsx +++ b/apps/desktop/src/components/ProxmuxSidebarPanel.tsx @@ -9,6 +9,8 @@ type ProxmuxClusterRow = { proxmoxUrl: string; /** Omitted on older plugin payloads; treated as false. */ allowInsecureTls?: boolean; + tlsTrustedCertPem?: string | null; + tlsTrustedLeafSha256?: string | null; }; type ListStateResponse = { @@ -257,6 +259,7 @@ export type ProxmuxSidebarPanelProps = { label: string; allowInsecureTls: boolean; proxmoxBaseUrl: string; + tlsTrustedCertPem?: string; }) => void | Promise; onOpenProxmoxLxcConsoleInPane?: (ctx: { clusterId: string; @@ -265,6 +268,7 @@ export type ProxmuxSidebarPanelProps = { label: string; allowInsecureTls: boolean; proxmoxBaseUrl: string; + tlsTrustedCertPem?: string; }) => void | Promise; }; @@ -718,6 +722,7 @@ export function ProxmuxSidebarPanel({ const proxmoxBaseUrl = activeCluster?.proxmoxUrl ?? ""; const allowInsecureTlsForOpens = activeCluster?.allowInsecureTls === true; + const tlsTrustedCertPemForOpens = typeof activeCluster?.tlsTrustedCertPem === "string" ? activeCluster.tlsTrustedCertPem.trim() : ""; const runOpenUrl = useCallback( async (url: string, label?: string) => { @@ -828,6 +833,7 @@ export function ProxmuxSidebarPanel({ label: "noVNC", allowInsecureTls: allowInsecureTlsForOpens, proxmoxBaseUrl, + ...(tlsTrustedCertPemForOpens ? { tlsTrustedCertPem: tlsTrustedCertPemForOpens } : {}), }); return; } @@ -883,6 +889,7 @@ export function ProxmuxSidebarPanel({ label: "LXC console", allowInsecureTls: allowInsecureTlsForOpens, proxmoxBaseUrl, + ...(tlsTrustedCertPemForOpens ? { tlsTrustedCertPem: tlsTrustedCertPemForOpens } : {}), }); return; } diff --git a/apps/desktop/src/components/SplitWorkspace.tsx b/apps/desktop/src/components/SplitWorkspace.tsx index 8902f57..e06d7e9 100644 --- a/apps/desktop/src/components/SplitWorkspace.tsx +++ b/apps/desktop/src/components/SplitWorkspace.tsx @@ -45,6 +45,7 @@ export type ProxmoxNativeConsolePanePayload = paneTitle: string; proxmoxBaseUrl: string; allowInsecureTls?: boolean; + tlsTrustedCertPem?: string; } | { kind: "lxc-term"; @@ -54,6 +55,7 @@ export type ProxmoxNativeConsolePanePayload = paneTitle: string; proxmoxBaseUrl: string; allowInsecureTls?: boolean; + tlsTrustedCertPem?: string; }; export type SplitPaneRendererBridge = { @@ -117,12 +119,21 @@ export type SplitPaneRendererBridge = { fileExportArchiveFormat: FileExportArchiveFormat; onFilePaneTitleChange: (paneIndex: number, payload: { short: string; full: string } | null) => void; semanticFileNameColors: boolean; - /** When false, SFTP/local file browser toolbar and views are disabled (File workspace plugin). */ + /** When false, SFTP/local file browser toolbar and views are disabled (NSS-Commander plugin). */ fileWorkspacePluginEnabled: boolean; /** When set, pane shows embedded web UI instead of terminal or file browser. */ webPanePayloadForPane: (paneIndex: number) => { url: string; title: string; allowInsecureTls?: boolean } | null; /** PROXMUX pane-native QEMU noVNC or LXC terminal (ticket + WebSocket), when session kind matches. */ proxmoxNativeConsoleForPane: (paneIndex: number) => ProxmoxNativeConsolePanePayload | null; + /** Incrementing nonce used to request a noVNC reconnect for a given pane. */ + proxmoxQemuVncReconnectNonceForPane: (paneIndex: number) => number; + requestProxmoxQemuVncReconnect: (paneIndex: number) => void; + openProxmoxQemuVncInAppWindow: (paneIndex: number) => void; + openProxmoxQemuVncInBrowser: (paneIndex: number) => void; + proxmoxLxcReconnectNonceForPane: (paneIndex: number) => number; + requestProxmoxLxcReconnect: (paneIndex: number) => void; + openProxmoxLxcInAppWindow: (paneIndex: number) => void; + openProxmoxLxcInBrowser: (paneIndex: number) => void; /** Surface errors from the web pane (e.g. failed in-app webview window). */ onWebPaneOpenInAppWindowError?: (message: string) => void; /** Proxmox console deep links need login first; same payload as the main-window assist banner. */ @@ -163,6 +174,9 @@ export function createSplitPaneRenderer(b: SplitPaneRendererBridge): (node: Spli const paneFileView = b.paneFileViewForPane(paneIndex); const paneCtxKind = b.paneContextSessionKindForPane(paneIndex); const remoteSpec = b.remoteSshSpecForPane(paneIndex); + const proxmoxNative = paneSessionId ? b.proxmoxNativeConsoleForPane(paneIndex) : null; + const hasQemuVncToolbarActions = proxmoxNative?.kind === "qemu-vnc"; + const hasLxcToolbarActions = proxmoxNative?.kind === "lxc-term"; const activatePaneAndMaybeFocusTerminal = () => { b.setActivePaneIndex(paneIndex); if (paneSessionId) { @@ -475,7 +489,7 @@ export function createSplitPaneRenderer(b: SplitPaneRendererBridge): (node: Spli title={ b.fileWorkspacePluginEnabled ? "Browse remote files (SFTP)" - : "Browse remote files (SFTP) — enable File workspace in Settings → Plugins" + : "Browse remote files (SFTP) — enable NSS-Commander in Settings → Plugins" } aria-label={`Browse remote files in pane ${paneIndex + 1}`} onPointerDown={(event) => event.stopPropagation()} @@ -512,7 +526,7 @@ export function createSplitPaneRenderer(b: SplitPaneRendererBridge): (node: Spli title={ b.fileWorkspacePluginEnabled ? "Browse local files" - : "Browse local files — enable File workspace in Settings → Plugins" + : "Browse local files — enable NSS-Commander in Settings → Plugins" } aria-label={`Browse local files in pane ${paneIndex + 1}`} onPointerDown={(event) => event.stopPropagation()} @@ -566,6 +580,116 @@ export function createSplitPaneRenderer(b: SplitPaneRendererBridge): (node: Spli )}