mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 10:21:34 +00:00
release-2.6.0 (#1085)
* fix: general bug fixes * fix: general qol additions * ci(deps): bump actions/setup-node in the github-actions group (#1068) Bumps the github-actions group with 1 update: [actions/setup-node](https://github.com/actions/setup-node). Updates `actions/setup-node` from 6 to 7 - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/setup-node dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps-dev): bump the dev-patch-updates group with 28 updates (#1069) Bumps the dev-patch-updates group with 28 updates: | Package | From | To | | --- | --- | --- | | [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.5.2` | `2.5.4` | | [@codemirror/view](https://github.com/codemirror/view) | `6.43.5` | `6.43.6` | | [@radix-ui/react-accordion](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/accordion) | `1.2.15` | `1.2.17` | | [@radix-ui/react-alert-dialog](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/alert-dialog) | `1.1.18` | `1.1.20` | | [@radix-ui/react-checkbox](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/checkbox) | `1.3.6` | `1.3.8` | | [@radix-ui/react-dialog](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/dialog) | `1.1.18` | `1.1.20` | | [@radix-ui/react-dropdown-menu](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/dropdown-menu) | `2.1.19` | `2.1.21` | | [@radix-ui/react-label](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/label) | `2.1.11` | `2.1.12` | | [@radix-ui/react-popover](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/popover) | `1.1.18` | `1.1.20` | | [@radix-ui/react-progress](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/progress) | `1.1.11` | `1.1.13` | | [@radix-ui/react-scroll-area](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/scroll-area) | `1.2.13` | `1.2.15` | | [@radix-ui/react-select](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/select) | `2.3.2` | `2.3.4` | | [@radix-ui/react-separator](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/separator) | `1.1.11` | `1.1.12` | | [@radix-ui/react-slider](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/slider) | `1.4.2` | `1.4.4` | | [@radix-ui/react-switch](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/switch) | `1.3.2` | `1.3.4` | | [@radix-ui/react-tabs](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/tabs) | `1.1.16` | `1.1.18` | | [@radix-ui/react-tooltip](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/tooltip) | `1.2.11` | `1.2.13` | | [@tailwindcss/vite](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-vite) | `4.3.2` | `4.3.3` | | [@uiw/codemirror-extensions-langs](https://github.com/uiwjs/react-codemirror) | `4.25.10` | `4.25.11` | | [@uiw/codemirror-theme-github](https://github.com/uiwjs/react-codemirror) | `4.25.10` | `4.25.11` | | [@uiw/react-codemirror](https://github.com/uiwjs/react-codemirror) | `4.25.10` | `4.25.11` | | [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) | `4.1.9` | `4.1.10` | | [@vitest/ui](https://github.com/vitest-dev/vitest/tree/HEAD/packages/ui) | `4.1.9` | `4.1.10` | | [i18next](https://github.com/i18next/i18next) | `26.3.4` | `26.3.6` | | [radix-ui](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/radix-ui) | `1.6.1` | `1.6.3` | | [react-i18next](https://github.com/i18next/react-i18next) | `17.0.8` | `17.0.10` | | [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `4.3.2` | `4.3.3` | | [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.9` | `4.1.10` | Updates `@biomejs/biome` from 2.5.2 to 2.5.4 - [Release notes](https://github.com/biomejs/biome/releases) - [Changelog](https://github.com/biomejs/biome/blob/main/packages/@biomejs/biome/CHANGELOG.md) - [Commits](https://github.com/biomejs/biome/commits/@biomejs/biome@2.5.4/packages/@biomejs/biome) Updates `@codemirror/view` from 6.43.5 to 6.43.6 - [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md) - [Commits](https://github.com/codemirror/view/commits) Updates `@radix-ui/react-accordion` from 1.2.15 to 1.2.17 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/accordion/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/accordion) Updates `@radix-ui/react-alert-dialog` from 1.1.18 to 1.1.20 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/alert-dialog/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/alert-dialog) Updates `@radix-ui/react-checkbox` from 1.3.6 to 1.3.8 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/checkbox/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/checkbox) Updates `@radix-ui/react-dialog` from 1.1.18 to 1.1.20 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/dialog/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/dialog) Updates `@radix-ui/react-dropdown-menu` from 2.1.19 to 2.1.21 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/dropdown-menu/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/dropdown-menu) Updates `@radix-ui/react-label` from 2.1.11 to 2.1.12 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/label/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/label) Updates `@radix-ui/react-popover` from 1.1.18 to 1.1.20 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/popover/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/popover) Updates `@radix-ui/react-progress` from 1.1.11 to 1.1.13 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/progress/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/progress) Updates `@radix-ui/react-scroll-area` from 1.2.13 to 1.2.15 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/scroll-area/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/scroll-area) Updates `@radix-ui/react-select` from 2.3.2 to 2.3.4 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/select/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/select) Updates `@radix-ui/react-separator` from 1.1.11 to 1.1.12 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/separator/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/separator) Updates `@radix-ui/react-slider` from 1.4.2 to 1.4.4 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/slider/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/slider) Updates `@radix-ui/react-switch` from 1.3.2 to 1.3.4 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/switch/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/switch) Updates `@radix-ui/react-tabs` from 1.1.16 to 1.1.18 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/tabs/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/tabs) Updates `@radix-ui/react-tooltip` from 1.2.11 to 1.2.13 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/tooltip/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/tooltip) Updates `@tailwindcss/vite` from 4.3.2 to 4.3.3 - [Release notes](https://github.com/tailwindlabs/tailwindcss/releases) - [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.3/packages/@tailwindcss-vite) Updates `@uiw/codemirror-extensions-langs` from 4.25.10 to 4.25.11 - [Release notes](https://github.com/uiwjs/react-codemirror/releases) - [Commits](https://github.com/uiwjs/react-codemirror/compare/v4.25.10...v4.25.11) Updates `@uiw/codemirror-theme-github` from 4.25.10 to 4.25.11 - [Release notes](https://github.com/uiwjs/react-codemirror/releases) - [Commits](https://github.com/uiwjs/react-codemirror/compare/v4.25.10...v4.25.11) Updates `@uiw/react-codemirror` from 4.25.10 to 4.25.11 - [Release notes](https://github.com/uiwjs/react-codemirror/releases) - [Commits](https://github.com/uiwjs/react-codemirror/compare/v4.25.10...v4.25.11) Updates `@vitest/coverage-v8` from 4.1.9 to 4.1.10 - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.10/packages/coverage-v8) Updates `@vitest/ui` from 4.1.9 to 4.1.10 - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.10/packages/ui) Updates `i18next` from 26.3.4 to 26.3.6 - [Release notes](https://github.com/i18next/i18next/releases) - [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md) - [Commits](https://github.com/i18next/i18next/compare/v26.3.4...v26.3.6) Updates `radix-ui` from 1.6.1 to 1.6.3 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/radix-ui/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/radix-ui) Updates `react-i18next` from 17.0.8 to 17.0.10 - [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md) - [Commits](https://github.com/i18next/react-i18next/compare/v17.0.8...v17.0.10) Updates `tailwindcss` from 4.3.2 to 4.3.3 - [Release notes](https://github.com/tailwindlabs/tailwindcss/releases) - [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.3/packages/tailwindcss) Updates `vitest` from 4.1.9 to 4.1.10 - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.10/packages/vitest) --- updated-dependencies: - dependency-name: "@biomejs/biome" dependency-version: 2.5.4 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@codemirror/view" dependency-version: 6.43.6 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-accordion" dependency-version: 1.2.17 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-alert-dialog" dependency-version: 1.1.20 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-checkbox" dependency-version: 1.3.8 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-dialog" dependency-version: 1.1.20 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-dropdown-menu" dependency-version: 2.1.21 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-label" dependency-version: 2.1.12 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-popover" dependency-version: 1.1.20 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-progress" dependency-version: 1.1.13 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-scroll-area" dependency-version: 1.2.15 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-select" dependency-version: 2.3.4 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-separator" dependency-version: 1.1.12 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-slider" dependency-version: 1.4.4 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-switch" dependency-version: 1.3.4 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-tabs" dependency-version: 1.1.18 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-tooltip" dependency-version: 1.2.13 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@tailwindcss/vite" dependency-version: 4.3.3 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@uiw/codemirror-extensions-langs" dependency-version: 4.25.11 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@uiw/codemirror-theme-github" dependency-version: 4.25.11 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@uiw/react-codemirror" dependency-version: 4.25.11 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@vitest/coverage-v8" dependency-version: 4.1.10 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@vitest/ui" dependency-version: 4.1.10 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: i18next dependency-version: 26.3.6 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: radix-ui dependency-version: 1.6.3 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: react-i18next dependency-version: 17.0.10 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: tailwindcss dependency-version: 4.3.3 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: vitest dependency-version: 4.1.10 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump ws in the prod-patch-updates group (#1071) Bumps the prod-patch-updates group with 1 update: [ws](https://github.com/websockets/ws). Updates `ws` from 8.21.0 to 8.21.1 - [Release notes](https://github.com/websockets/ws/releases) - [Commits](https://github.com/websockets/ws/compare/8.21.0...8.21.1) --- updated-dependencies: - dependency-name: ws dependency-version: 8.21.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: prod-patch-updates ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump the major-updates group with 2 updates (#1072) Bumps the major-updates group with 2 updates: [nanoid](https://github.com/ai/nanoid) and [typescript](https://github.com/microsoft/TypeScript). Updates `nanoid` from 5.1.16 to 6.0.0 - [Release notes](https://github.com/ai/nanoid/releases) - [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md) - [Commits](https://github.com/ai/nanoid/compare/5.1.16...6.0.0) Updates `typescript` from 6.0.3 to 7.0.2 - [Release notes](https://github.com/microsoft/TypeScript/releases) - [Commits](https://github.com/microsoft/TypeScript/commits) --- updated-dependencies: - dependency-name: nanoid dependency-version: 6.0.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: major-updates - dependency-name: typescript dependency-version: 7.0.2 dependency-type: direct:development update-type: version-update:semver-major dependency-group: major-updates ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * fix: general qol additions and new analytics/telemetrics feature * fix: incorrect version sent to posthog * feat: add multiplayer/shared sessions for terminal and guacd * feat: rework Electron desktop app to run standalone-first with optional two-way sync to a remote Termix server * Fix Guacamole tab visibility lifecycle (#1074) Co-authored-by: default-student <default-student@github.com> Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * fix(alerts): send channel config as object payload instead of JSON string (#1075) * fix tmux-monitor tailscale issue (#1076) * Initial plan * fix(tmux-monitor): explicitly handle tailscale auth in PanePreview hostConfig For Tailscale-auth hosts the pane-preview attach path was building the Terminal hostConfig with only the generic spread of host fields. This could omit or mismap auth-critical details and trigger a plain TCP/SSH reachability path that doesn't work with Tailscale-only SSH endpoints. The fix branches on `host.authType === "tailscale"` and: - Carries `authType: "tailscale"` explicitly so the backend always selects the Tailscale-aware PTY path regardless of how the host object evolves. - Derives `port` from `host.sshPort ?? host.port` so Tailscale SSH endpoints on a non-default SSH port are reached correctly. - Leaves all non-tailscale auth types on the unchanged code path. Reattach (bumping instanceId + attachNonce) continues to work because terminalHostConfig is recomputed on every render with the latest instanceIdRef.current value. * refactor(tmux-monitor): simplify tailscale port logic with extracted variable Address code review feedback: extract resolvedPort into a local variable to avoid the duplicated `host.sshPort ?? host.port` expression that was assigned to both `port` and `sshPort`. Restructure as an if/else block instead of an IIFE for readability. * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * chore: run prettier * chore: update beta release text * fix: cant update credential of an RDP host * feat: add custom key shortcuts * feat: add support for MFA over SSH * fix: Invalid websocket frame causing code 10006 crash triggering restart loop * fix(net): correct SSRF blocklist false-positive blocking all outbound IPv4 (#1079) * fix: correct IPv4-mapped-IPv6 blocklist entry blocking all outbound IPv4 ::ffff:0:0/96 in the IPv6 blocklist matches every IPv4 address once mapped, since Node's BlockList compares addresses in their mapped form internally regardless of the declared family. This caused safeOutboundFetch to reject all IPv4-resolved destinations as private, breaking outbound requests (e.g. ntfy/webhook notifications) whenever DNS resolved to IPv4. Replaced with individual mapped ranges mirroring the existing IPv4 blocklist. * test: cover isBlockedAddress and link the Node BlockList citation Exports isBlockedAddress so its family-crossing behavior around IPv4-mapped-IPv6 addresses can actually be asserted, instead of relying on manual container debugging to notice a regression. Also swaps the prior "Node's BlockList compares addresses in mapped form" comment for one citing the documented example in the Node docs (https://nodejs.org/api/net.html#class-netblocklist), since that behavior isn't otherwise obvious from the addSubnet/check call sites. Related: Termix-SSH/Support#1024 * refactor: derive IPv6 mirror from IPv4 list, split DNS error messages Two follow-ups from review: - The IPv6 blocklist previously hand-duplicated each IPv4 range as its IPv4-mapped-IPv6 equivalent. Nothing enforced the two stayed in sync, which is exactly how the original bug (a mismatched ::ffff:0:0/96 entry blocking all IPv4) was introduced in the first place. Now derived from a single blockedIpv4Ranges list in one loop. - The connect.lookup hook threw the same "Private destinations are not allowed" for both an empty DNS result and an actually-blocked address. An empty result is a resolution failure, not a privacy decision, and conflating the two is the same kind of opaque-error problem that made this bug slow to diagnose in production. Split into distinct messages. Also extracted the lookup hook itself (createDnsLookupHook) so it can be unit-tested against a fake resolver directly, instead of only through a real fetch()/Agent call — the bug lived entirely in this callback, and undici wraps any error thrown here as a generic "fetch failed" TypeError, which is why isolating it matters for testability. --------- Co-authored-by: brennanneoh <497569+brennanneoh@users.noreply.github.com> * fix(ssh): do not offer chacha20-poly1305 without the native ssh2 binding (#1081) The availability probe treated a working OpenSSL "chacha20" cipher as proof that chacha20-poly1305@openssh.com is usable. It is not: ssh2 pure-JS chacha20-poly1305 corrupts the transport, so the peer aborts the KEX ("incomplete message [preauth]") and the connection times out. Easy to hit on jump-host connections whose target sshd negotiates chacha20-poly1305 first. Only trust the native binding (sshcrypto.node); otherwise leave the cipher disabled so filterCiphers() drops it and AES-GCM is negotiated instead. Co-authored-by: XtraLarge <> * fix: add Swiss German server layout (#1078) * chore: update release notes * feat: continue improving desktop app 2-way sync with logic fixes and a migration dialog * fix: dekstop app showing auth form without syncing * feat: create desktop auto sessions for existing setups * feat: add electron backend killing * fix: electron login and session related bugs and updated readme for v2.6.0 * chore: finalize release notes * fix: click to expand hosts including extra bottom margin * fix: desktop auth modal failing to log users in * fix: desktop app failing to sync * fix: reverse proxy causing sync error * chore: lint, format, and bump version to 2.6.0 * chore: sync Crowdin translations for 2.6.0 --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: L.H. <117188168+default-student@users.noreply.github.com> Co-authored-by: default-student <default-student@github.com> Co-authored-by: Brad Baker <xyzulu@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Brennan Neoh <brennanneoh@users.noreply.github.com> Co-authored-by: brennanneoh <497569+brennanneoh@users.noreply.github.com> Co-authored-by: XtraLarge <eMail@WilliWerres.de> Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
This commit is contained in:
co-authored by
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
parent
cf3e2cb499
commit
1a26628a48
@@ -28,7 +28,7 @@ jobs:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: "npm"
|
||||
@@ -121,7 +121,7 @@ jobs:
|
||||
CHANGES=$(git log --oneline --no-merges "${{ steps.prev.outputs.sha }}..${{ needs.prep.outputs.sha }}" -- . ':!package-lock.json' | sed 's/^/- /')
|
||||
fi
|
||||
if [ -z "$CHANGES" ]; then
|
||||
CHANGES="- No new commits since the last beta (or this is the first beta build)."
|
||||
CHANGES="- No new commits since the last beta."
|
||||
fi
|
||||
|
||||
cat > BETA_RELEASE_BODY.md << EOF
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
name: Crowdin Sync
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 6 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
branch:
|
||||
description: "Branch to sync translations into"
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
crowdin:
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Resolve target branch
|
||||
id: branch
|
||||
run: |
|
||||
BRANCH="${{ inputs.branch }}"
|
||||
if [ -z "$BRANCH" ]; then
|
||||
BRANCH="${{ github.event.repository.default_branch }}"
|
||||
fi
|
||||
echo "name=$BRANCH" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Checkout branch
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ steps.branch.outputs.name }}
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GHCR_TOKEN }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
|
||||
- name: Upload sources to Crowdin
|
||||
uses: crowdin/github-action@v2
|
||||
with:
|
||||
upload_sources: true
|
||||
upload_translations: false
|
||||
download_translations: false
|
||||
create_pull_request: false
|
||||
push_translations: false
|
||||
token: ${{ secrets.CROWDIN_API_KEY }}
|
||||
project_id: "858252"
|
||||
env:
|
||||
CROWDIN_API_TOKEN: ${{ secrets.CROWDIN_API_KEY }}
|
||||
|
||||
- name: Machine pre-translate untranslated strings
|
||||
env:
|
||||
CROWDIN_API_KEY: ${{ secrets.CROWDIN_API_KEY }}
|
||||
run: node scripts/crowdin-pretranslate.cjs
|
||||
|
||||
- name: Download translations from Crowdin
|
||||
uses: crowdin/github-action@v2
|
||||
with:
|
||||
upload_sources: false
|
||||
upload_translations: false
|
||||
download_translations: true
|
||||
create_pull_request: false
|
||||
push_translations: false
|
||||
token: ${{ secrets.CROWDIN_API_KEY }}
|
||||
project_id: "858252"
|
||||
env:
|
||||
CROWDIN_API_TOKEN: ${{ secrets.CROWDIN_API_KEY }}
|
||||
|
||||
- name: Commit translations
|
||||
run: |
|
||||
git config user.name "LukeGus"
|
||||
git config user.email "bugattiguy527@gmail.com"
|
||||
|
||||
git add src/ui/locales/translated
|
||||
if git diff --cached --quiet; then
|
||||
echo "No translation changes to commit."
|
||||
exit 0
|
||||
fi
|
||||
git commit -m "chore: sync Crowdin translations"
|
||||
git push origin HEAD:"${{ steps.branch.outputs.name }}"
|
||||
@@ -43,6 +43,7 @@ on:
|
||||
jobs:
|
||||
build:
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
|
||||
@@ -72,7 +72,7 @@ jobs:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: "npm"
|
||||
@@ -166,7 +166,7 @@ jobs:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: "npm"
|
||||
@@ -380,7 +380,7 @@ jobs:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: "npm"
|
||||
@@ -966,7 +966,7 @@ jobs:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: "npm"
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: "npm"
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: "npm"
|
||||
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
|
||||
@@ -92,7 +92,7 @@ jobs:
|
||||
token: ${{ secrets.GHCR_TOKEN }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: "npm"
|
||||
@@ -144,7 +144,7 @@ jobs:
|
||||
token: ${{ secrets.GHCR_TOKEN }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
|
||||
@@ -225,7 +225,7 @@ jobs:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
|
||||
@@ -304,7 +304,7 @@ jobs:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
|
||||
@@ -420,7 +420,7 @@ jobs:
|
||||
path: termix
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version-file: "termix/.nvmrc"
|
||||
cache: "npm"
|
||||
@@ -513,7 +513,7 @@ jobs:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
|
||||
|
||||
@@ -189,6 +189,20 @@ SSH sessions and tabs stay open across devices/refreshes if enabled in user prof
|
||||
**Languages:**
|
||||
Built-in support ~30 languages (managed by [Crowdin](https://docs.termix.site/translations)).
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Session Sharing:**
|
||||
Share a live terminal, RDP, VNC, or Telnet session with others in real time. Share via a link (joined anonymously, no account needed) or with a specific Termix user, and choose read-only or read-write access. Shares can expire automatically or be revoked at any time, and session sharing can be toggled globally or per-host.
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Desktop Standalone + 2-Way Sync:**
|
||||
The Electron desktop app runs fully standalone with its own local backend and database, no server required. Optionally connect it to a remote Termix server for automatic two-way sync of hosts, credentials, snippets, and more, and choose whether SSH connections are started locally or through the remote server.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -293,6 +307,14 @@ networks:
|
||||
|
||||
<br />
|
||||
|
||||
## Telemetry
|
||||
|
||||
Termix sends a small anonymous usage ping once every 24 hours to help understand how many instances are running and which features are actually used. This only includes a randomly generated instance ID, a count of users and hosts, the app version, and whether certain features (terminal, file manager, tunnels, docker, etc.) were used in the last 24 hours. It never includes usernames, hostnames, IP addresses, credentials, or any other identifying or connection data.
|
||||
|
||||
This is opt-out and enabled by default. You can disable it at any time in Admin Settings under **General**.
|
||||
|
||||
<br />
|
||||
|
||||
## Donate
|
||||
|
||||
Termix is free and open source with no subscriptions or paid plans. If you find it useful, consider donating to help cover server costs, domains, and development time. Donations also help fund the time to research and learn what's needed to build features like SAML, Kubernetes, and Agent support. Track progress and donate below.
|
||||
|
||||
+56
-53
@@ -1,70 +1,73 @@
|
||||
<!-- SUMMARY -->
|
||||
|
||||
Revamped RBAC/sharing, session recording & replay, Vault auth for monitors, API key host enrollment, Proxmox guest auto sync, database refactor, plus 30+ bug fixes across terminal, file manager, RDP/VNC, and auth. DO NOT DOWNGRADE FROM THIS VERSION.
|
||||
Standalone-first Electron desktop app with optional remote sync, shared/multiplayer terminal and remote desktop sessions, improved SSH MFA support, custom key shortcuts, bug fixes across terminal, RDP/VNC, mobile, and auth.
|
||||
|
||||
<!-- /SUMMARY -->
|
||||
|
||||
<!-- YOUTUBE -->
|
||||
|
||||
https://youtu.be/c3UD4q2jW_8
|
||||
https://youtu.be/g0QjNdV3YYY
|
||||
|
||||
<!-- /YOUTUBE -->
|
||||
|
||||
<!-- UPDATE_LOG -->
|
||||
|
||||
- Revamped RBAC/sharing system (new UI, all auth types and host protocols now supported)
|
||||
- Complete admin control over user information (manage all users hosts, credentials, and snippets)
|
||||
- Support Vault auth for monitors
|
||||
- API key host enrollment endpoint
|
||||
- Allow pinned hosts with name sorting
|
||||
- Session recording and replay
|
||||
- Terminal font size shortcuts (ctrl + / -)
|
||||
- Open File Manager to tab right-click menu
|
||||
- Proxmox guest auto sync
|
||||
- Complete database refactor
|
||||
- 30-day donation reminder and new donation milestones that support research: (donate.termix.site)
|
||||
- Improve site performance with cache and poll pauses
|
||||
- Save quick connect sessions as hosts
|
||||
- Added simple telemetrics to PostHog (user count, total hosts across users, and version metrics).
|
||||
- Reworked Electron desktop app to run standalone-first with a now optional sync to a remote Termix server.
|
||||
- Added support for starting connections locally or from the remote server on desktop app.
|
||||
- Added support for custom key shortcuts.
|
||||
- Added support for more MFA types (SSH-only).
|
||||
- Added multiplayer/shared sessions for terminals and remote desktop (share via link or user).
|
||||
- Added support for logging into SSH hosts that require multi-factor authentication (like Duo or JumpCloud push/TOTP prompts).
|
||||
- Added the option to convert a Quick Connect session into a saved host after connecting.
|
||||
- Added an export option for sharing host entries without credentials, so a host list can be shared without leaking passwords or keys.
|
||||
- Unified the folder picker across hosts, credentials, and snippets so they all use the same searchable, create-in-place selector.
|
||||
- Allowed pasting into the key recording field from the clipboard.
|
||||
- Allowed sharing hosts that use authentication type "none".
|
||||
- Allowed setting authentication type "none" on RDP hosts.
|
||||
- Added the option to show two or more sidebar panels open at the same time.
|
||||
- Added global custom themes that can be applied across all hosts instead of per host.
|
||||
- Added a button to quickly create a Credentials entry from a host's existing authentication details.
|
||||
- Added persistent split screen, so your layout and assigned tabs are restored after closing and reopening the app.
|
||||
- Added tag matching to host search, so searching now matches tags as well as hostnames.
|
||||
- Brought back the ability to collapse snippets.
|
||||
- Added the ability to assign login credentials to an entire folder of hosts instead of one at a time.
|
||||
- Added the ability to share terminal, VNC, and RDP sessions with other users, including read-only and read-write modes.
|
||||
- Added the ability to share entire folders of hosts with other users instead of sharing hosts one by one.
|
||||
- Added the ability to make folders of hosts available to specific users instead of everyone recreating them.
|
||||
- Reworked SSH credentials to support both a password and an SSH key on the same credential, with an option to auto fill the password when prompted.
|
||||
- Added a custom group claim option for OIDC login, useful for identity providers like Zitadel that don't use a plain "groups" claim.
|
||||
<!-- /UPDATE_LOG -->
|
||||
|
||||
<!-- BUG_FIXES -->
|
||||
|
||||
- Syntax highlighting artifacts
|
||||
- Filter dashboard status hosts
|
||||
- Persist dashboard service link changes
|
||||
- Snippet text overflow
|
||||
- Persist remote desktop credential auth
|
||||
- Guard language switching failures
|
||||
- Resolve tunnel source credentials
|
||||
- Windows file delete command
|
||||
- Artifact release checkout ref
|
||||
- Command palette escape in fullscreen
|
||||
- Alerts and audit log normalization
|
||||
- macOS VNC protocol negotiation
|
||||
- Port knocking before SSH connect
|
||||
- Allow escape to close link confirmation
|
||||
- Prevent Electron modifier wheel zoom
|
||||
- Credential auth optional password
|
||||
- Retry transient terminal DNS lookups
|
||||
- OIDC redirect forwarded port handling
|
||||
- Preserve recent open tabs on startup
|
||||
- Terminal font selection
|
||||
- Poor font legibility in multiple places
|
||||
- File manager uploads failing
|
||||
- Tmux detection for non-POSTIX shells
|
||||
- OPKSSH js-yaml ESM import
|
||||
- Android Vietnamese IME input
|
||||
- Firefox RDP clipboard paste
|
||||
- Proxmox discovery over HTTPS
|
||||
- External editor actions in file preview
|
||||
- Firefox desktop OIDC callback
|
||||
- Status checks through jump hosts
|
||||
- Restore sudo password auto fill settings
|
||||
- Preserve file editor position on save
|
||||
- Sync cloud preference storage mode
|
||||
- Render RDP sessions at native pixel density
|
||||
- Restore database import in embedded desktop mode
|
||||
- Command autocomplete dropdown poor contrast
|
||||
- Allow clipboard paste in key recording field
|
||||
- Fix GitHub/google SSO "not defined" errors
|
||||
- Fixed the Add Channel dialog failing with "config is required" when adding Webhook or ntfy alert channels.
|
||||
- Fixed font size and UI scaling being too small even at the largest setting on high resolution displays.
|
||||
- Fixed the Docker integration not working on hosts using authentication type "none".
|
||||
- Fixed the latest Russian translation updates from Crowdin not being included in the app.
|
||||
- Fixed missing Nerd Font symbol support in the Android app.
|
||||
- Fixed credential changes on RDP hosts not saving properly.
|
||||
- Fixed credential folders not showing up in the folder dropdown when editing a credential.
|
||||
- Fixed RDP hosts not using their stored credential and falling back to a direct connection.
|
||||
- Fixed Cmd + scroll on Mac resizing the terminal instead of scrolling.
|
||||
- Fixed text repeating itself in the terminal when typing with a wireless keyboard on Android.
|
||||
- Fixed RDP connections failing when going through a jump host.
|
||||
- Fixed SSH connections failing when going through a jump host in some setups.
|
||||
- Fixed OIDC login failing with a database error when the identity provider didn't return a client ID.
|
||||
- Fixed SSH client-to-server tunnels failing with an authentication error.
|
||||
- Fixed Host Metrics disk usage only showing the root filesystem and ignoring other mounted disks.
|
||||
- Fixed Android navigation buttons covering the terminal's top bar keys.
|
||||
- Fixed Proxmox discovery importing DHCP LXC containers with an IP of 0.0.0.0 instead of their real address.
|
||||
- Fixed VNC connections to macOS Screen Sharing hanging after the handshake instead of connecting.
|
||||
- Fixed File Manager delete still failing on Windows hosts running PowerShell 5.1.
|
||||
- Fixed the terminal dropping characters while typing on iOS.
|
||||
- Fixed SSH lines like "[username@host]" being wrongly highlighted as a log level and breaking output formatting.
|
||||
- Fixed RDP touch mode on Android not registering taps as clicks.
|
||||
- Fixed VNC connections still failing due to a guacd protocol version mismatch.
|
||||
- Guacamole tab showing "connecting" instead of rendering the desktop.
|
||||
- Fixed tmux not using Tailscale when starting connections.
|
||||
- Fixed an invalid websocket frame from causing code 10006 crash triggering restart loop.
|
||||
- Remove chacha20-poly1305 without native ssh2 binding.
|
||||
- Corrected SSRF blocklist from false-positive on all IPv4.
|
||||
- Fixed terminal background image incorrectly displaying.
|
||||
<!-- /BUG_FIXES -->
|
||||
|
||||
+2
-1
@@ -58,7 +58,8 @@ WORKDIR /app
|
||||
|
||||
ENV DATA_DIR=/app/data \
|
||||
PORT=8080 \
|
||||
NODE_ENV=production
|
||||
NODE_ENV=production \
|
||||
POSTHOG_API_KEY=phc_xM8UznirsFxUkGE68gH4jzeqevf4kh76wGw7Ci7hH2dd
|
||||
|
||||
RUN apt-get update && apt-get install -y nginx gettext-base openssl ca-certificates gosu wget certbot python3-certbot-dns-cloudflare && \
|
||||
update-ca-certificates && \
|
||||
|
||||
@@ -226,6 +226,15 @@ http {
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location ~ ^/sync(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location ~ ^/termix-id(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
@@ -467,6 +476,15 @@ http {
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location ~ ^/session-sharing(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location /host/tunnel/ {
|
||||
proxy_pass http://127.0.0.1:30003;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
@@ -215,6 +215,15 @@ http {
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location ~ ^/sync(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location ~ ^/termix-id(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
@@ -456,6 +465,15 @@ http {
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location ~ ^/session-sharing(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location /host/tunnel/ {
|
||||
proxy_pass http://127.0.0.1:30003;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
@@ -189,6 +189,20 @@ Termix هي منصة مفتوحة المصدر ومجانية للأبد وذا
|
||||
**اللغات:**
|
||||
دعم مدمج لحوالي 30 لغة (تُدار بواسطة [Crowdin](https://docs.termix.site/translations)).
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**مشاركة الجلسة:**
|
||||
شارك جلسة طرفية أو RDP أو VNC أو Telnet مباشرة مع الآخرين في الوقت الفعلي. شارك عبر رابط (الانضمام بشكل مجهول، بدون الحاجة لحساب) أو مع مستخدم Termix محدد، واختر الوصول للقراءة فقط أو للقراءة والكتابة. يمكن أن تنتهي صلاحية المشاركات تلقائيًا أو يتم إلغاؤها في أي وقت، ويمكن تبديل مشاركة الجلسة عالميًا أو لكل مضيف على حدة.
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**تطبيق سطح مكتب مستقل + مزامنة ثنائية الاتجاه:**
|
||||
يعمل تطبيق سطح المكتب Electron بشكل مستقل تمامًا مع خلفية وقاعدة بيانات محلية خاصة به، دون الحاجة لخادم. يمكن اختياريًا توصيله بخادم Termix عن بُعد للمزامنة التلقائية ثنائية الاتجاه للمضيفين وبيانات الاعتماد والمقتطفات والمزيد، واختيار ما إذا كانت اتصالات SSH تبدأ محليًا أو عبر الخادم البعيد.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -189,6 +189,20 @@ Termix 是一个开源、永久免费、自托管的一体化服务器管理平
|
||||
**语言:**
|
||||
内置支持约 30 种语言(由 [Crowdin](https://docs.termix.site/translations) 管理)。
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**会话共享:**
|
||||
与他人实时共享终端、RDP、VNC 或 Telnet 会话。通过链接分享(匿名加入,无需帐户)或与特定的 Termix 用户分享,并选择只读或读写权限。共享可以自动过期或随时撤销,会话共享可以全局或按主机切换。
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**桌面独立运行 + 双向同步:**
|
||||
Electron 桌面应用可完全独立运行,拥有自己的本地后端和数据库,无需服务器。也可以选择连接到远程 Termix 服务器,实现主机、凭据、代码片段等的自动双向同步,并选择 SSH 连接是在本地启动还是通过远程服务器启动。
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -189,6 +189,20 @@ SSH-Sitzungen und Tabs bleiben uber Gerate/Aktualisierungen hinweg offen, wenn i
|
||||
**Sprachen:**
|
||||
Integrierte Unterstutzung fur ca. 30 Sprachen (verwaltet uber [Crowdin](https://docs.termix.site/translations)).
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Sitzungsfreigabe:**
|
||||
Teilen Sie eine Live-Terminal-, RDP-, VNC- oder Telnet-Sitzung in Echtzeit mit anderen. Freigabe uber einen Link (anonymer Beitritt, kein Konto erforderlich) oder mit einem bestimmten Termix-Benutzer, mit Wahl zwischen Nur-Lese- oder Lese-/Schreibzugriff. Freigaben konnen automatisch ablaufen oder jederzeit widerrufen werden, und die Sitzungsfreigabe kann global oder pro Host umgeschaltet werden.
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Eigenstandiger Desktop + bidirektionale Synchronisierung:**
|
||||
Die Electron-Desktop-App lauft vollstandig eigenstandig mit eigenem lokalem Backend und eigener Datenbank, kein Server erforderlich. Optional mit einem entfernten Termix-Server verbinden fur automatische bidirektionale Synchronisierung von Hosts, Zugangsdaten, Snippets und mehr, mit der Wahl, ob SSH-Verbindungen lokal oder uber den entfernten Server gestartet werden.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -189,6 +189,20 @@ Las sesiones SSH y pestanas permanecen abiertas entre dispositivos/actualizacion
|
||||
**Idiomas:**
|
||||
Soporte integrado para aproximadamente 30 idiomas (gestionado por [Crowdin](https://docs.termix.site/translations)).
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Uso compartido de sesion:**
|
||||
Comparte una sesion en vivo de terminal, RDP, VNC o Telnet con otras personas en tiempo real. Comparte mediante un enlace (se une de forma anonima, sin necesidad de cuenta) o con un usuario especifico de Termix, y elige acceso de solo lectura o de lectura y escritura. Las comparticiones pueden expirar automaticamente o revocarse en cualquier momento, y el uso compartido de sesiones se puede activar globalmente o por host.
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Aplicacion de escritorio independiente + sincronizacion bidireccional:**
|
||||
La aplicacion de escritorio Electron funciona de forma totalmente independiente con su propio backend y base de datos locales, sin necesidad de servidor. Opcionalmente, conectala a un servidor Termix remoto para sincronizacion bidireccional automatica de hosts, credenciales, fragmentos y mas, y elige si las conexiones SSH se inician localmente o a traves del servidor remoto.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -189,6 +189,20 @@ Les sessions SSH et les onglets restent ouverts sur tous les appareils/actualisa
|
||||
**Langues:**
|
||||
Support integre d'environ 30 langues (gere par [Crowdin](https://docs.termix.site/translations)).
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Partage de session:**
|
||||
Partagez une session de terminal, RDP, VNC ou Telnet en direct avec d'autres personnes en temps reel. Partagez via un lien (rejoint anonymement, sans compte necessaire) ou avec un utilisateur Termix specifique, et choisissez un acces en lecture seule ou en lecture-ecriture. Les partages peuvent expirer automatiquement ou etre revoques a tout moment, et le partage de session peut etre active globalement ou par hote.
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Application de bureau autonome + synchronisation bidirectionnelle:**
|
||||
L'application de bureau Electron fonctionne de maniere totalement autonome avec son propre backend et sa propre base de donnees locale, sans serveur requis. Connectez-la eventuellement a un serveur Termix distant pour une synchronisation bidirectionnelle automatique des hotes, des identifiants, des extraits de code et plus encore, et choisissez si les connexions SSH sont demarrees localement ou via le serveur distant.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -189,6 +189,20 @@ Termix एक ओपन-सोर्स, हमेशा के लिए मु
|
||||
**भाषाएँ:**
|
||||
लगभग 30 भाषाओं का बिल्ट-इन सपोर्ट ([Crowdin](https://docs.termix.site/translations) द्वारा प्रबंधित)।
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**सेशन शेयरिंग:**
|
||||
लाइव टर्मिनल, RDP, VNC, या Telnet सेशन को दूसरों के साथ रीयल टाइम में शेयर करें। लिंक के जरिए शेयर करें (गुमनाम रूप से जुड़ें, अकाउंट की जरूरत नहीं) या किसी खास Termix यूजर के साथ, और रीड-ओनली या रीड-राइट एक्सेस चुनें। शेयर अपने आप एक्सपायर हो सकते हैं या कभी भी रद्द किए जा सकते हैं, और सेशन शेयरिंग को ग्लोबली या प्रति होस्ट टॉगल किया जा सकता है।
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**डेस्कटॉप स्टैंडअलोन + 2-वे सिंक:**
|
||||
Electron डेस्कटॉप ऐप अपने खुद के लोकल बैकएंड और डेटाबेस के साथ पूरी तरह से स्टैंडअलोन चलता है, किसी सर्वर की जरूरत नहीं। चाहें तो इसे किसी रिमोट Termix सर्वर से कनेक्ट करें ताकि होस्ट्स, क्रेडेंशियल्स, स्निपेट्स और अन्य चीज़ों का ऑटोमैटिक 2-वे सिंक हो सके, और चुनें कि SSH कनेक्शन लोकली शुरू हों या रिमोट सर्वर के जरिए।
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -189,6 +189,20 @@ Le sessioni SSH e le schede rimangono aperte tra dispositivi/aggiornamenti se ab
|
||||
**Lingue:**
|
||||
Supporto integrato per circa 30 lingue (gestito da [Crowdin](https://docs.termix.site/translations)).
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Condivisione Sessione:**
|
||||
Condividi una sessione di terminale, RDP, VNC o Telnet dal vivo con altri in tempo reale. Condividi tramite un link (accesso anonimo, nessun account necessario) o con un utente Termix specifico, e scegli l'accesso in sola lettura o lettura/scrittura. Le condivisioni possono scadere automaticamente o essere revocate in qualsiasi momento, e la condivisione della sessione puo essere attivata globalmente o per singolo host.
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**App Desktop Standalone + Sincronizzazione Bidirezionale:**
|
||||
L'app desktop Electron funziona in modo completamente autonomo con il proprio backend e database locali, senza bisogno di un server. Facoltativamente, collegala a un server Termix remoto per la sincronizzazione bidirezionale automatica di host, credenziali, snippet e altro, scegliendo se le connessioni SSH vengono avviate localmente o tramite il server remoto.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -189,6 +189,20 @@ Tailnetのデバイスをリストしてホストとしてすばやく追加し
|
||||
**多言語対応:**
|
||||
約30言語の組み込みサポート([Crowdin](https://docs.termix.site/translations)で管理されています)。
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**セッション共有:**
|
||||
ターミナル、RDP、VNC、Telnetのライブセッションを他のユーザーとリアルタイムで共有できます。リンクで共有(匿名参加、アカウント不要)するか、特定のTermixユーザーと共有し、読み取り専用または読み取り/書き込みアクセスを選択できます。共有は自動的に期限切れになるか、いつでも取り消すことができ、セッション共有はグローバルまたはホストごとに切り替えられます。
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**デスクトップスタンドアロン + 双方向同期:**
|
||||
Electronデスクトップアプリは、独自のローカルバックエンドとデータベースを使用して完全にスタンドアロンで動作し、サーバーは不要です。オプションでリモートのTermixサーバーに接続し、ホスト、認証情報、スニペットなどの自動双方向同期を行い、SSH接続をローカルで開始するかリモートサーバー経由で開始するかを選択できます。
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -189,6 +189,20 @@ Tailscale 네트워크의 기기를 나열하여 호스트로 빠르게 추가
|
||||
**다국어 지원:**
|
||||
약 30개 언어 내장 지원([Crowdin](https://docs.termix.site/translations)으로 관리).
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**세션 공유:**
|
||||
터미널, RDP, VNC, Telnet 세션을 다른 사람과 실시간으로 공유하세요. 링크를 통해 공유(계정 없이 익명으로 참여)하거나 특정 Termix 사용자와 공유할 수 있으며, 읽기 전용 또는 읽기/쓰기 권한을 선택할 수 있습니다. 공유는 자동으로 만료되거나 언제든지 취소될 수 있으며, 세션 공유는 전역 또는 호스트별로 전환할 수 있습니다.
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**데스크톱 독립 실행 + 양방향 동기화:**
|
||||
Electron 데스크톱 앱은 자체 로컬 백엔드와 데이터베이스를 사용하여 서버 없이 완전히 독립적으로 실행됩니다. 선택적으로 원격 Termix 서버에 연결하여 호스트, 자격 증명, 스니펫 등을 자동으로 양방향 동기화하고, SSH 연결을 로컬에서 시작할지 원격 서버를 통해 시작할지 선택할 수 있습니다.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -189,6 +189,20 @@ Sessoes SSH e abas permanecem abertas entre dispositivos/atualizacoes se habilit
|
||||
**Idiomas:**
|
||||
Suporte integrado para aproximadamente 30 idiomas (gerenciado pelo [Crowdin](https://docs.termix.site/translations)).
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Compartilhamento de sessao:**
|
||||
Compartilhe uma sessao de terminal, RDP, VNC ou Telnet ao vivo com outras pessoas em tempo real. Compartilhe por meio de um link (entrada anonima, sem necessidade de conta) ou com um usuario especifico do Termix, e escolha acesso somente leitura ou leitura/gravacao. Os compartilhamentos podem expirar automaticamente ou ser revogados a qualquer momento, e o compartilhamento de sessao pode ser ativado globalmente ou por host.
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Aplicativo de desktop autonomo + sincronizacao bidirecional:**
|
||||
O aplicativo de desktop Electron funciona de forma totalmente autonoma com seu proprio backend e banco de dados locais, sem necessidade de servidor. Opcionalmente, conecte-o a um servidor Termix remoto para sincronizacao bidirecional automatica de hosts, credenciais, snippets e muito mais, e escolha se as conexoes SSH sao iniciadas localmente ou por meio do servidor remoto.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -189,6 +189,20 @@ SSH-сессии и вкладки остаются открытыми на вс
|
||||
**Языки:**
|
||||
Встроенная поддержка около 30 языков (управляется через [Crowdin](https://docs.termix.site/translations)).
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Общий доступ к сеансу:**
|
||||
Делитесь сеансом терминала, RDP, VNC или Telnet с другими в режиме реального времени. Делитесь по ссылке (анонимное присоединение, учетная запись не требуется) или с конкретным пользователем Termix, выбирая доступ только для чтения или для чтения и записи. Общий доступ может автоматически истекать или быть отозван в любое время, а общий доступ к сеансам можно включать глобально или для отдельного хоста.
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Автономное настольное приложение + двусторонняя синхронизация:**
|
||||
Настольное приложение на Electron полностью автономно, с собственным локальным бэкендом и базой данных, сервер не требуется. При желании подключите его к удаленному серверу Termix для автоматической двусторонней синхронизации хостов, учетных данных, сниппетов и прочего, и выберите, запускаются ли SSH-соединения локально или через удаленный сервер.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -189,6 +189,20 @@ Kullanici profilinde etkinlestirilmisse SSH oturumlari ve sekmeler cihazlar/yeni
|
||||
**Diller:**
|
||||
Yaklasik 30 dil icin yerlesik destek ([Crowdin](https://docs.termix.site/translations) tarafindan yonetilir).
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Oturum Paylasimi:**
|
||||
Canli bir terminal, RDP, VNC veya Telnet oturumunu baskalariyla gercek zamanli olarak paylasin. Bir baglanti uzerinden (anonim olarak katilir, hesap gerekmez) veya belirli bir Termix kullanicisiyla paylasin ve salt okunur veya okuma/yazma erisimi secin. Paylasimlar otomatik olarak sona erebilir veya istediginiz zaman iptal edilebilir; oturum paylasimi genel olarak veya sunucu bazinda acilip kapatilabilir.
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Bagimsiz Masaustu + Cift Yonlu Senkronizasyon:**
|
||||
Electron masaustu uygulamasi, kendi yerel arka ucu ve veritabaniyla tamamen bagimsiz calisir, sunucu gerekmez. Istege bagli olarak sunucular, kimlik bilgileri, kod parcaciklari ve daha fazlasinin otomatik cift yonlu senkronizasyonu icin uzak bir Termix sunucusuna baglayin ve SSH baglantilarinin yerel olarak mi yoksa uzak sunucu uzerinden mi baslatilacagini secin.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -189,6 +189,20 @@ Cac phien SSH va tab van mo tren cac thiet bi/lan lam moi neu duoc bat trong ho
|
||||
**Ngon Ngu:**
|
||||
Ho tro tich hop khoang 30 ngon ngu (duoc quan ly boi [Crowdin](https://docs.termix.site/translations)).
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Chia Se Phien:**
|
||||
Chia se mot phien terminal, RDP, VNC, hoac Telnet truc tiep voi nguoi khac theo thoi gian thuc. Chia se qua lien ket (tham gia an danh, khong can tai khoan) hoac voi mot nguoi dung Termix cu the, va chon quyen truy cap chi doc hoac doc/ghi. Cac lien ket chia se co the tu dong het han hoac bi thu hoi bat cu luc nao, va tinh nang chia se phien co the duoc bat/tat toan cuc hoac theo tung host.
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Ung Dung Desktop Doc Lap + Dong Bo 2 Chieu:**
|
||||
Ung dung desktop Electron chay hoan toan doc lap voi backend va co so du lieu cuc bo rieng, khong can may chu. Tuy chon ket noi voi may chu Termix tu xa de tu dong dong bo 2 chieu cac host, thong tin dang nhap, doan ma va nhieu hon nua, va chon xem cac ket noi SSH duoc khoi tao cuc bo hay thong qua may chu tu xa.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
+206
-95
@@ -20,6 +20,7 @@ const net = require("net");
|
||||
const { URL } = require("url");
|
||||
const { fork, spawn } = require("child_process");
|
||||
const WebSocket = require("ws");
|
||||
const remoteSync = require("./remote-sync.cjs");
|
||||
|
||||
// Portable mode: if a `.portable` marker exists next to the executable,
|
||||
// store all data in a `data` folder beside the exe instead of %APPDATA%.
|
||||
@@ -441,7 +442,10 @@ function isInvalidCertificateAllowedForUrl(url) {
|
||||
// fall through
|
||||
}
|
||||
|
||||
const config = getServerConfigSync();
|
||||
// The only remaining "connected remote server" a self-signed/invalid
|
||||
// certificate could legitimately apply to is the Remote Sync server
|
||||
// (also used for C2S tunnel relaying, see getC2SRelayUrl).
|
||||
const config = remoteSync.getRemoteSyncConfig();
|
||||
if (!config?.allowInvalidCertificate || !config?.serverUrl) return false;
|
||||
|
||||
return getOrigin(url) === getOrigin(config.serverUrl);
|
||||
@@ -816,7 +820,62 @@ function getBackendDataDir() {
|
||||
return dataDir;
|
||||
}
|
||||
|
||||
function getBackendPidFilePath() {
|
||||
return path.join(app.getPath("userData"), "backend.pid");
|
||||
}
|
||||
|
||||
// If the app was previously killed abnormally (crash, force-quit, Task
|
||||
// Manager) rather than through the normal quit flow, will-quit never fires
|
||||
// and stopBackendServer() never runs -- the forked backend child is a
|
||||
// genuinely separate OS process on Windows/mac/Linux, so it keeps running
|
||||
// and holding every port the backend binds (30001, 30003-30008, 30010,
|
||||
// 30012...). Every subsequent launch's own backend then fails outright
|
||||
// with EADDRINUSE and the app is stuck until something manually kills the
|
||||
// orphan. Reap any such leftover process, identified by PID file, before
|
||||
// spawning a new one.
|
||||
function reapOrphanedBackendProcess() {
|
||||
const pidFilePath = getBackendPidFilePath();
|
||||
let recordedPid;
|
||||
try {
|
||||
recordedPid = parseInt(fs.readFileSync(pidFilePath, "utf8").trim(), 10);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!Number.isInteger(recordedPid) || recordedPid <= 0) return;
|
||||
|
||||
try {
|
||||
// Signal 0 does not kill the process -- it only checks whether a
|
||||
// process with this PID exists and is signalable, throwing ESRCH if
|
||||
// not. This avoids killing an unrelated process that happens to have
|
||||
// reused the same PID since the last run.
|
||||
process.kill(recordedPid, 0);
|
||||
} catch {
|
||||
// No live process at that PID; nothing to reap.
|
||||
try {
|
||||
fs.unlinkSync(pidFilePath);
|
||||
} catch {
|
||||
// already absent
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
logToFile(
|
||||
`Found orphaned backend process from a previous session (pid ${recordedPid}), terminating it before starting a new one`,
|
||||
);
|
||||
try {
|
||||
process.kill(recordedPid, "SIGKILL");
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
try {
|
||||
fs.unlinkSync(pidFilePath);
|
||||
} catch {
|
||||
// already absent
|
||||
}
|
||||
}
|
||||
|
||||
function startBackendServer() {
|
||||
reapOrphanedBackendProcess();
|
||||
return new Promise((resolve) => {
|
||||
const { entryPath, backendCwd } = getBackendPaths();
|
||||
|
||||
@@ -852,11 +911,17 @@ function startBackendServer() {
|
||||
NODE_ENV: "production",
|
||||
ELECTRON_EMBEDDED: "true",
|
||||
PORT: "30001",
|
||||
VERSION: app.getVersion(),
|
||||
},
|
||||
stdio: ["pipe", "pipe", "pipe", "ipc"],
|
||||
});
|
||||
|
||||
logToFile("Backend process spawned, pid:", backendProcess.pid);
|
||||
try {
|
||||
fs.writeFileSync(getBackendPidFilePath(), String(backendProcess.pid));
|
||||
} catch {
|
||||
// Non-fatal: only means a future crash won't self-heal via reap.
|
||||
}
|
||||
|
||||
let resolved = false;
|
||||
const readyTimeout = setTimeout(() => {
|
||||
@@ -888,6 +953,7 @@ function startBackendServer() {
|
||||
backendStartFailed = true;
|
||||
}
|
||||
backendProcess = null;
|
||||
clearBackendPidFile();
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
clearTimeout(readyTimeout);
|
||||
@@ -907,6 +973,14 @@ function startBackendServer() {
|
||||
});
|
||||
}
|
||||
|
||||
function clearBackendPidFile() {
|
||||
try {
|
||||
fs.unlinkSync(getBackendPidFilePath());
|
||||
} catch {
|
||||
// already absent
|
||||
}
|
||||
}
|
||||
|
||||
function stopBackendServer() {
|
||||
if (!backendProcess) return;
|
||||
|
||||
@@ -929,6 +1003,7 @@ function stopBackendServer() {
|
||||
backendProcess.on("exit", () => {
|
||||
clearTimeout(forceKillTimeout);
|
||||
backendProcess = null;
|
||||
clearBackendPidFile();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1335,7 +1410,6 @@ ipcMain.handle("get-embedded-server-status", () => {
|
||||
return {
|
||||
running:
|
||||
backendProcess !== null && !backendProcess.killed && !backendStartFailed,
|
||||
embedded: !isDev,
|
||||
dataDir: isDev ? null : getBackendDataDir(),
|
||||
};
|
||||
});
|
||||
@@ -1442,6 +1516,80 @@ ipcMain.handle("save-server-config", (event, config) => {
|
||||
}
|
||||
});
|
||||
|
||||
// --- Remote sync (optional desktop <-> self-hosted server sync) ---
|
||||
|
||||
// Surfaces the pre-standalone-rework server-config.json (if a serverUrl was
|
||||
// ever set in it) so the renderer can prompt upgraded installs to set up
|
||||
// Remote Sync -- their hosts live on that old server and won't appear
|
||||
// locally until sync is enabled. A fresh install never had this file, so
|
||||
// this is naturally false for anyone who never used the old architecture.
|
||||
ipcMain.handle("get-legacy-server-config", () => {
|
||||
const config = getServerConfigSync();
|
||||
return { serverUrl: config?.serverUrl || null };
|
||||
});
|
||||
|
||||
ipcMain.handle("get-desktop-settings", () => {
|
||||
return remoteSync.getDesktopSettings();
|
||||
});
|
||||
|
||||
ipcMain.handle("save-desktop-settings", (_event, settings) => {
|
||||
return remoteSync.saveDesktopSettings(settings);
|
||||
});
|
||||
|
||||
ipcMain.handle("get-remote-sync-config", () => {
|
||||
return remoteSync.getRemoteSyncConfig();
|
||||
});
|
||||
|
||||
ipcMain.handle("save-remote-sync-config", (_event, config) => {
|
||||
return remoteSync.saveRemoteSyncConfig(config);
|
||||
});
|
||||
|
||||
ipcMain.handle("clear-remote-sync-config", async () => {
|
||||
const result = remoteSync.clearRemoteSyncConfig();
|
||||
remoteSync.clearRemoteSyncJwt();
|
||||
remoteSync.getRemoteSyncEngine()?.updateStatus({
|
||||
connected: false,
|
||||
syncing: false,
|
||||
needsReauth: false,
|
||||
lastError: null,
|
||||
});
|
||||
return result;
|
||||
});
|
||||
|
||||
ipcMain.handle("save-remote-sync-jwt", (_event, token) => {
|
||||
const result = remoteSync.saveRemoteSyncJwt(token);
|
||||
if (result.success) {
|
||||
remoteSync.getRemoteSyncEngine()?.updateStatus({
|
||||
connected: true,
|
||||
needsReauth: false,
|
||||
lastError: null,
|
||||
});
|
||||
remoteSync.getRemoteSyncEngine()?.syncNow();
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
ipcMain.handle("get-remote-sync-jwt", () => {
|
||||
return remoteSync.getRemoteSyncJwt();
|
||||
});
|
||||
|
||||
ipcMain.handle("clear-remote-sync-jwt", () => {
|
||||
return remoteSync.clearRemoteSyncJwt();
|
||||
});
|
||||
|
||||
ipcMain.handle("get-remote-sync-status", () => {
|
||||
return remoteSync.getRemoteSyncEngine()?.status || null;
|
||||
});
|
||||
|
||||
ipcMain.handle("remote-sync-now", async () => {
|
||||
return (await remoteSync.getRemoteSyncEngine()?.syncNow()) || null;
|
||||
});
|
||||
|
||||
ipcMain.handle("notify-local-login", (_event, token) => {
|
||||
remoteSync.getRemoteSyncEngine()?.setLocalJwt(token);
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
function getC2STunnelConfigPath() {
|
||||
return path.join(app.getPath("userData"), "c2s-tunnels.json");
|
||||
}
|
||||
@@ -1577,36 +1725,33 @@ const C2S_WS_HIGH_WATERMARK = 1024 * 1024;
|
||||
const C2S_WS_LOW_WATERMARK = 256 * 1024;
|
||||
const C2S_STREAM_WRITE_LIMIT = 8 * 1024 * 1024;
|
||||
|
||||
// C2S (client-to-server) tunnels relay through a connected, self-hosted
|
||||
// Termix server -- the same "remote server" concept Remote Sync connects
|
||||
// to, not the always-local embedded backend. There's no separate C2S
|
||||
// server-URL setting in the UI; it has always shared whatever remote
|
||||
// server the rest of the app was pointed at. Before the standalone-first
|
||||
// rework that was server-config.json; now it's remote-sync-config.json,
|
||||
// since that's the only remaining notion of "a connected remote server."
|
||||
function getC2SRelayUrl() {
|
||||
const config = getServerConfigSync();
|
||||
const serverUrl =
|
||||
config?.serverUrl || (!isDev ? "http://127.0.0.1:30003" : null);
|
||||
const config = remoteSync.getRemoteSyncConfig();
|
||||
const serverUrl = config?.serverUrl;
|
||||
if (!serverUrl) {
|
||||
throw new Error("No Termix server configured");
|
||||
throw new Error(
|
||||
"No remote Termix server connected -- enable Remote Sync first",
|
||||
);
|
||||
}
|
||||
|
||||
const base = serverUrl.replace(/\/$/, "");
|
||||
const relayHttpUrl = base.endsWith(":30003")
|
||||
? `${base}/ssh/tunnel/c2s/stream`
|
||||
: `${base}/ssh/tunnel/c2s/stream`;
|
||||
const relayHttpUrl = `${base}/ssh/tunnel/c2s/stream`;
|
||||
return relayHttpUrl.replace(/^http:/, "ws:").replace(/^https:/, "wss:");
|
||||
}
|
||||
|
||||
async function getC2SRelayHeaders(relayUrl) {
|
||||
if (!mainWindow?.webContents?.session) return {};
|
||||
|
||||
const cookieUrl = relayUrl
|
||||
.replace(/^ws:/, "http:")
|
||||
.replace(/^wss:/, "https:");
|
||||
const cookies = await mainWindow.webContents.session.cookies.get({
|
||||
url: cookieUrl,
|
||||
name: "jwt",
|
||||
});
|
||||
const jwt = cookies[0]?.value;
|
||||
async function getC2SRelayHeaders() {
|
||||
const jwt = remoteSync.getRemoteSyncJwt();
|
||||
if (!jwt) return {};
|
||||
|
||||
return {
|
||||
Cookie: `jwt=${encodeURIComponent(jwt)}`,
|
||||
Authorization: `Bearer ${jwt}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1706,7 +1851,7 @@ async function openC2SRelay(
|
||||
) {
|
||||
const tunnelName = tunnel.name || getC2STunnelName(tunnel);
|
||||
const relayUrl = getC2SRelayUrl();
|
||||
const headers = await getC2SRelayHeaders(relayUrl);
|
||||
const headers = await getC2SRelayHeaders();
|
||||
logToFile(`[c2s] opening relay for ${tunnelName}`, {
|
||||
relayUrl,
|
||||
targetHost,
|
||||
@@ -1811,7 +1956,7 @@ async function openC2SRelay(
|
||||
|
||||
async function testC2SRelay(tunnel, targetHost, targetPort) {
|
||||
const relayUrl = getC2SRelayUrl();
|
||||
const headers = await getC2SRelayHeaders(relayUrl);
|
||||
const headers = await getC2SRelayHeaders();
|
||||
const ws = new WebSocket(
|
||||
relayUrl,
|
||||
getWebSocketOptions(relayUrl, { headers }),
|
||||
@@ -2090,7 +2235,7 @@ async function startC2SRemoteTunnel(tunnel, index = 0) {
|
||||
}
|
||||
|
||||
const relayUrl = getC2SRelayUrl();
|
||||
const headers = await getC2SRelayHeaders(relayUrl);
|
||||
const headers = await getC2SRelayHeaders();
|
||||
const ws = new WebSocket(
|
||||
relayUrl,
|
||||
getWebSocketOptions(relayUrl, { headers }),
|
||||
@@ -2772,31 +2917,33 @@ ipcMain.handle("close-external-editor", (_event, editId) => {
|
||||
ipcMain.handle("test-server-connection", async (event, serverUrl) => {
|
||||
try {
|
||||
const normalizedServerUrl = serverUrl.replace(/\/$/, "");
|
||||
|
||||
const healthUrl = `${normalizedServerUrl}/health`;
|
||||
|
||||
// This is a best-effort reachability probe, not a hard gate: a reverse
|
||||
// proxy doing SSO in front of the real server (Pangolin, Authelia,
|
||||
// Cloudflare Access, etc.) intercepts this unauthenticated request
|
||||
// before it ever reaches Termix's own /health route, and returns its
|
||||
// own login page (HTML, or a redirect) instead of {"status":"ok"}.
|
||||
// That's a legitimate, working setup -- the login iframe shown right
|
||||
// after this check is what actually proves the server is real, by
|
||||
// completing an authenticated round-trip. So any response at all here
|
||||
// (any status code, any body) means "something is there, let the user
|
||||
// proceed"; only a network-level failure (nothing answered at all)
|
||||
// blocks continuing.
|
||||
try {
|
||||
const response = await httpFetch(healthUrl, {
|
||||
method: "GET",
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.text();
|
||||
|
||||
if (
|
||||
data.includes("<html") ||
|
||||
data.includes("<!DOCTYPE") ||
|
||||
data.includes("<head>") ||
|
||||
data.includes("<body>")
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
"Server returned HTML instead of JSON. This does not appear to be a Termix server.",
|
||||
};
|
||||
}
|
||||
const data = await response.text();
|
||||
const looksLikeHtml =
|
||||
data.includes("<html") ||
|
||||
data.includes("<!DOCTYPE") ||
|
||||
data.includes("<head>") ||
|
||||
data.includes("<body>");
|
||||
|
||||
if (response.ok && !looksLikeHtml) {
|
||||
try {
|
||||
const healthData = JSON.parse(data);
|
||||
if (
|
||||
@@ -2816,64 +2963,27 @@ ipcMain.handle("test-server-connection", async (event, serverUrl) => {
|
||||
console.log("Health endpoint did not return valid JSON");
|
||||
}
|
||||
}
|
||||
|
||||
// Reachable, but not a recognized Termix health response -- likely a
|
||||
// proxy/SSO login page in front of the real server. Let the user
|
||||
// proceed; the login step next will fail clearly if this really
|
||||
// isn't a Termix server.
|
||||
return {
|
||||
success: true,
|
||||
status: response.status,
|
||||
testedUrl: healthUrl,
|
||||
warning: looksLikeHtml
|
||||
? "Could not confirm this is a Termix server (the response looked like an HTML page, which can happen behind a login-protected reverse proxy). You can continue, and the next step will fail clearly if this isn't actually a Termix server."
|
||||
: "Server responded, but not with the expected health check format. Continuing anyway.",
|
||||
};
|
||||
} catch (urlError) {
|
||||
console.error("Health check failed:", urlError);
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
"Server is not responding. Please ensure the server is running and accessible.",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const versionUrl = `${normalizedServerUrl}/version`;
|
||||
const response = await httpFetch(versionUrl, {
|
||||
method: "GET",
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.text();
|
||||
|
||||
if (
|
||||
data.includes("<html") ||
|
||||
data.includes("<!DOCTYPE") ||
|
||||
data.includes("<head>") ||
|
||||
data.includes("<body>")
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
"Server returned HTML instead of JSON. This does not appear to be a Termix server.",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const versionData = JSON.parse(data);
|
||||
if (
|
||||
versionData &&
|
||||
(versionData.status === "up_to_date" ||
|
||||
versionData.status === "requires_update" ||
|
||||
(versionData.localVersion &&
|
||||
versionData.version &&
|
||||
versionData.latest_release))
|
||||
) {
|
||||
return {
|
||||
success: true,
|
||||
status: response.status,
|
||||
testedUrl: versionUrl,
|
||||
warning:
|
||||
"Health endpoint not available, but server appears to be running",
|
||||
};
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.log("Version endpoint did not return valid JSON");
|
||||
}
|
||||
}
|
||||
} catch (versionError) {
|
||||
console.error("Version check failed:", versionError);
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
"Server is not responding or does not appear to be a valid Termix server. Please ensure the server is running and accessible.",
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
@@ -2967,6 +3077,7 @@ app.whenReady().then(async () => {
|
||||
|
||||
createTray();
|
||||
createWindow();
|
||||
remoteSync.initRemoteSync(() => mainWindow);
|
||||
logToFile("=== Startup complete ===");
|
||||
});
|
||||
|
||||
|
||||
@@ -31,6 +31,13 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
startC2SAutoStartTunnels: () =>
|
||||
ipcRenderer.invoke("start-c2s-autostart-tunnels"),
|
||||
|
||||
onRemoteSyncStatusChanged: (callback) => {
|
||||
const listener = (_event, status) => callback(status);
|
||||
ipcRenderer.on("remote-sync-status-changed", listener);
|
||||
return () =>
|
||||
ipcRenderer.removeListener("remote-sync-status-changed", listener);
|
||||
},
|
||||
|
||||
clearSessionCookies: () => ipcRenderer.invoke("clear-session-cookies"),
|
||||
getSessionCookie: (name, targetUrl) =>
|
||||
ipcRenderer.invoke("get-session-cookie", name, targetUrl),
|
||||
|
||||
@@ -0,0 +1,516 @@
|
||||
// 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,
|
||||
};
|
||||
+4
-1
@@ -3,7 +3,10 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
|
||||
/>
|
||||
|
||||
<meta name="theme-color" content="#09090b" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
|
||||
Generated
+557
-554
File diff suppressed because it is too large
Load Diff
+31
-31
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "termix",
|
||||
"private": true,
|
||||
"version": "2.5.1",
|
||||
"version": "2.6.0",
|
||||
"description": "Self-hosted SSH and remote desktop management.",
|
||||
"author": "Karmaa",
|
||||
"main": "electron/main.cjs",
|
||||
@@ -14,7 +14,7 @@
|
||||
"format:check": "prettier --check .",
|
||||
"biome:check": "biome check biome.json package.json",
|
||||
"biome:fix": "biome check --write biome.json package.json",
|
||||
"postinstall": "node scripts/patch-app-builder-lib.cjs && node scripts/patch-guacamole-lite.cjs && node scripts/patch-better-sqlite3.cjs && node scripts/patch-nan.cjs && node scripts/patch-xterm-android-ime.cjs",
|
||||
"postinstall": "node scripts/patch-app-builder-lib.cjs && node scripts/patch-guacamole-lite.cjs && node scripts/patch-guacamole-common-js.cjs && node scripts/patch-better-sqlite3.cjs && node scripts/patch-nan.cjs && node scripts/patch-xterm-android-ime.cjs",
|
||||
"prebuild": "node scripts/write-electron-build-info.cjs",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint --fix .",
|
||||
@@ -65,22 +65,22 @@
|
||||
"ldapjs": "^3.0.7",
|
||||
"motion": "^12.42.2",
|
||||
"multer": "^2.2.0",
|
||||
"nanoid": "^5.1.16",
|
||||
"nanoid": "^6.0.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"serialport": "^13.0.0",
|
||||
"socks": "^2.8.7",
|
||||
"speakeasy": "^2.0.0",
|
||||
"ssh2": "^1.17.0",
|
||||
"undici": "^8.7.0",
|
||||
"ws": "^8.20.0"
|
||||
"ws": "^8.21.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.5.2",
|
||||
"@biomejs/biome": "2.5.4",
|
||||
"@codemirror/autocomplete": "^6.20.3",
|
||||
"@codemirror/commands": "^6.10.4",
|
||||
"@codemirror/search": "^6.7.1",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.43.5",
|
||||
"@codemirror/view": "^6.43.6",
|
||||
"@commitlint/cli": "^21.0.2",
|
||||
"@commitlint/config-conventional": "^21.0.2",
|
||||
"@deadendjs/swagger-jsdoc": "^8.1.2",
|
||||
@@ -92,23 +92,23 @@
|
||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||
"@fontsource/source-code-pro": "^5.2.7",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@radix-ui/react-accordion": "^1.2.15",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.18",
|
||||
"@radix-ui/react-checkbox": "^1.3.6",
|
||||
"@radix-ui/react-dialog": "^1.1.18",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.19",
|
||||
"@radix-ui/react-label": "^2.1.11",
|
||||
"@radix-ui/react-popover": "^1.1.18",
|
||||
"@radix-ui/react-progress": "^1.1.11",
|
||||
"@radix-ui/react-scroll-area": "^1.2.13",
|
||||
"@radix-ui/react-select": "^2.3.2",
|
||||
"@radix-ui/react-separator": "^1.1.11",
|
||||
"@radix-ui/react-slider": "^1.4.2",
|
||||
"@radix-ui/react-accordion": "^1.2.17",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.20",
|
||||
"@radix-ui/react-checkbox": "^1.3.8",
|
||||
"@radix-ui/react-dialog": "^1.1.20",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.21",
|
||||
"@radix-ui/react-label": "^2.1.12",
|
||||
"@radix-ui/react-popover": "^1.1.20",
|
||||
"@radix-ui/react-progress": "^1.1.13",
|
||||
"@radix-ui/react-scroll-area": "^1.2.15",
|
||||
"@radix-ui/react-select": "^2.3.4",
|
||||
"@radix-ui/react-separator": "^1.1.12",
|
||||
"@radix-ui/react-slider": "^1.4.4",
|
||||
"@radix-ui/react-slot": "^1.3.0",
|
||||
"@radix-ui/react-switch": "^1.3.2",
|
||||
"@radix-ui/react-tabs": "^1.1.16",
|
||||
"@radix-ui/react-tooltip": "^1.2.11",
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"@radix-ui/react-switch": "^1.3.4",
|
||||
"@radix-ui/react-tabs": "^1.1.18",
|
||||
"@radix-ui/react-tooltip": "^1.2.13",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
@@ -128,12 +128,12 @@
|
||||
"@types/speakeasy": "^2.0.10",
|
||||
"@types/ssh2": "^1.15.5",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@uiw/codemirror-extensions-langs": "^4.25.9",
|
||||
"@uiw/codemirror-theme-github": "^4.25.9",
|
||||
"@uiw/react-codemirror": "^4.25.9",
|
||||
"@uiw/codemirror-extensions-langs": "^4.25.11",
|
||||
"@uiw/codemirror-theme-github": "^4.25.11",
|
||||
"@uiw/react-codemirror": "^4.25.11",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"@vitest/coverage-v8": "^4.1.9",
|
||||
"@vitest/ui": "^4.1.9",
|
||||
"@vitest/coverage-v8": "^4.1.10",
|
||||
"@vitest/ui": "^4.1.10",
|
||||
"@xterm/addon-clipboard": "^0.2.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-unicode11": "^0.9.0",
|
||||
@@ -153,19 +153,19 @@
|
||||
"globals": "^17.5.0",
|
||||
"guacamole-common-js": "^1.5.0",
|
||||
"husky": "^9.1.7",
|
||||
"i18next": "^26.3.4",
|
||||
"i18next": "^26.3.6",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"jsdom": "^29.1.1",
|
||||
"lint-staged": "^17.0.8",
|
||||
"lucide-react": "^1.20.0",
|
||||
"prettier": "3.8.4",
|
||||
"radix-ui": "^1.6.1",
|
||||
"radix-ui": "^1.6.3",
|
||||
"react": "^19.2.7",
|
||||
"react-cytoscapejs": "^2.0.0",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-h5-audio-player": "^3.10.2",
|
||||
"react-hook-form": "^7.79.0",
|
||||
"react-i18next": "^17.0.4",
|
||||
"react-i18next": "^17.0.10",
|
||||
"react-icons": "^5.6.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-pdf": "^10.4.1",
|
||||
@@ -182,7 +182,7 @@
|
||||
"typescript-eslint": "^8.61.1",
|
||||
"vite": "^8.0.16",
|
||||
"vite-plugin-svgr": "^5.2.0",
|
||||
"vitest": "^4.1.9"
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{ts,tsx}": [
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const packageRoot = path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
"node_modules",
|
||||
"guacamole-common-js",
|
||||
);
|
||||
|
||||
const bundlePaths = [
|
||||
path.join(packageRoot, "dist", "esm", "guacamole-common.js"),
|
||||
path.join(packageRoot, "dist", "cjs", "guacamole-common.js"),
|
||||
];
|
||||
|
||||
const oldFlushBlock =
|
||||
" if (window.requestAnimationFrame && document.hasFocus())\n" +
|
||||
" asyncFlush();\n" +
|
||||
" else\n" +
|
||||
" syncFlush();";
|
||||
|
||||
const newFlushBlock =
|
||||
" // Electron can throttle or skip requestAnimationFrame() for inactive\n" +
|
||||
" // windows/tabs even while guacd is still sending display frames. Flush\n" +
|
||||
" // synchronously so Guacamole connections do not stall while waiting for\n" +
|
||||
" // a frame callback that may never run.\n" +
|
||||
" syncFlush();";
|
||||
|
||||
let patched = false;
|
||||
let foundBundle = false;
|
||||
|
||||
for (const bundlePath of bundlePaths) {
|
||||
if (!fs.existsSync(bundlePath)) {
|
||||
console.log(
|
||||
`[patch-guacamole-common-js] ${bundlePath} not found, skipping`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
foundBundle = true;
|
||||
let content = fs.readFileSync(bundlePath, "utf8");
|
||||
if (content.includes(newFlushBlock)) continue;
|
||||
|
||||
if (!content.includes(oldFlushBlock)) {
|
||||
console.log(
|
||||
`[patch-guacamole-common-js] Flush target not found in ${bundlePath}, skipping`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
content = content.replace(oldFlushBlock, newFlushBlock);
|
||||
fs.writeFileSync(bundlePath, content);
|
||||
patched = true;
|
||||
}
|
||||
|
||||
if (!foundBundle) {
|
||||
console.log("[patch-guacamole-common-js] File not found, skipping");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (!patched) {
|
||||
console.log("[patch-guacamole-common-js] Already patched");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(
|
||||
"[patch-guacamole-common-js] Patched display flush to avoid Electron requestAnimationFrame stalls",
|
||||
);
|
||||
@@ -17,14 +17,27 @@ const cryptPath = path.join(
|
||||
"lib",
|
||||
"Crypt.js",
|
||||
);
|
||||
const clientConnectionPath = path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
"node_modules",
|
||||
"guacamole-lite",
|
||||
"lib",
|
||||
"ClientConnection.js",
|
||||
);
|
||||
|
||||
if (!fs.existsSync(guacdClientPath) || !fs.existsSync(cryptPath)) {
|
||||
if (
|
||||
!fs.existsSync(guacdClientPath) ||
|
||||
!fs.existsSync(cryptPath) ||
|
||||
!fs.existsSync(clientConnectionPath)
|
||||
) {
|
||||
console.log("[patch-guacamole-lite] File not found, skipping");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
let guacdClientContent = fs.readFileSync(guacdClientPath, "utf8");
|
||||
let cryptContent = fs.readFileSync(cryptPath, "utf8");
|
||||
let clientConnectionContent = fs.readFileSync(clientConnectionPath, "utf8");
|
||||
|
||||
// Patch 1: protocol version negotiation.
|
||||
// guacamole-lite originally only accepted 1.0.0/1.1.0. Support the protocol
|
||||
@@ -56,20 +69,26 @@ const newVersionBlock =
|
||||
const oldTimezone = "if (protocolVersion === '1_1_0') {";
|
||||
const newTimezone = "if (protocolVersion !== '1_0_0') {";
|
||||
|
||||
// Patch 3: send the `name` handshake instruction for protocol >= 1.3.0.
|
||||
// The Guacamole protocol added the `name` instruction in 1.3.0 (an optional
|
||||
// human-readable identifier for the joining user). guacd 1.6.0 began requiring
|
||||
// it during the VNC handshake even when negotiating older protocol versions,
|
||||
// causing connections to silently drop right after "User joined". See
|
||||
// Patch 3: send the `name` handshake instruction for all protocol versions >= 1.1.0.
|
||||
// The Guacamole protocol added `name` in 1.3.0, but guacd 1.6.0 began requiring it
|
||||
// during the VNC handshake even when negotiating VERSION_1_1_0, causing connections to
|
||||
// silently drop right after "User joined". Sending it for all non-1.0.0 sessions is
|
||||
// harmless (guacd ignores unknown handshake instructions for older versions). See
|
||||
// Termix-SSH/Support#567 and #734.
|
||||
const oldConnect =
|
||||
" this.sendInstruction(['connect'].concat(connectArgs));";
|
||||
const newConnect =
|
||||
const oldNameConnect =
|
||||
" if (protocolVersion === '1_3_0' || protocolVersion === '1_5_0') {\n" +
|
||||
" this.sendInstruction(['name', this.connectionSettings.name || 'guacamole-lite']);\n" +
|
||||
" }\n" +
|
||||
"\n" +
|
||||
" this.sendInstruction(['connect'].concat(connectArgs));";
|
||||
const newConnect =
|
||||
" if (protocolVersion !== '1_0_0') {\n" +
|
||||
" this.sendInstruction(['name', this.connectionSettings.name || 'guacamole-lite']);\n" +
|
||||
" }\n" +
|
||||
"\n" +
|
||||
" this.sendInstruction(['connect'].concat(connectArgs));";
|
||||
|
||||
// Patch 4: answer guacd's dynamic argument requests locally.
|
||||
// macOS Screen Sharing can request VNC username/password through the
|
||||
@@ -156,13 +175,16 @@ if (!guacdClientContent.includes(newTimezone)) {
|
||||
}
|
||||
|
||||
if (!guacdClientContent.includes(newConnect)) {
|
||||
if (!guacdClientContent.includes(oldConnect)) {
|
||||
if (guacdClientContent.includes(oldNameConnect)) {
|
||||
guacdClientContent = guacdClientContent.replace(oldNameConnect, newConnect);
|
||||
} else if (guacdClientContent.includes(oldConnect)) {
|
||||
guacdClientContent = guacdClientContent.replace(oldConnect, newConnect);
|
||||
} else {
|
||||
console.log(
|
||||
"[patch-guacamole-lite] Connect target not found, skipping name patch",
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
guacdClientContent = guacdClientContent.replace(oldConnect, newConnect);
|
||||
patched = true;
|
||||
}
|
||||
|
||||
@@ -259,6 +281,94 @@ if (!cryptContent.includes(newDecryptBlock)) {
|
||||
patched = true;
|
||||
}
|
||||
|
||||
// Patch 7: drop client-to-guacd input instructions from read-only session-share
|
||||
// joins. guacd has no native read-only enforcement in the versions this project
|
||||
// targets, so Termix must gate here. Denylist (not allowlist) on purpose: an
|
||||
// unrecognized opcode is far more likely to be protocol plumbing (sync, blob,
|
||||
// clipboard streams) than a new input vector, so failing open is the safer
|
||||
// default for a client we already control.
|
||||
const oldSendMessageToGuacd =
|
||||
" sendMessageToGuacd(message) {\n" +
|
||||
" this.lastActivity = Date.now();\n" +
|
||||
" this.logger.log(LOGLEVEL.DEBUG, '[ >>> # ] Received from WS: ```' + message + '```');\n" +
|
||||
"\n" +
|
||||
" if (this.guacdClient) {\n" +
|
||||
" this.guacdClient.send(message, true);\n" +
|
||||
" }\n" +
|
||||
" }";
|
||||
const newSendMessageToGuacd =
|
||||
" sendMessageToGuacd(message) {\n" +
|
||||
" this.lastActivity = Date.now();\n" +
|
||||
" this.logger.log(LOGLEVEL.DEBUG, '[ >>> # ] Received from WS: ```' + message + '```');\n" +
|
||||
"\n" +
|
||||
" if (this.isReadOnlyJoin() && this.isInputInstruction(message)) {\n" +
|
||||
" return;\n" +
|
||||
" }\n" +
|
||||
"\n" +
|
||||
" if (this.guacdClient) {\n" +
|
||||
" this.guacdClient.send(message, true);\n" +
|
||||
" }\n" +
|
||||
" }\n" +
|
||||
"\n" +
|
||||
" isReadOnlyJoin() {\n" +
|
||||
" const connection = this.connectionSettings && this.connectionSettings.connection;\n" +
|
||||
" return !!(connection && connection.join && connection.readOnly === true);\n" +
|
||||
" }\n" +
|
||||
"\n" +
|
||||
" // Termix-only read-only gate, not part of the vendored library: extracts just\n" +
|
||||
" // the leading opcode from a raw '<len>.<opcode>,...;' instruction without the\n" +
|
||||
" // overhead of a full stateful parse.\n" +
|
||||
" isInputInstruction(message) {\n" +
|
||||
" const dot = message.indexOf('.');\n" +
|
||||
" if (dot === -1) return false;\n" +
|
||||
" const len = parseInt(message.substring(0, dot), 10);\n" +
|
||||
" if (isNaN(len)) return false;\n" +
|
||||
" const opcode = message.substring(dot + 1, dot + 1 + len);\n" +
|
||||
" return ['mouse', 'key', 'touch', 'size'].includes(opcode);\n" +
|
||||
" }";
|
||||
|
||||
if (!clientConnectionContent.includes("isReadOnlyJoin()")) {
|
||||
if (!clientConnectionContent.includes(oldSendMessageToGuacd)) {
|
||||
console.log(
|
||||
"[patch-guacamole-lite] sendMessageToGuacd target not found, skipping read-only patch",
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
clientConnectionContent = clientConnectionContent.replace(
|
||||
oldSendMessageToGuacd,
|
||||
newSendMessageToGuacd,
|
||||
);
|
||||
patched = true;
|
||||
}
|
||||
|
||||
// Patch 8: mergeConnectionOptions only preserves `join` across the settings
|
||||
// merge, dropping Termix's `readOnly` flag before sendMessageToGuacd can see it.
|
||||
const oldPreserveJoin =
|
||||
" // For join connections, preserve the join property\n" +
|
||||
" if (this.connectionSettings.connection.join) {\n" +
|
||||
" compiledSettings.join = this.connectionSettings.connection.join;\n" +
|
||||
" }";
|
||||
const newPreserveJoin =
|
||||
" // For join connections, preserve the join property\n" +
|
||||
" if (this.connectionSettings.connection.join) {\n" +
|
||||
" compiledSettings.join = this.connectionSettings.connection.join;\n" +
|
||||
" compiledSettings.readOnly = this.connectionSettings.connection.readOnly === true;\n" +
|
||||
" }";
|
||||
|
||||
if (!clientConnectionContent.includes("compiledSettings.readOnly")) {
|
||||
if (!clientConnectionContent.includes(oldPreserveJoin)) {
|
||||
console.log(
|
||||
"[patch-guacamole-lite] join-preserve target not found, skipping readOnly propagation patch",
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
clientConnectionContent = clientConnectionContent.replace(
|
||||
oldPreserveJoin,
|
||||
newPreserveJoin,
|
||||
);
|
||||
patched = true;
|
||||
}
|
||||
|
||||
if (!patched) {
|
||||
console.log("[patch-guacamole-lite] Already patched");
|
||||
process.exit(0);
|
||||
@@ -266,6 +376,7 @@ if (!patched) {
|
||||
|
||||
fs.writeFileSync(guacdClientPath, guacdClientContent);
|
||||
fs.writeFileSync(cryptPath, cryptContent);
|
||||
fs.writeFileSync(clientConnectionPath, clientConnectionContent);
|
||||
console.log(
|
||||
"[patch-guacamole-lite] Patched protocol VERSION_1_3_0/1_5_0 support, name handshake, required arguments, and UTF-8 token decrypt",
|
||||
"[patch-guacamole-lite] Patched protocol VERSION_1_3_0/1_5_0 support, name handshake, required arguments, UTF-8 token decrypt, and read-only join input filtering",
|
||||
);
|
||||
|
||||
@@ -69,6 +69,31 @@ describe("patch-guacamole-lite", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("sends name instruction for VERSION_1_1_0 to fix guacd 1.6.0 VNC drops", () => {
|
||||
const client = createPatchedClient({
|
||||
hostname: "192.0.2.10",
|
||||
port: 5900,
|
||||
password: "secret",
|
||||
width: 1280,
|
||||
height: 720,
|
||||
dpi: 96,
|
||||
});
|
||||
|
||||
client.sendHandshakeReply(["VERSION_1_1_0", "hostname", "port"]);
|
||||
|
||||
expect(client.sendInstruction).toHaveBeenCalledWith(["timezone"]);
|
||||
expect(client.sendInstruction).toHaveBeenCalledWith([
|
||||
"name",
|
||||
"guacamole-lite",
|
||||
]);
|
||||
expect(client.sendInstruction).toHaveBeenCalledWith([
|
||||
"connect",
|
||||
"VERSION_1_1_0",
|
||||
"192.0.2.10",
|
||||
5900,
|
||||
]);
|
||||
});
|
||||
|
||||
it("answers required credentials through argument value streams", () => {
|
||||
const client = createPatchedClient({
|
||||
username: "",
|
||||
|
||||
+28
-15
@@ -39,6 +39,19 @@ const nanHeaderPatched = patchFile(path.join(nanDir, "nan.h"), [
|
||||
# define __builtin_frame_address(level) _AddressOfReturnAddress()
|
||||
#endif
|
||||
|
||||
// v8::External::New()/->Value() gained a mandatory ExternalPointerTypeTag
|
||||
// argument in V8 15 (Electron 43+). Plain Node (V8 <= 13.x as of Node 24)
|
||||
// still uses the old 2-arg signatures, so this must be conditional rather
|
||||
// than assumed - a build can target either header set.
|
||||
#include <v8-version.h>
|
||||
#if defined(V8_MAJOR_VERSION) && V8_MAJOR_VERSION >= 15
|
||||
# define NAN_EXTERNAL_TAG_ARG , static_cast<v8::ExternalPointerTypeTag>(0)
|
||||
# define NAN_EXTERNAL_TAG_PARAM static_cast<v8::ExternalPointerTypeTag>(0)
|
||||
#else
|
||||
# define NAN_EXTERNAL_TAG_ARG
|
||||
# define NAN_EXTERNAL_TAG_PARAM
|
||||
#endif
|
||||
|
||||
#define NODE_0_10_MODULE_VERSION 11`,
|
||||
},
|
||||
]);
|
||||
@@ -63,23 +76,24 @@ const bindingPatched = patchFile(bindingPath, [
|
||||
},
|
||||
]);
|
||||
|
||||
// 2. nan_implementation_12_inl.h: replace v8::External::New() with the 3-arg form.
|
||||
// Electron 42 / V8 13+ requires an ExternalPointerTypeTag as the third argument.
|
||||
// 2. nan_implementation_12_inl.h: replace v8::External::New() with a form that
|
||||
// passes NAN_EXTERNAL_TAG_ARG - a macro (defined in the nan.h patch above)
|
||||
// that expands to the ExternalPointerTypeTag argument only when the target
|
||||
// V8 headers actually declare it (V8 15+ / Electron 43+).
|
||||
const implPath = path.join(nanDir, "nan_implementation_12_inl.h");
|
||||
let implPatched = false;
|
||||
if (fs.existsSync(implPath)) {
|
||||
let src = fs.readFileSync(implPath, "utf8");
|
||||
const before = src;
|
||||
|
||||
const TAG = "static_cast<v8::ExternalPointerTypeTag>(0)";
|
||||
if (!src.includes(TAG)) {
|
||||
if (!src.includes("NAN_EXTERNAL_TAG_ARG")) {
|
||||
src = src.replace(
|
||||
/v8::External::New\(v8::Isolate::GetCurrent\(\),\s*value\)/g,
|
||||
`v8::External::New(v8::Isolate::GetCurrent(), value, ${TAG})`,
|
||||
/v8::External::New\(v8::Isolate::GetCurrent\(\),\s*value(?:,\s*static_cast<v8::ExternalPointerTypeTag>\(0\))?\)/g,
|
||||
`v8::External::New(v8::Isolate::GetCurrent(), value NAN_EXTERNAL_TAG_ARG)`,
|
||||
);
|
||||
src = src.replace(
|
||||
/v8::External::New\(isolate,\s*reinterpret_cast<void \*>\(callback\)\)/g,
|
||||
`v8::External::New(isolate, reinterpret_cast<void *>(callback), ${TAG})`,
|
||||
/v8::External::New\(isolate,\s*reinterpret_cast<void \*>\(callback\)(?:,\s*static_cast<v8::ExternalPointerTypeTag>\(0\))?\)/g,
|
||||
`v8::External::New(isolate, reinterpret_cast<void *>(callback) NAN_EXTERNAL_TAG_ARG)`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -89,20 +103,19 @@ if (fs.existsSync(implPath)) {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. nan_callbacks_12_inl.h: replace ->Value() with ->Value(tag) on v8::External.
|
||||
// The new API requires an ExternalPointerTypeTag argument.
|
||||
// 3. nan_callbacks_12_inl.h: replace ->Value() with ->Value(NAN_EXTERNAL_TAG_PARAM)
|
||||
// on v8::External, same conditional-tag reasoning as above.
|
||||
const callbacksPath = path.join(nanDir, "nan_callbacks_12_inl.h");
|
||||
let callbacksPatched = false;
|
||||
if (fs.existsSync(callbacksPath)) {
|
||||
let src = fs.readFileSync(callbacksPath, "utf8");
|
||||
const before = src;
|
||||
|
||||
const TAG = "static_cast<v8::ExternalPointerTypeTag>(0)";
|
||||
if (!src.includes(TAG)) {
|
||||
// Pattern: .As<v8::External>()->Value()) — always followed by ))
|
||||
if (!src.includes("NAN_EXTERNAL_TAG_PARAM")) {
|
||||
// Pattern: .As<v8::External>()->Value()) or ->Value(<old hardcoded tag>))
|
||||
src = src.replace(
|
||||
/\.As<v8::External>\(\)->Value\(\)\)/g,
|
||||
`.As<v8::External>()->Value(${TAG}))`,
|
||||
/\.As<v8::External>\(\)->Value\((?:static_cast<v8::ExternalPointerTypeTag>\(0\))?\)\)/g,
|
||||
`.As<v8::External>()->Value(NAN_EXTERNAL_TAG_PARAM))`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,17 @@ const xtermDir = path.join(
|
||||
// xtermjs/xterm.js#3600 remains unresolved upstream. Android IMEs can restart
|
||||
// composition on the previous word and replace it with a shorter value (for
|
||||
// example, Vietnamese "Hoar" -> "Hỏa"). xterm 6.0 otherwise emits nothing.
|
||||
//
|
||||
// Also fixes _handleAnyTextareaChanges, which iOS Safari/WKWebView drives
|
||||
// for ordinary typing (it reports keyCode 229 for all software-keyboard
|
||||
// input, not just IME composition). That handler diffs the textarea value
|
||||
// via `newValue.replace(oldValue, "")`, a literal substring removal. When
|
||||
// keystrokes arrive faster than the function's setTimeout(0) callback runs,
|
||||
// several overlapping callbacks each capture a stale oldValue, so the
|
||||
// literal-substring search fails to match and the diff silently comes back
|
||||
// empty - characters are dropped instead of sent. Swap in the same
|
||||
// common-prefix diff used for composition-end above so a stale oldValue
|
||||
// still yields the correct delta.
|
||||
const patches = [
|
||||
{
|
||||
file: "xterm.mjs",
|
||||
@@ -34,6 +45,10 @@ const patches = [
|
||||
"e.start+=this._dataAlreadySent.length,this._isComposing?i=this._textarea.value.substring(e.start,this._compositionPosition.start):i=this._textarea.value.substring(e.start),i.length>0&&",
|
||||
"e.start+=this._dataAlreadySent.length;if(this._isComposing)i=this._textarea.value.substring(e.start,this._compositionPosition.start);else{const t=this._textarea.value;if(t.length<s.length){let e=0;const r=Math.min(t.length,s.length);for(;e<r&&t.charCodeAt(e)===s.charCodeAt(e);)e++;i=b.DEL.repeat(s.length-e)+t.substring(e)}else i=t.substring(e.start)}i.length>0&&",
|
||||
],
|
||||
[
|
||||
'_handleAnyTextareaChanges(){let t=this._textarea.value;setTimeout(()=>{if(!this._isComposing){let e=this._textarea.value,i=e.replace(t,"");this._dataAlreadySent=i,e.length>t.length?this._coreService.triggerDataEvent(i,!0):e.length<t.length?this._coreService.triggerDataEvent(`${b.DEL}`,!0):e.length===t.length&&e!==t&&this._coreService.triggerDataEvent(e,!0)}},0)}',
|
||||
"_handleAnyTextareaChanges(){let t=this._textarea.value;setTimeout(()=>{if(!this._isComposing){let e=this._textarea.value,r=0;const n=Math.min(e.length,t.length);for(;r<n&&e.charCodeAt(r)===t.charCodeAt(r);)r++;let i=e.length<t.length?b.DEL.repeat(t.length-r)+e.substring(r):e.substring(r);this._dataAlreadySent=i,i.length>0&&this._coreService.triggerDataEvent(i,!0)}},0)}",
|
||||
],
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -55,6 +70,10 @@ const patches = [
|
||||
"e.start+=this._dataAlreadySent.length,t=this._isComposing?this._textarea.value.substring(e.start,this._compositionPosition.start):this._textarea.value.substring(e.start),t.length>0&&",
|
||||
"e.start+=this._dataAlreadySent.length;this._isComposing?t=this._textarea.value.substring(e.start,this._compositionPosition.start):(()=>{const s=this._textarea.value;if(s.length<i.length){let e=0;const r=Math.min(s.length,i.length);for(;e<r&&s.charCodeAt(e)===i.charCodeAt(e);)e++;t=a.C0.DEL.repeat(i.length-e)+s.substring(e)}else t=s.substring(e.start)})(),t.length>0&&",
|
||||
],
|
||||
[
|
||||
'_handleAnyTextareaChanges(){const e=this._textarea.value;setTimeout((()=>{if(!this._isComposing){const t=this._textarea.value,i=t.replace(e,"");this._dataAlreadySent=i,t.length>e.length?this._coreService.triggerDataEvent(i,!0):t.length<e.length?this._coreService.triggerDataEvent(`${a.C0.DEL}`,!0):t.length===e.length&&t!==e&&this._coreService.triggerDataEvent(t,!0)}}),0)}',
|
||||
"_handleAnyTextareaChanges(){const e=this._textarea.value;setTimeout((()=>{if(!this._isComposing){const t=this._textarea.value;let r=0;const n=Math.min(t.length,e.length);for(;r<n&&t.charCodeAt(r)===e.charCodeAt(r);)r++;const i=t.length<e.length?a.C0.DEL.repeat(e.length-r)+t.substring(r):t.substring(r);this._dataAlreadySent=i,i.length>0&&this._coreService.triggerDataEvent(i,!0)}}),0)}",
|
||||
],
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -66,18 +85,24 @@ for (const { file, replacements } of patches) {
|
||||
}
|
||||
|
||||
let source = fs.readFileSync(filePath, "utf8");
|
||||
if (source.includes("_preCompositionValue")) {
|
||||
console.log(`[patch-xterm-android-ime] ${file} already patched`);
|
||||
continue;
|
||||
}
|
||||
let changed = false;
|
||||
|
||||
for (const [original, patched] of replacements) {
|
||||
if (source.includes(patched)) {
|
||||
continue;
|
||||
}
|
||||
if (!source.includes(original)) {
|
||||
throw new Error(
|
||||
`[patch-xterm-android-ime] Expected source not found in ${file}`,
|
||||
);
|
||||
}
|
||||
source = source.replace(original, patched);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!changed) {
|
||||
console.log(`[patch-xterm-android-ime] ${file} already patched`);
|
||||
continue;
|
||||
}
|
||||
|
||||
fs.writeFileSync(filePath, source);
|
||||
|
||||
@@ -12,6 +12,7 @@ import c2sTunnelPresetRoutes from "./routes/c2s-tunnel-presets.js";
|
||||
import terminalRoutes from "./routes/terminal.js";
|
||||
import sessionLogRoutes from "./routes/session-log-routes.js";
|
||||
import guacamoleRoutes from "../hosts/guacamole/routes.js";
|
||||
import sessionSharingRoutes from "../hosts/session-sharing/routes.js";
|
||||
import networkTopologyRoutes from "./routes/network-topology.js";
|
||||
import rbacRoutes from "./routes/rbac.js";
|
||||
import openTabsRoutes from "./routes/open-tabs.js";
|
||||
@@ -22,6 +23,7 @@ import { registerAuditLogRoutes } from "./routes/audit-log-routes.js";
|
||||
import { registerTailscaleRoutes } from "./routes/tailscale-routes.js";
|
||||
import vaultRoutes from "./routes/vault.js";
|
||||
import alertRulesRoutes from "./routes/alert-rules-routes.js";
|
||||
import syncRoutes from "./routes/sync.js";
|
||||
import { createCorsMiddleware } from "../utils/cors-config.js";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
@@ -1737,6 +1739,7 @@ app.use("/c2s-tunnel-presets", c2sTunnelPresetRoutes);
|
||||
app.use("/terminal", terminalRoutes);
|
||||
app.use("/session_logs", sessionLogRoutes);
|
||||
app.use("/guacamole", guacamoleRoutes);
|
||||
app.use("/session-sharing", sessionSharingRoutes);
|
||||
app.use("/network-topology", networkTopologyRoutes);
|
||||
app.use("/rbac", rbacRoutes);
|
||||
app.use("/open-tabs", openTabsRoutes);
|
||||
@@ -1747,6 +1750,7 @@ registerAuditLogRoutes(app, authenticateJWT);
|
||||
registerTailscaleRoutes(app, authenticateJWT);
|
||||
app.use("/vault", vaultRoutes);
|
||||
app.use("/", alertRulesRoutes);
|
||||
app.use("/sync", syncRoutes);
|
||||
|
||||
const frontendDistPaths = [
|
||||
path.join(__dirname, "../../../dist"),
|
||||
|
||||
@@ -390,9 +390,11 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
name TEXT NOT NULL,
|
||||
color TEXT,
|
||||
icon TEXT,
|
||||
credential_id INTEGER,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (credential_id) REFERENCES ssh_credentials (id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS recent_activity (
|
||||
@@ -493,6 +495,38 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
FOREIGN KEY (access_id) REFERENCES host_access (id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS session_shares (
|
||||
id TEXT PRIMARY KEY,
|
||||
host_id INTEGER NOT NULL,
|
||||
owner_user_id TEXT NOT NULL,
|
||||
protocol TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
tab_instance_id TEXT,
|
||||
share_type TEXT NOT NULL,
|
||||
target_user_id TEXT,
|
||||
link_token TEXT UNIQUE,
|
||||
permission_level TEXT NOT NULL DEFAULT 'read-only',
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TEXT NOT NULL,
|
||||
revoked_at TEXT,
|
||||
last_joined_at TEXT,
|
||||
join_count INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (owner_user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (target_user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS session_share_participants (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
share_id TEXT NOT NULL,
|
||||
user_id TEXT,
|
||||
guest_label TEXT,
|
||||
joined_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
left_at TEXT,
|
||||
FOREIGN KEY (share_id) REFERENCES session_shares (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
@@ -692,12 +726,16 @@ const addColumnIfNotExists = (
|
||||
sqlite.exec(`ALTER TABLE ${table}
|
||||
ADD COLUMN "${column}" ${definition};`);
|
||||
} catch (alterError) {
|
||||
databaseLogger.warn(`Failed to add column ${column} to ${table}`, {
|
||||
operation: "schema_migration",
|
||||
table,
|
||||
column,
|
||||
error: alterError,
|
||||
});
|
||||
const message =
|
||||
alterError instanceof Error ? alterError.message : String(alterError);
|
||||
databaseLogger.warn(
|
||||
`Failed to add column ${column} to ${table}: ${message}`,
|
||||
{
|
||||
operation: "schema_migration",
|
||||
table,
|
||||
column,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -736,6 +774,8 @@ const migrateSchema = () => {
|
||||
addColumnIfNotExists("user_preferences", "hidden_rail_tabs", "TEXT");
|
||||
addColumnIfNotExists("user_preferences", "compact_host_view", "INTEGER");
|
||||
addColumnIfNotExists("user_preferences", "status_color_scheme", "TEXT");
|
||||
addColumnIfNotExists("user_preferences", "custom_themes", "TEXT");
|
||||
addColumnIfNotExists("user_preferences", "custom_keybindings", "TEXT");
|
||||
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS dashboard_service_links (
|
||||
@@ -1378,6 +1418,19 @@ const migrateSchema = () => {
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT credential_id FROM ssh_folders LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec("ALTER TABLE ssh_folders ADD COLUMN credential_id INTEGER REFERENCES ssh_credentials(id) ON DELETE SET NULL");
|
||||
} catch (alterError) {
|
||||
databaseLogger.warn("Failed to add credential_id column to ssh_folders", {
|
||||
operation: "schema_migration",
|
||||
error: alterError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT sudo_password FROM ssh_data LIMIT 1").get();
|
||||
} catch {
|
||||
@@ -1440,6 +1493,8 @@ const migrateSchema = () => {
|
||||
{ column: "rdp_auth_type", sql: "ALTER TABLE ssh_data ADD COLUMN rdp_auth_type TEXT" },
|
||||
{ column: "vnc_auth_type", sql: "ALTER TABLE ssh_data ADD COLUMN vnc_auth_type TEXT" },
|
||||
{ column: "telnet_auth_type", sql: "ALTER TABLE ssh_data ADD COLUMN telnet_auth_type TEXT" },
|
||||
{ column: "allow_session_sharing", sql: "ALTER TABLE ssh_data ADD COLUMN allow_session_sharing INTEGER NOT NULL DEFAULT 1" },
|
||||
{ column: "connection_origin", sql: "ALTER TABLE ssh_data ADD COLUMN connection_origin TEXT" },
|
||||
];
|
||||
|
||||
for (const migration of sshDataMigrations) {
|
||||
@@ -1985,6 +2040,74 @@ const migrateSchema = () => {
|
||||
|
||||
addColumnIfNotExists("users", "sso_provider_id", "INTEGER");
|
||||
|
||||
try {
|
||||
const usersTableInfo = sqlite.prepare("PRAGMA table_info(users)").all() as Array<{
|
||||
cid: number;
|
||||
name: string;
|
||||
type: string;
|
||||
notnull: number;
|
||||
dflt_value: string | null;
|
||||
pk: number;
|
||||
}>;
|
||||
const legacyNotNullColumns = new Set([
|
||||
"client_id",
|
||||
"client_secret",
|
||||
"issuer_url",
|
||||
"authorization_url",
|
||||
"token_url",
|
||||
"identifier_path",
|
||||
"name_path",
|
||||
"scopes",
|
||||
]);
|
||||
const hasStaleNotNull = usersTableInfo.some(
|
||||
(col) => legacyNotNullColumns.has(col.name) && col.notnull === 1,
|
||||
);
|
||||
|
||||
if (hasStaleNotNull) {
|
||||
const tempTableName = "users_temp_migration";
|
||||
const columnDefs = usersTableInfo
|
||||
.map((col) => {
|
||||
const parts = [`"${col.name}"`, col.type || "TEXT"];
|
||||
if (col.pk === 1) parts.push("PRIMARY KEY");
|
||||
if (col.notnull === 1 && !legacyNotNullColumns.has(col.name)) {
|
||||
parts.push("NOT NULL");
|
||||
}
|
||||
if (col.dflt_value !== null) {
|
||||
parts.push(`DEFAULT ${col.dflt_value}`);
|
||||
}
|
||||
return parts.join(" ");
|
||||
})
|
||||
.join(",\n ");
|
||||
const allColumns = usersTableInfo.map((col) => `"${col.name}"`).join(", ");
|
||||
|
||||
sqlite.exec(`PRAGMA foreign_keys = OFF`);
|
||||
sqlite.exec(`
|
||||
CREATE TABLE ${tempTableName} (
|
||||
${columnDefs}
|
||||
);
|
||||
|
||||
INSERT INTO ${tempTableName} SELECT ${allColumns} FROM users;
|
||||
|
||||
DROP TABLE users;
|
||||
|
||||
ALTER TABLE ${tempTableName} RENAME TO users;
|
||||
`);
|
||||
sqlite.exec(`PRAGMA foreign_keys = ON`);
|
||||
|
||||
databaseLogger.info(
|
||||
"Successfully migrated users table to remove legacy OIDC NOT NULL constraints",
|
||||
{
|
||||
operation: "schema_migration_users_oidc_nullable",
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (migrationError) {
|
||||
databaseLogger.warn("Failed to migrate users table legacy OIDC columns", {
|
||||
operation: "schema_migration",
|
||||
error: migrationError,
|
||||
});
|
||||
}
|
||||
|
||||
// Migrate legacy single oidc_config settings blob into sso_providers table
|
||||
try {
|
||||
const migrationDone = getRawSettingValue("sso_migration_v1");
|
||||
@@ -2206,6 +2329,174 @@ const migrateSchema = () => {
|
||||
}
|
||||
// --- homepage end ---
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM session_shares LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS session_shares (
|
||||
id TEXT PRIMARY KEY,
|
||||
host_id INTEGER NOT NULL,
|
||||
owner_user_id TEXT NOT NULL,
|
||||
protocol TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
tab_instance_id TEXT,
|
||||
share_type TEXT NOT NULL,
|
||||
target_user_id TEXT,
|
||||
link_token TEXT UNIQUE,
|
||||
permission_level TEXT NOT NULL DEFAULT 'read-only',
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TEXT NOT NULL,
|
||||
revoked_at TEXT,
|
||||
last_joined_at TEXT,
|
||||
join_count INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (owner_user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (target_user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
sqlite.exec(
|
||||
"CREATE INDEX IF NOT EXISTS idx_session_shares_link_token ON session_shares(link_token)",
|
||||
);
|
||||
sqlite.exec(
|
||||
"CREATE INDEX IF NOT EXISTS idx_session_shares_target_user ON session_shares(target_user_id)",
|
||||
);
|
||||
sqlite.exec(
|
||||
"CREATE INDEX IF NOT EXISTS idx_session_shares_host ON session_shares(host_id)",
|
||||
);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create session_shares table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM session_share_participants LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS session_share_participants (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
share_id TEXT NOT NULL,
|
||||
user_id TEXT,
|
||||
guest_label TEXT,
|
||||
joined_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
left_at TEXT,
|
||||
FOREIGN KEY (share_id) REFERENCES session_shares (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
sqlite.exec(
|
||||
"CREATE INDEX IF NOT EXISTS idx_session_share_participants_share ON session_share_participants(share_id)",
|
||||
);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create session_share_participants table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- sync begin ---
|
||||
// Stable per-row identity used to match rows across two independently-
|
||||
// seeded databases (the embedded desktop backend and a connected remote
|
||||
// server) during sync. Local autoincrement ids collide across instances,
|
||||
// so a randomly-generated id is the join key instead. SQLite refuses a
|
||||
// non-constant DEFAULT (e.g. randomblob()) on ALTER TABLE ADD COLUMN for
|
||||
// tables with existing constraints ("Cannot add a column with
|
||||
// non-constant default"), so the column is added as plain nullable TEXT;
|
||||
// repositories set syncId explicitly on insert going forward, and
|
||||
// existing rows are backfilled by the UPDATE loop below.
|
||||
addColumnIfNotExists("ssh_data", "sync_id", "TEXT");
|
||||
addColumnIfNotExists("ssh_credentials", "sync_id", "TEXT");
|
||||
addColumnIfNotExists("ssh_folders", "sync_id", "TEXT");
|
||||
addColumnIfNotExists("snippets", "sync_id", "TEXT");
|
||||
addColumnIfNotExists("snippet_folders", "sync_id", "TEXT");
|
||||
addColumnIfNotExists("vault_profiles", "sync_id", "TEXT");
|
||||
addColumnIfNotExists("dashboard_service_links", "sync_id", "TEXT");
|
||||
// SQLite also rejects NOT NULL DEFAULT CURRENT_TIMESTAMP here for the same
|
||||
// "non-constant default" reason -- add nullable, then backfill from
|
||||
// created_at below and rely on the repository layer to keep it current.
|
||||
addColumnIfNotExists("dashboard_service_links", "updated_at", "TEXT");
|
||||
try {
|
||||
sqlite.exec(
|
||||
"UPDATE dashboard_service_links SET updated_at = created_at WHERE updated_at IS NULL",
|
||||
);
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
databaseLogger.warn(
|
||||
`Failed to backfill dashboard_service_links.updated_at: ${message}`,
|
||||
{ operation: "schema_migration", table: "dashboard_service_links" },
|
||||
);
|
||||
}
|
||||
addColumnIfNotExists("homepage_items", "sync_id", "TEXT");
|
||||
|
||||
const syncIdTables = [
|
||||
"ssh_data",
|
||||
"ssh_credentials",
|
||||
"ssh_folders",
|
||||
"snippets",
|
||||
"snippet_folders",
|
||||
"vault_profiles",
|
||||
"dashboard_service_links",
|
||||
"homepage_items",
|
||||
];
|
||||
|
||||
for (const table of syncIdTables) {
|
||||
try {
|
||||
const result = sqlite
|
||||
.prepare(
|
||||
`UPDATE ${table} SET sync_id = lower(hex(randomblob(16))) WHERE sync_id IS NULL`,
|
||||
)
|
||||
.run();
|
||||
if (result.changes > 0) {
|
||||
databaseLogger.info(
|
||||
`Backfilled sync_id for ${result.changes} row(s) in ${table}`,
|
||||
{ operation: "sync_id_backfill", table },
|
||||
);
|
||||
}
|
||||
sqlite.exec(
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_${table}_sync_id ON ${table}(sync_id)`,
|
||||
);
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
databaseLogger.warn(
|
||||
`Failed to backfill sync_id for ${table}: ${message}`,
|
||||
{
|
||||
operation: "sync_id_backfill",
|
||||
table,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM sync_tombstones LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS sync_tombstones (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
entity_type TEXT NOT NULL,
|
||||
sync_id TEXT NOT NULL,
|
||||
deleted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
sqlite.exec(
|
||||
"CREATE INDEX IF NOT EXISTS idx_sync_tombstones_user_entity ON sync_tombstones(user_id, entity_type)",
|
||||
);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create sync_tombstones table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
// --- sync end ---
|
||||
|
||||
databaseLogger.success("Schema migration completed", {
|
||||
operation: "schema_migration",
|
||||
});
|
||||
|
||||
@@ -153,6 +153,9 @@ export const hosts = sqliteTable("ssh_data", {
|
||||
enableSessionLogging: integer("enable_session_logging", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true),
|
||||
allowSessionSharing: integer("allow_session_sharing", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true),
|
||||
enableCommandHistory: integer("enable_command_history", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true),
|
||||
@@ -237,6 +240,12 @@ export const hosts = sqliteTable("ssh_data", {
|
||||
socks5Password: text("socks5_password"),
|
||||
socks5ProxyChain: text("socks5_proxy_chain"),
|
||||
|
||||
// null = use the desktop app's global default; "local" | "remote" pins
|
||||
// this specific host's SSH/Docker-console/Serial connections to originate
|
||||
// from the embedded local backend or a connected remote sync server.
|
||||
// Ignored for rdp/vnc/telnet, which always require the remote server.
|
||||
connectionOrigin: text("connection_origin"),
|
||||
|
||||
macAddress: text("mac_address"),
|
||||
wolBroadcastAddress: text("wol_broadcast_address"),
|
||||
portKnockSequence: text("port_knock_sequence"),
|
||||
@@ -248,6 +257,11 @@ export const hosts = sqliteTable("ssh_data", {
|
||||
hostKeyLastVerified: text("host_key_last_verified"),
|
||||
hostKeyChangedCount: integer("host_key_changed_count").default(0),
|
||||
|
||||
// Stable identity used to match this row across two independently-seeded
|
||||
// databases (the embedded backend and a connected remote server) during
|
||||
// sync -- local autoincrement ids collide across instances.
|
||||
syncId: text("sync_id").unique(),
|
||||
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
@@ -354,6 +368,7 @@ export const sshCredentials = sqliteTable("ssh_credentials", {
|
||||
|
||||
usageCount: integer("usage_count").notNull().default(0),
|
||||
lastUsed: text("last_used"),
|
||||
syncId: text("sync_id").unique(),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
@@ -388,6 +403,7 @@ export const snippets = sqliteTable("snippets", {
|
||||
description: text("description"),
|
||||
folder: text("folder"),
|
||||
order: integer("order").notNull().default(0),
|
||||
syncId: text("sync_id").unique(),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
@@ -405,6 +421,7 @@ export const snippetFolders = sqliteTable("snippet_folders", {
|
||||
name: text("name").notNull(),
|
||||
color: text("color"),
|
||||
icon: text("icon"),
|
||||
syncId: text("sync_id").unique(),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
@@ -462,6 +479,10 @@ export const sshFolders = sqliteTable("ssh_folders", {
|
||||
name: text("name").notNull(),
|
||||
color: text("color"),
|
||||
icon: text("icon"),
|
||||
credentialId: integer("credential_id").references(() => sshCredentials.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
syncId: text("sync_id").unique(),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
@@ -673,6 +694,62 @@ export const sessionRecordings = sqliteTable("session_recordings", {
|
||||
terminationReason: text("termination_reason"),
|
||||
});
|
||||
|
||||
export const sessionShares = sqliteTable("session_shares", {
|
||||
id: text("id").primaryKey(),
|
||||
|
||||
hostId: integer("host_id")
|
||||
.notNull()
|
||||
.references(() => hosts.id, { onDelete: "cascade" }),
|
||||
ownerUserId: text("owner_user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
|
||||
protocol: text("protocol").notNull(),
|
||||
|
||||
// Live-session binding: TerminalSessionManager's session.id for SSH, or
|
||||
// guacd's own guacamoleConnectionId for rdp/vnc/telnet. Neither is a DB
|
||||
// row (process-local, in-memory) so this intentionally has no FK.
|
||||
sessionId: text("session_id").notNull(),
|
||||
tabInstanceId: text("tab_instance_id"),
|
||||
|
||||
shareType: text("share_type").notNull(), // "link" | "user"
|
||||
targetUserId: text("target_user_id").references(() => users.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
linkToken: text("link_token").unique(),
|
||||
|
||||
permissionLevel: text("permission_level").notNull().default("read-only"),
|
||||
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
expiresAt: text("expires_at").notNull(),
|
||||
revokedAt: text("revoked_at"),
|
||||
|
||||
lastJoinedAt: text("last_joined_at"),
|
||||
joinCount: integer("join_count").notNull().default(0),
|
||||
});
|
||||
|
||||
export const sessionShareParticipants = sqliteTable(
|
||||
"session_share_participants",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
shareId: text("share_id")
|
||||
.notNull()
|
||||
.references(() => sessionShares.id, { onDelete: "cascade" }),
|
||||
|
||||
userId: text("user_id").references(() => users.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
guestLabel: text("guest_label"),
|
||||
|
||||
joinedAt: text("joined_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
leftAt: text("left_at"),
|
||||
},
|
||||
);
|
||||
|
||||
export const opksshTokens = sqliteTable("opkssh_tokens", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
@@ -724,6 +801,7 @@ export const vaultProfiles = sqliteTable("vault_profiles", {
|
||||
keyType: text("key_type"),
|
||||
// When true the profile is visible/usable by all users on the server
|
||||
shared: integer("shared", { mode: "boolean" }).notNull().default(false),
|
||||
syncId: text("sync_id").unique(),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
@@ -813,6 +891,8 @@ export const userPreferences = sqliteTable("user_preferences", {
|
||||
hiddenRailTabs: text("hidden_rail_tabs"),
|
||||
compactHostView: integer("compact_host_view", { mode: "boolean" }),
|
||||
statusColorScheme: text("status_color_scheme"),
|
||||
customThemes: text("custom_themes"),
|
||||
customKeybindings: text("custom_keybindings"),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
@@ -879,9 +959,13 @@ export const dashboardServiceLinks = sqliteTable("dashboard_service_links", {
|
||||
label: text("label").notNull(),
|
||||
url: text("url").notNull(),
|
||||
order: integer("order").notNull().default(0),
|
||||
syncId: text("sync_id").unique(),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
// --- termix-id begin ---
|
||||
@@ -1067,6 +1151,7 @@ export const homepageItems = sqliteTable("homepage_items", {
|
||||
title: text("title"),
|
||||
config: text("config").notNull().default("{}"),
|
||||
folderId: integer("folder_id"),
|
||||
syncId: text("sync_id").unique(),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
@@ -1088,3 +1173,20 @@ export const homepageLayouts = sqliteTable("homepage_layouts", {
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
// --- homepage end ---
|
||||
|
||||
// --- sync begin ---
|
||||
// Records a delete for a synced entity type so the other side of a sync
|
||||
// pair (embedded desktop backend <-> connected remote server) learns about
|
||||
// the deletion instead of re-creating the row on its next pull.
|
||||
export const syncTombstones = sqliteTable("sync_tombstones", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
entityType: text("entity_type").notNull(),
|
||||
syncId: text("sync_id").notNull(),
|
||||
deletedAt: text("deleted_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
// --- sync end ---
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { and, desc, eq, sql } from "drizzle-orm";
|
||||
import { randomUUID } from "crypto";
|
||||
import { sshCredentials, sshCredentialUsage } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||
@@ -18,7 +19,7 @@ export class CredentialRepository {
|
||||
async create(credential: NewCredentialRecord): Promise<CredentialRecord> {
|
||||
const rows = await this.context.drizzle
|
||||
.insert(sshCredentials)
|
||||
.values(credential)
|
||||
.values({ syncId: randomUUID(), ...credential })
|
||||
.returning();
|
||||
await this.afterWrite();
|
||||
return rows[0];
|
||||
@@ -30,7 +31,11 @@ export class CredentialRepository {
|
||||
): Promise<CredentialRecord> {
|
||||
const userDataKey = DataCrypto.validateUserAccess(userId);
|
||||
const tempId = credential.id ?? Date.now();
|
||||
const dataWithTempId = { ...credential, id: tempId };
|
||||
const dataWithTempId = {
|
||||
syncId: randomUUID(),
|
||||
...credential,
|
||||
id: tempId,
|
||||
};
|
||||
const encryptedCredential = this.encryptCredentialRecordForWrite(
|
||||
dataWithTempId,
|
||||
userId,
|
||||
@@ -140,7 +145,7 @@ export class CredentialRepository {
|
||||
): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.update(sshCredentials)
|
||||
.set({ folder: newName })
|
||||
.set({ folder: newName, updatedAt: sql`CURRENT_TIMESTAMP` })
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.userId, userId),
|
||||
@@ -163,7 +168,7 @@ export class CredentialRepository {
|
||||
): Promise<CredentialRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.update(sshCredentials)
|
||||
.set(update)
|
||||
.set({ ...update, updatedAt: sql`CURRENT_TIMESTAMP` })
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, credentialId),
|
||||
@@ -190,7 +195,7 @@ export class CredentialRepository {
|
||||
|
||||
const rows = await this.context.drizzle
|
||||
.update(sshCredentials)
|
||||
.set(encryptedUpdate)
|
||||
.set({ ...encryptedUpdate, updatedAt: sql`CURRENT_TIMESTAMP` })
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, credentialId),
|
||||
@@ -203,7 +208,10 @@ export class CredentialRepository {
|
||||
return this.decryptOne(rows[0] ?? null, userId);
|
||||
}
|
||||
|
||||
async deleteForUser(userId: string, credentialId: number): Promise<boolean> {
|
||||
async deleteForUser(
|
||||
userId: string,
|
||||
credentialId: number,
|
||||
): Promise<{ syncId: string | null } | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(sshCredentials)
|
||||
.where(
|
||||
@@ -212,10 +220,10 @@ export class CredentialRepository {
|
||||
eq(sshCredentials.userId, userId),
|
||||
),
|
||||
)
|
||||
.returning({ id: sshCredentials.id });
|
||||
.returning({ syncId: sshCredentials.syncId });
|
||||
|
||||
await this.afterWrite();
|
||||
return rows.length > 0;
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import { randomUUID } from "crypto";
|
||||
import { dashboardServiceLinks } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
|
||||
@@ -40,11 +41,13 @@ export class DashboardServiceLinkRepository {
|
||||
const [created] = await this.context.drizzle
|
||||
.insert(dashboardServiceLinks)
|
||||
.values({
|
||||
syncId: randomUUID(),
|
||||
userId,
|
||||
label: input.label,
|
||||
url: input.url,
|
||||
order: nextOrder,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
})
|
||||
.returning();
|
||||
await this.afterWrite();
|
||||
@@ -76,7 +79,7 @@ export class DashboardServiceLinkRepository {
|
||||
): Promise<DashboardServiceLinkRecord | null> {
|
||||
const [updated] = await this.context.drizzle
|
||||
.update(dashboardServiceLinks)
|
||||
.set(updates)
|
||||
.set({ ...updates, updatedAt: new Date().toISOString() })
|
||||
.where(
|
||||
and(
|
||||
eq(dashboardServiceLinks.id, id),
|
||||
@@ -92,7 +95,10 @@ export class DashboardServiceLinkRepository {
|
||||
return updated ?? null;
|
||||
}
|
||||
|
||||
async deleteForUser(userId: string, id: number): Promise<boolean> {
|
||||
async deleteForUser(
|
||||
userId: string,
|
||||
id: number,
|
||||
): Promise<{ syncId: string | null } | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(dashboardServiceLinks)
|
||||
.where(
|
||||
@@ -101,13 +107,11 @@ export class DashboardServiceLinkRepository {
|
||||
eq(dashboardServiceLinks.userId, userId),
|
||||
),
|
||||
)
|
||||
.returning({ id: dashboardServiceLinks.id });
|
||||
.returning({ syncId: dashboardServiceLinks.syncId });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length > 0;
|
||||
if (rows.length === 0) return null;
|
||||
await this.afterWrite();
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
|
||||
@@ -27,10 +27,12 @@ import { RecentActivityRepository } from "./recent-activity-repository.js";
|
||||
import { RoleRepository } from "./role-repository.js";
|
||||
import { SessionRecordingRepository } from "./session-recording-repository.js";
|
||||
import { SessionRepository } from "./session-repository.js";
|
||||
import { SessionShareRepository } from "./session-share-repository.js";
|
||||
import { SettingsRepository } from "./settings-repository.js";
|
||||
import { SharedHostSecretsRepository } from "./shared-host-secrets-repository.js";
|
||||
import { SnippetRepository } from "./snippet-repository.js";
|
||||
import { SshCredentialUsageRepository } from "./ssh-credential-usage-repository.js";
|
||||
import { SyncTombstoneRepository } from "./sync-tombstone-repository.js";
|
||||
import { SsoProviderRepository } from "./sso-provider-repository.js";
|
||||
import { TermixIdentityCaRepository } from "./termix-identity-ca-repository.js";
|
||||
import { TermixIdentityRepository } from "./termix-identity-repository.js";
|
||||
@@ -125,6 +127,13 @@ export function createCurrentDashboardServiceLinkRepository(): DashboardServiceL
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentSyncTombstoneRepository(): SyncTombstoneRepository {
|
||||
return new SyncTombstoneRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("sync_tombstone_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentDismissedAlertRepository(): DismissedAlertRepository {
|
||||
return new DismissedAlertRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
@@ -253,6 +262,13 @@ export function createCurrentSessionRepository(): SessionRepository {
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentSessionShareRepository(): SessionShareRepository {
|
||||
return new SessionShareRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("session_share_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentSettingsRepository(): SettingsRepository {
|
||||
return new SettingsRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import { randomUUID } from "crypto";
|
||||
import { homepageItems } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
|
||||
@@ -37,6 +38,7 @@ export class HomepageItemRepository {
|
||||
const [created] = await this.context.drizzle
|
||||
.insert(homepageItems)
|
||||
.values({
|
||||
syncId: randomUUID(),
|
||||
userId,
|
||||
typeId: input.typeId,
|
||||
title: input.title,
|
||||
@@ -82,17 +84,18 @@ export class HomepageItemRepository {
|
||||
return updated ?? null;
|
||||
}
|
||||
|
||||
async deleteForUser(userId: string, id: number): Promise<boolean> {
|
||||
async deleteForUser(
|
||||
userId: string,
|
||||
id: number,
|
||||
): Promise<{ syncId: string | null } | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(homepageItems)
|
||||
.where(and(eq(homepageItems.id, id), eq(homepageItems.userId, userId)))
|
||||
.returning({ id: homepageItems.id });
|
||||
.returning({ syncId: homepageItems.syncId });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length > 0;
|
||||
if (rows.length === 0) return null;
|
||||
await this.afterWrite();
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { and, eq, like, or, sql } from "drizzle-orm";
|
||||
import { randomUUID } from "crypto";
|
||||
import type { SQLiteColumn } from "drizzle-orm/sqlite-core";
|
||||
import { hosts, sshCredentials, sshFolders } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
@@ -72,13 +73,20 @@ export class HostFolderRepository {
|
||||
name: string,
|
||||
color: string | null | undefined,
|
||||
icon: string | null | undefined,
|
||||
credentialId?: number | null,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<{ folder: HostFolderRecord; created: boolean }> {
|
||||
const existing = await this.findFolder(userId, name);
|
||||
if (existing) {
|
||||
const [updated] = await this.context.drizzle
|
||||
.update(sshFolders)
|
||||
.set({ color, icon, updatedAt: now })
|
||||
.set({
|
||||
color,
|
||||
icon,
|
||||
credentialId:
|
||||
credentialId === undefined ? existing.credentialId : credentialId,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(and(eq(sshFolders.userId, userId), eq(sshFolders.name, name)))
|
||||
.returning();
|
||||
|
||||
@@ -89,10 +97,12 @@ export class HostFolderRepository {
|
||||
const [created] = await this.context.drizzle
|
||||
.insert(sshFolders)
|
||||
.values({
|
||||
syncId: randomUUID(),
|
||||
userId,
|
||||
name,
|
||||
color,
|
||||
icon,
|
||||
credentialId: credentialId ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
@@ -118,7 +128,7 @@ export class HostFolderRepository {
|
||||
async deleteHostsAndFolderRecords(
|
||||
userId: string,
|
||||
folderName: string,
|
||||
): Promise<void> {
|
||||
): Promise<{ hostSyncIds: string[]; folderSyncIds: string[] }> {
|
||||
const folderMatch = (col: SQLiteColumn) =>
|
||||
or(eq(col, folderName), like(col, `${folderName} / %`));
|
||||
|
||||
@@ -129,11 +139,21 @@ export class HostFolderRepository {
|
||||
.where(and(eq(hosts.userId, userId), folderMatch(hosts.folder)));
|
||||
}
|
||||
|
||||
await this.context.drizzle
|
||||
const deletedFolders = await this.context.drizzle
|
||||
.delete(sshFolders)
|
||||
.where(and(eq(sshFolders.userId, userId), folderMatch(sshFolders.name)));
|
||||
.where(and(eq(sshFolders.userId, userId), folderMatch(sshFolders.name)))
|
||||
.returning({ syncId: sshFolders.syncId });
|
||||
|
||||
await this.afterWrite();
|
||||
|
||||
return {
|
||||
hostSyncIds: hostsToDelete
|
||||
.map((h) => h.syncId)
|
||||
.filter((id): id is string => !!id),
|
||||
folderSyncIds: deletedFolders
|
||||
.map((f) => f.syncId)
|
||||
.filter((id): id is string => !!id),
|
||||
};
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||
import { randomUUID } from "crypto";
|
||||
import { hostAccess, hosts } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||
@@ -22,7 +23,7 @@ export class HostRepository {
|
||||
async create(host: NewHostRecord): Promise<HostRecord> {
|
||||
const rows = await this.context.drizzle
|
||||
.insert(hosts)
|
||||
.values(host)
|
||||
.values({ syncId: randomUUID(), ...host })
|
||||
.returning();
|
||||
await this.afterWrite();
|
||||
return rows[0];
|
||||
@@ -34,7 +35,11 @@ export class HostRepository {
|
||||
): Promise<HostRecord> {
|
||||
const userDataKey = DataCrypto.validateUserAccess(userId);
|
||||
const tempId = host.id ?? Date.now();
|
||||
const dataWithTempId = { ...host, id: tempId };
|
||||
const dataWithTempId = {
|
||||
syncId: randomUUID(),
|
||||
...host,
|
||||
id: tempId,
|
||||
};
|
||||
const encryptedHost = DataCrypto.encryptRecord(
|
||||
"ssh_data",
|
||||
dataWithTempId,
|
||||
@@ -147,7 +152,7 @@ export class HostRepository {
|
||||
): Promise<HostRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.update(hosts)
|
||||
.set(update)
|
||||
.set({ ...update, updatedAt: sql`CURRENT_TIMESTAMP` })
|
||||
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)))
|
||||
.returning();
|
||||
|
||||
@@ -170,7 +175,7 @@ export class HostRepository {
|
||||
|
||||
const rows = await this.context.drizzle
|
||||
.update(hosts)
|
||||
.set(encryptedUpdate)
|
||||
.set({ ...encryptedUpdate, updatedAt: sql`CURRENT_TIMESTAMP` })
|
||||
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)))
|
||||
.returning();
|
||||
|
||||
@@ -210,7 +215,7 @@ export class HostRepository {
|
||||
|
||||
const rows = await this.context.drizzle
|
||||
.update(hosts)
|
||||
.set(update)
|
||||
.set({ ...update, updatedAt: sql`CURRENT_TIMESTAMP` })
|
||||
.where(and(inArray(hosts.id, hostIds), eq(hosts.userId, userId)))
|
||||
.returning({ id: hosts.id });
|
||||
|
||||
@@ -221,16 +226,19 @@ export class HostRepository {
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async deleteForUser(userId: string, hostId: number): Promise<boolean> {
|
||||
async deleteForUser(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
): Promise<{ syncId: string | null } | null> {
|
||||
await this.deleteAccessForHost(hostId);
|
||||
|
||||
const rows = await this.context.drizzle
|
||||
.delete(hosts)
|
||||
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)))
|
||||
.returning({ id: hosts.id });
|
||||
.returning({ syncId: hosts.syncId });
|
||||
|
||||
await this.afterWrite();
|
||||
return rows.length > 0;
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { and, eq, inArray, isNotNull } from "drizzle-orm";
|
||||
import { hostAccess, hosts, sshCredentials } from "../db/schema.js";
|
||||
import { hostAccess, hosts, sshCredentials, sshFolders } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||
|
||||
@@ -315,6 +315,34 @@ export class HostResolutionRepository {
|
||||
return rows[0]?.overrideCredentialId ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the nearest assigned credential for a folder path, walking up
|
||||
* through parent folders (e.g. "Switches / Floor1" falls back to
|
||||
* "Switches" if the child folder has no credential of its own).
|
||||
*/
|
||||
async findFolderCredentialId(
|
||||
userId: string,
|
||||
folderPath: string,
|
||||
): Promise<number | null> {
|
||||
const segments = folderPath.split(" / ").filter(Boolean);
|
||||
if (segments.length === 0) return null;
|
||||
|
||||
const paths = segments.map((_, i) => segments.slice(0, i + 1).join(" / "));
|
||||
const rows = await this.context.drizzle
|
||||
.select({ name: sshFolders.name, credentialId: sshFolders.credentialId })
|
||||
.from(sshFolders)
|
||||
.where(
|
||||
and(eq(sshFolders.userId, userId), inArray(sshFolders.name, paths)),
|
||||
);
|
||||
|
||||
const byName = new Map(rows.map((row) => [row.name, row.credentialId]));
|
||||
for (let i = paths.length - 1; i >= 0; i--) {
|
||||
const credentialId = byName.get(paths[i]);
|
||||
if (credentialId) return credentialId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private decryptOne<T extends Record<string, unknown>>(
|
||||
tableName: "ssh_data" | "ssh_credentials",
|
||||
record: T | undefined,
|
||||
|
||||
@@ -58,7 +58,12 @@ export class SessionRecordingRepository {
|
||||
|
||||
async updateEnded(
|
||||
id: number,
|
||||
input: { endedAt: string; duration: number | null },
|
||||
input: {
|
||||
endedAt: string;
|
||||
duration: number | null;
|
||||
terminatedByOwner?: boolean;
|
||||
terminationReason?: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.update(sessionRecordings)
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import { and, eq, gt, isNull, lt } from "drizzle-orm";
|
||||
import {
|
||||
hosts,
|
||||
sessionShareParticipants,
|
||||
sessionShares,
|
||||
users,
|
||||
} from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
|
||||
export type SessionShareRecord = typeof sessionShares.$inferSelect;
|
||||
export type SessionShareParticipantRecord =
|
||||
typeof sessionShareParticipants.$inferSelect;
|
||||
|
||||
export type SessionShareType = "link" | "user";
|
||||
export type SessionSharePermissionLevel = "read-only" | "read-write";
|
||||
|
||||
export interface SessionShareCreateInput {
|
||||
id: string;
|
||||
hostId: number;
|
||||
ownerUserId: string;
|
||||
protocol: string;
|
||||
sessionId: string;
|
||||
tabInstanceId?: string | null;
|
||||
shareType: SessionShareType;
|
||||
targetUserId?: string | null;
|
||||
linkToken?: string | null;
|
||||
permissionLevel: SessionSharePermissionLevel;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export interface SessionShareWithHost extends SessionShareRecord {
|
||||
hostName: string | null;
|
||||
ownerUsername: string | null;
|
||||
}
|
||||
|
||||
export interface SharedWithMeRecord extends SessionShareRecord {
|
||||
hostName: string | null;
|
||||
ownerUsername: string | null;
|
||||
}
|
||||
|
||||
function activeShareFilter(now: string) {
|
||||
return and(isNull(sessionShares.revokedAt), gt(sessionShares.expiresAt, now));
|
||||
}
|
||||
|
||||
export class SessionShareRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async create(input: SessionShareCreateInput): Promise<SessionShareRecord> {
|
||||
const [created] = await this.context.drizzle
|
||||
.insert(sessionShares)
|
||||
.values({
|
||||
id: input.id,
|
||||
hostId: input.hostId,
|
||||
ownerUserId: input.ownerUserId,
|
||||
protocol: input.protocol,
|
||||
sessionId: input.sessionId,
|
||||
tabInstanceId: input.tabInstanceId ?? null,
|
||||
shareType: input.shareType,
|
||||
targetUserId: input.targetUserId ?? null,
|
||||
linkToken: input.linkToken ?? null,
|
||||
permissionLevel: input.permissionLevel,
|
||||
expiresAt: input.expiresAt,
|
||||
})
|
||||
.returning();
|
||||
|
||||
await this.afterWrite();
|
||||
return created;
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<SessionShareRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(sessionShares)
|
||||
.where(eq(sessionShares.id, id))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async findActiveById(
|
||||
id: string,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<SessionShareRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(sessionShares)
|
||||
.where(and(eq(sessionShares.id, id), activeShareFilter(now)))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async findByLinkToken(
|
||||
linkToken: string,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<SessionShareRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(sessionShares)
|
||||
.where(
|
||||
and(eq(sessionShares.linkToken, linkToken), activeShareFilter(now)),
|
||||
)
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async findActiveSharesForHost(
|
||||
hostId: number,
|
||||
ownerUserId: string,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<SessionShareRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(sessionShares)
|
||||
.where(
|
||||
and(
|
||||
eq(sessionShares.hostId, hostId),
|
||||
eq(sessionShares.ownerUserId, ownerUserId),
|
||||
activeShareFilter(now),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async findSharesTargetingUser(
|
||||
userId: string,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<SharedWithMeRecord[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({
|
||||
share: sessionShares,
|
||||
hostName: hosts.name,
|
||||
ownerUsername: users.username,
|
||||
})
|
||||
.from(sessionShares)
|
||||
.leftJoin(hosts, eq(sessionShares.hostId, hosts.id))
|
||||
.leftJoin(users, eq(sessionShares.ownerUserId, users.id))
|
||||
.where(
|
||||
and(
|
||||
eq(sessionShares.shareType, "user"),
|
||||
eq(sessionShares.targetUserId, userId),
|
||||
activeShareFilter(now),
|
||||
),
|
||||
);
|
||||
|
||||
return rows.map((row) => ({
|
||||
...row.share,
|
||||
hostName: row.hostName,
|
||||
ownerUsername: row.ownerUsername,
|
||||
}));
|
||||
}
|
||||
|
||||
async revoke(shareId: string, requestingUserId: string): Promise<boolean> {
|
||||
const rows = await this.context.drizzle
|
||||
.update(sessionShares)
|
||||
.set({ revokedAt: new Date().toISOString() })
|
||||
.where(
|
||||
and(
|
||||
eq(sessionShares.id, shareId),
|
||||
eq(sessionShares.ownerUserId, requestingUserId),
|
||||
),
|
||||
)
|
||||
.returning({ id: sessionShares.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async revokeAsAdmin(shareId: string): Promise<boolean> {
|
||||
const rows = await this.context.drizzle
|
||||
.update(sessionShares)
|
||||
.set({ revokedAt: new Date().toISOString() })
|
||||
.where(eq(sessionShares.id, shareId))
|
||||
.returning({ id: sessionShares.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async deleteExpiredShares(now = new Date().toISOString()): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(sessionShares)
|
||||
.where(lt(sessionShares.expiresAt, now))
|
||||
.returning({ id: sessionShares.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async touchShareUsage(
|
||||
shareId: string,
|
||||
lastJoinedAt = new Date().toISOString(),
|
||||
): Promise<void> {
|
||||
const current = await this.findById(shareId);
|
||||
await this.context.drizzle
|
||||
.update(sessionShares)
|
||||
.set({
|
||||
lastJoinedAt,
|
||||
joinCount: (current?.joinCount ?? 0) + 1,
|
||||
})
|
||||
.where(eq(sessionShares.id, shareId));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async recordParticipantJoin(
|
||||
shareId: string,
|
||||
userId: string | null,
|
||||
guestLabel: string | null,
|
||||
): Promise<SessionShareParticipantRecord> {
|
||||
const [created] = await this.context.drizzle
|
||||
.insert(sessionShareParticipants)
|
||||
.values({ shareId, userId, guestLabel })
|
||||
.returning();
|
||||
await this.afterWrite();
|
||||
return created;
|
||||
}
|
||||
|
||||
async recordParticipantLeave(participantId: number): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.update(sessionShareParticipants)
|
||||
.set({ leftAt: new Date().toISOString() })
|
||||
.where(eq(sessionShareParticipants.id, participantId));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async deleteSharesForHost(hostId: number): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(sessionShares)
|
||||
.where(eq(sessionShares.hostId, hostId))
|
||||
.returning({ id: sessionShares.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { and, asc, eq, sql } from "drizzle-orm";
|
||||
import { randomUUID } from "crypto";
|
||||
import { snippetFolders, snippets } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
|
||||
@@ -151,6 +152,7 @@ export class SnippetRepository {
|
||||
const rows = await this.context.drizzle
|
||||
.insert(snippets)
|
||||
.values({
|
||||
syncId: randomUUID(),
|
||||
userId,
|
||||
name: input.name.trim(),
|
||||
content: input.content.trim(),
|
||||
@@ -343,6 +345,7 @@ export class SnippetRepository {
|
||||
|
||||
const maxOrder = await this.maxOrderForFolder(userId, folderVal);
|
||||
await this.context.drizzle.insert(snippets).values({
|
||||
syncId: randomUUID(),
|
||||
userId,
|
||||
name: snippet.name.trim(),
|
||||
content: snippet.content.trim(),
|
||||
@@ -377,6 +380,7 @@ export class SnippetRepository {
|
||||
const rows = await this.context.drizzle
|
||||
.insert(snippetFolders)
|
||||
.values({
|
||||
syncId: randomUUID(),
|
||||
userId,
|
||||
name: name.trim(),
|
||||
color: color?.trim() || null,
|
||||
@@ -452,19 +456,24 @@ export class SnippetRepository {
|
||||
return { status: "renamed" };
|
||||
}
|
||||
|
||||
async deleteFolder(userId: string, name: string): Promise<void> {
|
||||
async deleteFolder(
|
||||
userId: string,
|
||||
name: string,
|
||||
): Promise<{ syncId: string | null } | null> {
|
||||
await this.context.drizzle
|
||||
.update(snippets)
|
||||
.set({ folder: null })
|
||||
.where(and(eq(snippets.userId, userId), eq(snippets.folder, name)));
|
||||
|
||||
await this.context.drizzle
|
||||
const rows = await this.context.drizzle
|
||||
.delete(snippetFolders)
|
||||
.where(
|
||||
and(eq(snippetFolders.userId, userId), eq(snippetFolders.name, name)),
|
||||
);
|
||||
)
|
||||
.returning({ syncId: snippetFolders.syncId });
|
||||
|
||||
await this.afterWrite();
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
private async findFolderByName(
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { and, eq, gt } from "drizzle-orm";
|
||||
import { syncTombstones } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
|
||||
export type SyncTombstoneRecord = typeof syncTombstones.$inferSelect;
|
||||
|
||||
export type SyncEntityType =
|
||||
| "hosts"
|
||||
| "sshCredentials"
|
||||
| "sshFolders"
|
||||
| "snippets"
|
||||
| "snippetFolders"
|
||||
| "vaultProfiles"
|
||||
| "dashboardServiceLinks"
|
||||
| "homepageItems";
|
||||
|
||||
export class SyncTombstoneRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async record(
|
||||
userId: string,
|
||||
entityType: SyncEntityType,
|
||||
syncId: string,
|
||||
): Promise<void> {
|
||||
if (!syncId) return;
|
||||
await this.context.drizzle.insert(syncTombstones).values({
|
||||
userId,
|
||||
entityType,
|
||||
syncId,
|
||||
});
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async recordMany(
|
||||
userId: string,
|
||||
entityType: SyncEntityType,
|
||||
syncIds: string[],
|
||||
): Promise<void> {
|
||||
const rows = syncIds.filter(Boolean);
|
||||
if (rows.length === 0) return;
|
||||
await this.context.drizzle
|
||||
.insert(syncTombstones)
|
||||
.values(rows.map((syncId) => ({ userId, entityType, syncId })));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async listSince(
|
||||
userId: string,
|
||||
entityType: SyncEntityType,
|
||||
since: string | null,
|
||||
): Promise<SyncTombstoneRecord[]> {
|
||||
const conditions = [
|
||||
eq(syncTombstones.userId, userId),
|
||||
eq(syncTombstones.entityType, entityType),
|
||||
];
|
||||
if (since) conditions.push(gt(syncTombstones.deletedAt, since));
|
||||
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(syncTombstones)
|
||||
.where(and(...conditions));
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { desc, eq, or } from "drizzle-orm";
|
||||
import { randomUUID } from "crypto";
|
||||
import { vaultProfiles } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
|
||||
@@ -47,6 +48,7 @@ export class VaultProfileRepository {
|
||||
const [created] = await this.context.drizzle
|
||||
.insert(vaultProfiles)
|
||||
.values({
|
||||
syncId: randomUUID(),
|
||||
userId: input.userId,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
@@ -98,17 +100,15 @@ export class VaultProfileRepository {
|
||||
return updated ?? null;
|
||||
}
|
||||
|
||||
async deleteById(id: number): Promise<boolean> {
|
||||
async deleteById(id: number): Promise<{ syncId: string | null } | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(vaultProfiles)
|
||||
.where(eq(vaultProfiles.id, id))
|
||||
.returning({ id: vaultProfiles.id });
|
||||
.returning({ syncId: vaultProfiles.syncId });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length > 0;
|
||||
if (rows.length === 0) return null;
|
||||
await this.afterWrite();
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
|
||||
@@ -28,7 +28,7 @@ export type AcmeSettings = {
|
||||
enabled: boolean;
|
||||
domain: string;
|
||||
email: string;
|
||||
challengeType: "http-webroot" | "dns-cloudflare";
|
||||
challengeType: "http-webroot" | "dns-cloudflare" | "manual";
|
||||
cloudflareToken: string;
|
||||
lastIssuedAt: string | null;
|
||||
certStatus: "none" | "valid" | "expiring" | "expired";
|
||||
@@ -166,7 +166,7 @@ export function registerAcmeSSLRoutes(
|
||||
* type: string
|
||||
* challengeType:
|
||||
* type: string
|
||||
* enum: [http-webroot, dns-cloudflare]
|
||||
* enum: [http-webroot, dns-cloudflare, manual]
|
||||
* cloudflareToken:
|
||||
* type: string
|
||||
* responses:
|
||||
@@ -414,4 +414,159 @@ export function registerAcmeSSLRoutes(
|
||||
res.status(500).json({ error: `Certificate request failed: ${message}` });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/manual-ssl-upload:
|
||||
* post:
|
||||
* summary: Upload a manual/custom SSL certificate and key (admin only)
|
||||
* description: Validates and installs a user-supplied PEM certificate and private key as the active Termix SSL certificate.
|
||||
* tags:
|
||||
* - Users
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* certificate:
|
||||
* type: string
|
||||
* privateKey:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Certificate uploaded and installed successfully.
|
||||
* 400:
|
||||
* description: Invalid or missing certificate/key.
|
||||
* 403:
|
||||
* description: Not authorized.
|
||||
* 500:
|
||||
* description: Certificate installation failed.
|
||||
*/
|
||||
router.post("/manual-ssl-upload", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const actor = await getAdminActor(userId);
|
||||
try {
|
||||
if (!actor) {
|
||||
return res.status(403).json({ error: "Not authorized" });
|
||||
}
|
||||
|
||||
const { certificate, privateKey } = req.body;
|
||||
|
||||
if (
|
||||
typeof certificate !== "string" ||
|
||||
typeof privateKey !== "string" ||
|
||||
!certificate.includes("BEGIN CERTIFICATE") ||
|
||||
!privateKey.includes("PRIVATE KEY")
|
||||
) {
|
||||
return res.status(400).json({
|
||||
error: "A valid PEM certificate and private key are required",
|
||||
});
|
||||
}
|
||||
|
||||
await fs.mkdir(SSL_DIR, { recursive: true });
|
||||
|
||||
const tmpCertFile = path.join(SSL_DIR, ".manual-upload.crt.tmp");
|
||||
const tmpKeyFile = path.join(SSL_DIR, ".manual-upload.key.tmp");
|
||||
|
||||
try {
|
||||
await fs.writeFile(tmpCertFile, certificate, { mode: 0o644 });
|
||||
await fs.writeFile(tmpKeyFile, privateKey, { mode: 0o600 });
|
||||
|
||||
try {
|
||||
execFileSync("openssl", ["x509", "-in", tmpCertFile, "-noout"], {
|
||||
stdio: "pipe",
|
||||
});
|
||||
execFileSync(
|
||||
"openssl",
|
||||
["pkey", "-in", tmpKeyFile, "-noout", "-check"],
|
||||
{ stdio: "pipe" },
|
||||
);
|
||||
} catch {
|
||||
return res.status(400).json({
|
||||
error:
|
||||
"The provided certificate or private key is not valid PEM data",
|
||||
});
|
||||
}
|
||||
|
||||
const certPubkey = execFileSync(
|
||||
"openssl",
|
||||
["x509", "-in", tmpCertFile, "-noout", "-pubkey"],
|
||||
{ stdio: "pipe" },
|
||||
);
|
||||
const keyPubkey = execFileSync(
|
||||
"openssl",
|
||||
["pkey", "-in", tmpKeyFile, "-pubout"],
|
||||
{ stdio: "pipe" },
|
||||
);
|
||||
|
||||
if (!certPubkey.equals(keyPubkey)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "The certificate and private key do not match" });
|
||||
}
|
||||
|
||||
const certDest = path.join(SSL_DIR, "termix.crt");
|
||||
const keyDest = path.join(SSL_DIR, "termix.key");
|
||||
await fs.rename(tmpCertFile, certDest);
|
||||
await fs.rename(tmpKeyFile, keyDest);
|
||||
await fs.chmod(keyDest, 0o600);
|
||||
await fs.chmod(certDest, 0o644);
|
||||
} finally {
|
||||
await fs.rm(tmpCertFile, { force: true });
|
||||
await fs.rm(tmpKeyFile, { force: true });
|
||||
}
|
||||
|
||||
const settingsRepository = createCurrentSettingsRepository();
|
||||
const existing = await settingsRepository.get("acme_ssl_settings");
|
||||
const current = existing ? JSON.parse(existing) : {};
|
||||
const updated = {
|
||||
...current,
|
||||
challengeType: "manual",
|
||||
lastIssuedAt: new Date().toISOString(),
|
||||
};
|
||||
await settingsRepository.set(
|
||||
"acme_ssl_settings",
|
||||
JSON.stringify(updated),
|
||||
);
|
||||
|
||||
authLogger.info("Manual SSL certificate installed", {
|
||||
operation: "manual_ssl_installed",
|
||||
});
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: actor.username ?? userId,
|
||||
action: "manual_ssl_upload",
|
||||
resourceType: "setting",
|
||||
details: JSON.stringify({ success: true }),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
|
||||
res.json({ success: true, ...(await getAcmeSettings()) });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
authLogger.error("Manual SSL certificate upload failed", err);
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: actor?.username ?? userId,
|
||||
action: "manual_ssl_upload",
|
||||
resourceType: "setting",
|
||||
details: JSON.stringify({ error: message }),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: false,
|
||||
});
|
||||
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: `Certificate installation failed: ${message}` });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
createCurrentHostResolutionRepository,
|
||||
createCurrentHostRepository,
|
||||
createCurrentUserRepository,
|
||||
createCurrentSyncTombstoneRepository,
|
||||
} from "../repositories/factory.js";
|
||||
|
||||
const router = express.Router();
|
||||
@@ -642,6 +643,13 @@ router.delete(
|
||||
userId,
|
||||
credentialId,
|
||||
);
|
||||
if (credentialToDelete.syncId) {
|
||||
await createCurrentSyncTombstoneRepository().record(
|
||||
userId,
|
||||
"sshCredentials",
|
||||
credentialToDelete.syncId,
|
||||
);
|
||||
}
|
||||
|
||||
// Shares stay in place; re-snapshot so recipients fall back to whatever
|
||||
// auth the host still has (or lose the stale credential copy).
|
||||
|
||||
@@ -4,7 +4,10 @@ import { dashboardLogger } from "../../utils/logger.js";
|
||||
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
|
||||
import { isNonEmptyString } from "./host-normalizers.js";
|
||||
import express from "express";
|
||||
import { createCurrentDashboardServiceLinkRepository } from "../repositories/factory.js";
|
||||
import {
|
||||
createCurrentDashboardServiceLinkRepository,
|
||||
createCurrentSyncTombstoneRepository,
|
||||
} from "../repositories/factory.js";
|
||||
|
||||
export const dashboardServiceLinksRouter = express.Router();
|
||||
|
||||
@@ -152,10 +155,18 @@ dashboardServiceLinksRouter.delete(
|
||||
return res.status(404).json({ error: "Not found" });
|
||||
}
|
||||
|
||||
await createCurrentDashboardServiceLinkRepository().deleteForUser(
|
||||
userId,
|
||||
id,
|
||||
);
|
||||
const deleted =
|
||||
await createCurrentDashboardServiceLinkRepository().deleteForUser(
|
||||
userId,
|
||||
id,
|
||||
);
|
||||
if (deleted?.syncId) {
|
||||
await createCurrentSyncTombstoneRepository().record(
|
||||
userId,
|
||||
"dashboardServiceLinks",
|
||||
deleted.syncId,
|
||||
);
|
||||
}
|
||||
|
||||
DatabaseSaveTrigger.triggerSave("dashboard_service_link_deleted");
|
||||
res.json({ message: "Service link deleted" });
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { Request } from "express";
|
||||
import type { UserRecord } from "../repositories/user-repository.js";
|
||||
|
||||
export function isLoopbackRequest(req: Request): boolean {
|
||||
const ip = req.ip || req.socket?.remoteAddress || "";
|
||||
return (
|
||||
ip === "127.0.0.1" ||
|
||||
ip === "::1" ||
|
||||
ip === "::ffff:127.0.0.1" ||
|
||||
ip.endsWith(":127.0.0.1")
|
||||
);
|
||||
}
|
||||
|
||||
export function extractBearerOrCookieToken(req: Request): string | undefined {
|
||||
const cookieToken = (req as Request & { cookies?: Record<string, string> })
|
||||
.cookies?.jwt;
|
||||
if (cookieToken) return cookieToken;
|
||||
|
||||
const authHeader = req.headers["authorization"];
|
||||
if (authHeader?.startsWith("Bearer ")) {
|
||||
return authHeader.slice("Bearer ".length);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decides who the desktop auto-session endpoint should silently log in as.
|
||||
*
|
||||
* The local embedded backend's trust boundary is machine access (loopback),
|
||||
* not any individual account's credentials -- anyone who can reach loopback
|
||||
* already has full filesystem access to the local, encrypted-at-rest
|
||||
* database and its keys. A login form must never appear for the local
|
||||
* backend, under any circumstance, including a local database that ended
|
||||
* up with more than one user (e.g. from repeated manual registration
|
||||
* during testing, or a household sharing one install) -- a user in that
|
||||
* state deserves to get into the app they installed, not a confusing,
|
||||
* unexplained dead end. So this always returns a single, deterministic
|
||||
* user: the admin account if one exists, else the earliest-registered
|
||||
* account. It never returns null.
|
||||
*/
|
||||
export function resolveDesktopAutoSessionUser(
|
||||
allUsers: UserRecord[],
|
||||
): UserRecord | null {
|
||||
if (allUsers.length === 0) return null;
|
||||
if (allUsers.length === 1) return allUsers[0];
|
||||
|
||||
const admin = allUsers.find((user) => user.isAdmin);
|
||||
if (admin) return admin;
|
||||
|
||||
return [...allUsers].sort(
|
||||
(a, b) =>
|
||||
new Date(a.registeredAt).getTime() - new Date(b.registeredAt).getTime(),
|
||||
)[0];
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import type { Request, Response } from "express";
|
||||
import { homepageLogger } from "../../utils/logger.js";
|
||||
import { createCurrentHomepageItemRepository } from "../repositories/factory.js";
|
||||
import {
|
||||
createCurrentHomepageItemRepository,
|
||||
createCurrentSyncTombstoneRepository,
|
||||
} from "../repositories/factory.js";
|
||||
import express from "express";
|
||||
|
||||
export const homepageItemsRouter = express.Router();
|
||||
@@ -184,7 +187,14 @@ homepageItemsRouter.delete("/:id", async (req: Request, res: Response) => {
|
||||
return res.status(404).json({ error: "Not found" });
|
||||
}
|
||||
|
||||
await itemRepository.deleteForUser(userId, id);
|
||||
const deleted = await itemRepository.deleteForUser(userId, id);
|
||||
if (deleted?.syncId) {
|
||||
await createCurrentSyncTombstoneRepository().record(
|
||||
userId,
|
||||
"homepageItems",
|
||||
deleted.syncId,
|
||||
);
|
||||
}
|
||||
res.json({ message: "Homepage item deleted" });
|
||||
} catch (err) {
|
||||
homepageLogger.error("Failed to delete homepage item", err);
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import { databaseLogger, sshLogger } from "../../utils/logger.js";
|
||||
import {
|
||||
createCurrentCommandHistoryRepository,
|
||||
createCurrentCredentialRepository,
|
||||
createCurrentFileManagerBookmarkRepository,
|
||||
createCurrentHostFolderRepository,
|
||||
createCurrentRecentActivityRepository,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
createCurrentSshCredentialUsageRepository,
|
||||
createCurrentSessionRecordingRepository,
|
||||
createCurrentTransferRecentRepository,
|
||||
createCurrentSyncTombstoneRepository,
|
||||
} from "../repositories/factory.js";
|
||||
import { isNonEmptyString } from "./host-normalizers.js";
|
||||
|
||||
@@ -138,7 +140,7 @@ export function registerHostFolderRoutes(
|
||||
* /host/folders/metadata:
|
||||
* put:
|
||||
* summary: Update folder metadata
|
||||
* description: Updates the metadata (color, icon) of a folder.
|
||||
* description: Updates the metadata (color, icon, assigned credential) of a folder.
|
||||
* tags:
|
||||
* - SSH
|
||||
* requestBody:
|
||||
@@ -154,6 +156,9 @@ export function registerHostFolderRoutes(
|
||||
* type: string
|
||||
* icon:
|
||||
* type: string
|
||||
* credentialId:
|
||||
* type: integer
|
||||
* nullable: true
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Folder metadata updated successfully.
|
||||
@@ -167,19 +172,46 @@ export function registerHostFolderRoutes(
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { name, color, icon } = req.body;
|
||||
const { name, color, icon, credentialId } = req.body;
|
||||
|
||||
if (!isNonEmptyString(userId) || !name) {
|
||||
return res.status(400).json({ error: "Folder name is required" });
|
||||
}
|
||||
|
||||
const normalizedCredentialId =
|
||||
credentialId === undefined
|
||||
? undefined
|
||||
: credentialId === null || credentialId === ""
|
||||
? null
|
||||
: Number(credentialId);
|
||||
|
||||
if (
|
||||
normalizedCredentialId !== undefined &&
|
||||
normalizedCredentialId !== null &&
|
||||
!Number.isInteger(normalizedCredentialId)
|
||||
) {
|
||||
return res.status(400).json({ error: "Invalid credential ID" });
|
||||
}
|
||||
|
||||
try {
|
||||
if (normalizedCredentialId) {
|
||||
const credential =
|
||||
await createCurrentCredentialRepository().findByIdForUser(
|
||||
userId,
|
||||
normalizedCredentialId,
|
||||
);
|
||||
if (!credential) {
|
||||
return res.status(404).json({ error: "Credential not found" });
|
||||
}
|
||||
}
|
||||
|
||||
const { folder, created } =
|
||||
await createCurrentHostFolderRepository().upsertMetadata(
|
||||
userId,
|
||||
name,
|
||||
color,
|
||||
icon,
|
||||
normalizedCredentialId,
|
||||
);
|
||||
|
||||
if (!created) {
|
||||
@@ -287,9 +319,17 @@ export function registerHostFolderRoutes(
|
||||
);
|
||||
}
|
||||
|
||||
await hostFolderRepository.deleteHostsAndFolderRecords(
|
||||
const { hostSyncIds, folderSyncIds } =
|
||||
await hostFolderRepository.deleteHostsAndFolderRecords(
|
||||
userId,
|
||||
folderName,
|
||||
);
|
||||
const tombstoneRepository = createCurrentSyncTombstoneRepository();
|
||||
await tombstoneRepository.recordMany(userId, "hosts", hostSyncIds);
|
||||
await tombstoneRepository.recordMany(
|
||||
userId,
|
||||
folderName,
|
||||
"sshFolders",
|
||||
folderSyncIds,
|
||||
);
|
||||
|
||||
try {
|
||||
|
||||
@@ -225,6 +225,9 @@ export function stripSensitiveFields(
|
||||
result.hasKeyPassword = !!host.keyPassword;
|
||||
result.hasPassword = !!host.password;
|
||||
result.hasSudoPassword = !!host.sudoPassword;
|
||||
result.hasRdpPassword = !!host.rdpPassword;
|
||||
result.hasVncPassword = !!host.vncPassword;
|
||||
result.hasTelnetPassword = !!host.telnetPassword;
|
||||
for (const field of SENSITIVE_FIELDS) {
|
||||
delete result[field];
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "../../hosts/credential-username.js";
|
||||
import {
|
||||
createCurrentCommandHistoryRepository,
|
||||
createCurrentCredentialRepository,
|
||||
createCurrentFileManagerBookmarkRepository,
|
||||
createCurrentOpksshTokenRepository,
|
||||
createCurrentRecentActivityRepository,
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
createCurrentHostResolutionRepository,
|
||||
createCurrentHostRepository,
|
||||
createCurrentUserRepository,
|
||||
createCurrentSyncTombstoneRepository,
|
||||
} from "../repositories/factory.js";
|
||||
import {
|
||||
isNonEmptyString,
|
||||
@@ -174,6 +176,7 @@ router.post(
|
||||
enableDocker,
|
||||
enableProxmox,
|
||||
enableTmuxMonitor,
|
||||
allowSessionSharing,
|
||||
showTerminalInSidebar,
|
||||
showFileManagerInSidebar,
|
||||
showTunnelInSidebar,
|
||||
@@ -199,6 +202,7 @@ router.post(
|
||||
socks5Username,
|
||||
socks5Password,
|
||||
socks5ProxyChain,
|
||||
connectionOrigin,
|
||||
portKnockSequence,
|
||||
overrideCredentialUsername,
|
||||
macAddress,
|
||||
@@ -287,6 +291,7 @@ router.post(
|
||||
enableDocker: enableDocker ? 1 : 0,
|
||||
enableProxmox: enableProxmox ? 1 : 0,
|
||||
enableTmuxMonitor: enableTmuxMonitor ? 1 : 0,
|
||||
allowSessionSharing: allowSessionSharing === false ? 0 : 1,
|
||||
showTerminalInSidebar: showTerminalInSidebar ? 1 : 0,
|
||||
showFileManagerInSidebar: showFileManagerInSidebar ? 1 : 0,
|
||||
showTunnelInSidebar: showTunnelInSidebar ? 1 : 0,
|
||||
@@ -328,6 +333,10 @@ router.post(
|
||||
socks5ProxyChain: socks5ProxyChain
|
||||
? JSON.stringify(socks5ProxyChain)
|
||||
: null,
|
||||
connectionOrigin:
|
||||
connectionOrigin === "local" || connectionOrigin === "remote"
|
||||
? connectionOrigin
|
||||
: null,
|
||||
macAddress: macAddress || null,
|
||||
wolBroadcastAddress: wolBroadcastAddress || null,
|
||||
portKnockSequence: portKnockSequence
|
||||
@@ -814,6 +823,7 @@ router.put(
|
||||
enableDocker,
|
||||
enableProxmox,
|
||||
enableTmuxMonitor,
|
||||
allowSessionSharing,
|
||||
showTerminalInSidebar,
|
||||
showFileManagerInSidebar,
|
||||
showTunnelInSidebar,
|
||||
@@ -839,6 +849,7 @@ router.put(
|
||||
socks5Username,
|
||||
socks5Password,
|
||||
socks5ProxyChain,
|
||||
connectionOrigin,
|
||||
portKnockSequence,
|
||||
overrideCredentialUsername,
|
||||
macAddress,
|
||||
@@ -924,6 +935,7 @@ router.put(
|
||||
enableDocker: enableDocker ? 1 : 0,
|
||||
enableProxmox: enableProxmox ? 1 : 0,
|
||||
enableTmuxMonitor: enableTmuxMonitor ? 1 : 0,
|
||||
allowSessionSharing: allowSessionSharing === false ? 0 : 1,
|
||||
showTerminalInSidebar: showTerminalInSidebar ? 1 : 0,
|
||||
showFileManagerInSidebar: showFileManagerInSidebar ? 1 : 0,
|
||||
showTunnelInSidebar: showTunnelInSidebar ? 1 : 0,
|
||||
@@ -965,6 +977,10 @@ router.put(
|
||||
socks5ProxyChain: socks5ProxyChain
|
||||
? JSON.stringify(socks5ProxyChain)
|
||||
: null,
|
||||
connectionOrigin:
|
||||
connectionOrigin === "local" || connectionOrigin === "remote"
|
||||
? connectionOrigin
|
||||
: null,
|
||||
macAddress: macAddress || null,
|
||||
wolBroadcastAddress: wolBroadcastAddress || null,
|
||||
portKnockSequence: portKnockSequence
|
||||
@@ -1482,7 +1498,7 @@ router.get(
|
||||
* name: field
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [password, sudoPassword, vncPassword]
|
||||
* enum: [password, sudoPassword, rdpPassword, vncPassword, telnetPassword, key, keyPassword]
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The requested password value.
|
||||
@@ -1498,7 +1514,17 @@ router.get(
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const field = (req.query.field as string) || "password";
|
||||
|
||||
if (!["password", "sudoPassword", "vncPassword"].includes(field)) {
|
||||
if (
|
||||
![
|
||||
"password",
|
||||
"sudoPassword",
|
||||
"rdpPassword",
|
||||
"vncPassword",
|
||||
"telnetPassword",
|
||||
"key",
|
||||
"keyPassword",
|
||||
].includes(field)
|
||||
) {
|
||||
return res.status(400).json({ error: "Invalid field" });
|
||||
}
|
||||
|
||||
@@ -1726,9 +1752,16 @@ router.get(
|
||||
* /host/db/hosts/export:
|
||||
* get:
|
||||
* summary: Export all SSH hosts
|
||||
* description: Exports all SSH hosts for the current user with decrypted credentials.
|
||||
* description: Exports all SSH hosts for the current user. By default credentials are decrypted and embedded. With `share=1`, secrets are omitted and credential-authenticated hosts instead reference a scrubbed `credentials` array by alias, suitable for handing off to another user.
|
||||
* tags:
|
||||
* - SSH
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: share
|
||||
* required: false
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Set to "1" to export without embedded secrets.
|
||||
* responses:
|
||||
* 200:
|
||||
* description: All exported SSH hosts.
|
||||
@@ -1743,6 +1776,7 @@ router.get(
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const shareMode = req.query.share === "1" || req.query.share === "true";
|
||||
|
||||
if (!isNonEmptyString(userId)) {
|
||||
return res.status(400).json({ error: "Invalid userId" });
|
||||
@@ -1753,10 +1787,12 @@ router.get(
|
||||
await createCurrentHostResolutionRepository().findHostsByUserId(userId);
|
||||
|
||||
const exportedHosts = [];
|
||||
const usedCredentialIds = new Set<number>();
|
||||
|
||||
for (const host of allHosts) {
|
||||
const resolvedHost =
|
||||
(await resolveHostCredentials(host, userId)) || host;
|
||||
const resolvedHost = shareMode
|
||||
? host
|
||||
: (await resolveHostCredentials(host, userId)) || host;
|
||||
|
||||
const exportedConnectionType =
|
||||
(resolvedHost.connectionType as string) || "ssh";
|
||||
@@ -1770,7 +1806,7 @@ router.get(
|
||||
ip: resolvedHost.ip,
|
||||
port: resolvedHost.port,
|
||||
username: resolvedHost.username,
|
||||
password: resolvedHost.password || null,
|
||||
password: shareMode ? null : resolvedHost.password || null,
|
||||
folder: resolvedHost.folder,
|
||||
tags:
|
||||
typeof resolvedHost.tags === "string"
|
||||
@@ -1793,8 +1829,8 @@ router.get(
|
||||
: {
|
||||
...baseExportData,
|
||||
authType: resolvedHost.authType,
|
||||
key: resolvedHost.key || null,
|
||||
keyPassword: resolvedHost.keyPassword || null,
|
||||
key: shareMode ? null : resolvedHost.key || null,
|
||||
keyPassword: shareMode ? null : resolvedHost.keyPassword || null,
|
||||
keyType: resolvedHost.keyType || null,
|
||||
credentialId: resolvedHost.credentialId || null,
|
||||
overrideCredentialUsername:
|
||||
@@ -1811,7 +1847,9 @@ router.get(
|
||||
showDockerInSidebar: !!resolvedHost.showDockerInSidebar,
|
||||
showServerStatsInSidebar: !!resolvedHost.showServerStatsInSidebar,
|
||||
defaultPath: resolvedHost.defaultPath,
|
||||
sudoPassword: resolvedHost.sudoPassword || null,
|
||||
sudoPassword: shareMode
|
||||
? null
|
||||
: resolvedHost.sudoPassword || null,
|
||||
tunnelConnections: resolvedHost.tunnelConnections
|
||||
? JSON.parse(resolvedHost.tunnelConnections as string)
|
||||
: [],
|
||||
@@ -1839,22 +1877,92 @@ router.get(
|
||||
socks5Host: resolvedHost.socks5Host || null,
|
||||
socks5Port: resolvedHost.socks5Port || null,
|
||||
socks5Username: resolvedHost.socks5Username || null,
|
||||
socks5Password: resolvedHost.socks5Password || null,
|
||||
socks5Password: shareMode
|
||||
? null
|
||||
: resolvedHost.socks5Password || null,
|
||||
socks5ProxyChain: resolvedHost.socks5ProxyChain
|
||||
? JSON.parse(resolvedHost.socks5ProxyChain as string)
|
||||
: null,
|
||||
};
|
||||
|
||||
if (
|
||||
shareMode &&
|
||||
!isRemoteDesktop &&
|
||||
resolvedHost.authType === "credential" &&
|
||||
resolvedHost.credentialId
|
||||
) {
|
||||
usedCredentialIds.add(resolvedHost.credentialId as number);
|
||||
}
|
||||
|
||||
exportedHosts.push(exportData);
|
||||
}
|
||||
|
||||
sshLogger.success("All hosts exported with decrypted credentials", {
|
||||
operation: "hosts_export_all",
|
||||
if (!shareMode) {
|
||||
sshLogger.success("All hosts exported with decrypted credentials", {
|
||||
operation: "hosts_export_all",
|
||||
count: exportedHosts.length,
|
||||
userId,
|
||||
});
|
||||
|
||||
return res.json({ hosts: exportedHosts });
|
||||
}
|
||||
|
||||
const exportedCredentials: Record<string, unknown>[] = [];
|
||||
if (usedCredentialIds.size > 0) {
|
||||
const credentialRepository = createCurrentCredentialRepository();
|
||||
const ownedCredentials =
|
||||
await credentialRepository.listDecryptedByUserId(userId);
|
||||
const credentialById = new Map(
|
||||
ownedCredentials.map((credential) => [credential.id, credential]),
|
||||
);
|
||||
|
||||
for (const host of exportedHosts as Record<string, unknown>[]) {
|
||||
const credentialId = host.credentialId as number | null;
|
||||
if (!credentialId) continue;
|
||||
const credential = credentialById.get(credentialId);
|
||||
if (!credential) continue;
|
||||
|
||||
host.credentialAlias = credential.name;
|
||||
|
||||
if (
|
||||
!exportedCredentials.some(
|
||||
(entry) => entry.alias === credential.name,
|
||||
)
|
||||
) {
|
||||
exportedCredentials.push({
|
||||
alias: credential.name,
|
||||
name: credential.name,
|
||||
description: credential.description || null,
|
||||
folder: credential.folder || null,
|
||||
tags:
|
||||
typeof credential.tags === "string"
|
||||
? credential.tags.split(",").filter(Boolean)
|
||||
: [],
|
||||
authType: credential.authType,
|
||||
username: credential.username || null,
|
||||
keyType: credential.keyType || null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const host of exportedHosts as Record<string, unknown>[]) {
|
||||
delete host.credentialId;
|
||||
}
|
||||
|
||||
sshLogger.success("All hosts exported for sharing without secrets", {
|
||||
operation: "hosts_export_all_share",
|
||||
count: exportedHosts.length,
|
||||
credentialCount: exportedCredentials.length,
|
||||
userId,
|
||||
});
|
||||
|
||||
res.json({ hosts: exportedHosts });
|
||||
res.json({
|
||||
version: "1",
|
||||
exportedAt: new Date().toISOString(),
|
||||
credentials: exportedCredentials,
|
||||
hosts: exportedHosts,
|
||||
});
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to export all SSH hosts", err, {
|
||||
operation: "hosts_export_all",
|
||||
@@ -1959,6 +2067,13 @@ router.delete(
|
||||
);
|
||||
|
||||
await createCurrentHostRepository().deleteForUser(userId, numericHostId);
|
||||
if (hostToDelete.syncId) {
|
||||
await createCurrentSyncTombstoneRepository().record(
|
||||
userId,
|
||||
"hosts",
|
||||
hostToDelete.syncId,
|
||||
);
|
||||
}
|
||||
|
||||
databaseLogger.success("SSH host deleted", {
|
||||
operation: "host_delete_success",
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
const VALID_ACTION_TYPES = [
|
||||
"copy",
|
||||
"paste",
|
||||
"sendControlCode",
|
||||
"sendText",
|
||||
"runSnippet",
|
||||
];
|
||||
|
||||
export function isValidKeyCombo(combo: unknown): boolean {
|
||||
return (
|
||||
!!combo &&
|
||||
typeof combo === "object" &&
|
||||
typeof (combo as { key?: unknown }).key === "string" &&
|
||||
typeof (combo as { isCode?: unknown }).isCode === "boolean" &&
|
||||
typeof (combo as { ctrl?: unknown }).ctrl === "boolean" &&
|
||||
typeof (combo as { alt?: unknown }).alt === "boolean" &&
|
||||
typeof (combo as { shift?: unknown }).shift === "boolean" &&
|
||||
typeof (combo as { meta?: unknown }).meta === "boolean"
|
||||
);
|
||||
}
|
||||
|
||||
export function isValidKeybindingAction(action: unknown): boolean {
|
||||
if (!action || typeof action !== "object") return false;
|
||||
const type = (action as { type?: unknown }).type;
|
||||
if (typeof type !== "string" || !VALID_ACTION_TYPES.includes(type))
|
||||
return false;
|
||||
if (type === "sendText") {
|
||||
return typeof (action as { text?: unknown }).text === "string";
|
||||
}
|
||||
if (type === "sendControlCode") {
|
||||
const code = (action as { controlCode?: unknown }).controlCode;
|
||||
return typeof code === "string" && /^[a-zA-Z]$/.test(code);
|
||||
}
|
||||
if (type === "runSnippet") {
|
||||
return typeof (action as { snippetId?: unknown }).snippetId === "string";
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function isValidKeybinding(entry: unknown): boolean {
|
||||
return (
|
||||
!!entry &&
|
||||
typeof entry === "object" &&
|
||||
typeof (entry as { id?: unknown }).id === "string" &&
|
||||
typeof (entry as { enabled?: unknown }).enabled === "boolean" &&
|
||||
isValidKeyCombo((entry as { combo?: unknown }).combo) &&
|
||||
isValidKeybindingAction((entry as { action?: unknown }).action)
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { sessionManager } from "../../hosts/terminal/session-manager.js";
|
||||
import {
|
||||
getCurrentSettingValue,
|
||||
createCurrentOpenTabRepository,
|
||||
createCurrentSessionShareRepository,
|
||||
} from "../repositories/factory.js";
|
||||
|
||||
const router = express.Router();
|
||||
@@ -277,12 +278,15 @@ router.delete("/:id", authenticateJWT, async (req: Request, res: Response) => {
|
||||
* /open-tabs/active-sessions:
|
||||
* get:
|
||||
* summary: Get all active backend sessions for the current user
|
||||
* description: Returns live terminal sessions from the session manager. Used by the Active Connections panel and tab restore logic.
|
||||
* description: >
|
||||
* Returns live terminal sessions from the session manager, both sessions the
|
||||
* caller owns and SSH sessions shared to the caller by another user (via
|
||||
* an in-app session share). Used by the Active Connections panel and tab restore logic.
|
||||
* tags:
|
||||
* - Open Tabs
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of active sessions.
|
||||
* description: List of active sessions (own and shared-with-me).
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
@@ -302,6 +306,17 @@ router.delete("/:id", authenticateJWT, async (req: Request, res: Response) => {
|
||||
* type: boolean
|
||||
* createdAt:
|
||||
* type: number
|
||||
* isOwnSession:
|
||||
* type: boolean
|
||||
* sharedByUsername:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* permissionLevel:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* shareId:
|
||||
* type: string
|
||||
* nullable: true
|
||||
*/
|
||||
router.get(
|
||||
"/active-sessions",
|
||||
@@ -309,17 +324,46 @@ router.get(
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const sessions = sessionManager.getUserSessions(userId);
|
||||
return res.json(
|
||||
sessions.map((s) => ({
|
||||
sessionId: s.id,
|
||||
hostId: s.hostId,
|
||||
hostName: s.hostName,
|
||||
tabInstanceId: s.attachedTabInstanceId ?? s.tabInstanceId ?? null,
|
||||
isConnected: s.isConnected,
|
||||
createdAt: s.createdAt,
|
||||
})),
|
||||
);
|
||||
const ownSessions = sessionManager.getUserSessions(userId);
|
||||
const result = ownSessions.map((s) => ({
|
||||
sessionId: s.id,
|
||||
hostId: s.hostId,
|
||||
hostName: s.hostName,
|
||||
tabInstanceId: s.attachedTabInstanceId ?? s.tabInstanceId ?? null,
|
||||
isConnected: s.isConnected,
|
||||
createdAt: s.createdAt,
|
||||
isOwnSession: true,
|
||||
sharedByUsername: null as string | null,
|
||||
permissionLevel: null as string | null,
|
||||
shareId: null as string | null,
|
||||
}));
|
||||
|
||||
const sharedWithMe =
|
||||
await createCurrentSessionShareRepository().findSharesTargetingUser(
|
||||
userId,
|
||||
);
|
||||
for (const share of sharedWithMe) {
|
||||
if (share.protocol !== "ssh") continue;
|
||||
const sharedSession = sessionManager.getSession(share.sessionId);
|
||||
if (!sharedSession || !sharedSession.isConnected) continue;
|
||||
result.push({
|
||||
sessionId: sharedSession.id,
|
||||
hostId: sharedSession.hostId,
|
||||
hostName: sharedSession.hostName,
|
||||
tabInstanceId:
|
||||
sharedSession.attachedTabInstanceId ??
|
||||
sharedSession.tabInstanceId ??
|
||||
null,
|
||||
isConnected: sharedSession.isConnected,
|
||||
createdAt: sharedSession.createdAt,
|
||||
isOwnSession: false,
|
||||
sharedByUsername: share.ownerUsername,
|
||||
permissionLevel: share.permissionLevel,
|
||||
shareId: share.id,
|
||||
});
|
||||
}
|
||||
|
||||
return res.json(result);
|
||||
} catch (e) {
|
||||
databaseLogger.error("Failed to get active sessions", e, {
|
||||
operation: "get_active_sessions",
|
||||
|
||||
@@ -488,16 +488,51 @@ async function discoverProxmoxGuestsForHost(
|
||||
|
||||
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,
|
||||
);
|
||||
return parseLxcIp(JSON.parse(cfgJson), config.preferredPrefixes);
|
||||
configIp = parseLxcIp(JSON.parse(cfgJson), config.preferredPrefixes);
|
||||
} catch {
|
||||
return null;
|
||||
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 {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "../../utils/permission-catalog.js";
|
||||
import {
|
||||
createCurrentCredentialRepository,
|
||||
createCurrentHostFolderRepository,
|
||||
createCurrentHostResolutionRepository,
|
||||
createCurrentRbacAccessRepository,
|
||||
createCurrentRoleRepository,
|
||||
@@ -311,6 +312,225 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /rbac/folder/share:
|
||||
* post:
|
||||
* summary: Share all hosts in a folder
|
||||
* description: Shares every host within a folder (and its subfolders) with one or more users and/or roles at a permission level. Only hosts owned by the caller are shared; skips hosts the caller may not share.
|
||||
* tags:
|
||||
* - RBAC
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required: [folder, targets]
|
||||
* properties:
|
||||
* folder:
|
||||
* type: string
|
||||
* targets:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* type:
|
||||
* type: string
|
||||
* enum: [user, role]
|
||||
* id:
|
||||
* oneOf:
|
||||
* - type: string
|
||||
* - type: integer
|
||||
* permissionLevel:
|
||||
* type: string
|
||||
* enum: [connect, view, edit, manage]
|
||||
* durationHours:
|
||||
* type: number
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Folder shared successfully.
|
||||
* 400:
|
||||
* description: Invalid request body.
|
||||
* 404:
|
||||
* description: Folder has no hosts.
|
||||
* 500:
|
||||
* description: Failed to share folder.
|
||||
*/
|
||||
router.post(
|
||||
"/folder/share",
|
||||
authenticateJWT,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const userId = req.userId!;
|
||||
const { folder } = req.body ?? {};
|
||||
|
||||
if (!isNonEmptyString(folder)) {
|
||||
return res.status(400).json({ error: "Folder name is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const targets = parseShareTargets(req.body ?? {});
|
||||
if (!targets) {
|
||||
return res.status(400).json({
|
||||
error:
|
||||
"targets must be a non-empty array of { type: 'user'|'role', id } entries",
|
||||
});
|
||||
}
|
||||
|
||||
const { durationHours, permissionLevel = "connect" } = req.body;
|
||||
|
||||
if (!isSharePermissionLevel(permissionLevel)) {
|
||||
return res.status(400).json({
|
||||
error: "Invalid permission level",
|
||||
validLevels: SHARE_PERMISSION_LEVELS,
|
||||
});
|
||||
}
|
||||
|
||||
const userRepository = createCurrentUserRepository();
|
||||
const roleRepository = createCurrentRoleRepository();
|
||||
for (const target of targets) {
|
||||
if (target.type === "user") {
|
||||
const targetUser = await userRepository.findById(target.id as string);
|
||||
if (!targetUser) {
|
||||
return res.status(404).json({
|
||||
error: "Target user not found",
|
||||
targetId: target.id,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const targetRole = await roleRepository.findRoleById(
|
||||
target.id as number,
|
||||
);
|
||||
if (!targetRole) {
|
||||
return res.status(404).json({
|
||||
error: "Target role not found",
|
||||
targetId: target.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hostsInFolder =
|
||||
await createCurrentHostFolderRepository().listHostsInFolder(
|
||||
userId,
|
||||
folder,
|
||||
);
|
||||
if (hostsInFolder.length === 0) {
|
||||
return res.status(404).json({ error: "Folder has no hosts" });
|
||||
}
|
||||
|
||||
const expiresAt = expiryFromDuration(durationHours);
|
||||
const rbacAccessRepository = createCurrentRbacAccessRepository();
|
||||
const { SharedHostSecretsManager } =
|
||||
await import("../../utils/shared-host-secrets-manager.js");
|
||||
const secretsManager = SharedHostSecretsManager.getInstance();
|
||||
|
||||
const hostResults: Array<{
|
||||
hostId: number;
|
||||
shared: boolean;
|
||||
reason?: string;
|
||||
}> = [];
|
||||
|
||||
for (const host of hostsInFolder) {
|
||||
if (targets.some((t) => t.type === "user" && t.id === host.userId)) {
|
||||
hostResults.push({
|
||||
hostId: host.id,
|
||||
shared: false,
|
||||
reason: "owner",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const sharing = await canManageHostSharing(userId, host.id);
|
||||
if (!sharing.allowed) {
|
||||
hostResults.push({
|
||||
hostId: host.id,
|
||||
shared: false,
|
||||
reason: "forbidden",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const target of targets) {
|
||||
const accessGrant = await rbacAccessRepository.upsertHostAccess({
|
||||
hostId: host.id,
|
||||
grantedBy: userId,
|
||||
permissionLevel,
|
||||
expiresAt,
|
||||
...(target.type === "user"
|
||||
? {
|
||||
targetType: "user" as const,
|
||||
targetUserId: target.id as string,
|
||||
}
|
||||
: {
|
||||
targetType: "role" as const,
|
||||
targetRoleId: target.id as number,
|
||||
}),
|
||||
});
|
||||
|
||||
try {
|
||||
if (target.type === "user") {
|
||||
await secretsManager.snapshotForUser(
|
||||
accessGrant.id,
|
||||
host.id,
|
||||
target.id as string,
|
||||
host.userId,
|
||||
);
|
||||
} else {
|
||||
await secretsManager.snapshotForRole(
|
||||
accessGrant.id,
|
||||
host.id,
|
||||
target.id as number,
|
||||
host.userId,
|
||||
);
|
||||
}
|
||||
} catch (snapshotError) {
|
||||
databaseLogger.warn("Share created but secret snapshot failed", {
|
||||
operation: "rbac_folder_share_snapshot_failed",
|
||||
hostId: host.id,
|
||||
accessId: accessGrant.id,
|
||||
error:
|
||||
snapshotError instanceof Error
|
||||
? snapshotError.message
|
||||
: "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
hostResults.push({ hostId: host.id, shared: true });
|
||||
}
|
||||
|
||||
const sharedCount = hostResults.filter((r) => r.shared).length;
|
||||
|
||||
databaseLogger.success("Folder shared successfully", {
|
||||
operation: "rbac_folder_share_success",
|
||||
userId,
|
||||
folder,
|
||||
hostsShared: sharedCount,
|
||||
targets: targets.length,
|
||||
permissionLevel,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Folder shared successfully",
|
||||
permissionLevel,
|
||||
expiresAt,
|
||||
hostsShared: sharedCount,
|
||||
hostsTotal: hostsInFolder.length,
|
||||
hostResults,
|
||||
});
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to share folder", error, {
|
||||
operation: "share_folder",
|
||||
folder,
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to share folder" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /rbac/host/{id}/access/{accessId}:
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
createCurrentRoleRepository,
|
||||
createCurrentSnippetRepository,
|
||||
createCurrentUserRepository,
|
||||
createCurrentSyncTombstoneRepository,
|
||||
} from "../repositories/factory.js";
|
||||
|
||||
const router = express.Router();
|
||||
@@ -400,7 +401,17 @@ router.delete(
|
||||
try {
|
||||
const folderName = decodeURIComponent(name);
|
||||
|
||||
await createCurrentSnippetRepository().deleteFolder(userId, folderName);
|
||||
const deletedFolder = await createCurrentSnippetRepository().deleteFolder(
|
||||
userId,
|
||||
folderName,
|
||||
);
|
||||
if (deletedFolder?.syncId) {
|
||||
await createCurrentSyncTombstoneRepository().record(
|
||||
userId,
|
||||
"snippetFolders",
|
||||
deletedFolder.syncId,
|
||||
);
|
||||
}
|
||||
|
||||
authLogger.success(
|
||||
`Snippet folder deleted: ${folderName} by user ${userId}`,
|
||||
@@ -1241,6 +1252,14 @@ router.delete(
|
||||
return res.status(404).json({ error: "Snippet not found" });
|
||||
}
|
||||
|
||||
if (existing.syncId) {
|
||||
await createCurrentSyncTombstoneRepository().record(
|
||||
userId,
|
||||
"snippets",
|
||||
existing.syncId,
|
||||
);
|
||||
}
|
||||
|
||||
databaseLogger.info("Command snippet deleted", {
|
||||
operation: "snippet_delete",
|
||||
userId,
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
import type { Request, Response } from "express";
|
||||
import express from "express";
|
||||
import { and, eq, gt } from "drizzle-orm";
|
||||
import {
|
||||
hosts,
|
||||
sshCredentials,
|
||||
sshFolders,
|
||||
snippets,
|
||||
snippetFolders,
|
||||
vaultProfiles,
|
||||
dashboardServiceLinks,
|
||||
homepageItems,
|
||||
} from "../db/schema.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||
import { databaseLogger } from "../../utils/logger.js";
|
||||
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import {
|
||||
createCurrentRepositoryContext,
|
||||
createCurrentSyncTombstoneRepository,
|
||||
} from "../repositories/factory.js";
|
||||
import type { SyncEntityType } from "../repositories/sync-tombstone-repository.js";
|
||||
|
||||
const router = express.Router();
|
||||
const authManager = AuthManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
|
||||
// Encrypted tables need DataCrypto to translate between the wire payload
|
||||
// (plaintext) and the stored row (encrypted). Everything else is stored
|
||||
// and synced as-is.
|
||||
const ENCRYPTED_ENTITY_TABLES: Partial<Record<SyncEntityType, string>> = {
|
||||
hosts: "ssh_data",
|
||||
sshCredentials: "ssh_credentials",
|
||||
};
|
||||
|
||||
interface EntityConfig {
|
||||
table:
|
||||
| typeof hosts
|
||||
| typeof sshCredentials
|
||||
| typeof sshFolders
|
||||
| typeof snippets
|
||||
| typeof snippetFolders
|
||||
| typeof vaultProfiles
|
||||
| typeof dashboardServiceLinks
|
||||
| typeof homepageItems;
|
||||
// Fields that only make sense on the device that created the row, or
|
||||
// that are managed elsewhere and must never be overwritten by a sync
|
||||
// payload from the other side.
|
||||
readOnlyFields: string[];
|
||||
}
|
||||
|
||||
const ENTITY_CONFIG: Record<SyncEntityType, EntityConfig> = {
|
||||
hosts: {
|
||||
table: hosts,
|
||||
readOnlyFields: ["connectionOrigin"],
|
||||
},
|
||||
sshCredentials: { table: sshCredentials, readOnlyFields: [] },
|
||||
sshFolders: { table: sshFolders, readOnlyFields: [] },
|
||||
snippets: { table: snippets, readOnlyFields: [] },
|
||||
snippetFolders: { table: snippetFolders, readOnlyFields: [] },
|
||||
vaultProfiles: { table: vaultProfiles, readOnlyFields: [] },
|
||||
dashboardServiceLinks: { table: dashboardServiceLinks, readOnlyFields: [] },
|
||||
homepageItems: { table: homepageItems, readOnlyFields: [] },
|
||||
};
|
||||
|
||||
const VALID_ENTITY_TYPES = new Set(Object.keys(ENTITY_CONFIG));
|
||||
|
||||
export function isValidEntityType(value: unknown): value is SyncEntityType {
|
||||
return typeof value === "string" && VALID_ENTITY_TYPES.has(value);
|
||||
}
|
||||
|
||||
function requireUserDataKey(userId: string): Buffer {
|
||||
return DataCrypto.validateUserAccess(userId);
|
||||
}
|
||||
|
||||
function decryptIfNeeded(
|
||||
entityType: SyncEntityType,
|
||||
row: Record<string, unknown>,
|
||||
userId: string,
|
||||
): Record<string, unknown> {
|
||||
const tableName = ENCRYPTED_ENTITY_TABLES[entityType];
|
||||
if (!tableName) return row;
|
||||
const userDataKey = DataCrypto.getUserDataKey(userId);
|
||||
if (!userDataKey) return row;
|
||||
return DataCrypto.decryptRecord(
|
||||
tableName,
|
||||
row,
|
||||
userId,
|
||||
userDataKey,
|
||||
) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function encryptIfNeeded(
|
||||
entityType: SyncEntityType,
|
||||
row: Record<string, unknown>,
|
||||
userId: string,
|
||||
): Record<string, unknown> {
|
||||
const tableName = ENCRYPTED_ENTITY_TABLES[entityType];
|
||||
if (!tableName) return row;
|
||||
const userDataKey = requireUserDataKey(userId);
|
||||
return DataCrypto.encryptRecord(
|
||||
tableName,
|
||||
row,
|
||||
userId,
|
||||
userDataKey,
|
||||
) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function stripWritePayload(
|
||||
entityType: SyncEntityType,
|
||||
payload: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const { readOnlyFields } = ENTITY_CONFIG[entityType];
|
||||
const clean = { ...payload };
|
||||
delete clean.id;
|
||||
delete clean.userId;
|
||||
delete clean.syncId;
|
||||
for (const field of readOnlyFields) delete clean[field];
|
||||
return clean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/{entityType}:
|
||||
* get:
|
||||
* summary: Pull synced rows for an entity type
|
||||
* description: Returns rows owned by the authenticated user whose updatedAt is newer than `since` (or all rows if omitted). Used by the desktop app's remote sync engine to reconcile the embedded backend against a connected remote server.
|
||||
* tags:
|
||||
* - Sync
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: entityType
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: query
|
||||
* name: since
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Rows updated since the given timestamp.
|
||||
* 400:
|
||||
* description: Unknown entity type.
|
||||
* 500:
|
||||
* description: Failed to fetch rows.
|
||||
*/
|
||||
router.get(
|
||||
"/:entityType",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const entityType = req.params.entityType;
|
||||
if (!isValidEntityType(entityType)) {
|
||||
return res.status(400).json({ error: "Unknown entity type" });
|
||||
}
|
||||
const since =
|
||||
typeof req.query.since === "string" && req.query.since
|
||||
? req.query.since
|
||||
: null;
|
||||
|
||||
try {
|
||||
const { table } = ENTITY_CONFIG[entityType];
|
||||
const context = createCurrentRepositoryContext();
|
||||
const conditions = [eq(table.userId, userId)];
|
||||
if (since && "updatedAt" in table) {
|
||||
conditions.push(gt((table as typeof hosts).updatedAt, since));
|
||||
}
|
||||
|
||||
const rows = await context.drizzle
|
||||
.select()
|
||||
.from(table as typeof hosts)
|
||||
.where(and(...conditions));
|
||||
|
||||
const decrypted = rows.map((row) =>
|
||||
decryptIfNeeded(entityType, row as Record<string, unknown>, userId),
|
||||
);
|
||||
|
||||
res.json({ rows: decrypted });
|
||||
} catch (err) {
|
||||
databaseLogger.error(`Failed to pull sync rows for ${entityType}`, err, {
|
||||
operation: "sync_pull",
|
||||
entityType,
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to fetch rows" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/{entityType}:
|
||||
* post:
|
||||
* summary: Upsert a synced row by syncId
|
||||
* description: Creates or updates a row by its syncId. Used by the desktop app's remote sync engine to push local-only or newer rows to the other side of a sync pair.
|
||||
* tags:
|
||||
* - Sync
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: entityType
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Row upserted.
|
||||
* 400:
|
||||
* description: Unknown entity type or missing syncId.
|
||||
* 500:
|
||||
* description: Failed to upsert row.
|
||||
*/
|
||||
router.post(
|
||||
"/:entityType",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const entityType = req.params.entityType;
|
||||
if (!isValidEntityType(entityType)) {
|
||||
return res.status(400).json({ error: "Unknown entity type" });
|
||||
}
|
||||
const payload = req.body?.row;
|
||||
const syncId = payload?.syncId;
|
||||
if (!payload || typeof syncId !== "string" || !syncId) {
|
||||
return res.status(400).json({ error: "Missing row.syncId" });
|
||||
}
|
||||
|
||||
try {
|
||||
const { table } = ENTITY_CONFIG[entityType];
|
||||
const context = createCurrentRepositoryContext();
|
||||
|
||||
const existingRows = await context.drizzle
|
||||
.select()
|
||||
.from(table as typeof hosts)
|
||||
.where(
|
||||
and(
|
||||
eq((table as typeof hosts).syncId, syncId),
|
||||
eq(table.userId, userId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
const existing = existingRows[0] as Record<string, unknown> | undefined;
|
||||
|
||||
const writePayload = stripWritePayload(entityType, payload);
|
||||
const encryptedPayload = encryptIfNeeded(
|
||||
entityType,
|
||||
writePayload,
|
||||
userId,
|
||||
);
|
||||
|
||||
let resultRow: Record<string, unknown>;
|
||||
if (existing) {
|
||||
const updatedRows = await context.drizzle
|
||||
.update(table as typeof hosts)
|
||||
.set(encryptedPayload)
|
||||
.where(
|
||||
and(
|
||||
eq((table as typeof hosts).id, existing.id as number),
|
||||
eq(table.userId, userId),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
resultRow = updatedRows[0] as Record<string, unknown>;
|
||||
} else {
|
||||
const insertedRows = await context.drizzle
|
||||
.insert(table as typeof hosts)
|
||||
.values({
|
||||
...encryptedPayload,
|
||||
userId,
|
||||
syncId,
|
||||
} as typeof hosts.$inferInsert)
|
||||
.returning();
|
||||
resultRow = insertedRows[0] as Record<string, unknown>;
|
||||
}
|
||||
|
||||
await DatabaseSaveTrigger.forceSave("sync_upsert");
|
||||
|
||||
res.json({
|
||||
row: decryptIfNeeded(entityType, resultRow, userId),
|
||||
created: !existing,
|
||||
});
|
||||
} catch (err) {
|
||||
databaseLogger.error(`Failed to upsert sync row for ${entityType}`, err, {
|
||||
operation: "sync_upsert",
|
||||
entityType,
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to upsert row" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/{entityType}/tombstones:
|
||||
* get:
|
||||
* summary: Pull deletion tombstones for an entity type
|
||||
* description: Returns tombstones recorded since `since` so the other side of a sync pair can apply the same deletions.
|
||||
* tags:
|
||||
* - Sync
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: entityType
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: query
|
||||
* name: since
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Tombstones recorded since the given timestamp.
|
||||
* 400:
|
||||
* description: Unknown entity type.
|
||||
* 500:
|
||||
* description: Failed to fetch tombstones.
|
||||
*/
|
||||
router.get(
|
||||
"/:entityType/tombstones",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const entityType = req.params.entityType;
|
||||
if (!isValidEntityType(entityType)) {
|
||||
return res.status(400).json({ error: "Unknown entity type" });
|
||||
}
|
||||
const since =
|
||||
typeof req.query.since === "string" && req.query.since
|
||||
? req.query.since
|
||||
: null;
|
||||
|
||||
try {
|
||||
const tombstones = await createCurrentSyncTombstoneRepository().listSince(
|
||||
userId,
|
||||
entityType,
|
||||
since,
|
||||
);
|
||||
res.json({ tombstones });
|
||||
} catch (err) {
|
||||
databaseLogger.error(
|
||||
`Failed to fetch sync tombstones for ${entityType}`,
|
||||
err,
|
||||
{ operation: "sync_tombstones_pull", entityType, userId },
|
||||
);
|
||||
res.status(500).json({ error: "Failed to fetch tombstones" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/tombstones:
|
||||
* post:
|
||||
* summary: Report a deletion from the other side of a sync pair
|
||||
* description: Applies a remote deletion locally (if the row still exists) and records the tombstone so future pulls stay consistent.
|
||||
* tags:
|
||||
* - Sync
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Deletion applied (or row already absent).
|
||||
* 400:
|
||||
* description: Unknown entity type or missing syncId.
|
||||
* 500:
|
||||
* description: Failed to apply deletion.
|
||||
*/
|
||||
router.post(
|
||||
"/tombstones",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const entityType = req.body?.entityType;
|
||||
const syncId = req.body?.syncId;
|
||||
if (
|
||||
!isValidEntityType(entityType) ||
|
||||
typeof syncId !== "string" ||
|
||||
!syncId
|
||||
) {
|
||||
return res.status(400).json({ error: "Missing entityType or syncId" });
|
||||
}
|
||||
|
||||
try {
|
||||
const { table } = ENTITY_CONFIG[entityType];
|
||||
const context = createCurrentRepositoryContext();
|
||||
|
||||
await context.drizzle
|
||||
.delete(table as typeof hosts)
|
||||
.where(
|
||||
and(
|
||||
eq((table as typeof hosts).syncId, syncId),
|
||||
eq(table.userId, userId),
|
||||
),
|
||||
);
|
||||
|
||||
await createCurrentSyncTombstoneRepository().record(
|
||||
userId,
|
||||
entityType,
|
||||
syncId,
|
||||
);
|
||||
await DatabaseSaveTrigger.forceSave("sync_tombstone_applied");
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to apply sync tombstone", err, {
|
||||
operation: "sync_tombstone_apply",
|
||||
entityType,
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to apply deletion" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
UserPreferenceRecord,
|
||||
UserPreferenceUpdate,
|
||||
} from "../repositories/user-preference-repository.js";
|
||||
import { isValidKeybinding } from "./keybinding-validation.js";
|
||||
|
||||
const router = express.Router();
|
||||
const authManager = AuthManager.getInstance();
|
||||
@@ -33,6 +34,8 @@ const pickPreferences = (row?: UserPreferenceRecord | null) => ({
|
||||
hiddenRailTabs: row?.hiddenRailTabs ?? null,
|
||||
compactHostView: row?.compactHostView ?? null,
|
||||
statusColorScheme: row?.statusColorScheme ?? null,
|
||||
customThemes: row?.customThemes ?? null,
|
||||
customKeybindings: row?.customKeybindings ?? null,
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -106,6 +109,14 @@ const pickPreferences = (row?: UserPreferenceRecord | null) => ({
|
||||
* statusColorScheme:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* customThemes:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* description: JSON-encoded array of the user's saved global custom terminal themes.
|
||||
* customKeybindings:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* description: JSON-encoded array of the user's custom terminal keybindings.
|
||||
*/
|
||||
router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
@@ -175,6 +186,12 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
* type: boolean
|
||||
* statusColorScheme:
|
||||
* type: string
|
||||
* customThemes:
|
||||
* type: string
|
||||
* description: JSON-encoded array of the user's saved global custom terminal themes.
|
||||
* customKeybindings:
|
||||
* type: string
|
||||
* description: JSON-encoded array of the user's custom terminal keybindings.
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Preferences updated successfully.
|
||||
@@ -201,6 +218,8 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
hiddenRailTabs,
|
||||
compactHostView,
|
||||
statusColorScheme,
|
||||
customThemes,
|
||||
customKeybindings,
|
||||
} = req.body as {
|
||||
reopenTabsOnLogin?: boolean;
|
||||
theme?: string | null;
|
||||
@@ -221,6 +240,8 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
hiddenRailTabs?: string | null;
|
||||
compactHostView?: boolean | null;
|
||||
statusColorScheme?: string | null;
|
||||
customThemes?: string | null;
|
||||
customKeybindings?: string | null;
|
||||
};
|
||||
|
||||
const updates: UserPreferenceUpdate = {
|
||||
@@ -244,12 +265,64 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
storageMode,
|
||||
hiddenRailTabs,
|
||||
statusColorScheme,
|
||||
customThemes,
|
||||
customKeybindings,
|
||||
})) {
|
||||
if (value !== undefined && value !== null && typeof value !== "string") {
|
||||
return res.status(400).json({ error: `${key} must be a string` });
|
||||
}
|
||||
}
|
||||
|
||||
if (customThemes !== undefined && customThemes !== null) {
|
||||
let parsedThemes: unknown;
|
||||
try {
|
||||
parsedThemes = JSON.parse(customThemes);
|
||||
} catch {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "customThemes must be a JSON-encoded array" });
|
||||
}
|
||||
if (!Array.isArray(parsedThemes) || parsedThemes.length > 100) {
|
||||
return res.status(400).json({
|
||||
error: "customThemes must be a JSON array of at most 100 themes",
|
||||
});
|
||||
}
|
||||
const isValidTheme = (entry: unknown): boolean =>
|
||||
!!entry &&
|
||||
typeof entry === "object" &&
|
||||
typeof (entry as { id?: unknown }).id === "string" &&
|
||||
typeof (entry as { name?: unknown }).name === "string" &&
|
||||
!!(entry as { colors?: unknown }).colors &&
|
||||
typeof (entry as { colors?: unknown }).colors === "object";
|
||||
if (!parsedThemes.every(isValidTheme)) {
|
||||
return res.status(400).json({
|
||||
error: "Each custom theme must have an id, name, and colors object",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (customKeybindings !== undefined && customKeybindings !== null) {
|
||||
let parsedKeybindings: unknown;
|
||||
try {
|
||||
parsedKeybindings = JSON.parse(customKeybindings);
|
||||
} catch {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "customKeybindings must be a JSON-encoded array" });
|
||||
}
|
||||
if (!Array.isArray(parsedKeybindings) || parsedKeybindings.length > 200) {
|
||||
return res.status(400).json({
|
||||
error: "customKeybindings must be a JSON array of at most 200 bindings",
|
||||
});
|
||||
}
|
||||
if (!parsedKeybindings.every(isValidKeybinding)) {
|
||||
return res.status(400).json({
|
||||
error:
|
||||
"Each custom keybinding must have an id, enabled flag, valid combo, and valid action",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const boolFields: Record<string, boolean | null | undefined> = {
|
||||
commandAutocomplete,
|
||||
commandPaletteEnabled,
|
||||
@@ -294,6 +367,9 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
if (compactHostView !== undefined) updates.compactHostView = compactHostView;
|
||||
if (statusColorScheme !== undefined)
|
||||
updates.statusColorScheme = statusColorScheme;
|
||||
if (customThemes !== undefined) updates.customThemes = customThemes;
|
||||
if (customKeybindings !== undefined)
|
||||
updates.customKeybindings = customKeybindings;
|
||||
|
||||
if (Object.keys(updates).length === 1) {
|
||||
return res.status(400).json({ error: "No preferences provided" });
|
||||
|
||||
@@ -519,6 +519,207 @@ export function registerUserSettingsRoutes(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/analytics-enabled:
|
||||
* get:
|
||||
* summary: Get analytics enabled setting
|
||||
* description: Returns whether anonymous usage telemetry is enabled.
|
||||
* tags:
|
||||
* - Users
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Analytics enabled status.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* enabled:
|
||||
* type: boolean
|
||||
*/
|
||||
router.get("/analytics-enabled", authenticateJWT, async (_req, res) => {
|
||||
try {
|
||||
res.json({
|
||||
enabled: await createCurrentSettingsRepository().getBoolean(
|
||||
"analytics_enabled",
|
||||
true,
|
||||
),
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to get analytics enabled setting", err);
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: "Failed to get analytics enabled setting" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/analytics-enabled:
|
||||
* patch:
|
||||
* summary: Update analytics enabled setting (admin only)
|
||||
* description: Enables or disables the daily anonymous usage telemetry heartbeat.
|
||||
* tags:
|
||||
* - Users
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* enabled:
|
||||
* type: boolean
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Setting updated.
|
||||
* 403:
|
||||
* description: Not authorized.
|
||||
* 500:
|
||||
* description: Failed to update setting.
|
||||
*/
|
||||
router.patch("/analytics-enabled", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const actor = await getAdminActor(userId);
|
||||
if (!actor) {
|
||||
return res.status(403).json({ error: "Not authorized" });
|
||||
}
|
||||
const { enabled } = req.body;
|
||||
if (typeof enabled !== "boolean") {
|
||||
return res.status(400).json({ error: "enabled must be a boolean" });
|
||||
}
|
||||
await createCurrentSettingsRepository().set(
|
||||
"analytics_enabled",
|
||||
enabled ? "true" : "false",
|
||||
);
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: actor.username ?? userId,
|
||||
action: "update_analytics_enabled",
|
||||
resourceType: "setting",
|
||||
details: JSON.stringify({ enabled }),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
|
||||
res.json({ enabled });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to update analytics enabled setting", err);
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: "Failed to update analytics enabled setting" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/session-sharing-enabled:
|
||||
* get:
|
||||
* summary: Get session sharing globally enabled setting
|
||||
* description: Returns whether live session sharing (terminal/RDP/VNC/Telnet share links and in-app joins) is allowed instance-wide. Overrides every per-host toggle when false.
|
||||
* tags:
|
||||
* - Users
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Session sharing enabled status.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* enabled:
|
||||
* type: boolean
|
||||
*/
|
||||
router.get("/session-sharing-enabled", authenticateJWT, async (_req, res) => {
|
||||
try {
|
||||
res.json({
|
||||
enabled: await createCurrentSettingsRepository().getBoolean(
|
||||
"session_sharing_globally_enabled",
|
||||
true,
|
||||
),
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to get session sharing enabled setting", err);
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: "Failed to get session sharing enabled setting" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/session-sharing-enabled:
|
||||
* patch:
|
||||
* summary: Update session sharing globally enabled setting (admin only)
|
||||
* description: Enables or disables live session sharing instance-wide, overriding every per-host allowSessionSharing toggle.
|
||||
* tags:
|
||||
* - Users
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* enabled:
|
||||
* type: boolean
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Setting updated.
|
||||
* 403:
|
||||
* description: Not authorized.
|
||||
* 500:
|
||||
* description: Failed to update setting.
|
||||
*/
|
||||
router.patch(
|
||||
"/session-sharing-enabled",
|
||||
authenticateJWT,
|
||||
async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const actor = await getAdminActor(userId);
|
||||
if (!actor) {
|
||||
return res.status(403).json({ error: "Not authorized" });
|
||||
}
|
||||
const { enabled } = req.body;
|
||||
if (typeof enabled !== "boolean") {
|
||||
return res.status(400).json({ error: "enabled must be a boolean" });
|
||||
}
|
||||
await createCurrentSettingsRepository().set(
|
||||
"session_sharing_globally_enabled",
|
||||
enabled ? "true" : "false",
|
||||
);
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: actor.username ?? userId,
|
||||
action: "update_session_sharing_enabled",
|
||||
resourceType: "setting",
|
||||
details: JSON.stringify({ enabled }),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
|
||||
res.json({ enabled });
|
||||
} catch (err) {
|
||||
authLogger.error(
|
||||
"Failed to update session sharing enabled setting",
|
||||
err,
|
||||
);
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: "Failed to update session sharing enabled setting" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/host-defaults:
|
||||
|
||||
@@ -18,6 +18,11 @@ import {
|
||||
isOidcTokenCallback,
|
||||
} from "../../utils/oidc-desktop-callback.js";
|
||||
import { deleteUserAndRelatedData } from "./delete-user-data.js";
|
||||
import {
|
||||
isLoopbackRequest,
|
||||
extractBearerOrCookieToken,
|
||||
resolveDesktopAutoSessionUser,
|
||||
} from "./desktop-auto-session.js";
|
||||
import { shouldShowDonationModal } from "./donation-modal-utils.js";
|
||||
import {
|
||||
getOIDCConfigFromEnv,
|
||||
@@ -1848,9 +1853,13 @@ router.post(
|
||||
* description: Not authenticated.
|
||||
*/
|
||||
router.get("/me/token", authenticateJWT, (req: Request, res: Response) => {
|
||||
const token = (req as Request & { cookies: Record<string, string> }).cookies
|
||||
?.jwt;
|
||||
res.json({ token: token || null });
|
||||
// authenticateJWT accepts either the jwt cookie or an Authorization:
|
||||
// Bearer header (see auth-manager.ts's createAuthMiddleware) -- this must
|
||||
// check both too, or a request that only carried the header (e.g. the
|
||||
// Electron renderer's own axios interceptor, which always attaches a
|
||||
// stored localStorage JWT as a Bearer header) would pass authentication
|
||||
// here but still get back a null token.
|
||||
res.json({ token: extractBearerOrCookieToken(req) ?? null });
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -1880,6 +1889,80 @@ router.get("/setup-required", async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/internal/auto-session:
|
||||
* post:
|
||||
* summary: Mint a session for the sole local desktop user
|
||||
* description: Used by the Electron desktop app to skip the login form entirely when running standalone against the embedded local backend. Only available over loopback. Logs in as the sole local user regardless of its credentials; if the local database has more than one user (e.g. repeated manual registration), deterministically logs in as the admin account, or the earliest-registered account if none is admin -- a login form must never appear for the local backend under any circumstance. Only declines if zero local users exist at all, which normal desktop provisioning never produces. Provisions the resolved user's data-encryption key if missing before minting the session, matching every other login path -- self-heals an account that previously ended up with a valid session but no usable encryption key.
|
||||
* tags:
|
||||
* - Users
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Session created.
|
||||
* 403:
|
||||
* description: Forbidden, or no local users exist.
|
||||
* 500:
|
||||
* description: Failed to create session.
|
||||
*/
|
||||
router.post("/internal/auto-session", async (req, res) => {
|
||||
try {
|
||||
if (!isLoopbackRequest(req)) {
|
||||
authLogger.warn(
|
||||
"Rejected non-loopback attempt to access auto-session endpoint",
|
||||
{ source: req.ip },
|
||||
);
|
||||
return res.status(403).json({ error: "Forbidden" });
|
||||
}
|
||||
|
||||
const userRepository = createCurrentUserRepository();
|
||||
const allUsers = await userRepository.listAll();
|
||||
const userRecord = resolveDesktopAutoSessionUser(allUsers);
|
||||
if (!userRecord) {
|
||||
return res.status(403).json({
|
||||
error: "No local users exist",
|
||||
});
|
||||
}
|
||||
await authManager.registerUser(userRecord.id);
|
||||
const existingToken = extractBearerOrCookieToken(req);
|
||||
if (existingToken) {
|
||||
const existingPayload = await authManager.verifyJWTToken(existingToken);
|
||||
if (existingPayload?.userId === userRecord.id) {
|
||||
return res.json({
|
||||
success: true,
|
||||
is_admin: !!userRecord.isAdmin,
|
||||
username: userRecord.username,
|
||||
token: existingToken,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const token = await authManager.generateJWTToken(userRecord.id, {
|
||||
deviceType: "desktop",
|
||||
deviceInfo: "Termix Desktop (local)",
|
||||
rememberMe: true,
|
||||
});
|
||||
|
||||
const response = {
|
||||
success: true,
|
||||
is_admin: !!userRecord.isAdmin,
|
||||
username: userRecord.username,
|
||||
token,
|
||||
};
|
||||
|
||||
return res
|
||||
.cookie(
|
||||
"jwt",
|
||||
token,
|
||||
authManager.getSecureCookieOptions(req, 30 * 24 * 60 * 60 * 1000),
|
||||
)
|
||||
.json(response);
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to create auto-session", err);
|
||||
res.status(500).json({ error: "Failed to create auto-session" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/count:
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Request, Response } from "express";
|
||||
import {
|
||||
createCurrentVaultProfileRepository,
|
||||
createCurrentUserRepository,
|
||||
createCurrentSyncTombstoneRepository,
|
||||
} from "../repositories/factory.js";
|
||||
import type { VaultProfileUpdateInput } from "../repositories/vault-profile-repository.js";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
@@ -421,7 +422,14 @@ router.delete(
|
||||
.status(403)
|
||||
.json({ error: "Only the owner can delete this profile" });
|
||||
}
|
||||
await repository.deleteById(id);
|
||||
const deleted = await repository.deleteById(id);
|
||||
if (deleted?.syncId) {
|
||||
await createCurrentSyncTombstoneRepository().record(
|
||||
userId,
|
||||
"vaultProfiles",
|
||||
deleted.syncId,
|
||||
);
|
||||
}
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to delete vault profile", err);
|
||||
|
||||
@@ -117,6 +117,24 @@ export class SSHAuthManager {
|
||||
return;
|
||||
}
|
||||
|
||||
// JumpCloud Protect / DUO-style push MFA: a menu choice ("Choose [1] Push,
|
||||
// or [2] TOTP:") followed by an empty-answerable confirm ("Press enter to
|
||||
// send Push request:"). Checked before the TOTP regex because the menu
|
||||
// prompt's own text ("...or [2] TOTP:") would otherwise match it and get
|
||||
// misrouted into the numeric-code flow.
|
||||
const pushPromptPattern =
|
||||
/choose.*push.*totp|press enter.*(push|send)|push notification|authentication by phone/i;
|
||||
const isPushPrompt = promptTexts.some((p) => pushPromptPattern.test(p));
|
||||
|
||||
if (isPushPrompt) {
|
||||
sshLogger.info("Push/menu MFA prompt detected", {
|
||||
operation: "ssh_keyboard_interactive_push",
|
||||
hostId: this.context.hostId,
|
||||
});
|
||||
this.handlePasswordAuth(prompts, finish, resolvedCredentials, hostConfig);
|
||||
return;
|
||||
}
|
||||
|
||||
const totpPromptIndex = prompts.findIndex((p) =>
|
||||
/verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test(
|
||||
p.prompt,
|
||||
@@ -313,6 +331,10 @@ export class SSHAuthManager {
|
||||
? passwordPromptIndex
|
||||
: firstUnansweredIndex;
|
||||
|
||||
const pushPromptPattern =
|
||||
/choose.*push.*totp|press enter.*(push|send)|push notification|authentication by phone/i;
|
||||
const isPushPrompt = pushPromptPattern.test(prompts[promptIndex].prompt);
|
||||
|
||||
this.context.keyboardInteractiveFinish = (userResponses: string[]) => {
|
||||
const userInput = (userResponses[0] || "").trim();
|
||||
|
||||
@@ -333,22 +355,25 @@ export class SSHAuthManager {
|
||||
clearTimeout(this.context.totpTimeout);
|
||||
}
|
||||
|
||||
this.context.totpTimeout = setTimeout(() => {
|
||||
if (this.context.keyboardInteractiveFinish) {
|
||||
this.context.keyboardInteractiveFinish = null;
|
||||
this.context.keyboardInteractiveResponded = false;
|
||||
sshLogger.warn("Password prompt timeout", {
|
||||
operation: "password_timeout",
|
||||
hostId: this.context.hostId,
|
||||
});
|
||||
this.context.ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "Password verification timeout. Please reconnect.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
}, 180000);
|
||||
this.context.totpTimeout = setTimeout(
|
||||
() => {
|
||||
if (this.context.keyboardInteractiveFinish) {
|
||||
this.context.keyboardInteractiveFinish = null;
|
||||
this.context.keyboardInteractiveResponded = false;
|
||||
sshLogger.warn("Password prompt timeout", {
|
||||
operation: "password_timeout",
|
||||
hostId: this.context.hostId,
|
||||
});
|
||||
this.context.ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "Password verification timeout. Please reconnect.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
isPushPrompt ? 300000 : 180000,
|
||||
);
|
||||
|
||||
this.sendLog("auth", "info", "Password authentication required");
|
||||
|
||||
@@ -356,6 +381,7 @@ export class SSHAuthManager {
|
||||
JSON.stringify({
|
||||
type: "password_required",
|
||||
prompt: prompts[promptIndex].prompt,
|
||||
echo: prompts[promptIndex].echo,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
|
||||
@@ -32,6 +32,12 @@ const wss = new WebSocketServer({
|
||||
port: 30009,
|
||||
});
|
||||
|
||||
wss.on("error", (error) => {
|
||||
sshLogger.error("Docker console WebSocket server error", error, {
|
||||
operation: "wss_error",
|
||||
});
|
||||
});
|
||||
|
||||
async function detectShell(
|
||||
session: SSHSession,
|
||||
containerId: string,
|
||||
|
||||
@@ -280,6 +280,7 @@ export function registerDockerSshRoutes(app: express.Express): void {
|
||||
|
||||
if (userProvidedPassword) {
|
||||
resolvedCredentials.password = userProvidedPassword;
|
||||
resolvedCredentials.authType = "password";
|
||||
}
|
||||
if (userProvidedSshKey) {
|
||||
resolvedCredentials.sshKey = userProvidedSshKey;
|
||||
|
||||
@@ -27,12 +27,64 @@ const GUACAMOLE_RECORDINGS_DIR =
|
||||
path.join(DATA_DIR, "session_recordings", "guacamole");
|
||||
|
||||
type GuacamoleClientConnection = {
|
||||
guacamoleConnectionId?: string;
|
||||
connectionSettings?: {
|
||||
connection?: { type?: string };
|
||||
connection?: { type?: string; join?: string; readOnly?: boolean };
|
||||
recording?: GuacamoleRecordingMetadata;
|
||||
termixMeta?: {
|
||||
termixConnectId: string;
|
||||
hostId: number;
|
||||
ownerUserId: string;
|
||||
protocol: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export interface GuacSessionInfo {
|
||||
guacamoleConnectionId: string;
|
||||
hostId: number;
|
||||
ownerUserId: string;
|
||||
protocol: string;
|
||||
openedAt: number;
|
||||
}
|
||||
|
||||
// Keyed by termixConnectId (routes.ts's correlation id), populated once the
|
||||
// primary connection's guacd handshake completes.
|
||||
const guacSessionByConnectId = new Map<string, GuacSessionInfo>();
|
||||
// Keyed by guacd's own guacamoleConnectionId, for join-time lookups.
|
||||
const guacSessionByGuacamoleId = new Map<string, GuacSessionInfo>();
|
||||
const pendingConnectResolvers = new Map<
|
||||
string,
|
||||
(info: GuacSessionInfo | null) => void
|
||||
>();
|
||||
|
||||
export function waitForGuacdOpen(
|
||||
termixConnectId: string,
|
||||
timeoutMs = 10000,
|
||||
): Promise<GuacSessionInfo | null> {
|
||||
const existing = guacSessionByConnectId.get(termixConnectId);
|
||||
if (existing) return Promise.resolve(existing);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const finish = (info: GuacSessionInfo | null) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
pendingConnectResolvers.delete(termixConnectId);
|
||||
resolve(info);
|
||||
};
|
||||
|
||||
pendingConnectResolvers.set(termixConnectId, finish);
|
||||
setTimeout(() => finish(null), timeoutMs);
|
||||
});
|
||||
}
|
||||
|
||||
export function getGuacSessionInfo(
|
||||
guacamoleConnectionId: string,
|
||||
): GuacSessionInfo | null {
|
||||
return guacSessionByGuacamoleId.get(guacamoleConnectionId) ?? null;
|
||||
}
|
||||
|
||||
async function persistGuacamoleRecording(
|
||||
clientConnection: GuacamoleClientConnection,
|
||||
): Promise<void> {
|
||||
@@ -118,7 +170,6 @@ const clientOptions = {
|
||||
vnc: {
|
||||
"swap-red-blue": false,
|
||||
cursor: "remote",
|
||||
security: "any",
|
||||
width: 1280,
|
||||
height: 720,
|
||||
},
|
||||
@@ -149,6 +200,25 @@ function createGuacServer(): GuacamoleLite {
|
||||
operation: "guac_connection_open",
|
||||
type: clientConnection.connectionSettings?.connection?.type,
|
||||
});
|
||||
|
||||
const termixMeta = clientConnection.connectionSettings?.termixMeta;
|
||||
const guacamoleConnectionId = clientConnection.guacamoleConnectionId;
|
||||
const isJoin = !!clientConnection.connectionSettings?.connection?.join;
|
||||
|
||||
if (!isJoin && termixMeta && guacamoleConnectionId) {
|
||||
const info: GuacSessionInfo = {
|
||||
guacamoleConnectionId,
|
||||
hostId: termixMeta.hostId,
|
||||
ownerUserId: termixMeta.ownerUserId,
|
||||
protocol: termixMeta.protocol,
|
||||
openedAt: Date.now(),
|
||||
};
|
||||
guacSessionByConnectId.set(termixMeta.termixConnectId, info);
|
||||
guacSessionByGuacamoleId.set(guacamoleConnectionId, info);
|
||||
|
||||
const resolver = pendingConnectResolvers.get(termixMeta.termixConnectId);
|
||||
if (resolver) resolver(info);
|
||||
}
|
||||
});
|
||||
|
||||
server.on("close", (clientConnection: GuacamoleClientConnection) => {
|
||||
@@ -156,6 +226,15 @@ function createGuacServer(): GuacamoleLite {
|
||||
operation: "guac_connection_close",
|
||||
type: clientConnection.connectionSettings?.connection?.type,
|
||||
});
|
||||
|
||||
const isJoin = !!clientConnection.connectionSettings?.connection?.join;
|
||||
const termixMeta = clientConnection.connectionSettings?.termixMeta;
|
||||
const guacamoleConnectionId = clientConnection.guacamoleConnectionId;
|
||||
if (!isJoin && termixMeta && guacamoleConnectionId) {
|
||||
guacSessionByConnectId.delete(termixMeta.termixConnectId);
|
||||
guacSessionByGuacamoleId.delete(guacamoleConnectionId);
|
||||
}
|
||||
|
||||
persistGuacamoleRecording(clientConnection).catch((error) => {
|
||||
guacLogger.error("Failed to persist Guacamole recording", error, {
|
||||
operation: "guac_recording_persist_error",
|
||||
|
||||
@@ -3,16 +3,18 @@ import { GuacamoleTokenService } from "./token-service.js";
|
||||
import { guacLogger } from "../../utils/logger.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { PermissionManager } from "../../utils/permission-manager.js";
|
||||
import { Client } from "ssh2";
|
||||
import net from "net";
|
||||
import crypto from "crypto";
|
||||
import path from "path";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import type { AuthenticatedRequest, ProxyNode } from "../../../types/index.js";
|
||||
import {
|
||||
createCurrentHostResolutionRepository,
|
||||
createCurrentSettingsRepository,
|
||||
} from "../../database/repositories/factory.js";
|
||||
import { resolveGuacdOptions } from "../../utils/guacd-config.js";
|
||||
import { createJumpHostChain } from "../jump-host-chain.js";
|
||||
import type { SOCKS5Config } from "../../utils/socks5-helper.js";
|
||||
import { waitForGuacdOpen } from "./guacamole-server.js";
|
||||
|
||||
const router = express.Router();
|
||||
const tokenService = GuacamoleTokenService.getInstance();
|
||||
@@ -165,6 +167,12 @@ router.post("/token", async (req, res) => {
|
||||
* type: string
|
||||
* enum: [rdp, vnc, telnet]
|
||||
* description: Override the host's default connection type
|
||||
* promptedUsername:
|
||||
* type: string
|
||||
* description: Username for this connection only, used when the host's RDP auth type is "none". Not persisted.
|
||||
* promptedPassword:
|
||||
* type: string
|
||||
* description: Password for this connection only, used when the host's RDP auth type is "none". Not persisted.
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Connection token generated successfully
|
||||
@@ -176,6 +184,10 @@ router.post("/token", async (req, res) => {
|
||||
* token:
|
||||
* type: string
|
||||
* description: Encrypted connection token
|
||||
* guacamoleConnectionId:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* description: guacd's own connection id for this session, once the handshake completes. Used to mint session-share join tokens.
|
||||
* 400:
|
||||
* description: Invalid request or unsupported connection type
|
||||
* 403:
|
||||
@@ -421,12 +433,22 @@ router.post(
|
||||
let username: string;
|
||||
let password: string;
|
||||
|
||||
const rdpAuthTypeForConnect = isSharedConnection
|
||||
? null
|
||||
: (host.rdpAuthType as string) ||
|
||||
(host.rdpCredentialId ? "credential" : "direct");
|
||||
|
||||
switch (connectionType) {
|
||||
case "rdp":
|
||||
username =
|
||||
(host.rdpUser as string) || (host.username as string) || "";
|
||||
password =
|
||||
(host.rdpPassword as string) || (host.password as string) || "";
|
||||
if (rdpAuthTypeForConnect === "none") {
|
||||
username = String(req.body?.promptedUsername || "");
|
||||
password = String(req.body?.promptedPassword || "");
|
||||
} else {
|
||||
username =
|
||||
(host.rdpUser as string) || (host.username as string) || "";
|
||||
password =
|
||||
(host.rdpPassword as string) || (host.password as string) || "";
|
||||
}
|
||||
port = (host.rdpPort as number) || port || 3389;
|
||||
break;
|
||||
case "vnc":
|
||||
@@ -463,65 +485,91 @@ router.post(
|
||||
|
||||
if (jumpHosts.length > 0) {
|
||||
try {
|
||||
const { resolveHostById } = await import("../host-resolver.js");
|
||||
const jumpHost = await resolveHostById(jumpHosts[0].hostId, userId);
|
||||
if (jumpHost) {
|
||||
const tunnelPort = await new Promise<number>((resolve, reject) => {
|
||||
const sshClient = new Client();
|
||||
sshClient.on("ready", () => {
|
||||
const server = net.createServer((sock) => {
|
||||
sshClient.forwardOut(
|
||||
"127.0.0.1",
|
||||
0,
|
||||
hostname,
|
||||
port,
|
||||
(err, stream) => {
|
||||
if (err) {
|
||||
sock.destroy();
|
||||
return;
|
||||
}
|
||||
sock.pipe(stream).pipe(sock);
|
||||
},
|
||||
);
|
||||
});
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const addr = server.address() as net.AddressInfo;
|
||||
// Auto-cleanup after 1 hour
|
||||
setTimeout(
|
||||
() => {
|
||||
server.close();
|
||||
sshClient.end();
|
||||
},
|
||||
60 * 60 * 1000,
|
||||
);
|
||||
resolve(addr.port);
|
||||
});
|
||||
});
|
||||
sshClient.on("error", reject);
|
||||
let socks5ProxyChain: ProxyNode[] = [];
|
||||
if (hostRecord.socks5ProxyChain) {
|
||||
try {
|
||||
socks5ProxyChain =
|
||||
typeof hostRecord.socks5ProxyChain === "string"
|
||||
? JSON.parse(hostRecord.socks5ProxyChain as string)
|
||||
: (hostRecord.socks5ProxyChain as ProxyNode[]);
|
||||
} catch {
|
||||
socks5ProxyChain = [];
|
||||
}
|
||||
}
|
||||
|
||||
const connectOpts: Record<string, unknown> = {
|
||||
host: jumpHost.ip,
|
||||
port: jumpHost.port || 22,
|
||||
username: jumpHost.username,
|
||||
readyTimeout: 30000,
|
||||
};
|
||||
if (jumpHost.key) {
|
||||
connectOpts.privateKey = jumpHost.key;
|
||||
if (jumpHost.keyPassword)
|
||||
connectOpts.passphrase = jumpHost.keyPassword;
|
||||
} else if (jumpHost.password) {
|
||||
connectOpts.password = jumpHost.password;
|
||||
}
|
||||
sshClient.connect(connectOpts);
|
||||
});
|
||||
hostname = "127.0.0.1";
|
||||
port = tunnelPort;
|
||||
guacLogger.info("SSH tunnel established for guacamole", {
|
||||
operation: "guac_ssh_tunnel",
|
||||
hostId,
|
||||
tunnelPort,
|
||||
const proxyConfig: SOCKS5Config | null =
|
||||
hostRecord.useSocks5 &&
|
||||
(hostRecord.socks5Host || socks5ProxyChain.length > 0)
|
||||
? {
|
||||
useSocks5: hostRecord.useSocks5 as boolean,
|
||||
socks5Host: hostRecord.socks5Host as string | undefined,
|
||||
socks5Port: hostRecord.socks5Port as number | undefined,
|
||||
socks5Username: hostRecord.socks5Username as
|
||||
| string
|
||||
| undefined,
|
||||
socks5Password: hostRecord.socks5Password as
|
||||
| string
|
||||
| undefined,
|
||||
socks5ProxyChain,
|
||||
}
|
||||
: null;
|
||||
|
||||
const jumpClient = await createJumpHostChain(
|
||||
jumpHosts,
|
||||
userId,
|
||||
proxyConfig,
|
||||
);
|
||||
|
||||
if (!jumpClient) {
|
||||
guacLogger.error(
|
||||
"Failed to establish jump host chain for guacamole",
|
||||
undefined,
|
||||
{ operation: "guac_ssh_tunnel_error", hostId },
|
||||
);
|
||||
return res.status(500).json({
|
||||
error: "Failed to establish SSH tunnel to remote host",
|
||||
});
|
||||
}
|
||||
|
||||
const targetHostname = hostname;
|
||||
const targetPort = port;
|
||||
const tunnelPort = await new Promise<number>((resolve, reject) => {
|
||||
const server = net.createServer((sock) => {
|
||||
jumpClient.forwardOut(
|
||||
"127.0.0.1",
|
||||
0,
|
||||
targetHostname,
|
||||
targetPort,
|
||||
(err, stream) => {
|
||||
if (err) {
|
||||
sock.destroy();
|
||||
return;
|
||||
}
|
||||
sock.pipe(stream).pipe(sock);
|
||||
},
|
||||
);
|
||||
});
|
||||
server.on("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const addr = server.address() as net.AddressInfo;
|
||||
// Auto-cleanup after 1 hour
|
||||
setTimeout(
|
||||
() => {
|
||||
server.close();
|
||||
jumpClient.end();
|
||||
},
|
||||
60 * 60 * 1000,
|
||||
);
|
||||
resolve(addr.port);
|
||||
});
|
||||
});
|
||||
hostname = "127.0.0.1";
|
||||
port = tunnelPort;
|
||||
guacLogger.info("SSH tunnel established for guacamole", {
|
||||
operation: "guac_ssh_tunnel",
|
||||
hostId,
|
||||
tunnelPort,
|
||||
});
|
||||
} catch (tunnelError) {
|
||||
guacLogger.error("Failed to establish SSH tunnel", tunnelError, {
|
||||
operation: "guac_ssh_tunnel_error",
|
||||
@@ -541,7 +589,8 @@ router.post(
|
||||
? { guacdPort: perConnectionGuacdPort }
|
||||
: {}),
|
||||
};
|
||||
const recordingEnabled = host.enableSessionLogging !== false;
|
||||
const recordingEnabled =
|
||||
connectionType !== "vnc" && host.enableSessionLogging !== false;
|
||||
const recordingName = `${crypto.randomUUID()}.guac`;
|
||||
const recordingPath =
|
||||
process.env.GUACD_RECORDING_PATH ||
|
||||
@@ -564,6 +613,14 @@ router.post(
|
||||
guacConfig["recording-include-keys"] = true;
|
||||
}
|
||||
|
||||
const termixConnectId = crypto.randomUUID();
|
||||
const termixMeta = {
|
||||
termixConnectId,
|
||||
hostId,
|
||||
ownerUserId: userId,
|
||||
protocol: connectionType as "rdp" | "vnc" | "telnet",
|
||||
};
|
||||
|
||||
switch (connectionType) {
|
||||
case "rdp":
|
||||
if (guacConfig["enable-drive"] && !guacConfig["drive-path"]) {
|
||||
@@ -591,6 +648,7 @@ router.post(
|
||||
...guacdOverrides,
|
||||
},
|
||||
recordingMetadata,
|
||||
termixMeta,
|
||||
);
|
||||
break;
|
||||
case "vnc":
|
||||
@@ -600,11 +658,11 @@ router.post(
|
||||
password,
|
||||
{
|
||||
port,
|
||||
security: "any",
|
||||
...guacConfig,
|
||||
...guacdOverrides,
|
||||
},
|
||||
recordingMetadata,
|
||||
termixMeta,
|
||||
);
|
||||
break;
|
||||
case "telnet":
|
||||
@@ -618,13 +676,19 @@ router.post(
|
||||
...guacdOverrides,
|
||||
},
|
||||
recordingMetadata,
|
||||
termixMeta,
|
||||
);
|
||||
break;
|
||||
default:
|
||||
return res.status(400).json({ error: "Invalid connection type" });
|
||||
}
|
||||
|
||||
res.json({ token });
|
||||
const sessionInfo = await waitForGuacdOpen(termixConnectId, 10000);
|
||||
|
||||
res.json({
|
||||
token,
|
||||
guacamoleConnectionId: sessionInfo?.guacamoleConnectionId ?? null,
|
||||
});
|
||||
} catch (error) {
|
||||
guacLogger.error("Failed to generate guacamole token for host", error, {
|
||||
operation: "guac_host_token_error",
|
||||
|
||||
@@ -2,11 +2,13 @@ import crypto from "crypto";
|
||||
import { guacLogger } from "../../utils/logger.js";
|
||||
|
||||
export interface GuacamoleConnectionSettings {
|
||||
type: "rdp" | "vnc" | "telnet";
|
||||
type?: "rdp" | "vnc" | "telnet";
|
||||
join?: string;
|
||||
readOnly?: boolean;
|
||||
guacdHost?: string;
|
||||
guacdPort?: number;
|
||||
settings: {
|
||||
hostname: string;
|
||||
hostname?: string;
|
||||
port?: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
@@ -28,9 +30,17 @@ export interface GuacamoleConnectionSettings {
|
||||
};
|
||||
}
|
||||
|
||||
export interface TermixGuacMeta {
|
||||
termixConnectId: string;
|
||||
hostId: number;
|
||||
ownerUserId: string;
|
||||
protocol: "rdp" | "vnc" | "telnet";
|
||||
}
|
||||
|
||||
export interface GuacamoleToken {
|
||||
connection: GuacamoleConnectionSettings;
|
||||
recording?: GuacamoleRecordingMetadata;
|
||||
termixMeta?: TermixGuacMeta;
|
||||
}
|
||||
|
||||
export interface GuacamoleRecordingMetadata {
|
||||
@@ -137,6 +147,7 @@ export class GuacamoleTokenService {
|
||||
guacdPort?: number;
|
||||
} = {},
|
||||
recording?: GuacamoleRecordingMetadata,
|
||||
termixMeta?: TermixGuacMeta,
|
||||
): string {
|
||||
const { guacdHost, guacdPort, ...settingsOptions } = options;
|
||||
const token: GuacamoleToken = {
|
||||
@@ -155,6 +166,7 @@ export class GuacamoleTokenService {
|
||||
},
|
||||
},
|
||||
recording,
|
||||
termixMeta,
|
||||
};
|
||||
return this.encryptToken(token);
|
||||
}
|
||||
@@ -168,6 +180,7 @@ export class GuacamoleTokenService {
|
||||
guacdPort?: number;
|
||||
} = {},
|
||||
recording?: GuacamoleRecordingMetadata,
|
||||
termixMeta?: TermixGuacMeta,
|
||||
): string {
|
||||
const { guacdHost, guacdPort, ...settingsOptions } = options;
|
||||
const token: GuacamoleToken = {
|
||||
@@ -184,6 +197,7 @@ export class GuacamoleTokenService {
|
||||
},
|
||||
},
|
||||
recording,
|
||||
termixMeta,
|
||||
};
|
||||
return this.encryptToken(token);
|
||||
}
|
||||
@@ -197,6 +211,7 @@ export class GuacamoleTokenService {
|
||||
guacdPort?: number;
|
||||
} = {},
|
||||
recording?: GuacamoleRecordingMetadata,
|
||||
termixMeta?: TermixGuacMeta,
|
||||
): string {
|
||||
const { guacdHost, guacdPort, ...settingsOptions } = options;
|
||||
const token: GuacamoleToken = {
|
||||
@@ -213,6 +228,20 @@ export class GuacamoleTokenService {
|
||||
},
|
||||
},
|
||||
recording,
|
||||
termixMeta,
|
||||
};
|
||||
return this.encryptToken(token);
|
||||
}
|
||||
|
||||
// join tokens never carry recording params - only the primary connection's
|
||||
// token should write recording-path/recording-name to guacd.
|
||||
createJoinToken(guacamoleConnectionId: string, readOnly: boolean): string {
|
||||
const token: GuacamoleToken = {
|
||||
connection: {
|
||||
join: guacamoleConnectionId,
|
||||
readOnly,
|
||||
settings: {},
|
||||
},
|
||||
};
|
||||
return this.encryptToken(token);
|
||||
}
|
||||
|
||||
@@ -122,35 +122,61 @@ export async function resolveHostById(
|
||||
repository,
|
||||
);
|
||||
if (!resolved) return null;
|
||||
} else if (host.credentialId) {
|
||||
try {
|
||||
const cred = (await repository.findCredentialByIdForUser(
|
||||
host.credentialId as number,
|
||||
ownerId,
|
||||
)) as Record<string, unknown> | null;
|
||||
|
||||
if (cred) {
|
||||
host.password = pickResolvedPassword(host.password, cred.password);
|
||||
// Prefer the normalised private key; fall back to raw key field
|
||||
host.key = (cred.privateKey || cred.key) as string | null;
|
||||
host.keyPassword = cred.keyPassword;
|
||||
host.keyType = cred.keyType;
|
||||
// CA-signed certificate for cert-based auth
|
||||
(host as Record<string, unknown>).certPublicKey =
|
||||
cred.certPublicKey || null;
|
||||
host.username = pickResolvedUsername(
|
||||
host.username,
|
||||
cred.username,
|
||||
host.overrideCredentialUsername,
|
||||
} else {
|
||||
let effectiveCredentialId = host.credentialId as number | null | undefined;
|
||||
if (
|
||||
!effectiveCredentialId &&
|
||||
host.authType === "credential" &&
|
||||
host.folder
|
||||
) {
|
||||
try {
|
||||
effectiveCredentialId = await repository.findFolderCredentialId(
|
||||
ownerId,
|
||||
host.folder as string,
|
||||
);
|
||||
host.authType = host.key ? "key" : host.password ? "password" : "none";
|
||||
} catch (e) {
|
||||
sshLogger.warn("Failed to resolve folder credential for host", {
|
||||
operation: "host_resolver_folder_credential",
|
||||
hostId,
|
||||
error: e instanceof Error ? e.message : "Unknown",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (effectiveCredentialId) {
|
||||
try {
|
||||
const cred = (await repository.findCredentialByIdForUser(
|
||||
effectiveCredentialId,
|
||||
ownerId,
|
||||
)) as Record<string, unknown> | null;
|
||||
|
||||
if (cred) {
|
||||
host.password = pickResolvedPassword(host.password, cred.password);
|
||||
// Prefer the normalised private key; fall back to raw key field
|
||||
host.key = (cred.privateKey || cred.key) as string | null;
|
||||
host.keyPassword = cred.keyPassword;
|
||||
host.keyType = cred.keyType;
|
||||
// CA-signed certificate for cert-based auth
|
||||
(host as Record<string, unknown>).certPublicKey =
|
||||
cred.certPublicKey || null;
|
||||
host.username = pickResolvedUsername(
|
||||
host.username,
|
||||
cred.username,
|
||||
host.overrideCredentialUsername,
|
||||
);
|
||||
host.authType = host.key
|
||||
? "key"
|
||||
: host.password
|
||||
? "password"
|
||||
: "none";
|
||||
}
|
||||
} catch (e) {
|
||||
sshLogger.warn("Failed to resolve credential for host", {
|
||||
operation: "host_resolver_credential",
|
||||
hostId,
|
||||
error: e instanceof Error ? e.message : "Unknown",
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
sshLogger.warn("Failed to resolve credential for host", {
|
||||
operation: "host_resolver_credential",
|
||||
hostId,
|
||||
error: e instanceof Error ? e.message : "Unknown",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ import { collectSystemMetrics } from "./widgets/system-collector.js";
|
||||
import { collectLoginStats } from "./widgets/login-stats-collector.js";
|
||||
import { collectPortsMetrics } from "./widgets/ports-collector.js";
|
||||
import { collectFirewallMetrics } from "./widgets/firewall-collector.js";
|
||||
import { collectTemperatureMetrics } from "./widgets/temperature-collector.js";
|
||||
import {
|
||||
createSocks5Connection,
|
||||
type SOCKS5Config,
|
||||
@@ -146,6 +147,7 @@ const DEFAULT_STATS_CONFIG: StatsConfig = {
|
||||
"processes",
|
||||
"ports",
|
||||
"firewall",
|
||||
"temperature",
|
||||
],
|
||||
statusCheckEnabled: true,
|
||||
statusCheckInterval: 60,
|
||||
@@ -1582,6 +1584,21 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
|
||||
// expected
|
||||
}
|
||||
|
||||
let temperature: {
|
||||
source: "sysfs" | "sensors" | "none";
|
||||
highestCelsius: number | null;
|
||||
sensors: Array<{ label: string; celsius: number }>;
|
||||
} = {
|
||||
source: "none",
|
||||
highestCelsius: null,
|
||||
sensors: [],
|
||||
};
|
||||
try {
|
||||
temperature = await collectTemperatureMetrics(client);
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
|
||||
const result = {
|
||||
cpu,
|
||||
memory,
|
||||
@@ -1593,6 +1610,7 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
|
||||
login_stats,
|
||||
ports,
|
||||
firewall,
|
||||
temperature,
|
||||
};
|
||||
|
||||
metricsCache.set(host.id, result);
|
||||
|
||||
@@ -1,6 +1,67 @@
|
||||
import type { Client } from "ssh2";
|
||||
import { execCommand, toFixedNum } from "./common-utils.js";
|
||||
|
||||
const PSEUDO_FS_RE = /^(tmpfs|devtmpfs|overlay|udev|none|shm)$/;
|
||||
|
||||
export interface DfRow {
|
||||
filesystem: string;
|
||||
mount: string;
|
||||
parts: string[];
|
||||
}
|
||||
|
||||
export function parseDfLines(output: string): DfRow[] {
|
||||
return output
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const parts = line.split(/\s+/);
|
||||
return { filesystem: parts[0] || "", mount: parts[5] || "", parts };
|
||||
})
|
||||
.filter(
|
||||
(row) => row.parts.length >= 6 && !PSEUDO_FS_RE.test(row.filesystem),
|
||||
);
|
||||
}
|
||||
|
||||
// Finds the index of the most-utilized real filesystem in a `df -B1`-style
|
||||
// row set (parts[1] = total bytes, parts[2] = used bytes), so a nearly-full
|
||||
// secondary mount (e.g. /data) isn't hidden behind a healthy root filesystem.
|
||||
export function findWorstMountIndex(bytesRows: DfRow[]): {
|
||||
index: number;
|
||||
usedBytes: number;
|
||||
totalBytes: number;
|
||||
} {
|
||||
let worstIndex = -1;
|
||||
let worstUsedBytes = -1;
|
||||
let worstTotalBytes = 0;
|
||||
|
||||
bytesRows.forEach((row, index) => {
|
||||
const totalBytes = Number(row.parts[1]);
|
||||
const usedBytes = Number(row.parts[2]);
|
||||
if (
|
||||
!Number.isFinite(totalBytes) ||
|
||||
!Number.isFinite(usedBytes) ||
|
||||
totalBytes <= 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const usedRatio = usedBytes / totalBytes;
|
||||
const worstRatio =
|
||||
worstTotalBytes > 0 ? worstUsedBytes / worstTotalBytes : -1;
|
||||
if (usedRatio > worstRatio) {
|
||||
worstIndex = index;
|
||||
worstUsedBytes = usedBytes;
|
||||
worstTotalBytes = totalBytes;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
index: worstIndex,
|
||||
usedBytes: worstUsedBytes,
|
||||
totalBytes: worstTotalBytes,
|
||||
};
|
||||
}
|
||||
|
||||
export async function collectDiskMetrics(client: Client): Promise<{
|
||||
percent: number | null;
|
||||
usedHuman: string | null;
|
||||
@@ -14,41 +75,28 @@ export async function collectDiskMetrics(client: Client): Promise<{
|
||||
|
||||
try {
|
||||
const [diskOutHuman, diskOutBytes] = await Promise.all([
|
||||
execCommand(client, "df -h -P / | tail -n +2"),
|
||||
execCommand(client, "df -B1 -P / | tail -n +2"),
|
||||
execCommand(client, "df -h -P | tail -n +2"),
|
||||
execCommand(client, "df -B1 -P | tail -n +2"),
|
||||
]);
|
||||
|
||||
const humanLine =
|
||||
diskOutHuman.stdout
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)[0] || "";
|
||||
const bytesLine =
|
||||
diskOutBytes.stdout
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)[0] || "";
|
||||
const humanRows = parseDfLines(diskOutHuman.stdout);
|
||||
const bytesRows = parseDfLines(diskOutBytes.stdout);
|
||||
const worst = findWorstMountIndex(bytesRows);
|
||||
|
||||
const humanParts = humanLine.split(/\s+/);
|
||||
const bytesParts = bytesLine.split(/\s+/);
|
||||
if (worst.totalBytes > 0) {
|
||||
diskPercent = Math.max(
|
||||
0,
|
||||
Math.min(100, (worst.usedBytes / worst.totalBytes) * 100),
|
||||
);
|
||||
|
||||
if (humanParts.length >= 6 && bytesParts.length >= 6) {
|
||||
totalHuman = humanParts[1] || null;
|
||||
usedHuman = humanParts[2] || null;
|
||||
availableHuman = humanParts[3] || null;
|
||||
|
||||
const totalBytes = Number(bytesParts[1]);
|
||||
const usedBytes = Number(bytesParts[2]);
|
||||
|
||||
if (
|
||||
Number.isFinite(totalBytes) &&
|
||||
Number.isFinite(usedBytes) &&
|
||||
totalBytes > 0
|
||||
) {
|
||||
diskPercent = Math.max(
|
||||
0,
|
||||
Math.min(100, (usedBytes / totalBytes) * 100),
|
||||
);
|
||||
const humanRow =
|
||||
humanRows.length === bytesRows.length
|
||||
? humanRows[worst.index]
|
||||
: humanRows.find((row) => row.mount === bytesRows[worst.index].mount);
|
||||
if (humanRow) {
|
||||
totalHuman = humanRow.parts[1] || null;
|
||||
usedHuman = humanRow.parts[2] || null;
|
||||
availableHuman = humanRow.parts[3] || null;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -21,6 +21,12 @@ const authManager = AuthManager.getInstance();
|
||||
|
||||
const wss = new WebSocketServer({ port: 30011 });
|
||||
|
||||
wss.on("error", (error) => {
|
||||
sshLogger.error("Serial WebSocket server error", error, {
|
||||
operation: "wss_error",
|
||||
});
|
||||
});
|
||||
|
||||
wss.on("connection", async (ws: WebSocket, req) => {
|
||||
let userId: string | undefined;
|
||||
|
||||
|
||||
@@ -0,0 +1,539 @@
|
||||
import crypto from "crypto";
|
||||
import express from "express";
|
||||
import type { Request, Response } from "express";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { PermissionManager } from "../../utils/permission-manager.js";
|
||||
import { sshLogger } from "../../utils/logger.js";
|
||||
import { sessionManager } from "../terminal/session-manager.js";
|
||||
import { getGuacSessionInfo } from "../guacamole/guacamole-server.js";
|
||||
import { GuacamoleTokenService } from "../guacamole/token-service.js";
|
||||
import {
|
||||
createCurrentSessionShareRepository,
|
||||
createCurrentSettingsRepository,
|
||||
createCurrentHostResolutionRepository,
|
||||
} from "../../database/repositories/factory.js";
|
||||
|
||||
const router = express.Router();
|
||||
const authManager = AuthManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
const permissionManager = PermissionManager.getInstance();
|
||||
const tokenService = GuacamoleTokenService.getInstance();
|
||||
|
||||
const DEFAULT_EXPIRY_HOURS = 24;
|
||||
const MAX_EXPIRY_HOURS = 24 * 30;
|
||||
|
||||
type Protocol = "ssh" | "rdp" | "vnc" | "telnet";
|
||||
type PermissionLevel = "read-only" | "read-write";
|
||||
|
||||
interface ResolveRateEntry {
|
||||
count: number;
|
||||
windowStart: number;
|
||||
}
|
||||
const resolveAttempts = new Map<string, ResolveRateEntry>();
|
||||
const RESOLVE_WINDOW_MS = 60 * 1000;
|
||||
const RESOLVE_MAX_ATTEMPTS = 30;
|
||||
|
||||
function isResolveRateLimited(ip: string): boolean {
|
||||
const now = Date.now();
|
||||
const entry = resolveAttempts.get(ip);
|
||||
if (!entry || now - entry.windowStart > RESOLVE_WINDOW_MS) {
|
||||
resolveAttempts.set(ip, { count: 1, windowStart: now });
|
||||
return false;
|
||||
}
|
||||
entry.count += 1;
|
||||
return entry.count > RESOLVE_MAX_ATTEMPTS;
|
||||
}
|
||||
|
||||
setInterval(
|
||||
() => {
|
||||
const now = Date.now();
|
||||
for (const [ip, entry] of resolveAttempts.entries()) {
|
||||
if (now - entry.windowStart > RESOLVE_WINDOW_MS)
|
||||
resolveAttempts.delete(ip);
|
||||
}
|
||||
},
|
||||
5 * 60 * 1000,
|
||||
);
|
||||
|
||||
async function isSharingEnabledForHost(hostId: number): Promise<{
|
||||
enabled: boolean;
|
||||
hostOwnerId: string | null;
|
||||
}> {
|
||||
const globalEnabled = await createCurrentSettingsRepository().getBoolean(
|
||||
"session_sharing_globally_enabled",
|
||||
true,
|
||||
);
|
||||
if (!globalEnabled) return { enabled: false, hostOwnerId: null };
|
||||
|
||||
const hostResolutionRepository = createCurrentHostResolutionRepository();
|
||||
const hostOwnerId = await hostResolutionRepository.findHostOwnerId(hostId);
|
||||
if (!hostOwnerId) return { enabled: false, hostOwnerId: null };
|
||||
|
||||
const host = await hostResolutionRepository.findHostById(hostId, hostOwnerId);
|
||||
if (!host) return { enabled: false, hostOwnerId: null };
|
||||
|
||||
return {
|
||||
enabled: host.allowSessionSharing !== false,
|
||||
hostOwnerId,
|
||||
};
|
||||
}
|
||||
|
||||
function computeExpiresAt(expiryHours: number | undefined): string {
|
||||
const hours = Math.min(
|
||||
Math.max(expiryHours ?? DEFAULT_EXPIRY_HOURS, 1),
|
||||
MAX_EXPIRY_HOURS,
|
||||
);
|
||||
return new Date(Date.now() + hours * 60 * 60 * 1000).toISOString();
|
||||
}
|
||||
|
||||
function isLiveSessionOwnedBy(
|
||||
protocol: Protocol,
|
||||
sessionId: string,
|
||||
userId: string,
|
||||
): boolean {
|
||||
if (protocol === "ssh") {
|
||||
const session = sessionManager.getSession(sessionId);
|
||||
return !!session && session.isConnected && session.userId === userId;
|
||||
}
|
||||
const info = getGuacSessionInfo(sessionId);
|
||||
return !!info && info.ownerUserId === userId;
|
||||
}
|
||||
|
||||
function isLiveSession(protocol: Protocol, sessionId: string): boolean {
|
||||
if (protocol === "ssh") {
|
||||
const session = sessionManager.getSession(sessionId);
|
||||
return !!session && session.isConnected;
|
||||
}
|
||||
return !!getGuacSessionInfo(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /session-sharing/create:
|
||||
* post:
|
||||
* summary: Create a session share (link or targeted user)
|
||||
* description: Mints a share grant for a live terminal/RDP/VNC/Telnet session. Caller must own the live session.
|
||||
* tags:
|
||||
* - Session Sharing
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - hostId
|
||||
* - sessionId
|
||||
* - protocol
|
||||
* - shareType
|
||||
* - permissionLevel
|
||||
* properties:
|
||||
* hostId:
|
||||
* type: integer
|
||||
* sessionId:
|
||||
* type: string
|
||||
* tabInstanceId:
|
||||
* type: string
|
||||
* protocol:
|
||||
* type: string
|
||||
* enum: [ssh, rdp, vnc, telnet]
|
||||
* shareType:
|
||||
* type: string
|
||||
* enum: [link, user]
|
||||
* targetUserId:
|
||||
* type: string
|
||||
* permissionLevel:
|
||||
* type: string
|
||||
* enum: [read-only, read-write]
|
||||
* expiryHours:
|
||||
* type: number
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Share created
|
||||
* 400:
|
||||
* description: Invalid request
|
||||
* 403:
|
||||
* description: Sharing disabled, or caller does not own the session
|
||||
* 500:
|
||||
* description: Server error
|
||||
*/
|
||||
router.post("/create", authenticateJWT, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId!;
|
||||
const {
|
||||
hostId,
|
||||
sessionId,
|
||||
tabInstanceId,
|
||||
protocol,
|
||||
shareType,
|
||||
targetUserId,
|
||||
permissionLevel,
|
||||
expiryHours,
|
||||
} = req.body ?? {};
|
||||
|
||||
if (!hostId || !sessionId || !protocol || !shareType || !permissionLevel) {
|
||||
return res.status(400).json({ error: "Missing required fields" });
|
||||
}
|
||||
if (!["ssh", "rdp", "vnc", "telnet"].includes(protocol)) {
|
||||
return res.status(400).json({ error: "Invalid protocol" });
|
||||
}
|
||||
if (!["link", "user"].includes(shareType)) {
|
||||
return res.status(400).json({ error: "Invalid shareType" });
|
||||
}
|
||||
if (!["read-only", "read-write"].includes(permissionLevel)) {
|
||||
return res.status(400).json({ error: "Invalid permissionLevel" });
|
||||
}
|
||||
if (shareType === "user" && !targetUserId) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "targetUserId is required for user shares" });
|
||||
}
|
||||
|
||||
const numericHostId = Number(hostId);
|
||||
|
||||
const { enabled: sharingEnabled } =
|
||||
await isSharingEnabledForHost(numericHostId);
|
||||
if (!sharingEnabled) {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: "Session sharing is disabled for this host" });
|
||||
}
|
||||
|
||||
if (!isLiveSessionOwnedBy(protocol, String(sessionId), userId)) {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: "You do not own this live session" });
|
||||
}
|
||||
|
||||
if (shareType === "user") {
|
||||
const accessInfo = await permissionManager.canAccessHost(
|
||||
targetUserId,
|
||||
numericHostId,
|
||||
"connect",
|
||||
);
|
||||
if (!accessInfo.hasAccess) {
|
||||
return res.status(403).json({
|
||||
error: "Target user does not have access to this host",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const shareId = crypto.randomUUID();
|
||||
const linkToken =
|
||||
shareType === "link"
|
||||
? crypto.randomBytes(24).toString("base64url")
|
||||
: null;
|
||||
const expiresAt = computeExpiresAt(expiryHours);
|
||||
|
||||
const created = await createCurrentSessionShareRepository().create({
|
||||
id: shareId,
|
||||
hostId: numericHostId,
|
||||
ownerUserId: userId,
|
||||
protocol,
|
||||
sessionId: String(sessionId),
|
||||
tabInstanceId: tabInstanceId ?? null,
|
||||
shareType,
|
||||
targetUserId: shareType === "user" ? targetUserId : null,
|
||||
linkToken,
|
||||
permissionLevel,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
res.json({
|
||||
shareId: created.id,
|
||||
linkToken: created.linkToken,
|
||||
expiresAt: created.expiresAt,
|
||||
});
|
||||
} catch (error) {
|
||||
sshLogger.error("Failed to create session share", error, {
|
||||
operation: "session_share_create_error",
|
||||
});
|
||||
res.status(500).json({ error: "Failed to create session share" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /session-sharing/host/{hostId}/active:
|
||||
* get:
|
||||
* summary: List active session shares for a host
|
||||
* description: Returns active (non-revoked, non-expired) shares owned by the caller for the given host.
|
||||
* tags:
|
||||
* - Session Sharing
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: hostId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of active shares
|
||||
* 400:
|
||||
* description: Invalid host id
|
||||
* 500:
|
||||
* description: Server error
|
||||
*/
|
||||
router.get(
|
||||
"/host/:hostId/active",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId!;
|
||||
const hostId = Number.parseInt(String(req.params.hostId), 10);
|
||||
if (!hostId || Number.isNaN(hostId)) {
|
||||
return res.status(400).json({ error: "Invalid host ID" });
|
||||
}
|
||||
|
||||
const shares =
|
||||
await createCurrentSessionShareRepository().findActiveSharesForHost(
|
||||
hostId,
|
||||
userId,
|
||||
);
|
||||
|
||||
res.json({ shares });
|
||||
} catch (error) {
|
||||
sshLogger.error("Failed to list session shares", error, {
|
||||
operation: "session_share_list_error",
|
||||
});
|
||||
res.status(500).json({ error: "Failed to list session shares" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /session-sharing/{shareId}:
|
||||
* delete:
|
||||
* summary: Revoke a session share
|
||||
* description: Revokes a share. Owner or admin only. Best-effort kick of live SSH participants; guac joins are not force-disconnected in v1.
|
||||
* tags:
|
||||
* - Session Sharing
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: shareId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Share revoked
|
||||
* 403:
|
||||
* description: Not authorized to revoke this share
|
||||
* 404:
|
||||
* description: Share not found
|
||||
* 500:
|
||||
* description: Server error
|
||||
*/
|
||||
router.delete(
|
||||
"/:shareId",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId!;
|
||||
const shareId = String(req.params.shareId);
|
||||
|
||||
const repository = createCurrentSessionShareRepository();
|
||||
const share = await repository.findById(shareId);
|
||||
if (!share) {
|
||||
return res.status(404).json({ error: "Share not found" });
|
||||
}
|
||||
|
||||
let revoked = await repository.revoke(shareId, userId);
|
||||
if (!revoked) {
|
||||
if (await permissionManager.isAdmin(userId)) {
|
||||
revoked = await repository.revokeAsAdmin(shareId);
|
||||
}
|
||||
}
|
||||
|
||||
if (!revoked) {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: "Not authorized to revoke this share" });
|
||||
}
|
||||
|
||||
// Best-effort kick of live participants. SSH sessions support ending
|
||||
// just the guests via ownerEndSession; guac joins aren't force-kickable
|
||||
// from a REST handler (guacamole-lite exposes no kick API), so a revoked
|
||||
// guac link only blocks *future* resolves until the guest's own socket ends.
|
||||
if (share.protocol === "ssh") {
|
||||
try {
|
||||
sessionManager.ownerEndSession(
|
||||
share.sessionId,
|
||||
"Session share revoked by owner",
|
||||
);
|
||||
} catch {
|
||||
// best-effort only
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
sshLogger.error("Failed to revoke session share", error, {
|
||||
operation: "session_share_revoke_error",
|
||||
});
|
||||
res.status(500).json({ error: "Failed to revoke session share" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /session-sharing/resolve/{linkToken}:
|
||||
* get:
|
||||
* summary: Resolve a guest share link
|
||||
* description: Public, unauthenticated endpoint for anonymous share-link guests. Never returns host name, IP, username, or hostId. Rate-limited per IP.
|
||||
* tags:
|
||||
* - Session Sharing
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: linkToken
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Resolved share connection info
|
||||
* 404:
|
||||
* description: Link not found, expired, revoked, or sharing disabled
|
||||
* 429:
|
||||
* description: Too many requests
|
||||
* 500:
|
||||
* description: Server error
|
||||
*/
|
||||
router.get("/resolve/:linkToken", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const ip = req.ip || req.socket.remoteAddress || "unknown";
|
||||
if (isResolveRateLimited(ip)) {
|
||||
return res.status(429).json({ error: "Too many requests" });
|
||||
}
|
||||
|
||||
const linkToken = String(req.params.linkToken);
|
||||
const repository = createCurrentSessionShareRepository();
|
||||
const share = await repository.findByLinkToken(linkToken);
|
||||
if (!share) {
|
||||
return res.status(404).json({ error: "Link not found or expired" });
|
||||
}
|
||||
|
||||
const { enabled: sharingEnabled } = await isSharingEnabledForHost(
|
||||
share.hostId,
|
||||
);
|
||||
if (!sharingEnabled) {
|
||||
return res.status(404).json({ error: "Link not found or expired" });
|
||||
}
|
||||
|
||||
const protocol = share.protocol as Protocol;
|
||||
if (!isLiveSession(protocol, share.sessionId)) {
|
||||
return res.status(404).json({ error: "Session is no longer active" });
|
||||
}
|
||||
|
||||
// Field-by-field by design - never spread a host row into this response.
|
||||
// Anonymous guests must never see hostname/IP/username/hostId (decision #5).
|
||||
const response: {
|
||||
protocol: Protocol;
|
||||
permissionLevel: PermissionLevel;
|
||||
wsPath: string;
|
||||
connectParams?: Record<string, string>;
|
||||
} = {
|
||||
protocol,
|
||||
permissionLevel: share.permissionLevel as PermissionLevel,
|
||||
wsPath:
|
||||
protocol === "ssh"
|
||||
? `/terminal/ws?shareToken=${encodeURIComponent(linkToken)}`
|
||||
: "/guacamole/websocket/",
|
||||
};
|
||||
|
||||
if (protocol !== "ssh") {
|
||||
const joinToken = tokenService.createJoinToken(
|
||||
share.sessionId,
|
||||
share.permissionLevel === "read-only",
|
||||
);
|
||||
response.connectParams = { token: joinToken };
|
||||
}
|
||||
|
||||
try {
|
||||
await repository.touchShareUsage(share.id);
|
||||
await repository.recordParticipantJoin(share.id, null, "Guest");
|
||||
} catch {
|
||||
// best-effort, never fail the resolve response over audit bookkeeping
|
||||
}
|
||||
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
sshLogger.error("Failed to resolve session share link", error, {
|
||||
operation: "session_share_resolve_error",
|
||||
});
|
||||
res.status(500).json({ error: "Failed to resolve share link" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /session-sharing/{shareId}/end:
|
||||
* post:
|
||||
* summary: End a shared session for all participants
|
||||
* description: Owner-only. Terminates the underlying session and notifies joined participants. Guac protocol kick is best-effort in v1.
|
||||
* tags:
|
||||
* - Session Sharing
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: shareId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Session ended
|
||||
* 403:
|
||||
* description: Not the owner of this share
|
||||
* 404:
|
||||
* description: Share not found
|
||||
* 500:
|
||||
* description: Server error
|
||||
*/
|
||||
router.post(
|
||||
"/:shareId/end",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId!;
|
||||
const shareId = String(req.params.shareId);
|
||||
|
||||
const repository = createCurrentSessionShareRepository();
|
||||
const share = await repository.findById(shareId);
|
||||
if (!share) {
|
||||
return res.status(404).json({ error: "Share not found" });
|
||||
}
|
||||
if (share.ownerUserId !== userId) {
|
||||
return res.status(403).json({ error: "Not the owner of this share" });
|
||||
}
|
||||
|
||||
if (share.protocol === "ssh") {
|
||||
sessionManager.ownerEndSession(
|
||||
share.sessionId,
|
||||
"Session ended by owner",
|
||||
);
|
||||
}
|
||||
// Guac protocols: no kick API available from a REST handler in v1 - see
|
||||
// DELETE /:shareId for the same limitation.
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
sshLogger.error("Failed to end shared session", error, {
|
||||
operation: "session_share_end_error",
|
||||
});
|
||||
res.status(500).json({ error: "Failed to end shared session" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -20,7 +20,14 @@ import { SSHAuthManager } from "../auth-manager.js";
|
||||
import type { ProxyNode } from "../../../types/index.js";
|
||||
import { SSHHostKeyVerifier } from "../host-key-verifier.js";
|
||||
import { createJumpHostChain } from "../jump-host-chain.js";
|
||||
import { sessionManager } from "./session-manager.js";
|
||||
import {
|
||||
sessionManager,
|
||||
isMessageAllowedForParticipant,
|
||||
} from "./session-manager.js";
|
||||
import {
|
||||
createCurrentSessionShareRepository,
|
||||
createCurrentSettingsRepository,
|
||||
} from "../../database/repositories/factory.js";
|
||||
import {
|
||||
detectTmux,
|
||||
attachOrCreateTmuxSession,
|
||||
@@ -105,10 +112,172 @@ const wss = new WebSocketServer({
|
||||
port: 30002,
|
||||
});
|
||||
|
||||
wss.on("error", (error) => {
|
||||
sshLogger.error("WebSocket server error", error, {
|
||||
operation: "wss_error",
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Auth path for anonymous share-link guests (?shareToken=<linkToken>).
|
||||
* Never touches DataCrypto/user credentials - guests join an already-live
|
||||
* stream and never decrypt stored secrets.
|
||||
*/
|
||||
async function handleShareTokenConnection(
|
||||
ws: WebSocket,
|
||||
req: import("http").IncomingMessage,
|
||||
shareToken: string,
|
||||
): Promise<void> {
|
||||
const shareRepo = createCurrentSessionShareRepository();
|
||||
const share = await shareRepo.findByLinkToken(shareToken);
|
||||
if (!share) {
|
||||
ws.close(1008, "Invalid or expired share link");
|
||||
return;
|
||||
}
|
||||
if (share.protocol !== "ssh") {
|
||||
ws.close(1008, "Unsupported share protocol");
|
||||
return;
|
||||
}
|
||||
|
||||
const globallyEnabled = await createCurrentSettingsRepository().getBoolean(
|
||||
"session_sharing_globally_enabled",
|
||||
true,
|
||||
);
|
||||
if (!globallyEnabled) {
|
||||
ws.close(1008, "Session sharing is disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
const host = await createCurrentHostResolutionRepository().findHostById(
|
||||
share.hostId,
|
||||
share.ownerUserId,
|
||||
);
|
||||
if (!host || host.allowSessionSharing === false) {
|
||||
ws.close(1008, "Session sharing is disabled for this host");
|
||||
return;
|
||||
}
|
||||
|
||||
const session = sessionManager.getSession(share.sessionId);
|
||||
if (!session || !session.isConnected) {
|
||||
ws.close(1008, "Session has ended");
|
||||
return;
|
||||
}
|
||||
|
||||
const permissionLevel = share.permissionLevel as "read-write" | "read-only";
|
||||
const joined = sessionManager.joinAsParticipant(share.sessionId, ws, {
|
||||
userId: null,
|
||||
permissionLevel,
|
||||
guestLabel: "Guest",
|
||||
shareId: share.id,
|
||||
});
|
||||
if (!joined) {
|
||||
ws.close(1008, "Session is no longer active");
|
||||
return;
|
||||
}
|
||||
|
||||
shareRepo.touchShareUsage(share.id).catch(() => {});
|
||||
shareRepo.recordParticipantJoin(share.id, null, "Guest").catch(() => {});
|
||||
|
||||
const buffered = sessionManager.getBuffer(joined);
|
||||
if (buffered) {
|
||||
ws.send(JSON.stringify({ type: "data", data: buffered }));
|
||||
}
|
||||
ws.send(
|
||||
JSON.stringify({ type: "sessionAttached", sessionId: share.sessionId }),
|
||||
);
|
||||
ws.send(JSON.stringify({ type: "connected", message: "Joined session" }));
|
||||
|
||||
const currentSessionId: string = share.sessionId;
|
||||
|
||||
let wsAlive = true;
|
||||
ws.on("pong", () => {
|
||||
wsAlive = true;
|
||||
});
|
||||
const wsPingInterval = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
if (!wsAlive) {
|
||||
ws.terminate();
|
||||
return;
|
||||
}
|
||||
wsAlive = false;
|
||||
ws.ping();
|
||||
} else {
|
||||
clearInterval(wsPingInterval);
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
ws.on("close", () => {
|
||||
clearInterval(wsPingInterval);
|
||||
sessionManager.removeParticipant(currentSessionId, ws);
|
||||
sshLogger.info("Guest left shared terminal session", {
|
||||
operation: "terminal_guest_disconnect",
|
||||
sessionId: currentSessionId,
|
||||
shareId: share.id,
|
||||
});
|
||||
});
|
||||
|
||||
ws.on("message", (msg: RawData) => {
|
||||
let parsed: WebSocketMessage;
|
||||
try {
|
||||
parsed = JSON.parse(msg.toString()) as WebSocketMessage;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const { type, data } = parsed;
|
||||
|
||||
const liveSession = sessionManager.getSession(currentSessionId);
|
||||
const participant = liveSession
|
||||
? sessionManager.getParticipantForWs(liveSession, ws)
|
||||
: null;
|
||||
if (!isMessageAllowedForParticipant(participant, type)) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case "input": {
|
||||
const inputData = data as string;
|
||||
sessionManager.bufferInput(currentSessionId, inputData);
|
||||
const inputStream = liveSession?.sshStream;
|
||||
if (inputStream) {
|
||||
try {
|
||||
inputStream.write(Buffer.from(inputData, "utf8"));
|
||||
} catch {
|
||||
inputStream.write(Buffer.from(inputData, "latin1"));
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "ping":
|
||||
ws.send(JSON.stringify({ type: "pong" }));
|
||||
break;
|
||||
case "disconnect":
|
||||
sessionManager.removeParticipant(currentSessionId, ws);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
wss.on("connection", async (ws: WebSocket, req) => {
|
||||
let userId: string | undefined;
|
||||
let sessionId: string | undefined;
|
||||
|
||||
ws.on("error", (error) => {
|
||||
sshLogger.error("WebSocket connection error", error, {
|
||||
operation: "ws_error",
|
||||
sessionId,
|
||||
});
|
||||
});
|
||||
|
||||
const urlObj = new URL(req.url || "", "http://localhost");
|
||||
const shareToken = urlObj.searchParams.get("shareToken");
|
||||
|
||||
if (shareToken) {
|
||||
await handleShareTokenConnection(ws, req, shareToken);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let token: string | undefined;
|
||||
|
||||
@@ -126,7 +295,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
const urlObj = new URL(req.url || "", "http://localhost");
|
||||
const qp = urlObj.searchParams.get("token");
|
||||
if (qp) token = qp;
|
||||
}
|
||||
@@ -242,11 +410,20 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
if (currentSessionId) {
|
||||
const session = sessionManager.getSession(currentSessionId);
|
||||
if (session?.isConnected) {
|
||||
// Only detach if this WS is still the one attached to the session.
|
||||
// If a refresh reconnected and reattached a new WS before this close
|
||||
// event fired, we must not clobber that new attachment.
|
||||
if (session.attachedWs === ws || session.attachedWs === null) {
|
||||
sessionManager.detachWs(currentSessionId);
|
||||
const participant = sessionManager.getParticipantForWs(session, ws);
|
||||
if (participant && !participant.isOwner) {
|
||||
sessionManager.removeParticipant(currentSessionId, ws);
|
||||
} else {
|
||||
// Only detach if this WS is still the owner's attached socket, or
|
||||
// no owner is currently attached. If a refresh reconnected and
|
||||
// reattached a new WS before this close event fired, we must not
|
||||
// clobber that new attachment.
|
||||
const ownerStillAttached = Array.from(
|
||||
session.participants.values(),
|
||||
).some((p) => p.isOwner && p.ws !== ws);
|
||||
if (!ownerStillAttached) {
|
||||
sessionManager.detachWs(currentSessionId);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
sessionManager.destroySession(currentSessionId);
|
||||
@@ -295,6 +472,21 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
|
||||
const { type, data } = parsed;
|
||||
|
||||
// Server-side gate: non-owner participants (read-only or read-write
|
||||
// guests/joiners) may only send input/ping/disconnect - everything else
|
||||
// (auth flows, tmux, resize, etc.) is owner-only and silently ignored.
|
||||
if (type !== "joinSharedSession") {
|
||||
const gateSession = currentSessionId
|
||||
? sessionManager.getSession(currentSessionId)
|
||||
: null;
|
||||
const gateParticipant = gateSession
|
||||
? sessionManager.getParticipantForWs(gateSession, ws)
|
||||
: null;
|
||||
if (!isMessageAllowedForParticipant(gateParticipant, type)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case "connectToHost": {
|
||||
const connectData = data as ConnectToHostData;
|
||||
@@ -445,7 +637,20 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
break;
|
||||
}
|
||||
|
||||
case "disconnect":
|
||||
case "disconnect": {
|
||||
const disconnectSession = currentSessionId
|
||||
? sessionManager.getSession(currentSessionId)
|
||||
: null;
|
||||
const disconnectParticipant = disconnectSession
|
||||
? sessionManager.getParticipantForWs(disconnectSession, ws)
|
||||
: null;
|
||||
if (disconnectParticipant && !disconnectParticipant.isOwner) {
|
||||
if (currentSessionId) {
|
||||
sessionManager.removeParticipant(currentSessionId, ws);
|
||||
currentSessionId = null;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (currentSessionId) {
|
||||
sessionManager.destroySession(currentSessionId);
|
||||
currentSessionId = null;
|
||||
@@ -454,6 +659,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
sshConn = null;
|
||||
sshStream = null;
|
||||
break;
|
||||
}
|
||||
|
||||
case "get_cwd": {
|
||||
const activeConn =
|
||||
@@ -474,10 +680,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
execStream.stderr.on("data", () => {});
|
||||
execStream.on("close", () => {
|
||||
const cwd = stdout.trim() || "/";
|
||||
const attachedWs =
|
||||
sessionManager.getSession(currentSessionId)?.attachedWs ?? ws;
|
||||
if (attachedWs.readyState === WebSocket.OPEN) {
|
||||
attachedWs.send(JSON.stringify({ type: "cwd", path: cwd }));
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "cwd", path: cwd }));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -517,10 +721,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
execStream.stderr.on("data", () => {});
|
||||
execStream.on("close", () => {
|
||||
const resolvedPath = stdout.trim() || requestedPath;
|
||||
const attachedWs =
|
||||
sessionManager.getSession(currentSessionId)?.attachedWs ?? ws;
|
||||
if (attachedWs.readyState === WebSocket.OPEN) {
|
||||
attachedWs.send(
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "open_file_in_editor",
|
||||
path: resolvedPath,
|
||||
@@ -1001,6 +1203,105 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
break;
|
||||
}
|
||||
|
||||
case "joinSharedSession": {
|
||||
const joinData = data as { shareId: string; tabInstanceId?: string };
|
||||
try {
|
||||
const shareRepo = createCurrentSessionShareRepository();
|
||||
const share = await shareRepo.findActiveById(joinData.shareId);
|
||||
if (
|
||||
!share ||
|
||||
share.shareType !== "user" ||
|
||||
share.targetUserId !== userId ||
|
||||
share.protocol !== "ssh"
|
||||
) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "Share not found or not accessible",
|
||||
}),
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
const { PermissionManager } =
|
||||
await import("../../utils/permission-manager.js");
|
||||
const access = await PermissionManager.getInstance().canAccessHost(
|
||||
userId,
|
||||
share.hostId,
|
||||
"connect",
|
||||
);
|
||||
if (!access.hasAccess) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "Share not found or not accessible",
|
||||
}),
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
const joinedSession = sessionManager.joinAsParticipant(
|
||||
share.sessionId,
|
||||
ws,
|
||||
{
|
||||
userId,
|
||||
permissionLevel: share.permissionLevel as
|
||||
| "read-write"
|
||||
| "read-only",
|
||||
tabInstanceId: joinData.tabInstanceId,
|
||||
shareId: share.id,
|
||||
},
|
||||
);
|
||||
if (!joinedSession) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "Shared session is no longer active",
|
||||
}),
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
currentSessionId = share.sessionId;
|
||||
sshStream = joinedSession.sshStream;
|
||||
sshConn = joinedSession.sshConn;
|
||||
isConnecting = false;
|
||||
isConnected = true;
|
||||
|
||||
shareRepo.touchShareUsage(share.id).catch(() => {});
|
||||
shareRepo
|
||||
.recordParticipantJoin(share.id, userId, null)
|
||||
.catch(() => {});
|
||||
|
||||
const buffered = sessionManager.getBuffer(joinedSession);
|
||||
if (buffered) {
|
||||
ws.send(JSON.stringify({ type: "data", data: buffered }));
|
||||
}
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "sessionAttached",
|
||||
sessionId: share.sessionId,
|
||||
}),
|
||||
);
|
||||
ws.send(
|
||||
JSON.stringify({ type: "connected", message: "Joined session" }),
|
||||
);
|
||||
} catch (error) {
|
||||
sshLogger.error("Failed to join shared session", error, {
|
||||
operation: "terminal_join_shared_session_error",
|
||||
userId,
|
||||
shareId: joinData.shareId,
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "Failed to join shared session",
|
||||
}),
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
sshLogger.warn("Unknown message type received", {
|
||||
operation: "websocket_message_unknown_type",
|
||||
@@ -1288,39 +1589,56 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
};
|
||||
}
|
||||
|
||||
sendLog("dns", "info", `Starting address resolution of ${ip}`);
|
||||
const connectsViaJumpHosts = !!(
|
||||
hostConfig.jumpHosts &&
|
||||
hostConfig.jumpHosts.length > 0 &&
|
||||
hostConfig.userId
|
||||
);
|
||||
|
||||
let connectHost = ip;
|
||||
try {
|
||||
const resolution = await resolveHostForSshConnect(ip);
|
||||
connectHost = resolution.host;
|
||||
if (resolution.resolvedAddress && resolution.resolvedAddress !== ip) {
|
||||
sendLog(
|
||||
"dns",
|
||||
"success",
|
||||
`Resolved ${ip} to ${resolution.resolvedAddress}`,
|
||||
{ attempts: resolution.attempts },
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
sshLogger.error("SSH hostname resolution failed", error, {
|
||||
operation: "terminal_dns_resolve",
|
||||
hostId: id,
|
||||
ip,
|
||||
port,
|
||||
transient: isRetriableDnsError(error),
|
||||
});
|
||||
sendLog("dns", "error", `DNS resolution failed for ${ip}: ${message}`);
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: isRetriableDnsError(error)
|
||||
? "SSH error: DNS lookup temporarily failed. Check the Docker/container DNS configuration or try again."
|
||||
: "SSH error: Could not resolve hostname from the Termix server container.",
|
||||
}),
|
||||
if (connectsViaJumpHosts) {
|
||||
// The target is only reachable through the jump host's network (e.g. a
|
||||
// VPN-only address), so DNS must be resolved there, not on this host.
|
||||
sendLog(
|
||||
"dns",
|
||||
"info",
|
||||
`Skipping local address resolution of ${ip} (resolved by jump host)`,
|
||||
);
|
||||
cleanupAuthState(connectionTimeout);
|
||||
return;
|
||||
} else {
|
||||
sendLog("dns", "info", `Starting address resolution of ${ip}`);
|
||||
try {
|
||||
const resolution = await resolveHostForSshConnect(ip);
|
||||
connectHost = resolution.host;
|
||||
if (resolution.resolvedAddress && resolution.resolvedAddress !== ip) {
|
||||
sendLog(
|
||||
"dns",
|
||||
"success",
|
||||
`Resolved ${ip} to ${resolution.resolvedAddress}`,
|
||||
{ attempts: resolution.attempts },
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Unknown error";
|
||||
sshLogger.error("SSH hostname resolution failed", error, {
|
||||
operation: "terminal_dns_resolve",
|
||||
hostId: id,
|
||||
ip,
|
||||
port,
|
||||
transient: isRetriableDnsError(error),
|
||||
});
|
||||
sendLog("dns", "error", `DNS resolution failed for ${ip}: ${message}`);
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: isRetriableDnsError(error)
|
||||
? "SSH error: DNS lookup temporarily failed. Check the Docker/container DNS configuration or try again."
|
||||
: "SSH error: Could not resolve hostname from the Termix server container.",
|
||||
}),
|
||||
);
|
||||
cleanupAuthState(connectionTimeout);
|
||||
return;
|
||||
}
|
||||
}
|
||||
sendLog("tcp", "info", `Connecting to ${ip} port ${port}`);
|
||||
|
||||
@@ -1619,12 +1937,10 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
const session = sessionManager.getSession(boundSessionId);
|
||||
if (session) {
|
||||
sessionManager.bufferOutput(boundSessionId!, utf8String);
|
||||
|
||||
if (session.attachedWs?.readyState === WebSocket.OPEN) {
|
||||
session.attachedWs.send(
|
||||
JSON.stringify({ type: "data", data: utf8String }),
|
||||
);
|
||||
}
|
||||
sessionManager.broadcast(boundSessionId!, {
|
||||
type: "data",
|
||||
data: utf8String,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
sshLogger.error("Error encoding terminal data", error, {
|
||||
@@ -1636,34 +1952,28 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
const session = sessionManager.getSession(boundSessionId);
|
||||
if (session) {
|
||||
sessionManager.bufferOutput(boundSessionId!, fallback);
|
||||
|
||||
if (session.attachedWs?.readyState === WebSocket.OPEN) {
|
||||
session.attachedWs.send(
|
||||
JSON.stringify({ type: "data", data: fallback }),
|
||||
);
|
||||
}
|
||||
sessionManager.broadcast(boundSessionId!, {
|
||||
type: "data",
|
||||
data: fallback,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
stream.on("close", (code: number | null) => {
|
||||
const session = sessionManager.getSession(boundSessionId);
|
||||
if (session?.attachedWs?.readyState === WebSocket.OPEN) {
|
||||
if (session) {
|
||||
if (code != null) {
|
||||
session.attachedWs.send(
|
||||
JSON.stringify({
|
||||
type: "session_ended",
|
||||
code,
|
||||
}),
|
||||
);
|
||||
sessionManager.broadcast(boundSessionId!, {
|
||||
type: "session_ended",
|
||||
code,
|
||||
});
|
||||
} else {
|
||||
session.attachedWs.send(
|
||||
JSON.stringify({
|
||||
type: "disconnected",
|
||||
message: "Connection lost",
|
||||
graceful: true,
|
||||
}),
|
||||
);
|
||||
sessionManager.broadcast(boundSessionId!, {
|
||||
type: "disconnected",
|
||||
message: "Connection lost",
|
||||
graceful: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (boundSessionId) {
|
||||
@@ -1683,13 +1993,11 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
username,
|
||||
});
|
||||
const session = sessionManager.getSession(boundSessionId);
|
||||
if (session?.attachedWs?.readyState === WebSocket.OPEN) {
|
||||
session.attachedWs.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "SSH stream error: " + err.message,
|
||||
}),
|
||||
);
|
||||
if (session) {
|
||||
sessionManager.broadcast(boundSessionId!, {
|
||||
type: "error",
|
||||
message: "SSH stream error: " + err.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2014,7 +2322,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
sendLog(
|
||||
"auth",
|
||||
"error",
|
||||
"Tailscale SSH authentication failed. Ensure Tailscale is running on the server, SSH is advertised (tailscale set --ssh), and your ACL policy permits this connection.",
|
||||
`Tailscale SSH authentication failed for user "${username}". Ensure Tailscale is running on the server, SSH is advertised (tailscale set --ssh), and your ACL policy grants the "${username}" user to your identity (check tailscale.com/s/ssh for the check/action ACL syntax). If your Tailscale identity maps to a different Unix user, update the username on this host.`,
|
||||
);
|
||||
if (currentSessionId) {
|
||||
sessionManager.destroySession(currentSessionId);
|
||||
@@ -2024,8 +2332,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message:
|
||||
"Tailscale SSH authentication failed. Ensure Tailscale is running on the server, SSH is advertised (tailscale set --ssh), and your ACL policy permits this connection.",
|
||||
message: `Tailscale SSH authentication failed for user "${username}". Ensure Tailscale is running on the server, SSH is advertised (tailscale set --ssh), and your ACL policy grants the "${username}" user to your identity. If your Tailscale identity maps to a different Unix user, update the username on this host.`,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
|
||||
@@ -15,6 +15,16 @@ const DEFAULT_TIMEOUT_MINUTES = 30;
|
||||
const HEALTH_CHECK_INTERVAL_MS = 60_000;
|
||||
const MAX_SESSIONS_PER_USER = 10;
|
||||
|
||||
export interface SessionParticipant {
|
||||
ws: WebSocket;
|
||||
userId: string | null; // null for anonymous link guests
|
||||
permissionLevel: "read-write" | "read-only";
|
||||
isOwner: boolean;
|
||||
guestLabel?: string;
|
||||
tabInstanceId?: string;
|
||||
joinedViaShareId?: string;
|
||||
}
|
||||
|
||||
export interface TerminalSession {
|
||||
id: string;
|
||||
userId: string;
|
||||
@@ -32,7 +42,7 @@ export interface TerminalSession {
|
||||
isConnected: boolean;
|
||||
createdAt: number;
|
||||
|
||||
attachedWs: WebSocket | null;
|
||||
participants: Map<string, SessionParticipant>;
|
||||
lastDetachedAt: number | null;
|
||||
detachTimeout: NodeJS.Timeout | null;
|
||||
|
||||
@@ -48,6 +58,33 @@ export interface TerminalSession {
|
||||
sessionLoggingEnabled: boolean;
|
||||
sessionStartedAt: number;
|
||||
lastPersistedBytes: number;
|
||||
terminatedByOwner: boolean;
|
||||
terminationReason: string | null;
|
||||
}
|
||||
|
||||
/** Message types a non-owner participant may legally send. */
|
||||
const NON_OWNER_ALLOWED_MESSAGE_TYPES = new Set([
|
||||
"input",
|
||||
"ping",
|
||||
"disconnect",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Server-side gate for whether a participant may send a given WS message
|
||||
* type. The owner may send anything; non-owners are limited to input (if
|
||||
* read-write), ping, and disconnect. Pure function so read-only enforcement
|
||||
* is unit-testable without a real WebSocketServer.
|
||||
*/
|
||||
export function isMessageAllowedForParticipant(
|
||||
participant: Pick<SessionParticipant, "isOwner" | "permissionLevel"> | null,
|
||||
messageType: string,
|
||||
): boolean {
|
||||
if (!participant || participant.isOwner) return true;
|
||||
if (!NON_OWNER_ALLOWED_MESSAGE_TYPES.has(messageType)) return false;
|
||||
if (messageType === "input" && participant.permissionLevel === "read-only") {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
class TerminalSessionManager {
|
||||
@@ -81,7 +118,7 @@ class TerminalSessionManager {
|
||||
const userSessions = this.getUserSessions(userId);
|
||||
if (userSessions.length >= MAX_SESSIONS_PER_USER) {
|
||||
const detached = userSessions
|
||||
.filter((s) => s.attachedWs === null)
|
||||
.filter((s) => this.getOwnerParticipant(s) === null)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(a.lastDetachedAt ?? a.createdAt) -
|
||||
@@ -109,7 +146,7 @@ class TerminalSessionManager {
|
||||
operation: "session_tab_duplicate_skip",
|
||||
existingSessionId: existing.id,
|
||||
tabInstanceId,
|
||||
hasAttachedWs: existing.attachedWs !== null,
|
||||
hasAttachedWs: this.getOwnerParticipant(existing) !== null,
|
||||
},
|
||||
);
|
||||
return existing.id;
|
||||
@@ -151,7 +188,7 @@ class TerminalSessionManager {
|
||||
rows,
|
||||
isConnected: false,
|
||||
createdAt: now,
|
||||
attachedWs: null,
|
||||
participants: new Map(),
|
||||
lastDetachedAt: null,
|
||||
detachTimeout: null,
|
||||
outputBuffer: [],
|
||||
@@ -166,6 +203,8 @@ class TerminalSessionManager {
|
||||
sessionLoggingEnabled,
|
||||
sessionStartedAt: now,
|
||||
lastPersistedBytes: 0,
|
||||
terminatedByOwner: false,
|
||||
terminationReason: null,
|
||||
};
|
||||
this.sessions.set(id, session);
|
||||
|
||||
@@ -199,6 +238,25 @@ class TerminalSessionManager {
|
||||
session.isConnected = true;
|
||||
}
|
||||
|
||||
/** Finds the owner's participant entry, if currently attached. */
|
||||
private getOwnerParticipant(
|
||||
session: TerminalSession,
|
||||
): SessionParticipant | null {
|
||||
for (const participant of session.participants.values()) {
|
||||
if (participant.isOwner) return participant;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private getOwnerEntry(
|
||||
session: TerminalSession,
|
||||
): [string, SessionParticipant] | null {
|
||||
for (const entry of session.participants.entries()) {
|
||||
if (entry[1].isOwner) return entry;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
attachWs(
|
||||
sessionId: string,
|
||||
userId: string,
|
||||
@@ -234,8 +292,9 @@ class TerminalSessionManager {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ownerParticipant = this.getOwnerParticipant(session);
|
||||
const isDetached =
|
||||
!session.attachedWs || session.attachedWs.readyState !== WebSocket.OPEN;
|
||||
!ownerParticipant || ownerParticipant.ws.readyState !== WebSocket.OPEN;
|
||||
const isOriginalTab =
|
||||
(session.attachedTabInstanceId ?? session.tabInstanceId) ===
|
||||
tabInstanceId;
|
||||
@@ -282,9 +341,10 @@ class TerminalSessionManager {
|
||||
);
|
||||
}
|
||||
|
||||
if (session.attachedWs && session.attachedWs !== ws) {
|
||||
const ownerEntry = this.getOwnerEntry(session);
|
||||
if (ownerEntry && ownerEntry[1].ws !== ws) {
|
||||
try {
|
||||
session.attachedWs.send(
|
||||
ownerEntry[1].ws.send(
|
||||
JSON.stringify({
|
||||
type: "sessionTakenOver",
|
||||
sessionId,
|
||||
@@ -294,7 +354,7 @@ class TerminalSessionManager {
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
session.attachedWs = null;
|
||||
session.participants.delete(ownerEntry[0]);
|
||||
}
|
||||
|
||||
if (session.detachTimeout) {
|
||||
@@ -302,7 +362,14 @@ class TerminalSessionManager {
|
||||
session.detachTimeout = null;
|
||||
}
|
||||
|
||||
session.attachedWs = ws;
|
||||
const participantId = crypto.randomUUID();
|
||||
session.participants.set(participantId, {
|
||||
ws,
|
||||
userId,
|
||||
permissionLevel: "read-write",
|
||||
isOwner: true,
|
||||
tabInstanceId,
|
||||
});
|
||||
session.attachedTabInstanceId = tabInstanceId;
|
||||
session.lastDetachedAt = null;
|
||||
|
||||
@@ -316,6 +383,110 @@ class TerminalSessionManager {
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a non-owner participant (in-app share join or anonymous link guest).
|
||||
* Purely additive - never evicts the owner or any other participant.
|
||||
*/
|
||||
joinAsParticipant(
|
||||
sessionId: string,
|
||||
ws: WebSocket,
|
||||
opts: {
|
||||
userId: string | null;
|
||||
permissionLevel: "read-write" | "read-only";
|
||||
guestLabel?: string;
|
||||
tabInstanceId?: string;
|
||||
shareId?: string;
|
||||
},
|
||||
): TerminalSession | null {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session || !session.isConnected) return null;
|
||||
|
||||
const participantId = crypto.randomUUID();
|
||||
session.participants.set(participantId, {
|
||||
ws,
|
||||
userId: opts.userId,
|
||||
permissionLevel: opts.permissionLevel,
|
||||
isOwner: false,
|
||||
guestLabel: opts.guestLabel,
|
||||
tabInstanceId: opts.tabInstanceId,
|
||||
joinedViaShareId: opts.shareId,
|
||||
});
|
||||
|
||||
sshLogger.info("Participant joined shared session", {
|
||||
operation: "session_join_participant",
|
||||
sessionId,
|
||||
userId: opts.userId,
|
||||
permissionLevel: opts.permissionLevel,
|
||||
shareId: opts.shareId,
|
||||
});
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
/** Fans out a message to every OPEN participant socket; skips closed ones and send failures. */
|
||||
broadcast(sessionId: string, message: object): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
const payload = JSON.stringify(message);
|
||||
for (const participant of session.participants.values()) {
|
||||
if (participant.ws.readyState !== WebSocket.OPEN) continue;
|
||||
try {
|
||||
participant.ws.send(payload);
|
||||
} catch {
|
||||
/* ignore individual send failures, keep broadcasting to the rest */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Finds the participant entry (owner or not) for a given socket. */
|
||||
getParticipantForWs(
|
||||
session: TerminalSession,
|
||||
ws: WebSocket,
|
||||
): SessionParticipant | null {
|
||||
for (const participant of session.participants.values()) {
|
||||
if (participant.ws === ws) return participant;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a non-owner participant's socket. No detach timeout or session
|
||||
* destruction side effects - a guest leaving must never end the session.
|
||||
*/
|
||||
removeParticipant(sessionId: string, ws: WebSocket): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
for (const [id, participant] of session.participants.entries()) {
|
||||
if (participant.ws === ws && !participant.isOwner) {
|
||||
session.participants.delete(id);
|
||||
sshLogger.info("Participant left shared session", {
|
||||
operation: "session_leave_participant",
|
||||
sessionId,
|
||||
userId: participant.userId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Broadcasts termination to all guests, then destroys the session. */
|
||||
ownerEndSession(sessionId: string, reason: string): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
|
||||
this.broadcast(sessionId, { type: "sessionTerminatedByOwner", reason });
|
||||
session.terminatedByOwner = true;
|
||||
session.terminationReason = reason;
|
||||
|
||||
sshLogger.info("Owner ended shared session", {
|
||||
operation: "session_owner_end",
|
||||
sessionId,
|
||||
reason,
|
||||
});
|
||||
|
||||
this.destroySession(sessionId);
|
||||
}
|
||||
|
||||
detachWs(sessionId: string): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
@@ -325,7 +496,10 @@ class TerminalSessionManager {
|
||||
session.detachTimeout = null;
|
||||
}
|
||||
|
||||
session.attachedWs = null;
|
||||
const ownerEntry = this.getOwnerEntry(session);
|
||||
if (ownerEntry) {
|
||||
session.participants.delete(ownerEntry[0]);
|
||||
}
|
||||
session.lastDetachedAt = Date.now();
|
||||
|
||||
// Persist log immediately when the user detaches so it appears right away,
|
||||
@@ -365,6 +539,23 @@ class TerminalSessionManager {
|
||||
fs.promises.unlink(session.recordingPath).catch(() => {});
|
||||
}
|
||||
|
||||
for (const participant of session.participants.values()) {
|
||||
if (participant.isOwner) continue;
|
||||
if (participant.ws.readyState !== WebSocket.OPEN) continue;
|
||||
try {
|
||||
participant.ws.send(
|
||||
JSON.stringify({
|
||||
type: "sessionExpired",
|
||||
sessionId,
|
||||
message: "Session has ended",
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
session.participants.clear();
|
||||
|
||||
if (session.sshStream) {
|
||||
try {
|
||||
session.sshStream.end();
|
||||
@@ -440,12 +631,16 @@ class TerminalSessionManager {
|
||||
recordingPath: session.recordingPath,
|
||||
protocol: "ssh",
|
||||
format: "asciicast",
|
||||
terminatedByOwner: session.terminatedByOwner || undefined,
|
||||
terminationReason: session.terminationReason ?? undefined,
|
||||
});
|
||||
session.recordingId = created.id;
|
||||
} else {
|
||||
await repo.updateEnded(session.recordingId, {
|
||||
endedAt: new Date(endedAt).toISOString(),
|
||||
duration,
|
||||
terminatedByOwner: session.terminatedByOwner || undefined,
|
||||
terminationReason: session.terminationReason ?? undefined,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -569,10 +764,10 @@ class TerminalSessionManager {
|
||||
for (const [id, session] of this.sessions) {
|
||||
if (!session.isConnected) continue;
|
||||
|
||||
if (
|
||||
session.attachedWs &&
|
||||
session.attachedWs.readyState === WebSocket.OPEN
|
||||
) {
|
||||
const hasOpenParticipant = Array.from(session.participants.values()).some(
|
||||
(p) => p.ws.readyState === WebSocket.OPEN,
|
||||
);
|
||||
if (hasOpenParticipant) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -42,9 +42,21 @@ const c2sRelayWss = new WebSocketServer({
|
||||
path: "/ssh/tunnel/c2s/stream",
|
||||
});
|
||||
|
||||
c2sRelayWss.on("error", (error) => {
|
||||
tunnelLogger.error("C2S relay WebSocket server error", error, {
|
||||
operation: "c2s_relay_wss_error",
|
||||
});
|
||||
});
|
||||
|
||||
c2sRelayWss.on("connection", (ws, req) => {
|
||||
let opened = false;
|
||||
|
||||
ws.on("error", (error) => {
|
||||
tunnelLogger.error("C2S relay WebSocket connection error", error, {
|
||||
operation: "c2s_relay_ws_error",
|
||||
});
|
||||
});
|
||||
|
||||
ws.once("message", async (raw) => {
|
||||
try {
|
||||
const token = extractRequestToken(req);
|
||||
|
||||
@@ -15,6 +15,74 @@ import {
|
||||
setGlobalLogLevel,
|
||||
} from "./utils/logger.js";
|
||||
|
||||
async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
|
||||
const { createCurrentUserRepository, createCurrentRoleRepository } =
|
||||
await import("./database/repositories/factory.js");
|
||||
const { AuthManager } = await import("./utils/auth-manager.js");
|
||||
const crypto = await import("crypto");
|
||||
|
||||
const userRepository = createCurrentUserRepository();
|
||||
const existingCount = await userRepository.countAll();
|
||||
if (existingCount > 0) {
|
||||
const allUsers = await userRepository.listAll();
|
||||
for (const user of allUsers) {
|
||||
try {
|
||||
await AuthManager.getInstance().registerUser(user.id);
|
||||
} catch (dekError) {
|
||||
systemLogger.error(
|
||||
"Failed to verify/provision data-encryption key for existing user",
|
||||
dekError,
|
||||
{ operation: "desktop_dek_healing", userId: user.id },
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const { isFirstUser } = await userRepository.createFirstLocalUser({
|
||||
id,
|
||||
username: "local",
|
||||
passwordHash: "",
|
||||
isOidc: false,
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
issuerUrl: "",
|
||||
authorizationUrl: "",
|
||||
tokenUrl: "",
|
||||
identifierPath: "",
|
||||
namePath: "",
|
||||
scopes: "openid email profile",
|
||||
totpSecret: null,
|
||||
totpEnabled: false,
|
||||
totpBackupCodes: null,
|
||||
});
|
||||
|
||||
try {
|
||||
await createCurrentRoleRepository().assignRoleNameToUser({
|
||||
userId: id,
|
||||
roleName: isFirstUser ? "admin" : "user",
|
||||
grantedBy: id,
|
||||
});
|
||||
} catch (roleError) {
|
||||
systemLogger.error(
|
||||
"Failed to assign default role to auto-provisioned local user",
|
||||
roleError,
|
||||
{ operation: "desktop_auto_provision_role" },
|
||||
);
|
||||
}
|
||||
|
||||
await AuthManager.getInstance().registerUser(
|
||||
id,
|
||||
crypto.randomBytes(32).toString("hex"),
|
||||
);
|
||||
|
||||
systemLogger.success("Auto-provisioned local desktop user", {
|
||||
operation: "desktop_auto_provision",
|
||||
userId: id,
|
||||
});
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const initStartTime = Date.now();
|
||||
try {
|
||||
@@ -61,6 +129,8 @@ import {
|
||||
}
|
||||
}
|
||||
}
|
||||
process.env.VERSION = version;
|
||||
|
||||
versionLogger.info(`Termix Backend starting - Version: ${version}`, {
|
||||
operation: "startup",
|
||||
version: version,
|
||||
@@ -105,6 +175,10 @@ import {
|
||||
await import("./utils/crypto-migration/shared-host-secrets-migration.js");
|
||||
await runSharedHostSecretsMigration();
|
||||
|
||||
if (process.env.ELECTRON_EMBEDDED === "true") {
|
||||
await provisionLocalDesktopUserIfNeeded();
|
||||
}
|
||||
|
||||
import("./utils/opkssh-binary-manager.js").then(
|
||||
({ OPKSSHBinaryManager }) => {
|
||||
OPKSSHBinaryManager.ensureBinary().catch((error) => {
|
||||
@@ -170,6 +244,9 @@ import {
|
||||
});
|
||||
}
|
||||
|
||||
const { startAnalyticsHeartbeat } = await import("./utils/analytics.js");
|
||||
startAnalyticsHeartbeat();
|
||||
|
||||
systemLogger.success("Termix backend started successfully", {
|
||||
operation: "backend_init_complete",
|
||||
port: process.env.PORT || 4090,
|
||||
|
||||
@@ -32,7 +32,9 @@ describe("DashboardServiceLinkRepository", () => {
|
||||
label TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
"order" INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
sync_id TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
@@ -99,8 +101,10 @@ describe("DashboardServiceLinkRepository", () => {
|
||||
);
|
||||
expect(writeCount).toBe(2);
|
||||
|
||||
expect(await repo.deleteForUser("user-2", link.id)).toBe(false);
|
||||
expect(await repo.deleteForUser("user-1", link.id)).toBe(true);
|
||||
expect(await repo.deleteForUser("user-2", link.id)).toBeNull();
|
||||
expect(await repo.deleteForUser("user-1", link.id)).toEqual({
|
||||
syncId: expect.any(String),
|
||||
});
|
||||
expect(writeCount).toBe(3);
|
||||
});
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ describe("HomepageItemRepository", () => {
|
||||
title TEXT,
|
||||
config TEXT NOT NULL DEFAULT '{}',
|
||||
folder_id INTEGER,
|
||||
sync_id TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -107,8 +108,10 @@ describe("HomepageItemRepository", () => {
|
||||
).toBeNull();
|
||||
expect(writeCount).toBe(2);
|
||||
|
||||
expect(await repo.deleteForUser("user-2", item.id)).toBe(false);
|
||||
expect(await repo.deleteForUser("user-1", item.id)).toBe(true);
|
||||
expect(await repo.deleteForUser("user-2", item.id)).toBeNull();
|
||||
expect(await repo.deleteForUser("user-1", item.id)).toEqual({
|
||||
syncId: expect.any(String),
|
||||
});
|
||||
expect(writeCount).toBe(3);
|
||||
});
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ describe("HostRepository and CredentialRepository", () => {
|
||||
cert_public_key TEXT,
|
||||
usage_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_used TEXT,
|
||||
sync_id TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
@@ -87,6 +88,7 @@ describe("HostRepository and CredentialRepository", () => {
|
||||
vault_profile_id INTEGER,
|
||||
enable_terminal INTEGER NOT NULL DEFAULT 1,
|
||||
enable_session_logging INTEGER NOT NULL DEFAULT 1,
|
||||
allow_session_sharing INTEGER NOT NULL DEFAULT 1,
|
||||
enable_command_history INTEGER NOT NULL DEFAULT 1,
|
||||
enable_tunnel INTEGER NOT NULL DEFAULT 1,
|
||||
tunnel_connections TEXT,
|
||||
@@ -150,6 +152,8 @@ describe("HostRepository and CredentialRepository", () => {
|
||||
host_key_first_seen TEXT,
|
||||
host_key_last_verified TEXT,
|
||||
host_key_changed_count INTEGER DEFAULT 0,
|
||||
connection_origin TEXT,
|
||||
sync_id TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
@@ -216,18 +220,27 @@ describe("HostRepository and CredentialRepository", () => {
|
||||
).toBe("primary");
|
||||
expect((await repo.credentials.findById(created.id))?.name).toBe("primary");
|
||||
|
||||
// Backdate updated_at so the update's CURRENT_TIMESTAMP bump is
|
||||
// deterministically observable regardless of clock resolution --
|
||||
// the sync engine's last-write-wins conflict resolution depends on
|
||||
// every mutating update actually advancing this column.
|
||||
repo.sqlite
|
||||
.prepare("UPDATE ssh_credentials SET updated_at = ? WHERE id = ?")
|
||||
.run("2000-01-01 00:00:00", created.id);
|
||||
|
||||
const updated = await repo.credentials.updateForUser("user-1", created.id, {
|
||||
folder: "ops",
|
||||
tags: "linux,admin",
|
||||
});
|
||||
expect(updated?.folder).toBe("ops");
|
||||
expect(updated?.updatedAt).not.toBe("2000-01-01 00:00:00");
|
||||
|
||||
expect(
|
||||
await repo.credentials.findByIdForUser("user-2", created.id),
|
||||
).toBeNull();
|
||||
expect(await repo.credentials.deleteForUser("user-1", created.id)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(await repo.credentials.deleteForUser("user-1", created.id)).toEqual({
|
||||
syncId: expect.any(String),
|
||||
});
|
||||
expect(
|
||||
await repo.credentials.findByIdForUser("user-1", created.id),
|
||||
).toBeNull();
|
||||
@@ -335,15 +348,20 @@ describe("HostRepository and CredentialRepository", () => {
|
||||
|
||||
expect(raw.password).toBe("user-encrypted-password");
|
||||
|
||||
repo.sqlite
|
||||
.prepare("UPDATE ssh_credentials SET updated_at = ? WHERE id = ?")
|
||||
.run("2000-01-01 00:00:00", created.id);
|
||||
|
||||
await repo.credentials.updateEncryptedForUser("user-1", created.id, {
|
||||
password: "updated-secret",
|
||||
});
|
||||
|
||||
const updatedRaw = repo.sqlite
|
||||
.prepare("SELECT password FROM ssh_credentials WHERE id = ?")
|
||||
.get(created.id) as { password: string };
|
||||
.prepare("SELECT password, updated_at FROM ssh_credentials WHERE id = ?")
|
||||
.get(created.id) as { password: string; updated_at: string };
|
||||
|
||||
expect(updatedRaw.password).toBe("user-encrypted-password");
|
||||
expect(updatedRaw.updated_at).not.toBe("2000-01-01 00:00:00");
|
||||
expect(DataCrypto.encryptRecord).toHaveBeenCalledWith(
|
||||
"ssh_credentials",
|
||||
expect.objectContaining({ password: "updated-secret" }),
|
||||
@@ -374,7 +392,7 @@ describe("HostRepository and CredentialRepository", () => {
|
||||
const onWrite = vi.fn();
|
||||
const repo = await createRepositories(onWrite);
|
||||
|
||||
await repo.credentials.create({
|
||||
const primary = await repo.credentials.create({
|
||||
userId: "user-1",
|
||||
name: "primary",
|
||||
authType: "password",
|
||||
@@ -392,6 +410,9 @@ describe("HostRepository and CredentialRepository", () => {
|
||||
authType: "password",
|
||||
folder: "prod",
|
||||
});
|
||||
repo.sqlite
|
||||
.prepare("UPDATE ssh_credentials SET updated_at = ? WHERE id = ?")
|
||||
.run("2000-01-01 00:00:00", primary.id);
|
||||
onWrite.mockClear();
|
||||
|
||||
await expect(
|
||||
@@ -401,6 +422,11 @@ describe("HostRepository and CredentialRepository", () => {
|
||||
expect(await repo.credentials.listFolders("user-1")).toEqual(["ops"]);
|
||||
expect(await repo.credentials.listFolders("user-2")).toEqual(["prod"]);
|
||||
expect(onWrite).toHaveBeenCalledTimes(1);
|
||||
|
||||
const renamedRow = repo.sqlite
|
||||
.prepare("SELECT updated_at FROM ssh_credentials WHERE id = ?")
|
||||
.get(primary.id) as { updated_at: string };
|
||||
expect(renamedRow.updated_at).not.toBe("2000-01-01 00:00:00");
|
||||
});
|
||||
|
||||
it("returns empty credential reads when user data is locked", async () => {
|
||||
@@ -441,14 +467,21 @@ describe("HostRepository and CredentialRepository", () => {
|
||||
(await repo.hosts.listByUserId("user-1")).map((item) => item.id),
|
||||
).toEqual([host.id]);
|
||||
|
||||
repo.sqlite
|
||||
.prepare("UPDATE ssh_data SET updated_at = ? WHERE id = ?")
|
||||
.run("2000-01-01 00:00:00", host.id);
|
||||
|
||||
const updated = await repo.hosts.updateForUser("user-1", host.id, {
|
||||
name: "web-1-renamed",
|
||||
folder: "prod",
|
||||
});
|
||||
expect(updated?.name).toBe("web-1-renamed");
|
||||
expect(updated?.updatedAt).not.toBe("2000-01-01 00:00:00");
|
||||
expect(await repo.hosts.findByIdForUser("user-2", host.id)).toBeNull();
|
||||
|
||||
expect(await repo.hosts.deleteForUser("user-1", host.id)).toBe(true);
|
||||
expect(await repo.hosts.deleteForUser("user-1", host.id)).toEqual({
|
||||
syncId: expect.any(String),
|
||||
});
|
||||
expect(await repo.hosts.findById(host.id)).toBeNull();
|
||||
});
|
||||
|
||||
@@ -484,15 +517,20 @@ describe("HostRepository and CredentialRepository", () => {
|
||||
|
||||
expect(raw.password).toBe("encrypted-host-password");
|
||||
|
||||
repo.sqlite
|
||||
.prepare("UPDATE ssh_data SET updated_at = ? WHERE id = ?")
|
||||
.run("2000-01-01 00:00:00", created.id);
|
||||
|
||||
await repo.hosts.updateEncryptedForUser("user-1", created.id, {
|
||||
password: "updated-secret",
|
||||
});
|
||||
|
||||
const updatedRaw = repo.sqlite
|
||||
.prepare("SELECT password FROM ssh_data WHERE id = ?")
|
||||
.get(created.id) as { password: string };
|
||||
.prepare("SELECT password, updated_at FROM ssh_data WHERE id = ?")
|
||||
.get(created.id) as { password: string; updated_at: string };
|
||||
|
||||
expect(updatedRaw.password).toBe("encrypted-host-password");
|
||||
expect(updatedRaw.updated_at).not.toBe("2000-01-01 00:00:00");
|
||||
expect(DataCrypto.encryptRecord).toHaveBeenCalledWith(
|
||||
"ssh_data",
|
||||
expect.objectContaining({ password: "updated-secret" }),
|
||||
@@ -617,6 +655,9 @@ describe("HostRepository and CredentialRepository", () => {
|
||||
username: "root",
|
||||
authType: "password",
|
||||
});
|
||||
repo.sqlite
|
||||
.prepare("UPDATE ssh_data SET updated_at = ? WHERE id IN (?, ?)")
|
||||
.run("2000-01-01 00:00:00", first.id, second.id);
|
||||
onWrite.mockClear();
|
||||
|
||||
const states = await repo.hosts.listBulkUpdateState("user-1", [
|
||||
@@ -634,6 +675,12 @@ describe("HostRepository and CredentialRepository", () => {
|
||||
expect((await repo.hosts.findById(first.id))?.folder).toBe("ops");
|
||||
expect((await repo.hosts.findById(other.id))?.folder).toBeNull();
|
||||
expect(onWrite).toHaveBeenCalledTimes(1);
|
||||
expect((await repo.hosts.findById(first.id))?.updatedAt).not.toBe(
|
||||
"2000-01-01 00:00:00",
|
||||
);
|
||||
expect((await repo.hosts.findById(second.id))?.updatedAt).not.toBe(
|
||||
"2000-01-01 00:00:00",
|
||||
);
|
||||
});
|
||||
|
||||
it("records credential usage and increments usage counters", async () => {
|
||||
@@ -686,6 +733,8 @@ describe("HostRepository and CredentialRepository", () => {
|
||||
.run(host.id, "user-2", "user-1");
|
||||
|
||||
expect(await repo.hosts.deleteAccessForHost(host.id)).toBe(1);
|
||||
expect(await repo.hosts.deleteForUser("user-1", host.id)).toBe(true);
|
||||
expect(await repo.hosts.deleteForUser("user-1", host.id)).toEqual({
|
||||
syncId: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,6 +35,7 @@ describe("HostFolderRepository", () => {
|
||||
name TEXT NOT NULL,
|
||||
folder TEXT,
|
||||
auth_type TEXT NOT NULL,
|
||||
sync_id TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -66,6 +67,7 @@ describe("HostFolderRepository", () => {
|
||||
vault_profile_id INTEGER,
|
||||
enable_terminal INTEGER NOT NULL DEFAULT 1,
|
||||
enable_session_logging INTEGER NOT NULL DEFAULT 1,
|
||||
allow_session_sharing INTEGER NOT NULL DEFAULT 1,
|
||||
enable_command_history INTEGER NOT NULL DEFAULT 1,
|
||||
enable_tunnel INTEGER NOT NULL DEFAULT 1,
|
||||
tunnel_connections TEXT,
|
||||
@@ -129,6 +131,8 @@ describe("HostFolderRepository", () => {
|
||||
host_key_first_seen TEXT,
|
||||
host_key_last_verified TEXT,
|
||||
host_key_changed_count INTEGER DEFAULT 0,
|
||||
connection_origin TEXT,
|
||||
sync_id TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -139,6 +143,8 @@ describe("HostFolderRepository", () => {
|
||||
name TEXT NOT NULL,
|
||||
color TEXT,
|
||||
icon TEXT,
|
||||
credential_id INTEGER,
|
||||
sync_id TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -216,6 +222,7 @@ describe("HostFolderRepository", () => {
|
||||
"prod",
|
||||
"#abcdef",
|
||||
"folder",
|
||||
undefined,
|
||||
"2026-02-01T00:00:00.000Z",
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
@@ -228,6 +235,7 @@ describe("HostFolderRepository", () => {
|
||||
"new",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"2026-03-01T00:00:00.000Z",
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
@@ -237,6 +245,28 @@ describe("HostFolderRepository", () => {
|
||||
expect(writes).toBe(2);
|
||||
});
|
||||
|
||||
it("assigns a credential to a folder and resolves it for nested paths", async () => {
|
||||
const { repository } = await createRepository();
|
||||
|
||||
await expect(
|
||||
repository.upsertMetadata(
|
||||
"user-1",
|
||||
"prod",
|
||||
undefined,
|
||||
undefined,
|
||||
1,
|
||||
"2026-02-01T00:00:00.000Z",
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
created: false,
|
||||
folder: { credentialId: 1 },
|
||||
});
|
||||
|
||||
const folders = await repository.listFolders("user-1");
|
||||
const prodFolder = folders.find((f) => f.name === "prod");
|
||||
expect(prodFolder?.credentialId).toBe(1);
|
||||
});
|
||||
|
||||
it("lists and deletes hosts and folder records in a folder tree", async () => {
|
||||
let writes = 0;
|
||||
const { repository, sqlite } = await createRepository(() => {
|
||||
|
||||
@@ -61,6 +61,7 @@ describe("HostResolutionRepository", () => {
|
||||
vault_profile_id INTEGER,
|
||||
enable_terminal INTEGER NOT NULL DEFAULT 1,
|
||||
enable_session_logging INTEGER NOT NULL DEFAULT 1,
|
||||
allow_session_sharing INTEGER NOT NULL DEFAULT 1,
|
||||
enable_command_history INTEGER NOT NULL DEFAULT 1,
|
||||
enable_tunnel INTEGER NOT NULL DEFAULT 1,
|
||||
tunnel_connections TEXT,
|
||||
@@ -124,6 +125,8 @@ describe("HostResolutionRepository", () => {
|
||||
host_key_first_seen TEXT,
|
||||
host_key_last_verified TEXT,
|
||||
host_key_changed_count INTEGER DEFAULT 0,
|
||||
connection_origin TEXT,
|
||||
sync_id TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -147,6 +150,7 @@ describe("HostResolutionRepository", () => {
|
||||
cert_public_key TEXT,
|
||||
usage_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_used TEXT,
|
||||
sync_id TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -165,6 +169,18 @@ describe("HostResolutionRepository", () => {
|
||||
override_credential_id INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE ssh_folders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
color TEXT,
|
||||
icon TEXT,
|
||||
credential_id INTEGER,
|
||||
sync_id TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
INSERT INTO ssh_data (
|
||||
@@ -185,6 +201,11 @@ describe("HostResolutionRepository", () => {
|
||||
host_id, user_id, granted_by, permission_level, override_credential_id
|
||||
)
|
||||
VALUES (1, 'user-2', 'user-1', 'execute', 8);
|
||||
INSERT INTO ssh_folders (user_id, name, credential_id)
|
||||
VALUES
|
||||
('user-1', 'switches', 7),
|
||||
('user-1', 'switches / floor1', NULL),
|
||||
('user-1', 'no-cred', NULL);
|
||||
`);
|
||||
|
||||
return new HostResolutionRepository(context, onWrite);
|
||||
@@ -492,4 +513,24 @@ describe("HostResolutionRepository", () => {
|
||||
repository.findOverrideCredentialId(1, "user-1"),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("resolves a folder's assigned credential, walking up to parent folders", async () => {
|
||||
const repository = await createRepository();
|
||||
|
||||
await expect(
|
||||
repository.findFolderCredentialId("user-1", "switches"),
|
||||
).resolves.toBe(7);
|
||||
await expect(
|
||||
repository.findFolderCredentialId("user-1", "switches / floor1"),
|
||||
).resolves.toBe(7);
|
||||
await expect(
|
||||
repository.findFolderCredentialId("user-1", "no-cred"),
|
||||
).resolves.toBeNull();
|
||||
await expect(
|
||||
repository.findFolderCredentialId("user-1", "unknown"),
|
||||
).resolves.toBeNull();
|
||||
await expect(
|
||||
repository.findFolderCredentialId("user-1", ""),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { TestSqliteDatabase } from "./test-support.js";
|
||||
import { SessionShareRepository } from "../../../database/repositories/session-share-repository.js";
|
||||
|
||||
describe("SessionShareRepository", () => {
|
||||
let adapter: TestSqliteDatabase | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepository(
|
||||
onWrite?: () => void | Promise<void>,
|
||||
): Promise<SessionShareRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE ssh_data (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
ip TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE session_shares (
|
||||
id TEXT PRIMARY KEY,
|
||||
host_id INTEGER NOT NULL,
|
||||
owner_user_id TEXT NOT NULL,
|
||||
protocol TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
tab_instance_id TEXT,
|
||||
share_type TEXT NOT NULL,
|
||||
target_user_id TEXT,
|
||||
link_token TEXT UNIQUE,
|
||||
permission_level TEXT NOT NULL DEFAULT 'read-only',
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TEXT NOT NULL,
|
||||
revoked_at TEXT,
|
||||
last_joined_at TEXT,
|
||||
join_count INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE session_share_participants (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
share_id TEXT NOT NULL,
|
||||
user_id TEXT,
|
||||
guest_label TEXT,
|
||||
joined_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
left_at TEXT
|
||||
);
|
||||
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('owner-1', 'alice', 'hash'), ('guest-1', 'bob', 'hash');
|
||||
INSERT INTO ssh_data (id, user_id, name, ip)
|
||||
VALUES (1, 'owner-1', 'host-one', '10.0.0.1'), (2, 'owner-1', 'host-two', '10.0.0.2');
|
||||
`);
|
||||
|
||||
return new SessionShareRepository(context, onWrite);
|
||||
}
|
||||
|
||||
const FAR_FUTURE = "2999-01-01T00:00:00.000Z";
|
||||
const FAR_PAST = "2000-01-01T00:00:00.000Z";
|
||||
|
||||
it("creates a share and finds it by id", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
const created = await repo.create({
|
||||
id: "share-1",
|
||||
hostId: 1,
|
||||
ownerUserId: "owner-1",
|
||||
protocol: "ssh",
|
||||
sessionId: "session-1",
|
||||
shareType: "link",
|
||||
linkToken: "token-abc",
|
||||
permissionLevel: "read-only",
|
||||
expiresAt: FAR_FUTURE,
|
||||
});
|
||||
|
||||
expect(created).toMatchObject({
|
||||
id: "share-1",
|
||||
hostId: 1,
|
||||
ownerUserId: "owner-1",
|
||||
protocol: "ssh",
|
||||
linkToken: "token-abc",
|
||||
permissionLevel: "read-only",
|
||||
});
|
||||
|
||||
const found = await repo.findById("share-1");
|
||||
expect(found).toMatchObject({ id: "share-1", sessionId: "session-1" });
|
||||
});
|
||||
|
||||
it("findByLinkToken excludes revoked shares", async () => {
|
||||
const repo = await createRepository();
|
||||
await repo.create({
|
||||
id: "share-revoked",
|
||||
hostId: 1,
|
||||
ownerUserId: "owner-1",
|
||||
protocol: "ssh",
|
||||
sessionId: "session-1",
|
||||
shareType: "link",
|
||||
linkToken: "token-revoked",
|
||||
permissionLevel: "read-only",
|
||||
expiresAt: FAR_FUTURE,
|
||||
});
|
||||
|
||||
expect(await repo.findByLinkToken("token-revoked")).not.toBeNull();
|
||||
|
||||
await repo.revoke("share-revoked", "owner-1");
|
||||
|
||||
expect(await repo.findByLinkToken("token-revoked")).toBeNull();
|
||||
});
|
||||
|
||||
it("findByLinkToken excludes expired shares", async () => {
|
||||
const repo = await createRepository();
|
||||
await repo.create({
|
||||
id: "share-expired",
|
||||
hostId: 1,
|
||||
ownerUserId: "owner-1",
|
||||
protocol: "ssh",
|
||||
sessionId: "session-1",
|
||||
shareType: "link",
|
||||
linkToken: "token-expired",
|
||||
permissionLevel: "read-only",
|
||||
expiresAt: FAR_PAST,
|
||||
});
|
||||
|
||||
expect(await repo.findByLinkToken("token-expired")).toBeNull();
|
||||
});
|
||||
|
||||
it("findByLinkToken returns active, non-expired, non-revoked shares", async () => {
|
||||
const repo = await createRepository();
|
||||
await repo.create({
|
||||
id: "share-active",
|
||||
hostId: 1,
|
||||
ownerUserId: "owner-1",
|
||||
protocol: "vnc",
|
||||
sessionId: "guac-session-1",
|
||||
shareType: "link",
|
||||
linkToken: "token-active",
|
||||
permissionLevel: "read-write",
|
||||
expiresAt: FAR_FUTURE,
|
||||
});
|
||||
|
||||
const found = await repo.findByLinkToken("token-active");
|
||||
expect(found).toMatchObject({
|
||||
id: "share-active",
|
||||
protocol: "vnc",
|
||||
permissionLevel: "read-write",
|
||||
});
|
||||
});
|
||||
|
||||
it("findSharesTargetingUser returns only active user-targeted shares with host/owner metadata", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
await repo.create({
|
||||
id: "share-user-active",
|
||||
hostId: 1,
|
||||
ownerUserId: "owner-1",
|
||||
protocol: "ssh",
|
||||
sessionId: "session-1",
|
||||
shareType: "user",
|
||||
targetUserId: "guest-1",
|
||||
permissionLevel: "read-write",
|
||||
expiresAt: FAR_FUTURE,
|
||||
});
|
||||
|
||||
// Expired user share for the same target - must be excluded
|
||||
await repo.create({
|
||||
id: "share-user-expired",
|
||||
hostId: 2,
|
||||
ownerUserId: "owner-1",
|
||||
protocol: "ssh",
|
||||
sessionId: "session-2",
|
||||
shareType: "user",
|
||||
targetUserId: "guest-1",
|
||||
permissionLevel: "read-only",
|
||||
expiresAt: FAR_PAST,
|
||||
});
|
||||
|
||||
// Link share, not targeting a user - must be excluded even though it's active
|
||||
await repo.create({
|
||||
id: "share-link-active",
|
||||
hostId: 1,
|
||||
ownerUserId: "owner-1",
|
||||
protocol: "ssh",
|
||||
sessionId: "session-3",
|
||||
shareType: "link",
|
||||
linkToken: "token-unrelated",
|
||||
permissionLevel: "read-only",
|
||||
expiresAt: FAR_FUTURE,
|
||||
});
|
||||
|
||||
const shares = await repo.findSharesTargetingUser("guest-1");
|
||||
expect(shares).toHaveLength(1);
|
||||
expect(shares[0]).toMatchObject({
|
||||
id: "share-user-active",
|
||||
hostName: "host-one",
|
||||
ownerUsername: "alice",
|
||||
});
|
||||
});
|
||||
|
||||
it("revoke only affects the requesting owner's own share", async () => {
|
||||
const repo = await createRepository();
|
||||
await repo.create({
|
||||
id: "share-owned",
|
||||
hostId: 1,
|
||||
ownerUserId: "owner-1",
|
||||
protocol: "ssh",
|
||||
sessionId: "session-1",
|
||||
shareType: "link",
|
||||
linkToken: "token-owned",
|
||||
permissionLevel: "read-only",
|
||||
expiresAt: FAR_FUTURE,
|
||||
});
|
||||
|
||||
expect(await repo.revoke("share-owned", "guest-1")).toBe(false);
|
||||
expect(await repo.revoke("share-owned", "owner-1")).toBe(true);
|
||||
});
|
||||
|
||||
it("revokeAsAdmin revokes regardless of owner", async () => {
|
||||
const repo = await createRepository();
|
||||
await repo.create({
|
||||
id: "share-admin-target",
|
||||
hostId: 1,
|
||||
ownerUserId: "owner-1",
|
||||
protocol: "ssh",
|
||||
sessionId: "session-1",
|
||||
shareType: "link",
|
||||
linkToken: "token-admin",
|
||||
permissionLevel: "read-only",
|
||||
expiresAt: FAR_FUTURE,
|
||||
});
|
||||
|
||||
expect(await repo.revokeAsAdmin("share-admin-target")).toBe(true);
|
||||
expect(await repo.findByLinkToken("token-admin")).toBeNull();
|
||||
});
|
||||
|
||||
it("deleteExpiredShares removes only expired rows", async () => {
|
||||
const repo = await createRepository();
|
||||
await repo.create({
|
||||
id: "share-old",
|
||||
hostId: 1,
|
||||
ownerUserId: "owner-1",
|
||||
protocol: "ssh",
|
||||
sessionId: "session-1",
|
||||
shareType: "link",
|
||||
linkToken: "token-old",
|
||||
permissionLevel: "read-only",
|
||||
expiresAt: FAR_PAST,
|
||||
});
|
||||
await repo.create({
|
||||
id: "share-current",
|
||||
hostId: 1,
|
||||
ownerUserId: "owner-1",
|
||||
protocol: "ssh",
|
||||
sessionId: "session-2",
|
||||
shareType: "link",
|
||||
linkToken: "token-current",
|
||||
permissionLevel: "read-only",
|
||||
expiresAt: FAR_FUTURE,
|
||||
});
|
||||
|
||||
const deletedCount = await repo.deleteExpiredShares();
|
||||
expect(deletedCount).toBe(1);
|
||||
expect(await repo.findById("share-old")).toBeNull();
|
||||
expect(await repo.findById("share-current")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("touchShareUsage increments joinCount and sets lastJoinedAt", async () => {
|
||||
const repo = await createRepository();
|
||||
await repo.create({
|
||||
id: "share-touch",
|
||||
hostId: 1,
|
||||
ownerUserId: "owner-1",
|
||||
protocol: "ssh",
|
||||
sessionId: "session-1",
|
||||
shareType: "link",
|
||||
linkToken: "token-touch",
|
||||
permissionLevel: "read-only",
|
||||
expiresAt: FAR_FUTURE,
|
||||
});
|
||||
|
||||
await repo.touchShareUsage("share-touch", "2026-01-01T00:00:00.000Z");
|
||||
let row = await repo.findById("share-touch");
|
||||
expect(row?.joinCount).toBe(1);
|
||||
expect(row?.lastJoinedAt).toBe("2026-01-01T00:00:00.000Z");
|
||||
|
||||
await repo.touchShareUsage("share-touch", "2026-01-02T00:00:00.000Z");
|
||||
row = await repo.findById("share-touch");
|
||||
expect(row?.joinCount).toBe(2);
|
||||
});
|
||||
|
||||
it("records and closes participant joins", async () => {
|
||||
const repo = await createRepository();
|
||||
await repo.create({
|
||||
id: "share-participants",
|
||||
hostId: 1,
|
||||
ownerUserId: "owner-1",
|
||||
protocol: "ssh",
|
||||
sessionId: "session-1",
|
||||
shareType: "link",
|
||||
linkToken: "token-participants",
|
||||
permissionLevel: "read-write",
|
||||
expiresAt: FAR_FUTURE,
|
||||
});
|
||||
|
||||
const participant = await repo.recordParticipantJoin(
|
||||
"share-participants",
|
||||
null,
|
||||
"Guest",
|
||||
);
|
||||
expect(participant).toMatchObject({
|
||||
shareId: "share-participants",
|
||||
userId: null,
|
||||
guestLabel: "Guest",
|
||||
});
|
||||
expect(participant.leftAt).toBeNull();
|
||||
|
||||
await repo.recordParticipantLeave(participant.id);
|
||||
});
|
||||
|
||||
it("write hook fires on mutating operations", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
await repo.create({
|
||||
id: "share-write-hook",
|
||||
hostId: 1,
|
||||
ownerUserId: "owner-1",
|
||||
protocol: "ssh",
|
||||
sessionId: "session-1",
|
||||
shareType: "link",
|
||||
linkToken: "token-write-hook",
|
||||
permissionLevel: "read-only",
|
||||
expiresAt: FAR_FUTURE,
|
||||
});
|
||||
expect(writeCount).toBe(1);
|
||||
|
||||
await repo.revoke("share-write-hook", "owner-1");
|
||||
expect(writeCount).toBe(2);
|
||||
});
|
||||
|
||||
it("deleteSharesForHost removes all shares for a host", async () => {
|
||||
const repo = await createRepository();
|
||||
await repo.create({
|
||||
id: "share-host-1a",
|
||||
hostId: 1,
|
||||
ownerUserId: "owner-1",
|
||||
protocol: "ssh",
|
||||
sessionId: "session-1",
|
||||
shareType: "link",
|
||||
linkToken: "token-h1a",
|
||||
permissionLevel: "read-only",
|
||||
expiresAt: FAR_FUTURE,
|
||||
});
|
||||
await repo.create({
|
||||
id: "share-host-1b",
|
||||
hostId: 1,
|
||||
ownerUserId: "owner-1",
|
||||
protocol: "ssh",
|
||||
sessionId: "session-2",
|
||||
shareType: "link",
|
||||
linkToken: "token-h1b",
|
||||
permissionLevel: "read-only",
|
||||
expiresAt: FAR_FUTURE,
|
||||
});
|
||||
await repo.create({
|
||||
id: "share-host-2",
|
||||
hostId: 2,
|
||||
ownerUserId: "owner-1",
|
||||
protocol: "ssh",
|
||||
sessionId: "session-3",
|
||||
shareType: "link",
|
||||
linkToken: "token-h2",
|
||||
permissionLevel: "read-only",
|
||||
expiresAt: FAR_FUTURE,
|
||||
});
|
||||
|
||||
expect(await repo.deleteSharesForHost(1)).toBe(2);
|
||||
expect(await repo.findById("share-host-2")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,7 @@ describe("SnippetRepository", () => {
|
||||
description TEXT,
|
||||
folder TEXT,
|
||||
"order" INTEGER NOT NULL DEFAULT 0,
|
||||
sync_id TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
host_filter TEXT
|
||||
@@ -40,6 +41,7 @@ describe("SnippetRepository", () => {
|
||||
name TEXT NOT NULL,
|
||||
color TEXT,
|
||||
icon TEXT,
|
||||
sync_id TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { TestSqliteDatabase } from "./test-support.js";
|
||||
import { SyncTombstoneRepository } from "../../../database/repositories/sync-tombstone-repository.js";
|
||||
|
||||
describe("SyncTombstoneRepository", () => {
|
||||
let adapter: TestSqliteDatabase | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepository(
|
||||
onWrite?: () => void | Promise<void>,
|
||||
): Promise<SyncTombstoneRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE sync_tombstones (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
entity_type TEXT NOT NULL,
|
||||
sync_id TEXT NOT NULL,
|
||||
deleted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
`);
|
||||
|
||||
return new SyncTombstoneRepository(context, onWrite);
|
||||
}
|
||||
|
||||
it("records a tombstone and lists it back for the owning user", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
await repo.record("user-1", "hosts", "sync-abc");
|
||||
expect(writeCount).toBe(1);
|
||||
|
||||
const rows = await repo.listSince("user-1", "hosts", null);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toMatchObject({
|
||||
userId: "user-1",
|
||||
entityType: "hosts",
|
||||
syncId: "sync-abc",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not record a tombstone for an empty syncId", async () => {
|
||||
const repo = await createRepository();
|
||||
await repo.record("user-1", "hosts", "");
|
||||
const rows = await repo.listSince("user-1", "hosts", null);
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("recordMany writes multiple tombstones and filters out falsy ids", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
await repo.recordMany("user-1", "hosts", ["a", "", "b", "c"]);
|
||||
expect(writeCount).toBe(1);
|
||||
|
||||
const rows = await repo.listSince("user-1", "hosts", null);
|
||||
expect(rows.map((r) => r.syncId).sort()).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("recordMany is a no-op when given no syncIds", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
await repo.recordMany("user-1", "hosts", []);
|
||||
expect(writeCount).toBe(0);
|
||||
});
|
||||
|
||||
it("scopes listSince by userId and entityType", async () => {
|
||||
const repo = await createRepository();
|
||||
await repo.record("user-1", "hosts", "sync-1");
|
||||
await repo.record("user-1", "snippets", "sync-2");
|
||||
await repo.record("user-2", "hosts", "sync-3");
|
||||
|
||||
const rows = await repo.listSince("user-1", "hosts", null);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].syncId).toBe("sync-1");
|
||||
});
|
||||
|
||||
it("filters listSince by the since timestamp", async () => {
|
||||
const adapterLocal = new TestSqliteDatabase();
|
||||
adapter = adapterLocal;
|
||||
const context = await adapterLocal.connect();
|
||||
context.sqlite?.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE sync_tombstones (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
entity_type TEXT NOT NULL,
|
||||
sync_id TEXT NOT NULL,
|
||||
deleted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash');
|
||||
|
||||
INSERT INTO sync_tombstones (user_id, entity_type, sync_id, deleted_at)
|
||||
VALUES
|
||||
('user-1', 'hosts', 'old', '2026-01-01T00:00:00.000Z'),
|
||||
('user-1', 'hosts', 'new', '2026-06-01T00:00:00.000Z');
|
||||
`);
|
||||
const repo = new SyncTombstoneRepository(context);
|
||||
|
||||
const rows = await repo.listSince(
|
||||
"user-1",
|
||||
"hosts",
|
||||
"2026-03-01T00:00:00.000Z",
|
||||
);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].syncId).toBe("new");
|
||||
});
|
||||
});
|
||||
@@ -49,6 +49,7 @@ describe("UserDataExportRepository", () => {
|
||||
vault_profile_id INTEGER,
|
||||
enable_terminal INTEGER NOT NULL DEFAULT 1,
|
||||
enable_session_logging INTEGER NOT NULL DEFAULT 1,
|
||||
allow_session_sharing INTEGER NOT NULL DEFAULT 1,
|
||||
enable_command_history INTEGER NOT NULL DEFAULT 1,
|
||||
enable_tunnel INTEGER NOT NULL DEFAULT 1,
|
||||
tunnel_connections TEXT,
|
||||
@@ -112,6 +113,8 @@ describe("UserDataExportRepository", () => {
|
||||
host_key_first_seen TEXT,
|
||||
host_key_last_verified TEXT,
|
||||
host_key_changed_count INTEGER DEFAULT 0,
|
||||
connection_origin TEXT,
|
||||
sync_id TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -135,6 +138,7 @@ describe("UserDataExportRepository", () => {
|
||||
cert_public_key TEXT,
|
||||
usage_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_used TEXT,
|
||||
sync_id TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
@@ -47,6 +47,8 @@ describe("UserPreferenceRepository", () => {
|
||||
hidden_rail_tabs TEXT,
|
||||
compact_host_view INTEGER,
|
||||
status_color_scheme TEXT,
|
||||
custom_themes TEXT,
|
||||
custom_keybindings TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ describe("VaultProfileRepository", () => {
|
||||
valid_principals TEXT,
|
||||
key_type TEXT,
|
||||
shared INTEGER NOT NULL DEFAULT 0,
|
||||
sync_id TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -119,8 +120,8 @@ describe("VaultProfileRepository", () => {
|
||||
});
|
||||
|
||||
expect(await repo.updateById(999, { name: "missing" })).toBeNull();
|
||||
expect(await repo.deleteById(1)).toBe(true);
|
||||
expect(await repo.deleteById(1)).toBe(false);
|
||||
expect(await repo.deleteById(1)).toEqual({ syncId: null });
|
||||
expect(await repo.deleteById(1)).toBeNull();
|
||||
expect(await repo.findById(1)).toBeNull();
|
||||
expect(writeCount).toBe(2);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Request } from "express";
|
||||
import type { UserRecord } from "../../../database/repositories/user-repository.js";
|
||||
import {
|
||||
isLoopbackRequest,
|
||||
extractBearerOrCookieToken,
|
||||
resolveDesktopAutoSessionUser,
|
||||
} from "../../../database/routes/desktop-auto-session.js";
|
||||
|
||||
function makeUser(overrides: Partial<UserRecord> = {}): UserRecord {
|
||||
return {
|
||||
id: "user-1",
|
||||
username: "local",
|
||||
passwordHash: "",
|
||||
isOidc: false,
|
||||
totpEnabled: false,
|
||||
isAdmin: false,
|
||||
registeredAt: "2026-01-01T00:00:00.000Z",
|
||||
...overrides,
|
||||
} as UserRecord;
|
||||
}
|
||||
|
||||
describe("isLoopbackRequest", () => {
|
||||
it.each(["127.0.0.1", "::1", "::ffff:127.0.0.1"])(
|
||||
"accepts %s as loopback",
|
||||
(ip) => {
|
||||
expect(isLoopbackRequest({ ip, socket: {} } as unknown as Request)).toBe(
|
||||
true,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("accepts an IPv4-mapped loopback suffix", () => {
|
||||
expect(
|
||||
isLoopbackRequest({
|
||||
ip: "::ffff:127.0.0.1",
|
||||
socket: {},
|
||||
} as unknown as Request),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a non-loopback IP", () => {
|
||||
expect(
|
||||
isLoopbackRequest({
|
||||
ip: "192.168.1.50",
|
||||
socket: {},
|
||||
} as unknown as Request),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to socket.remoteAddress when req.ip is empty", () => {
|
||||
expect(
|
||||
isLoopbackRequest({
|
||||
ip: "",
|
||||
socket: { remoteAddress: "127.0.0.1" },
|
||||
} as unknown as Request),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractBearerOrCookieToken", () => {
|
||||
it("prefers the jwt cookie over the Authorization header", () => {
|
||||
const req = {
|
||||
cookies: { jwt: "cookie-token" },
|
||||
headers: { authorization: "Bearer header-token" },
|
||||
} as unknown as Request;
|
||||
expect(extractBearerOrCookieToken(req)).toBe("cookie-token");
|
||||
});
|
||||
|
||||
it("falls back to a Bearer Authorization header", () => {
|
||||
const req = {
|
||||
cookies: {},
|
||||
headers: { authorization: "Bearer header-token" },
|
||||
} as unknown as Request;
|
||||
expect(extractBearerOrCookieToken(req)).toBe("header-token");
|
||||
});
|
||||
|
||||
it("returns undefined when neither is present", () => {
|
||||
const req = { cookies: {}, headers: {} } as unknown as Request;
|
||||
expect(extractBearerOrCookieToken(req)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores a non-Bearer Authorization header", () => {
|
||||
const req = {
|
||||
cookies: {},
|
||||
headers: { authorization: "Basic abc123" },
|
||||
} as unknown as Request;
|
||||
expect(extractBearerOrCookieToken(req)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveDesktopAutoSessionUser", () => {
|
||||
it("returns the sole local user regardless of having a real password", () => {
|
||||
const user = makeUser({ passwordHash: "$2a$10$realbcryptvaluehere" });
|
||||
expect(resolveDesktopAutoSessionUser([user])).toBe(user);
|
||||
});
|
||||
|
||||
it("returns the sole local user even when OIDC-enabled", () => {
|
||||
const user = makeUser({ isOidc: true });
|
||||
expect(resolveDesktopAutoSessionUser([user])).toBe(user);
|
||||
});
|
||||
|
||||
it("returns the sole local user even when TOTP-enabled", () => {
|
||||
const user = makeUser({ totpEnabled: true });
|
||||
expect(resolveDesktopAutoSessionUser([user])).toBe(user);
|
||||
});
|
||||
|
||||
it("returns the auto-provisioned passwordless placeholder", () => {
|
||||
const user = makeUser({ passwordHash: "" });
|
||||
expect(resolveDesktopAutoSessionUser([user])).toBe(user);
|
||||
});
|
||||
|
||||
it("declines when zero users exist", () => {
|
||||
expect(resolveDesktopAutoSessionUser([])).toBeNull();
|
||||
});
|
||||
|
||||
it("never declines for a multi-user local database -- prefers the admin account", () => {
|
||||
const admin = makeUser({
|
||||
id: "user-2",
|
||||
isAdmin: true,
|
||||
registeredAt: "2026-02-01T00:00:00.000Z",
|
||||
});
|
||||
const result = resolveDesktopAutoSessionUser([
|
||||
makeUser({
|
||||
id: "user-1",
|
||||
isAdmin: false,
|
||||
registeredAt: "2026-01-01T00:00:00.000Z",
|
||||
}),
|
||||
admin,
|
||||
]);
|
||||
expect(result).toBe(admin);
|
||||
});
|
||||
|
||||
it("falls back to the earliest-registered account when no admin exists", () => {
|
||||
const earliest = makeUser({
|
||||
id: "user-1",
|
||||
registeredAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
const result = resolveDesktopAutoSessionUser([
|
||||
makeUser({ id: "user-2", registeredAt: "2026-03-01T00:00:00.000Z" }),
|
||||
earliest,
|
||||
makeUser({ id: "user-3", registeredAt: "2026-02-01T00:00:00.000Z" }),
|
||||
]);
|
||||
expect(result).toBe(earliest);
|
||||
});
|
||||
});
|
||||
@@ -160,6 +160,28 @@ describe("stripSensitiveFields", () => {
|
||||
expect(result.hasPassword).toBe(false);
|
||||
expect(result.hasKey).toBe(false);
|
||||
});
|
||||
|
||||
it("strips rdp/vnc/telnet passwords and adds their presence flags", () => {
|
||||
const result = stripSensitiveFields({
|
||||
name: "rdp-box",
|
||||
rdpPassword: "rdp-secret",
|
||||
vncPassword: "vnc-secret",
|
||||
telnetPassword: "telnet-secret",
|
||||
});
|
||||
expect(result.rdpPassword).toBeUndefined();
|
||||
expect(result.vncPassword).toBeUndefined();
|
||||
expect(result.telnetPassword).toBeUndefined();
|
||||
expect(result.hasRdpPassword).toBe(true);
|
||||
expect(result.hasVncPassword).toBe(true);
|
||||
expect(result.hasTelnetPassword).toBe(true);
|
||||
});
|
||||
|
||||
it("marks rdp/vnc/telnet presence flags false when absent", () => {
|
||||
const result = stripSensitiveFields({ name: "rdp-box" });
|
||||
expect(result.hasRdpPassword).toBe(false);
|
||||
expect(result.hasVncPassword).toBe(false);
|
||||
expect(result.hasTelnetPassword).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("transformHostResponse", () => {
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
isValidKeyCombo,
|
||||
isValidKeybindingAction,
|
||||
isValidKeybinding,
|
||||
} from "../../../database/routes/keybinding-validation.js";
|
||||
|
||||
const validCombo = {
|
||||
key: "c",
|
||||
isCode: false,
|
||||
ctrl: true,
|
||||
alt: false,
|
||||
shift: false,
|
||||
meta: false,
|
||||
};
|
||||
|
||||
describe("isValidKeyCombo", () => {
|
||||
it("accepts a well-formed combo", () => {
|
||||
expect(isValidKeyCombo(validCombo)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a combo missing a boolean field", () => {
|
||||
const { ctrl: _ctrl, ...rest } = validCombo;
|
||||
expect(isValidKeyCombo(rest)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a non-object", () => {
|
||||
expect(isValidKeyCombo("ctrl+c")).toBe(false);
|
||||
expect(isValidKeyCombo(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidKeybindingAction", () => {
|
||||
it("accepts copy and paste with no extra fields", () => {
|
||||
expect(isValidKeybindingAction({ type: "copy" })).toBe(true);
|
||||
expect(isValidKeybindingAction({ type: "paste" })).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an unknown action type", () => {
|
||||
expect(isValidKeybindingAction({ type: "explode" })).toBe(false);
|
||||
});
|
||||
|
||||
it("requires text for sendText", () => {
|
||||
expect(isValidKeybindingAction({ type: "sendText" })).toBe(false);
|
||||
expect(isValidKeybindingAction({ type: "sendText", text: "ls -la" })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("requires a single-letter controlCode for sendControlCode", () => {
|
||||
expect(
|
||||
isValidKeybindingAction({ type: "sendControlCode", controlCode: "w" }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isValidKeybindingAction({ type: "sendControlCode", controlCode: "ww" }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isValidKeybindingAction({ type: "sendControlCode", controlCode: "1" }),
|
||||
).toBe(false);
|
||||
expect(isValidKeybindingAction({ type: "sendControlCode" })).toBe(false);
|
||||
});
|
||||
|
||||
it("requires snippetId for runSnippet", () => {
|
||||
expect(
|
||||
isValidKeybindingAction({ type: "runSnippet", snippetId: "42" }),
|
||||
).toBe(true);
|
||||
expect(isValidKeybindingAction({ type: "runSnippet" })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidKeybinding", () => {
|
||||
const base = {
|
||||
id: "kb-1",
|
||||
enabled: true,
|
||||
combo: validCombo,
|
||||
action: { type: "copy" },
|
||||
};
|
||||
|
||||
it("accepts a well-formed keybinding", () => {
|
||||
expect(isValidKeybinding(base)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a keybinding missing id", () => {
|
||||
const { id: _id, ...rest } = base;
|
||||
expect(isValidKeybinding(rest)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a keybinding missing enabled", () => {
|
||||
const { enabled: _enabled, ...rest } = base;
|
||||
expect(isValidKeybinding(rest)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a keybinding with an invalid combo", () => {
|
||||
expect(isValidKeybinding({ ...base, combo: {} })).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a keybinding with an invalid action", () => {
|
||||
expect(isValidKeybinding({ ...base, action: { type: "sendText" } })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isValidEntityType,
|
||||
stripWritePayload,
|
||||
} from "../../../database/routes/sync.js";
|
||||
|
||||
describe("isValidEntityType", () => {
|
||||
it("accepts every whitelisted sync entity type", () => {
|
||||
for (const type of [
|
||||
"hosts",
|
||||
"sshCredentials",
|
||||
"sshFolders",
|
||||
"snippets",
|
||||
"snippetFolders",
|
||||
"vaultProfiles",
|
||||
"dashboardServiceLinks",
|
||||
"homepageItems",
|
||||
]) {
|
||||
expect(isValidEntityType(type)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects unknown or non-string entity types", () => {
|
||||
expect(isValidEntityType("hostAccess")).toBe(false);
|
||||
expect(isValidEntityType("")).toBe(false);
|
||||
expect(isValidEntityType(undefined)).toBe(false);
|
||||
expect(isValidEntityType(42)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripWritePayload", () => {
|
||||
it("strips id, userId, and syncId from every entity type", () => {
|
||||
const payload = {
|
||||
id: 1,
|
||||
userId: "user-1",
|
||||
syncId: "abc",
|
||||
name: "prod-db",
|
||||
};
|
||||
expect(stripWritePayload("sshFolders", payload)).toEqual({
|
||||
name: "prod-db",
|
||||
});
|
||||
});
|
||||
|
||||
it("also strips desktop-only fields flagged read-only for hosts", () => {
|
||||
const payload = {
|
||||
id: 1,
|
||||
userId: "user-1",
|
||||
syncId: "abc",
|
||||
name: "web",
|
||||
connectionOrigin: "remote",
|
||||
};
|
||||
expect(stripWritePayload("hosts", payload)).toEqual({ name: "web" });
|
||||
});
|
||||
|
||||
it("does not mutate the original payload object", () => {
|
||||
const payload = { id: 1, userId: "user-1", syncId: "abc", name: "x" };
|
||||
stripWritePayload("snippets", payload);
|
||||
expect(payload).toEqual({
|
||||
id: 1,
|
||||
userId: "user-1",
|
||||
syncId: "abc",
|
||||
name: "x",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentHostResolutionRepository: () => ({
|
||||
findCredentialByIdForUser: async () => null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
sshLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
authLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
import { SSHAuthManager } from "../../hosts/auth-manager.js";
|
||||
|
||||
function createManager() {
|
||||
const sent: Record<string, unknown>[] = [];
|
||||
const ws = { send: (data: string) => sent.push(JSON.parse(data)) } as any;
|
||||
const manager = new SSHAuthManager({
|
||||
userId: "user-1",
|
||||
ws,
|
||||
hostId: 1,
|
||||
isKeyboardInteractive: false,
|
||||
keyboardInteractiveResponded: false,
|
||||
keyboardInteractiveFinish: null,
|
||||
totpPromptSent: false,
|
||||
warpgateAuthPromptSent: false,
|
||||
totpTimeout: null,
|
||||
warpgateAuthTimeout: null,
|
||||
totpAttempts: 0,
|
||||
});
|
||||
return { manager, sent };
|
||||
}
|
||||
|
||||
describe("SSHAuthManager.handleKeyboardInteractive", () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("routes a TOTP verification prompt to the totp flow", () => {
|
||||
const { manager, sent } = createManager();
|
||||
const finish = vi.fn();
|
||||
|
||||
manager.handleKeyboardInteractive(
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
[{ prompt: "Verification code: ", echo: true }],
|
||||
finish,
|
||||
{ username: "root", authType: "none" },
|
||||
);
|
||||
|
||||
expect(sent).toEqual([
|
||||
{
|
||||
type: "connection_log",
|
||||
data: {
|
||||
stage: "auth",
|
||||
level: "info",
|
||||
message: "TOTP verification required",
|
||||
},
|
||||
},
|
||||
{ type: "totp_required", prompt: "Verification code: " },
|
||||
]);
|
||||
expect(finish).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forwards echo:true for a JumpCloud-style push/TOTP menu prompt", () => {
|
||||
const { manager, sent } = createManager();
|
||||
const finish = vi.fn();
|
||||
|
||||
manager.handleKeyboardInteractive(
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
[{ prompt: "Choose [1] Push, or [2] TOTP: ", echo: true }],
|
||||
finish,
|
||||
{ username: "root", authType: "none" },
|
||||
);
|
||||
|
||||
expect(sent).toEqual([
|
||||
{
|
||||
type: "connection_log",
|
||||
data: {
|
||||
stage: "auth",
|
||||
level: "info",
|
||||
message: "Password authentication required",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "password_required",
|
||||
prompt: "Choose [1] Push, or [2] TOTP: ",
|
||||
echo: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("silently auto-answers a plain password prompt when a stored password exists", () => {
|
||||
const { manager, sent } = createManager();
|
||||
const finish = vi.fn();
|
||||
|
||||
manager.handleKeyboardInteractive(
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
[{ prompt: "Password: ", echo: false }],
|
||||
finish,
|
||||
{ username: "root", password: "hunter2", authType: "password" },
|
||||
);
|
||||
|
||||
expect(finish).toHaveBeenCalledWith(["hunter2"]);
|
||||
expect(sent).toEqual([]);
|
||||
});
|
||||
|
||||
it("prompts the user for a push-confirm prompt and accepts an empty response", () => {
|
||||
const { manager, sent } = createManager();
|
||||
const finish = vi.fn();
|
||||
|
||||
manager.handleKeyboardInteractive(
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
[{ prompt: "Press enter to send Push request: ", echo: true }],
|
||||
finish,
|
||||
{ username: "root", authType: "none" },
|
||||
);
|
||||
|
||||
expect(sent).toEqual([
|
||||
{
|
||||
type: "connection_log",
|
||||
data: {
|
||||
stage: "auth",
|
||||
level: "info",
|
||||
message: "Password authentication required",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "password_required",
|
||||
prompt: "Press enter to send Push request: ",
|
||||
echo: true,
|
||||
},
|
||||
]);
|
||||
|
||||
manager.context.keyboardInteractiveFinish?.([""]);
|
||||
|
||||
expect(finish).toHaveBeenCalledWith([""]);
|
||||
});
|
||||
|
||||
it("uses a longer timeout for push-style prompts than generic prompts", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { manager, sent } = createManager();
|
||||
const finish = vi.fn();
|
||||
|
||||
manager.handleKeyboardInteractive(
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
[{ prompt: "Press enter to send Push request: ", echo: true }],
|
||||
finish,
|
||||
{ username: "root", authType: "none" },
|
||||
);
|
||||
|
||||
vi.advanceTimersByTime(180001);
|
||||
expect(sent.some((m) => m.type === "error")).toBe(false);
|
||||
|
||||
vi.advanceTimersByTime(120000);
|
||||
expect(sent.some((m) => m.type === "error")).toBe(true);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("routes Warpgate prompts to the warpgate flow, not the generic path", () => {
|
||||
const { manager, sent } = createManager();
|
||||
const finish = vi.fn();
|
||||
|
||||
manager.handleKeyboardInteractive(
|
||||
"Warpgate Authentication",
|
||||
"Visit https://warpgate.example.com/auth to continue. Security key: AB12",
|
||||
"",
|
||||
[{ prompt: "Press enter once done: ", echo: true }],
|
||||
finish,
|
||||
{ username: "root", authType: "none" },
|
||||
);
|
||||
|
||||
expect(sent).toEqual([
|
||||
{
|
||||
type: "connection_log",
|
||||
data: {
|
||||
stage: "auth",
|
||||
level: "info",
|
||||
message: "Warpgate authentication required",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "warpgate_auth_required",
|
||||
url: "https://warpgate.example.com/auth",
|
||||
securityKey: "AB12",
|
||||
instructions:
|
||||
"Visit https://warpgate.example.com/auth to continue. Security key: AB12",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user