Compare commits

...
Author SHA1 Message Date
ZacharyZcR 9706426421 fix: allow exec on shared hosts (#1362) 2026-08-29 00:30:49 +08:00
ZacharyZcR 0f949c5e24 fix: support Vault auth in file manager (#1361) 2026-08-29 00:24:08 +08:00
ZacharyZcR 6f387f1058 fix: reflect live SSH sessions in host status (#1359) 2026-08-28 10:45:36 +08:00
ZacharyZcR 5582087025 test: update Guacamole toolbar display mock (#1360) 2026-08-28 10:41:59 +08:00
ZacharyZcR 363e61961c fix: reset host virtualizer after editing (#1358) 2026-08-28 10:37:12 +08:00
ZacharyZcR 9a8f0ba888 fix: unify connection toolbar visibility (#1357) 2026-08-28 10:37:05 +08:00
ZacharyZcR 0f39ce6369 fix: upload files to redirected RDP drives (#1356) 2026-08-28 10:36:54 +08:00
ZacharyZcR bf67f56c51 fix: recover expired dashboard metrics sessions (#1355) 2026-08-28 10:36:47 +08:00
ZacharyZcR c129666d7f fix: harden connection, payload, and persisted state handling (#1354)
* fix: clean up Cloudflare tunnel timeouts

* fix: couple tunnel socket lifecycle

* fix: validate Docker console messages

* fix: bound homepage proxy responses

* fix: bound reconnect and response failures

* fix: harden persisted and socket state

* fix: support local connections to shared hosts
2026-08-28 10:36:39 +08:00
ZacharyZcR 703e8cd037 fix: enforce SSH pool connection limits (#1353)
* fix: enforce SSH pool connection limits

* fix: discard stale pooled connections
2026-08-28 10:36:31 +08:00
ZacharyZcR 14d4128266 fix: harden file reads and timer cleanup (#1352)
* fix: harden file reads and timer cleanup

* fix: preserve literal file path escapes
2026-08-28 10:36:08 +08:00
ZacharyZcR 50f1882fa9 fix: surface remote sync reauthentication failures (#1351) 2026-08-27 17:06:16 +08:00
ZacharyZcR 6277d15c2a fix: preserve omitted host protocol settings (#1350) 2026-08-27 13:41:25 +08:00
Wali Lambert ef4e66659c fix: stop SSH-authenticating hosts during routine status polling (#1347) 2026-08-27 06:40:24 +08:00
ZacharyZcR 19d4d91eee fix: retry protected file reads with sudo (#1349) 2026-08-27 06:39:16 +08:00
ZacharyZcR 6323459af2 feat: add terminal copy-on-select option (#1346) 2026-08-27 06:39:05 +08:00
ZacharyZcR 6406c3a923 fix: prompt shared RDP users for credentials (#1345) 2026-08-27 06:38:56 +08:00
ZacharyZcR f848dee343 fix: package sharp for both macOS architectures (#1344) 2026-08-27 06:38:33 +08:00
ZacharyZcR 302ac19e6c feat: folder shares apply to hosts added later (#1343)
* feat: folder shares apply to hosts added later

Sharing a folder only fanned grants out to the hosts in it at the time.
The share is now also kept as a standing rule on the folder, and a host
created in or moved into it (or a subfolder) inherits the same access
and secret snapshots. Rules follow folder renames and can be stopped
from the share dialog.

* fix: stabilize folder access migrations
2026-08-25 04:50:51 +08:00
ZacharyZcR 82143946c7 feat: share credentials with users and roles, inherit data on account deletion (#1342)
* feat: share credentials with users and roles, inherit data on account deletion

Credentials can be shared at "use" or "manage" level. Recipients get
a copy re-encrypted under their own data key (shared_credential_secrets),
kept in step with the owner's row through the same lifecycle hooks as
shared host secrets. One gate, findUsableCredential(), replaces the
private-namespace lookups so a shared credential works wherever a
private one does. Deleting a user now hands their hosts and credentials
to a successor (the deleting admin by default) instead of revoking
everything they shared.

* fix: harden credential ownership transfer
2026-08-25 04:12:47 +08:00
ZacharyZcR 5f55289e00 feat: 1Password Connect secret sources for SSH credentials (#1341)
* feat: 1Password Connect secret sources for SSH credentials

Hosts and credentials can hold op://vault/item/field references instead
of secrets; they are resolved at connect time from the user's secret
source (1Password Connect) at the single point where every subsystem
receives plaintext credentials, so terminal, SFTP, Docker, metrics and
tunnels all work without per-subsystem changes. Sources are per user,
optionally shared, with the access token encrypted under the owner's
data key; resolved values are cached briefly in memory.

* style: format secret source changes
2026-08-25 03:06:37 +08:00
ZacharyZcR 32d77fc6d0 feat: Step CA SSH certificates as a host authentication type (#1340)
* feat: Step CA SSH certificates as a host authentication type

Issue short-lived SSH user certificates from a smallstep CA through its
OIDC provisioner, over the CA's HTTP API rather than the step binary.
Everything after issuance reuses the OPKSSH plumbing: the same encrypted
per-user/host token store, WebSocket dialog and ssh2 certificate
injection, with the connect paths branching on a shared
usesIssuedCertificate() predicate. Instance-wide CA settings live in the
admin panel, with a private-host allowlist for the SSRF guard.

* fix: harden Step CA callback flow

* style: format Step CA changes
2026-08-25 02:56:44 +08:00
ZacharyZcR 0ab7cf2ab8 feat: improve collaboration rooms (#1338) 2026-08-25 02:15:44 +08:00
ZacharyZcR 8d0bcb3b1f fix: guide users to Auto-Tmux when a persisted session expires (#1336)
* fix: guide users to Auto-Tmux when a persisted session expires

A timed-out terminal session silently reconnected to a fresh shell, so
people running long jobs lost them with no explanation and never learned
about Auto-Tmux. Explain the expiry with a one-click Enable Auto-Tmux
action, let admins default it for new hosts and tune the persistence
timeout from the UI, and move the setting up with copy that says what it
does. The global default stays off.

* style: format terminal expiry notice
2026-08-25 02:15:23 +08:00
ZacharyZcR ae9cce4de3 feat: compact snippet list option (#1339)
A "Show Commands" toggle in the snippets settings menu hides the command
text under each snippet name, for people who dock the panel on the
narrow right rail and only need the names. Local preference, on by
default.
2026-08-25 02:15:09 +08:00
ZacharyZcR c17134a2a4 fix: authenticate unwatched hosts during the status probe (#1337)
With metrics enabled, the status probe left SSH authentication to the
metrics poll - which only runs while someone is viewing the host. An
unwatched host therefore never left "reachable", while a host with
metrics disabled (whose probe always authenticates) showed online. The
probe now authenticates whenever no metrics poll will.
2026-08-25 02:14:51 +08:00
ZacharyZcR 404608867f feat: quick connect for RDP and VNC (#1335)
The Quick Connect panel gets a protocol switch. RDP/VNC quick hosts are
built like SSH ones (never saved) and opened as regular remote desktop
tabs; GuacamoleApp mints their token from the typed fields through the
existing /guacamole/token endpoint instead of a host-row lookup.
2026-08-25 01:40:42 +08:00
ZacharyZcR 672f5ba80b fix: make RDP drive redirection writable on the stock deployment (#1333)
* fix: make RDP drive redirection writable on the stock deployment

The default drive-path was /drive on the guacd side, which the official
guacd image cannot create as its non-root user, so every upload was
refused with guacd's raw "FAIL (CANNOT OPEN)" ack. Default to
GUACD_DRIVE_PATH (set to the shared termix-data volume in compose) with
one folder per user, and explain guacd's refusal in the file browser.

* style: format RDP drive settings
2026-08-25 01:40:20 +08:00
ZacharyZcR 8260af2d57 fix: harden collaboration room access (#1332)
* fix: harden collaboration room access

* fix: confirm guest link lifecycle changes
2026-08-25 01:36:19 +08:00
ZacharyZcR c51c3a9449 fix: show the full command line in the process inspector (#1334)
The CMD column rendered ps's comm field, which the kernel caps at 15
characters, so anything longer looked truncated no matter how wide the
column was. The full args were already collected; show them.
2026-08-25 01:36:02 +08:00
ZacharyZcR dc47c4ca86 fix: restore RDP clipboard paste across browsers (#1331) 2026-08-25 00:56:26 +08:00
ZacharyZcR 81d79cc89b feat: collaboration rooms with switchable presenter (#1328)
* feat: add collaboration rooms with switchable presenter

Rooms are a group of members watching one stage - the live SSH/RDP/VNC
session the current presenter shares. Any member can take over the
stage; the host can invite, force-stop and end the meeting. Stages
reuse session_shares (new room share type), so gating, recording,
expiry and the global sharing toggle all apply unchanged.

* feat: add stage control handoff to collaboration rooms

The presenter or host can grant any member write access to the live
stage and take it back; members can raise a hand to ask. SSH flips the
participant's permission on the live gate; RDP/VNC re-mint the viewer's
join token. Control clears on every stage switch.

* feat: guest links, role invites and invite awareness for collab rooms

- Anonymous guest link per room (host toggles/rotates), followed by
  polling the public resolve endpoint; SSH guests join over the terminal
  WS with roomGuestToken, guac guests get read-only join tokens
- Invite by role (expands to current members, snapshot semantics)
- Toast when a room you were invited to appears
- Stale stages are cleared lazily when the presenter is gone
- Telnet presenting, expired-tab fallback, documented single-instance
  and guac-kick limits
- Tests for the collab routes, room hub, share access and control flip

* fix: keep remote desktop collaboration read-only
2026-08-25 00:56:04 +08:00
ZacharyZcR d35458f78b fix: allow approved private notification hosts (#1330) 2026-08-25 00:55:36 +08:00
ZacharyZcR f06d540466 fix: reject malformed Guacamole tokens safely (#1329) 2026-08-25 00:55:14 +08:00
ZacharyZcR f3a1087f51 feat: enforce RBAC and harden collaboration features (#1327)
* feat: enforce RBAC and harden collaboration features

- Mount requirePermission on hosts/snippets/credentials/automations/AI routes
- Seed and backfill system role permissions on every dialect at startup
- Support personal credential overrides for RDP/VNC/Telnet shared hosts
- Broadcast participant presence in shared terminal sessions
- Make audit log forwarding configurable from the admin panel
- Add role members endpoint and snippet folder sharing

* fix: enforce RBAC across split routes
2026-08-24 19:47:29 +08:00
ZacharyZcR 69002e6416 feat: add semantic motion system (#1320)
* feat: add semantic motion system

* feat: animate session workspace transitions

* feat: refine motion accessibility and transfer feedback
2026-08-24 19:36:10 +08:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> fafd428072 chore(deps): bump motion in the major-updates group (#1326)
Bumps the major-updates group with 1 update: [motion](https://github.com/motiondivision/motion).


Updates `motion` from 12.43.0 to 13.1.1
- [Changelog](https://github.com/motiondivision/motion/blob/main/CHANGELOG.md)
- [Commits](https://github.com/motiondivision/motion/compare/v12.43.0...v13.1.1)

---
updated-dependencies:
- dependency-name: motion
  dependency-version: 13.1.1
  dependency-type: direct:production
  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>
2026-08-24 01:18:48 -05:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> f3e09d4cbd chore(deps): bump the prod-patch-updates group with 5 updates (#1324)
Bumps the prod-patch-updates group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [@tanstack/react-virtual](https://github.com/TanStack/virtual/tree/HEAD/packages/react-virtual) | `3.14.9` | `3.14.10` |
| [better-sqlite3](https://github.com/WiseLibs/better-sqlite3) | `13.0.2` | `13.0.3` |
| [jose](https://github.com/panva/jose) | `6.2.8` | `6.2.9` |
| [mysql2](https://github.com/sidorares/node-mysql2) | `3.23.2` | `3.23.4` |
| [ws](https://github.com/websockets/ws) | `8.21.1` | `8.21.3` |


Updates `@tanstack/react-virtual` from 3.14.9 to 3.14.10
- [Release notes](https://github.com/TanStack/virtual/releases)
- [Changelog](https://github.com/TanStack/virtual/blob/main/packages/react-virtual/CHANGELOG.md)
- [Commits](https://github.com/TanStack/virtual/commits/@tanstack/react-virtual@3.14.10/packages/react-virtual)

Updates `better-sqlite3` from 13.0.2 to 13.0.3
- [Release notes](https://github.com/WiseLibs/better-sqlite3/releases)
- [Commits](https://github.com/WiseLibs/better-sqlite3/compare/v13.0.2...v13.0.3)

Updates `jose` from 6.2.8 to 6.2.9
- [Release notes](https://github.com/panva/jose/releases)
- [Changelog](https://github.com/panva/jose/blob/main/CHANGELOG.md)
- [Commits](https://github.com/panva/jose/compare/v6.2.8...v6.2.9)

Updates `mysql2` from 3.23.2 to 3.23.4
- [Release notes](https://github.com/sidorares/node-mysql2/releases)
- [Changelog](https://github.com/sidorares/node-mysql2/blob/master/Changelog.md)
- [Commits](https://github.com/sidorares/node-mysql2/compare/v3.23.2...v3.23.4)

Updates `ws` from 8.21.1 to 8.21.3
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/8.21.1...8.21.3)

---
updated-dependencies:
- dependency-name: "@tanstack/react-virtual"
  dependency-version: 3.14.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: better-sqlite3
  dependency-version: 13.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: jose
  dependency-version: 6.2.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: mysql2
  dependency-version: 3.23.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: ws
  dependency-version: 8.21.3
  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>
2026-08-24 01:17:43 -05:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> cc68fe580f chore(deps-dev): bump the dev-patch-updates group with 15 updates (#1322)
Bumps the dev-patch-updates group with 15 updates:

| Package | From | To |
| --- | --- | --- |
| [@codemirror/view](https://github.com/codemirror/view) | `6.43.7` | `6.43.9` |
| [@commitlint/cli](https://github.com/conventional-changelog/commitlint/tree/HEAD/@commitlint/cli) | `21.2.1` | `21.2.2` |
| [@commitlint/config-conventional](https://github.com/conventional-changelog/commitlint/tree/HEAD/@commitlint/config-conventional) | `21.2.0` | `21.2.2` |
| [@testing-library/jest-dom](https://github.com/testing-library/jest-dom) | `7.0.0` | `7.0.1` |
| [@testing-library/user-event](https://github.com/testing-library/user-event) | `14.6.1` | `14.6.5` |
| [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) | `4.1.10` | `4.1.11` |
| [@vitest/ui](https://github.com/vitest-dev/vitest/tree/HEAD/packages/ui) | `4.1.10` | `4.1.11` |
| [concurrently](https://github.com/open-cli-tools/concurrently) | `10.0.4` | `10.0.5` |
| [cytoscape](https://github.com/cytoscape/cytoscape.js) | `3.34.0` | `3.34.1` |
| [eslint](https://github.com/eslint/eslint) | `10.8.0` | `10.8.1` |
| [eslint-plugin-react-refresh](https://github.com/ArnaudBarre/eslint-plugin-react-refresh) | `0.5.3` | `0.5.4` |
| [react-i18next](https://github.com/i18next/react-i18next) | `17.0.11` | `17.0.12` |
| [sonner](https://github.com/emilkowalski/sonner) | `2.0.7` | `2.0.8` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.2.0` | `8.2.2` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.10` | `4.1.11` |


Updates `@codemirror/view` from 6.43.7 to 6.43.9
- [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/view/commits)

Updates `@commitlint/cli` from 21.2.1 to 21.2.2
- [Release notes](https://github.com/conventional-changelog/commitlint/releases)
- [Changelog](https://github.com/conventional-changelog/commitlint/blob/master/@commitlint/cli/CHANGELOG.md)
- [Commits](https://github.com/conventional-changelog/commitlint/commits/v21.2.2/@commitlint/cli)

Updates `@commitlint/config-conventional` from 21.2.0 to 21.2.2
- [Release notes](https://github.com/conventional-changelog/commitlint/releases)
- [Changelog](https://github.com/conventional-changelog/commitlint/blob/master/@commitlint/config-conventional/CHANGELOG.md)
- [Commits](https://github.com/conventional-changelog/commitlint/commits/v21.2.2/@commitlint/config-conventional)

Updates `@testing-library/jest-dom` from 7.0.0 to 7.0.1
- [Release notes](https://github.com/testing-library/jest-dom/releases)
- [Changelog](https://github.com/testing-library/jest-dom/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/jest-dom/compare/v7.0.0...v7.0.1)

Updates `@testing-library/user-event` from 14.6.1 to 14.6.5
- [Release notes](https://github.com/testing-library/user-event/releases)
- [Changelog](https://github.com/testing-library/user-event/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/user-event/compare/v14.6.1...v14.6.5)

Updates `@vitest/coverage-v8` from 4.1.10 to 4.1.11
- [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.11/packages/coverage-v8)

Updates `@vitest/ui` from 4.1.10 to 4.1.11
- [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.11/packages/ui)

Updates `concurrently` from 10.0.4 to 10.0.5
- [Release notes](https://github.com/open-cli-tools/concurrently/releases)
- [Commits](https://github.com/open-cli-tools/concurrently/compare/v10.0.4...v10.0.5)

Updates `cytoscape` from 3.34.0 to 3.34.1
- [Release notes](https://github.com/cytoscape/cytoscape.js/releases)
- [Commits](https://github.com/cytoscape/cytoscape.js/compare/v3.34.0...v3.34.1)

Updates `eslint` from 10.8.0 to 10.8.1
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.8.0...v10.8.1)

Updates `eslint-plugin-react-refresh` from 0.5.3 to 0.5.4
- [Release notes](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/releases)
- [Changelog](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/compare/v0.5.3...v0.5.4)

Updates `react-i18next` from 17.0.11 to 17.0.12
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/react-i18next/compare/v17.0.11...v17.0.12)

Updates `sonner` from 2.0.7 to 2.0.8
- [Release notes](https://github.com/emilkowalski/sonner/releases)
- [Commits](https://github.com/emilkowalski/sonner/compare/v2.0.7...v2.0.8)

Updates `vite` from 8.2.0 to 8.2.2
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.2.2/packages/vite)

Updates `vitest` from 4.1.10 to 4.1.11
- [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.11/packages/vitest)

---
updated-dependencies:
- dependency-name: "@codemirror/view"
  dependency-version: 6.43.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@commitlint/cli"
  dependency-version: 21.2.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@commitlint/config-conventional"
  dependency-version: 21.2.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@testing-library/jest-dom"
  dependency-version: 7.0.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@testing-library/user-event"
  dependency-version: 14.6.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@vitest/coverage-v8"
  dependency-version: 4.1.11
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@vitest/ui"
  dependency-version: 4.1.11
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: concurrently
  dependency-version: 10.0.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: cytoscape
  dependency-version: 3.34.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: eslint
  dependency-version: 10.8.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: eslint-plugin-react-refresh
  dependency-version: 0.5.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: react-i18next
  dependency-version: 17.0.12
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: sonner
  dependency-version: 2.0.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: vite
  dependency-version: 8.2.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: vitest
  dependency-version: 4.1.11
  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>
2026-08-24 01:16:39 -05:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 488e3f014b chore(deps): bump node in /docker in the docker-major-updates group (#1321)
Bumps the docker-major-updates group in /docker with 1 update: node.


Updates `node` from 24-slim to 26-slim

---
updated-dependencies:
- dependency-name: node
  dependency-version: 26-slim
  dependency-type: direct:production
  dependency-group: docker-major-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-24 01:16:00 -05:00
ZacharyZcR e5ea61e28a Fix remote desktop connection timeout (#1319) 2026-08-24 08:43:12 +08:00
ZacharyZcR f0cb81c3b5 fix: verify OPKSSH binary integrity (#1318) 2026-08-24 07:58:09 +08:00
ZacharyZcR 2de9bb236b fix: harden application trust boundaries (#1317) 2026-08-24 07:55:17 +08:00
ZacharyZcR 30d72554fc fix: harden HTTP trust boundaries (#1316) 2026-08-24 07:52:31 +08:00
Neo c4c9b51294 fix: force classic auth for macOS VNC (#1313) 2026-08-24 05:40:45 +08:00
ZacharyZcR b3cc66efdf feat: add host context menu actions (#1315) 2026-08-24 05:31:31 +08:00
ZacharyZcR c476ef6b3b feat: add VNC display zoom controls (#1314) 2026-08-24 05:31:14 +08:00
ZacharyZcR 20eca69d56 feat: support additional TOTP authenticators (#1312) 2026-08-24 05:30:55 +08:00
Angad SinghandAngad Singh 26757813c8 fix: use matching Undici fetch for private AI providers (#1309)
Co-authored-by: Angad Singh <angad@singhangad.in>
2026-08-24 04:26:28 +08:00
ZacharyZcR fa0fa7f836 fix: support macOS VNC connections (#1311) 2026-08-24 03:59:37 +08:00
ZacharyZcR ad266956cc fix: restore split layout selection (#1310) 2026-08-24 03:59:11 +08:00
ZacharyZcR cad7520c9f Add configurable global hotkeys (#1305) 2026-08-24 01:10:19 +08:00
ZacharyZcR 9ee50d624a Improve file manager navigation and compact layout (#1308) 2026-08-24 01:09:00 +08:00
ZacharyZcR 433ede0b0d Add selectable host temperature sensors (#1307) 2026-08-24 01:08:39 +08:00
ZacharyZcR 5e76aec2bf Add accessible interface font choices (#1306) 2026-08-24 01:08:03 +08:00
ZacharyZcR f1226b1f1b Fix command palette keyboard navigation (#1304) 2026-08-24 01:05:24 +08:00
ZacharyZcR 0a9086fb79 Fix Proxmox sync jump host persistence (#1303) 2026-08-23 22:57:37 +08:00
ZacharyZcR c42cd40a2c Fix synced client tunnel endpoints (#1302) 2026-08-23 22:55:45 +08:00
ZacharyZcR 780cfb58e2 Fix Fleet command results layout (#1301) 2026-08-23 22:55:41 +08:00
ZacharyZcR a4b61cc27f Fix Proxmox credential guest imports (#1300) 2026-08-23 22:45:24 +08:00
ZacharyZcR b5d13c3664 Fix private AI custom endpoints (#1299) 2026-08-23 22:38:03 +08:00
LukeGus fbf267fe5f chore: bump Homebrew cask to 2.7.1 2026-08-23 01:30:26 +00:00
76fd9eedbf release-2.7.1 (#1296)
* Add Helm and GitOps deployment setup

* fix: build better-sqlite3 from source in Docker (#1267)

* fix: preserve runtime SSL settings (#1268)

* fix: support forwarding from the memory SSH agent (#1269)

* fix: support forwarding from the memory agent

* style: format memory agent test

* fix: prompt for encrypted SFTP key passphrases (#1270)

* fix: prompt for SFTP key passphrases

* style: format SSH key utility test

* fix: include host context in automation notifications (#1271)

* fix: include host context in automation notifications

* style: format automation notification changes

* fix: reserve sidebar height for host tags (#1272)

* fix: keep host action rows stable at large font sizes (#1273)

* fix: honor certificate setting during server probe (#1274)

* fix: package standard Linux icon sizes (#1275)

* fix: avoid duplicate Docker HTTPS listener (#1276)

* Fix host status without metrics collection (#1277)

* fix: allow eight-digit secure auth codes (#1263)

Allow TOTP prompts to accept secure auth codes longer than six digits without blocking valid authentication attempts.

Generated with Codebuff 🤖

Co-authored-by: Chetan <chetan.development@gmail.com>
Co-authored-by: Codebuff <noreply@codebuff.com>

* Harden Helm deployment defaults

* Update Helm workflow action

* Exclude Helm templates from Prettier

* Fix browser RDP file drops (#1279)

* Fix Proxmox guest credential usernames (#1280)

* Add WSL local terminal option (#1281)

* refactor: split the transfer engine into focused modules (#1282)

* refactor: extract SFTP promisify helpers into sftp-promisify module

* refactor: extract transfer timing and rate stats into transfer-stats module

* refactor: extract transfer error classes and recovery checks into transfer-errors module

* refactor: extract host/path utility helpers into transfer-host-utils module

* refactor: extract SFTP directory tree helpers into transfer-sftp-dir module

* refactor: extract segment copy job builder into transfer-segment-copy module

* refactor: extract file scan and sample helpers into transfer-scan module

* refactor: move throttled progress helper into transfer-stats module

* style: format transfer modules

* perf: optimize tmux monitor aggregation (#1283)

* fix: reserve credential tag row height (#1284)

* feat: edit AI provider model settings (#1285)

* fix: clarify click-to-expand host setting (#1286)

* fix: allow portable imports on remote databases (#1287)

* fix: allow HTTPS to share the configured port (#1288)

* fix: resolve synced jump hosts on the server (#1289)

* fix: make terminal clipboard shortcuts layout independent (#1290)

* fix: use compatible fetch dispatcher for Tailscale (#1291)

* fix: add OIDC environment recovery override (#1292)

* fix: coalesce rapid mobile terminal input (#1293)

* fix: coalesce rapid mobile terminal input

* fix: support clean xterm patch installs

* fix: resolve synced remote desktop host IDs (#1295)

* feat: make the SFTP file manager path bar editable (#1294)

Co-authored-by: Maxime Bonillo <257463937+dropafterfree@users.noreply.github.com>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>

* feat: add passkey sign in to the login screen

* fix: remove rounded corners from the host list search bar

* fix: stop image storage settings text wrapping to one word per line

* fix: prevent malformed websocket messages from crashing the server

* chore: increment version

* fix: remove gaps between host rows in the sidebar list

Keep sub-pixel row measurements and stop wiping the size cache on hover.

* fix: Failed to connect through jump hosts (#1180)

https://github.com/Termix-SSH/Support/issues/1180

* feat: Progress bar for file downloads in the file manager (#1158)

https://github.com/Termix-SSH/Support/issues/1158

* feat: Allow setting Silent OIDC Login via ENV var (#1174)

https://github.com/Termix-SSH/Support/issues/1174

* feat: `IdentityFile` to limit the number of attempts by agents (#1165)

https://github.com/Termix-SSH/Support/issues/1165

* feat: Credentials clone (#1159)

https://github.com/Termix-SSH/Support/issues/1159

* chore: update release notes

* docs: move helm setup guide to the docs site

* fix: type errors in FilteredAgent agent identity handling

* fix: remove stale better-sqlite3 prebuilds so the source build is used

* fix: actually build better-sqlite3 from source so arm64 docker images work

* fix: credential edit pencil in host editor and add clone action to credential list

* fix: clear editingHost so the credential pencil actually opens the editor

* chore: run format and lint

* fix: folder drag and drop upload failing in the file manager

* chore: sync Crowdin translations for 2.7.1

---------

Co-authored-by: alex-ctms <alex-ctms@users.noreply.github.com>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
Co-authored-by: Chetan Kumar <74929596+ckloop@users.noreply.github.com>
Co-authored-by: Chetan <chetan.development@gmail.com>
Co-authored-by: Codebuff <noreply@codebuff.com>
Co-authored-by: ZacharyZcR <payasonorahc@protonmail.com>
Co-authored-by: dropafterfree <maxime.bonillo@gmail.com>
Co-authored-by: Maxime Bonillo <257463937+dropafterfree@users.noreply.github.com>
2026-08-22 19:47:40 -05:00
Luke Gustafson 566b908daf Update sha256 checksum for Termix cask 2026-08-19 17:43:15 -05:00
LukeGus d6e8ee4784 fix: stop the release from resetting the docs branch and losing hand-written pages 2026-08-19 15:54:06 -05:00
LukeGus 8af4cbdec4 fix: clean API docs before regen so the sidebar picks up new endpoints 2026-08-19 15:44:30 -05:00
LukeGus e17b21ff62 chore: bump Homebrew cask to 2.7.0 2026-08-19 20:28:29 +00:00
LukeGus a15372a224 fix: stop connection screens crashing outside the connection log provider 2026-08-19 15:09:17 -05:00
479 changed files with 162312 additions and 3775 deletions
+76
View File
@@ -0,0 +1,76 @@
name: Deploy Helm
on:
workflow_dispatch:
inputs:
namespace:
description: "Kubernetes namespace"
required: true
default: termix
release:
description: "Helm release name"
required: true
default: termix
values_file:
description: "Values file to use"
required: true
default: charts/termix/values-gitops-example.yaml
image_tag:
description: "Image tag to deploy"
required: false
default: ""
permissions:
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Set up Helm
uses: azure/setup-helm@v5
with:
version: v3.15.4
- name: Configure kubeconfig
env:
KUBE_CONFIG: ${{ secrets.KUBE_CONFIG }}
run: |
test -n "$KUBE_CONFIG"
echo "$KUBE_CONFIG" | base64 -d > "$RUNNER_TEMP/kubeconfig"
chmod 600 "$RUNNER_TEMP/kubeconfig"
- name: Lint chart
run: helm lint charts/termix
- name: Deploy
env:
KUBECONFIG: ${{ runner.temp }}/kubeconfig
IMAGE_TAG: ${{ inputs.image_tag }}
RELEASE_NAME: ${{ inputs.release }}
TARGET_NAMESPACE: ${{ inputs.namespace }}
VALUES_FILE: ${{ inputs.values_file }}
run: |
set -euo pipefail
[[ "$RELEASE_NAME" =~ ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ ]]
[[ "$TARGET_NAMESPACE" =~ ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ ]]
case "$VALUES_FILE" in
charts/termix/*.yaml) ;;
*) echo "values_file must be a YAML file under charts/termix" >&2; exit 1 ;;
esac
test -f "$VALUES_FILE"
ARGS=()
if [ -n "$IMAGE_TAG" ]; then
ARGS+=(--set "image.tag=$IMAGE_TAG")
fi
helm upgrade --install "$RELEASE_NAME" charts/termix \
--namespace "$TARGET_NAMESPACE" \
--create-namespace \
--values "$VALUES_FILE" \
--atomic \
--timeout 10m \
"${ARGS[@]}"
+17
View File
@@ -427,6 +427,7 @@ jobs:
npm ci
npm install --force @rollup/rollup-darwin-arm64
npm install dmg-license
node scripts/install-macos-sharp.cjs
- name: Check for Code Signing Certificates
id: check_certs
@@ -538,6 +539,14 @@ jobs:
fi
npx electron-builder --mac dmg --universal --x64 --arm64 --publish never
- name: Verify macOS sharp packaging
run: |
artifacts=(release/termix_macos_*_dmg.dmg)
if [ -f release/termix_macos_universal_mas.pkg ]; then
artifacts+=(release/termix_macos_universal_mas.pkg)
fi
node scripts/verify-macos-sharp.cjs "${artifacts[@]}"
- name: Upload macOS MAS PKG
if: steps.check_certs.outputs.has_certs == 'true' && hashFiles('release/termix_macos_universal_mas.pkg') != '' && (inputs.artifact_destination == 'file' || inputs.artifact_destination == 'release' || inputs.artifact_destination == 'submit')
uses: actions/upload-artifact@v7
@@ -923,6 +932,9 @@ jobs:
echo "dmg_name=$DMG_NAME" >> $GITHUB_OUTPUT
echo "checksum=$CHECKSUM" >> $GITHUB_OUTPUT
- name: Verify macOS sharp packaging
run: node scripts/verify-macos-sharp.cjs release_asset/termix_macos_universal_dmg.dmg
- name: Prepare Homebrew submission files
run: |
VERSION="${{ steps.package-version.outputs.version }}"
@@ -1013,6 +1025,7 @@ jobs:
npm ci
npm install --force @rollup/rollup-darwin-arm64
npm install dmg-license
node scripts/install-macos-sharp.cjs
- name: Check for Code Signing Certificates
id: check_certs
@@ -1154,6 +1167,10 @@ jobs:
BUILD_VERSION="${{ steps.build_number.outputs.build_version || github.run_number }}"
npm run build && npx electron-builder --mac mas --universal --config.buildVersion="$BUILD_VERSION"
- name: Verify macOS sharp packaging
if: steps.check_certs.outputs.has_certs == 'true'
run: node scripts/verify-macos-sharp.cjs release/termix_macos_universal_mas.pkg
- name: Generate App Store release notes
id: asc_notes
if: steps.check_certs.outputs.has_certs == 'true' && steps.check_asc_creds.outputs.has_credentials == 'true'
+75
View File
@@ -0,0 +1,75 @@
name: Helm
on:
pull_request:
paths:
- "charts/**"
- ".github/workflows/helm.yml"
- "deploy/**"
push:
branches: [main]
paths:
- "charts/**"
- ".github/workflows/helm.yml"
- "deploy/**"
workflow_dispatch:
inputs:
publish:
description: "Publish chart to GHCR OCI registry"
required: true
type: boolean
default: false
permissions:
contents: read
jobs:
lint:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Set up Helm
uses: azure/setup-helm@v5
with:
version: v3.15.4
- name: Lint chart
run: helm lint charts/termix
- name: Render default chart
run: helm template termix charts/termix --namespace termix
- name: Render GitOps example
run: helm template termix charts/termix --values charts/termix/values-gitops-example.yaml --namespace termix
- name: Render Traefik example
run: helm template termix charts/termix --values charts/termix/values-traefik.yaml --namespace termix
publish:
needs: lint
if: github.event_name == 'workflow_dispatch' && inputs.publish
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Set up Helm
uses: azure/setup-helm@v5
with:
version: v3.15.4
- name: Log in to GHCR
run: echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io --username "${{ github.actor }}" --password-stdin
- name: Package chart
run: helm package charts/termix --destination .helm-packages
- name: Push chart
run: |
OWNER="$(echo "$GITHUB_REPOSITORY_OWNER" | tr '[:upper:]' '[:lower:]')"
helm push .helm-packages/termix-*.tgz "oci://ghcr.io/$OWNER/charts"
+11 -3
View File
@@ -443,13 +443,21 @@ jobs:
- name: Create docs release branch
working-directory: docs-repo
run: git checkout -B "dev-${{ needs.prep.outputs.version }}"
run: |
BRANCH="dev-${{ needs.prep.outputs.version }}"
if git ls-remote --exit-code origin "refs/heads/$BRANCH" >/dev/null 2>&1; then
echo "Reusing existing docs branch $BRANCH."
git checkout -B "$BRANCH" "origin/$BRANCH"
else
git checkout -B "$BRANCH"
fi
- name: Overwrite OpenAPI spec and regenerate API docs
working-directory: docs-repo
run: |
cp ../termix/openapi.json static/openapi.json
npm ci
npm run docusaurus clean-api-docs termix
npm run docusaurus gen-api-docs termix
- name: Commit and push docs branch
@@ -471,7 +479,7 @@ jobs:
git add -A
git commit -m "feat: update API docs for ${{ needs.prep.outputs.version }}"
git push --force origin "dev-${{ needs.prep.outputs.version }}"
git push origin "dev-${{ needs.prep.outputs.version }}"
- name: Open and squash-merge docs PR
if: ${{ inputs.mode != 'Dry run' }}
@@ -534,7 +542,7 @@ jobs:
docs,
publish-youtube,
]
if: ${{ always() && (inputs.mode == 'Everything' || inputs.mode == 'Skip submit') && needs.merge-to-main.result == 'success' && needs.electron-release.result == 'success' }}
if: ${{ always() && (inputs.mode == 'Everything' || inputs.mode == 'Skip submit') && needs.merge-to-main.result == 'success' && needs.docs.result == 'success' }}
runs-on: blacksmith-2vcpu-ubuntu-2404
steps:
- name: Checkout repository
+2
View File
@@ -0,0 +1,2 @@
include:
- local: deploy/gitlab/.gitlab-ci.yml
+3
View File
@@ -20,3 +20,6 @@ openapi.json
# Generated by drizzle-kit; formatting is the tool's own
drizzle/
# Helm templates contain Go template syntax, not plain YAML
charts/*/templates/
+2 -2
View File
@@ -1,6 +1,6 @@
cask "termix" do
version "2.6.1"
sha256 "716fcbd4ad4aef3dc19f2eb84cee8c46179860eaac180c98cdcadbceca0dacc6"
version "2.7.1"
sha256 "dbebf8d25b6ae4e2e8aa03b3b20865473234645207d55bd00844a7c7f8d2998c"
url "https://github.com/Termix-SSH/Termix/releases/download/release-#{version}-tag/termix_macos_universal_dmg.dmg"
name "Termix"
+22 -2
View File
@@ -313,6 +313,10 @@ Around 30 languages built in, managed through [Crowdin](https://docs.termix.site
Visit the [Termix Docs](https://docs.termix.site/install) for full installation instructions across all platforms.
Deploying to Kubernetes? The Helm chart is in `charts/termix`, and setup instructions
covering Ingress, Traefik, Argo CD, GitHub Actions, and GitLab CI are at
[docs.termix.site/install/server/kubernetes](https://docs.termix.site/install/server/kubernetes).
Sample Docker Compose file (you can omit `guacd` and the network if you don't plan on using remote desktop features):
```yaml
@@ -327,6 +331,11 @@ services:
- termix-data:/app/data
environment:
PORT: "8080"
GUACD_HOST: "guacd"
GUACD_TUNNEL_HOST: "termix"
GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole"
# guacd, not the Termix container, reads and writes redirected-drive files.
GUACD_DRIVE_PATH: "/termix-data/rdp-drive"
depends_on:
- guacd
networks:
@@ -336,8 +345,10 @@ services:
image: guacamole/guacd:1.6.0
container_name: guacd
restart: unless-stopped
ports:
- "4822:4822"
volumes:
# The official guacd image runs as a non-root user. Keep the drive path
# in this writable shared volume instead of bind-mounting /drive.
- termix-data:/termix-data
networks:
- termix-net
@@ -350,6 +361,15 @@ networks:
driver: bridge
```
For multiple Termix backend instances, set the same `REDIS_URL` and optional
`TERMIX_REDIS_PREFIX` on every instance. Redis synchronizes collaboration room
presence, control requests, controller state, and events. It also routes Step CA
OAuth callbacks back to the instance holding the user's terminal; the optional
`TERMIX_STEP_CA_REDIS_PREFIX` isolates those short-lived encrypted messages.
Keep WebSocket session affinity enabled because live SSH and remote desktop
transports remain attached to the backend instance that opened them. A single
instance needs no Redis.
### Command Line Interface
Termix also has a CLI, so you can manage your servers from a terminal and use Termix in your own scripts.
+40 -105
View File
@@ -1,6 +1,6 @@
<!-- SUMMARY -->
Termix AI, automations, fleets, workspaces, subhosts, Proxmox metrics, a context aware terminal toolbar, split screen tabs, onboarding, PostgreSQL/MySQL support, and a large batch of fixes.
Credential cloning, a WSL local terminal, Helm and GitOps deployment, an editable file manager path bar, download progress bars, and a large batch of connection, sync, and remote desktop fixes.
<!-- /SUMMARY -->
@@ -12,114 +12,49 @@ https://youtu.be/lngaePO96tM
<!-- UPDATE_LOG -->
- Added completely optional and disabled/removed by default Termix AI, an assistant that can work with your hosts and terminals
- This feature was added based off a 60% (yes) to 40% (no) Discord vote.
- The assistant cannot change anything on its own. It can only read a limited set of your Termix data and propose actions, and every change or command runs only after you approve it, using your own account and permissions.
- It has no access to credentials, SSH keys, vaults, users, roles, sessions, SSO, certificates, audit logs, or instance settings. Secrets are also stripped from anything sent to a model provider.
- Both an admin and each user must turn it on before it does anything, and it stays off after upgrading.
- Added automations with events, channels, and steps
- Added a fleet system with snippets, packages, files, and inventory
- Added workspaces to save and restore your tab layout
- Added subhosts so hosts can be organized under a parent host
- Added Proxmox metrics integration
- Added a context aware terminal toolbar with quick links, host info, image pasting, and a movable desktop layout
- Added interactive terminal macros
- Added first-class split screen tabs
- Added a file manager trash instead of permanent deletes
- Added a local terminal to the desktop app
- Added inheritable connection defaults so hosts can share settings
- Added an onboarding flow with an interface simplicity system
- Added PostgreSQL and MySQL support alongside SQLite
- Added a redesigned host and credential sidebar with synced preferences and drag-to-reorder
- Added the option to open some app rail tabs as their own tab or in a right sidebar
- Added folder select to host multi select
- Added connection logs for RDP, VNC, and Telnet hosts
- Added native RDP launching on Windows desktop
- Added a drive file browser and drag-and-drop upload for RDP
- Added terminal image handoff so images open on your local machine
- Added custom terminal font selection
- Added trusted proxy authentication
- Added global touch input settings
- Added adaptive transfers that pick the fastest route and verify integrity
- Added adaptive polling and preloading that respond to activity and network cost
- Added adaptive SSH local echo for high latency connections
- Added custom disk and network metric options
- Added the ability to exclude specific mounts from disk usage metrics
- Added expanded snippet options
- Added downloadable session logs as text files
- Added keyboard shortcuts to move between open tabs
- Added Discord webhook notification channels
- Added paste support when not running over HTTPS
- Added Headscale API key and custom API endpoint support
- Added a Meta key option for terminals
- Added BE-AZERTY keyboard layout for remote desktop
- Added PKCE to the OIDC login flow
- Added Proxmox VMID and Docker tags to discovered guests
- Added custom SSL certificate support in admin settings
- Greatly improved performance across metrics polling and host management for large setups
- Added the ability to clone an existing credential
- Added a WSL option for the local terminal
- Added Helm charts and a GitOps deployment setup
- Added an editable path bar to the file manager
- Added a progress bar for file downloads in the file manager
- Added an identity file option so agent authentication stops after the right key
- Added an environment variable to turn on silent OIDC login
- Added editable model settings for AI providers
- Improved tmux monitor performance when aggregating sessions
- Improved Linux packaging with standard icon sizes
<!-- /UPDATE_LOG -->
<!-- BUG_FIXES -->
- Periodic SSH terminal stalls caused by SQLite telemetry writes
- Missing OPKSSH binary breaking installs without internet access
- Session recording writes slowing down terminals
- SGR mouse tracking escape codes printing as text
- Terminal display distortion with special characters
- Windows Ctrl+W not closing the active tab
- Tray Quit not terminating the desktop app
- Mobile terminal scrollback not matching xterm wheel behavior
- tmux breaking on UTF-8 paths
- Sudo password auto-fill not persisting
- SSH and sudo passwords not being saved or auto-filled
- Switching SSH authentication away from Vault failing
- Host edits being discarded without a warning
- Quick-created credentials not being selected
- Saved RDP connection settings not being preserved
- RDP domain credentials not being prompted for
- Windows key mapping in remote desktop sessions
- VNC failing to connect to macOS screen sharing
- Mouse input breaking on touch-capable devices in RDP and VNC
- Docker runtime selection not persisting, plus Docker manager UI issues
- Desktop Docker console WebSocket not being authenticated
- Folders intermittently disappearing from duplicate requests
- Folder deletion not refreshing the host list
- Proxmox guest identity being lost on edit
- Long host names shifting dashboard metrics
- Host list rows resizing unexpectedly
- Metrics collection all firing at once on startup
- Session activity writes hitting the database too often
- Reachable and available hosts being treated the same
- SSH keepalives could not be disabled
- OIDC group claims from multiple sources not being merged
- OIDC discovery issuers with trailing slashes failing
- LDAP logins not using preferred_username
- Trusted MFA devices not being bound to a specific client install
- 2FA could not be disabled with a single credential
- Profile API keys not being shown after creation
- SSH agent authentication being unclear in the host editor
- Tunnel status stream not requiring authentication
- SFTP and Docker console accepting a mismatched host id
- SSH connections whose host id resolved elsewhere being accepted
- User-managed CA certificates not being applied over SFTP
- Already-shared hosts losing their SSH authentication
- Real client IP not being captured for SSH login alerts behind a reverse proxy
- Audit log IPs not using the real client IP
- Homepage System Overview update indicator never firing
- Webhook notification channels not working
- Remote sync failing behind an nginx proxy
- First server sync not refreshing the UI
- Desktop app not showing update prompts and hiding the version badge
- Desktop Tailscale configuration being lost
- Command palette not loading new activity, plus Enter now opens the first result
- Database connection failures during login not being reported clearly
- audit_logs.user_id not being nullable on fresh SQLite installs
- Sync upserts writing to the wrong row
- Database migration failures on tables without an id column
- ssh_credentials rebuilds not matching the live schema
- Sidebar reset and fullscreen buttons sharing the same icon
- Host list icons not matching the tab bar icons
- Keep Linux credential storage working on unrecognized desktops
- Passkey sign in not showing up on the login screen
- Connections through jump hosts failing
- Jump hosts and remote desktop hosts not resolving after a sync
- Malformed websocket messages crashing the server
- Encrypted file manager keys not prompting for a passphrase
- SSH agent forwarding not working with the in-memory agent
- Two factor prompts rejecting codes longer than six digits
- OIDC lockout with no way to recover from environment settings
- Tailscale requests failing on some setups
- Terminal clipboard shortcuts not working on non-QWERTY layouts
- Rapid mobile terminal input being sent one keystroke at a time
- HTTPS not being able to share the configured port
- Portable imports failing on remote databases
- Host status not showing when metrics collection is off
- Proxmox guest credential usernames being wrong
- File drops not working for RDP in the browser
- Duplicate Docker HTTPS listener on startup
- Remote sync server probe ignoring the certificate setting
- Runtime SSL settings not being preserved
- Automation notifications missing host details
- Connection screens crashing outside the connection log provider
- better-sqlite3 failing in Docker on some platforms
- Host action rows shifting at large font sizes
- Sidebar height jumping when hosts or credentials have tags
- Gaps between host rows in the sidebar list
- Rounded corners on the host list search bar
- Image storage settings text wrapping to one word per line
- Unclear wording on the click-to-expand host setting
- Dragging a folder into the file manager failing to upload
<!-- /BUG_FIXES -->
+18
View File
@@ -3,3 +3,21 @@
## Reporting a Vulnerability
Please report any vulnerabilities to [GitHub Security](https://github.com/Termix-SSH/Termix/security/advisories).
## External secret storage
By default, a single-container installation generates its keys in the Termix
data directory for ease of recovery. Production deployments that keep backups
or database files outside a trusted encrypted volume should set
`TERMIX_REQUIRE_EXTERNAL_SECRETS=true` and provide all four keys through a
secret manager:
- `JWT_SECRET` (at least 64 characters)
- `DATABASE_KEY` (64 hexadecimal characters)
- `ENCRYPTION_KEY` (64 hexadecimal characters)
- `INTERNAL_AUTH_TOKEN` (at least 32 characters)
Each value can instead be mounted as a Docker or Kubernetes secret and supplied
with its corresponding `_FILE` variable, such as `ENCRYPTION_KEY_FILE`.
Hardened mode fails closed instead of writing a replacement key beside the
encrypted database.
+6
View File
@@ -0,0 +1,6 @@
.git/
.github/
node_modules/
dist/
coverage/
*.tgz
+16
View File
@@ -0,0 +1,16 @@
apiVersion: v2
name: termix
description: Self-hosted SSH and remote desktop management for Kubernetes.
type: application
version: 0.1.0
appVersion: 2.7.0
home: https://github.com/Termix-SSH/Termix
sources:
- https://github.com/Termix-SSH/Termix
maintainers:
- name: Termix maintainers
keywords:
- ssh
- remote-desktop
- guacamole
- terminal
+17
View File
@@ -0,0 +1,17 @@
Termix has been installed.
Service:
{{ include "termix.fullname" . }}:{{ .Values.service.port }}
{{- if .Values.ingress.enabled }}
Ingress hosts:
{{- range .Values.ingress.hosts }}
- {{ .host }}
{{- end }}
{{- else if .Values.traefik.ingressRoute.enabled }}
Traefik IngressRoute:
https://{{ .Values.traefik.ingressRoute.host }}{{ .Values.traefik.ingressRoute.pathPrefix }}
{{- else }}
Port-forward for local testing:
kubectl -n {{ .Release.Namespace }} port-forward svc/{{ include "termix.fullname" . }} 8080:{{ .Values.service.port }}
{{- end }}
+45
View File
@@ -0,0 +1,45 @@
{{- define "termix.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- define "termix.fullname" -}}
{{- if .Values.fullnameOverride -}}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- $name := default .Chart.Name .Values.nameOverride -}}
{{- if contains $name .Release.Name -}}
{{- .Release.Name | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{- define "termix.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- define "termix.labels" -}}
helm.sh/chart: {{ include "termix.chart" . }}
app.kubernetes.io/name: {{ include "termix.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end -}}
{{- define "termix.selectorLabels" -}}
app.kubernetes.io/name: {{ include "termix.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end -}}
{{- define "termix.serviceAccountName" -}}
{{- if .Values.serviceAccount.create -}}
{{- default (include "termix.fullname" .) .Values.serviceAccount.name -}}
{{- else -}}
{{- default "default" .Values.serviceAccount.name -}}
{{- end -}}
{{- end -}}
{{- define "termix.secretName" -}}
{{- default (printf "%s-secret" (include "termix.fullname" .)) .Values.secrets.name -}}
{{- end -}}
+148
View File
@@ -0,0 +1,148 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "termix.fullname" . }}
labels:
{{- include "termix.labels" . | nindent 4 }}
spec:
{{- if not .Values.autoscaling.enabled }}
replicas: {{ .Values.replicaCount }}
{{- end }}
strategy:
{{- toYaml .Values.strategy | nindent 4 }}
selector:
matchLabels:
{{- include "termix.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "termix.selectorLabels" . | nindent 8 }}
{{- with .Values.podLabels }}
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
serviceAccountName: {{ include "termix.serviceAccountName" . }}
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
containers:
- name: termix
securityContext:
{{- toYaml .Values.securityContext | nindent 12 }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: {{ .Values.service.port }}
protocol: TCP
env:
{{- range $key, $value := .Values.env }}
- name: {{ $key }}
value: {{ $value | quote }}
{{- end }}
{{- if .Values.guacd.enabled }}
- name: GUACD_HOST
value: "127.0.0.1"
{{- else if .Values.guacd.host }}
- name: GUACD_HOST
value: {{ .Values.guacd.host | quote }}
{{- end }}
- name: GUACD_TUNNEL_HOST
value: {{ include "termix.fullname" . | quote }}
{{- if ne .Values.database.dialect "sqlite" }}
- name: DATABASE_DIALECT
value: {{ .Values.database.dialect | quote }}
{{- end }}
{{- if .Values.database.existingSecret.name }}
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: {{ .Values.database.existingSecret.name }}
key: {{ .Values.database.existingSecret.urlKey }}
{{- end }}
{{- if .Values.secrets.create }}
{{- range $key, $value := .Values.secrets.data }}
{{- if $value }}
- name: {{ $key }}
valueFrom:
secretKeyRef:
name: {{ include "termix.secretName" $ }}
key: {{ $key }}
{{- end }}
{{- end }}
{{- end }}
{{- with .Values.extraEnv }}
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.extraEnvFrom }}
envFrom:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- if .Values.probes.liveness.enabled }}
livenessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: {{ .Values.probes.liveness.initialDelaySeconds }}
periodSeconds: {{ .Values.probes.liveness.periodSeconds }}
timeoutSeconds: {{ .Values.probes.liveness.timeoutSeconds }}
failureThreshold: {{ .Values.probes.liveness.failureThreshold }}
{{- end }}
{{- if .Values.probes.readiness.enabled }}
readinessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: {{ .Values.probes.readiness.initialDelaySeconds }}
periodSeconds: {{ .Values.probes.readiness.periodSeconds }}
timeoutSeconds: {{ .Values.probes.readiness.timeoutSeconds }}
failureThreshold: {{ .Values.probes.readiness.failureThreshold }}
{{- end }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
volumeMounts:
- name: data
mountPath: /app/data
{{- if .Values.guacd.enabled }}
- name: guacd
image: "{{ .Values.guacd.image.repository }}:{{ .Values.guacd.image.tag }}"
imagePullPolicy: {{ .Values.guacd.image.pullPolicy }}
securityContext:
{{- toYaml .Values.guacd.securityContext | nindent 12 }}
ports:
- name: guacd
containerPort: {{ .Values.guacd.service.port }}
protocol: TCP
resources:
{{- toYaml .Values.guacd.resources | nindent 12 }}
volumeMounts:
- name: data
mountPath: /termix-data
{{- end }}
volumes:
- name: data
{{- if .Values.persistence.enabled }}
persistentVolumeClaim:
claimName: {{ default (printf "%s-data" (include "termix.fullname" .)) .Values.persistence.existingClaim }}
{{- else }}
emptyDir: {}
{{- end }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
+22
View File
@@ -0,0 +1,22 @@
{{- if .Values.autoscaling.enabled -}}
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: {{ include "termix.fullname" . }}
labels:
{{- include "termix.labels" . | nindent 4 }}
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: {{ include "termix.fullname" . }}
minReplicas: {{ .Values.autoscaling.minReplicas }}
maxReplicas: {{ .Values.autoscaling.maxReplicas }}
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
{{- end }}
+35
View File
@@ -0,0 +1,35 @@
{{- if .Values.ingress.enabled -}}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "termix.fullname" . }}
labels:
{{- include "termix.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if .Values.ingress.className }}
ingressClassName: {{ .Values.ingress.className }}
{{- end }}
{{- if .Values.ingress.tls }}
tls:
{{- toYaml .Values.ingress.tls | nindent 4 }}
{{- end }}
rules:
{{- range .Values.ingress.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
pathType: {{ .pathType }}
backend:
service:
name: {{ include "termix.fullname" $ }}
port:
name: http
{{- end }}
{{- end }}
{{- end }}
@@ -0,0 +1,19 @@
{{- if .Values.networkPolicy.enabled -}}
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: {{ include "termix.fullname" . }}
labels:
{{- include "termix.labels" . | nindent 4 }}
spec:
podSelector:
matchLabels:
{{- include "termix.selectorLabels" . | nindent 6 }}
policyTypes:
- Ingress
- Egress
ingress:
{{- toYaml .Values.networkPolicy.ingress | nindent 4 }}
egress:
{{- toYaml .Values.networkPolicy.egress | nindent 4 }}
{{- end }}
+13
View File
@@ -0,0 +1,13 @@
{{- if .Values.podDisruptionBudget.enabled -}}
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ include "termix.fullname" . }}
labels:
{{- include "termix.labels" . | nindent 4 }}
spec:
minAvailable: {{ .Values.podDisruptionBudget.minAvailable }}
selector:
matchLabels:
{{- include "termix.selectorLabels" . | nindent 6 }}
{{- end }}
+21
View File
@@ -0,0 +1,21 @@
{{- if and .Values.persistence.enabled (not .Values.persistence.existingClaim) -}}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "termix.fullname" . }}-data
labels:
{{- include "termix.labels" . | nindent 4 }}
{{- with .Values.persistence.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
accessModes:
{{- toYaml .Values.persistence.accessModes | nindent 4 }}
resources:
requests:
storage: {{ .Values.persistence.size | quote }}
{{- if .Values.persistence.storageClass }}
storageClassName: {{ .Values.persistence.storageClass | quote }}
{{- end }}
{{- end }}
+15
View File
@@ -0,0 +1,15 @@
{{- if .Values.secrets.create -}}
apiVersion: v1
kind: Secret
metadata:
name: {{ include "termix.secretName" . }}
labels:
{{- include "termix.labels" . | nindent 4 }}
type: Opaque
stringData:
{{- range $key, $value := .Values.secrets.data }}
{{- if $value }}
{{ $key }}: {{ $value | quote }}
{{- end }}
{{- end }}
{{- end }}
+19
View File
@@ -0,0 +1,19 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "termix.fullname" . }}
labels:
{{- include "termix.labels" . | nindent 4 }}
{{- with .Values.service.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "termix.selectorLabels" . | nindent 4 }}
@@ -0,0 +1,13 @@
{{- if .Values.serviceAccount.create -}}
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "termix.serviceAccountName" . }}
labels:
{{- include "termix.labels" . | nindent 4 }}
{{- with .Values.serviceAccount.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
automountServiceAccountToken: {{ .Values.serviceAccount.automount }}
{{- end }}
@@ -0,0 +1,34 @@
{{- if .Values.traefik.ingressRoute.enabled -}}
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: {{ include "termix.fullname" . }}
labels:
{{- include "termix.labels" . | nindent 4 }}
{{- with .Values.traefik.ingressRoute.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
entryPoints:
{{- toYaml .Values.traefik.ingressRoute.entryPoints | nindent 4 }}
routes:
- match: Host(`{{ .Values.traefik.ingressRoute.host }}`) && PathPrefix(`{{ .Values.traefik.ingressRoute.pathPrefix }}`)
kind: Rule
services:
- name: {{ include "termix.fullname" . }}
port: {{ .Values.service.port }}
{{- with .Values.traefik.ingressRoute.middlewares }}
middlewares:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- if .Values.traefik.ingressRoute.tls.enabled }}
tls:
{{- if .Values.traefik.ingressRoute.tls.secretName }}
secretName: {{ .Values.traefik.ingressRoute.tls.secretName }}
{{- end }}
{{- if .Values.traefik.ingressRoute.tls.certResolver }}
certResolver: {{ .Values.traefik.ingressRoute.tls.certResolver }}
{{- end }}
{{- end }}
{{- end }}
+12
View File
@@ -0,0 +1,12 @@
{{- if and .Values.ingress.enabled .Values.traefik.ingressRoute.enabled -}}
{{- fail "ingress and traefik.ingressRoute cannot both be enabled" -}}
{{- end -}}
{{- if and (eq .Values.database.dialect "sqlite") (or (gt (int .Values.replicaCount) 1) .Values.autoscaling.enabled) -}}
{{- fail "SQLite supports only one replica; use Postgres or MySQL before enabling replicas or autoscaling" -}}
{{- end -}}
{{- if and (not .Values.guacd.enabled) (not .Values.guacd.host) -}}
{{- fail "guacd.host is required when the bundled guacd sidecar is disabled" -}}
{{- end -}}
{{- if and .Values.database.existingSecret.name .Values.secrets.create .Values.secrets.data.DATABASE_URL -}}
{{- fail "DATABASE_URL must come from either database.existingSecret or secrets.data, not both" -}}
{{- end -}}
+28
View File
@@ -0,0 +1,28 @@
image:
repository: ghcr.io/termix-ssh/termix
tag: "2.7.0"
ingress:
enabled: true
className: nginx
hosts:
- host: termix.example.com
paths:
- path: /
pathType: Prefix
tls:
- secretName: termix-tls
hosts:
- termix.example.com
persistence:
enabled: true
size: 20Gi
secrets:
create: false
database:
dialect: sqlite
extraEnvFrom: []
+32
View File
@@ -0,0 +1,32 @@
ingress:
enabled: false
traefik:
ingressRoute:
enabled: true
entryPoints:
- websecure
host: termix.example.com
pathPrefix: /
tls:
enabled: true
certResolver: letsencrypt
persistence:
enabled: true
size: 20Gi
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
memory: 2Gi
guacd:
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
memory: 512Mi
+151
View File
@@ -0,0 +1,151 @@
replicaCount: 1
image:
repository: ghcr.io/termix-ssh/termix
pullPolicy: IfNotPresent
tag: ""
imagePullSecrets: []
nameOverride: ""
fullnameOverride: ""
serviceAccount:
create: true
automount: false
annotations: {}
name: ""
podAnnotations: {}
podLabels: {}
podSecurityContext:
fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch
securityContext: {}
service:
type: ClusterIP
port: 8080
annotations: {}
ingress:
enabled: false
className: ""
annotations: {}
hosts:
- host: termix.example.com
paths:
- path: /
pathType: Prefix
tls: []
traefik:
ingressRoute:
enabled: false
entryPoints:
- websecure
host: termix.example.com
pathPrefix: /
tls:
enabled: true
secretName: ""
certResolver: ""
middlewares: []
annotations: {}
resources: {}
autoscaling:
enabled: false
minReplicas: 1
maxReplicas: 3
targetCPUUtilizationPercentage: 80
persistence:
enabled: true
accessModes:
- ReadWriteOnce
size: 10Gi
storageClass: ""
annotations: {}
existingClaim: ""
env:
PORT: "8080"
DATA_DIR: /app/data
NODE_ENV: production
TERMIX_REQUIRE_EXTERNAL_SECRETS: "false"
GUACD_RECORDING_PATH: /termix-data/session_recordings/guacamole
GUACD_RECORDING_BACKEND_PATH: /app/data/session_recordings/guacamole
extraEnv: []
extraEnvFrom: []
secrets:
create: false
name: ""
data:
JWT_SECRET: ""
DATABASE_KEY: ""
ENCRYPTION_KEY: ""
INTERNAL_AUTH_TOKEN: ""
DATABASE_URL: ""
GUACAMOLE_ENCRYPTION_KEY: ""
database:
dialect: sqlite
existingSecret:
name: ""
urlKey: DATABASE_URL
guacd:
enabled: true
host: ""
image:
repository: guacamole/guacd
tag: 1.6.0
pullPolicy: IfNotPresent
resources: {}
podAnnotations: {}
podLabels: {}
securityContext:
runAsNonRoot: true
runAsUser: 1000
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
service:
port: 4822
annotations: {}
nodeSelector: {}
tolerations: []
affinity: {}
strategy:
type: Recreate
probes:
liveness:
enabled: true
initialDelaySeconds: 60
periodSeconds: 30
timeoutSeconds: 10
failureThreshold: 3
readiness:
enabled: true
initialDelaySeconds: 15
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 6
podDisruptionBudget:
enabled: false
minAvailable: 1
networkPolicy:
enabled: false
ingress: []
egress: []
+25
View File
@@ -0,0 +1,25 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: termix-traefik
namespace: argocd
spec:
project: default
source:
repoURL: https://github.com/Termix-SSH/Termix.git
targetRevision: main
path: charts/termix
helm:
releaseName: termix
valueFiles:
- values-traefik.yaml
destination:
server: https://kubernetes.default.svc
namespace: termix
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
- PruneLast=true
+25
View File
@@ -0,0 +1,25 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: termix
namespace: argocd
spec:
project: default
source:
repoURL: https://github.com/Termix-SSH/Termix.git
targetRevision: main
path: charts/termix
helm:
releaseName: termix
valueFiles:
- values-gitops-example.yaml
destination:
server: https://kubernetes.default.svc
namespace: termix
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
- PruneLast=true
+70
View File
@@ -0,0 +1,70 @@
stages:
- test
- build
- package
- deploy
variables:
DOCKER_TLS_CERTDIR: "/certs"
IMAGE_TAG: "$CI_COMMIT_SHORT_SHA"
HELM_EXPERIMENTAL_OCI: "1"
helm-lint:
image:
name: alpine/helm:3.15.4
entrypoint: [""]
stage: test
script:
- helm lint charts/termix
- helm template termix charts/termix --values charts/termix/values-gitops-example.yaml >/tmp/termix.yaml
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH
docker-build-push:
image: docker:27
stage: build
services:
- docker:27-dind
before_script:
- echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY" -u "$CI_REGISTRY_USER" --password-stdin
script:
- docker build -f docker/Dockerfile -t "$CI_REGISTRY_IMAGE:$IMAGE_TAG" -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG" .
- docker push "$CI_REGISTRY_IMAGE:$IMAGE_TAG"
- docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG"
rules:
- if: $CI_COMMIT_BRANCH
helm-package-push:
image:
name: alpine/helm:3.15.4
entrypoint: [""]
stage: package
before_script:
- echo "$CI_REGISTRY_PASSWORD" | helm registry login "$CI_REGISTRY" -u "$CI_REGISTRY_USER" --password-stdin
script:
- helm dependency update charts/termix
- helm package charts/termix --destination .helm-packages
- helm push .helm-packages/termix-*.tgz "oci://$CI_REGISTRY_IMAGE/charts"
artifacts:
paths:
- .helm-packages/
rules:
- if: $CI_COMMIT_TAG
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
deploy:
image:
name: alpine/helm:3.15.4
entrypoint: [""]
stage: deploy
environment:
name: production
before_script:
- echo "$KUBE_CONFIG" | base64 -d > kubeconfig
- chmod 600 kubeconfig
script:
- helm upgrade --install termix charts/termix --namespace termix --create-namespace --kubeconfig kubeconfig --set image.repository="$CI_REGISTRY_IMAGE" --set image.tag="$IMAGE_TAG" --values charts/termix/values-gitops-example.yaml --atomic --timeout 10m
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
when: manual
+17 -9
View File
@@ -1,5 +1,5 @@
# Stage 1: Install dependencies
FROM node:24-slim AS deps
FROM node:26-slim AS deps
WORKDIR /app
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
@@ -31,30 +31,35 @@ WORKDIR /app
COPY . .
RUN npm rebuild better-sqlite3
RUN rm -rf node_modules/better-sqlite3/prebuilds && \
npm run build-release --prefix node_modules/better-sqlite3 && \
test -f node_modules/better-sqlite3/build/Release/better_sqlite3.node
RUN npm run build:backend
# Stage 4: Download OPKSSH binary for the target platform so the image works offline
FROM node:24-slim AS opkssh-downloader
FROM node:26-slim AS opkssh-downloader
ARG TARGETARCH
ARG OPKSSH_VERSION=v0.16.0
ARG OPKSSH_SHA256_AMD64=c018c3e7baf98612b923e742dd87be38650bf61e3b755fb2bc90de177568b1bf
ARG OPKSSH_SHA256_ARM64=9dd10c2b6ce99cde18e52c054877ca014134b291fd82afe71741c68db4f83d44
WORKDIR /opkssh
RUN apt-get update && apt-get install -y curl ca-certificates && rm -rf /var/lib/apt/lists/*
RUN case "$TARGETARCH" in \
amd64) OPKSSH_ARCH=amd64 ;; \
arm64) OPKSSH_ARCH=arm64 ;; \
amd64) OPKSSH_ARCH=amd64; OPKSSH_SHA256="$OPKSSH_SHA256_AMD64" ;; \
arm64) OPKSSH_ARCH=arm64; OPKSSH_SHA256="$OPKSSH_SHA256_ARM64" ;; \
*) echo "Unsupported architecture: $TARGETARCH" && exit 1 ;; \
esac && \
curl -fSL -o "opkssh-linux-${OPKSSH_ARCH}" \
"https://github.com/openpubkey/opkssh/releases/download/${OPKSSH_VERSION}/opkssh-linux-${OPKSSH_ARCH}" && \
echo "$OPKSSH_SHA256 opkssh-linux-${OPKSSH_ARCH}" | sha256sum -c - && \
chmod 755 "opkssh-linux-${OPKSSH_ARCH}" && \
echo -n "$OPKSSH_VERSION" > version.txt
# Stage 5: Production dependencies only
FROM node:24-slim AS production-deps
FROM node:26-slim AS production-deps
WORKDIR /app
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
@@ -67,11 +72,14 @@ COPY scripts/patch-guacamole-lite.cjs ./scripts/
RUN npm ci --omit=dev --ignore-scripts && \
node scripts/patch-guacamole-lite.cjs && \
npm rebuild better-sqlite3 bcryptjs ssh2 && \
rm -rf node_modules/better-sqlite3/prebuilds && \
npm run build-release --prefix node_modules/better-sqlite3 && \
test -f node_modules/better-sqlite3/build/Release/better_sqlite3.node && \
npm rebuild bcryptjs ssh2 && \
npm cache clean --force
# Stage 6: Final optimized image
FROM node:24-slim
FROM node:26-slim
WORKDIR /app
ENV DATA_DIR=/app/data \
@@ -101,7 +109,7 @@ COPY --chown=node:node drizzle ./drizzle
VOLUME ["/app/data"]
EXPOSE ${PORT} 30001 30002 30003 30004 30005 30006 30007 30008 30009 30010 30011 30012
EXPOSE ${PORT}
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD wget -q -O /dev/null http://localhost:30001/health || exit 1
+4
View File
@@ -15,6 +15,10 @@ services:
GUACD_HOST: "guacd-dev"
GUACD_TUNNEL_HOST: "termix-dev"
GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole"
GUACD_DRIVE_PATH: "/termix-data/rdp-drive"
# REDIS_URL: "redis://redis:6379"
# TERMIX_REDIS_PREFIX: "termix:collab"
# TERMIX_STEP_CA_REDIS_PREFIX: "termix:step-ca"
depends_on:
- guacd-dev
networks:
+13
View File
@@ -12,6 +12,19 @@ services:
GUACD_HOST: "guacd"
GUACD_TUNNEL_HOST: "termix"
GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole"
# Where guacd stores files for RDP drive redirection (one folder per
# user is created underneath). Must be writable by guacd's user.
GUACD_DRIVE_PATH: "/termix-data/rdp-drive"
# Multi-instance deployments should point every backend at the same
# Redis. Without it, collaboration rooms keep their single-instance
# in-memory fallback.
# REDIS_URL: "redis://redis:6379"
# TERMIX_REDIS_PREFIX: "termix:collab"
# TERMIX_STEP_CA_REDIS_PREFIX: "termix:step-ca"
# Hardened deployments can require keys from environment variables or
# Docker secrets mounted through JWT_SECRET_FILE, DATABASE_KEY_FILE,
# ENCRYPTION_KEY_FILE and INTERNAL_AUTH_TOKEN_FILE.
# TERMIX_REQUIRE_EXTERNAL_SECRETS: "true"
# Trusted reverse-proxy authentication is disabled by default. When
# enabled, do not expose this container directly to untrusted clients.
# TRUSTED_PROXY_AUTH_ENABLED: "true"
+32 -1
View File
@@ -23,6 +23,18 @@ if [ "$(id -u)" = "0" ]; then
fi
DATA_DIR=${DATA_DIR:-/app/data}
RUNTIME_ENABLE_SSL_SET=${ENABLE_SSL+x}
RUNTIME_ENABLE_SSL=${ENABLE_SSL-}
RUNTIME_SSL_PORT_SET=${SSL_PORT+x}
RUNTIME_SSL_PORT=${SSL_PORT-}
RUNTIME_SSL_CERT_PATH_SET=${SSL_CERT_PATH+x}
RUNTIME_SSL_CERT_PATH=${SSL_CERT_PATH-}
RUNTIME_SSL_KEY_PATH_SET=${SSL_KEY_PATH+x}
RUNTIME_SSL_KEY_PATH=${SSL_KEY_PATH-}
RUNTIME_SSL_DOMAIN_SET=${SSL_DOMAIN+x}
RUNTIME_SSL_DOMAIN=${SSL_DOMAIN-}
if [ -f "$DATA_DIR/.env" ]; then
echo "Loading persisted SSL settings from $DATA_DIR/.env"
set -a
@@ -30,11 +42,18 @@ if [ -f "$DATA_DIR/.env" ]; then
set +a
fi
[ "$RUNTIME_ENABLE_SSL_SET" = "x" ] && ENABLE_SSL=$RUNTIME_ENABLE_SSL
[ "$RUNTIME_SSL_PORT_SET" = "x" ] && SSL_PORT=$RUNTIME_SSL_PORT
[ "$RUNTIME_SSL_CERT_PATH_SET" = "x" ] && SSL_CERT_PATH=$RUNTIME_SSL_CERT_PATH
[ "$RUNTIME_SSL_KEY_PATH_SET" = "x" ] && SSL_KEY_PATH=$RUNTIME_SSL_KEY_PATH
[ "$RUNTIME_SSL_DOMAIN_SET" = "x" ] && SSL_DOMAIN=$RUNTIME_SSL_DOMAIN
export PORT=${PORT:-8080}
export ENABLE_SSL=${ENABLE_SSL:-false}
export SSL_PORT=${SSL_PORT:-8443}
export SSL_CERT_PATH=${SSL_CERT_PATH:-/app/data/ssl/termix.crt}
export SSL_KEY_PATH=${SSL_KEY_PATH:-/app/data/ssl/termix.key}
export TERMIX_SSL_TERMINATED_BY_NGINX=true
echo "Configuring web UI to run on port: $PORT"
@@ -49,6 +68,11 @@ fi
mkdir -p /tmp/nginx
envsubst '${PORT} ${SSL_PORT} ${SSL_CERT_PATH} ${SSL_KEY_PATH}' < $NGINX_CONF_SOURCE > /tmp/nginx/nginx.conf
if [ "$ENABLE_SSL" = "true" ] && [ "$PORT" = "$SSL_PORT" ]; then
echo "HTTP and HTTPS use port $SSL_PORT; disabling the HTTP redirect listener"
sed -i '/# BEGIN HTTP_REDIRECT_SERVER/,/# END HTTP_REDIRECT_SERVER/d' /tmp/nginx/nginx.conf
fi
mkdir -p /app/data /app/uploads /app/data/.opk /app/data/acme-webroot/.well-known/acme-challenge
chmod 755 /app/data /app/uploads /app/data/.opk 2>/dev/null || true
@@ -148,7 +172,14 @@ if [ -n "$BASE_PATH" ]; then
echo "Injecting BASE_PATH: $BASE_PATH"
# Strip trailing slash for use as a path prefix
CLEAN_BASE_PATH="${BASE_PATH%/}"
find /app/html -name "index.html" -exec sed -i "s|window.__TERMIX_BASE_PATH__ = \"\"|window.__TERMIX_BASE_PATH__ = \"$CLEAN_BASE_PATH\"|g" {} \;
case "$CLEAN_BASE_PATH" in
/*) ;;
*) echo "BASE_PATH must start with /" >&2; exit 1 ;;
esac
case "$CLEAN_BASE_PATH" in
*[!A-Za-z0-9_./~-]*) echo "BASE_PATH contains unsupported characters" >&2; exit 1 ;;
esac
find /app/html -name "index.html" -exec sed -i "s|name=\"termix-base-path\" content=\"\"|name=\"termix-base-path\" content=\"$CLEAN_BASE_PATH\"|g" {} \;
# Patch sw.js static asset paths with the base path prefix
find /app/html -name "sw.js" -exec sed -i "s|__TERMIX_SW_BASE_PATH__|$CLEAN_BASE_PATH|g" {} \;
else
+6
View File
@@ -69,12 +69,14 @@ http {
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
# BEGIN HTTP_REDIRECT_SERVER
server {
listen ${PORT};
server_name _;
return 301 https://$host:${SSL_PORT}$request_uri;
}
# END HTTP_REDIRECT_SERVER
server {
listen ${SSL_PORT} ssl;
@@ -159,6 +161,10 @@ http {
root /app/html;
index index.html index.htm;
expires off;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options nosniff always;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always;
+4
View File
@@ -142,6 +142,10 @@ http {
root /app/html;
index index.html index.htm;
expires off;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header X-Content-Type-Options nosniff always;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always;
try_files $uri $uri/ /index.html;
+7
View File
@@ -350,6 +350,13 @@ networks:
driver: bridge
```
部署多个 Termix 后端实例时,请为所有实例配置相同的 `REDIS_URL`,并可选配置
`TERMIX_REDIS_PREFIX`。Redis 会同步协作房间的在线成员、控制请求、控制权和事件,
也会把 Step CA OAuth 回调路由回持有用户终端的实例;可通过
`TERMIX_STEP_CA_REDIS_PREFIX` 隔离这些短期加密消息。实时 SSH 与远程桌面传输仍
依附于创建连接的后端实例,因此负载均衡器需要保持 WebSocket 会话亲和性。
单实例部署无需 Redis。
### 命令行工具
Termix 还提供命令行工具,你可以在终端里管理服务器,也可以把 Termix 用在自己的脚本中。
+34
View File
@@ -0,0 +1,34 @@
CREATE TABLE `collab_room_members` (
`id` int AUTO_INCREMENT NOT NULL,
`room_id` varchar(255) NOT NULL,
`user_id` varchar(255) NOT NULL,
`room_role` text NOT NULL DEFAULT ('member'),
`added_by` varchar(255),
`created_at` varchar(255) NOT NULL DEFAULT (CURRENT_TIMESTAMP),
CONSTRAINT `collab_room_members_id` PRIMARY KEY(`id`),
CONSTRAINT `idx_collab_room_members_room_user` UNIQUE(`room_id`,`user_id`)
);
--> statement-breakpoint
CREATE TABLE `collab_rooms` (
`id` varchar(255) NOT NULL,
`name` varchar(255) NOT NULL,
`owner_user_id` varchar(255) NOT NULL,
`persistent` boolean NOT NULL DEFAULT false,
`presenter_user_id` varchar(255),
`stage_protocol` text,
`stage_host_id` int,
`stage_share_id` varchar(255),
`created_at` varchar(255) NOT NULL DEFAULT (CURRENT_TIMESTAMP),
`ended_at` text,
CONSTRAINT `collab_rooms_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
ALTER TABLE `collab_room_members` ADD CONSTRAINT `collab_room_members_room_id_collab_rooms_id_fk` FOREIGN KEY (`room_id`) REFERENCES `collab_rooms`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `collab_room_members` ADD CONSTRAINT `collab_room_members_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `collab_room_members` ADD CONSTRAINT `collab_room_members_added_by_users_id_fk` FOREIGN KEY (`added_by`) REFERENCES `users`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `collab_rooms` ADD CONSTRAINT `collab_rooms_owner_user_id_users_id_fk` FOREIGN KEY (`owner_user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `collab_rooms` ADD CONSTRAINT `collab_rooms_presenter_user_id_users_id_fk` FOREIGN KEY (`presenter_user_id`) REFERENCES `users`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `collab_rooms` ADD CONSTRAINT `collab_rooms_stage_host_id_ssh_data_id_fk` FOREIGN KEY (`stage_host_id`) REFERENCES `ssh_data`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `collab_rooms` ADD CONSTRAINT `collab_rooms_stage_share_id_session_shares_id_fk` FOREIGN KEY (`stage_share_id`) REFERENCES `session_shares`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX `idx_collab_room_members_user` ON `collab_room_members` (`user_id`);--> statement-breakpoint
CREATE INDEX `idx_collab_rooms_owner` ON `collab_rooms` (`owner_user_id`);
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE `collab_rooms` ADD `guest_link_token` varchar(255);--> statement-breakpoint
ALTER TABLE `collab_rooms` ADD CONSTRAINT `idx_collab_rooms_guest_token` UNIQUE(`guest_link_token`);
+15
View File
@@ -0,0 +1,15 @@
CREATE TABLE `secret_sources` (
`id` varchar(255) NOT NULL,
`user_id` varchar(255) NOT NULL,
`name` varchar(255) NOT NULL,
`kind` text NOT NULL DEFAULT ('onepassword-connect'),
`base_url` text NOT NULL,
`token` text NOT NULL,
`shared` boolean NOT NULL DEFAULT false,
`created_at` varchar(255) NOT NULL DEFAULT (CURRENT_TIMESTAMP),
`updated_at` varchar(255) NOT NULL DEFAULT (CURRENT_TIMESTAMP),
CONSTRAINT `secret_sources_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
ALTER TABLE `secret_sources` ADD CONSTRAINT `secret_sources_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX `idx_secret_sources_user` ON `secret_sources` (`user_id`);
+42
View File
@@ -0,0 +1,42 @@
CREATE TABLE `credential_access` (
`id` int AUTO_INCREMENT NOT NULL,
`credential_id` int NOT NULL,
`user_id` varchar(255),
`role_id` int,
`granted_by` varchar(255) NOT NULL,
`permission_level` text NOT NULL DEFAULT ('use'),
`expires_at` varchar(255),
`created_at` varchar(255) NOT NULL DEFAULT (CURRENT_TIMESTAMP),
CONSTRAINT `credential_access_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `shared_credential_secrets` (
`id` int AUTO_INCREMENT NOT NULL,
`credential_access_id` int NOT NULL,
`target_user_id` varchar(255) NOT NULL,
`credential_id` int NOT NULL,
`encrypted_username` text,
`auth_type` text NOT NULL DEFAULT ('password'),
`encrypted_password` text,
`encrypted_key` text,
`encrypted_key_password` text,
`key_type` text,
`public_key` text,
`cert_public_key` text,
`created_at` varchar(255) NOT NULL DEFAULT (CURRENT_TIMESTAMP),
`updated_at` varchar(255) NOT NULL DEFAULT (CURRENT_TIMESTAMP),
CONSTRAINT `shared_credential_secrets_id` PRIMARY KEY(`id`),
CONSTRAINT `idx_shared_credential_secrets_scope` UNIQUE(`credential_access_id`,`target_user_id`)
);
--> statement-breakpoint
ALTER TABLE `credential_access` ADD CONSTRAINT `credential_access_credential_id_ssh_credentials_id_fk` FOREIGN KEY (`credential_id`) REFERENCES `ssh_credentials`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `credential_access` ADD CONSTRAINT `credential_access_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `credential_access` ADD CONSTRAINT `credential_access_role_id_roles_id_fk` FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `credential_access` ADD CONSTRAINT `credential_access_granted_by_users_id_fk` FOREIGN KEY (`granted_by`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `shared_credential_secrets` ADD CONSTRAINT `shared_cred_secrets_access_id_fk` FOREIGN KEY (`credential_access_id`) REFERENCES `credential_access`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `shared_credential_secrets` ADD CONSTRAINT `shared_credential_secrets_target_user_id_users_id_fk` FOREIGN KEY (`target_user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `shared_credential_secrets` ADD CONSTRAINT `shared_credential_secrets_credential_id_ssh_credentials_id_fk` FOREIGN KEY (`credential_id`) REFERENCES `ssh_credentials`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX `idx_credential_access_user_id` ON `credential_access` (`user_id`);--> statement-breakpoint
CREATE INDEX `idx_credential_access_role_id` ON `credential_access` (`role_id`);--> statement-breakpoint
CREATE INDEX `idx_credential_access_credential_id` ON `credential_access` (`credential_id`);--> statement-breakpoint
CREATE INDEX `idx_shared_credential_secrets_target` ON `shared_credential_secrets` (`target_user_id`,`credential_id`);
+22
View File
@@ -0,0 +1,22 @@
CREATE TABLE `folder_access` (
`id` int AUTO_INCREMENT NOT NULL,
`owner_user_id` varchar(255) NOT NULL,
`folder` varchar(255) NOT NULL,
`user_id` varchar(255),
`role_id` int,
`granted_by` varchar(255) NOT NULL,
`permission_level` text NOT NULL DEFAULT ('connect'),
`expires_at` varchar(255),
`created_at` varchar(255) NOT NULL DEFAULT (CURRENT_TIMESTAMP),
CONSTRAINT `folder_access_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
ALTER TABLE `ssh_data` MODIFY COLUMN `folder` varchar(255);--> statement-breakpoint
ALTER TABLE `snippets` MODIFY COLUMN `folder` varchar(255);--> statement-breakpoint
ALTER TABLE `ssh_credentials` MODIFY COLUMN `folder` varchar(255);--> statement-breakpoint
ALTER TABLE `vault_profiles` MODIFY COLUMN `folder` varchar(255);--> statement-breakpoint
ALTER TABLE `folder_access` ADD CONSTRAINT `folder_access_owner_user_id_users_id_fk` FOREIGN KEY (`owner_user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `folder_access` ADD CONSTRAINT `folder_access_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `folder_access` ADD CONSTRAINT `folder_access_role_id_roles_id_fk` FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `folder_access` ADD CONSTRAINT `folder_access_granted_by_users_id_fk` FOREIGN KEY (`granted_by`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX `idx_folder_access_owner_folder` ON `folder_access` (`owner_user_id`,`folder`);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+35
View File
@@ -106,6 +106,41 @@
"when": 1786757023790,
"tag": "0014_bitter_nextwave",
"breakpoints": true
},
{
"idx": 15,
"version": "5",
"when": 1787576997080,
"tag": "0015_red_cobalt_man",
"breakpoints": true
},
{
"idx": 16,
"version": "5",
"when": 1787581983770,
"tag": "0016_unusual_tyrannus",
"breakpoints": true
},
{
"idx": 17,
"version": "5",
"when": 1787596414390,
"tag": "0017_spicy_proteus",
"breakpoints": true
},
{
"idx": 18,
"version": "5",
"when": 1787600445146,
"tag": "0018_fancy_barracuda",
"breakpoints": true
},
{
"idx": 19,
"version": "5",
"when": 1787602480861,
"tag": "0019_certain_archangel",
"breakpoints": true
}
]
}
+32
View File
@@ -0,0 +1,32 @@
CREATE TABLE "collab_room_members" (
"id" serial PRIMARY KEY NOT NULL,
"room_id" varchar(255) NOT NULL,
"user_id" varchar(255) NOT NULL,
"room_role" text DEFAULT 'member' NOT NULL,
"added_by" varchar(255),
"created_at" varchar(255) DEFAULT CURRENT_TIMESTAMP NOT NULL
);
--> statement-breakpoint
CREATE TABLE "collab_rooms" (
"id" varchar(255) PRIMARY KEY NOT NULL,
"name" varchar(255) NOT NULL,
"owner_user_id" varchar(255) NOT NULL,
"persistent" boolean DEFAULT false NOT NULL,
"presenter_user_id" varchar(255),
"stage_protocol" text,
"stage_host_id" integer,
"stage_share_id" varchar(255),
"created_at" varchar(255) DEFAULT CURRENT_TIMESTAMP NOT NULL,
"ended_at" text
);
--> statement-breakpoint
ALTER TABLE "collab_room_members" ADD CONSTRAINT "collab_room_members_room_id_collab_rooms_id_fk" FOREIGN KEY ("room_id") REFERENCES "public"."collab_rooms"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "collab_room_members" ADD CONSTRAINT "collab_room_members_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "collab_room_members" ADD CONSTRAINT "collab_room_members_added_by_users_id_fk" FOREIGN KEY ("added_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "collab_rooms" ADD CONSTRAINT "collab_rooms_owner_user_id_users_id_fk" FOREIGN KEY ("owner_user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "collab_rooms" ADD CONSTRAINT "collab_rooms_presenter_user_id_users_id_fk" FOREIGN KEY ("presenter_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "collab_rooms" ADD CONSTRAINT "collab_rooms_stage_host_id_ssh_data_id_fk" FOREIGN KEY ("stage_host_id") REFERENCES "public"."ssh_data"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "collab_rooms" ADD CONSTRAINT "collab_rooms_stage_share_id_session_shares_id_fk" FOREIGN KEY ("stage_share_id") REFERENCES "public"."session_shares"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "idx_collab_room_members_room_user" ON "collab_room_members" USING btree ("room_id","user_id");--> statement-breakpoint
CREATE INDEX "idx_collab_room_members_user" ON "collab_room_members" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "idx_collab_rooms_owner" ON "collab_rooms" USING btree ("owner_user_id");
@@ -0,0 +1,2 @@
ALTER TABLE "collab_rooms" ADD COLUMN "guest_link_token" varchar(255);--> statement-breakpoint
CREATE UNIQUE INDEX "idx_collab_rooms_guest_token" ON "collab_rooms" USING btree ("guest_link_token");
@@ -0,0 +1,14 @@
CREATE TABLE "secret_sources" (
"id" varchar(255) PRIMARY KEY NOT NULL,
"user_id" varchar(255) NOT NULL,
"name" varchar(255) NOT NULL,
"kind" text DEFAULT 'onepassword-connect' NOT NULL,
"base_url" text NOT NULL,
"token" text NOT NULL,
"shared" boolean DEFAULT false NOT NULL,
"created_at" varchar(255) DEFAULT CURRENT_TIMESTAMP NOT NULL,
"updated_at" varchar(255) DEFAULT CURRENT_TIMESTAMP NOT NULL
);
--> statement-breakpoint
ALTER TABLE "secret_sources" ADD CONSTRAINT "secret_sources_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_secret_sources_user" ON "secret_sources" USING btree ("user_id");
@@ -0,0 +1,40 @@
CREATE TABLE "credential_access" (
"id" serial PRIMARY KEY NOT NULL,
"credential_id" integer NOT NULL,
"user_id" varchar(255),
"role_id" integer,
"granted_by" varchar(255) NOT NULL,
"permission_level" text DEFAULT 'use' NOT NULL,
"expires_at" varchar(255),
"created_at" varchar(255) DEFAULT CURRENT_TIMESTAMP NOT NULL
);
--> statement-breakpoint
CREATE TABLE "shared_credential_secrets" (
"id" serial PRIMARY KEY NOT NULL,
"credential_access_id" integer NOT NULL,
"target_user_id" varchar(255) NOT NULL,
"credential_id" integer NOT NULL,
"encrypted_username" text,
"auth_type" text DEFAULT 'password' NOT NULL,
"encrypted_password" text,
"encrypted_key" text,
"encrypted_key_password" text,
"key_type" text,
"public_key" text,
"cert_public_key" text,
"created_at" varchar(255) DEFAULT CURRENT_TIMESTAMP NOT NULL,
"updated_at" varchar(255) DEFAULT CURRENT_TIMESTAMP NOT NULL
);
--> statement-breakpoint
ALTER TABLE "credential_access" ADD CONSTRAINT "credential_access_credential_id_ssh_credentials_id_fk" FOREIGN KEY ("credential_id") REFERENCES "public"."ssh_credentials"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "credential_access" ADD CONSTRAINT "credential_access_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "credential_access" ADD CONSTRAINT "credential_access_role_id_roles_id_fk" FOREIGN KEY ("role_id") REFERENCES "public"."roles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "credential_access" ADD CONSTRAINT "credential_access_granted_by_users_id_fk" FOREIGN KEY ("granted_by") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "shared_credential_secrets" ADD CONSTRAINT "shared_credential_secrets_credential_access_id_credential_access_id_fk" FOREIGN KEY ("credential_access_id") REFERENCES "public"."credential_access"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "shared_credential_secrets" ADD CONSTRAINT "shared_credential_secrets_target_user_id_users_id_fk" FOREIGN KEY ("target_user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "shared_credential_secrets" ADD CONSTRAINT "shared_credential_secrets_credential_id_ssh_credentials_id_fk" FOREIGN KEY ("credential_id") REFERENCES "public"."ssh_credentials"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_credential_access_user_id" ON "credential_access" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "idx_credential_access_role_id" ON "credential_access" USING btree ("role_id");--> statement-breakpoint
CREATE INDEX "idx_credential_access_credential_id" ON "credential_access" USING btree ("credential_id");--> statement-breakpoint
CREATE UNIQUE INDEX "idx_shared_credential_secrets_scope" ON "shared_credential_secrets" USING btree ("credential_access_id","target_user_id");--> statement-breakpoint
CREATE INDEX "idx_shared_credential_secrets_target" ON "shared_credential_secrets" USING btree ("target_user_id","credential_id");
+21
View File
@@ -0,0 +1,21 @@
CREATE TABLE "folder_access" (
"id" serial PRIMARY KEY NOT NULL,
"owner_user_id" varchar(255) NOT NULL,
"folder" varchar(255) NOT NULL,
"user_id" varchar(255),
"role_id" integer,
"granted_by" varchar(255) NOT NULL,
"permission_level" text DEFAULT 'connect' NOT NULL,
"expires_at" varchar(255),
"created_at" varchar(255) DEFAULT CURRENT_TIMESTAMP NOT NULL
);
--> statement-breakpoint
ALTER TABLE "ssh_data" ALTER COLUMN "folder" SET DATA TYPE varchar(255);--> statement-breakpoint
ALTER TABLE "snippets" ALTER COLUMN "folder" SET DATA TYPE varchar(255);--> statement-breakpoint
ALTER TABLE "ssh_credentials" ALTER COLUMN "folder" SET DATA TYPE varchar(255);--> statement-breakpoint
ALTER TABLE "vault_profiles" ALTER COLUMN "folder" SET DATA TYPE varchar(255);--> statement-breakpoint
ALTER TABLE "folder_access" ADD CONSTRAINT "folder_access_owner_user_id_users_id_fk" FOREIGN KEY ("owner_user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "folder_access" ADD CONSTRAINT "folder_access_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "folder_access" ADD CONSTRAINT "folder_access_role_id_roles_id_fk" FOREIGN KEY ("role_id") REFERENCES "public"."roles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "folder_access" ADD CONSTRAINT "folder_access_granted_by_users_id_fk" FOREIGN KEY ("granted_by") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_folder_access_owner_folder" ON "folder_access" USING btree ("owner_user_id","folder");
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+35
View File
@@ -106,6 +106,41 @@
"when": 1786757021444,
"tag": "0014_unusual_maelstrom",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1787576994553,
"tag": "0015_early_spitfire",
"breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1787581981251,
"tag": "0016_slippery_anita_blake",
"breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1787596411353,
"tag": "0017_curved_pet_avengers",
"breakpoints": true
},
{
"idx": 18,
"version": "7",
"when": 1787600442827,
"tag": "0018_puzzling_night_nurse",
"breakpoints": true
},
{
"idx": 19,
"version": "7",
"when": 1787602478567,
"tag": "0019_oval_puff_adder",
"breakpoints": true
}
]
}
@@ -0,0 +1,32 @@
CREATE TABLE `collab_room_members` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`room_id` text NOT NULL,
`user_id` text NOT NULL,
`room_role` text DEFAULT 'member' NOT NULL,
`added_by` text,
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
FOREIGN KEY (`room_id`) REFERENCES `collab_rooms`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`added_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE UNIQUE INDEX `idx_collab_room_members_room_user` ON `collab_room_members` (`room_id`,`user_id`);--> statement-breakpoint
CREATE INDEX `idx_collab_room_members_user` ON `collab_room_members` (`user_id`);--> statement-breakpoint
CREATE TABLE `collab_rooms` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`owner_user_id` text NOT NULL,
`persistent` integer DEFAULT false NOT NULL,
`presenter_user_id` text,
`stage_protocol` text,
`stage_host_id` integer,
`stage_share_id` text,
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
`ended_at` text,
FOREIGN KEY (`owner_user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`presenter_user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null,
FOREIGN KEY (`stage_host_id`) REFERENCES `ssh_data`(`id`) ON UPDATE no action ON DELETE set null,
FOREIGN KEY (`stage_share_id`) REFERENCES `session_shares`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE INDEX `idx_collab_rooms_owner` ON `collab_rooms` (`owner_user_id`);
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE `collab_rooms` ADD `guest_link_token` text;--> statement-breakpoint
CREATE UNIQUE INDEX `idx_collab_rooms_guest_token` ON `collab_rooms` (`guest_link_token`);
@@ -0,0 +1,14 @@
CREATE TABLE `secret_sources` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`name` text NOT NULL,
`kind` text DEFAULT 'onepassword-connect' NOT NULL,
`base_url` text NOT NULL,
`token` text NOT NULL,
`shared` integer DEFAULT false NOT NULL,
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
`updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `idx_secret_sources_user` ON `secret_sources` (`user_id`);
@@ -0,0 +1,40 @@
CREATE TABLE `credential_access` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`credential_id` integer NOT NULL,
`user_id` text,
`role_id` integer,
`granted_by` text NOT NULL,
`permission_level` text DEFAULT 'use' NOT NULL,
`expires_at` text,
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
FOREIGN KEY (`credential_id`) REFERENCES `ssh_credentials`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`granted_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `idx_credential_access_user_id` ON `credential_access` (`user_id`);--> statement-breakpoint
CREATE INDEX `idx_credential_access_role_id` ON `credential_access` (`role_id`);--> statement-breakpoint
CREATE INDEX `idx_credential_access_credential_id` ON `credential_access` (`credential_id`);--> statement-breakpoint
CREATE TABLE `shared_credential_secrets` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`credential_access_id` integer NOT NULL,
`target_user_id` text NOT NULL,
`credential_id` integer NOT NULL,
`encrypted_username` text,
`auth_type` text DEFAULT 'password' NOT NULL,
`encrypted_password` text,
`encrypted_key` text(16384),
`encrypted_key_password` text,
`key_type` text,
`public_key` text(4096),
`cert_public_key` text(8192),
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
`updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
FOREIGN KEY (`credential_access_id`) REFERENCES `credential_access`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`target_user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`credential_id`) REFERENCES `ssh_credentials`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `idx_shared_credential_secrets_scope` ON `shared_credential_secrets` (`credential_access_id`,`target_user_id`);--> statement-breakpoint
CREATE INDEX `idx_shared_credential_secrets_target` ON `shared_credential_secrets` (`target_user_id`,`credential_id`);
+17
View File
@@ -0,0 +1,17 @@
CREATE TABLE `folder_access` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`owner_user_id` text NOT NULL,
`folder` text NOT NULL,
`user_id` text,
`role_id` integer,
`granted_by` text NOT NULL,
`permission_level` text DEFAULT 'connect' NOT NULL,
`expires_at` text,
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
FOREIGN KEY (`owner_user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`granted_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `idx_folder_access_owner_folder` ON `folder_access` (`owner_user_id`,`folder`);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+35
View File
@@ -78,6 +78,41 @@
"when": 1786757019248,
"tag": "0010_mean_queen_noir",
"breakpoints": true
},
{
"idx": 11,
"version": "6",
"when": 1787576992255,
"tag": "0011_wakeful_titanium_man",
"breakpoints": true
},
{
"idx": 12,
"version": "6",
"when": 1787581978969,
"tag": "0012_yellow_firedrake",
"breakpoints": true
},
{
"idx": 13,
"version": "6",
"when": 1787596405780,
"tag": "0013_tranquil_master_mold",
"breakpoints": true
},
{
"idx": 14,
"version": "6",
"when": 1787600440707,
"tag": "0014_lean_doctor_octopus",
"breakpoints": true
},
{
"idx": 15,
"version": "6",
"when": 1787602476319,
"tag": "0015_glossy_shotgun",
"breakpoints": true
}
]
}
+1 -1
View File
@@ -82,7 +82,7 @@
"arch": ["x64", "arm64", "armv7l"]
}
],
"icon": "public/icon.png",
"icon": "public/icons",
"category": "Development",
"executableName": "termix",
"maintainer": "Termix <mail@termix.site>",
+21
View File
@@ -0,0 +1,21 @@
function resolveLocalShell(platform, requestedShell, env = process.env) {
if (platform === "win32") {
if (requestedShell === "wsl") {
return { file: "wsl.exe", args: [] };
}
return {
file: env.TERMIX_LOCAL_SHELL || "powershell.exe",
args: ["-NoLogo"],
};
}
return {
file:
env.TERMIX_LOCAL_SHELL ||
env.SHELL ||
(platform === "darwin" ? "/bin/zsh" : "/bin/bash"),
args: ["-l"],
};
}
module.exports = { resolveLocalShell };
+52 -28
View File
@@ -21,7 +21,7 @@ const net = require("net");
const tls = require("tls");
const zlib = require("zlib");
const crypto = require("crypto");
const { URL } = require("url");
const { URL, pathToFileURL } = require("url");
const { fork, spawn } = require("child_process");
const pty = require("node-pty");
const WebSocket = require("ws");
@@ -30,25 +30,10 @@ const { launchNativeRdp } = require("./native-rdp.cjs");
const { isCloseActiveTabInput } = require("./keyboard-shortcuts.cjs");
const { quitApp } = require("./app-quit.cjs");
const { selectLinuxPasswordStore } = require("./linux-password-store.cjs");
const { resolveLocalShell } = require("./local-shell.cjs");
const localTerminalSessions = new Map();
function localShell() {
if (process.platform === "win32") {
return {
file: process.env.TERMIX_LOCAL_SHELL || "powershell.exe",
args: ["-NoLogo"],
};
}
return {
file:
process.env.TERMIX_LOCAL_SHELL ||
process.env.SHELL ||
(process.platform === "darwin" ? "/bin/zsh" : "/bin/bash"),
args: ["-l"],
};
}
function ownedLocalTerminal(event, sessionId) {
if (typeof sessionId !== "string" || !/^[a-f0-9-]{36}$/.test(sessionId)) {
return null;
@@ -537,7 +522,11 @@ function httpFetch(url, options = {}) {
method: options.method || "GET",
headers: options.headers || {},
timeout: options.timeout || 10000,
...(isHttps ? getTlsVerificationOptions(url) : {}),
...(isHttps
? options.allowInvalidCertificate
? { rejectUnauthorized: false }
: getTlsVerificationOptions(url)
: {}),
};
const req = client.request(url, requestOptions, (res) => {
@@ -547,6 +536,16 @@ function httpFetch(url, options = {}) {
// Node's http/https modules never auto-decompress, so an unhandled
// content-encoding here silently turns the body into garbage bytes.
let stream = res;
const maxResponseBytes = options.maxResponseBytes || 10 * 1024 * 1024;
let responseBytes = 0;
let settled = false;
const fail = (error) => {
if (settled) return;
settled = true;
stream.destroy();
req.destroy();
reject(error);
};
const encoding = (res.headers["content-encoding"] || "")
.toLowerCase()
.trim();
@@ -563,8 +562,17 @@ function httpFetch(url, options = {}) {
return;
}
stream.on("data", (chunk) => chunks.push(chunk));
stream.on("data", (chunk) => {
responseBytes += chunk.length;
if (responseBytes > maxResponseBytes) {
fail(new Error(`Response exceeds ${maxResponseBytes} bytes`));
return;
}
chunks.push(chunk);
});
stream.on("end", () => {
if (settled) return;
settled = true;
const data = Buffer.concat(chunks).toString("utf8");
resolve({
ok: res.statusCode >= 200 && res.statusCode < 300,
@@ -573,7 +581,7 @@ function httpFetch(url, options = {}) {
json: () => Promise.resolve(JSON.parse(data)),
});
});
stream.on("error", reject);
stream.on("error", fail);
});
req.on("error", reject);
@@ -1204,11 +1212,12 @@ function createWindow() {
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
webSecurity: false,
sandbox: true,
webSecurity: true,
preload: path.join(__dirname, "preload.js"),
partition: termixSessionPartition,
allowRunningInsecureContent: true,
webviewTag: true,
allowRunningInsecureContent: false,
webviewTag: false,
offscreen: false,
},
show: true,
@@ -1388,6 +1397,13 @@ function createWindow() {
}
return { action: "deny" };
});
mainWindow.webContents.on("will-navigate", (event, url) => {
const allowedUrl = isDev
? url.startsWith("http://localhost:5173/")
: url === pathToFileURL(path.join(appRoot, "dist", "index.html")).href;
if (!allowedUrl) event.preventDefault();
});
}
ipcMain.handle("get-app-version", () => {
@@ -1657,7 +1673,7 @@ ipcMain.handle("clear-remote-sync-config", async () => {
return result;
});
ipcMain.handle("save-remote-sync-jwt", (_event, token) => {
ipcMain.handle("save-remote-sync-jwt", async (_event, token) => {
const result = remoteSync.saveRemoteSyncJwt(token);
if (result.success) {
remoteSync.getRemoteSyncEngine()?.updateStatus({
@@ -1665,7 +1681,8 @@ ipcMain.handle("save-remote-sync-jwt", (_event, token) => {
needsReauth: false,
lastError: null,
});
remoteSync.getRemoteSyncEngine()?.syncNow();
const status = await remoteSync.getRemoteSyncEngine()?.syncNow();
return { ...result, status: status || null };
}
return result;
});
@@ -2928,7 +2945,7 @@ ipcMain.handle("local-terminal-start", (event, dimensions = {}) => {
const cols = Math.min(500, Math.max(2, Number(dimensions.cols) || 80));
const rows = Math.min(300, Math.max(1, Number(dimensions.rows) || 24));
const sessionId = crypto.randomUUID();
const shellConfig = localShell();
const shellConfig = resolveLocalShell(process.platform, dimensions.shell);
const child = pty.spawn(shellConfig.file, shellConfig.args, {
name: "xterm-256color",
cols,
@@ -3134,7 +3151,11 @@ ipcMain.handle("close-external-editor", (_event, editId) => {
}
});
ipcMain.handle("test-server-connection", async (event, serverUrl) => {
async function testServerConnection(
_event,
serverUrl,
allowInvalidCertificate = false,
) {
try {
const normalizedServerUrl = serverUrl.replace(/\/$/, "");
const healthUrl = `${normalizedServerUrl}/health`;
@@ -3154,6 +3175,7 @@ ipcMain.handle("test-server-connection", async (event, serverUrl) => {
const response = await httpFetch(healthUrl, {
method: "GET",
timeout: 10000,
allowInvalidCertificate,
});
const data = await response.text();
@@ -3207,7 +3229,9 @@ ipcMain.handle("test-server-connection", async (event, serverUrl) => {
} catch (error) {
return { success: false, error: error.message };
}
});
}
ipcMain.handle("test-server-connection", testServerConnection);
function createMenu() {
if (process.platform === "darwin") {
+24 -1
View File
@@ -1,5 +1,28 @@
const { contextBridge, ipcRenderer } = require("electron");
const ALLOWED_INVOKE_CHANNELS = new Set([
"check-electron-update",
"clear-remote-sync-config",
"get-desktop-settings",
"get-legacy-server-config",
"get-remote-sync-config",
"get-remote-sync-jwt",
"get-remote-sync-status",
"get-remote-sync-user-info",
"remote-sync-now",
"save-desktop-settings",
"save-remote-sync-config",
"save-remote-sync-jwt",
"test-server-connection",
]);
function invokeAllowed(channel, ...args) {
if (!ALLOWED_INVOKE_CHANNELS.has(channel)) {
return Promise.reject(new Error(`IPC channel is not allowed: ${channel}`));
}
return ipcRenderer.invoke(channel, ...args);
}
contextBridge.exposeInMainWorld("electronAPI", {
getAppVersion: () => ipcRenderer.invoke("get-app-version"),
getPlatform: () => ipcRenderer.invoke("get-platform"),
@@ -103,7 +126,7 @@ contextBridge.exposeInMainWorld("electronAPI", {
return () => ipcRenderer.removeListener(channel, listener);
},
invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args),
invoke: invokeAllowed,
});
contextBridge.exposeInMainWorld("electronClipboard", {
+24 -2
View File
@@ -37,7 +37,20 @@ function writeJson(filePath, value) {
if (!fs.existsSync(userDataPath)) {
fs.mkdirSync(userDataPath, { recursive: true });
}
fs.writeFileSync(filePath, JSON.stringify(value, null, 2));
const temporaryPath = `${filePath}.${process.pid}-${Date.now()}.tmp`;
try {
fs.writeFileSync(temporaryPath, JSON.stringify(value, null, 2), {
mode: 0o600,
});
fs.renameSync(temporaryPath, filePath);
} catch (error) {
try {
fs.unlinkSync(temporaryPath);
} catch {
// already absent
}
throw error;
}
}
function getDesktopSettingsPath() {
@@ -191,6 +204,7 @@ class RemoteSyncEngine {
this.getMainWindow = getMainWindow;
this.localJwt = null;
this.timer = null;
this.startupTimer = null;
this.syncing = false;
this.status = {
connected: false,
@@ -220,11 +234,15 @@ class RemoteSyncEngine {
const config = getRemoteSyncConfig();
this.status.connected = !!config?.serverUrl;
if (this.timer) clearInterval(this.timer);
if (this.startupTimer) clearTimeout(this.startupTimer);
this.timer = setInterval(() => this.syncNow(), SYNC_INTERVAL_MS);
if (config?.serverUrl) {
// Fire an initial sync shortly after startup rather than waiting a
// full interval, but don't block app boot on it.
setTimeout(() => this.syncNow(), 5000);
this.startupTimer = setTimeout(() => {
this.startupTimer = null;
this.syncNow();
}, 5000);
}
}
@@ -233,6 +251,10 @@ class RemoteSyncEngine {
clearInterval(this.timer);
this.timer = null;
}
if (this.startupTimer) {
clearTimeout(this.startupTimer);
this.startupTimer = null;
}
}
async syncNow() {
+1 -3
View File
@@ -9,6 +9,7 @@
/>
<meta name="theme-color" content="#09090b" />
<meta name="termix-base-path" content="" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta
name="apple-mobile-web-app-status-bar-style"
@@ -68,9 +69,6 @@
</style>
</head>
<body>
<script>
window.__TERMIX_BASE_PATH__ = "";
</script>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
+389 -279
View File
File diff suppressed because it is too large Load Diff
+22 -21
View File
@@ -1,7 +1,7 @@
{
"name": "termix",
"private": true,
"version": "2.7.0",
"version": "2.7.1",
"description": "Self-hosted SSH and remote desktop management.",
"author": "Karmaa",
"main": "electron/main.cjs",
@@ -49,12 +49,12 @@
"@anthropic-ai/sdk": "^0.116.0",
"@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.3.2",
"@tanstack/react-virtual": "^3.14.9",
"@tanstack/react-virtual": "^3.14.10",
"@types/compression": "^1.8.1",
"@types/ldapjs": "^3.0.6",
"axios": "^1.19.0",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^13.0.2",
"better-sqlite3": "^13.0.3",
"body-parser": "^2.3.0",
"chalk": "^6.0.0",
"compression": "^1.8.1",
@@ -64,33 +64,34 @@
"drizzle-orm": "^0.45.2",
"express": "^5.2.1",
"guacamole-lite": "^1.2.0",
"jose": "^6.2.8",
"jose": "^6.2.9",
"js-yaml": "^5.2.3",
"jsonwebtoken": "^9.0.3",
"jszip": "^3.10.1",
"ldapjs": "^3.0.7",
"motion": "^12.43.0",
"motion": "^13.1.1",
"multer": "^2.2.0",
"mysql2": "^3.23.2",
"mysql2": "^3.23.4",
"nanoid": "^6.0.1",
"node-pty": "^1.1.0",
"pg": "^8.22.0",
"qrcode": "^1.5.4",
"redis": "^6.2.1",
"serialport": "^13.0.0",
"sharp": "^0.35.3",
"socks": "^2.8.7",
"speakeasy": "^2.0.0",
"ssh2": "^1.17.0",
"undici": "^8.10.0",
"ws": "^8.21.1"
"ws": "^8.21.3"
},
"devDependencies": {
"@codemirror/autocomplete": "^6.20.3",
"@codemirror/commands": "^6.10.4",
"@codemirror/search": "^6.7.1",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.43.7",
"@commitlint/cli": "^21.2.1",
"@codemirror/view": "^6.43.9",
"@commitlint/cli": "^21.2.2",
"@commitlint/config-conventional": "^21.2.0",
"@deadendjs/swagger-jsdoc": "^8.1.2",
"@electron/notarize": "^3.1.1",
@@ -119,9 +120,9 @@
"@radix-ui/react-tooltip": "^1.2.16",
"@tailwindcss/vite": "^4.3.3",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^7.0.0",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@testing-library/user-event": "^14.6.5",
"@types/better-sqlite3": "^9.6.0",
"@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.19",
@@ -142,8 +143,8 @@
"@uiw/codemirror-theme-github": "^4.25.11",
"@uiw/react-codemirror": "^4.25.11",
"@vitejs/plugin-react": "^6.0.5",
"@vitest/coverage-v8": "^4.1.10",
"@vitest/ui": "^4.1.10",
"@vitest/coverage-v8": "^4.1.11",
"@vitest/ui": "^4.1.11",
"@xterm/addon-clipboard": "^0.2.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-search": "^0.16.0",
@@ -153,14 +154,14 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"concurrently": "^10.0.4",
"cytoscape": "^3.34.0",
"concurrently": "^10.0.5",
"cytoscape": "^3.34.1",
"drizzle-kit": "^0.31.10",
"electron": "^43.2.0",
"electron-builder": "^26.15.3",
"eslint": "^10.8.0",
"eslint": "^10.8.1",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.3",
"eslint-plugin-react-refresh": "^0.5.4",
"eslint-plugin-unused-imports": "^4.4.1",
"globals": "^17.8.0",
"guacamole-common-js": "^1.5.0",
@@ -177,7 +178,7 @@
"react-dom": "^19.2.8",
"react-h5-audio-player": "^3.10.2",
"react-hook-form": "^7.84.0",
"react-i18next": "^17.0.11",
"react-i18next": "^17.0.12",
"react-icons": "^5.7.0",
"react-markdown": "^10.1.0",
"react-pdf": "^10.4.1",
@@ -185,15 +186,15 @@
"react-syntax-highlighter": "^16.1.1",
"react-xtermjs": "^1.0.10",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.7",
"sonner": "^2.0.8",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.4",
"tw-animate-css": "^1.4.0",
"typescript": "~6.0.3",
"typescript-eslint": "^8.66.0",
"vite": "^8.2.0",
"vite": "^8.2.2",
"vite-plugin-svgr": "^5.2.0",
"vitest": "^4.1.10"
"vitest": "^4.1.11"
},
"lint-staged": {
"*.{ts,tsx}": [
@@ -0,0 +1,21 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const main = readFileSync("electron/main.cjs", "utf8");
const preload = readFileSync("electron/preload.js", "utf8");
describe("Electron security boundary", () => {
it("keeps the renderer sandbox and browser security enabled", () => {
expect(main).toContain("sandbox: true");
expect(main).toContain("webSecurity: true");
expect(main).toContain("allowRunningInsecureContent: false");
expect(main).toContain("webviewTag: false");
});
it("does not expose an unrestricted IPC invoke primitive", () => {
expect(preload).toContain("invoke: invokeAllowed");
expect(preload).not.toContain(
"invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args)",
);
});
});
+2 -2
View File
@@ -171,8 +171,8 @@ function transform(source, dialect) {
);
const imports = isPg
? `import {\n pgTable,\n text,\n varchar,\n integer,\n serial,\n boolean,\n doublePrecision,\n index,\n uniqueIndex,\n type AnyPgColumn,\n} from "drizzle-orm/pg-core";`
: `import {\n mysqlTable,\n text,\n varchar,\n int,\n boolean,\n double,\n index,\n uniqueIndex,\n type AnyMySqlColumn,\n} from "drizzle-orm/mysql-core";`;
? `import {\n pgTable,\n text,\n varchar,\n integer,\n serial,\n boolean,\n doublePrecision,\n index,\n uniqueIndex,\n foreignKey,\n type AnyPgColumn,\n} from "drizzle-orm/pg-core";`
: `import {\n mysqlTable,\n text,\n varchar,\n int,\n boolean,\n double,\n index,\n uniqueIndex,\n foreignKey,\n type AnyMySqlColumn,\n} from "drizzle-orm/mysql-core";`;
out = out.replace(
/import\s*\{[^}]*\}\s*from\s*"drizzle-orm\/sqlite-core";/,
+27
View File
@@ -0,0 +1,27 @@
const fs = require("node:fs");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
function findPackage(start) {
let directory = path.dirname(start);
while (directory !== path.dirname(directory)) {
const manifest = path.join(directory, "package.json");
if (fs.existsSync(manifest)) return JSON.parse(fs.readFileSync(manifest));
directory = path.dirname(directory);
}
throw new Error("Could not locate the installed sharp package manifest");
}
const sharpPackage = findPackage(require.resolve("sharp"));
const packages = [
"@img/sharp-darwin-arm64",
"@img/sharp-darwin-x64",
"@img/sharp-libvips-darwin-arm64",
"@img/sharp-libvips-darwin-x64",
].map((name) => `${name}@${sharpPackage.optionalDependencies[name]}`);
execFileSync(
process.platform === "win32" ? "npm.cmd" : "npm",
["install", "--force", "--no-save", ...packages],
{ stdio: "inherit" },
);
+34 -2
View File
@@ -25,11 +25,20 @@ const clientConnectionPath = path.join(
"lib",
"ClientConnection.js",
);
const serverPath = path.join(
__dirname,
"..",
"node_modules",
"guacamole-lite",
"lib",
"Server.js",
);
if (
!fs.existsSync(guacdClientPath) ||
!fs.existsSync(cryptPath) ||
!fs.existsSync(clientConnectionPath)
!fs.existsSync(clientConnectionPath) ||
!fs.existsSync(serverPath)
) {
console.log("[patch-guacamole-lite] File not found, skipping");
process.exit(0);
@@ -52,6 +61,7 @@ function missingAnchor(patch) {
let guacdClientContent = fs.readFileSync(guacdClientPath, "utf8");
let cryptContent = fs.readFileSync(cryptPath, "utf8");
let clientConnectionContent = fs.readFileSync(clientConnectionPath, "utf8");
let serverContent = fs.readFileSync(serverPath, "utf8");
// Patch 1: protocol version negotiation.
// guacamole-lite originally only accepted 1.0.0/1.1.0. Support the protocol
@@ -358,6 +368,27 @@ if (!clientConnectionContent.includes("compiledSettings.readOnly")) {
patched = true;
}
// Patch 9: ClientConnection closes malformed-token WebSockets in its
// constructor, but Server.newConnection still called connect() afterwards.
// That dereferenced the absent connection settings and turned one bad token
// into an unhandled rejection that could terminate the backend process.
const oldConnectionSetup =
" newConnection.on('ready', async (clientConnection) => {";
const newConnectionSetup =
" if (!newConnection.connectionSettings || !newConnection.connectionSettings.connection) {\n" +
" return;\n" +
" }\n" +
"\n" +
oldConnectionSetup;
if (!serverContent.includes("!newConnection.connectionSettings.connection")) {
if (!serverContent.includes(oldConnectionSetup)) {
missingAnchor("invalid-token connection guard");
}
serverContent = serverContent.replace(oldConnectionSetup, newConnectionSetup);
patched = true;
}
if (!patched) {
console.log("[patch-guacamole-lite] Already patched");
process.exit(0);
@@ -366,6 +397,7 @@ if (!patched) {
fs.writeFileSync(guacdClientPath, guacdClientContent);
fs.writeFileSync(cryptPath, cryptContent);
fs.writeFileSync(clientConnectionPath, clientConnectionContent);
fs.writeFileSync(serverPath, serverContent);
console.log(
"[patch-guacamole-lite] Patched protocol VERSION_1_3_0/1_5_0 support, name handshake, required arguments, UTF-8 token decrypt, and read-only join input filtering",
"[patch-guacamole-lite] Patched protocol negotiation, name handshake, required arguments, UTF-8 token decrypt, read-only joins, and malformed-token handling",
);
+45
View File
@@ -5,6 +5,7 @@ import { describe, expect, it, vi } from "vitest";
const require = createRequire(import.meta.url);
const GuacdClient = require("../node_modules/guacamole-lite/lib/GuacdClient.js");
const GuacamoleLite = require("../node_modules/guacamole-lite/lib/Server.js");
type PatchedGuacdClient = {
connectionSettings: Record<string, unknown>;
@@ -26,6 +27,50 @@ function createPatchedClient(
}
describe("patch-guacamole-lite", () => {
it("rejects a malformed token without starting or retaining a connection", async () => {
const server = Object.assign(Object.create(GuacamoleLite.prototype), {
connectionsCount: 0,
clientOptions: {
crypt: {
cypher: "AES-256-CBC",
key: Buffer.alloc(32, 7),
},
log: {
level: 0,
stdLog: vi.fn(),
errorLog: vi.fn(),
},
},
callbacks: {
processConnectionSettings: vi.fn(),
},
extractGuacdOptions: vi.fn(async () => ({
guacdOptions: { host: "127.0.0.1", port: 4822 },
connectionInfo: null,
isJoin: false,
targetSessionId: null,
})),
activeConnections: new Map(),
emit: vi.fn(),
});
const webSocket = {
OPEN: 1,
readyState: 1,
send: vi.fn(),
close: vi.fn(),
on: vi.fn(),
removeAllListeners: vi.fn(),
};
await server.newConnection(webSocket, {
url: "/guacamole/websocket/?token=not-an-encrypted-token",
});
expect(webSocket.close).toHaveBeenCalledOnce();
expect(server.activeConnections.size).toBe(0);
expect(server.emit).not.toHaveBeenCalledWith("open", expect.anything());
});
it("handles guacd dynamic argument requests", () => {
const guacdClientPath = path.join(
process.cwd(),
+24 -10
View File
@@ -30,8 +30,11 @@ const patches = [
file: "xterm.mjs",
replacements: [
[
'this._compositionPosition={start:0,end:0},this._dataAlreadySent=""',
'this._compositionPosition={start:0,end:0},this._preCompositionValue="",this._dataAlreadySent=""',
[
'this._compositionPosition={start:0,end:0},this._dataAlreadySent=""',
'this._compositionPosition={start:0,end:0},this._preCompositionValue="",this._dataAlreadySent=""',
],
'this._compositionPosition={start:0,end:0},this._preCompositionValue="",this._pendingTextareaValue=null,this._dataAlreadySent=""',
],
[
'this._compositionPosition.start=this._textarea.value.length,this._compositionView.textContent=""',
@@ -46,8 +49,11 @@ const patches = [
"e.start+=this._dataAlreadySent.length;if(this._isComposing)i=this._textarea.value.substring(e.start,this._compositionPosition.start);else{const t=this._textarea.value;if(t.length<s.length){let e=0;const r=Math.min(t.length,s.length);for(;e<r&&t.charCodeAt(e)===s.charCodeAt(e);)e++;i=b.DEL.repeat(s.length-e)+t.substring(e)}else i=t.substring(e.start)}i.length>0&&",
],
[
'_handleAnyTextareaChanges(){let t=this._textarea.value;setTimeout(()=>{if(!this._isComposing){let e=this._textarea.value,i=e.replace(t,"");this._dataAlreadySent=i,e.length>t.length?this._coreService.triggerDataEvent(i,!0):e.length<t.length?this._coreService.triggerDataEvent(`${b.DEL}`,!0):e.length===t.length&&e!==t&&this._coreService.triggerDataEvent(e,!0)}},0)}',
"_handleAnyTextareaChanges(){let t=this._textarea.value;setTimeout(()=>{if(!this._isComposing){let e=this._textarea.value,r=0;const n=Math.min(e.length,t.length);for(;r<n&&e.charCodeAt(r)===t.charCodeAt(r);)r++;let i=e.length<t.length?b.DEL.repeat(t.length-r)+e.substring(r):e.substring(r);this._dataAlreadySent=i,i.length>0&&this._coreService.triggerDataEvent(i,!0)}},0)}",
[
'_handleAnyTextareaChanges(){let t=this._textarea.value;setTimeout(()=>{if(!this._isComposing){let e=this._textarea.value,i=e.replace(t,"");this._dataAlreadySent=i,e.length>t.length?this._coreService.triggerDataEvent(i,!0):e.length<t.length?this._coreService.triggerDataEvent(`${b.DEL}`,!0):e.length===t.length&&e!==t&&this._coreService.triggerDataEvent(e,!0)}},0)}',
"_handleAnyTextareaChanges(){let t=this._textarea.value;setTimeout(()=>{if(!this._isComposing){let e=this._textarea.value,r=0;const n=Math.min(e.length,t.length);for(;r<n&&e.charCodeAt(r)===t.charCodeAt(r);)r++;let i=e.length<t.length?b.DEL.repeat(t.length-r)+e.substring(r):e.substring(r);this._dataAlreadySent=i,i.length>0&&this._coreService.triggerDataEvent(i,!0)}},0)}",
],
"_handleAnyTextareaChanges(){if(this._pendingTextareaValue!==null)return;this._pendingTextareaValue=this._textarea.value,setTimeout(()=>{const t=this._pendingTextareaValue;this._pendingTextareaValue=null;if(!this._isComposing){let e=this._textarea.value,r=0;const n=Math.min(e.length,t.length);for(;r<n&&e.charCodeAt(r)===t.charCodeAt(r);)r++;let i=e.length<t.length?b.DEL.repeat(t.length-r)+e.substring(r):e.substring(r);this._dataAlreadySent=i,i.length>0&&this._coreService.triggerDataEvent(i,!0)}},0)}",
],
],
},
@@ -55,8 +61,11 @@ const patches = [
file: "xterm.js",
replacements: [
[
'this._compositionPosition={start:0,end:0},this._dataAlreadySent=""',
'this._compositionPosition={start:0,end:0},this._preCompositionValue="",this._dataAlreadySent=""',
[
'this._compositionPosition={start:0,end:0},this._dataAlreadySent=""',
'this._compositionPosition={start:0,end:0},this._preCompositionValue="",this._dataAlreadySent=""',
],
'this._compositionPosition={start:0,end:0},this._preCompositionValue="",this._pendingTextareaValue=null,this._dataAlreadySent=""',
],
[
'this._compositionPosition.start=this._textarea.value.length,this._compositionView.textContent=""',
@@ -71,8 +80,11 @@ const patches = [
"e.start+=this._dataAlreadySent.length;this._isComposing?t=this._textarea.value.substring(e.start,this._compositionPosition.start):(()=>{const s=this._textarea.value;if(s.length<i.length){let e=0;const r=Math.min(s.length,i.length);for(;e<r&&s.charCodeAt(e)===i.charCodeAt(e);)e++;t=a.C0.DEL.repeat(i.length-e)+s.substring(e)}else t=s.substring(e.start)})(),t.length>0&&",
],
[
'_handleAnyTextareaChanges(){const e=this._textarea.value;setTimeout((()=>{if(!this._isComposing){const t=this._textarea.value,i=t.replace(e,"");this._dataAlreadySent=i,t.length>e.length?this._coreService.triggerDataEvent(i,!0):t.length<e.length?this._coreService.triggerDataEvent(`${a.C0.DEL}`,!0):t.length===e.length&&t!==e&&this._coreService.triggerDataEvent(t,!0)}}),0)}',
"_handleAnyTextareaChanges(){const e=this._textarea.value;setTimeout((()=>{if(!this._isComposing){const t=this._textarea.value;let r=0;const n=Math.min(t.length,e.length);for(;r<n&&t.charCodeAt(r)===e.charCodeAt(r);)r++;const i=t.length<e.length?a.C0.DEL.repeat(e.length-r)+t.substring(r):t.substring(r);this._dataAlreadySent=i,i.length>0&&this._coreService.triggerDataEvent(i,!0)}}),0)}",
[
'_handleAnyTextareaChanges(){const e=this._textarea.value;setTimeout((()=>{if(!this._isComposing){const t=this._textarea.value,i=t.replace(e,"");this._dataAlreadySent=i,t.length>e.length?this._coreService.triggerDataEvent(i,!0):t.length<e.length?this._coreService.triggerDataEvent(`${a.C0.DEL}`,!0):t.length===e.length&&t!==e&&this._coreService.triggerDataEvent(t,!0)}}),0)}',
"_handleAnyTextareaChanges(){const e=this._textarea.value;setTimeout((()=>{if(!this._isComposing){const t=this._textarea.value;let r=0;const n=Math.min(t.length,e.length);for(;r<n&&t.charCodeAt(r)===e.charCodeAt(r);)r++;const i=t.length<e.length?a.C0.DEL.repeat(e.length-r)+t.substring(r):t.substring(r);this._dataAlreadySent=i,i.length>0&&this._coreService.triggerDataEvent(i,!0)}}),0)}",
],
"_handleAnyTextareaChanges(){if(this._pendingTextareaValue!==null)return;this._pendingTextareaValue=this._textarea.value,setTimeout((()=>{const e=this._pendingTextareaValue;this._pendingTextareaValue=null;if(!this._isComposing){const t=this._textarea.value;let r=0;const n=Math.min(t.length,e.length);for(;r<n&&t.charCodeAt(r)===e.charCodeAt(r);)r++;const i=t.length<e.length?a.C0.DEL.repeat(e.length-r)+t.substring(r):t.substring(r);this._dataAlreadySent=i,i.length>0&&this._coreService.triggerDataEvent(i,!0)}}),0)}",
],
],
},
@@ -91,12 +103,14 @@ for (const { file, replacements } of patches) {
if (source.includes(patched)) {
continue;
}
if (!source.includes(original)) {
const originals = Array.isArray(original) ? original : [original];
const matched = originals.find((candidate) => source.includes(candidate));
if (!matched) {
throw new Error(
`[patch-xterm-android-ime] Expected source not found in ${file}`,
);
}
source = source.replace(original, patched);
source = source.replace(matched, patched);
changed = true;
}
+150
View File
@@ -0,0 +1,150 @@
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { execFileSync, spawnSync } = require("node:child_process");
const architectures = {
x64: ["x64"],
arm64: ["arm64"],
universal: ["x64", "arm64"],
};
function expectedArchitecture(artifact) {
const name = path.basename(artifact);
if (name.includes("_x64_")) return "x64";
if (name.includes("_arm64_")) return "arm64";
if (name.includes("_universal_")) return "universal";
throw new Error(`Cannot determine architecture from artifact name: ${name}`);
}
function findApp(root) {
const pending = [root];
while (pending.length) {
const current = pending.pop();
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
const candidate = path.join(current, entry.name);
if (entry.isDirectory() && entry.name.endsWith(".app")) return candidate;
if (entry.isDirectory()) pending.push(candidate);
}
}
throw new Error(`No .app bundle found below ${root}`);
}
function containsFile(root, suffix) {
const pending = [root];
while (pending.length) {
const current = pending.pop();
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
const candidate = path.join(current, entry.name);
if (entry.isDirectory()) pending.push(candidate);
if (entry.isFile() && entry.name.endsWith(suffix)) return true;
}
}
return false;
}
function verifyApp(app, architecture, runtimeCheck) {
const modules = path.join(
app,
"Contents/Resources/app.asar.unpacked/node_modules",
);
for (const arch of architectures[architecture]) {
for (const [packageName, nativeSuffix] of [
[`sharp-darwin-${arch}`, ".node"],
[`sharp-libvips-darwin-${arch}`, ".dylib"],
]) {
const packagePath = path.join(modules, "@img", packageName);
if (
!fs.existsSync(packagePath) ||
!containsFile(packagePath, nativeSuffix)
) {
throw new Error(
`${path.basename(app)} is missing the native binary from @img/${packageName}`,
);
}
}
}
if (!runtimeCheck) return;
const executable = path.join(app, "Contents/MacOS/Termix");
const sharpPath = path.join(modules, "sharp");
const smoke = [
"const sharp = require(process.argv[1]);",
"sharp({create:{width:1,height:1,channels:4,background:'#000'}})",
".png().toBuffer().then(() => process.exit(0)).catch(e => { console.error(e); process.exit(1); });",
].join("");
for (const arch of architectures[architecture]) {
const result = spawnSync(
"arch",
[
arch === "x64" ? "-x86_64" : "-arm64",
executable,
"-e",
smoke,
sharpPath,
],
{
encoding: "utf8",
env: { ...process.env, ELECTRON_RUN_AS_NODE: "1" },
},
);
if (result.status !== 0) {
throw new Error(
`${path.basename(app)} failed the ${arch} sharp runtime smoke test:\n${result.stderr || result.stdout}`,
);
}
}
}
function verifyArtifact(artifact) {
const architecture = expectedArchitecture(artifact);
const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "termix-sharp-"));
let mountedAt;
try {
if (artifact.endsWith(".dmg")) {
mountedAt = path.join(temporaryRoot, "mounted");
fs.mkdirSync(mountedAt);
execFileSync("hdiutil", [
"attach",
artifact,
"-readonly",
"-nobrowse",
"-mountpoint",
mountedAt,
]);
verifyApp(findApp(mountedAt), architecture, true);
} else if (artifact.endsWith(".pkg")) {
const expanded = path.join(temporaryRoot, "expanded");
execFileSync("pkgutil", ["--expand-full", artifact, expanded]);
verifyApp(findApp(expanded), architecture, false);
} else {
throw new Error(`Unsupported macOS artifact: ${artifact}`);
}
console.log(`Verified macOS sharp packaging: ${path.basename(artifact)}`);
} finally {
if (mountedAt) {
spawnSync("hdiutil", ["detach", mountedAt], { stdio: "ignore" });
}
fs.rmSync(temporaryRoot, { recursive: true, force: true });
}
}
module.exports = { expectedArchitecture, verifyApp };
if (require.main === module) {
if (process.platform !== "darwin") {
throw new Error("macOS sharp artifact verification must run on macOS");
}
if (process.argv.length < 3) {
throw new Error(
"Usage: node scripts/verify-macos-sharp.cjs <artifact> [...]",
);
}
for (const artifact of process.argv.slice(2))
verifyArtifact(path.resolve(artifact));
}
+62
View File
@@ -0,0 +1,62 @@
import fs from "node:fs";
import { createRequire } from "node:module";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
const require = createRequire(import.meta.url);
const { expectedArchitecture, verifyApp } = require("./verify-macos-sharp.cjs");
const temporaryDirectories: string[] = [];
function createApp(architectures: string[]) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "termix-sharp-test-"));
temporaryDirectories.push(root);
const app = path.join(root, "Termix.app");
const modules = path.join(
app,
"Contents/Resources/app.asar.unpacked/node_modules/@img",
);
for (const architecture of architectures) {
const sharp = path.join(modules, `sharp-darwin-${architecture}/lib`);
const libvips = path.join(
modules,
`sharp-libvips-darwin-${architecture}/lib`,
);
fs.mkdirSync(sharp, { recursive: true });
fs.mkdirSync(libvips, { recursive: true });
fs.writeFileSync(path.join(sharp, `sharp-darwin-${architecture}.node`), "");
fs.writeFileSync(path.join(libvips, "libvips.dylib"), "");
}
return app;
}
afterEach(() => {
for (const directory of temporaryDirectories.splice(0)) {
fs.rmSync(directory, { recursive: true, force: true });
}
});
describe("macOS sharp artifact verification", () => {
it("derives the expected architecture from artifact names", () => {
expect(expectedArchitecture("termix_macos_x64_dmg.dmg")).toBe("x64");
expect(expectedArchitecture("termix_macos_arm64_dmg.dmg")).toBe("arm64");
expect(expectedArchitecture("termix_macos_universal_mas.pkg")).toBe(
"universal",
);
});
it("accepts a universal app with both sharp architectures", () => {
expect(() =>
verifyApp(createApp(["x64", "arm64"]), "universal", false),
).not.toThrow();
});
it("rejects an x64 app containing only arm64 sharp binaries", () => {
expect(() => verifyApp(createApp(["arm64"]), "x64", false)).toThrow(
/sharp-darwin-x64/,
);
});
});
+10 -5
View File
@@ -51,8 +51,7 @@ function normalizeHost(hostname: string): string {
/**
* True when the URL names a destination the SSRF guard would refuse. A bare
* hostname that is not an IP literal (e.g. "ollama.internal") is treated as
* private only if it is "localhost" -- anything else resolves through DNS and
* is caught at connect time by the guard instead.
* private only if it is "localhost" -- anything else needs DNS resolution.
*/
export function isPrivateDestination(rawUrl: string): boolean {
let url: URL;
@@ -98,12 +97,18 @@ export function evaluateEgress(
const host = normalizeHost(url.hostname);
const isPrivate = isPrivateDestination(rawUrl);
const normalized = allowlist.map((entry) => entry.trim().toLowerCase());
// An explicitly allowlisted hostname may resolve to a private address. It
// must use the private fetch path; sending it through safeOutboundFetch
// would reject it after DNS resolution and make hostname allowlist entries
// ineffective. Only administrators can write this list.
if (normalized.includes(host)) {
return { allowed: true, isPrivate: true };
}
if (!isPrivate) return { allowed: true, isPrivate: false };
const normalized = allowlist.map((entry) => entry.trim().toLowerCase());
if (normalized.includes(host)) return { allowed: true, isPrivate: true };
return {
allowed: false,
isPrivate: true,
+14
View File
@@ -1,6 +1,7 @@
import { getErrorMessage } from "../utils/error-message.js";
import express from "express";
import type { AuthenticatedRequest } from "../../types/index.js";
import { PermissionManager } from "../utils/permission-manager.js";
import { AuthManager } from "../utils/auth-manager.js";
import { databaseLogger } from "../utils/logger.js";
import {
@@ -36,6 +37,7 @@ import { applyProposal } from "./tools/executor.js";
const router = express.Router();
const authManager = AuthManager.getInstance();
const permissionManager = PermissionManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
const requireDataAccess = authManager.createDataAccessMiddleware();
const aiGate = createAiGate();
@@ -97,6 +99,7 @@ router.get("/status", authenticateJWT, async (req, res) => {
router.get(
"/providers",
authenticateJWT,
permissionManager.requirePermission("ai.use"),
requireDataAccess,
aiGate,
async (req, res) => {
@@ -147,6 +150,7 @@ router.get(
router.post(
"/providers",
authenticateJWT,
permissionManager.requirePermission("ai.manage_providers"),
requireDataAccess,
aiGate,
async (req, res) => {
@@ -224,6 +228,7 @@ router.post(
router.patch(
"/providers/:id",
authenticateJWT,
permissionManager.requirePermission("ai.manage_providers"),
requireDataAccess,
aiGate,
async (req, res) => {
@@ -286,6 +291,7 @@ router.patch(
router.delete(
"/providers/:id",
authenticateJWT,
permissionManager.requirePermission("ai.manage_providers"),
requireDataAccess,
aiGate,
async (req, res) => {
@@ -358,6 +364,7 @@ router.delete(
router.post(
"/probe-models",
authenticateJWT,
permissionManager.requirePermission("ai.manage_providers"),
requireDataAccess,
aiGate,
async (req, res) => {
@@ -423,6 +430,7 @@ router.post(
router.get(
"/providers/:id/models",
authenticateJWT,
permissionManager.requirePermission("ai.use"),
requireDataAccess,
aiGate,
async (req, res) => {
@@ -470,6 +478,7 @@ router.get(
router.get(
"/conversations",
authenticateJWT,
permissionManager.requirePermission("ai.use"),
requireDataAccess,
aiGate,
async (req, res) => {
@@ -510,6 +519,7 @@ router.get(
router.get(
"/conversations/:id",
authenticateJWT,
permissionManager.requirePermission("ai.use"),
requireDataAccess,
aiGate,
async (req, res) => {
@@ -560,6 +570,7 @@ router.get(
router.delete(
"/conversations/:id",
authenticateJWT,
permissionManager.requirePermission("ai.use"),
requireDataAccess,
aiGate,
async (req, res) => {
@@ -622,6 +633,7 @@ router.delete(
router.post(
"/chat/stream",
authenticateJWT,
permissionManager.requirePermission("ai.use"),
requireDataAccess,
aiGate,
async (req, res) => {
@@ -840,6 +852,7 @@ router.post(
router.post(
"/proposals/:id/apply",
authenticateJWT,
permissionManager.requirePermission("ai.apply_proposals"),
requireDataAccess,
aiGate,
async (req, res) => {
@@ -931,6 +944,7 @@ router.post(
router.post(
"/proposals/:id/reject",
authenticateJWT,
permissionManager.requirePermission("ai.use"),
requireDataAccess,
aiGate,
async (req, res) => {
+4 -7
View File
@@ -1,4 +1,4 @@
import { getFetchDispatcher } from "../../utils/proxy-agent.js";
import { fetchWithProxy } from "../../utils/proxy-agent.js";
import { safeOutboundFetch } from "../../utils/safe-outbound-fetch.js";
import { evaluateEgress, readPrivateAllowlist } from "../egress.js";
import { AiProviderError } from "./types.js";
@@ -9,8 +9,8 @@ import { AiProviderError } from "./types.js";
*
* Public hosts use safeOutboundFetch, which re-checks the resolved address at
* connect time. Allowlisted private hosts cannot use it (its whole job is to
* refuse them), so they fall back to plain fetch with the proxy dispatcher --
* still respecting corporate proxy configuration.
* refuse them), so they use the installed Undici fetch implementation with
* its matching proxy dispatcher -- still respecting proxy configuration.
*/
export async function providerFetch(
url: string,
@@ -24,10 +24,7 @@ export async function providerFetch(
}
if (decision.isPrivate) {
return fetch(url, {
...init,
dispatcher: getFetchDispatcher(url),
} as RequestInit);
return fetchWithProxy(url, init);
}
return safeOutboundFetch(url, init) as unknown as Promise<Response>;
+6 -6
View File
@@ -5,12 +5,10 @@ import type { AiTool, ToolDefinitionShape } from "./types.js";
/**
* The allowlist, and the security boundary for the whole feature.
*
* A model can only ever invoke what appears here. This matters more than usual
* in this codebase: PermissionManager.requirePermission exists but is currently
* mounted on zero routes, so RBAC strings are a vocabulary for the admin role
* editor rather than route enforcement. "The assistant cannot reach credentials
* or user administration" is therefore a property of this list, not of the
* permission system.
* A model can only ever invoke what appears here. Route-level RBAC gates the
* HTTP API, but tools run in-process with the calling user's identity and never
* pass through a router, so "the assistant cannot reach credentials or user
* administration" is a property of this list, not of the permission system.
*
* Anything touching credentials, vaults, RBAC, users, identity, certificates,
* SSO or instance settings is deliberately absent and must stay absent.
@@ -51,6 +49,8 @@ export const FORBIDDEN_DOMAINS = [
"identity",
"certificate",
"opkssh",
"stepca",
"step_ca",
"acme",
"ssl",
"audit",
+27 -1
View File
@@ -10,6 +10,7 @@ import {
} from "../../types/automations.js";
import { createCurrentAutomationRepository } from "../database/repositories/factory.js";
import { statsLogger } from "../utils/logger.js";
import { resolveHostById } from "../hosts/host-resolver.js";
import { executeStep } from "./actions/index.js";
import type { StepExecutionContext, StepResult } from "./actions/types.js";
import { compare } from "./conditions.js";
@@ -175,8 +176,33 @@ export class AutomationEngine {
if (!claimed) this.running.add(automation.id);
const trigger = { ...(request.triggerContext ?? {}) };
trigger.type ??= request.triggerType;
let host: TemplateContext["host"];
if (request.triggerHostId) {
try {
const resolved = await resolveHostById(
request.triggerHostId,
automation.userId,
);
if (resolved) {
host = {
id: request.triggerHostId,
name: resolved.name || resolved.ip,
ip: resolved.ip,
username: resolved.username,
port: resolved.port,
};
trigger.hostName ??= host.name;
}
} catch {
// A notification should still run with its numeric host id.
}
}
const template: TemplateContext = {
trigger: request.triggerContext ?? {},
host,
trigger,
steps: {},
vars: {},
run: {
+8 -6
View File
@@ -1,13 +1,14 @@
import { safeOutboundFetch } from "../utils/safe-outbound-fetch.js";
import { readNotificationPrivateAllowlist } from "../utils/notification-egress.js";
/**
* Outbound HTTP for automation steps and notification channels.
*
* safeOutboundFetch refuses private and loopback addresses, which is the right
* default against SSRF but also blocks the self-hosted ntfy or Gotify sitting
* on a LAN that many installs actually use. Rather than weaken the guard
* globally, a destination can opt in explicitly; everything else about the
* guard (scheme, embedded credentials, no redirects) still applies.
* on a LAN that many installs actually use. Private delivery therefore needs
* both a channel opt-in and an exact host in the administrator allowlist;
* scheme validation, DNS pinning and redirect refusal remain in force.
*/
export interface AutomationFetchOptions {
method?: string;
@@ -52,8 +53,8 @@ export async function automationFetch(
}
/**
* The opt-in path. Keeps the parts of the guard that are always right and
* drops only the address blocklist.
* The opt-in path still goes through the guarded resolver. Only an exact host
* authorized by an administrator may resolve to a private address.
*/
async function privateNetworkFetch(
rawUrl: string,
@@ -73,5 +74,6 @@ async function privateNetworkFetch(
throw new Error("URLs with embedded credentials are not allowed");
}
return fetch(rawUrl, { ...init, redirect: "error" });
const allowlist = await readNotificationPrivateAllowlist();
return safeOutboundFetch(rawUrl, init, allowlist);
}
+10
View File
@@ -88,6 +88,16 @@ async function sendWebhook(
headers,
body: JSON.stringify({
title: notification.title,
hostName:
notification.context?.host?.name ??
notification.context?.trigger?.hostName,
hostId:
notification.context?.host?.id ?? notification.context?.trigger?.hostId,
ruleName: notification.title,
ruleId: notification.context?.run?.automationId,
triggerType: notification.context?.trigger?.type,
value: notification.context?.trigger?.value,
threshold: notification.context?.trigger?.threshold,
message: notification.body,
severity: notification.severity,
timestamp: new Date().toISOString(),
+14 -6
View File
@@ -17,6 +17,7 @@ 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 collabRoutes from "../hosts/collab/routes.js";
import networkTopologyRoutes from "./routes/network-topology.js";
import rbacRoutes from "./routes/rbac.js";
import openTabsRoutes from "./routes/open-tabs.js";
@@ -29,6 +30,7 @@ import termixIdRoutes from "./routes/termix-id.js";
import { registerAuditLogRoutes } from "./routes/audit-log-routes.js";
import { registerTailscaleRoutes } from "./routes/tailscale-routes.js";
import vaultRoutes from "./routes/vault.js";
import secretSourceRoutes from "./routes/secret-sources.js";
import alertRulesRoutes from "./routes/alert-rules-routes.js";
import aiRoutes from "../ai/index.js";
import automationsRoutes from "./routes/automations.js";
@@ -73,7 +75,7 @@ const __dirname = path.dirname(__filename);
const app = express();
app.set("trust proxy", true);
app.set("trust proxy", "loopback");
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
@@ -259,9 +261,8 @@ async function fetchGitHubAPI<T>(
}
}
app.use(bodyParser.json({ limit: "1gb" }));
app.use(bodyParser.urlencoded({ limit: "1gb", extended: true }));
app.use(bodyParser.raw({ limit: "5gb", type: "application/octet-stream" }));
app.use(bodyParser.json({ limit: "2mb" }));
app.use(bodyParser.urlencoded({ limit: "2mb", extended: true }));
app.use(cookieParser());
app.use((_req, res, next) => {
res.setHeader("Cache-Control", "no-store");
@@ -1753,6 +1754,7 @@ app.use("/terminal", terminalRoutes);
app.use("/session_logs", sessionLogRoutes);
app.use("/guacamole", guacamoleRoutes);
app.use("/session-sharing", sessionSharingRoutes);
app.use("/collab", collabRoutes);
app.use("/network-topology", networkTopologyRoutes);
app.use("/rbac", rbacRoutes);
app.use("/open-tabs", openTabsRoutes);
@@ -1765,6 +1767,7 @@ app.use("/termix-id", termixIdRoutes);
registerAuditLogRoutes(app, authenticateJWT);
registerTailscaleRoutes(app, authenticateJWT);
app.use("/vault", vaultRoutes);
app.use("/secret-sources", secretSourceRoutes);
// Before the alert routes, which are mounted at the root and would otherwise
// have first claim on the path.
app.use("/automations", automationsRoutes);
@@ -2035,7 +2038,7 @@ httpServer.on("error", (err: NodeJS.ErrnoException) => {
});
export const serverReady = new Promise<void>((resolve) => {
httpServer.listen(HTTP_PORT, async () => {
httpServer.listen(HTTP_PORT, "127.0.0.1", async () => {
if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(uploadsDir, { recursive: true });
}
@@ -2052,7 +2055,12 @@ if (sslConfig.enabled) {
ssl_port: sslConfig.port,
backend_http_port: HTTP_PORT,
});
}
if (
sslConfig.enabled &&
process.env.TERMIX_SSL_TERMINATED_BY_NGINX !== "true"
) {
try {
const httpsServer = https.createServer(
{
@@ -2079,7 +2087,7 @@ if (sslConfig.enabled) {
});
});
httpsServer.listen(sslConfig.port, () => {
httpsServer.listen(sslConfig.port, "127.0.0.1", () => {
databaseLogger.success(
`Backend is now also listening for HTTPS directly`,
{
+130 -14
View File
@@ -21,6 +21,7 @@ import {
} from "../../utils/data-dir-guard.js";
import { getDefaultGuacdUrl } from "../../utils/guacd-config.js";
import { resolveDatabaseDialect, type DatabaseDialect } from "./dialect.js";
import { SYSTEM_ROLE_DEFAULTS } from "../../utils/permission-catalog.js";
import { connectRemoteDatabase } from "./connect.js";
import { runRemoteMigrations } from "./migrate.js";
import type { PortableDatabase } from "../repositories/database-context.js";
@@ -577,6 +578,107 @@ async function initializeCompleteDatabase(): Promise<void> {
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS collab_rooms (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
owner_user_id TEXT NOT NULL,
persistent INTEGER NOT NULL DEFAULT 0,
presenter_user_id TEXT,
stage_protocol TEXT,
stage_host_id INTEGER,
stage_share_id TEXT,
guest_link_token TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
ended_at TEXT,
FOREIGN KEY (owner_user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (presenter_user_id) REFERENCES users (id) ON DELETE SET NULL,
FOREIGN KEY (stage_host_id) REFERENCES ssh_data (id) ON DELETE SET NULL,
FOREIGN KEY (stage_share_id) REFERENCES session_shares (id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS collab_room_members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
room_id TEXT NOT NULL,
user_id TEXT NOT NULL,
room_role TEXT NOT NULL DEFAULT 'member',
added_by TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (room_id, user_id),
FOREIGN KEY (room_id) REFERENCES collab_rooms (id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (added_by) REFERENCES users (id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS secret_sources (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
kind TEXT NOT NULL DEFAULT 'onepassword-connect',
base_url TEXT NOT NULL,
token TEXT NOT NULL,
shared INTEGER NOT NULL DEFAULT 0,
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
);
CREATE TABLE IF NOT EXISTS credential_access (
id INTEGER PRIMARY KEY AUTOINCREMENT,
credential_id INTEGER NOT NULL,
user_id TEXT,
role_id INTEGER,
granted_by TEXT NOT NULL,
permission_level TEXT NOT NULL DEFAULT 'use',
expires_at TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (credential_id) REFERENCES ssh_credentials (id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (role_id) REFERENCES roles (id) ON DELETE CASCADE,
FOREIGN KEY (granted_by) REFERENCES users (id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_credential_access_user_id ON credential_access (user_id);
CREATE INDEX IF NOT EXISTS idx_credential_access_role_id ON credential_access (role_id);
CREATE INDEX IF NOT EXISTS idx_credential_access_credential_id ON credential_access (credential_id);
CREATE TABLE IF NOT EXISTS shared_credential_secrets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
credential_access_id INTEGER NOT NULL,
target_user_id TEXT NOT NULL,
credential_id INTEGER NOT NULL,
encrypted_username TEXT,
auth_type TEXT NOT NULL DEFAULT 'password',
encrypted_password TEXT,
encrypted_key TEXT,
encrypted_key_password TEXT,
key_type TEXT,
public_key TEXT,
cert_public_key TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (credential_access_id, target_user_id),
FOREIGN KEY (credential_access_id) REFERENCES credential_access (id) ON DELETE CASCADE,
FOREIGN KEY (target_user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (credential_id) REFERENCES ssh_credentials (id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_shared_credential_secrets_target ON shared_credential_secrets (target_user_id, credential_id);
CREATE TABLE IF NOT EXISTS folder_access (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner_user_id TEXT NOT NULL,
folder TEXT NOT NULL,
user_id TEXT,
role_id INTEGER,
granted_by TEXT NOT NULL,
permission_level TEXT NOT NULL DEFAULT 'connect',
expires_at TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (owner_user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (role_id) REFERENCES roles (id) ON DELETE CASCADE,
FOREIGN KEY (granted_by) REFERENCES users (id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_folder_access_owner_folder ON folder_access (owner_user_id, folder);
CREATE TABLE IF NOT EXISTS api_keys (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
@@ -1953,20 +2055,24 @@ const migrateSchema = () => {
});
}
const systemRoles = [
{
name: "admin",
displayName: "rbac.roles.admin",
description: "Administrator with full access",
permissions: null,
},
{
name: "user",
displayName: "rbac.roles.user",
description: "Regular user",
permissions: null,
},
];
const systemRoles = Object.entries(SYSTEM_ROLE_DEFAULTS).map(
([name, defaults]) => ({
name,
displayName: `rbac.roles.${name}`,
description: defaults.description,
permissions: JSON.stringify(defaults.permissions),
}),
);
// Route-level RBAC needs the permission lists to exist; roles seeded by
// earlier versions carried NULL there. Backfill only NULL so an admin's
// edits to these roles are never overwritten.
const backfillPermissions = sqlite.prepare(
"UPDATE roles SET permissions = ? WHERE name = ? AND is_system = 1 AND permissions IS NULL",
);
for (const role of systemRoles) {
backfillPermissions.run(role.permissions, role.name);
}
for (const role of systemRoles) {
const existingRole = sqlite.prepare("SELECT id FROM roles WHERE name = ?").get(role.name);
@@ -2035,6 +2141,10 @@ const migrateSchema = () => {
}
addColumnIfNotExists("users", "sso_provider_id", "INTEGER");
addColumnIfNotExists("collab_rooms", "guest_link_token", "TEXT");
sqlite.exec(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_collab_rooms_guest_token ON collab_rooms (guest_link_token)",
);
try {
const usersTableInfo = sqlite.prepare("PRAGMA table_info(users)").all() as Array<{
@@ -2986,6 +3096,12 @@ async function initializeRemoteDatabase(
await primeCurrentSettingsCache();
startSettingsCacheRefresh();
// The SQLite bootstrap seeds system roles inline below; migrations for the
// remote dialects never did, and route-level RBAC denies a user with no
// usable role, so they are seeded (and backfilled) here.
const { ensureSystemRoles } = await import("../../utils/system-roles.js");
await ensureSystemRoles();
databaseLogger.info(`${dialect} database ready`, {
operation: "db_init_complete",
dialect,
+241 -4
View File
@@ -16,6 +16,7 @@ import {
double,
index,
uniqueIndex,
foreignKey,
type AnyMySqlColumn,
} from "drizzle-orm/mysql-core";
import { sql } from "drizzle-orm";
@@ -150,7 +151,7 @@ export const hosts = mysqlTable(
ip: text("ip").notNull(),
port: int("port").notNull(),
username: text("username").notNull(),
folder: text("folder"),
folder: varchar("folder", { length: 255 }),
// Sub-host nesting: a host acting as an organizational parent for other
// hosts, mutually exclusive with folder (see host route validation).
parentHostId: int("parent_host_id").references(
@@ -439,7 +440,7 @@ export const sshCredentials = mysqlTable(
.references(() => users.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(),
description: text("description"),
folder: text("folder"),
folder: varchar("folder", { length: 255 }),
tags: text("tags"),
pin: boolean("pin").notNull().default(false),
// Manual drag-to-reorder position within a folder. Null means the
@@ -505,7 +506,7 @@ export const snippets = mysqlTable(
name: varchar("name", { length: 255 }).notNull(),
content: text("content").notNull(),
description: text("description"),
folder: text("folder"),
folder: varchar("folder", { length: 255 }),
order: int("order").notNull().default(0),
syncId: varchar("sync_id", { length: 255 }).unique(),
createdAt: varchar("created_at", { length: 255 })
@@ -1022,7 +1023,7 @@ export const vaultProfiles = mysqlTable("vault_profiles", {
.references(() => users.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(),
description: text("description"),
folder: text("folder"),
folder: varchar("folder", { length: 255 }),
tags: text("tags"),
// Vault server connection (non-secret)
vaultAddr: text("vault_addr").notNull(),
@@ -1939,3 +1940,239 @@ export const aiProposals = mysqlTable(
],
);
// --- ai end ---
// --- collab rooms ---
/**
* A collaboration room: a group of users watching one "stage" - the live
* session the current presenter is showing. The stage points at a
* shareType="room" row in session_shares, so transport, gating, recording and
* expiry all reuse the session-sharing machinery.
*/
export const collabRooms = mysqlTable(
"collab_rooms",
{
id: varchar("id", { length: 255 }).primaryKey(),
name: varchar("name", { length: 255 }).notNull(),
ownerUserId: varchar("owner_user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
// Persistent rooms survive being emptied and can be re-used; one-off
// rooms are ended explicitly and never listed again.
persistent: boolean("persistent")
.notNull()
.default(false),
presenterUserId: varchar("presenter_user_id", { length: 255 }).references(() => users.id, {
onDelete: "set null",
}),
stageProtocol: text("stage_protocol"),
stageHostId: int("stage_host_id").references(() => hosts.id, {
onDelete: "set null",
}),
stageShareId: varchar("stage_share_id", { length: 255 }).references(() => sessionShares.id, {
onDelete: "set null",
}),
// Set = anonymous guests may watch the stage through this token.
guestLinkToken: varchar("guest_link_token", { length: 255 }),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
endedAt: text("ended_at"),
},
(table) => [
index("idx_collab_rooms_owner").on(table.ownerUserId),
uniqueIndex("idx_collab_rooms_guest_token").on(table.guestLinkToken),
],
);
export const collabRoomMembers = mysqlTable(
"collab_room_members",
{
id: int("id").autoincrement().primaryKey(),
roomId: varchar("room_id", { length: 255 })
.notNull()
.references(() => collabRooms.id, { onDelete: "cascade" }),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
// "host" runs the room: invites, force-switches the presenter, ends it.
roomRole: text("room_role").notNull().default("member"),
addedBy: varchar("added_by", { length: 255 }).references(() => users.id, {
onDelete: "set null",
}),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
(table) => [
uniqueIndex("idx_collab_room_members_room_user").on(
table.roomId,
table.userId,
),
index("idx_collab_room_members_user").on(table.userId),
],
);
// --- secret sources ---
/**
* An external password manager Termix pulls secrets from at connect time,
* instead of storing them. Only the access token is secret; it is encrypted
* with the owner's data key under the row id. Hosts and credentials refer to
* entries by reference ("op://vault/item/field") in their secret fields.
*/
export const secretSources = mysqlTable(
"secret_sources",
{
id: varchar("id", { length: 255 }).primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(),
// "onepassword-connect" for now; the reference syntax is per kind.
kind: text("kind").notNull().default("onepassword-connect"),
baseUrl: text("base_url").notNull(),
token: text("token").notNull(),
// Visible to every user; secrets still decrypt with the owner's key.
shared: boolean("shared").notNull().default(false),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: varchar("updated_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
(table) => [index("idx_secret_sources_user").on(table.userId)],
);
// --- credential sharing ---
/**
* Who may use or manage someone else's credential. Same shape as
* snippet_access; "use" attaches it to hosts and connects, "manage" also
* edits and re-shares it.
*/
export const credentialAccess = mysqlTable(
"credential_access",
{
id: int("id").autoincrement().primaryKey(),
credentialId: int("credential_id")
.notNull()
.references(() => sshCredentials.id, { onDelete: "cascade" }),
userId: varchar("user_id", { length: 255 }).references(() => users.id, { onDelete: "cascade" }),
roleId: int("role_id").references(() => roles.id, {
onDelete: "cascade",
}),
grantedBy: varchar("granted_by", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
permissionLevel: text("permission_level").notNull().default("use"),
expiresAt: varchar("expires_at", { length: 255 }),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
(table) => [
index("idx_credential_access_user_id").on(table.userId),
index("idx_credential_access_role_id").on(table.roleId),
index("idx_credential_access_credential_id").on(table.credentialId),
],
);
/**
* A recipient's copy of a shared credential's secrets, re-encrypted under
* the recipient's data key (the owner's key cannot be used by anyone else).
* Rebuilt whenever the owner edits the credential; one row per grant and
* recipient, like shared_host_secrets.
*/
export const sharedCredentialSecrets = mysqlTable(
"shared_credential_secrets",
{
id: int("id").autoincrement().primaryKey(),
credentialAccessId: int("credential_access_id").notNull(),
targetUserId: varchar("target_user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
credentialId: int("credential_id")
.notNull()
.references(() => sshCredentials.id, { onDelete: "cascade" }),
encryptedUsername: text("encrypted_username"),
authType: text("auth_type").notNull().default("password"),
encryptedPassword: text("encrypted_password"),
encryptedKey: text("encrypted_key"),
encryptedKeyPassword: text("encrypted_key_password"),
keyType: text("key_type"),
publicKey: text("public_key"),
certPublicKey: text("cert_public_key"),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: varchar("updated_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
(table) => [
foreignKey({
columns: [table.credentialAccessId],
foreignColumns: [credentialAccess.id],
name: "shared_cred_secrets_access_id_fk",
}).onDelete("cascade"),
uniqueIndex("idx_shared_credential_secrets_scope").on(
table.credentialAccessId,
table.targetUserId,
),
index("idx_shared_credential_secrets_target").on(
table.targetUserId,
table.credentialId,
),
],
);
// --- folder access rules ---
/**
* A standing share on a host folder. Sharing a folder fans out host_access
* grants to the hosts in it today; this row is what makes hosts created in
* or moved into the folder later inherit the same access.
*/
export const folderAccess = mysqlTable(
"folder_access",
{
id: int("id").autoincrement().primaryKey(),
ownerUserId: varchar("owner_user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
// The folder path as stored on hosts ("Parent / Child"); subfolders inherit.
folder: varchar("folder", { length: 255 }).notNull(),
userId: varchar("user_id", { length: 255 }).references(() => users.id, { onDelete: "cascade" }),
roleId: int("role_id").references(() => roles.id, {
onDelete: "cascade",
}),
grantedBy: varchar("granted_by", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
permissionLevel: text("permission_level").notNull().default("connect"),
expiresAt: varchar("expires_at", { length: 255 }),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
(table) => [
index("idx_folder_access_owner_folder").on(table.ownerUserId, table.folder),
],
);
+241 -4
View File
@@ -17,6 +17,7 @@ import {
doublePrecision,
index,
uniqueIndex,
foreignKey,
type AnyPgColumn,
} from "drizzle-orm/pg-core";
import { sql } from "drizzle-orm";
@@ -151,7 +152,7 @@ export const hosts = pgTable(
ip: text("ip").notNull(),
port: integer("port").notNull(),
username: text("username").notNull(),
folder: text("folder"),
folder: varchar("folder", { length: 255 }),
// Sub-host nesting: a host acting as an organizational parent for other
// hosts, mutually exclusive with folder (see host route validation).
parentHostId: integer("parent_host_id").references(
@@ -440,7 +441,7 @@ export const sshCredentials = pgTable(
.references(() => users.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(),
description: text("description"),
folder: text("folder"),
folder: varchar("folder", { length: 255 }),
tags: text("tags"),
pin: boolean("pin").notNull().default(false),
// Manual drag-to-reorder position within a folder. Null means the
@@ -506,7 +507,7 @@ export const snippets = pgTable(
name: varchar("name", { length: 255 }).notNull(),
content: text("content").notNull(),
description: text("description"),
folder: text("folder"),
folder: varchar("folder", { length: 255 }),
order: integer("order").notNull().default(0),
syncId: varchar("sync_id", { length: 255 }).unique(),
createdAt: varchar("created_at", { length: 255 })
@@ -1023,7 +1024,7 @@ export const vaultProfiles = pgTable("vault_profiles", {
.references(() => users.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(),
description: text("description"),
folder: text("folder"),
folder: varchar("folder", { length: 255 }),
tags: text("tags"),
// Vault server connection (non-secret)
vaultAddr: text("vault_addr").notNull(),
@@ -1940,3 +1941,239 @@ export const aiProposals = pgTable(
],
);
// --- ai end ---
// --- collab rooms ---
/**
* A collaboration room: a group of users watching one "stage" - the live
* session the current presenter is showing. The stage points at a
* shareType="room" row in session_shares, so transport, gating, recording and
* expiry all reuse the session-sharing machinery.
*/
export const collabRooms = pgTable(
"collab_rooms",
{
id: varchar("id", { length: 255 }).primaryKey(),
name: varchar("name", { length: 255 }).notNull(),
ownerUserId: varchar("owner_user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
// Persistent rooms survive being emptied and can be re-used; one-off
// rooms are ended explicitly and never listed again.
persistent: boolean("persistent")
.notNull()
.default(false),
presenterUserId: varchar("presenter_user_id", { length: 255 }).references(() => users.id, {
onDelete: "set null",
}),
stageProtocol: text("stage_protocol"),
stageHostId: integer("stage_host_id").references(() => hosts.id, {
onDelete: "set null",
}),
stageShareId: varchar("stage_share_id", { length: 255 }).references(() => sessionShares.id, {
onDelete: "set null",
}),
// Set = anonymous guests may watch the stage through this token.
guestLinkToken: varchar("guest_link_token", { length: 255 }),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
endedAt: text("ended_at"),
},
(table) => [
index("idx_collab_rooms_owner").on(table.ownerUserId),
uniqueIndex("idx_collab_rooms_guest_token").on(table.guestLinkToken),
],
);
export const collabRoomMembers = pgTable(
"collab_room_members",
{
id: serial("id").primaryKey(),
roomId: varchar("room_id", { length: 255 })
.notNull()
.references(() => collabRooms.id, { onDelete: "cascade" }),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
// "host" runs the room: invites, force-switches the presenter, ends it.
roomRole: text("room_role").notNull().default("member"),
addedBy: varchar("added_by", { length: 255 }).references(() => users.id, {
onDelete: "set null",
}),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
uniqueIndex("idx_collab_room_members_room_user").on(
table.roomId,
table.userId,
),
index("idx_collab_room_members_user").on(table.userId),
],
);
// --- secret sources ---
/**
* An external password manager Termix pulls secrets from at connect time,
* instead of storing them. Only the access token is secret; it is encrypted
* with the owner's data key under the row id. Hosts and credentials refer to
* entries by reference ("op://vault/item/field") in their secret fields.
*/
export const secretSources = pgTable(
"secret_sources",
{
id: varchar("id", { length: 255 }).primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(),
// "onepassword-connect" for now; the reference syntax is per kind.
kind: text("kind").notNull().default("onepassword-connect"),
baseUrl: text("base_url").notNull(),
token: text("token").notNull(),
// Visible to every user; secrets still decrypt with the owner's key.
shared: boolean("shared").notNull().default(false),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: varchar("updated_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [index("idx_secret_sources_user").on(table.userId)],
);
// --- credential sharing ---
/**
* Who may use or manage someone else's credential. Same shape as
* snippet_access; "use" attaches it to hosts and connects, "manage" also
* edits and re-shares it.
*/
export const credentialAccess = pgTable(
"credential_access",
{
id: serial("id").primaryKey(),
credentialId: integer("credential_id")
.notNull()
.references(() => sshCredentials.id, { onDelete: "cascade" }),
userId: varchar("user_id", { length: 255 }).references(() => users.id, { onDelete: "cascade" }),
roleId: integer("role_id").references(() => roles.id, {
onDelete: "cascade",
}),
grantedBy: varchar("granted_by", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
permissionLevel: text("permission_level").notNull().default("use"),
expiresAt: varchar("expires_at", { length: 255 }),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
index("idx_credential_access_user_id").on(table.userId),
index("idx_credential_access_role_id").on(table.roleId),
index("idx_credential_access_credential_id").on(table.credentialId),
],
);
/**
* A recipient's copy of a shared credential's secrets, re-encrypted under
* the recipient's data key (the owner's key cannot be used by anyone else).
* Rebuilt whenever the owner edits the credential; one row per grant and
* recipient, like shared_host_secrets.
*/
export const sharedCredentialSecrets = pgTable(
"shared_credential_secrets",
{
id: serial("id").primaryKey(),
credentialAccessId: integer("credential_access_id").notNull(),
targetUserId: varchar("target_user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
credentialId: integer("credential_id")
.notNull()
.references(() => sshCredentials.id, { onDelete: "cascade" }),
encryptedUsername: text("encrypted_username"),
authType: text("auth_type").notNull().default("password"),
encryptedPassword: text("encrypted_password"),
encryptedKey: text("encrypted_key"),
encryptedKeyPassword: text("encrypted_key_password"),
keyType: text("key_type"),
publicKey: text("public_key"),
certPublicKey: text("cert_public_key"),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: varchar("updated_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
foreignKey({
columns: [table.credentialAccessId],
foreignColumns: [credentialAccess.id],
name: "shared_cred_secrets_access_id_fk",
}).onDelete("cascade"),
uniqueIndex("idx_shared_credential_secrets_scope").on(
table.credentialAccessId,
table.targetUserId,
),
index("idx_shared_credential_secrets_target").on(
table.targetUserId,
table.credentialId,
),
],
);
// --- folder access rules ---
/**
* A standing share on a host folder. Sharing a folder fans out host_access
* grants to the hosts in it today; this row is what makes hosts created in
* or moved into the folder later inherit the same access.
*/
export const folderAccess = pgTable(
"folder_access",
{
id: serial("id").primaryKey(),
ownerUserId: varchar("owner_user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
// The folder path as stored on hosts ("Parent / Child"); subfolders inherit.
folder: varchar("folder", { length: 255 }).notNull(),
userId: varchar("user_id", { length: 255 }).references(() => users.id, { onDelete: "cascade" }),
roleId: integer("role_id").references(() => roles.id, {
onDelete: "cascade",
}),
grantedBy: varchar("granted_by", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
permissionLevel: text("permission_level").notNull().default("connect"),
expiresAt: varchar("expires_at", { length: 255 }),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
index("idx_folder_access_owner_folder").on(table.ownerUserId, table.folder),
],
);
+237
View File
@@ -5,6 +5,7 @@ import {
real,
index,
uniqueIndex,
foreignKey,
type AnySQLiteColumn,
} from "drizzle-orm/sqlite-core";
import { sql } from "drizzle-orm";
@@ -1936,3 +1937,239 @@ export const aiProposals = sqliteTable(
],
);
// --- ai end ---
// --- collab rooms ---
/**
* A collaboration room: a group of users watching one "stage" - the live
* session the current presenter is showing. The stage points at a
* shareType="room" row in session_shares, so transport, gating, recording and
* expiry all reuse the session-sharing machinery.
*/
export const collabRooms = sqliteTable(
"collab_rooms",
{
id: text("id").primaryKey(),
name: text("name").notNull(),
ownerUserId: text("owner_user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
// Persistent rooms survive being emptied and can be re-used; one-off
// rooms are ended explicitly and never listed again.
persistent: integer("persistent", { mode: "boolean" })
.notNull()
.default(false),
presenterUserId: text("presenter_user_id").references(() => users.id, {
onDelete: "set null",
}),
stageProtocol: text("stage_protocol"),
stageHostId: integer("stage_host_id").references(() => hosts.id, {
onDelete: "set null",
}),
stageShareId: text("stage_share_id").references(() => sessionShares.id, {
onDelete: "set null",
}),
// Set = anonymous guests may watch the stage through this token.
guestLinkToken: text("guest_link_token"),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
endedAt: text("ended_at"),
},
(table) => [
index("idx_collab_rooms_owner").on(table.ownerUserId),
uniqueIndex("idx_collab_rooms_guest_token").on(table.guestLinkToken),
],
);
export const collabRoomMembers = sqliteTable(
"collab_room_members",
{
id: integer("id").primaryKey({ autoIncrement: true }),
roomId: text("room_id")
.notNull()
.references(() => collabRooms.id, { onDelete: "cascade" }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
// "host" runs the room: invites, force-switches the presenter, ends it.
roomRole: text("room_role").notNull().default("member"),
addedBy: text("added_by").references(() => users.id, {
onDelete: "set null",
}),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
uniqueIndex("idx_collab_room_members_room_user").on(
table.roomId,
table.userId,
),
index("idx_collab_room_members_user").on(table.userId),
],
);
// --- secret sources ---
/**
* An external password manager Termix pulls secrets from at connect time,
* instead of storing them. Only the access token is secret; it is encrypted
* with the owner's data key under the row id. Hosts and credentials refer to
* entries by reference ("op://vault/item/field") in their secret fields.
*/
export const secretSources = sqliteTable(
"secret_sources",
{
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: text("name").notNull(),
// "onepassword-connect" for now; the reference syntax is per kind.
kind: text("kind").notNull().default("onepassword-connect"),
baseUrl: text("base_url").notNull(),
token: text("token").notNull(),
// Visible to every user; secrets still decrypt with the owner's key.
shared: integer("shared", { mode: "boolean" }).notNull().default(false),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [index("idx_secret_sources_user").on(table.userId)],
);
// --- credential sharing ---
/**
* Who may use or manage someone else's credential. Same shape as
* snippet_access; "use" attaches it to hosts and connects, "manage" also
* edits and re-shares it.
*/
export const credentialAccess = sqliteTable(
"credential_access",
{
id: integer("id").primaryKey({ autoIncrement: true }),
credentialId: integer("credential_id")
.notNull()
.references(() => sshCredentials.id, { onDelete: "cascade" }),
userId: text("user_id").references(() => users.id, { onDelete: "cascade" }),
roleId: integer("role_id").references(() => roles.id, {
onDelete: "cascade",
}),
grantedBy: text("granted_by")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
permissionLevel: text("permission_level").notNull().default("use"),
expiresAt: text("expires_at"),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
index("idx_credential_access_user_id").on(table.userId),
index("idx_credential_access_role_id").on(table.roleId),
index("idx_credential_access_credential_id").on(table.credentialId),
],
);
/**
* A recipient's copy of a shared credential's secrets, re-encrypted under
* the recipient's data key (the owner's key cannot be used by anyone else).
* Rebuilt whenever the owner edits the credential; one row per grant and
* recipient, like shared_host_secrets.
*/
export const sharedCredentialSecrets = sqliteTable(
"shared_credential_secrets",
{
id: integer("id").primaryKey({ autoIncrement: true }),
credentialAccessId: integer("credential_access_id").notNull(),
targetUserId: text("target_user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
credentialId: integer("credential_id")
.notNull()
.references(() => sshCredentials.id, { onDelete: "cascade" }),
encryptedUsername: text("encrypted_username"),
authType: text("auth_type").notNull().default("password"),
encryptedPassword: text("encrypted_password"),
encryptedKey: text("encrypted_key", { length: 16384 }),
encryptedKeyPassword: text("encrypted_key_password"),
keyType: text("key_type"),
publicKey: text("public_key", { length: 4096 }),
certPublicKey: text("cert_public_key", { length: 8192 }),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
foreignKey({
columns: [table.credentialAccessId],
foreignColumns: [credentialAccess.id],
name: "shared_cred_secrets_access_id_fk",
}).onDelete("cascade"),
uniqueIndex("idx_shared_credential_secrets_scope").on(
table.credentialAccessId,
table.targetUserId,
),
index("idx_shared_credential_secrets_target").on(
table.targetUserId,
table.credentialId,
),
],
);
// --- folder access rules ---
/**
* A standing share on a host folder. Sharing a folder fans out host_access
* grants to the hosts in it today; this row is what makes hosts created in
* or moved into the folder later inherit the same access.
*/
export const folderAccess = sqliteTable(
"folder_access",
{
id: integer("id").primaryKey({ autoIncrement: true }),
ownerUserId: text("owner_user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
// The folder path as stored on hosts ("Parent / Child"); subfolders inherit.
folder: text("folder").notNull(),
userId: text("user_id").references(() => users.id, { onDelete: "cascade" }),
roleId: integer("role_id").references(() => roles.id, {
onDelete: "cascade",
}),
grantedBy: text("granted_by")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
permissionLevel: text("permission_level").notNull().default("connect"),
expiresAt: text("expires_at"),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
index("idx_folder_access_owner_folder").on(table.ownerUserId, table.folder),
],
);
@@ -0,0 +1,222 @@
import { and, desc, eq, isNull } from "drizzle-orm";
import { collabRoomMembers, collabRooms, users } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { insertReturning } from "./returning.js";
import { rowsAffected } from "./mutation-result.js";
export type CollabRoomRecord = typeof collabRooms.$inferSelect;
export type CollabRoomMemberRecord = typeof collabRoomMembers.$inferSelect;
export type CollabRoomRole = "host" | "member";
export interface CollabRoomMemberWithUser {
userId: string;
username: string;
roomRole: string;
createdAt: string;
}
export interface CollabRoomStage {
presenterUserId: string | null;
stageProtocol: string | null;
stageHostId: number | null;
stageShareId: string | null;
}
export class CollabRoomRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async createRoom(input: {
id: string;
name: string;
ownerUserId: string;
persistent: boolean;
}): Promise<CollabRoomRecord> {
const [created] = await insertReturning(this.context, collabRooms, {
id: input.id,
name: input.name,
ownerUserId: input.ownerUserId,
persistent: input.persistent,
});
await this.afterWrite();
return created;
}
async findById(id: string): Promise<CollabRoomRecord | null> {
const rows = await this.context.drizzle
.select()
.from(collabRooms)
.where(eq(collabRooms.id, id))
.limit(1);
return rows[0] ?? null;
}
/** The live room whose stage points at this share, if any. */
async findByStageShareId(shareId: string): Promise<CollabRoomRecord | null> {
const rows = await this.context.drizzle
.select()
.from(collabRooms)
.where(
and(eq(collabRooms.stageShareId, shareId), isNull(collabRooms.endedAt)),
)
.limit(1);
return rows[0] ?? null;
}
async findByGuestToken(token: string): Promise<CollabRoomRecord | null> {
const rows = await this.context.drizzle
.select()
.from(collabRooms)
.where(
and(eq(collabRooms.guestLinkToken, token), isNull(collabRooms.endedAt)),
)
.limit(1);
return rows[0] ?? null;
}
async setGuestToken(roomId: string, token: string | null): Promise<void> {
await this.context.drizzle
.update(collabRooms)
.set({ guestLinkToken: token })
.where(eq(collabRooms.id, roomId));
await this.afterWrite();
}
async listForUser(userId: string): Promise<CollabRoomRecord[]> {
const rows = await this.context.drizzle
.select({ room: collabRooms })
.from(collabRoomMembers)
.innerJoin(collabRooms, eq(collabRoomMembers.roomId, collabRooms.id))
.where(
and(eq(collabRoomMembers.userId, userId), isNull(collabRooms.endedAt)),
)
.orderBy(desc(collabRooms.createdAt));
return rows.map((row) => row.room);
}
async findMember(
roomId: string,
userId: string,
): Promise<CollabRoomMemberRecord | null> {
const rows = await this.context.drizzle
.select()
.from(collabRoomMembers)
.where(
and(
eq(collabRoomMembers.roomId, roomId),
eq(collabRoomMembers.userId, userId),
),
)
.limit(1);
return rows[0] ?? null;
}
async addMember(input: {
roomId: string;
userId: string;
roomRole: CollabRoomRole;
addedBy: string | null;
}): Promise<boolean> {
if (await this.findMember(input.roomId, input.userId)) return false;
await this.context.drizzle.insert(collabRoomMembers).values({
roomId: input.roomId,
userId: input.userId,
roomRole: input.roomRole,
addedBy: input.addedBy,
});
await this.afterWrite();
return true;
}
async removeMember(roomId: string, userId: string): Promise<void> {
await this.context.drizzle
.delete(collabRoomMembers)
.where(
and(
eq(collabRoomMembers.roomId, roomId),
eq(collabRoomMembers.userId, userId),
),
);
await this.afterWrite();
}
async listMembers(roomId: string): Promise<CollabRoomMemberWithUser[]> {
return this.context.drizzle
.select({
userId: collabRoomMembers.userId,
username: users.username,
roomRole: collabRoomMembers.roomRole,
createdAt: collabRoomMembers.createdAt,
})
.from(collabRoomMembers)
.innerJoin(users, eq(collabRoomMembers.userId, users.id))
.where(eq(collabRoomMembers.roomId, roomId))
.orderBy(users.username);
}
async updateStage(roomId: string, stage: CollabRoomStage): Promise<void> {
await this.context.drizzle
.update(collabRooms)
.set(stage)
.where(eq(collabRooms.id, roomId));
await this.afterWrite();
}
async replaceStage(
roomId: string,
expectedShareId: string | null,
stage: CollabRoomStage,
): Promise<boolean> {
const result = await this.context.drizzle
.update(collabRooms)
.set(stage)
.where(
and(
eq(collabRooms.id, roomId),
expectedShareId
? eq(collabRooms.stageShareId, expectedShareId)
: isNull(collabRooms.stageShareId),
),
);
const changed = rowsAffected(result) > 0;
if (changed) await this.afterWrite();
return changed;
}
async clearStage(roomId: string): Promise<void> {
return this.updateStage(roomId, {
presenterUserId: null,
stageProtocol: null,
stageHostId: null,
stageShareId: null,
});
}
async endRoom(roomId: string): Promise<void> {
await this.context.drizzle
.update(collabRooms)
.set({
endedAt: new Date().toISOString(),
presenterUserId: null,
stageProtocol: null,
stageHostId: null,
stageShareId: null,
})
.where(eq(collabRooms.id, roomId));
await this.afterWrite();
}
async deleteRoom(roomId: string): Promise<void> {
await this.context.drizzle
.delete(collabRooms)
.where(eq(collabRooms.id, roomId));
await this.afterWrite();
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}

Some files were not shown because too many files have changed in this diff Show More