mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 10:21:34 +00:00
+2


![copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>](/assets/img/avatar_default.png)



![dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>](/assets/img/avatar_default.png)


Luke Gustafson
XtraLarge <>
dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
L.H.
default-student
Brad Baker
copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Copilot Autofix powered by AI
Brennan Neoh
brennanneoh
XtraLarge
ZacharyZcR
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>
1060 lines
31 KiB
TypeScript
1060 lines
31 KiB
TypeScript
import express from "express";
|
|
import { Client as SSHClient } from "ssh2";
|
|
import { logger } from "../../utils/logger.js";
|
|
import { DataCrypto } from "../../utils/data-crypto.js";
|
|
import {
|
|
createCurrentCredentialRepository,
|
|
createCurrentHostRepository,
|
|
} from "../repositories/factory.js";
|
|
import { AuthManager } from "../../utils/auth-manager.js";
|
|
import type { AuthenticatedRequest } from "../../../types/index.js";
|
|
import type { SSHHost } from "../../../types/index.js";
|
|
import { SSHHostKeyVerifier } from "../../hosts/host-key-verifier.js";
|
|
|
|
const router = express.Router();
|
|
const proxmoxLogger = logger;
|
|
const runningSyncs = new Set<string>();
|
|
|
|
const MIN_SYNC_INTERVAL_MINUTES = 5;
|
|
const DEFAULT_SYNC_INTERVAL_MINUTES = 15;
|
|
|
|
const authManager = AuthManager.getInstance();
|
|
const authenticateJWT = authManager.createAuthMiddleware();
|
|
const requireDataAccess = authManager.createDataAccessMiddleware();
|
|
|
|
// Helpers
|
|
|
|
// Proxmox node names are restricted to [a-zA-Z0-9-] by PVE itself,
|
|
// but we validate defensively before using in a shell command.
|
|
const SAFE_NODE_RE = /^[a-zA-Z0-9._-]{1,64}$/;
|
|
|
|
function isSafeNodeName(name: string): boolean {
|
|
return SAFE_NODE_RE.test(name);
|
|
}
|
|
|
|
function execCommand(
|
|
client: SSHClient,
|
|
command: string,
|
|
timeoutMs = 8000,
|
|
): Promise<string> {
|
|
return new Promise((resolve, reject) => {
|
|
let settled = false;
|
|
const timer = setTimeout(() => {
|
|
if (!settled) {
|
|
settled = true;
|
|
reject(new Error(`Command timed out after ${timeoutMs}ms`));
|
|
}
|
|
}, timeoutMs);
|
|
|
|
client.exec(command, (err, stream) => {
|
|
if (err) {
|
|
clearTimeout(timer);
|
|
return reject(err);
|
|
}
|
|
let stdout = "";
|
|
let stderr = "";
|
|
stream.on("close", (code: number) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(timer);
|
|
if (code !== 0)
|
|
reject(new Error(stderr || `Command exited with code ${code}`));
|
|
else resolve(stdout);
|
|
});
|
|
stream.on("data", (data: Buffer) => {
|
|
stdout += data.toString();
|
|
});
|
|
stream.stderr.on("data", (data: Buffer) => {
|
|
stderr += data.toString();
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
// Parse all IPs from LXC net config, then return the one matching the preferred prefix.
|
|
function parseLxcIp(
|
|
config: Record<string, unknown>,
|
|
preferredPrefixes: string[] = [],
|
|
): string | null {
|
|
const ips: string[] = [];
|
|
for (const [key, value] of Object.entries(config)) {
|
|
if (/^net\d+$/.test(key) && typeof value === "string") {
|
|
const m = value.match(/ip=(\d{1,3}(?:\.\d{1,3}){3})/);
|
|
if (m) ips.push(m[1]);
|
|
}
|
|
}
|
|
if (!ips.length) return null;
|
|
for (const prefix of preferredPrefixes) {
|
|
const match = ips.find((ip) => ip.startsWith(prefix));
|
|
if (match) return match;
|
|
}
|
|
return ips[0];
|
|
}
|
|
|
|
function matchesAny(name: string, patterns: string[]): boolean {
|
|
const lower = name.toLowerCase();
|
|
return patterns.some((p) => lower.includes(p.toLowerCase()));
|
|
}
|
|
|
|
function parseProxmoxConfig(raw: unknown): {
|
|
windowsPatterns: string[];
|
|
dockerPatterns: string[];
|
|
preferredPrefixes: string[];
|
|
defaultCredentialId: number | null;
|
|
defaultAuthType: string;
|
|
autoSyncEnabled: boolean;
|
|
syncIntervalMinutes: number;
|
|
markMissingGuests: boolean;
|
|
} {
|
|
const split = (s: string) =>
|
|
s
|
|
.split(",")
|
|
.map((x) => x.trim())
|
|
.filter(Boolean);
|
|
if (!raw || typeof raw !== "object") {
|
|
return {
|
|
windowsPatterns: ["win", "windows"],
|
|
dockerPatterns: ["docker"],
|
|
preferredPrefixes: [],
|
|
defaultCredentialId: null,
|
|
defaultAuthType: "password",
|
|
autoSyncEnabled: false,
|
|
syncIntervalMinutes: DEFAULT_SYNC_INTERVAL_MINUTES,
|
|
markMissingGuests: true,
|
|
};
|
|
}
|
|
const cfg = raw as Record<string, unknown>;
|
|
const interval =
|
|
typeof cfg.syncIntervalMinutes === "number"
|
|
? cfg.syncIntervalMinutes
|
|
: Number.parseInt(String(cfg.syncIntervalMinutes ?? ""), 10);
|
|
return {
|
|
defaultCredentialId:
|
|
typeof cfg.defaultCredentialId === "number"
|
|
? cfg.defaultCredentialId
|
|
: null,
|
|
defaultAuthType:
|
|
typeof cfg.defaultAuthType === "string"
|
|
? cfg.defaultAuthType
|
|
: "password",
|
|
windowsPatterns: split(
|
|
typeof cfg.windowsPatterns === "string"
|
|
? cfg.windowsPatterns
|
|
: "win,windows",
|
|
),
|
|
dockerPatterns: split(
|
|
typeof cfg.dockerPatterns === "string" ? cfg.dockerPatterns : "docker",
|
|
),
|
|
preferredPrefixes: split(
|
|
typeof cfg.preferredPrefixes === "string" ? cfg.preferredPrefixes : "",
|
|
),
|
|
autoSyncEnabled: cfg.autoSyncEnabled === true,
|
|
syncIntervalMinutes:
|
|
Number.isFinite(interval) && interval >= MIN_SYNC_INTERVAL_MINUTES
|
|
? interval
|
|
: DEFAULT_SYNC_INTERVAL_MINUTES,
|
|
markMissingGuests: cfg.markMissingGuests !== false,
|
|
};
|
|
}
|
|
|
|
type ProxmoxGuest = {
|
|
name: string;
|
|
vmid: number;
|
|
type: "qemu" | "lxc";
|
|
node: string;
|
|
status: string;
|
|
ip: string | null;
|
|
connectionType: "ssh" | "rdp";
|
|
enableDocker: boolean;
|
|
};
|
|
|
|
type ProxmoxSource = {
|
|
source: "proxmox";
|
|
sourceHostId: number;
|
|
node: string;
|
|
vmid: number;
|
|
type: "qemu" | "lxc";
|
|
lastSeenAt?: string;
|
|
lastStatus?: string;
|
|
missingSince?: string | null;
|
|
};
|
|
|
|
type ProxmoxSyncResult = {
|
|
created: number;
|
|
updated: number;
|
|
markedMissing: number;
|
|
skipped: number;
|
|
errors: string[];
|
|
};
|
|
|
|
function parseJsonObject(value: unknown): Record<string, unknown> {
|
|
if (!value) return {};
|
|
if (typeof value === "object") return value as Record<string, unknown>;
|
|
if (typeof value !== "string") return {};
|
|
try {
|
|
const parsed = JSON.parse(value);
|
|
return parsed && typeof parsed === "object"
|
|
? (parsed as Record<string, unknown>)
|
|
: {};
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function getProxmoxSource(host: Record<string, unknown>): ProxmoxSource | null {
|
|
const config = parseJsonObject(host.proxmoxConfig);
|
|
const source = config.source;
|
|
if (!source || typeof source !== "object") return null;
|
|
const src = source as Record<string, unknown>;
|
|
if (
|
|
src.source !== "proxmox" ||
|
|
typeof src.sourceHostId !== "number" ||
|
|
typeof src.node !== "string" ||
|
|
typeof src.vmid !== "number" ||
|
|
(src.type !== "qemu" && src.type !== "lxc")
|
|
) {
|
|
return null;
|
|
}
|
|
return src as ProxmoxSource;
|
|
}
|
|
|
|
function proxmoxSourceKey(source: ProxmoxSource): string {
|
|
return `${source.sourceHostId}:${source.node}:${source.type}:${source.vmid}`;
|
|
}
|
|
|
|
function guestSourceKey(sourceHostId: number, guest: ProxmoxGuest): string {
|
|
return `${sourceHostId}:${guest.node}:${guest.type}:${guest.vmid}`;
|
|
}
|
|
|
|
function mergeTags(
|
|
existing: unknown,
|
|
additions: string[],
|
|
removals: string[] = [],
|
|
): string {
|
|
const removeSet = new Set(removals);
|
|
const base =
|
|
typeof existing === "string"
|
|
? existing
|
|
.split(",")
|
|
.map((tag) => tag.trim())
|
|
.filter(Boolean)
|
|
: Array.isArray(existing)
|
|
? existing
|
|
.map((tag) => (typeof tag === "string" ? tag.trim() : ""))
|
|
.filter(Boolean)
|
|
: [];
|
|
return [...new Set([...base, ...additions])]
|
|
.filter((tag) => !removeSet.has(tag))
|
|
.join(",");
|
|
}
|
|
|
|
function resolveProxmoxImportAuth(
|
|
defaultAuthType: string | undefined,
|
|
credentialId: number | null | undefined,
|
|
): {
|
|
authType: string;
|
|
credentialId: number | null;
|
|
overrideCredentialUsername: number;
|
|
} {
|
|
if (defaultAuthType === "credential" || (!defaultAuthType && credentialId)) {
|
|
return credentialId
|
|
? { authType: "credential", credentialId, overrideCredentialUsername: 1 }
|
|
: { authType: "none", credentialId: null, overrideCredentialUsername: 0 };
|
|
}
|
|
|
|
if (defaultAuthType && !["password", "key"].includes(defaultAuthType)) {
|
|
return {
|
|
authType: defaultAuthType,
|
|
credentialId: null,
|
|
overrideCredentialUsername: 0,
|
|
};
|
|
}
|
|
|
|
return {
|
|
authType: "none",
|
|
credentialId: null,
|
|
overrideCredentialUsername: 0,
|
|
};
|
|
}
|
|
|
|
async function discoverProxmoxGuestsForHost(
|
|
userId: string,
|
|
parsedHostId: number,
|
|
): Promise<{
|
|
host: SSHHost;
|
|
guests: ProxmoxGuest[];
|
|
credentialId: number | null;
|
|
defaultCredentialId: number | null;
|
|
config: ReturnType<typeof parseProxmoxConfig>;
|
|
}> {
|
|
if (!DataCrypto.canUserAccessData(userId)) {
|
|
const error = new Error("Session expired — please log in again");
|
|
(error as Error & { code?: string }).code = "SESSION_EXPIRED";
|
|
throw error;
|
|
}
|
|
|
|
const hostRecord = await createCurrentHostRepository().findDecryptedByIdAs(
|
|
userId,
|
|
parsedHostId,
|
|
);
|
|
|
|
if (!hostRecord) {
|
|
const error = new Error("Host not found");
|
|
(error as Error & { status?: number }).status = 404;
|
|
throw error;
|
|
}
|
|
|
|
const host = hostRecord as unknown as SSHHost;
|
|
const proxmoxCfgRaw = parseJsonObject(host.proxmoxConfig);
|
|
const config = parseProxmoxConfig(proxmoxCfgRaw);
|
|
|
|
if (host.userId !== userId) {
|
|
const { PermissionManager } =
|
|
await import("../../utils/permission-manager.js");
|
|
const pm = PermissionManager.getInstance();
|
|
const access = await pm.canAccessHost(userId, parsedHostId, "connect");
|
|
if (!access.hasAccess) {
|
|
const error = new Error("Access denied");
|
|
(error as Error & { status?: number }).status = 403;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
let resolvedCredentials: {
|
|
password?: string;
|
|
sshKey?: string;
|
|
keyPassword?: string;
|
|
authType?: string;
|
|
} = {
|
|
password: host.password,
|
|
sshKey: host.key,
|
|
keyPassword: host.keyPassword,
|
|
authType: host.authType,
|
|
};
|
|
|
|
const hostCredentialId = host.credentialId ?? null;
|
|
|
|
if (host.credentialId) {
|
|
if (userId !== host.userId) {
|
|
try {
|
|
const { SharedHostSecretsManager } =
|
|
await import("../../utils/shared-host-secrets-manager.js");
|
|
const sharedCred =
|
|
await SharedHostSecretsManager.getInstance().getSecretForUser(
|
|
host.id,
|
|
userId,
|
|
"ssh",
|
|
);
|
|
if (sharedCred) {
|
|
resolvedCredentials = {
|
|
password: sharedCred.password,
|
|
sshKey: sharedCred.key,
|
|
keyPassword: sharedCred.keyPassword,
|
|
authType: sharedCred.authType,
|
|
};
|
|
}
|
|
} catch (err) {
|
|
proxmoxLogger.error("Failed to resolve shared credential", err, {
|
|
operation: "proxmox_discover",
|
|
hostId: parsedHostId,
|
|
userId,
|
|
});
|
|
}
|
|
} else {
|
|
const cred =
|
|
await createCurrentCredentialRepository().findDecryptedByIdForUser(
|
|
userId,
|
|
host.credentialId as number,
|
|
);
|
|
if (cred) {
|
|
const c = cred;
|
|
resolvedCredentials = {
|
|
password: c.password as string | undefined,
|
|
sshKey: (c.key || c.privateKey) as string | undefined,
|
|
keyPassword: c.keyPassword as string | undefined,
|
|
authType: c.authType as string | undefined,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
const sshConfig: Record<string, unknown> = {
|
|
host: host.ip?.replace(/^\[|\]$/g, "") || host.ip,
|
|
port: host.port || 22,
|
|
username: host.username,
|
|
tryKeyboard: false,
|
|
readyTimeout: 30000,
|
|
hostVerifier: await SSHHostKeyVerifier.createHostVerifier(
|
|
parsedHostId,
|
|
host.ip,
|
|
host.port || 22,
|
|
null,
|
|
userId,
|
|
false,
|
|
),
|
|
};
|
|
|
|
const authType = resolvedCredentials.authType;
|
|
if (authType === "key" && resolvedCredentials.sshKey) {
|
|
sshConfig.privateKey = resolvedCredentials.sshKey;
|
|
if (resolvedCredentials.keyPassword)
|
|
sshConfig.passphrase = resolvedCredentials.keyPassword;
|
|
} else if (authType === "agent") {
|
|
const { applyAgentAuth } =
|
|
await import("../../hosts/terminal-auth-helpers.js");
|
|
const result = await applyAgentAuth(
|
|
sshConfig,
|
|
host.terminalConfig as unknown as Record<string, unknown> | undefined,
|
|
);
|
|
if ("error" in result) {
|
|
const error = new Error(result.error);
|
|
(error as Error & { status?: number }).status = 400;
|
|
throw error;
|
|
}
|
|
} else if (resolvedCredentials.password) {
|
|
sshConfig.password = resolvedCredentials.password;
|
|
}
|
|
|
|
const client = new SSHClient();
|
|
try {
|
|
await new Promise<void>((resolve, reject) => {
|
|
client.on("ready", resolve);
|
|
client.on("error", reject);
|
|
client.connect(sshConfig as import("ssh2").ConnectConfig);
|
|
});
|
|
|
|
proxmoxLogger.info("Proxmox discovery SSH connection established", {
|
|
operation: "proxmox_discover",
|
|
hostId: parsedHostId,
|
|
userId,
|
|
});
|
|
|
|
const pveshCheck = await execCommand(
|
|
client,
|
|
"command -v pvesh >/dev/null 2>&1 && echo ok || echo missing",
|
|
);
|
|
if (pveshCheck.trim() !== "ok") {
|
|
const error = new Error("pvesh not found — is this a Proxmox node?");
|
|
(error as Error & { status?: number }).status = 422;
|
|
throw error;
|
|
}
|
|
|
|
const resourcesJson = await execCommand(
|
|
client,
|
|
"pvesh get /cluster/resources --output-format json 2>/dev/null",
|
|
);
|
|
|
|
let resources: Array<Record<string, unknown>>;
|
|
try {
|
|
resources = JSON.parse(resourcesJson);
|
|
} catch {
|
|
const error = new Error(
|
|
"Failed to parse pvesh output — unexpected response",
|
|
);
|
|
(error as Error & { status?: number }).status = 502;
|
|
throw error;
|
|
}
|
|
|
|
type GuestBase = {
|
|
name: string;
|
|
vmid: number;
|
|
type: "qemu" | "lxc";
|
|
node: string;
|
|
status: string;
|
|
};
|
|
|
|
const guestBases: GuestBase[] = [];
|
|
for (const r of resources) {
|
|
const type = r.type as string;
|
|
if (type !== "qemu" && type !== "lxc") continue;
|
|
if (r.template) continue;
|
|
const node = r.node as string;
|
|
if (!isSafeNodeName(node)) {
|
|
proxmoxLogger.warn("Skipping guest with unsafe node name", {
|
|
operation: "proxmox_discover",
|
|
node,
|
|
vmid: r.vmid,
|
|
});
|
|
continue;
|
|
}
|
|
guestBases.push({
|
|
name: (r.name as string) || String(r.vmid),
|
|
vmid: Number(r.vmid),
|
|
type: type as "qemu" | "lxc",
|
|
node,
|
|
status: (r.status as string) || "unknown",
|
|
});
|
|
}
|
|
|
|
async function resolveIp(g: GuestBase): Promise<string | null> {
|
|
if (g.type === "lxc") {
|
|
let configIp: string | null = null;
|
|
try {
|
|
const cfgJson = await execCommand(
|
|
client,
|
|
`pvesh get /nodes/${g.node}/lxc/${g.vmid}/config --output-format json 2>/dev/null`,
|
|
8000,
|
|
);
|
|
configIp = parseLxcIp(JSON.parse(cfgJson), config.preferredPrefixes);
|
|
} catch {
|
|
configIp = null;
|
|
}
|
|
if (configIp) return configIp;
|
|
// Static config parsing found nothing (e.g. net0 uses ip=dhcp).
|
|
// Fall back to the live interface list for running containers.
|
|
if (g.status === "running") {
|
|
try {
|
|
const ifRaw = await execCommand(
|
|
client,
|
|
`pvesh get /nodes/${g.node}/lxc/${g.vmid}/interfaces --output-format json 2>/dev/null`,
|
|
5000,
|
|
);
|
|
const data = JSON.parse(ifRaw);
|
|
const entries: Array<Record<string, unknown>> = Array.isArray(data)
|
|
? data
|
|
: [];
|
|
const allIps: string[] = [];
|
|
for (const entry of entries) {
|
|
if (entry.name === "lo") continue;
|
|
const inet = entry.inet;
|
|
if (typeof inet !== "string") continue;
|
|
const m = inet.match(/^(\d{1,3}(?:\.\d{1,3}){3})\/\d+$/);
|
|
if (m && !m[1].startsWith("127.")) allIps.push(m[1]);
|
|
}
|
|
if (allIps.length) {
|
|
for (const prefix of config.preferredPrefixes) {
|
|
const match = allIps.find((ip) => ip.startsWith(prefix));
|
|
if (match) return match;
|
|
}
|
|
return allIps[0];
|
|
}
|
|
} catch {
|
|
// Guest not running or interfaces unavailable
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
if (g.type === "qemu" && g.status === "running") {
|
|
try {
|
|
const ifJson = await execCommand(
|
|
client,
|
|
`pvesh get /nodes/${g.node}/qemu/${g.vmid}/agent/network-get-interfaces --output-format json 2>/dev/null`,
|
|
5000,
|
|
);
|
|
const data = JSON.parse(ifJson);
|
|
const ifaces: Array<Record<string, unknown>> = Array.isArray(
|
|
data?.result,
|
|
)
|
|
? data.result
|
|
: Array.isArray(data)
|
|
? data
|
|
: [];
|
|
const allIps: string[] = [];
|
|
for (const iface of ifaces) {
|
|
if (iface.name === "lo") continue;
|
|
const addrs =
|
|
(iface["ip-addresses"] as Array<Record<string, string>>) ?? [];
|
|
for (const a of addrs) {
|
|
if (
|
|
a["ip-address-type"] === "ipv4" &&
|
|
!a["ip-address"].startsWith("127.")
|
|
) {
|
|
allIps.push(a["ip-address"]);
|
|
}
|
|
}
|
|
}
|
|
if (allIps.length) {
|
|
for (const prefix of config.preferredPrefixes) {
|
|
const match = allIps.find((ip) => ip.startsWith(prefix));
|
|
if (match) return match;
|
|
}
|
|
return allIps[0];
|
|
}
|
|
} catch {
|
|
// Guest agent absent or timed out
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const CONCURRENCY = 6;
|
|
const ips: (string | null)[] = new Array(guestBases.length).fill(null);
|
|
let cursor = 0;
|
|
async function ipWorker() {
|
|
while (cursor < guestBases.length) {
|
|
const i = cursor++;
|
|
ips[i] = await resolveIp(guestBases[i]);
|
|
}
|
|
}
|
|
await Promise.all(
|
|
Array.from({ length: Math.min(CONCURRENCY, guestBases.length) }, () =>
|
|
ipWorker(),
|
|
),
|
|
);
|
|
|
|
const guests: ProxmoxGuest[] = guestBases.map((g, i) => ({
|
|
...g,
|
|
ip: ips[i],
|
|
connectionType: matchesAny(g.name, config.windowsPatterns)
|
|
? "rdp"
|
|
: "ssh",
|
|
enableDocker: matchesAny(g.name, config.dockerPatterns),
|
|
}));
|
|
|
|
proxmoxLogger.info("Proxmox discovery completed", {
|
|
operation: "proxmox_discover",
|
|
hostId: parsedHostId,
|
|
userId,
|
|
guestCount: guests.length,
|
|
});
|
|
|
|
return {
|
|
host,
|
|
guests,
|
|
credentialId: hostCredentialId,
|
|
defaultCredentialId: config.defaultCredentialId,
|
|
config,
|
|
};
|
|
} finally {
|
|
try {
|
|
client.end();
|
|
} catch {
|
|
// ignore cleanup errors
|
|
}
|
|
}
|
|
}
|
|
|
|
async function syncProxmoxHost(
|
|
userId: string,
|
|
sourceHostId: number,
|
|
): Promise<ProxmoxSyncResult> {
|
|
const lockKey = `${userId}:${sourceHostId}`;
|
|
if (runningSyncs.has(lockKey)) {
|
|
return {
|
|
created: 0,
|
|
updated: 0,
|
|
markedMissing: 0,
|
|
skipped: 0,
|
|
errors: ["Sync already running"],
|
|
};
|
|
}
|
|
|
|
runningSyncs.add(lockKey);
|
|
const result: ProxmoxSyncResult = {
|
|
created: 0,
|
|
updated: 0,
|
|
markedMissing: 0,
|
|
skipped: 0,
|
|
errors: [],
|
|
};
|
|
const startedAt = new Date().toISOString();
|
|
|
|
try {
|
|
const discovery = await discoverProxmoxGuestsForHost(userId, sourceHostId);
|
|
const sourceHostName = discovery.host.name || "Proxmox";
|
|
const defaultCredentialId =
|
|
discovery.config.defaultCredentialId ?? discovery.credentialId ?? null;
|
|
const importAuth = resolveProxmoxImportAuth(
|
|
discovery.config.defaultAuthType,
|
|
defaultCredentialId,
|
|
);
|
|
const now = new Date().toISOString();
|
|
|
|
const existingHosts =
|
|
(await createCurrentHostRepository().listDecryptedByUserId(
|
|
userId,
|
|
)) as unknown as Record<string, unknown>[];
|
|
const existingBySource = new Map<string, Record<string, unknown>>();
|
|
for (const host of existingHosts) {
|
|
const source = getProxmoxSource(host);
|
|
if (source?.sourceHostId === sourceHostId) {
|
|
existingBySource.set(proxmoxSourceKey(source), host);
|
|
}
|
|
}
|
|
|
|
const seen = new Set<string>();
|
|
for (const guest of discovery.guests) {
|
|
const key = guestSourceKey(sourceHostId, guest);
|
|
seen.add(key);
|
|
const existing = existingBySource.get(key);
|
|
const source: ProxmoxSource = {
|
|
source: "proxmox",
|
|
sourceHostId,
|
|
node: guest.node,
|
|
vmid: guest.vmid,
|
|
type: guest.type,
|
|
lastSeenAt: now,
|
|
lastStatus: guest.status,
|
|
missingSince: null,
|
|
};
|
|
|
|
if (!existing && !guest.ip) {
|
|
result.skipped++;
|
|
result.errors.push(
|
|
`${guest.name}: skipped because no IP address was discovered`,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
const baseConfig = existing
|
|
? parseJsonObject(existing.proxmoxConfig)
|
|
: {};
|
|
const proxmoxConfig = {
|
|
...baseConfig,
|
|
source,
|
|
};
|
|
const existingConnectionType =
|
|
existing?.connectionType === "ssh" || existing?.connectionType === "rdp"
|
|
? existing.connectionType
|
|
: null;
|
|
const connectionType = existingConnectionType ?? guest.connectionType;
|
|
const port =
|
|
typeof existing?.port === "number"
|
|
? existing.port
|
|
: connectionType === "rdp"
|
|
? 3389
|
|
: 22;
|
|
const username =
|
|
typeof existing?.username === "string" && existing.username
|
|
? existing.username
|
|
: connectionType === "rdp"
|
|
? null
|
|
: "root";
|
|
const update: Record<string, unknown> = {
|
|
name: guest.name,
|
|
ip: guest.ip || existing?.ip,
|
|
port,
|
|
username,
|
|
connectionType,
|
|
folder: existing?.folder || sourceHostName,
|
|
tags: mergeTags(
|
|
existing?.tags,
|
|
["proxmox", guest.type, guest.node],
|
|
["proxmox-missing"],
|
|
),
|
|
proxmoxConfig: JSON.stringify(proxmoxConfig),
|
|
updatedAt: now,
|
|
};
|
|
|
|
if (existing) {
|
|
await createCurrentHostRepository().updateEncryptedForUser(
|
|
userId,
|
|
existing.id as number,
|
|
update,
|
|
);
|
|
result.updated++;
|
|
continue;
|
|
}
|
|
|
|
Object.assign(update, {
|
|
enableTerminal: connectionType !== "rdp",
|
|
enableFileManager: connectionType !== "rdp",
|
|
enableTunnel: connectionType !== "rdp",
|
|
enableDocker: guest.enableDocker,
|
|
enableSsh: connectionType === "ssh",
|
|
enableRdp: connectionType === "rdp",
|
|
});
|
|
|
|
await createCurrentHostRepository().createEncryptedForUser(userId, {
|
|
...update,
|
|
userId,
|
|
createdAt: now,
|
|
pin: false,
|
|
authType: connectionType === "rdp" ? "password" : importAuth.authType,
|
|
credentialId: connectionType === "ssh" ? importAuth.credentialId : null,
|
|
overrideCredentialUsername: importAuth.overrideCredentialUsername,
|
|
password: null,
|
|
key: null,
|
|
keyPassword: null,
|
|
keyType: null,
|
|
rdpUser: null,
|
|
rdpPassword: null,
|
|
rdpDomain: null,
|
|
rdpSecurity: null,
|
|
rdpIgnoreCert: 0,
|
|
rdpPort: connectionType === "rdp" ? 3389 : null,
|
|
vncUser: null,
|
|
vncPassword: null,
|
|
vncPort: null,
|
|
telnetUser: null,
|
|
telnetPassword: null,
|
|
telnetPort: null,
|
|
defaultPath: "/",
|
|
tunnelConnections: "[]",
|
|
jumpHosts: null,
|
|
quickActions: null,
|
|
statsConfig: null,
|
|
dockerConfig: null,
|
|
terminalConfig: null,
|
|
forceKeyboardInteractive: "false",
|
|
useSocks5: 0,
|
|
socks5Host: null,
|
|
socks5Port: null,
|
|
socks5Username: null,
|
|
socks5Password: null,
|
|
socks5ProxyChain: null,
|
|
portKnockSequence: null,
|
|
showTerminalInSidebar: 0,
|
|
showFileManagerInSidebar: 0,
|
|
showTunnelInSidebar: 0,
|
|
showDockerInSidebar: 0,
|
|
showServerStatsInSidebar: 0,
|
|
});
|
|
result.created++;
|
|
}
|
|
|
|
if (discovery.config.markMissingGuests) {
|
|
for (const [key, existing] of existingBySource.entries()) {
|
|
if (seen.has(key)) continue;
|
|
const config = parseJsonObject(existing.proxmoxConfig);
|
|
const source = getProxmoxSource(existing);
|
|
if (!source) continue;
|
|
const missingSince = source.missingSince || now;
|
|
await createCurrentHostRepository().updateEncryptedForUser(
|
|
userId,
|
|
existing.id as number,
|
|
{
|
|
tags: mergeTags(existing.tags, ["proxmox-missing"]),
|
|
proxmoxConfig: JSON.stringify({
|
|
...config,
|
|
source: {
|
|
...source,
|
|
missingSince,
|
|
},
|
|
}),
|
|
updatedAt: now,
|
|
},
|
|
);
|
|
result.markedMissing++;
|
|
}
|
|
}
|
|
|
|
await writeSyncStatus(userId, sourceHostId, {
|
|
lastSyncAt: startedAt,
|
|
lastSyncStatus: "success",
|
|
lastSyncError: null,
|
|
lastSyncResult: result,
|
|
});
|
|
|
|
return result;
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : "Unknown error";
|
|
result.errors.push(message);
|
|
await writeSyncStatus(userId, sourceHostId, {
|
|
lastSyncAt: startedAt,
|
|
lastSyncStatus: "error",
|
|
lastSyncError: message,
|
|
lastSyncResult: result,
|
|
});
|
|
throw error;
|
|
} finally {
|
|
runningSyncs.delete(lockKey);
|
|
}
|
|
}
|
|
|
|
async function writeSyncStatus(
|
|
userId: string,
|
|
hostId: number,
|
|
patch: Record<string, unknown>,
|
|
): Promise<void> {
|
|
const hostRepository = createCurrentHostRepository();
|
|
const row = await hostRepository.findByIdForUser(userId, hostId);
|
|
if (!row) return;
|
|
const config = parseJsonObject(row.proxmoxConfig);
|
|
await hostRepository.updateForUser(userId, hostId, {
|
|
proxmoxConfig: JSON.stringify({ ...config, ...patch }),
|
|
});
|
|
}
|
|
|
|
router.post("/sync", authenticateJWT, requireDataAccess, async (req, res) => {
|
|
const { hostId } = req.body as { hostId?: unknown };
|
|
const userId = (req as unknown as AuthenticatedRequest).userId;
|
|
|
|
const parsedHostId = Number(hostId);
|
|
if (!hostId || !Number.isInteger(parsedHostId) || parsedHostId <= 0) {
|
|
return res.status(400).json({ error: "Missing or invalid hostId" });
|
|
}
|
|
|
|
try {
|
|
const result = await syncProxmoxHost(userId, parsedHostId);
|
|
return res.json(result);
|
|
} catch (err: unknown) {
|
|
const message = err instanceof Error ? err.message : "Unknown error";
|
|
const status =
|
|
(err as Error & { code?: string; status?: number }).code ===
|
|
"SESSION_EXPIRED"
|
|
? 401
|
|
: (err as Error & { status?: number }).status || 500;
|
|
proxmoxLogger.error("Proxmox sync failed", err, {
|
|
operation: "proxmox_sync",
|
|
hostId: parsedHostId,
|
|
userId,
|
|
});
|
|
return res.status(status).json({ error: `Sync failed: ${message}` });
|
|
}
|
|
});
|
|
|
|
async function runDueProxmoxAutoSyncs(): Promise<void> {
|
|
try {
|
|
const rows = await createCurrentHostRepository().listProxmoxEnabled();
|
|
|
|
const now = Date.now();
|
|
for (const row of rows) {
|
|
const configRaw = parseJsonObject(row.proxmoxConfig);
|
|
const config = parseProxmoxConfig(configRaw);
|
|
if (!config.autoSyncEnabled) continue;
|
|
if (!DataCrypto.canUserAccessData(row.userId)) continue;
|
|
|
|
const lastSyncAt =
|
|
typeof configRaw.lastSyncAt === "string"
|
|
? Date.parse(configRaw.lastSyncAt)
|
|
: 0;
|
|
const intervalMs = config.syncIntervalMinutes * 60 * 1000;
|
|
if (lastSyncAt && now - lastSyncAt < intervalMs) continue;
|
|
|
|
syncProxmoxHost(row.userId, row.id).catch((error) => {
|
|
proxmoxLogger.error("Scheduled Proxmox sync failed", error, {
|
|
operation: "proxmox_auto_sync",
|
|
hostId: row.id,
|
|
userId: row.userId,
|
|
});
|
|
});
|
|
}
|
|
} catch (error) {
|
|
proxmoxLogger.error("Failed to scan Proxmox auto sync jobs", error, {
|
|
operation: "proxmox_auto_sync_scan",
|
|
});
|
|
}
|
|
}
|
|
|
|
const proxmoxAutoSyncTimer = setInterval(runDueProxmoxAutoSyncs, 60 * 1000);
|
|
proxmoxAutoSyncTimer.unref?.();
|
|
const proxmoxAutoSyncStartupTimer = setTimeout(
|
|
runDueProxmoxAutoSyncs,
|
|
30 * 1000,
|
|
);
|
|
proxmoxAutoSyncStartupTimer.unref?.();
|
|
|
|
/**
|
|
* @openapi
|
|
* /proxmox/discover:
|
|
* post:
|
|
* summary: Discover Proxmox guests on a node
|
|
* description: >
|
|
* Connects to an existing SSH host (a Proxmox node) using its stored
|
|
* credentials, runs pvesh to enumerate all guests (VMs and LXC
|
|
* containers) in the cluster, and returns them ready to be imported as
|
|
* Termix hosts. No separate Proxmox API token is required.
|
|
* tags: [Proxmox]
|
|
* security:
|
|
* - bearerAuth: []
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* required: [hostId]
|
|
* properties:
|
|
* hostId:
|
|
* type: number
|
|
* description: ID of the SSH host that is a Proxmox node.
|
|
* responses:
|
|
* 200:
|
|
* description: Discovered guests.
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* guests:
|
|
* type: array
|
|
* items:
|
|
* type: object
|
|
* properties:
|
|
* name:
|
|
* type: string
|
|
* vmid:
|
|
* type: number
|
|
* type:
|
|
* type: string
|
|
* enum: [qemu, lxc]
|
|
* node:
|
|
* type: string
|
|
* status:
|
|
* type: string
|
|
* ip:
|
|
* type: string
|
|
* nullable: true
|
|
* connectionType:
|
|
* type: string
|
|
* enum: [ssh, rdp]
|
|
* enableDocker:
|
|
* type: boolean
|
|
* credentialId:
|
|
* type: number
|
|
* nullable: true
|
|
* defaultCredentialId:
|
|
* type: number
|
|
* nullable: true
|
|
* 400:
|
|
* description: Missing or invalid hostId.
|
|
* 401:
|
|
* description: Authentication required or session expired.
|
|
* 403:
|
|
* description: Access denied to the host.
|
|
* 404:
|
|
* description: Host not found.
|
|
* 422:
|
|
* description: Host is not a Proxmox node or is unreachable.
|
|
* 500:
|
|
* description: Discovery failed.
|
|
*/
|
|
router.post(
|
|
"/discover",
|
|
authenticateJWT,
|
|
requireDataAccess,
|
|
async (req, res) => {
|
|
const { hostId } = req.body as { hostId?: unknown };
|
|
const userId = (req as unknown as AuthenticatedRequest).userId;
|
|
|
|
const parsedHostId = Number(hostId);
|
|
if (!hostId || !Number.isInteger(parsedHostId) || parsedHostId <= 0) {
|
|
return res.status(400).json({ error: "Missing or invalid hostId" });
|
|
}
|
|
|
|
try {
|
|
const discovery = await discoverProxmoxGuestsForHost(
|
|
userId,
|
|
parsedHostId,
|
|
);
|
|
return res.json({
|
|
guests: discovery.guests,
|
|
credentialId: discovery.credentialId,
|
|
defaultCredentialId: discovery.defaultCredentialId,
|
|
});
|
|
} catch (err: unknown) {
|
|
const message = err instanceof Error ? err.message : "Unknown error";
|
|
proxmoxLogger.error("Proxmox discovery failed", err, {
|
|
operation: "proxmox_discover",
|
|
hostId: parsedHostId,
|
|
userId,
|
|
});
|
|
|
|
const status =
|
|
(err as Error & { code?: string; status?: number }).code ===
|
|
"SESSION_EXPIRED"
|
|
? 401
|
|
: (err as Error & { status?: number }).status ||
|
|
(message.includes("Authentication failed") ||
|
|
message.includes("connect ECONNREFUSED") ||
|
|
message.includes("connect ETIMEDOUT")
|
|
? 422
|
|
: 500);
|
|
return res.status(status).json({ error: `Discovery failed: ${message}` });
|
|
}
|
|
},
|
|
);
|
|
|
|
export default router;
|