mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-28 05:14:54 +02:00
1a26628a48
* 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>
517 lines
15 KiB
JavaScript
517 lines
15 KiB
JavaScript
// Remote sync engine for the desktop app's optional connection to a
|
|
// self-hosted Termix server. Runs entirely in the Electron main process:
|
|
// - Holds the remote JWT (safeStorage-encrypted on disk, never exposed to
|
|
// the renderer's localStorage) and the local embedded backend's JWT
|
|
// (cached in memory only, handed over by the renderer at local-login
|
|
// time via notify-local-login).
|
|
// - On a timer, pulls + pushes each synced entity type between the
|
|
// embedded backend (always localhost:30001) and the configured remote
|
|
// server, reconciling by syncId with last-write-wins on updatedAt, and
|
|
// propagating tombstones (deletions) in both directions.
|
|
// - Pushes connection/sync status to the renderer via IPC so the Settings
|
|
// UI and a global banner can reflect it without polling.
|
|
|
|
const { app, safeStorage } = require("electron");
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
|
|
const SYNCED_ENTITY_TYPES = [
|
|
"hosts",
|
|
"sshCredentials",
|
|
"sshFolders",
|
|
"snippets",
|
|
"snippetFolders",
|
|
"vaultProfiles",
|
|
"dashboardServiceLinks",
|
|
"homepageItems",
|
|
];
|
|
|
|
const SYNC_INTERVAL_MS = 90 * 1000;
|
|
const EMBEDDED_BASE_URL = "http://127.0.0.1:30001";
|
|
|
|
function dataPath(filename) {
|
|
return path.join(app.getPath("userData"), filename);
|
|
}
|
|
|
|
function readJson(filePath, fallback) {
|
|
try {
|
|
if (!fs.existsSync(filePath)) return fallback;
|
|
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
} catch {
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
function writeJson(filePath, value) {
|
|
const userDataPath = app.getPath("userData");
|
|
if (!fs.existsSync(userDataPath)) {
|
|
fs.mkdirSync(userDataPath, { recursive: true });
|
|
}
|
|
fs.writeFileSync(filePath, JSON.stringify(value, null, 2));
|
|
}
|
|
|
|
function getDesktopSettingsPath() {
|
|
return dataPath("desktop-settings.json");
|
|
}
|
|
|
|
function getRemoteSyncConfigPath() {
|
|
return dataPath("remote-sync-config.json");
|
|
}
|
|
|
|
function getRemoteSyncCredentialPath() {
|
|
return dataPath("remote-sync-credential.json");
|
|
}
|
|
|
|
function getRemoteSyncStatePath() {
|
|
return dataPath("remote-sync-state.json");
|
|
}
|
|
|
|
function getDesktopSettings() {
|
|
return readJson(getDesktopSettingsPath(), {
|
|
defaultConnectionOrigin: "local",
|
|
migrationNoticeAcknowledged: false,
|
|
});
|
|
}
|
|
|
|
function saveDesktopSettings(settings) {
|
|
writeJson(getDesktopSettingsPath(), settings);
|
|
return { success: true };
|
|
}
|
|
|
|
function getRemoteSyncConfig() {
|
|
return readJson(getRemoteSyncConfigPath(), null);
|
|
}
|
|
|
|
function saveRemoteSyncConfig(config) {
|
|
writeJson(getRemoteSyncConfigPath(), config);
|
|
return { success: true };
|
|
}
|
|
|
|
function clearRemoteSyncConfig() {
|
|
try {
|
|
fs.unlinkSync(getRemoteSyncConfigPath());
|
|
} catch {
|
|
// already absent
|
|
}
|
|
return { success: true };
|
|
}
|
|
|
|
function getSafeStorageAvailable() {
|
|
try {
|
|
return safeStorage.isEncryptionAvailable();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function saveRemoteSyncJwt(token) {
|
|
if (!getSafeStorageAvailable()) {
|
|
return { success: false, error: "Encryption unavailable on this system" };
|
|
}
|
|
writeJson(getRemoteSyncCredentialPath(), {
|
|
encrypted: true,
|
|
value: safeStorage.encryptString(token).toString("base64"),
|
|
obtainedAt: new Date().toISOString(),
|
|
});
|
|
return { success: true };
|
|
}
|
|
|
|
function getRemoteSyncJwt() {
|
|
const record = readJson(getRemoteSyncCredentialPath(), null);
|
|
if (!record?.encrypted || !getSafeStorageAvailable()) return null;
|
|
try {
|
|
return safeStorage.decryptString(Buffer.from(record.value, "base64"));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function clearRemoteSyncJwt() {
|
|
try {
|
|
fs.unlinkSync(getRemoteSyncCredentialPath());
|
|
} catch {
|
|
// already absent
|
|
}
|
|
return { success: true };
|
|
}
|
|
|
|
function decodeJwtExpiry(token) {
|
|
try {
|
|
const payloadB64 = token.split(".")[1];
|
|
const payload = JSON.parse(
|
|
Buffer.from(payloadB64, "base64").toString("utf8"),
|
|
);
|
|
return typeof payload.exp === "number" ? payload.exp * 1000 : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function isJwtExpiredOrExpiringSoon(token, marginMs = 60 * 1000) {
|
|
const expiresAt = decodeJwtExpiry(token);
|
|
if (expiresAt === null) return false;
|
|
return Date.now() + marginMs >= expiresAt;
|
|
}
|
|
|
|
class RemoteSyncEngine {
|
|
constructor(getMainWindow) {
|
|
this.getMainWindow = getMainWindow;
|
|
this.localJwt = null;
|
|
this.timer = null;
|
|
this.syncing = false;
|
|
this.status = {
|
|
connected: false,
|
|
syncing: false,
|
|
lastSyncedAt: null,
|
|
lastError: null,
|
|
needsReauth: false,
|
|
};
|
|
}
|
|
|
|
setLocalJwt(token) {
|
|
this.localJwt = token || null;
|
|
}
|
|
|
|
emitStatus() {
|
|
const win = this.getMainWindow?.();
|
|
if (!win || win.isDestroyed()) return;
|
|
win.webContents.send("remote-sync-status-changed", this.status);
|
|
}
|
|
|
|
updateStatus(patch) {
|
|
this.status = { ...this.status, ...patch };
|
|
this.emitStatus();
|
|
}
|
|
|
|
start() {
|
|
const config = getRemoteSyncConfig();
|
|
this.status.connected = !!config?.serverUrl;
|
|
if (this.timer) clearInterval(this.timer);
|
|
this.timer = setInterval(() => this.syncNow(), SYNC_INTERVAL_MS);
|
|
if (config?.serverUrl) {
|
|
// Fire an initial sync shortly after startup rather than waiting a
|
|
// full interval, but don't block app boot on it.
|
|
setTimeout(() => this.syncNow(), 5000);
|
|
}
|
|
}
|
|
|
|
stop() {
|
|
if (this.timer) {
|
|
clearInterval(this.timer);
|
|
this.timer = null;
|
|
}
|
|
}
|
|
|
|
async syncNow() {
|
|
if (this.syncing) return this.status;
|
|
const config = getRemoteSyncConfig();
|
|
if (!config?.serverUrl) {
|
|
this.updateStatus({ connected: false, syncing: false });
|
|
return this.status;
|
|
}
|
|
|
|
const remoteJwt = getRemoteSyncJwt();
|
|
if (!remoteJwt) {
|
|
this.updateStatus({
|
|
connected: true,
|
|
syncing: false,
|
|
needsReauth: true,
|
|
lastError: "Not signed in to remote server",
|
|
});
|
|
return this.status;
|
|
}
|
|
if (isJwtExpiredOrExpiringSoon(remoteJwt)) {
|
|
this.updateStatus({
|
|
connected: true,
|
|
syncing: false,
|
|
needsReauth: true,
|
|
lastError: "Remote session expired",
|
|
});
|
|
return this.status;
|
|
}
|
|
if (!this.localJwt) {
|
|
// Local login hasn't handed us a token yet -- this is expected for the
|
|
// first tick or two right after a cold boot (renderer hasn't finished
|
|
// its own session check yet), but if it never arrives (e.g. a gap in
|
|
// whichever code path establishes the local session), sync would
|
|
// otherwise silently no-op forever with no visible error. Surface it
|
|
// as a normal, non-alarming "not synced yet" status rather than
|
|
// leaving lastSyncedAt/lastError untouched.
|
|
this.updateStatus({
|
|
connected: true,
|
|
syncing: false,
|
|
lastError: "Waiting for local session",
|
|
});
|
|
return this.status;
|
|
}
|
|
|
|
this.syncing = true;
|
|
this.updateStatus({ connected: true, syncing: true, lastError: null });
|
|
|
|
try {
|
|
const state = readJson(getRemoteSyncStatePath(), { entities: {} });
|
|
let sawAuthFailure = false;
|
|
|
|
for (const entityType of SYNCED_ENTITY_TYPES) {
|
|
const entityState = state.entities[entityType] || {
|
|
lastPulledAt: null,
|
|
lastPushedAt: null,
|
|
};
|
|
|
|
const result = await this.syncEntity({
|
|
entityType,
|
|
remoteBaseUrl: config.serverUrl.replace(/\/$/, ""),
|
|
remoteJwt,
|
|
since: entityState.lastPulledAt,
|
|
});
|
|
|
|
if (result.authFailure) {
|
|
sawAuthFailure = true;
|
|
break;
|
|
}
|
|
|
|
state.entities[entityType] = {
|
|
lastPulledAt: result.syncedAt,
|
|
lastPushedAt: result.syncedAt,
|
|
};
|
|
}
|
|
|
|
if (sawAuthFailure) {
|
|
this.updateStatus({
|
|
syncing: false,
|
|
needsReauth: true,
|
|
lastError: "Remote server rejected the session",
|
|
});
|
|
return this.status;
|
|
}
|
|
|
|
writeJson(getRemoteSyncStatePath(), state);
|
|
writeJson(getRemoteSyncConfigPath(), {
|
|
...config,
|
|
lastSyncedAt: new Date().toISOString(),
|
|
lastSyncStatus: "ok",
|
|
lastSyncError: null,
|
|
});
|
|
|
|
this.updateStatus({
|
|
connected: true,
|
|
syncing: false,
|
|
needsReauth: false,
|
|
lastSyncedAt: new Date().toISOString(),
|
|
lastError: null,
|
|
});
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
writeJson(getRemoteSyncConfigPath(), {
|
|
...config,
|
|
lastSyncStatus: "error",
|
|
lastSyncError: message,
|
|
});
|
|
this.updateStatus({ syncing: false, lastError: message });
|
|
} finally {
|
|
this.syncing = false;
|
|
}
|
|
|
|
return this.status;
|
|
}
|
|
|
|
async fetchJson(url, token, options = {}) {
|
|
const res = await fetch(url, {
|
|
...options,
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${token}`,
|
|
...(options.headers || {}),
|
|
},
|
|
});
|
|
if (res.status === 401 || res.status === 403) {
|
|
const err = new Error(`Auth failed (${res.status})`);
|
|
err.authFailure = true;
|
|
throw err;
|
|
}
|
|
if (!res.ok) {
|
|
throw new Error(`Request failed (${res.status}): ${url}`);
|
|
}
|
|
|
|
const text = await res.text();
|
|
// A reverse-proxy SSO in front of the remote server (Pangolin, Authelia,
|
|
// etc.) can intercept even an authenticated, Bearer-token'd request and
|
|
// serve its own login page instead of forwarding to Termix -- that comes
|
|
// back as a normal 200 OK, so the status checks above don't catch it.
|
|
// This is NOT the same as needsReauth/a bad Termix JWT: sync runs as a
|
|
// plain server-to-server fetch() in this main process, with no browser
|
|
// cookie jar at all, so re-authenticating through the login iframe (which
|
|
// only affects the renderer's browser session) can never fix this --
|
|
// reconnecting would tell the user to do something that doesn't help.
|
|
// The proxy has to allow this traffic through some other way (an API
|
|
// bypass rule, a separate hostname/port that isn't proxy-gated, etc.),
|
|
// so this gets its own distinct, honest error rather than piggybacking
|
|
// on needsReauth or a raw JSON.parse crash.
|
|
const looksLikeHtml =
|
|
text.includes("<html") ||
|
|
text.includes("<!DOCTYPE") ||
|
|
text.includes("<head>") ||
|
|
text.includes("<body>");
|
|
if (looksLikeHtml) {
|
|
const err = new Error(
|
|
"The reverse proxy in front of this server is blocking sync traffic with its own login page. Reconnecting won't fix this -- the proxy needs to let Termix's API requests through (e.g. an SSO bypass rule for the sync API, or a non-proxied hostname/port for it).",
|
|
);
|
|
err.proxyBlocked = true;
|
|
throw err;
|
|
}
|
|
|
|
try {
|
|
return JSON.parse(text);
|
|
} catch {
|
|
throw new Error(`Server returned invalid JSON: ${url}`);
|
|
}
|
|
}
|
|
|
|
async pullSide(baseUrl, token, entityType, since) {
|
|
const url = `${baseUrl}/sync/${entityType}${since ? `?since=${encodeURIComponent(since)}` : ""}`;
|
|
const data = await this.fetchJson(url, token);
|
|
return data.rows || [];
|
|
}
|
|
|
|
async pullTombstones(baseUrl, token, entityType, since) {
|
|
const url = `${baseUrl}/sync/${entityType}/tombstones${since ? `?since=${encodeURIComponent(since)}` : ""}`;
|
|
const data = await this.fetchJson(url, token);
|
|
return data.tombstones || [];
|
|
}
|
|
|
|
async pushRow(baseUrl, token, entityType, row) {
|
|
await this.fetchJson(`${baseUrl}/sync/${entityType}`, token, {
|
|
method: "POST",
|
|
body: JSON.stringify({ row }),
|
|
});
|
|
}
|
|
|
|
async pushTombstone(baseUrl, token, entityType, syncId) {
|
|
await this.fetchJson(`${baseUrl}/sync/tombstones`, token, {
|
|
method: "POST",
|
|
body: JSON.stringify({ entityType, syncId }),
|
|
});
|
|
}
|
|
|
|
async syncEntity({ entityType, remoteBaseUrl, remoteJwt, since }) {
|
|
const syncedAt = new Date().toISOString();
|
|
try {
|
|
const [localRows, remoteRows, localTombstones, remoteTombstones] =
|
|
await Promise.all([
|
|
this.pullSide(EMBEDDED_BASE_URL, this.localJwt, entityType, since),
|
|
this.pullSide(remoteBaseUrl, remoteJwt, entityType, since),
|
|
this.pullTombstones(
|
|
EMBEDDED_BASE_URL,
|
|
this.localJwt,
|
|
entityType,
|
|
since,
|
|
),
|
|
this.pullTombstones(remoteBaseUrl, remoteJwt, entityType, since),
|
|
]);
|
|
|
|
const tombstonedSyncIds = new Set([
|
|
...localTombstones.map((t) => t.syncId),
|
|
...remoteTombstones.map((t) => t.syncId),
|
|
]);
|
|
|
|
const localBySyncId = new Map(
|
|
localRows.filter((r) => r.syncId).map((r) => [r.syncId, r]),
|
|
);
|
|
const remoteBySyncId = new Map(
|
|
remoteRows.filter((r) => r.syncId).map((r) => [r.syncId, r]),
|
|
);
|
|
const allSyncIds = new Set([
|
|
...localBySyncId.keys(),
|
|
...remoteBySyncId.keys(),
|
|
]);
|
|
|
|
for (const syncId of allSyncIds) {
|
|
if (tombstonedSyncIds.has(syncId)) continue;
|
|
|
|
const localRow = localBySyncId.get(syncId);
|
|
const remoteRow = remoteBySyncId.get(syncId);
|
|
|
|
if (localRow && !remoteRow) {
|
|
await this.pushRow(remoteBaseUrl, remoteJwt, entityType, localRow);
|
|
} else if (remoteRow && !localRow) {
|
|
await this.pushRow(
|
|
EMBEDDED_BASE_URL,
|
|
this.localJwt,
|
|
entityType,
|
|
remoteRow,
|
|
);
|
|
} else if (localRow && remoteRow) {
|
|
const localUpdatedAt = new Date(localRow.updatedAt || 0).getTime();
|
|
const remoteUpdatedAt = new Date(remoteRow.updatedAt || 0).getTime();
|
|
if (localUpdatedAt > remoteUpdatedAt) {
|
|
await this.pushRow(remoteBaseUrl, remoteJwt, entityType, localRow);
|
|
} else if (remoteUpdatedAt > localUpdatedAt) {
|
|
await this.pushRow(
|
|
EMBEDDED_BASE_URL,
|
|
this.localJwt,
|
|
entityType,
|
|
remoteRow,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Apply tombstones to whichever side hasn't already deleted the row.
|
|
for (const tombstone of localTombstones) {
|
|
if (remoteBySyncId.has(tombstone.syncId)) {
|
|
await this.pushTombstone(
|
|
remoteBaseUrl,
|
|
remoteJwt,
|
|
entityType,
|
|
tombstone.syncId,
|
|
);
|
|
}
|
|
}
|
|
for (const tombstone of remoteTombstones) {
|
|
if (localBySyncId.has(tombstone.syncId)) {
|
|
await this.pushTombstone(
|
|
EMBEDDED_BASE_URL,
|
|
this.localJwt,
|
|
entityType,
|
|
tombstone.syncId,
|
|
);
|
|
}
|
|
}
|
|
|
|
return { syncedAt };
|
|
} catch (error) {
|
|
if (error?.authFailure) {
|
|
return { syncedAt, authFailure: true };
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
let engine = null;
|
|
|
|
function initRemoteSync(getMainWindow) {
|
|
engine = new RemoteSyncEngine(getMainWindow);
|
|
engine.start();
|
|
return engine;
|
|
}
|
|
|
|
function getRemoteSyncEngine() {
|
|
return engine;
|
|
}
|
|
|
|
module.exports = {
|
|
initRemoteSync,
|
|
getRemoteSyncEngine,
|
|
getDesktopSettings,
|
|
saveDesktopSettings,
|
|
getRemoteSyncConfig,
|
|
saveRemoteSyncConfig,
|
|
clearRemoteSyncConfig,
|
|
saveRemoteSyncJwt,
|
|
getRemoteSyncJwt,
|
|
clearRemoteSyncJwt,
|
|
isJwtExpiredOrExpiringSoon,
|
|
decodeJwtExpiry,
|
|
};
|