mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
release-2.6.0 (#1085)
* fix: general bug fixes * fix: general qol additions * ci(deps): bump actions/setup-node in the github-actions group (#1068) Bumps the github-actions group with 1 update: [actions/setup-node](https://github.com/actions/setup-node). Updates `actions/setup-node` from 6 to 7 - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/setup-node dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps-dev): bump the dev-patch-updates group with 28 updates (#1069) Bumps the dev-patch-updates group with 28 updates: | Package | From | To | | --- | --- | --- | | [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.5.2` | `2.5.4` | | [@codemirror/view](https://github.com/codemirror/view) | `6.43.5` | `6.43.6` | | [@radix-ui/react-accordion](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/accordion) | `1.2.15` | `1.2.17` | | [@radix-ui/react-alert-dialog](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/alert-dialog) | `1.1.18` | `1.1.20` | | [@radix-ui/react-checkbox](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/checkbox) | `1.3.6` | `1.3.8` | | [@radix-ui/react-dialog](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/dialog) | `1.1.18` | `1.1.20` | | [@radix-ui/react-dropdown-menu](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/dropdown-menu) | `2.1.19` | `2.1.21` | | [@radix-ui/react-label](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/label) | `2.1.11` | `2.1.12` | | [@radix-ui/react-popover](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/popover) | `1.1.18` | `1.1.20` | | [@radix-ui/react-progress](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/progress) | `1.1.11` | `1.1.13` | | [@radix-ui/react-scroll-area](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/scroll-area) | `1.2.13` | `1.2.15` | | [@radix-ui/react-select](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/select) | `2.3.2` | `2.3.4` | | [@radix-ui/react-separator](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/separator) | `1.1.11` | `1.1.12` | | [@radix-ui/react-slider](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/slider) | `1.4.2` | `1.4.4` | | [@radix-ui/react-switch](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/switch) | `1.3.2` | `1.3.4` | | [@radix-ui/react-tabs](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/tabs) | `1.1.16` | `1.1.18` | | [@radix-ui/react-tooltip](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/tooltip) | `1.2.11` | `1.2.13` | | [@tailwindcss/vite](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-vite) | `4.3.2` | `4.3.3` | | [@uiw/codemirror-extensions-langs](https://github.com/uiwjs/react-codemirror) | `4.25.10` | `4.25.11` | | [@uiw/codemirror-theme-github](https://github.com/uiwjs/react-codemirror) | `4.25.10` | `4.25.11` | | [@uiw/react-codemirror](https://github.com/uiwjs/react-codemirror) | `4.25.10` | `4.25.11` | | [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) | `4.1.9` | `4.1.10` | | [@vitest/ui](https://github.com/vitest-dev/vitest/tree/HEAD/packages/ui) | `4.1.9` | `4.1.10` | | [i18next](https://github.com/i18next/i18next) | `26.3.4` | `26.3.6` | | [radix-ui](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/radix-ui) | `1.6.1` | `1.6.3` | | [react-i18next](https://github.com/i18next/react-i18next) | `17.0.8` | `17.0.10` | | [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `4.3.2` | `4.3.3` | | [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.9` | `4.1.10` | Updates `@biomejs/biome` from 2.5.2 to 2.5.4 - [Release notes](https://github.com/biomejs/biome/releases) - [Changelog](https://github.com/biomejs/biome/blob/main/packages/@biomejs/biome/CHANGELOG.md) - [Commits](https://github.com/biomejs/biome/commits/@biomejs/biome@2.5.4/packages/@biomejs/biome) Updates `@codemirror/view` from 6.43.5 to 6.43.6 - [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md) - [Commits](https://github.com/codemirror/view/commits) Updates `@radix-ui/react-accordion` from 1.2.15 to 1.2.17 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/accordion/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/accordion) Updates `@radix-ui/react-alert-dialog` from 1.1.18 to 1.1.20 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/alert-dialog/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/alert-dialog) Updates `@radix-ui/react-checkbox` from 1.3.6 to 1.3.8 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/checkbox/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/checkbox) Updates `@radix-ui/react-dialog` from 1.1.18 to 1.1.20 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/dialog/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/dialog) Updates `@radix-ui/react-dropdown-menu` from 2.1.19 to 2.1.21 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/dropdown-menu/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/dropdown-menu) Updates `@radix-ui/react-label` from 2.1.11 to 2.1.12 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/label/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/label) Updates `@radix-ui/react-popover` from 1.1.18 to 1.1.20 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/popover/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/popover) Updates `@radix-ui/react-progress` from 1.1.11 to 1.1.13 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/progress/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/progress) Updates `@radix-ui/react-scroll-area` from 1.2.13 to 1.2.15 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/scroll-area/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/scroll-area) Updates `@radix-ui/react-select` from 2.3.2 to 2.3.4 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/select/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/select) Updates `@radix-ui/react-separator` from 1.1.11 to 1.1.12 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/separator/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/separator) Updates `@radix-ui/react-slider` from 1.4.2 to 1.4.4 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/slider/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/slider) Updates `@radix-ui/react-switch` from 1.3.2 to 1.3.4 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/switch/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/switch) Updates `@radix-ui/react-tabs` from 1.1.16 to 1.1.18 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/tabs/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/tabs) Updates `@radix-ui/react-tooltip` from 1.2.11 to 1.2.13 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/tooltip/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/tooltip) Updates `@tailwindcss/vite` from 4.3.2 to 4.3.3 - [Release notes](https://github.com/tailwindlabs/tailwindcss/releases) - [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.3/packages/@tailwindcss-vite) Updates `@uiw/codemirror-extensions-langs` from 4.25.10 to 4.25.11 - [Release notes](https://github.com/uiwjs/react-codemirror/releases) - [Commits](https://github.com/uiwjs/react-codemirror/compare/v4.25.10...v4.25.11) Updates `@uiw/codemirror-theme-github` from 4.25.10 to 4.25.11 - [Release notes](https://github.com/uiwjs/react-codemirror/releases) - [Commits](https://github.com/uiwjs/react-codemirror/compare/v4.25.10...v4.25.11) Updates `@uiw/react-codemirror` from 4.25.10 to 4.25.11 - [Release notes](https://github.com/uiwjs/react-codemirror/releases) - [Commits](https://github.com/uiwjs/react-codemirror/compare/v4.25.10...v4.25.11) Updates `@vitest/coverage-v8` from 4.1.9 to 4.1.10 - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.10/packages/coverage-v8) Updates `@vitest/ui` from 4.1.9 to 4.1.10 - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.10/packages/ui) Updates `i18next` from 26.3.4 to 26.3.6 - [Release notes](https://github.com/i18next/i18next/releases) - [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md) - [Commits](https://github.com/i18next/i18next/compare/v26.3.4...v26.3.6) Updates `radix-ui` from 1.6.1 to 1.6.3 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/radix-ui/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/radix-ui) Updates `react-i18next` from 17.0.8 to 17.0.10 - [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md) - [Commits](https://github.com/i18next/react-i18next/compare/v17.0.8...v17.0.10) Updates `tailwindcss` from 4.3.2 to 4.3.3 - [Release notes](https://github.com/tailwindlabs/tailwindcss/releases) - [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.3/packages/tailwindcss) Updates `vitest` from 4.1.9 to 4.1.10 - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.10/packages/vitest) --- updated-dependencies: - dependency-name: "@biomejs/biome" dependency-version: 2.5.4 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@codemirror/view" dependency-version: 6.43.6 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-accordion" dependency-version: 1.2.17 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-alert-dialog" dependency-version: 1.1.20 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-checkbox" dependency-version: 1.3.8 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-dialog" dependency-version: 1.1.20 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-dropdown-menu" dependency-version: 2.1.21 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-label" dependency-version: 2.1.12 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-popover" dependency-version: 1.1.20 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-progress" dependency-version: 1.1.13 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-scroll-area" dependency-version: 1.2.15 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-select" dependency-version: 2.3.4 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-separator" dependency-version: 1.1.12 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-slider" dependency-version: 1.4.4 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-switch" dependency-version: 1.3.4 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-tabs" dependency-version: 1.1.18 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-tooltip" dependency-version: 1.2.13 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@tailwindcss/vite" dependency-version: 4.3.3 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@uiw/codemirror-extensions-langs" dependency-version: 4.25.11 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@uiw/codemirror-theme-github" dependency-version: 4.25.11 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@uiw/react-codemirror" dependency-version: 4.25.11 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@vitest/coverage-v8" dependency-version: 4.1.10 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@vitest/ui" dependency-version: 4.1.10 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: i18next dependency-version: 26.3.6 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: radix-ui dependency-version: 1.6.3 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: react-i18next dependency-version: 17.0.10 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: tailwindcss dependency-version: 4.3.3 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: vitest dependency-version: 4.1.10 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump ws in the prod-patch-updates group (#1071) Bumps the prod-patch-updates group with 1 update: [ws](https://github.com/websockets/ws). Updates `ws` from 8.21.0 to 8.21.1 - [Release notes](https://github.com/websockets/ws/releases) - [Commits](https://github.com/websockets/ws/compare/8.21.0...8.21.1) --- updated-dependencies: - dependency-name: ws dependency-version: 8.21.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: prod-patch-updates ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump the major-updates group with 2 updates (#1072) Bumps the major-updates group with 2 updates: [nanoid](https://github.com/ai/nanoid) and [typescript](https://github.com/microsoft/TypeScript). Updates `nanoid` from 5.1.16 to 6.0.0 - [Release notes](https://github.com/ai/nanoid/releases) - [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md) - [Commits](https://github.com/ai/nanoid/compare/5.1.16...6.0.0) Updates `typescript` from 6.0.3 to 7.0.2 - [Release notes](https://github.com/microsoft/TypeScript/releases) - [Commits](https://github.com/microsoft/TypeScript/commits) --- updated-dependencies: - dependency-name: nanoid dependency-version: 6.0.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: major-updates - dependency-name: typescript dependency-version: 7.0.2 dependency-type: direct:development update-type: version-update:semver-major dependency-group: major-updates ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * fix: general qol additions and new analytics/telemetrics feature * fix: incorrect version sent to posthog * feat: add multiplayer/shared sessions for terminal and guacd * feat: rework Electron desktop app to run standalone-first with optional two-way sync to a remote Termix server * Fix Guacamole tab visibility lifecycle (#1074) Co-authored-by: default-student <default-student@github.com> Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * fix(alerts): send channel config as object payload instead of JSON string (#1075) * fix tmux-monitor tailscale issue (#1076) * Initial plan * fix(tmux-monitor): explicitly handle tailscale auth in PanePreview hostConfig For Tailscale-auth hosts the pane-preview attach path was building the Terminal hostConfig with only the generic spread of host fields. This could omit or mismap auth-critical details and trigger a plain TCP/SSH reachability path that doesn't work with Tailscale-only SSH endpoints. The fix branches on `host.authType === "tailscale"` and: - Carries `authType: "tailscale"` explicitly so the backend always selects the Tailscale-aware PTY path regardless of how the host object evolves. - Derives `port` from `host.sshPort ?? host.port` so Tailscale SSH endpoints on a non-default SSH port are reached correctly. - Leaves all non-tailscale auth types on the unchanged code path. Reattach (bumping instanceId + attachNonce) continues to work because terminalHostConfig is recomputed on every render with the latest instanceIdRef.current value. * refactor(tmux-monitor): simplify tailscale port logic with extracted variable Address code review feedback: extract resolvedPort into a local variable to avoid the duplicated `host.sshPort ?? host.port` expression that was assigned to both `port` and `sshPort`. Restructure as an if/else block instead of an IIFE for readability. * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * chore: run prettier * chore: update beta release text * fix: cant update credential of an RDP host * feat: add custom key shortcuts * feat: add support for MFA over SSH * fix: Invalid websocket frame causing code 10006 crash triggering restart loop * fix(net): correct SSRF blocklist false-positive blocking all outbound IPv4 (#1079) * fix: correct IPv4-mapped-IPv6 blocklist entry blocking all outbound IPv4 ::ffff:0:0/96 in the IPv6 blocklist matches every IPv4 address once mapped, since Node's BlockList compares addresses in their mapped form internally regardless of the declared family. This caused safeOutboundFetch to reject all IPv4-resolved destinations as private, breaking outbound requests (e.g. ntfy/webhook notifications) whenever DNS resolved to IPv4. Replaced with individual mapped ranges mirroring the existing IPv4 blocklist. * test: cover isBlockedAddress and link the Node BlockList citation Exports isBlockedAddress so its family-crossing behavior around IPv4-mapped-IPv6 addresses can actually be asserted, instead of relying on manual container debugging to notice a regression. Also swaps the prior "Node's BlockList compares addresses in mapped form" comment for one citing the documented example in the Node docs (https://nodejs.org/api/net.html#class-netblocklist), since that behavior isn't otherwise obvious from the addSubnet/check call sites. Related: Termix-SSH/Support#1024 * refactor: derive IPv6 mirror from IPv4 list, split DNS error messages Two follow-ups from review: - The IPv6 blocklist previously hand-duplicated each IPv4 range as its IPv4-mapped-IPv6 equivalent. Nothing enforced the two stayed in sync, which is exactly how the original bug (a mismatched ::ffff:0:0/96 entry blocking all IPv4) was introduced in the first place. Now derived from a single blockedIpv4Ranges list in one loop. - The connect.lookup hook threw the same "Private destinations are not allowed" for both an empty DNS result and an actually-blocked address. An empty result is a resolution failure, not a privacy decision, and conflating the two is the same kind of opaque-error problem that made this bug slow to diagnose in production. Split into distinct messages. Also extracted the lookup hook itself (createDnsLookupHook) so it can be unit-tested against a fake resolver directly, instead of only through a real fetch()/Agent call — the bug lived entirely in this callback, and undici wraps any error thrown here as a generic "fetch failed" TypeError, which is why isolating it matters for testability. --------- Co-authored-by: brennanneoh <497569+brennanneoh@users.noreply.github.com> * fix(ssh): do not offer chacha20-poly1305 without the native ssh2 binding (#1081) The availability probe treated a working OpenSSL "chacha20" cipher as proof that chacha20-poly1305@openssh.com is usable. It is not: ssh2 pure-JS chacha20-poly1305 corrupts the transport, so the peer aborts the KEX ("incomplete message [preauth]") and the connection times out. Easy to hit on jump-host connections whose target sshd negotiates chacha20-poly1305 first. Only trust the native binding (sshcrypto.node); otherwise leave the cipher disabled so filterCiphers() drops it and AES-GCM is negotiated instead. Co-authored-by: XtraLarge <> * fix: add Swiss German server layout (#1078) * chore: update release notes * feat: continue improving desktop app 2-way sync with logic fixes and a migration dialog * fix: dekstop app showing auth form without syncing * feat: create desktop auto sessions for existing setups * feat: add electron backend killing * fix: electron login and session related bugs and updated readme for v2.6.0 * chore: finalize release notes * fix: click to expand hosts including extra bottom margin * fix: desktop auth modal failing to log users in * fix: desktop app failing to sync * fix: reverse proxy causing sync error * chore: lint, format, and bump version to 2.6.0 * chore: sync Crowdin translations for 2.6.0 --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: L.H. <117188168+default-student@users.noreply.github.com> Co-authored-by: default-student <default-student@github.com> Co-authored-by: Brad Baker <xyzulu@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Brennan Neoh <brennanneoh@users.noreply.github.com> Co-authored-by: brennanneoh <497569+brennanneoh@users.noreply.github.com> Co-authored-by: XtraLarge <eMail@WilliWerres.de> Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
This commit is contained in:
232 files changed
+50585
-32016
No files matched your search
@@ -1,6 +1,6 @@
|
||||
import { authApi, handleApiError } from "@/main-axios";
|
||||
|
||||
export type AcmeChallengeType = "http-webroot" | "dns-cloudflare";
|
||||
export type AcmeChallengeType = "http-webroot" | "dns-cloudflare" | "manual";
|
||||
|
||||
export type AcmeSettings = {
|
||||
enabled: boolean;
|
||||
@@ -45,3 +45,15 @@ export async function requestAcmeCertificate(): Promise<
|
||||
handleApiError(error, "request ACME certificate");
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadManualSslCertificate(payload: {
|
||||
certificate: string;
|
||||
privateKey: string;
|
||||
}): Promise<AcmeSettings & { success: boolean }> {
|
||||
try {
|
||||
const response = await authApi.post("/users/manual-ssl-upload", payload);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "upload manual SSL certificate");
|
||||
}
|
||||
}
|
||||
@@ -99,7 +99,14 @@ export async function adminDeleteUserHost(
|
||||
export async function adminGetHostPassword(
|
||||
targetUserId: string,
|
||||
hostId: number,
|
||||
field: "password" | "sudoPassword" | "vncPassword" = "password",
|
||||
field:
|
||||
| "password"
|
||||
| "sudoPassword"
|
||||
| "rdpPassword"
|
||||
| "vncPassword"
|
||||
| "telnetPassword"
|
||||
| "key"
|
||||
| "keyPassword" = "password",
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const response = await sshHostApi.get(
|
||||
|
||||
@@ -96,7 +96,14 @@ export async function getSSHHostWithCredentials(
|
||||
|
||||
export async function getHostPassword(
|
||||
hostId: number,
|
||||
field: "password" | "sudoPassword" | "vncPassword" = "password",
|
||||
field:
|
||||
| "password"
|
||||
| "sudoPassword"
|
||||
| "rdpPassword"
|
||||
| "vncPassword"
|
||||
| "telnetPassword"
|
||||
| "key"
|
||||
| "keyPassword" = "password",
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const response = await sshHostApi.get(
|
||||
@@ -200,6 +207,7 @@ export async function updateFolderMetadata(
|
||||
name: string,
|
||||
color?: string,
|
||||
icon?: string,
|
||||
credentialId?: number | null,
|
||||
): Promise<void> {
|
||||
try {
|
||||
sshLogger.info("Updating folder metadata", {
|
||||
@@ -207,12 +215,14 @@ export async function updateFolderMetadata(
|
||||
name,
|
||||
color,
|
||||
icon,
|
||||
credentialId,
|
||||
});
|
||||
|
||||
await authApi.put("/host/folders/metadata", {
|
||||
name,
|
||||
color,
|
||||
icon,
|
||||
credentialId,
|
||||
});
|
||||
|
||||
sshLogger.success("Folder metadata updated successfully", {
|
||||
|
||||
@@ -72,6 +72,7 @@ export interface GuacamoleTokenRequest {
|
||||
|
||||
export interface GuacamoleTokenResponse {
|
||||
token: string;
|
||||
guacamoleConnectionId?: string | null;
|
||||
}
|
||||
|
||||
type GuacamoleConfigSource = {
|
||||
@@ -208,12 +209,18 @@ export async function getGuacamoleToken(
|
||||
export async function getGuacamoleTokenFromHost(
|
||||
hostId: number,
|
||||
protocol?: "rdp" | "vnc" | "telnet",
|
||||
promptedCredentials?: { username?: string; password?: string },
|
||||
): Promise<GuacamoleTokenResponse> {
|
||||
try {
|
||||
const response = await authApi.post(
|
||||
`/guacamole/connect-host/${hostId}`,
|
||||
protocol ? { protocol } : {},
|
||||
);
|
||||
const response = await authApi.post(`/guacamole/connect-host/${hostId}`, {
|
||||
...(protocol ? { protocol } : {}),
|
||||
...(promptedCredentials?.username
|
||||
? { promptedUsername: promptedCredentials.username }
|
||||
: {}),
|
||||
...(promptedCredentials?.password
|
||||
? { promptedPassword: promptedCredentials.password }
|
||||
: {}),
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "get guacamole token from host");
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { handleApiError, statsApi } from "@/main-axios";
|
||||
import type { HostMetricsLayout } from "@/types/host-metrics";
|
||||
|
||||
// Every function below is keyed by a host's numeric database id, and the
|
||||
// receiving backend must own that host in its own database -- a synced
|
||||
// host has a different numeric id on each side (only its syncId matches
|
||||
// across them). These calls always target the embedded local backend; see
|
||||
// getAllServerStatuses in host-metrics-status-api.ts for the one metrics
|
||||
// call that IS safely merged across local + remote (a process-local,
|
||||
// in-memory aggregate keyed by whichever host ids that process happens to
|
||||
// know about, not a per-host lookup).
|
||||
|
||||
export interface MetricsHistoryRow {
|
||||
ts: string;
|
||||
cpu_percent: number | null;
|
||||
|
||||
@@ -1,8 +1,31 @@
|
||||
import axios, { type AxiosRequestConfig } from "axios";
|
||||
import { handleApiError, statsApi } from "@/main-axios";
|
||||
import {
|
||||
handleApiError,
|
||||
statsApi,
|
||||
getRemoteStatsApi,
|
||||
isElectron,
|
||||
} from "@/main-axios";
|
||||
import type { ServerMetrics, ServerStatus } from "@/main-axios";
|
||||
import { getCachedServerStatuses } from "@/lib/hosts-request-cache";
|
||||
|
||||
// Metrics collection/viewer registration below (startMetricsPolling,
|
||||
// registerMetricsViewer, etc.) is NOT origin-routed: the backend that
|
||||
// receives the call must own the target host by numeric database id, and a
|
||||
// synced host has a different numeric id in each database (only its
|
||||
// syncId matches across them). Only the aggregate status read is merged
|
||||
// across local + remote, same as tunnel status.
|
||||
async function isRemoteSyncConnected(): Promise<boolean> {
|
||||
if (!isElectron()) return false;
|
||||
try {
|
||||
const config = (await window.electronAPI?.invoke?.(
|
||||
"get-remote-sync-config",
|
||||
)) as { serverUrl?: string } | null;
|
||||
return !!config?.serverUrl;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
type ApiConnectionLog = {
|
||||
type: "info" | "success" | "warning" | "error";
|
||||
stage: string;
|
||||
@@ -76,6 +99,7 @@ export async function getAllServerStatuses(): Promise<
|
||||
> {
|
||||
return getCachedServerStatuses(async () => {
|
||||
let lastError: unknown = null;
|
||||
let localStatuses: Record<number, ServerStatus> = {};
|
||||
|
||||
for (let i = 0; i < STATUS_RETRY_SCHEDULE.length; i++) {
|
||||
const { timeoutMs, pauseAfterMs } = STATUS_RETRY_SCHEDULE[i];
|
||||
@@ -89,7 +113,9 @@ export async function getAllServerStatuses(): Promise<
|
||||
// blips don't look like real outages.
|
||||
__silentRetry: !isFinalAttempt,
|
||||
} as AxiosRequestConfig & { __silentRetry?: boolean });
|
||||
return response.data || {};
|
||||
localStatuses = response.data || {};
|
||||
lastError = null;
|
||||
break;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (!isTransientStatusError(error)) {
|
||||
@@ -102,8 +128,24 @@ export async function getAllServerStatuses(): Promise<
|
||||
}
|
||||
}
|
||||
|
||||
handleApiError(lastError, "fetch server statuses");
|
||||
return {};
|
||||
if (lastError) {
|
||||
handleApiError(lastError, "fetch server statuses");
|
||||
return {};
|
||||
}
|
||||
|
||||
if (await isRemoteSyncConnected()) {
|
||||
try {
|
||||
const remoteResult = await getRemoteStatsApi().get("/status", {
|
||||
timeout: 8000,
|
||||
__silentRetry: true,
|
||||
} as AxiosRequestConfig & { __silentRetry?: boolean });
|
||||
return { ...localStatuses, ...(remoteResult.data || {}) };
|
||||
} catch {
|
||||
// remote unreachable this tick -- fall back to local-only statuses
|
||||
}
|
||||
}
|
||||
|
||||
return localStatuses;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { authApi } from "@/main-axios";
|
||||
import { createTtlRequestCache } from "@/lib/ttl-request-cache";
|
||||
import type { TerminalTheme } from "@/lib/terminal-themes";
|
||||
import type { CustomKeybinding } from "@/types/keybindings";
|
||||
|
||||
// OPEN TABS API
|
||||
// ============================================================================
|
||||
@@ -41,6 +43,10 @@ export interface ActiveSessionInfo {
|
||||
tabInstanceId: string | null;
|
||||
isConnected: boolean;
|
||||
createdAt: number;
|
||||
isOwnSession: boolean;
|
||||
sharedByUsername: string | null;
|
||||
permissionLevel: string | null;
|
||||
shareId: string | null;
|
||||
}
|
||||
|
||||
const activeSessionsCache = createTtlRequestCache<ActiveSessionInfo[]>(2_000);
|
||||
@@ -82,6 +88,12 @@ export async function getActiveSessions(): Promise<ActiveSessionInfo[]> {
|
||||
// USER PREFERENCES API
|
||||
// ============================================================================
|
||||
|
||||
export interface SavedCustomTheme {
|
||||
id: string;
|
||||
name: string;
|
||||
colors: TerminalTheme["colors"];
|
||||
}
|
||||
|
||||
export interface UserPreferences {
|
||||
reopenTabsOnLogin: boolean;
|
||||
theme?: string | null;
|
||||
@@ -102,6 +114,30 @@ export interface UserPreferences {
|
||||
hiddenRailTabs?: string | null;
|
||||
compactHostView?: boolean | null;
|
||||
statusColorScheme?: string | null;
|
||||
customThemes?: string | null;
|
||||
customKeybindings?: string | null;
|
||||
}
|
||||
|
||||
export function parseCustomThemes(raw?: string | null): SavedCustomTheme[] {
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function parseCustomKeybindings(
|
||||
raw?: string | null,
|
||||
): CustomKeybinding[] {
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUserPreferences(): Promise<UserPreferences> {
|
||||
|
||||
@@ -124,6 +124,31 @@ export async function shareHost(
|
||||
}
|
||||
}
|
||||
|
||||
export async function shareFolder(
|
||||
folder: string,
|
||||
shareData: {
|
||||
targets: ShareTarget[];
|
||||
permissionLevel: SharePermissionLevel;
|
||||
durationHours?: number;
|
||||
},
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
expiresAt: string | null;
|
||||
hostsShared: number;
|
||||
hostsTotal: number;
|
||||
hostResults: Array<{ hostId: number; shared: boolean; reason?: string }>;
|
||||
}> {
|
||||
try {
|
||||
const response = await rbacApi.post("/rbac/folder/share", {
|
||||
folder,
|
||||
...shareData,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "share folder");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateHostAccess(
|
||||
hostId: number,
|
||||
accessId: number,
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import axios from "axios";
|
||||
import { getBasePath } from "@/lib/base-path";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import { authApi, handleApiError } from "@/main-axios";
|
||||
|
||||
export interface ResolvedShareLink {
|
||||
protocol: "ssh" | "rdp" | "vnc" | "telnet";
|
||||
permissionLevel: "read-only" | "read-write";
|
||||
wsPath: string;
|
||||
connectParams?: { token: string };
|
||||
}
|
||||
|
||||
export type ShareLinkErrorKind = "not-found" | "rate-limited" | "unknown";
|
||||
|
||||
export class ShareLinkError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly kind: ShareLinkErrorKind,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ShareLinkError";
|
||||
}
|
||||
}
|
||||
|
||||
const isDev = (): boolean =>
|
||||
!isElectron() &&
|
||||
process.env.NODE_ENV === "development" &&
|
||||
(window.location.port === "3000" ||
|
||||
window.location.port === "5173" ||
|
||||
window.location.port === "");
|
||||
|
||||
// Guests have no session/JWT, so this deliberately builds a bare base URL
|
||||
// rather than going through main-axios's authenticated instances. The
|
||||
// desktop app always runs its embedded local backend as the source of
|
||||
// truth, so a share link opened there always resolves against it --
|
||||
// joining a session hosted on someone else's remote server isn't
|
||||
// supported from the desktop app today.
|
||||
async function resolveApiBaseUrl(): Promise<string> {
|
||||
if (isDev()) {
|
||||
const protocol = window.location.protocol === "https:" ? "https" : "http";
|
||||
return `${protocol}://localhost:30001`;
|
||||
}
|
||||
if (isElectron()) {
|
||||
return "http://127.0.0.1:30001";
|
||||
}
|
||||
return getBasePath();
|
||||
}
|
||||
|
||||
export async function resolveShareLink(
|
||||
linkToken: string,
|
||||
): Promise<ResolvedShareLink> {
|
||||
const baseUrl = await resolveApiBaseUrl();
|
||||
try {
|
||||
const response = await axios.get(
|
||||
`${baseUrl}/session-sharing/resolve/${encodeURIComponent(linkToken)}`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
if (error.response?.status === 404) {
|
||||
throw new ShareLinkError(
|
||||
"Share link is invalid, expired, or revoked",
|
||||
"not-found",
|
||||
);
|
||||
}
|
||||
if (error.response?.status === 429) {
|
||||
throw new ShareLinkError(
|
||||
"Too many attempts, please try again shortly",
|
||||
"rate-limited",
|
||||
);
|
||||
}
|
||||
}
|
||||
throw new ShareLinkError("Failed to resolve share link", "unknown");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SESSION SHARING (authenticated owner-side API)
|
||||
// ============================================================================
|
||||
|
||||
export type SessionShareProtocol = "ssh" | "rdp" | "vnc" | "telnet";
|
||||
export type SessionShareType = "link" | "user";
|
||||
export type SessionSharePermissionLevel = "read-only" | "read-write";
|
||||
|
||||
export interface SessionShareRecord {
|
||||
id: string;
|
||||
hostId: number;
|
||||
ownerUserId: string;
|
||||
protocol: SessionShareProtocol;
|
||||
sessionId: string;
|
||||
tabInstanceId: string | null;
|
||||
shareType: SessionShareType;
|
||||
targetUserId: string | null;
|
||||
linkToken: string | null;
|
||||
permissionLevel: SessionSharePermissionLevel;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
revokedAt: string | null;
|
||||
lastJoinedAt: string | null;
|
||||
joinCount: number;
|
||||
}
|
||||
|
||||
export interface CreateSessionShareRequest {
|
||||
hostId: number;
|
||||
sessionId: string;
|
||||
tabInstanceId?: string;
|
||||
protocol: SessionShareProtocol;
|
||||
shareType: SessionShareType;
|
||||
targetUserId?: string;
|
||||
permissionLevel: SessionSharePermissionLevel;
|
||||
expiryHours?: number;
|
||||
}
|
||||
|
||||
export interface CreateSessionShareResponse {
|
||||
shareId: string;
|
||||
linkToken: string | null;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export async function createSessionShare(
|
||||
request: CreateSessionShareRequest,
|
||||
): Promise<CreateSessionShareResponse> {
|
||||
try {
|
||||
const response = await authApi.post("/session-sharing/create", request);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "create session share");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getActiveSessionShares(
|
||||
hostId: number,
|
||||
): Promise<{ shares: SessionShareRecord[] }> {
|
||||
try {
|
||||
const response = await authApi.get(
|
||||
`/session-sharing/host/${hostId}/active`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch active session shares");
|
||||
}
|
||||
}
|
||||
|
||||
export async function revokeSessionShare(
|
||||
shareId: string,
|
||||
): Promise<{ success: true }> {
|
||||
try {
|
||||
const response = await authApi.delete(`/session-sharing/${shareId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "revoke session share");
|
||||
}
|
||||
}
|
||||
|
||||
export async function endSessionShareSession(
|
||||
shareId: string,
|
||||
): Promise<{ success: true }> {
|
||||
try {
|
||||
const response = await authApi.post(`/session-sharing/${shareId}/end`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "end shared session");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// GLOBAL ADMIN TOGGLE
|
||||
// ============================================================================
|
||||
|
||||
export async function getSessionSharingGloballyEnabled(): Promise<{
|
||||
enabled: boolean;
|
||||
}> {
|
||||
try {
|
||||
const response = await authApi.get("/users/session-sharing-enabled");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch session sharing enabled setting");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateSessionSharingGloballyEnabled(
|
||||
enabled: boolean,
|
||||
): Promise<{ enabled: boolean }> {
|
||||
try {
|
||||
const response = await authApi.patch("/users/session-sharing-enabled", {
|
||||
enabled,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "update session sharing enabled setting");
|
||||
}
|
||||
}
|
||||
@@ -145,6 +145,32 @@ export async function updateGuacamoleSettings(settings: {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ANALYTICS SETTINGS
|
||||
// ============================================================================
|
||||
|
||||
export async function getAnalyticsEnabled(): Promise<{ enabled: boolean }> {
|
||||
try {
|
||||
const response = await authApi.get("/users/analytics-enabled");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch analytics enabled setting");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateAnalyticsEnabled(
|
||||
enabled: boolean,
|
||||
): Promise<{ enabled: boolean }> {
|
||||
try {
|
||||
const response = await authApi.patch("/users/analytics-enabled", {
|
||||
enabled,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "update analytics enabled setting");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HOST DEFAULTS SETTINGS
|
||||
// ============================================================================
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import axios from "axios";
|
||||
import { authApi, fileManagerApi, handleApiError } from "@/main-axios";
|
||||
import {
|
||||
authApi,
|
||||
fileManagerApi,
|
||||
handleApiError,
|
||||
getFileManagerApiForSession,
|
||||
setSessionOrigin,
|
||||
clearSessionOrigin,
|
||||
} from "@/main-axios";
|
||||
import { resolveConnectionOrigin } from "@/lib/connection-origin";
|
||||
import { fileLogger } from "@/lib/frontend-logger";
|
||||
import type { SSHHost } from "@/types/index";
|
||||
|
||||
@@ -72,7 +80,7 @@ export async function connectSSH(
|
||||
},
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.post(
|
||||
const response = await getFileManagerApiForSession(sessionId).post(
|
||||
"/ssh/connect",
|
||||
{ sessionId, ...config },
|
||||
{ timeout: 120000 },
|
||||
@@ -121,12 +129,15 @@ export async function disconnectSSH(
|
||||
sessionId: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.post("/ssh/disconnect", {
|
||||
sessionId,
|
||||
});
|
||||
const response = await getFileManagerApiForSession(sessionId).post(
|
||||
"/ssh/disconnect",
|
||||
{ sessionId },
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "disconnect SSH");
|
||||
} finally {
|
||||
clearSessionOrigin(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,10 +146,10 @@ export async function verifySSHTOTP(
|
||||
totpCode: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.post("/ssh/connect-totp", {
|
||||
sessionId,
|
||||
totpCode,
|
||||
});
|
||||
const response = await getFileManagerApiForSession(sessionId).post(
|
||||
"/ssh/connect-totp",
|
||||
{ sessionId, totpCode },
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "verify SSH TOTP");
|
||||
@@ -149,9 +160,10 @@ export async function verifySSHWarpgate(
|
||||
sessionId: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.post("/ssh/connect-warpgate", {
|
||||
sessionId,
|
||||
});
|
||||
const response = await getFileManagerApiForSession(sessionId).post(
|
||||
"/ssh/connect-warpgate",
|
||||
{ sessionId },
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "verify SSH Warpgate");
|
||||
@@ -239,9 +251,10 @@ export async function getSSHStatus(
|
||||
sessionId: string,
|
||||
): Promise<{ connected: boolean }> {
|
||||
try {
|
||||
const response = await fileManagerApi.get("/ssh/status", {
|
||||
params: { sessionId },
|
||||
});
|
||||
const response = await getFileManagerApiForSession(sessionId).get(
|
||||
"/ssh/status",
|
||||
{ params: { sessionId } },
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "get SSH status");
|
||||
@@ -252,9 +265,10 @@ export async function keepSSHAlive(
|
||||
sessionId: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.post("/ssh/keepalive", {
|
||||
sessionId,
|
||||
});
|
||||
const response = await getFileManagerApiForSession(sessionId).post(
|
||||
"/ssh/keepalive",
|
||||
{ sessionId },
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "SSH keepalive");
|
||||
@@ -266,9 +280,10 @@ export async function listSSHFiles(
|
||||
path: string,
|
||||
): Promise<{ files: unknown[]; path: string }> {
|
||||
try {
|
||||
const response = await fileManagerApi.get("/ssh/listFiles", {
|
||||
params: { sessionId, path },
|
||||
});
|
||||
const response = await getFileManagerApiForSession(sessionId).get(
|
||||
"/ssh/listFiles",
|
||||
{ params: { sessionId, path } },
|
||||
);
|
||||
return response.data || { files: [], path };
|
||||
} catch (error) {
|
||||
handleApiError(error, "list SSH files");
|
||||
@@ -281,9 +296,10 @@ export async function identifySSHSymlink(
|
||||
path: string,
|
||||
): Promise<{ path: string; target: string; type: "directory" | "file" }> {
|
||||
try {
|
||||
const response = await fileManagerApi.get("/ssh/identifySymlink", {
|
||||
params: { sessionId, path },
|
||||
});
|
||||
const response = await getFileManagerApiForSession(sessionId).get(
|
||||
"/ssh/identifySymlink",
|
||||
{ params: { sessionId, path } },
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "identify SSH symlink");
|
||||
@@ -295,9 +311,10 @@ export async function resolveSSHPath(
|
||||
path: string,
|
||||
): Promise<string> {
|
||||
try {
|
||||
const response = await fileManagerApi.get("/ssh/resolvePath", {
|
||||
params: { sessionId, path },
|
||||
});
|
||||
const response = await getFileManagerApiForSession(sessionId).get(
|
||||
"/ssh/resolvePath",
|
||||
{ params: { sessionId, path } },
|
||||
);
|
||||
return response.data?.resolvedPath || path;
|
||||
} catch {
|
||||
return path;
|
||||
@@ -313,9 +330,10 @@ export async function readSSHFile(
|
||||
encoding?: "base64" | "utf8";
|
||||
}> {
|
||||
try {
|
||||
const response = await fileManagerApi.get("/ssh/readFile", {
|
||||
params: { sessionId, path },
|
||||
});
|
||||
const response = await getFileManagerApiForSession(sessionId).get(
|
||||
"/ssh/readFile",
|
||||
{ params: { sessionId, path } },
|
||||
);
|
||||
return response.data;
|
||||
} catch (error: unknown) {
|
||||
if (error.response?.status === 404) {
|
||||
@@ -340,13 +358,10 @@ export async function writeSSHFile(
|
||||
userId?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.post("/ssh/writeFile", {
|
||||
sessionId,
|
||||
path,
|
||||
content,
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
const response = await getFileManagerApiForSession(sessionId).post(
|
||||
"/ssh/writeFile",
|
||||
{ sessionId, path, content, hostId, userId },
|
||||
);
|
||||
|
||||
if (
|
||||
response.data &&
|
||||
@@ -410,7 +425,7 @@ export async function uploadSSHFile(
|
||||
form.append("totalSize", String(file.size));
|
||||
form.append("chunk", chunkBlob, fileName);
|
||||
|
||||
const response = await fileManagerApi.postForm(
|
||||
const response = await getFileManagerApiForSession(sessionId).postForm(
|
||||
"/ssh/uploadFileChunk",
|
||||
form,
|
||||
{ timeout: 0 },
|
||||
@@ -444,7 +459,7 @@ export async function uploadSSHFile(
|
||||
if (userId !== undefined) form.append("userId", userId);
|
||||
form.append("file", file, fileName);
|
||||
|
||||
const response = await fileManagerApi.postForm(
|
||||
const response = await getFileManagerApiForSession(sessionId).postForm(
|
||||
"/ssh/uploadFileStream",
|
||||
form,
|
||||
{
|
||||
@@ -464,7 +479,7 @@ export async function downloadSSHFile(
|
||||
userId?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.post(
|
||||
const response = await getFileManagerApiForSession(sessionId).post(
|
||||
"/ssh/downloadFile",
|
||||
{
|
||||
sessionId,
|
||||
@@ -484,7 +499,7 @@ export async function downloadSSHFileStream(
|
||||
sessionId: string,
|
||||
filePath: string,
|
||||
): Promise<void> {
|
||||
const response = await fileManagerApi.post(
|
||||
const response = await getFileManagerApiForSession(sessionId).post(
|
||||
"/ssh/downloadFileStream",
|
||||
{ sessionId, path: filePath },
|
||||
{ responseType: "blob", timeout: 0 },
|
||||
@@ -503,14 +518,10 @@ export async function createSSHFile(
|
||||
userId?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.post("/ssh/createFile", {
|
||||
sessionId,
|
||||
path,
|
||||
fileName,
|
||||
content,
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
const response = await getFileManagerApiForSession(sessionId).post(
|
||||
"/ssh/createFile",
|
||||
{ sessionId, path, fileName, content, hostId, userId },
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "create SSH file");
|
||||
@@ -525,13 +536,10 @@ export async function createSSHFolder(
|
||||
userId?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.post("/ssh/createFolder", {
|
||||
sessionId,
|
||||
path,
|
||||
folderName,
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
const response = await getFileManagerApiForSession(sessionId).post(
|
||||
"/ssh/createFolder",
|
||||
{ sessionId, path, folderName, hostId, userId },
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "create SSH folder");
|
||||
@@ -546,15 +554,18 @@ export async function deleteSSHItem(
|
||||
userId?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.delete("/ssh/deleteItem", {
|
||||
data: {
|
||||
sessionId,
|
||||
path,
|
||||
isDirectory,
|
||||
hostId,
|
||||
userId,
|
||||
const response = await getFileManagerApiForSession(sessionId).delete(
|
||||
"/ssh/deleteItem",
|
||||
{
|
||||
data: {
|
||||
sessionId,
|
||||
path,
|
||||
isDirectory,
|
||||
hostId,
|
||||
userId,
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "delete SSH item");
|
||||
@@ -566,7 +577,7 @@ export async function setSudoPassword(
|
||||
password: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await fileManagerApi.post("/sudo-password", {
|
||||
await getFileManagerApiForSession(sessionId).post("/sudo-password", {
|
||||
sessionId,
|
||||
password,
|
||||
});
|
||||
@@ -583,7 +594,7 @@ export async function copySSHItem(
|
||||
userId?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.post(
|
||||
const response = await getFileManagerApiForSession(sessionId).post(
|
||||
"/ssh/copyItem",
|
||||
{
|
||||
sessionId,
|
||||
@@ -611,13 +622,10 @@ export async function renameSSHItem(
|
||||
userId?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.put("/ssh/renameItem", {
|
||||
sessionId,
|
||||
oldPath,
|
||||
newName,
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
const response = await getFileManagerApiForSession(sessionId).put(
|
||||
"/ssh/renameItem",
|
||||
{ sessionId, oldPath, newName, hostId, userId },
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "rename SSH item");
|
||||
@@ -633,7 +641,7 @@ export async function moveSSHItem(
|
||||
userId?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.put(
|
||||
const response = await getFileManagerApiForSession(sessionId).put(
|
||||
"/ssh/moveItem",
|
||||
{
|
||||
sessionId,
|
||||
@@ -670,13 +678,10 @@ export async function changeSSHPermissions(
|
||||
userId,
|
||||
});
|
||||
|
||||
const response = await fileManagerApi.post("/ssh/changePermissions", {
|
||||
sessionId,
|
||||
path,
|
||||
permissions,
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
const response = await getFileManagerApiForSession(sessionId).post(
|
||||
"/ssh/changePermissions",
|
||||
{ sessionId, path, permissions, hostId, userId },
|
||||
);
|
||||
|
||||
fileLogger.success("SSH file permissions changed successfully", {
|
||||
operation: "change_permissions",
|
||||
@@ -715,13 +720,10 @@ export async function extractSSHArchive(
|
||||
userId,
|
||||
});
|
||||
|
||||
const response = await fileManagerApi.post("/ssh/extractArchive", {
|
||||
sessionId,
|
||||
archivePath,
|
||||
extractPath,
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
const response = await getFileManagerApiForSession(sessionId).post(
|
||||
"/ssh/extractArchive",
|
||||
{ sessionId, archivePath, extractPath, hostId, userId },
|
||||
);
|
||||
|
||||
fileLogger.success("Archive extracted successfully", {
|
||||
operation: "extract_archive",
|
||||
@@ -762,14 +764,17 @@ export async function compressSSHFiles(
|
||||
userId,
|
||||
});
|
||||
|
||||
const response = await fileManagerApi.post("/ssh/compressFiles", {
|
||||
sessionId,
|
||||
paths,
|
||||
archiveName,
|
||||
format: format || "zip",
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
const response = await getFileManagerApiForSession(sessionId).post(
|
||||
"/ssh/compressFiles",
|
||||
{
|
||||
sessionId,
|
||||
paths,
|
||||
archiveName,
|
||||
format: format || "zip",
|
||||
hostId,
|
||||
userId,
|
||||
},
|
||||
);
|
||||
|
||||
fileLogger.success("Files compressed successfully", {
|
||||
operation: "compress_files",
|
||||
@@ -811,6 +816,12 @@ export async function ensureSSHSessionForHost(
|
||||
host: SSHHost,
|
||||
): Promise<EnsureSSHSessionResult> {
|
||||
const sessionId = host.id.toString();
|
||||
const origin = await resolveConnectionOrigin({
|
||||
connectionType: host.connectionType,
|
||||
connectionOrigin: host.connectionOrigin,
|
||||
});
|
||||
setSessionOrigin(sessionId, origin);
|
||||
|
||||
try {
|
||||
const status = await getSSHStatus(sessionId);
|
||||
if (status?.connected) {
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import { AxiosError } from "axios";
|
||||
import {
|
||||
authApi,
|
||||
handleApiError,
|
||||
isElectron,
|
||||
markUserAuthenticated,
|
||||
} from "@/main-axios";
|
||||
import { authApi, handleApiError, markUserAuthenticated } from "@/main-axios";
|
||||
import type { AuthResponse } from "@/main-axios";
|
||||
|
||||
// ALERTS
|
||||
@@ -63,25 +58,6 @@ export async function verifyTOTPLogin(
|
||||
rememberMe,
|
||||
});
|
||||
|
||||
const isInIframe =
|
||||
typeof window !== "undefined" && window.self !== window.top;
|
||||
|
||||
if (isInIframe && isElectron() && response.data.success) {
|
||||
try {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: "AUTH_SUCCESS",
|
||||
source: "totp_verify",
|
||||
platform: "desktop",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
window.location.origin,
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("[main-axios] Error posting message to parent:", e);
|
||||
}
|
||||
}
|
||||
|
||||
if (response.data.success) {
|
||||
markUserAuthenticated();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import axios from "axios";
|
||||
import { authApi, handleApiError, tunnelApi } from "@/main-axios";
|
||||
import {
|
||||
authApi,
|
||||
handleApiError,
|
||||
tunnelApi,
|
||||
getRemoteTunnelApi,
|
||||
isElectron,
|
||||
} from "@/main-axios";
|
||||
import type {
|
||||
C2STunnelPreset,
|
||||
TunnelConfig,
|
||||
@@ -9,13 +15,46 @@ import type {
|
||||
|
||||
// TUNNEL MANAGEMENT
|
||||
// ============================================================================
|
||||
//
|
||||
// Tunnel status is a process-local, in-memory view (no DB lookup) so it's
|
||||
// safe to read from both the embedded backend and a connected remote server
|
||||
// and merge the results. connectTunnel/disconnectTunnel/cancelTunnel are
|
||||
// NOT origin-routed: they resolve the target host by numeric database id
|
||||
// against whichever backend receives the request, and a synced host has a
|
||||
// different numeric id in each database (only its syncId matches across
|
||||
// them) -- routing those calls to a remote backend would need a
|
||||
// local-id-to-remote-id resolution step that doesn't exist yet. They always
|
||||
// target the embedded local backend for now.
|
||||
|
||||
async function isRemoteSyncConnected(): Promise<boolean> {
|
||||
if (!isElectron()) return false;
|
||||
try {
|
||||
const config = (await window.electronAPI?.invoke?.(
|
||||
"get-remote-sync-config",
|
||||
)) as { serverUrl?: string } | null;
|
||||
return !!config?.serverUrl;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTunnelStatuses(): Promise<
|
||||
Record<string, TunnelStatus>
|
||||
> {
|
||||
try {
|
||||
const response = await tunnelApi.get("/tunnel/status");
|
||||
return response.data || {};
|
||||
const [localResult, remoteConnected] = await Promise.all([
|
||||
tunnelApi.get("/tunnel/status"),
|
||||
isRemoteSyncConnected(),
|
||||
]);
|
||||
const localStatuses = localResult.data || {};
|
||||
if (!remoteConnected) return localStatuses;
|
||||
|
||||
try {
|
||||
const remoteResult = await getRemoteTunnelApi().get("/tunnel/status");
|
||||
return { ...localStatuses, ...(remoteResult.data || {}) };
|
||||
} catch {
|
||||
return localStatuses;
|
||||
}
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch tunnel statuses");
|
||||
}
|
||||
@@ -30,9 +69,18 @@ export function subscribeTunnelStatuses(
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
let latestLocal: Record<string, TunnelStatus> = {};
|
||||
let latestRemote: Record<string, TunnelStatus> = {};
|
||||
let remotePollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const emitMerged = () => {
|
||||
onStatuses({ ...latestLocal, ...latestRemote });
|
||||
};
|
||||
|
||||
source.addEventListener("statuses", (event) => {
|
||||
try {
|
||||
onStatuses(JSON.parse(event.data) as Record<string, TunnelStatus>);
|
||||
latestLocal = JSON.parse(event.data) as Record<string, TunnelStatus>;
|
||||
emitMerged();
|
||||
} catch {
|
||||
onError?.();
|
||||
}
|
||||
@@ -42,7 +90,27 @@ export function subscribeTunnelStatuses(
|
||||
onError?.();
|
||||
};
|
||||
|
||||
return () => source.close();
|
||||
// Remote tunnel status has no SSE stream exposed to the desktop app yet,
|
||||
// so poll it at a modest interval when a remote server is connected.
|
||||
isRemoteSyncConnected().then((connected) => {
|
||||
if (!connected) return;
|
||||
const pollRemote = async () => {
|
||||
try {
|
||||
const result = await getRemoteTunnelApi().get("/tunnel/status");
|
||||
latestRemote = result.data || {};
|
||||
emitMerged();
|
||||
} catch {
|
||||
// remote unreachable this tick -- keep last known remote statuses
|
||||
}
|
||||
};
|
||||
pollRemote();
|
||||
remotePollTimer = setInterval(pollRemote, 5000);
|
||||
});
|
||||
|
||||
return () => {
|
||||
source.close();
|
||||
if (remotePollTimer) clearInterval(remotePollTimer);
|
||||
};
|
||||
}
|
||||
|
||||
export async function getTunnelStatusByName(
|
||||
|
||||
Reference in new issue
Block a user