release-2.9.0 (#1532)

* fix(macos): skip single-instance lock in Mac App Store builds (#1454)

fix(macos): skip single-instance lock in Mac App Store builds

* Fix/backend optional sharp (#1455)

Fix/backend optional sharp

* feat: issue #1218 (#1218)

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

* feat: Add option to use saved global custom theme under Connection Defaults (#1209)

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

* feat: Add Fleet Sharing functionality to the UI (#1228)

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

* feat: Add a list view to the Docker management page (#1237)

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

* feat: issue #1264 (#1264)

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

* feat: 2FA With FortiToken (#1288)

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

* feat: Allow to disable showing paths to folders (#1274)

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

* feat: Sync Network Graph between Desktop and Remote Server (#1245)

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

* feat: Version Number on offline servers (#1291)

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

* feat: Support OrbStack Docker socket path on macOS / Fix "Docker is not installed" on mac... (#1302)

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

* feat: Sidebar host click should focus existing tab instead of opening a new connection (#1289)

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

* fix: Search in side bar shows overlapping hosts when grouped by tags (#1303)

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

* fix: I can't send a file on its own. (#1304)

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

* fix: sudo password not autofilling (#1248)

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

* fix: node-MainThread (#1300)

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

* fix: Tunnel authentication with shared credentials (#1295)

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

* fix: host key updates not syncing, metrics 404 race on first connect

Host key writes never bumped updatedAt so sync never picked them up.
Also retry the first metrics fetch briefly instead of failing right away.

* chore: increment ver

* feat: add category and icon fields to plugin manifest schema

* ci(deps): bump the github-actions group with 2 updates (#1458)

Bumps the github-actions group with 2 updates: [crowdin/github-action](https://github.com/crowdin/github-action) and [actions/github-script](https://github.com/actions/github-script).


Updates `crowdin/github-action` from 2 to 3
- [Release notes](https://github.com/crowdin/github-action/releases)
- [Commits](https://github.com/crowdin/github-action/compare/v2...v3)

Updates `actions/github-script` from 7 to 9
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/v7...v9)

---
updated-dependencies:
- dependency-name: crowdin/github-action
  dependency-version: '3'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: actions/github-script
  dependency-version: '9'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps-dev): bump the dev-patch-updates group with 13 updates (#1459)

Bumps the dev-patch-updates group with 13 updates:

| Package | From | To |
| --- | --- | --- |
| [@codemirror/commands](https://github.com/codemirror/commands) | `6.11.0` | `6.11.1` |
| [@codemirror/search](https://github.com/codemirror/search) | `6.7.1` | `6.7.2` |
| [@codemirror/view](https://github.com/codemirror/view) | `6.43.9` | `6.43.12` |
| [@testing-library/dom](https://github.com/testing-library/dom-testing-library) | `10.4.1` | `10.4.2` |
| [@testing-library/react](https://github.com/testing-library/react-testing-library) | `16.3.2` | `16.3.3` |
| [@testing-library/user-event](https://github.com/testing-library/user-event) | `14.6.5` | `14.6.7` |
| [@types/ssh2](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/ssh2) | `1.15.5` | `1.15.6` |
| [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) | `6.1.0` | `6.1.1` |
| [cytoscape](https://github.com/cytoscape/cytoscape.js) | `3.34.1` | `3.34.3` |
| [eslint-plugin-react-refresh](https://github.com/ArnaudBarre/eslint-plugin-react-refresh) | `0.5.4` | `0.5.7` |
| [i18next](https://github.com/i18next/i18next) | `26.4.0` | `26.4.2` |
| [prettier](https://github.com/prettier/prettier) | `3.9.6` | `3.9.8` |
| [react-i18next](https://github.com/i18next/react-i18next) | `17.0.12` | `17.0.14` |


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

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

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

Updates `@testing-library/dom` from 10.4.1 to 10.4.2
- [Release notes](https://github.com/testing-library/dom-testing-library/releases)
- [Changelog](https://github.com/testing-library/dom-testing-library/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/dom-testing-library/compare/v10.4.1...v10.4.2)

Updates `@testing-library/react` from 16.3.2 to 16.3.3
- [Release notes](https://github.com/testing-library/react-testing-library/releases)
- [Changelog](https://github.com/testing-library/react-testing-library/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/react-testing-library/compare/v16.3.2...v16.3.3)

Updates `@testing-library/user-event` from 14.6.5 to 14.6.7
- [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.5...v14.6.7)

Updates `@types/ssh2` from 1.15.5 to 1.15.6
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/ssh2)

Updates `@vitejs/plugin-react` from 6.1.0 to 6.1.1
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@6.1.1/packages/plugin-react)

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

Updates `eslint-plugin-react-refresh` from 0.5.4 to 0.5.7
- [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.4...v0.5.7)

Updates `i18next` from 26.4.0 to 26.4.2
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v26.4.0...v26.4.2)

Updates `prettier` from 3.9.6 to 3.9.8
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.9.6...3.9.8)

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

---
updated-dependencies:
- dependency-name: "@codemirror/commands"
  dependency-version: 6.11.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@codemirror/search"
  dependency-version: 6.7.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@codemirror/view"
  dependency-version: 6.43.12
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@testing-library/dom"
  dependency-version: 10.4.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@testing-library/react"
  dependency-version: 16.3.3
  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.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@types/ssh2"
  dependency-version: 1.15.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@vitejs/plugin-react"
  dependency-version: 6.1.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: cytoscape
  dependency-version: 3.34.3
  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.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: i18next
  dependency-version: 26.4.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: prettier
  dependency-version: 3.9.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: react-i18next
  dependency-version: 17.0.14
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump the prod-patch-updates group with 6 updates (#1461)

Bumps the prod-patch-updates group with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [@tanstack/react-virtual](https://github.com/TanStack/virtual/tree/HEAD/packages/react-virtual) | `3.14.10` | `3.14.13` |
| [compression](https://github.com/expressjs/compression) | `1.8.1` | `1.8.2` |
| [jose](https://github.com/panva/jose) | `6.2.9` | `6.2.12` |
| [jszip](https://github.com/Stuk/jszip) | `3.10.1` | `3.10.2` |
| [socks](https://github.com/JoshGlazebrook/socks) | `2.8.9` | `2.8.10` |
| [undici](https://github.com/nodejs/undici) | `8.10.0` | `8.10.2` |


Updates `@tanstack/react-virtual` from 3.14.10 to 3.14.13
- [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.13/packages/react-virtual)

Updates `compression` from 1.8.1 to 1.8.2
- [Release notes](https://github.com/expressjs/compression/releases)
- [Changelog](https://github.com/expressjs/compression/blob/master/HISTORY.md)
- [Commits](https://github.com/expressjs/compression/compare/v1.8.1...v1.8.2)

Updates `jose` from 6.2.9 to 6.2.12
- [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.9...v6.2.12)

Updates `jszip` from 3.10.1 to 3.10.2
- [Changelog](https://github.com/Stuk/jszip/blob/main/CHANGES.md)
- [Commits](https://github.com/Stuk/jszip/compare/v3.10.1...v3.10.2)

Updates `socks` from 2.8.9 to 2.8.10
- [Release notes](https://github.com/JoshGlazebrook/socks/releases)
- [Commits](https://github.com/JoshGlazebrook/socks/compare/2.8.9...2.8.10)

Updates `undici` from 8.10.0 to 8.10.2
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v8.10.0...v8.10.2)

---
updated-dependencies:
- dependency-name: "@tanstack/react-virtual"
  dependency-version: 3.14.13
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: compression
  dependency-version: 1.8.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: jose
  dependency-version: 6.2.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: jszip
  dependency-version: 3.10.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: socks
  dependency-version: 2.8.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: undici
  dependency-version: 8.10.2
  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>

* feat: add plugin worker protocol, bootstrap and plugin_storage table

Worker-side ctx proxy plus the message envelope it talks over, and the
per-plugin key/value table behind ctx.storage with migrations for all
three dialects. Rows cascade with the plugin so uninstalling leaves
nothing behind.

* feat: add allowlisted host view for plugins

An allowlist rather than a list of fields to strip. A blocklist rots:
the moment a new secret-bearing column lands on SSHHost it starts
leaking silently. With an allowlist a new field stays invisible to
plugins until someone adds it here on purpose.

* feat: add plugin loader with manifest validation and crash restart

Validates an unpacked plugin, spawns a worker_threads worker and manages
activate/deactivate/crash-restart with backoff, disabling after three
tries.

The crash counter clears only after a plugin stays up past a stability
window, not on activation. Clearing it on activation let a plugin that
activates cleanly and then dies restart forever, because every attempt
looked like the first.

loader.ts documents what the worker boundary does and does not buy you.
Node's permission model is process-wide, not per-worker, so it cannot be
enabled for plugin workers alone and is not claimed here.

* feat: route internal events through a shared plugin event bus

Formalises three ad-hoc publish paths that already existed:
automation-events.ts, metrics/automation-bridge.ts and the
hostSessionStatus singleton, now the host.session.status topic.

Both invariants the old code relied on are preserved. Publishing stays
fire-and-forget, so a failing subscriber cannot disturb a metrics poll or
a host delete. The automations engine subscribes to the bus at scheduler
start instead of being imported ad hoc, which keeps the edge
one-directional: automations may import repositories, hosts modules must
not import automations.

* feat: add plugin ctx broker with permission gate and audit

Every ctx call a plugin makes is checked against its granted
capabilities, performed by the main thread, then audited. A capability
must be both declared in the manifest and granted in the database, so
widening a plugin's reach always needs a new manifest the user can see,
never just a database row.

Audit attribution is set by the broker, not the plugin. The plugin
supplies only the details, so it cannot forge the actor.

fix: stop plugin workers seeing plaintext credentials in error messages

A failing ctx.ssh.connect handed the worker the raw error from deep in
the SSH stack, which embeds the connect config. The worker received, in
full:

  Authentication failed for root using password <SECRET>

where <SECRET> was the host's real plaintext password. Any plugin with
ssh.exec could read it by catching the error, defeating the whole point
of the handle-based ssh API.

Errors crossing the boundary are now sanitised. Only PluginFacingError
and PluginPermissionError, both authored here, reach a plugin verbatim;
anything else becomes a generic message and the real one goes to the
log. This uses a marker class rather than matching on message text,
because matching would start leaking again the moment an upstream error
happened to contain a familiar phrase.

Covered by credential-isolation.test.ts, which plants a sentinel in
every secret-bearing field and asserts it appears in no message the
worker ever receives.

* feat: dispatch plugin HTTP routes and wire the runtime into startup

ctx.http.route now registers a real router on the /plugin-api/:pluginId
dispatcher, replacing its stub 404. A plugin never touches the Express
req/res: it gets a plain summary and returns a plain object, so sockets
and session cookies stay on this side of the boundary. Authorization and
cookie headers are not forwarded.

Plugins load last in the start-up sequence, after the server is fully
wired, and are terminated before the database on shutdown so none can
outlive it. A plugin that fails to load cannot stop the backend.

fix: correct the nginx plugin-api location regex

The pattern was ^/plugin-api/(/.*)?$, which needs a double slash and so
never matched /plugin-api/<id>/<route>. Every other block in the file
uses ^/prefix(/.*)?$. Left alone, plugin routes would work in dev and
404 only behind Docker.

* fix: update stale SFTP transfer test for single file destination paths

The test still asserted transferToHost was called with the destination
directory "/srv". Commit 7727fa09 (#1304) deliberately changed a single
file transfer to send the full destination path including the filename,
so the correct expectation is "/srv/remote-1.txt". The code was right
and the test was left behind.

* feat: ship the ssh terminal as a first-party in-process plugin

Adds a hardcoded process:transport-owner tier so the terminal can own its
WebSocket server and ssh2 clients, which cannot cross the worker boundary.

* feat: ship docker as a first-party in-process plugin

Moves docker backend and frontend into plugins/docker/, alongside
ssh-terminal. Adds a host editor tab seam, a dashboard card registry,
and a build step to compile plugin backends into dist.

* feat: ship host metrics as a first-party in-process plugin

* fix: harden plugin auth and add per-request identity for ctx calls

Require auth on /plugin-api, gate plugin enable/grant/revoke routes
behind admin.plugins.manage, and thread the calling user through
worker ctx.hosts/ctx.ssh calls instead of always using the install
owner. Also adds the plugin permissions grant/revoke UI.

* feat: ship the AI assistant as a first-party in-process plugin

Moves src/backend/ai and its routes into plugins/ai, mounted through a
small /ai dispatcher instead of its own port. Moves the ai-enabled and
ai-private-endpoints settings routes out of user-settings-routes.ts
into the plugin too, so it has no leftover core dependents.

* fix: remove compiled plugin JS left behind in plugins/ after build

copy-bundled-plugins.cjs compiled and rewrote plugin backend TS in
place, copied it to dist/plugins, but never deleted the .js it had
just emitted next to the .ts sources. Those files shadowed the real
source for vitest/tsx and had import paths rewritten for the dist
layout, so tests failed with "Cannot find module" once a local build
had run. Affects docker, host-metrics and ai, not just this one.

* feat: add a permission-gated backend service registry for plugins

* feat: add a permission-gated UI action registry and action slots for plugins

Moves the terminal toolbar's hardwired AI button into a slot the ssh-terminal
plugin offers and the ai plugin fills, gated on ai.services.use.

* feat: add cross-plugin secret references gated by the provider's RBAC permission

* feat: move proxmox discovery/import/sync into a first-party plugin

* feat: move remote desktop (RDP/VNC/Telnet) into a first-party plugin

Relocates guacamole backend/frontend source and tests into
plugins/remote-desktop, following the ssh-terminal/docker pattern.

* feat: wire remote-desktop plugin into core and rename i18n keys

Repoints core consumers (dispatcher, collab, session-sharing, tab
utils) at the plugin, adds isTabTypeAvailable gating for rdp/vnc/
telnet, widens hostCapability to accept an array, and renames the
guacamole i18n namespace to remoteDesktop.

* feat: move fleets into a first-party plugin

* feat: move automations into a first-party plugin

* feat: remove legacy alert rules, superseded by automations

Alert rules already auto-migrate into automations on boot and the old
engine stands down after. Deletes the rule/firing tables, routes, UI,
and AI tools; keeps notification channels since automations still use
them. Also fixes automations' notify step, which still called the
deleted repository.

* feat: move network topology into a first-party plugin

* feat: move tailscale device discovery and settings into a plugin

Adds plugins/tailscale/ with a devices sidebar tab, admin settings
section, and re-auth dialog. Host Metrics tailscale manager card and
SSH re-auth banner parsing stay in core.

* feat: move workspaces into a first-party plugin

* feat: move web endpoint into a first-party plugin

* feat: collapse plugin runtime to one tier behind a plugin SDK

* feat: make each plugin an npm workspace built by a termix-plugin CLI

* feat: give plugins their own tables, migrations and sync entities

* feat: serve plugin http and websockets from one mount instead of ports

* feat: namespace plugin permissions and keep them valid when a plugin is off

* feat: give plugins their own settings and one settings screen

* feat: load plugin frontends at runtime and remove plugin ids from the shell

* fix: mock the ai status fetch in its activate test

* feat: add auth extension points and one ssh connect pipeline

* feat: rebuild workspaces as the reference plugin

* fix: gate second factors after SSO/LDAP behind an admin setting

* feat: convert network-topology to a full plugin (B1)

* feat: move snippets backend and tables into a plugin (B2)

* feat: point core frontend snippets pickers at the plugin (B2)

* feat: add the snippets plugin (B2)

* feat: wire ai, automations, fleets and ssh-terminal to snippets (B2)

* feat: finish the tailscale plugin, including SSH auth (B3)

* feat: finish the fleets plugin, add ctx.hosts and host-commands SDK (B4)

* chore: update proxmox and host-metrics locale files for B5

* chore: regenerate drizzle migration journals for B5

* feat: add ctx.hosts create/update to the SDK, host-metrics drops proxmox

* feat: move proxmox node stats and collectors into the proxmox plugin

* feat: drop proxmox host columns, use plugin settings instead

* feat: finish the fleets plugin, add ctx.hosts and host-commands SDK (B4)

* feat: convert file manager into a plugin

* feat: convert ssh tunnels into a plugin

* feat: finish the web-endpoint plugin, add ctx.desktop (B8)

* feat: move the terminal fully into the ssh-terminal plugin (B9)

* feat: convert tmux monitoring into a plugin (B10)

* feat: convert serial console into the serial plugin

* feat: move session sharing and collab rooms into a plugin (B12)

* feat: turn session recording into a plugin (B13)

* feat: finish the remote-desktop plugin (B14)

* feat: finish the host-metrics plugin, move host status into core (B16)

* feat: finish the docker plugin (B15)

* feat: finish the automations plugin (B17)

* feat: finish the ai plugin (B18)

* feat: finish the homepage plugin, move service links and canvas out of core (B19)

* feat: convert wake-on-lan into a plugin (B20)

* feat: convert secret-sources into a plugin (B20)

* feat: move totp and passkeys into plugins (C1)

* feat: move sso and ldap logins into plugins (C2)

* feat: move opkssh and warpgate into plugins (C3)

* feat: move step-ca into a plugin (C4)

* feat: move vault ssh signing into a plugin (C5)

* feat: move termix identity into a plugin (C6)

* feat: move acme certificate automation into a plugin (C7)

* fix: finish 2.9.0 plugin moves and get every check green

* chore: lock the plugin boundary and remove 2.9.0 leftovers

* fix: harden the plugin runtime after the 2.9.0 security review

* feat: add plugin packing, signing, pinned trust and tmxplug bundling

* test: prove 2.8 to 2.9.0 upgrade is lossless and fix what it caught

* fix: give bundled plugin backends a real require for CJS deps

* feat: improve UI loading time and jitter

* feat: show credential sharing inline in the sidebar like host sharing

* feat: tailscale add host and copy ip, fix outbound fetch hang and plugin ESM warning

* feat: unify alerts into one plugin, channels, rules and inbox

* feat: rebuild desktop sync as linked server accounts, fix packaged plugin bundles and UI scaling

* fix: set blacksmith builder cache-key to stop broken gha cache fallback

* fix: link plugin-sdk workspace before npm ci in docker deps stage

* fix: link every plugin workspace before npm ci in docker deps stage

* fix(host-metrics): exclude image and firmware filesystems (#1469)

* fix(host-metrics): exclude image and firmware filesystems

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(hosts): normalize imported jump host references (#1470)

* fix(hosts): normalize imported jump host references

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(ci): build plugin SDK and repair MySQL settings migration (#1471)

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(file-manager): keep inline rename consistent (#1476)

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(file-manager): keep inline rename consistent

* fix(dashboard): reuse translated host status labels (#1475)

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(dashboard): reuse translated host status labels

* docs(api): clarify local and remote version fields (#1474)

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* docs(api): clarify local and remote version fields

* fix(file-manager): preserve selected backgrounds on hover (#1473)

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(file-manager): preserve selection feedback on hover

* test(ui): await dialog focus cleanup before teardown

* fix(file-manager): restore Ctrl/Cmd+F file search (#1472)

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(file-manager): focus file search with Ctrl or Cmd F

* fix: keep rounded-full circular in production builds

* fix(file-manager): cancel uploads and stop remote writes (#1477)

* fix(file-manager): cancel uploads and stop remote writes

* test(css): exercise the configured PostCSS plugin chain

* fix(file-manager): apply compact density to directory tree (#1478)

* fix(file-manager): apply compact density to directory tree

* test(css): exercise the configured PostCSS plugin chain

* fix(file-manager): preserve font sizes in compact mode (#1480)

* fix(file-manager): preserve font sizes in compact mode

* test(css): exercise the configured PostCSS plugin chain

* fix(file-manager): prioritize file names in narrow lists (#1483)

* fix(file-manager): prioritize file names in narrow lists

* test(css): exercise the configured PostCSS plugin chain

* test(identity): preserve full Ed25519 public keys in fixtures

* test(remote-desktop): always mutate ciphertext in tamper test

* fix(remote-desktop): probe the saved host guacd endpoint (#1481)

* fix(remote-desktop): probe the saved host guacd endpoint

* test(css): exercise the configured PostCSS plugin chain

* test(identity): preserve full Ed25519 public keys in fixtures

* fix(homepage): check allowlisted private service reachability (#1479)

* fix(homepage): check allowlisted private service reachability

* test(css): exercise the configured PostCSS plugin chain

* fix(remote-desktop): stop unsolicited clipboard read prompts (#1482)

* fix(remote-desktop): read clipboard only on explicit paste

* test(css): exercise the configured PostCSS plugin chain

* test(identity): preserve full Ed25519 public keys in fixtures

* test(remote-desktop): always mutate ciphertext in tamper test

* chore: simplify plugin readmes, changelogs and manifest descriptions

* fix: keep plugins loaded after signing in from the login screen

* fix(file-manager): accept OS file drops over existing rows (#1484)

* fix(file-manager): accept OS file drops over existing rows

* test(file-manager): target the file label in drop tests

* test(css): exercise the configured PostCSS plugin chain

* test(identity): preserve full Ed25519 public keys in fixtures

* test(remote-desktop): always mutate ciphertext in tamper test

* fix(file-manager): pipeline SFTP download reads (#1485)

* feat: add plugin sdk build to beta release

* fix: make link-store secret check immune to random base64 matches

* fix(ldap): preserve the directory-provided bind DN (#1486)

* fix: compute beta changes across dev branches and move the beta tag

* chore: untrack plugin sdk ARCHITECTURE.md

* chore: remove references to plugin sdk ARCHITECTURE.md

* feat: merge host editor terminal tabs into one owned by ssh-terminal

* feat: add descriptions to plugin host enable toggles

* fix(file-manager): show download progress in file viewers (#1487)

* fix(electron): signal backend readiness over IPC (#1488)

* fix(split-screen): allow layout creation without randomUUID (#1490)

* fix(split-screen): allow layout creation without randomUUID

* test(credentials): avoid flaky Ed25519 key generation

* fix(sidebar): remeasure visible rows after density changes (#1489)

* fix(sidebar): remeasure visible rows after density changes

* test(credentials): avoid flaky Ed25519 key generation

* feat(split-screen): redesign split screen with free-form panes and pane picker

* feat(sidebar): add host click behavior setting, default to always opening a new tab

* feat(snippets): redesign panel to match the host list

* fix(session-recording): stop session logs panel from refetching in a loop

* feat(snippets): bring back sharing, target hosts and drag reorder

* feat(ssh-terminal): add toolbar position, start state, display and fade host settings

* feat(ssh-terminal): add host setting to turn off password prompt auto-fill

* fix(ai): stop streams getting cut off and keep tool steps across messages

* feat: improve quick connect system using plugin api

* fix: proxmox guest sync, startup snippet sync, drop dead cloudflare ssh transport

* feat: move terminal settings, look and ssh tools into ssh-terminal plugin

* feat: move snippets, keybinding actions and macros out of core into plugins

* feat: store rdp, vnc and telnet logins in a generic per-protocol table

* feat: type the plugin host record, move plugin strings out of core and add lint checks

* feat: replace core homepage widget registry with generic extension points

* feat: plugins only see their own host settings in the browser

* feat: harden plugin api for separate repos, signed updates and generic external login

* fix: keep plugin css below core utilities and version plugin assets by their css

* fix: load and save plugin host settings in the editor and tighten settings row spacing

* feat: redesign user host defaults system

* fix(remote-desktop): release held keys on focus loss (#1498)

* fix(session-sharing): replace local presentation after takeover (#1500)

* fix(file-manager): refresh disk usage with directory listing (#1503)

* fix(ssh-terminal): preserve input order during local echo reconciliation (#1505)

* feat(remote-desktop): expose display zoom controls for RDP (#1514)

* fix(file-manager): restore text selection and copying in Markdown previews (#1518)

* fix(sidebar): keep compact host addresses visible (#1524)

* fix(hosts): preserve plugin settings when reopening the editor (#1499)

* fix(ssh-terminal): close owner sockets when presentations unmount (#1501)

* fix(session-sharing): synchronize shared SSH terminal dimensions (#1502)

* fix(session-sharing): show participant presence in member terminals (#1504)

* fix(ai): sync provider configuration and keys across linked devices (#1506)

* feat(ssh-terminal): add a duplicate tab action (#1510)

* feat(file-manager): allow disabling move-to-trash confirmation (#1511)

* feat(file-manager): default to the remote login directory (#1512)

* feat(homepage): synchronize canvas layouts across devices (#1515)

* fix(shortcuts): prevent accidental double-Shift palette activation (#1519)

* feat(tmux-monitor): add a unified multi-host view (#1520)

* fix(electron): allow explicit quit with active connections (#1522)

* feat: reconnect all disconnected terminal tabs (#1523)

* fix(hosts): preserve recording and sharing settings in the dev editor (#1509)

* fix(database): preserve RDP domain during startup backfill (#1497)

* feat(sidebar): add keyboard type-ahead search for hosts (#1516)

* feat(automations): add per-host maintenance mode and recurring schedules (#1521)

* feat(tmux): add a per-host mouse toggle and preserve remote configuration (#1513)

* fix(session-sharing): use the linked server for desktop meetings and links (#1507)

* fix(tunnels): support TOTP for client tunnels and jump hosts (#1508)

* fix: type errors and lint failures from merged plugin prs

* feat(installer): make the Windows desktop shortcut optional (#1517)

* fix: ship the plugin sdk and serialport in desktop builds

* fix: prefer ipv4 when resolving ssh host names (#1365)

* fix: readable terminal selection with the light theme (#1319)

* fix: build desktop session share links on the linked server (#1350)

* fix: show meeting guests to members and hide the roster from guests (#1353)

* fix: reliable file manager saves with sudo prompt and no base64 guessing (#1335)

* fix: show disk usage for the mount being browsed (#1347)

* fix: load the linux tray icon reliably and log tray failures (#1332)

* fix: cover the gnome close path in the desktop quit test (#1332)

* fix: prompt for jump host TOTP in terminal, file manager and metrics (#1312)

* feat: optional auto reconnect for dropped ssh sessions (#1321)

* feat: tighter compact host rows with inline tags (#1325)

* feat: bring back the alert feed homepage widget

* feat: pick target terminals for snippets again

* feat: share button on connection tabs again

* feat: recent servers list when linking the desktop app

* feat: list the fixed app shortcuts in keyboard shortcuts

* fix: translate hardcoded toasts and drop em dashes from ui text

* fix: translate leftover hardcoded ui text

* fix: toolbar files button opens at the shell directory again

* fix: show the remote desktop note under connection origin again

* chore: openapi docs for host metrics manager routes

* chore: openapi docs for undocumented routes and fix two broken doc blocks

* fix: declare terminal.listSessions use for snippet targets

* revert: drop the alert feed widget, the notification center replaces it

* feat: restore the alert feed homepage widget

* fix: quick connect fills the host address from plugin auth editors like tailscale

* fix: docker container logs use the base background color

* chore: fix dependabot alerts and apply safe npm upgrades

* fix: proxmox stats tab reads its switch from plugin host settings

* fix: count plugin ssh logins toward host status and stop showing reachable as a warning

* fix: hide the built-in source pill in the host editor

* fix: host status shows online when the port answers and only warns on a failed login

* fix: drop the top border on the split layout menu

* fix: host defaults tabs sit where the host editor puts them and skip plugins with nothing to default

* fix: remote desktop auth method labels and notes for vnc and telnet

* fix: add description to the web endpoint enable switch

* fix: crowdin sync and pretranslate for plugin locales, run plugin tests on release

* feat: move telemetry out of core into a usage statistics plugin

* fix: recent activity card no longer cuts off host names early

* fix: remove duplicate top border on split screen menu

* fix: redesign session log player controls, fix tooltip overlap and drop dot separators

* fix: file manager and sftp text showing raw i18n keys

* fix: onboarding rerunning for existing users after upgrading

* fix: fleet detail buttons overflowing the sidebar

* fix: session log player scrolling off screen horizontally

* fix: network graph lag when adding hosts and on every render

* fix: maintenance index name, shared host read only view and badge polling

* fix: meeting tabs, workspace tab merging, rail panels as tabs and general ui lag

* chore: update release notes

* fix: remote desktop tabs reconnecting on every tab switch

* fix: build plugin sdk before linting in release workflow

* chore: lint, format, and bump version to 2.9.0

* chore: skip crowdin pre-translate in release for now

* chore: sync Crowdin translations for 2.9.0

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Koi <38512047+datlt4@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-10-01 16:20:16 -05:00
1 parent 49fa488093
commit 3643e7af97
4405 files changed
+1341026 -289722

No files matched your search

+78
View File
@@ -0,0 +1,78 @@
const { randomUUID } = require("node:crypto");
function createC2SAuthBridge(ipcMain, getWindow) {
const pending = new Map();
ipcMain.handle("answer-c2s-auth", (event, id, answer) => {
const item = pending.get(id);
if (!item || item.sender !== event.sender) return false;
if (answer !== null && (typeof answer !== "string" || answer.length > 8192))
return false;
item.finish(answer);
return true;
});
return function attach(ws, tunnelName) {
const active = new Set();
const close = () => {
for (const id of active) pending.get(id)?.finish(null);
ws.off("message", onMessage);
ws.off("close", close);
ws.off("error", close);
};
const onMessage = (raw, binary) => {
if (binary) return;
let message;
try {
message = JSON.parse(raw.toString());
} catch {
return;
}
if (message.type !== "auth-prompt") return;
const window = getWindow();
if (
!window ||
window.isDestroyed() ||
active.size ||
typeof message.requestId !== "string" ||
!["totp", "input", "browser"].includes(message.request?.kind) // plugin-id-ok: SSH prompt kind, not the plugin
) {
ws.close();
return;
}
const sender = window.webContents;
const id = randomUUID();
const finish = (answer) => {
if (!pending.delete(id)) return;
active.delete(id);
clearTimeout(timer);
sender.off("destroyed", cancelled);
sender.off("did-start-loading", cancelled);
if (!sender.isDestroyed())
sender.send("c2s-auth-prompt", { id, closed: true });
if (ws.readyState === 1)
ws.send(
JSON.stringify({
type: "auth-response",
requestId: message.requestId,
answer,
}),
);
if (answer === null) ws.close();
};
const cancelled = () => finish(null);
const timer = setTimeout(cancelled, 60_000);
active.add(id);
pending.set(id, { sender, finish });
sender.once("destroyed", cancelled);
sender.once("did-start-loading", cancelled);
sender.send("c2s-auth-prompt", {
id,
tunnelName,
request: message.request,
});
};
ws.on("message", onMessage);
ws.once("close", close);
ws.once("error", close);
};
}
module.exports = { createC2SAuthBridge };
+80
View File
@@ -0,0 +1,80 @@
// Desktop-only settings kept next to the app's data, and the server address
// older versions saved, so linking can offer it again.
const { app } = require("electron");
const fs = require("fs");
const path = require("path");
function dataPath(filename) {
return path.join(app.getPath("userData"), filename);
}
function readJson(filePath, fallback) {
try {
if (!fs.existsSync(filePath)) return fallback;
return JSON.parse(fs.readFileSync(filePath, "utf8"));
} catch {
return fallback;
}
}
function writeJson(filePath, value) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
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) {
fs.rmSync(temporaryPath, { force: true });
throw error;
}
}
const DEFAULTS = { defaultConnectionOrigin: "local" };
function getDesktopSettings() {
return { ...DEFAULTS, ...readJson(dataPath("desktop-settings.json"), {}) };
}
function saveDesktopSettings(settings) {
writeJson(dataPath("desktop-settings.json"), {
...getDesktopSettings(),
...settings,
});
return { success: true };
}
// The server a 2.8 or older desktop was pointed at, if any.
function getPreviousServerUrl() {
for (const file of ["remote-sync-config.json", "server-config.json"]) {
const config = readJson(dataPath(file), null);
if (config && typeof config.serverUrl === "string" && config.serverUrl) {
return config.serverUrl;
}
}
return null;
}
// The old sync engine's session and cursors are useless to the new one,
// and the session is a credential, so they go.
function removeOldSyncFiles() {
for (const file of [
"remote-sync-credential.json",
"remote-sync-state.json",
]) {
try {
fs.rmSync(dataPath(file), { force: true });
} catch {
// Best effort.
}
}
}
module.exports = {
getDesktopSettings,
saveDesktopSettings,
getPreviousServerUrl,
removeOldSyncFiles,
};
+53
View File
@@ -0,0 +1,53 @@
!include MUI2.nsh
!include nsDialogs.nsh
!include LogicLib.nsh
!ifndef BUILD_UNINSTALLER
Var TermixDesktopCheckbox
Var TermixDesktopChoice
; Keep electron-builder's --no-desktop-shortcut flag, adding the UI choice.
!macro _TermixNoDesktopShortcut _a _b _t _f
StrCmp $TermixDesktopChoice ${BST_UNCHECKED} `${_t}` 0
${StdUtils.TestParameter} $R9 "no-desktop-shortcut"
StrCmp $R9 "true" `${_t}` `${_f}`
!macroend
!undef isNoDesktopShortcut
!define isNoDesktopShortcut `"" TermixNoDesktopShortcut ""`
; Defined here, not at include time: electron-builder includes this file
; before it adds the StdUtils plugin directory that isUpdated needs.
!macro customPageAfterChangeDir
Page custom TermixShortcutPage TermixShortcutPageLeave
Function TermixShortcutPage
; Updates retain electron-builder's existing keep-shortcuts behavior.
${If} ${isUpdated}
Abort
${EndIf}
!insertmacro MUI_HEADER_TEXT "Shortcuts" "Choose whether to create a desktop shortcut."
nsDialogs::Create 1018
Pop $0
${If} $0 == error
Abort
${EndIf}
${NSD_CreateCheckbox} 0 0 100% 16u "Create a desktop shortcut"
Pop $TermixDesktopCheckbox
${If} $TermixDesktopChoice == ""
StrCpy $TermixDesktopChoice ${BST_CHECKED}
${EndIf}
${StdUtils.TestParameter} $R9 "no-desktop-shortcut"
${If} $R9 == "true"
StrCpy $TermixDesktopChoice ${BST_UNCHECKED}
EnableWindow $TermixDesktopCheckbox 0
${EndIf}
${NSD_SetState} $TermixDesktopCheckbox $TermixDesktopChoice
nsDialogs::Show
FunctionEnd
Function TermixShortcutPageLeave
${NSD_GetState} $TermixDesktopCheckbox $TermixDesktopChoice
FunctionEnd
!macroend
!endif
@@ -1,6 +1,6 @@
const { randomUUID } = require("node:crypto");
function webUrl(value) {
function isolatedUrl(value) {
const url = new URL(value);
if (
!["http:", "https:"].includes(url.protocol) ||
@@ -8,27 +8,35 @@ function webUrl(value) {
url.password
) {
throw new Error(
"Web endpoints require an HTTP(S) URL without embedded credentials",
"Isolated windows require an HTTP(S) URL without embedded credentials",
);
}
return url;
}
function createWebEndpointWindows({ BrowserWindow, session, getMainWindow }) {
/**
* Opens a BrowserWindow in its own non-persistent session, isolated from the
* main Termix window's cookies and storage. Used for any URL a plugin wants
* shown without sharing Termix's own session (a tunnelled or direct host web
* UI today; core has no other caller yet).
*
* Two ways in, both trusted: the renderer's own IPC call (checked against the
* main window's webContents before reaching here) and the embedded backend's
* fork IPC request (trusted because it is this app's own forked process, not
* arbitrary web content -- see the "backend-request" handler below).
*/
function createIsolatedWindows({ BrowserWindow, session, getMainWindow }) {
const certificates = new WeakMap();
async function open(event, options = {}) {
async function open(options = {}) {
const main = getMainWindow();
if (
!main ||
event.sender !== main.webContents ||
event.senderFrame !== main.webContents.mainFrame
) {
throw new Error("Only the main Termix window can open web endpoints");
if (!main) {
throw new Error("No window to attach an isolated window to");
}
const url = webUrl(options.url);
const isolated = session.fromPartition(`web-endpoint-${randomUUID()}`, {
cache: false,
});
const url = isolatedUrl(options.url);
const isolated = session.fromPartition(
options.partition || `isolated-window-${randomUUID()}`,
{ cache: false },
);
isolated.setPermissionRequestHandler((_contents, _permission, callback) =>
callback(false),
);
@@ -57,7 +65,7 @@ function createWebEndpointWindows({ BrowserWindow, session, getMainWindow }) {
win.setMenu(null);
const canNavigate = (target) => {
try {
return target === "about:blank" || !!webUrl(target);
return target === "about:blank" || !!isolatedUrl(target);
} catch {
return false;
}
@@ -90,7 +98,7 @@ function createWebEndpointWindows({ BrowserWindow, session, getMainWindow }) {
const win = new BrowserWindow({
width: 1100,
height: 800,
title: `Web endpoint — ${url.hostname}`,
title: options.title || url.hostname,
webPreferences: preferences,
});
configure(win);
@@ -125,4 +133,4 @@ function createWebEndpointWindows({ BrowserWindow, session, getMainWindow }) {
}
return { open, handleCertificateError };
}
module.exports = { createWebEndpointWindows, webUrl };
module.exports = { createIsolatedWindows, isolatedUrl };
+98
View File
@@ -0,0 +1,98 @@
// The server this desktop is linked to, as the embedded backend reports it.
// The backend owns the link (and its secrets); main only keeps what it needs
// to reach that one origin: the proxy headers and basic auth a reverse proxy
// in front of it wants, whether its certificate may be self-signed, and the
// session for requests main makes itself (C2S tunnels).
let linked = null;
function getOrigin(url) {
try {
return new URL(url).origin;
} catch {
return null;
}
}
function setLinkedServer(config) {
if (!config || !config.origin) {
linked = null;
return;
}
linked = {
origin: config.origin,
serverUrl: config.serverUrl || config.origin,
token: config.token || null,
headers: Array.isArray(config.headers) ? config.headers : [],
basicAuth:
config.basicAuth && config.basicAuth.username ? config.basicAuth : null,
allowInvalidCertificate: !!config.allowInvalidCertificate,
};
}
function getLinkedServer() {
return linked;
}
function isLinkedOrigin(url) {
return !!linked && getOrigin(url) === linked.origin;
}
function basicAuthValue(basicAuth) {
return `Basic ${Buffer.from(
`${basicAuth.username}:${basicAuth.password || ""}`,
).toString("base64")}`;
}
// Adds the link's proxy headers to a request bound for the linked origin.
// With basic auth in front, Authorization belongs to the proxy, so a Termix
// bearer token moves into the jwt cookie, which Termix reads first.
function applyLinkHeaders(url, headers) {
if (!isLinkedOrigin(url)) return headers;
const out = { ...headers };
for (const header of linked.headers) {
if (header && header.name && header.value) out[header.name] = header.value;
}
if (linked.basicAuth) {
const authKey = Object.keys(out).find(
(key) => key.toLowerCase() === "authorization",
);
const current = authKey ? String(out[authKey]) : "";
if (authKey) delete out[authKey];
const bearer = current.match(/^Bearer\s+(.+)$/i);
if (bearer) {
const cookieKey =
Object.keys(out).find((key) => key.toLowerCase() === "cookie") ||
"Cookie";
const existing = out[cookieKey] ? `${out[cookieKey]}; ` : "";
out[cookieKey] = `${existing}jwt=${bearer[1]}`;
}
out.Authorization = basicAuthValue(linked.basicAuth);
}
return out;
}
// Headers for a request main itself makes to the linked server.
function linkedRequestHeaders(extra = {}) {
if (!linked) return { ...extra };
const base = { "X-Electron-App": "true", ...extra };
if (linked.token) base.Authorization = `Bearer ${linked.token}`;
return applyLinkHeaders(linked.origin, base);
}
// Answers a basic auth challenge from the linked origin's proxy.
function answerLogin(authInfo, url) {
if (!linked || !linked.basicAuth) return null;
if (authInfo && authInfo.isProxy) return null;
if (!isLinkedOrigin(url)) return null;
return linked.basicAuth;
}
module.exports = {
setLinkedServer,
getLinkedServer,
isLinkedOrigin,
applyLinkHeaders,
linkedRequestHeaders,
answerLogin,
};
+48 -35
View File
@@ -10,8 +10,8 @@
// Trust boundary: the renderer never supplies a URL or headers. It names an
// origin ("local" | "remote") and a route from a fixed allowlist; the main
// process resolves the actual Termix backend URL and attaches credentials
// itself (session cookies for the embedded backend, the stored Remote Sync JWT
// for the remote server). Nothing here can be pointed at another host.
// itself (session cookies for the embedded backend, the linked server's
// session). Nothing here can be pointed at another host.
const fs = require("fs");
const fsp = require("fs/promises");
@@ -35,6 +35,7 @@ const IPC = {
DOWNLOAD: "local-transfer:download",
CANCEL: "local-transfer:cancel",
PROGRESS: "local-transfer:progress",
SET_API: "local-transfer:set-api",
};
const READ_CHUNK_BYTES = 1024 * 1024;
@@ -317,16 +318,27 @@ function writeToRequest(request, chunk) {
}
const TRANSFER_ROUTES = Object.freeze({
uploadFileStream: "/ssh/uploadFileStream",
downloadFileStream: "/ssh/downloadFileStream",
uploadFileStream: "/uploadFileStream",
downloadFileStream: "/downloadFileStream",
});
const DEVICE_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
// A compact JWT (base64url segments joined by dots); anything else is refused.
const AUTH_TOKEN_PATTERN = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
const DEFAULT_LOCAL_FILE_MANAGER_BASE =
"http://localhost:30004/ssh/file_manager";
// The plugin that serves the two streaming routes tells us its /plugin-api/
// path, so nothing here names it. Only a /plugin-api/<id> path is accepted,
// which keeps every transfer on the backend's plugin routes.
const API_PATH_PATTERN = /^\/plugin-api\/[a-z0-9][a-z0-9-]*$/;
const LOCAL_BACKEND = "http://localhost:30001";
let registeredApiPath = null;
function setTransferApiPath(apiPath) {
if (typeof apiPath !== "string" || !API_PATH_PATTERN.test(apiPath)) {
throw new Error("Invalid transfer API path");
}
registeredApiPath = apiPath;
}
function normalizeHttpBase(candidate, what) {
let parsed;
@@ -342,13 +354,15 @@ function normalizeHttpBase(candidate, what) {
}
// Resolves where a transfer may go. Only the two file-manager streaming
// routes are reachable, and only on the embedded backend or the configured
// Remote Sync server; credentials come from the main process, not the caller.
function createTargetResolver({
localBaseUrl = DEFAULT_LOCAL_FILE_MANAGER_BASE,
getRemoteSyncConfig,
getRemoteSyncJwt,
}) {
// routes are reachable, and only on the embedded backend or the linked
// server; credentials come from the main process, not the caller.
function createTargetResolver({ localBaseUrl, getLinkedServer, apiPath }) {
const resolveApiPath = () => {
const resolved = apiPath ?? registeredApiPath;
if (!resolved)
throw new Error("Local file transfers are not available yet");
return resolved;
};
return function resolveTransferTarget({
origin,
route,
@@ -388,27 +402,23 @@ function createTargetResolver({
headers.Authorization = `Bearer ${authToken}`;
}
return {
url: `${normalizeHttpBase(localBaseUrl, "Local backend URL")}${routePath}`,
url: `${normalizeHttpBase(
localBaseUrl ?? `${LOCAL_BACKEND}${resolveApiPath()}`,
"Local backend URL",
)}${routePath}`,
headers,
};
}
if (origin === "remote") {
const config =
typeof getRemoteSyncConfig === "function"
? getRemoteSyncConfig()
: null;
if (!config || !config.serverUrl) {
throw new Error("Remote sync server is not configured");
const linked =
typeof getLinkedServer === "function" ? getLinkedServer() : null;
if (!linked || !linked.serverUrl) {
throw new Error("This device is not linked to a server");
}
const base = normalizeHttpBase(
config.serverUrl,
"Remote sync server URL",
);
const jwt =
typeof getRemoteSyncJwt === "function" ? getRemoteSyncJwt() : null;
if (jwt) headers.Authorization = `Bearer ${jwt}`;
return { url: `${base}/ssh/file_manager${routePath}`, headers };
const base = normalizeHttpBase(linked.serverUrl, "Linked server URL");
if (linked.token) headers.Authorization = `Bearer ${linked.token}`;
return { url: `${base}${resolveApiPath()}${routePath}`, headers };
}
throw new Error(`Unknown transfer origin: ${String(origin)}`);
@@ -878,8 +888,7 @@ function wrap(handler) {
function createLocalFileHandlers({
net,
shell,
getRemoteSyncConfig,
getRemoteSyncJwt,
getLinkedServer,
localBaseUrl,
publishFs,
}) {
@@ -888,8 +897,7 @@ function createLocalFileHandlers({
}
const resolveTransferTarget = createTargetResolver({
localBaseUrl,
getRemoteSyncConfig,
getRemoteSyncJwt,
getLinkedServer,
});
const deps = {
net,
@@ -898,6 +906,11 @@ function createLocalFileHandlers({
};
return {
[IPC.SET_API]: wrap(async (_event, apiPath) => {
setTransferApiPath(apiPath);
return {};
}),
[IPC.HOME]: wrap(async () => ({
home: os.homedir(),
separator: path.sep,
@@ -1018,12 +1031,11 @@ function createLocalFileHandlers({
function registerLocalFileHandlers({ ipcMain, shell }) {
// Real Electron wiring; tests build the handlers directly instead.
const { net } = require("electron");
const remoteSync = require("./remote-sync.cjs");
const { getLinkedServer } = require("./linked-server.cjs");
const handlers = createLocalFileHandlers({
net,
shell,
getRemoteSyncConfig: remoteSync.getRemoteSyncConfig,
getRemoteSyncJwt: remoteSync.getRemoteSyncJwt,
getLinkedServer,
});
for (const [channel, handler] of Object.entries(handlers)) {
ipcMain.handle(channel, handler);
@@ -1037,6 +1049,7 @@ module.exports = {
// exported for tests / reuse
createLocalFileHandlers,
createTargetResolver,
setTransferApiPath,
publishDownload,
defaultPublishFs,
assertWithinRoot,
+267 -414
View File
@@ -27,7 +27,8 @@ const { URL, pathToFileURL } = require("url");
const { fork, spawn } = require("child_process");
const pty = require("node-pty");
const WebSocket = require("ws");
const remoteSync = require("./remote-sync.cjs");
const linkedServer = require("./linked-server.cjs");
const desktopSettings = require("./desktop-settings.cjs");
const { launchNativeRdp } = require("./native-rdp.cjs");
const { isCloseActiveTabInput } = require("./keyboard-shortcuts.cjs");
const { quitApp } = require("./app-quit.cjs");
@@ -54,7 +55,7 @@ function closeLocalTerminalsFor(ownerId) {
}
// The main process's Node.js networking (the `https`/`http` modules used by
// httpFetch below, and the global `fetch` used by remote-sync.cjs) only
// httpFetch below and the global `fetch`) only
// trusts Node's bundled Mozilla CA list by default, not the OS/system trust
// store. Chromium (the renderer, i.e. the web app and the login iframe) uses
// the OS trust store instead, so a certificate that's valid in-browser --
@@ -432,20 +433,6 @@ function isInsecureModeEnabled() {
);
}
function getServerConfigPath() {
return path.join(app.getPath("userData"), "server-config.json");
}
function getServerConfigSync() {
try {
const configPath = getServerConfigPath();
if (!fs.existsSync(configPath)) return null;
return JSON.parse(fs.readFileSync(configPath, "utf8"));
} catch {
return null;
}
}
function getOrigin(url) {
try {
return new URL(url).origin;
@@ -493,13 +480,11 @@ function isInvalidCertificateAllowedForUrl(url) {
// fall through
}
// The only remaining "connected remote server" a self-signed/invalid
// certificate could legitimately apply to is the Remote Sync server
// (also used for C2S tunnel relaying, see getC2SRelayUrl).
const config = remoteSync.getRemoteSyncConfig();
if (!config?.allowInvalidCertificate || !config?.serverUrl) return false;
return getOrigin(url) === getOrigin(config.serverUrl);
// Otherwise only the server this desktop is linked to, when its link
// allows a self-signed certificate.
const linked = linkedServer.getLinkedServer();
if (!linked?.allowInvalidCertificate) return false;
return getOrigin(url) === linked.origin;
}
function getTlsVerificationOptions(url) {
@@ -633,15 +618,57 @@ if (isInsecureModeEnabled()) {
app.commandLine.appendSwitch("--enable-features=NetworkService");
let mainWindow = null;
const { createWebEndpointWindows } = require("./web-endpoint-window.cjs");
const webEndpointWindows = createWebEndpointWindows({
const { createIsolatedWindows } = require("./isolated-window.cjs");
const isolatedWindows = createIsolatedWindows({
BrowserWindow,
session,
getMainWindow: () => mainWindow,
});
ipcMain.handle("open-isolated-web-endpoint", (event, options) =>
webEndpointWindows.open(event, options),
);
// Requests from the embedded backend over the fork IPC channel (the other
// direction from the "shutdown" message main already sends it). A plugin's
// ctx.desktop.openIsolatedWindow is the one caller today.
const BACKEND_REQUEST_HANDLERS = {
"open-isolated-window": (payload) => isolatedWindows.open(payload),
// The OS client for a protocol. Only RDP has one wired up.
"launch-external-client": (payload) =>
payload?.protocol === "rdp"
? launchNativeRdp(payload)
: {
success: false,
error: `No external client for ${payload?.protocol ?? "this protocol"}`,
},
"sync-proxy-config": (payload) => {
linkedServer.setLinkedServer(payload);
return { success: true };
},
};
async function handleBackendRequest(msg) {
if (!msg || msg.type !== "backend-request") return;
const { id, channel, payload } = msg;
const handler = BACKEND_REQUEST_HANDLERS[channel];
const reply = (response) => {
if (backendProcess && !backendProcess.killed) {
try {
backendProcess.send({ type: "backend-response", id, ...response });
} catch (error) {
logToFile("Failed to reply to backend request:", error.message);
}
}
};
if (!handler) {
reply({ ok: false, error: `Unknown backend request channel: ${channel}` });
return;
}
try {
const result = await handler(payload);
reply({ ok: true, result });
} catch (error) {
reply({ ok: false, error: error.message || String(error) });
}
}
let backendProcess = null;
let backendStartFailed = false;
// Why the embedded backend died, once it has. Null while it is healthy
@@ -826,12 +853,7 @@ app.on(
"certificate-error",
(event, _webContents, url, error, certificate, callback) => {
if (
webEndpointWindows.handleCertificateError(
event,
_webContents,
url,
callback,
)
isolatedWindows.handleCertificateError(event, _webContents, url, callback)
)
return;
if (isWebEndpointCertificateAllowed(url)) {
@@ -862,6 +884,16 @@ app.on(
},
);
// A reverse proxy in front of the linked server may ask for basic auth; the
// link's saved username and password answer it. Anything else gets the
// default behaviour.
app.on("login", (event, _webContents, details, authInfo, callback) => {
const credentials = linkedServer.answerLogin(authInfo, details?.url);
if (!credentials) return;
event.preventDefault();
callback(credentials.username, credentials.password || "");
});
function getElectronBuildTimestamp() {
try {
const buildInfo = require("./build-info.cjs");
@@ -1024,8 +1056,8 @@ function getBackendPidFilePath() {
// Manager) rather than through the normal quit flow, will-quit never fires
// and stopBackendServer() never runs -- the forked backend child is a
// genuinely separate OS process on Windows/mac/Linux, so it keeps running
// and holding every port the backend binds (30001, 30003-30008, 30010,
// 30012...). Every subsequent launch's own backend then fails outright
// and holding the port the backend binds (30001). Every subsequent
// launch's own backend then fails outright
// with EADDRINUSE and the app is stuck until something manually kills the
// orphan. Reap any such leftover process, identified by PID file, before
// spawning a new one.
@@ -1155,6 +1187,16 @@ function startBackendServer() {
logToFile("[backend:stderr]", chunk.trim());
});
backendProcess.on("message", (msg) => {
if (!resolved && msg?.type === "backend-ready") {
resolved = true;
clearTimeout(readyTimeout);
logToFile("Backend ready signal received via IPC");
resolve(true);
}
void handleBackendRequest(msg);
});
backendProcess.on("exit", (code, signal) => {
logToFile(`Backend process exited with code ${code}, signal ${signal}`);
if (!resolved && code !== 0) {
@@ -1227,7 +1269,9 @@ function stopBackendServer() {
});
}
const gotTheLock = app.requestSingleInstanceLock();
// App Sandbox denies the singleton socket bind, which reads as a held lock and
// quits the app on every launch. macOS reactivates bundled apps anyway.
const gotTheLock = process.mas || app.requestSingleInstanceLock();
if (!gotTheLock) {
console.log("Another instance is already running, quitting...");
app.quit();
@@ -1266,7 +1310,16 @@ function createTray() {
} else if (process.platform === "win32") {
trayIcon = path.join(publicRoot, "icon.ico");
} else {
trayIcon = path.join(publicRoot, "icons", "32x32.png");
trayIcon = loadLinuxTrayIcon(nativeImage, publicRoot);
if (!trayIcon) {
logToFile("Tray icon not found, skipping the system tray");
return;
}
if (/gnome/i.test(process.env.XDG_CURRENT_DESKTOP || "")) {
logToFile(
"GNOME shows tray icons only with the AppIndicator extension installed",
);
}
}
tray = new Tray(trayIcon);
@@ -1304,12 +1357,29 @@ function createTray() {
}
});
console.log("System tray created successfully");
logToFile("System tray created");
} catch (err) {
console.error("Failed to create system tray:", err);
tray = null;
logToFile("Failed to create system tray:", err?.message || String(err));
}
}
function loadLinuxTrayIcon(nativeImage, publicRoot) {
const roots = [
publicRoot,
path.join(process.resourcesPath || "", "app.asar.unpacked", "public"),
];
for (const root of roots) {
for (const size of ["32x32", "24x24", "48x48"]) {
const image = nativeImage.createFromPath(
path.join(root, "icons", `${size}.png`),
);
if (!image.isEmpty()) return image;
}
}
return null;
}
function createWindow() {
const appVersion = app.getVersion();
const electronVersion = process.versions.electron;
@@ -1388,7 +1458,12 @@ function createWindow() {
);
}
callback({ requestHeaders: details.requestHeaders });
callback({
requestHeaders: linkedServer.applyLinkHeaders(
details.url,
details.requestHeaders,
),
});
},
);
@@ -1497,10 +1572,24 @@ function createWindow() {
console.log("Frontend loaded successfully");
});
mainWindow.webContents.on("will-prevent-unload", (event) => {
// Explicit Quit must not be vetoed by the renderer's connection guard.
// In this Electron event, preventDefault allows the unload to proceed.
if (isQuitting) event.preventDefault();
});
mainWindow.on("close", (event) => {
if (!isQuitting && tray && !tray.isDestroyed()) {
event.preventDefault();
mainWindow.hide();
// Stock GNOME has no tray to bring a hidden window back from.
if (
process.platform === "linux" &&
/gnome/i.test(process.env.XDG_CURRENT_DESKTOP || "")
) {
mainWindow.minimize();
} else {
mainWindow.hide();
}
}
});
@@ -1532,119 +1621,10 @@ ipcMain.handle("get-app-version", () => {
return app.getVersion();
});
const GITHUB_API_BASE = "https://api.github.com";
const REPO_OWNER = "Termix-SSH";
const REPO_NAME = "Termix";
const githubCache = new Map();
const CACHE_DURATION = 30 * 60 * 1000;
async function fetchGitHubAPI(endpoint, cacheKey) {
const cached = githubCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_DURATION) {
return {
data: cached.data,
cached: true,
cache_age: Date.now() - cached.timestamp,
};
}
try {
const response = await httpFetch(`${GITHUB_API_BASE}${endpoint}`, {
headers: {
Accept: "application/vnd.github+json",
"User-Agent": "TermixElectronUpdateChecker/1.0",
"X-GitHub-Api-Version": "2022-11-28",
},
timeout: 10000,
});
if (!response.ok) {
throw new Error(
`GitHub API error: ${response.status} ${response.statusText}`,
);
}
const data = await response.json();
githubCache.set(cacheKey, {
data,
timestamp: Date.now(),
});
return {
data: data,
cached: false,
};
} catch (error) {
console.error("Failed to fetch from GitHub API:", error);
throw error;
}
}
ipcMain.handle("check-electron-update", async () => {
try {
const localVersion = app.getVersion();
const releaseData = await fetchGitHubAPI(
`/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest`,
"latest_release_electron",
);
const rawTag = releaseData.data.tag_name || releaseData.data.name || "";
const remoteVersionMatch = rawTag.match(/(\d+\.\d+(\.\d+)?)/);
const remoteVersion = remoteVersionMatch ? remoteVersionMatch[1] : null;
if (!remoteVersion) {
return {
success: false,
error: "Remote version not found",
localVersion,
};
}
const versionComparison = compareSemver(localVersion, remoteVersion);
const status =
versionComparison === null || versionComparison === 0
? "up_to_date"
: versionComparison > 0
? "beta"
: "requires_update";
const result = {
success: true,
status,
localVersion: localVersion,
remoteVersion: remoteVersion,
latest_release: {
tag_name: releaseData.data.tag_name,
name: releaseData.data.name,
published_at: releaseData.data.published_at,
html_url: releaseData.data.html_url,
body: releaseData.data.body,
},
cached: releaseData.cached,
cache_age: releaseData.cache_age,
};
return result;
} catch (error) {
return {
success: false,
error: error.message,
localVersion: app.getVersion(),
};
}
});
ipcMain.handle("get-platform", () => {
return process.platform;
});
ipcMain.handle("open-native-rdp", (_event, options) =>
launchNativeRdp(options),
);
ipcMain.handle("get-embedded-server-status", () => {
return {
running:
@@ -1657,9 +1637,9 @@ ipcMain.handle("get-embedded-server-status", () => {
};
});
// OIDC System Browser Authentication (RFC 8252)
// External login through the system browser (RFC 8252)
ipcMain.handle(
"oidc-system-browser-auth",
"external-browser-login",
async (_event, authUrl, callbackPort) => {
const http = require("http");
@@ -1672,13 +1652,21 @@ ipcMain.handle(
const success = url.searchParams.get("success");
const error = url.searchParams.get("error");
const token = url.searchParams.get("token");
const secondFactor = url.searchParams.get("second_factor") === "1";
res.writeHead(200, { "Content-Type": "text/html" });
res.end(
`<html><body><h2>${success === "true" ? "Authentication successful!" : "Authentication failed."}</h2><p>You can close this tab and return to Termix.</p><script>window.close()</script></body></html>`,
`<html><body><h2>${success === "true" || secondFactor ? "Authentication successful!" : "Authentication failed."}</h2><p>You can close this tab and return to Termix.</p><script>window.close()</script></body></html>`,
);
if (success === "true") {
if (secondFactor) {
finish({
success: false,
secondFactor: true,
tempToken: url.searchParams.get("temp_token"),
factors: url.searchParams.get("second_factors"),
});
} else if (success === "true") {
finish({ success: true, token });
} else {
finish({
@@ -1725,7 +1713,7 @@ ipcMain.handle(
// Timeout after 5 minutes
timeout = setTimeout(
() => {
fail(new Error("OIDC authentication timed out"));
fail(new Error("External login timed out"));
},
5 * 60 * 1000,
);
@@ -1733,109 +1721,20 @@ ipcMain.handle(
},
);
ipcMain.handle("get-server-config", () => {
try {
return getServerConfigSync();
} catch (error) {
console.error("Error reading server config:", error);
return null;
}
});
ipcMain.handle("save-server-config", (event, config) => {
try {
const userDataPath = app.getPath("userData");
const configPath = getServerConfigPath();
if (!fs.existsSync(userDataPath)) {
fs.mkdirSync(userDataPath, { recursive: true });
}
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
return { success: true };
} catch (error) {
console.error("Error saving server config:", error);
return { success: false, error: error.message };
}
});
// --- Remote sync (optional desktop <-> self-hosted server sync) ---
// Surfaces the pre-standalone-rework server-config.json (if a serverUrl was
// ever set in it) so the renderer can prompt upgraded installs to set up
// Remote Sync -- their hosts live on that old server and won't appear
// locally until sync is enabled. A fresh install never had this file, so
// this is naturally false for anyone who never used the old architecture.
ipcMain.handle("get-legacy-server-config", () => {
const config = getServerConfigSync();
return { serverUrl: config?.serverUrl || null };
});
// Desktop-only settings, such as whether hosts connect from this device or
// through the linked server by default.
ipcMain.handle("get-desktop-settings", () => {
return remoteSync.getDesktopSettings();
return desktopSettings.getDesktopSettings();
});
ipcMain.handle("save-desktop-settings", (_event, settings) => {
return remoteSync.saveDesktopSettings(settings);
return desktopSettings.saveDesktopSettings(settings);
});
ipcMain.handle("get-remote-sync-config", () => {
return remoteSync.getRemoteSyncConfig();
});
ipcMain.handle("save-remote-sync-config", (_event, config) => {
return remoteSync.saveRemoteSyncConfig(config);
});
ipcMain.handle("clear-remote-sync-config", async () => {
const result = remoteSync.clearRemoteSyncConfig();
remoteSync.clearRemoteSyncJwt();
remoteSync.getRemoteSyncEngine()?.updateStatus({
connected: false,
syncing: false,
needsReauth: false,
lastError: null,
});
return result;
});
ipcMain.handle("save-remote-sync-jwt", async (_event, token) => {
const result = remoteSync.saveRemoteSyncJwt(token);
if (result.success) {
remoteSync.getRemoteSyncEngine()?.updateStatus({
connected: true,
needsReauth: false,
lastError: null,
});
const status = await remoteSync.getRemoteSyncEngine()?.syncNow();
return { ...result, status: status || null };
}
return result;
});
ipcMain.handle("get-remote-sync-jwt", () => {
return remoteSync.getRemoteSyncJwt();
});
ipcMain.handle("clear-remote-sync-jwt", () => {
return remoteSync.clearRemoteSyncJwt();
});
ipcMain.handle("get-remote-sync-status", () => {
return remoteSync.getRemoteSyncEngine()?.status || null;
});
ipcMain.handle("get-remote-sync-user-info", () => {
return remoteSync.getRemoteSyncUserInfo();
});
ipcMain.handle("remote-sync-now", async () => {
return (await remoteSync.getRemoteSyncEngine()?.syncNow()) || null;
});
ipcMain.handle("notify-local-login", (_event, token) => {
remoteSync.getRemoteSyncEngine()?.setLocalJwt(token);
return { success: true };
// The server an older version of the app was pointed at, offered again when
// linking so upgraded installs do not have to look it up.
ipcMain.handle("get-previous-server-url", () => {
return { serverUrl: desktopSettings.getPreviousServerUrl() };
});
function getC2STunnelConfigPath() {
@@ -2004,40 +1903,45 @@ const C2S_WS_HIGH_WATERMARK = 1024 * 1024;
const C2S_WS_LOW_WATERMARK = 256 * 1024;
const C2S_STREAM_WRITE_LIMIT = 8 * 1024 * 1024;
// C2S (client-to-server) tunnels relay through a connected, self-hosted
// Termix server -- the same "remote server" concept Remote Sync connects
// to, not the always-local embedded backend. There's no separate C2S
// server-URL setting in the UI; it has always shared whatever remote
// server the rest of the app was pointed at. Before the standalone-first
// rework that was server-config.json; now it's remote-sync-config.json,
// since that's the only remaining notion of "a connected remote server."
function getC2SRelayUrl() {
const config = remoteSync.getRemoteSyncConfig();
const serverUrl = config?.serverUrl;
if (!serverUrl) {
throw new Error(
"No remote Termix server connected -- enable Remote Sync first",
);
}
const base = serverUrl.replace(/\/$/, "");
const relayHttpUrl = `${base}/ssh/tunnel/c2s/stream`;
return relayHttpUrl.replace(/^http:/, "ws:").replace(/^https:/, "wss:");
}
// C2S (client-to-server) tunnels relay through the server this desktop is
// linked to, not the embedded backend.
function getC2SRemoteBaseUrl() {
const config = remoteSync.getRemoteSyncConfig();
const serverUrl = config?.serverUrl;
const serverUrl = linkedServer.getLinkedServer()?.serverUrl;
if (!serverUrl) {
throw new Error(
"No remote Termix server connected -- enable Remote Sync first",
"This device is not linked to a Termix server. Link it in Sync first.",
);
}
return serverUrl.replace(/\/$/, "");
}
// The socket path on the linked server that relays C2S streams. The plugin
// that owns the relay tells us where it is, so nothing here names it.
let c2sRelayPath = null;
const C2S_RELAY_PATH_PATTERN =
/^\/plugin-ws\/[a-z0-9][a-z0-9-]*\/[A-Za-z0-9/_-]+$/;
ipcMain.handle("set-c2s-relay-path", (_event, relayPath) => {
if (
typeof relayPath !== "string" ||
!C2S_RELAY_PATH_PATTERN.test(relayPath)
) {
return { success: false, error: "Invalid relay path" };
}
c2sRelayPath = relayPath;
return { success: true };
});
function getC2SRelayUrl() {
if (!c2sRelayPath) {
throw new Error("Client tunnels are not available yet. Try again shortly.");
}
const relayHttpUrl = `${getC2SRemoteBaseUrl()}${c2sRelayPath}`;
return relayHttpUrl.replace(/^http:/, "ws:").replace(/^https:/, "wss:");
}
const C2S_REMOTE_SESSION_EXPIRED_ERROR =
"Remote Termix session expired. Reconnect Remote Sync and try again.";
"This device was signed out of its server. Sign in again from Sync and try again.";
function normalizeC2SAuthToken(authToken) {
return typeof authToken === "string" ? authToken.trim() : "";
@@ -2052,7 +1956,9 @@ function isC2SAuthError(message) {
value === "missing authentication token" ||
value === "invalid token" ||
value === "session expired" ||
value === "session not found"
value === "session not found" ||
// The plugin relay refuses an unauthenticated upgrade before any message.
value === "unexpected server response: 401"
);
}
@@ -2073,17 +1979,13 @@ function createC2SFailure(message, fallback) {
}
async function fetchC2SRemoteJson(pathname) {
const remoteSyncJwt = remoteSync.getRemoteSyncJwt();
if (!remoteSyncJwt || remoteSync.isJwtExpiredOrExpiringSoon(remoteSyncJwt)) {
if (!linkedServer.getLinkedServer()?.token) {
throw new Error(C2S_REMOTE_SESSION_EXPIRED_ERROR);
}
const url = `${getC2SRemoteBaseUrl()}${pathname}`;
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${remoteSyncJwt}`,
"X-Electron-App": "true",
},
const response = await httpFetch(url, {
headers: linkedServer.linkedRequestHeaders(),
});
if (response.status === 401 || response.status === 403) {
throw new Error(C2S_REMOTE_SESSION_EXPIRED_ERROR);
@@ -2102,14 +2004,18 @@ async function resolveC2SRemoteSourceHost(tunnel) {
: "";
if (!sourceHostSyncId) return normalized;
const data = await fetchC2SRemoteJson("/sync/hosts");
const remoteHost = (Array.isArray(data?.rows) ? data.rows : []).find(
(row) => row?.syncId === sourceHostSyncId,
);
let remoteHost = null;
try {
remoteHost = await fetchC2SRemoteJson(
`/sync/v2/hosts/${encodeURIComponent(sourceHostSyncId)}`,
);
} catch (error) {
if (error.message === C2S_REMOTE_SESSION_EXPIRED_ERROR) throw error;
}
const remoteHostId = Number(remoteHost?.id);
if (!Number.isInteger(remoteHostId) || remoteHostId < 1) {
throw new Error(
"Intermediate Host is not available on the remote Termix server yet. Run Remote Sync for hosts, then try again.",
"The intermediate host is not on the server yet. Sync, then try again.",
);
}
@@ -2123,18 +2029,11 @@ async function resolveC2SRemoteSourceHost(tunnel) {
}
async function getC2SRelayHeaders(relayUrl) {
if (linkedServer.getLinkedServer()?.token) {
return linkedServer.linkedRequestHeaders();
}
const headers = { "X-Electron-App": "true" };
const remoteSyncJwt = remoteSync.getRemoteSyncJwt();
if (remoteSyncJwt && !remoteSync.isJwtExpiredOrExpiringSoon(remoteSyncJwt)) {
headers.Authorization = `Bearer ${remoteSyncJwt}`;
return headers;
}
if (remoteSyncJwt) {
throw new Error(C2S_REMOTE_SESSION_EXPIRED_ERROR);
}
if (!mainWindow?.webContents?.session) {
throw new Error(C2S_REMOTE_SESSION_EXPIRED_ERROR);
}
@@ -2155,6 +2054,11 @@ async function getC2SRelayHeaders(relayUrl) {
return headers;
}
const attachC2SAuth = require("./c2s-auth.cjs").createC2SAuthBridge(
ipcMain,
() => mainWindow,
);
function getC2STunnelName(tunnel, index = 0) {
if (tunnel.name) return tunnel.name;
const localAddress = getC2SLocalAddress(tunnel);
@@ -2271,6 +2175,7 @@ async function openC2SRelay(
relayUrl,
getWebSocketOptions(relayUrl, { headers }),
);
attachC2SAuth(ws, tunnel.name || getC2STunnelName(tunnel));
const pendingChunks = [];
let ready = false;
let closed = false;
@@ -2360,22 +2265,33 @@ async function openC2SRelay(
});
}
async function testC2SRelay(tunnel, targetHost, targetPort, authToken) {
async function testC2SRelay(
tunnel,
targetHost,
targetPort,
authToken,
runtime,
) {
const tunnelConfig = normalizeC2STunnelAddresses(tunnel);
const relayUrl = getC2SRelayUrl();
const headers = await getC2SRelayHeaders(relayUrl, authToken);
if (runtime && c2sTunnelRuntimes.get(tunnel.name) !== runtime) {
return { success: false, error: "Tunnel stopped" };
}
const ws = new WebSocket(
relayUrl,
getWebSocketOptions(relayUrl, { headers }),
);
attachC2SAuth(ws, tunnel.name || getC2STunnelName(tunnel));
if (runtime) runtime.authSocket = ws;
return new Promise((resolve) => {
let settled = false;
const settle = (result) => {
if (settled) return;
settled = true;
try {
ws.close();
if (!runtime || !result.success) ws.close();
} catch {
// expected during shutdown
}
@@ -2384,12 +2300,13 @@ async function testC2SRelay(tunnel, targetHost, targetPort, authToken) {
const timer = setTimeout(() => {
settle({ success: false, error: "Tunnel test timed out" });
}, 15000);
}, 180_000);
ws.on("open", () => {
ws.send(
JSON.stringify({
type: "test",
keepAlive: !!runtime,
tunnelConfig,
targetHost,
targetPort,
@@ -2418,6 +2335,9 @@ async function testC2SRelay(tunnel, targetHost, targetPort, authToken) {
settle(createC2SFailure(error.message, "Tunnel test failed"));
});
ws.on("close", () => {
if (runtime && c2sTunnelRuntimes.get(tunnel.name) === runtime) {
setC2STunnelError(tunnel.name, "Endpoint SSH connection closed");
}
clearTimeout(timer);
settle({ success: false, error: "Tunnel test connection closed" });
});
@@ -2670,6 +2590,7 @@ async function startC2SRemoteTunnel(tunnel, index = 0, authToken) {
relayUrl,
getWebSocketOptions(relayUrl, { headers }),
);
attachC2SAuth(ws, tunnel.name || getC2STunnelName(tunnel));
const sockets = new Map();
let closed = false;
@@ -2858,7 +2779,10 @@ async function startC2STunnel(tunnel, index = 0, authToken) {
const resolvedTunnel = await resolveC2SRemoteSourceHost(tunnel);
const mode = resolvedTunnel.mode || resolvedTunnel.tunnelType || "local";
const tunnelName = getC2STunnelName(resolvedTunnel, index);
const tunnelConfig = normalizeC2STunnelAddresses(resolvedTunnel);
const tunnelConfig = {
...normalizeC2STunnelAddresses(resolvedTunnel),
sessionId: crypto.randomUUID(),
};
const bindHost = getC2SLocalAddress(tunnelConfig);
const sourcePort = Number(resolvedTunnel.sourcePort);
logToFile(`[c2s] starting tunnel ${tunnelName}`, {
@@ -2909,6 +2833,10 @@ async function startC2STunnel(tunnel, index = 0, authToken) {
const sockets = new Set();
const server = net.createServer((socket) => {
if (!c2sTunnelRuntimes.get(tunnelName)?.status.connected) {
socket.destroy();
return;
}
sockets.add(socket);
socket.on("close", () => sockets.delete(socket));
if (mode === "dynamic") {
@@ -2951,33 +2879,38 @@ async function startC2STunnel(tunnel, index = 0, authToken) {
reason: "Verifying endpoint SSH connection",
});
const verifyTunnel =
mode === "dynamic"
? testC2SRelay(
{ ...tunnel, name: `${tunnelName}::verify`, mode },
undefined,
undefined,
)
: testC2SRelay(
{ ...tunnel, name: `${tunnelName}::verify`, mode },
tunnel.targetHost || "127.0.0.1",
Number(tunnel.endpointPort),
);
const runtime = c2sTunnelRuntimes.get(tunnelName);
const verifyTunnel = testC2SRelay(
{ ...tunnelConfig, name: tunnelName, mode },
mode === "dynamic" ? undefined : getC2SRemoteAddress(tunnelConfig),
mode === "dynamic" ? undefined : Number(tunnelConfig.endpointPort),
authToken,
runtime,
);
verifyTunnel.then((result) => {
if (!c2sTunnelRuntimes.has(tunnelName)) return;
if (result.success) {
setC2STunnelStatus(tunnelName, {
connected: true,
status: "CONNECTED",
});
} else {
setC2STunnelError(
tunnelName,
result.error || "Endpoint SSH connection failed",
);
}
});
verifyTunnel
.then((result) => {
if (c2sTunnelRuntimes.get(tunnelName) !== runtime) return;
if (result.success) {
setC2STunnelStatus(tunnelName, {
connected: true,
status: "CONNECTED",
});
} else {
setC2STunnelError(
tunnelName,
result.error || "Endpoint SSH connection failed",
);
}
})
.catch((error) => {
if (c2sTunnelRuntimes.get(tunnelName) === runtime) {
setC2STunnelError(
tunnelName,
error.message || "Endpoint SSH connection failed",
);
}
});
resolve({ success: true, tunnelName });
});
@@ -2994,6 +2927,7 @@ async function stopC2STunnel(tunnelName) {
connected: false,
status: "DISCONNECTING",
});
runtime.authSocket?.close();
return new Promise((resolve) => {
if (typeof runtime.close === "function") {
@@ -3018,6 +2952,7 @@ async function stopC2STunnel(tunnelName) {
function stopAllC2STunnels() {
for (const [tunnelName, runtime] of c2sTunnelRuntimes.entries()) {
try {
runtime.authSocket?.close();
if (typeof runtime.close === "function") {
runtime.close();
} else {
@@ -3741,88 +3676,6 @@ ipcMain.handle("close-external-editor", (_event, editId) => {
}
});
async function testServerConnection(
_event,
serverUrl,
allowInvalidCertificate = false,
) {
try {
const normalizedServerUrl = serverUrl.replace(/\/$/, "");
const healthUrl = `${normalizedServerUrl}/health`;
// This is a best-effort reachability probe, not a hard gate: a reverse
// proxy doing SSO in front of the real server (Pangolin, Authelia,
// Cloudflare Access, etc.) intercepts this unauthenticated request
// before it ever reaches Termix's own /health route, and returns its
// own login page (HTML, or a redirect) instead of {"status":"ok"}.
// That's a legitimate, working setup -- the login iframe shown right
// after this check is what actually proves the server is real, by
// completing an authenticated round-trip. So any response at all here
// (any status code, any body) means "something is there, let the user
// proceed"; only a network-level failure (nothing answered at all)
// blocks continuing.
try {
const response = await httpFetch(healthUrl, {
method: "GET",
timeout: 10000,
allowInvalidCertificate,
});
const data = await response.text();
const looksLikeHtml =
data.includes("<html") ||
data.includes("<!DOCTYPE") ||
data.includes("<head>") ||
data.includes("<body>");
if (response.ok && !looksLikeHtml) {
try {
const healthData = JSON.parse(data);
if (
healthData &&
(healthData.status === "ok" ||
healthData.status === "healthy" ||
healthData.healthy === true ||
healthData.database === "connected")
) {
return {
success: true,
status: response.status,
testedUrl: healthUrl,
};
}
} catch (parseError) {
console.log("Health endpoint did not return valid JSON");
}
}
// Reachable, but not a recognized Termix health response -- likely a
// proxy/SSO login page in front of the real server. Let the user
// proceed; the login step next will fail clearly if this really
// isn't a Termix server.
return {
success: true,
status: response.status,
testedUrl: healthUrl,
warning: looksLikeHtml
? "Could not confirm this is a Termix server (the response looked like an HTML page, which can happen behind a login-protected reverse proxy). You can continue, and the next step will fail clearly if this isn't actually a Termix server."
: "Server responded, but not with the expected health check format. Continuing anyway.",
};
} catch (urlError) {
console.error("Health check failed:", urlError);
return {
success: false,
error:
"Server is not responding. Please ensure the server is running and accessible.",
};
}
} catch (error) {
return { success: false, error: error.message };
}
}
ipcMain.handle("test-server-connection", testServerConnection);
// Local disk browsing + streamed local<->remote transfers for the file
// manager's dual-pane mode (see electron/local-files.cjs).
registerLocalFileHandlers({ ipcMain, shell });
@@ -3915,7 +3768,7 @@ app.whenReady().then(async () => {
createTray();
createWindow();
remoteSync.initRemoteSync(() => mainWindow);
desktopSettings.removeOldSyncFiles();
logToFile("=== Startup complete ===");
});
+23 -22
View File
@@ -1,22 +1,10 @@
const { contextBridge, ipcRenderer } = require("electron");
const { contextBridge, ipcRenderer, webFrame } = 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",
"notify-local-login",
"remote-sync-now",
"get-previous-server-url",
"save-desktop-settings",
"save-remote-sync-config",
"save-remote-sync-jwt",
"test-server-connection",
"allow-invalid-certificate-for-origin",
"open-isolated-web-endpoint",
]);
function invokeAllowed(channel, ...args) {
@@ -28,10 +16,15 @@ function invokeAllowed(channel, ...args) {
contextBridge.exposeInMainWorld("electronAPI", {
getAppVersion: () => ipcRenderer.invoke("get-app-version"),
// Kept so the interface size can reset a zoom an earlier build applied.
setZoomFactor: (factor) => {
const value = Number(factor);
if (!Number.isFinite(value)) return;
webFrame.setZoomFactor(Math.min(2, Math.max(0.5, value)));
},
getPlatform: () => ipcRenderer.invoke("get-platform"),
getEmbeddedServerStatus: () =>
ipcRenderer.invoke("get-embedded-server-status"),
openNativeRdp: (options) => ipcRenderer.invoke("open-native-rdp", options),
removeAllListeners: (channel) => ipcRenderer.removeAllListeners(channel),
isElectron: true,
@@ -39,6 +32,13 @@ contextBridge.exposeInMainWorld("electronAPI", {
getSetting: (key) => ipcRenderer.invoke("get-setting", key),
setSetting: (key, value) => ipcRenderer.invoke("set-setting", key, value),
answerC2SAuth: (id, answer) =>
ipcRenderer.invoke("answer-c2s-auth", id, answer),
onC2SAuthPrompt: (callback) => {
const listener = (_event, prompt) => callback(prompt);
ipcRenderer.on("c2s-auth-prompt", listener);
return () => ipcRenderer.removeListener("c2s-auth-prompt", listener);
},
getC2STunnelConfig: () => ipcRenderer.invoke("get-c2s-tunnel-config"),
saveC2STunnelConfig: (config) =>
ipcRenderer.invoke("save-c2s-tunnel-config", config),
@@ -60,13 +60,9 @@ contextBridge.exposeInMainWorld("electronAPI", {
},
startC2SAutoStartTunnels: () =>
ipcRenderer.invoke("start-c2s-autostart-tunnels"),
setC2SRelayPath: (relayPath) =>
ipcRenderer.invoke("set-c2s-relay-path", relayPath),
onRemoteSyncStatusChanged: (callback) => {
const listener = (_event, status) => callback(status);
ipcRenderer.on("remote-sync-status-changed", listener);
return () =>
ipcRenderer.removeListener("remote-sync-status-changed", listener);
},
onCloseActiveTab: (callback) => {
const listener = () => callback();
ipcRenderer.on("close-active-tab", listener);
@@ -85,8 +81,11 @@ contextBridge.exposeInMainWorld("electronAPI", {
timeoutMs,
),
externalBrowserLogin: (authUrl, callbackPort) =>
ipcRenderer.invoke("external-browser-login", authUrl, callbackPort),
// 2.8 name, read by older server login pages. Remove in 3.0.0.
oidcSystemBrowserAuth: (authUrl, callbackPort) =>
ipcRenderer.invoke("oidc-system-browser-auth", authUrl, callbackPort),
ipcRenderer.invoke("external-browser-login", authUrl, callbackPort),
openExternalEditor: (fileData) =>
ipcRenderer.invoke("open-external-editor", fileData),
@@ -164,6 +163,8 @@ contextBridge.exposeInMainWorld("electronAPI", {
open: (targetPath) => ipcRenderer.invoke("local-fs:open", targetPath),
},
localTransfer: {
setApiPath: (apiPath) =>
ipcRenderer.invoke("local-transfer:set-api", apiPath),
upload: (options) => ipcRenderer.invoke("local-transfer:upload", options),
download: (options) =>
ipcRenderer.invoke("local-transfer:download", options),
-15
View File
@@ -1,15 +0,0 @@
const SYNCED_ENTITY_TYPES = Object.freeze([
// Ordered by reference dependency: hosts and snippets resolve credential,
// vault and folder syncIds, so those have to exist on the other side first.
"sshCredentials",
"vaultProfiles",
"sshFolders",
"snippetFolders",
"hosts",
"snippets",
"dashboardServiceLinks",
"homepageItems",
"userPreferences",
]);
module.exports = { SYNCED_ENTITY_TYPES };
-27
View File
@@ -1,27 +0,0 @@
const { Agent, fetch: undiciFetch } = require("undici");
function createRemoteSyncFetch(getConfig) {
let invalidCertificateAgent;
return function fetchRemoteSync(url, options = {}) {
const config = getConfig();
const target = new URL(url);
const allowInvalidCertificate =
config?.allowInvalidCertificate === true &&
config.serverUrl &&
target.protocol === "https:" &&
target.origin === new URL(config.serverUrl).origin;
if (!allowInvalidCertificate) return fetch(url, options);
invalidCertificateAgent ??= new Agent({
connect: { rejectUnauthorized: false },
});
return undiciFetch(url, {
...options,
dispatcher: invalidCertificateAgent,
// A redirected request must not inherit the configured origin's TLS exception.
redirect: "error",
});
};
}
module.exports = { createRemoteSyncFetch };
-604
View File
@@ -1,604 +0,0 @@
// Remote sync engine for the desktop app's optional connection to a
// self-hosted Termix server. Runs entirely in the Electron main process:
// - Holds the remote JWT (safeStorage-encrypted on disk, never exposed to
// the renderer's localStorage) and the local embedded backend's JWT
// (cached in memory only, handed over by the renderer at local-login
// time via notify-local-login).
// - On a timer, pulls + pushes each synced entity type between the
// embedded backend (always localhost:30001) and the configured remote
// server, reconciling by syncId with last-write-wins on updatedAt, and
// propagating tombstones (deletions) in both directions.
// - Pushes connection/sync status to the renderer via IPC so the Settings
// UI and a global banner can reflect it without polling.
const { app, safeStorage } = require("electron");
const fs = require("fs");
const path = require("path");
const { orderSyncWrites } = require("./sync-write-order.cjs");
const { SYNCED_ENTITY_TYPES } = require("./remote-sync-entities.cjs");
const { createRemoteSyncFetch } = require("./remote-sync-fetch.cjs");
const fetchRemoteSync = createRemoteSyncFetch(getRemoteSyncConfig);
const SYNC_INTERVAL_MS = 90 * 1000;
const EMBEDDED_BASE_URL = "http://127.0.0.1:30001";
function dataPath(filename) {
return path.join(app.getPath("userData"), filename);
}
function readJson(filePath, fallback) {
try {
if (!fs.existsSync(filePath)) return fallback;
return JSON.parse(fs.readFileSync(filePath, "utf8"));
} catch {
return fallback;
}
}
function writeJson(filePath, value) {
const userDataPath = app.getPath("userData");
if (!fs.existsSync(userDataPath)) {
fs.mkdirSync(userDataPath, { recursive: true });
}
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() {
return dataPath("desktop-settings.json");
}
function getRemoteSyncConfigPath() {
return dataPath("remote-sync-config.json");
}
function getRemoteSyncCredentialPath() {
return dataPath("remote-sync-credential.json");
}
function getRemoteSyncStatePath() {
return dataPath("remote-sync-state.json");
}
function getDesktopSettings() {
return readJson(getDesktopSettingsPath(), {
defaultConnectionOrigin: "local",
migrationNoticeAcknowledged: false,
});
}
function saveDesktopSettings(settings) {
writeJson(getDesktopSettingsPath(), settings);
return { success: true };
}
function getRemoteSyncConfig() {
return readJson(getRemoteSyncConfigPath(), null);
}
function saveRemoteSyncConfig(config) {
writeJson(getRemoteSyncConfigPath(), config);
return { success: true };
}
function clearRemoteSyncConfig() {
try {
fs.unlinkSync(getRemoteSyncConfigPath());
} catch {
// already absent
}
return { success: true };
}
function getSafeStorageAvailable() {
try {
return safeStorage.isEncryptionAvailable();
} catch {
return false;
}
}
function saveRemoteSyncJwt(token) {
if (!getSafeStorageAvailable()) {
// Carries a stable reason alongside the message: the renderer has a
// translated explanation for this one, because "no OS keyring" is a
// machine-level problem the user has to go and fix, not something signing
// in again can resolve.
return {
success: false,
reason: "encryption_unavailable",
error: "Encryption unavailable on this system",
};
}
writeJson(getRemoteSyncCredentialPath(), {
encrypted: true,
value: safeStorage.encryptString(token).toString("base64"),
obtainedAt: new Date().toISOString(),
});
return { success: true };
}
function getRemoteSyncJwt() {
const record = readJson(getRemoteSyncCredentialPath(), null);
if (!record?.encrypted || !getSafeStorageAvailable()) return null;
try {
return safeStorage.decryptString(Buffer.from(record.value, "base64"));
} catch {
return null;
}
}
function clearRemoteSyncJwt() {
try {
fs.unlinkSync(getRemoteSyncCredentialPath());
} catch {
// already absent
}
return { success: true };
}
async function getRemoteSyncUserInfo() {
const config = getRemoteSyncConfig();
const token = getRemoteSyncJwt();
if (!config?.serverUrl || !token || isJwtExpiredOrExpiringSoon(token)) {
return null;
}
const baseUrl = config.serverUrl.replace(/\/$/, "");
const userResponse = await fetchRemoteSync(`${baseUrl}/users/me`, {
headers: { Authorization: `Bearer ${token}`, "X-Electron-App": "true" },
});
if (!userResponse.ok) return null;
const user = await userResponse.json();
const rolesResponse = await fetchRemoteSync(
`${baseUrl}/rbac/users/${encodeURIComponent(user.userId)}/roles`,
{
headers: { Authorization: `Bearer ${token}`, "X-Electron-App": "true" },
},
);
const roles = rolesResponse.ok
? (await rolesResponse.json()).roles || []
: [];
return {
userId: user.userId,
username: user.username,
is_admin: !!user.is_admin,
is_oidc: !!user.is_oidc,
is_dual_auth: !!user.is_dual_auth,
totp_enabled: !!user.totp_enabled,
roles,
};
}
function decodeJwtExpiry(token) {
try {
const payloadB64 = token.split(".")[1];
const payload = JSON.parse(
Buffer.from(payloadB64, "base64").toString("utf8"),
);
return typeof payload.exp === "number" ? payload.exp * 1000 : null;
} catch {
return null;
}
}
function isJwtExpiredOrExpiringSoon(token, marginMs = 60 * 1000) {
const expiresAt = decodeJwtExpiry(token);
if (expiresAt === null) return false;
return Date.now() + marginMs >= expiresAt;
}
class RemoteSyncEngine {
constructor(getMainWindow) {
this.getMainWindow = getMainWindow;
this.localJwt = null;
this.timer = null;
this.startupTimer = null;
this.syncing = false;
this.status = {
connected: false,
syncing: false,
lastSyncedAt: null,
lastError: null,
needsReauth: false,
};
}
setLocalJwt(token) {
this.localJwt = token || null;
}
emitStatus() {
const win = this.getMainWindow?.();
if (!win || win.isDestroyed()) return;
win.webContents.send("remote-sync-status-changed", this.status);
}
updateStatus(patch) {
this.status = { ...this.status, ...patch };
this.emitStatus();
}
start() {
const config = getRemoteSyncConfig();
this.status.connected = !!config?.serverUrl;
if (this.timer) clearInterval(this.timer);
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.
this.startupTimer = setTimeout(() => {
this.startupTimer = null;
this.syncNow();
}, 5000);
}
}
stop() {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
if (this.startupTimer) {
clearTimeout(this.startupTimer);
this.startupTimer = null;
}
}
async syncNow() {
if (this.syncing) return this.status;
const config = getRemoteSyncConfig();
if (!config?.serverUrl) {
this.updateStatus({ connected: false, syncing: false });
return this.status;
}
const remoteJwt = getRemoteSyncJwt();
if (!remoteJwt) {
this.updateStatus({
connected: true,
syncing: false,
needsReauth: true,
lastError: "Not signed in to remote server",
});
return this.status;
}
if (isJwtExpiredOrExpiringSoon(remoteJwt)) {
this.updateStatus({
connected: true,
syncing: false,
needsReauth: true,
lastError: "Remote session expired",
});
return this.status;
}
if (!this.localJwt) {
// Local login hasn't handed us a token yet -- this is expected for the
// first tick or two right after a cold boot (renderer hasn't finished
// its own session check yet), but if it never arrives (e.g. a gap in
// whichever code path establishes the local session), sync would
// otherwise silently no-op forever with no visible error. Surface it
// as a normal, non-alarming "not synced yet" status rather than
// leaving lastSyncedAt/lastError untouched.
this.updateStatus({
connected: true,
syncing: false,
lastError: "Waiting for local session",
});
return this.status;
}
this.syncing = true;
this.updateStatus({ connected: true, syncing: true, lastError: null });
try {
const state = readJson(getRemoteSyncStatePath(), { entities: {} });
let sawAuthFailure = false;
for (const entityType of SYNCED_ENTITY_TYPES) {
const entityState = state.entities[entityType] || {
lastPulledAt: null,
lastPushedAt: null,
};
const result = await this.syncEntity({
entityType,
remoteBaseUrl: config.serverUrl.replace(/\/$/, ""),
remoteJwt,
since: entityState.lastPulledAt,
});
if (result.authFailure) {
sawAuthFailure = true;
break;
}
state.entities[entityType] = {
lastPulledAt: result.syncedAt,
lastPushedAt: result.syncedAt,
};
}
if (sawAuthFailure) {
this.updateStatus({
syncing: false,
needsReauth: true,
lastError: "Remote server rejected the session",
});
return this.status;
}
writeJson(getRemoteSyncStatePath(), state);
writeJson(getRemoteSyncConfigPath(), {
...config,
lastSyncedAt: new Date().toISOString(),
lastSyncStatus: "ok",
lastSyncError: null,
});
this.updateStatus({
connected: true,
syncing: false,
needsReauth: false,
lastSyncedAt: new Date().toISOString(),
lastError: null,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
writeJson(getRemoteSyncConfigPath(), {
...config,
lastSyncStatus: "error",
lastSyncError: message,
});
this.updateStatus({ syncing: false, lastError: message });
} finally {
this.syncing = false;
}
return this.status;
}
async fetchJson(url, token, options = {}) {
const res = await fetchRemoteSync(url, {
...options,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
...(options.headers || {}),
},
});
if (res.status === 401 || res.status === 403) {
const err = new Error(`Auth failed (${res.status})`);
err.authFailure = true;
throw err;
}
if (!res.ok) {
throw new Error(`Request failed (${res.status}): ${url}`);
}
const text = await res.text();
// A reverse-proxy SSO in front of the remote server (Pangolin, Authelia,
// etc.) can intercept even an authenticated, Bearer-token'd request and
// serve its own login page instead of forwarding to Termix -- that comes
// back as a normal 200 OK, so the status checks above don't catch it.
// This is NOT the same as needsReauth/a bad Termix JWT: sync runs as a
// plain server-to-server fetch() in this main process, with no browser
// cookie jar at all, so re-authenticating through the login iframe (which
// only affects the renderer's browser session) can never fix this --
// reconnecting would tell the user to do something that doesn't help.
// The proxy has to allow this traffic through some other way (an API
// bypass rule, a separate hostname/port that isn't proxy-gated, etc.),
// so this gets its own distinct, honest error rather than piggybacking
// on needsReauth or a raw JSON.parse crash.
const looksLikeHtml =
text.includes("<html") ||
text.includes("<!DOCTYPE") ||
text.includes("<head>") ||
text.includes("<body>");
if (looksLikeHtml) {
const err = new Error(
"The reverse proxy in front of this server is blocking sync traffic with its own login page. Reconnecting won't fix this. The proxy needs to let Termix's API requests through (e.g. an SSO bypass rule for the sync API, or a non-proxied hostname/port for it).",
);
err.proxyBlocked = true;
throw err;
}
try {
return JSON.parse(text);
} catch {
throw new Error(`Server returned invalid JSON: ${url}`);
}
}
async pullSide(baseUrl, token, entityType, since) {
const url = `${baseUrl}/sync/${entityType}${since ? `?since=${encodeURIComponent(since)}` : ""}`;
const data = await this.fetchJson(url, token);
return data.rows || [];
}
/**
* Every syncId a side currently holds, ignoring the incremental window.
* Used only to decide whether a deletion still has something to delete.
*/
async pullSyncIds(baseUrl, token, entityType) {
const rows = await this.pullSide(baseUrl, token, entityType, null);
return new Set(rows.filter((row) => row.syncId).map((row) => row.syncId));
}
async pullTombstones(baseUrl, token, entityType, since) {
const url = `${baseUrl}/sync/${entityType}/tombstones${since ? `?since=${encodeURIComponent(since)}` : ""}`;
const data = await this.fetchJson(url, token);
return data.tombstones || [];
}
async pushRow(baseUrl, token, entityType, row) {
await this.fetchJson(`${baseUrl}/sync/${entityType}`, token, {
method: "POST",
body: JSON.stringify({ row }),
});
}
async pushTombstone(baseUrl, token, entityType, syncId) {
await this.fetchJson(`${baseUrl}/sync/tombstones`, token, {
method: "POST",
body: JSON.stringify({ entityType, syncId }),
});
}
async syncEntity({ entityType, remoteBaseUrl, remoteJwt, since }) {
const syncedAt = new Date().toISOString();
try {
const [localRows, remoteRows, localTombstones, remoteTombstones] =
await Promise.all([
this.pullSide(EMBEDDED_BASE_URL, this.localJwt, entityType, since),
this.pullSide(remoteBaseUrl, remoteJwt, entityType, since),
this.pullTombstones(
EMBEDDED_BASE_URL,
this.localJwt,
entityType,
since,
),
this.pullTombstones(remoteBaseUrl, remoteJwt, entityType, since),
]);
const tombstonedSyncIds = new Set([
...localTombstones.map((t) => t.syncId),
...remoteTombstones.map((t) => t.syncId),
]);
const localBySyncId = new Map(
localRows.filter((r) => r.syncId).map((r) => [r.syncId, r]),
);
const remoteBySyncId = new Map(
remoteRows.filter((r) => r.syncId).map((r) => [r.syncId, r]),
);
const allSyncIds = new Set([
...localBySyncId.keys(),
...remoteBySyncId.keys(),
]);
const writes = [];
for (const syncId of allSyncIds) {
if (tombstonedSyncIds.has(syncId)) continue;
const localRow = localBySyncId.get(syncId);
const remoteRow = remoteBySyncId.get(syncId);
const localTime = new Date(localRow?.updatedAt || 0).getTime();
const remoteTime = new Date(remoteRow?.updatedAt || 0).getTime();
if (localRow && (!remoteRow || localTime > remoteTime)) {
writes.push({
baseUrl: remoteBaseUrl,
token: remoteJwt,
row: localRow,
});
} else if (remoteRow && (!localRow || remoteTime > localTime)) {
writes.push({
baseUrl: EMBEDDED_BASE_URL,
token: this.localJwt,
row: remoteRow,
});
}
}
for (const { baseUrl, token, row } of orderSyncWrites(
entityType,
writes,
)) {
await this.pushRow(baseUrl, token, entityType, row);
}
// Apply tombstones to whichever side hasn't already deleted the row.
//
// The presence check cannot use localRows/remoteRows: those are the
// incremental window, and a row deleted on one side while untouched on
// the other is by definition outside it, so every deletion was dropped.
// It also cannot be skipped -- pushing unconditionally makes the
// receiving side record a fresh tombstone, which the next pass would push
// back, forever. So ask the receiving side what it actually still holds,
// and only when there is a deletion to apply.
if (localTombstones.length) {
const remoteSyncIds = await this.pullSyncIds(
remoteBaseUrl,
remoteJwt,
entityType,
);
for (const tombstone of localTombstones) {
if (remoteSyncIds.has(tombstone.syncId)) {
await this.pushTombstone(
remoteBaseUrl,
remoteJwt,
entityType,
tombstone.syncId,
);
}
}
}
if (remoteTombstones.length) {
const localSyncIds = await this.pullSyncIds(
EMBEDDED_BASE_URL,
this.localJwt,
entityType,
);
for (const tombstone of remoteTombstones) {
if (localSyncIds.has(tombstone.syncId)) {
await this.pushTombstone(
EMBEDDED_BASE_URL,
this.localJwt,
entityType,
tombstone.syncId,
);
}
}
}
return { syncedAt };
} catch (error) {
if (error?.authFailure) {
return { syncedAt, authFailure: true };
}
throw error;
}
}
}
let engine = null;
function initRemoteSync(getMainWindow) {
engine = new RemoteSyncEngine(getMainWindow);
engine.start();
return engine;
}
function getRemoteSyncEngine() {
return engine;
}
module.exports = {
initRemoteSync,
getRemoteSyncEngine,
getDesktopSettings,
saveDesktopSettings,
getRemoteSyncConfig,
saveRemoteSyncConfig,
clearRemoteSyncConfig,
saveRemoteSyncJwt,
getRemoteSyncJwt,
clearRemoteSyncJwt,
getRemoteSyncUserInfo,
isJwtExpiredOrExpiringSoon,
decodeJwtExpiry,
};
-31
View File
@@ -1,31 +0,0 @@
// Sort writes per destination: response order is lost when both sides are merged.
function orderSyncWrites(entityType, writes) {
if (entityType !== "hosts") return writes;
const destinations = new Map();
for (const write of writes) {
if (!destinations.has(write.baseUrl)) {
destinations.set(write.baseUrl, new Map());
}
destinations.get(write.baseUrl).set(write.row.syncId, write);
}
const ordered = [];
const visited = new Set();
const visiting = new Set();
const visit = (write) => {
if (visited.has(write)) return;
if (visiting.has(write))
throw new Error("Cyclic parent host sync dependency");
visiting.add(write);
const parent = destinations
.get(write.baseUrl)
.get(write.row.parentHostSyncId);
if (parent) visit(parent);
visiting.delete(write);
visited.add(write);
ordered.push(write);
};
writes.forEach(visit);
return ordered;
}
module.exports = { orderSyncWrites };