mirror of
https://github.com/YuzuZensai/Termix.git
synced 2026-11-12 11:21:55 +00:00
* 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>
1973 lines
68 KiB
TypeScript
1973 lines
68 KiB
TypeScript
import { getErrorMessage } from "../../utils/error-message.js";
|
|
import { drizzle } from "drizzle-orm/better-sqlite3";
|
|
import Database from "better-sqlite3";
|
|
import * as schema from "./schema.js";
|
|
import fs from "fs";
|
|
import path from "path";
|
|
import { databaseLogger } from "../../utils/logger.js";
|
|
import { DatabaseFileEncryption } from "../../utils/database-file-encryption.js";
|
|
import { SystemCrypto } from "../../utils/system-crypto.js";
|
|
import { DatabaseMigration } from "../../utils/database-migration.js";
|
|
import {
|
|
ensureSharedHostAuthOverrideProtocolSchema,
|
|
migrateLegacySharedHostAuthOverrides,
|
|
} from "../../utils/shared-host-auth-override-migration.js";
|
|
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
|
|
import { migrateAuditRetention } from "../../utils/audit-retention-migration.js";
|
|
import { createPerformanceIndexes } from "./performance-indexes.js";
|
|
import {
|
|
assertDataDirIsNotMisconfigured,
|
|
DataDirMisconfiguredError,
|
|
} from "../../utils/data-dir-guard.js";
|
|
import { resolveDatabaseDialect, type DatabaseDialect } from "./dialect.js";
|
|
import { SYSTEM_ROLE_DEFAULTS } from "../../utils/permission-catalog.js";
|
|
import { connectRemoteDatabase } from "./connect.js";
|
|
import { runRemoteMigrations } from "./migrate.js";
|
|
import type { PortableDatabase } from "../repositories/database-context.js";
|
|
|
|
const dataDir = process.env.DATA_DIR || "./db/data";
|
|
const dbDir = path.resolve(dataDir);
|
|
if (!fs.existsSync(dbDir)) {
|
|
fs.mkdirSync(dbDir, { recursive: true });
|
|
}
|
|
|
|
const enableFileEncryption = process.env.DB_FILE_ENCRYPTION !== "false";
|
|
const dbPath = path.join(dataDir, "db.sqlite");
|
|
const encryptedDbPath = `${dbPath}.encrypted`;
|
|
|
|
const actualDbPath = ":memory:";
|
|
let memoryDatabase: Database.Database;
|
|
let isNewDatabase = false;
|
|
let sqlite: Database.Database;
|
|
|
|
function getRawSettingValue(key: string): string | null {
|
|
const row = sqlite
|
|
.prepare("SELECT value FROM settings WHERE key = ?")
|
|
.get(key) as { value?: string } | undefined;
|
|
|
|
return row?.value ?? null;
|
|
}
|
|
|
|
function setRawSettingValue(key: string, value: string): void {
|
|
sqlite
|
|
.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
|
|
.run(key, value);
|
|
}
|
|
|
|
function ensureRawSettingDefault(key: string, value: string): void {
|
|
if (getRawSettingValue(key) === null) {
|
|
sqlite
|
|
.prepare("INSERT INTO settings (key, value) VALUES (?, ?)")
|
|
.run(key, value);
|
|
}
|
|
}
|
|
|
|
async function initializeDatabaseAsync(): Promise<void> {
|
|
const systemCrypto = SystemCrypto.getInstance();
|
|
|
|
await systemCrypto.getDatabaseKey();
|
|
if (enableFileEncryption) {
|
|
try {
|
|
if (DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath)) {
|
|
const decryptedBuffer =
|
|
await DatabaseFileEncryption.decryptDatabaseToBuffer(encryptedDbPath);
|
|
|
|
memoryDatabase = new Database(decryptedBuffer);
|
|
|
|
try {
|
|
memoryDatabase
|
|
.prepare("SELECT COUNT(*) as count FROM sessions")
|
|
.get() as { count: number };
|
|
} catch {
|
|
// expected - sessions table may not exist yet
|
|
}
|
|
} else {
|
|
const migration = new DatabaseMigration(dataDir);
|
|
const migrationStatus = migration.checkMigrationStatus();
|
|
|
|
if (migrationStatus.needsMigration) {
|
|
const migrationResult = await migration.migrateDatabase();
|
|
|
|
if (migrationResult.success) {
|
|
migration.cleanupOldBackups();
|
|
|
|
if (
|
|
DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath)
|
|
) {
|
|
const decryptedBuffer =
|
|
await DatabaseFileEncryption.decryptDatabaseToBuffer(
|
|
encryptedDbPath,
|
|
);
|
|
memoryDatabase = new Database(decryptedBuffer);
|
|
isNewDatabase = false;
|
|
} else {
|
|
throw new Error(
|
|
"Migration completed but encrypted database file not found",
|
|
);
|
|
}
|
|
} else {
|
|
databaseLogger.error("Automatic database migration failed", null, {
|
|
operation: "auto_migration_failed",
|
|
error: migrationResult.error,
|
|
migratedTables: migrationResult.migratedTables,
|
|
migratedRows: migrationResult.migratedRows,
|
|
duration: migrationResult.duration,
|
|
backupPath: migrationResult.backupPath,
|
|
});
|
|
throw new Error(
|
|
`Database migration failed: ${migrationResult.error}. Backup available at: ${migrationResult.backupPath}`,
|
|
);
|
|
}
|
|
} else {
|
|
assertDataDirIsNotMisconfigured(dataDir);
|
|
memoryDatabase = new Database(":memory:");
|
|
isNewDatabase = true;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
// Not a decryption problem: the database is fine, we are pointed at the
|
|
// wrong directory. Surface that message as-is.
|
|
if (error instanceof DataDirMisconfiguredError) throw error;
|
|
|
|
databaseLogger.error("Failed to initialize memory database", error, {
|
|
operation: "db_memory_init_failed",
|
|
errorMessage: getErrorMessage(error),
|
|
errorStack: error instanceof Error ? error.stack : undefined,
|
|
encryptedDbExists:
|
|
DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath),
|
|
databaseKeyAvailable: !!process.env.DATABASE_KEY,
|
|
databaseKeyLength: process.env.DATABASE_KEY?.length || 0,
|
|
});
|
|
|
|
try {
|
|
const diagnosticInfo =
|
|
DatabaseFileEncryption.getDiagnosticInfo(encryptedDbPath);
|
|
databaseLogger.error(
|
|
"Database encryption diagnostic completed - check logs above for details",
|
|
null,
|
|
{
|
|
operation: "db_encryption_diagnostic_completed",
|
|
filesConsistent: diagnosticInfo.validation.filesConsistent,
|
|
sizeMismatch: diagnosticInfo.validation.sizeMismatch,
|
|
},
|
|
);
|
|
} catch (diagError) {
|
|
databaseLogger.warn("Failed to generate diagnostic information", {
|
|
operation: "db_diagnostic_failed",
|
|
error:
|
|
getErrorMessage(diagError),
|
|
});
|
|
}
|
|
|
|
throw new Error(
|
|
`Database decryption failed: ${getErrorMessage(error)}. This prevents data loss.`,
|
|
{ cause: error },
|
|
);
|
|
}
|
|
} else {
|
|
assertDataDirIsNotMisconfigured(dataDir);
|
|
|
|
// The database still lives in memory and is serialised out on every write;
|
|
// turning encryption off only changes whether that file is ciphertext. It
|
|
// has to be read back, or each restart starts empty and silently discards
|
|
// everything the previous run saved.
|
|
const existing = readPlainDatabaseFile();
|
|
if (existing) {
|
|
memoryDatabase = new Database(existing);
|
|
databaseLogger.info("Loaded unencrypted database from disk", {
|
|
operation: "db_load_plain",
|
|
path: dbPath,
|
|
bytes: existing.length,
|
|
});
|
|
} else {
|
|
memoryDatabase = new Database(":memory:");
|
|
isNewDatabase = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
/** The plain database file, or null when there is nothing to restore. */
|
|
function readPlainDatabaseFile(): Buffer | null {
|
|
try {
|
|
const contents = fs.readFileSync(dbPath);
|
|
return contents.length > 0 ? contents : null;
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function initializeCompleteDatabase(): Promise<void> {
|
|
await initializeDatabaseAsync();
|
|
|
|
databaseLogger.info(`Initializing SQLite database`, {
|
|
operation: "db_init",
|
|
path: actualDbPath,
|
|
encrypted:
|
|
enableFileEncryption &&
|
|
DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath),
|
|
inMemory: true,
|
|
isNewDatabase,
|
|
});
|
|
|
|
sqlite = memoryDatabase;
|
|
|
|
sqlite.exec("PRAGMA foreign_keys = ON");
|
|
|
|
db = drizzle(sqlite, { schema });
|
|
|
|
sqlite.exec(`
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id TEXT PRIMARY KEY,
|
|
username TEXT NOT NULL,
|
|
password_hash TEXT NOT NULL,
|
|
is_admin INTEGER NOT NULL DEFAULT 0,
|
|
is_oidc INTEGER NOT NULL DEFAULT 0,
|
|
oidc_identifier TEXT,
|
|
client_id TEXT,
|
|
client_secret TEXT,
|
|
issuer_url TEXT,
|
|
authorization_url TEXT,
|
|
token_url TEXT,
|
|
identifier_path TEXT,
|
|
name_path TEXT,
|
|
scopes TEXT DEFAULT 'openid email profile',
|
|
registered_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
donation_modal_dismissed INTEGER NOT NULL DEFAULT 0
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS settings (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
id TEXT PRIMARY KEY,
|
|
user_id TEXT NOT NULL,
|
|
jwt_token TEXT NOT NULL,
|
|
device_type TEXT NOT NULL,
|
|
device_info TEXT NOT NULL,
|
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
expires_at TEXT NOT NULL,
|
|
last_active_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS trusted_devices (
|
|
id TEXT PRIMARY KEY,
|
|
user_id TEXT NOT NULL,
|
|
device_fingerprint TEXT NOT NULL,
|
|
device_type TEXT NOT NULL,
|
|
device_info TEXT NOT NULL,
|
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
expires_at TEXT NOT NULL,
|
|
last_used_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS user_external_identities (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id TEXT NOT NULL,
|
|
provider_id TEXT NOT NULL,
|
|
subject TEXT NOT NULL,
|
|
email TEXT,
|
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE (provider_id, subject),
|
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_user_external_identities_user ON user_external_identities (user_id);
|
|
|
|
CREATE TABLE IF NOT EXISTS user_second_factors (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id TEXT NOT NULL,
|
|
plugin_id TEXT NOT NULL,
|
|
factor_id TEXT NOT NULL,
|
|
enrolled_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE (user_id, plugin_id, factor_id),
|
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS ssh_data (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id TEXT NOT NULL,
|
|
name TEXT,
|
|
ip TEXT NOT NULL,
|
|
port INTEGER NOT NULL,
|
|
username TEXT NOT NULL,
|
|
folder TEXT,
|
|
tags TEXT,
|
|
pin INTEGER NOT NULL DEFAULT 0,
|
|
sort_order INTEGER,
|
|
auth_type TEXT NOT NULL,
|
|
password TEXT,
|
|
key TEXT,
|
|
key_password TEXT,
|
|
key_type TEXT,
|
|
force_keyboard_interactive TEXT,
|
|
status_check_enabled INTEGER NOT NULL DEFAULT 1,
|
|
status_check_interval INTEGER,
|
|
terminal_config TEXT,
|
|
notes TEXT,
|
|
use_socks5 INTEGER,
|
|
socks5_host TEXT,
|
|
socks5_port INTEGER,
|
|
socks5_username TEXT,
|
|
socks5_password TEXT,
|
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS ssh_credentials (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id TEXT NOT NULL,
|
|
name TEXT NOT NULL,
|
|
description TEXT,
|
|
folder TEXT,
|
|
tags TEXT,
|
|
auth_type TEXT NOT NULL,
|
|
username TEXT,
|
|
password TEXT,
|
|
key TEXT,
|
|
key_password TEXT,
|
|
key_type TEXT,
|
|
usage_count INTEGER NOT NULL DEFAULT 0,
|
|
last_used TEXT,
|
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS ssh_credential_usage (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
credential_id INTEGER NOT NULL,
|
|
host_id INTEGER NOT NULL,
|
|
user_id TEXT NOT NULL,
|
|
used_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY (credential_id) REFERENCES ssh_credentials (id) ON DELETE CASCADE,
|
|
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE,
|
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS ssh_folders (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id TEXT NOT NULL,
|
|
name TEXT NOT NULL,
|
|
color TEXT,
|
|
icon TEXT,
|
|
credential_id INTEGER,
|
|
sort_order INTEGER,
|
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
|
FOREIGN KEY (credential_id) REFERENCES ssh_credentials (id) ON DELETE SET NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS recent_activity (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id TEXT NOT NULL,
|
|
type TEXT NOT NULL,
|
|
host_id INTEGER NOT NULL,
|
|
host_name TEXT,
|
|
timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
|
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS host_access (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
host_id INTEGER NOT NULL,
|
|
user_id TEXT,
|
|
role_id INTEGER,
|
|
granted_by TEXT NOT NULL,
|
|
permission_level TEXT NOT NULL DEFAULT 'use',
|
|
expires_at TEXT,
|
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
last_accessed_at TEXT,
|
|
access_count INTEGER NOT NULL DEFAULT 0,
|
|
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE,
|
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
|
FOREIGN KEY (role_id) REFERENCES roles (id) ON DELETE CASCADE,
|
|
FOREIGN KEY (granted_by) REFERENCES users (id) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS roles (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL UNIQUE,
|
|
display_name TEXT NOT NULL,
|
|
description TEXT,
|
|
is_system INTEGER NOT NULL DEFAULT 0,
|
|
permissions TEXT,
|
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS user_roles (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id TEXT NOT NULL,
|
|
role_id INTEGER NOT NULL,
|
|
granted_by TEXT,
|
|
granted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE(user_id, role_id),
|
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
|
FOREIGN KEY (role_id) REFERENCES roles (id) ON DELETE CASCADE,
|
|
FOREIGN KEY (granted_by) REFERENCES users (id) ON DELETE SET NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS audit_logs (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id TEXT,
|
|
username TEXT NOT NULL,
|
|
action TEXT NOT NULL,
|
|
resource_type TEXT NOT NULL,
|
|
resource_id TEXT,
|
|
resource_name TEXT,
|
|
details TEXT,
|
|
ip_address TEXT,
|
|
user_agent TEXT,
|
|
success INTEGER NOT NULL,
|
|
error_message TEXT,
|
|
timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS credential_access (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
credential_id INTEGER NOT NULL,
|
|
user_id TEXT,
|
|
role_id INTEGER,
|
|
granted_by TEXT NOT NULL,
|
|
permission_level TEXT NOT NULL DEFAULT 'use',
|
|
expires_at TEXT,
|
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY (credential_id) REFERENCES ssh_credentials (id) ON DELETE CASCADE,
|
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
|
FOREIGN KEY (role_id) REFERENCES roles (id) ON DELETE CASCADE,
|
|
FOREIGN KEY (granted_by) REFERENCES users (id) ON DELETE CASCADE
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_credential_access_user_id ON credential_access (user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_credential_access_role_id ON credential_access (role_id);
|
|
CREATE INDEX IF NOT EXISTS idx_credential_access_credential_id ON credential_access (credential_id);
|
|
|
|
CREATE TABLE IF NOT EXISTS shared_credential_secrets (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
credential_access_id INTEGER NOT NULL,
|
|
target_user_id TEXT NOT NULL,
|
|
credential_id INTEGER NOT NULL,
|
|
encrypted_username TEXT,
|
|
auth_type TEXT NOT NULL DEFAULT 'password',
|
|
encrypted_password TEXT,
|
|
encrypted_key TEXT,
|
|
encrypted_key_password TEXT,
|
|
key_type TEXT,
|
|
public_key TEXT,
|
|
cert_public_key TEXT,
|
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE (credential_access_id, target_user_id),
|
|
FOREIGN KEY (credential_access_id) REFERENCES credential_access (id) ON DELETE CASCADE,
|
|
FOREIGN KEY (target_user_id) REFERENCES users (id) ON DELETE CASCADE,
|
|
FOREIGN KEY (credential_id) REFERENCES ssh_credentials (id) ON DELETE CASCADE
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_shared_credential_secrets_target ON shared_credential_secrets (target_user_id, credential_id);
|
|
|
|
CREATE TABLE IF NOT EXISTS folder_access (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
owner_user_id TEXT NOT NULL,
|
|
folder TEXT NOT NULL,
|
|
user_id TEXT,
|
|
role_id INTEGER,
|
|
granted_by TEXT NOT NULL,
|
|
permission_level TEXT NOT NULL DEFAULT 'connect',
|
|
expires_at TEXT,
|
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY (owner_user_id) REFERENCES users (id) ON DELETE CASCADE,
|
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
|
FOREIGN KEY (role_id) REFERENCES roles (id) ON DELETE CASCADE,
|
|
FOREIGN KEY (granted_by) REFERENCES users (id) ON DELETE CASCADE
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_folder_access_owner_folder ON folder_access (owner_user_id, folder);
|
|
|
|
CREATE TABLE IF NOT EXISTS plugins (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
version TEXT NOT NULL,
|
|
tier TEXT NOT NULL DEFAULT 'available',
|
|
source TEXT NOT NULL DEFAULT 'community',
|
|
registry_id TEXT,
|
|
state TEXT NOT NULL DEFAULT 'disabled',
|
|
last_error TEXT,
|
|
installed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
auto_update INTEGER NOT NULL DEFAULT 0,
|
|
manifest_json TEXT NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_plugins_registry_id ON plugins (registry_id);
|
|
|
|
CREATE TABLE IF NOT EXISTS plugin_permission_grants (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
plugin_id TEXT NOT NULL,
|
|
capability TEXT NOT NULL,
|
|
granted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
-- Nullable: a bundled grant is made by the install, not by a user.
|
|
granted_by TEXT,
|
|
source TEXT NOT NULL DEFAULT 'admin',
|
|
UNIQUE (plugin_id, capability),
|
|
FOREIGN KEY (plugin_id) REFERENCES plugins (id) ON DELETE CASCADE,
|
|
FOREIGN KEY (granted_by) REFERENCES users (id) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS plugin_registries (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
url TEXT NOT NULL,
|
|
kind TEXT NOT NULL DEFAULT 'community',
|
|
enabled INTEGER NOT NULL DEFAULT 1,
|
|
signing_key TEXT,
|
|
last_checked_at TEXT,
|
|
last_index_hash TEXT
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS plugin_install_counts (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
plugin_id TEXT NOT NULL,
|
|
registry_id TEXT NOT NULL,
|
|
count INTEGER NOT NULL DEFAULT 0,
|
|
source TEXT NOT NULL DEFAULT 'aggregate-telemetry',
|
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE (plugin_id, registry_id)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS plugin_storage (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
plugin_id TEXT NOT NULL,
|
|
key TEXT NOT NULL,
|
|
value TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE (plugin_id, key),
|
|
FOREIGN KEY (plugin_id) REFERENCES plugins (id) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS plugin_migrations (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
plugin_id TEXT NOT NULL,
|
|
migration_id TEXT NOT NULL,
|
|
checksum TEXT NOT NULL,
|
|
applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE (plugin_id, migration_id),
|
|
FOREIGN KEY (plugin_id) REFERENCES plugins (id) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS plugin_settings (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
plugin_id TEXT NOT NULL,
|
|
scope TEXT NOT NULL,
|
|
scope_id TEXT,
|
|
key TEXT NOT NULL,
|
|
value TEXT,
|
|
encrypted INTEGER NOT NULL DEFAULT 0,
|
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE (plugin_id, scope, scope_id, key),
|
|
FOREIGN KEY (plugin_id) REFERENCES plugins (id) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_plugin_settings_plugin_scope ON plugin_settings (plugin_id, scope);
|
|
|
|
CREATE TABLE IF NOT EXISTS rbac_known_permissions (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
permission TEXT NOT NULL,
|
|
plugin_id TEXT,
|
|
first_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE (permission)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS rbac_applied_defaults (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
role_name TEXT NOT NULL,
|
|
permission TEXT NOT NULL,
|
|
applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE (role_name, permission)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS api_keys (
|
|
id TEXT PRIMARY KEY,
|
|
user_id TEXT NOT NULL,
|
|
name TEXT NOT NULL,
|
|
token_hash TEXT NOT NULL,
|
|
token_prefix TEXT NOT NULL,
|
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
expires_at TEXT,
|
|
last_used_at TEXT,
|
|
is_active INTEGER NOT NULL DEFAULT 1,
|
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS user_open_tabs (
|
|
id TEXT PRIMARY KEY,
|
|
user_id TEXT NOT NULL,
|
|
tab_type TEXT NOT NULL,
|
|
host_id INTEGER,
|
|
label TEXT NOT NULL,
|
|
tab_order INTEGER NOT NULL DEFAULT 0,
|
|
backend_session_id TEXT,
|
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
|
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS user_preferences (
|
|
user_id TEXT PRIMARY KEY,
|
|
reopen_tabs_on_login INTEGER NOT NULL DEFAULT 0,
|
|
theme TEXT,
|
|
font_size TEXT,
|
|
accent_color TEXT,
|
|
language TEXT,
|
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS host_sidebar_preferences (
|
|
user_id TEXT PRIMARY KEY,
|
|
data TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS credential_sidebar_preferences (
|
|
user_id TEXT PRIMARY KEY,
|
|
data TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS ui_preferences (
|
|
user_id TEXT PRIMARY KEY,
|
|
data TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
|
);
|
|
|
|
`);
|
|
|
|
// Expired open tabs are dropped when the tab list is read, against the
|
|
// terminal plugin's own session timeout.
|
|
|
|
try {
|
|
const result = sqlite
|
|
.prepare("DELETE FROM sessions WHERE expires_at <= ?")
|
|
.run(new Date().toISOString());
|
|
if (result.changes > 0) {
|
|
databaseLogger.info("Expired sessions cleaned up on startup", {
|
|
operation: "db_init_session_cleanup",
|
|
deletedSessions: result.changes,
|
|
});
|
|
}
|
|
} catch (e) {
|
|
databaseLogger.warn("Could not clear expired sessions on startup", {
|
|
operation: "db_init_session_cleanup_failed",
|
|
error: e,
|
|
});
|
|
}
|
|
|
|
migrateSchema();
|
|
vacuumIfFreelistBloated();
|
|
|
|
try {
|
|
ensureRawSettingDefault("allow_registration", "true");
|
|
} catch (e) {
|
|
databaseLogger.warn("Could not initialize default settings", {
|
|
operation: "db_init",
|
|
error: e,
|
|
});
|
|
}
|
|
|
|
try {
|
|
ensureRawSettingDefault("allow_password_login", "true");
|
|
} catch (e) {
|
|
databaseLogger.warn("Could not initialize allow_password_login setting", {
|
|
operation: "db_init",
|
|
error: e,
|
|
});
|
|
}
|
|
}
|
|
|
|
const addColumnIfNotExists = (
|
|
table: string,
|
|
column: string,
|
|
definition: string,
|
|
) => {
|
|
try {
|
|
sqlite
|
|
.prepare(
|
|
`SELECT "${column}"
|
|
FROM ${table} LIMIT 1`,
|
|
)
|
|
.get();
|
|
} catch {
|
|
try {
|
|
sqlite.exec(`ALTER TABLE ${table}
|
|
ADD COLUMN "${column}" ${definition};`);
|
|
} catch (alterError) {
|
|
const message =
|
|
alterError instanceof Error ? alterError.message : String(alterError);
|
|
databaseLogger.warn(
|
|
`Failed to add column ${column} to ${table}: ${message}`,
|
|
{
|
|
operation: "schema_migration",
|
|
table,
|
|
column,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Drops the NOT NULL on plugin_permission_grants.granted_by.
|
|
*
|
|
* A bundled grant is made by the install rather than by a person, so there is
|
|
* no user id to record. SQLite cannot relax a constraint in place, so the
|
|
* table is rebuilt; it only ever holds a handful of rows.
|
|
*/
|
|
const relaxPluginGrantGrantedBy = () => {
|
|
try {
|
|
const sql = sqlite
|
|
.prepare(
|
|
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'plugin_permission_grants'`,
|
|
)
|
|
.get() as { sql?: string } | undefined;
|
|
|
|
if (!sql?.sql || !/granted_by\s+TEXT\s+NOT\s+NULL/i.test(sql.sql)) {
|
|
return;
|
|
}
|
|
|
|
sqlite.exec(`
|
|
PRAGMA foreign_keys=OFF;
|
|
BEGIN;
|
|
CREATE TABLE plugin_permission_grants_new (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
plugin_id TEXT NOT NULL,
|
|
capability TEXT NOT NULL,
|
|
granted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
granted_by TEXT,
|
|
source TEXT NOT NULL DEFAULT 'admin',
|
|
UNIQUE (plugin_id, capability),
|
|
FOREIGN KEY (plugin_id) REFERENCES plugins (id) ON DELETE CASCADE,
|
|
FOREIGN KEY (granted_by) REFERENCES users (id) ON DELETE CASCADE
|
|
);
|
|
INSERT INTO plugin_permission_grants_new
|
|
(id, plugin_id, capability, granted_at, granted_by, source)
|
|
SELECT id, plugin_id, capability, granted_at, granted_by, 'admin'
|
|
FROM plugin_permission_grants;
|
|
DROP TABLE plugin_permission_grants;
|
|
ALTER TABLE plugin_permission_grants_new RENAME TO plugin_permission_grants;
|
|
COMMIT;
|
|
PRAGMA foreign_keys=ON;
|
|
`);
|
|
|
|
databaseLogger.info(
|
|
"Relaxed plugin_permission_grants.granted_by to allow bundled grants",
|
|
{ operation: "schema_migration" },
|
|
);
|
|
} catch (error) {
|
|
databaseLogger.warn(
|
|
`Failed to relax plugin_permission_grants.granted_by: ${
|
|
error instanceof Error ? error.message : String(error)
|
|
}`,
|
|
{ operation: "schema_migration" },
|
|
);
|
|
}
|
|
};
|
|
|
|
const migrateSchema = () => {
|
|
addColumnIfNotExists("user_preferences", "theme", "TEXT");
|
|
addColumnIfNotExists("user_preferences", "font_size", "TEXT");
|
|
addColumnIfNotExists("user_preferences", "accent_color", "TEXT");
|
|
addColumnIfNotExists("user_preferences", "language", "TEXT");
|
|
addColumnIfNotExists("user_preferences", "storage_mode", "TEXT");
|
|
addColumnIfNotExists("user_preferences", "command_autocomplete", "INTEGER");
|
|
addColumnIfNotExists("user_preferences", "command_palette_enabled", "INTEGER");
|
|
addColumnIfNotExists("user_preferences", "show_host_tags", "INTEGER");
|
|
addColumnIfNotExists("user_preferences", "host_tray_on_click", "INTEGER");
|
|
addColumnIfNotExists("user_preferences", "pin_app_rail", "INTEGER");
|
|
addColumnIfNotExists(
|
|
"user_preferences",
|
|
"expand_app_rail_on_hover",
|
|
"INTEGER",
|
|
);
|
|
addColumnIfNotExists(
|
|
"user_preferences",
|
|
"show_pin_app_rail_button",
|
|
"INTEGER",
|
|
);
|
|
addColumnIfNotExists("user_preferences", "folders_collapsed", "INTEGER");
|
|
addColumnIfNotExists("user_preferences", "confirm_snippet_execution", "INTEGER");
|
|
addColumnIfNotExists("user_preferences", "disable_update_check", "INTEGER");
|
|
addColumnIfNotExists("user_preferences", "confirm_tab_close", "INTEGER");
|
|
addColumnIfNotExists("user_preferences", "hidden_rail_tabs", "TEXT");
|
|
addColumnIfNotExists("user_preferences", "compact_host_view", "INTEGER");
|
|
addColumnIfNotExists("user_preferences", "status_color_scheme", "TEXT");
|
|
addColumnIfNotExists("user_preferences", "custom_themes", "TEXT");
|
|
addColumnIfNotExists("user_preferences", "custom_keybindings", "TEXT");
|
|
addColumnIfNotExists("user_preferences", "terminal_defaults", "TEXT");
|
|
addColumnIfNotExists("user_preferences", "terminal_macros", "TEXT");
|
|
|
|
addColumnIfNotExists("users", "is_admin", "INTEGER NOT NULL DEFAULT 0");
|
|
|
|
addColumnIfNotExists("users", "is_oidc", "INTEGER NOT NULL DEFAULT 0");
|
|
addColumnIfNotExists("users", "oidc_identifier", "TEXT");
|
|
addColumnIfNotExists("users", "client_id", "TEXT");
|
|
addColumnIfNotExists("users", "client_secret", "TEXT");
|
|
addColumnIfNotExists("users", "issuer_url", "TEXT");
|
|
addColumnIfNotExists("users", "authorization_url", "TEXT");
|
|
addColumnIfNotExists("users", "token_url", "TEXT");
|
|
|
|
addColumnIfNotExists("users", "identifier_path", "TEXT");
|
|
addColumnIfNotExists("users", "name_path", "TEXT");
|
|
addColumnIfNotExists("users", "scopes", "TEXT");
|
|
|
|
const hadRegisteredAtColumn = (() => {
|
|
try {
|
|
sqlite.prepare(`SELECT "registered_at" FROM users LIMIT 1`).get();
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
})();
|
|
// SQLite's ALTER TABLE ADD COLUMN rejects non-constant defaults like
|
|
// CURRENT_TIMESTAMP, so the column is added empty and backfilled below.
|
|
addColumnIfNotExists("users", "registered_at", "TEXT");
|
|
if (!hadRegisteredAtColumn) {
|
|
// Pre-existing users are backdated past the 30 day mark so they see the
|
|
// donation modal immediately on upgrade instead of waiting a fresh
|
|
// 30 days as if they had just registered.
|
|
try {
|
|
sqlite.exec(
|
|
`UPDATE users SET registered_at = datetime('now', '-31 days') WHERE registered_at IS NULL`,
|
|
);
|
|
} catch (backfillError) {
|
|
databaseLogger.warn("Failed to backfill users.registered_at", {
|
|
operation: "schema_migration",
|
|
error:
|
|
getErrorMessage(backfillError, String(backfillError)),
|
|
});
|
|
}
|
|
} else {
|
|
try {
|
|
sqlite.exec(
|
|
`UPDATE users SET registered_at = CURRENT_TIMESTAMP WHERE registered_at IS NULL`,
|
|
);
|
|
} catch (backfillError) {
|
|
databaseLogger.warn(
|
|
"Failed to backfill NULL users.registered_at values",
|
|
{
|
|
operation: "schema_migration",
|
|
error:
|
|
getErrorMessage(backfillError, String(backfillError)),
|
|
},
|
|
);
|
|
}
|
|
}
|
|
addColumnIfNotExists(
|
|
"users",
|
|
"donation_modal_dismissed",
|
|
"INTEGER NOT NULL DEFAULT 0",
|
|
);
|
|
|
|
addColumnIfNotExists("sessions", "oidc_sub", "TEXT");
|
|
addColumnIfNotExists("sessions", "oidc_sid", "TEXT");
|
|
addColumnIfNotExists("sessions", "sso_provider_id", "INTEGER");
|
|
|
|
addColumnIfNotExists("ssh_data", "name", "TEXT");
|
|
addColumnIfNotExists("ssh_data", "folder", "TEXT");
|
|
addColumnIfNotExists("ssh_data", "tags", "TEXT");
|
|
addColumnIfNotExists("ssh_data", "pin", "INTEGER NOT NULL DEFAULT 0");
|
|
addColumnIfNotExists("ssh_data", "sort_order", "INTEGER");
|
|
addColumnIfNotExists("ssh_folders", "sort_order", "INTEGER");
|
|
addColumnIfNotExists(
|
|
"ssh_data",
|
|
"auth_type",
|
|
'TEXT NOT NULL DEFAULT "password"',
|
|
);
|
|
addColumnIfNotExists("ssh_data", "password", "TEXT");
|
|
addColumnIfNotExists("ssh_data", "key", "TEXT");
|
|
addColumnIfNotExists("ssh_data", "key_password", "TEXT");
|
|
addColumnIfNotExists("ssh_data", "key_type", "TEXT");
|
|
addColumnIfNotExists("ssh_data", "jump_hosts", "TEXT");
|
|
addColumnIfNotExists(
|
|
"ssh_data",
|
|
"created_at",
|
|
"TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP",
|
|
);
|
|
addColumnIfNotExists(
|
|
"ssh_data",
|
|
"updated_at",
|
|
"TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP",
|
|
);
|
|
addColumnIfNotExists("ssh_data", "force_keyboard_interactive", "TEXT");
|
|
addColumnIfNotExists(
|
|
"ssh_data",
|
|
"credential_id",
|
|
"INTEGER REFERENCES ssh_credentials(id) ON DELETE SET NULL",
|
|
);
|
|
addColumnIfNotExists(
|
|
"ssh_data",
|
|
"override_credential_username",
|
|
"INTEGER",
|
|
);
|
|
|
|
addColumnIfNotExists(
|
|
"ssh_data",
|
|
"status_check_enabled",
|
|
"INTEGER NOT NULL DEFAULT 1",
|
|
);
|
|
addColumnIfNotExists("ssh_data", "status_check_interval", "INTEGER");
|
|
addColumnIfNotExists("ssh_data", "terminal_config", "TEXT");
|
|
addColumnIfNotExists("ssh_data", "quick_actions", "TEXT");
|
|
|
|
addColumnIfNotExists("ssh_data", "connection_type", 'TEXT NOT NULL DEFAULT "ssh"');
|
|
addColumnIfNotExists("ssh_data", "domain", "TEXT");
|
|
addColumnIfNotExists("ssh_data", "notes", "TEXT");
|
|
|
|
addColumnIfNotExists("ssh_data", "use_socks5", "INTEGER");
|
|
addColumnIfNotExists("ssh_data", "socks5_host", "TEXT");
|
|
addColumnIfNotExists("ssh_data", "socks5_port", "INTEGER");
|
|
addColumnIfNotExists("ssh_data", "socks5_username", "TEXT");
|
|
addColumnIfNotExists("ssh_data", "socks5_password", "TEXT");
|
|
addColumnIfNotExists("ssh_data", "socks5_proxy_chain", "TEXT");
|
|
|
|
addColumnIfNotExists("ssh_data", "host_key_fingerprint", "TEXT");
|
|
addColumnIfNotExists("ssh_data", "host_key_type", "TEXT");
|
|
addColumnIfNotExists("ssh_data", "host_key_algorithm", "TEXT DEFAULT 'sha256'");
|
|
addColumnIfNotExists("ssh_data", "host_key_first_seen", "TEXT");
|
|
addColumnIfNotExists("ssh_data", "host_key_last_verified", "TEXT");
|
|
addColumnIfNotExists("ssh_data", "host_key_changed_count", "INTEGER DEFAULT 0");
|
|
|
|
|
|
addColumnIfNotExists("ssh_credentials", "private_key", "TEXT");
|
|
addColumnIfNotExists("ssh_credentials", "public_key", "TEXT");
|
|
addColumnIfNotExists("ssh_credentials", "detected_key_type", "TEXT");
|
|
|
|
addColumnIfNotExists("ssh_credentials", "cert_public_key", "TEXT");
|
|
|
|
addColumnIfNotExists(
|
|
"ssh_credentials",
|
|
"pin",
|
|
"INTEGER NOT NULL DEFAULT 0",
|
|
);
|
|
addColumnIfNotExists("ssh_credentials", "sort_order", "INTEGER");
|
|
|
|
try {
|
|
const tableInfo = sqlite.prepare("PRAGMA table_info(ssh_credentials)").all() as Array<{
|
|
cid: number;
|
|
name: string;
|
|
type: string;
|
|
notnull: number;
|
|
dflt_value: string | null;
|
|
pk: number;
|
|
}>;
|
|
const usernameCol = tableInfo.find((col) => col.name === "username");
|
|
|
|
if (usernameCol && usernameCol.notnull === 1) {
|
|
const tempTableName = "ssh_credentials_temp_migration";
|
|
const allColumns = tableInfo.map((col) => `"${col.name}"`).join(", ");
|
|
|
|
// Derive the replacement table from the live definition instead of
|
|
// restating it here. The table keeps gaining columns (cert_public_key,
|
|
// pin, sort_order, sync_id, ...), and a second copy of the column list
|
|
// falls behind every time one is added — leaving the copy narrower than
|
|
// the table, so the INSERT below fails and the constraint stays put.
|
|
const createSql = sqlite
|
|
.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'ssh_credentials'")
|
|
.pluck()
|
|
.get() as string | undefined;
|
|
|
|
if (!createSql) {
|
|
throw new Error("ssh_credentials has no stored table definition");
|
|
}
|
|
|
|
// Only the table name is rewritten; replace() stops at the first match,
|
|
// and in a CREATE TABLE statement that is the table being defined.
|
|
const renamedSql = createSql.replace("ssh_credentials", tempTableName);
|
|
const tempCreateSql = renamedSql.replace(/(["`[]?username["`\]]?\s+TEXT)\s+NOT\s+NULL/i, "$1");
|
|
|
|
if (tempCreateSql === renamedSql) {
|
|
throw new Error("could not derive a nullable-username definition for ssh_credentials");
|
|
}
|
|
|
|
// DROP TABLE takes the table's indexes with it, so replay them afterwards.
|
|
const indexDefs = sqlite
|
|
.prepare(
|
|
"SELECT sql FROM sqlite_master WHERE type = 'index' AND tbl_name = 'ssh_credentials' AND sql IS NOT NULL",
|
|
)
|
|
.pluck()
|
|
.all() as string[];
|
|
|
|
sqlite.exec(`PRAGMA foreign_keys = OFF`);
|
|
sqlite.exec(`
|
|
${tempCreateSql};
|
|
|
|
INSERT INTO ${tempTableName} (${allColumns}) SELECT ${allColumns} FROM ssh_credentials;
|
|
|
|
DROP TABLE ssh_credentials;
|
|
|
|
ALTER TABLE ${tempTableName} RENAME TO ssh_credentials;
|
|
`);
|
|
for (const indexSql of indexDefs) {
|
|
sqlite.exec(indexSql);
|
|
}
|
|
sqlite.exec(`PRAGMA foreign_keys = ON`);
|
|
|
|
databaseLogger.info("Successfully migrated ssh_credentials table to remove username NOT NULL constraint", {
|
|
operation: "schema_migration_username_nullable",
|
|
restoredIndexes: indexDefs.length,
|
|
});
|
|
}
|
|
} catch (migrationError) {
|
|
databaseLogger.warn("Failed to migrate ssh_credentials username column", {
|
|
operation: "schema_migration",
|
|
error: migrationError,
|
|
});
|
|
}
|
|
|
|
try {
|
|
const auditLogColumns = sqlite.prepare("PRAGMA table_info(audit_logs)").all() as Array<{
|
|
name: string;
|
|
notnull: number;
|
|
}>;
|
|
const auditUserIdCol = auditLogColumns.find((col) => col.name === "user_id");
|
|
|
|
if (auditUserIdCol && auditUserIdCol.notnull === 1) {
|
|
const tempTableName = "audit_logs_temp_migration";
|
|
const columns = [
|
|
"id",
|
|
"user_id",
|
|
"username",
|
|
"action",
|
|
"resource_type",
|
|
"resource_id",
|
|
"resource_name",
|
|
"details",
|
|
"ip_address",
|
|
"user_agent",
|
|
"success",
|
|
"error_message",
|
|
"timestamp",
|
|
].join(", ");
|
|
|
|
sqlite.exec(`PRAGMA foreign_keys = OFF`);
|
|
sqlite.exec(`
|
|
CREATE TABLE ${tempTableName} (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id TEXT,
|
|
username TEXT NOT NULL,
|
|
action TEXT NOT NULL,
|
|
resource_type TEXT NOT NULL,
|
|
resource_id TEXT,
|
|
resource_name TEXT,
|
|
details TEXT,
|
|
ip_address TEXT,
|
|
user_agent TEXT,
|
|
success INTEGER NOT NULL,
|
|
error_message TEXT,
|
|
timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL
|
|
);
|
|
|
|
INSERT INTO ${tempTableName} (${columns}) SELECT ${columns} FROM audit_logs;
|
|
|
|
DROP TABLE audit_logs;
|
|
|
|
ALTER TABLE ${tempTableName} RENAME TO audit_logs;
|
|
`);
|
|
sqlite.exec(`PRAGMA foreign_keys = ON`);
|
|
|
|
databaseLogger.info("Successfully migrated audit_logs table to remove user_id NOT NULL constraint", {
|
|
operation: "schema_migration_audit_user_id_nullable",
|
|
});
|
|
}
|
|
} catch (migrationError) {
|
|
databaseLogger.warn("Failed to migrate audit_logs user_id column", {
|
|
operation: "schema_migration",
|
|
error: migrationError,
|
|
});
|
|
}
|
|
|
|
try {
|
|
sqlite.prepare("SELECT role_id FROM host_access LIMIT 1").get();
|
|
} catch {
|
|
try {
|
|
sqlite.exec("ALTER TABLE host_access ADD COLUMN role_id INTEGER REFERENCES roles(id) ON DELETE CASCADE");
|
|
} catch (alterError) {
|
|
databaseLogger.warn("Failed to add role_id column", {
|
|
operation: "schema_migration",
|
|
error: alterError,
|
|
});
|
|
}
|
|
}
|
|
|
|
try {
|
|
sqlite.prepare("SELECT override_credential_id FROM host_access LIMIT 1").get();
|
|
} catch {
|
|
try {
|
|
sqlite.exec("ALTER TABLE host_access ADD COLUMN override_credential_id INTEGER REFERENCES ssh_credentials(id) ON DELETE SET NULL");
|
|
} catch (alterError) {
|
|
databaseLogger.warn("Failed to add override_credential_id column", {
|
|
operation: "schema_migration",
|
|
error: alterError,
|
|
});
|
|
}
|
|
}
|
|
|
|
try {
|
|
ensureSharedHostAuthOverrideProtocolSchema(sqlite);
|
|
} catch (schemaError) {
|
|
databaseLogger.warn("Failed to prepare shared_host_auth_overrides table", {
|
|
operation: "schema_migration",
|
|
error: schemaError,
|
|
});
|
|
}
|
|
|
|
try {
|
|
migrateLegacySharedHostAuthOverrides(
|
|
sqlite,
|
|
getRawSettingValue,
|
|
setRawSettingValue,
|
|
);
|
|
} catch (migrateError) {
|
|
databaseLogger.warn("Failed to migrate shared host auth overrides", {
|
|
operation: "schema_migration",
|
|
error: migrateError,
|
|
});
|
|
}
|
|
|
|
try {
|
|
sqlite.prepare("SELECT credential_id FROM ssh_folders LIMIT 1").get();
|
|
} catch {
|
|
try {
|
|
sqlite.exec("ALTER TABLE ssh_folders ADD COLUMN credential_id INTEGER REFERENCES ssh_credentials(id) ON DELETE SET NULL");
|
|
} catch (alterError) {
|
|
databaseLogger.warn("Failed to add credential_id column to ssh_folders", {
|
|
operation: "schema_migration",
|
|
error: alterError,
|
|
});
|
|
}
|
|
}
|
|
|
|
try {
|
|
sqlite.prepare("SELECT sudo_password FROM ssh_data LIMIT 1").get();
|
|
} catch {
|
|
try {
|
|
sqlite.exec("ALTER TABLE ssh_data ADD COLUMN sudo_password TEXT");
|
|
} catch (alterError) {
|
|
databaseLogger.warn("Failed to add sudo_password column", {
|
|
operation: "schema_migration",
|
|
error: alterError,
|
|
});
|
|
}
|
|
}
|
|
|
|
const sshDataMigrations: Array<{ column: string; sql: string }> = [
|
|
{ column: "connection_type", sql: "ALTER TABLE ssh_data ADD COLUMN connection_type TEXT NOT NULL DEFAULT 'ssh'" },
|
|
{ column: "credential_id", sql: "ALTER TABLE ssh_data ADD COLUMN credential_id INTEGER" },
|
|
{ column: "override_credential_username", sql: "ALTER TABLE ssh_data ADD COLUMN override_credential_username INTEGER" },
|
|
{ column: "share_ssh_auth", sql: "ALTER TABLE ssh_data ADD COLUMN share_ssh_auth INTEGER NOT NULL DEFAULT 0" },
|
|
{ column: "jump_hosts", sql: "ALTER TABLE ssh_data ADD COLUMN jump_hosts TEXT" },
|
|
{ column: "quick_actions", sql: "ALTER TABLE ssh_data ADD COLUMN quick_actions TEXT" },
|
|
{ column: "socks5_proxy_chain", sql: "ALTER TABLE ssh_data ADD COLUMN socks5_proxy_chain TEXT" },
|
|
{ column: "host_key_fingerprint", sql: "ALTER TABLE ssh_data ADD COLUMN host_key_fingerprint TEXT" },
|
|
{ column: "host_key_type", sql: "ALTER TABLE ssh_data ADD COLUMN host_key_type TEXT" },
|
|
{ column: "host_key_algorithm", sql: "ALTER TABLE ssh_data ADD COLUMN host_key_algorithm TEXT NOT NULL DEFAULT 'sha256'" },
|
|
{ column: "host_key_first_seen", sql: "ALTER TABLE ssh_data ADD COLUMN host_key_first_seen TEXT" },
|
|
{ column: "host_key_last_verified", sql: "ALTER TABLE ssh_data ADD COLUMN host_key_last_verified TEXT" },
|
|
{ column: "host_key_changed_count", sql: "ALTER TABLE ssh_data ADD COLUMN host_key_changed_count INTEGER NOT NULL DEFAULT 0" },
|
|
{ column: "port_knock_sequence", sql: "ALTER TABLE ssh_data ADD COLUMN port_knock_sequence TEXT" },
|
|
{ column: "enable_ssh", sql: "ALTER TABLE ssh_data ADD COLUMN enable_ssh INTEGER NOT NULL DEFAULT 1" },
|
|
{ column: "ssh_port", sql: "ALTER TABLE ssh_data ADD COLUMN ssh_port INTEGER DEFAULT 22" },
|
|
{ column: "connection_origin", sql: "ALTER TABLE ssh_data ADD COLUMN connection_origin TEXT" },
|
|
{ column: "parent_host_id", sql: "ALTER TABLE ssh_data ADD COLUMN parent_host_id INTEGER REFERENCES ssh_data(id) ON DELETE SET NULL" },
|
|
];
|
|
|
|
for (const migration of sshDataMigrations) {
|
|
try {
|
|
sqlite.prepare(`SELECT ${migration.column} FROM ssh_data LIMIT 1`).get();
|
|
} catch {
|
|
try {
|
|
sqlite.exec(migration.sql);
|
|
} catch (alterError) {
|
|
databaseLogger.warn(`Failed to add ${migration.column} column`, {
|
|
operation: "schema_migration",
|
|
error: alterError,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// share_ssh_auth arrived with 2.6.1 and defaults to 0, but sharing a host
|
|
// used to pass the owner's SSH authentication along unconditionally. Every
|
|
// host shared before the upgrade therefore stopped supplying credentials to
|
|
// its recipients the moment the column appeared, and they were left with
|
|
// "No valid authentication method provided".
|
|
//
|
|
// Turn it on for hosts that are already shared, which is where the previous
|
|
// behaviour was in effect and consented to. Hosts nobody has shared keep the
|
|
// new default; the owner decides when they share one.
|
|
try {
|
|
if (getRawSettingValue("share_ssh_auth_backfill_v1") === null) {
|
|
const backfilled = sqlite
|
|
.prepare(
|
|
`UPDATE ssh_data SET share_ssh_auth = 1
|
|
WHERE share_ssh_auth = 0
|
|
AND id IN (SELECT DISTINCT host_id FROM host_access)`,
|
|
)
|
|
.run();
|
|
|
|
if (backfilled.changes > 0) {
|
|
databaseLogger.info(
|
|
`Restored shared SSH authentication for ${backfilled.changes} already-shared host(s)`,
|
|
{ operation: "share_ssh_auth_backfill_v1" },
|
|
);
|
|
}
|
|
setRawSettingValue("share_ssh_auth_backfill_v1", "true");
|
|
}
|
|
} catch (e) {
|
|
databaseLogger.warn("Failed to backfill share_ssh_auth", {
|
|
operation: "share_ssh_auth_backfill_v1",
|
|
error: e,
|
|
});
|
|
}
|
|
|
|
try {
|
|
sqlite.prepare("SELECT id FROM shared_host_secrets LIMIT 1").get();
|
|
} catch {
|
|
try {
|
|
sqlite.exec(`
|
|
CREATE TABLE IF NOT EXISTS shared_host_secrets (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
host_access_id INTEGER NOT NULL,
|
|
target_user_id TEXT NOT NULL,
|
|
protocol TEXT NOT NULL DEFAULT 'ssh',
|
|
source_type TEXT NOT NULL DEFAULT 'credential',
|
|
original_credential_id INTEGER,
|
|
encrypted_username TEXT,
|
|
encrypted_auth_type TEXT,
|
|
encrypted_password TEXT,
|
|
encrypted_key TEXT,
|
|
encrypted_key_password TEXT,
|
|
encrypted_key_type TEXT,
|
|
encrypted_domain TEXT,
|
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE(host_access_id, target_user_id, protocol),
|
|
FOREIGN KEY (host_access_id) REFERENCES host_access (id) ON DELETE CASCADE,
|
|
FOREIGN KEY (original_credential_id) REFERENCES ssh_credentials (id) ON DELETE CASCADE,
|
|
FOREIGN KEY (target_user_id) REFERENCES users (id) ON DELETE CASCADE
|
|
);
|
|
`);
|
|
} catch (createError) {
|
|
databaseLogger.warn("Failed to create shared_host_secrets table", {
|
|
operation: "schema_migration",
|
|
error: createError,
|
|
});
|
|
}
|
|
}
|
|
|
|
addColumnIfNotExists("shared_host_secrets", "encrypted_fields", "TEXT");
|
|
|
|
try {
|
|
sqlite.exec(`
|
|
CREATE TABLE IF NOT EXISTS host_protocol_auth (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
host_id INTEGER NOT NULL,
|
|
user_id TEXT NOT NULL,
|
|
protocol TEXT NOT NULL,
|
|
auth_type TEXT NOT NULL DEFAULT 'direct',
|
|
credential_id INTEGER,
|
|
username TEXT,
|
|
password TEXT,
|
|
fields TEXT,
|
|
secret_fields TEXT,
|
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE,
|
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
|
FOREIGN KEY (credential_id) REFERENCES ssh_credentials (id) ON DELETE SET NULL
|
|
);
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_host_protocol_auth_host_protocol ON host_protocol_auth (host_id, protocol);
|
|
CREATE INDEX IF NOT EXISTS idx_host_protocol_auth_user ON host_protocol_auth (user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_host_protocol_auth_credential ON host_protocol_auth (credential_id);
|
|
`);
|
|
} catch (createError) {
|
|
databaseLogger.warn("Failed to create host_protocol_auth table", {
|
|
operation: "schema_migration",
|
|
error: createError,
|
|
});
|
|
}
|
|
|
|
addColumnIfNotExists("ssh_data", "default_overrides", "TEXT");
|
|
try {
|
|
sqlite.exec(`
|
|
CREATE TABLE IF NOT EXISTS host_defaults (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
level TEXT NOT NULL,
|
|
scope_key TEXT NOT NULL,
|
|
user_id TEXT,
|
|
folder_id INTEGER,
|
|
namespace TEXT NOT NULL,
|
|
key TEXT NOT NULL,
|
|
value TEXT,
|
|
updated_by TEXT,
|
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
|
FOREIGN KEY (folder_id) REFERENCES ssh_folders (id) ON DELETE CASCADE
|
|
);
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_host_defaults_scope_key ON host_defaults (scope_key, namespace, key);
|
|
CREATE INDEX IF NOT EXISTS idx_host_defaults_user ON host_defaults (user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_host_defaults_folder ON host_defaults (folder_id);
|
|
`);
|
|
} catch (createError) {
|
|
databaseLogger.warn("Failed to create host_defaults table", {
|
|
operation: "schema_migration",
|
|
error: createError,
|
|
});
|
|
}
|
|
|
|
try {
|
|
if (getRawSettingValue("rbac_permission_levels_v2") === null) {
|
|
sqlite.exec(
|
|
"UPDATE host_access SET permission_level = 'connect' WHERE permission_level = 'view'",
|
|
);
|
|
setRawSettingValue("rbac_permission_levels_v2", "done");
|
|
}
|
|
} catch (migrateError) {
|
|
databaseLogger.warn("Failed to migrate legacy view permission level", {
|
|
operation: "schema_migration",
|
|
error: migrateError,
|
|
});
|
|
}
|
|
|
|
try {
|
|
const existingRoles = sqlite.prepare("SELECT name, is_system FROM roles").all() as Array<{ name: string; is_system: number }>;
|
|
|
|
try {
|
|
const validSystemRoles = ['admin', 'user'];
|
|
const unwantedRoleNames = ['superAdmin', 'powerUser', 'readonly', 'member'];
|
|
const deleteByName = sqlite.prepare("DELETE FROM roles WHERE name = ?");
|
|
for (const roleName of unwantedRoleNames) {
|
|
deleteByName.run(roleName);
|
|
}
|
|
|
|
const deleteOldSystemRole = sqlite.prepare("DELETE FROM roles WHERE name = ? AND is_system = 1");
|
|
for (const role of existingRoles) {
|
|
if (role.is_system === 1 && !validSystemRoles.includes(role.name) && !unwantedRoleNames.includes(role.name)) {
|
|
deleteOldSystemRole.run(role.name);
|
|
}
|
|
}
|
|
} catch (cleanupError) {
|
|
databaseLogger.warn("Failed to clean up old system roles", {
|
|
operation: "schema_migration",
|
|
error: cleanupError,
|
|
});
|
|
}
|
|
|
|
const systemRoles = Object.entries(SYSTEM_ROLE_DEFAULTS).map(
|
|
([name, defaults]) => ({
|
|
name,
|
|
displayName: `rbac.roles.${name}`,
|
|
description: defaults.description,
|
|
permissions: JSON.stringify(defaults.permissions),
|
|
}),
|
|
);
|
|
|
|
// Route-level RBAC needs the permission lists to exist; roles seeded by
|
|
// earlier versions carried NULL there. Backfill only NULL so an admin's
|
|
// edits to these roles are never overwritten.
|
|
const backfillPermissions = sqlite.prepare(
|
|
"UPDATE roles SET permissions = ? WHERE name = ? AND is_system = 1 AND permissions IS NULL",
|
|
);
|
|
for (const role of systemRoles) {
|
|
backfillPermissions.run(role.permissions, role.name);
|
|
}
|
|
|
|
for (const role of systemRoles) {
|
|
const existingRole = sqlite.prepare("SELECT id FROM roles WHERE name = ?").get(role.name);
|
|
if (!existingRole) {
|
|
try {
|
|
sqlite.prepare(`
|
|
INSERT INTO roles (name, display_name, description, is_system, permissions)
|
|
VALUES (?, ?, ?, 1, ?)
|
|
`).run(role.name, role.displayName, role.description, role.permissions);
|
|
} catch (insertError) {
|
|
databaseLogger.warn(`Failed to create system role: ${role.name}`, {
|
|
operation: "schema_migration",
|
|
error: insertError,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
try {
|
|
const adminUsers = sqlite.prepare("SELECT id FROM users WHERE is_admin = 1").all() as { id: string }[];
|
|
const normalUsers = sqlite.prepare("SELECT id FROM users WHERE is_admin = 0").all() as { id: string }[];
|
|
|
|
const adminRole = sqlite.prepare("SELECT id FROM roles WHERE name = 'admin'").get() as { id: number } | undefined;
|
|
const userRole = sqlite.prepare("SELECT id FROM roles WHERE name = 'user'").get() as { id: number } | undefined;
|
|
|
|
if (adminRole) {
|
|
const insertUserRole = sqlite.prepare(`
|
|
INSERT OR IGNORE INTO user_roles (user_id, role_id, granted_at)
|
|
VALUES (?, ?, CURRENT_TIMESTAMP)
|
|
`);
|
|
|
|
for (const admin of adminUsers) {
|
|
try {
|
|
insertUserRole.run(admin.id, adminRole.id);
|
|
} catch {
|
|
// Ignore duplicate errors
|
|
}
|
|
}
|
|
}
|
|
|
|
if (userRole) {
|
|
const insertUserRole = sqlite.prepare(`
|
|
INSERT OR IGNORE INTO user_roles (user_id, role_id, granted_at)
|
|
VALUES (?, ?, CURRENT_TIMESTAMP)
|
|
`);
|
|
|
|
for (const user of normalUsers) {
|
|
try {
|
|
insertUserRole.run(user.id, userRole.id);
|
|
} catch {
|
|
// Ignore duplicate errors
|
|
}
|
|
}
|
|
}
|
|
} catch (migrationError) {
|
|
databaseLogger.warn("Failed to migrate existing users to roles", {
|
|
operation: "schema_migration",
|
|
error: migrationError,
|
|
});
|
|
}
|
|
} catch (seedError) {
|
|
databaseLogger.warn("Failed to seed system roles", {
|
|
operation: "schema_migration",
|
|
error: seedError,
|
|
});
|
|
}
|
|
|
|
addColumnIfNotExists("users", "sso_provider_id", "INTEGER");
|
|
|
|
try {
|
|
const usersTableInfo = sqlite.prepare("PRAGMA table_info(users)").all() as Array<{
|
|
cid: number;
|
|
name: string;
|
|
type: string;
|
|
notnull: number;
|
|
dflt_value: string | null;
|
|
pk: number;
|
|
}>;
|
|
const legacyNotNullColumns = new Set([
|
|
"client_id",
|
|
"client_secret",
|
|
"issuer_url",
|
|
"authorization_url",
|
|
"token_url",
|
|
"identifier_path",
|
|
"name_path",
|
|
"scopes",
|
|
]);
|
|
const hasStaleNotNull = usersTableInfo.some(
|
|
(col) => legacyNotNullColumns.has(col.name) && col.notnull === 1,
|
|
);
|
|
|
|
if (hasStaleNotNull) {
|
|
const tempTableName = "users_temp_migration";
|
|
const columnDefs = usersTableInfo
|
|
.map((col) => {
|
|
const parts = [`"${col.name}"`, col.type || "TEXT"];
|
|
if (col.pk === 1) parts.push("PRIMARY KEY");
|
|
if (col.notnull === 1 && !legacyNotNullColumns.has(col.name)) {
|
|
parts.push("NOT NULL");
|
|
}
|
|
if (col.dflt_value !== null) {
|
|
parts.push(`DEFAULT ${col.dflt_value}`);
|
|
}
|
|
return parts.join(" ");
|
|
})
|
|
.join(",\n ");
|
|
const allColumns = usersTableInfo.map((col) => `"${col.name}"`).join(", ");
|
|
|
|
sqlite.exec(`PRAGMA foreign_keys = OFF`);
|
|
sqlite.exec(`
|
|
CREATE TABLE ${tempTableName} (
|
|
${columnDefs}
|
|
);
|
|
|
|
INSERT INTO ${tempTableName} SELECT ${allColumns} FROM users;
|
|
|
|
DROP TABLE users;
|
|
|
|
ALTER TABLE ${tempTableName} RENAME TO users;
|
|
`);
|
|
sqlite.exec(`PRAGMA foreign_keys = ON`);
|
|
|
|
databaseLogger.info(
|
|
"Successfully migrated users table to remove legacy OIDC NOT NULL constraints",
|
|
{
|
|
operation: "schema_migration_users_oidc_nullable",
|
|
},
|
|
);
|
|
}
|
|
} catch (migrationError) {
|
|
databaseLogger.warn("Failed to migrate users table legacy OIDC columns", {
|
|
operation: "schema_migration",
|
|
error: migrationError,
|
|
});
|
|
}
|
|
|
|
// --- alerts begin ---
|
|
// alert_rules, alert_rule_channels and alert_firings were only ever created
|
|
// here, never declared in schema.ts, so they never existed on Postgres or
|
|
// MySQL at all. Automations replaced them; drop the SQLite leftovers.
|
|
for (const table of [
|
|
"alert_firings",
|
|
"alert_rule_channels",
|
|
"alert_rules",
|
|
]) {
|
|
try {
|
|
sqlite.exec(`DROP TABLE IF EXISTS ${table};`);
|
|
} catch (dropError) {
|
|
databaseLogger.warn(`Failed to drop legacy ${table} table`, {
|
|
operation: "schema_migration",
|
|
error: dropError,
|
|
});
|
|
}
|
|
}
|
|
// --- alerts end ---
|
|
|
|
|
|
// --- sync begin ---
|
|
// Stable per-row identity used to match rows across two independently-
|
|
// seeded databases (the embedded desktop backend and a connected remote
|
|
// server) during sync. Local autoincrement ids collide across instances,
|
|
// so a randomly-generated id is the join key instead. SQLite refuses a
|
|
// non-constant DEFAULT (e.g. randomblob()) on ALTER TABLE ADD COLUMN for
|
|
// tables with existing constraints ("Cannot add a column with
|
|
// non-constant default"), so the column is added as plain nullable TEXT;
|
|
// repositories set syncId explicitly on insert going forward, and
|
|
// existing rows are backfilled by the UPDATE loop below.
|
|
addColumnIfNotExists("ssh_data", "sync_id", "TEXT");
|
|
addColumnIfNotExists("ssh_credentials", "sync_id", "TEXT");
|
|
addColumnIfNotExists("ssh_folders", "sync_id", "TEXT");
|
|
|
|
// Plugin runtime: lastError reports why a plugin is blocked or failed, and
|
|
// grants record whether a capability came from an admin or from bundling.
|
|
addColumnIfNotExists("plugins", "last_error", "TEXT");
|
|
addColumnIfNotExists(
|
|
"plugin_permission_grants",
|
|
"source",
|
|
"TEXT NOT NULL DEFAULT 'admin'",
|
|
);
|
|
relaxPluginGrantGrantedBy();
|
|
|
|
const syncIdTables = ["ssh_data", "ssh_credentials", "ssh_folders"];
|
|
|
|
for (const table of syncIdTables) {
|
|
try {
|
|
const result = sqlite
|
|
.prepare(
|
|
`UPDATE ${table} SET sync_id = lower(hex(randomblob(16))) WHERE sync_id IS NULL`,
|
|
)
|
|
.run();
|
|
if (result.changes > 0) {
|
|
databaseLogger.info(
|
|
`Backfilled sync_id for ${result.changes} row(s) in ${table}`,
|
|
{ operation: "sync_id_backfill", table },
|
|
);
|
|
}
|
|
sqlite.exec(
|
|
`CREATE UNIQUE INDEX IF NOT EXISTS idx_${table}_sync_id ON ${table}(sync_id)`,
|
|
);
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
databaseLogger.warn(
|
|
`Failed to backfill sync_id for ${table}: ${message}`,
|
|
{
|
|
operation: "sync_id_backfill",
|
|
table,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
addColumnIfNotExists(
|
|
"ssh_data",
|
|
"local_only",
|
|
"INTEGER NOT NULL DEFAULT 0",
|
|
);
|
|
addColumnIfNotExists("ssh_data", "shared_source", "TEXT");
|
|
addColumnIfNotExists("ssh_data", "ssh_options", "TEXT");
|
|
addColumnIfNotExists("ssh_credentials", "shared_source", "TEXT");
|
|
addColumnIfNotExists(
|
|
"ssh_folders",
|
|
"local_only",
|
|
"INTEGER NOT NULL DEFAULT 0",
|
|
);
|
|
|
|
try {
|
|
sqlite.exec(`
|
|
DROP TABLE IF EXISTS sync_tombstones;
|
|
|
|
CREATE TABLE IF NOT EXISTS sync_records (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
entity_type TEXT NOT NULL,
|
|
sync_id TEXT NOT NULL,
|
|
revision INTEGER NOT NULL DEFAULT 0,
|
|
seq INTEGER NOT NULL DEFAULT 0,
|
|
hash TEXT,
|
|
deleted INTEGER NOT NULL DEFAULT 0,
|
|
error TEXT,
|
|
error_hash TEXT,
|
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_sync_records_user_entity_sync
|
|
ON sync_records(user_id, entity_type, sync_id);
|
|
CREATE INDEX IF NOT EXISTS idx_sync_records_user_seq
|
|
ON sync_records(user_id, seq);
|
|
|
|
CREATE TABLE IF NOT EXISTS sync_conflicts (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
entity_type TEXT NOT NULL,
|
|
sync_id TEXT NOT NULL,
|
|
local_row TEXT NOT NULL,
|
|
server_revision INTEGER NOT NULL,
|
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_sync_conflicts_user
|
|
ON sync_conflicts(user_id);
|
|
|
|
CREATE TABLE IF NOT EXISTS sync_link (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
server_url TEXT NOT NULL,
|
|
server_name TEXT,
|
|
server_version TEXT,
|
|
session_token TEXT,
|
|
custom_headers TEXT,
|
|
basic_auth TEXT,
|
|
allow_invalid_certificate INTEGER NOT NULL DEFAULT 0,
|
|
remote_user_id TEXT,
|
|
remote_username TEXT,
|
|
account TEXT,
|
|
scope TEXT,
|
|
known_types TEXT,
|
|
cursor INTEGER NOT NULL DEFAULT 0,
|
|
status TEXT NOT NULL DEFAULT 'idle',
|
|
last_error TEXT,
|
|
linked_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
last_sync_at TEXT
|
|
);
|
|
`);
|
|
} catch (createError) {
|
|
databaseLogger.warn("Failed to create sync tables", {
|
|
operation: "schema_migration",
|
|
error: createError,
|
|
});
|
|
}
|
|
// --- sync end ---
|
|
|
|
// Audit trails and session recordings used to be deleted along with the user
|
|
// they referenced, which defeats the point of keeping them.
|
|
migrateAuditRetention(sqlite);
|
|
|
|
// Runs last so every table and column added above already exists.
|
|
createPerformanceIndexes(sqlite);
|
|
|
|
databaseLogger.success("Schema migration completed", {
|
|
operation: "schema_migration",
|
|
});
|
|
};
|
|
|
|
// A trivial telemetry write forces `serialize()` to rewrite every free page
|
|
// along with the live ones, so a database that has accumulated a large
|
|
// freelist (from years of unbounded metrics/audit growth before retention
|
|
// pruning existed) turns every future save into a multi-megabyte rewrite.
|
|
// Reclaiming that space once at startup keeps steady-state saves cheap.
|
|
const VACUUM_FREELIST_COUNT_THRESHOLD = 2000;
|
|
const VACUUM_FREELIST_RATIO_THRESHOLD = 0.5;
|
|
|
|
function vacuumIfFreelistBloated(): void {
|
|
try {
|
|
const pageCount = sqlite.pragma("page_count", { simple: true }) as number;
|
|
const freelistCount = sqlite.pragma("freelist_count", {
|
|
simple: true,
|
|
}) as number;
|
|
if (pageCount <= 0) return;
|
|
|
|
const freelistRatio = freelistCount / pageCount;
|
|
if (
|
|
freelistCount < VACUUM_FREELIST_COUNT_THRESHOLD ||
|
|
freelistRatio < VACUUM_FREELIST_RATIO_THRESHOLD
|
|
) {
|
|
return;
|
|
}
|
|
|
|
databaseLogger.info("Reclaiming bloated SQLite freelist on startup", {
|
|
operation: "db_startup_vacuum",
|
|
pageCount,
|
|
freelistCount,
|
|
freelistRatio,
|
|
});
|
|
sqlite.exec("VACUUM");
|
|
} catch (error) {
|
|
databaseLogger.warn("Failed to vacuum database on startup", {
|
|
operation: "db_startup_vacuum_failed",
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
}
|
|
}
|
|
|
|
async function saveMemoryDatabaseToFile(): Promise<void> {
|
|
if (!memoryDatabase) return;
|
|
|
|
try {
|
|
const buffer = memoryDatabase.serialize();
|
|
|
|
if (!fs.existsSync(dataDir)) {
|
|
fs.mkdirSync(dataDir, { recursive: true });
|
|
}
|
|
|
|
try {
|
|
memoryDatabase
|
|
.prepare("SELECT COUNT(*) as count FROM sessions")
|
|
.get() as { count: number };
|
|
} catch {
|
|
// expected - sessions table may not exist yet
|
|
}
|
|
|
|
if (enableFileEncryption) {
|
|
await DatabaseFileEncryption.encryptDatabaseFromBuffer(
|
|
buffer,
|
|
encryptedDbPath,
|
|
);
|
|
} else {
|
|
fs.writeFileSync(dbPath, buffer);
|
|
}
|
|
|
|
DatabaseSaveTrigger.markClean();
|
|
} catch (error) {
|
|
databaseLogger.error("Failed to save in-memory database", error, {
|
|
operation: "memory_db_save_failed",
|
|
enableFileEncryption,
|
|
});
|
|
}
|
|
}
|
|
|
|
async function handlePostInitFileEncryption() {
|
|
try {
|
|
if (memoryDatabase) {
|
|
DatabaseSaveTrigger.initialize(saveMemoryDatabaseToFile);
|
|
|
|
if (enableFileEncryption) {
|
|
await saveMemoryDatabaseToFile();
|
|
}
|
|
|
|
setInterval(() => {
|
|
if (DatabaseSaveTrigger.isDirty) {
|
|
saveMemoryDatabaseToFile();
|
|
}
|
|
}, 5 * 60 * 1000);
|
|
}
|
|
|
|
if (!enableFileEncryption) return;
|
|
|
|
try {
|
|
const migration = new DatabaseMigration(dataDir);
|
|
migration.cleanupOldBackups();
|
|
} catch (cleanupError) {
|
|
databaseLogger.warn("Failed to cleanup old migration files", {
|
|
operation: "migration_cleanup_startup_failed",
|
|
error:
|
|
getErrorMessage(cleanupError),
|
|
});
|
|
}
|
|
} catch (error) {
|
|
databaseLogger.error(
|
|
"Failed to handle database file encryption setup",
|
|
error,
|
|
{
|
|
operation: "db_encrypt_setup_failed",
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
async function initializeDatabase(): Promise<void> {
|
|
const dialect = resolveDatabaseDialect();
|
|
|
|
if (dialect !== "sqlite") {
|
|
await initializeRemoteDatabase(dialect);
|
|
return;
|
|
}
|
|
|
|
await initializeCompleteDatabase();
|
|
await handlePostInitFileEncryption();
|
|
}
|
|
|
|
/**
|
|
* Startup against Postgres or MySQL.
|
|
*
|
|
* Shorter than the SQLite path because most of what that one does has no
|
|
* counterpart here: there is no file to decrypt, no in-memory copy to keep in
|
|
* step with disk, and the schema comes from drizzle-kit migrations instead of
|
|
* the inline DDL below.
|
|
*
|
|
* What does carry over is the settings cache. 27 call sites read settings
|
|
* synchronously, which better-sqlite3 allows and no remote driver does, so the
|
|
* table is loaded once here before anything asks for it.
|
|
*/
|
|
async function initializeRemoteDatabase(
|
|
dialect: Exclude<DatabaseDialect, "sqlite">,
|
|
): Promise<void> {
|
|
databaseLogger.info(`Connecting to ${dialect} database`, {
|
|
operation: "db_init",
|
|
dialect,
|
|
});
|
|
|
|
db = await connectRemoteDatabase(dialect);
|
|
await runRemoteMigrations(dialect, db);
|
|
|
|
// Imported here rather than at the top: factory.ts imports getDb from this
|
|
// module, and a static import would close the cycle at module-load time.
|
|
const { primeCurrentSettingsCache, startSettingsCacheRefresh } = await import(
|
|
"../repositories/factory.js"
|
|
);
|
|
await primeCurrentSettingsCache();
|
|
startSettingsCacheRefresh();
|
|
|
|
// The SQLite bootstrap seeds system roles inline below; migrations for the
|
|
// remote dialects never did, and route-level RBAC denies a user with no
|
|
// usable role, so they are seeded (and backfilled) here.
|
|
const { ensureSystemRoles } = await import("../../utils/system-roles.js");
|
|
await ensureSystemRoles();
|
|
|
|
databaseLogger.info(`${dialect} database ready`, {
|
|
operation: "db_init_complete",
|
|
dialect,
|
|
});
|
|
}
|
|
|
|
export { initializeDatabase };
|
|
|
|
async function cleanupDatabase() {
|
|
if (memoryDatabase) {
|
|
try {
|
|
await saveMemoryDatabaseToFile();
|
|
} catch (error) {
|
|
databaseLogger.error(
|
|
"Failed to save in-memory database before shutdown",
|
|
error,
|
|
{
|
|
operation: "shutdown_save_failed",
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
try {
|
|
if (sqlite) {
|
|
sqlite.close();
|
|
}
|
|
} catch (error) {
|
|
databaseLogger.warn("Error closing database connection", {
|
|
operation: "db_close_error",
|
|
error: getErrorMessage(error),
|
|
});
|
|
}
|
|
|
|
try {
|
|
const tempDir = path.join(dataDir, ".temp");
|
|
if (fs.existsSync(tempDir)) {
|
|
const files = fs.readdirSync(tempDir);
|
|
for (const file of files) {
|
|
try {
|
|
fs.unlinkSync(path.join(tempDir, file));
|
|
} catch {
|
|
// expected - file cleanup best effort
|
|
}
|
|
}
|
|
|
|
try {
|
|
fs.rmdirSync(tempDir);
|
|
} catch {
|
|
// expected - dir cleanup best effort
|
|
}
|
|
}
|
|
} catch {
|
|
// expected - temp dir cleanup best effort
|
|
}
|
|
}
|
|
|
|
process.on("exit", () => {
|
|
if (sqlite) {
|
|
try {
|
|
sqlite.close();
|
|
} catch {
|
|
// expected - database may already be closed
|
|
}
|
|
}
|
|
});
|
|
|
|
process.on("SIGINT", async () => {
|
|
databaseLogger.info("Received SIGINT, cleaning up...", {
|
|
operation: "shutdown",
|
|
});
|
|
await cleanupDatabase();
|
|
process.exit(0);
|
|
});
|
|
|
|
process.on("SIGTERM", async () => {
|
|
databaseLogger.info("Received SIGTERM, cleaning up...", {
|
|
operation: "shutdown",
|
|
});
|
|
await cleanupDatabase();
|
|
process.exit(0);
|
|
});
|
|
|
|
let db: PortableDatabase;
|
|
|
|
export function getDb(): PortableDatabase {
|
|
if (!db) {
|
|
throw new Error(
|
|
"Database not initialized. Ensure initializeDatabase() is called before accessing db.",
|
|
);
|
|
}
|
|
return db;
|
|
}
|
|
|
|
export function getSqlite(): Database.Database {
|
|
if (!sqlite) {
|
|
const dialect = resolveDatabaseDialect();
|
|
if (dialect !== "sqlite") {
|
|
throw new Error(
|
|
`No SQLite handle: DATABASE_DIALECT is "${dialect}". This caller needs a ` +
|
|
`synchronous query, which only SQLite offers — give it an async path instead.`,
|
|
);
|
|
}
|
|
throw new Error(
|
|
"SQLite not initialized. Ensure initializeDatabase() is called before accessing sqlite.",
|
|
);
|
|
}
|
|
return sqlite;
|
|
}
|
|
|
|
|
|
|
|
export { saveMemoryDatabaseToFile };
|
|
|
|
export { DatabaseSaveTrigger };
|