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:
Luke Gustafson
2026-07-26 18:47:27 -05:00
committed by GitHub
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
232 changed files with 50585 additions and 32016 deletions
+4
View File
@@ -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"),
+298 -7
View File
@@ -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",
});
+102
View File
@@ -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> {
+157 -2
View File
@@ -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];
}
+128 -13
View File
@@ -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)
);
}
+57 -13
View File
@@ -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",
+37 -2
View File
@@ -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 {
+220
View File
@@ -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}:
+20 -1
View File
@@ -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,
+415
View File
@@ -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:
+86 -3
View File
@@ -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:
+9 -1
View File
@@ -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);