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

@@ -0,0 +1,127 @@
/**
* Moves the 2.8 ACME configuration into the acme-ssl plugin.
*
* 2.8 kept it as one JSON row, `acme_ssl_settings`, in core settings. Each
* field becomes a plugin admin setting. "manual" was never ACME (it meant an
* uploaded certificate, which core still handles), so it turns renewal off.
* The Cloudflare token was plaintext and lands as an encrypted secret; it is
* then removed from the old row. The rest of the row stays for one release.
*
* Idempotent: a field already set in plugin_settings is never overwritten.
*/
import { databaseLogger } from "../utils/logger.js";
import {
createCurrentPluginRepository,
createCurrentPluginSettingsRepository,
createCurrentSettingsRepository,
} from "../database/repositories/factory.js";
import { encryptSystemSecret } from "../utils/system-secret-crypto.js";
const PLUGIN_ID = "acme-ssl";
const LEGACY_KEY = "acme_ssl_settings";
export interface AcmeSslSettingsMigrationResult {
moved: string[];
}
interface LegacyAcmeSettings {
enabled?: unknown;
domain?: unknown;
email?: unknown;
challengeType?: unknown;
cloudflareToken?: unknown;
}
const text = (value: unknown) =>
typeof value === "string" && value.trim() ? value.trim() : null;
export async function runAcmeSslSettingsMigration(): Promise<AcmeSslSettingsMigrationResult> {
const result: AcmeSslSettingsMigrationResult = { moved: [] };
try {
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
if (!plugin) return result;
const settings = createCurrentSettingsRepository();
const raw = await settings.get(LEGACY_KEY);
if (!raw) return result;
let legacy: LegacyAcmeSettings;
try {
legacy = JSON.parse(raw) as LegacyAcmeSettings;
} catch {
return result;
}
if (!legacy || typeof legacy !== "object") return result;
const pluginSettings = createCurrentPluginSettingsRepository();
const writeIfUnset = async (
field: string,
value: unknown,
secret = false,
) => {
const existing = await pluginSettings.get(
PLUGIN_ID,
"admin",
null,
field,
);
if (existing && existing.value !== null) return;
await pluginSettings.set(
PLUGIN_ID,
"admin",
null,
field,
JSON.stringify(value),
secret,
);
result.moved.push(field);
};
const challenge = legacy.challengeType;
const manual = challenge === "manual";
const domain = text(legacy.domain);
if (domain) await writeIfUnset("domain", domain);
const email = text(legacy.email);
if (email) await writeIfUnset("email", email);
if (challenge === "dns-cloudflare") {
await writeIfUnset("challengeType", "dns-cloudflare");
} else if (challenge === "http-webroot") {
await writeIfUnset("challengeType", "http-01");
}
if (typeof legacy.enabled === "boolean") {
await writeIfUnset("autoRenew", legacy.enabled && !manual);
}
const token = text(legacy.cloudflareToken);
if (token) {
await writeIfUnset(
"cloudflareToken",
await encryptSystemSecret(token),
true,
);
const { cloudflareToken: _dropped, ...rest } = legacy;
await settings.set(LEGACY_KEY, JSON.stringify(rest));
}
if (result.moved.length > 0) {
databaseLogger.info(
`Moved ${result.moved.length} ACME setting(s) into plugin settings`,
{
operation: "acme_ssl_settings_migration",
moved: result.moved.join(", "),
},
);
}
} catch (error) {
// Must not stop the backend: the plugin then shows "not configured".
databaseLogger.warn("ACME settings migration failed", {
operation: "acme_ssl_settings_migration",
error: error instanceof Error ? error.message : String(error),
});
}
return result;
}
@@ -0,0 +1,254 @@
/**
* Moves the AI assistant's settings and provider keys into the ai plugin.
*
* - settings.ai_globally_enabled and ai_private_endpoint_allowlist become the
* admin settings globallyEnabled and privateEndpoints (one host per line).
* - user_preferences.ai_assistant_enabled and ai_read_only_commands become
* each user's enabled and allowReadOnlyCommands.
* - ssh_data.enable_ai_assistant becomes the host setting enableAiAssistant.
* - Each provider's api_key, encrypted under its owner's data key, becomes a
* ctx.secrets row "provider:<id>" for that owner, and the column is emptied
* so no key material is left in the plugin's table.
*
* The old columns stay in the database, unused: core's drizzle migrations
* run before this does, so dropping them there would lose the data first.
* The providers table is ai_providers until the plugin adopts it and
* p_ai_providers after, so both names are tried.
*
* Idempotent: nothing is written where the plugin already has a row, and a
* key is only cleared once its copy exists. Safe on every boot.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../utils/logger.js";
import {
createCurrentPluginRepository,
createCurrentPluginSettingsRepository,
createCurrentSettingsRepository,
} from "../database/repositories/factory.js";
import { DataCrypto } from "../utils/data-crypto.js";
import { LazyFieldEncryption } from "../utils/lazy-field-encryption.js";
import { encryptSystemSecret } from "../utils/system-secret-crypto.js";
import {
runStatement,
selectRows,
} from "../utils/crypto-migration/raw-rows.js";
const PLUGIN_ID = "ai";
export interface AiSettingsMigrationResult {
admin: string[];
users: number;
hosts: number;
keys: number;
}
type Flag = number | boolean | string | null | undefined;
function isTrue(value: Flag): boolean {
return value === true || value === 1 || value === "1" || value === "true";
}
function isSet(value: Flag): boolean {
return value !== null && value !== undefined;
}
/** The legacy JSON array as the textarea the plugin now reads. */
export function allowlistToText(raw: string): string | null {
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return null;
return parsed
.filter((entry): entry is string => typeof entry === "string")
.map((entry) => entry.trim())
.filter(Boolean)
.join("\n");
} catch {
return null;
}
}
async function tryRows<T>(query: ReturnType<typeof sql>): Promise<T[]> {
try {
return await selectRows<T>(query);
} catch {
// A fresh install never had the table or column.
return [];
}
}
export async function runAiSettingsMigration(): Promise<AiSettingsMigrationResult> {
const result: AiSettingsMigrationResult = {
admin: [],
users: 0,
hosts: 0,
keys: 0,
};
// plugin_settings has a foreign key to plugins: nothing to migrate into
// until the plugin row exists.
try {
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
if (!plugin) return result;
} catch {
return result;
}
const pluginSettings = createCurrentPluginSettingsRepository();
const setIfMissing = async (
scope: "admin" | "user" | "host",
scopeId: string | null,
key: string,
value: unknown,
): Promise<boolean> => {
const existing = await pluginSettings.get(PLUGIN_ID, scope, scopeId, key);
if (existing) return false;
await pluginSettings.set(
PLUGIN_ID,
scope,
scopeId,
key,
JSON.stringify(value),
);
return true;
};
try {
const settings = createCurrentSettingsRepository();
const enabled = await settings.get("ai_globally_enabled");
if (enabled !== null && enabled !== "") {
if (await setIfMissing("admin", null, "globallyEnabled", isTrue(enabled)))
result.admin.push("globallyEnabled");
}
const allowlist = await settings.get("ai_private_endpoint_allowlist");
const text = allowlist ? allowlistToText(allowlist) : null;
if (text !== null) {
if (await setIfMissing("admin", null, "privateEndpoints", text))
result.admin.push("privateEndpoints");
}
} catch (error) {
warn("admin settings", error);
}
const users = await tryRows<{
user_id: string;
ai_assistant_enabled: Flag;
ai_read_only_commands: Flag;
}>(
sql`SELECT user_id, ai_assistant_enabled, ai_read_only_commands FROM user_preferences`,
);
for (const row of users) {
try {
let moved = false;
if (isSet(row.ai_assistant_enabled)) {
moved =
(await setIfMissing(
"user",
row.user_id,
"enabled",
isTrue(row.ai_assistant_enabled),
)) || moved;
}
if (isSet(row.ai_read_only_commands)) {
moved =
(await setIfMissing(
"user",
row.user_id,
"allowReadOnlyCommands",
isTrue(row.ai_read_only_commands),
)) || moved;
}
if (moved) result.users++;
} catch (error) {
warn("a user's settings", error);
}
}
const hosts = await tryRows<{ id: number; enable_ai_assistant: Flag }>(
sql`SELECT id, enable_ai_assistant FROM ssh_data`,
);
for (const row of hosts) {
if (!isTrue(row.enable_ai_assistant)) continue;
try {
if (
await setIfMissing("host", String(row.id), "enableAiAssistant", true)
) {
result.hosts++;
}
} catch (error) {
warn("a host's setting", error);
}
}
for (const table of ["ai_providers", "p_ai_providers"]) {
const providers = await tryRows<{
id: number;
user_id: string;
api_key: string | null;
}>(
sql`SELECT id, user_id, api_key FROM ${sql.identifier(table)} WHERE api_key IS NOT NULL AND api_key <> ''`,
);
for (const row of providers) {
try {
const key = `provider:${row.id}`;
const existing = await pluginSettings.get(
PLUGIN_ID,
"secret",
row.user_id,
key,
);
if (!existing) {
const dek = DataCrypto.getUserDataKey(row.user_id);
if (!dek) continue;
const plaintext = LazyFieldEncryption.safeGetFieldValue(
row.api_key as string,
dek,
String(row.id),
"apiKey",
);
if (!plaintext) continue;
await pluginSettings.set(
PLUGIN_ID,
"secret",
row.user_id,
key,
JSON.stringify(await encryptSystemSecret(plaintext)),
true,
);
result.keys++;
}
await runStatement(
sql`UPDATE ${sql.identifier(table)} SET api_key = NULL WHERE id = ${row.id}`,
);
} catch (error) {
warn("a provider key", error);
}
}
}
if (
result.admin.length > 0 ||
result.users > 0 ||
result.hosts > 0 ||
result.keys > 0
) {
databaseLogger.info("Moved AI assistant settings into the ai plugin", {
operation: "ai_settings_migration",
admin: result.admin.join(", "),
users: result.users,
hosts: result.hosts,
keys: result.keys,
});
}
return result;
}
function warn(what: string, error: unknown): void {
// A failed copy must not stop the backend; the next boot tries again.
databaseLogger.warn(`AI settings migration failed for ${what}`, {
operation: "ai_settings_migration",
error: error instanceof Error ? error.message : String(error),
});
}
+108
View File
@@ -0,0 +1,108 @@
/**
* Boot copies of 2.8 data into plugin settings and plugin tables.
*
* They run after the plugins are seeded and activated: plugin_settings has a
* foreign key to plugins, and some copies write into tables a plugin adopts
* in its own migrations. Each one is idempotent, so they also run again when
* an admin enables a plugin without a restart.
*/
import { databaseLogger } from "../utils/logger.js";
import { getErrorMessage } from "../utils/error-message.js";
import { runTailscaleSettingsMigration } from "./tailscale-settings-migration.js";
import { runProxmoxSettingsMigration } from "./proxmox-settings-migration.js";
import { runFileManagerSettingsMigration } from "./file-manager-settings-migration.js";
import { runTunnelsSettingsMigration } from "./tunnels-settings-migration.js";
import { runWebEndpointSettingsMigration } from "./web-endpoint-settings-migration.js";
import { runSshTerminalSettingsMigration } from "./ssh-terminal-settings-migration.js";
import { runSshTerminalLookMigration } from "./ssh-terminal-look-migration.js";
import { runSshTerminalMacrosMigration } from "./ssh-terminal-macros-migration.js";
import { runSnippetsSettingsMigration } from "./snippets-settings-migration.js";
import { runTmuxMonitorSettingsMigration } from "./tmux-monitor-settings-migration.js";
import { runSessionSharingSettingsMigration } from "./session-sharing-settings-migration.js";
import { runTelemetrySettingsMigration } from "./telemetry-settings-migration.js";
import { runSessionRecordingSettingsMigration } from "./session-recording-settings-migration.js";
import { runRemoteDesktopSettingsMigration } from "./remote-desktop-settings-migration.js";
import { runHostMetricsSettingsMigration } from "./host-metrics-settings-migration.js";
import { runDockerSettingsMigration } from "./docker-settings-migration.js";
import { runAiSettingsMigration } from "./ai-settings-migration.js";
import { runWakeOnLanSettingsMigration } from "./wake-on-lan-settings-migration.js";
import { runWarpgateSettingsMigration } from "./warpgate-settings-migration.js";
import { runStepCaSettingsMigration } from "./step-ca-settings-migration.js";
import { runAcmeSslSettingsMigration } from "./acme-ssl-settings-migration.js";
import { runVaultSettingsMigration } from "./vault-settings-migration.js";
import { runSecretSourcesTokenMigration } from "./secret-sources-token-migration.js";
import { runTotpMigration } from "./totp-migration.js";
import { runNotificationChannelMigration } from "./notification-channel-migration.js";
import { runTermixIdentityCaMigration } from "./termix-identity-ca-migration.js";
import { runSsoSettingsMigration } from "./sso-settings-migration.js";
import { runHostDefaultsMigration } from "./host-defaults-migration.js";
const MIGRATIONS: Array<[string, () => Promise<unknown>]> = [
["runTailscaleSettingsMigration", runTailscaleSettingsMigration],
["runProxmoxSettingsMigration", runProxmoxSettingsMigration],
["runFileManagerSettingsMigration", runFileManagerSettingsMigration],
["runTunnelsSettingsMigration", runTunnelsSettingsMigration],
["runWebEndpointSettingsMigration", runWebEndpointSettingsMigration],
["runSshTerminalSettingsMigration", runSshTerminalSettingsMigration],
["runSshTerminalLookMigration", runSshTerminalLookMigration],
["runSshTerminalMacrosMigration", runSshTerminalMacrosMigration],
["runSnippetsSettingsMigration", runSnippetsSettingsMigration],
["runTmuxMonitorSettingsMigration", runTmuxMonitorSettingsMigration],
["runSessionSharingSettingsMigration", runSessionSharingSettingsMigration],
[
"runSessionRecordingSettingsMigration",
runSessionRecordingSettingsMigration,
],
["runRemoteDesktopSettingsMigration", runRemoteDesktopSettingsMigration],
["runHostMetricsSettingsMigration", runHostMetricsSettingsMigration],
["runDockerSettingsMigration", runDockerSettingsMigration],
["runAiSettingsMigration", runAiSettingsMigration],
["runWakeOnLanSettingsMigration", runWakeOnLanSettingsMigration],
["runWarpgateSettingsMigration", runWarpgateSettingsMigration],
["runStepCaSettingsMigration", runStepCaSettingsMigration],
["runAcmeSslSettingsMigration", runAcmeSslSettingsMigration],
["runVaultSettingsMigration", runVaultSettingsMigration],
["runSecretSourcesTokenMigration", runSecretSourcesTokenMigration],
["runTotpMigration", runTotpMigration],
["runNotificationChannelMigration", runNotificationChannelMigration],
["runTermixIdentityCaMigration", runTermixIdentityCaMigration],
["runSsoSettingsMigration", runSsoSettingsMigration],
["runTelemetrySettingsMigration", runTelemetrySettingsMigration],
// Last: it reads what the moves above wrote.
["runHostDefaultsMigration", runHostDefaultsMigration],
];
export async function runPluginDataMigrations(): Promise<void> {
for (const [name, run] of MIGRATIONS) {
try {
await run();
} catch (error) {
databaseLogger.warn(`Plugin data migration ${name} failed`, {
operation: "plugin_data_migration",
error: getErrorMessage(error),
});
}
}
await applyHostDefaults();
}
/**
* Classifies hosts nobody classified yet (from an older release, or for a
* plugin just started) and brings every host up to date with its defaults.
* A linked desktop gets its hosts already resolved from its server.
*/
async function applyHostDefaults(): Promise<void> {
try {
const { getLink } = await import("../sync/client/link-store.js");
if (await getLink().catch(() => null)) return;
const { recomputeEverything } =
await import("../hosts/defaults/recompute.js");
recomputeEverything("plugins started");
} catch (error) {
databaseLogger.warn("Could not apply host defaults", {
operation: "plugin_data_migration",
error: getErrorMessage(error),
});
}
}
@@ -0,0 +1,118 @@
/**
* Moves each host's Docker options out of ssh_data and into the docker
* plugin's host settings: enable_docker becomes enableDocker and the runtime
* in docker_config becomes containerRuntime.
*
* The columns stay in the database, unused: core's drizzle migrations run
* before this does, so dropping them there would lose the data first.
*
* Idempotent: a host that already has any docker row is skipped, so this is
* safe on every boot.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../utils/logger.js";
import {
createCurrentPluginRepository,
createCurrentPluginSettingsRepository,
} from "../database/repositories/factory.js";
import { selectRows } from "../utils/crypto-migration/raw-rows.js";
const PLUGIN_ID = "docker";
interface LegacyHostRow {
id: number;
enable_docker: number | boolean | string | null;
docker_config: string | null;
}
/** The plugin's host settings for one legacy row, defaults left out. */
export function dockerSettingsFromRow(
row: LegacyHostRow,
): Record<string, unknown> {
const values: Record<string, unknown> = {};
const flag = row.enable_docker;
if (flag === true || flag === 1 || flag === "1" || flag === "true") {
values.enableDocker = true;
}
if (row.docker_config) {
try {
const config = JSON.parse(row.docker_config) as { runtime?: unknown };
if (config?.runtime === "podman") values.containerRuntime = "podman";
} catch {
// unreadable config means the default runtime
}
}
return values;
}
export interface DockerSettingsMigrationResult {
hostsMoved: number;
hostsSkipped: number;
}
export async function runDockerSettingsMigration(): Promise<DockerSettingsMigrationResult> {
const result: DockerSettingsMigrationResult = {
hostsMoved: 0,
hostsSkipped: 0,
};
// plugin_settings has a foreign key to plugins: nothing to migrate into
// until the plugin row exists.
try {
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
if (!plugin) return result;
} catch {
return result;
}
const pluginSettings = createCurrentPluginSettingsRepository();
let rows: LegacyHostRow[];
try {
// Raw SQL: schema.ts no longer declares these columns.
rows = await selectRows<LegacyHostRow>(
sql`SELECT id, enable_docker, docker_config FROM ssh_data`,
);
} catch {
// A fresh install never had the columns.
return result;
}
for (const row of rows) {
const values = dockerSettingsFromRow(row);
if (Object.keys(values).length === 0) continue;
const scopeId = String(row.id);
try {
const existing = await pluginSettings.getAll(PLUGIN_ID, "host", scopeId);
if (existing.length > 0) {
result.hostsSkipped++;
continue;
}
for (const [key, value] of Object.entries(values)) {
await pluginSettings.set(
PLUGIN_ID,
"host",
scopeId,
key,
JSON.stringify(value),
);
}
result.hostsMoved++;
} catch (error) {
databaseLogger.warn("Docker host settings migration failed for a host", {
operation: "docker_settings_migration",
hostId: row.id,
error: error instanceof Error ? error.message : String(error),
});
}
}
if (result.hostsMoved > 0) {
databaseLogger.info(
`Moved Docker settings for ${result.hostsMoved} host(s) into plugin settings`,
{ operation: "docker_settings_migration" },
);
}
return result;
}
@@ -0,0 +1,109 @@
/**
* Moves sign-in identities into their own table.
*
* users.oidc_identifier packed provider and subject into one string:
* "ldap:<provider>:<id>", "github:<provider>:<id>", or a bare OIDC subject
* whose provider was users.sso_provider_id. Each becomes a
* user_external_identities row; LDAP ones under provider "ldap:<provider>",
* the id the ldap plugin signs in with. TOTP enrolment moves in totp-migration.ts.
*
* Lossless: the old column is left as it is. Idempotent: rows that already
* exist are skipped, so it runs on every boot.
*/
import { databaseLogger } from "../utils/logger.js";
import {
createCurrentUserAuthRepository,
createCurrentUserRepository,
} from "../database/repositories/factory.js";
/** The provider id the sso plugin gives its env-configured provider. */
const LEGACY_OIDC_PROVIDER_ID = "legacy-oidc";
export interface ExternalIdentityMigrationResult {
identities: number;
skipped: number;
}
/** Splits a stored identifier into provider and subject. */
export function parseLegacyIdentifier(
identifier: string,
ssoProviderId: number | null | undefined,
): { providerId: string; subject: string } | null {
if (!identifier) return null;
const prefixed = /^(ldap|github):([^:]+):(.+)$/.exec(identifier);
if (prefixed) {
const provider = prefixed[2];
const missing = provider === "null" || provider === "undefined";
// LDAP providers moved to their own table in 2.9 and their identities
// carry the "ldap:" prefix so their ids cannot clash with SSO ones.
if (prefixed[1] === "ldap") {
return missing
? null
: { providerId: `ldap:${provider}`, subject: prefixed[3] };
}
return {
providerId: missing ? LEGACY_OIDC_PROVIDER_ID : provider,
subject: prefixed[3],
};
}
return {
providerId:
ssoProviderId !== null && ssoProviderId !== undefined
? String(ssoProviderId)
: LEGACY_OIDC_PROVIDER_ID,
subject: identifier,
};
}
export async function runExternalIdentityMigration(): Promise<ExternalIdentityMigrationResult> {
const result: ExternalIdentityMigrationResult = {
identities: 0,
skipped: 0,
};
try {
const users = await createCurrentUserRepository().listAll();
const auth = createCurrentUserAuthRepository();
for (const user of users) {
if (user.oidcIdentifier) {
const parsed = parseLegacyIdentifier(
user.oidcIdentifier,
user.ssoProviderId,
);
if (parsed) {
const existing = await auth.findIdentity(
parsed.providerId,
parsed.subject,
);
if (existing) {
result.skipped++;
} else {
await auth.linkIdentity({
userId: user.id,
providerId: parsed.providerId,
subject: parsed.subject,
});
result.identities++;
}
}
}
}
if (result.identities > 0) {
databaseLogger.info("Moved sign-in identities", {
operation: "external_identity_migration",
...result,
});
}
} catch (error) {
databaseLogger.error("External identity migration failed", error, {
operation: "external_identity_migration",
});
}
return result;
}
@@ -0,0 +1,102 @@
/**
* Moves the three file-manager host columns into the file-manager plugin's
* host-scope settings, before those columns are dropped from ssh_data.
*
* Idempotent: skips a host that already has a file-manager settings row, so
* it is safe to run on every boot. Lossless: reads straight off the
* still-present ssh_data columns, so it must run and finish before those
* columns are ever dropped from schema.ts.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../utils/logger.js";
import {
legacyFlag,
selectLegacyRows,
} from "../utils/crypto-migration/raw-rows.js";
import { createCurrentPluginSettingsRepository } from "../database/repositories/factory.js";
export interface FileManagerSettingsMigrationResult {
moved: number;
skipped: number;
}
interface LegacyFileManagerRow {
id: number;
enable_file_manager: number | boolean | null;
default_path: string | null;
scp_legacy: number | boolean | null;
}
export async function runFileManagerSettingsMigration(): Promise<FileManagerSettingsMigrationResult> {
const result: FileManagerSettingsMigrationResult = { moved: 0, skipped: 0 };
try {
// Raw SQL, not the typed schema: this runs once schema.ts has already
// dropped these columns, so the query is the only thing left that still
// knows they used to exist.
const rows = await selectLegacyRows<LegacyFileManagerRow>(sql`
SELECT id, enable_file_manager, default_path, scp_legacy
FROM ssh_data
WHERE enable_file_manager = false OR default_path IS NOT NULL
OR scp_legacy = true
`);
if (rows.length === 0) return result;
const pluginSettingsRepository = createCurrentPluginSettingsRepository();
for (const row of rows) {
const scopeId = String(row.id);
const existing = await pluginSettingsRepository.get(
"file-manager",
"host",
scopeId,
"enableFileManager",
);
if (existing && existing.value !== null) {
result.skipped++;
continue;
}
await pluginSettingsRepository.set(
"file-manager",
"host",
scopeId,
"enableFileManager",
JSON.stringify(legacyFlag(row.enable_file_manager, true)),
);
await pluginSettingsRepository.set(
"file-manager",
"host",
scopeId,
"defaultPath",
JSON.stringify(row.default_path ?? null),
);
await pluginSettingsRepository.set(
"file-manager",
"host",
scopeId,
"scpLegacy",
JSON.stringify(!!row.scp_legacy),
);
result.moved++;
}
if (result.moved > 0) {
databaseLogger.info(
`Moved file manager settings for ${result.moved} host(s) into plugin settings`,
{ operation: "file_manager_settings_migration", moved: result.moved },
);
}
} catch (error) {
// A failed migration must not stop the backend. Hosts simply keep
// whatever file-manager settings they already had (none, on a fresh run).
databaseLogger.warn("File manager settings migration failed", {
operation: "file_manager_settings_migration",
error: error instanceof Error ? error.message : String(error),
});
}
return result;
}
@@ -0,0 +1,447 @@
/**
* Moves 2.8 and early 2.9 default settings into host defaults, and gets hosts
* ready to be classified against them.
*
* Levels, each moved once:
* - core `settings.host_defaults` (SOCKS5 and the status check) becomes the
* server level. Its credential and SOCKS5 password are dropped: a
* credential belongs to one user and a password is never a default.
* - a folder's credential becomes that folder's auth default.
* - ssh-terminal's admin "new host" look, tmux and command history become the
* server level; each user's terminal defaults, local echo and link click
* setting become their user level.
* - host-metrics' "enabled for new hosts" becomes the server level.
* - remote-desktop's per-user display settings become the user level.
*
* Hosts: a host saved before host defaults gets the value it really used
* written in, where that was not its stored value. A terminal that
* followed the user's look used the user's look; an echo mode of "default"
* used the user's setting; a credential host with no credential used its
* folder's; a remote desktop used the connecting user's display settings.
* The classify pass that follows compares those against the new levels, so
* nothing a user sees changes.
*
* The old rows stay in place for a downgrade until 3.0.0.
*/
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import {
createCurrentHostDefaultsRepository,
createCurrentPluginSettingsRepository,
createCurrentSettingsRepository,
} from "../database/repositories/factory.js";
import type { HostRow } from "../database/repositories/host-defaults-repository.js";
import { hostSettingsPlugins } from "../database/routes/host-plugin-settings.js";
import {
CORE_NAMESPACE,
defaultValuesEqual,
normalizeSocks5Default,
parseDefaultOverrides,
} from "../../types/host-defaults.js";
import { encodeDefaultValue } from "../hosts/defaults/levels.js";
import { folderChainPaths } from "../hosts/defaults/resolve.js";
import { databaseLogger } from "../utils/logger.js";
const TERMINAL = "ssh-terminal";
const METRICS = "host-metrics";
const DESKTOP = "remote-desktop";
const TERMINAL_APPEARANCE_KEYS = [
"theme",
"cursorBlink",
"cursorStyle",
"fontSize",
"fontFamily",
"scrollback",
"letterSpacing",
"lineHeight",
"bellStyle",
"minimumContrastRatio",
"backgroundImage",
"backgroundImageOpacity",
"customThemeColors",
];
/** ssh-terminal admin key -> the host key it seeded. */
const TERMINAL_NEW_HOST_KEYS: Record<string, string> = {
commandHistoryForNewHosts: "enableCommandHistory",
newHostTheme: "theme",
newHostFontFamily: "fontFamily",
newHostFontSize: "fontSize",
newHostCursorStyle: "cursorStyle",
newHostCursorBlink: "cursorBlink",
newHostAutoTmux: "autoTmux",
};
/** ssh-terminal keys whose "default" meant the user's own setting. */
const TERMINAL_USER_MODES: Record<string, string> = {
localEcho: "auto",
linkClickBehavior: "confirm",
};
const DESKTOP_DISPLAY_KEYS = [
"colorDepth",
"resizeMethod",
"forceLossless",
"enableWallpaper",
"enableFontSmoothing",
"enableDesktopComposition",
"disableAudio",
"enablePrinting",
"enableDrive",
"disableCopy",
"disablePaste",
];
const MARKER_PREFIX = "host_defaults_moved_";
function decode(raw: string | null | undefined): unknown {
if (raw === null || raw === undefined) return undefined;
try {
let parsed = JSON.parse(raw);
if (typeof parsed === "string" && /^[[{]/.test(parsed)) {
parsed = JSON.parse(parsed);
}
return parsed;
} catch {
return undefined;
}
}
function asObject(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
async function once(name: string, run: () => Promise<void>): Promise<void> {
const settings = createCurrentSettingsRepository();
const marker = `${MARKER_PREFIX}${name}`;
if (await settings.get(marker)) return;
await run();
await settings.set(marker, new Date().toISOString());
}
type Level =
| { level: "admin" }
| { level: "user"; userId: string }
| { level: "folder"; userId: string; folderId: number };
async function setDefault(
scope: Level,
namespace: string,
key: string,
value: unknown,
): Promise<void> {
const repository = createCurrentHostDefaultsRepository();
const existing = await repository.listScope(scope);
if (existing.some((row) => row.namespace === namespace && row.key === key)) {
return;
}
await repository.apply(
scope,
[{ namespace, key, value: encodeDefaultValue(value) }],
[],
null,
);
}
async function moveCoreLevels(): Promise<void> {
const raw = await createCurrentSettingsRepository().get("host_defaults");
const stored = asObject(decode(raw)) ?? {};
if (stored.useSocks5 === true) {
await setDefault(
{ level: "admin" },
CORE_NAMESPACE,
"socks5",
normalizeSocks5Default({ ...stored, socks5ProxyChain: null }),
);
}
if (stored.statusCheckEnabled === false) {
await setDefault(
{ level: "admin" },
CORE_NAMESPACE,
"statusCheckEnabled",
false,
);
}
if (stored.credentialId !== undefined || stored.socks5Password) {
databaseLogger.info(
"Host defaults no longer hold a credential or proxy password; set a credential per user or folder instead",
{ operation: "host_defaults_migration" },
);
}
}
async function moveFolderCredentials(): Promise<void> {
const { createCurrentRepositoryContext } =
await import("../database/repositories/factory.js");
const { sshFolders } = await import("../database/db/schema.js");
const { isNotNull } = await import("drizzle-orm");
const rows = await createCurrentRepositoryContext()
.drizzle.select({
id: sshFolders.id,
userId: sshFolders.userId,
credentialId: sshFolders.credentialId,
})
.from(sshFolders)
.where(isNotNull(sshFolders.credentialId));
for (const row of rows) {
await setDefault(
{ level: "folder", userId: row.userId, folderId: row.id },
CORE_NAMESPACE,
"auth",
{
authType: "credential",
credentialId: row.credentialId,
overrideCredentialUsername: false,
agentSocketPath: null,
agentIdentity: null,
},
);
}
}
async function storedRows(
pluginId: string,
scope: "admin" | "user",
key: string,
) {
return (
await createCurrentPluginSettingsRepository().listByKey(
pluginId,
scope,
key,
)
).filter((row) => row.value !== null);
}
async function moveTerminalLevels(): Promise<void> {
for (const [adminKey, hostKey] of Object.entries(TERMINAL_NEW_HOST_KEYS)) {
for (const row of await storedRows(TERMINAL, "admin", adminKey)) {
const value = decode(row.value);
if (value === undefined || value === null) continue;
await setDefault({ level: "admin" }, TERMINAL, hostKey, value);
}
}
for (const row of await storedRows(TERMINAL, "user", "terminalDefaults")) {
const values = asObject(decode(row.value));
if (!values || !row.scopeId) continue;
for (const key of TERMINAL_APPEARANCE_KEYS) {
if (values[key] === undefined) continue;
await setDefault(
{ level: "user", userId: row.scopeId },
TERMINAL,
key,
values[key],
);
}
}
for (const key of Object.keys(TERMINAL_USER_MODES)) {
for (const row of await storedRows(TERMINAL, "user", key)) {
const value = decode(row.value);
if (typeof value !== "string" || !row.scopeId) continue;
await setDefault(
{ level: "user", userId: row.scopeId },
TERMINAL,
key,
value,
);
}
}
}
async function moveMetricsLevels(): Promise<void> {
for (const row of await storedRows(METRICS, "admin", "enabledForNewHosts")) {
if (decode(row.value) === false) {
await setDefault({ level: "admin" }, METRICS, "metricsEnabled", false);
}
}
}
async function moveDesktopLevels(): Promise<void> {
for (const key of DESKTOP_DISPLAY_KEYS) {
for (const row of await storedRows(DESKTOP, "user", key)) {
const value = decode(row.value);
if (typeof value !== "string" || value === "inherit" || !row.scopeId) {
continue;
}
await setDefault(
{ level: "user", userId: row.scopeId },
DESKTOP,
key,
value,
);
}
}
}
/**
* Hosts saved before host defaults (by an older release, or 2.8 after a
* downgrade), which still carry the old meaning. A host saved since has its
* overrides set, and a namespace it lacks is classified as it stands.
*/
function unclassified(rows: HostRow[]): HostRow[] {
return rows.filter(
(row) =>
!row.sharedSource && parseDefaultOverrides(row.defaultOverrides) === null,
);
}
async function userValues(
pluginId: string,
key: string,
): Promise<Map<string, unknown>> {
const result = new Map<string, unknown>();
for (const row of await storedRows(pluginId, "user", key)) {
if (row.scopeId) result.set(row.scopeId, decode(row.value));
}
return result;
}
function manifestDefault(manifest: PluginManifest, key: string): unknown {
return manifest.contributes?.settings?.host?.fields.find(
(field) => field.key === key,
)?.default;
}
/**
* Writes one host value, or removes the row when it is the manifest default
* so the host reads that default the usual way.
*/
async function writeHostValue(
manifest: PluginManifest,
hostId: number,
key: string,
value: unknown,
): Promise<void> {
const settings = createCurrentPluginSettingsRepository();
if (defaultValuesEqual(value, manifestDefault(manifest, key))) {
await settings.delete(manifest.id, "host", String(hostId), key);
} else {
await settings.set(
manifest.id,
"host",
String(hostId),
key,
JSON.stringify(value),
);
}
}
async function bakeTerminalHosts(
manifest: PluginManifest,
rows: HostRow[],
): Promise<void> {
const hosts = unclassified(rows);
if (hosts.length === 0) return;
const settings = createCurrentPluginSettingsRepository();
const lookByUser = await userValues(TERMINAL, "terminalDefaults");
const modesByUser = new Map(
await Promise.all(
Object.keys(TERMINAL_USER_MODES).map(
async (key) => [key, await userValues(TERMINAL, key)] as const,
),
),
);
for (const host of hosts) {
const stored = new Map(
(await settings.getAll(TERMINAL, "host", String(host.id))).map((row) => [
row.key,
decode(row.value),
]),
);
if (stored.get("inheritAppearance") !== false) {
const look = asObject(lookByUser.get(host.userId)) ?? {};
for (const key of TERMINAL_APPEARANCE_KEYS) {
const value = look[key] ?? manifestDefault(manifest, key);
await writeHostValue(manifest, host.id, key, value);
}
}
for (const [key, fallback] of Object.entries(TERMINAL_USER_MODES)) {
const own = stored.get(key);
if (own !== undefined && own !== "default") continue;
const user = modesByUser.get(key)?.get(host.userId);
await writeHostValue(
manifest,
host.id,
key,
typeof user === "string" && user !== "default" ? user : fallback,
);
}
}
}
async function bakeDesktopHosts(
manifest: PluginManifest,
rows: HostRow[],
): Promise<void> {
const hosts = unclassified(rows);
if (hosts.length === 0) return;
const byKey = new Map(
await Promise.all(
DESKTOP_DISPLAY_KEYS.map(
async (key) => [key, await userValues(DESKTOP, key)] as const,
),
),
);
for (const host of hosts) {
for (const key of DESKTOP_DISPLAY_KEYS) {
const value = byKey.get(key)?.get(host.userId);
if (typeof value !== "string" || value === "inherit") continue;
await writeHostValue(manifest, host.id, key, value);
}
}
}
/** A credential host with no credential of its own used its folder's. */
async function bakeFolderCredentials(rows: HostRow[]): Promise<void> {
const hosts = unclassified(rows).filter(
(row) => row.authType === "credential" && !row.credentialId && row.folder,
);
if (hosts.length === 0) return;
const repository = createCurrentHostDefaultsRepository();
const { createCurrentHostResolutionRepository } =
await import("../database/repositories/factory.js");
const resolution = createCurrentHostResolutionRepository();
const patches: Array<{ id: number; values: Partial<HostRow> }> = [];
for (const host of hosts) {
if (folderChainPaths(host.folder).length === 0) continue;
const credentialId = await resolution.findFolderCredentialId(
host.userId,
host.folder!,
);
if (credentialId) patches.push({ id: host.id, values: { credentialId } });
}
await repository.updateHosts(patches);
}
export async function runHostDefaultsMigration(): Promise<void> {
const manifests = new Map(
hostSettingsPlugins().map((manifest) => [manifest.id, manifest]),
);
try {
await once("core", async () => {
await moveCoreLevels();
await moveFolderCredentials();
});
if (manifests.has(TERMINAL)) await once(TERMINAL, moveTerminalLevels);
if (manifests.has(METRICS)) await once(METRICS, moveMetricsLevels);
if (manifests.has(DESKTOP)) await once(DESKTOP, moveDesktopLevels);
const rows = await createCurrentHostDefaultsRepository().listHosts({
all: true,
});
await bakeFolderCredentials(rows);
const terminal = manifests.get(TERMINAL);
if (terminal) await bakeTerminalHosts(terminal, rows);
const desktop = manifests.get(DESKTOP);
if (desktop) await bakeDesktopHosts(desktop, rows);
} catch (error) {
databaseLogger.warn("Host defaults migration failed", {
operation: "host_defaults_migration",
error: error instanceof Error ? error.message : String(error),
});
}
}
@@ -0,0 +1,182 @@
/**
* Moves Host Metrics' settings out of core and into the host-metrics
* plugin's own settings.
*
* Admin keys from `settings`: global_metrics_interval,
* metrics_history_retention_days and metricsEnabled inside host_defaults (the
* default for new hosts). Per host, the metrics half of
* ssh_data.stats_config: metricsEnabled, the host's own interval, the enabled
* widgets and the mount lists. The status half is core and moved by
* host-status-config-migration.ts.
*
* The old settings rows and the column stay in place, unused.
*
* Idempotent: an admin key already set, or a host that already has any
* host-metrics row, is skipped, so this is safe on every boot.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../utils/logger.js";
import {
createCurrentPluginRepository,
createCurrentPluginSettingsRepository,
createCurrentSettingsRepository,
} from "../database/repositories/factory.js";
import { selectRows } from "../utils/crypto-migration/raw-rows.js";
const PLUGIN_ID = "host-metrics";
/** A JSON object stored as text, or {} when it is anything else. */
function parseStatsConfig(raw: string | null): Record<string, unknown> {
try {
let parsed: unknown = raw ? JSON.parse(raw) : null;
if (typeof parsed === "string") parsed = JSON.parse(parsed);
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: {};
} catch {
return {};
}
}
function seconds(value: unknown, min: number, max: number): number | null {
const parsed = Number(value);
return Number.isInteger(parsed) && parsed >= min && parsed <= max
? parsed
: null;
}
/** The plugin's host settings for one stats_config value, defaults left out. */
export function hostSettingsFromStatsConfig(
raw: string | null,
): Record<string, unknown> {
const config = parseStatsConfig(raw);
const values: Record<string, unknown> = {};
if (config.metricsEnabled === false) values.metricsEnabled = false;
if (config.useGlobalMetricsInterval === false) {
const interval = seconds(config.metricsInterval, 5, 3600);
if (interval !== null) values.metricsInterval = interval;
}
if (Array.isArray(config.enabledWidgets)) {
values.enabledWidgets = config.enabledWidgets.filter(
(widget): widget is string => typeof widget === "string",
);
}
if (Array.isArray(config.excludedMounts) && config.excludedMounts.length) {
values.excludedMounts = config.excludedMounts.filter(
(mount): mount is string => typeof mount === "string",
);
}
if (Array.isArray(config.monitoredMounts) && config.monitoredMounts.length) {
values.monitoredMounts = config.monitoredMounts.filter(
(mount) =>
!!mount &&
typeof mount === "object" &&
typeof (mount as { path?: unknown }).path === "string",
);
}
return values;
}
export interface HostMetricsSettingsMigrationResult {
moved: string[];
hostsMoved: number;
hostsSkipped: number;
}
export async function runHostMetricsSettingsMigration(): Promise<HostMetricsSettingsMigrationResult> {
const result: HostMetricsSettingsMigrationResult = {
moved: [],
hostsMoved: 0,
hostsSkipped: 0,
};
// plugin_settings has a foreign key to plugins: nothing to migrate into
// until the plugin row exists.
try {
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
if (!plugin) return result;
} catch {
return result;
}
const pluginSettings = createCurrentPluginSettingsRepository();
const write = (
scope: "admin" | "host",
scopeId: string | null,
key: string,
value: unknown,
) =>
pluginSettings.set(PLUGIN_ID, scope, scopeId, key, JSON.stringify(value));
try {
const settings = createCurrentSettingsRepository();
const moves: [string, string, number, number][] = [
["global_metrics_interval", "metricsInterval", 5, 3600],
["metrics_history_retention_days", "historyRetentionDays", 1, 90],
];
for (const [legacyKey, field, min, max] of moves) {
const value = seconds(await settings.get(legacyKey), min, max);
if (value === null) continue;
const existing = await pluginSettings.get(
PLUGIN_ID,
"admin",
null,
field,
);
if (existing && existing.value !== null) continue;
await write("admin", null, field, value);
result.moved.push(legacyKey);
}
const defaults = parseStatsConfig(await settings.get("host_defaults"));
if (defaults.metricsEnabled === false) {
const existing = await pluginSettings.get(
PLUGIN_ID,
"admin",
null,
"enabledForNewHosts",
);
if (!existing || existing.value === null) {
await write("admin", null, "enabledForNewHosts", false);
result.moved.push("host_defaults.metricsEnabled");
}
}
} catch (error) {
databaseLogger.warn("Host Metrics admin settings migration failed", {
operation: "host_metrics_settings_migration",
error: error instanceof Error ? error.message : String(error),
});
}
try {
// Raw SQL: schema.ts no longer declares stats_config.
const rows = await selectRows<{ id: number; stats_config: string | null }>(
sql`SELECT id, stats_config FROM ssh_data WHERE stats_config IS NOT NULL`,
);
for (const row of rows) {
const values = hostSettingsFromStatsConfig(row.stats_config);
if (Object.keys(values).length === 0) continue;
const scopeId = String(row.id);
const existing = await pluginSettings.getAll(PLUGIN_ID, "host", scopeId);
if (existing.length > 0) {
result.hostsSkipped++;
continue;
}
for (const [key, value] of Object.entries(values)) {
await write("host", scopeId, key, value);
}
result.hostsMoved++;
}
} catch {
// A fresh install never had the column.
}
if (result.moved.length > 0 || result.hostsMoved > 0) {
databaseLogger.info(
`Moved ${result.moved.length} Host Metrics setting(s) and ${result.hostsMoved} host(s) into plugin settings`,
{ operation: "host_metrics_settings_migration" },
);
}
return result;
}
@@ -0,0 +1,148 @@
/**
* Moves 2.8 LDAP directories into the ldap plugin.
*
* 2.8 kept LDAP and SSO providers in one sso_providers table. The sso plugin
* adopts that table (as p_sso_providers) and ignores its LDAP rows; this
* copies them into p_ldap_providers once the ldap plugin has created it, then
* deletes them from the source.
*
* Ids are kept, because users.oidc_identifier ("ldap:<id>:<uid>") and their
* identities ("ldap:<id>") name them. Bare "<id>" identities from before the
* prefix are renamed too. It runs before the ldap plugin first activates, so
* the plugin cannot have used an id yet; if it somehow has, the old row gets
* a fresh id and only its bare identities follow it.
*
* Needs neither plugin to be running, only their tables. Idempotent.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../utils/logger.js";
import { resolveDatabaseDialect } from "../database/db/dialect.js";
import {
runStatement,
selectRows,
} from "../utils/crypto-migration/raw-rows.js";
const TARGET = "p_ldap_providers";
const SOURCES = ["p_sso_providers", "sso_providers"];
interface LegacyRow {
id: number;
name: string;
enabled: unknown;
display_order: number;
config: string;
created_at: string;
updated_at: string;
}
export interface LdapProviderMigrationResult {
/** Directories copied into the plugin's table. */
moved: number;
/** Of those, how many needed a new id. */
renumbered: number;
}
async function tableExists(name: string): Promise<boolean> {
try {
await selectRows(sql`SELECT 1 FROM ${sql.identifier(name)} WHERE 1 = 0`);
return true;
} catch {
return false;
}
}
function isOn(value: unknown): boolean {
return value === true || value === 1 || value === "1" || value === "t";
}
async function renameIdentities(from: string, to: string): Promise<void> {
await runStatement(
sql`UPDATE user_external_identities SET provider_id = ${to} WHERE provider_id = ${from}`,
);
}
/** Explicit ids leave a Postgres sequence behind them. */
async function syncSequence(): Promise<void> {
if (resolveDatabaseDialect() !== "postgres") return;
await runStatement(
sql`SELECT setval(pg_get_serial_sequence(${TARGET}, 'id'), (SELECT COALESCE(MAX(id), 1) FROM ${sql.identifier(TARGET)}))`,
);
}
async function insertRow(row: LegacyRow, id: number | null): Promise<number> {
const enabled = isOn(row.enabled);
const values = sql`${row.name}, ${resolveDatabaseDialect() === "sqlite" ? (enabled ? 1 : 0) : enabled}, ${row.display_order ?? 0}, ${row.config}, ${row.created_at}, ${row.updated_at}`;
if (id !== null) {
await runStatement(
sql`INSERT INTO ${sql.identifier(TARGET)} (id, name, enabled, display_order, config, created_at, updated_at) VALUES (${id}, ${values})`,
);
return id;
}
await runStatement(
sql`INSERT INTO ${sql.identifier(TARGET)} (name, enabled, display_order, config, created_at, updated_at) VALUES (${values})`,
);
const [inserted] = await selectRows<{ id: number }>(
sql`SELECT MAX(id) AS id FROM ${sql.identifier(TARGET)}`,
);
return Number(inserted.id);
}
export async function runLdapProviderMigration(): Promise<LdapProviderMigrationResult> {
const result: LdapProviderMigrationResult = { moved: 0, renumbered: 0 };
try {
if (!(await tableExists(TARGET))) return result;
for (const source of SOURCES) {
if (!(await tableExists(source))) continue;
const rows = await selectRows<LegacyRow>(
sql`SELECT id, name, enabled, display_order, config, created_at, updated_at FROM ${sql.identifier(source)} WHERE type = 'ldap' ORDER BY id`,
);
// Rows whose id is free keep it; the rest go last, so a fresh id can
// never land on one that is still waiting to be copied.
const clashing: LegacyRow[] = [];
for (const row of rows) {
const taken = await selectRows(
sql`SELECT id FROM ${sql.identifier(TARGET)} WHERE id = ${row.id}`,
);
if (taken.length > 0) {
clashing.push(row);
continue;
}
await insertRow(row, row.id);
await renameIdentities(String(row.id), `ldap:${row.id}`);
await runStatement(
sql`DELETE FROM ${sql.identifier(source)} WHERE id = ${row.id}`,
);
result.moved++;
}
if (clashing.length > 0) await syncSequence();
for (const row of clashing) {
const id = await insertRow(row, null);
// "ldap:<id>" already names the plugin's own directory, so only the
// bare ids from before the prefix can follow this one.
await renameIdentities(String(row.id), `ldap:${id}`);
await runStatement(
sql`DELETE FROM ${sql.identifier(source)} WHERE id = ${row.id}`,
);
result.moved++;
result.renumbered++;
}
}
if (result.moved > 0) await syncSequence();
if (result.moved > 0) {
databaseLogger.info("Moved LDAP directories into the ldap plugin", {
operation: "ldap_provider_migration",
...result,
});
}
} catch (error) {
databaseLogger.error("LDAP directory migration failed", error, {
operation: "ldap_provider_migration_failed",
});
}
return result;
}
@@ -0,0 +1,186 @@
/**
* Moves 2.8 notification channels into the alerts plugin.
*
* 2.8 kept them in core's notification_channels, each config encrypted under
* its owner's data key. This copies every row into p_alerts_channels with the
* same id, because automations' notify steps and links name it, then empties
* the old config column so no webhook token or ntfy key stays behind.
*
* The plugin reads configs sealed with the installation key, so it can send
* an alert with no user signed in. A config is resealed as soon as its
* owner's data key opens: at boot for most users, at their next password
* login for a 2.8 key only the password unwraps. Until then the channel shows
* as needing to be saved again.
*
* The old table stays until 3.0.0, like the old columns. Idempotent.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../utils/logger.js";
import { resolveDatabaseDialect } from "../database/db/dialect.js";
import { DataCrypto } from "../utils/data-crypto.js";
import { LazyFieldEncryption } from "../utils/lazy-field-encryption.js";
import {
encryptSystemSecret,
isSystemEncrypted,
} from "../utils/system-secret-crypto.js";
import {
runStatement,
selectRows,
} from "../utils/crypto-migration/raw-rows.js";
const TARGET = "p_alerts_channels";
const SOURCE = "notification_channels";
interface LegacyChannel {
id: number;
user_id: string;
name: string;
type: string;
config: string | null;
enabled: unknown;
created_at: string | null;
}
export interface NotificationChannelMigrationResult {
/** Channels copied into the plugin's table. */
moved: number;
/** Configs sealed with the installation key. */
sealed: number;
}
async function tableExists(name: string): Promise<boolean> {
try {
await selectRows(sql`SELECT 1 FROM ${sql.identifier(name)} WHERE 1 = 0`);
return true;
} catch {
return false;
}
}
function isOn(value: unknown): boolean {
return value === true || value === 1 || value === "1" || value === "t";
}
/** Explicit ids leave a Postgres sequence behind them. */
async function syncSequence(): Promise<void> {
if (resolveDatabaseDialect() !== "postgres") return;
await runStatement(
sql`SELECT setval(pg_get_serial_sequence(${TARGET}, 'id'), (SELECT COALESCE(MAX(id), 1) FROM ${sql.identifier(TARGET)}))`,
);
}
/** The config in plain JSON, or null while the owner's key is closed. */
function openLegacyConfig(row: {
id: number;
user_id: string;
config: string;
}): string | null {
const dek = DataCrypto.getUserDataKey(row.user_id);
if (!dek) return null;
const plain = LazyFieldEncryption.safeGetFieldValue(
row.config,
dek,
String(row.id),
"config",
);
if (!plain) return null;
try {
JSON.parse(plain);
return plain;
} catch {
return null;
}
}
async function copyChannels(
result: NotificationChannelMigrationResult,
): Promise<void> {
if (!(await tableExists(SOURCE))) return;
const rows = await selectRows<LegacyChannel>(
sql`SELECT id, user_id, name, type, config, enabled, created_at FROM ${sql.identifier(SOURCE)} ORDER BY id`,
);
const sqlite = resolveDatabaseDialect() === "sqlite";
let copied = false;
for (const row of rows) {
// Emptied once copied.
if (!row.config) continue;
const taken = await selectRows(
sql`SELECT id FROM ${sql.identifier(TARGET)} WHERE id = ${row.id}`,
);
if (taken.length > 0) continue;
const enabled = isOn(row.enabled);
const createdAt = row.created_at ?? new Date().toISOString();
await runStatement(
sql`INSERT INTO ${sql.identifier(TARGET)} (id, user_id, name, type, config, enabled, created_at, updated_at) VALUES (${row.id}, ${row.user_id}, ${row.name}, ${row.type}, ${row.config}, ${sqlite ? (enabled ? 1 : 0) : enabled}, ${createdAt}, ${createdAt})`,
);
await runStatement(
sql`UPDATE ${sql.identifier(SOURCE)} SET config = '' WHERE id = ${row.id}`,
);
copied = true;
result.moved++;
}
if (copied) await syncSequence();
}
async function sealConfigs(
result: NotificationChannelMigrationResult,
onlyUserId?: string,
): Promise<void> {
const rows = await selectRows<{
id: number;
user_id: string;
config: string | null;
}>(
onlyUserId
? sql`SELECT id, user_id, config FROM ${sql.identifier(TARGET)} WHERE user_id = ${onlyUserId}`
: sql`SELECT id, user_id, config FROM ${sql.identifier(TARGET)}`,
);
for (const row of rows) {
if (!row.config || isSystemEncrypted(row.config)) continue;
try {
const plain = openLegacyConfig({
id: Number(row.id),
user_id: row.user_id,
config: row.config,
});
if (!plain) continue;
await runStatement(
sql`UPDATE ${sql.identifier(TARGET)} SET config = ${await encryptSystemSecret(plain)} WHERE id = ${row.id}`,
);
result.sealed++;
} catch (error) {
databaseLogger.warn("Could not seal a notification channel's config", {
operation: "notification_channel_migration",
channelId: row.id,
error: error instanceof Error ? error.message : String(error),
});
}
}
}
/**
* Pass a user id to reseal only that user's channels, as a password login
* does once it has opened a data key the boot run could not.
*/
export async function runNotificationChannelMigration(
onlyUserId?: string,
): Promise<NotificationChannelMigrationResult> {
const result: NotificationChannelMigrationResult = { moved: 0, sealed: 0 };
try {
if (!(await tableExists(TARGET))) return result;
if (!onlyUserId) await copyChannels(result);
await sealConfigs(result, onlyUserId);
} catch (error) {
databaseLogger.error("Notification channel migration failed", error, {
operation: "notification_channel_migration",
});
}
if (result.moved > 0 || result.sealed > 0) {
databaseLogger.info("Moved notification channels into the alerts plugin", {
operation: "notification_channel_migration",
...result,
});
}
return result;
}
@@ -0,0 +1,95 @@
/**
* Moves a 2.8 OPKSSH setup into the opkssh plugin.
*
* The config file moves from DATA_DIR/.opk/config.yml to the plugin's own
* folder (DATA_DIR/plugin-data/opkssh/config.yml). The old file is copied, not
* moved, so a downgrade still finds it. An install that had a config also
* keeps sending the pre-2.9 redirect URI (/host/opkssh-callback), because
* that is the one registered with its identity providers: the plugin's
* legacyCallback admin setting is turned on.
*
* Idempotent: an existing target file or setting is never overwritten.
*/
import { promises as fs } from "node:fs";
import path from "node:path";
import { databaseLogger } from "../utils/logger.js";
import {
createCurrentPluginRepository,
createCurrentPluginSettingsRepository,
} from "../database/repositories/factory.js";
const PLUGIN_ID = "opkssh";
export interface OpksshConfigMigrationResult {
copied: boolean;
legacyCallback: boolean;
}
async function exists(file: string): Promise<boolean> {
try {
await fs.access(file);
return true;
} catch {
return false;
}
}
export async function runOpksshConfigMigration(
dataDir: string = process.env.DATA_DIR ||
path.join(process.cwd(), "db", "data"),
): Promise<OpksshConfigMigrationResult> {
const result: OpksshConfigMigrationResult = {
copied: false,
legacyCallback: false,
};
const legacyConfig = path.join(dataDir, ".opk", "config.yml");
if (!(await exists(legacyConfig))) return result;
try {
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
if (!plugin) return result;
} catch {
return result;
}
try {
const target = path.join(dataDir, "plugin-data", PLUGIN_ID, "config.yml");
if (!(await exists(target))) {
await fs.mkdir(path.dirname(target), { recursive: true });
await fs.copyFile(legacyConfig, target);
result.copied = true;
}
const settings = createCurrentPluginSettingsRepository();
const current = await settings.get(
PLUGIN_ID,
"admin",
null,
"legacyCallback",
);
if (!current) {
await settings.set(
PLUGIN_ID,
"admin",
null,
"legacyCallback",
JSON.stringify(true),
);
result.legacyCallback = true;
}
} catch (error) {
databaseLogger.warn("OPKSSH config migration failed", {
operation: "opkssh_config_migration",
error: error instanceof Error ? error.message : String(error),
});
}
if (result.copied) {
databaseLogger.info("Copied the OPKSSH config into the opkssh plugin", {
operation: "opkssh_config_migration",
});
}
return result;
}
+24
View File
@@ -0,0 +1,24 @@
/**
* Core data that moves into a plugin's tables once they exist. Runs whenever
* a plugin's migrations applied something, at boot or when a plugin is
* enabled later, so the data is in place before the plugin's activate runs. Each move is
* idempotent and does nothing until its target table exists.
*/
import { runLdapProviderMigration } from "./ldap-provider-migration.js";
import { runOpksshConfigMigration } from "./opkssh-config-migration.js";
import { runTermixIdentityCaMigration } from "./termix-identity-ca-migration.js";
import { runSessionRecordingDataMigration } from "./session-recording-data-migration.js";
import { runHostMetricsSettingsMigration } from "./host-metrics-settings-migration.js";
import { runNotificationChannelMigration } from "./notification-channel-migration.js";
export async function runPluginDataMoves(): Promise<void> {
await runLdapProviderMigration();
await runOpksshConfigMigration();
await runTermixIdentityCaMigration();
// Both set how long history is kept, which the plugin prunes by on activate.
await runSessionRecordingDataMigration();
await runHostMetricsSettingsMigration();
// Automations name channels by id, so they are in place before it starts.
await runNotificationChannelMigration();
}
@@ -0,0 +1,242 @@
/**
* Copies each host's 2.8 RDP, VNC and Telnet logins out of the ssh_data
* columns into host_protocol_auth, re-encrypted with the owner's data key
* under the new table's context.
*
* The columns stay in the database, unused, until 3.0.0. A user's hosts are
* copied once their data key opens (at boot for a system-wrapped key, at the
* next password login for a 2.8 key that only the password opens), and a
* per-user marker then stops a later boot from bringing back a login the
* user removed. A host that already has a login for a protocol is skipped,
* so a run that stopped half way is safe to repeat.
*
* Very old installs kept a remote desktop host's login in username,
* password and domain; 2.8 copied those into the protocol columns on every
* boot, and this does the same for a host whose protocol columns are empty.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../utils/logger.js";
import { DataCrypto } from "../utils/data-crypto.js";
import { FieldCrypto } from "../utils/field-crypto.js";
import {
createCurrentHostProtocolAuthRepository,
createCurrentSettingsRepository,
} from "../database/repositories/factory.js";
import type { HostProtocolLogin } from "../database/repositories/host-protocol-auth-repository.js";
import {
selectLegacyRows,
selectRows,
} from "../utils/crypto-migration/raw-rows.js";
interface LegacyProtocol {
id: string;
user: string;
password: string;
passwordField: string;
credential: string;
authType: string;
domain?: string;
}
const PROTOCOLS: LegacyProtocol[] = [
{
id: "rdp",
user: "rdp_user",
password: "rdp_password",
passwordField: "rdpPassword",
credential: "rdp_credential_id",
authType: "rdp_auth_type",
domain: "rdp_domain",
},
{
id: "vnc",
user: "vnc_user",
password: "vnc_password",
passwordField: "vncPassword",
credential: "vnc_credential_id",
authType: "vnc_auth_type",
},
{
id: "telnet",
user: "telnet_user",
password: "telnet_password",
passwordField: "telnetPassword",
credential: "telnet_credential_id",
authType: "telnet_auth_type",
},
];
const AUTH_TYPES = new Set(["direct", "credential", "none"]);
export function copiedMarker(userId: string): string {
return `protocol_auth_copied_v1:${userId}`;
}
type LegacyRow = Record<string, unknown> & { id: number; user_id: string };
function text(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
/**
* A 2.8 secret column read raw: plaintext, or an envelope written under the
* field's own name, its column name, or "password" when 2.8 copied the
* host's password blob into it.
*/
export function openLegacySecret(
value: unknown,
dek: Buffer,
recordId: string,
fieldNames: string[],
): string | null {
const raw = typeof value === "string" ? value : "";
if (!raw) return null;
if (!FieldCrypto.isEncrypted(raw)) return raw;
for (const fieldName of fieldNames) {
try {
return FieldCrypto.decryptField(raw, dek, recordId, fieldName);
} catch {
// Try the next name.
}
}
return null;
}
/** The login a 2.8 host row held for one protocol, or null for none. */
export function legacyLogin(
row: LegacyRow,
protocol: LegacyProtocol,
dek: Buffer,
credentialExists: (id: number) => boolean,
): HostProtocolLogin | null {
const recordId = String(row.id);
const storedType = text(row[protocol.authType]);
const credentialValue = Number(row[protocol.credential]);
const credentialId =
Number.isInteger(credentialValue) &&
credentialValue > 0 &&
credentialExists(credentialValue)
? credentialValue
: null;
let username = text(row[protocol.user]);
let password = openLegacySecret(row[protocol.password], dek, recordId, [
protocol.passwordField,
protocol.password,
"password",
]);
let domain = protocol.domain ? text(row[protocol.domain]) : "";
const hasOwn =
!!storedType ||
!!row[protocol.credential] ||
!!username ||
!!row[protocol.password] ||
!!domain;
if (!hasOwn) {
if (text(row.connection_type) !== protocol.id) return null;
username = text(row.username);
password = openLegacySecret(row.password, dek, recordId, ["password"]);
domain = protocol.domain ? text(row.domain) : "";
if (!username && !password && !domain) return null;
} else if (protocol.domain && row[protocol.domain] == null) {
// A saved empty domain was cleared on purpose, so only fill a missing one.
domain = text(row.domain);
}
const authType = AUTH_TYPES.has(storedType)
? storedType
: credentialId
? "credential"
: "direct";
const direct = authType === "direct";
return {
protocol: protocol.id,
authType,
credentialId: authType === "credential" ? credentialId : null,
username: direct ? username || null : null,
password: direct ? password : null,
fields: domain ? { domain } : {},
secretFields: {},
};
}
export interface ProtocolAuthMigrationResult {
copied: number;
pendingUsers: number;
}
/** Copies every user's logins, or only `userId`'s after a login opened their key. */
export async function runProtocolAuthMigration(
userId?: string,
): Promise<ProtocolAuthMigrationResult> {
const result: ProtocolAuthMigrationResult = { copied: 0, pendingUsers: 0 };
const rows = await selectLegacyRows<LegacyRow>(sql`
SELECT id, user_id, connection_type, username, password, domain,
rdp_user, rdp_password, rdp_domain, rdp_credential_id, rdp_auth_type,
vnc_user, vnc_password, vnc_credential_id, vnc_auth_type,
telnet_user, telnet_password, telnet_credential_id, telnet_auth_type
FROM ssh_data
`);
if (rows.length === 0) return result;
let credentialIds: Set<number>;
try {
credentialIds = new Set(
(
await selectRows<{ id: number | string }>(
sql`SELECT id FROM ssh_credentials`,
)
).map((row) => Number(row.id)),
);
} catch {
return result;
}
const settings = createCurrentSettingsRepository();
const repository = createCurrentHostProtocolAuthRepository();
const byUser = new Map<string, LegacyRow[]>();
for (const row of rows) {
const owner = String(row.user_id);
if (userId && owner !== userId) continue;
byUser.set(owner, [...(byUser.get(owner) ?? []), row]);
}
for (const [owner, hosts] of byUser) {
try {
if ((await settings.get(copiedMarker(owner))) === "done") continue;
const dek = DataCrypto.getUserDataKey(owner);
if (!dek) {
result.pendingUsers++;
continue;
}
for (const host of hosts) {
const hostId = Number(host.id);
for (const protocol of PROTOCOLS) {
const login = legacyLogin(host, protocol, dek, (id) =>
credentialIds.has(id),
);
if (!login) continue;
if (await repository.findRow(hostId, protocol.id)) continue;
await repository.upsert(owner, hostId, login, dek);
result.copied++;
}
}
await settings.set(copiedMarker(owner), "done");
} catch (error) {
databaseLogger.warn("Protocol login copy failed for a user", {
operation: "protocol_auth_migration",
userId: owner,
error: error instanceof Error ? error.message : String(error),
});
}
}
if (result.copied > 0) {
databaseLogger.info(
`Moved ${result.copied} remote desktop login(s) into host_protocol_auth`,
{ operation: "protocol_auth_migration" },
);
}
return result;
}
@@ -0,0 +1,119 @@
/**
* Moves the four Proxmox host columns into the proxmox plugin's host-scope
* settings, before those columns are dropped from ssh_data.
*
* Idempotent: skips a host that already has a proxmox settings row, so it is
* safe to run on every boot. Lossless: reads straight off the still-present
* ssh_data columns, so it must run and finish before those columns are ever
* dropped from schema.ts.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../utils/logger.js";
import { selectLegacyRows } from "../utils/crypto-migration/raw-rows.js";
import { createCurrentPluginSettingsRepository } from "../database/repositories/factory.js";
export interface ProxmoxSettingsMigrationResult {
moved: number;
skipped: number;
}
interface LegacyProxmoxRow {
id: number;
enable_proxmox: number | boolean | null;
proxmox_config: string | null;
enable_proxmox_stats: number | boolean | null;
proxmox_stats_config: string | null;
}
export async function runProxmoxSettingsMigration(): Promise<ProxmoxSettingsMigrationResult> {
const result: ProxmoxSettingsMigrationResult = { moved: 0, skipped: 0 };
try {
// Raw SQL, not the typed schema: this runs once schema.ts has already
// dropped these columns, so the query is the only thing left that still
// knows they used to exist.
const rows = await selectLegacyRows<LegacyProxmoxRow>(sql`
SELECT id, enable_proxmox, proxmox_config, enable_proxmox_stats, proxmox_stats_config
FROM ssh_data
WHERE enable_proxmox = true OR proxmox_config IS NOT NULL
OR enable_proxmox_stats = true OR proxmox_stats_config IS NOT NULL
`);
if (rows.length === 0) return result;
const pluginSettingsRepository = createCurrentPluginSettingsRepository();
for (const row of rows) {
const scopeId = String(row.id);
const existing = await pluginSettingsRepository.get(
"proxmox",
"host",
scopeId,
"enableProxmox",
);
if (existing && existing.value !== null) {
result.skipped++;
continue;
}
await pluginSettingsRepository.set(
"proxmox",
"host",
scopeId,
"enableProxmox",
JSON.stringify(!!row.enable_proxmox),
);
await pluginSettingsRepository.set(
"proxmox",
"host",
scopeId,
"proxmoxConfig",
JSON.stringify(
row.proxmox_config ? safeParse(row.proxmox_config) : null,
),
);
await pluginSettingsRepository.set(
"proxmox",
"host",
scopeId,
"enableProxmoxStats",
JSON.stringify(!!row.enable_proxmox_stats),
);
await pluginSettingsRepository.set(
"proxmox",
"host",
scopeId,
"proxmoxStatsConfig",
JSON.stringify(
row.proxmox_stats_config ? safeParse(row.proxmox_stats_config) : null,
),
);
result.moved++;
}
if (result.moved > 0) {
databaseLogger.info(
`Moved Proxmox settings for ${result.moved} host(s) into plugin settings`,
{ operation: "proxmox_settings_migration", moved: result.moved },
);
}
} catch (error) {
// A failed migration must not stop the backend. Hosts simply keep
// whatever proxmox settings they already had (none, on a fresh run).
databaseLogger.warn("Proxmox settings migration failed", {
operation: "proxmox_settings_migration",
error: error instanceof Error ? error.message : String(error),
});
}
return result;
}
function safeParse(raw: string): unknown {
try {
return JSON.parse(raw);
} catch {
return null;
}
}
@@ -0,0 +1,289 @@
/**
* Moves Remote Desktop's settings out of core and into the remote-desktop
* plugin's own settings.
*
* Admin keys from `settings`: guac_enabled and guac_url. Per user, the RDP
* defaults JSON in user_preferences.rdp_defaults. Per host, the option
* columns in ssh_data: the three protocol switches and ports, rdp_security,
* rdp_ignore_cert and guacamole_config, folding in the older security,
* ignore_cert and connection_type-only shapes, plus enable_terminal_toolbar,
* which also drove the remote desktop toolbar.
*
* The logins (users, passwords, credential ids, auth types, domain) stay in
* core, next to the host, where sharing needs them.
*
* The columns stay in the database, unused: core's drizzle migrations run
* before this does, so dropping them there would lose the data first.
*
* Idempotent: an admin key already set, or a user or host that already has
* any remote-desktop row, is skipped, so this is safe on every boot.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../utils/logger.js";
import {
createCurrentPluginRepository,
createCurrentPluginSettingsRepository,
createCurrentSettingsRepository,
} from "../database/repositories/factory.js";
import {
runStatement,
selectRows,
} from "../utils/crypto-migration/raw-rows.js";
const PLUGIN_ID = "remote-desktop";
const PROTOCOLS = ["rdp", "vnc", "telnet"] as const;
const DEFAULT_PORTS = { rdp: 3389, vnc: 5900, telnet: 23 };
type Flag = number | boolean | string | null | undefined;
/** true, 1 and "1" are on; null means the column was never set. */
function flag(value: Flag): boolean | undefined {
if (value === null || value === undefined) return undefined;
return value === true || value === 1 || value === "1" || value === "true";
}
function port(value: unknown): number | undefined {
const parsed = Number(value);
return Number.isInteger(parsed) && parsed > 0 && parsed <= 65535
? parsed
: undefined;
}
function jsonObject(value: unknown): Record<string, unknown> | undefined {
if (typeof value !== "string" || !value) return undefined;
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? parsed
: undefined;
} catch {
return undefined;
}
}
interface LegacyHostRow {
id: number;
port: number | null;
connection_type: string | null;
enable_rdp: Flag;
enable_vnc: Flag;
enable_telnet: Flag;
rdp_port: number | null;
vnc_port: number | null;
telnet_port: number | null;
rdp_security: string | null;
rdp_ignore_cert: Flag;
security: string | null;
ignore_cert: Flag;
guacamole_config: string | null;
enable_terminal_toolbar: Flag;
}
/** The plugin's host settings for one legacy row, defaults left out. */
export function hostSettingsFromRow(
row: LegacyHostRow,
): Record<string, unknown> {
const values: Record<string, unknown> = {};
const flags = {
rdp: flag(row.enable_rdp) === true,
vnc: flag(row.enable_vnc) === true,
telnet: flag(row.enable_telnet) === true,
};
const legacyType = PROTOCOLS.find((p) => p === row.connection_type);
// Hosts from before the per-protocol switches only had connection_type.
if (!flags.rdp && !flags.vnc && !flags.telnet && legacyType) {
flags[legacyType] = true;
}
const ports = {
rdp: port(row.rdp_port),
vnc: port(row.vnc_port),
telnet: port(row.telnet_port),
};
if (legacyType && ports[legacyType] === undefined) {
ports[legacyType] = port(row.port);
}
for (const protocol of PROTOCOLS) {
const key = `enable${protocol[0].toUpperCase()}${protocol.slice(1)}`;
if (flags[protocol]) values[key] = true;
const value = ports[protocol];
if (value !== undefined && value !== DEFAULT_PORTS[protocol]) {
values[`${protocol}Port`] = value;
}
}
const security = row.rdp_security || row.security;
if (security) values.rdpSecurity = security;
const ignoreCert = flag(row.rdp_ignore_cert) ?? flag(row.ignore_cert);
if (ignoreCert) values.rdpIgnoreCert = true;
const config = jsonObject(row.guacamole_config);
if (config && Object.keys(config).length > 0) values.guacamoleConfig = config;
if (flag(row.enable_terminal_toolbar) === false) values.enableToolbar = false;
return values;
}
/** Old camelCase defaults JSON -> the plugin's user settings. */
function userSettingsFromDefaults(
defaults: Record<string, unknown>,
): Record<string, string> {
const values: Record<string, string> = {};
const depth = Number(defaults.colorDepth);
if ([16, 24, 32].includes(depth)) values.colorDepth = String(depth);
if (
defaults.resizeMethod === "display-update" ||
defaults.resizeMethod === "reconnect"
) {
values.resizeMethod = defaults.resizeMethod;
}
for (const key of [
"forceLossless",
"enableWallpaper",
"enableFontSmoothing",
"enableDesktopComposition",
"disableAudio",
"enablePrinting",
"enableDrive",
"disableCopy",
"disablePaste",
]) {
if (typeof defaults[key] === "boolean") {
values[key] = defaults[key] ? "on" : "off";
}
}
return values;
}
export interface RemoteDesktopSettingsMigrationResult {
moved: string[];
usersMoved: number;
hostsMoved: number;
hostsSkipped: number;
}
export async function runRemoteDesktopSettingsMigration(): Promise<RemoteDesktopSettingsMigrationResult> {
const result: RemoteDesktopSettingsMigrationResult = {
moved: [],
usersMoved: 0,
hostsMoved: 0,
hostsSkipped: 0,
};
// plugin_settings has a foreign key to plugins: nothing to migrate into
// until the plugin row exists.
try {
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
if (!plugin) return result;
} catch {
return result;
}
const pluginSettings = createCurrentPluginSettingsRepository();
const write = (
scope: "admin" | "user" | "host",
scopeId: string | null,
key: string,
value: unknown,
) =>
pluginSettings.set(PLUGIN_ID, scope, scopeId, key, JSON.stringify(value));
try {
const settings = createCurrentSettingsRepository();
const moves: [string, string, (raw: string) => unknown][] = [
["guac_enabled", "enabled", (raw) => raw !== "false" && raw !== "0"],
["guac_url", "guacdUrl", (raw) => raw],
];
for (const [legacyKey, field, parse] of moves) {
const legacy = await settings.get(legacyKey);
if (legacy === null || legacy === "") continue;
const existing = await pluginSettings.get(
PLUGIN_ID,
"admin",
null,
field,
);
if (existing && existing.value !== null) continue;
await write("admin", null, field, parse(legacy));
result.moved.push(legacyKey);
}
} catch (error) {
databaseLogger.warn("Remote Desktop admin settings migration failed", {
operation: "remote_desktop_settings_migration",
error: error instanceof Error ? error.message : String(error),
});
}
try {
const rows = await selectRows<{
user_id: string;
rdp_defaults: string | null;
}>(sql`SELECT user_id, rdp_defaults FROM user_preferences`);
for (const row of rows) {
const defaults = jsonObject(row.rdp_defaults);
if (!defaults) continue;
const values = userSettingsFromDefaults(defaults);
if (Object.keys(values).length === 0) continue;
const existing = await pluginSettings.getAll(
PLUGIN_ID,
"user",
row.user_id,
);
if (existing.length > 0) continue;
for (const [key, value] of Object.entries(values)) {
await write("user", row.user_id, key, value);
}
result.usersMoved++;
}
} catch {
// A fresh install never had the column.
}
try {
// Raw SQL: schema.ts no longer declares these columns.
const rows = await selectRows<LegacyHostRow>(sql`
SELECT id, port, connection_type, enable_rdp, enable_vnc, enable_telnet,
rdp_port, vnc_port, telnet_port, rdp_security, rdp_ignore_cert,
security, ignore_cert, guacamole_config, enable_terminal_toolbar
FROM ssh_data
`);
for (const row of rows) {
const values = hostSettingsFromRow(row);
if (Object.keys(values).length === 0) continue;
const scopeId = String(row.id);
const existing = await pluginSettings.getAll(PLUGIN_ID, "host", scopeId);
if (existing.length > 0) {
result.hostsSkipped++;
continue;
}
for (const [key, value] of Object.entries(values)) {
await write("host", scopeId, key, value);
}
result.hostsMoved++;
}
// Those same connection_type-only hosts kept the enable_ssh default of
// on, which core used to correct on every read. FALSE and NOT read the
// same on SQLite's integers and on real booleans.
await runStatement(sql`
UPDATE ssh_data SET enable_ssh = FALSE
WHERE connection_type IN ('rdp', 'vnc', 'telnet')
AND (enable_rdp IS NULL OR NOT enable_rdp)
AND (enable_vnc IS NULL OR NOT enable_vnc)
AND (enable_telnet IS NULL OR NOT enable_telnet)
`);
} catch {
// A fresh install never had the columns.
}
if (
result.moved.length > 0 ||
result.usersMoved > 0 ||
result.hostsMoved > 0
) {
databaseLogger.info(
`Moved ${result.moved.length} Remote Desktop setting(s), ${result.usersMoved} user(s) and ${result.hostsMoved} host(s) into plugin settings`,
{ operation: "remote_desktop_settings_migration" },
);
}
return result;
}
@@ -0,0 +1,117 @@
/**
* Moves each secret source's access token out of secret_sources.token,
* encrypted under its owner's data key, into a ctx.secrets row "source:<id>"
* for that owner, encrypted under the installation key - the same move the
* ai plugin made for provider API keys. The column is then emptied so no
* token material is left in the adopted table.
*
* The old column stays in the database, unused, until this runs: core's
* drizzle migrations run before this does, so dropping it there would lose
* the token first. The table is secret_sources until the plugin adopts it
* and p_secret_sources_sources after, so both names are tried.
*
* Idempotent: a source whose ctx.secrets row already exists is skipped, and
* a token is only cleared once its copy exists. Safe on every boot.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../utils/logger.js";
import {
createCurrentPluginRepository,
createCurrentPluginSettingsRepository,
} from "../database/repositories/factory.js";
import { DataCrypto } from "../utils/data-crypto.js";
import { LazyFieldEncryption } from "../utils/lazy-field-encryption.js";
import { encryptSystemSecret } from "../utils/system-secret-crypto.js";
import {
runStatement,
selectRows,
} from "../utils/crypto-migration/raw-rows.js";
const PLUGIN_ID = "secret-sources";
export interface SecretSourcesTokenMigrationResult {
moved: number;
}
async function tryRows<T>(query: ReturnType<typeof sql>): Promise<T[]> {
try {
return await selectRows<T>(query);
} catch {
// A fresh install never had the table or column.
return [];
}
}
export async function runSecretSourcesTokenMigration(): Promise<SecretSourcesTokenMigrationResult> {
const result: SecretSourcesTokenMigrationResult = { moved: 0 };
// plugin_settings has a foreign key to plugins: nothing to migrate into
// until the plugin row exists.
try {
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
if (!plugin) return result;
} catch {
return result;
}
const pluginSettings = createCurrentPluginSettingsRepository();
for (const table of ["secret_sources", "p_secret_sources_sources"]) {
const rows = await tryRows<{
id: string;
user_id: string;
token: string | null;
}>(
sql`SELECT id, user_id, token FROM ${sql.identifier(table)} WHERE token IS NOT NULL AND token <> ''`,
);
for (const row of rows) {
try {
const key = `source:${row.id}`;
const existing = await pluginSettings.get(
PLUGIN_ID,
"secret",
row.user_id,
key,
);
if (!existing) {
const dek = DataCrypto.getUserDataKey(row.user_id);
if (!dek) continue;
const plaintext = LazyFieldEncryption.safeGetFieldValue(
row.token as string,
dek,
row.id,
"token",
);
if (!plaintext) continue;
await pluginSettings.set(
PLUGIN_ID,
"secret",
row.user_id,
key,
JSON.stringify(await encryptSystemSecret(plaintext)),
true,
);
result.moved++;
}
await runStatement(
sql`UPDATE ${sql.identifier(table)} SET token = NULL WHERE id = ${row.id}`,
);
} catch (error) {
databaseLogger.warn("Secret source token migration failed for a row", {
operation: "secret_sources_token_migration",
error: error instanceof Error ? error.message : String(error),
});
}
}
}
if (result.moved > 0) {
databaseLogger.info(
`Moved ${result.moved} secret source token(s) into the secret-sources plugin`,
{ operation: "secret_sources_token_migration" },
);
}
return result;
}
@@ -0,0 +1,167 @@
/**
* Moves what a 2.8 install kept for session recordings into the
* session-recording plugin, before the plugin activates. Its retention sweep
* runs on activate, so both have to be in place first:
*
* - The retention period: the `session_recording_retention_days` setting, or
* the SESSION_RECORDING_RETENTION_DAYS environment variable 2.8 fell back
* to, becomes the plugin's `retentionDays`. Without it the plugin's 30 day
* default would prune an install that kept a year.
* - The files: 2.8 wrote them under DATA_DIR/session_logs (SSH) and
* DATA_DIR/session_recordings (guacd). The plugin only plays back and
* prunes files inside its own data folder, so each file a row points at is
* moved there, keeping its path below DATA_DIR, and the row follows it.
* Moved rather than copied: recordings can be large, and 2.8 cannot read
* the adopted table after a downgrade anyway.
*
* Idempotent: an existing retention value is kept, and a row that already
* points inside the plugin's folder is skipped.
*/
import fs from "node:fs/promises";
import path from "node:path";
import { sql } from "drizzle-orm";
import { databaseLogger } from "../utils/logger.js";
import {
createCurrentPluginRepository,
createCurrentPluginSettingsRepository,
createCurrentSettingsRepository,
} from "../database/repositories/factory.js";
import {
runStatement,
selectRows,
} from "../utils/crypto-migration/raw-rows.js";
import { getPluginDataDir } from "../plugins/paths.js";
const PLUGIN_ID = "session-recording";
const TABLE = "p_session_recording_session_recordings";
const LEGACY_DIRS = ["session_logs", "session_recordings"];
export interface SessionRecordingDataMigrationResult {
retentionDays: number | null;
moved: number;
}
function days(value: unknown): number | null {
const parsed = parseInt(String(value ?? ""), 10);
return parsed >= 1 && parsed <= 3650 ? parsed : null;
}
async function exists(file: string): Promise<boolean> {
try {
await fs.access(file);
return true;
} catch {
return false;
}
}
async function move(source: string, target: string): Promise<void> {
await fs.mkdir(path.dirname(target), { recursive: true });
try {
await fs.rename(source, target);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "EXDEV") throw error;
await fs.copyFile(source, target);
await fs.unlink(source);
}
}
async function migrateRetention(): Promise<number | null> {
const settings = createCurrentPluginSettingsRepository();
const existing = await settings.get(
PLUGIN_ID,
"admin",
null,
"retentionDays",
);
if (existing && existing.value !== null) return null;
const legacy =
days(
await createCurrentSettingsRepository().get(
"session_recording_retention_days",
),
) ?? days(process.env.SESSION_RECORDING_RETENTION_DAYS);
if (legacy === null) return null;
await settings.set(
PLUGIN_ID,
"admin",
null,
"retentionDays",
JSON.stringify(legacy),
);
return legacy;
}
async function migrateFiles(): Promise<number> {
let rows: Array<{ id: number; recording_path: string | null }>;
try {
rows = await selectRows(
sql`SELECT id, recording_path FROM ${sql.identifier(TABLE)} WHERE recording_path IS NOT NULL`,
);
} catch {
// The plugin has not adopted the table yet.
return 0;
}
const dataDir = path.resolve(process.env.DATA_DIR || "./db/data");
const pluginDir = getPluginDataDir(PLUGIN_ID);
let moved = 0;
for (const row of rows) {
const source = path.resolve(row.recording_path!);
const relative = path.relative(dataDir, source);
const [top] = relative.split(path.sep);
if (relative.startsWith("..") || !LEGACY_DIRS.includes(top)) continue;
const target = path.join(pluginDir, relative);
try {
if (!(await exists(target))) {
if (!(await exists(source))) continue;
await move(source, target);
}
await runStatement(
sql`UPDATE ${sql.identifier(TABLE)} SET recording_path = ${target} WHERE id = ${row.id}`,
);
moved++;
} catch (error) {
databaseLogger.warn("Could not move a session recording file", {
operation: "session_recording_data_migration",
recordingId: row.id,
error: error instanceof Error ? error.message : String(error),
});
}
}
return moved;
}
export async function runSessionRecordingDataMigration(): Promise<SessionRecordingDataMigrationResult> {
const result: SessionRecordingDataMigrationResult = {
retentionDays: null,
moved: 0,
};
try {
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
if (!plugin) return result;
result.retentionDays = await migrateRetention();
result.moved = await migrateFiles();
} catch (error) {
databaseLogger.warn("Session recording data migration failed", {
operation: "session_recording_data_migration",
error: error instanceof Error ? error.message : String(error),
});
return result;
}
if (result.retentionDays !== null || result.moved > 0) {
databaseLogger.info("Moved 2.8 session recordings into the plugin", {
operation: "session_recording_data_migration",
retentionDays: result.retentionDays,
moved: result.moved,
});
}
return result;
}
@@ -0,0 +1,91 @@
/**
* Moves the session logging host column into the session-recording plugin's
* host-scope settings, before the column is dropped from ssh_data.
*
* The legacy column defaulted to true; the plugin's enable switch defaults to
* false like every other plugin's. So unlike a migration whose old and new
* defaults agree, every host needs an explicit row here, not just the ones
* that turned the feature off.
*
* Idempotent: skips a host that already has a session-recording settings row,
* so it is safe to run on every boot.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../utils/logger.js";
import {
legacyFlag,
selectLegacyRows,
} from "../utils/crypto-migration/raw-rows.js";
import { createCurrentPluginSettingsRepository } from "../database/repositories/factory.js";
export interface SessionRecordingSettingsMigrationResult {
moved: number;
skipped: number;
}
interface LegacySessionLoggingRow {
id: number;
enable_session_logging: number | boolean | null;
}
export async function runSessionRecordingSettingsMigration(): Promise<SessionRecordingSettingsMigrationResult> {
const result: SessionRecordingSettingsMigrationResult = {
moved: 0,
skipped: 0,
};
try {
// Raw SQL, not the typed schema, so this keeps working once schema.ts
// stops declaring this column.
const rows = await selectLegacyRows<LegacySessionLoggingRow>(sql`
SELECT id, enable_session_logging FROM ssh_data
`);
if (rows.length === 0) return result;
const pluginSettingsRepository = createCurrentPluginSettingsRepository();
for (const row of rows) {
const scopeId = String(row.id);
const existing = await pluginSettingsRepository.get(
"session-recording",
"host",
scopeId,
"enableSessionRecording",
);
if (existing && existing.value !== null) {
result.skipped++;
continue;
}
await pluginSettingsRepository.set(
"session-recording",
"host",
scopeId,
"enableSessionRecording",
JSON.stringify(legacyFlag(row.enable_session_logging, true)),
);
result.moved++;
}
if (result.moved > 0) {
databaseLogger.info(
`Moved session recording settings for ${result.moved} host(s) into plugin settings`,
{
operation: "session_recording_settings_migration",
moved: result.moved,
},
);
}
} catch (error) {
// A failed migration must not stop the backend. Hosts simply keep the
// plugin's own default (off) until this runs successfully.
databaseLogger.warn("Session recording settings migration failed", {
operation: "session_recording_settings_migration",
error: error instanceof Error ? error.message : String(error),
});
}
return result;
}
@@ -0,0 +1,107 @@
/**
* Moves session sharing's two settings into the session-sharing plugin:
* the instance-wide switch (settings.session_sharing_globally_enabled) into
* its admin setting, and ssh_data.allow_session_sharing into its host setting.
*
* Idempotent: nothing is written where the plugin already has a row, so it is
* safe on every boot. Both default to on, so only a switch that was turned off
* needs a row. The legacy settings row is left in place for a release.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../utils/logger.js";
import { selectLegacyRows } from "../utils/crypto-migration/raw-rows.js";
import {
createCurrentPluginSettingsRepository,
createCurrentSettingsRepository,
} from "../database/repositories/factory.js";
const PLUGIN_ID = "session-sharing";
const LEGACY_GLOBAL_KEY = "session_sharing_globally_enabled";
export interface SessionSharingSettingsMigrationResult {
movedGlobal: boolean;
movedHosts: number;
skipped: number;
}
interface LegacyHostRow {
id: number;
}
export async function runSessionSharingSettingsMigration(): Promise<SessionSharingSettingsMigrationResult> {
const result: SessionSharingSettingsMigrationResult = {
movedGlobal: false,
movedHosts: 0,
skipped: 0,
};
try {
const pluginSettings = createCurrentPluginSettingsRepository();
const legacy =
await createCurrentSettingsRepository().get(LEGACY_GLOBAL_KEY);
if (legacy === "false") {
const existing = await pluginSettings.get(
PLUGIN_ID,
"admin",
null,
"globallyEnabled",
);
if (existing && existing.value !== null) {
result.skipped++;
} else {
await pluginSettings.set(
PLUGIN_ID,
"admin",
null,
"globallyEnabled",
JSON.stringify(false),
);
result.movedGlobal = true;
}
}
// Raw SQL, so this keeps working once schema.ts stops declaring the column.
const rows = await selectLegacyRows<LegacyHostRow>(sql`
SELECT id FROM ssh_data WHERE allow_session_sharing = false
`);
for (const row of rows) {
const scopeId = String(row.id);
const existing = await pluginSettings.get(
PLUGIN_ID,
"host",
scopeId,
"allowSessionSharing",
);
if (existing && existing.value !== null) {
result.skipped++;
continue;
}
await pluginSettings.set(
PLUGIN_ID,
"host",
scopeId,
"allowSessionSharing",
JSON.stringify(false),
);
result.movedHosts++;
}
if (result.movedGlobal || result.movedHosts > 0) {
databaseLogger.info("Moved session sharing settings into the plugin", {
operation: "session_sharing_settings_migration",
movedGlobal: result.movedGlobal,
movedHosts: result.movedHosts,
});
}
} catch (error) {
// Never stops the backend. Sharing then stays on, the plugin's default.
databaseLogger.warn("Session sharing settings migration failed", {
operation: "session_sharing_settings_migration",
error: error instanceof Error ? error.message : String(error),
});
}
return result;
}
@@ -0,0 +1,185 @@
/**
* Moves what core kept for snippets into the snippets plugin's settings.
*
* Per host: ssh_data.quick_actions becomes the quickActions host setting,
* and terminal_config.startupSnippetId the startupSnippetId one. Per user:
* user_preferences.confirm_snippet_execution and folders_collapsed become the
* confirmExecution and foldersCollapsed user settings.
*
* The columns keep their values until 3.0.0 drops them.
*
* Idempotent: a host that already has either host setting, and a user key
* already set, are skipped, so this is safe on every boot.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../utils/logger.js";
import {
legacyFlag,
selectLegacyRows,
} from "../utils/crypto-migration/raw-rows.js";
import {
createCurrentPluginRepository,
createCurrentPluginSettingsRepository,
} from "../database/repositories/factory.js";
const PLUGIN_ID = "snippets";
const HOST_KEYS = ["quickActions", "startupSnippetId"];
function parseJson(value: unknown): unknown {
if (typeof value !== "string" || !value) return value ?? undefined;
try {
let parsed = JSON.parse(value);
if (typeof parsed === "string") parsed = JSON.parse(parsed);
return parsed;
} catch {
return undefined;
}
}
function snippetId(value: unknown): number | null {
const id = typeof value === "string" ? Number(value) : value;
return typeof id === "number" && Number.isInteger(id) && id > 0 ? id : null;
}
/** The snippets host settings one 2.8 ssh_data row carried. */
export function snippetHostSettingsFromRow(row: {
quick_actions?: unknown;
terminal_config?: unknown;
}): Record<string, unknown> {
const values: Record<string, unknown> = {};
const actions = parseJson(row.quick_actions);
if (Array.isArray(actions)) {
const quickActions = actions.flatMap((entry) => {
const id = snippetId((entry as { snippetId?: unknown })?.snippetId);
if (id === null) return [];
const name = (entry as { name?: unknown }).name;
return [{ name: typeof name === "string" ? name : "", snippetId: id }];
});
if (quickActions.length > 0) values.quickActions = quickActions;
}
const config = parseJson(row.terminal_config) as
{ startupSnippetId?: unknown } | undefined;
const startup = snippetId(config?.startupSnippetId);
if (startup !== null) values.startupSnippetId = startup;
return values;
}
export interface SnippetsSettingsMigrationResult {
hostsMoved: number;
hostsSkipped: number;
usersMoved: number;
}
export async function runSnippetsSettingsMigration(): Promise<SnippetsSettingsMigrationResult> {
const result: SnippetsSettingsMigrationResult = {
hostsMoved: 0,
hostsSkipped: 0,
usersMoved: 0,
};
try {
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
if (!plugin) return result;
} catch {
return result;
}
const pluginSettings = createCurrentPluginSettingsRepository();
try {
const rows = await selectLegacyRows<{
id: number;
quick_actions: unknown;
terminal_config: unknown;
}>(sql`
SELECT id, quick_actions, terminal_config FROM ssh_data
WHERE quick_actions IS NOT NULL OR terminal_config IS NOT NULL
`);
for (const row of rows) {
const values = snippetHostSettingsFromRow(row);
if (Object.keys(values).length === 0) continue;
const scopeId = String(row.id);
let done = false;
for (const key of HOST_KEYS) {
if (await pluginSettings.get(PLUGIN_ID, "host", scopeId, key)) {
done = true;
}
}
if (done) {
result.hostsSkipped++;
continue;
}
for (const [key, value] of Object.entries(values)) {
await pluginSettings.set(
PLUGIN_ID,
"host",
scopeId,
key,
JSON.stringify(value),
);
}
result.hostsMoved++;
}
} catch (error) {
databaseLogger.warn("Snippets host settings migration failed", {
operation: "snippets_settings_migration",
error: error instanceof Error ? error.message : String(error),
});
}
try {
const rows = await selectLegacyRows<{
user_id: string;
confirm_snippet_execution: unknown;
folders_collapsed: unknown;
}>(sql`
SELECT user_id, confirm_snippet_execution, folders_collapsed
FROM user_preferences
`);
for (const row of rows) {
const values: Record<string, boolean> = {};
if (row.confirm_snippet_execution != null) {
values.confirmExecution = legacyFlag(
row.confirm_snippet_execution,
false,
);
}
if (row.folders_collapsed != null) {
values.foldersCollapsed = legacyFlag(row.folders_collapsed, true);
}
let moved = false;
for (const [key, value] of Object.entries(values)) {
const existing = await pluginSettings.get(
PLUGIN_ID,
"user",
row.user_id,
key,
);
if (existing && existing.value !== null) continue;
await pluginSettings.set(
PLUGIN_ID,
"user",
row.user_id,
key,
JSON.stringify(value),
);
moved = true;
}
if (moved) result.usersMoved++;
}
} catch (error) {
databaseLogger.warn("Snippets user settings migration failed", {
operation: "snippets_settings_migration",
error: error instanceof Error ? error.message : String(error),
});
}
if (result.hostsMoved > 0 || result.usersMoved > 0) {
databaseLogger.info(
`Moved the snippet settings of ${result.hostsMoved} host(s) and ${result.usersMoved} user(s) into plugin settings`,
{ operation: "snippets_settings_migration" },
);
}
return result;
}
@@ -0,0 +1,318 @@
/**
* Moves the terminal's look and behavior out of core and into the
* ssh-terminal plugin's settings.
*
* Per host, the terminal keys of ssh_data.terminal_config become host
* settings: every behavior key the host saved (tmux, mosh, auto-fill, link
* and echo modes, syntax highlighting and the rest), and its look only when
* it saved one. 2.8 stripped the look from a host that followed the user's
* defaults, so a host with any look key had opted out: it gets
* inheritAppearance false and its look copied. The SSH connection options in
* the same JSON are core's and move to ssh_data.ssh_options
* (ssh-options-migration.ts); startupSnippetId and a legacy sudo password
* stay where they are.
*
* Per user, user_preferences.terminal_defaults, custom_themes and
* command_autocomplete become user settings.
*
* The columns keep their values until 3.0.0 drops them.
*
* Idempotent: a host that already has any of these rows, and a user key that
* is already set, are skipped, so this is safe on every boot.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../utils/logger.js";
import { selectLegacyRows } from "../utils/crypto-migration/raw-rows.js";
import {
createCurrentPluginRepository,
createCurrentPluginSettingsRepository,
} from "../database/repositories/factory.js";
const PLUGIN_ID = "ssh-terminal";
const APPEARANCE_KEYS = [
"theme",
"cursorBlink",
"cursorStyle",
"fontSize",
"fontFamily",
"scrollback",
"letterSpacing",
"lineHeight",
"bellStyle",
"minimumContrastRatio",
"backgroundImage",
"backgroundImageOpacity",
"customThemeColors",
];
const BEHAVIOR_KEYS = [
"rightClickSelectsWord",
"macOptionIsMeta",
"fastScrollModifier",
"fastScrollSensitivity",
"backspaceMode",
"autoMosh",
"moshCommand",
"autoTmux",
"useSSHTitle",
"syntaxHighlighting",
"syntaxHighlightingOptions",
"linkClickBehavior",
"localEcho",
"passwordPromptAutoFill",
"sudoPasswordAutoFill",
];
const HOST_KEYS = ["inheritAppearance", ...APPEARANCE_KEYS, ...BEHAVIOR_KEYS];
/** The plugin's select fields, which refuse anything outside their options. */
const ENUMS: Record<string, string[]> = {
cursorStyle: ["block", "underline", "bar"],
bellStyle: ["none", "sound", "visual", "both"],
fastScrollModifier: ["alt", "ctrl", "shift"],
backspaceMode: ["normal", "control-h"],
linkClickBehavior: ["default", "confirm", "direct"],
localEcho: ["default", "off", "auto", "on"],
};
const NUMBER_KEYS = new Set([
"fontSize",
"scrollback",
"letterSpacing",
"lineHeight",
"minimumContrastRatio",
"backgroundImageOpacity",
"fastScrollSensitivity",
]);
const LEGACY_TERMIX_THEMES = [
"Termix Dark",
"Termix Light",
"termixDark",
"termixLight",
];
function isObject(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function parseJson(value: unknown): unknown {
if (typeof value !== "string" || !value) return undefined;
try {
let parsed = JSON.parse(value);
// Some rows were stringified twice.
if (typeof parsed === "string") parsed = JSON.parse(parsed);
return parsed;
} catch {
return undefined;
}
}
/** One key's value in the type its plugin field declares, else undefined. */
export function terminalValue(key: string, value: unknown): unknown {
if (value === undefined) return undefined;
if (ENUMS[key]) {
return typeof value === "string" && ENUMS[key].includes(value)
? value
: undefined;
}
if (NUMBER_KEYS.has(key)) {
if (value === null || value === "") return undefined;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
switch (key) {
case "theme":
if (typeof value !== "string" || !value) return undefined;
return LEGACY_TERMIX_THEMES.includes(value) ? "termix" : value;
case "fontFamily":
return typeof value === "string" && value ? value : undefined;
case "backgroundImage":
case "moshCommand":
return value === null
? ""
: typeof value === "string"
? value
: undefined;
case "customThemeColors":
return isObject(value) ? value : undefined;
case "syntaxHighlightingOptions":
return isObject(value) ? value : undefined;
default:
return typeof value === "boolean" ? value : undefined;
}
}
/** The ssh-terminal host settings one 2.8 terminal_config value carried. */
export function hostSettingsFromTerminalConfig(
raw: unknown,
): Record<string, unknown> {
const config = typeof raw === "string" ? parseJson(raw) : raw;
if (!isObject(config)) return {};
const values: Record<string, unknown> = {};
for (const key of BEHAVIOR_KEYS) {
const value = terminalValue(key, config[key]);
if (value !== undefined) values[key] = value;
}
if (APPEARANCE_KEYS.some((key) => key in config)) {
values.inheritAppearance = false;
for (const key of APPEARANCE_KEYS) {
const value = terminalValue(key, config[key]);
if (value !== undefined) values[key] = value;
}
}
return values;
}
/** The ssh-terminal user settings one user_preferences row carried. */
export function userSettingsFromPreferences(row: {
terminal_defaults?: unknown;
custom_themes?: unknown;
command_autocomplete?: unknown;
}): Record<string, unknown> {
const values: Record<string, unknown> = {};
const defaults = parseJson(row.terminal_defaults);
if (isObject(defaults)) {
const picked: Record<string, unknown> = {};
for (const key of APPEARANCE_KEYS) {
const value = terminalValue(key, defaults[key]);
if (value !== undefined) picked[key] = value;
}
if (Object.keys(picked).length > 0) values.terminalDefaults = picked;
}
const themes = parseJson(row.custom_themes);
if (Array.isArray(themes) && themes.length > 0) values.customThemes = themes;
const autocomplete = row.command_autocomplete;
if (autocomplete !== null && autocomplete !== undefined) {
values.commandAutocomplete =
autocomplete === true ||
autocomplete === 1 ||
autocomplete === "1" ||
autocomplete === "true";
}
return values;
}
export interface SshTerminalLookMigrationResult {
hostsMoved: number;
hostsSkipped: number;
usersMoved: number;
}
export async function runSshTerminalLookMigration(): Promise<SshTerminalLookMigrationResult> {
const result: SshTerminalLookMigrationResult = {
hostsMoved: 0,
hostsSkipped: 0,
usersMoved: 0,
};
// plugin_settings has a foreign key to plugins: nothing to migrate into
// until the plugin row exists.
try {
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
if (!plugin) return result;
} catch {
return result;
}
const pluginSettings = createCurrentPluginSettingsRepository();
try {
const rows = await selectLegacyRows<{
id: number;
terminal_config: string | null;
}>(sql`
SELECT id, terminal_config FROM ssh_data
WHERE terminal_config IS NOT NULL
`);
const moves = rows
.map((row) => ({
scopeId: String(row.id),
values: hostSettingsFromTerminalConfig(row.terminal_config),
}))
.filter((move) => Object.keys(move.values).length > 0);
// A few reads for every candidate host rather than one per host.
const done = new Set<string | null>();
for (let i = 0; i < moves.length; i += 500) {
const existing = await pluginSettings.getAllForScopeIds(
"host",
moves.slice(i, i + 500).map((move) => move.scopeId),
);
for (const setting of existing) {
if (setting.pluginId === PLUGIN_ID && HOST_KEYS.includes(setting.key)) {
done.add(setting.scopeId);
}
}
}
for (const { scopeId, values } of moves) {
if (done.has(scopeId)) {
result.hostsSkipped++;
continue;
}
for (const [key, value] of Object.entries(values)) {
await pluginSettings.set(
PLUGIN_ID,
"host",
scopeId,
key,
JSON.stringify(value),
);
}
result.hostsMoved++;
}
} catch (error) {
databaseLogger.warn("SSH terminal host look migration failed", {
operation: "ssh_terminal_look_migration",
error: error instanceof Error ? error.message : String(error),
});
}
try {
const rows = await selectLegacyRows<{
user_id: string;
terminal_defaults: string | null;
custom_themes: string | null;
command_autocomplete: unknown;
}>(sql`
SELECT user_id, terminal_defaults, custom_themes, command_autocomplete
FROM user_preferences
`);
for (const row of rows) {
const values = userSettingsFromPreferences(row);
let moved = false;
for (const [key, value] of Object.entries(values)) {
const existing = await pluginSettings.get(
PLUGIN_ID,
"user",
row.user_id,
key,
);
if (existing && existing.value !== null) continue;
await pluginSettings.set(
PLUGIN_ID,
"user",
row.user_id,
key,
JSON.stringify(value),
);
moved = true;
}
if (moved) result.usersMoved++;
}
} catch (error) {
databaseLogger.warn("SSH terminal user settings migration failed", {
operation: "ssh_terminal_look_migration",
error: error instanceof Error ? error.message : String(error),
});
}
if (result.hostsMoved > 0 || result.usersMoved > 0) {
databaseLogger.info(
`Moved the terminal settings of ${result.hostsMoved} host(s) and ${result.usersMoved} user(s) into plugin settings`,
{ operation: "ssh_terminal_look_migration" },
);
}
return result;
}
@@ -0,0 +1,98 @@
/**
* Moves user_preferences.terminal_macros into the ssh-terminal plugin's
* macros user setting. Macros a browser kept on its own (storage mode
* "local") are moved by the plugin's frontend the first time the panel opens.
*
* The column keeps its value until 3.0.0 drops it.
*
* Idempotent: a user whose macros setting is already set is skipped.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../utils/logger.js";
import { selectLegacyRows } from "../utils/crypto-migration/raw-rows.js";
import {
createCurrentPluginRepository,
createCurrentPluginSettingsRepository,
} from "../database/repositories/factory.js";
const PLUGIN_ID = "ssh-terminal";
const MAX_MACROS = 100;
/** The saved macros in a 2.8 terminal_macros value, or null for none. */
export function macrosFromPreferences(value: unknown): unknown[] | null {
if (typeof value !== "string" || !value) return null;
let parsed: unknown;
try {
parsed = JSON.parse(value);
} catch {
return null;
}
if (!Array.isArray(parsed)) return null;
const macros = parsed
.filter(
(macro) =>
!!macro &&
typeof macro === "object" &&
typeof (macro as { id?: unknown }).id === "string" &&
typeof (macro as { name?: unknown }).name === "string" &&
Array.isArray((macro as { steps?: unknown }).steps),
)
.slice(0, MAX_MACROS);
return macros.length > 0 ? macros : null;
}
export async function runSshTerminalMacrosMigration(): Promise<{
usersMoved: number;
}> {
const result = { usersMoved: 0 };
try {
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
if (!plugin) return result;
} catch {
return result;
}
const pluginSettings = createCurrentPluginSettingsRepository();
try {
const rows = await selectLegacyRows<{
user_id: string;
terminal_macros: unknown;
}>(sql`
SELECT user_id, terminal_macros FROM user_preferences
WHERE terminal_macros IS NOT NULL
`);
for (const row of rows) {
const macros = macrosFromPreferences(row.terminal_macros);
if (!macros) continue;
const existing = await pluginSettings.get(
PLUGIN_ID,
"user",
row.user_id,
"macros",
);
if (existing && existing.value !== null) continue;
await pluginSettings.set(
PLUGIN_ID,
"user",
row.user_id,
"macros",
JSON.stringify(macros),
);
result.usersMoved++;
}
} catch (error) {
databaseLogger.warn("Terminal macros migration failed", {
operation: "ssh_terminal_macros_migration",
error: error instanceof Error ? error.message : String(error),
});
}
if (result.usersMoved > 0) {
databaseLogger.info(
`Moved the terminal macros of ${result.usersMoved} user(s) into plugin settings`,
{ operation: "ssh_terminal_macros_migration" },
);
}
return result;
}
@@ -0,0 +1,299 @@
/**
* Moves the terminal's settings out of core and into the ssh-terminal
* plugin's own settings.
*
* Admin keys from the core `settings` table: session persistence, command
* history, the terminal half of the admin host defaults (a new host's font,
* theme, cursor and auto tmux), touch input tuning and terminal image
* storage. The image storage
* local directory was never sent to a browser, so it lands as a secret field,
* which keeps it that way. The legacy rows are left in place for a release;
* nothing reads them any more.
*
* Host columns from ssh_data: enable_terminal, enable_terminal_toolbar and
* enable_command_history. All three default to on in the plugin, so only
* hosts that turned one off need a row.
*
* Idempotent: an admin key already set in the plugin, or a host that already
* has an enableTerminal row, is skipped, so this is safe on every boot.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../utils/logger.js";
import {
legacyFlag,
selectLegacyRows,
} from "../utils/crypto-migration/raw-rows.js";
import {
createCurrentPluginRepository,
createCurrentPluginSettingsRepository,
createCurrentSettingsRepository,
} from "../database/repositories/factory.js";
import { encryptSystemSecret } from "../utils/system-secret-crypto.js";
const PLUGIN_ID = "ssh-terminal";
type Parse = (raw: string) => unknown;
const asNumber: Parse = (raw) => {
const value = Number(raw);
return Number.isFinite(value) ? value : undefined;
};
const asBoolean: Parse = (raw) =>
raw === "true" || raw === "1"
? true
: raw === "false" || raw === "0"
? false
: undefined;
const asString: Parse = (raw) => raw;
const asJson: Parse = (raw) => {
try {
return JSON.parse(raw);
} catch {
return undefined;
}
};
const CURSOR_STYLES = ["block", "underline", "bar"];
/** 2.8 spellings of the theme that follows the app. */
const LEGACY_TERMIX_THEMES = [
"Termix Dark",
"Termix Light",
"termixDark",
"termixLight",
];
/** One terminal key of the admin host defaults, or undefined when unusable. */
function newHostDefault(key: string, defaults: unknown): unknown {
if (!defaults || typeof defaults !== "object") return undefined;
const value = (defaults as Record<string, unknown>)[key];
switch (key) {
case "fontSize": {
const size = Number(value);
return Number.isFinite(size) && size >= 8 && size <= 36
? size
: undefined;
}
case "fontFamily":
return typeof value === "string" && value ? value : undefined;
case "theme":
if (typeof value !== "string" || !value) return undefined;
return LEGACY_TERMIX_THEMES.includes(value) ? "termix" : value;
case "cursorStyle":
return typeof value === "string" && CURSOR_STYLES.includes(value)
? value
: undefined;
default:
return typeof value === "boolean" ? value : undefined;
}
}
/** Legacy core key -> the admin field the plugin declares. */
const ADMIN_MOVES: {
legacyKey: string;
field: string;
parse: Parse;
secret?: boolean;
}[] = [
{
legacyKey: "terminal_session_timeout_minutes",
field: "sessionTimeoutMinutes",
parse: asNumber,
},
{
legacyKey: "terminal_session_persistence_enabled",
field: "sessionPersistence",
parse: asBoolean,
},
{
legacyKey: "command_history_enabled",
field: "commandHistoryEnabled",
parse: asBoolean,
},
{
// The admin host defaults carried the new-host command history switch.
legacyKey: "host_defaults",
field: "commandHistoryForNewHosts",
parse: (raw) => {
const value = (asJson(raw) as { enableCommandHistory?: unknown } | null)
?.enableCommandHistory;
return typeof value === "boolean" ? value : undefined;
},
},
...(
[
["fontSize", "newHostFontSize"],
["fontFamily", "newHostFontFamily"],
["theme", "newHostTheme"],
["cursorStyle", "newHostCursorStyle"],
["cursorBlink", "newHostCursorBlink"],
["autoTmux", "newHostAutoTmux"],
] as const
).map(([legacyField, field]) => ({
// The terminal half of the admin host defaults, for new hosts.
legacyKey: "host_defaults",
field,
parse: (raw: string) => newHostDefault(legacyField, asJson(raw)),
})),
{ legacyKey: "touch_input_settings", field: "touchInput", parse: asJson },
{
legacyKey: "terminal_image_storage_mode",
field: "imageStorageMode",
parse: asString,
},
{
legacyKey: "terminal_image_local_dir",
field: "imageLocalDir",
parse: asString,
secret: true,
},
{
legacyKey: "terminal_image_host_path",
field: "imageHostPath",
parse: asString,
},
{ legacyKey: "terminal_image_ttl_ms", field: "imageTtlMs", parse: asNumber },
{
legacyKey: "terminal_image_max_count",
field: "imageMaxCount",
parse: asNumber,
},
{
legacyKey: "terminal_image_max_storage_bytes",
field: "imageMaxBytes",
parse: asNumber,
},
];
interface LegacyTerminalHostRow {
id: number;
enable_terminal: number | boolean | null;
enable_terminal_toolbar: number | boolean | null;
enable_command_history: number | boolean | null;
}
/** A column that was never set keeps the old default, on. */
function isOn(value: number | boolean | null): boolean {
return legacyFlag(value, true);
}
export interface SshTerminalSettingsMigrationResult {
moved: string[];
skipped: string[];
hostsMoved: number;
hostsSkipped: number;
}
export async function runSshTerminalSettingsMigration(): Promise<SshTerminalSettingsMigrationResult> {
const result: SshTerminalSettingsMigrationResult = {
moved: [],
skipped: [],
hostsMoved: 0,
hostsSkipped: 0,
};
try {
// plugin_settings has a foreign key to plugins: nothing to migrate into
// until the plugin row exists.
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
if (!plugin) return result;
const settingsRepository = createCurrentSettingsRepository();
const pluginSettingsRepository = createCurrentPluginSettingsRepository();
for (const move of ADMIN_MOVES) {
const legacyValue = await settingsRepository.get(move.legacyKey);
if (legacyValue === null || legacyValue === "") continue;
const existing = await pluginSettingsRepository.get(
PLUGIN_ID,
"admin",
null,
move.field,
);
if (existing && existing.value !== null) {
result.skipped.push(move.legacyKey);
continue;
}
const parsed = move.parse(legacyValue);
if (parsed === undefined) {
result.skipped.push(move.legacyKey);
continue;
}
const stored = move.secret
? await encryptSystemSecret(String(parsed))
: parsed;
await pluginSettingsRepository.set(
PLUGIN_ID,
"admin",
null,
move.field,
JSON.stringify(stored),
!!move.secret,
);
result.moved.push(move.legacyKey);
}
// Raw SQL, not the typed schema, so this keeps working once schema.ts
// stops declaring these columns.
const rows = await selectLegacyRows<LegacyTerminalHostRow>(sql`
SELECT id, enable_terminal, enable_terminal_toolbar, enable_command_history
FROM ssh_data
WHERE enable_terminal = false
OR enable_terminal_toolbar = false
OR enable_command_history = false
`);
for (const row of rows) {
const scopeId = String(row.id);
const existing = await pluginSettingsRepository.get(
PLUGIN_ID,
"host",
scopeId,
"enableTerminal",
);
if (existing && existing.value !== null) {
result.hostsSkipped++;
continue;
}
const values: Record<string, boolean> = {
enableTerminal: isOn(row.enable_terminal),
enableTerminalToolbar: isOn(row.enable_terminal_toolbar),
enableCommandHistory: isOn(row.enable_command_history),
};
for (const [key, value] of Object.entries(values)) {
await pluginSettingsRepository.set(
PLUGIN_ID,
"host",
scopeId,
key,
JSON.stringify(value),
);
}
result.hostsMoved++;
}
if (result.moved.length > 0 || result.hostsMoved > 0) {
databaseLogger.info(
`Moved ${result.moved.length} terminal setting(s) and ${result.hostsMoved} host(s) into plugin settings`,
{
operation: "ssh_terminal_settings_migration",
moved: result.moved.join(", "),
hostsMoved: result.hostsMoved,
},
);
}
} catch (error) {
// A failed migration must not stop the backend. The terminal falls back
// to its defaults, which is a visible, recoverable state.
databaseLogger.warn("SSH terminal settings migration failed", {
operation: "ssh_terminal_settings_migration",
error: error instanceof Error ? error.message : String(error),
});
}
return result;
}
@@ -0,0 +1,59 @@
/**
* Moves the silent sign-in default out of core settings and into the sso
* plugin's admin settings. Core used to own the toggle and read it on the
* login page; the sso plugin now marks its own login instance to start on
* its own.
*
* Idempotent: a value already in plugin settings wins and the core copy is
* dropped.
*/
import { databaseLogger } from "../utils/logger.js";
import {
createCurrentPluginRepository,
createCurrentPluginSettingsRepository,
createCurrentSettingsRepository,
} from "../database/repositories/factory.js";
const PLUGIN_ID = "sso";
const LEGACY_KEY = "oidc_silent_login_default";
const FIELD = "silentLoginDefault";
export interface SsoSettingsMigrationResult {
moved: boolean;
}
export async function runSsoSettingsMigration(): Promise<SsoSettingsMigrationResult> {
try {
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
if (!plugin) return { moved: false };
const settings = createCurrentSettingsRepository();
const legacy = await settings.get(LEGACY_KEY);
if (legacy === null || legacy === "") return { moved: false };
const pluginSettings = createCurrentPluginSettingsRepository();
const existing = await pluginSettings.get(PLUGIN_ID, "admin", null, FIELD);
if (!existing || existing.value === null) {
await pluginSettings.set(
PLUGIN_ID,
"admin",
null,
FIELD,
JSON.stringify(legacy === "true"),
false,
);
}
await settings.delete(LEGACY_KEY);
databaseLogger.info("Moved the silent sign-in default into sso settings", {
operation: "sso_settings_migration",
});
return { moved: true };
} catch (error) {
databaseLogger.warn("SSO settings migration failed", {
operation: "sso_settings_migration",
error: error instanceof Error ? error.message : String(error),
});
return { moved: false };
}
}
@@ -0,0 +1,110 @@
/**
* Moves the 2.8 Step CA configuration into the step-ca plugin.
*
* The CA URL, root fingerprint, OIDC provisioner and private endpoint
* allowlist lived in core `settings` rows. They become the plugin's admin
* settings. An install that had a CA configured keeps sending the pre-2.9
* redirect URI (/host/step-ca-callback), because that is the one registered
* with its identity provider: the plugin's legacyCallback setting is turned
* on.
*
* Idempotent: a value already present in plugin_settings is never
* overwritten. The old rows stay for one release so a downgrade still works.
*/
import { databaseLogger } from "../utils/logger.js";
import {
createCurrentPluginRepository,
createCurrentPluginSettingsRepository,
createCurrentSettingsRepository,
} from "../database/repositories/factory.js";
import { parseNotificationAllowlist } from "../utils/notification-egress.js";
const PLUGIN_ID = "step-ca";
const MOVES: { legacyKey: string; field: string }[] = [
{ legacyKey: "step_ca_url", field: "caUrl" },
{ legacyKey: "step_ca_fingerprint", field: "fingerprint" },
{ legacyKey: "step_ca_provisioner", field: "provisioner" },
];
const LEGACY_ALLOWLIST_KEY = "step_ca_private_endpoint_allowlist";
export interface StepCaSettingsMigrationResult {
moved: string[];
legacyCallback: boolean;
}
export async function runStepCaSettingsMigration(): Promise<StepCaSettingsMigrationResult> {
const result: StepCaSettingsMigrationResult = {
moved: [],
legacyCallback: false,
};
try {
// plugin_settings has a foreign key to plugins: nothing to move into yet.
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
if (!plugin) return result;
const settings = createCurrentSettingsRepository();
const pluginSettings = createCurrentPluginSettingsRepository();
const writeIfUnset = async (field: string, value: unknown) => {
const existing = await pluginSettings.get(
PLUGIN_ID,
"admin",
null,
field,
);
if (existing && existing.value !== null) return false;
await pluginSettings.set(
PLUGIN_ID,
"admin",
null,
field,
JSON.stringify(value),
);
return true;
};
for (const move of MOVES) {
const value = (await settings.get(move.legacyKey))?.trim();
if (!value) continue;
if (await writeIfUnset(move.field, value)) {
result.moved.push(move.legacyKey);
}
}
const hosts = parseNotificationAllowlist(
await settings.get(LEGACY_ALLOWLIST_KEY),
);
if (
hosts.length > 0 &&
(await writeIfUnset("privateEndpoints", hosts.join(", ")))
) {
result.moved.push(LEGACY_ALLOWLIST_KEY);
}
if ((await settings.get("step_ca_url"))?.trim()) {
result.legacyCallback = await writeIfUnset("legacyCallback", true);
}
if (result.moved.length > 0) {
databaseLogger.info(
`Moved ${result.moved.length} Step CA setting(s) into plugin settings`,
{
operation: "step_ca_settings_migration",
moved: result.moved.join(", "),
},
);
}
} catch (error) {
// Must not stop the backend: the plugin then reports "not configured".
databaseLogger.warn("Step CA settings migration failed", {
operation: "step_ca_settings_migration",
error: error instanceof Error ? error.message : String(error),
});
}
return result;
}
@@ -0,0 +1,105 @@
/**
* Moves the Tailscale API key and base URL out of core settings.
*
* Both lived in the core `settings` table, written by a core route that existed
* only to serve one plugin. A6 gives plugins their own settings, so they belong
* in plugin_settings under the tailscale plugin.
*
* The key was stored in plaintext, and the route that read it back masked only
* its tail while being open to any authenticated user. Landing it as a secret
* field encrypts it at rest, so this migration is a fix as much as a move.
*
* Idempotent in two ways: it stops when a value is already present in the new
* home, and encryptSystemSecret returns an already-encrypted value unchanged,
* so a second pass can neither duplicate nor double-encrypt.
*/
import { databaseLogger } from "../utils/logger.js";
import {
createCurrentPluginRepository,
createCurrentPluginSettingsRepository,
createCurrentSettingsRepository,
} from "../database/repositories/factory.js";
import { encryptSystemSecret } from "../utils/system-secret-crypto.js";
const PLUGIN_ID = "tailscale";
/** Legacy core key -> the field the plugin now declares. */
const MOVES: { legacyKey: string; field: string; secret: boolean }[] = [
{ legacyKey: "tailscale_api_key", field: "apiKey", secret: true },
{ legacyKey: "tailscale_api_base_url", field: "apiBaseUrl", secret: false },
];
export interface TailscaleSettingsMigrationResult {
moved: string[];
skipped: string[];
}
export async function runTailscaleSettingsMigration(): Promise<TailscaleSettingsMigrationResult> {
const moved: string[] = [];
const skipped: string[] = [];
try {
// The FK to plugins means there is nothing to migrate into until the
// plugin has been seeded. Not an error: a build without it just has no
// tailscale rows.
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
if (!plugin) return { moved, skipped };
const settingsRepository = createCurrentSettingsRepository();
const pluginSettingsRepository = createCurrentPluginSettingsRepository();
for (const move of MOVES) {
const legacyValue = await settingsRepository.get(move.legacyKey);
if (legacyValue === null || legacyValue === "") {
continue;
}
const existing = await pluginSettingsRepository.get(
PLUGIN_ID,
"admin",
null,
move.field,
);
if (existing && existing.value !== null) {
// Already moved. Drop the stale copy rather than leaving a plaintext
// key behind for a reader that predates this change.
await settingsRepository.delete(move.legacyKey);
skipped.push(move.legacyKey);
continue;
}
const stored = move.secret
? await encryptSystemSecret(legacyValue)
: legacyValue;
await pluginSettingsRepository.set(
PLUGIN_ID,
"admin",
null,
move.field,
JSON.stringify(stored),
move.secret,
);
await settingsRepository.delete(move.legacyKey);
moved.push(move.legacyKey);
}
if (moved.length > 0) {
databaseLogger.info(
`Moved ${moved.length} Tailscale setting(s) into plugin settings`,
{ operation: "tailscale_settings_migration", moved: moved.join(", ") },
);
}
} catch (error) {
// A failed migration must not stop the backend. The plugin falls back to
// "no key configured", which is a visible, recoverable state.
databaseLogger.warn("Tailscale settings migration failed", {
operation: "tailscale_settings_migration",
error: error instanceof Error ? error.message : String(error),
});
}
return { moved, skipped };
}
@@ -0,0 +1,74 @@
/**
* Moves the 2.8 telemetry settings into the telemetry plugin: the admin
* switch (settings.analytics_enabled) into its `enabled` admin setting, and
* the instance id (settings.analytics_instance_id) into `instanceId`, so the
* instance keeps reporting under the same id.
*
* Idempotent: nothing is written where the plugin already has a row. The
* legacy settings rows stay in place until 3.0.0. The plugin waits a few
* minutes before its first send, so this lands first.
*/
import { databaseLogger } from "../utils/logger.js";
import {
createCurrentPluginRepository,
createCurrentPluginSettingsRepository,
createCurrentSettingsRepository,
} from "../database/repositories/factory.js";
const PLUGIN_ID = "telemetry";
export interface TelemetrySettingsMigrationResult {
movedEnabled: boolean;
movedInstanceId: boolean;
}
export async function runTelemetrySettingsMigration(): Promise<TelemetrySettingsMigrationResult> {
const result: TelemetrySettingsMigrationResult = {
movedEnabled: false,
movedInstanceId: false,
};
// plugin_settings has a foreign key to plugins.
try {
if (!(await createCurrentPluginRepository().findById(PLUGIN_ID))) {
return result;
}
} catch {
return result;
}
const coreSettings = createCurrentSettingsRepository();
const pluginSettings = createCurrentPluginSettingsRepository();
const moves: Array<[string, string, unknown]> = [];
if ((await coreSettings.get("analytics_enabled")) === "false") {
moves.push(["analytics_enabled", "enabled", false]);
}
const instanceId = await coreSettings.get("analytics_instance_id");
if (instanceId) {
moves.push(["analytics_instance_id", "instanceId", instanceId]);
}
for (const [, key, value] of moves) {
const existing = await pluginSettings.get(PLUGIN_ID, "admin", null, key);
if (existing && existing.value !== null) continue;
await pluginSettings.set(
PLUGIN_ID,
"admin",
null,
key,
JSON.stringify(value),
);
if (key === "enabled") result.movedEnabled = true;
else result.movedInstanceId = true;
}
if (result.movedEnabled || result.movedInstanceId) {
databaseLogger.info("Moved telemetry settings into plugin settings", {
operation: "telemetry_settings_migration",
...result,
});
}
return result;
}
@@ -0,0 +1,80 @@
/**
* Reseals 2.8 Termix ID certificate authority keys for the termix-identity
* plugin.
*
* Core encrypted termix_identity_ca.private_key with the owner's data key.
* The plugin adopts that table as p_termix_identity_ca and reads the key with
* ctx.secrets.unseal, which only opens installation-key encryption. Each row
* still under a data key is decrypted and written back with
* encryptSystemSecret, what ctx.secrets.seal writes.
*
* Does nothing until the plugin's table exists. A row whose owner's data key
* cannot be opened is skipped and tried again next time. Idempotent.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../utils/logger.js";
import { DataCrypto } from "../utils/data-crypto.js";
import { LazyFieldEncryption } from "../utils/lazy-field-encryption.js";
import {
encryptSystemSecret,
isSystemEncrypted,
} from "../utils/system-secret-crypto.js";
import {
runStatement,
selectRows,
} from "../utils/crypto-migration/raw-rows.js";
const CA_TABLE = "p_termix_identity_ca";
interface CaRow {
id: number;
user_id: string;
private_key: string | null;
}
export async function runTermixIdentityCaMigration(): Promise<number> {
let rows: CaRow[];
try {
rows = await selectRows<CaRow>(
sql`SELECT id, user_id, private_key FROM ${sql.identifier(CA_TABLE)}`,
);
} catch {
// The plugin has not adopted the table yet.
return 0;
}
let resealed = 0;
for (const row of rows) {
if (!row.private_key || isSystemEncrypted(row.private_key)) continue;
try {
const dek = DataCrypto.getUserDataKey(row.user_id);
if (!dek) continue;
const plain = LazyFieldEncryption.safeGetFieldValue(
row.private_key,
dek,
String(row.id),
"privateKey",
);
if (!plain) continue;
await runStatement(
sql`UPDATE ${sql.identifier(CA_TABLE)} SET private_key = ${await encryptSystemSecret(plain)} WHERE id = ${row.id}`,
);
resealed++;
} catch (error) {
databaseLogger.warn("Termix ID CA reseal failed for a row", {
operation: "termix_identity_ca_migration",
caId: row.id,
error: error instanceof Error ? error.message : String(error),
});
}
}
if (resealed > 0) {
databaseLogger.info("Resealed Termix ID CA keys for the plugin", {
operation: "termix_identity_ca_migration",
resealed,
});
}
return resealed;
}
@@ -0,0 +1,83 @@
/**
* Moves the tmux monitor host column into the tmux-monitor plugin's host-scope
* settings, before the column is dropped from ssh_data.
*
* Idempotent: skips a host that already has a tmux-monitor settings row, so it
* is safe to run on every boot. Lossless: reads straight off the still-present
* ssh_data column, so it must run and finish before that column is ever
* dropped from schema.ts.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../utils/logger.js";
import { selectLegacyRows } from "../utils/crypto-migration/raw-rows.js";
import { createCurrentPluginSettingsRepository } from "../database/repositories/factory.js";
export interface TmuxMonitorSettingsMigrationResult {
moved: number;
skipped: number;
}
interface LegacyTmuxMonitorRow {
id: number;
enable_tmux_monitor: number | boolean | null;
}
export async function runTmuxMonitorSettingsMigration(): Promise<TmuxMonitorSettingsMigrationResult> {
const result: TmuxMonitorSettingsMigrationResult = { moved: 0, skipped: 0 };
try {
// Raw SQL, not the typed schema, so this keeps working once schema.ts
// stops declaring this column. The plugin's enable switch defaults to
// off, matching the old column's default, so only hosts that turned it
// on need a row.
const rows = await selectLegacyRows<LegacyTmuxMonitorRow>(sql`
SELECT id, enable_tmux_monitor
FROM ssh_data
WHERE enable_tmux_monitor = true
`);
if (rows.length === 0) return result;
const pluginSettingsRepository = createCurrentPluginSettingsRepository();
for (const row of rows) {
const scopeId = String(row.id);
const existing = await pluginSettingsRepository.get(
"tmux-monitor",
"host",
scopeId,
"enableTmuxMonitor",
);
if (existing && existing.value !== null) {
result.skipped++;
continue;
}
await pluginSettingsRepository.set(
"tmux-monitor",
"host",
scopeId,
"enableTmuxMonitor",
JSON.stringify(!!row.enable_tmux_monitor),
);
result.moved++;
}
if (result.moved > 0) {
databaseLogger.info(
`Moved tmux monitor settings for ${result.moved} host(s) into plugin settings`,
{ operation: "tmux_monitor_settings_migration", moved: result.moved },
);
}
} catch (error) {
// A failed migration must not stop the backend. Hosts simply keep the
// monitor off, the plugin's default.
databaseLogger.warn("Tmux monitor settings migration failed", {
operation: "tmux_monitor_settings_migration",
error: error instanceof Error ? error.message : String(error),
});
}
return result;
}
+179
View File
@@ -0,0 +1,179 @@
/**
* Moves 2.8 TOTP enrolments into the totp plugin.
*
* 1. Every user_second_factors row A8 wrote for core's TOTP ("core"/"totp")
* becomes the plugin's ("totp"/"totp"), and every user with
* users.totp_enabled gets one. This runs whether or not the plugin is
* enabled, so those users stay behind a second factor: with the plugin off
* the login fails closed instead of skipping it.
* 2. Once the plugin's p_totp_enrollments table exists, each enabled user's
* secret and backup codes are decrypted with their data key and written
* there sealed with the installation key (what ctx.secrets.seal writes),
* then the users columns are cleared.
*
* The columns stay in the database until 3.0.0, since core's drizzle
* migrations run before this does. A user whose data key cannot be opened
* at boot is skipped and tried again on the next one. Idempotent.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../utils/logger.js";
import { DataCrypto } from "../utils/data-crypto.js";
import { LazyFieldEncryption } from "../utils/lazy-field-encryption.js";
import { encryptSystemSecret } from "../utils/system-secret-crypto.js";
import {
runStatement,
selectRows,
} from "../utils/crypto-migration/raw-rows.js";
const PLUGIN_ID = "totp";
const FACTOR_ID = "totp";
const ENROLLMENTS_TABLE = "p_totp_enrollments";
export interface TotpMigrationResult {
/** user_second_factors rows written or moved to the plugin. */
factors: number;
/** Secrets moved into the plugin's table. */
secrets: number;
}
interface LegacyTotpUser {
id: string;
totp_enabled: unknown;
totp_secret: string | null;
totp_backup_codes: string | null;
}
async function tryRows<T>(query: ReturnType<typeof sql>): Promise<T[] | null> {
try {
return await selectRows<T>(query);
} catch {
// A fresh install never had the column or the table.
return null;
}
}
function isOn(value: unknown): boolean {
return value === true || value === 1 || value === "1" || value === "t";
}
function readBackupCodes(
stored: string | null,
dek: Buffer,
userId: string,
): string[] {
if (!stored) return [];
const raw = LazyFieldEncryption.safeGetFieldValue(
stored,
dek,
userId,
"totpBackupCodes",
);
try {
const codes = raw ? JSON.parse(raw) : [];
return Array.isArray(codes)
? codes.filter((code): code is string => typeof code === "string")
: [];
} catch {
return [];
}
}
async function factorUsers(pluginId: string): Promise<Set<string>> {
const rows = await selectRows<{ user_id: string }>(
sql`SELECT user_id FROM user_second_factors WHERE plugin_id = ${pluginId} AND factor_id = ${FACTOR_ID}`,
);
return new Set(rows.map((row) => row.user_id));
}
/**
* Pass a user id to move only that user, as a password login does once it
* has opened a data key the boot run could not.
*/
export async function runTotpMigration(
onlyUserId?: string,
): Promise<TotpMigrationResult> {
const result: TotpMigrationResult = { factors: 0, secrets: 0 };
try {
const enrolled = await factorUsers(PLUGIN_ID);
for (const userId of await factorUsers("core")) {
if (enrolled.has(userId)) {
await runStatement(
sql`DELETE FROM user_second_factors WHERE user_id = ${userId} AND plugin_id = 'core' AND factor_id = ${FACTOR_ID}`,
);
} else {
await runStatement(
sql`UPDATE user_second_factors SET plugin_id = ${PLUGIN_ID} WHERE user_id = ${userId} AND plugin_id = 'core' AND factor_id = ${FACTOR_ID}`,
);
enrolled.add(userId);
result.factors++;
}
}
const users = (
(await tryRows<LegacyTotpUser>(
onlyUserId
? sql`SELECT id, totp_enabled, totp_secret, totp_backup_codes FROM users WHERE id = ${onlyUserId}`
: sql`SELECT id, totp_enabled, totp_secret, totp_backup_codes FROM users`,
)) ?? []
).filter((user) => isOn(user.totp_enabled));
for (const user of users) {
if (enrolled.has(user.id)) continue;
await runStatement(
sql`INSERT INTO user_second_factors (user_id, plugin_id, factor_id) VALUES (${user.id}, ${PLUGIN_ID}, ${FACTOR_ID})`,
);
enrolled.add(user.id);
result.factors++;
}
const moved = await tryRows<{ user_id: string }>(
sql`SELECT user_id FROM ${sql.identifier(ENROLLMENTS_TABLE)}`,
);
if (moved) {
const done = new Set(moved.map((row) => row.user_id));
for (const user of users) {
if (done.has(user.id) || !user.totp_secret) continue;
try {
const dek = DataCrypto.getUserDataKey(user.id);
if (!dek) continue;
const secret = LazyFieldEncryption.safeGetFieldValue(
user.totp_secret,
dek,
user.id,
"totpSecret",
);
if (!secret) continue;
const codes = readBackupCodes(user.totp_backup_codes, dek, user.id);
await runStatement(
sql`INSERT INTO ${sql.identifier(ENROLLMENTS_TABLE)} (user_id, secret, backup_codes) VALUES (${user.id}, ${await encryptSystemSecret(secret)}, ${await encryptSystemSecret(JSON.stringify(codes))})`,
);
await runStatement(
sql`UPDATE users SET totp_enabled = FALSE, totp_secret = NULL, totp_backup_codes = NULL WHERE id = ${user.id}`,
);
result.secrets++;
} catch (error) {
databaseLogger.warn("TOTP migration failed for a user", {
operation: "totp_migration",
userId: user.id,
error: error instanceof Error ? error.message : String(error),
});
}
}
}
} catch (error) {
databaseLogger.error("TOTP migration failed", error, {
operation: "totp_migration",
});
}
if (result.factors > 0 || result.secrets > 0) {
databaseLogger.info("Moved TOTP enrolment into the totp plugin", {
operation: "totp_migration",
...result,
});
}
return result;
}
@@ -0,0 +1,101 @@
/**
* Moves the two tunnel host columns into the tunnels plugin's host-scope
* settings, before those columns are dropped from ssh_data.
*
* Idempotent: skips a host that already has a tunnels settings row, so it is
* safe to run on every boot. Lossless: reads straight off the still-present
* ssh_data columns, so it must run and finish before those columns are ever
* dropped from schema.ts.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../utils/logger.js";
import { selectLegacyRows } from "../utils/crypto-migration/raw-rows.js";
import { createCurrentPluginSettingsRepository } from "../database/repositories/factory.js";
export interface TunnelsSettingsMigrationResult {
moved: number;
skipped: number;
}
interface LegacyTunnelRow {
id: number;
enable_tunnel: number | boolean | null;
tunnel_connections: string | null;
}
function parseConnections(raw: string | null): unknown[] {
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
export async function runTunnelsSettingsMigration(): Promise<TunnelsSettingsMigrationResult> {
const result: TunnelsSettingsMigrationResult = { moved: 0, skipped: 0 };
try {
// Raw SQL, not the typed schema, so this keeps working once schema.ts
// stops declaring these columns. The plugin's enable switch defaults to
// off, so every host that had tunnels on (the old column default) or
// any saved tunnel has to be copied, not just the non-default ones.
const rows = await selectLegacyRows<LegacyTunnelRow>(sql`
SELECT id, enable_tunnel, tunnel_connections
FROM ssh_data
WHERE enable_tunnel = true OR tunnel_connections IS NOT NULL
`);
if (rows.length === 0) return result;
const pluginSettingsRepository = createCurrentPluginSettingsRepository();
for (const row of rows) {
const scopeId = String(row.id);
const existing = await pluginSettingsRepository.get(
"tunnels",
"host",
scopeId,
"enableTunnel",
);
if (existing && existing.value !== null) {
result.skipped++;
continue;
}
await pluginSettingsRepository.set(
"tunnels",
"host",
scopeId,
"enableTunnel",
JSON.stringify(!!row.enable_tunnel),
);
await pluginSettingsRepository.set(
"tunnels",
"host",
scopeId,
"tunnelConnections",
JSON.stringify(parseConnections(row.tunnel_connections)),
);
result.moved++;
}
if (result.moved > 0) {
databaseLogger.info(
`Moved tunnel settings for ${result.moved} host(s) into plugin settings`,
{ operation: "tunnels_settings_migration", moved: result.moved },
);
}
} catch (error) {
// A failed migration must not stop the backend. Hosts simply keep
// whatever tunnel settings they already had (none, on a fresh run).
databaseLogger.warn("Tunnel settings migration failed", {
operation: "tunnels_settings_migration",
error: error instanceof Error ? error.message : String(error),
});
}
return result;
}
@@ -0,0 +1,149 @@
/**
* Moves each host's Vault signer profile out of ssh_data and into the vault
* plugin's profileId host setting.
*
* An install that used Vault keeps sending the pre-2.9 redirect URI
* (/vault/oidc/callback), because that is the one its Vault OIDC roles
* allow: the plugin's legacyCallback setting is turned on.
*
* The column stays in the database, unused: core's drizzle migrations run
* before this does, so dropping it there would lose the data first.
*
* Idempotent: a host or setting that already has a value is skipped, so this
* is safe on every boot.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../utils/logger.js";
import {
createCurrentPluginRepository,
createCurrentPluginSettingsRepository,
} from "../database/repositories/factory.js";
import { selectRows } from "../utils/crypto-migration/raw-rows.js";
const PLUGIN_ID = "vault";
const SETTING_KEY = "profileId";
interface LegacyHostRow {
id: number;
vault_profile_id: number | string | null;
}
export interface VaultSettingsMigrationResult {
hostsMoved: number;
hostsSkipped: number;
legacyCallback: boolean;
}
async function legacyRows(): Promise<LegacyHostRow[]> {
try {
// Raw SQL: schema.ts no longer declares vault_profile_id.
return await selectRows<LegacyHostRow>(
sql`SELECT id, vault_profile_id FROM ssh_data WHERE vault_profile_id IS NOT NULL`,
);
} catch {
// A fresh install never had the column.
return [];
}
}
/** Whether 2.8 had any Vault profile, before or after the plugin adopted them. */
async function hadProfiles(): Promise<boolean> {
for (const table of ["vault_profiles", "p_vault_profiles"]) {
try {
const rows = await selectRows<unknown>(
sql`SELECT 1 FROM ${sql.identifier(table)} LIMIT 1`,
);
if (rows.length > 0) return true;
} catch {
// table not there
}
}
return false;
}
export async function runVaultSettingsMigration(): Promise<VaultSettingsMigrationResult> {
const result: VaultSettingsMigrationResult = {
hostsMoved: 0,
hostsSkipped: 0,
legacyCallback: false,
};
// plugin_settings has a foreign key to plugins: nothing to migrate into
// until the plugin row exists.
try {
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
if (!plugin) return result;
} catch {
return result;
}
const pluginSettings = createCurrentPluginSettingsRepository();
const rows = await legacyRows();
for (const row of rows) {
const profileId = Number(row.vault_profile_id);
if (!Number.isInteger(profileId) || profileId <= 0) continue;
const scopeId = String(row.id);
try {
const existing = await pluginSettings.get(
PLUGIN_ID,
"host",
scopeId,
SETTING_KEY,
);
if (existing) {
result.hostsSkipped++;
continue;
}
await pluginSettings.set(
PLUGIN_ID,
"host",
scopeId,
SETTING_KEY,
JSON.stringify(profileId),
);
result.hostsMoved++;
} catch (error) {
databaseLogger.warn("Vault host settings migration failed for a host", {
operation: "vault_settings_migration",
hostId: row.id,
error: error instanceof Error ? error.message : String(error),
});
}
}
// Decided once, on the first boot with the plugin: written either way, so
// profiles created on a fresh install later never turn it on.
try {
const existing = await pluginSettings.get(
PLUGIN_ID,
"admin",
null,
"legacyCallback",
);
if (!existing || existing.value === null) {
result.legacyCallback = rows.length > 0 || (await hadProfiles());
await pluginSettings.set(
PLUGIN_ID,
"admin",
null,
"legacyCallback",
JSON.stringify(result.legacyCallback),
);
}
} catch (error) {
databaseLogger.warn("Vault legacy callback migration failed", {
operation: "vault_settings_migration",
error: error instanceof Error ? error.message : String(error),
});
}
if (result.hostsMoved > 0) {
databaseLogger.info(
`Moved the Vault profile for ${result.hostsMoved} host(s) into plugin settings`,
{ operation: "vault_settings_migration" },
);
}
return result;
}
@@ -0,0 +1,113 @@
/**
* Moves each host's Wake-on-LAN settings out of ssh_data and into the
* wake-on-lan plugin's host settings: mac_address becomes macAddress and
* wol_broadcast_address becomes broadcastAddress.
*
* The columns stay in the database, unused: core's drizzle migrations run
* before this does, so dropping them there would lose the data first.
*
* Idempotent: a host that already has any wake-on-lan row is skipped, so
* this is safe on every boot.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../utils/logger.js";
import {
createCurrentPluginRepository,
createCurrentPluginSettingsRepository,
} from "../database/repositories/factory.js";
import { selectRows } from "../utils/crypto-migration/raw-rows.js";
const PLUGIN_ID = "wake-on-lan";
interface LegacyHostRow {
id: number;
mac_address: string | null;
wol_broadcast_address: string | null;
}
/** The plugin's host settings for one legacy row, defaults left out. */
export function wakeOnLanSettingsFromRow(
row: LegacyHostRow,
): Record<string, unknown> {
const values: Record<string, unknown> = {};
if (row.mac_address) values.macAddress = row.mac_address;
if (row.wol_broadcast_address) {
values.broadcastAddress = row.wol_broadcast_address;
}
return values;
}
export interface WakeOnLanSettingsMigrationResult {
hostsMoved: number;
hostsSkipped: number;
}
export async function runWakeOnLanSettingsMigration(): Promise<WakeOnLanSettingsMigrationResult> {
const result: WakeOnLanSettingsMigrationResult = {
hostsMoved: 0,
hostsSkipped: 0,
};
// plugin_settings has a foreign key to plugins: nothing to migrate into
// until the plugin row exists.
try {
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
if (!plugin) return result;
} catch {
return result;
}
const pluginSettings = createCurrentPluginSettingsRepository();
let rows: LegacyHostRow[];
try {
// Raw SQL: schema.ts no longer declares these columns.
rows = await selectRows<LegacyHostRow>(
sql`SELECT id, mac_address, wol_broadcast_address FROM ssh_data`,
);
} catch {
// A fresh install never had the columns.
return result;
}
for (const row of rows) {
const values = wakeOnLanSettingsFromRow(row);
if (Object.keys(values).length === 0) continue;
const scopeId = String(row.id);
try {
const existing = await pluginSettings.getAll(PLUGIN_ID, "host", scopeId);
if (existing.length > 0) {
result.hostsSkipped++;
continue;
}
for (const [key, value] of Object.entries(values)) {
await pluginSettings.set(
PLUGIN_ID,
"host",
scopeId,
key,
JSON.stringify(value),
);
}
result.hostsMoved++;
} catch (error) {
databaseLogger.warn(
"Wake-on-LAN host settings migration failed for a host",
{
operation: "wake_on_lan_settings_migration",
hostId: row.id,
error: error instanceof Error ? error.message : String(error),
},
);
}
}
if (result.hostsMoved > 0) {
databaseLogger.info(
`Moved Wake-on-LAN settings for ${result.hostsMoved} host(s) into plugin settings`,
{ operation: "wake_on_lan_settings_migration" },
);
}
return result;
}
@@ -0,0 +1,134 @@
/**
* Moves each host's Warpgate flag out of ssh_data and into the warpgate
* plugin's useWarpgate host setting. Hosts from before 2.7 that still carry
* auth_type "warpgate" get the setting too, and auth type "none", which is
* what that auth type meant.
*
* The column stays in the database, unused: core's drizzle migrations run
* before this does, so dropping it there would lose the data first.
*
* Idempotent: a host that already has the setting is skipped, so this is
* safe on every boot.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../utils/logger.js";
import {
createCurrentPluginRepository,
createCurrentPluginSettingsRepository,
} from "../database/repositories/factory.js";
import {
runStatement,
selectRows,
} from "../utils/crypto-migration/raw-rows.js";
const PLUGIN_ID = "warpgate";
const SETTING_KEY = "useWarpgate";
interface LegacyHostRow {
id: number;
use_warpgate?: number | boolean | string | null;
auth_type: string | null;
}
export function usesWarpgate(row: LegacyHostRow): boolean {
const flag = row.use_warpgate;
return (
flag === true ||
flag === 1 ||
flag === "1" ||
flag === "true" ||
row.auth_type === "warpgate"
);
}
export interface WarpgateSettingsMigrationResult {
hostsMoved: number;
hostsSkipped: number;
authTypesFixed: number;
}
async function legacyRows(): Promise<LegacyHostRow[]> {
try {
// Raw SQL: schema.ts no longer declares use_warpgate.
return await selectRows<LegacyHostRow>(
sql`SELECT id, use_warpgate, auth_type FROM ssh_data`,
);
} catch {
// A fresh install never had the column.
try {
return await selectRows<LegacyHostRow>(
sql`SELECT id, auth_type FROM ssh_data WHERE auth_type = 'warpgate'`,
);
} catch {
return [];
}
}
}
export async function runWarpgateSettingsMigration(): Promise<WarpgateSettingsMigrationResult> {
const result: WarpgateSettingsMigrationResult = {
hostsMoved: 0,
hostsSkipped: 0,
authTypesFixed: 0,
};
// plugin_settings has a foreign key to plugins: nothing to migrate into
// until the plugin row exists.
try {
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
if (!plugin) return result;
} catch {
return result;
}
const pluginSettings = createCurrentPluginSettingsRepository();
for (const row of await legacyRows()) {
if (!usesWarpgate(row)) continue;
const scopeId = String(row.id);
try {
const existing = await pluginSettings.get(
PLUGIN_ID,
"host",
scopeId,
SETTING_KEY,
);
if (existing) {
result.hostsSkipped++;
} else {
await pluginSettings.set(
PLUGIN_ID,
"host",
scopeId,
SETTING_KEY,
JSON.stringify(true),
);
result.hostsMoved++;
}
if (row.auth_type === "warpgate") {
await runStatement(
sql`UPDATE ssh_data SET auth_type = 'none' WHERE id = ${row.id}`,
);
result.authTypesFixed++;
}
} catch (error) {
databaseLogger.warn(
"Warpgate host settings migration failed for a host",
{
operation: "warpgate_settings_migration",
hostId: row.id,
error: error instanceof Error ? error.message : String(error),
},
);
}
}
if (result.hostsMoved > 0) {
databaseLogger.info(
`Moved the Warpgate flag for ${result.hostsMoved} host(s) into plugin settings`,
{ operation: "warpgate_settings_migration" },
);
}
return result;
}
@@ -0,0 +1,98 @@
/**
* Moves the two web endpoint host columns into the web-endpoint plugin's
* host-scope settings, before those columns are dropped from ssh_data.
*
* Idempotent: skips a host that already has a web-endpoint settings row, so
* it is safe to run on every boot. Lossless: reads straight off the
* still-present ssh_data columns, so it must run and finish before those
* columns are ever dropped from schema.ts.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../utils/logger.js";
import { selectLegacyRows } from "../utils/crypto-migration/raw-rows.js";
import { createCurrentPluginSettingsRepository } from "../database/repositories/factory.js";
export interface WebEndpointSettingsMigrationResult {
moved: number;
skipped: number;
}
interface LegacyWebEndpointRow {
id: number;
enable_web_ui: number | boolean | null;
web_ui_config: string | null;
}
function parseConfig(raw: string | null): unknown {
if (!raw) return null;
try {
return JSON.parse(raw);
} catch {
return null;
}
}
export async function runWebEndpointSettingsMigration(): Promise<WebEndpointSettingsMigrationResult> {
const result: WebEndpointSettingsMigrationResult = { moved: 0, skipped: 0 };
try {
// Raw SQL, not the typed schema, so this keeps working once schema.ts
// stops declaring these columns.
const rows = await selectLegacyRows<LegacyWebEndpointRow>(sql`
SELECT id, enable_web_ui, web_ui_config
FROM ssh_data
WHERE enable_web_ui = true OR web_ui_config IS NOT NULL
`);
if (rows.length === 0) return result;
const pluginSettingsRepository = createCurrentPluginSettingsRepository();
for (const row of rows) {
const scopeId = String(row.id);
const existing = await pluginSettingsRepository.get(
"web-endpoint",
"host",
scopeId,
"enableWebUi",
);
if (existing && existing.value !== null) {
result.skipped++;
continue;
}
await pluginSettingsRepository.set(
"web-endpoint",
"host",
scopeId,
"enableWebUi",
JSON.stringify(!!row.enable_web_ui),
);
await pluginSettingsRepository.set(
"web-endpoint",
"host",
scopeId,
"webUiConfig",
JSON.stringify(parseConfig(row.web_ui_config)),
);
result.moved++;
}
if (result.moved > 0) {
databaseLogger.info(
`Moved web endpoint settings for ${result.moved} host(s) into plugin settings`,
{ operation: "web_endpoint_settings_migration", moved: result.moved },
);
}
} catch (error) {
// A failed migration must not stop the backend. Hosts simply keep
// whatever web endpoint settings they already had (none, on a fresh run).
databaseLogger.warn("Web endpoint settings migration failed", {
operation: "web_endpoint_settings_migration",
error: error instanceof Error ? error.message : String(error),
});
}
return result;
}