mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 18:31:33 +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
@@ -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",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -65,4 +65,41 @@ describe("GuacamoleTokenService", () => {
|
||||
|
||||
expect(tokenService.decryptToken(token)?.recording).toEqual(recording);
|
||||
});
|
||||
|
||||
it("preserves termixMeta through the encrypt/decrypt round trip", () => {
|
||||
const termixMeta = {
|
||||
termixConnectId: "connect-1",
|
||||
hostId: 7,
|
||||
ownerUserId: "user-1",
|
||||
protocol: "rdp" as const,
|
||||
};
|
||||
const token = tokenService.createRdpToken(
|
||||
"windows.example.test",
|
||||
"Administrator",
|
||||
"secret",
|
||||
{},
|
||||
undefined,
|
||||
termixMeta,
|
||||
);
|
||||
|
||||
expect(tokenService.decryptToken(token)?.termixMeta).toEqual(termixMeta);
|
||||
});
|
||||
|
||||
it("createJoinToken sets connection.join, not connection.type", () => {
|
||||
const token = tokenService.createJoinToken("guacd-conn-123", true);
|
||||
const decrypted = tokenService.decryptToken(token);
|
||||
|
||||
expect(decrypted?.connection.join).toBe("guacd-conn-123");
|
||||
expect(decrypted?.connection.type).toBeUndefined();
|
||||
expect(decrypted?.connection.readOnly).toBe(true);
|
||||
});
|
||||
|
||||
it("createJoinToken round-trips a read-write join through decryptToken", () => {
|
||||
const token = tokenService.createJoinToken("guacd-conn-456", false);
|
||||
const decrypted = tokenService.decryptToken(token);
|
||||
|
||||
expect(decrypted?.connection.join).toBe("guacd-conn-456");
|
||||
expect(decrypted?.connection.readOnly).toBe(false);
|
||||
expect(decrypted?.recording).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ const state = vi.hoisted(() => ({
|
||||
credentials: new Map<string, Record<string, unknown>>(),
|
||||
sharedSecret: null as Record<string, unknown> | null,
|
||||
auditCalls: [] as Record<string, unknown>[],
|
||||
folderCredentialId: null as number | null,
|
||||
}));
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
@@ -17,6 +18,7 @@ vi.mock("../../database/repositories/factory.js", () => ({
|
||||
findOverrideCredentialId: async () => state.overrideCredentialId,
|
||||
findCredentialByIdForUser: async (credentialId: number, userId: string) =>
|
||||
state.credentials.get(`${credentialId}:${userId}`) ?? null,
|
||||
findFolderCredentialId: async () => state.folderCredentialId,
|
||||
}),
|
||||
createCurrentVaultProfileRepository: () => ({
|
||||
findById: async () => null,
|
||||
@@ -101,6 +103,7 @@ beforeEach(() => {
|
||||
state.credentials.clear();
|
||||
state.sharedSecret = null;
|
||||
state.auditCalls = [];
|
||||
state.folderCredentialId = null;
|
||||
});
|
||||
|
||||
describe("resolveHostById", () => {
|
||||
@@ -138,6 +141,63 @@ describe("resolveHostById", () => {
|
||||
expect(host.sudoPassword).toBe("owner-sudo");
|
||||
});
|
||||
|
||||
it("falls back to the host's folder-assigned credential when none is set on the host", async () => {
|
||||
state.host = baseHost({
|
||||
authType: "credential",
|
||||
credentialId: null,
|
||||
folder: "switches",
|
||||
username: "",
|
||||
password: null,
|
||||
});
|
||||
state.folderCredentialId = 11;
|
||||
state.credentials.set("11:owner", {
|
||||
id: 11,
|
||||
username: "folder-user",
|
||||
authType: "password",
|
||||
password: "folder-pass",
|
||||
privateKey: null,
|
||||
key: null,
|
||||
keyPassword: null,
|
||||
keyType: null,
|
||||
});
|
||||
|
||||
const host = (await resolveHostById(42, "owner")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(host.password).toBe("folder-pass");
|
||||
expect(host.username).toBe("folder-user");
|
||||
expect(host.authType).toBe("password");
|
||||
});
|
||||
|
||||
it("prefers the host's own credential over its folder's credential", async () => {
|
||||
state.host = baseHost({
|
||||
authType: "credential",
|
||||
credentialId: 9,
|
||||
folder: "switches",
|
||||
username: "",
|
||||
password: null,
|
||||
});
|
||||
state.folderCredentialId = 11;
|
||||
state.credentials.set("9:owner", {
|
||||
id: 9,
|
||||
username: "host-user",
|
||||
authType: "password",
|
||||
password: "host-pass",
|
||||
privateKey: null,
|
||||
key: null,
|
||||
keyPassword: null,
|
||||
keyType: null,
|
||||
});
|
||||
|
||||
const host = (await resolveHostById(42, "owner")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(host.username).toBe("host-user");
|
||||
expect(host.password).toBe("host-pass");
|
||||
});
|
||||
|
||||
it("uses the share snapshot for a non-owner and strips owner-only secrets", async () => {
|
||||
state.host = baseHost({ username: "" });
|
||||
state.sharedSecret = {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
parseDfLines,
|
||||
findWorstMountIndex,
|
||||
} from "../../../../hosts/metrics/widgets/disk-collector.js";
|
||||
|
||||
describe("parseDfLines", () => {
|
||||
it("parses df -P output into rows", () => {
|
||||
const output =
|
||||
"/dev/nvme0n1p2 3848290697216 1046898851840 2606516101120 29% /\n" +
|
||||
"/dev/nvme1n1p1 15393162788864 15239230844928 153931922841 99% /data\n";
|
||||
const rows = parseDfLines(output);
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0].mount).toBe("/");
|
||||
expect(rows[1].mount).toBe("/data");
|
||||
});
|
||||
|
||||
it("filters out pseudo filesystems", () => {
|
||||
const output =
|
||||
"tmpfs 8000 0 8000 0% /dev/shm\n" +
|
||||
"overlay 100 50 50 50% /\n" +
|
||||
"/dev/sda1 100 50 50 50% /mnt/data\n";
|
||||
const rows = parseDfLines(output);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].mount).toBe("/mnt/data");
|
||||
});
|
||||
});
|
||||
|
||||
describe("findWorstMountIndex", () => {
|
||||
it("picks the most-utilized mount, not just the first row", () => {
|
||||
const rows = parseDfLines(
|
||||
"/dev/nvme0n1p2 3848290697216 1046898851840 2606516101120 29% /\n" +
|
||||
"/dev/nvme1n1p1 15393162788864 15239230844928 153931922841 99% /data\n",
|
||||
);
|
||||
const worst = findWorstMountIndex(rows);
|
||||
expect(worst.index).toBe(1);
|
||||
expect(worst.totalBytes).toBe(15393162788864);
|
||||
expect(worst.usedBytes).toBe(15239230844928);
|
||||
});
|
||||
|
||||
it("falls back to the only mount available", () => {
|
||||
const rows = parseDfLines("/dev/sda1 100 30 70 30% /\n");
|
||||
const worst = findWorstMountIndex(rows);
|
||||
expect(worst.index).toBe(0);
|
||||
});
|
||||
|
||||
it("skips rows with invalid or zero totals", () => {
|
||||
const rows = parseDfLines(
|
||||
"/dev/sda1 0 0 0 0% /broken\n" + "/dev/sda2 100 40 60 40% /ok\n",
|
||||
);
|
||||
const worst = findWorstMountIndex(rows);
|
||||
expect(worst.index).toBe(1);
|
||||
});
|
||||
|
||||
it("returns index -1 when there are no usable rows", () => {
|
||||
const worst = findWorstMountIndex([]);
|
||||
expect(worst.index).toBe(-1);
|
||||
expect(worst.totalBytes).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,513 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Request, Response } from "express";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
currentUserId: "user-1",
|
||||
globalSharingEnabled: true,
|
||||
hosts: new Map<number, { userId: string; allowSessionSharing: boolean }>(),
|
||||
hostOwnerAccess: new Map<string, boolean>(), // `${userId}:${hostId}` -> hasAccess
|
||||
sshSessions: new Map<string, { userId: string; isConnected: boolean }>(),
|
||||
guacSessions: new Map<
|
||||
string,
|
||||
{ ownerUserId: string; hostId: number; protocol: string }
|
||||
>(),
|
||||
shares: new Map<string, Record<string, unknown>>(),
|
||||
admins: new Set<string>(),
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
sshLogger: {
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/auth-manager.js", () => ({
|
||||
AuthManager: {
|
||||
getInstance: () => ({
|
||||
createAuthMiddleware:
|
||||
() =>
|
||||
(req: Record<string, unknown>, _res: unknown, next: () => void) => {
|
||||
req.userId = state.currentUserId;
|
||||
next();
|
||||
},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/permission-manager.js", () => ({
|
||||
PermissionManager: {
|
||||
getInstance: () => ({
|
||||
canAccessHost: async (
|
||||
userId: string,
|
||||
hostId: number,
|
||||
_action: string,
|
||||
) => ({
|
||||
hasAccess: state.hostOwnerAccess.get(`${userId}:${hostId}`) ?? false,
|
||||
}),
|
||||
isAdmin: async (userId: string) => state.admins.has(userId),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../hosts/terminal/session-manager.js", () => ({
|
||||
sessionManager: {
|
||||
getSession: (sessionId: string) => {
|
||||
const session = state.sshSessions.get(sessionId);
|
||||
if (!session) return null;
|
||||
return { ...session };
|
||||
},
|
||||
ownerEndSession: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../hosts/guacamole/guacamole-server.js", () => ({
|
||||
getGuacSessionInfo: (guacamoleConnectionId: string) =>
|
||||
state.guacSessions.get(guacamoleConnectionId) ?? null,
|
||||
}));
|
||||
|
||||
vi.mock("../../../hosts/guacamole/token-service.js", () => ({
|
||||
GuacamoleTokenService: {
|
||||
getInstance: () => ({
|
||||
createJoinToken: (guacamoleConnectionId: string, readOnly: boolean) =>
|
||||
`join-token:${guacamoleConnectionId}:${readOnly}`,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentSessionShareRepository: () => ({
|
||||
create: async (input: Record<string, unknown>) => {
|
||||
const row = {
|
||||
...input,
|
||||
createdAt: "2026-07-20T00:00:00.000Z",
|
||||
revokedAt: null,
|
||||
lastJoinedAt: null,
|
||||
joinCount: 0,
|
||||
};
|
||||
state.shares.set(input.id as string, row);
|
||||
return row;
|
||||
},
|
||||
findById: async (id: string) => state.shares.get(id) ?? null,
|
||||
findByLinkToken: async (linkToken: string) => {
|
||||
for (const share of state.shares.values()) {
|
||||
if (
|
||||
share.linkToken === linkToken &&
|
||||
!share.revokedAt &&
|
||||
(share.expiresAt as string) > new Date().toISOString()
|
||||
) {
|
||||
return share;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
findActiveSharesForHost: async (hostId: number, ownerUserId: string) => {
|
||||
return [...state.shares.values()].filter(
|
||||
(s) =>
|
||||
s.hostId === hostId && s.ownerUserId === ownerUserId && !s.revokedAt,
|
||||
);
|
||||
},
|
||||
revoke: async (shareId: string, requestingUserId: string) => {
|
||||
const share = state.shares.get(shareId);
|
||||
if (!share || share.ownerUserId !== requestingUserId) return false;
|
||||
share.revokedAt = "2026-07-20T01:00:00.000Z";
|
||||
return true;
|
||||
},
|
||||
revokeAsAdmin: async (shareId: string) => {
|
||||
const share = state.shares.get(shareId);
|
||||
if (!share) return false;
|
||||
share.revokedAt = "2026-07-20T01:00:00.000Z";
|
||||
return true;
|
||||
},
|
||||
touchShareUsage: async () => {},
|
||||
recordParticipantJoin: async () => ({ id: 1 }),
|
||||
}),
|
||||
createCurrentSettingsRepository: () => ({
|
||||
getBoolean: async () => state.globalSharingEnabled,
|
||||
}),
|
||||
createCurrentHostResolutionRepository: () => ({
|
||||
findHostOwnerId: async (hostId: number) =>
|
||||
state.hosts.get(hostId)?.userId ?? null,
|
||||
findHostById: async (hostId: number) => {
|
||||
const host = state.hosts.get(hostId);
|
||||
if (!host) return null;
|
||||
return { allowSessionSharing: host.allowSessionSharing };
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const { default: router } =
|
||||
await import("../../../hosts/session-sharing/routes.js");
|
||||
|
||||
type RouteLayer = {
|
||||
route?: {
|
||||
path: string;
|
||||
methods: Record<string, boolean>;
|
||||
stack: {
|
||||
handle: (req: Request, res: Response, next: () => void) => unknown;
|
||||
}[];
|
||||
};
|
||||
};
|
||||
|
||||
function findHandlers(method: string, path: string) {
|
||||
const layers = (router as unknown as { stack: RouteLayer[] }).stack;
|
||||
const layer = layers.find(
|
||||
(l) => l.route?.path === path && l.route.methods[method],
|
||||
);
|
||||
if (!layer?.route) throw new Error(`No route for ${method} ${path}`);
|
||||
return layer.route.stack.map((s) => s.handle);
|
||||
}
|
||||
|
||||
function makeReqRes(overrides: {
|
||||
body?: Record<string, unknown>;
|
||||
params?: Record<string, unknown>;
|
||||
ip?: string;
|
||||
}) {
|
||||
const req = {
|
||||
body: overrides.body ?? {},
|
||||
params: overrides.params ?? {},
|
||||
headers: {},
|
||||
ip: overrides.ip ?? "127.0.0.1",
|
||||
socket: { remoteAddress: overrides.ip ?? "127.0.0.1" },
|
||||
} as unknown as Request;
|
||||
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
jsonBody: null as unknown,
|
||||
status(code: number) {
|
||||
(this as unknown as { statusCode: number }).statusCode = code;
|
||||
return this;
|
||||
},
|
||||
json(payload: unknown) {
|
||||
(this as unknown as { jsonBody: unknown }).jsonBody = payload;
|
||||
return this;
|
||||
},
|
||||
} as unknown as Response & { statusCode: number; jsonBody: unknown };
|
||||
|
||||
return { req, res };
|
||||
}
|
||||
|
||||
async function invoke(
|
||||
method: string,
|
||||
path: string,
|
||||
overrides: {
|
||||
body?: Record<string, unknown>;
|
||||
params?: Record<string, unknown>;
|
||||
ip?: string;
|
||||
} = {},
|
||||
) {
|
||||
const handlers = findHandlers(method, path);
|
||||
const { req, res } = makeReqRes(overrides);
|
||||
|
||||
for (const handler of handlers) {
|
||||
let calledNext = false;
|
||||
await handler(req, res, () => {
|
||||
calledNext = true;
|
||||
});
|
||||
if (!calledNext) break;
|
||||
}
|
||||
|
||||
return res as unknown as {
|
||||
statusCode: number;
|
||||
jsonBody: Record<string, unknown> | null;
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
state.currentUserId = "user-1";
|
||||
state.globalSharingEnabled = true;
|
||||
state.hosts = new Map([
|
||||
[1, { userId: "user-1", allowSessionSharing: true }],
|
||||
[2, { userId: "user-1", allowSessionSharing: false }],
|
||||
]);
|
||||
state.hostOwnerAccess = new Map([["user-2:1", true]]);
|
||||
state.sshSessions = new Map([
|
||||
["session-1", { userId: "user-1", isConnected: true }],
|
||||
]);
|
||||
state.guacSessions = new Map([
|
||||
["guac-conn-1", { ownerUserId: "user-1", hostId: 1, protocol: "vnc" }],
|
||||
]);
|
||||
state.shares = new Map();
|
||||
state.admins = new Set();
|
||||
});
|
||||
|
||||
describe("POST /session-sharing/create", () => {
|
||||
it("rejects a caller who does not own the live session", async () => {
|
||||
state.currentUserId = "user-2";
|
||||
const res = await invoke("post", "/create", {
|
||||
body: {
|
||||
hostId: 1,
|
||||
sessionId: "session-1",
|
||||
protocol: "ssh",
|
||||
shareType: "link",
|
||||
permissionLevel: "read-only",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.jsonBody).toMatchObject({
|
||||
error: "You do not own this live session",
|
||||
});
|
||||
});
|
||||
|
||||
it("creates a link share for the session owner", async () => {
|
||||
const res = await invoke("post", "/create", {
|
||||
body: {
|
||||
hostId: 1,
|
||||
sessionId: "session-1",
|
||||
protocol: "ssh",
|
||||
shareType: "link",
|
||||
permissionLevel: "read-only",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.jsonBody).toMatchObject({ shareId: expect.any(String) });
|
||||
expect((res.jsonBody as Record<string, unknown>).linkToken).toBeTruthy();
|
||||
});
|
||||
|
||||
it("rejects a user share when the target lacks host access", async () => {
|
||||
const res = await invoke("post", "/create", {
|
||||
body: {
|
||||
hostId: 1,
|
||||
sessionId: "session-1",
|
||||
protocol: "ssh",
|
||||
shareType: "user",
|
||||
targetUserId: "no-access-user",
|
||||
permissionLevel: "read-write",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.jsonBody).toMatchObject({
|
||||
error: "Target user does not have access to this host",
|
||||
});
|
||||
});
|
||||
|
||||
it("global kill switch overrides an enabled per-host toggle", async () => {
|
||||
state.globalSharingEnabled = false;
|
||||
const res = await invoke("post", "/create", {
|
||||
body: {
|
||||
hostId: 1,
|
||||
sessionId: "session-1",
|
||||
protocol: "ssh",
|
||||
shareType: "link",
|
||||
permissionLevel: "read-only",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.jsonBody).toMatchObject({
|
||||
error: "Session sharing is disabled for this host",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects when the per-host toggle is off even though global is on", async () => {
|
||||
const res = await invoke("post", "/create", {
|
||||
body: {
|
||||
hostId: 2,
|
||||
sessionId: "session-1",
|
||||
protocol: "ssh",
|
||||
shareType: "link",
|
||||
permissionLevel: "read-only",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /session-sharing/resolve/:linkToken", () => {
|
||||
async function createActiveLinkShare(
|
||||
overrides: Partial<Record<string, unknown>> = {},
|
||||
) {
|
||||
await invoke("post", "/create", {
|
||||
body: {
|
||||
hostId: 1,
|
||||
sessionId: "session-1",
|
||||
protocol: "ssh",
|
||||
shareType: "link",
|
||||
permissionLevel: "read-only",
|
||||
...overrides,
|
||||
},
|
||||
});
|
||||
const [share] = [...state.shares.values()];
|
||||
return share as { linkToken: string; id: string };
|
||||
}
|
||||
|
||||
it("never includes hostname, ip, username, or hostId in the response body", async () => {
|
||||
const share = await createActiveLinkShare();
|
||||
|
||||
const res = await invoke("get", "/resolve/:linkToken", {
|
||||
params: { linkToken: share.linkToken },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.jsonBody as Record<string, unknown>;
|
||||
const serialized = JSON.stringify(body).toLowerCase();
|
||||
|
||||
expect(body).not.toHaveProperty("hostname");
|
||||
expect(body).not.toHaveProperty("ip");
|
||||
expect(body).not.toHaveProperty("username");
|
||||
expect(body).not.toHaveProperty("hostId");
|
||||
expect(body).not.toHaveProperty("hostName");
|
||||
expect(serialized).not.toContain("10.0.0");
|
||||
expect(serialized).not.toContain("hostname");
|
||||
expect(serialized).not.toContain('"ip"');
|
||||
expect(serialized).not.toContain("username");
|
||||
});
|
||||
|
||||
it("returns only protocol/permissionLevel/wsPath(/connectParams) for ssh", async () => {
|
||||
const share = await createActiveLinkShare();
|
||||
|
||||
const res = await invoke("get", "/resolve/:linkToken", {
|
||||
params: { linkToken: share.linkToken },
|
||||
});
|
||||
|
||||
expect(res.jsonBody).toEqual({
|
||||
protocol: "ssh",
|
||||
permissionLevel: "read-only",
|
||||
wsPath: `/terminal/ws?shareToken=${encodeURIComponent(share.linkToken)}`,
|
||||
});
|
||||
});
|
||||
|
||||
it("mints a fresh join token for guac protocols", async () => {
|
||||
await invoke("post", "/create", {
|
||||
body: {
|
||||
hostId: 1,
|
||||
sessionId: "guac-conn-1",
|
||||
protocol: "vnc",
|
||||
shareType: "link",
|
||||
permissionLevel: "read-only",
|
||||
},
|
||||
});
|
||||
const [share] = [...state.shares.values()] as {
|
||||
linkToken: string;
|
||||
}[];
|
||||
|
||||
const res = await invoke("get", "/resolve/:linkToken", {
|
||||
params: { linkToken: share.linkToken },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect((res.jsonBody as Record<string, unknown>).connectParams).toEqual({
|
||||
token: "join-token:guac-conn-1:true",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects an unknown link token", async () => {
|
||||
const res = await invoke("get", "/resolve/:linkToken", {
|
||||
params: { linkToken: "does-not-exist" },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("rejects a revoked link token", async () => {
|
||||
const share = await createActiveLinkShare();
|
||||
await invoke("delete", "/:shareId", { params: { shareId: share.id } });
|
||||
|
||||
const res = await invoke("get", "/resolve/:linkToken", {
|
||||
params: { linkToken: share.linkToken },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("rejects an expired link token", async () => {
|
||||
state.shares.set("share-expired", {
|
||||
id: "share-expired",
|
||||
hostId: 1,
|
||||
ownerUserId: "user-1",
|
||||
protocol: "ssh",
|
||||
sessionId: "session-1",
|
||||
shareType: "link",
|
||||
linkToken: "expired-token",
|
||||
permissionLevel: "read-only",
|
||||
expiresAt: "2000-01-01T00:00:00.000Z",
|
||||
revokedAt: null,
|
||||
});
|
||||
|
||||
const res = await invoke("get", "/resolve/:linkToken", {
|
||||
params: { linkToken: "expired-token" },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("re-checks the global kill switch at resolve time, not just at creation time", async () => {
|
||||
const share = await createActiveLinkShare();
|
||||
|
||||
state.globalSharingEnabled = false;
|
||||
|
||||
const res = await invoke("get", "/resolve/:linkToken", {
|
||||
params: { linkToken: share.linkToken },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /session-sharing/:shareId", () => {
|
||||
it("allows the owner to revoke their own share", async () => {
|
||||
await invoke("post", "/create", {
|
||||
body: {
|
||||
hostId: 1,
|
||||
sessionId: "session-1",
|
||||
protocol: "ssh",
|
||||
shareType: "link",
|
||||
permissionLevel: "read-only",
|
||||
},
|
||||
});
|
||||
const [share] = [...state.shares.values()] as { id: string }[];
|
||||
|
||||
const res = await invoke("delete", "/:shareId", {
|
||||
params: { shareId: share.id },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("rejects a non-owner, non-admin caller", async () => {
|
||||
await invoke("post", "/create", {
|
||||
body: {
|
||||
hostId: 1,
|
||||
sessionId: "session-1",
|
||||
protocol: "ssh",
|
||||
shareType: "link",
|
||||
permissionLevel: "read-only",
|
||||
},
|
||||
});
|
||||
const [share] = [...state.shares.values()] as { id: string }[];
|
||||
|
||||
state.currentUserId = "user-2";
|
||||
const res = await invoke("delete", "/:shareId", {
|
||||
params: { shareId: share.id },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it("allows an admin to revoke someone else's share", async () => {
|
||||
await invoke("post", "/create", {
|
||||
body: {
|
||||
hostId: 1,
|
||||
sessionId: "session-1",
|
||||
protocol: "ssh",
|
||||
shareType: "link",
|
||||
permissionLevel: "read-only",
|
||||
},
|
||||
});
|
||||
const [share] = [...state.shares.values()] as { id: string }[];
|
||||
|
||||
state.currentUserId = "admin-1";
|
||||
state.admins.add("admin-1");
|
||||
const res = await invoke("delete", "/:shareId", {
|
||||
params: { shareId: share.id },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -49,9 +49,19 @@ vi.mock("fs", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const { sessionManager } =
|
||||
const { sessionManager, isMessageAllowedForParticipant } =
|
||||
await import("../../../hosts/terminal/session-manager.js");
|
||||
|
||||
// Minimal fake WebSocket - only the surface session-manager touches.
|
||||
function makeFakeWs(readyState = 1 /* OPEN */) {
|
||||
return {
|
||||
readyState,
|
||||
send: vi.fn(),
|
||||
} as unknown as import("ws").WebSocket;
|
||||
}
|
||||
const WS_OPEN = 1;
|
||||
const WS_CLOSED = 3;
|
||||
|
||||
describe("TerminalSessionManager - session logging", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -150,3 +160,273 @@ describe("TerminalSessionManager - session logging", () => {
|
||||
sessionManager.destroySession(id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TerminalSessionManager - multiplayer participants", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockMkdir.mockResolvedValue(undefined);
|
||||
mockWriteFile.mockResolvedValue(undefined);
|
||||
mockCreate.mockResolvedValue({ id: 1 });
|
||||
mockUpdateEnded.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
function createConnectedSession(): string {
|
||||
const id = sessionManager.createSession(
|
||||
"owner-1",
|
||||
1,
|
||||
"host",
|
||||
80,
|
||||
24,
|
||||
undefined,
|
||||
false,
|
||||
);
|
||||
// Mark connected without a real ssh2 stream - only isConnected is read
|
||||
// by attachWs/joinAsParticipant.
|
||||
const session = sessionManager.getSession(id)!;
|
||||
session.isConnected = true;
|
||||
return id;
|
||||
}
|
||||
|
||||
it("joinAsParticipant adds a participant without evicting the owner", () => {
|
||||
const id = createConnectedSession();
|
||||
const ownerWs = makeFakeWs();
|
||||
sessionManager.attachWs(id, "owner-1", ownerWs);
|
||||
|
||||
const guestWs = makeFakeWs();
|
||||
const session = sessionManager.joinAsParticipant(id, guestWs, {
|
||||
userId: null,
|
||||
permissionLevel: "read-only",
|
||||
guestLabel: "Guest",
|
||||
});
|
||||
|
||||
expect(session).not.toBeNull();
|
||||
expect(session!.participants.size).toBe(2);
|
||||
const ownerParticipant = sessionManager.getParticipantForWs(
|
||||
session!,
|
||||
ownerWs,
|
||||
);
|
||||
expect(ownerParticipant?.isOwner).toBe(true);
|
||||
expect(ownerWs.send).not.toHaveBeenCalled();
|
||||
|
||||
sessionManager.destroySession(id);
|
||||
});
|
||||
|
||||
it("joinAsParticipant returns null for a nonexistent or unconnected session", () => {
|
||||
expect(
|
||||
sessionManager.joinAsParticipant("does-not-exist", makeFakeWs(), {
|
||||
userId: null,
|
||||
permissionLevel: "read-only",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("broadcast sends to all OPEN participant sockets and skips CLOSED ones", () => {
|
||||
const id = createConnectedSession();
|
||||
const ownerWs = makeFakeWs(WS_OPEN);
|
||||
sessionManager.attachWs(id, "owner-1", ownerWs);
|
||||
|
||||
const openGuestWs = makeFakeWs(WS_OPEN);
|
||||
const closedGuestWs = makeFakeWs(WS_CLOSED);
|
||||
sessionManager.joinAsParticipant(id, openGuestWs, {
|
||||
userId: null,
|
||||
permissionLevel: "read-only",
|
||||
});
|
||||
sessionManager.joinAsParticipant(id, closedGuestWs, {
|
||||
userId: null,
|
||||
permissionLevel: "read-only",
|
||||
});
|
||||
|
||||
sessionManager.broadcast(id, { type: "data", data: "hello" });
|
||||
|
||||
expect(ownerWs.send).toHaveBeenCalledWith(
|
||||
JSON.stringify({ type: "data", data: "hello" }),
|
||||
);
|
||||
expect(openGuestWs.send).toHaveBeenCalledWith(
|
||||
JSON.stringify({ type: "data", data: "hello" }),
|
||||
);
|
||||
expect(closedGuestWs.send).not.toHaveBeenCalled();
|
||||
|
||||
sessionManager.destroySession(id);
|
||||
});
|
||||
|
||||
it("broadcast does not throw if a socket's send throws", () => {
|
||||
const id = createConnectedSession();
|
||||
const throwingWs = makeFakeWs(WS_OPEN);
|
||||
(throwingWs.send as ReturnType<typeof vi.fn>).mockImplementation(() => {
|
||||
throw new Error("send failed");
|
||||
});
|
||||
sessionManager.attachWs(id, "owner-1", throwingWs);
|
||||
|
||||
expect(() =>
|
||||
sessionManager.broadcast(id, { type: "data", data: "x" }),
|
||||
).not.toThrow();
|
||||
|
||||
sessionManager.destroySession(id);
|
||||
});
|
||||
|
||||
it("broadcast is a no-op for a nonexistent session", () => {
|
||||
expect(() =>
|
||||
sessionManager.broadcast("does-not-exist", { type: "data" }),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("owner detach arms the idle timeout (existing behavior)", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const id = createConnectedSession();
|
||||
const ownerWs = makeFakeWs();
|
||||
sessionManager.attachWs(id, "owner-1", ownerWs);
|
||||
|
||||
sessionManager.detachWs(id);
|
||||
const session = sessionManager.getSession(id);
|
||||
expect(session?.detachTimeout).not.toBeNull();
|
||||
expect(session?.lastDetachedAt).not.toBeNull();
|
||||
|
||||
sessionManager.destroySession(id);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("removeParticipant on a non-owner does not arm a timeout or destroy the session", () => {
|
||||
const id = createConnectedSession();
|
||||
const ownerWs = makeFakeWs();
|
||||
sessionManager.attachWs(id, "owner-1", ownerWs);
|
||||
|
||||
const guestWs = makeFakeWs();
|
||||
sessionManager.joinAsParticipant(id, guestWs, {
|
||||
userId: null,
|
||||
permissionLevel: "read-write",
|
||||
});
|
||||
|
||||
sessionManager.removeParticipant(id, guestWs);
|
||||
|
||||
const session = sessionManager.getSession(id);
|
||||
expect(session).not.toBeNull();
|
||||
expect(session?.detachTimeout).toBeNull();
|
||||
expect(session?.participants.size).toBe(1);
|
||||
expect(sessionManager.getParticipantForWs(session!, guestWs)).toBeNull();
|
||||
|
||||
sessionManager.destroySession(id);
|
||||
});
|
||||
|
||||
it("removeParticipant is a no-op when the ws belongs to the owner", () => {
|
||||
const id = createConnectedSession();
|
||||
const ownerWs = makeFakeWs();
|
||||
sessionManager.attachWs(id, "owner-1", ownerWs);
|
||||
|
||||
sessionManager.removeParticipant(id, ownerWs);
|
||||
|
||||
const session = sessionManager.getSession(id);
|
||||
expect(session?.participants.size).toBe(1);
|
||||
expect(sessionManager.getParticipantForWs(session!, ownerWs)?.isOwner).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
sessionManager.destroySession(id);
|
||||
});
|
||||
|
||||
it("destroySession cleans up all participants, not just the owner", () => {
|
||||
const id = createConnectedSession();
|
||||
const ownerWs = makeFakeWs();
|
||||
sessionManager.attachWs(id, "owner-1", ownerWs);
|
||||
|
||||
const guestWs = makeFakeWs();
|
||||
sessionManager.joinAsParticipant(id, guestWs, {
|
||||
userId: null,
|
||||
permissionLevel: "read-only",
|
||||
});
|
||||
|
||||
sessionManager.destroySession(id);
|
||||
|
||||
expect(guestWs.send).toHaveBeenCalled();
|
||||
expect(sessionManager.getSession(id)).toBeNull();
|
||||
});
|
||||
|
||||
it("ownerEndSession notifies non-owner participants and destroys the session", () => {
|
||||
const id = createConnectedSession();
|
||||
const ownerWs = makeFakeWs();
|
||||
sessionManager.attachWs(id, "owner-1", ownerWs);
|
||||
|
||||
const guestWs = makeFakeWs();
|
||||
sessionManager.joinAsParticipant(id, guestWs, {
|
||||
userId: null,
|
||||
permissionLevel: "read-write",
|
||||
});
|
||||
|
||||
sessionManager.ownerEndSession(id, "owner ended the session");
|
||||
|
||||
expect(guestWs.send).toHaveBeenCalledWith(
|
||||
JSON.stringify({
|
||||
type: "sessionTerminatedByOwner",
|
||||
reason: "owner ended the session",
|
||||
}),
|
||||
);
|
||||
expect(sessionManager.getSession(id)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isMessageAllowedForParticipant", () => {
|
||||
it("allows any message type for the owner or when there is no participant", () => {
|
||||
expect(isMessageAllowedForParticipant(null, "connectToHost")).toBe(true);
|
||||
expect(
|
||||
isMessageAllowedForParticipant(
|
||||
{ isOwner: true, permissionLevel: "read-write" },
|
||||
"resize",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("drops input from a read-only participant", () => {
|
||||
expect(
|
||||
isMessageAllowedForParticipant(
|
||||
{ isOwner: false, permissionLevel: "read-only" },
|
||||
"input",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("allows input from a read-write non-owner participant", () => {
|
||||
expect(
|
||||
isMessageAllowedForParticipant(
|
||||
{ isOwner: false, permissionLevel: "read-write" },
|
||||
"input",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("allows ping and disconnect for any non-owner participant", () => {
|
||||
expect(
|
||||
isMessageAllowedForParticipant(
|
||||
{ isOwner: false, permissionLevel: "read-only" },
|
||||
"ping",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isMessageAllowedForParticipant(
|
||||
{ isOwner: false, permissionLevel: "read-only" },
|
||||
"disconnect",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks resize and auth/tmux message types for non-owner participants regardless of permission level", () => {
|
||||
for (const type of [
|
||||
"resize",
|
||||
"totp_response",
|
||||
"password_response",
|
||||
"tmux_attach",
|
||||
"tmux_detach",
|
||||
"get_cwd",
|
||||
"vault_start_auth",
|
||||
"opkssh_start_auth",
|
||||
]) {
|
||||
expect(
|
||||
isMessageAllowedForParticipant(
|
||||
{ isOwner: false, permissionLevel: "read-write" },
|
||||
type,
|
||||
),
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
const mockGetBoolean = vi.fn();
|
||||
const mockGet = vi.fn();
|
||||
const mockSet = vi.fn();
|
||||
const mockPost = vi.fn();
|
||||
|
||||
function makeChain(resolveValue: unknown) {
|
||||
const chain: Record<string, unknown> = {};
|
||||
const methods = ["from", "where", "groupBy"];
|
||||
for (const m of methods) {
|
||||
chain[m] = vi.fn(() => chain);
|
||||
}
|
||||
(chain as unknown as Promise<unknown>).then = (cb: (v: unknown) => unknown) =>
|
||||
Promise.resolve(resolveValue).then(cb);
|
||||
return chain;
|
||||
}
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentSettingsRepository: () => ({
|
||||
getBoolean: mockGetBoolean,
|
||||
get: mockGet,
|
||||
set: mockSet,
|
||||
}),
|
||||
createCurrentRepositoryContext: () => ({
|
||||
drizzle: {
|
||||
select: vi.fn(() => makeChain([{ count: 0 }])),
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../database/db/schema.js", () => ({
|
||||
users: {},
|
||||
hosts: {},
|
||||
recentActivity: { type: "type", timestamp: "timestamp" },
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
Logger: class {
|
||||
info = vi.fn();
|
||||
warn = vi.fn();
|
||||
error = vi.fn();
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("axios", () => ({
|
||||
default: { post: mockPost },
|
||||
}));
|
||||
|
||||
describe("analytics", () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
it("isAnalyticsEnabled defaults to true via the settings repository", async () => {
|
||||
mockGetBoolean.mockResolvedValue(true);
|
||||
const { isAnalyticsEnabled } = await import("../../utils/analytics.js");
|
||||
|
||||
const result = await isAnalyticsEnabled();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockGetBoolean).toHaveBeenCalledWith("analytics_enabled", true);
|
||||
});
|
||||
|
||||
it("getOrCreateInstanceId returns the existing id without generating one", async () => {
|
||||
mockGet.mockResolvedValue("existing-id");
|
||||
const { getOrCreateInstanceId } = await import("../../utils/analytics.js");
|
||||
|
||||
const id = await getOrCreateInstanceId();
|
||||
|
||||
expect(id).toBe("existing-id");
|
||||
expect(mockSet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("getOrCreateInstanceId generates and persists a new id when absent", async () => {
|
||||
mockGet.mockResolvedValue(null);
|
||||
const { getOrCreateInstanceId } = await import("../../utils/analytics.js");
|
||||
|
||||
const id = await getOrCreateInstanceId();
|
||||
|
||||
expect(id).toMatch(/^[0-9a-f-]{36}$/);
|
||||
expect(mockSet).toHaveBeenCalledWith("analytics_instance_id", id);
|
||||
});
|
||||
|
||||
it("collectAndSendHeartbeat does not call PostHog when POSTHOG_API_KEY is unset", async () => {
|
||||
delete process.env.POSTHOG_API_KEY;
|
||||
const { collectAndSendHeartbeat } =
|
||||
await import("../../utils/analytics.js");
|
||||
|
||||
await collectAndSendHeartbeat();
|
||||
|
||||
expect(mockPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("collectAndSendHeartbeat does not call PostHog when analytics is disabled", async () => {
|
||||
process.env.POSTHOG_API_KEY = "phc_test";
|
||||
mockGetBoolean.mockResolvedValue(false);
|
||||
const { collectAndSendHeartbeat } =
|
||||
await import("../../utils/analytics.js");
|
||||
|
||||
await collectAndSendHeartbeat();
|
||||
|
||||
expect(mockPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("collectAndSendHeartbeat posts a heartbeat event with the expected shape when enabled", async () => {
|
||||
process.env.POSTHOG_API_KEY = "phc_test";
|
||||
mockGetBoolean.mockResolvedValue(true);
|
||||
mockGet.mockResolvedValue("instance-123");
|
||||
mockPost.mockResolvedValue({});
|
||||
const { collectAndSendHeartbeat } =
|
||||
await import("../../utils/analytics.js");
|
||||
|
||||
await collectAndSendHeartbeat();
|
||||
|
||||
expect(mockPost).toHaveBeenCalledTimes(1);
|
||||
const [url, body] = mockPost.mock.calls[0];
|
||||
expect(url).toContain("/capture/");
|
||||
expect(body).toMatchObject({
|
||||
api_key: "phc_test",
|
||||
event: "instance_heartbeat",
|
||||
distinct_id: "instance-123",
|
||||
properties: expect.objectContaining({
|
||||
user_count: 0,
|
||||
host_count: 0,
|
||||
used_terminal: 0,
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { LookupAddress, LookupAllOptions } from "dns";
|
||||
import {
|
||||
createDnsLookupHook,
|
||||
isBlockedAddress,
|
||||
} from "../../utils/safe-outbound-fetch.js";
|
||||
|
||||
describe("isBlockedAddress", () => {
|
||||
it("allows public IPv4 addresses", () => {
|
||||
expect(isBlockedAddress("8.8.8.8")).toBe(false);
|
||||
expect(isBlockedAddress("104.21.52.150")).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks private/reserved IPv4 ranges", () => {
|
||||
expect(isBlockedAddress("10.0.0.1")).toBe(true);
|
||||
expect(isBlockedAddress("172.16.0.1")).toBe(true);
|
||||
expect(isBlockedAddress("192.168.1.1")).toBe(true);
|
||||
expect(isBlockedAddress("127.0.0.1")).toBe(true);
|
||||
expect(isBlockedAddress("169.254.1.1")).toBe(true);
|
||||
expect(isBlockedAddress("100.64.0.1")).toBe(true);
|
||||
});
|
||||
|
||||
it("allows public IPv6 addresses", () => {
|
||||
expect(isBlockedAddress("2606:4700:3034::ac43:c88d")).toBe(false);
|
||||
expect(isBlockedAddress("2001:4860:4860::8888")).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks private/reserved IPv6 ranges", () => {
|
||||
expect(isBlockedAddress("::1")).toBe(true);
|
||||
expect(isBlockedAddress("fc00::1")).toBe(true);
|
||||
expect(isBlockedAddress("fe80::1")).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks IPv4-mapped-IPv6 spoofing of private addresses", () => {
|
||||
expect(isBlockedAddress("::ffff:127.0.0.1")).toBe(true);
|
||||
expect(isBlockedAddress("::ffff:192.168.1.1")).toBe(true);
|
||||
expect(isBlockedAddress("::ffff:10.0.0.1")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not block IPv4-mapped-IPv6 form of public addresses", () => {
|
||||
expect(isBlockedAddress("::ffff:104.21.52.150")).toBe(false);
|
||||
expect(isBlockedAddress("::ffff:8.8.8.8")).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks unparseable input", () => {
|
||||
expect(isBlockedAddress("not-an-ip")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// These exercise createDnsLookupHook directly against a fake resolver,
|
||||
// bypassing fetch()/undici entirely. That's the actual code path the
|
||||
// original bug lived in — a public IPv4 address getting misclassified as
|
||||
// private — and testing it through a real Agent/fetch call would only
|
||||
// add flakiness (real TCP connects, undici's own quirks) without adding
|
||||
// coverage of the logic that actually broke.
|
||||
function runHook(
|
||||
addresses: LookupAddress[],
|
||||
error: NodeJS.ErrnoException | null = null,
|
||||
) {
|
||||
const fakeLookup = (
|
||||
_host: string,
|
||||
_opts: LookupAllOptions,
|
||||
cb: (err: NodeJS.ErrnoException | null, addrs: LookupAddress[]) => void,
|
||||
) => cb(error, addresses);
|
||||
|
||||
const hook = createDnsLookupHook(fakeLookup);
|
||||
const callback = vi.fn();
|
||||
hook("example.invalid", { all: true }, callback);
|
||||
return callback;
|
||||
}
|
||||
|
||||
describe("createDnsLookupHook", () => {
|
||||
it("allows a public IPv4 address through", () => {
|
||||
const callback = runHook([{ address: "104.21.52.150", family: 4 }]);
|
||||
expect(callback).toHaveBeenCalledWith(null, "104.21.52.150", 4);
|
||||
});
|
||||
|
||||
it("rejects a private address with the private-destination error", () => {
|
||||
const callback = runHook([{ address: "192.168.1.1", family: 4 }]);
|
||||
expect(callback).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "Private destinations are not allowed",
|
||||
}),
|
||||
"",
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects with a distinct error when DNS returns no addresses", () => {
|
||||
const callback = runHook([]);
|
||||
expect(callback).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "DNS resolution returned no addresses",
|
||||
}),
|
||||
"",
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it("propagates a real DNS lookup error untouched", () => {
|
||||
const dnsError = Object.assign(new Error("getaddrinfo ENOTFOUND"), {
|
||||
code: "ENOTFOUND",
|
||||
});
|
||||
const callback = runHook([], dnsError);
|
||||
expect(callback).toHaveBeenCalledWith(dnsError, "", 0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import crypto from "crypto";
|
||||
import axios from "axios";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { users, hosts, recentActivity } from "../database/db/schema.js";
|
||||
import {
|
||||
createCurrentSettingsRepository,
|
||||
createCurrentRepositoryContext,
|
||||
} from "../database/repositories/factory.js";
|
||||
import { Logger } from "./logger.js";
|
||||
|
||||
export const analyticsLogger = new Logger("ANALYTICS", "📈", "#06b6d4");
|
||||
|
||||
const FEATURE_ACTIVITY_TYPES = [
|
||||
"terminal",
|
||||
"file_manager",
|
||||
"tunnel",
|
||||
"docker",
|
||||
"telnet",
|
||||
"vnc",
|
||||
"rdp",
|
||||
"server_stats",
|
||||
] as const;
|
||||
|
||||
const POSTHOG_HOST = process.env.POSTHOG_HOST || "https://us.i.posthog.com";
|
||||
const HEARTBEAT_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export async function isAnalyticsEnabled(): Promise<boolean> {
|
||||
return createCurrentSettingsRepository().getBoolean(
|
||||
"analytics_enabled",
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getOrCreateInstanceId(): Promise<string> {
|
||||
const settings = createCurrentSettingsRepository();
|
||||
const existing = await settings.get("analytics_instance_id");
|
||||
if (existing) return existing;
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
await settings.set("analytics_instance_id", id);
|
||||
return id;
|
||||
}
|
||||
|
||||
function getAppVersion(): string {
|
||||
return process.env.VERSION || "unknown";
|
||||
}
|
||||
|
||||
async function collectFeatureUsage(): Promise<Record<string, number>> {
|
||||
const since = new Date(Date.now() - HEARTBEAT_INTERVAL_MS).toISOString();
|
||||
const db = createCurrentRepositoryContext().drizzle;
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
type: recentActivity.type,
|
||||
count: sql<number>`count(*)`,
|
||||
})
|
||||
.from(recentActivity)
|
||||
.where(sql`${recentActivity.timestamp} >= ${since}`)
|
||||
.groupBy(recentActivity.type);
|
||||
|
||||
const counts = new Map(rows.map((row) => [row.type, Number(row.count)]));
|
||||
const usage: Record<string, number> = {};
|
||||
for (const type of FEATURE_ACTIVITY_TYPES) {
|
||||
usage[`used_${type}`] = counts.get(type) ?? 0;
|
||||
}
|
||||
return usage;
|
||||
}
|
||||
|
||||
async function collectCounts(): Promise<{
|
||||
userCount: number;
|
||||
hostCount: number;
|
||||
}> {
|
||||
const db = createCurrentRepositoryContext().drizzle;
|
||||
|
||||
const [userRows, hostRows] = await Promise.all([
|
||||
db.select({ count: sql<number>`count(*)` }).from(users),
|
||||
db.select({ count: sql<number>`count(*)` }).from(hosts),
|
||||
]);
|
||||
|
||||
return {
|
||||
userCount: Number(userRows[0]?.count ?? 0),
|
||||
hostCount: Number(hostRows[0]?.count ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
export async function collectAndSendHeartbeat(): Promise<void> {
|
||||
const apiKey = process.env.POSTHOG_API_KEY;
|
||||
if (!apiKey) return;
|
||||
|
||||
try {
|
||||
if (!(await isAnalyticsEnabled())) return;
|
||||
|
||||
const instanceId = await getOrCreateInstanceId();
|
||||
const { userCount, hostCount } = await collectCounts();
|
||||
const featureUsage = await collectFeatureUsage();
|
||||
|
||||
await axios.post(
|
||||
`${POSTHOG_HOST}/capture/`,
|
||||
{
|
||||
api_key: apiKey,
|
||||
event: "instance_heartbeat",
|
||||
distinct_id: instanceId,
|
||||
properties: {
|
||||
version: getAppVersion(),
|
||||
user_count: userCount,
|
||||
host_count: hostCount,
|
||||
...featureUsage,
|
||||
},
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
|
||||
analyticsLogger.info("Sent daily usage heartbeat", {
|
||||
operation: "analytics_heartbeat_sent",
|
||||
});
|
||||
} catch (err) {
|
||||
analyticsLogger.warn("Failed to send usage heartbeat", {
|
||||
operation: "analytics_heartbeat_failed",
|
||||
error: err instanceof Error ? err.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function startAnalyticsHeartbeat(): void {
|
||||
if (!process.env.POSTHOG_API_KEY) {
|
||||
analyticsLogger.info("Analytics disabled: POSTHOG_API_KEY not set", {
|
||||
operation: "analytics_disabled_no_key",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
void collectAndSendHeartbeat();
|
||||
setInterval(() => void collectAndSendHeartbeat(), HEARTBEAT_INTERVAL_MS);
|
||||
}
|
||||
@@ -1,28 +1,54 @@
|
||||
import { lookup } from "dns";
|
||||
import { lookup, type LookupAddress, type LookupAllOptions } from "dns";
|
||||
import { BlockList, isIP } from "net";
|
||||
import { Agent } from "undici";
|
||||
|
||||
type DnsLookupFn = (
|
||||
hostname: string,
|
||||
options: LookupAllOptions,
|
||||
callback: (
|
||||
err: NodeJS.ErrnoException | null,
|
||||
addresses: LookupAddress[],
|
||||
) => void,
|
||||
) => void;
|
||||
|
||||
type LookupHookCallback = (
|
||||
error: NodeJS.ErrnoException | Error | null,
|
||||
address: string,
|
||||
family: number,
|
||||
) => void;
|
||||
|
||||
const blockedAddresses = new BlockList();
|
||||
|
||||
for (const [network, prefix] of [
|
||||
// Derived, not hand-duplicated: Node's BlockList matches addresses across
|
||||
// families through their IPv4-mapped-IPv6 form regardless of which `type`
|
||||
// you pass to check()/addSubnet() (see the addAddress('123.123.123.123') /
|
||||
// check('::ffff:123.123.123.123') example on
|
||||
// https://nodejs.org/api/net.html#class-netblocklist). So every IPv4 range
|
||||
// below needs an "::ffff:<net>" mirror in the IPv6 list, or a spoofed
|
||||
// literal like "::ffff:127.0.0.1" slips through unblocked. Generating the
|
||||
// mirror from this list instead of maintaining two lists by hand means the
|
||||
// two can't drift out of sync the way they did before.
|
||||
const blockedIpv4Ranges = [
|
||||
["0.0.0.0", 8],
|
||||
["10.0.0.0", 8],
|
||||
["100.64.0.0", 10],
|
||||
["100.64.0.0", 10], // CGNAT
|
||||
["127.0.0.0", 8],
|
||||
["169.254.0.0", 16],
|
||||
["169.254.0.0", 16], // link-local
|
||||
["172.16.0.0", 12],
|
||||
["192.168.0.0", 16],
|
||||
["198.18.0.0", 15],
|
||||
["224.0.0.0", 4],
|
||||
["240.0.0.0", 4],
|
||||
] as const) {
|
||||
["198.18.0.0", 15], // benchmarking
|
||||
["224.0.0.0", 4], // multicast
|
||||
["240.0.0.0", 4], // reserved
|
||||
] as const;
|
||||
|
||||
for (const [network, prefix] of blockedIpv4Ranges) {
|
||||
blockedAddresses.addSubnet(network, prefix, "ipv4");
|
||||
blockedAddresses.addSubnet(`::ffff:${network}`, prefix + 96, "ipv6");
|
||||
}
|
||||
|
||||
for (const [network, prefix] of [
|
||||
["::", 128],
|
||||
["::1", 128],
|
||||
["::ffff:0:0", 96],
|
||||
["fc00::", 7],
|
||||
["fe80::", 10],
|
||||
["ff00::", 8],
|
||||
@@ -30,7 +56,7 @@ for (const [network, prefix] of [
|
||||
blockedAddresses.addSubnet(network, prefix, "ipv6");
|
||||
}
|
||||
|
||||
function isBlockedAddress(address: string): boolean {
|
||||
export function isBlockedAddress(address: string): boolean {
|
||||
const family = isIP(address);
|
||||
return (
|
||||
family === 0 ||
|
||||
@@ -38,6 +64,42 @@ function isBlockedAddress(address: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
// Extracted so the blocklist decision can be tested directly against a
|
||||
// fake DNS resolver, instead of only through a real fetch()/Agent call —
|
||||
// the actual bug here lived entirely in this callback, several layers
|
||||
// below where undici's own "fetch failed" wrapping would otherwise hide it.
|
||||
export function createDnsLookupHook(dnsLookup: DnsLookupFn = lookup) {
|
||||
return function lookupHook(
|
||||
host: string,
|
||||
lookupOptions: LookupAllOptions,
|
||||
callback: LookupHookCallback,
|
||||
): void {
|
||||
dnsLookup(
|
||||
host,
|
||||
{ ...lookupOptions, all: true, verbatim: true },
|
||||
(error, addresses) => {
|
||||
if (error) return callback(error, "", 0);
|
||||
if (!addresses.length) {
|
||||
return callback(
|
||||
new Error("DNS resolution returned no addresses"),
|
||||
"",
|
||||
0,
|
||||
);
|
||||
}
|
||||
if (addresses.some(({ address }) => isBlockedAddress(address))) {
|
||||
return callback(
|
||||
new Error("Private destinations are not allowed"),
|
||||
"",
|
||||
0,
|
||||
);
|
||||
}
|
||||
const selected = addresses[0];
|
||||
callback(null, selected.address, selected.family);
|
||||
},
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export async function safeOutboundFetch(
|
||||
rawUrl: string,
|
||||
options: RequestInit,
|
||||
@@ -58,27 +120,7 @@ export async function safeOutboundFetch(
|
||||
|
||||
const dispatcher = new Agent({
|
||||
connect: {
|
||||
lookup(host, lookupOptions, callback) {
|
||||
lookup(
|
||||
host,
|
||||
{ ...lookupOptions, all: true, verbatim: true },
|
||||
(error, addresses) => {
|
||||
if (error) return callback(error, "", 0);
|
||||
if (
|
||||
!addresses.length ||
|
||||
addresses.some(({ address }) => isBlockedAddress(address))
|
||||
) {
|
||||
return callback(
|
||||
new Error("Private destinations are not allowed"),
|
||||
"",
|
||||
0,
|
||||
);
|
||||
}
|
||||
const selected = addresses[0];
|
||||
callback(null, selected.address, selected.family);
|
||||
},
|
||||
);
|
||||
},
|
||||
lookup: createDnsLookupHook(),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -31,13 +31,13 @@ try {
|
||||
nativeRequire("ssh2/lib/protocol/crypto/build/Release/sshcrypto.node");
|
||||
ssh2BindingAvailable = true;
|
||||
} catch {
|
||||
try {
|
||||
// ESM fallback: check if chacha20 works via OpenSSL createCipheriv
|
||||
crypto.createCipheriv("chacha20", Buffer.alloc(32), Buffer.alloc(16));
|
||||
ssh2BindingAvailable = true;
|
||||
} catch {
|
||||
ssh2BindingAvailable = false;
|
||||
}
|
||||
// The pure-JS fallback in ssh2 for chacha20-poly1305@openssh.com is broken and
|
||||
// corrupts the transport: the target sshd aborts the KEX with
|
||||
// "ssh_dispatch_run_fatal: ... incomplete message [preauth]" and the client times out.
|
||||
// A working OpenSSL "chacha20" cipher is NOT sufficient here — only the native
|
||||
// binding (sshcrypto.node) makes chacha20-poly1305 usable. Keep it disabled otherwise
|
||||
// so filterCiphers() drops it and the connection negotiates AES-GCM instead.
|
||||
ssh2BindingAvailable = false;
|
||||
}
|
||||
|
||||
function filterCiphers(list: CipherAlgorithm[]): CipherAlgorithm[] {
|
||||
|
||||
+89
-15
@@ -65,6 +65,12 @@ const ElectronVersionCheck = lazy(() =>
|
||||
})),
|
||||
);
|
||||
|
||||
// Anonymous guest view for shared terminal/RDP/VNC/Telnet sessions (?view=shared&token=<linkToken>).
|
||||
// Rendered outside FullscreenAppGate since guests never have a JWT/cookie to verify.
|
||||
const SharedSessionView = lazy(
|
||||
() => import("@/features/session-sharing/SharedSessionView"),
|
||||
);
|
||||
|
||||
type Phase =
|
||||
| "verifying"
|
||||
| "idle-auth"
|
||||
@@ -174,11 +180,15 @@ function App() {
|
||||
stored?.loggedIn ? "verifying" : "idle-auth",
|
||||
);
|
||||
const [authUsername, setAuthUsername] = useState(stored?.username ?? "");
|
||||
const [verifyRetryCount, setVerifyRetryCount] = useState(0);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// Track whether fading-in came from a fresh login (vs. session verification on page load).
|
||||
// When session-verified, Auth must not mount during the transition — it would trigger
|
||||
// silent OIDC redirect and cause an infinite refresh loop.
|
||||
const fadingInFromLoginRef = useRef(false);
|
||||
// Dedupes concurrent handleLogout() calls within the same tick -- see
|
||||
// handleLogout for why phase state alone isn't sufficient for this.
|
||||
const loggingOutRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const savedAccent = localStorage.getItem("termix-accent");
|
||||
@@ -204,7 +214,18 @@ function App() {
|
||||
if (isElectron()) {
|
||||
try {
|
||||
const token = await getCurrentToken();
|
||||
if (token) localStorage.setItem("jwt", token);
|
||||
if (token) {
|
||||
localStorage.setItem("jwt", token);
|
||||
// Remote Sync's engine (main process) needs this local JWT to
|
||||
// authenticate against the embedded backend during sync, same
|
||||
// as a fresh login provides via handleLogin below -- a session
|
||||
// restore (the common case on every normal launch) must hand
|
||||
// it over too, or sync silently never runs after the first
|
||||
// app restart.
|
||||
window.electronAPI
|
||||
?.invoke?.("notify-local-login", token)
|
||||
.catch(() => {});
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal: WebSocket connections will fall back to cookie auth
|
||||
}
|
||||
@@ -213,36 +234,83 @@ function App() {
|
||||
setPhase("fading-in");
|
||||
timerRef.current = setTimeout(() => setPhase("idle-app"), 450);
|
||||
})
|
||||
.catch(() => {
|
||||
clearStoredAuth();
|
||||
setPhase("idle-auth");
|
||||
.catch((err: unknown) => {
|
||||
// Only treat a genuine auth rejection (401/403) as "not logged in".
|
||||
// Anything else (network hiccup, backend still starting up, a
|
||||
// transient 5xx) is not proof the session is invalid -- clearing
|
||||
// stored auth here would drop the user back to Auth.tsx, which in
|
||||
// Electron immediately mints a brand-new auto-session, silently
|
||||
// swapping out the JWT/cookie from under any still-in-flight
|
||||
// requests and causing spurious "Session expired" toasts.
|
||||
const status =
|
||||
(err as { status?: number; response?: { status?: number } })
|
||||
?.status ??
|
||||
(err as { response?: { status?: number } })?.response?.status;
|
||||
if (status === 401 || status === 403) {
|
||||
clearStoredAuth();
|
||||
setPhase("idle-auth");
|
||||
return;
|
||||
}
|
||||
// Transient failure: retry rather than logging out. In Electron the
|
||||
// embedded local backend is bundled, always-on infrastructure that
|
||||
// always eventually comes up (a slow cold boot just takes longer),
|
||||
// and Auth.tsx never shows a login form for it anyway -- so there's
|
||||
// no reason to ever give up and manufacture a logout here. Outside
|
||||
// Electron a genuinely broken backend still needs to surface the
|
||||
// login screen eventually, so that case keeps a retry cap.
|
||||
if (!isElectron() && verifyRetryCount >= 5) {
|
||||
clearStoredAuth();
|
||||
setPhase("idle-auth");
|
||||
return;
|
||||
}
|
||||
const delay = isElectron()
|
||||
? Math.min(1000 * 2 ** verifyRetryCount, 10000)
|
||||
: 3000;
|
||||
timerRef.current = setTimeout(() => {
|
||||
setVerifyRetryCount((c) => c + 1);
|
||||
}, delay);
|
||||
});
|
||||
}, [phase]);
|
||||
}, [phase, verifyRetryCount]);
|
||||
|
||||
function handleLogin(u: string) {
|
||||
loggingOutRef.current = false;
|
||||
setAuthUsername(u);
|
||||
fadingInFromLoginRef.current = true;
|
||||
setPhase("fading-in");
|
||||
timerRef.current = setTimeout(() => setPhase("idle-app"), 450);
|
||||
if (isElectron()) {
|
||||
window.electronAPI?.startC2SAutoStartTunnels?.().catch(() => {});
|
||||
const localJwt = localStorage.getItem("jwt");
|
||||
if (localJwt) {
|
||||
window.electronAPI
|
||||
?.invoke?.("notify-local-login", localJwt)
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
// A single background hiccup can trigger several independent 401s at
|
||||
// once (e.g. a burst of unrelated polls all failing together in the
|
||||
// same tick), each calling this. React batches the resulting setPhase
|
||||
// calls, so checking `phase` here can't distinguish the first call in
|
||||
// a batch from the second -- both would see the same pre-update value
|
||||
// and both would proceed, each overwriting timerRef with a fresh
|
||||
// 450ms timer. A steady trickle of these could keep resetting the
|
||||
// countdown so the transition never actually completes, which looks
|
||||
// exactly like "nothing happens." loggingOutRef is synchronous and
|
||||
// isn't subject to batching, so it correctly dedupes within one tick.
|
||||
if (loggingOutRef.current) return;
|
||||
loggingOutRef.current = true;
|
||||
clearStoredAuth();
|
||||
setPhase("fading-out");
|
||||
timerRef.current = setTimeout(() => {
|
||||
setAuthUsername("");
|
||||
setPhase("idle-auth");
|
||||
loggingOutRef.current = false;
|
||||
}, 450);
|
||||
}
|
||||
|
||||
function handleChangeServer() {
|
||||
localStorage.setItem("termix_show_server_config", "true");
|
||||
handleLogout();
|
||||
}
|
||||
|
||||
const showApp =
|
||||
phase === "idle-app" || phase === "fading-in" || phase === "fading-out";
|
||||
const showAuth =
|
||||
@@ -288,11 +356,7 @@ function App() {
|
||||
}}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<AppShell
|
||||
username={authUsername}
|
||||
onLogout={handleLogout}
|
||||
onChangeServer={handleChangeServer}
|
||||
/>
|
||||
<AppShell username={authUsername} onLogout={handleLogout} />
|
||||
</Suspense>
|
||||
</div>
|
||||
)}
|
||||
@@ -322,6 +386,16 @@ function RootApp() {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const isFullscreen = searchParams.has("view");
|
||||
|
||||
// Anonymous guests have no cookie/JWT at all, so this bypasses FullscreenAppGate's
|
||||
// auth check entirely rather than waiting on a getUserInfo() call that would always fail.
|
||||
if (searchParams.get("view") === "shared") {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<SharedSessionView />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
if (isFullscreen) {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
|
||||
Vendored
+9
-1
@@ -64,6 +64,15 @@ export interface ElectronAPI {
|
||||
started: number;
|
||||
errors: string[];
|
||||
}>;
|
||||
onRemoteSyncStatusChanged?: (
|
||||
callback: (status: {
|
||||
connected: boolean;
|
||||
syncing: boolean;
|
||||
lastSyncedAt: string | null;
|
||||
lastError: string | null;
|
||||
needsReauth: boolean;
|
||||
}) => void,
|
||||
) => () => void;
|
||||
clearSessionCookies: () => Promise<void>;
|
||||
getSessionCookie: (
|
||||
name: string,
|
||||
@@ -157,7 +166,6 @@ declare global {
|
||||
interface Window {
|
||||
electronAPI: ElectronAPI;
|
||||
IS_ELECTRON: boolean;
|
||||
configuredServerUrl?: string | null;
|
||||
electronClipboard?: {
|
||||
writeText(text: string): Promise<boolean>;
|
||||
readText(): Promise<string>;
|
||||
|
||||
Vendored
+33
@@ -97,6 +97,39 @@ declare module "guacamole-common-js" {
|
||||
up: boolean;
|
||||
down: boolean;
|
||||
}
|
||||
|
||||
interface MouseEvent {
|
||||
state: Mouse.State;
|
||||
preventDefault(): void;
|
||||
stopPropagation(): void;
|
||||
}
|
||||
|
||||
class Touchpad {
|
||||
constructor(element: HTMLElement);
|
||||
currentState: Mouse.State;
|
||||
clickTimingThreshold: number;
|
||||
clickMoveThreshold: number;
|
||||
scrollThreshold: number;
|
||||
onEach(
|
||||
types: string[],
|
||||
listener: (event: Mouse.MouseEvent) => void,
|
||||
): void;
|
||||
on(type: string, listener: (event: Mouse.MouseEvent) => void): void;
|
||||
}
|
||||
|
||||
class Touchscreen {
|
||||
constructor(element: HTMLElement);
|
||||
currentState: Mouse.State;
|
||||
clickTimingThreshold: number;
|
||||
clickMoveThreshold: number;
|
||||
scrollThreshold: number;
|
||||
longPressThreshold: number;
|
||||
onEach(
|
||||
types: string[],
|
||||
listener: (event: Mouse.MouseEvent) => void,
|
||||
): void;
|
||||
on(type: string, listener: (event: Mouse.MouseEvent) => void): void;
|
||||
}
|
||||
}
|
||||
|
||||
class Keyboard {
|
||||
|
||||
+5
-2
@@ -151,6 +151,7 @@ export interface Host {
|
||||
enableDocker: boolean;
|
||||
enableProxmox: boolean;
|
||||
enableTmuxMonitor: boolean;
|
||||
allowSessionSharing?: boolean;
|
||||
proxmoxConfig?: ProxmoxConfig | null;
|
||||
showTerminalInSidebar: boolean;
|
||||
showFileManagerInSidebar: boolean;
|
||||
@@ -207,7 +208,7 @@ export interface Host {
|
||||
telnetUser?: string;
|
||||
telnetPassword?: string;
|
||||
telnetCredentialId?: number | null;
|
||||
rdpAuthType?: "direct" | "credential" | null;
|
||||
rdpAuthType?: "direct" | "credential" | "none" | null;
|
||||
vncAuthType?: "direct" | "credential" | null;
|
||||
telnetAuthType?: "direct" | "credential" | null;
|
||||
createdAt: string;
|
||||
@@ -272,6 +273,7 @@ export interface HostData {
|
||||
enableDocker?: boolean;
|
||||
enableProxmox?: boolean;
|
||||
enableTmuxMonitor?: boolean;
|
||||
allowSessionSharing?: boolean;
|
||||
proxmoxConfig?: ProxmoxConfig | Record<string, unknown> | null;
|
||||
showTerminalInSidebar?: boolean;
|
||||
showFileManagerInSidebar?: boolean;
|
||||
@@ -329,7 +331,7 @@ export interface HostData {
|
||||
telnetUser?: string;
|
||||
telnetPassword?: string;
|
||||
telnetCredentialId?: number | null;
|
||||
rdpAuthType?: "direct" | "credential" | null;
|
||||
rdpAuthType?: "direct" | "credential" | "none" | null;
|
||||
vncAuthType?: "direct" | "credential" | null;
|
||||
telnetAuthType?: "direct" | "credential" | null;
|
||||
}
|
||||
@@ -343,6 +345,7 @@ export interface SSHFolder {
|
||||
name: string;
|
||||
color?: string;
|
||||
icon?: string;
|
||||
credentialId?: number | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@ export type Host = {
|
||||
useSocks5?: boolean;
|
||||
socks5Host?: string;
|
||||
socks5Port?: number;
|
||||
connectionOrigin?: "local" | "remote" | null;
|
||||
socks5Username?: string;
|
||||
socks5Password?: string;
|
||||
socks5ProxyChain?: {
|
||||
@@ -152,19 +153,26 @@ export type Host = {
|
||||
vncPort: number;
|
||||
telnetPort: number;
|
||||
|
||||
rdpAuthType?: "direct" | "credential" | "none";
|
||||
rdpCredentialId?: string;
|
||||
rdpUser?: string;
|
||||
rdpPassword?: string;
|
||||
hasRdpPassword?: boolean;
|
||||
domain?: string;
|
||||
security?: string;
|
||||
ignoreCert?: boolean;
|
||||
|
||||
vncAuthType?: "direct" | "credential";
|
||||
vncCredentialId?: string;
|
||||
vncPassword?: string;
|
||||
hasVncPassword?: boolean;
|
||||
vncUser?: string;
|
||||
|
||||
telnetAuthType?: "direct" | "credential";
|
||||
telnetCredentialId?: string;
|
||||
telnetUser?: string;
|
||||
telnetPassword?: string;
|
||||
hasTelnetPassword?: boolean;
|
||||
|
||||
guacamoleConfig?: Record<string, unknown>;
|
||||
forceKeyboardInteractive?: boolean;
|
||||
@@ -217,6 +225,7 @@ export type HostFolder = {
|
||||
path?: string;
|
||||
color?: string;
|
||||
icon?: string;
|
||||
credentialId?: number | null;
|
||||
};
|
||||
|
||||
export type TabType =
|
||||
@@ -276,6 +285,9 @@ export type Tab = {
|
||||
host?: Host;
|
||||
openedAt: number;
|
||||
restoredSessionId?: string | null;
|
||||
/** Set when this tab joins someone else's live shared session instead of connecting/attaching its own. */
|
||||
joinSharedSessionId?: string | null;
|
||||
joinShareId?: string | null;
|
||||
initialFilePath?: string;
|
||||
serialConfig?: SerialConfig;
|
||||
terminalRef?: import("react").RefObject<{
|
||||
@@ -286,6 +298,8 @@ export type Tab = {
|
||||
fit?: () => void;
|
||||
notifyResize?: () => void;
|
||||
getApplicationCursorKeysMode?: () => boolean;
|
||||
openShareModal?: () => void;
|
||||
canShare?: () => boolean;
|
||||
} | null>;
|
||||
};
|
||||
|
||||
|
||||
+329
-179
@@ -126,10 +126,13 @@ import {
|
||||
getActiveSessions,
|
||||
getUserPreferences,
|
||||
dismissDonationModal,
|
||||
isElectron,
|
||||
type UserPreferences,
|
||||
type OpenTabRecord,
|
||||
} from "@/main-axios";
|
||||
import { DonationReminderModal } from "@/user/DonationReminderModal.tsx";
|
||||
import { RemoteSyncBanner } from "@/components/RemoteSyncBanner.tsx";
|
||||
import { MigrationNoticeDialog } from "@/components/MigrationNoticeDialog.tsx";
|
||||
import { dbHealthMonitor } from "@/lib/db-health-monitor";
|
||||
import type { SSHHostWithStatus } from "@/main-axios";
|
||||
import { ServerStatusProvider } from "@/lib/ServerStatusContext";
|
||||
@@ -141,7 +144,10 @@ import { quickConnectHostToPayload } from "@/sidebar/quick-connect-host";
|
||||
|
||||
function buildHostTree(
|
||||
hosts: SSHHostWithStatus[],
|
||||
folderMeta?: Map<string, { color?: string; icon?: string }>,
|
||||
folderMeta?: Map<
|
||||
string,
|
||||
{ color?: string; icon?: string; credentialId?: number | null }
|
||||
>,
|
||||
): HostFolder {
|
||||
const root: HostFolder = { name: "root", children: [] };
|
||||
const folderMap = new Map<string, HostFolder>();
|
||||
@@ -159,6 +165,7 @@ function buildHostTree(
|
||||
path: accumulated,
|
||||
color: meta?.color,
|
||||
icon: meta?.icon,
|
||||
credentialId: meta?.credentialId ?? null,
|
||||
children: [],
|
||||
};
|
||||
folderMap.set(accumulated, folder);
|
||||
@@ -189,11 +196,9 @@ export { tabIcon, renderTabContent } from "@/shell/tabUtils";
|
||||
export function AppShell({
|
||||
username,
|
||||
onLogout,
|
||||
onChangeServer,
|
||||
}: {
|
||||
username: string;
|
||||
onLogout: () => void;
|
||||
onChangeServer?: () => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { setTheme } = useTheme();
|
||||
@@ -218,11 +223,14 @@ export function AppShell({
|
||||
const [splitMode, setSplitMode] = useState<SplitMode>(
|
||||
() => (localStorage.getItem("termix_splitMode") as SplitMode) ?? "none",
|
||||
);
|
||||
const [paneTabIds, setPaneTabIds] = useState<(string | null)[]>(
|
||||
() =>
|
||||
JSON.parse(localStorage.getItem("termix_paneTabIds") ?? "null") ??
|
||||
Array(6).fill(null),
|
||||
// paneTabIds holds live tab.id values, which change on every restore, so we
|
||||
// can't restore it from storage directly. It starts empty and gets filled in
|
||||
// once by the reconciliation effect below, keyed off the stable instanceId
|
||||
// values saved in termix_paneInstanceIds.
|
||||
const [paneTabIds, setPaneTabIds] = useState<(string | null)[]>(() =>
|
||||
Array(6).fill(null),
|
||||
);
|
||||
const paneLayoutRestoredRef = useRef(false);
|
||||
useEffect(() => {
|
||||
paneTabIdsRef.current = paneTabIds;
|
||||
}, [paneTabIds]);
|
||||
@@ -231,6 +239,13 @@ export function AppShell({
|
||||
const [hostsLoading, setHostsLoading] = useState(true);
|
||||
const [allHosts, setAllHosts] = useState<Host[]>([]);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
// Remote sync is not yet configurable (added in a later phase), so this
|
||||
// is always false for now -- admin/user-management UI stays hidden until
|
||||
// the desktop app is connected to a remote Termix server, since a
|
||||
// standalone local install has exactly one implicit user and nothing to
|
||||
// administer.
|
||||
const [isRemoteSyncConnected] = useState(false);
|
||||
const showMultiUserUI = isAdmin && (!isElectron() || isRemoteSyncConnected);
|
||||
const [userId, setUserId] = useState<string | null>(null);
|
||||
const [showDonationModal, setShowDonationModal] = useState(false);
|
||||
const [backgroundTabRecords, setBackgroundTabRecords] = useState<
|
||||
@@ -239,6 +254,9 @@ export function AppShell({
|
||||
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
const [railView, setRailView] = useState<RailView>("hosts");
|
||||
const [remoteSyncInitialServerUrl, setRemoteSyncInitialServerUrl] = useState<
|
||||
string | undefined
|
||||
>(undefined);
|
||||
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
||||
const saved = localStorage.getItem("termix_sidebarWidth");
|
||||
return saved ? parseInt(saved, 10) : 291;
|
||||
@@ -258,8 +276,15 @@ export function AppShell({
|
||||
}, [splitMode]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("termix_paneTabIds", JSON.stringify(paneTabIds));
|
||||
}, [paneTabIds]);
|
||||
// Don't overwrite the saved layout with the empty initial state before
|
||||
// reconciliation has had a chance to restore it.
|
||||
if (!paneLayoutRestoredRef.current) return;
|
||||
const instanceIds = paneTabIds.map((id) => {
|
||||
if (id == null) return null;
|
||||
return tabs.find((t) => t.id === id)?.instanceId ?? null;
|
||||
});
|
||||
localStorage.setItem("termix_paneInstanceIds", JSON.stringify(instanceIds));
|
||||
}, [paneTabIds, tabs]);
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
@@ -798,11 +823,15 @@ export function AppShell({
|
||||
]);
|
||||
const converted = raw.map(sshHostToHost);
|
||||
setAllHosts(converted);
|
||||
const folderMeta = new Map<string, { color?: string; icon?: string }>();
|
||||
const folderMeta = new Map<
|
||||
string,
|
||||
{ color?: string; icon?: string; credentialId?: number | null }
|
||||
>();
|
||||
for (const f of folders) {
|
||||
folderMeta.set(f.name, {
|
||||
color: f.color ?? undefined,
|
||||
icon: f.icon ?? undefined,
|
||||
credentialId: f.credentialId ?? null,
|
||||
});
|
||||
}
|
||||
setRealHostTree(buildHostTree(raw, folderMeta));
|
||||
@@ -968,6 +997,35 @@ export function AppShell({
|
||||
loadSavedTabs();
|
||||
}, [hostsLoaded, userPrefsLoaded]);
|
||||
|
||||
// Restore split-screen pane assignments once tabs are settled. Saved assignments are
|
||||
// keyed by instanceId (stable across reloads) and remapped to the live tab.id here,
|
||||
// since tab.id is regenerated every time a tab is (re)opened.
|
||||
useEffect(() => {
|
||||
if (!tabsReady || paneLayoutRestoredRef.current) return;
|
||||
paneLayoutRestoredRef.current = true;
|
||||
|
||||
try {
|
||||
const savedInstanceIds: (string | null)[] = JSON.parse(
|
||||
localStorage.getItem("termix_paneInstanceIds") ?? "null",
|
||||
);
|
||||
if (!Array.isArray(savedInstanceIds)) return;
|
||||
|
||||
const restored = savedInstanceIds.map((instanceId) => {
|
||||
if (instanceId == null) return null;
|
||||
return tabs.find((t) => t.instanceId === instanceId)?.id ?? null;
|
||||
});
|
||||
if (restored.some((id) => id != null)) {
|
||||
setPaneTabIds(restored);
|
||||
} else {
|
||||
// None of the saved panes could be restored (e.g. reopen-tabs-on-login
|
||||
// is disabled), so drop back to a single view instead of an empty split.
|
||||
setSplitMode("none");
|
||||
}
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
}, [tabsReady, tabs]);
|
||||
|
||||
// Debounced tab-order sync: when tab order changes, patch each persistent tab's tabOrder in DB.
|
||||
const orderSyncTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null,
|
||||
@@ -1006,6 +1064,8 @@ export function AppShell({
|
||||
savedLabel?: string;
|
||||
initialFilePath?: string;
|
||||
serialConfig?: SerialConfig;
|
||||
joinSharedSessionId?: string | null;
|
||||
joinShareId?: string | null;
|
||||
},
|
||||
) {
|
||||
const tabId = `${host.name}-${type}-${Date.now()}`;
|
||||
@@ -1022,6 +1082,8 @@ export function AppShell({
|
||||
const savedLabel = restore?.savedLabel;
|
||||
const initialFilePath = restore?.initialFilePath;
|
||||
const serialConfig = restore?.serialConfig;
|
||||
const joinSharedSessionId = restore?.joinSharedSessionId ?? null;
|
||||
const joinShareId = restore?.joinShareId ?? null;
|
||||
// A saved label that doesn't match the bare host name or the auto-numbered pattern is a custom label
|
||||
const isCustomLabel =
|
||||
savedLabel != null &&
|
||||
@@ -1043,6 +1105,8 @@ export function AppShell({
|
||||
openedAt,
|
||||
terminalRef: ref,
|
||||
restoredSessionId: restore?.restoredSessionId ?? null,
|
||||
joinSharedSessionId,
|
||||
joinShareId,
|
||||
initialFilePath,
|
||||
serialConfig,
|
||||
},
|
||||
@@ -1075,6 +1139,8 @@ export function AppShell({
|
||||
openedAt,
|
||||
terminalRef: ref,
|
||||
restoredSessionId: restore?.restoredSessionId ?? null,
|
||||
joinSharedSessionId,
|
||||
joinShareId,
|
||||
initialFilePath,
|
||||
serialConfig,
|
||||
},
|
||||
@@ -1330,6 +1396,17 @@ export function AppShell({
|
||||
}
|
||||
}
|
||||
|
||||
function openShareForTab(id: string) {
|
||||
const tab = tabs.find((t) => t.id === id);
|
||||
if (!tab) return;
|
||||
const ref = tab.terminalRef?.current;
|
||||
if (ref?.canShare?.()) {
|
||||
ref.openShareModal?.();
|
||||
} else {
|
||||
toast.error(t("sessionSharing.notReadyToShare"));
|
||||
}
|
||||
}
|
||||
|
||||
function closeTab(id: string) {
|
||||
const tab = tabs.find((t) => t.id === id);
|
||||
const confirmEnabled = localStorage.getItem("confirmTabClose") === "true";
|
||||
@@ -1675,6 +1752,56 @@ export function AppShell({
|
||||
}}
|
||||
onRenameTab={renameTab}
|
||||
onReorderTabs={setTabs}
|
||||
onJoinSharedSession={(session) => {
|
||||
if (!session.shareId) return;
|
||||
const existingHost = allHosts.find(
|
||||
(h) => h.id === String(session.hostId),
|
||||
);
|
||||
const host: Host = existingHost ?? {
|
||||
id: String(session.hostId),
|
||||
name: session.hostName,
|
||||
username: "",
|
||||
ip: "",
|
||||
port: 0,
|
||||
folder: "",
|
||||
online: false,
|
||||
cpu: null,
|
||||
ram: null,
|
||||
lastAccess: new Date().toISOString(),
|
||||
authType: "none",
|
||||
enableTerminal: false,
|
||||
enableCommandHistory: false,
|
||||
enableTunnel: false,
|
||||
enableFileManager: false,
|
||||
enableDocker: false,
|
||||
enableProxmox: false,
|
||||
enableTmuxMonitor: false,
|
||||
enableSsh: false,
|
||||
enableRdp: false,
|
||||
enableVnc: false,
|
||||
enableTelnet: false,
|
||||
sshPort: 22,
|
||||
rdpPort: 3389,
|
||||
vncPort: 5900,
|
||||
telnetPort: 23,
|
||||
serverTunnels: [],
|
||||
quickActions: [],
|
||||
};
|
||||
const instanceId =
|
||||
typeof crypto.randomUUID === "function"
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
||||
openTab(host, "terminal", {
|
||||
instanceId,
|
||||
restoredSessionId: null,
|
||||
joinSharedSessionId: session.sessionId,
|
||||
joinShareId: session.shareId,
|
||||
savedLabel: t("connections.sharedSessionLabel", {
|
||||
hostName: session.hostName,
|
||||
}),
|
||||
});
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -1690,16 +1817,16 @@ export function AppShell({
|
||||
<UserProfilePanel
|
||||
username={username}
|
||||
onLogout={onLogout}
|
||||
onChangeServer={onChangeServer}
|
||||
userPrefs={userPrefs}
|
||||
onPrefsChange={(updates) =>
|
||||
setUserPrefs((current) => ({ ...current, ...updates }))
|
||||
}
|
||||
remoteSyncInitialServerUrl={remoteSyncInitialServerUrl}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{railView === "admin-settings" && isAdmin && (
|
||||
{railView === "admin-settings" && showMultiUserUI && (
|
||||
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
|
||||
<AdminSettingsPanel
|
||||
onEditingChange={setSidebarEditing}
|
||||
@@ -1754,180 +1881,203 @@ export function AppShell({
|
||||
|
||||
return (
|
||||
<ServerStatusProvider isAuthenticated={!!username}>
|
||||
<div className="flex w-screen bg-background" style={{ height: "100dvh" }}>
|
||||
{/* Skinny icon rail — desktop only, hidden on mobile */}
|
||||
<AppRail
|
||||
railView={railView}
|
||||
sidebarOpen={sidebarOpen}
|
||||
splitMode={splitMode}
|
||||
username={username}
|
||||
isAdmin={isAdmin}
|
||||
onRailClick={handleRailClick}
|
||||
onOpenTab={openSingletonTab}
|
||||
onLogout={onLogout}
|
||||
/>
|
||||
|
||||
{/* Desktop: inline resizable sidebar */}
|
||||
{!isMobile && (
|
||||
<div
|
||||
className={`relative flex flex-col min-h-0 bg-sidebar shrink-0 overflow-hidden ${sidebarOpen ? `border-r transition-colors ${sidebarDragging ? "border-accent-brand/60" : "border-border"}` : ""}`}
|
||||
style={{
|
||||
width: sidebarOpen ? (sidebarEditing ? 560 : sidebarWidth) : 0,
|
||||
transition: sidebarDragging ? "none" : "width 0.2s",
|
||||
}}
|
||||
>
|
||||
{sidebarHeader}
|
||||
{sidebarPanelContent}
|
||||
|
||||
{sidebarOpen && !sidebarEditing && (
|
||||
<div
|
||||
onMouseDown={onSidebarMouseDown}
|
||||
className={`absolute right-0 top-0 bottom-0 w-1 cursor-col-resize z-30 transition-colors ${sidebarDragging ? "bg-accent-brand/60" : "hover:bg-accent-brand/40"}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile: sidebar as overlay sheet */}
|
||||
{isMobile && (
|
||||
<Sheet open={sidebarOpen} onOpenChange={setSidebarOpen}>
|
||||
<SheetContent
|
||||
side="left"
|
||||
showCloseButton={false}
|
||||
className="p-0 flex flex-col min-h-0 w-[min(85vw,360px)] max-w-full bg-sidebar border-r border-border gap-0"
|
||||
style={{ height: "100dvh" }}
|
||||
>
|
||||
{sidebarHeader}
|
||||
{sidebarPanelContent}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)}
|
||||
|
||||
{/* Main content area */}
|
||||
<div
|
||||
className={`relative flex flex-col flex-1 min-w-0 overflow-hidden transition-all duration-200 ${!isMobile && !sidebarOpen ? "pl-6" : ""}`}
|
||||
>
|
||||
{!isMobile && !sidebarOpen && (
|
||||
<button
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
title="Open Sidebar"
|
||||
className="absolute left-0 top-0 bottom-0 z-20 flex items-center justify-center w-6 bg-sidebar border-r border-border text-muted-foreground hover:text-accent-brand hover:bg-accent-brand/5 transition-colors"
|
||||
>
|
||||
<ChevronRight className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<div className="flex flex-col flex-1 min-w-0 min-h-0 overflow-hidden">
|
||||
<TabBar
|
||||
tabs={tabs}
|
||||
activeTabId={activeTabId}
|
||||
splitMode={splitMode}
|
||||
paneTabIds={paneTabIds}
|
||||
focusedPaneIndex={focusedPaneIndex}
|
||||
onSetActiveTab={setActiveTabId}
|
||||
onCloseTab={closeTab}
|
||||
onRefreshTab={refreshTab}
|
||||
onReorderTabs={setTabs}
|
||||
onSplitTab={splitTabQuick}
|
||||
onAddToSplit={addTabToSplit}
|
||||
onRemoveFromSplit={removeTabFromSplit}
|
||||
onRenameTab={renameTab}
|
||||
onOpenFileManager={(tabId) => {
|
||||
const targetTab = tabs.find((t) => t.id === tabId);
|
||||
if (targetTab?.host) openTab(targetTab.host, "files");
|
||||
<div
|
||||
className="flex flex-col w-screen bg-background"
|
||||
style={{ height: "100dvh" }}
|
||||
>
|
||||
{isElectron() && (
|
||||
<>
|
||||
<RemoteSyncBanner
|
||||
onReconnect={() => {
|
||||
setRailView("user-profile");
|
||||
if (!sidebarOpen) setSidebarOpen(true);
|
||||
}}
|
||||
isAppFullscreen={isAppFullscreen}
|
||||
onToggleAppFullscreen={toggleAppFullscreen}
|
||||
/>
|
||||
<div className="relative flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||
{/* Split view — always mounted when not mobile, hidden via CSS when inactive */}
|
||||
{!isMobile && (
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
display: isSplit ? "flex" : "none",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<SplitView
|
||||
tabs={tabs}
|
||||
paneTabIds={paneTabIds}
|
||||
splitMode={splitMode}
|
||||
focusedPaneIndex={focusedPaneIndex}
|
||||
onTerminalResize={resizeAllTerminals}
|
||||
onPaneContentRef={onPaneContentRef}
|
||||
onPaneClick={setFocusedPaneIndex}
|
||||
onAssignPane={assignPane}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Normal-view container. Tab nodes are appended here (or to pane elements)
|
||||
by the DOM-placement effect above. React portals each tab's content
|
||||
into its stable per-tab node so the component is never remounted.
|
||||
When split is active, shown on top only if the active tab is not in a pane. */}
|
||||
<div
|
||||
ref={normalViewRef}
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
display:
|
||||
isSplit && !isMobile && paneTabIds.includes(activeTabId)
|
||||
? "none"
|
||||
: undefined,
|
||||
zIndex:
|
||||
isSplit && !paneTabIds.includes(activeTabId)
|
||||
? 10
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const tabNode = getTabNode(tab.id, tab.type === "terminal");
|
||||
const paneIdx = isSplit ? paneTabIds.indexOf(tab.id) : -1;
|
||||
const inPane = paneIdx !== -1;
|
||||
const activeInline = !inPane && tab.id === activeTabId;
|
||||
return createPortal(
|
||||
renderTabContent(
|
||||
tab,
|
||||
openSingletonTab,
|
||||
openTab,
|
||||
closeTab,
|
||||
inPane || activeInline,
|
||||
(host, filePath) =>
|
||||
openTab(host, "files", {
|
||||
instanceId:
|
||||
typeof crypto.randomUUID === "function"
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`,
|
||||
restoredSessionId: null,
|
||||
initialFilePath: filePath,
|
||||
}),
|
||||
(host, _path) => openTab(host, "files"),
|
||||
(host, path) =>
|
||||
openTab(host, "terminal", {
|
||||
instanceId:
|
||||
typeof crypto.randomUUID === "function"
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`,
|
||||
restoredSessionId: null,
|
||||
initialFilePath: path,
|
||||
}),
|
||||
renameTab,
|
||||
saveQuickConnectHost,
|
||||
),
|
||||
tabNode,
|
||||
tab.id,
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom nav bar — mobile only */}
|
||||
<MobileBottomBar
|
||||
<MigrationNoticeDialog
|
||||
onOpenRemoteSync={(url) => {
|
||||
setRemoteSyncInitialServerUrl(url);
|
||||
setRailView("user-profile");
|
||||
if (!sidebarOpen) setSidebarOpen(true);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{/* Skinny icon rail — desktop only, hidden on mobile */}
|
||||
<AppRail
|
||||
railView={railView}
|
||||
sidebarOpen={sidebarOpen}
|
||||
splitMode={splitMode}
|
||||
username={username}
|
||||
isAdmin={showMultiUserUI}
|
||||
onRailClick={handleRailClick}
|
||||
onOpenTab={openSingletonTab}
|
||||
onLogout={onLogout}
|
||||
/>
|
||||
|
||||
{/* Desktop: inline resizable sidebar */}
|
||||
{!isMobile && (
|
||||
<div
|
||||
className={`relative flex flex-col min-h-0 bg-sidebar shrink-0 overflow-hidden ${sidebarOpen ? `border-r transition-colors ${sidebarDragging ? "border-accent-brand/60" : "border-border"}` : ""}`}
|
||||
style={{
|
||||
width: sidebarOpen ? (sidebarEditing ? 560 : sidebarWidth) : 0,
|
||||
transition: sidebarDragging ? "none" : "width 0.2s",
|
||||
}}
|
||||
>
|
||||
{sidebarHeader}
|
||||
{sidebarPanelContent}
|
||||
|
||||
{sidebarOpen && !sidebarEditing && (
|
||||
<div
|
||||
onMouseDown={onSidebarMouseDown}
|
||||
className={`absolute right-0 top-0 bottom-0 w-1 cursor-col-resize z-30 transition-colors ${sidebarDragging ? "bg-accent-brand/60" : "hover:bg-accent-brand/40"}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile: sidebar as overlay sheet */}
|
||||
{isMobile && (
|
||||
<Sheet open={sidebarOpen} onOpenChange={setSidebarOpen}>
|
||||
<SheetContent
|
||||
side="left"
|
||||
showCloseButton={false}
|
||||
className="p-0 flex flex-col min-h-0 w-[min(85vw,360px)] max-w-full bg-sidebar border-r border-border gap-0"
|
||||
style={{ height: "100dvh" }}
|
||||
>
|
||||
{sidebarHeader}
|
||||
{sidebarPanelContent}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)}
|
||||
|
||||
{/* Main content area */}
|
||||
<div
|
||||
className={`relative flex flex-col flex-1 min-w-0 overflow-hidden transition-all duration-200 ${!isMobile && !sidebarOpen ? "pl-6" : ""}`}
|
||||
>
|
||||
{!isMobile && !sidebarOpen && (
|
||||
<button
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
title="Open Sidebar"
|
||||
className="absolute left-0 top-0 bottom-0 z-20 flex items-center justify-center w-6 bg-sidebar border-r border-border text-muted-foreground hover:text-accent-brand hover:bg-accent-brand/5 transition-colors"
|
||||
>
|
||||
<ChevronRight className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<div className="flex flex-col flex-1 min-w-0 min-h-0 overflow-hidden">
|
||||
<TabBar
|
||||
tabs={tabs}
|
||||
activeTabId={activeTabId}
|
||||
splitMode={splitMode}
|
||||
paneTabIds={paneTabIds}
|
||||
focusedPaneIndex={focusedPaneIndex}
|
||||
onSetActiveTab={setActiveTabId}
|
||||
onCloseTab={closeTab}
|
||||
onRefreshTab={refreshTab}
|
||||
onReorderTabs={setTabs}
|
||||
onSplitTab={splitTabQuick}
|
||||
onAddToSplit={addTabToSplit}
|
||||
onRemoveFromSplit={removeTabFromSplit}
|
||||
onRenameTab={renameTab}
|
||||
onOpenFileManager={(tabId) => {
|
||||
const targetTab = tabs.find((t) => t.id === tabId);
|
||||
if (targetTab?.host) openTab(targetTab.host, "files");
|
||||
}}
|
||||
onOpenShare={openShareForTab}
|
||||
isAppFullscreen={isAppFullscreen}
|
||||
onToggleAppFullscreen={toggleAppFullscreen}
|
||||
/>
|
||||
<div className="relative flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||
{/* Split view — always mounted when not mobile, hidden via CSS when inactive */}
|
||||
{!isMobile && (
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
display: isSplit ? "flex" : "none",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<SplitView
|
||||
tabs={tabs}
|
||||
paneTabIds={paneTabIds}
|
||||
splitMode={splitMode}
|
||||
focusedPaneIndex={focusedPaneIndex}
|
||||
onTerminalResize={resizeAllTerminals}
|
||||
onPaneContentRef={onPaneContentRef}
|
||||
onPaneClick={setFocusedPaneIndex}
|
||||
onAssignPane={assignPane}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Normal-view container. Tab nodes are appended here (or to pane elements)
|
||||
by the DOM-placement effect above. React portals each tab's content
|
||||
into its stable per-tab node so the component is never remounted.
|
||||
When split is active, shown on top only if the active tab is not in a pane. */}
|
||||
<div
|
||||
ref={normalViewRef}
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
display:
|
||||
isSplit && !isMobile && paneTabIds.includes(activeTabId)
|
||||
? "none"
|
||||
: undefined,
|
||||
zIndex:
|
||||
isSplit && !paneTabIds.includes(activeTabId)
|
||||
? 10
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const tabNode = getTabNode(tab.id, tab.type === "terminal");
|
||||
const paneIdx = isSplit ? paneTabIds.indexOf(tab.id) : -1;
|
||||
const inPane = paneIdx !== -1;
|
||||
const activeInline = !inPane && tab.id === activeTabId;
|
||||
return createPortal(
|
||||
renderTabContent(
|
||||
tab,
|
||||
openSingletonTab,
|
||||
openTab,
|
||||
closeTab,
|
||||
inPane || activeInline,
|
||||
(host, filePath) =>
|
||||
openTab(host, "files", {
|
||||
instanceId:
|
||||
typeof crypto.randomUUID === "function"
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`,
|
||||
restoredSessionId: null,
|
||||
initialFilePath: filePath,
|
||||
}),
|
||||
(host, _path) => openTab(host, "files"),
|
||||
(host, path) =>
|
||||
openTab(host, "terminal", {
|
||||
instanceId:
|
||||
typeof crypto.randomUUID === "function"
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`,
|
||||
restoredSessionId: null,
|
||||
initialFilePath: path,
|
||||
}),
|
||||
renameTab,
|
||||
saveQuickConnectHost,
|
||||
),
|
||||
tabNode,
|
||||
tab.id,
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom nav bar — mobile only */}
|
||||
<MobileBottomBar
|
||||
railView={railView}
|
||||
sidebarOpen={sidebarOpen}
|
||||
splitMode={splitMode}
|
||||
onRailClick={handleRailClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { authApi, handleApiError } from "@/main-axios";
|
||||
|
||||
export type AcmeChallengeType = "http-webroot" | "dns-cloudflare";
|
||||
export type AcmeChallengeType = "http-webroot" | "dns-cloudflare" | "manual";
|
||||
|
||||
export type AcmeSettings = {
|
||||
enabled: boolean;
|
||||
@@ -45,3 +45,15 @@ export async function requestAcmeCertificate(): Promise<
|
||||
handleApiError(error, "request ACME certificate");
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadManualSslCertificate(payload: {
|
||||
certificate: string;
|
||||
privateKey: string;
|
||||
}): Promise<AcmeSettings & { success: boolean }> {
|
||||
try {
|
||||
const response = await authApi.post("/users/manual-ssl-upload", payload);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "upload manual SSL certificate");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,7 +99,14 @@ export async function adminDeleteUserHost(
|
||||
export async function adminGetHostPassword(
|
||||
targetUserId: string,
|
||||
hostId: number,
|
||||
field: "password" | "sudoPassword" | "vncPassword" = "password",
|
||||
field:
|
||||
| "password"
|
||||
| "sudoPassword"
|
||||
| "rdpPassword"
|
||||
| "vncPassword"
|
||||
| "telnetPassword"
|
||||
| "key"
|
||||
| "keyPassword" = "password",
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const response = await sshHostApi.get(
|
||||
|
||||
@@ -96,7 +96,14 @@ export async function getSSHHostWithCredentials(
|
||||
|
||||
export async function getHostPassword(
|
||||
hostId: number,
|
||||
field: "password" | "sudoPassword" | "vncPassword" = "password",
|
||||
field:
|
||||
| "password"
|
||||
| "sudoPassword"
|
||||
| "rdpPassword"
|
||||
| "vncPassword"
|
||||
| "telnetPassword"
|
||||
| "key"
|
||||
| "keyPassword" = "password",
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const response = await sshHostApi.get(
|
||||
@@ -200,6 +207,7 @@ export async function updateFolderMetadata(
|
||||
name: string,
|
||||
color?: string,
|
||||
icon?: string,
|
||||
credentialId?: number | null,
|
||||
): Promise<void> {
|
||||
try {
|
||||
sshLogger.info("Updating folder metadata", {
|
||||
@@ -207,12 +215,14 @@ export async function updateFolderMetadata(
|
||||
name,
|
||||
color,
|
||||
icon,
|
||||
credentialId,
|
||||
});
|
||||
|
||||
await authApi.put("/host/folders/metadata", {
|
||||
name,
|
||||
color,
|
||||
icon,
|
||||
credentialId,
|
||||
});
|
||||
|
||||
sshLogger.success("Folder metadata updated successfully", {
|
||||
|
||||
@@ -72,6 +72,7 @@ export interface GuacamoleTokenRequest {
|
||||
|
||||
export interface GuacamoleTokenResponse {
|
||||
token: string;
|
||||
guacamoleConnectionId?: string | null;
|
||||
}
|
||||
|
||||
type GuacamoleConfigSource = {
|
||||
@@ -208,12 +209,18 @@ export async function getGuacamoleToken(
|
||||
export async function getGuacamoleTokenFromHost(
|
||||
hostId: number,
|
||||
protocol?: "rdp" | "vnc" | "telnet",
|
||||
promptedCredentials?: { username?: string; password?: string },
|
||||
): Promise<GuacamoleTokenResponse> {
|
||||
try {
|
||||
const response = await authApi.post(
|
||||
`/guacamole/connect-host/${hostId}`,
|
||||
protocol ? { protocol } : {},
|
||||
);
|
||||
const response = await authApi.post(`/guacamole/connect-host/${hostId}`, {
|
||||
...(protocol ? { protocol } : {}),
|
||||
...(promptedCredentials?.username
|
||||
? { promptedUsername: promptedCredentials.username }
|
||||
: {}),
|
||||
...(promptedCredentials?.password
|
||||
? { promptedPassword: promptedCredentials.password }
|
||||
: {}),
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "get guacamole token from host");
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { handleApiError, statsApi } from "@/main-axios";
|
||||
import type { HostMetricsLayout } from "@/types/host-metrics";
|
||||
|
||||
// Every function below is keyed by a host's numeric database id, and the
|
||||
// receiving backend must own that host in its own database -- a synced
|
||||
// host has a different numeric id on each side (only its syncId matches
|
||||
// across them). These calls always target the embedded local backend; see
|
||||
// getAllServerStatuses in host-metrics-status-api.ts for the one metrics
|
||||
// call that IS safely merged across local + remote (a process-local,
|
||||
// in-memory aggregate keyed by whichever host ids that process happens to
|
||||
// know about, not a per-host lookup).
|
||||
|
||||
export interface MetricsHistoryRow {
|
||||
ts: string;
|
||||
cpu_percent: number | null;
|
||||
|
||||
@@ -1,8 +1,31 @@
|
||||
import axios, { type AxiosRequestConfig } from "axios";
|
||||
import { handleApiError, statsApi } from "@/main-axios";
|
||||
import {
|
||||
handleApiError,
|
||||
statsApi,
|
||||
getRemoteStatsApi,
|
||||
isElectron,
|
||||
} from "@/main-axios";
|
||||
import type { ServerMetrics, ServerStatus } from "@/main-axios";
|
||||
import { getCachedServerStatuses } from "@/lib/hosts-request-cache";
|
||||
|
||||
// Metrics collection/viewer registration below (startMetricsPolling,
|
||||
// registerMetricsViewer, etc.) is NOT origin-routed: the backend that
|
||||
// receives the call must own the target host by numeric database id, and a
|
||||
// synced host has a different numeric id in each database (only its
|
||||
// syncId matches across them). Only the aggregate status read is merged
|
||||
// across local + remote, same as tunnel status.
|
||||
async function isRemoteSyncConnected(): Promise<boolean> {
|
||||
if (!isElectron()) return false;
|
||||
try {
|
||||
const config = (await window.electronAPI?.invoke?.(
|
||||
"get-remote-sync-config",
|
||||
)) as { serverUrl?: string } | null;
|
||||
return !!config?.serverUrl;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
type ApiConnectionLog = {
|
||||
type: "info" | "success" | "warning" | "error";
|
||||
stage: string;
|
||||
@@ -76,6 +99,7 @@ export async function getAllServerStatuses(): Promise<
|
||||
> {
|
||||
return getCachedServerStatuses(async () => {
|
||||
let lastError: unknown = null;
|
||||
let localStatuses: Record<number, ServerStatus> = {};
|
||||
|
||||
for (let i = 0; i < STATUS_RETRY_SCHEDULE.length; i++) {
|
||||
const { timeoutMs, pauseAfterMs } = STATUS_RETRY_SCHEDULE[i];
|
||||
@@ -89,7 +113,9 @@ export async function getAllServerStatuses(): Promise<
|
||||
// blips don't look like real outages.
|
||||
__silentRetry: !isFinalAttempt,
|
||||
} as AxiosRequestConfig & { __silentRetry?: boolean });
|
||||
return response.data || {};
|
||||
localStatuses = response.data || {};
|
||||
lastError = null;
|
||||
break;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (!isTransientStatusError(error)) {
|
||||
@@ -102,8 +128,24 @@ export async function getAllServerStatuses(): Promise<
|
||||
}
|
||||
}
|
||||
|
||||
handleApiError(lastError, "fetch server statuses");
|
||||
return {};
|
||||
if (lastError) {
|
||||
handleApiError(lastError, "fetch server statuses");
|
||||
return {};
|
||||
}
|
||||
|
||||
if (await isRemoteSyncConnected()) {
|
||||
try {
|
||||
const remoteResult = await getRemoteStatsApi().get("/status", {
|
||||
timeout: 8000,
|
||||
__silentRetry: true,
|
||||
} as AxiosRequestConfig & { __silentRetry?: boolean });
|
||||
return { ...localStatuses, ...(remoteResult.data || {}) };
|
||||
} catch {
|
||||
// remote unreachable this tick -- fall back to local-only statuses
|
||||
}
|
||||
}
|
||||
|
||||
return localStatuses;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { authApi } from "@/main-axios";
|
||||
import { createTtlRequestCache } from "@/lib/ttl-request-cache";
|
||||
import type { TerminalTheme } from "@/lib/terminal-themes";
|
||||
import type { CustomKeybinding } from "@/types/keybindings";
|
||||
|
||||
// OPEN TABS API
|
||||
// ============================================================================
|
||||
@@ -41,6 +43,10 @@ export interface ActiveSessionInfo {
|
||||
tabInstanceId: string | null;
|
||||
isConnected: boolean;
|
||||
createdAt: number;
|
||||
isOwnSession: boolean;
|
||||
sharedByUsername: string | null;
|
||||
permissionLevel: string | null;
|
||||
shareId: string | null;
|
||||
}
|
||||
|
||||
const activeSessionsCache = createTtlRequestCache<ActiveSessionInfo[]>(2_000);
|
||||
@@ -82,6 +88,12 @@ export async function getActiveSessions(): Promise<ActiveSessionInfo[]> {
|
||||
// USER PREFERENCES API
|
||||
// ============================================================================
|
||||
|
||||
export interface SavedCustomTheme {
|
||||
id: string;
|
||||
name: string;
|
||||
colors: TerminalTheme["colors"];
|
||||
}
|
||||
|
||||
export interface UserPreferences {
|
||||
reopenTabsOnLogin: boolean;
|
||||
theme?: string | null;
|
||||
@@ -102,6 +114,30 @@ export interface UserPreferences {
|
||||
hiddenRailTabs?: string | null;
|
||||
compactHostView?: boolean | null;
|
||||
statusColorScheme?: string | null;
|
||||
customThemes?: string | null;
|
||||
customKeybindings?: string | null;
|
||||
}
|
||||
|
||||
export function parseCustomThemes(raw?: string | null): SavedCustomTheme[] {
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function parseCustomKeybindings(
|
||||
raw?: string | null,
|
||||
): CustomKeybinding[] {
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUserPreferences(): Promise<UserPreferences> {
|
||||
|
||||
@@ -124,6 +124,31 @@ export async function shareHost(
|
||||
}
|
||||
}
|
||||
|
||||
export async function shareFolder(
|
||||
folder: string,
|
||||
shareData: {
|
||||
targets: ShareTarget[];
|
||||
permissionLevel: SharePermissionLevel;
|
||||
durationHours?: number;
|
||||
},
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
expiresAt: string | null;
|
||||
hostsShared: number;
|
||||
hostsTotal: number;
|
||||
hostResults: Array<{ hostId: number; shared: boolean; reason?: string }>;
|
||||
}> {
|
||||
try {
|
||||
const response = await rbacApi.post("/rbac/folder/share", {
|
||||
folder,
|
||||
...shareData,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "share folder");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateHostAccess(
|
||||
hostId: number,
|
||||
accessId: number,
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import axios from "axios";
|
||||
import { getBasePath } from "@/lib/base-path";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import { authApi, handleApiError } from "@/main-axios";
|
||||
|
||||
export interface ResolvedShareLink {
|
||||
protocol: "ssh" | "rdp" | "vnc" | "telnet";
|
||||
permissionLevel: "read-only" | "read-write";
|
||||
wsPath: string;
|
||||
connectParams?: { token: string };
|
||||
}
|
||||
|
||||
export type ShareLinkErrorKind = "not-found" | "rate-limited" | "unknown";
|
||||
|
||||
export class ShareLinkError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly kind: ShareLinkErrorKind,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ShareLinkError";
|
||||
}
|
||||
}
|
||||
|
||||
const isDev = (): boolean =>
|
||||
!isElectron() &&
|
||||
process.env.NODE_ENV === "development" &&
|
||||
(window.location.port === "3000" ||
|
||||
window.location.port === "5173" ||
|
||||
window.location.port === "");
|
||||
|
||||
// Guests have no session/JWT, so this deliberately builds a bare base URL
|
||||
// rather than going through main-axios's authenticated instances. The
|
||||
// desktop app always runs its embedded local backend as the source of
|
||||
// truth, so a share link opened there always resolves against it --
|
||||
// joining a session hosted on someone else's remote server isn't
|
||||
// supported from the desktop app today.
|
||||
async function resolveApiBaseUrl(): Promise<string> {
|
||||
if (isDev()) {
|
||||
const protocol = window.location.protocol === "https:" ? "https" : "http";
|
||||
return `${protocol}://localhost:30001`;
|
||||
}
|
||||
if (isElectron()) {
|
||||
return "http://127.0.0.1:30001";
|
||||
}
|
||||
return getBasePath();
|
||||
}
|
||||
|
||||
export async function resolveShareLink(
|
||||
linkToken: string,
|
||||
): Promise<ResolvedShareLink> {
|
||||
const baseUrl = await resolveApiBaseUrl();
|
||||
try {
|
||||
const response = await axios.get(
|
||||
`${baseUrl}/session-sharing/resolve/${encodeURIComponent(linkToken)}`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
if (error.response?.status === 404) {
|
||||
throw new ShareLinkError(
|
||||
"Share link is invalid, expired, or revoked",
|
||||
"not-found",
|
||||
);
|
||||
}
|
||||
if (error.response?.status === 429) {
|
||||
throw new ShareLinkError(
|
||||
"Too many attempts, please try again shortly",
|
||||
"rate-limited",
|
||||
);
|
||||
}
|
||||
}
|
||||
throw new ShareLinkError("Failed to resolve share link", "unknown");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SESSION SHARING (authenticated owner-side API)
|
||||
// ============================================================================
|
||||
|
||||
export type SessionShareProtocol = "ssh" | "rdp" | "vnc" | "telnet";
|
||||
export type SessionShareType = "link" | "user";
|
||||
export type SessionSharePermissionLevel = "read-only" | "read-write";
|
||||
|
||||
export interface SessionShareRecord {
|
||||
id: string;
|
||||
hostId: number;
|
||||
ownerUserId: string;
|
||||
protocol: SessionShareProtocol;
|
||||
sessionId: string;
|
||||
tabInstanceId: string | null;
|
||||
shareType: SessionShareType;
|
||||
targetUserId: string | null;
|
||||
linkToken: string | null;
|
||||
permissionLevel: SessionSharePermissionLevel;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
revokedAt: string | null;
|
||||
lastJoinedAt: string | null;
|
||||
joinCount: number;
|
||||
}
|
||||
|
||||
export interface CreateSessionShareRequest {
|
||||
hostId: number;
|
||||
sessionId: string;
|
||||
tabInstanceId?: string;
|
||||
protocol: SessionShareProtocol;
|
||||
shareType: SessionShareType;
|
||||
targetUserId?: string;
|
||||
permissionLevel: SessionSharePermissionLevel;
|
||||
expiryHours?: number;
|
||||
}
|
||||
|
||||
export interface CreateSessionShareResponse {
|
||||
shareId: string;
|
||||
linkToken: string | null;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export async function createSessionShare(
|
||||
request: CreateSessionShareRequest,
|
||||
): Promise<CreateSessionShareResponse> {
|
||||
try {
|
||||
const response = await authApi.post("/session-sharing/create", request);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "create session share");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getActiveSessionShares(
|
||||
hostId: number,
|
||||
): Promise<{ shares: SessionShareRecord[] }> {
|
||||
try {
|
||||
const response = await authApi.get(
|
||||
`/session-sharing/host/${hostId}/active`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch active session shares");
|
||||
}
|
||||
}
|
||||
|
||||
export async function revokeSessionShare(
|
||||
shareId: string,
|
||||
): Promise<{ success: true }> {
|
||||
try {
|
||||
const response = await authApi.delete(`/session-sharing/${shareId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "revoke session share");
|
||||
}
|
||||
}
|
||||
|
||||
export async function endSessionShareSession(
|
||||
shareId: string,
|
||||
): Promise<{ success: true }> {
|
||||
try {
|
||||
const response = await authApi.post(`/session-sharing/${shareId}/end`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "end shared session");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// GLOBAL ADMIN TOGGLE
|
||||
// ============================================================================
|
||||
|
||||
export async function getSessionSharingGloballyEnabled(): Promise<{
|
||||
enabled: boolean;
|
||||
}> {
|
||||
try {
|
||||
const response = await authApi.get("/users/session-sharing-enabled");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch session sharing enabled setting");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateSessionSharingGloballyEnabled(
|
||||
enabled: boolean,
|
||||
): Promise<{ enabled: boolean }> {
|
||||
try {
|
||||
const response = await authApi.patch("/users/session-sharing-enabled", {
|
||||
enabled,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "update session sharing enabled setting");
|
||||
}
|
||||
}
|
||||
@@ -145,6 +145,32 @@ export async function updateGuacamoleSettings(settings: {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ANALYTICS SETTINGS
|
||||
// ============================================================================
|
||||
|
||||
export async function getAnalyticsEnabled(): Promise<{ enabled: boolean }> {
|
||||
try {
|
||||
const response = await authApi.get("/users/analytics-enabled");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch analytics enabled setting");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateAnalyticsEnabled(
|
||||
enabled: boolean,
|
||||
): Promise<{ enabled: boolean }> {
|
||||
try {
|
||||
const response = await authApi.patch("/users/analytics-enabled", {
|
||||
enabled,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "update analytics enabled setting");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HOST DEFAULTS SETTINGS
|
||||
// ============================================================================
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import axios from "axios";
|
||||
import { authApi, fileManagerApi, handleApiError } from "@/main-axios";
|
||||
import {
|
||||
authApi,
|
||||
fileManagerApi,
|
||||
handleApiError,
|
||||
getFileManagerApiForSession,
|
||||
setSessionOrigin,
|
||||
clearSessionOrigin,
|
||||
} from "@/main-axios";
|
||||
import { resolveConnectionOrigin } from "@/lib/connection-origin";
|
||||
import { fileLogger } from "@/lib/frontend-logger";
|
||||
import type { SSHHost } from "@/types/index";
|
||||
|
||||
@@ -72,7 +80,7 @@ export async function connectSSH(
|
||||
},
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.post(
|
||||
const response = await getFileManagerApiForSession(sessionId).post(
|
||||
"/ssh/connect",
|
||||
{ sessionId, ...config },
|
||||
{ timeout: 120000 },
|
||||
@@ -121,12 +129,15 @@ export async function disconnectSSH(
|
||||
sessionId: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.post("/ssh/disconnect", {
|
||||
sessionId,
|
||||
});
|
||||
const response = await getFileManagerApiForSession(sessionId).post(
|
||||
"/ssh/disconnect",
|
||||
{ sessionId },
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "disconnect SSH");
|
||||
} finally {
|
||||
clearSessionOrigin(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,10 +146,10 @@ export async function verifySSHTOTP(
|
||||
totpCode: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.post("/ssh/connect-totp", {
|
||||
sessionId,
|
||||
totpCode,
|
||||
});
|
||||
const response = await getFileManagerApiForSession(sessionId).post(
|
||||
"/ssh/connect-totp",
|
||||
{ sessionId, totpCode },
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "verify SSH TOTP");
|
||||
@@ -149,9 +160,10 @@ export async function verifySSHWarpgate(
|
||||
sessionId: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.post("/ssh/connect-warpgate", {
|
||||
sessionId,
|
||||
});
|
||||
const response = await getFileManagerApiForSession(sessionId).post(
|
||||
"/ssh/connect-warpgate",
|
||||
{ sessionId },
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "verify SSH Warpgate");
|
||||
@@ -239,9 +251,10 @@ export async function getSSHStatus(
|
||||
sessionId: string,
|
||||
): Promise<{ connected: boolean }> {
|
||||
try {
|
||||
const response = await fileManagerApi.get("/ssh/status", {
|
||||
params: { sessionId },
|
||||
});
|
||||
const response = await getFileManagerApiForSession(sessionId).get(
|
||||
"/ssh/status",
|
||||
{ params: { sessionId } },
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "get SSH status");
|
||||
@@ -252,9 +265,10 @@ export async function keepSSHAlive(
|
||||
sessionId: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.post("/ssh/keepalive", {
|
||||
sessionId,
|
||||
});
|
||||
const response = await getFileManagerApiForSession(sessionId).post(
|
||||
"/ssh/keepalive",
|
||||
{ sessionId },
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "SSH keepalive");
|
||||
@@ -266,9 +280,10 @@ export async function listSSHFiles(
|
||||
path: string,
|
||||
): Promise<{ files: unknown[]; path: string }> {
|
||||
try {
|
||||
const response = await fileManagerApi.get("/ssh/listFiles", {
|
||||
params: { sessionId, path },
|
||||
});
|
||||
const response = await getFileManagerApiForSession(sessionId).get(
|
||||
"/ssh/listFiles",
|
||||
{ params: { sessionId, path } },
|
||||
);
|
||||
return response.data || { files: [], path };
|
||||
} catch (error) {
|
||||
handleApiError(error, "list SSH files");
|
||||
@@ -281,9 +296,10 @@ export async function identifySSHSymlink(
|
||||
path: string,
|
||||
): Promise<{ path: string; target: string; type: "directory" | "file" }> {
|
||||
try {
|
||||
const response = await fileManagerApi.get("/ssh/identifySymlink", {
|
||||
params: { sessionId, path },
|
||||
});
|
||||
const response = await getFileManagerApiForSession(sessionId).get(
|
||||
"/ssh/identifySymlink",
|
||||
{ params: { sessionId, path } },
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "identify SSH symlink");
|
||||
@@ -295,9 +311,10 @@ export async function resolveSSHPath(
|
||||
path: string,
|
||||
): Promise<string> {
|
||||
try {
|
||||
const response = await fileManagerApi.get("/ssh/resolvePath", {
|
||||
params: { sessionId, path },
|
||||
});
|
||||
const response = await getFileManagerApiForSession(sessionId).get(
|
||||
"/ssh/resolvePath",
|
||||
{ params: { sessionId, path } },
|
||||
);
|
||||
return response.data?.resolvedPath || path;
|
||||
} catch {
|
||||
return path;
|
||||
@@ -313,9 +330,10 @@ export async function readSSHFile(
|
||||
encoding?: "base64" | "utf8";
|
||||
}> {
|
||||
try {
|
||||
const response = await fileManagerApi.get("/ssh/readFile", {
|
||||
params: { sessionId, path },
|
||||
});
|
||||
const response = await getFileManagerApiForSession(sessionId).get(
|
||||
"/ssh/readFile",
|
||||
{ params: { sessionId, path } },
|
||||
);
|
||||
return response.data;
|
||||
} catch (error: unknown) {
|
||||
if (error.response?.status === 404) {
|
||||
@@ -340,13 +358,10 @@ export async function writeSSHFile(
|
||||
userId?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.post("/ssh/writeFile", {
|
||||
sessionId,
|
||||
path,
|
||||
content,
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
const response = await getFileManagerApiForSession(sessionId).post(
|
||||
"/ssh/writeFile",
|
||||
{ sessionId, path, content, hostId, userId },
|
||||
);
|
||||
|
||||
if (
|
||||
response.data &&
|
||||
@@ -410,7 +425,7 @@ export async function uploadSSHFile(
|
||||
form.append("totalSize", String(file.size));
|
||||
form.append("chunk", chunkBlob, fileName);
|
||||
|
||||
const response = await fileManagerApi.postForm(
|
||||
const response = await getFileManagerApiForSession(sessionId).postForm(
|
||||
"/ssh/uploadFileChunk",
|
||||
form,
|
||||
{ timeout: 0 },
|
||||
@@ -444,7 +459,7 @@ export async function uploadSSHFile(
|
||||
if (userId !== undefined) form.append("userId", userId);
|
||||
form.append("file", file, fileName);
|
||||
|
||||
const response = await fileManagerApi.postForm(
|
||||
const response = await getFileManagerApiForSession(sessionId).postForm(
|
||||
"/ssh/uploadFileStream",
|
||||
form,
|
||||
{
|
||||
@@ -464,7 +479,7 @@ export async function downloadSSHFile(
|
||||
userId?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.post(
|
||||
const response = await getFileManagerApiForSession(sessionId).post(
|
||||
"/ssh/downloadFile",
|
||||
{
|
||||
sessionId,
|
||||
@@ -484,7 +499,7 @@ export async function downloadSSHFileStream(
|
||||
sessionId: string,
|
||||
filePath: string,
|
||||
): Promise<void> {
|
||||
const response = await fileManagerApi.post(
|
||||
const response = await getFileManagerApiForSession(sessionId).post(
|
||||
"/ssh/downloadFileStream",
|
||||
{ sessionId, path: filePath },
|
||||
{ responseType: "blob", timeout: 0 },
|
||||
@@ -503,14 +518,10 @@ export async function createSSHFile(
|
||||
userId?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.post("/ssh/createFile", {
|
||||
sessionId,
|
||||
path,
|
||||
fileName,
|
||||
content,
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
const response = await getFileManagerApiForSession(sessionId).post(
|
||||
"/ssh/createFile",
|
||||
{ sessionId, path, fileName, content, hostId, userId },
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "create SSH file");
|
||||
@@ -525,13 +536,10 @@ export async function createSSHFolder(
|
||||
userId?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.post("/ssh/createFolder", {
|
||||
sessionId,
|
||||
path,
|
||||
folderName,
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
const response = await getFileManagerApiForSession(sessionId).post(
|
||||
"/ssh/createFolder",
|
||||
{ sessionId, path, folderName, hostId, userId },
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "create SSH folder");
|
||||
@@ -546,15 +554,18 @@ export async function deleteSSHItem(
|
||||
userId?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.delete("/ssh/deleteItem", {
|
||||
data: {
|
||||
sessionId,
|
||||
path,
|
||||
isDirectory,
|
||||
hostId,
|
||||
userId,
|
||||
const response = await getFileManagerApiForSession(sessionId).delete(
|
||||
"/ssh/deleteItem",
|
||||
{
|
||||
data: {
|
||||
sessionId,
|
||||
path,
|
||||
isDirectory,
|
||||
hostId,
|
||||
userId,
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "delete SSH item");
|
||||
@@ -566,7 +577,7 @@ export async function setSudoPassword(
|
||||
password: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await fileManagerApi.post("/sudo-password", {
|
||||
await getFileManagerApiForSession(sessionId).post("/sudo-password", {
|
||||
sessionId,
|
||||
password,
|
||||
});
|
||||
@@ -583,7 +594,7 @@ export async function copySSHItem(
|
||||
userId?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.post(
|
||||
const response = await getFileManagerApiForSession(sessionId).post(
|
||||
"/ssh/copyItem",
|
||||
{
|
||||
sessionId,
|
||||
@@ -611,13 +622,10 @@ export async function renameSSHItem(
|
||||
userId?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.put("/ssh/renameItem", {
|
||||
sessionId,
|
||||
oldPath,
|
||||
newName,
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
const response = await getFileManagerApiForSession(sessionId).put(
|
||||
"/ssh/renameItem",
|
||||
{ sessionId, oldPath, newName, hostId, userId },
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "rename SSH item");
|
||||
@@ -633,7 +641,7 @@ export async function moveSSHItem(
|
||||
userId?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.put(
|
||||
const response = await getFileManagerApiForSession(sessionId).put(
|
||||
"/ssh/moveItem",
|
||||
{
|
||||
sessionId,
|
||||
@@ -670,13 +678,10 @@ export async function changeSSHPermissions(
|
||||
userId,
|
||||
});
|
||||
|
||||
const response = await fileManagerApi.post("/ssh/changePermissions", {
|
||||
sessionId,
|
||||
path,
|
||||
permissions,
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
const response = await getFileManagerApiForSession(sessionId).post(
|
||||
"/ssh/changePermissions",
|
||||
{ sessionId, path, permissions, hostId, userId },
|
||||
);
|
||||
|
||||
fileLogger.success("SSH file permissions changed successfully", {
|
||||
operation: "change_permissions",
|
||||
@@ -715,13 +720,10 @@ export async function extractSSHArchive(
|
||||
userId,
|
||||
});
|
||||
|
||||
const response = await fileManagerApi.post("/ssh/extractArchive", {
|
||||
sessionId,
|
||||
archivePath,
|
||||
extractPath,
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
const response = await getFileManagerApiForSession(sessionId).post(
|
||||
"/ssh/extractArchive",
|
||||
{ sessionId, archivePath, extractPath, hostId, userId },
|
||||
);
|
||||
|
||||
fileLogger.success("Archive extracted successfully", {
|
||||
operation: "extract_archive",
|
||||
@@ -762,14 +764,17 @@ export async function compressSSHFiles(
|
||||
userId,
|
||||
});
|
||||
|
||||
const response = await fileManagerApi.post("/ssh/compressFiles", {
|
||||
sessionId,
|
||||
paths,
|
||||
archiveName,
|
||||
format: format || "zip",
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
const response = await getFileManagerApiForSession(sessionId).post(
|
||||
"/ssh/compressFiles",
|
||||
{
|
||||
sessionId,
|
||||
paths,
|
||||
archiveName,
|
||||
format: format || "zip",
|
||||
hostId,
|
||||
userId,
|
||||
},
|
||||
);
|
||||
|
||||
fileLogger.success("Files compressed successfully", {
|
||||
operation: "compress_files",
|
||||
@@ -811,6 +816,12 @@ export async function ensureSSHSessionForHost(
|
||||
host: SSHHost,
|
||||
): Promise<EnsureSSHSessionResult> {
|
||||
const sessionId = host.id.toString();
|
||||
const origin = await resolveConnectionOrigin({
|
||||
connectionType: host.connectionType,
|
||||
connectionOrigin: host.connectionOrigin,
|
||||
});
|
||||
setSessionOrigin(sessionId, origin);
|
||||
|
||||
try {
|
||||
const status = await getSSHStatus(sessionId);
|
||||
if (status?.connected) {
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import { AxiosError } from "axios";
|
||||
import {
|
||||
authApi,
|
||||
handleApiError,
|
||||
isElectron,
|
||||
markUserAuthenticated,
|
||||
} from "@/main-axios";
|
||||
import { authApi, handleApiError, markUserAuthenticated } from "@/main-axios";
|
||||
import type { AuthResponse } from "@/main-axios";
|
||||
|
||||
// ALERTS
|
||||
@@ -63,25 +58,6 @@ export async function verifyTOTPLogin(
|
||||
rememberMe,
|
||||
});
|
||||
|
||||
const isInIframe =
|
||||
typeof window !== "undefined" && window.self !== window.top;
|
||||
|
||||
if (isInIframe && isElectron() && response.data.success) {
|
||||
try {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: "AUTH_SUCCESS",
|
||||
source: "totp_verify",
|
||||
platform: "desktop",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
window.location.origin,
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("[main-axios] Error posting message to parent:", e);
|
||||
}
|
||||
}
|
||||
|
||||
if (response.data.success) {
|
||||
markUserAuthenticated();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import axios from "axios";
|
||||
import { authApi, handleApiError, tunnelApi } from "@/main-axios";
|
||||
import {
|
||||
authApi,
|
||||
handleApiError,
|
||||
tunnelApi,
|
||||
getRemoteTunnelApi,
|
||||
isElectron,
|
||||
} from "@/main-axios";
|
||||
import type {
|
||||
C2STunnelPreset,
|
||||
TunnelConfig,
|
||||
@@ -9,13 +15,46 @@ import type {
|
||||
|
||||
// TUNNEL MANAGEMENT
|
||||
// ============================================================================
|
||||
//
|
||||
// Tunnel status is a process-local, in-memory view (no DB lookup) so it's
|
||||
// safe to read from both the embedded backend and a connected remote server
|
||||
// and merge the results. connectTunnel/disconnectTunnel/cancelTunnel are
|
||||
// NOT origin-routed: they resolve the target host by numeric database id
|
||||
// against whichever backend receives the request, and a synced host has a
|
||||
// different numeric id in each database (only its syncId matches across
|
||||
// them) -- routing those calls to a remote backend would need a
|
||||
// local-id-to-remote-id resolution step that doesn't exist yet. They always
|
||||
// target the embedded local backend for now.
|
||||
|
||||
async function isRemoteSyncConnected(): Promise<boolean> {
|
||||
if (!isElectron()) return false;
|
||||
try {
|
||||
const config = (await window.electronAPI?.invoke?.(
|
||||
"get-remote-sync-config",
|
||||
)) as { serverUrl?: string } | null;
|
||||
return !!config?.serverUrl;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTunnelStatuses(): Promise<
|
||||
Record<string, TunnelStatus>
|
||||
> {
|
||||
try {
|
||||
const response = await tunnelApi.get("/tunnel/status");
|
||||
return response.data || {};
|
||||
const [localResult, remoteConnected] = await Promise.all([
|
||||
tunnelApi.get("/tunnel/status"),
|
||||
isRemoteSyncConnected(),
|
||||
]);
|
||||
const localStatuses = localResult.data || {};
|
||||
if (!remoteConnected) return localStatuses;
|
||||
|
||||
try {
|
||||
const remoteResult = await getRemoteTunnelApi().get("/tunnel/status");
|
||||
return { ...localStatuses, ...(remoteResult.data || {}) };
|
||||
} catch {
|
||||
return localStatuses;
|
||||
}
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch tunnel statuses");
|
||||
}
|
||||
@@ -30,9 +69,18 @@ export function subscribeTunnelStatuses(
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
let latestLocal: Record<string, TunnelStatus> = {};
|
||||
let latestRemote: Record<string, TunnelStatus> = {};
|
||||
let remotePollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const emitMerged = () => {
|
||||
onStatuses({ ...latestLocal, ...latestRemote });
|
||||
};
|
||||
|
||||
source.addEventListener("statuses", (event) => {
|
||||
try {
|
||||
onStatuses(JSON.parse(event.data) as Record<string, TunnelStatus>);
|
||||
latestLocal = JSON.parse(event.data) as Record<string, TunnelStatus>;
|
||||
emitMerged();
|
||||
} catch {
|
||||
onError?.();
|
||||
}
|
||||
@@ -42,7 +90,27 @@ export function subscribeTunnelStatuses(
|
||||
onError?.();
|
||||
};
|
||||
|
||||
return () => source.close();
|
||||
// Remote tunnel status has no SSE stream exposed to the desktop app yet,
|
||||
// so poll it at a modest interval when a remote server is connected.
|
||||
isRemoteSyncConnected().then((connected) => {
|
||||
if (!connected) return;
|
||||
const pollRemote = async () => {
|
||||
try {
|
||||
const result = await getRemoteTunnelApi().get("/tunnel/status");
|
||||
latestRemote = result.data || {};
|
||||
emitMerged();
|
||||
} catch {
|
||||
// remote unreachable this tick -- keep last known remote statuses
|
||||
}
|
||||
};
|
||||
pollRemote();
|
||||
remotePollTimer = setInterval(pollRemote, 5000);
|
||||
});
|
||||
|
||||
return () => {
|
||||
source.close();
|
||||
if (remotePollTimer) clearInterval(remotePollTimer);
|
||||
};
|
||||
}
|
||||
|
||||
export async function getTunnelStatusByName(
|
||||
|
||||
+124
-157
@@ -29,17 +29,13 @@ import {
|
||||
completePasswordReset,
|
||||
getOIDCAuthorizeUrl,
|
||||
verifyTOTPLogin,
|
||||
getServerConfig,
|
||||
saveServerConfig,
|
||||
isElectron,
|
||||
getEmbeddedServerStatus,
|
||||
getCurrentToken,
|
||||
getOidcSilentLoginDefault,
|
||||
requestDesktopAutoSession,
|
||||
} from "@/main-axios";
|
||||
import { getSSOProviders, ldapLogin } from "@/api/sso-provider-api";
|
||||
import type { SSOProviderPublic } from "@/types/index";
|
||||
import { ElectronServerConfig as ServerConfigComponent } from "@/auth/ElectronServerConfig";
|
||||
import { ElectronLoginForm } from "@/auth/ElectronLoginForm";
|
||||
import { Checkbox } from "@/components/checkbox";
|
||||
import { changeAppLanguage, normalizeLanguageCode } from "@/i18n/i18n";
|
||||
import {
|
||||
@@ -263,13 +259,23 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
const [firstUser, setFirstUser] = useState(false);
|
||||
const [dbConnectionFailed, setDbConnectionFailed] = useState(false);
|
||||
const [dbHealthChecking, setDbHealthChecking] = useState(true);
|
||||
|
||||
const [showServerConfig, setShowServerConfig] = useState<boolean | null>(
|
||||
null,
|
||||
);
|
||||
const [currentServerUrl, setCurrentServerUrl] = useState("");
|
||||
const [webviewAuthSuccess, setWebviewAuthSuccess] = useState(false);
|
||||
|
||||
// Electron, non-iframed only: the desktop app never shows a login form
|
||||
// when running standalone -- the embedded backend auto-provisions a
|
||||
// single local user on first boot, and this component silently exchanges
|
||||
// that for a session instead of rendering login/register.
|
||||
// null = probe still in flight (Electron only, blocks rendering below).
|
||||
// true = probe settled with no auto-login (not applicable outside
|
||||
// Electron, multiple users exist, or setup is genuinely required) --
|
||||
// safe to fall through to the normal form/health-check flow.
|
||||
// Auto-login success never sets this; it calls onLogin directly and this
|
||||
// component unmounts.
|
||||
const [desktopAutoSessionDone, setDesktopAutoSessionDone] = useState<
|
||||
boolean | null
|
||||
>(!isElectron() || isInElectronWebView() ? true : null);
|
||||
const [desktopAutoSessionRetries, setDesktopAutoSessionRetries] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem("rememberMe", rememberMe.toString());
|
||||
@@ -320,7 +326,11 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (showServerConfig) return;
|
||||
// Runs once the auto-session probe has settled (immediately outside
|
||||
// Electron, since it starts at true there; after the probe resolves in
|
||||
// Electron). Waiting avoids flashing a login screen the user is about
|
||||
// to skip past via auto-login.
|
||||
if (desktopAutoSessionDone !== true) return;
|
||||
setDbHealthChecking(true);
|
||||
getSetupRequired()
|
||||
.then((res) => {
|
||||
@@ -332,53 +342,59 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
})
|
||||
.catch(() => setDbConnectionFailed(true))
|
||||
.finally(() => setDbHealthChecking(false));
|
||||
}, [showServerConfig]);
|
||||
}, [desktopAutoSessionDone]);
|
||||
|
||||
// A cold first launch spawns the embedded backend as a separate process
|
||||
// that can take anywhere from a couple seconds to much longer to finish
|
||||
// booting (DB init, SSL, antivirus scanning a freshly-unpacked binary,
|
||||
// slow disks, etc.) -- well after the renderer has already mounted. The
|
||||
// embedded backend is bundled, always-on infrastructure, not something
|
||||
// that can be "not there" -- it always eventually comes up. So a
|
||||
// "retry" outcome (connection error, not a real verdict) is retried
|
||||
// forever with capped backoff rather than ever giving up and falling
|
||||
// through to the login form: that form is not a valid destination for a
|
||||
// standalone install with no remote sync configured, since the only
|
||||
// local account has no password to log in with. Only a definitive
|
||||
// "declined" (backend reachable and says no -- multiple users, or the
|
||||
// sole local user has a real credential) stops retrying and shows the
|
||||
// real form.
|
||||
useEffect(() => {
|
||||
const checkElectron = async () => {
|
||||
if (isInElectronWebView()) {
|
||||
setShowServerConfig(false);
|
||||
return;
|
||||
}
|
||||
if (isElectron()) {
|
||||
const forceShow = localStorage.getItem("termix_show_server_config");
|
||||
if (forceShow === "true") {
|
||||
localStorage.removeItem("termix_show_server_config");
|
||||
try {
|
||||
const config = await getServerConfig();
|
||||
setCurrentServerUrl(config?.serverUrl || "");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setShowServerConfig(true);
|
||||
if (desktopAutoSessionDone !== null) return;
|
||||
let cancelled = false;
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
requestDesktopAutoSession()
|
||||
.then((outcome) => {
|
||||
if (cancelled) return;
|
||||
if (outcome.kind === "success") {
|
||||
storeAuth(outcome.data.username || "");
|
||||
onLogin(
|
||||
outcome.data.username || "",
|
||||
outcome.data.userId || undefined,
|
||||
!!outcome.data.is_admin,
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const [config, status] = await Promise.all([
|
||||
getServerConfig(),
|
||||
getEmbeddedServerStatus(),
|
||||
]);
|
||||
if (
|
||||
status?.embedded &&
|
||||
status?.running &&
|
||||
config &&
|
||||
!config.serverUrl
|
||||
) {
|
||||
setShowServerConfig(false);
|
||||
setCurrentServerUrl("");
|
||||
return;
|
||||
}
|
||||
setCurrentServerUrl(config?.serverUrl || "");
|
||||
setShowServerConfig(!config || !config.serverUrl);
|
||||
} catch {
|
||||
setShowServerConfig(true);
|
||||
if (outcome.kind === "retry") {
|
||||
const delay = Math.min(1000 * 2 ** desktopAutoSessionRetries, 10000);
|
||||
retryTimer = setTimeout(() => {
|
||||
if (!cancelled) setDesktopAutoSessionRetries((c) => c + 1);
|
||||
}, delay);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
setShowServerConfig(false);
|
||||
}
|
||||
setDesktopAutoSessionDone(true);
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
const delay = Math.min(1000 * 2 ** desktopAutoSessionRetries, 10000);
|
||||
retryTimer = setTimeout(() => {
|
||||
if (!cancelled) setDesktopAutoSessionRetries((c) => c + 1);
|
||||
}, delay);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (retryTimer) clearTimeout(retryTimer);
|
||||
};
|
||||
checkElectron();
|
||||
}, []);
|
||||
}, [desktopAutoSessionDone, desktopAutoSessionRetries, onLogin]);
|
||||
|
||||
useEffect(() => {
|
||||
if (view === "totp" && totpInputRef.current) totpInputRef.current.focus();
|
||||
@@ -474,36 +490,6 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
}
|
||||
}, [onLogin, t]);
|
||||
|
||||
const handleElectronAuthSuccess = useCallback(
|
||||
async (token: string | null) => {
|
||||
try {
|
||||
if (!token) {
|
||||
// No token in postMessage — fall back to waiting for the HttpOnly cookie
|
||||
const cookieReady = await window.electronAPI?.waitForSessionCookie?.(
|
||||
"jwt",
|
||||
currentServerUrl,
|
||||
null,
|
||||
5000,
|
||||
);
|
||||
if (cookieReady && !cookieReady.success)
|
||||
throw new Error(cookieReady.error || "Auth cookie not ready");
|
||||
}
|
||||
const meRes = await getUserInfo();
|
||||
if (!meRes) throw new Error("Failed to get user info");
|
||||
storeAuth(meRes.username || "");
|
||||
onLogin(
|
||||
meRes.username || "",
|
||||
meRes.userId || undefined,
|
||||
!!meRes.is_admin,
|
||||
);
|
||||
toast.success(t("messages.loginSuccess"));
|
||||
} catch {
|
||||
toast.error(t("errors.failedUserInfo"));
|
||||
}
|
||||
},
|
||||
[onLogin, currentServerUrl, t],
|
||||
);
|
||||
|
||||
function resetAll() {
|
||||
setUsername("");
|
||||
setPassword("");
|
||||
@@ -549,11 +535,19 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
return;
|
||||
}
|
||||
if (isInElectronWebView()) {
|
||||
// The iframe's login request never carries the X-Electron-App header
|
||||
// (only the top-level Electron renderer's axios instances do), so the
|
||||
// backend never includes the JWT in the login response body -- it
|
||||
// only lands in an HttpOnly cookie scoped to this iframe's origin.
|
||||
// Read it back via /users/me/token, same as the mobile-webview OIDC
|
||||
// callback below does, so the parent window can persist it.
|
||||
const token = res?.token ?? (await getCurrentToken());
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: "AUTH_SUCCESS",
|
||||
source: "auth_component",
|
||||
platform: "desktop",
|
||||
token: token ?? null,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
"*",
|
||||
@@ -607,6 +601,35 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
setView("totp");
|
||||
return;
|
||||
}
|
||||
if (isInMobileWebView()) {
|
||||
// Native-app requests get the JWT in the login response body.
|
||||
const token = res?.token ?? "";
|
||||
(window as ExtendedWindow).ReactNativeWebView?.postMessage(
|
||||
JSON.stringify({ type: "AUTH_SUCCESS", token }),
|
||||
);
|
||||
setWebviewAuthSuccess(true);
|
||||
return;
|
||||
}
|
||||
if (isInElectronWebView()) {
|
||||
// Registration inside the Remote Sync iframe must hand off to the
|
||||
// parent window the same way handleLogin does -- otherwise this
|
||||
// component's own onLogin() below fires on the iframe's own,
|
||||
// independent copy of the app, rendering the full remote AppShell
|
||||
// inside the small login dialog instead of closing it.
|
||||
const token = res?.token ?? (await getCurrentToken());
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: "AUTH_SUCCESS",
|
||||
source: "auth_component",
|
||||
platform: "desktop",
|
||||
token: token ?? null,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
"*",
|
||||
);
|
||||
setWebviewAuthSuccess(true);
|
||||
return;
|
||||
}
|
||||
const meRes = await getUserInfo();
|
||||
storeAuth(meRes.username || username.trim());
|
||||
toast.success(t("messages.registrationSuccess"));
|
||||
@@ -650,11 +673,16 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
return;
|
||||
}
|
||||
if (isInElectronWebView()) {
|
||||
// See the equivalent branch in handleLogin: the iframe never sends
|
||||
// X-Electron-App, so the JWT never lands in the response body here
|
||||
// either -- read it back from the HttpOnly cookie that was just set.
|
||||
const token = res?.token ?? (await getCurrentToken());
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: "AUTH_SUCCESS",
|
||||
source: "totp_auth_component",
|
||||
platform: "desktop",
|
||||
token: token ?? null,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
"*",
|
||||
@@ -935,46 +963,19 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
oidcSilentLoginDefaultLoaded,
|
||||
]);
|
||||
|
||||
// Electron server config / webview auth success screens
|
||||
if (isElectron() && !isInElectronWebView()) {
|
||||
if (showServerConfig === null)
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-background">
|
||||
<div className="w-5 h-5 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
if (showServerConfig)
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-background p-6">
|
||||
<div className="w-full max-w-md">
|
||||
<ServerConfigComponent
|
||||
onServerConfigured={() => window.location.reload()}
|
||||
onUseEmbedded={async () => {
|
||||
await saveServerConfig({
|
||||
serverUrl: "",
|
||||
lastUpdated: new Date().toISOString(),
|
||||
});
|
||||
setShowServerConfig(false);
|
||||
setCurrentServerUrl("");
|
||||
}}
|
||||
onCancel={() => setShowServerConfig(false)}
|
||||
isFirstTime={!currentServerUrl}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
if (!webviewAuthSuccess && showServerConfig === false && currentServerUrl)
|
||||
return (
|
||||
<div className="w-full h-screen flex items-center justify-center p-4 bg-background">
|
||||
<div className="w-full max-w-4xl h-[90vh]">
|
||||
<ElectronLoginForm
|
||||
serverUrl={currentServerUrl}
|
||||
onAuthSuccess={handleElectronAuthSuccess}
|
||||
onChangeServer={() => setShowServerConfig(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
// Electron, non-iframed: wait for the auto-session probe before rendering
|
||||
// anything, so a standalone desktop install never flashes a login form
|
||||
// it's about to skip past.
|
||||
if (
|
||||
isElectron() &&
|
||||
!isInElectronWebView() &&
|
||||
desktopAutoSessionDone === null
|
||||
) {
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-background">
|
||||
<div className="w-5 h-5 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (webviewAuthSuccess || (isInElectronWebView() && webviewAuthSuccess))
|
||||
@@ -1018,30 +1019,11 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{isElectron() && currentServerUrl && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("serverConfig.serverUrl")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground font-mono truncate max-w-[180px]">
|
||||
{currentServerUrl}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowServerConfig(true)}
|
||||
>
|
||||
{t("common.edit")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (dbHealthChecking && showServerConfig === false)
|
||||
if (dbHealthChecking)
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-background">
|
||||
<div className="w-5 h-5 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
@@ -1068,21 +1050,6 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex flex-col bg-background overflow-hidden">
|
||||
{isElectron() && !isInElectronWebView() && showServerConfig === false && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border shrink-0">
|
||||
<button
|
||||
onClick={() => setShowServerConfig(true)}
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
{t("serverConfig.changeServer")}
|
||||
</button>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("serverConfig.localServer")}
|
||||
</span>
|
||||
<div className="w-20" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Left decorative panel */}
|
||||
<div className="hidden lg:flex flex-col w-[420px] shrink-0 bg-sidebar border-r border-border relative overflow-hidden select-none">
|
||||
|
||||
@@ -7,6 +7,12 @@ interface ElectronLoginFormProps {
|
||||
serverUrl: string;
|
||||
onAuthSuccess: (token: string | null) => void | Promise<void>;
|
||||
onChangeServer: () => void;
|
||||
// "local" (default): the app's own login, JWT goes to localStorage like
|
||||
// every other client. "remoteSync": this iframe is authenticating a
|
||||
// Settings-triggered connection to a remote Termix server for the sync
|
||||
// engine -- the JWT is handed to the Electron main process's encrypted
|
||||
// store instead, never exposed to the renderer's localStorage.
|
||||
targetPurpose?: "local" | "remoteSync";
|
||||
}
|
||||
|
||||
const AUTH_MESSAGE_SOURCES = new Set([
|
||||
@@ -19,6 +25,7 @@ export function ElectronLoginForm({
|
||||
serverUrl,
|
||||
onAuthSuccess,
|
||||
onChangeServer,
|
||||
targetPurpose = "local",
|
||||
}: ElectronLoginFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -43,7 +50,11 @@ export function ElectronLoginForm({
|
||||
|
||||
try {
|
||||
if (token) {
|
||||
localStorage.setItem("jwt", token);
|
||||
if (targetPurpose === "remoteSync") {
|
||||
await window.electronAPI?.invoke?.("save-remote-sync-jwt", token);
|
||||
} else {
|
||||
localStorage.setItem("jwt", token);
|
||||
}
|
||||
}
|
||||
await onAuthSuccessRef.current(token);
|
||||
} catch {
|
||||
@@ -53,7 +64,7 @@ export function ElectronLoginForm({
|
||||
hasAuthenticatedRef.current = false;
|
||||
}
|
||||
},
|
||||
[t],
|
||||
[t, targetPurpose],
|
||||
);
|
||||
|
||||
// postMessage from server Auth.tsx after the backend has set the HttpOnly cookie.
|
||||
@@ -204,7 +215,7 @@ export function ElectronLoginForm({
|
||||
const isEmbeddedServer = serverUrl.includes("localhost:30001");
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 w-screen h-screen bg-background flex flex-col">
|
||||
<div className="relative w-full h-full bg-background flex flex-col">
|
||||
{isAuthenticating && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background z-50">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
@@ -212,7 +223,7 @@ export function ElectronLoginForm({
|
||||
)}
|
||||
|
||||
{!isAuthenticating && (
|
||||
<div className="flex items-center justify-between p-4 bg-background border-b border-border">
|
||||
<div className="flex items-center justify-between p-4 pr-12 bg-background border-b border-border">
|
||||
<button
|
||||
onClick={onChangeServer}
|
||||
className="flex items-center gap-2 text-foreground hover:text-primary transition-colors"
|
||||
|
||||
@@ -1,399 +0,0 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { Input } from "@/components/input.tsx";
|
||||
import { Label } from "@/components/label.tsx";
|
||||
import { Alert, AlertTitle, AlertDescription } from "@/components/alert.tsx";
|
||||
import { Switch } from "@/components/switch.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
getServerConfig,
|
||||
saveServerConfig,
|
||||
getEmbeddedServerStatus,
|
||||
setEmbeddedMode,
|
||||
type ServerConfig,
|
||||
} from "@/main-axios.ts";
|
||||
import { Server, Monitor, Loader2, ChevronDown, X } from "lucide-react";
|
||||
|
||||
const SAVED_URLS_KEY = "termix_saved_server_urls";
|
||||
const MAX_SAVED_URLS = 5;
|
||||
|
||||
function getSavedUrls(): string[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(SAVED_URLS_KEY);
|
||||
if (!raw) return [];
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function addSavedUrl(url: string) {
|
||||
const urls = getSavedUrls().filter((u) => u !== url);
|
||||
urls.unshift(url);
|
||||
localStorage.setItem(
|
||||
SAVED_URLS_KEY,
|
||||
JSON.stringify(urls.slice(0, MAX_SAVED_URLS)),
|
||||
);
|
||||
}
|
||||
|
||||
function removeSavedUrl(url: string) {
|
||||
const urls = getSavedUrls().filter((u) => u !== url);
|
||||
localStorage.setItem(SAVED_URLS_KEY, JSON.stringify(urls));
|
||||
}
|
||||
|
||||
interface ServerConfigProps {
|
||||
onServerConfigured: (serverUrl: string) => void;
|
||||
onUseEmbedded?: () => void;
|
||||
onCancel?: () => void;
|
||||
isFirstTime?: boolean;
|
||||
}
|
||||
|
||||
export function ElectronServerConfig({
|
||||
onServerConfigured,
|
||||
onUseEmbedded,
|
||||
onCancel,
|
||||
isFirstTime = false,
|
||||
}: ServerConfigProps) {
|
||||
const { t } = useTranslation();
|
||||
const [serverUrl, setServerUrl] = useState("");
|
||||
const [allowInvalidCertificate, setAllowInvalidCertificate] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [embeddedLoading, setEmbeddedLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [embeddedAvailable, setEmbeddedAvailable] = useState<boolean | null>(
|
||||
null,
|
||||
);
|
||||
const [savedUrls, setSavedUrls] = useState<string[]>([]);
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadServerConfig();
|
||||
checkEmbeddedBackend();
|
||||
setSavedUrls(getSavedUrls());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setDropdownOpen(false);
|
||||
}
|
||||
}
|
||||
if (dropdownOpen) {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
}
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [dropdownOpen]);
|
||||
|
||||
const loadServerConfig = async () => {
|
||||
try {
|
||||
const config = await getServerConfig();
|
||||
if (config?.serverUrl) {
|
||||
setServerUrl(config.serverUrl);
|
||||
}
|
||||
setAllowInvalidCertificate(!!config?.allowInvalidCertificate);
|
||||
} catch (error) {
|
||||
console.error("Server config operation failed:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const checkEmbeddedBackend = async () => {
|
||||
try {
|
||||
const status = await getEmbeddedServerStatus();
|
||||
setEmbeddedAvailable(!!status?.embedded);
|
||||
} catch {
|
||||
setEmbeddedAvailable(true);
|
||||
}
|
||||
};
|
||||
|
||||
const probeBackend = async (): Promise<boolean> => {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 3000);
|
||||
try {
|
||||
const res = await fetch("http://localhost:30001/health", {
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timer);
|
||||
return res.ok;
|
||||
} catch {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
const controller2 = new AbortController();
|
||||
const timer2 = setTimeout(() => controller2.abort(), 3000);
|
||||
try {
|
||||
await fetch("http://localhost:30001/version", {
|
||||
signal: controller2.signal,
|
||||
});
|
||||
clearTimeout(timer2);
|
||||
return true;
|
||||
} catch {
|
||||
clearTimeout(timer2);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleUseEmbedded = async () => {
|
||||
setEmbeddedLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
const maxRetries = 15;
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
if (await probeBackend()) {
|
||||
setEmbeddedMode(true);
|
||||
if (onUseEmbedded) {
|
||||
onUseEmbedded();
|
||||
} else {
|
||||
onServerConfigured("http://localhost:30001");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (i < maxRetries - 1) {
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
}
|
||||
}
|
||||
setError(t("serverConfig.embeddedNotReady"));
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : t("serverConfig.embeddedNotReady"),
|
||||
);
|
||||
} finally {
|
||||
setEmbeddedLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveConfig = async () => {
|
||||
if (!serverUrl.trim()) {
|
||||
setError(t("serverConfig.enterServerUrl"));
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const normalizedUrl = serverUrl.trim();
|
||||
|
||||
if (
|
||||
!normalizedUrl.startsWith("http://") &&
|
||||
!normalizedUrl.startsWith("https://")
|
||||
) {
|
||||
setError(t("serverConfig.mustIncludeProtocol"));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const config: ServerConfig = {
|
||||
serverUrl: normalizedUrl,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
allowInvalidCertificate:
|
||||
normalizedUrl.startsWith("https://") && allowInvalidCertificate,
|
||||
};
|
||||
|
||||
const success = await saveServerConfig(config);
|
||||
|
||||
if (success) {
|
||||
addSavedUrl(normalizedUrl);
|
||||
setSavedUrls(getSavedUrls());
|
||||
onServerConfigured(normalizedUrl);
|
||||
} else {
|
||||
setError(t("serverConfig.saveFailed"));
|
||||
}
|
||||
} catch {
|
||||
setError(t("serverConfig.saveError"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUrlChange = (value: string) => {
|
||||
setServerUrl(value);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-background p-6">
|
||||
<div className="flex flex-col gap-5 p-6 border border-border bg-card max-w-md w-full">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Server className="size-4 text-accent-brand shrink-0" />
|
||||
<p className="font-bold">{t("serverConfig.title")}</p>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("serverConfig.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{embeddedAvailable !== false && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={handleUseEmbedded}
|
||||
disabled={embeddedLoading || loading}
|
||||
>
|
||||
{embeddedLoading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
{t("serverConfig.embeddedConnecting")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-2">
|
||||
<Monitor className="size-4" />
|
||||
{t("serverConfig.useEmbedded")}
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-bold bg-yellow-500/20 text-yellow-600 dark:text-yellow-400 border border-yellow-500/30">
|
||||
BETA
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground -mt-3">
|
||||
{t("serverConfig.embeddedDesc")}
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("common.or") || "OR"}
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="server-url">{t("serverConfig.serverUrl")}</Label>
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<Input
|
||||
id="server-url"
|
||||
type="text"
|
||||
placeholder="https://your-server.com"
|
||||
value={serverUrl}
|
||||
onChange={(e) => handleUrlChange(e.target.value)}
|
||||
disabled={loading || embeddedLoading}
|
||||
className={savedUrls.length > 0 ? "pr-9" : ""}
|
||||
onFocus={() => {
|
||||
if (savedUrls.length > 0) setDropdownOpen(true);
|
||||
}}
|
||||
/>
|
||||
{savedUrls.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
onClick={() => setDropdownOpen((o) => !o)}
|
||||
disabled={loading || embeddedLoading}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label={t("serverConfig.savedServers")}
|
||||
>
|
||||
<ChevronDown className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
{dropdownOpen && savedUrls.length > 0 && (
|
||||
<div className="absolute z-50 w-full top-full mt-1 border border-border bg-card shadow-md">
|
||||
<p className="px-2.5 py-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground border-b border-border">
|
||||
{t("serverConfig.savedServers")}
|
||||
</p>
|
||||
{savedUrls.map((url) => (
|
||||
<div
|
||||
key={url}
|
||||
className="flex items-center justify-between group hover:bg-muted transition-colors"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 text-left px-2.5 py-2 text-sm font-mono truncate"
|
||||
onClick={() => {
|
||||
handleUrlChange(url);
|
||||
setDropdownOpen(false);
|
||||
}}
|
||||
>
|
||||
{url}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="px-2 py-2 text-muted-foreground hover:text-destructive transition-colors shrink-0"
|
||||
title={t("serverConfig.removeServer")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeSavedUrl(url);
|
||||
const updated = getSavedUrls();
|
||||
setSavedUrls(updated);
|
||||
if (updated.length === 0) setDropdownOpen(false);
|
||||
}}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{serverUrl.trim().startsWith("https://") && (
|
||||
<div className="flex items-start justify-between gap-3 border border-border bg-muted/20 p-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label htmlFor="allow-invalid-certificate">
|
||||
{t("serverConfig.allowInvalidCertificate")}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("serverConfig.allowInvalidCertificateDesc")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="allow-invalid-certificate"
|
||||
checked={allowInvalidCertificate}
|
||||
onCheckedChange={setAllowInvalidCertificate}
|
||||
disabled={loading || embeddedLoading}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t("common.error")}</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
{onCancel && !isFirstTime && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={onCancel}
|
||||
disabled={loading || embeddedLoading}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
className={`bg-accent-brand hover:bg-accent-brand/90 text-background font-bold ${onCancel && !isFirstTime ? "flex-1" : "w-full"}`}
|
||||
onClick={handleSaveConfig}
|
||||
disabled={loading || embeddedLoading || !serverUrl.trim()}
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 border-2 border-background border-t-transparent rounded-full animate-spin" />
|
||||
{t("serverConfig.saving")}
|
||||
</span>
|
||||
) : (
|
||||
t("serverConfig.saveConfig")
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
{t("serverConfig.helpText")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,37 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { Auth } from "@/auth/LoginPage.tsx";
|
||||
|
||||
interface LoginScreenProps {
|
||||
authLoading: boolean;
|
||||
onAuthSuccess: (authData: {
|
||||
isAdmin: boolean;
|
||||
username: string | null;
|
||||
userId: string | null;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export function LoginScreen({
|
||||
authLoading,
|
||||
onAuthSuccess,
|
||||
}: LoginScreenProps): React.ReactElement {
|
||||
const [loggedIn, setLoggedIn] = useState(false);
|
||||
const [, setIsAdmin] = useState(false);
|
||||
const [, setUsername] = useState<string | null>(null);
|
||||
const [, setUserId] = useState<string | null>(null);
|
||||
const [, setDbError] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Auth
|
||||
setLoggedIn={setLoggedIn}
|
||||
setIsAdmin={setIsAdmin}
|
||||
setUsername={setUsername}
|
||||
setUserId={setUserId}
|
||||
loggedIn={loggedIn}
|
||||
authLoading={authLoading}
|
||||
setDbError={setDbError}
|
||||
onAuthSuccess={onAuthSuccess}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Server } from "lucide-react";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/dialog.tsx";
|
||||
|
||||
type DesktopSettings = {
|
||||
defaultConnectionOrigin: "local" | "remote";
|
||||
migrationNoticeAcknowledged?: boolean;
|
||||
};
|
||||
|
||||
// One-time notice for Electron installs that previously pointed the whole
|
||||
// app at a remote Termix server (the pre-2.6.0 architecture). That install
|
||||
// now runs a fully local, standalone backend by default -- the hosts,
|
||||
// credentials, and snippets that lived on the old remote server won't show
|
||||
// up here until the user explicitly turns on Remote Sync and reconnects to
|
||||
// that same server. A fresh install never had a legacy serverUrl, so this
|
||||
// never fires for anyone who didn't go through the old flow, and it only
|
||||
// ever shows once per install (tracked in desktop-settings.json).
|
||||
export function MigrationNoticeDialog({
|
||||
onOpenRemoteSync,
|
||||
}: {
|
||||
onOpenRemoteSync: (serverUrl: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [legacyServerUrl, setLegacyServerUrl] = useState<string | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isElectron()) return;
|
||||
let cancelled = false;
|
||||
|
||||
Promise.all([
|
||||
window.electronAPI?.invoke?.("get-legacy-server-config") as Promise<{
|
||||
serverUrl: string | null;
|
||||
} | null>,
|
||||
window.electronAPI?.invoke?.("get-desktop-settings") as Promise<
|
||||
DesktopSettings | undefined
|
||||
>,
|
||||
])
|
||||
.then(([legacyConfig, settings]) => {
|
||||
if (cancelled) return;
|
||||
const serverUrl = legacyConfig?.serverUrl || null;
|
||||
if (serverUrl && !settings?.migrationNoticeAcknowledged) {
|
||||
setLegacyServerUrl(serverUrl);
|
||||
setOpen(true);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const acknowledge = async () => {
|
||||
setOpen(false);
|
||||
const settings = ((await window.electronAPI?.invoke?.(
|
||||
"get-desktop-settings",
|
||||
)) as DesktopSettings | undefined) ?? { defaultConnectionOrigin: "local" };
|
||||
await window.electronAPI?.invoke?.("save-desktop-settings", {
|
||||
...settings,
|
||||
migrationNoticeAcknowledged: true,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSetUpSync = async () => {
|
||||
const url = legacyServerUrl || "";
|
||||
await acknowledge();
|
||||
onOpenRemoteSync(url);
|
||||
};
|
||||
|
||||
if (!legacyServerUrl) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => !next && acknowledge()}>
|
||||
<DialogContent className="bg-card border border-border max-w-lg">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="size-4 text-accent-brand" />
|
||||
<DialogTitle>{t("migrationNotice.title")}</DialogTitle>
|
||||
</div>
|
||||
<DialogDescription className="text-sm text-foreground/90 pt-2 space-y-2">
|
||||
<p>{t("migrationNotice.body1")}</p>
|
||||
<p>{t("migrationNotice.body2", { url: legacyServerUrl })}</p>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="flex items-center gap-2 sm:justify-between">
|
||||
<Button variant="outline" size="sm" onClick={acknowledge}>
|
||||
{t("migrationNotice.dismiss")}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSetUpSync}>
|
||||
{t("migrationNotice.setUpSync")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
|
||||
interface RemoteSyncStatus {
|
||||
connected: boolean;
|
||||
syncing: boolean;
|
||||
lastSyncedAt: string | null;
|
||||
lastError: string | null;
|
||||
needsReauth: boolean;
|
||||
}
|
||||
|
||||
// Non-blocking banner shown when a connected remote sync server needs
|
||||
// re-authentication. Never gates or hides any other UI -- the local app
|
||||
// keeps working fully regardless of remote sync state.
|
||||
export function RemoteSyncBanner({ onReconnect }: { onReconnect: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [status, setStatus] = useState<RemoteSyncStatus | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isElectron()) return;
|
||||
window.electronAPI
|
||||
?.invoke?.("get-remote-sync-status")
|
||||
.then((s) => setStatus((s as RemoteSyncStatus) ?? null))
|
||||
.catch(() => {});
|
||||
const unsubscribe = window.electronAPI?.onRemoteSyncStatusChanged?.(
|
||||
(nextStatus: RemoteSyncStatus) => setStatus(nextStatus),
|
||||
);
|
||||
return () => unsubscribe?.();
|
||||
}, []);
|
||||
|
||||
if (!status?.connected || !status.needsReauth) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 px-4 py-2 bg-yellow-500/10 border-b border-yellow-500/30 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="size-3.5 text-yellow-600 dark:text-yellow-400 shrink-0" />
|
||||
<span>{t("remoteSync.bannerMessage")}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReconnect}
|
||||
className="font-bold text-accent-brand hover:text-accent-brand/70 transition-colors shrink-0"
|
||||
>
|
||||
{t("remoteSync.bannerReconnect")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user