From 1cdbfc674cf042b1982faecb2a96edb2e0362199 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Sun, 18 Oct 2026 21:17:54 -0500 Subject: [PATCH] feat: move desktop, status, import, audit and branding features into plugins --- CHANGELOG.md | 13 + README.md | 4 + drizzle/mysql/0074_mature_puff_adder.sql | 4 + drizzle/mysql/meta/0074_snapshot.json | 4663 ++++++++++++++++ drizzle/mysql/meta/_journal.json | 7 + drizzle/postgres/0074_bouncy_sersi.sql | 4 + drizzle/postgres/meta/0074_snapshot.json | 4692 +++++++++++++++++ drizzle/postgres/meta/_journal.json | 7 + drizzle/sqlite/0069_normal_ozymandias.sql | 4 + drizzle/sqlite/meta/0069_snapshot.json | 4391 +++++++++++++++ drizzle/sqlite/meta/_journal.json | 7 + electron/c2s-auth.cjs | 78 - electron/external-editor.cjs | 70 - electron/local-files.cjs | 1064 ---- electron/local-shell.cjs | 71 - electron/main.cjs | 1979 +------ electron/native-rdp.cjs | 85 - electron/preload.js | 111 - electron/rename-local-path.cjs | 22 - env.catalog.json | 17 +- package-lock.json | 2 +- packages/plugin-sdk/CHANGELOG.md | 16 + packages/plugin-sdk/PLUGIN-GUIDE.md | 142 +- packages/plugin-sdk/package.json | 6 +- .../plugin-sdk/schema/manifest.schema.json | 10 + packages/plugin-sdk/src/backend.ts | 433 ++ packages/plugin-sdk/src/capabilities.ts | 5 + packages/plugin-sdk/src/db.ts | 1 + packages/plugin-sdk/src/desktop.ts | 286 - packages/plugin-sdk/src/frontend.ts | 31 +- packages/plugin-sdk/src/manifest.ts | 33 +- packages/plugin-sdk/src/ssh-keys.ts | 122 + packages/plugin-sdk/src/testing.ts | 282 +- packages/plugin-sdk/ui.api.txt | 22 +- scripts/check-core-plugin-ids.cjs | 4 + scripts/check-sdk-ui-exports.cjs | 20 +- scripts/electron-backend-ready.test.ts | 6 +- scripts/electron-external-editor.test.ts | 40 - scripts/electron-security-boundary.test.ts | 18 +- src/backend/database/db/index.ts | 11 - .../database/db/performance-indexes.ts | 7 +- src/backend/database/db/schema.mysql.ts | 22 - src/backend/database/db/schema.pg.ts | 22 - src/backend/database/db/schema.ts | 22 - src/backend/database/repositories/factory.ts | 8 - .../recent-activity-repository.ts | 107 - .../database/routes/audit-log-routes.ts | 127 - .../database/routes/branding-routes.ts | 139 +- .../database/routes/branding-settings.ts | 104 +- .../routes/credential-deploy-routes.ts | 436 -- .../database/routes/credential-key-routes.ts | 519 -- src/backend/database/routes/credentials.ts | 17 - .../database/routes/dashboard-routes.ts | 18 +- .../database/routes/delete-user-data.ts | 4 +- .../database/routes/host-bulk-routes.ts | 824 +-- .../database/routes/host-folder-routes.ts | 6 +- .../database/routes/host-status-routes.ts | 32 +- src/backend/database/routes/host.ts | 585 +- src/backend/database/routes/plugins.ts | 11 +- .../routes/user-password-reset-routes.ts | 4 +- .../database/routes/user-settings-routes.ts | 124 - src/backend/database/routes/users.ts | 2 +- src/backend/hosts/delete-host.ts | 4 +- src/backend/hosts/import/host-export.ts | 213 + src/backend/hosts/import/host-import.ts | 829 +++ src/backend/hosts/resolve-host-credentials.ts | 211 + .../hosts/status/host-status-service.ts | 491 -- src/backend/hosts/status/host-status.ts | 9 - src/backend/hosts/status/limiter.ts | 81 - src/backend/plugins/activity-provider.ts | 105 + src/backend/plugins/ctx-audit.ts | 143 + src/backend/plugins/ctx-credentials.ts | 68 + src/backend/plugins/ctx-desktop-local.ts | 259 + src/backend/plugins/ctx-host-import.ts | 192 + src/backend/plugins/ctx-hosts.ts | 91 +- src/backend/plugins/ctx-probe.ts | 172 + src/backend/plugins/ctx-ssh-auth.ts | 40 +- src/backend/plugins/ctx-ssh-exec.ts | 123 + src/backend/plugins/ctx.ts | 14 + src/backend/plugins/desktop-local/local-fs.ts | 572 ++ .../plugins/desktop-local/local-pty.ts | 167 + src/backend/plugins/events.ts | 2 +- src/backend/plugins/host-status-provider.ts | 149 + src/backend/plugins/loader.ts | 17 +- .../probe}/jump-probe-pool.ts | 7 + .../status => plugins/probe}/tcp-ping.ts | 0 src/backend/plugins/registry.ts | 12 + src/backend/plugins/settings.ts | 4 + src/backend/services/recent-activity.ts | 90 - src/backend/starter.ts | 8 +- src/backend/sync/client/http.ts | 32 + src/backend/sync/client/plugins.ts | 13 +- src/backend/sync/server/routes.ts | 9 +- .../recent-activity-repository.test.ts | 99 - .../database/routes/branding-routes.test.ts | 238 +- .../database/routes/branding-settings.test.ts | 169 +- .../credentials-secret-reference.test.ts | 6 - .../database/routes/dashboard-routes.test.ts | 13 +- .../database/routes/host-bulk-routes.test.ts | 2 +- .../host-import-terminal-settings.test.ts | 58 +- .../routes/host-status-routes.test.ts | 24 +- .../routes/rbac-route-coverage.test.ts | 10 - .../routes/user-password-reset-routes.test.ts | 9 +- src/backend/tests/electron/c2s-auth.test.ts | 86 - .../tests/electron/c2s-runtime.test.ts | 238 - .../tests/electron/linked-certificate.test.ts | 12 +- .../electron/local-collection-limit.test.ts | 54 - .../tests/electron/local-files.test.ts | 1166 ---- src/backend/tests/electron/native-rdp.test.ts | 36 - .../tests/electron/rename-local-path.test.ts | 41 - .../tests/hosts/import/host-export.test.ts | 147 + .../tests/hosts/import/host-import.test.ts | 134 + .../hosts/status/host-status-service.test.ts | 312 -- .../tests/hosts/status/host-status.test.ts | 17 - .../tests/hosts/status/limiter.test.ts | 162 - .../tests/plugins/activity-provider.test.ts | 80 + src/backend/tests/plugins/ctx-audit.test.ts | 189 +- .../plugins/ctx-credentials-keys.test.ts | 179 + .../tests/plugins/ctx-desktop-local.test.ts | 248 + .../tests/plugins/ctx-host-import.test.ts | 264 + src/backend/tests/plugins/ctx-hosts.test.ts | 19 +- src/backend/tests/plugins/ctx-probe.test.ts | 182 + .../tests/plugins/ctx-ssh-auth.test.ts | 4 +- .../tests/plugins/ctx-ssh-exec.test.ts | 104 + .../tests/plugins/desktop-local-fs.test.ts | 483 ++ .../desktop-local-pty.test.ts} | 32 +- .../plugins/host-status-provider.test.ts | 99 + src/backend/tests/plugins/manifest.test.ts | 22 + src/backend/tests/plugins/migrations.test.ts | 4 + .../probe}/jump-probe-pool.test.ts | 2 +- .../status => plugins/probe}/tcp-ping.test.ts | 2 +- .../tests/plugins/sdk-ssh-keys.test.ts | 60 + .../tests/plugins/security/ctx-guards.test.ts | 11 +- .../audit-forwarding-migration.test.ts | 144 + .../host-status-settings-migration.test.ts | 86 + src/backend/tests/utils/audit-export.test.ts | 125 - .../tests/utils/audit-forwarder.test.ts | 169 - .../upgrade/audit-forwarding-migration.ts | 87 + .../upgrade/host-status-settings-migration.ts | 64 + src/backend/upgrade/plugin-data-moves.ts | 6 + src/backend/utils/audit-export.ts | 70 - src/backend/utils/audit-forwarder.ts | 152 - src/backend/utils/audit-hooks.ts | 32 + src/backend/utils/audit-logger.ts | 8 +- src/main.tsx | 3 - src/types/onboarding.ts | 2 - src/ui/api/audit-log-api.ts | 30 - src/ui/api/credentials-api.ts | 110 - src/ui/api/settings-api.ts | 17 - src/ui/api/ssh-host-management-api.ts | 86 - src/ui/locales/en.json | 66 +- src/ui/main-axios.ts | 10 - src/ui/onboarding/onboarding-flow.ts | 12 +- src/ui/onboarding/onboarding-registry.ts | 11 + src/ui/onboarding/onboarding-steps.ts | 29 +- src/ui/onboarding/steps/AppearanceStep.tsx | 82 - src/ui/onboarding/steps/PresetStep.tsx | 109 - src/ui/plugin-host/app.ts | 30 +- src/ui/plugin-host/sdk-ui.ts | 27 +- src/ui/sidebar/host-contributions.ts | 29 + src/ui/sidebar/host-export-payload.ts | 225 - .../tests/onboarding/onboarding-flow.test.ts | 39 + .../tests/onboarding/onboarding-steps.test.ts | 22 +- .../tests/sidebar/host-contributions.test.ts | 59 + .../tests/sidebar/host-export-payload.test.ts | 323 -- src/ui/tests/types/onboarding.test.ts | 8 +- 166 files changed, 21298 insertions(+), 12041 deletions(-) create mode 100644 drizzle/mysql/0074_mature_puff_adder.sql create mode 100644 drizzle/mysql/meta/0074_snapshot.json create mode 100644 drizzle/postgres/0074_bouncy_sersi.sql create mode 100644 drizzle/postgres/meta/0074_snapshot.json create mode 100644 drizzle/sqlite/0069_normal_ozymandias.sql create mode 100644 drizzle/sqlite/meta/0069_snapshot.json delete mode 100644 electron/c2s-auth.cjs delete mode 100644 electron/external-editor.cjs delete mode 100644 electron/local-files.cjs delete mode 100644 electron/local-shell.cjs delete mode 100644 electron/native-rdp.cjs delete mode 100644 electron/rename-local-path.cjs create mode 100644 packages/plugin-sdk/src/ssh-keys.ts delete mode 100644 scripts/electron-external-editor.test.ts delete mode 100644 src/backend/database/repositories/recent-activity-repository.ts delete mode 100644 src/backend/database/routes/credential-deploy-routes.ts delete mode 100644 src/backend/database/routes/credential-key-routes.ts create mode 100644 src/backend/hosts/import/host-export.ts create mode 100644 src/backend/hosts/import/host-import.ts create mode 100644 src/backend/hosts/resolve-host-credentials.ts delete mode 100644 src/backend/hosts/status/host-status-service.ts delete mode 100644 src/backend/hosts/status/host-status.ts delete mode 100644 src/backend/hosts/status/limiter.ts create mode 100644 src/backend/plugins/activity-provider.ts create mode 100644 src/backend/plugins/ctx-audit.ts create mode 100644 src/backend/plugins/ctx-desktop-local.ts create mode 100644 src/backend/plugins/ctx-host-import.ts create mode 100644 src/backend/plugins/ctx-probe.ts create mode 100644 src/backend/plugins/ctx-ssh-exec.ts create mode 100644 src/backend/plugins/desktop-local/local-fs.ts create mode 100644 src/backend/plugins/desktop-local/local-pty.ts create mode 100644 src/backend/plugins/host-status-provider.ts rename src/backend/{hosts/status => plugins/probe}/jump-probe-pool.ts (90%) rename src/backend/{hosts/status => plugins/probe}/tcp-ping.ts (100%) delete mode 100644 src/backend/services/recent-activity.ts delete mode 100644 src/backend/tests/database/repositories/recent-activity-repository.test.ts delete mode 100644 src/backend/tests/electron/c2s-auth.test.ts delete mode 100644 src/backend/tests/electron/c2s-runtime.test.ts delete mode 100644 src/backend/tests/electron/local-collection-limit.test.ts delete mode 100644 src/backend/tests/electron/local-files.test.ts delete mode 100644 src/backend/tests/electron/native-rdp.test.ts delete mode 100644 src/backend/tests/electron/rename-local-path.test.ts create mode 100644 src/backend/tests/hosts/import/host-export.test.ts create mode 100644 src/backend/tests/hosts/import/host-import.test.ts delete mode 100644 src/backend/tests/hosts/status/host-status-service.test.ts delete mode 100644 src/backend/tests/hosts/status/host-status.test.ts delete mode 100644 src/backend/tests/hosts/status/limiter.test.ts create mode 100644 src/backend/tests/plugins/activity-provider.test.ts create mode 100644 src/backend/tests/plugins/ctx-credentials-keys.test.ts create mode 100644 src/backend/tests/plugins/ctx-desktop-local.test.ts create mode 100644 src/backend/tests/plugins/ctx-host-import.test.ts create mode 100644 src/backend/tests/plugins/ctx-probe.test.ts create mode 100644 src/backend/tests/plugins/ctx-ssh-exec.test.ts create mode 100644 src/backend/tests/plugins/desktop-local-fs.test.ts rename src/backend/tests/{electron/local-shell.test.ts => plugins/desktop-local-pty.test.ts} (73%) create mode 100644 src/backend/tests/plugins/host-status-provider.test.ts rename src/backend/tests/{hosts/status => plugins/probe}/jump-probe-pool.test.ts (98%) rename src/backend/tests/{hosts/status => plugins/probe}/tcp-ping.test.ts (95%) create mode 100644 src/backend/tests/plugins/sdk-ssh-keys.test.ts create mode 100644 src/backend/tests/upgrade/audit-forwarding-migration.test.ts create mode 100644 src/backend/tests/upgrade/host-status-settings-migration.test.ts delete mode 100644 src/backend/tests/utils/audit-export.test.ts delete mode 100644 src/backend/tests/utils/audit-forwarder.test.ts create mode 100644 src/backend/upgrade/audit-forwarding-migration.ts create mode 100644 src/backend/upgrade/host-status-settings-migration.ts delete mode 100644 src/backend/utils/audit-export.ts delete mode 100644 src/backend/utils/audit-forwarder.ts create mode 100644 src/backend/utils/audit-hooks.ts delete mode 100644 src/ui/onboarding/steps/AppearanceStep.tsx delete mode 100644 src/ui/onboarding/steps/PresetStep.tsx delete mode 100644 src/ui/sidebar/host-export-payload.ts create mode 100644 src/ui/tests/sidebar/host-contributions.test.ts delete mode 100644 src/ui/tests/sidebar/host-export-payload.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 301be4959..9f711436b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,19 @@ - The themes besides light and dark now come from a bundled themes plugin. It can be swapped but not removed, and Termix keeps a basic light and dark theme of its own - A theme that no longer exists shows as System instead of an unstyled page - Installs and updates from a registry check the Termix version a plugin needs, not only its plugin API +- Host online and offline dots now come from the Host Status plugin. The check interval setting moved to its settings and keeps its value. Without the plugin hosts show no dot +- Audit log forwarding and the CSV and NDJSON export now come from the Audit Tools plugin. The collector URL and token move to its settings on upgrade. The audit log viewer, retention and the audit log itself stay in Termix +- Client tunnels on your own computer and opening a host in Windows Remote Desktop moved out of the desktop app into plugins, so fixes to them no longer need a new desktop build +- The local terminal, the local side of the file manager with its transfers, and opening a file in an external editor moved out of the desktop app into plugins, so fixes to them no longer need a new desktop build +- The recent activity list and the first run steps for the preset and for theme, accent, font and language now come from bundled plugins. Existing entries and choices are kept, and people who went through those steps are not asked again +- Branding (app name, tagline and logo) is now saved by the bundled layout plugin. An existing setup keeps its branding, and the sign in screen still reads it from Termix +- Without the plugin that stores recent activity, the list stays empty and nothing is recorded +- Importing hosts from a JSON or SSH config file and exporting hosts now come from the Import and Export plugin. Exports are now written to the audit log. Without the plugin there is no import or export +- Generating and checking SSH keys in the credential editor and deploying a key to a host now come from the Credential Tools plugin. Deploys are now written to the audit log. Without the plugin those buttons are hidden + +### Removed + +- The desktop app no longer has its own client tunnel and native Remote Desktop code ## 26.10.2 diff --git a/README.md b/README.md index 431c00761..d6b3c217d 100644 --- a/README.md +++ b/README.md @@ -62,15 +62,19 @@ Plugins add everything else. The official ones ship with Termix, and admins can | [ACME Certificates](https://github.com/Termix-SSH/Plugin-ACME-SSL) | Gets the Termix HTTPS certificate from Let's Encrypt or any ACME provider and renews it for you. | | [AI Assistant](https://github.com/Termix-SSH/Plugin-AI) | An AI assistant that reads your setup and proposes changes for you to approve. | | [Alerts](https://github.com/Termix-SSH/Plugin-Alerts) | One inbox for every alert in Termix, with delivery to webhooks, ntfy, Discord and email. | +| [Audit Tools](https://github.com/Termix-SSH/Plugin-Audit-Tools) | Send the audit log to your SIEM and export it as CSV or NDJSON. | | [Automations](https://github.com/Termix-SSH/Plugin-Automations) | Run steps on your hosts on a schedule or when something happens. | | [Classic Layout](https://github.com/Termix-SSH/Plugin-Classic-Layout) | The classic Termix layout with an icon rail, sidebar, tab bar and dock. | | [Classic Themes](https://github.com/Termix-SSH/Plugin-Classic-Themes) | Light, dark and seven more color themes for Termix. | +| [Credential Tools](https://github.com/Termix-SSH/Plugin-Credential-Tools) | Generate SSH keys, check them and copy a public key to a host. | | [Docker](https://github.com/Termix-SSH/Plugin-Docker) | Manage Docker and Podman containers on your hosts over SSH, with logs, stats and a console. | | [File Manager](https://github.com/Termix-SSH/Plugin-File-Manager) | Browse, edit and move files over SFTP, including straight from one server to another. | | [Fleets](https://github.com/Termix-SSH/Plugin-Fleets) | Group hosts into fleets and run commands, package actions and file transfers on all of them at once. | | [HashiCorp Vault](https://github.com/Termix-SSH/Plugin-Vault) | Connect to hosts with short-lived SSH certificates signed by HashiCorp Vault. | | [Homepage](https://github.com/Termix-SSH/Plugin-Homepage) | A drag and drop widget page for your servers and services, plus service links for the dashboard. | | [Host Metrics](https://github.com/Termix-SSH/Plugin-Host-Metrics) | Live CPU, memory, disk, network and GPU stats, plus tools to manage your hosts. | +| [Host Status](https://github.com/Termix-SSH/Plugin-Host-Status) | Shows if each host is online with a dot in the host list, checked by connecting to its port. | +| [Import and Export](https://github.com/Termix-SSH/Plugin-Import-Export) | Import hosts from a JSON file or an SSH config file and export them to share or back up. | | [LDAP](https://github.com/Termix-SSH/Plugin-LDAP) | Sign in with your LDAP or Active Directory username and password. | | [Network Topology](https://github.com/Termix-SSH/Plugin-Network-Topology) | Draws your hosts and the links between them as a live, interactive graph. | | [OPKSSH](https://github.com/Termix-SSH/Plugin-OPKSSH) | Connect to hosts with short-lived OpenPubkey SSH certificates after signing in with your identity provider. | diff --git a/drizzle/mysql/0074_mature_puff_adder.sql b/drizzle/mysql/0074_mature_puff_adder.sql new file mode 100644 index 000000000..4a1b9eb98 --- /dev/null +++ b/drizzle/mysql/0074_mature_puff_adder.sql @@ -0,0 +1,4 @@ +-- recent_activity moved to the homepage plugin, whose migration renames it to +-- p_homepage_recent_activity. Dropping it here would lose every entry, so this +-- only stops drizzle tracking it. SELECT 1 because MySQL rejects an empty query. +SELECT 1; diff --git a/drizzle/mysql/meta/0074_snapshot.json b/drizzle/mysql/meta/0074_snapshot.json new file mode 100644 index 000000000..2adb78371 --- /dev/null +++ b/drizzle/mysql/meta/0074_snapshot.json @@ -0,0 +1,4663 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "d510d006-0efe-4be9-b1a2-7ee7ba966a0b", + "prevId": "5d267d9f-4402-4b0e-a14c-3d69a3854963", + "tables": { + "api_keys": { + "name": "api_keys", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_prefix": { + "name": "token_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + } + }, + "indexes": { + "idx_api_keys_user_id": { + "name": "idx_api_keys_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "api_keys_user_id_users_id_fk": { + "name": "api_keys_user_id_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "api_keys_id": { + "name": "api_keys_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "audit_logs": { + "name": "audit_logs", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource_type": { + "name": "resource_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "details": { + "name": "details", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_audit_logs_timestamp": { + "name": "idx_audit_logs_timestamp", + "columns": [ + "timestamp" + ], + "isUnique": false + }, + "idx_audit_logs_user_ts": { + "name": "idx_audit_logs_user_ts", + "columns": [ + "user_id", + "timestamp" + ], + "isUnique": false + }, + "idx_audit_logs_action_ts": { + "name": "idx_audit_logs_action_ts", + "columns": [ + "action", + "timestamp" + ], + "isUnique": false + }, + "idx_audit_logs_resource_ts": { + "name": "idx_audit_logs_resource_ts", + "columns": [ + "resource_type", + "timestamp" + ], + "isUnique": false + } + }, + "foreignKeys": { + "audit_logs_user_id_users_id_fk": { + "name": "audit_logs_user_id_users_id_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "audit_logs_id": { + "name": "audit_logs_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "credential_access": { + "name": "credential_access", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "credential_id": { + "name": "credential_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('use')" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_credential_access_user_id": { + "name": "idx_credential_access_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_credential_access_role_id": { + "name": "idx_credential_access_role_id", + "columns": [ + "role_id" + ], + "isUnique": false + }, + "idx_credential_access_credential_id": { + "name": "idx_credential_access_credential_id", + "columns": [ + "credential_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "credential_access_credential_id_ssh_credentials_id_fk": { + "name": "credential_access_credential_id_ssh_credentials_id_fk", + "tableFrom": "credential_access", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_access_user_id_users_id_fk": { + "name": "credential_access_user_id_users_id_fk", + "tableFrom": "credential_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_access_role_id_roles_id_fk": { + "name": "credential_access_role_id_roles_id_fk", + "tableFrom": "credential_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_access_granted_by_users_id_fk": { + "name": "credential_access_granted_by_users_id_fk", + "tableFrom": "credential_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "credential_access_id": { + "name": "credential_access_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "credential_sidebar_preferences": { + "name": "credential_sidebar_preferences", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "credential_sidebar_preferences_user_id_users_id_fk": { + "name": "credential_sidebar_preferences_user_id_users_id_fk", + "tableFrom": "credential_sidebar_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "credential_sidebar_preferences_user_id": { + "name": "credential_sidebar_preferences_user_id", + "columns": [ + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "folder_access": { + "name": "folder_access", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "folder": { + "name": "folder", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('connect')" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_folder_access_owner_folder": { + "name": "idx_folder_access_owner_folder", + "columns": [ + "owner_user_id", + "folder" + ], + "isUnique": false + } + }, + "foreignKeys": { + "folder_access_owner_user_id_users_id_fk": { + "name": "folder_access_owner_user_id_users_id_fk", + "tableFrom": "folder_access", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_access_user_id_users_id_fk": { + "name": "folder_access_user_id_users_id_fk", + "tableFrom": "folder_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_access_role_id_roles_id_fk": { + "name": "folder_access_role_id_roles_id_fk", + "tableFrom": "folder_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_access_granted_by_users_id_fk": { + "name": "folder_access_granted_by_users_id_fk", + "tableFrom": "folder_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "folder_access_id": { + "name": "folder_access_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "host_access": { + "name": "host_access", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('connect')" + }, + "expires_at": { + "name": "expires_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "last_accessed_at": { + "name": "last_accessed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_count": { + "name": "access_count", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "idx_host_access_user_id": { + "name": "idx_host_access_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_host_access_role_id": { + "name": "idx_host_access_role_id", + "columns": [ + "role_id" + ], + "isUnique": false + }, + "idx_host_access_host_id": { + "name": "idx_host_access_host_id", + "columns": [ + "host_id" + ], + "isUnique": false + }, + "idx_host_access_expires_at": { + "name": "idx_host_access_expires_at", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "host_access_host_id_ssh_data_id_fk": { + "name": "host_access_host_id_ssh_data_id_fk", + "tableFrom": "host_access", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_access_user_id_users_id_fk": { + "name": "host_access_user_id_users_id_fk", + "tableFrom": "host_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_access_role_id_roles_id_fk": { + "name": "host_access_role_id_roles_id_fk", + "tableFrom": "host_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_access_granted_by_users_id_fk": { + "name": "host_access_granted_by_users_id_fk", + "tableFrom": "host_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "host_access_id": { + "name": "host_access_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "host_defaults": { + "name": "host_defaults", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "level": { + "name": "level", + "type": "enum('admin','user','folder')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope_key": { + "name": "scope_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "folder_id": { + "name": "folder_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "namespace": { + "name": "namespace", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_host_defaults_scope_key": { + "name": "idx_host_defaults_scope_key", + "columns": [ + "scope_key", + "namespace", + "key" + ], + "isUnique": true + }, + "idx_host_defaults_user": { + "name": "idx_host_defaults_user", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_host_defaults_folder": { + "name": "idx_host_defaults_folder", + "columns": [ + "folder_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "host_defaults_user_id_users_id_fk": { + "name": "host_defaults_user_id_users_id_fk", + "tableFrom": "host_defaults", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_defaults_folder_id_ssh_folders_id_fk": { + "name": "host_defaults_folder_id_ssh_folders_id_fk", + "tableFrom": "host_defaults", + "tableTo": "ssh_folders", + "columnsFrom": [ + "folder_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "host_defaults_id": { + "name": "host_defaults_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "host_protocol_auth": { + "name": "host_protocol_auth", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "protocol": { + "name": "protocol", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('direct')" + }, + "credential_id": { + "name": "credential_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fields": { + "name": "fields", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "secret_fields": { + "name": "secret_fields", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_host_protocol_auth_host_protocol": { + "name": "idx_host_protocol_auth_host_protocol", + "columns": [ + "host_id", + "protocol" + ], + "isUnique": true + }, + "idx_host_protocol_auth_user": { + "name": "idx_host_protocol_auth_user", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_host_protocol_auth_credential": { + "name": "idx_host_protocol_auth_credential", + "columns": [ + "credential_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "host_protocol_auth_host_id_ssh_data_id_fk": { + "name": "host_protocol_auth_host_id_ssh_data_id_fk", + "tableFrom": "host_protocol_auth", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_protocol_auth_user_id_users_id_fk": { + "name": "host_protocol_auth_user_id_users_id_fk", + "tableFrom": "host_protocol_auth", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_protocol_auth_credential_id_ssh_credentials_id_fk": { + "name": "host_protocol_auth_credential_id_ssh_credentials_id_fk", + "tableFrom": "host_protocol_auth", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "host_protocol_auth_id": { + "name": "host_protocol_auth_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "host_sidebar_preferences": { + "name": "host_sidebar_preferences", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "host_sidebar_preferences_user_id_users_id_fk": { + "name": "host_sidebar_preferences_user_id_users_id_fk", + "tableFrom": "host_sidebar_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "host_sidebar_preferences_user_id": { + "name": "host_sidebar_preferences_user_id", + "columns": [ + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "ssh_data": { + "name": "ssh_data", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connection_type": { + "name": "connection_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('ssh')" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "port": { + "name": "port", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "folder": { + "name": "folder", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parent_host_id": { + "name": "parent_host_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pin": { + "name": "pin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "share_ssh_auth": { + "name": "share_ssh_auth", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "force_keyboard_interactive": { + "name": "force_keyboard_interactive", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_password": { + "name": "key_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sudo_password": { + "name": "sudo_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "credential_id": { + "name": "credential_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "override_credential_username": { + "name": "override_credential_username", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "jump_hosts": { + "name": "jump_hosts", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_check_enabled": { + "name": "status_check_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "status_check_interval": { + "name": "status_check_interval", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ssh_options": { + "name": "ssh_options", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enable_ssh": { + "name": "enable_ssh", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "ssh_port": { + "name": "ssh_port", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 22 + }, + "use_socks5": { + "name": "use_socks5", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_host": { + "name": "socks5_host", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_port": { + "name": "socks5_port", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_username": { + "name": "socks5_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_password": { + "name": "socks5_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_proxy_chain": { + "name": "socks5_proxy_chain", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connection_origin": { + "name": "connection_origin", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "port_knock_sequence": { + "name": "port_knock_sequence", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_fingerprint": { + "name": "host_key_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_type": { + "name": "host_key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_algorithm": { + "name": "host_key_algorithm", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "('sha256')" + }, + "host_key_first_seen": { + "name": "host_key_first_seen", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_last_verified": { + "name": "host_key_last_verified", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_changed_count": { + "name": "host_key_changed_count", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "local_only": { + "name": "local_only", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "shared_source": { + "name": "shared_source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_overrides": { + "name": "default_overrides", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_ssh_data_user_id": { + "name": "idx_ssh_data_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_ssh_data_parent_host": { + "name": "idx_ssh_data_parent_host", + "columns": [ + "parent_host_id" + ], + "isUnique": false + }, + "idx_ssh_data_credential": { + "name": "idx_ssh_data_credential", + "columns": [ + "credential_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ssh_data_user_id_users_id_fk": { + "name": "ssh_data_user_id_users_id_fk", + "tableFrom": "ssh_data", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_data_parent_host_id_ssh_data_id_fk": { + "name": "ssh_data_parent_host_id_ssh_data_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_data", + "columnsFrom": [ + "parent_host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ssh_data_id": { + "name": "ssh_data_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "ssh_data_sync_id_unique": { + "name": "ssh_data_sync_id_unique", + "columns": [ + "sync_id" + ] + } + }, + "checkConstraint": {} + }, + "plugin_install_counts": { + "name": "plugin_install_counts", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "registry_id": { + "name": "registry_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_plugin_install_counts_plugin_registry": { + "name": "idx_plugin_install_counts_plugin_registry", + "columns": [ + "plugin_id", + "registry_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "plugin_install_counts_id": { + "name": "plugin_install_counts_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "plugin_migrations": { + "name": "plugin_migrations", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "migration_id": { + "name": "migration_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_at": { + "name": "applied_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_plugin_migrations_plugin_migration": { + "name": "idx_plugin_migrations_plugin_migration", + "columns": [ + "plugin_id", + "migration_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "plugin_migrations_plugin_id_plugins_id_fk": { + "name": "plugin_migrations_plugin_id_plugins_id_fk", + "tableFrom": "plugin_migrations", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "plugin_migrations_id": { + "name": "plugin_migrations_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "plugin_permission_grants": { + "name": "plugin_permission_grants", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "capability": { + "name": "capability", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "granted_at": { + "name": "granted_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('admin')" + } + }, + "indexes": { + "idx_plugin_permission_grants_plugin_capability": { + "name": "idx_plugin_permission_grants_plugin_capability", + "columns": [ + "plugin_id", + "capability" + ], + "isUnique": true + } + }, + "foreignKeys": { + "plugin_permission_grants_plugin_id_plugins_id_fk": { + "name": "plugin_permission_grants_plugin_id_plugins_id_fk", + "tableFrom": "plugin_permission_grants", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "plugin_permission_grants_granted_by_users_id_fk": { + "name": "plugin_permission_grants_granted_by_users_id_fk", + "tableFrom": "plugin_permission_grants", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "plugin_permission_grants_id": { + "name": "plugin_permission_grants_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "plugin_registries": { + "name": "plugin_registries", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('community')" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "signing_key": { + "name": "signing_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_index_hash": { + "name": "last_index_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "plugin_registries_id": { + "name": "plugin_registries_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "plugin_settings": { + "name": "plugin_settings", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "enum('admin','user','host','secret')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope_id": { + "name": "scope_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted": { + "name": "encrypted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_plugin_settings_scope_key": { + "name": "idx_plugin_settings_scope_key", + "columns": [ + "plugin_id", + "scope", + "scope_id", + "key" + ], + "isUnique": true + }, + "idx_plugin_settings_plugin_scope": { + "name": "idx_plugin_settings_plugin_scope", + "columns": [ + "plugin_id", + "scope" + ], + "isUnique": false + } + }, + "foreignKeys": { + "plugin_settings_plugin_id_plugins_id_fk": { + "name": "plugin_settings_plugin_id_plugins_id_fk", + "tableFrom": "plugin_settings", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "plugin_settings_id": { + "name": "plugin_settings_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "plugin_storage": { + "name": "plugin_storage", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_plugin_storage_plugin_key": { + "name": "idx_plugin_storage_plugin_key", + "columns": [ + "plugin_id", + "key" + ], + "isUnique": true + } + }, + "foreignKeys": { + "plugin_storage_plugin_id_plugins_id_fk": { + "name": "plugin_storage_plugin_id_plugins_id_fk", + "tableFrom": "plugin_storage", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "plugin_storage_id": { + "name": "plugin_storage_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "plugins": { + "name": "plugins", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('available')" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('community')" + }, + "registry_id": { + "name": "registry_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('disabled')" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "installed_at": { + "name": "installed_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "auto_update": { + "name": "auto_update", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "pinned_version": { + "name": "pinned_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('stable')" + }, + "manifest_json": { + "name": "manifest_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_plugins_registry_id": { + "name": "idx_plugins_registry_id", + "columns": [ + "registry_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "plugins_id": { + "name": "plugins_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "rbac_applied_defaults": { + "name": "rbac_applied_defaults", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "role_name": { + "name": "role_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_at": { + "name": "applied_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_rbac_applied_defaults_role_permission": { + "name": "idx_rbac_applied_defaults_role_permission", + "columns": [ + "role_name", + "permission" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "rbac_applied_defaults_id": { + "name": "rbac_applied_defaults_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "rbac_known_permissions": { + "name": "rbac_known_permissions", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "permission": { + "name": "permission", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_rbac_known_permissions_permission": { + "name": "idx_rbac_known_permissions_permission", + "columns": [ + "permission" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "rbac_known_permissions_id": { + "name": "rbac_known_permissions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "roles": { + "name": "roles", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "roles_id": { + "name": "roles_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "roles_name_unique": { + "name": "roles_name_unique", + "columns": [ + "name" + ] + } + }, + "checkConstraint": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "jwt_token": { + "name": "jwt_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_info": { + "name": "device_info", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "external_session_ref": { + "name": "external_session_ref", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "expires_at": { + "name": "expires_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_active_at": { + "name": "last_active_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_sessions_user_id": { + "name": "idx_sessions_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_sessions_expires_at": { + "name": "idx_sessions_expires_at", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sessions_id": { + "name": "sessions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "settings": { + "name": "settings", + "columns": { + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "settings_key": { + "name": "settings_key", + "columns": [ + "key" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "shared_credential_secrets": { + "name": "shared_credential_secrets", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "credential_access_id": { + "name": "credential_access_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_user_id": { + "name": "target_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credential_id": { + "name": "credential_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encrypted_username": { + "name": "encrypted_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('password')" + }, + "encrypted_password": { + "name": "encrypted_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_key": { + "name": "encrypted_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_key_password": { + "name": "encrypted_key_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cert_public_key": { + "name": "cert_public_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_shared_credential_secrets_scope": { + "name": "idx_shared_credential_secrets_scope", + "columns": [ + "credential_access_id", + "target_user_id" + ], + "isUnique": true + }, + "idx_shared_credential_secrets_target": { + "name": "idx_shared_credential_secrets_target", + "columns": [ + "target_user_id", + "credential_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "shared_credential_secrets_target_user_id_users_id_fk": { + "name": "shared_credential_secrets_target_user_id_users_id_fk", + "tableFrom": "shared_credential_secrets", + "tableTo": "users", + "columnsFrom": [ + "target_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_credential_secrets_credential_id_ssh_credentials_id_fk": { + "name": "shared_credential_secrets_credential_id_ssh_credentials_id_fk", + "tableFrom": "shared_credential_secrets", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_cred_secrets_access_id_fk": { + "name": "shared_cred_secrets_access_id_fk", + "tableFrom": "shared_credential_secrets", + "tableTo": "credential_access", + "columnsFrom": [ + "credential_access_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "shared_credential_secrets_id": { + "name": "shared_credential_secrets_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "shared_host_auth_overrides": { + "name": "shared_host_auth_overrides", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "protocol": { + "name": "protocol", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ssh'" + }, + "credential_id": { + "name": "credential_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "shared_host_auth_overrides_host_user_protocol_unique": { + "name": "shared_host_auth_overrides_host_user_protocol_unique", + "columns": [ + "host_id", + "user_id", + "protocol" + ], + "isUnique": true + } + }, + "foreignKeys": { + "shared_host_auth_overrides_host_id_ssh_data_id_fk": { + "name": "shared_host_auth_overrides_host_id_ssh_data_id_fk", + "tableFrom": "shared_host_auth_overrides", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_auth_overrides_user_id_users_id_fk": { + "name": "shared_host_auth_overrides_user_id_users_id_fk", + "tableFrom": "shared_host_auth_overrides", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_auth_overrides_credential_id_ssh_credentials_id_fk": { + "name": "shared_host_auth_overrides_credential_id_ssh_credentials_id_fk", + "tableFrom": "shared_host_auth_overrides", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "shared_host_auth_overrides_id": { + "name": "shared_host_auth_overrides_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "shared_host_secrets": { + "name": "shared_host_secrets", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "host_access_id": { + "name": "host_access_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_user_id": { + "name": "target_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "protocol": { + "name": "protocol", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ssh'" + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('credential')" + }, + "original_credential_id": { + "name": "original_credential_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_username": { + "name": "encrypted_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_auth_type": { + "name": "encrypted_auth_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_password": { + "name": "encrypted_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_key": { + "name": "encrypted_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_key_password": { + "name": "encrypted_key_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_key_type": { + "name": "encrypted_key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_domain": { + "name": "encrypted_domain", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_fields": { + "name": "encrypted_fields", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_shared_host_secrets_scope": { + "name": "idx_shared_host_secrets_scope", + "columns": [ + "host_access_id", + "target_user_id", + "protocol" + ], + "isUnique": true + } + }, + "foreignKeys": { + "shared_host_secrets_host_access_id_host_access_id_fk": { + "name": "shared_host_secrets_host_access_id_host_access_id_fk", + "tableFrom": "shared_host_secrets", + "tableTo": "host_access", + "columnsFrom": [ + "host_access_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_secrets_target_user_id_users_id_fk": { + "name": "shared_host_secrets_target_user_id_users_id_fk", + "tableFrom": "shared_host_secrets", + "tableTo": "users", + "columnsFrom": [ + "target_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_secrets_original_credential_id_ssh_credentials_id_fk": { + "name": "shared_host_secrets_original_credential_id_ssh_credentials_id_fk", + "tableFrom": "shared_host_secrets", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "original_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "shared_host_secrets_id": { + "name": "shared_host_secrets_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "ssh_credential_usage": { + "name": "ssh_credential_usage", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "credential_id": { + "name": "credential_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "used_at": { + "name": "used_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_ssh_credential_usage_credential": { + "name": "idx_ssh_credential_usage_credential", + "columns": [ + "credential_id" + ], + "isUnique": false + }, + "idx_ssh_credential_usage_user": { + "name": "idx_ssh_credential_usage_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ssh_credential_usage_credential_id_ssh_credentials_id_fk": { + "name": "ssh_credential_usage_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_credential_usage", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_credential_usage_host_id_ssh_data_id_fk": { + "name": "ssh_credential_usage_host_id_ssh_data_id_fk", + "tableFrom": "ssh_credential_usage", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_credential_usage_user_id_users_id_fk": { + "name": "ssh_credential_usage_user_id_users_id_fk", + "tableFrom": "ssh_credential_usage", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ssh_credential_usage_id": { + "name": "ssh_credential_usage_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "ssh_credentials": { + "name": "ssh_credentials", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "folder": { + "name": "folder", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pin": { + "name": "pin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_password": { + "name": "key_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "detected_key_type": { + "name": "detected_key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cert_public_key": { + "name": "cert_public_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "usage_count": { + "name": "usage_count", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "shared_source": { + "name": "shared_source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_ssh_credentials_user_id": { + "name": "idx_ssh_credentials_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ssh_credentials_user_id_users_id_fk": { + "name": "ssh_credentials_user_id_users_id_fk", + "tableFrom": "ssh_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ssh_credentials_id": { + "name": "ssh_credentials_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "ssh_credentials_sync_id_unique": { + "name": "ssh_credentials_sync_id_unique", + "columns": [ + "sync_id" + ] + } + }, + "checkConstraint": {} + }, + "ssh_folders": { + "name": "ssh_folders", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "local_only": { + "name": "local_only", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_ssh_folders_user_id": { + "name": "idx_ssh_folders_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ssh_folders_user_id_users_id_fk": { + "name": "ssh_folders_user_id_users_id_fk", + "tableFrom": "ssh_folders", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ssh_folders_id": { + "name": "ssh_folders_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "ssh_folders_sync_id_unique": { + "name": "ssh_folders_sync_id_unique", + "columns": [ + "sync_id" + ] + } + }, + "checkConstraint": {} + }, + "sync_conflicts": { + "name": "sync_conflicts", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "local_row": { + "name": "local_row", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "server_revision": { + "name": "server_revision", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_sync_conflicts_user": { + "name": "idx_sync_conflicts_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sync_conflicts_user_id_users_id_fk": { + "name": "sync_conflicts_user_id_users_id_fk", + "tableFrom": "sync_conflicts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sync_conflicts_id": { + "name": "sync_conflicts_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "sync_link": { + "name": "sync_link", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "server_url": { + "name": "server_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "server_name": { + "name": "server_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "server_version": { + "name": "server_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "custom_headers": { + "name": "custom_headers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "basic_auth": { + "name": "basic_auth", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allow_invalid_certificate": { + "name": "allow_invalid_certificate", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "remote_user_id": { + "name": "remote_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remote_username": { + "name": "remote_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "account": { + "name": "account", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "known_types": { + "name": "known_types", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cursor": { + "name": "cursor", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('idle')" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "linked_at": { + "name": "linked_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "sync_link_user_id_users_id_fk": { + "name": "sync_link_user_id_users_id_fk", + "tableFrom": "sync_link", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sync_link_id": { + "name": "sync_link_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "sync_records": { + "name": "sync_records", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision": { + "name": "revision", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "seq": { + "name": "seq", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_hash": { + "name": "error_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_sync_records_user_entity_sync": { + "name": "idx_sync_records_user_entity_sync", + "columns": [ + "user_id", + "entity_type", + "sync_id" + ], + "isUnique": true + }, + "idx_sync_records_user_seq": { + "name": "idx_sync_records_user_seq", + "columns": [ + "user_id", + "seq" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sync_records_user_id_users_id_fk": { + "name": "sync_records_user_id_users_id_fk", + "tableFrom": "sync_records", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sync_records_id": { + "name": "sync_records_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "trusted_devices": { + "name": "trusted_devices", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_fingerprint": { + "name": "device_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_info": { + "name": "device_info", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_trusted_devices_user_id": { + "name": "idx_trusted_devices_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "trusted_devices_user_id_users_id_fk": { + "name": "trusted_devices_user_id_users_id_fk", + "tableFrom": "trusted_devices", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "trusted_devices_id": { + "name": "trusted_devices_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "ui_preferences": { + "name": "ui_preferences", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "ui_preferences_user_id_users_id_fk": { + "name": "ui_preferences_user_id_users_id_fk", + "tableFrom": "ui_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ui_preferences_user_id": { + "name": "ui_preferences_user_id", + "columns": [ + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user_external_identities": { + "name": "user_external_identities", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_user_external_identities_provider_subject": { + "name": "idx_user_external_identities_provider_subject", + "columns": [ + "provider_id", + "subject" + ], + "isUnique": true + }, + "idx_user_external_identities_user": { + "name": "idx_user_external_identities_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_external_identities_user_id_users_id_fk": { + "name": "user_external_identities_user_id_users_id_fk", + "tableFrom": "user_external_identities", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_external_identities_id": { + "name": "user_external_identities_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user_open_tabs": { + "name": "user_open_tabs", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_type": { + "name": "tab_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_order": { + "name": "tab_order", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "backend_session_id": { + "name": "backend_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_user_open_tabs_user_id": { + "name": "idx_user_open_tabs_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_open_tabs_user_id_users_id_fk": { + "name": "user_open_tabs_user_id_users_id_fk", + "tableFrom": "user_open_tabs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_open_tabs_host_id_ssh_data_id_fk": { + "name": "user_open_tabs_host_id_ssh_data_id_fk", + "tableFrom": "user_open_tabs", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_open_tabs_id": { + "name": "user_open_tabs_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user_preferences": { + "name": "user_preferences", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reopen_tabs_on_login": { + "name": "reopen_tabs_on_login", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "font_size": { + "name": "font_size", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "accent_color": { + "name": "accent_color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "storage_mode": { + "name": "storage_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "command_palette_enabled": { + "name": "command_palette_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "show_host_tags": { + "name": "show_host_tags", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_tray_on_click": { + "name": "host_tray_on_click", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pin_app_rail": { + "name": "pin_app_rail", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expand_app_rail_on_hover": { + "name": "expand_app_rail_on_hover", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "show_pin_app_rail_button": { + "name": "show_pin_app_rail_button", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "folders_collapsed": { + "name": "folders_collapsed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "disable_update_check": { + "name": "disable_update_check", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "confirm_tab_close": { + "name": "confirm_tab_close", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hidden_rail_tabs": { + "name": "hidden_rail_tabs", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "compact_host_view": { + "name": "compact_host_view", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_color_scheme": { + "name": "status_color_scheme", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "custom_keybindings": { + "name": "custom_keybindings", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "layout_id": { + "name": "layout_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "theme_id": { + "name": "theme_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "user_preferences_user_id_users_id_fk": { + "name": "user_preferences_user_id_users_id_fk", + "tableFrom": "user_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_preferences_user_id": { + "name": "user_preferences_user_id", + "columns": [ + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user_roles": { + "name": "user_roles", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "granted_at": { + "name": "granted_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_user_roles_user_role": { + "name": "idx_user_roles_user_role", + "columns": [ + "user_id", + "role_id" + ], + "isUnique": true + }, + "idx_user_roles_role_id": { + "name": "idx_user_roles_role_id", + "columns": [ + "role_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_role_id_roles_id_fk": { + "name": "user_roles_role_id_roles_id_fk", + "tableFrom": "user_roles", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_granted_by_users_id_fk": { + "name": "user_roles_granted_by_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_roles_id": { + "name": "user_roles_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user_second_factors": { + "name": "user_second_factors", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "factor_id": { + "name": "factor_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enrolled_at": { + "name": "enrolled_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_user_second_factors_user_factor": { + "name": "idx_user_second_factors_user_factor", + "columns": [ + "user_id", + "plugin_id", + "factor_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "user_second_factors_user_id_users_id_fk": { + "name": "user_second_factors_user_id_users_id_fk", + "tableFrom": "user_second_factors", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_second_factors_id": { + "name": "user_second_factors_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "registered_at": { + "name": "registered_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_users_username": { + "name": "idx_users_username", + "columns": [ + "username" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/mysql/meta/_journal.json b/drizzle/mysql/meta/_journal.json index fd37620c4..0067bd5c7 100644 --- a/drizzle/mysql/meta/_journal.json +++ b/drizzle/mysql/meta/_journal.json @@ -519,6 +519,13 @@ "when": 1792357129912, "tag": "0073_user_look_preferences", "breakpoints": true + }, + { + "idx": 74, + "version": "5", + "when": 1792374489419, + "tag": "0074_mature_puff_adder", + "breakpoints": true } ] } \ No newline at end of file diff --git a/drizzle/postgres/0074_bouncy_sersi.sql b/drizzle/postgres/0074_bouncy_sersi.sql new file mode 100644 index 000000000..4a1b9eb98 --- /dev/null +++ b/drizzle/postgres/0074_bouncy_sersi.sql @@ -0,0 +1,4 @@ +-- recent_activity moved to the homepage plugin, whose migration renames it to +-- p_homepage_recent_activity. Dropping it here would lose every entry, so this +-- only stops drizzle tracking it. SELECT 1 because MySQL rejects an empty query. +SELECT 1; diff --git a/drizzle/postgres/meta/0074_snapshot.json b/drizzle/postgres/meta/0074_snapshot.json new file mode 100644 index 000000000..bb1fff386 --- /dev/null +++ b/drizzle/postgres/meta/0074_snapshot.json @@ -0,0 +1,4692 @@ +{ + "id": "54fb180b-bc95-4868-b619-b9317d3c22da", + "prevId": "4d77fb47-8211-4b7b-a4d3-8c3c3c414bbf", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_prefix": { + "name": "token_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": { + "idx_api_keys_user_id": { + "name": "idx_api_keys_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_keys_user_id_users_id_fk": { + "name": "api_keys_user_id_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timestamp": { + "name": "timestamp", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_audit_logs_timestamp": { + "name": "idx_audit_logs_timestamp", + "columns": [ + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_logs_user_ts": { + "name": "idx_audit_logs_user_ts", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_logs_action_ts": { + "name": "idx_audit_logs_action_ts", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_logs_resource_ts": { + "name": "idx_audit_logs_resource_ts", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_logs_user_id_users_id_fk": { + "name": "audit_logs_user_id_users_id_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_access": { + "name": "credential_access", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'use'" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_credential_access_user_id": { + "name": "idx_credential_access_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_credential_access_role_id": { + "name": "idx_credential_access_role_id", + "columns": [ + { + "expression": "role_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_credential_access_credential_id": { + "name": "idx_credential_access_credential_id", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_access_credential_id_ssh_credentials_id_fk": { + "name": "credential_access_credential_id_ssh_credentials_id_fk", + "tableFrom": "credential_access", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_access_user_id_users_id_fk": { + "name": "credential_access_user_id_users_id_fk", + "tableFrom": "credential_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_access_role_id_roles_id_fk": { + "name": "credential_access_role_id_roles_id_fk", + "tableFrom": "credential_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_access_granted_by_users_id_fk": { + "name": "credential_access_granted_by_users_id_fk", + "tableFrom": "credential_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_sidebar_preferences": { + "name": "credential_sidebar_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "credential_sidebar_preferences_user_id_users_id_fk": { + "name": "credential_sidebar_preferences_user_id_users_id_fk", + "tableFrom": "credential_sidebar_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.folder_access": { + "name": "folder_access", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "folder": { + "name": "folder", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'connect'" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_folder_access_owner_folder": { + "name": "idx_folder_access_owner_folder", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "folder", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "folder_access_owner_user_id_users_id_fk": { + "name": "folder_access_owner_user_id_users_id_fk", + "tableFrom": "folder_access", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_access_user_id_users_id_fk": { + "name": "folder_access_user_id_users_id_fk", + "tableFrom": "folder_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_access_role_id_roles_id_fk": { + "name": "folder_access_role_id_roles_id_fk", + "tableFrom": "folder_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_access_granted_by_users_id_fk": { + "name": "folder_access_granted_by_users_id_fk", + "tableFrom": "folder_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.host_access": { + "name": "host_access", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'connect'" + }, + "expires_at": { + "name": "expires_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "last_accessed_at": { + "name": "last_accessed_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_count": { + "name": "access_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "idx_host_access_user_id": { + "name": "idx_host_access_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_host_access_role_id": { + "name": "idx_host_access_role_id", + "columns": [ + { + "expression": "role_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_host_access_host_id": { + "name": "idx_host_access_host_id", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_host_access_expires_at": { + "name": "idx_host_access_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "host_access_host_id_ssh_data_id_fk": { + "name": "host_access_host_id_ssh_data_id_fk", + "tableFrom": "host_access", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_access_user_id_users_id_fk": { + "name": "host_access_user_id_users_id_fk", + "tableFrom": "host_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_access_role_id_roles_id_fk": { + "name": "host_access_role_id_roles_id_fk", + "tableFrom": "host_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_access_granted_by_users_id_fk": { + "name": "host_access_granted_by_users_id_fk", + "tableFrom": "host_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.host_defaults": { + "name": "host_defaults", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_key": { + "name": "scope_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "namespace": { + "name": "namespace", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_host_defaults_scope_key": { + "name": "idx_host_defaults_scope_key", + "columns": [ + { + "expression": "scope_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_host_defaults_user": { + "name": "idx_host_defaults_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_host_defaults_folder": { + "name": "idx_host_defaults_folder", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "host_defaults_user_id_users_id_fk": { + "name": "host_defaults_user_id_users_id_fk", + "tableFrom": "host_defaults", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_defaults_folder_id_ssh_folders_id_fk": { + "name": "host_defaults_folder_id_ssh_folders_id_fk", + "tableFrom": "host_defaults", + "tableTo": "ssh_folders", + "columnsFrom": [ + "folder_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.host_protocol_auth": { + "name": "host_protocol_auth", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "protocol": { + "name": "protocol", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'direct'" + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fields": { + "name": "fields", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_fields": { + "name": "secret_fields", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_host_protocol_auth_host_protocol": { + "name": "idx_host_protocol_auth_host_protocol", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "protocol", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_host_protocol_auth_user": { + "name": "idx_host_protocol_auth_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_host_protocol_auth_credential": { + "name": "idx_host_protocol_auth_credential", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "host_protocol_auth_host_id_ssh_data_id_fk": { + "name": "host_protocol_auth_host_id_ssh_data_id_fk", + "tableFrom": "host_protocol_auth", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_protocol_auth_user_id_users_id_fk": { + "name": "host_protocol_auth_user_id_users_id_fk", + "tableFrom": "host_protocol_auth", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_protocol_auth_credential_id_ssh_credentials_id_fk": { + "name": "host_protocol_auth_credential_id_ssh_credentials_id_fk", + "tableFrom": "host_protocol_auth", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.host_sidebar_preferences": { + "name": "host_sidebar_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "host_sidebar_preferences_user_id_users_id_fk": { + "name": "host_sidebar_preferences_user_id_users_id_fk", + "tableFrom": "host_sidebar_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ssh_data": { + "name": "ssh_data", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection_type": { + "name": "connection_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ssh'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "folder": { + "name": "folder", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_host_id": { + "name": "parent_host_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pin": { + "name": "pin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "share_ssh_auth": { + "name": "share_ssh_auth", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "force_keyboard_interactive": { + "name": "force_keyboard_interactive", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_password": { + "name": "key_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sudo_password": { + "name": "sudo_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "override_credential_username": { + "name": "override_credential_username", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "jump_hosts": { + "name": "jump_hosts", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_check_enabled": { + "name": "status_check_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "status_check_interval": { + "name": "status_check_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ssh_options": { + "name": "ssh_options", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enable_ssh": { + "name": "enable_ssh", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "ssh_port": { + "name": "ssh_port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 22 + }, + "use_socks5": { + "name": "use_socks5", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "socks5_host": { + "name": "socks5_host", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "socks5_port": { + "name": "socks5_port", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "socks5_username": { + "name": "socks5_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "socks5_password": { + "name": "socks5_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "socks5_proxy_chain": { + "name": "socks5_proxy_chain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "connection_origin": { + "name": "connection_origin", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "port_knock_sequence": { + "name": "port_knock_sequence", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "host_key_fingerprint": { + "name": "host_key_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "host_key_type": { + "name": "host_key_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "host_key_algorithm": { + "name": "host_key_algorithm", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'sha256'" + }, + "host_key_first_seen": { + "name": "host_key_first_seen", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "host_key_last_verified": { + "name": "host_key_last_verified", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "host_key_changed_count": { + "name": "host_key_changed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "local_only": { + "name": "local_only", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "shared_source": { + "name": "shared_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_overrides": { + "name": "default_overrides", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_ssh_data_user_id": { + "name": "idx_ssh_data_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_ssh_data_parent_host": { + "name": "idx_ssh_data_parent_host", + "columns": [ + { + "expression": "parent_host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_ssh_data_credential": { + "name": "idx_ssh_data_credential", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ssh_data_user_id_users_id_fk": { + "name": "ssh_data_user_id_users_id_fk", + "tableFrom": "ssh_data", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_data_parent_host_id_ssh_data_id_fk": { + "name": "ssh_data_parent_host_id_ssh_data_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_data", + "columnsFrom": [ + "parent_host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ssh_data_sync_id_unique": { + "name": "ssh_data_sync_id_unique", + "nullsNotDistinct": false, + "columns": [ + "sync_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_install_counts": { + "name": "plugin_install_counts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "registry_id": { + "name": "registry_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_plugin_install_counts_plugin_registry": { + "name": "idx_plugin_install_counts_plugin_registry", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registry_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_migrations": { + "name": "plugin_migrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "migration_id": { + "name": "migration_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "applied_at": { + "name": "applied_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_plugin_migrations_plugin_migration": { + "name": "idx_plugin_migrations_plugin_migration", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "migration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_migrations_plugin_id_plugins_id_fk": { + "name": "plugin_migrations_plugin_id_plugins_id_fk", + "tableFrom": "plugin_migrations", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_permission_grants": { + "name": "plugin_permission_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "capability": { + "name": "capability", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "granted_at": { + "name": "granted_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'admin'" + } + }, + "indexes": { + "idx_plugin_permission_grants_plugin_capability": { + "name": "idx_plugin_permission_grants_plugin_capability", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "capability", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_permission_grants_plugin_id_plugins_id_fk": { + "name": "plugin_permission_grants_plugin_id_plugins_id_fk", + "tableFrom": "plugin_permission_grants", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "plugin_permission_grants_granted_by_users_id_fk": { + "name": "plugin_permission_grants_granted_by_users_id_fk", + "tableFrom": "plugin_permission_grants", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_registries": { + "name": "plugin_registries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'community'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "signing_key": { + "name": "signing_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_index_hash": { + "name": "last_index_hash", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_settings": { + "name": "plugin_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted": { + "name": "encrypted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_plugin_settings_scope_key": { + "name": "idx_plugin_settings_scope_key", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_plugin_settings_plugin_scope": { + "name": "idx_plugin_settings_plugin_scope", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_settings_plugin_id_plugins_id_fk": { + "name": "plugin_settings_plugin_id_plugins_id_fk", + "tableFrom": "plugin_settings", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_storage": { + "name": "plugin_storage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_plugin_storage_plugin_key": { + "name": "idx_plugin_storage_plugin_key", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_storage_plugin_id_plugins_id_fk": { + "name": "plugin_storage_plugin_id_plugins_id_fk", + "tableFrom": "plugin_storage", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugins": { + "name": "plugins", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tier": { + "name": "tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'available'" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'community'" + }, + "registry_id": { + "name": "registry_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'disabled'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "installed_at": { + "name": "installed_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "auto_update": { + "name": "auto_update", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "pinned_version": { + "name": "pinned_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'stable'" + }, + "manifest_json": { + "name": "manifest_json", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_plugins_registry_id": { + "name": "idx_plugins_registry_id", + "columns": [ + { + "expression": "registry_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rbac_applied_defaults": { + "name": "rbac_applied_defaults", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "role_name": { + "name": "role_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "applied_at": { + "name": "applied_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_rbac_applied_defaults_role_permission": { + "name": "idx_rbac_applied_defaults_role_permission", + "columns": [ + { + "expression": "role_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rbac_known_permissions": { + "name": "rbac_known_permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_rbac_known_permissions_permission": { + "name": "idx_rbac_known_permissions_permission", + "columns": [ + { + "expression": "permission", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.roles": { + "name": "roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "roles_name_unique": { + "name": "roles_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "jwt_token": { + "name": "jwt_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_info": { + "name": "device_info", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_session_ref": { + "name": "external_session_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "last_active_at": { + "name": "last_active_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_sessions_user_id": { + "name": "idx_sessions_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_sessions_expires_at": { + "name": "idx_sessions_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shared_credential_secrets": { + "name": "shared_credential_secrets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "credential_access_id": { + "name": "credential_access_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "target_user_id": { + "name": "target_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "encrypted_username": { + "name": "encrypted_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'password'" + }, + "encrypted_password": { + "name": "encrypted_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_key": { + "name": "encrypted_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_key_password": { + "name": "encrypted_key_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cert_public_key": { + "name": "cert_public_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_shared_credential_secrets_scope": { + "name": "idx_shared_credential_secrets_scope", + "columns": [ + { + "expression": "credential_access_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_shared_credential_secrets_target": { + "name": "idx_shared_credential_secrets_target", + "columns": [ + { + "expression": "target_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shared_credential_secrets_target_user_id_users_id_fk": { + "name": "shared_credential_secrets_target_user_id_users_id_fk", + "tableFrom": "shared_credential_secrets", + "tableTo": "users", + "columnsFrom": [ + "target_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_credential_secrets_credential_id_ssh_credentials_id_fk": { + "name": "shared_credential_secrets_credential_id_ssh_credentials_id_fk", + "tableFrom": "shared_credential_secrets", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_cred_secrets_access_id_fk": { + "name": "shared_cred_secrets_access_id_fk", + "tableFrom": "shared_credential_secrets", + "tableTo": "credential_access", + "columnsFrom": [ + "credential_access_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shared_host_auth_overrides": { + "name": "shared_host_auth_overrides", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "protocol": { + "name": "protocol", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'ssh'" + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "shared_host_auth_overrides_host_user_protocol_unique": { + "name": "shared_host_auth_overrides_host_user_protocol_unique", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "protocol", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shared_host_auth_overrides_host_id_ssh_data_id_fk": { + "name": "shared_host_auth_overrides_host_id_ssh_data_id_fk", + "tableFrom": "shared_host_auth_overrides", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_auth_overrides_user_id_users_id_fk": { + "name": "shared_host_auth_overrides_user_id_users_id_fk", + "tableFrom": "shared_host_auth_overrides", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_auth_overrides_credential_id_ssh_credentials_id_fk": { + "name": "shared_host_auth_overrides_credential_id_ssh_credentials_id_fk", + "tableFrom": "shared_host_auth_overrides", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shared_host_secrets": { + "name": "shared_host_secrets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "host_access_id": { + "name": "host_access_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "target_user_id": { + "name": "target_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "protocol": { + "name": "protocol", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'ssh'" + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'credential'" + }, + "original_credential_id": { + "name": "original_credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "encrypted_username": { + "name": "encrypted_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_auth_type": { + "name": "encrypted_auth_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_password": { + "name": "encrypted_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_key": { + "name": "encrypted_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_key_password": { + "name": "encrypted_key_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_key_type": { + "name": "encrypted_key_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_domain": { + "name": "encrypted_domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_fields": { + "name": "encrypted_fields", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_shared_host_secrets_scope": { + "name": "idx_shared_host_secrets_scope", + "columns": [ + { + "expression": "host_access_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "protocol", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shared_host_secrets_host_access_id_host_access_id_fk": { + "name": "shared_host_secrets_host_access_id_host_access_id_fk", + "tableFrom": "shared_host_secrets", + "tableTo": "host_access", + "columnsFrom": [ + "host_access_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_secrets_target_user_id_users_id_fk": { + "name": "shared_host_secrets_target_user_id_users_id_fk", + "tableFrom": "shared_host_secrets", + "tableTo": "users", + "columnsFrom": [ + "target_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_secrets_original_credential_id_ssh_credentials_id_fk": { + "name": "shared_host_secrets_original_credential_id_ssh_credentials_id_fk", + "tableFrom": "shared_host_secrets", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "original_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ssh_credential_usage": { + "name": "ssh_credential_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_ssh_credential_usage_credential": { + "name": "idx_ssh_credential_usage_credential", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_ssh_credential_usage_user": { + "name": "idx_ssh_credential_usage_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ssh_credential_usage_credential_id_ssh_credentials_id_fk": { + "name": "ssh_credential_usage_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_credential_usage", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_credential_usage_host_id_ssh_data_id_fk": { + "name": "ssh_credential_usage_host_id_ssh_data_id_fk", + "tableFrom": "ssh_credential_usage", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_credential_usage_user_id_users_id_fk": { + "name": "ssh_credential_usage_user_id_users_id_fk", + "tableFrom": "ssh_credential_usage", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ssh_credentials": { + "name": "ssh_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder": { + "name": "folder", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pin": { + "name": "pin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_password": { + "name": "key_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "detected_key_type": { + "name": "detected_key_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cert_public_key": { + "name": "cert_public_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "usage_count": { + "name": "usage_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "shared_source": { + "name": "shared_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_ssh_credentials_user_id": { + "name": "idx_ssh_credentials_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ssh_credentials_user_id_users_id_fk": { + "name": "ssh_credentials_user_id_users_id_fk", + "tableFrom": "ssh_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ssh_credentials_sync_id_unique": { + "name": "ssh_credentials_sync_id_unique", + "nullsNotDistinct": false, + "columns": [ + "sync_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ssh_folders": { + "name": "ssh_folders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "local_only": { + "name": "local_only", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_ssh_folders_user_id": { + "name": "idx_ssh_folders_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ssh_folders_user_id_users_id_fk": { + "name": "ssh_folders_user_id_users_id_fk", + "tableFrom": "ssh_folders", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ssh_folders_sync_id_unique": { + "name": "ssh_folders_sync_id_unique", + "nullsNotDistinct": false, + "columns": [ + "sync_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_conflicts": { + "name": "sync_conflicts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sync_id": { + "name": "sync_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "local_row": { + "name": "local_row", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_revision": { + "name": "server_revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_sync_conflicts_user": { + "name": "idx_sync_conflicts_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sync_conflicts_user_id_users_id_fk": { + "name": "sync_conflicts_user_id_users_id_fk", + "tableFrom": "sync_conflicts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_link": { + "name": "sync_link", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "server_url": { + "name": "server_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_name": { + "name": "server_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "server_version": { + "name": "server_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_headers": { + "name": "custom_headers", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "basic_auth": { + "name": "basic_auth", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allow_invalid_certificate": { + "name": "allow_invalid_certificate", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "remote_user_id": { + "name": "remote_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "remote_username": { + "name": "remote_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account": { + "name": "account", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "known_types": { + "name": "known_types", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cursor": { + "name": "cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linked_at": { + "name": "linked_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "sync_link_user_id_users_id_fk": { + "name": "sync_link_user_id_users_id_fk", + "tableFrom": "sync_link", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_records": { + "name": "sync_records", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_hash": { + "name": "error_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_sync_records_user_entity_sync": { + "name": "idx_sync_records_user_entity_sync", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sync_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_sync_records_user_seq": { + "name": "idx_sync_records_user_seq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sync_records_user_id_users_id_fk": { + "name": "sync_records_user_id_users_id_fk", + "tableFrom": "sync_records", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.trusted_devices": { + "name": "trusted_devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "device_fingerprint": { + "name": "device_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_info": { + "name": "device_info", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_trusted_devices_user_id": { + "name": "idx_trusted_devices_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "trusted_devices_user_id_users_id_fk": { + "name": "trusted_devices_user_id_users_id_fk", + "tableFrom": "trusted_devices", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ui_preferences": { + "name": "ui_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "ui_preferences_user_id_users_id_fk": { + "name": "ui_preferences_user_id_users_id_fk", + "tableFrom": "ui_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_external_identities": { + "name": "user_external_identities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_user_external_identities_provider_subject": { + "name": "idx_user_external_identities_provider_subject", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_user_external_identities_user": { + "name": "idx_user_external_identities_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_external_identities_user_id_users_id_fk": { + "name": "user_external_identities_user_id_users_id_fk", + "tableFrom": "user_external_identities", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_open_tabs": { + "name": "user_open_tabs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "tab_type": { + "name": "tab_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tab_order": { + "name": "tab_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "backend_session_id": { + "name": "backend_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_user_open_tabs_user_id": { + "name": "idx_user_open_tabs_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_open_tabs_user_id_users_id_fk": { + "name": "user_open_tabs_user_id_users_id_fk", + "tableFrom": "user_open_tabs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_open_tabs_host_id_ssh_data_id_fk": { + "name": "user_open_tabs_host_id_ssh_data_id_fk", + "tableFrom": "user_open_tabs", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_preferences": { + "name": "user_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "reopen_tabs_on_login": { + "name": "reopen_tabs_on_login", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "font_size": { + "name": "font_size", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "accent_color": { + "name": "accent_color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "storage_mode": { + "name": "storage_mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "command_palette_enabled": { + "name": "command_palette_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "show_host_tags": { + "name": "show_host_tags", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "host_tray_on_click": { + "name": "host_tray_on_click", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "pin_app_rail": { + "name": "pin_app_rail", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "expand_app_rail_on_hover": { + "name": "expand_app_rail_on_hover", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "show_pin_app_rail_button": { + "name": "show_pin_app_rail_button", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "folders_collapsed": { + "name": "folders_collapsed", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "disable_update_check": { + "name": "disable_update_check", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "confirm_tab_close": { + "name": "confirm_tab_close", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "hidden_rail_tabs": { + "name": "hidden_rail_tabs", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "compact_host_view": { + "name": "compact_host_view", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "status_color_scheme": { + "name": "status_color_scheme", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_keybindings": { + "name": "custom_keybindings", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "layout_id": { + "name": "layout_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "theme_id": { + "name": "theme_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "user_preferences_user_id_users_id_fk": { + "name": "user_preferences_user_id_users_id_fk", + "tableFrom": "user_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_roles": { + "name": "user_roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "granted_at": { + "name": "granted_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_user_roles_user_role": { + "name": "idx_user_roles_user_role", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_user_roles_role_id": { + "name": "idx_user_roles_role_id", + "columns": [ + { + "expression": "role_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_role_id_roles_id_fk": { + "name": "user_roles_role_id_roles_id_fk", + "tableFrom": "user_roles", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_granted_by_users_id_fk": { + "name": "user_roles_granted_by_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_second_factors": { + "name": "user_second_factors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "factor_id": { + "name": "factor_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "enrolled_at": { + "name": "enrolled_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_user_second_factors_user_factor": { + "name": "idx_user_second_factors_user_factor", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "factor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_second_factors_user_id_users_id_fk": { + "name": "user_second_factors_user_id_users_id_fk", + "tableFrom": "user_second_factors", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "registered_at": { + "name": "registered_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_users_username": { + "name": "idx_users_username", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/postgres/meta/_journal.json b/drizzle/postgres/meta/_journal.json index 834d84c12..ed7aed764 100644 --- a/drizzle/postgres/meta/_journal.json +++ b/drizzle/postgres/meta/_journal.json @@ -519,6 +519,13 @@ "when": 1792357128746, "tag": "0073_user_look_preferences", "breakpoints": true + }, + { + "idx": 74, + "version": "7", + "when": 1792374488837, + "tag": "0074_bouncy_sersi", + "breakpoints": true } ] } \ No newline at end of file diff --git a/drizzle/sqlite/0069_normal_ozymandias.sql b/drizzle/sqlite/0069_normal_ozymandias.sql new file mode 100644 index 000000000..4a1b9eb98 --- /dev/null +++ b/drizzle/sqlite/0069_normal_ozymandias.sql @@ -0,0 +1,4 @@ +-- recent_activity moved to the homepage plugin, whose migration renames it to +-- p_homepage_recent_activity. Dropping it here would lose every entry, so this +-- only stops drizzle tracking it. SELECT 1 because MySQL rejects an empty query. +SELECT 1; diff --git a/drizzle/sqlite/meta/0069_snapshot.json b/drizzle/sqlite/meta/0069_snapshot.json new file mode 100644 index 000000000..8fcf5565e --- /dev/null +++ b/drizzle/sqlite/meta/0069_snapshot.json @@ -0,0 +1,4391 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "35475485-31b7-4d14-b093-f4378c8826e6", + "prevId": "811cbb62-1490-48cd-9959-fd470be269d2", + "tables": { + "api_keys": { + "name": "api_keys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_prefix": { + "name": "token_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + } + }, + "indexes": { + "idx_api_keys_user_id": { + "name": "idx_api_keys_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "api_keys_user_id_users_id_fk": { + "name": "api_keys_user_id_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audit_logs": { + "name": "audit_logs", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "details": { + "name": "details", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "success": { + "name": "success", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_audit_logs_timestamp": { + "name": "idx_audit_logs_timestamp", + "columns": [ + "timestamp" + ], + "isUnique": false + }, + "idx_audit_logs_user_ts": { + "name": "idx_audit_logs_user_ts", + "columns": [ + "user_id", + "timestamp" + ], + "isUnique": false + }, + "idx_audit_logs_action_ts": { + "name": "idx_audit_logs_action_ts", + "columns": [ + "action", + "timestamp" + ], + "isUnique": false + }, + "idx_audit_logs_resource_ts": { + "name": "idx_audit_logs_resource_ts", + "columns": [ + "resource_type", + "timestamp" + ], + "isUnique": false + } + }, + "foreignKeys": { + "audit_logs_user_id_users_id_fk": { + "name": "audit_logs_user_id_users_id_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "credential_access": { + "name": "credential_access", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'use'" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_credential_access_user_id": { + "name": "idx_credential_access_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_credential_access_role_id": { + "name": "idx_credential_access_role_id", + "columns": [ + "role_id" + ], + "isUnique": false + }, + "idx_credential_access_credential_id": { + "name": "idx_credential_access_credential_id", + "columns": [ + "credential_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "credential_access_credential_id_ssh_credentials_id_fk": { + "name": "credential_access_credential_id_ssh_credentials_id_fk", + "tableFrom": "credential_access", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_access_user_id_users_id_fk": { + "name": "credential_access_user_id_users_id_fk", + "tableFrom": "credential_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_access_role_id_roles_id_fk": { + "name": "credential_access_role_id_roles_id_fk", + "tableFrom": "credential_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_access_granted_by_users_id_fk": { + "name": "credential_access_granted_by_users_id_fk", + "tableFrom": "credential_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "credential_sidebar_preferences": { + "name": "credential_sidebar_preferences", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "credential_sidebar_preferences_user_id_users_id_fk": { + "name": "credential_sidebar_preferences_user_id_users_id_fk", + "tableFrom": "credential_sidebar_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "folder_access": { + "name": "folder_access", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "folder": { + "name": "folder", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'connect'" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_folder_access_owner_folder": { + "name": "idx_folder_access_owner_folder", + "columns": [ + "owner_user_id", + "folder" + ], + "isUnique": false + } + }, + "foreignKeys": { + "folder_access_owner_user_id_users_id_fk": { + "name": "folder_access_owner_user_id_users_id_fk", + "tableFrom": "folder_access", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_access_user_id_users_id_fk": { + "name": "folder_access_user_id_users_id_fk", + "tableFrom": "folder_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_access_role_id_roles_id_fk": { + "name": "folder_access_role_id_roles_id_fk", + "tableFrom": "folder_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_access_granted_by_users_id_fk": { + "name": "folder_access_granted_by_users_id_fk", + "tableFrom": "folder_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "host_access": { + "name": "host_access", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'connect'" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "last_accessed_at": { + "name": "last_accessed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_count": { + "name": "access_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "idx_host_access_user_id": { + "name": "idx_host_access_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_host_access_role_id": { + "name": "idx_host_access_role_id", + "columns": [ + "role_id" + ], + "isUnique": false + }, + "idx_host_access_host_id": { + "name": "idx_host_access_host_id", + "columns": [ + "host_id" + ], + "isUnique": false + }, + "idx_host_access_expires_at": { + "name": "idx_host_access_expires_at", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "host_access_host_id_ssh_data_id_fk": { + "name": "host_access_host_id_ssh_data_id_fk", + "tableFrom": "host_access", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_access_user_id_users_id_fk": { + "name": "host_access_user_id_users_id_fk", + "tableFrom": "host_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_access_role_id_roles_id_fk": { + "name": "host_access_role_id_roles_id_fk", + "tableFrom": "host_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_access_granted_by_users_id_fk": { + "name": "host_access_granted_by_users_id_fk", + "tableFrom": "host_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "host_defaults": { + "name": "host_defaults", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "folder_id": { + "name": "folder_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_host_defaults_scope_key": { + "name": "idx_host_defaults_scope_key", + "columns": [ + "scope_key", + "namespace", + "key" + ], + "isUnique": true + }, + "idx_host_defaults_user": { + "name": "idx_host_defaults_user", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_host_defaults_folder": { + "name": "idx_host_defaults_folder", + "columns": [ + "folder_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "host_defaults_user_id_users_id_fk": { + "name": "host_defaults_user_id_users_id_fk", + "tableFrom": "host_defaults", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_defaults_folder_id_ssh_folders_id_fk": { + "name": "host_defaults_folder_id_ssh_folders_id_fk", + "tableFrom": "host_defaults", + "tableTo": "ssh_folders", + "columnsFrom": [ + "folder_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "host_protocol_auth": { + "name": "host_protocol_auth", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "protocol": { + "name": "protocol", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'direct'" + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fields": { + "name": "fields", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "secret_fields": { + "name": "secret_fields", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_host_protocol_auth_host_protocol": { + "name": "idx_host_protocol_auth_host_protocol", + "columns": [ + "host_id", + "protocol" + ], + "isUnique": true + }, + "idx_host_protocol_auth_user": { + "name": "idx_host_protocol_auth_user", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_host_protocol_auth_credential": { + "name": "idx_host_protocol_auth_credential", + "columns": [ + "credential_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "host_protocol_auth_host_id_ssh_data_id_fk": { + "name": "host_protocol_auth_host_id_ssh_data_id_fk", + "tableFrom": "host_protocol_auth", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_protocol_auth_user_id_users_id_fk": { + "name": "host_protocol_auth_user_id_users_id_fk", + "tableFrom": "host_protocol_auth", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_protocol_auth_credential_id_ssh_credentials_id_fk": { + "name": "host_protocol_auth_credential_id_ssh_credentials_id_fk", + "tableFrom": "host_protocol_auth", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "host_sidebar_preferences": { + "name": "host_sidebar_preferences", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "host_sidebar_preferences_user_id_users_id_fk": { + "name": "host_sidebar_preferences_user_id_users_id_fk", + "tableFrom": "host_sidebar_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ssh_data": { + "name": "ssh_data", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connection_type": { + "name": "connection_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ssh'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "folder": { + "name": "folder", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parent_host_id": { + "name": "parent_host_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pin": { + "name": "pin", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "share_ssh_auth": { + "name": "share_ssh_auth", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "force_keyboard_interactive": { + "name": "force_keyboard_interactive", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text(8192)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_password": { + "name": "key_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sudo_password": { + "name": "sudo_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "override_credential_username": { + "name": "override_credential_username", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "jump_hosts": { + "name": "jump_hosts", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_check_enabled": { + "name": "status_check_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "status_check_interval": { + "name": "status_check_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ssh_options": { + "name": "ssh_options", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enable_ssh": { + "name": "enable_ssh", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "ssh_port": { + "name": "ssh_port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 22 + }, + "use_socks5": { + "name": "use_socks5", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_host": { + "name": "socks5_host", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_port": { + "name": "socks5_port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_username": { + "name": "socks5_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_password": { + "name": "socks5_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_proxy_chain": { + "name": "socks5_proxy_chain", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connection_origin": { + "name": "connection_origin", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "port_knock_sequence": { + "name": "port_knock_sequence", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_fingerprint": { + "name": "host_key_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_type": { + "name": "host_key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_algorithm": { + "name": "host_key_algorithm", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'sha256'" + }, + "host_key_first_seen": { + "name": "host_key_first_seen", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_last_verified": { + "name": "host_key_last_verified", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_changed_count": { + "name": "host_key_changed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "sync_id": { + "name": "sync_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "local_only": { + "name": "local_only", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "shared_source": { + "name": "shared_source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_overrides": { + "name": "default_overrides", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "ssh_data_sync_id_unique": { + "name": "ssh_data_sync_id_unique", + "columns": [ + "sync_id" + ], + "isUnique": true + }, + "idx_ssh_data_user_id": { + "name": "idx_ssh_data_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_ssh_data_parent_host": { + "name": "idx_ssh_data_parent_host", + "columns": [ + "parent_host_id" + ], + "isUnique": false + }, + "idx_ssh_data_credential": { + "name": "idx_ssh_data_credential", + "columns": [ + "credential_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ssh_data_user_id_users_id_fk": { + "name": "ssh_data_user_id_users_id_fk", + "tableFrom": "ssh_data", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_data_parent_host_id_ssh_data_id_fk": { + "name": "ssh_data_parent_host_id_ssh_data_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_data", + "columnsFrom": [ + "parent_host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_install_counts": { + "name": "plugin_install_counts", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "registry_id": { + "name": "registry_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_plugin_install_counts_plugin_registry": { + "name": "idx_plugin_install_counts_plugin_registry", + "columns": [ + "plugin_id", + "registry_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_migrations": { + "name": "plugin_migrations", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "migration_id": { + "name": "migration_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_at": { + "name": "applied_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_plugin_migrations_plugin_migration": { + "name": "idx_plugin_migrations_plugin_migration", + "columns": [ + "plugin_id", + "migration_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "plugin_migrations_plugin_id_plugins_id_fk": { + "name": "plugin_migrations_plugin_id_plugins_id_fk", + "tableFrom": "plugin_migrations", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_permission_grants": { + "name": "plugin_permission_grants", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "capability": { + "name": "capability", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "granted_at": { + "name": "granted_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'admin'" + } + }, + "indexes": { + "idx_plugin_permission_grants_plugin_capability": { + "name": "idx_plugin_permission_grants_plugin_capability", + "columns": [ + "plugin_id", + "capability" + ], + "isUnique": true + } + }, + "foreignKeys": { + "plugin_permission_grants_plugin_id_plugins_id_fk": { + "name": "plugin_permission_grants_plugin_id_plugins_id_fk", + "tableFrom": "plugin_permission_grants", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "plugin_permission_grants_granted_by_users_id_fk": { + "name": "plugin_permission_grants_granted_by_users_id_fk", + "tableFrom": "plugin_permission_grants", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_registries": { + "name": "plugin_registries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'community'" + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "signing_key": { + "name": "signing_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_index_hash": { + "name": "last_index_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_settings": { + "name": "plugin_settings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted": { + "name": "encrypted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_plugin_settings_scope_key": { + "name": "idx_plugin_settings_scope_key", + "columns": [ + "plugin_id", + "scope", + "scope_id", + "key" + ], + "isUnique": true + }, + "idx_plugin_settings_plugin_scope": { + "name": "idx_plugin_settings_plugin_scope", + "columns": [ + "plugin_id", + "scope" + ], + "isUnique": false + } + }, + "foreignKeys": { + "plugin_settings_plugin_id_plugins_id_fk": { + "name": "plugin_settings_plugin_id_plugins_id_fk", + "tableFrom": "plugin_settings", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_storage": { + "name": "plugin_storage", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_plugin_storage_plugin_key": { + "name": "idx_plugin_storage_plugin_key", + "columns": [ + "plugin_id", + "key" + ], + "isUnique": true + } + }, + "foreignKeys": { + "plugin_storage_plugin_id_plugins_id_fk": { + "name": "plugin_storage_plugin_id_plugins_id_fk", + "tableFrom": "plugin_storage", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugins": { + "name": "plugins", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'available'" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'community'" + }, + "registry_id": { + "name": "registry_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'disabled'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "installed_at": { + "name": "installed_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "auto_update": { + "name": "auto_update", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "pinned_version": { + "name": "pinned_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'stable'" + }, + "manifest_json": { + "name": "manifest_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_plugins_registry_id": { + "name": "idx_plugins_registry_id", + "columns": [ + "registry_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rbac_applied_defaults": { + "name": "rbac_applied_defaults", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "role_name": { + "name": "role_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_at": { + "name": "applied_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_rbac_applied_defaults_role_permission": { + "name": "idx_rbac_applied_defaults_role_permission", + "columns": [ + "role_name", + "permission" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rbac_known_permissions": { + "name": "rbac_known_permissions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_rbac_known_permissions_permission": { + "name": "idx_rbac_known_permissions_permission", + "columns": [ + "permission" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "roles": { + "name": "roles", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_system": { + "name": "is_system", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "roles_name_unique": { + "name": "roles_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "jwt_token": { + "name": "jwt_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_info": { + "name": "device_info", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "external_session_ref": { + "name": "external_session_ref", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_active_at": { + "name": "last_active_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_sessions_user_id": { + "name": "idx_sessions_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_sessions_expires_at": { + "name": "idx_sessions_expires_at", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "settings": { + "name": "settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "shared_credential_secrets": { + "name": "shared_credential_secrets", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "credential_access_id": { + "name": "credential_access_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_user_id": { + "name": "target_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encrypted_username": { + "name": "encrypted_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'password'" + }, + "encrypted_password": { + "name": "encrypted_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_key": { + "name": "encrypted_key", + "type": "text(16384)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_key_password": { + "name": "encrypted_key_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text(4096)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cert_public_key": { + "name": "cert_public_key", + "type": "text(8192)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_shared_credential_secrets_scope": { + "name": "idx_shared_credential_secrets_scope", + "columns": [ + "credential_access_id", + "target_user_id" + ], + "isUnique": true + }, + "idx_shared_credential_secrets_target": { + "name": "idx_shared_credential_secrets_target", + "columns": [ + "target_user_id", + "credential_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "shared_credential_secrets_target_user_id_users_id_fk": { + "name": "shared_credential_secrets_target_user_id_users_id_fk", + "tableFrom": "shared_credential_secrets", + "tableTo": "users", + "columnsFrom": [ + "target_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_credential_secrets_credential_id_ssh_credentials_id_fk": { + "name": "shared_credential_secrets_credential_id_ssh_credentials_id_fk", + "tableFrom": "shared_credential_secrets", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_cred_secrets_access_id_fk": { + "name": "shared_cred_secrets_access_id_fk", + "tableFrom": "shared_credential_secrets", + "tableTo": "credential_access", + "columnsFrom": [ + "credential_access_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "shared_host_auth_overrides": { + "name": "shared_host_auth_overrides", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "protocol": { + "name": "protocol", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ssh'" + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "shared_host_auth_overrides_host_user_protocol_unique": { + "name": "shared_host_auth_overrides_host_user_protocol_unique", + "columns": [ + "host_id", + "user_id", + "protocol" + ], + "isUnique": true + } + }, + "foreignKeys": { + "shared_host_auth_overrides_host_id_ssh_data_id_fk": { + "name": "shared_host_auth_overrides_host_id_ssh_data_id_fk", + "tableFrom": "shared_host_auth_overrides", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_auth_overrides_user_id_users_id_fk": { + "name": "shared_host_auth_overrides_user_id_users_id_fk", + "tableFrom": "shared_host_auth_overrides", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_auth_overrides_credential_id_ssh_credentials_id_fk": { + "name": "shared_host_auth_overrides_credential_id_ssh_credentials_id_fk", + "tableFrom": "shared_host_auth_overrides", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "shared_host_secrets": { + "name": "shared_host_secrets", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "host_access_id": { + "name": "host_access_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_user_id": { + "name": "target_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "protocol": { + "name": "protocol", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ssh'" + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'credential'" + }, + "original_credential_id": { + "name": "original_credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_username": { + "name": "encrypted_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_auth_type": { + "name": "encrypted_auth_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_password": { + "name": "encrypted_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_key": { + "name": "encrypted_key", + "type": "text(16384)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_key_password": { + "name": "encrypted_key_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_key_type": { + "name": "encrypted_key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_domain": { + "name": "encrypted_domain", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_fields": { + "name": "encrypted_fields", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_shared_host_secrets_scope": { + "name": "idx_shared_host_secrets_scope", + "columns": [ + "host_access_id", + "target_user_id", + "protocol" + ], + "isUnique": true + } + }, + "foreignKeys": { + "shared_host_secrets_host_access_id_host_access_id_fk": { + "name": "shared_host_secrets_host_access_id_host_access_id_fk", + "tableFrom": "shared_host_secrets", + "tableTo": "host_access", + "columnsFrom": [ + "host_access_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_secrets_target_user_id_users_id_fk": { + "name": "shared_host_secrets_target_user_id_users_id_fk", + "tableFrom": "shared_host_secrets", + "tableTo": "users", + "columnsFrom": [ + "target_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_secrets_original_credential_id_ssh_credentials_id_fk": { + "name": "shared_host_secrets_original_credential_id_ssh_credentials_id_fk", + "tableFrom": "shared_host_secrets", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "original_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ssh_credential_usage": { + "name": "ssh_credential_usage", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "used_at": { + "name": "used_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_ssh_credential_usage_credential": { + "name": "idx_ssh_credential_usage_credential", + "columns": [ + "credential_id" + ], + "isUnique": false + }, + "idx_ssh_credential_usage_user": { + "name": "idx_ssh_credential_usage_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ssh_credential_usage_credential_id_ssh_credentials_id_fk": { + "name": "ssh_credential_usage_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_credential_usage", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_credential_usage_host_id_ssh_data_id_fk": { + "name": "ssh_credential_usage_host_id_ssh_data_id_fk", + "tableFrom": "ssh_credential_usage", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_credential_usage_user_id_users_id_fk": { + "name": "ssh_credential_usage_user_id_users_id_fk", + "tableFrom": "ssh_credential_usage", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ssh_credentials": { + "name": "ssh_credentials", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "folder": { + "name": "folder", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pin": { + "name": "pin", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text(16384)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "private_key": { + "name": "private_key", + "type": "text(16384)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text(4096)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_password": { + "name": "key_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "detected_key_type": { + "name": "detected_key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cert_public_key": { + "name": "cert_public_key", + "type": "text(8192)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "usage_count": { + "name": "usage_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "shared_source": { + "name": "shared_source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "ssh_credentials_sync_id_unique": { + "name": "ssh_credentials_sync_id_unique", + "columns": [ + "sync_id" + ], + "isUnique": true + }, + "idx_ssh_credentials_user_id": { + "name": "idx_ssh_credentials_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ssh_credentials_user_id_users_id_fk": { + "name": "ssh_credentials_user_id_users_id_fk", + "tableFrom": "ssh_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ssh_folders": { + "name": "ssh_folders", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "local_only": { + "name": "local_only", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "ssh_folders_sync_id_unique": { + "name": "ssh_folders_sync_id_unique", + "columns": [ + "sync_id" + ], + "isUnique": true + }, + "idx_ssh_folders_user_id": { + "name": "idx_ssh_folders_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ssh_folders_user_id_users_id_fk": { + "name": "ssh_folders_user_id_users_id_fk", + "tableFrom": "ssh_folders", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sync_conflicts": { + "name": "sync_conflicts", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "local_row": { + "name": "local_row", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "server_revision": { + "name": "server_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_sync_conflicts_user": { + "name": "idx_sync_conflicts_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sync_conflicts_user_id_users_id_fk": { + "name": "sync_conflicts_user_id_users_id_fk", + "tableFrom": "sync_conflicts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sync_link": { + "name": "sync_link", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "server_url": { + "name": "server_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "server_name": { + "name": "server_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "server_version": { + "name": "server_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "custom_headers": { + "name": "custom_headers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "basic_auth": { + "name": "basic_auth", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allow_invalid_certificate": { + "name": "allow_invalid_certificate", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "remote_user_id": { + "name": "remote_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remote_username": { + "name": "remote_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "account": { + "name": "account", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "known_types": { + "name": "known_types", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cursor": { + "name": "cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'idle'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "linked_at": { + "name": "linked_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "sync_link_user_id_users_id_fk": { + "name": "sync_link_user_id_users_id_fk", + "tableFrom": "sync_link", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sync_records": { + "name": "sync_records", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted": { + "name": "deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_hash": { + "name": "error_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_sync_records_user_entity_sync": { + "name": "idx_sync_records_user_entity_sync", + "columns": [ + "user_id", + "entity_type", + "sync_id" + ], + "isUnique": true + }, + "idx_sync_records_user_seq": { + "name": "idx_sync_records_user_seq", + "columns": [ + "user_id", + "seq" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sync_records_user_id_users_id_fk": { + "name": "sync_records_user_id_users_id_fk", + "tableFrom": "sync_records", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "trusted_devices": { + "name": "trusted_devices", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_fingerprint": { + "name": "device_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_info": { + "name": "device_info", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_trusted_devices_user_id": { + "name": "idx_trusted_devices_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "trusted_devices_user_id_users_id_fk": { + "name": "trusted_devices_user_id_users_id_fk", + "tableFrom": "trusted_devices", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ui_preferences": { + "name": "ui_preferences", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "ui_preferences_user_id_users_id_fk": { + "name": "ui_preferences_user_id_users_id_fk", + "tableFrom": "ui_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_external_identities": { + "name": "user_external_identities", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_user_external_identities_provider_subject": { + "name": "idx_user_external_identities_provider_subject", + "columns": [ + "provider_id", + "subject" + ], + "isUnique": true + }, + "idx_user_external_identities_user": { + "name": "idx_user_external_identities_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_external_identities_user_id_users_id_fk": { + "name": "user_external_identities_user_id_users_id_fk", + "tableFrom": "user_external_identities", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_open_tabs": { + "name": "user_open_tabs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_type": { + "name": "tab_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_order": { + "name": "tab_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "backend_session_id": { + "name": "backend_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_user_open_tabs_user_id": { + "name": "idx_user_open_tabs_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_open_tabs_user_id_users_id_fk": { + "name": "user_open_tabs_user_id_users_id_fk", + "tableFrom": "user_open_tabs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_open_tabs_host_id_ssh_data_id_fk": { + "name": "user_open_tabs_host_id_ssh_data_id_fk", + "tableFrom": "user_open_tabs", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_preferences": { + "name": "user_preferences", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "reopen_tabs_on_login": { + "name": "reopen_tabs_on_login", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "font_size": { + "name": "font_size", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "accent_color": { + "name": "accent_color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "storage_mode": { + "name": "storage_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "command_palette_enabled": { + "name": "command_palette_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "show_host_tags": { + "name": "show_host_tags", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_tray_on_click": { + "name": "host_tray_on_click", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pin_app_rail": { + "name": "pin_app_rail", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expand_app_rail_on_hover": { + "name": "expand_app_rail_on_hover", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "show_pin_app_rail_button": { + "name": "show_pin_app_rail_button", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "folders_collapsed": { + "name": "folders_collapsed", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "disable_update_check": { + "name": "disable_update_check", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "confirm_tab_close": { + "name": "confirm_tab_close", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hidden_rail_tabs": { + "name": "hidden_rail_tabs", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "compact_host_view": { + "name": "compact_host_view", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_color_scheme": { + "name": "status_color_scheme", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "custom_keybindings": { + "name": "custom_keybindings", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "layout_id": { + "name": "layout_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "theme_id": { + "name": "theme_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "user_preferences_user_id_users_id_fk": { + "name": "user_preferences_user_id_users_id_fk", + "tableFrom": "user_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_roles": { + "name": "user_roles", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "granted_at": { + "name": "granted_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_user_roles_user_role": { + "name": "idx_user_roles_user_role", + "columns": [ + "user_id", + "role_id" + ], + "isUnique": true + }, + "idx_user_roles_role_id": { + "name": "idx_user_roles_role_id", + "columns": [ + "role_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_role_id_roles_id_fk": { + "name": "user_roles_role_id_roles_id_fk", + "tableFrom": "user_roles", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_granted_by_users_id_fk": { + "name": "user_roles_granted_by_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_second_factors": { + "name": "user_second_factors", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "factor_id": { + "name": "factor_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enrolled_at": { + "name": "enrolled_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_user_second_factors_user_factor": { + "name": "idx_user_second_factors_user_factor", + "columns": [ + "user_id", + "plugin_id", + "factor_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "user_second_factors_user_id_users_id_fk": { + "name": "user_second_factors_user_id_users_id_fk", + "tableFrom": "user_second_factors", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_admin": { + "name": "is_admin", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "registered_at": { + "name": "registered_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_users_username": { + "name": "idx_users_username", + "columns": [ + "username" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/sqlite/meta/_journal.json b/drizzle/sqlite/meta/_journal.json index 8348d4639..c564ffd9f 100644 --- a/drizzle/sqlite/meta/_journal.json +++ b/drizzle/sqlite/meta/_journal.json @@ -484,6 +484,13 @@ "when": 1792357127581, "tag": "0068_user_look_preferences", "breakpoints": true + }, + { + "idx": 69, + "version": "6", + "when": 1792374488196, + "tag": "0069_normal_ozymandias", + "breakpoints": true } ] } \ No newline at end of file diff --git a/electron/c2s-auth.cjs b/electron/c2s-auth.cjs deleted file mode 100644 index 4bb52944a..000000000 --- a/electron/c2s-auth.cjs +++ /dev/null @@ -1,78 +0,0 @@ -const { randomUUID } = require("node:crypto"); - -function createC2SAuthBridge(ipcMain, getWindow) { - const pending = new Map(); - ipcMain.handle("answer-c2s-auth", (event, id, answer) => { - const item = pending.get(id); - if (!item || item.sender !== event.sender) return false; - if (answer !== null && (typeof answer !== "string" || answer.length > 8192)) - return false; - item.finish(answer); - return true; - }); - return function attach(ws, tunnelName) { - const active = new Set(); - const close = () => { - for (const id of active) pending.get(id)?.finish(null); - ws.off("message", onMessage); - ws.off("close", close); - ws.off("error", close); - }; - const onMessage = (raw, binary) => { - if (binary) return; - let message; - try { - message = JSON.parse(raw.toString()); - } catch { - return; - } - if (message.type !== "auth-prompt") return; - const window = getWindow(); - if ( - !window || - window.isDestroyed() || - active.size || - typeof message.requestId !== "string" || - !["totp", "input", "browser"].includes(message.request?.kind) // plugin-id-ok: SSH prompt kind, not the plugin - ) { - ws.close(); - return; - } - const sender = window.webContents; - const id = randomUUID(); - const finish = (answer) => { - if (!pending.delete(id)) return; - active.delete(id); - clearTimeout(timer); - sender.off("destroyed", cancelled); - sender.off("did-start-loading", cancelled); - if (!sender.isDestroyed()) - sender.send("c2s-auth-prompt", { id, closed: true }); - if (ws.readyState === 1) - ws.send( - JSON.stringify({ - type: "auth-response", - requestId: message.requestId, - answer, - }), - ); - if (answer === null) ws.close(); - }; - const cancelled = () => finish(null); - const timer = setTimeout(cancelled, 60_000); - active.add(id); - pending.set(id, { sender, finish }); - sender.once("destroyed", cancelled); - sender.once("did-start-loading", cancelled); - sender.send("c2s-auth-prompt", { - id, - tunnelName, - request: message.request, - }); - }; - ws.on("message", onMessage); - ws.once("close", close); - ws.once("error", close); - }; -} -module.exports = { createC2SAuthBridge }; diff --git a/electron/external-editor.cjs b/electron/external-editor.cjs deleted file mode 100644 index 0cc098608..000000000 --- a/electron/external-editor.cjs +++ /dev/null @@ -1,70 +0,0 @@ -const path = require("path"); - -// Saved by the file manager after the user picks an editor in a file dialog. -const EXTERNAL_EDITOR_SETTING = "fileManager.externalEditorPath"; - -// Extensions the OS would run rather than open in an editor. With no editor -// configured the temp copy goes to shell.openPath, and its name comes from -// the remote server. -const LAUNCHABLE_EXTENSIONS = new Set([ - // Windows - "application", - "appref-ms", - "bat", - "chm", - "cmd", - "com", - "cpl", - "exe", - "gadget", - "hta", - "inf", - "jar", - "js", - "jse", - "library-ms", - "lnk", - "msc", - "msi", - "msp", - "mst", - "pif", - "ps1", - "psd1", - "psm1", - "py", - "pyw", - "reg", - "scf", - "scr", - "search-ms", - "settingcontent-ms", - "url", - "vbe", - "vbs", - "ws", - "wsf", - "wsh", - "xll", - // macOS - "app", - "command", - "pkg", - "terminal", - "tool", - "workflow", - // Linux - "appimage", - "desktop", - "run", - "sh", -]); - -function isLaunchableFileName(fileName) { - // Windows drops trailing dots and spaces, so "a.exe. " is saved as a.exe. - const name = path.basename(String(fileName || "")).replace(/[. ]+$/, ""); - const extension = path.extname(name).slice(1).toLowerCase(); - return LAUNCHABLE_EXTENSIONS.has(extension); -} - -module.exports = { EXTERNAL_EDITOR_SETTING, isLaunchableFileName }; diff --git a/electron/local-files.cjs b/electron/local-files.cjs deleted file mode 100644 index 971cfa9bf..000000000 --- a/electron/local-files.cjs +++ /dev/null @@ -1,1064 +0,0 @@ -// Local filesystem access + streamed local<->remote transfers for the -// desktop app's dual-pane file manager. -// -// The renderer has no Node access (contextIsolation), so browsing the user's -// own disk and moving bytes between it and the backend has to happen here. -// Uploads/downloads are streamed through Electron's `net` stack against the -// same file-manager HTTP routes the renderer already uses, so nothing is ever -// buffered whole in memory. -// -// Trust boundary: the renderer never supplies a URL or headers. It names an -// origin ("local" | "remote") and a route from a fixed allowlist; the main -// process resolves the actual Termix backend URL and attaches credentials -// itself (session cookies for the embedded backend, the linked server's -// session). Nothing here can be pointed at another host. - -const fs = require("fs"); -const fsp = require("fs/promises"); -const os = require("os"); -const path = require("path"); -const { URL } = require("url"); - -const IPC = { - HOME: "local-fs:home", - LIST: "local-fs:list", - MKDIR: "local-fs:mkdir", - CREATE_FILE: "local-fs:create-file", - RENAME: "local-fs:rename", - TRASH: "local-fs:trash", - ENSURE_DIR: "local-fs:ensure-dir", - EXISTS: "local-fs:exists", - WALK: "local-fs:walk", - REVEAL: "local-fs:reveal", - OPEN: "local-fs:open", - UPLOAD: "local-transfer:upload", - DOWNLOAD: "local-transfer:download", - CANCEL: "local-transfer:cancel", - PROGRESS: "local-transfer:progress", - SET_API: "local-transfer:set-api", -}; - -const READ_CHUNK_BYTES = 1024 * 1024; -const PROGRESS_INTERVAL_MS = 150; - -const activeTransfers = new Map(); - -function isAbsoluteLocalPath(candidate) { - return typeof candidate === "string" && path.isAbsolute(candidate); -} - -function normalizeLocalPath(candidate) { - if (!isAbsoluteLocalPath(candidate)) { - throw new Error("A local absolute path is required"); - } - return path.normalize(candidate); -} - -function isHiddenEntry(name) { - return name.startsWith("."); -} - -// A single path segment: no separators, not "." / "..", no NULs. -function requireEntryName(name, what) { - const safeName = String(name || "").trim(); - if ( - !safeName || - safeName === "." || - safeName === ".." || - /[\/\\\0]/.test(safeName) - ) { - throw new Error(`Invalid ${what}`); - } - return safeName; -} - -// Asserts that `candidate` (already absolute) lies strictly inside `root` -// after normalisation on the *current* platform. `pathImpl` is injectable so -// the Windows rules can be exercised in tests on any OS. Traversal that -// survives normalisation ("..\\x" is a plain file name on POSIX but a parent -// reference on Windows) is caught here, as are absolute / drive-qualified -// names that path.join or path.resolve would let take over. -function assertWithinRoot(rootPath, candidate, pathImpl = path) { - if (typeof rootPath !== "string" || !rootPath.trim()) { - throw new LocalFileError("EINVAL", "A download folder is required"); - } - const root = pathImpl.normalize(pathImpl.resolve(rootPath)); - const target = pathImpl.normalize(pathImpl.resolve(candidate)); - const rel = pathImpl.relative(root, target); - const escapes = - rel === "" || - rel === ".." || - rel.startsWith(`..${pathImpl.sep}`) || - pathImpl.isAbsolute(rel) || - // A different drive on Windows yields an absolute relative path; a UNC - // or drive-qualified segment must never survive either. - rel.split(pathImpl.sep).some((seg) => seg === ".." || seg === ""); - if (escapes) { - throw new LocalFileError( - "EINVAL", - `"${pathImpl.basename(candidate)}" would be written outside the selected folder`, - ); - } - return target; -} - -// Resolve a deliberately selected linked root once, but never follow links -// supplied as descendants of a downloaded tree. -async function prepareDownloadPath(rootPath, candidate, directory = false) { - const target = assertWithinRoot(rootPath, normalizeLocalPath(candidate)); - const selected = path.resolve(rootPath); - const realRoot = await fsp.realpath(selected); - if (!(await fsp.stat(realRoot)).isDirectory()) { - throw new LocalFileError("EINVAL", "The download root is not a directory"); - } - const parts = path.relative(selected, target).split(path.sep); - let current = realRoot; - for (let i = 0; i < parts.length; i++) { - current = path.join(current, parts[i]); - const needsDirectory = directory || i < parts.length - 1; - if (needsDirectory) { - await fsp.mkdir(current).catch((error) => { - if (error.code !== "EEXIST") throw error; - }); - } - const stat = await fsp.lstat(current).catch((error) => { - if (error.code === "ENOENT" && !needsDirectory) return null; - throw error; - }); - if (stat?.isSymbolicLink()) { - throw new LocalFileError( - "EINVAL", - "Downloads cannot follow links inside the selected folder", - ); - } - if (needsDirectory && !stat.isDirectory()) { - throw new LocalFileError( - "ENOTDIR", - "A download parent is not a directory", - ); - } - } - return { root: realRoot, path: current }; -} - -async function pathExists(target) { - try { - await fsp.lstat(target); - return true; - } catch { - return false; - } -} - -async function describeEntry(dirPath, dirent) { - const entryPath = path.join(dirPath, dirent.name); - let type = "file"; - let size = 0; - let modifiedTimestamp; - let linkTarget; - - try { - if (dirent.isSymbolicLink()) { - type = "link"; - try { - linkTarget = await fsp.readlink(entryPath); - } catch { - // dangling or unreadable link - } - // Follow the link so a symlinked directory still navigates like one. - try { - const target = await fsp.stat(entryPath); - if (target.isDirectory()) type = "directory"; - size = target.size; - modifiedTimestamp = target.mtimeMs; - } catch { - const own = await fsp.lstat(entryPath); - modifiedTimestamp = own.mtimeMs; - } - } else if (dirent.isDirectory()) { - type = "directory"; - const stat = await fsp.stat(entryPath); - modifiedTimestamp = stat.mtimeMs; - } else { - const stat = await fsp.stat(entryPath); - size = stat.size; - modifiedTimestamp = stat.mtimeMs; - } - } catch { - // Unreadable entry: still list it so the user sees it exists. - } - - return { - name: dirent.name, - path: entryPath, - type, - size, - modifiedTimestamp, - linkTarget, - hidden: isHiddenEntry(dirent.name), - }; -} - -async function listDirectory(dirPath) { - const resolved = normalizeLocalPath(dirPath); - const dirents = await fsp.readdir(resolved, { withFileTypes: true }); - const entries = await Promise.all( - dirents.map((dirent) => describeEntry(resolved, dirent)), - ); - const parent = path.dirname(resolved); - return { - path: resolved, - parent: parent === resolved ? null : parent, - entries, - }; -} - -// Expands a set of dropped local paths into the flat list of files (with -// paths relative to the drop root) plus any empty directories, mirroring what -// the browser's FileSystemEntry walker produces for OS drops. -async function walkPaths(rootPaths) { - const files = []; - const emptyDirs = []; - let totalBytes = 0; - - async function walkDir(absDir, relDir) { - const dirents = await fsp.readdir(absDir, { withFileTypes: true }); - if (dirents.length === 0) { - emptyDirs.push(relDir); - return; - } - for (const dirent of dirents) { - const abs = path.join(absDir, dirent.name); - const rel = `${relDir}/${dirent.name}`; - if (dirent.isDirectory()) { - await walkDir(abs, rel); - } else if (dirent.isFile()) { - const stat = await fsp.stat(abs); - files.push({ localPath: abs, relativePath: rel, size: stat.size }); - totalBytes += stat.size; - } else if (dirent.isSymbolicLink()) { - // Upload what the link points at, if it is a regular file. - try { - const stat = await fsp.stat(abs); - if (stat.isFile()) { - files.push({ localPath: abs, relativePath: rel, size: stat.size }); - totalBytes += stat.size; - } - } catch { - // dangling link: skip - } - } - } - } - - for (const rootPath of rootPaths) { - const abs = normalizeLocalPath(rootPath); - const stat = await fsp.stat(abs); - const name = path.basename(abs); - if (stat.isDirectory()) { - await walkDir(abs, name); - } else if (stat.isFile()) { - files.push({ localPath: abs, relativePath: name, size: stat.size }); - totalBytes += stat.size; - } - } - - return { files, emptyDirs, totalBytes }; -} - -function toHeaderMap(headers) { - const out = {}; - if (!headers || typeof headers !== "object") return out; - for (const [key, value] of Object.entries(headers)) { - if (value === undefined || value === null) continue; - out[key] = String(value); - } - return out; -} - -function makeProgressReporter(sender, transferId) { - let lastSentAt = 0; - return (transferred, total, force = false) => { - const now = Date.now(); - if (!force && now - lastSentAt < PROGRESS_INTERVAL_MS) return; - lastSentAt = now; - if (sender.isDestroyed()) return; - sender.send(IPC.PROGRESS, { transferId, transferred, total }); - }; -} - -function collectBody(response) { - return new Promise((resolve) => { - const chunks = []; - response.on("data", (chunk) => chunks.push(chunk)); - response.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); - response.on("error", () => resolve("")); - }); -} - -function describeHttpError(statusCode, bodyText) { - try { - const parsed = JSON.parse(bodyText); - if (parsed && typeof parsed.error === "string") return parsed.error; - if (parsed && typeof parsed.message === "string") return parsed.message; - } catch { - // not JSON - } - return bodyText?.trim() || `Request failed with status ${statusCode}`; -} - -function writeToRequest(request, chunk) { - return new Promise((resolve, reject) => { - try { - request.write(chunk, () => resolve()); - } catch (error) { - reject(error); - } - }); -} - -const TRANSFER_ROUTES = Object.freeze({ - uploadFileStream: "/uploadFileStream", - downloadFileStream: "/downloadFileStream", -}); - -const DEVICE_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/; -// A compact JWT (base64url segments joined by dots); anything else is refused. -const AUTH_TOKEN_PATTERN = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/; - -// The plugin that serves the two streaming routes tells us its /plugin-api/ -// path, so nothing here names it. Only a /plugin-api/ path is accepted, -// which keeps every transfer on the backend's plugin routes. -const API_PATH_PATTERN = /^\/plugin-api\/[a-z0-9][a-z0-9-]*$/; -let localBackendPort = 30001; - -function setLocalBackendPort(port) { - localBackendPort = port; -} -let registeredApiPath = null; - -function setTransferApiPath(apiPath) { - if (typeof apiPath !== "string" || !API_PATH_PATTERN.test(apiPath)) { - throw new Error("Invalid transfer API path"); - } - registeredApiPath = apiPath; -} - -function normalizeHttpBase(candidate, what) { - let parsed; - try { - parsed = new URL(String(candidate || "")); - } catch { - throw new Error(`${what} is not a valid URL`); - } - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { - throw new Error(`${what} must use http or https`); - } - return parsed.toString().replace(/\/$/, ""); -} - -// Resolves where a transfer may go. Only the two file-manager streaming -// routes are reachable, and only on the embedded backend or the linked -// server; credentials come from the main process, not the caller. -function createTargetResolver({ localBaseUrl, getLinkedServer, apiPath }) { - const resolveApiPath = () => { - const resolved = apiPath ?? registeredApiPath; - if (!resolved) - throw new Error("Local file transfers are not available yet"); - return resolved; - }; - return function resolveTransferTarget({ - origin, - route, - deviceId, - authToken, - } = {}) { - const routePath = TRANSFER_ROUTES[route]; - if (!routePath) { - throw new Error(`Unknown transfer route: ${String(route)}`); - } - - const headers = { "X-Electron-App": "true" }; - if (deviceId !== undefined && deviceId !== null && deviceId !== "") { - if (typeof deviceId !== "string" || !DEVICE_ID_PATTERN.test(deviceId)) { - throw new Error("Invalid device id"); - } - headers["X-Termix-Device-ID"] = deviceId; - } - - if (origin === "local") { - // The desktop renderer authenticates against the embedded backend with - // the token it keeps in localStorage (see the Electron branch of the - // axios request interceptor), and the `jwt` cookie only exists for a - // while after an interactive login. Send that same token here, so a - // transfer does not depend on a cookie the rest of the app no longer - // needs; the session cookie still rides along as a fallback. The - // renderer already uses this token on every request it makes itself, - // so forwarding it grants nothing new, and the URL stays ours. - if (authToken !== undefined && authToken !== null && authToken !== "") { - if ( - typeof authToken !== "string" || - authToken.length > 8192 || - !AUTH_TOKEN_PATTERN.test(authToken) - ) { - throw new Error("Invalid auth token"); - } - headers.Authorization = `Bearer ${authToken}`; - } - return { - url: `${normalizeHttpBase( - localBaseUrl ?? - `http://localhost:${localBackendPort}${resolveApiPath()}`, - "Local backend URL", - )}${routePath}`, - headers, - }; - } - - if (origin === "remote") { - const linked = - typeof getLinkedServer === "function" ? getLinkedServer() : null; - if (!linked || !linked.serverUrl) { - throw new Error("This device is not linked to a server"); - } - const base = normalizeHttpBase(linked.serverUrl, "Linked server URL"); - if (linked.token) headers.Authorization = `Bearer ${linked.token}`; - return { url: `${base}${resolveApiPath()}${routePath}`, headers }; - } - - throw new Error(`Unknown transfer origin: ${String(origin)}`); - }; -} - -function createNetRequest(net, event, method, url) { - return net.request({ - method, - url, - session: event.sender.session, - useSessionCookies: true, - }); -} - -// Streams one local file to the backend's multipart `uploadFileStream` route. -async function uploadLocalFile({ net, resolveTransferTarget }, event, options) { - const { - transferId, - origin, - deviceId, - authToken, - fields, - localPath, - fileName, - } = options || {}; - - if (!transferId || !localPath) { - throw new Error("Missing upload parameters"); - } - const { url, headers } = resolveTransferTarget({ - origin, - route: "uploadFileStream", - deviceId, - authToken, - }); - - const absPath = normalizeLocalPath(localPath); - const stat = await fsp.stat(absPath); - if (!stat.isFile()) { - throw new Error("Only regular files can be uploaded"); - } - - const boundary = `----TermixLocalUpload${Date.now()}${Math.random() - .toString(36) - .slice(2)}`; - const safeName = String(fileName || path.basename(absPath)) - .replace(/[\r\n]/g, " ") - .replace(/"/g, "%22"); - - let preamble = ""; - for (const [key, value] of Object.entries(fields || {})) { - if (value === undefined || value === null) continue; - preamble += - `--${boundary}\r\n` + - `Content-Disposition: form-data; name="${key}"\r\n\r\n` + - `${String(value)}\r\n`; - } - preamble += - `--${boundary}\r\n` + - `Content-Disposition: form-data; name="file"; filename="${safeName}"\r\n` + - `Content-Type: application/octet-stream\r\n\r\n`; - const epilogue = `\r\n--${boundary}--\r\n`; - - const preambleBuffer = Buffer.from(preamble, "utf8"); - const epilogueBuffer = Buffer.from(epilogue, "utf8"); - - const request = createNetRequest(net, event, "POST", url); - for (const [key, value] of Object.entries(toHeaderMap(headers))) { - request.setHeader(key, value); - } - request.setHeader( - "Content-Type", - `multipart/form-data; boundary=${boundary}`, - ); - // Without chunked encoding Electron buffers the whole body in the main - // process before sending; chunked keeps memory flat for multi-GB files. - // Busboy on the backend parses the multipart stream incrementally either way. - request.chunkedEncoding = true; - - const report = makeProgressReporter(event.sender, transferId); - const readStream = fs.createReadStream(absPath, { - highWaterMark: READ_CHUNK_BYTES, - }); - - const state = { - cancelled: false, - abort: () => { - state.cancelled = true; - readStream.destroy(); - request.abort(); - }, - }; - activeTransfers.set(transferId, state); - - // The server may answer early (auth failure, missing session) while the - // body is still streaming; stop pushing bytes as soon as it does. - let responded = false; - const responsePromise = new Promise((resolve, reject) => { - request.on("response", async (response) => { - responded = true; - const bodyText = await collectBody(response); - resolve({ statusCode: response.statusCode, bodyText }); - }); - request.on("error", (error) => reject(error)); - request.on("abort", () => reject(new Error("Transfer cancelled"))); - }); - // Avoid an unhandled rejection if we bail out before awaiting below. - responsePromise.catch(() => {}); - - // A write callback never fires once the request has errored or been - // aborted, so every write races against the response promise's rejection. - const write = (chunk) => - Promise.race([writeToRequest(request, chunk), responsePromise]); - - try { - await write(preambleBuffer); - let sent = 0; - for await (const chunk of readStream) { - if (state.cancelled) throw new Error("Transfer cancelled"); - if (responded) break; - await write(chunk); - sent += chunk.length; - report(sent, stat.size); - } - if (!responded) { - await write(epilogueBuffer); - request.end(); - } - - const { statusCode, bodyText } = await responsePromise; - if (statusCode < 200 || statusCode >= 300) { - throw new Error(describeHttpError(statusCode, bodyText)); - } - report(stat.size, stat.size, true); - return { success: true, bytes: stat.size }; - } catch (error) { - readStream.destroy(); - throw error; - } finally { - activeTransfers.delete(transferId); - } -} - -class LocalFileError extends Error { - constructor(code, message) { - super(message); - this.name = "LocalFileError"; - this.code = code; - } -} - -// Destinations with a download in flight, so two transfers can never race -// each other onto the same file. -const activeDestinations = new Set(); - -function partialPathFor(absDest, transferId) { - const token = String(transferId) - .replace(/[^A-Za-z0-9_-]/g, "") - .slice(0, 48); - return `${absDest}.${token || "transfer"}.termix-part`; -} - -// File operations used to publish a download, injectable so the replace -// strategy can be tested against Windows-like semantics (where rename() onto -// an existing name fails) without running on Windows. -const defaultPublishFs = Object.freeze({ - rename: (from, to) => fsp.rename(from, to), - link: (from, to) => fsp.link(from, to), - copyFileExcl: (from, to) => - fsp.copyFile(from, to, fs.constants.COPYFILE_EXCL), - rm: (target) => fsp.rm(target, { force: true }), - lstat: (target) => fsp.lstat(target), -}); - -function replacedPathFor(absDest, transferId) { - const token = String(transferId) - .replace(/[^A-Za-z0-9_-]/g, "") - .slice(0, 48); - return `${absDest}.${token || "transfer"}.termix-replaced`; -} - -// Puts `sourcePath` under `absDest` without ever replacing an existing file: -// link() is atomic and fails with EEXIST if the name is taken; filesystems -// without hard links fall back to an exclusive copy. The source is removed -// once the destination exists. -async function publishExclusive(io, sourcePath, absDest) { - const exists = () => - new LocalFileError("EEXIST", `"${path.basename(absDest)}" already exists`); - try { - await io.link(sourcePath, absDest); - } catch (error) { - if (error && error.code === "EEXIST") throw exists(); - try { - await io.copyFileExcl(sourcePath, absDest); - } catch (copyError) { - if (copyError && copyError.code === "EEXIST") throw exists(); - throw copyError; - } - } - await io.rm(sourcePath); -} - -// Replaces `absDest` with the finished partial. rename() over an existing -// file is not portable: POSIX replaces it, but on Windows the call commonly -// fails (EEXIST / EPERM, always when the file is open), so the swap never -// renames onto an occupied name. Instead: -// 1. move the current file aside to a transfer-unique sibling -// (renaming to a fresh name is safe everywhere); -// 2. publish the partial exclusively under the now-free name; -// 3. delete the aside copy. -// If step 1 fails nothing has changed and the caller gets EBUSY. If step 2 -// fails the aside copy is moved back, so the original survives. -async function replaceExisting(io, partialPath, absDest, transferId) { - let current; - try { - current = await io.lstat(absDest); - } catch (error) { - if (error && error.code === "ENOENT") { - // Nothing to replace after all (the file went away meanwhile). - await publishExclusive(io, partialPath, absDest); - return; - } - throw error; - } - if (current.isDirectory()) { - throw new LocalFileError( - "EISDIR", - `"${path.basename(absDest)}" is a folder and cannot be replaced by a file`, - ); - } - - const asidePath = replacedPathFor(absDest, transferId); - try { - await io.rename(absDest, asidePath); - } catch (error) { - if ( - error && - (error.code === "EPERM" || - error.code === "EBUSY" || - error.code === "EACCES") - ) { - throw new LocalFileError( - "EBUSY", - `"${path.basename(absDest)}" is in use and could not be replaced`, - ); - } - throw error; - } - - try { - await publishExclusive(io, partialPath, absDest); - } catch (error) { - // Put the original back under its name; the partial is cleaned up by - // the caller. - await io.rm(absDest).catch(() => {}); - await io.rename(asidePath, absDest).catch(() => {}); - throw error; - } - - // The old contents are no longer reachable under the real name; removing - // the aside copy can still fail on Windows if another process holds it - // open, so retry once before giving up and leaving it for the user. - try { - await io.rm(asidePath); - } catch { - await new Promise((resolve) => setTimeout(resolve, 100)); - await io.rm(asidePath).catch((error) => { - console.warn( - `[local-files] replaced "${absDest}" but could not remove the previous copy at "${asidePath}": ${error && error.message}`, - ); - }); - } -} - -// Moves a finished partial file onto its final name. Without `overwrite` the -// publish is exclusive: an existing file is never replaced, even if it -// appeared while the download was running. With `overwrite` the existing file -// is swapped out in a way that also works on Windows. -async function publishDownload( - partialPath, - absDest, - overwrite, - transferId, - io = defaultPublishFs, -) { - if (overwrite) { - await replaceExisting(io, partialPath, absDest, transferId); - return; - } - await publishExclusive(io, partialPath, absDest); -} - -// Streams one remote file (via the backend's `downloadFileStream` route) into -// a local destination. Bytes go to a transfer-unique temp sibling first and -// are published under the real name only on success, so a failed transfer -// never leaves a truncated file behind. Existing files are refused unless -// the caller explicitly asked to overwrite. -async function downloadToLocal( - { net, resolveTransferTarget, publishFs }, - event, - options, -) { - const { - transferId, - origin, - deviceId, - authToken, - body, - destPath, - rootPath, - expectedSize, - } = options || {}; - const overwrite = options?.overwrite === true; - - if (!transferId || !destPath) { - throw new Error("Missing download parameters"); - } - const { url, headers } = resolveTransferTarget({ - origin, - route: "downloadFileStream", - deviceId, - authToken, - }); - - // The renderer builds destPath from remote names; never trust that it - // stayed inside the folder the user picked. - const destination = await prepareDownloadPath(rootPath, destPath); - const absDest = destination.path; - if (activeDestinations.has(absDest)) { - throw new LocalFileError( - "EBUSY", - `"${path.basename(absDest)}" is already being downloaded`, - ); - } - activeDestinations.add(absDest); - - const partialPath = partialPathFor( - path.join(destination.root, path.basename(absDest)), - transferId, - ); - const report = makeProgressReporter(event.sender, transferId); - const state = { - cancelled: false, - abort: () => { - state.cancelled = true; - request.abort(); - }, - }; - let request = null; - - try { - if (!overwrite && (await pathExists(absDest))) { - throw new LocalFileError( - "EEXIST", - `"${path.basename(absDest)}" already exists`, - ); - } - - request = createNetRequest(net, event, "POST", url); - for (const [key, value] of Object.entries(toHeaderMap(headers))) { - request.setHeader(key, value); - } - const payload = Buffer.from(JSON.stringify(body || {}), "utf8"); - request.setHeader("Content-Type", "application/json"); - // No explicit Content-Length: Electron's net module forbids apps from - // setting it (the request fails with net::ERR_INVALID_ARGUMENT) and - // computes it itself from the buffered body when chunked encoding is off. - activeTransfers.set(transferId, state); - - await new Promise((resolve, reject) => { - let settled = false; - const fail = (error) => { - if (settled) return; - settled = true; - reject(error); - }; - - request.on("error", fail); - request.on("abort", () => fail(new Error("Transfer cancelled"))); - request.on("response", (response) => { - const statusCode = response.statusCode; - if (statusCode < 200 || statusCode >= 300) { - collectBody(response).then((bodyText) => - fail(new Error(describeHttpError(statusCode, bodyText))), - ); - return; - } - - const lengthHeader = response.headers["content-length"]; - const total = - Number( - Array.isArray(lengthHeader) ? lengthHeader[0] : lengthHeader, - ) || - Number(expectedSize) || - undefined; - - // "wx": the temp name is ours alone; refuse to reuse a stale one. - const writeStream = fs.createWriteStream(partialPath, { flags: "wx" }); - let received = 0; - - response.on("data", (chunk) => { - received += chunk.length; - const ok = writeStream.write(chunk); - if (!ok) { - response.pause(); - writeStream.once("drain", () => response.resume()); - } - report(received, total); - }); - response.on("end", () => { - writeStream.end(() => { - report(received, total ?? received, true); - if (settled) return; - settled = true; - resolve(); - }); - }); - response.on("error", (error) => { - writeStream.destroy(); - fail(error); - }); - writeStream.on("error", (error) => { - request.abort(); - fail(error); - }); - }); - - request.end(payload); - }); - - await prepareDownloadPath(destination.root, absDest); - await publishDownload( - partialPath, - absDest, - overwrite, - transferId, - publishFs || defaultPublishFs, - ); - return { success: true, path: absDest }; - } catch (error) { - await fsp.rm(partialPath, { force: true }).catch(() => {}); - throw error; - } finally { - activeTransfers.delete(transferId); - activeDestinations.delete(absDest); - } -} - -function wrap(handler) { - return async (event, ...args) => { - try { - const result = await handler(event, ...args); - return { success: true, ...(result || {}) }; - } catch (error) { - return { - success: false, - error: error && error.message ? error.message : String(error), - code: error && error.code ? error.code : undefined, - }; - } - }; -} - -// Builds the IPC handler map from injected dependencies so the whole boundary -// can be exercised in tests with a Node http shim instead of Electron. -function createLocalFileHandlers({ - net, - shell, - getLinkedServer, - localBaseUrl, - publishFs, -}) { - if (!net || typeof net.request !== "function") { - throw new Error("createLocalFileHandlers requires a net implementation"); - } - const resolveTransferTarget = createTargetResolver({ - localBaseUrl, - getLinkedServer, - }); - const deps = { - net, - resolveTransferTarget, - publishFs: publishFs || defaultPublishFs, - }; - - return { - [IPC.SET_API]: wrap(async (_event, apiPath) => { - setTransferApiPath(apiPath); - return {}; - }), - - [IPC.HOME]: wrap(async () => ({ - home: os.homedir(), - separator: path.sep, - platform: process.platform, - })), - - [IPC.LIST]: wrap(async (_event, dirPath) => listDirectory(dirPath)), - - [IPC.MKDIR]: wrap(async (_event, parentPath, name) => { - const parent = normalizeLocalPath(parentPath); - const safeName = requireEntryName(name, "folder name"); - const target = path.join(parent, safeName); - await fsp.mkdir(target); - return { path: target }; - }), - - [IPC.CREATE_FILE]: wrap(async (_event, parentPath, name) => { - const parent = normalizeLocalPath(parentPath); - const safeName = requireEntryName(name, "file name"); - const target = path.join(parent, safeName); - // "wx" fails if the file already exists rather than truncating it. - await fsp.writeFile(target, "", { flag: "wx" }); - return { path: target }; - }), - - [IPC.RENAME]: wrap(async (_event, oldPath, newName) => { - const source = normalizeLocalPath(oldPath); - const safeName = requireEntryName(newName, "name"); - const target = path.join(path.dirname(source), safeName); - if (target === source) return { path: source }; - if (await pathExists(target)) { - throw new LocalFileError("EEXIST", `"${safeName}" already exists`); - } - await fsp.rename(source, target); - return { path: target }; - }), - - // Moves entries to the OS trash (Finder Trash / Recycle Bin) rather than - // deleting outright, so a mis-click in the file manager is recoverable. - [IPC.TRASH]: wrap(async (_event, targetPaths) => { - if (!Array.isArray(targetPaths) || targetPaths.length === 0) { - throw new Error("No local paths provided"); - } - const failed = []; - for (const candidate of targetPaths) { - const target = normalizeLocalPath(candidate); - try { - await shell.trashItem(target); - } catch (error) { - failed.push({ - path: target, - error: error && error.message ? error.message : String(error), - }); - } - } - return { trashed: targetPaths.length - failed.length, failed }; - }), - - [IPC.ENSURE_DIR]: wrap(async (_event, dirPath, rootPath) => { - let target = normalizeLocalPath(dirPath); - // Transfers pass the folder the user picked; the directory skeleton of - // a downloaded tree must stay inside it. - if (rootPath !== undefined) { - target = (await prepareDownloadPath(rootPath, target, true)).path; - } else { - await fsp.mkdir(target, { recursive: true }); - } - return { path: target }; - }), - - // Which of the given paths already exist; lets the renderer ask about - // collisions before a batch download starts. - [IPC.EXISTS]: wrap(async (_event, targetPaths) => { - if (!Array.isArray(targetPaths)) { - throw new Error("Expected a list of paths"); - } - const existing = []; - for (const candidate of targetPaths) { - const target = normalizeLocalPath(candidate); - if (await pathExists(target)) existing.push(target); - } - return { existing }; - }), - - [IPC.WALK]: wrap(async (_event, rootPaths) => { - if (!Array.isArray(rootPaths) || rootPaths.length === 0) { - throw new Error("No local paths provided"); - } - return walkPaths(rootPaths); - }), - - [IPC.REVEAL]: wrap(async (_event, targetPath) => { - shell.showItemInFolder(normalizeLocalPath(targetPath)); - }), - - [IPC.OPEN]: wrap(async (_event, targetPath) => { - const error = await shell.openPath(normalizeLocalPath(targetPath)); - if (error) throw new Error(error); - }), - - [IPC.UPLOAD]: wrap((event, options) => - uploadLocalFile(deps, event, options), - ), - - [IPC.DOWNLOAD]: wrap((event, options) => - downloadToLocal(deps, event, options), - ), - - [IPC.CANCEL]: wrap(async (_event, transferId) => { - const state = activeTransfers.get(transferId); - if (!state) return { cancelled: false }; - state.abort(); - return { cancelled: true }; - }), - }; -} - -function registerLocalFileHandlers({ ipcMain, shell }) { - // Real Electron wiring; tests build the handlers directly instead. - const { net } = require("electron"); - const { getLinkedServer } = require("./linked-server.cjs"); - const handlers = createLocalFileHandlers({ - net, - shell, - getLinkedServer, - }); - for (const [channel, handler] of Object.entries(handlers)) { - ipcMain.handle(channel, handler); - } -} - -module.exports = { - IPC, - TRANSFER_ROUTES, - registerLocalFileHandlers, - // exported for tests / reuse - createLocalFileHandlers, - createTargetResolver, - setTransferApiPath, - setLocalBackendPort, - publishDownload, - defaultPublishFs, - assertWithinRoot, - walkPaths, - listDirectory, -}; diff --git a/electron/local-shell.cjs b/electron/local-shell.cjs deleted file mode 100644 index 39daa9a8b..000000000 --- a/electron/local-shell.cjs +++ /dev/null @@ -1,71 +0,0 @@ -const fs = require("node:fs"); -const path = require("node:path"); - -function isUsableShell(file, exists) { - return !!file && (!path.isAbsolute(file) || exists(file)); -} - -function resolveLocalShell( - platform, - requestedShell, - env = process.env, - exists = fs.existsSync, -) { - if (platform === "win32") { - if (requestedShell === "wsl") { - return { file: "wsl.exe", args: [] }; - } - return { - file: env.TERMIX_LOCAL_SHELL || "powershell.exe", - args: ["-NoLogo"], - }; - } - - // A SHELL left pointing at an uninstalled shell makes the spawn fail. - const fallbacks = - platform === "darwin" - ? ["/bin/zsh", "/bin/bash", "/bin/sh"] - : ["/bin/bash", "/bin/sh"]; - const file = - [env.TERMIX_LOCAL_SHELL, env.SHELL, ...fallbacks].find((candidate) => - isUsableShell(candidate, exists), - ) || fallbacks[0]; - return { file, args: ["-l"] }; -} - -/** - * node-pty starts every shell through its spawn-helper binary, and a packaged - * copy that lost its execute bit fails with "posix_spawnp failed". Returns - * how many helpers were fixed. - */ -function repairSpawnHelpers(ptyDir, fsImpl = fs) { - // A universal macOS build ships app-arm64.asar and app-x64.asar. - const dir = ptyDir.replace( - /\b(app(?:-[a-z0-9]+)?)\.asar(?!\.unpacked)/, - "$1.asar.unpacked", - ); - const candidates = [ - path.join(dir, "build", "Release", "spawn-helper"), - path.join( - dir, - "prebuilds", - `${process.platform}-${process.arch}`, - "spawn-helper", - ), - ]; - let fixed = 0; - for (const helper of candidates) { - try { - const mode = fsImpl.statSync(helper).mode; - if ((mode & 0o111) === 0) { - fsImpl.chmodSync(helper, mode | 0o755); - fixed += 1; - } - } catch { - // Missing or read-only; nothing to fix here. - } - } - return fixed; -} - -module.exports = { resolveLocalShell, repairSpawnHelpers }; diff --git a/electron/main.cjs b/electron/main.cjs index ae3d49749..892f324da 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -15,56 +15,24 @@ const path = require("path"); const { getUnpackedAppRoot } = require("./backend-paths.cjs"); const { classifyBackendFailure } = require("./backend-failure.cjs"); const fs = require("fs"); -const { renameLocalPath } = require("./rename-local-path.cjs"); -const os = require("os"); -const https = require("https"); const http = require("http"); const net = require("net"); const tls = require("tls"); const zlib = require("zlib"); const crypto = require("crypto"); const { URL, pathToFileURL } = require("url"); -const { fork, spawn } = require("child_process"); -const pty = require("node-pty"); -const WebSocket = require("ws"); +const { fork } = require("child_process"); const linkedServer = require("./linked-server.cjs"); const desktopSettings = require("./desktop-settings.cjs"); const autoUpdates = require("./auto-updater.cjs"); -const { launchNativeRdp } = require("./native-rdp.cjs"); const { isCloseActiveTabInput } = require("./keyboard-shortcuts.cjs"); const { quitApp } = require("./app-quit.cjs"); const { selectLinuxPasswordStore } = require("./linux-password-store.cjs"); -const { resolveLocalShell, repairSpawnHelpers } = require("./local-shell.cjs"); -const { - registerLocalFileHandlers, - setLocalBackendPort, -} = require("./local-files.cjs"); -const { - EXTERNAL_EDITOR_SETTING, - isLaunchableFileName, -} = require("./external-editor.cjs"); +// Saved by the file manager after the user picks an editor in a file dialog. +const EXTERNAL_EDITOR_SETTING = "fileManager.externalEditorPath"; const { isExternalLoginUrl, isCallbackPort } = require("./external-login.cjs"); -const localTerminalSessions = new Map(); - -function ownedLocalTerminal(event, sessionId) { - if (typeof sessionId !== "string" || !/^[a-f0-9-]{36}$/.test(sessionId)) { - return null; - } - const session = localTerminalSessions.get(sessionId); - return session?.ownerId === event.sender.id ? session : null; -} - -function closeLocalTerminalsFor(ownerId) { - for (const [sessionId, session] of localTerminalSessions) { - if (session.ownerId !== ownerId) continue; - session.process.kill(); - localTerminalSessions.delete(sessionId); - } -} - -// The main process's Node.js networking (the `https`/`http` modules used by -// httpFetch below and the global `fetch`) only +// The main process's Node.js networking (the global `fetch`) only // trusts Node's bundled Mozilla CA list by default, not the OS/system trust // store. Chromium (the renderer, i.e. the web app and the login iframe) uses // the OS trust store instead, so a certificate that's valid in-browser -- @@ -494,98 +462,6 @@ function getTlsVerificationOptions(url) { }; } -function getWebSocketOptions(url, options = {}) { - return { - ...options, - ...(String(url).startsWith("wss:") ? getTlsVerificationOptions(url) : {}), - }; -} - -function httpFetch(url, options = {}) { - return new Promise((resolve, reject) => { - const urlObj = new URL(url); - const isHttps = urlObj.protocol === "https:"; - const client = isHttps ? https : http; - - const requestOptions = { - method: options.method || "GET", - headers: options.headers || {}, - timeout: options.timeout || 10000, - ...(isHttps - ? options.allowInvalidCertificate - ? { rejectUnauthorized: false } - : getTlsVerificationOptions(url) - : {}), - }; - - const req = client.request(url, requestOptions, (res) => { - const chunks = []; - // Reverse proxies (nginx and friends) commonly gzip/deflate/br-compress - // responses regardless of client Accept-Encoding. Unlike browser fetch, - // Node's http/https modules never auto-decompress, so an unhandled - // content-encoding here silently turns the body into garbage bytes. - let stream = res; - const maxResponseBytes = options.maxResponseBytes || 10 * 1024 * 1024; - let responseBytes = 0; - let settled = false; - const fail = (error) => { - if (settled) return; - settled = true; - stream.destroy(); - req.destroy(); - reject(error); - }; - const encoding = (res.headers["content-encoding"] || "") - .toLowerCase() - .trim(); - try { - if (encoding === "gzip" || encoding === "x-gzip") { - stream = res.pipe(zlib.createGunzip()); - } else if (encoding === "br") { - stream = res.pipe(zlib.createBrotliDecompress()); - } else if (encoding === "deflate") { - stream = res.pipe(zlib.createInflate()); - } - } catch (decompressError) { - reject(decompressError); - return; - } - - stream.on("data", (chunk) => { - responseBytes += chunk.length; - if (responseBytes > maxResponseBytes) { - fail(new Error(`Response exceeds ${maxResponseBytes} bytes`)); - return; - } - chunks.push(chunk); - }); - stream.on("end", () => { - if (settled) return; - settled = true; - const data = Buffer.concat(chunks).toString("utf8"); - resolve({ - ok: res.statusCode >= 200 && res.statusCode < 300, - status: res.statusCode, - text: () => Promise.resolve(data), - json: () => Promise.resolve(JSON.parse(data)), - }); - }); - stream.on("error", fail); - }); - - req.on("error", reject); - req.on("timeout", () => { - req.destroy(); - reject(new Error("Request timeout")); - }); - - if (options.body) { - req.write(options.body); - } - req.end(); - }); -} - if (process.platform === "linux") { app.commandLine.appendSwitch("--ozone-platform-hint=auto"); @@ -631,14 +507,24 @@ const isolatedWindows = createIsolatedWindows({ // ctx.desktop.openIsolatedWindow is the one caller today. const BACKEND_REQUEST_HANDLERS = { "open-isolated-window": (payload) => isolatedWindows.open(payload), - // The OS client for a protocol. Only RDP has one wired up. - "launch-external-client": (payload) => - payload?.protocol === "rdp" - ? launchNativeRdp(payload) - : { - success: false, - error: `No external client for ${payload?.protocol ?? "this protocol"}`, - }, + // Plugins open OS clients themselves now (desktop:local), so none is wired here. + "launch-external-client": (payload) => ({ + success: false, + error: `No external client for ${payload?.protocol ?? "this protocol"}`, + }), + // Generic shell actions for a plugin's desktop:local file work. + "shell-trash-item": async (payload) => { + await shell.trashItem(String(payload?.path ?? "")); + return { success: true }; + }, + "shell-show-item": (payload) => { + shell.showItemInFolder(String(payload?.path ?? "")); + return { success: true }; + }, + "shell-open-path": async (payload) => { + const error = await shell.openPath(String(payload?.path ?? "")); + return error ? { success: false, error } : { success: true }; + }, "sync-proxy-config": (payload) => { linkedServer.setLinkedServer(payload); return { success: true }; @@ -699,7 +585,6 @@ let backendStderrTail = ""; let tray = null; let isQuitting = false; const tempFiles = new Map(); -const externalEditorSessions = new Map(); // Paths the user picked in an open dialog this session. Only these may be // saved as the external editor, so the renderer cannot name an executable. const dialogChosenPaths = new Set(); @@ -751,67 +636,6 @@ function cleanupManagedTempFile(tempId) { tempFiles.delete(tempId); } -function closeExternalEditorSession(editId) { - const session = externalEditorSessions.get(editId); - if (!session) return; - if (session.timer) clearTimeout(session.timer); - try { - session.watcher.close(); - } catch { - // watcher may already be closed - } - externalEditorSessions.delete(editId); - cleanupManagedTempFile(editId); -} - -function notifyExternalEditorSaved(editId) { - const session = externalEditorSessions.get(editId); - if (!session || !mainWindow || mainWindow.isDestroyed()) return; - - try { - const stat = fs.statSync(session.path); - if (stat.mtimeMs === session.lastMtimeMs) return; - session.lastMtimeMs = stat.mtimeMs; - - const content = fs.readFileSync(session.path, "utf8"); - mainWindow.webContents.send("external-editor-saved", { - editId, - content, - encoding: "utf8", - path: session.path, - }); - } catch (error) { - logToFile("Failed to read external editor file:", error.message); - } -} - -function openPathWithEditor(filePath, editorPath) { - if (!editorPath) { - return shell.openPath(filePath); - } - - return new Promise((resolve) => { - let settled = false; - const child = spawn(editorPath, [filePath], { - detached: true, - stdio: "ignore", - }); - - child.once("error", (error) => { - if (settled) return; - settled = true; - resolve(error.message); - }); - - setTimeout(() => { - if (settled) return; - settled = true; - child.unref(); - resolve(""); - }, 500); - }); -} - // Origins the renderer has explicitly marked as allowed to present an invalid // certificate, for web endpoints whose ignoreCert is set. Session scoped and // never persisted. @@ -1190,6 +1014,7 @@ function startBackendServer() { ELECTRON_EMBEDDED: "true", PORT: String(backendPort), EMBEDDED_BACKEND_PORT: String(backendPort), + ELECTRON_USER_DATA: app.getPath("userData"), VERSION: app.getVersion(), }, stdio: ["pipe", "pipe", "pipe", "ipc"], @@ -1858,1353 +1683,6 @@ ipcMain.handle("get-previous-server-url", () => { return { serverUrl: desktopSettings.getPreviousServerUrl() }; }); -function getC2STunnelConfigPath() { - return path.join(app.getPath("userData"), "c2s-tunnels.json"); -} - -ipcMain.handle("get-c2s-tunnel-config", () => { - try { - const configPath = getC2STunnelConfigPath(); - if (!fs.existsSync(configPath)) { - return []; - } - const configData = fs.readFileSync(configPath, "utf8"); - const parsed = JSON.parse(configData); - return Array.isArray(parsed) ? parsed : []; - } catch (error) { - console.error("Error reading C2S tunnel config:", error); - return []; - } -}); - -ipcMain.handle("save-c2s-tunnel-config", async (_event, config) => { - try { - if (!Array.isArray(config)) { - return { success: false, error: "C2S tunnel config must be an array" }; - } - const autoStartListeners = new Set(); - const autoStartRemoteListeners = new Set(); - for (const tunnel of config) { - if (!tunnel?.autoStart) continue; - const mode = tunnel.mode || tunnel.tunnelType || "local"; - if (mode === "remote") { - const sourceHostId = Number(tunnel.sourceHostId); - const sourcePort = Number(tunnel.sourcePort); - const remoteAddress = getC2SRemoteAddress(tunnel); - if ( - !Number.isInteger(sourceHostId) || - sourceHostId < 1 || - !Number.isInteger(sourcePort) || - sourcePort < 1 || - sourcePort > 65535 - ) { - return { - success: false, - error: "Invalid remote client tunnel endpoint or port", - }; - } - const listenerKey = `${sourceHostId}:${remoteAddress}:${sourcePort}`; - if (autoStartRemoteListeners.has(listenerKey)) { - return { - success: false, - error: `Another auto-start client tunnel already uses remote ${listenerKey}`, - }; - } - autoStartRemoteListeners.add(listenerKey); - continue; - } - - const bindHost = getC2SLocalAddress(tunnel); - const sourcePort = Number(tunnel.sourcePort); - const listenerKey = `${bindHost}:${sourcePort}`; - if (autoStartListeners.has(listenerKey)) { - return { - success: false, - error: `Another auto-start client tunnel already uses ${listenerKey}`, - }; - } - autoStartListeners.add(listenerKey); - } - for (const listenerKey of autoStartListeners) { - const sourcePortSeparator = listenerKey.lastIndexOf(":"); - const bindHost = listenerKey.slice(0, sourcePortSeparator); - const sourcePort = listenerKey.slice(sourcePortSeparator + 1); - const result = await checkLocalPortAvailable( - bindHost, - Number(sourcePort), - ); - const ownedByClientTunnel = Array.from(c2sTunnelRuntimes.values()).some( - (runtime) => - runtime.bindHost === bindHost && - runtime.sourcePort === Number(sourcePort), - ); - if (!result.available && !ownedByClientTunnel) { - return { - success: false, - error: `Cannot auto-start client tunnel on ${listenerKey}: ${result.error || "port is already in use"}`, - }; - } - } - const userDataPath = app.getPath("userData"); - if (!fs.existsSync(userDataPath)) { - fs.mkdirSync(userDataPath, { recursive: true }); - } - fs.writeFileSync(getC2STunnelConfigPath(), JSON.stringify(config, null, 2)); - return { success: true }; - } catch (error) { - console.error("Error saving C2S tunnel config:", error); - return { success: false, error: error.message }; - } -}); - -function checkLocalPortAvailable(host, port) { - return new Promise((resolve) => { - const server = net.createServer(); - server.once("error", (error) => { - resolve({ available: false, error: error.message }); - }); - server.once("listening", () => { - server.close(() => resolve({ available: true })); - }); - server.listen({ host, port }); - }); -} - -function checkTcpConnection(host, port) { - return new Promise((resolve) => { - const socket = net.createConnection({ host, port }); - const timer = setTimeout(() => { - socket.destroy(); - resolve({ success: false, error: "Connection timed out" }); - }, 5000); - - socket.once("connect", () => { - clearTimeout(timer); - socket.destroy(); - resolve({ success: true }); - }); - socket.once("error", (error) => { - clearTimeout(timer); - socket.destroy(); - resolve({ success: false, error: error.message }); - }); - }); -} - -function getC2SAddress(value, fallback) { - const address = typeof value === "string" ? value.trim() : ""; - return address || fallback; -} - -function getC2SLocalAddress(tunnel) { - return getC2SAddress(tunnel?.localAddress || tunnel?.bindHost, "127.0.0.1"); -} - -function getC2SRemoteAddress(tunnel) { - return getC2SAddress( - tunnel?.remoteAddress || tunnel?.targetHost, - "127.0.0.1", - ); -} - -function normalizeC2STunnelAddresses(tunnel) { - const localAddress = getC2SLocalAddress(tunnel); - const remoteAddress = getC2SRemoteAddress(tunnel); - return { - ...tunnel, - localAddress, - remoteAddress, - bindHost: localAddress, - targetHost: remoteAddress, - }; -} - -const c2sTunnelRuntimes = new Map(); -const C2S_WS_HIGH_WATERMARK = 1024 * 1024; -const C2S_WS_LOW_WATERMARK = 256 * 1024; -const C2S_STREAM_WRITE_LIMIT = 8 * 1024 * 1024; - -// A proxy in front of the server may drop an idle WebSocket, which stopped -// the tunnel. Pings count as traffic. -function keepC2SSocketAlive(ws) { - const timer = setInterval(() => { - if (ws.readyState === WebSocket.OPEN) ws.ping(); - }, 25_000); - timer.unref?.(); - ws.once("close", () => clearInterval(timer)); -} - -// C2S (client-to-server) tunnels relay through the server this desktop is -// linked to, not the embedded backend. -function getC2SRemoteBaseUrl() { - const serverUrl = linkedServer.getLinkedServer()?.serverUrl; - if (!serverUrl) { - throw new Error( - "This device is not linked to a Termix server. Link it in Sync first.", - ); - } - return serverUrl.replace(/\/$/, ""); -} - -// The socket path on the linked server that relays C2S streams. The plugin -// that owns the relay tells us where it is, so nothing here names it. -let c2sRelayPath = null; -const C2S_RELAY_PATH_PATTERN = - /^\/plugin-ws\/[a-z0-9][a-z0-9-]*\/[A-Za-z0-9/_-]+$/; - -ipcMain.handle("set-c2s-relay-path", (_event, relayPath) => { - if ( - typeof relayPath !== "string" || - !C2S_RELAY_PATH_PATTERN.test(relayPath) - ) { - return { success: false, error: "Invalid relay path" }; - } - c2sRelayPath = relayPath; - return { success: true }; -}); - -function getC2SRelayUrl() { - if (!c2sRelayPath) { - throw new Error("Client tunnels are not available yet. Try again shortly."); - } - const relayHttpUrl = `${getC2SRemoteBaseUrl()}${c2sRelayPath}`; - return relayHttpUrl.replace(/^http:/, "ws:").replace(/^https:/, "wss:"); -} - -const C2S_REMOTE_SESSION_EXPIRED_ERROR = - "This device was signed out of its server. Sign in again from Sync and try again."; - -function normalizeC2SAuthToken(authToken) { - return typeof authToken === "string" ? authToken.trim() : ""; -} - -function isC2SAuthError(message) { - const value = String(message || "") - .trim() - .toLowerCase(); - return ( - value === "authentication required" || - value === "missing authentication token" || - value === "invalid token" || - value === "session expired" || - value === "session not found" || - // The plugin relay refuses an unauthenticated upgrade before any message. - value === "unexpected server response: 401" - ); -} - -function normalizeC2SErrorMessage(message, fallback = "Client tunnel failed") { - const value = message || fallback; - return isC2SAuthError(value) ? C2S_REMOTE_SESSION_EXPIRED_ERROR : value; -} - -function createC2SFailure(message, fallback) { - const error = normalizeC2SErrorMessage(message, fallback); - return { - success: false, - error, - ...(error === C2S_REMOTE_SESSION_EXPIRED_ERROR - ? { code: "REMOTE_SESSION_EXPIRED" } - : {}), - }; -} - -async function fetchC2SRemoteJson(pathname) { - if (!linkedServer.getLinkedServer()?.token) { - throw new Error(C2S_REMOTE_SESSION_EXPIRED_ERROR); - } - - const url = `${getC2SRemoteBaseUrl()}${pathname}`; - const response = await httpFetch(url, { - headers: linkedServer.linkedRequestHeaders(), - }); - if (response.status === 401 || response.status === 403) { - throw new Error(C2S_REMOTE_SESSION_EXPIRED_ERROR); - } - if (!response.ok) { - throw new Error(`Request failed (${response.status}): ${url}`); - } - return response.json(); -} - -async function resolveC2SRemoteSourceHost(tunnel) { - const normalized = normalizeC2STunnelAddresses(tunnel); - const sourceHostSyncId = - typeof normalized.sourceHostSyncId === "string" - ? normalized.sourceHostSyncId.trim() - : ""; - if (!sourceHostSyncId) return normalized; - - let remoteHost = null; - try { - remoteHost = await fetchC2SRemoteJson( - `/sync/v2/hosts/${encodeURIComponent(sourceHostSyncId)}`, - ); - } catch (error) { - if (error.message === C2S_REMOTE_SESSION_EXPIRED_ERROR) throw error; - } - const remoteHostId = Number(remoteHost?.id); - if (!Number.isInteger(remoteHostId) || remoteHostId < 1) { - throw new Error( - "The intermediate host is not on the server yet. Sync, then try again.", - ); - } - - return { - ...normalized, - sourceHostId: remoteHostId, - sourceHostSyncId, - sourceHostName: remoteHost.name || normalized.sourceHostName, - endpointHost: normalized.endpointHost || remoteHost.name, - }; -} - -async function getC2SRelayHeaders(relayUrl) { - if (linkedServer.getLinkedServer()?.token) { - return linkedServer.linkedRequestHeaders(); - } - const headers = { "X-Electron-App": "true" }; - - if (!mainWindow?.webContents?.session) { - throw new Error(C2S_REMOTE_SESSION_EXPIRED_ERROR); - } - - const cookieUrl = relayUrl - .replace(/^ws:/, "http:") - .replace(/^wss:/, "https:"); - const cookies = await mainWindow.webContents.session.cookies.get({ - url: cookieUrl, - name: "jwt", - }); - const jwt = cookies[0]?.value; - if (!jwt) { - throw new Error(C2S_REMOTE_SESSION_EXPIRED_ERROR); - } - - headers.Cookie = `jwt=${encodeURIComponent(jwt)}`; - return headers; -} - -const attachC2SAuth = require("./c2s-auth.cjs").createC2SAuthBridge( - ipcMain, - () => mainWindow, -); - -function getC2STunnelName(tunnel, index = 0) { - if (tunnel.name) return tunnel.name; - const localAddress = getC2SLocalAddress(tunnel); - const remoteAddress = getC2SRemoteAddress(tunnel); - return [ - "c2s", - index, - tunnel.sourceHostId || 0, - tunnel.mode || tunnel.tunnelType || "local", - localAddress, - remoteAddress, - tunnel.sourcePort, - tunnel.endpointPort || 0, - ].join("::"); -} - -function getC2STunnelStatus(tunnelName) { - return ( - c2sTunnelRuntimes.get(tunnelName)?.status || { - connected: false, - status: "DISCONNECTED", - } - ); -} - -function getAllC2STunnelStatuses() { - const statuses = {}; - for (const [tunnelName] of c2sTunnelRuntimes.entries()) { - statuses[tunnelName] = getC2STunnelStatus(tunnelName); - } - return statuses; -} - -function emitC2STunnelStatuses() { - if (!mainWindow || mainWindow.isDestroyed()) return; - mainWindow.webContents.send("c2s-tunnel-statuses", getAllC2STunnelStatuses()); -} - -function setC2STunnelStatus(tunnelName, status) { - const runtime = c2sTunnelRuntimes.get(tunnelName); - if (runtime) { - runtime.status = status; - emitC2STunnelStatuses(); - } -} - -function setC2STunnelError(tunnelName, message) { - logToFile(`[c2s] ${tunnelName} failed:`, message); - setC2STunnelStatus(tunnelName, { - connected: false, - status: "ERROR", - reason: message, - }); -} - -function parseSocks5Target(buffer) { - if (buffer.length < 7 || buffer[0] !== 0x05 || buffer[1] !== 0x01) { - return null; - } - - const addressType = buffer[3]; - let offset = 4; - let host; - - if (addressType === 0x01) { - if (buffer.length < offset + 4 + 2) return null; - host = Array.from(buffer.subarray(offset, offset + 4)).join("."); - offset += 4; - } else if (addressType === 0x03) { - const length = buffer[offset]; - offset += 1; - if (buffer.length < offset + length + 2) return null; - host = buffer.subarray(offset, offset + length).toString("utf8"); - offset += length; - } else if (addressType === 0x04) { - if (buffer.length < offset + 16 + 2) return null; - const parts = []; - for (let i = 0; i < 16; i += 2) { - parts.push(buffer.readUInt16BE(offset + i).toString(16)); - } - host = parts.join(":"); - offset += 16; - } else { - throw new Error("Unsupported SOCKS5 address type"); - } - - const port = buffer.readUInt16BE(offset); - return { host, port, bytesRead: offset + 2 }; -} - -async function openC2SRelay( - tunnel, - targetHost, - targetPort, - socket, - initialData, - authToken, -) { - const tunnelConfig = normalizeC2STunnelAddresses(tunnel); - const tunnelName = tunnel.name || getC2STunnelName(tunnel); - const relayUrl = getC2SRelayUrl(); - const headers = await getC2SRelayHeaders(relayUrl, authToken); - logToFile(`[c2s] opening relay for ${tunnelName}`, { - relayUrl, - targetHost, - targetPort, - }); - const ws = new WebSocket( - relayUrl, - getWebSocketOptions(relayUrl, { headers }), - ); - attachC2SAuth(ws, tunnel.name || getC2STunnelName(tunnel)); - keepC2SSocketAlive(ws); - const pendingChunks = []; - let ready = false; - let closed = false; - - const cleanup = () => { - if (closed) return; - closed = true; - try { - socket.destroy(); - } catch { - // expected during shutdown - } - try { - ws.close(); - } catch { - // expected during shutdown - } - }; - - const sendChunk = (chunk) => { - if (ready && ws.readyState === WebSocket.OPEN) { - ws.send(chunk); - } else { - pendingChunks.push(chunk); - } - }; - - socket.on("data", sendChunk); - socket.on("close", cleanup); - // One connection failing must not stop the tunnel: the listener refuses - // new connections while the tunnel is not connected, and a browser opens - // many at once. - socket.on("error", (error) => { - logToFile(`[c2s] ${tunnelName} local socket error:`, error.message); - cleanup(); - }); - ws.on("close", cleanup); - ws.on("error", (error) => { - logToFile(`[c2s] ${tunnelName} relay error:`, error.message); - cleanup(); - }); - - ws.on("open", () => { - logToFile(`[c2s] relay connected for ${tunnelName}`); - ws.send( - JSON.stringify({ - type: "open", - tunnelConfig, - targetHost, - targetPort, - }), - ); - }); - - ws.on("message", (data, isBinary) => { - if (isBinary) { - socket.write(Buffer.isBuffer(data) ? data : Buffer.from(data)); - return; - } - - try { - const message = JSON.parse(data.toString()); - if (message.type === "ready") { - ready = true; - logToFile(`[c2s] relay ready for ${tunnelName}`); - if (initialData?.length) { - ws.send(initialData); - } - while (pendingChunks.length > 0) { - ws.send(pendingChunks.shift()); - } - } else if (message.type === "error") { - const relayError = normalizeC2SErrorMessage( - message.error, - "Relay rejected the client tunnel", - ); - logToFile("[c2s] relay error:", relayError); - if (relayError === C2S_REMOTE_SESSION_EXPIRED_ERROR) { - setC2STunnelError(tunnelName, relayError); - } - cleanup(); - } - } catch (error) { - logToFile("[c2s] invalid relay message:", error.message); - cleanup(); - } - }); -} - -async function testC2SRelay( - tunnel, - targetHost, - targetPort, - authToken, - runtime, -) { - const tunnelConfig = normalizeC2STunnelAddresses(tunnel); - const relayUrl = getC2SRelayUrl(); - const headers = await getC2SRelayHeaders(relayUrl, authToken); - if (runtime && c2sTunnelRuntimes.get(tunnel.name) !== runtime) { - return { success: false, error: "Tunnel stopped" }; - } - const ws = new WebSocket( - relayUrl, - getWebSocketOptions(relayUrl, { headers }), - ); - attachC2SAuth(ws, tunnel.name || getC2STunnelName(tunnel)); - keepC2SSocketAlive(ws); - - if (runtime) runtime.authSocket = ws; - return new Promise((resolve) => { - let settled = false; - // The relay sends its reason and then closes; keep the reason. - let relayError = null; - const settle = (result) => { - if (settled) return; - settled = true; - try { - if (!runtime || !result.success) ws.close(); - } catch { - // expected during shutdown - } - resolve(result); - }; - - const timer = setTimeout(() => { - settle({ success: false, error: "Tunnel test timed out" }); - }, 180_000); - - ws.on("open", () => { - ws.send( - JSON.stringify({ - type: "test", - keepAlive: !!runtime, - tunnelConfig, - targetHost, - targetPort, - }), - ); - }); - ws.on("message", (data, isBinary) => { - if (isBinary) return; - - try { - const message = JSON.parse(data.toString()); - if (message.type === "ready") { - clearTimeout(timer); - settle({ success: true }); - } else if (message.type === "error") { - clearTimeout(timer); - relayError = normalizeC2SErrorMessage( - message.error, - "Tunnel test failed", - ); - settle(createC2SFailure(message.error, "Tunnel test failed")); - } - } catch (error) { - clearTimeout(timer); - settle(createC2SFailure(error.message, "Tunnel test failed")); - } - }); - ws.on("error", (error) => { - clearTimeout(timer); - settle(createC2SFailure(error.message, "Tunnel test failed")); - }); - ws.on("close", () => { - if (runtime && c2sTunnelRuntimes.get(tunnel.name) === runtime) { - setC2STunnelError( - tunnel.name, - relayError || "Endpoint SSH connection closed", - ); - } - clearTimeout(timer); - settle({ success: false, error: "Tunnel test connection closed" }); - }); - }); -} - -async function testC2STunnel(tunnel, index = 0, authToken) { - const resolvedTunnel = await resolveC2SRemoteSourceHost(tunnel); - const mode = resolvedTunnel.mode || resolvedTunnel.tunnelType || "local"; - const testTunnel = { - ...normalizeC2STunnelAddresses(resolvedTunnel), - name: `${getC2STunnelName(resolvedTunnel, index)}::test`, - mode, - }; - const localAddress = getC2SLocalAddress(testTunnel); - const remoteAddress = getC2SRemoteAddress(testTunnel); - const sourcePort = Number(resolvedTunnel.sourcePort); - const endpointPort = Number(resolvedTunnel.endpointPort); - - if (!resolvedTunnel.sourceHostId) { - return { success: false, error: "Endpoint SSH host is required" }; - } - - if (mode === "remote") { - const localTarget = await checkTcpConnection(localAddress, endpointPort); - if (!localTarget.success) { - return { - success: false, - error: `Local target ${localAddress}:${endpointPort} is not reachable: ${localTarget.error}`, - }; - } - - return testC2SRelay(testTunnel, undefined, undefined, authToken); - } - - if (!Number.isInteger(sourcePort) || sourcePort < 1 || sourcePort > 65535) { - return { success: false, error: "Invalid local port" }; - } - - const runtime = c2sTunnelRuntimes.get( - getC2STunnelName(resolvedTunnel, index), - ); - if (!runtime) { - const availability = await checkLocalPortAvailable( - localAddress, - sourcePort, - ); - if (!availability.available) { - return { - success: false, - error: `Local listener ${localAddress}:${sourcePort} is not available: ${availability.error}`, - }; - } - } - - if (mode === "dynamic") { - return testC2SRelay(testTunnel, undefined, undefined, authToken); - } - - if (!Number.isInteger(endpointPort) || endpointPort < 1) { - return { success: false, error: "Invalid remote port" }; - } - - return testC2SRelay(testTunnel, remoteAddress, endpointPort, authToken); -} - -function handleC2SDynamicConnection(tunnel, socket, authToken) { - const tunnelName = tunnel.name || getC2STunnelName(tunnel); - let buffer = Buffer.alloc(0); - let stage = "greeting"; - - const fail = (code = 0x01, message = "SOCKS5 request failed") => { - setC2STunnelError(tunnelName, message); - if (!socket.destroyed) { - socket.write(Buffer.from([0x05, code, 0x00, 0x01, 0, 0, 0, 0, 0, 0])); - socket.destroy(); - } - }; - - const onData = (chunk) => { - buffer = Buffer.concat([buffer, chunk]); - - try { - if (stage === "greeting") { - if (buffer.length < 2) return; - if (buffer[0] !== 0x05) { - fail(0x01, "Invalid SOCKS5 greeting"); - return; - } - const methodsLength = buffer[1]; - if (buffer.length < 2 + methodsLength) return; - socket.write(Buffer.from([0x05, 0x00])); - buffer = buffer.subarray(2 + methodsLength); - stage = "connect"; - } - - if (stage === "connect") { - const target = parseSocks5Target(buffer); - if (!target) return; - - stage = "piping"; - socket.off("data", onData); - const remainder = buffer.subarray(target.bytesRead); - socket.write(Buffer.from([0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0])); - openC2SRelay( - tunnel, - target.host, - target.port, - socket, - remainder, - authToken, - ).catch((error) => { - const message = normalizeC2SErrorMessage( - error.message, - "Dynamic relay failed", - ); - logToFile("[c2s] dynamic relay failed:", message); - fail(0x05, message); - }); - } - } catch (error) { - logToFile("[c2s] SOCKS5 parse failed:", error.message); - fail(0x01, error.message || "SOCKS5 parse failed"); - } - }; - - socket.on("data", onData); - socket.on("error", () => socket.destroy()); -} - -function handleC2SLocalConnection(tunnel, socket, authToken) { - const tunnelName = tunnel.name || getC2STunnelName(tunnel); - const targetHost = getC2SRemoteAddress(tunnel); - const targetPort = Number(tunnel.endpointPort); - openC2SRelay( - tunnel, - targetHost, - targetPort, - socket, - undefined, - authToken, - ).catch((error) => { - const message = normalizeC2SErrorMessage( - error.message, - "Local relay failed", - ); - logToFile("[c2s] local relay failed:", message); - if (message === C2S_REMOTE_SESSION_EXPIRED_ERROR) { - setC2STunnelError(tunnelName, message); - } - socket.destroy(); - }); -} - -function pauseSourceForC2SWebSocket(ws, source) { - if (!source?.pause || !source?.resume) return; - if (ws.bufferedAmount <= C2S_WS_HIGH_WATERMARK) return; - - source.pause(); - const resumeTimer = setInterval(() => { - if ( - ws.readyState !== WebSocket.OPEN || - source.destroyed || - ws.bufferedAmount <= C2S_WS_LOW_WATERMARK - ) { - clearInterval(resumeTimer); - if (ws.readyState === WebSocket.OPEN && !source.destroyed) { - source.resume(); - } - } - }, 25); -} - -function sendC2SRemoteMessage(ws, message, source) { - if (ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify(message), (error) => { - if (error && source?.destroy) { - source.destroy(error); - } - }); - pauseSourceForC2SWebSocket(ws, source); - } -} - -function writeC2SRemoteChunk(target, chunk, ws, closeTarget) { - if (!target || target.destroyed) return; - - if (target.writableLength > C2S_STREAM_WRITE_LIMIT) { - closeTarget(); - return; - } - - const canContinue = target.write(chunk); - if (!canContinue && typeof ws.pause === "function") { - ws.pause(); - target.once("drain", () => { - if (ws.readyState === WebSocket.OPEN && typeof ws.resume === "function") { - ws.resume(); - } - }); - } -} - -async function startC2SRemoteTunnel(tunnel, index = 0, authToken) { - const tunnelName = getC2STunnelName(tunnel, index); - const tunnelConfig = normalizeC2STunnelAddresses(tunnel); - const localHost = getC2SLocalAddress(tunnelConfig); - const remoteHost = getC2SRemoteAddress(tunnelConfig); - const localPort = Number(tunnel.endpointPort); - const remotePort = Number(tunnel.sourcePort); - - if (!tunnel.sourceHostId) { - return { success: false, error: "Endpoint SSH host is required" }; - } - if (!Number.isInteger(remotePort) || remotePort < 1 || remotePort > 65535) { - return { success: false, error: "Invalid remote port" }; - } - if (!Number.isInteger(localPort) || localPort < 1 || localPort > 65535) { - return { success: false, error: "Invalid local port" }; - } - - const localTarget = await checkTcpConnection(localHost, localPort); - if (!localTarget.success) { - return { - success: false, - error: `Local target ${localHost}:${localPort} is not reachable: ${localTarget.error}`, - }; - } - - const existing = c2sTunnelRuntimes.get(tunnelName); - if (existing) { - return { success: true, tunnelName }; - } - - for (const runtime of c2sTunnelRuntimes.values()) { - if ( - runtime.mode === "remote" && - runtime.sourceHostId === Number(tunnel.sourceHostId) && - runtime.remoteAddress === remoteHost && - runtime.sourcePort === remotePort - ) { - return { - success: false, - error: `Another client remote tunnel already uses ${remoteHost}:${remotePort} on this endpoint`, - }; - } - } - - const relayUrl = getC2SRelayUrl(); - const headers = await getC2SRelayHeaders(relayUrl, authToken); - const ws = new WebSocket( - relayUrl, - getWebSocketOptions(relayUrl, { headers }), - ); - attachC2SAuth(ws, tunnel.name || getC2STunnelName(tunnel)); - keepC2SSocketAlive(ws); - const sockets = new Map(); - let closed = false; - - const cleanup = () => { - if (closed) return; - closed = true; - for (const socket of sockets.values()) { - socket.destroy(); - } - sockets.clear(); - try { - ws.close(); - } catch { - // expected during shutdown - } - }; - - c2sTunnelRuntimes.set(tunnelName, { - ws, - sockets, - mode: "remote", - sourceHostId: Number(tunnel.sourceHostId), - sourcePort: remotePort, - bindHost: localHost, - remoteAddress: remoteHost, - status: { connected: false, status: "CONNECTING" }, - close: cleanup, - }); - emitC2STunnelStatuses(); - - return new Promise((resolve) => { - let settled = false; - const settle = (result) => { - if (settled) return; - settled = true; - resolve(result); - }; - - ws.on("open", () => { - logToFile(`[c2s] opening remote tunnel ${tunnelName}`, { - relayUrl, - remoteHost, - remotePort, - localHost, - localPort, - }); - ws.send( - JSON.stringify({ - type: "open", - tunnelConfig: { ...tunnelConfig, name: tunnelName, mode: "remote" }, - }), - ); - }); - - ws.on("message", (data, isBinary) => { - if (isBinary) return; - - let message; - try { - message = JSON.parse(data.toString()); - } catch (error) { - const message = normalizeC2SErrorMessage( - error.message, - "Invalid relay message", - ); - setC2STunnelError(tunnelName, message); - cleanup(); - settle(createC2SFailure(message, "Invalid relay message")); - return; - } - - if (message.type === "ready") { - setC2STunnelStatus(tunnelName, { - connected: true, - status: "CONNECTED", - }); - settle({ success: true, tunnelName }); - return; - } - - if (message.type === "error") { - const error = normalizeC2SErrorMessage( - message.error, - "Relay rejected the client tunnel", - ); - setC2STunnelError(tunnelName, error); - cleanup(); - c2sTunnelRuntimes.delete(tunnelName); - emitC2STunnelStatuses(); - settle(createC2SFailure(error, "Relay rejected the client tunnel")); - return; - } - - if (message.type === "connection" && message.streamId) { - const socket = net.createConnection( - { host: localHost, port: localPort }, - () => { - logToFile(`[c2s] remote stream ${message.streamId} connected`, { - tunnelName, - localHost, - localPort, - }); - }, - ); - sockets.set(message.streamId, socket); - socket.on("data", (chunk) => { - sendC2SRemoteMessage( - ws, - { - type: "data", - streamId: message.streamId, - data: chunk.toString("base64"), - }, - socket, - ); - }); - socket.on("close", () => { - sockets.delete(message.streamId); - sendC2SRemoteMessage(ws, { - type: "close", - streamId: message.streamId, - }); - }); - socket.on("error", (error) => { - logToFile(`[c2s] remote stream ${message.streamId} failed:`, { - tunnelName, - error: error.message, - }); - sockets.delete(message.streamId); - sendC2SRemoteMessage(ws, { - type: "close", - streamId: message.streamId, - error: error.message, - }); - }); - return; - } - - if (message.type === "data" && message.streamId && message.data) { - const socket = sockets.get(message.streamId); - writeC2SRemoteChunk( - socket, - Buffer.from(message.data, "base64"), - ws, - () => { - if (socket) { - sockets.delete(message.streamId); - socket.destroy(); - } - }, - ); - return; - } - - if (message.type === "close" && message.streamId) { - const socket = sockets.get(message.streamId); - if (socket) { - sockets.delete(message.streamId); - socket.destroy(); - } - } - }); - - ws.on("close", () => { - cleanup(); - c2sTunnelRuntimes.delete(tunnelName); - emitC2STunnelStatuses(); - settle({ success: false, error: "Remote tunnel relay closed" }); - }); - - ws.on("error", (error) => { - const message = normalizeC2SErrorMessage( - error.message, - "Relay connection failed", - ); - setC2STunnelError(tunnelName, message); - cleanup(); - c2sTunnelRuntimes.delete(tunnelName); - emitC2STunnelStatuses(); - settle(createC2SFailure(message, "Relay connection failed")); - }); - }); -} - -async function startC2STunnel(tunnel, index = 0, authToken) { - const resolvedTunnel = await resolveC2SRemoteSourceHost(tunnel); - const mode = resolvedTunnel.mode || resolvedTunnel.tunnelType || "local"; - const tunnelName = getC2STunnelName(resolvedTunnel, index); - const tunnelConfig = { - ...normalizeC2STunnelAddresses(resolvedTunnel), - sessionId: crypto.randomUUID(), - }; - const bindHost = getC2SLocalAddress(tunnelConfig); - const sourcePort = Number(resolvedTunnel.sourcePort); - logToFile(`[c2s] starting tunnel ${tunnelName}`, { - mode, - bindHost, - remoteAddress: getC2SRemoteAddress(tunnelConfig), - sourcePort, - sourceHostId: resolvedTunnel.sourceHostId, - sourceHostSyncId: resolvedTunnel.sourceHostSyncId, - endpointPort: resolvedTunnel.endpointPort, - }); - - if (mode === "remote") { - return startC2SRemoteTunnel(resolvedTunnel, index, authToken); - } - if (!resolvedTunnel.sourceHostId) { - return { success: false, error: "Endpoint SSH host is required" }; - } - if (!Number.isInteger(sourcePort) || sourcePort < 1 || sourcePort > 65535) { - return { success: false, error: "Invalid local port" }; - } - - const existing = c2sTunnelRuntimes.get(tunnelName); - if (existing) { - return { success: true, tunnelName }; - } - - for (const runtime of c2sTunnelRuntimes.values()) { - if ( - runtime.mode !== "remote" && - runtime.bindHost === bindHost && - runtime.sourcePort === sourcePort - ) { - return { - success: false, - error: `Another client tunnel already uses ${bindHost}:${sourcePort}`, - }; - } - } - - const availability = await checkLocalPortAvailable(bindHost, sourcePort); - if (!availability.available) { - return { - success: false, - error: availability.error || "Port is already in use", - }; - } - - const sockets = new Set(); - const server = net.createServer((socket) => { - if (!c2sTunnelRuntimes.get(tunnelName)?.status.connected) { - socket.destroy(); - return; - } - sockets.add(socket); - socket.on("close", () => sockets.delete(socket)); - if (mode === "dynamic") { - handleC2SDynamicConnection( - { ...tunnelConfig, name: tunnelName, mode }, - socket, - authToken, - ); - } else { - handleC2SLocalConnection( - { ...tunnelConfig, name: tunnelName, mode }, - socket, - authToken, - ); - } - }); - - c2sTunnelRuntimes.set(tunnelName, { - server, - sockets, - bindHost, - sourcePort, - status: { connected: false, status: "CONNECTING" }, - }); - - return new Promise((resolve) => { - server.once("error", (error) => { - c2sTunnelRuntimes.delete(tunnelName); - logToFile(`[c2s] failed to listen for ${tunnelName}:`, error.message); - emitC2STunnelStatuses(); - resolve({ success: false, error: error.message }); - }); - server.listen({ host: bindHost, port: sourcePort }, () => { - logToFile( - `[c2s] listening for ${tunnelName} on ${bindHost}:${sourcePort}`, - ); - setC2STunnelStatus(tunnelName, { - connected: false, - status: "CONNECTING", - reason: "Verifying endpoint SSH connection", - }); - - const runtime = c2sTunnelRuntimes.get(tunnelName); - const verifyTunnel = testC2SRelay( - { ...tunnelConfig, name: tunnelName, mode }, - mode === "dynamic" ? undefined : getC2SRemoteAddress(tunnelConfig), - mode === "dynamic" ? undefined : Number(tunnelConfig.endpointPort), - authToken, - runtime, - ); - - verifyTunnel - .then((result) => { - if (c2sTunnelRuntimes.get(tunnelName) !== runtime) return; - if (result.success) { - setC2STunnelStatus(tunnelName, { - connected: true, - status: "CONNECTED", - }); - } else { - setC2STunnelError( - tunnelName, - result.error || "Endpoint SSH connection failed", - ); - } - }) - .catch((error) => { - if (c2sTunnelRuntimes.get(tunnelName) === runtime) { - setC2STunnelError( - tunnelName, - error.message || "Endpoint SSH connection failed", - ); - } - }); - - resolve({ success: true, tunnelName }); - }); - }); -} - -async function stopC2STunnel(tunnelName) { - const runtime = c2sTunnelRuntimes.get(tunnelName); - if (!runtime) { - return { success: true }; - } - - setC2STunnelStatus(tunnelName, { - connected: false, - status: "DISCONNECTING", - }); - runtime.authSocket?.close(); - - return new Promise((resolve) => { - if (typeof runtime.close === "function") { - runtime.close(); - c2sTunnelRuntimes.delete(tunnelName); - emitC2STunnelStatuses(); - resolve({ success: true }); - return; - } - - for (const socket of runtime.sockets || []) { - socket.destroy(); - } - runtime.server?.close(() => { - c2sTunnelRuntimes.delete(tunnelName); - emitC2STunnelStatuses(); - resolve({ success: true }); - }); - }); -} - -function stopAllC2STunnels() { - for (const [tunnelName, runtime] of c2sTunnelRuntimes.entries()) { - try { - runtime.authSocket?.close(); - if (typeof runtime.close === "function") { - runtime.close(); - } else { - for (const socket of runtime.sockets || []) { - socket.destroy(); - } - runtime.server?.close(); - } - } catch (error) { - logToFile(`[c2s] failed to stop tunnel ${tunnelName}:`, error.message); - } - c2sTunnelRuntimes.delete(tunnelName); - } - emitC2STunnelStatuses(); -} - -async function startC2SAutoStartTunnels() { - const configPath = getC2STunnelConfigPath(); - if (!fs.existsSync(configPath)) { - return { success: true, started: 0, errors: [] }; - } - - const config = JSON.parse(fs.readFileSync(configPath, "utf8")); - const tunnels = Array.isArray(config) ? config : []; - const errors = []; - let started = 0; - - for (let index = 0; index < tunnels.length; index += 1) { - const tunnel = tunnels[index]; - if (!tunnel?.autoStart) continue; - const result = await startC2STunnel(tunnel, index); - if (result.success) { - started += 1; - } else { - errors.push(result.error || "Failed to start client tunnel"); - } - } - - return { success: errors.length === 0, started, errors }; -} - -ipcMain.handle("check-local-port-available", async (_event, host, port) => { - const sourcePort = Number(port); - if ( - !host || - !Number.isInteger(sourcePort) || - sourcePort < 1 || - sourcePort > 65535 - ) { - return { available: false, error: "Invalid local bind address or port" }; - } - return checkLocalPortAvailable(host, sourcePort); -}); - -ipcMain.handle("start-c2s-tunnel", async (_event, tunnel, index, authToken) => { - try { - return await startC2STunnel( - tunnel, - Number(index) || 0, - normalizeC2SAuthToken(authToken), - ); - } catch (error) { - return createC2SFailure(error.message, "Failed to start client tunnel"); - } -}); - -ipcMain.handle("test-c2s-tunnel", async (_event, tunnel, index, authToken) => { - try { - return await testC2STunnel( - tunnel, - Number(index) || 0, - normalizeC2SAuthToken(authToken), - ); - } catch (error) { - return createC2SFailure(error.message, "Failed to test client tunnel"); - } -}); - -ipcMain.handle("stop-c2s-tunnel", async (_event, tunnelName) => { - try { - return await stopC2STunnel(tunnelName); - } catch (error) { - return { success: false, error: error.message }; - } -}); - -ipcMain.handle("get-c2s-tunnel-statuses", () => { - return getAllC2STunnelStatuses(); -}); - -ipcMain.handle("start-c2s-autostart-tunnels", async () => { - try { - return await startC2SAutoStartTunnels(); - } catch (error) { - return { success: false, started: 0, errors: [error.message] }; - } -}); - -ipcMain.handle("get-c2s-tunnel-preset-default-name", () => { - const now = new Date(); - const date = now.toISOString().slice(0, 10); - const platform = - process.platform === "darwin" - ? "macOS" - : process.platform === "win32" - ? "Windows" - : "Linux"; - const release = os.release(); - const computerName = os.hostname(); - return `[${date}] ${computerName} (${platform} ${release})`; -}); - function readSetting(key) { const settingsPath = path.join(app.getPath("userData"), "settings.json"); if (!fs.existsSync(settingsPath)) return null; @@ -3277,106 +1755,6 @@ ipcMain.handle("clipboard-write-text", (_event, text) => { ipcMain.handle("clipboard-read-text", () => clipboard.readText()); -ipcMain.handle("local-terminal-start", (event, dimensions = {}) => { - const cols = Math.min(500, Math.max(2, Number(dimensions.cols) || 80)); - const rows = Math.min(300, Math.max(1, Number(dimensions.rows) || 24)); - const sessionId = crypto.randomUUID(); - const shellConfig = resolveLocalShell(process.platform, dimensions.shell); - const spawnShell = () => - pty.spawn(shellConfig.file, shellConfig.args, { - name: "xterm-256color", - cols, - rows, - cwd: os.homedir(), - env: { - ...process.env, - TERM: "xterm-256color", - COLORTERM: "truecolor", - }, - }); - let child; - try { - child = spawnShell(); - } catch (error) { - const ptyDir = path.dirname(require.resolve("node-pty/package.json")); - if ( - !String(error?.message).includes("posix_spawnp") || - repairSpawnHelpers(ptyDir) === 0 - ) { - throw error; - } - logToFile("[local-terminal] restored the spawn-helper execute bit"); - child = spawnShell(); - } - const ownerId = event.sender.id; - const session = { ownerId, process: child, ready: false, buffered: "" }; - localTerminalSessions.set(sessionId, session); - child.onData((data) => { - if (!session.ready) { - session.buffered = (session.buffered + data).slice(-1024 * 1024); - return; - } - if (!event.sender.isDestroyed()) { - event.sender.send(`local-terminal:data:${sessionId}`, data); - } - }); - child.onExit(({ exitCode }) => { - localTerminalSessions.delete(sessionId); - if (!event.sender.isDestroyed()) { - event.sender.send(`local-terminal:exit:${sessionId}`, exitCode); - } - }); - event.sender.once("destroyed", () => closeLocalTerminalsFor(ownerId)); - return { sessionId, shell: shellConfig.file }; -}); - -ipcMain.handle("local-terminal-ready", (event, sessionId) => { - const session = ownedLocalTerminal(event, sessionId); - if (!session) return false; - session.ready = true; - if (session.buffered && !event.sender.isDestroyed()) { - event.sender.send(`local-terminal:data:${sessionId}`, session.buffered); - session.buffered = ""; - } - return true; -}); - -ipcMain.handle("local-terminal-write", (event, sessionId, data) => { - const session = ownedLocalTerminal(event, sessionId); - if (!session || typeof data !== "string" || data.length > 64 * 1024) { - return false; - } - session.process.write(data); - return true; -}); - -ipcMain.handle("local-terminal-resize", (event, sessionId, cols, rows) => { - const session = ownedLocalTerminal(event, sessionId); - const width = Number(cols); - const height = Number(rows); - if ( - !session || - !Number.isInteger(width) || - !Number.isInteger(height) || - width < 2 || - width > 500 || - height < 1 || - height > 300 - ) { - return false; - } - session.process.resize(width, height); - return true; -}); - -ipcMain.handle("local-terminal-close", (event, sessionId) => { - const session = ownedLocalTerminal(event, sessionId); - if (!session) return false; - localTerminalSessions.delete(sessionId); - session.process.kill(); - return true; -}); - ipcMain.handle("show-save-dialog", async (_event, options) => { return dialog.showSaveDialog(mainWindow, options || {}); }); @@ -3387,255 +1765,6 @@ ipcMain.handle("show-open-dialog", async (_event, options) => { return result; }); -function toLocalFileType(stat) { - if (stat.isDirectory()) return "directory"; - if (stat.isFile()) return "file"; - if (stat.isSymbolicLink()) return "link"; - return "other"; -} - -function getLocalEntry(entryPath, name = path.basename(entryPath)) { - const stat = fs.lstatSync(entryPath); - return { - name, - path: entryPath, - type: toLocalFileType(stat), - size: stat.isFile() ? stat.size : 0, - created: stat.birthtime.toISOString(), - modified: stat.mtime.toISOString(), - modifiedTimestamp: stat.mtimeMs, - hidden: name.startsWith("."), - permissions: (stat.mode & 0o777).toString(8).padStart(3, "0"), - owner: String(stat.uid), - group: String(stat.gid), - }; -} - -function normalizeRelativeLocalPath(relativePath) { - return relativePath.split(path.sep).join("/"); -} - -function validateLocalName(name) { - if (typeof name !== "string" || !name.trim()) { - throw new Error("Missing name"); - } - const trimmed = name.trim(); - if ( - trimmed.includes("/") || - trimmed.includes("\\") || - trimmed === "." || - trimmed === ".." - ) { - throw new Error("Name cannot contain path separators"); - } - return trimmed; -} - -function ensureLocalDirectory(dirPath) { - if (typeof dirPath !== "string" || !dirPath) { - throw new Error("Missing directory path"); - } - const stat = fs.statSync(dirPath); - if (!stat.isDirectory()) { - throw new Error("Path is not a directory"); - } -} - -function collectLocalFilesFromPath(rootPath, rootName, files, limit) { - if (files.length >= limit) return; - - const stat = fs.lstatSync(rootPath); - if (stat.isSymbolicLink()) return; - - if (stat.isFile()) { - files.push({ - path: rootPath, - name: path.basename(rootPath), - relativePath: normalizeRelativeLocalPath(rootName), - size: stat.size, - created: stat.birthtime.toISOString(), - modified: stat.mtime.toISOString(), - }); - return; - } - - if (!stat.isDirectory()) return; - - for (const childName of fs.readdirSync(rootPath)) { - if (files.length >= limit) return; - const childPath = path.join(rootPath, childName); - collectLocalFilesFromPath( - childPath, - path.join(rootName, childName), - files, - limit, - ); - } -} - -ipcMain.handle("get-local-home-directory", () => os.homedir()); - -ipcMain.handle("list-local-directory", (_event, dirPath) => { - try { - const requestedPath = - typeof dirPath === "string" && dirPath.trim() ? dirPath : os.homedir(); - const stat = fs.statSync(requestedPath); - if (!stat.isDirectory()) { - return { - success: false, - path: requestedPath, - entries: [], - error: "Path is not a directory", - }; - } - - const entries = fs - .readdirSync(requestedPath, { withFileTypes: true }) - .map((entry) => { - try { - return getLocalEntry( - path.join(requestedPath, entry.name), - entry.name, - ); - } catch { - return null; - } - }) - .filter(Boolean) - .sort((a, b) => { - if (a.type === "directory" && b.type !== "directory") return -1; - if (a.type !== "directory" && b.type === "directory") return 1; - return a.name.localeCompare(b.name); - }); - - return { - success: true, - path: requestedPath, - parent: path.dirname(requestedPath), - entries, - }; - } catch (error) { - return { - success: false, - path: typeof dirPath === "string" ? dirPath : "", - entries: [], - error: error instanceof Error ? error.message : "Failed to list path", - }; - } -}); - -ipcMain.handle("stat-local-paths", (_event, paths) => { - const pathList = Array.isArray(paths) ? paths : []; - return pathList.map((entryPath) => { - try { - return { success: true, ...getLocalEntry(entryPath) }; - } catch (error) { - return { - success: false, - path: entryPath, - error: error instanceof Error ? error.message : "Failed to stat path", - }; - } - }); -}); - -ipcMain.handle("collect-local-files", (_event, paths) => { - try { - const pathList = Array.isArray(paths) ? paths : []; - const files = []; - const limit = 10000; - - for (const entryPath of pathList) { - if (typeof entryPath !== "string" || !entryPath) continue; - collectLocalFilesFromPath( - entryPath, - path.basename(entryPath), - files, - limit + 1, - ); - if (files.length > limit) break; - } - - return { - success: true, - files: files.slice(0, limit), - truncated: files.length > limit, - }; - } catch (error) { - return { - success: false, - files: [], - error: error instanceof Error ? error.message : "Failed to collect files", - }; - } -}); - -ipcMain.handle("create-local-folder", (_event, parentPath, folderName) => { - try { - ensureLocalDirectory(parentPath); - const safeName = validateLocalName(folderName); - const targetPath = path.join(parentPath, safeName); - fs.mkdirSync(targetPath); - return { success: true, ...getLocalEntry(targetPath, safeName) }; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : "Failed to create folder", - }; - } -}); - -ipcMain.handle("rename-local-path", (_event, entryPath, newName) => { - try { - if (typeof entryPath !== "string" || !entryPath) { - throw new Error("Missing path"); - } - const safeName = validateLocalName(newName); - const targetPath = path.join(path.dirname(entryPath), safeName); - renameLocalPath(entryPath, targetPath); - return { success: true, ...getLocalEntry(targetPath, safeName) }; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : "Failed to rename path", - }; - } -}); - -ipcMain.handle("trash-local-path", async (_event, entryPath) => { - try { - if (typeof entryPath !== "string" || !entryPath) { - throw new Error("Missing path"); - } - await shell.trashItem(entryPath); - return { success: true }; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : "Failed to delete path", - }; - } -}); - -ipcMain.handle("chmod-local-path", (_event, entryPath, permissions) => { - try { - if (typeof entryPath !== "string" || !entryPath) { - throw new Error("Missing path"); - } - if (typeof permissions !== "string" || !/^[0-7]{3,4}$/.test(permissions)) { - throw new Error("Permissions must be an octal value"); - } - fs.chmodSync(entryPath, parseInt(permissions, 8)); - return { success: true, ...getLocalEntry(entryPath) }; - } catch (error) { - return { - success: false, - error: - error instanceof Error ? error.message : "Failed to update permissions", - }; - } -}); - ipcMain.handle("create-temp-file", async (_event, fileData) => { try { const result = createManagedTempFile( @@ -3705,63 +1834,6 @@ ipcMain.handle("cleanup-temp-file", (_event, tempId) => { } }); -ipcMain.handle("open-external-editor", async (_event, fileData) => { - try { - // The editor comes from the saved setting, never from the caller. - const editorPath = readSetting(EXTERNAL_EDITOR_SETTING) || null; - if (!editorPath && isLaunchableFileName(fileData?.fileName)) { - return { - success: false, - error: "Choose an external editor to open this file type", - }; - } - - const result = createManagedTempFile( - fileData?.fileName, - fileData?.content, - fileData?.encoding, - ); - const editId = result.tempId; - const stat = fs.statSync(result.path); - const watcher = fs.watch(result.path, { persistent: false }, () => { - const session = externalEditorSessions.get(editId); - if (!session) return; - if (session.timer) clearTimeout(session.timer); - session.timer = setTimeout(() => notifyExternalEditorSaved(editId), 500); - }); - - externalEditorSessions.set(editId, { - path: result.path, - watcher, - timer: null, - lastMtimeMs: stat.mtimeMs, - }); - - const openError = await openPathWithEditor(result.path, editorPath); - if (openError) { - closeExternalEditorSession(editId); - return { success: false, error: openError }; - } - - return { success: true, editId, path: result.path }; - } catch (error) { - return { success: false, error: error.message }; - } -}); - -ipcMain.handle("close-external-editor", (_event, editId) => { - try { - closeExternalEditorSession(editId); - return { success: true }; - } catch (error) { - return { success: false, error: error.message }; - } -}); - -// Local disk browsing + streamed local<->remote transfers for the file -// manager's dual-pane mode (see electron/local-files.cjs). -registerLocalFileHandlers({ ipcMain, shell }); - function createMenu() { if (process.platform === "darwin") { const template = [ @@ -3841,7 +1913,6 @@ app.whenReady().then(async () => { if (!isDev) { backendPort = await findBackendPort(); - setLocalBackendPort(backendPort); const result = await startBackendServer(); logToFile("startBackendServer result:", result); } else { @@ -3881,13 +1952,9 @@ app.on("before-quit", () => { app.on("will-quit", () => { console.log("App will quit..."); - for (const editId of externalEditorSessions.keys()) { - closeExternalEditorSession(editId); - } for (const tempId of tempFiles.keys()) { cleanupManagedTempFile(tempId); } - stopAllC2STunnels(); stopBackendServer(); }); diff --git a/electron/native-rdp.cjs b/electron/native-rdp.cjs deleted file mode 100644 index 49dd68877..000000000 --- a/electron/native-rdp.cjs +++ /dev/null @@ -1,85 +0,0 @@ -const fs = require("fs"); -const os = require("os"); -const path = require("path"); -const { spawn } = require("child_process"); - -function singleLine(value, maxLength = 255) { - return String(value ?? "") - .replace(/[\r\n\0]/g, "") - .trim() - .slice(0, maxLength); -} - -function validateNativeRdpOptions(options) { - const host = singleLine(options?.host); - const port = Number(options?.port ?? 3389); - if (!host || host.length > 253 || /[\\/]/.test(host)) { - throw new Error("Invalid RDP host"); - } - if (!Number.isInteger(port) || port < 1 || port > 65535) { - throw new Error("Invalid RDP port"); - } - return { - host, - port, - username: singleLine(options?.username), - domain: singleLine(options?.domain), - }; -} - -function buildRdpFile(options) { - const { host, port, username, domain } = validateNativeRdpOptions(options); - const address = host.includes(":") ? `[${host}]:${port}` : `${host}:${port}`; - const qualifiedUsername = username - ? domain - ? `${domain}\\${username}` - : username - : ""; - return [ - `full address:s:${address}`, - "prompt for credentials:i:1", - "administrative session:i:0", - ...(qualifiedUsername ? [`username:s:${qualifiedUsername}`] : []), - "", - ].join("\r\n"); -} - -async function launchNativeRdp(options, platform = process.platform) { - if (platform !== "win32") { - return { - success: false, - error: "Windows Remote Desktop is only available on Windows", - }; - } - - const rdpContent = buildRdpFile(options); - const tempDir = await fs.promises.mkdtemp( - path.join(os.tmpdir(), "termix-rdp-"), - ); - const rdpPath = path.join(tempDir, "connection.rdp"); - await fs.promises.writeFile(rdpPath, rdpContent, { - encoding: "utf8", - mode: 0o600, - }); - - return new Promise((resolve) => { - const child = spawn("mstsc.exe", [rdpPath], { - detached: true, - windowsHide: true, - stdio: "ignore", - }); - const cleanup = () => - fs.promises.rm(tempDir, { recursive: true, force: true }).catch(() => {}); - child.once("error", (error) => { - cleanup(); - resolve({ success: false, error: error.message }); - }); - child.once("spawn", () => { - child.unref(); - setTimeout(cleanup, 30_000).unref(); - resolve({ success: true }); - }); - }); -} - -module.exports = { buildRdpFile, launchNativeRdp, validateNativeRdpOptions }; diff --git a/electron/preload.js b/electron/preload.js index 69eb4a4b0..afef50c22 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -58,36 +58,6 @@ contextBridge.exposeInMainWorld("electronAPI", { getSetting: (key) => ipcRenderer.invoke("get-setting", key), setSetting: (key, value) => ipcRenderer.invoke("set-setting", key, value), - answerC2SAuth: (id, answer) => - ipcRenderer.invoke("answer-c2s-auth", id, answer), - onC2SAuthPrompt: (callback) => { - const listener = (_event, prompt) => callback(prompt); - ipcRenderer.on("c2s-auth-prompt", listener); - return () => ipcRenderer.removeListener("c2s-auth-prompt", listener); - }, - getC2STunnelConfig: () => ipcRenderer.invoke("get-c2s-tunnel-config"), - saveC2STunnelConfig: (config) => - ipcRenderer.invoke("save-c2s-tunnel-config", config), - checkLocalPortAvailable: (host, port) => - ipcRenderer.invoke("check-local-port-available", host, port), - getC2STunnelPresetDefaultName: () => - ipcRenderer.invoke("get-c2s-tunnel-preset-default-name"), - startC2STunnel: (tunnel, index, authToken) => - ipcRenderer.invoke("start-c2s-tunnel", tunnel, index, authToken), - testC2STunnel: (tunnel, index, authToken) => - ipcRenderer.invoke("test-c2s-tunnel", tunnel, index, authToken), - stopC2STunnel: (tunnelName) => - ipcRenderer.invoke("stop-c2s-tunnel", tunnelName), - getC2STunnelStatuses: () => ipcRenderer.invoke("get-c2s-tunnel-statuses"), - onC2STunnelStatuses: (callback) => { - const listener = (_event, statuses) => callback(statuses); - ipcRenderer.on("c2s-tunnel-statuses", listener); - return () => ipcRenderer.removeListener("c2s-tunnel-statuses", listener); - }, - startC2SAutoStartTunnels: () => - ipcRenderer.invoke("start-c2s-autostart-tunnels"), - setC2SRelayPath: (relayPath) => - ipcRenderer.invoke("set-c2s-relay-path", relayPath), onCloseActiveTab: (callback) => { const listener = () => callback(); @@ -100,32 +70,8 @@ contextBridge.exposeInMainWorld("electronAPI", { externalBrowserLogin: (authUrl, callbackPort) => ipcRenderer.invoke("external-browser-login", authUrl, callbackPort), - openExternalEditor: (fileData) => - ipcRenderer.invoke("open-external-editor", fileData), - closeExternalEditor: (editId) => - ipcRenderer.invoke("close-external-editor", editId), - onExternalEditorSaved: (callback) => { - const listener = (_event, payload) => callback(payload); - ipcRenderer.on("external-editor-saved", listener); - return () => ipcRenderer.removeListener("external-editor-saved", listener); - }, - showSaveDialog: (options) => ipcRenderer.invoke("show-save-dialog", options), showOpenDialog: (options) => ipcRenderer.invoke("show-open-dialog", options), - getLocalHomeDirectory: () => ipcRenderer.invoke("get-local-home-directory"), - listLocalDirectory: (dirPath) => - ipcRenderer.invoke("list-local-directory", dirPath), - statLocalPaths: (paths) => ipcRenderer.invoke("stat-local-paths", paths), - collectLocalFiles: (paths) => - ipcRenderer.invoke("collect-local-files", paths), - createLocalFolder: (parentPath, folderName) => - ipcRenderer.invoke("create-local-folder", parentPath, folderName), - renameLocalPath: (entryPath, newName) => - ipcRenderer.invoke("rename-local-path", entryPath, newName), - trashLocalPath: (entryPath) => - ipcRenderer.invoke("trash-local-path", entryPath), - chmodLocalPath: (entryPath, permissions) => - ipcRenderer.invoke("chmod-local-path", entryPath, permissions), createTempFile: (fileData) => ipcRenderer.invoke("create-temp-file", fileData), createTempFolder: (folderData) => @@ -134,63 +80,6 @@ contextBridge.exposeInMainWorld("electronAPI", { ipcRenderer.invoke("start-drag-to-desktop", dragData), cleanupTempFile: (tempId) => ipcRenderer.invoke("cleanup-temp-file", tempId), - startLocalTerminal: (dimensions) => - ipcRenderer.invoke("local-terminal-start", dimensions), - writeLocalTerminal: (sessionId, data) => - ipcRenderer.invoke("local-terminal-write", sessionId, data), - readyLocalTerminal: (sessionId) => - ipcRenderer.invoke("local-terminal-ready", sessionId), - resizeLocalTerminal: (sessionId, cols, rows) => - ipcRenderer.invoke("local-terminal-resize", sessionId, cols, rows), - closeLocalTerminal: (sessionId) => - ipcRenderer.invoke("local-terminal-close", sessionId), - onLocalTerminalData: (sessionId, callback) => { - const channel = `local-terminal:data:${sessionId}`; - const listener = (_event, data) => callback(data); - ipcRenderer.on(channel, listener); - return () => ipcRenderer.removeListener(channel, listener); - }, - onLocalTerminalExit: (sessionId, callback) => { - const channel = `local-terminal:exit:${sessionId}`; - const listener = (_event, exitCode) => callback(exitCode); - ipcRenderer.on(channel, listener); - return () => ipcRenderer.removeListener(channel, listener); - }, - - // Dual-pane file manager: local disk browsing and streamed transfers. - localFs: { - home: () => ipcRenderer.invoke("local-fs:home"), - list: (dirPath) => ipcRenderer.invoke("local-fs:list", dirPath), - mkdir: (parentPath, name) => - ipcRenderer.invoke("local-fs:mkdir", parentPath, name), - createFile: (parentPath, name) => - ipcRenderer.invoke("local-fs:create-file", parentPath, name), - rename: (oldPath, newName) => - ipcRenderer.invoke("local-fs:rename", oldPath, newName), - trash: (paths) => ipcRenderer.invoke("local-fs:trash", paths), - ensureDir: (dirPath, rootPath) => - ipcRenderer.invoke("local-fs:ensure-dir", dirPath, rootPath), - exists: (paths) => ipcRenderer.invoke("local-fs:exists", paths), - walk: (paths) => ipcRenderer.invoke("local-fs:walk", paths), - reveal: (targetPath) => ipcRenderer.invoke("local-fs:reveal", targetPath), - open: (targetPath) => ipcRenderer.invoke("local-fs:open", targetPath), - }, - localTransfer: { - setApiPath: (apiPath) => - ipcRenderer.invoke("local-transfer:set-api", apiPath), - upload: (options) => ipcRenderer.invoke("local-transfer:upload", options), - download: (options) => - ipcRenderer.invoke("local-transfer:download", options), - cancel: (transferId) => - ipcRenderer.invoke("local-transfer:cancel", transferId), - onProgress: (callback) => { - const listener = (_event, payload) => callback(payload); - ipcRenderer.on("local-transfer:progress", listener); - return () => - ipcRenderer.removeListener("local-transfer:progress", listener); - }, - }, - invoke: invokeAllowed, }); diff --git a/electron/rename-local-path.cjs b/electron/rename-local-path.cjs deleted file mode 100644 index b989985ba..000000000 --- a/electron/rename-local-path.cjs +++ /dev/null @@ -1,22 +0,0 @@ -const fs = require("node:fs"); - -function renameLocalPath(source, target) { - if (source === target) return; - try { - fs.lstatSync(target); - const error = new Error("A file or folder with that name already exists"); - error.code = "EEXIST"; - throw error; - } catch (error) { - if (error.code !== "ENOENT") throw error; - } - if (fs.lstatSync(source).isDirectory()) { - fs.renameSync(source, target); - return; - } - // link is exclusive: a destination created concurrently is never replaced. - fs.linkSync(source, target); - fs.unlinkSync(source); -} - -module.exports = { renameLocalPath }; diff --git a/env.catalog.json b/env.catalog.json index 6567e9373..8993819a3 100644 --- a/env.catalog.json +++ b/env.catalog.json @@ -332,17 +332,6 @@ "default": "10000", "description": "Most audit entries kept. The oldest go first." }, - { - "name": "AUDIT_LOG_FORWARD_URL", - "group": "logging", - "description": "Also POST every audit entry as JSON to this URL. Overrides the admin setting." - }, - { - "name": "AUDIT_LOG_FORWARD_TOKEN", - "group": "logging", - "secret": true, - "description": "Bearer token sent with forwarded audit entries." - }, { "name": "SESSION_RECORDING_RETENTION_DAYS", "group": "logging", @@ -433,6 +422,12 @@ "internal": true, "description": "Set by the desktop app when it runs its own backend." }, + { + "name": "ELECTRON_USER_DATA", + "group": "desktop", + "internal": true, + "description": "Set by the desktop app to its user data folder." + }, { "name": "EMBEDDED_BACKEND_PORT", "group": "desktop", diff --git a/package-lock.json b/package-lock.json index 94eb72e22..d1782c3af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15058,7 +15058,7 @@ }, "packages/plugin-sdk": { "name": "@termix-ssh/plugin-sdk", - "version": "1.0.12", + "version": "1.0.13", "license": "Apache-2.0", "dependencies": { "@deadendjs/swagger-jsdoc": "^8.1.2", diff --git a/packages/plugin-sdk/CHANGELOG.md b/packages/plugin-sdk/CHANGELOG.md index 6ca580fc2..da2b8f899 100644 --- a/packages/plugin-sdk/CHANGELOG.md +++ b/packages/plugin-sdk/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## 1.0.13 + +Plugin API 1.5.0. + +### Added + +- `runtime: "desktop"` in the manifest for a plugin that only loads on the desktop app +- `desktop:local` and `ctx.desktop.local`: file system, local shell and machine info on the desktop app's own backend +- `ctx.audit.subscribe`, `ctx.audit.list` and `ctx.audit.exportBatches` under `audit:read` +- `ctx.hosts.importMany`, `importSshConfig`, `exportMany` and `registerImportNormalizer`, with the `hosts:export` capability +- `ctx.credentials.publicKey` and `resolveKeyRefs`, `ctx.ssh.exec`, and `@termix-ssh/plugin-sdk/ssh-keys` +- `ctx.hosts.status.provide` and `ctx.hosts.activity.provide` so a plugin can own host status and recent activity, with `ctx.network.tcpProbe`, `ctx.ssh.probe`, `ctx.hosts.probeTargets` and `ctx.hosts.sessionActive` +- `registerHostStatusIndicator` and `registerHostEditorToolbarItem`, and `order` for host badges +- Onboarding steps can sit in a `start` section, take a `position` and `replaces` a core step +- A plugin can provide the app name, tagline and logo through the `.brandingProvider` registry key + ## 1.0.12 Plugin API 1.4.0. diff --git a/packages/plugin-sdk/PLUGIN-GUIDE.md b/packages/plugin-sdk/PLUGIN-GUIDE.md index 3740019d1..0d39d449b 100644 --- a/packages/plugin-sdk/PLUGIN-GUIDE.md +++ b/packages/plugin-sdk/PLUGIN-GUIDE.md @@ -1,6 +1,6 @@ # Termix plugin guide -SDK 1.0.12 | Plugin API 1.4.0 +SDK 1.0.13 | Plugin API 1.5.0 This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside `@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed. @@ -325,6 +325,24 @@ try { commands. `@termix-ssh/plugin-sdk/host-commands` has helpers to detect the OS and run package manager and sudo commands. Hosts from `ctx.hosts` never carry secrets. Pass the host or its id to `ctx.ssh`. +### The desktop app + +Set `"runtime": "desktop"` in the manifest for a plugin that only makes sense on a desktop app. It never +loads on a server, ignores server sync and may declare `desktop:local`. Any plugin can declare +`desktop:local` and check `ctx.desktop.local.available()` at runtime. + +`ctx.desktop.local` works on the desktop app's own backend only: + +- `info()`: home folder, platform, user data folder, backend port and the linked server (url and token) or null +- `fs`: `list`, `mkdir`, `createFile`, `rename`, `ensureDir`, `exists`, `walk`, `trash`, `reveal`, `open`, + `openRead` and `openDownload`. Downloads stay inside the folder the user picked, links in it are refused and + an existing file is never replaced by accident +- `pty.spawn()`: a local shell, stopped when the plugin stops +- `pluginFetch(origin, subPath, init)`: calls this plugin's own routes on the desktop's backend ("local") or the linked server ("remote") as the acting user. Bodies can be streams, so a plugin can move big files without the renderer + +Use `ctx.audit.subscribe`, `list` and `exportBatches` (`audit:read`) to read the audit trail. Subscribers +are called after a line is accepted and never delay the action that wrote it. + ### Events, timers, alerts, services ```ts @@ -470,13 +488,15 @@ your plugin's error boundary and Suspense. | `registerHostEditorSection` | Your own host editor section (host settings get one for free) | | `registerHostProtocol` | A connection protocol next to SSH | | `registerHostBadge`, `registerHostContextMenuItem` | Badges and menu items on host rows | +| `registerHostStatusIndicator` | The status dot or stripe on a host row (a layout draws it) | +| `registerHostEditorToolbarItem` | A control in the host editor toolbar (a layout draws it) | | `registerPaletteEntry`, `registerPaletteGroup` | Command palette entries | | `registerKeybindingAction`, `registerKeybindingDefault` | Bindable actions | | `registerSettingsComponent` | A component for a `custom` settings field | | `registerAction`, `invokeAction` | Actions across plugins | | `registerSlotContribution`, `declareActionSlot` | Fill or own a UI slot | | `registerComponent`, `registerExtension` | Offer a component by id, add to an extension point | -| `registerOnboardingStep` | A first run setup step | +| `registerOnboardingStep` | A first run setup step (`section`, `position`, `replaces`) | | `registerLoginMethod`, `registerSecondFactorUI`, `registerSshAuthEditor` | Sign in UI | | `registerLayout` | A whole-app layout (`contributes.layouts`, API 1.4) | | `api` | Axios client for your `/plugin-api//` routes, auth included | @@ -484,6 +504,16 @@ your plugin's error boundary and Suspense. | `docs`, `tabs`, `confirm`, `listHosts`, `getHost`, `t`, `hasPermission`, `desktop` | Helpers | | `onSettingsChanged`, `onDispose` | Hooks | +Badges, status indicators and toolbar items take `{ id, when(host), component, order? }`; lower `order` +comes first. An onboarding step sits in a section (`start`, `setup`, `explore`, `security`) or at an exact +`position` from 1 to 99. `replaces: ["preset"]` lists older step keys the step took over, so people who saw +those are not shown it again. + +Branding: a plugin that owns the app name, tagline and logo provides +`ctx.registry.provide(".brandingProvider", async () => ({ appName, tagline, logo }))` in its backend. +Core reads it for `GET /users/branding` (the login page, the browser tab and the sync server name) and falls +back to "Termix" with no logo when no plugin provides one. `logo` is a PNG data URL or null. + Hooks from `@termix-ssh/plugin-sdk/frontend`: `useTranslation`, `usePermission`, `useSettings`, `useHost`, `useHosts`, `usePluginApi`, `useCurrentUser`, `useTheme`, `useToast`, `useDocsUrl`, `useTabs`, `useSlotContributions`, `useSshAuthTypes`, `useExtensions`, `useShell`. @@ -828,60 +858,66 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie ## SDK entry points -| Import | What it is | -| -------------------------------------------------------------------- | --------------------------------------------------------------------- | -| `@termix-ssh/plugin-sdk/backend` | `PluginContext` and the backend types | -| `@termix-ssh/plugin-sdk/frontend` | `TermixApp`, `TabProps` and the hooks | -| `@termix-ssh/plugin-sdk/ui` | The UI kit, served by Termix at runtime | -| `@termix-ssh/plugin-sdk/db` | `defineTable` and column builders | -| `@termix-ssh/plugin-sdk/manifest` | `PluginManifest` type and manifest checks | -| `@termix-ssh/plugin-sdk/capabilities` | The capability catalog | -| `@termix-ssh/plugin-sdk/settings` | Settings field validation | -| `@termix-ssh/plugin-sdk/testing` | `createMockCtx`, `createTestDb`, `renderWithApp`, `createFakeContext` | -| `@termix-ssh/plugin-sdk/vitest-preset` | `pluginVitestConfig` | -| `@termix-ssh/plugin-sdk/host-commands` | OS detection, package manager and sudo helpers over ssh2 | -| `@termix-ssh/plugin-sdk/ssh-certs` | `applyCertificateAuth()` for certificate based SSH auth | -| `@termix-ssh/plugin-sdk/desktop` | The desktop app bridge, typed. Check it exists before use | -| `@termix-ssh/plugin-sdk/openapi` | `buildOpenApi()` | -| `@termix-ssh/plugin-sdk/changelog` | Changelog parsing and checks | -| `@termix-ssh/plugin-sdk/docs` | Docs link and env var helpers | -| `@termix-ssh/plugin-sdk/ddl`, `@termix-ssh/plugin-sdk/table-builder` | Table to SQL and Drizzle mapping. You rarely need these | +| Import | What it is | +| -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | +| `@termix-ssh/plugin-sdk/backend` | `PluginContext` and the backend types | +| `@termix-ssh/plugin-sdk/frontend` | `TermixApp`, `TabProps` and the hooks | +| `@termix-ssh/plugin-sdk/ui` | The UI kit, served by Termix at runtime | +| `@termix-ssh/plugin-sdk/db` | `defineTable` and column builders | +| `@termix-ssh/plugin-sdk/manifest` | `PluginManifest` type and manifest checks | +| `@termix-ssh/plugin-sdk/capabilities` | The capability catalog | +| `@termix-ssh/plugin-sdk/settings` | Settings field validation | +| `@termix-ssh/plugin-sdk/testing` | `createMockCtx`, `createTestDb`, `renderWithApp`, `createFakeContext` | +| `@termix-ssh/plugin-sdk/vitest-preset` | `pluginVitestConfig` | +| `@termix-ssh/plugin-sdk/host-commands` | OS detection, package manager and sudo helpers over ssh2 | +| `@termix-ssh/plugin-sdk/ssh-certs` | `applyCertificateAuth()` for certificate based SSH auth | +| `@termix-ssh/plugin-sdk/ssh-keys` | `generateSshKeyPair()`, `detectSshKeyType()`, `publicKeyFromPrivate()`, `validateSshKeyPair()` | +| `@termix-ssh/plugin-sdk/desktop` | The desktop app bridge, typed. Check it exists before use | +| `@termix-ssh/plugin-sdk/openapi` | `buildOpenApi()` | +| `@termix-ssh/plugin-sdk/changelog` | Changelog parsing and checks | +| `@termix-ssh/plugin-sdk/docs` | Docs link and env var helpers | +| `@termix-ssh/plugin-sdk/ddl`, `@termix-ssh/plugin-sdk/table-builder` | Table to SQL and Drizzle mapping. You rarely need these | ## Capabilities -| Capability | Risk | What it allows | -| -------------------- | -------- | ------------------------------------------------------- | -| `credentials:read` | critical | Read stored secrets for hosts the user can reach | -| `system:tls` | critical | Replace the HTTPS certificate, answer CA domain checks | -| `ssh:connect` | high | Run any command on hosts, with the user's access | -| `process:spawn` | high | Run programs on the Termix server | -| `users:write` | high | Add users and change their roles | -| `users:impersonate` | high | Act as another user, or get a session for them, audited | -| `db:core-refs` | high | Query core users, roles and hosts tables (read only) | -| `auth:provide` | high | Add login methods, second factors, SSH auth types | -| `device:serial` | high | Open serial and USB ports on the server | -| `notify:hub` | high | Receive every alert (the alerts hub only) | -| `ui:layout` | high | Replace the whole app layout | -| `hosts:write` | medium | Add and edit hosts | -| `credentials:use` | medium | Connect with saved credentials without seeing them | -| `credentials:write` | medium | Save new credentials | -| `network:outbound` | medium | Outbound HTTP through `ctx.fetch` | -| `network:serve` | medium | HTTP routes and sockets | -| `network:broadcast` | medium | Local network broadcasts, like Wake-on-LAN | -| `users:read` | medium | Usernames, roles and UI language | -| `events:core` | medium | Listen to core events | -| `notify:send` | medium | Send alerts | -| `audit:read` | medium | Read the audit log | -| `desktop:window` | medium | Open windows in the desktop app | -| `hosts:read` | low | Read hosts the user can see | -| `db:own` | low | Your own tables | -| `kv:own` | low | Small key value state | -| `files:own` | low | Your own folder on disk | -| `secrets:own` | low | Your own encrypted secrets | -| `settings:read-core` | low | Read Termix server settings | -| `plugins:read` | low | See installed plugins | -| `ui:surface` | low | Add tabs, panels and settings | -| `ui:theme` | low | Add color themes, CSS only | +| Capability | Risk | What it allows | +| ------------------------ | -------- | ------------------------------------------------------- | +| `credentials:read` | critical | Read stored secrets for hosts the user can reach | +| `system:tls` | critical | Replace the HTTPS certificate, answer CA domain checks | +| `ssh:connect` | high | Run any command on hosts, with the user's access | +| `process:spawn` | high | Run programs on the Termix server | +| `users:write` | high | Add users and change their roles | +| `users:impersonate` | high | Act as another user, or get a session for them, audited | +| `db:core-refs` | high | Query core users, roles and hosts tables (read only) | +| `auth:provide` | high | Add login methods, second factors, SSH auth types | +| `device:serial` | high | Open serial and USB ports on the server | +| `desktop:local` | high | Use the user's own computer, desktop app only | +| `hosts:export` | high | Export hosts with their saved logins | +| `notify:hub` | high | Receive every alert (the alerts hub only) | +| `ui:layout` | high | Replace the whole app layout | +| `hosts:write` | medium | Add and edit hosts | +| `hosts:status-provide` | medium | Be the source of host status dots | +| `hosts:activity-provide` | medium | Be the source of the recent activity list | +| `network:probe` | medium | Check that a host answers on a TCP port | +| `credentials:use` | medium | Connect with saved credentials without seeing them | +| `credentials:write` | medium | Save new credentials | +| `network:outbound` | medium | Outbound HTTP through `ctx.fetch` | +| `network:serve` | medium | HTTP routes and sockets | +| `network:broadcast` | medium | Local network broadcasts, like Wake-on-LAN | +| `users:read` | medium | Usernames, roles and UI language | +| `events:core` | medium | Listen to core events | +| `notify:send` | medium | Send alerts | +| `audit:read` | medium | Read the audit log | +| `desktop:window` | medium | Open windows in the desktop app | +| `hosts:read` | low | Read hosts the user can see | +| `db:own` | low | Your own tables | +| `kv:own` | low | Small key value state | +| `files:own` | low | Your own folder on disk | +| `secrets:own` | low | Your own encrypted secrets | +| `settings:read-core` | low | Read Termix server settings | +| `plugins:read` | low | See installed plugins | +| `ui:surface` | low | Add tabs, panels and settings | +| `ui:theme` | low | Add color themes, CSS only | An update that adds a capability waits for an admin to approve it. diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index 713c78a0b..46a0afb1a 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@termix-ssh/plugin-sdk", - "version": "1.0.12", + "version": "1.0.13", "description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.", "license": "Apache-2.0", "repository": { @@ -85,6 +85,10 @@ "types": "./dist/ssh-certs.d.ts", "default": "./dist/ssh-certs.js" }, + "./ssh-keys": { + "types": "./dist/ssh-keys.d.ts", + "default": "./dist/ssh-keys.js" + }, "./tsconfig.plugin.json": "./tsconfig.plugin.json", "./schema/manifest.schema.json": "./schema/manifest.schema.json", "./ui": { diff --git a/packages/plugin-sdk/schema/manifest.schema.json b/packages/plugin-sdk/schema/manifest.schema.json index 05c7878cd..1913584aa 100644 --- a/packages/plugin-sdk/schema/manifest.schema.json +++ b/packages/plugin-sdk/schema/manifest.schema.json @@ -154,6 +154,7 @@ "system:tls", "device:serial", "hosts:write", + "hosts:export", "credentials:use", "credentials:write", "network:outbound", @@ -163,7 +164,11 @@ "events:core", "notify:send", "audit:read", + "network:probe", + "hosts:status-provide", + "hosts:activity-provide", "desktop:window", + "desktop:local", "users:impersonate", "db:core-refs", "hosts:read", @@ -1261,6 +1266,11 @@ "enum": ["mirror", "local", "server"], "description": "How a desktop linked to a server treats this plugin: mirror follows the server, local is decided on each desktop, server never runs on a linked desktop. Defaults to mirror." }, + "runtime": { + "type": "string", + "enum": ["desktop"], + "description": "desktop: the plugin only loads on a desktop app's own embedded backend, never on a server. It implies desktop local and may declare desktop:local." + }, "nativeDependencies": { "type": "array", "minItems": 1, diff --git a/packages/plugin-sdk/src/backend.ts b/packages/plugin-sdk/src/backend.ts index e0d27c189..66cf20a9e 100644 --- a/packages/plugin-sdk/src/backend.ts +++ b/packages/plugin-sdk/src/backend.ts @@ -848,6 +848,168 @@ export interface PluginHosts { * Needs hosts:read. */ status: PluginHostStatusApi; + /** + * The hosts the acting user may connect to (their own and shared ones) as a + * status provider needs them: address, port and jump hosts, no secrets. + * Pass hostIds to narrow it. Needs hosts:read. + */ + probeTargets: (filter?: { + hostIds?: number[]; + }) => Promise; + /** Whether a session is open to the host right now. Needs hosts:read. */ + sessionActive: (hostId: number) => boolean; + /** + * Imports Termix JSON host rows for the acting user, the same way POST + * /host/bulk-import does: up to 100 rows, jump hosts remapped by exportId, + * defaults, protocol logins and plugin settings applied, one save at the end. + * Needs hosts:write, plus credentials:write when credentials are passed, and + * the acting user needs the core hosts.create and hosts.edit permissions. + */ + importMany: ( + hosts: Record[], + options?: PluginHostImportOptions, + ) => Promise; + /** Imports the Host entries of an OpenSSH config file. Same rules as importMany. */ + importSshConfig: ( + content: string, + options?: Pick, + ) => Promise; + /** + * The acting user's own hosts as Termix JSON. Without share the secrets are + * included, decrypted. With share every secret is left out and credential + * logins point at a scrubbed credentials list by alias. Needs hosts:export + * and is always audited. + */ + exportMany: (options?: { + share?: boolean; + /** Only these of the user's hosts. */ + hostIds?: number[]; + }) => Promise; + /** + * Validates and writes this plugin's own host settings from an imported + * row. Called for every imported row once the host exists. Return the + * values to write, or null to write nothing. Removed when the plugin + * deactivates. Needs hosts:write. + */ + registerImportNormalizer: ( + normalizer: PluginHostImportNormalizer, + ) => () => void; + /** The recent activity list on the dashboard. */ + activity: PluginHostActivityApi; +} + +export interface PluginHostImportOptions { + /** Update a host that matches on ip, port and username instead of skipping it. */ + overwrite?: boolean; + /** + * All or nothing: if any row fails, the hosts this call created are deleted + * and the result says rolledBack. Cannot be combined with overwrite. + */ + atomic?: boolean; + /** Credential placeholders rows can refer to by credentialAlias. */ + credentials?: Array>; +} + +export interface PluginHostImportResult { + success: number; + updated: number; + skipped: number; + failed: number; + errors: string[]; + createdHostIds: number[]; + rolledBack?: boolean; +} + +export interface PluginHostExport { + hosts: Array>; + /** Share exports only. */ + version?: string; + exportedAt?: string; + credentials?: Array>; +} + +export type PluginHostImportNormalizer = ( + raw: Record, +) => Record | null; + +export interface PluginHostProbeTarget { + id: number; + /** The user who owns the host; its jump hosts are theirs. */ + ownerUserId: string; + ip: string; + port: number; + connectionType: string; + jumpHosts: Array<{ hostId: number }>; + statusCheckEnabled: boolean; + /** Seconds, or null to use the global interval. */ + statusCheckInterval: number | null; +} + +/** + * The plugin that owns host status. Core keeps the routes, the sync feed and + * ctx.hosts.status; this fills them. One provider at a time. + */ +export interface PluginHostStatusProvider { + start: () => void; + stop: () => void; + get: (hostId: number) => PluginHostStatusEntry | null; + /** + * Every status known, after polling is brought in line with the request: + * with hostIds, exactly those of the user's hosts; without, all of them. + */ + statusesFor: ( + userId: string, + hostIds: number[] | null, + ) => Promise>; + /** Restarts the user's checks. */ + refresh: (userId: string) => Promise; + check: (hostId: number) => Promise; + reportLogin: (hostId: number, outcome: { ok: boolean }) => void; + registerPort: ( + connectionType: string, + resolve: ( + hostId: number, + ) => number | undefined | Promise, + ) => () => void; + /** The global check interval in seconds. */ + getIntervalSeconds: () => Promise; + setIntervalSeconds: (seconds: number) => Promise; +} + +export interface PluginActivityEntry { + id: number; + userId: string; + type: string; + hostId: number; + hostName: string | null; + timestamp: string; +} + +export type PluginRecordActivityResult = + | { status: "logged"; id: number } + | { status: "rate_limited" } + | { status: "invalid_type" } + | { status: "denied" } + | { status: "unavailable" }; + +/** The plugin that stores recent activity. One provider at a time. */ +export interface PluginActivityProvider { + record: ( + userId: string, + entry: { type: string; hostId: number; hostName: string }, + ) => Promise; + list: (userId: string, limit: number) => Promise; + deleteByUserId: (userId: string) => Promise; + deleteByHostIds: (hostIds: number[]) => Promise; +} + +export interface PluginHostActivityApi { + /** + * Becomes the store behind the dashboard's recent activity and + * ctx.hosts.recordActivity. Needs hosts:activity-provide. Removed on + * deactivate. + */ + provide: (provider: PluginActivityProvider) => () => void; } /** @@ -896,6 +1058,11 @@ export interface PluginHostStatusApi { hostId: number, ) => number | undefined | Promise, ) => () => void; + /** + * Becomes core's host status. Needs hosts:status-provide. Removed on + * deactivate, which hands status back to core's built-in checks. + */ + provide: (provider: PluginHostStatusProvider) => () => void; } /** @@ -1060,6 +1227,25 @@ export interface PluginSshConnectOptions { sock?: unknown; } +export interface PluginSshExecOptions extends PluginSshConnectOptions { + /** Time to wait for the command to finish. Default 30s, at most 10 minutes. */ + timeoutMs?: number; + /** Most bytes kept of each of stdout and stderr. Default 256 KB, at most 4 MB. */ + maxOutputBytes?: number; + /** Written to the command's standard input, then closed. */ + stdin?: string; +} + +export interface PluginSshExecResult { + stdout: string; + stderr: string; + /** The exit code. Null when the command was killed or ran out of time. */ + code: number | null; + /** Output went past maxOutputBytes and the rest was dropped. */ + truncated: boolean; + timedOut: boolean; +} + export interface PluginSshConnection { /** An ssh2 Client, already "ready". */ client: Client; @@ -1079,12 +1265,47 @@ export interface PluginSshConnection { * audit line per new connection. Connections still open on deactivate are * closed. */ +export interface PluginNetwork { + /** + * Whether a TCP port answers. An SSH banner is read and answered politely; + * nothing is ever sent that logs in. Loopback, private and link-local + * addresses are allowed, since status checks target the user's own hosts. + */ + tcpProbe: ( + host: string, + port: number, + options?: { timeoutMs?: number }, + ) => Promise; +} + export interface PluginSsh { + /** + * Whether a TCP port answers through the host's jump host chain. Chains are + * shared and closed when idle. The acting user must be able to connect to + * the host. Needs ssh:connect. + */ + probe: ( + hostId: number, + options?: { port?: number; timeoutMs?: number }, + ) => Promise; + connect: ( host: number | PluginSshHost, options?: PluginSshConnectOptions, ) => Promise>; + /** + * Runs one command on a host with its stored login and closes the + * connection. The acting user needs connect access to the host, the same as + * connect. Output is capped and the command is stopped at the time limit. + * Needs ssh:connect and credentials:use. + */ + exec: ( + host: number | PluginSshHost, + command: string, + options?: PluginSshExecOptions, + ) => Promise; + /** Borrows a pooled connection. The pool key is `::
`. */ withConnection: ( host: number | PluginSshHost, @@ -1603,6 +1824,148 @@ export interface PluginDesktop { ) => Promise<{ success: boolean; error?: string }>; /** Whether the server runs embedded in the desktop app, so the calls above can work. */ available: () => boolean; + /** + * The user's own machine. Only works on the desktop app's embedded backend + * and needs desktop:local; every call is audited once per plugin start. + */ + readonly local: PluginDesktopLocal; +} + +export interface PluginLocalDirEntry { + name: string; + path: string; + type: "file" | "directory" | "link"; + size: number; + modifiedTimestamp?: number; + linkTarget?: string; + hidden: boolean; +} + +export interface PluginLocalWalkResult { + files: Array<{ localPath: string; relativePath: string; size: number }>; + emptyDirs: string[]; + totalBytes: number; +} + +export interface PluginLocalPty { + readonly pid: number; + /** The shell program that was started. */ + readonly shell: string; + write: (data: string) => void; + resize: (cols: number, rows: number) => void; + kill: () => void; + onData: (handler: (data: string) => void) => () => void; + onExit: (handler: (exitCode: number) => void) => () => void; +} + +/** A download being written next to its final name, published when done. */ +export interface PluginLocalDownload { + /** Final path inside the chosen folder, links inside it already refused. */ + readonly path: string; + readonly stream: NodeJS.WritableStream; + /** Moves the finished partial onto its final name. */ + publish: (options?: { overwrite?: boolean }) => Promise; + /** Removes the partial file. Safe to call after publish. */ + abort: () => Promise; +} + +export interface PluginLocalResponse { + readonly status: number; + readonly ok: boolean; + readonly headers: { get: (name: string) => string | null }; + /** A web stream of the body, or null when there is none. */ + readonly body: AsyncIterable | null; + text: () => Promise; +} + +export interface PluginDesktopLocal { + /** Whether local access is possible: embedded backend and capability granted. */ + available: () => boolean; + /** + * Calls this plugin's own HTTP routes on the embedded backend ("local") or + * on the server the desktop is linked to ("remote"), as the acting user. + * The path is under /plugin-api/; nothing else is + * reachable. The link's proxy headers and certificate rules apply. A body + * may be a string, a buffer or a Node stream. + */ + pluginFetch: ( + origin: "local" | "remote", + subPath: string, + init?: { + method?: string; + headers?: Record; + body?: string | Buffer | NodeJS.ReadableStream; + signal?: AbortSignal; + }, + ) => Promise; + info: () => Promise<{ + home: string; + separator: string; + platform: NodeJS.Platform; + /** The desktop app's user data folder. */ + userDataDir: string; + /** The port the embedded backend listens on. */ + backendPort: number; + /** The server this desktop is linked to, or null. */ + link: { + serverUrl: string; + token: string | null; + /** + * Headers for a request to the linked server: the session, plus the + * proxy headers and basic auth the link was set up with. + */ + headers: Record; + /** The link accepts a self-signed certificate. */ + allowInvalidCertificate: boolean; + } | null; + }>; + /** + * GET a JSON route on the linked server with the link's session, proxy + * headers and certificate rules. Rejects with an error whose `kind` is + * "signed_out" when the server refused the session. + */ + linkJson: (pathname: string) => Promise; + readonly fs: { + list: (dirPath: string) => Promise<{ + path: string; + parent: string | null; + entries: PluginLocalDirEntry[]; + }>; + mkdir: (parentPath: string, name: string) => Promise; + createFile: (parentPath: string, name: string) => Promise; + /** Renames inside the same folder; never replaces an existing name. */ + rename: (path: string, newName: string) => Promise; + /** Creates a folder; with rootPath it must stay inside that folder. */ + ensureDir: (dirPath: string, rootPath?: string) => Promise; + /** Which of the given paths exist. */ + exists: (paths: string[]) => Promise; + walk: (rootPaths: string[]) => Promise; + /** Sends entries to the operating system's trash. */ + trash: (paths: string[]) => Promise<{ + trashed: number; + failed: Array<{ path: string; error: string }>; + }>; + reveal: (path: string) => Promise; + open: (path: string) => Promise; + openRead: (path: string) => Promise<{ + stream: NodeJS.ReadableStream; + size: number; + }>; + openDownload: (options: { + rootPath: string; + destPath: string; + transferId: string; + overwrite?: boolean; + }) => Promise; + }; + readonly pty: { + spawn: (options?: { + cols?: number; + rows?: number; + /** "wsl" on Windows starts WSL instead of PowerShell. */ + shell?: string; + }) => Promise; + }; } /** @@ -1699,6 +2062,23 @@ export interface PluginCredentials { scheme: string, resolve: (userId: string, reference: string) => Promise, ) => void; + /** + * The public key line of an SSH key credential the acting user can see + * (their own, with the core credentials.view permission). Derived from the + * private key when none is stored, including a key kept behind a secret + * reference. Null when the credential is missing, is not a key, or cannot + * be read. Needs credentials:use. Audited. + */ + publicKey: (credentialId: number) => Promise; + /** + * Replaces secret references ("op://...") in a key and its passphrase with + * the real values, for the acting user. Plain values pass through. Hands + * private key material to the plugin. Needs credentials:read. Audited. + */ + resolveKeyRefs: ( + key: string, + passphrase?: string | null, + ) => Promise<{ key: string; passphrase?: string }>; } /** @@ -1721,6 +2101,57 @@ export interface PluginAudit { */ request?: unknown; }) => Promise; + /** + * Called for every audit line core or any plugin writes, after it is + * accepted. Fire and forget: a slow or throwing handler never delays or + * fails the audited action. Lines written before the plugin started are not + * replayed; read them with list or export. Returns an unsubscribe function; + * it is also removed when the plugin deactivates. Needs audit:read. + */ + subscribe: (handler: (entry: PluginAuditEntry) => void) => () => void; + /** A page of stored lines, newest first. Needs audit:read. */ + list: (options?: { + filters?: PluginAuditFilters; + limit?: number; + offset?: number; + }) => Promise<{ entries: PluginAuditEntry[]; total: number }>; + /** + * Stored lines in time order, in batches, so an export never holds the + * whole trail in memory. Needs audit:read. + */ + exportBatches: ( + filters?: PluginAuditFilters, + batchSize?: number, + ) => AsyncGenerator; +} + +export interface PluginAuditFilters { + userId?: string; + action?: string; + resourceType?: string; + success?: boolean; + /** ISO date-time. */ + startDate?: string; + /** ISO date-time. */ + endDate?: string; +} + +export interface PluginAuditEntry { + /** Only on stored lines (list and export). */ + id?: number; + /** ISO date-time. */ + timestamp: string; + userId: string | null; + username: string; + action: string; + resourceType: string; + resourceId: string | null; + resourceName: string | null; + details: string | null; + ipAddress: string | null; + userAgent: string | null; + success: boolean; + errorMessage: string | null; } /** A notification channel a user set up. Its config never leaves the hub. */ @@ -2066,6 +2497,8 @@ export interface PluginContext { readonly notify: PluginNotify; /** Outbound HTTP through core's SSRF guard. Needs network:outbound. */ readonly fetch: PluginFetch; + /** Plain reachability checks. Needs network:probe. */ + readonly network: PluginNetwork; /** Programs on the Termix server. Needs process:spawn. */ readonly process: PluginProcess; /** The server certificate. Needs system:tls. */ diff --git a/packages/plugin-sdk/src/capabilities.ts b/packages/plugin-sdk/src/capabilities.ts index 7d8a6c25c..e71cd39e2 100644 --- a/packages/plugin-sdk/src/capabilities.ts +++ b/packages/plugin-sdk/src/capabilities.ts @@ -52,16 +52,21 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [ entry("ui:layout", "high"), entry("hosts:write", "medium"), + entry("hosts:export", "high"), entry("credentials:use", "medium"), entry("credentials:write", "medium"), entry("network:outbound", "medium"), entry("network:serve", "medium"), entry("network:broadcast", "medium"), + entry("network:probe", "medium"), + entry("hosts:status-provide", "medium"), + entry("hosts:activity-provide", "medium"), entry("users:read", "medium"), entry("events:core", "medium"), entry("notify:send", "medium"), entry("audit:read", "medium"), entry("desktop:window", "medium"), + entry("desktop:local", "high"), entry("hosts:read", "low"), entry("db:own", "low"), diff --git a/packages/plugin-sdk/src/db.ts b/packages/plugin-sdk/src/db.ts index 918ef7285..823789c8f 100644 --- a/packages/plugin-sdk/src/db.ts +++ b/packages/plugin-sdk/src/db.ts @@ -256,6 +256,7 @@ export const LEGACY_TABLE_OWNERS: Readonly> = { homepage_items: "homepage", homepage_layouts: "homepage", dashboard_service_links: "homepage", + recent_activity: "homepage", secret_sources: "secret-sources", opkssh_tokens: "opkssh", vault_profiles: "vault", diff --git a/packages/plugin-sdk/src/desktop.ts b/packages/plugin-sdk/src/desktop.ts index 86e73e633..19346342a 100644 --- a/packages/plugin-sdk/src/desktop.ts +++ b/packages/plugin-sdk/src/desktop.ts @@ -3,8 +3,6 @@ * only inside the Termix desktop app; check isElectron before calling it. */ -import type { PluginSshPromptRequest } from "./backend.js"; - export interface ServerConfig { serverUrl?: string; allowInvalidCertificate?: boolean; @@ -33,27 +31,6 @@ export interface DialogResult { [key: string]: unknown; } -export interface LocalDirectoryResult { - success: boolean; - path: string; - parent?: string; - entries: LocalFileEntry[]; - error?: string; -} - -export interface LocalCollectedFile { - path: string; - name: string; - relativePath: string; - size: number; - created?: string; - modified: string; -} - -export type LocalPathMutationResult = - | ({ success: true } & Partial) - | { success: false; error?: string }; - export interface ElectronAPI { getAppVersion: () => Promise; setZoomFactor?: (factor: number) => void; @@ -67,60 +44,6 @@ export interface ElectronAPI { serverUrl: string, allowInvalidCertificate?: boolean, ) => Promise; - answerC2SAuth: (id: string, answer: string | null) => Promise; - onC2SAuthPrompt: ( - callback: (prompt: { - id: string; - closed?: boolean; - tunnelName?: string; - request?: PluginSshPromptRequest; - }) => void, - ) => () => void; - getC2STunnelConfig: () => Promise; - saveC2STunnelConfig: ( - config: unknown[], - ) => Promise<{ success: boolean; error?: string }>; - checkLocalPortAvailable: ( - host: string, - port: number, - ) => Promise<{ available: boolean; error?: string }>; - getC2STunnelPresetDefaultName: () => Promise; - startC2STunnel: ( - tunnel: unknown, - index: number, - authToken?: string, - ) => Promise<{ - success: boolean; - tunnelName?: string; - error?: string; - code?: string; - }>; - testC2STunnel: ( - tunnel: unknown, - index: number, - authToken?: string, - ) => Promise<{ - success: boolean; - message?: string; - error?: string; - code?: string; - }>; - stopC2STunnel: ( - tunnelName: string, - ) => Promise<{ success: boolean; error?: string }>; - getC2STunnelStatuses: () => Promise>; - onC2STunnelStatuses?: ( - callback: (statuses: Record) => void, - ) => () => void; - startC2SAutoStartTunnels: () => Promise<{ - success: boolean; - started: number; - errors: string[]; - }>; - /** Where the linked server relays client tunnel streams, a /plugin-ws/ path. */ - setC2SRelayPath?: ( - relayPath: string, - ) => Promise<{ success: boolean; error?: string }>; onCloseActiveTab?: (callback: () => void) => () => void; clearSessionCookies: () => Promise; getSessionCookie: ( @@ -136,62 +59,6 @@ export interface ElectronAPI { showSaveDialog: (options: DialogOptions) => Promise; showOpenDialog: (options: DialogOptions) => Promise; - getLocalHomeDirectory?: () => Promise; - listLocalDirectory?: (path?: string) => Promise; - statLocalPaths?: ( - paths: string[], - ) => Promise< - Array< - | (LocalFileEntry & { success: true }) - | { success: false; path: string; error?: string } - > - >; - collectLocalFiles?: (paths: string[]) => Promise<{ - success: boolean; - files: LocalCollectedFile[]; - truncated?: boolean; - error?: string; - }>; - createLocalFolder?: ( - parentPath: string, - folderName: string, - ) => Promise; - renameLocalPath?: ( - path: string, - newName: string, - ) => Promise; - trashLocalPath?: (path: string) => Promise; - chmodLocalPath?: ( - path: string, - permissions: string, - ) => Promise; - - openExternalEditor: (fileData: { - fileName: string; - content: string; - encoding?: "utf8" | "base64"; - editorPath?: string | null; - }) => Promise<{ - success: boolean; - editId?: string; - path?: string; - error?: string; - }>; - - closeExternalEditor: (editId: string) => Promise<{ - success: boolean; - error?: string; - }>; - - onExternalEditorSaved?: ( - callback: (payload: { - editId: string; - content: string; - encoding: "utf8"; - path: string; - }) => void, - ) => () => void; - onUpdateAvailable: (callback: () => void) => void; onUpdateDownloaded: (callback: () => void) => void; @@ -238,159 +105,6 @@ export interface ElectronAPI { success: boolean; error?: string; }>; - - startLocalTerminal(dimensions: { - cols: number; - rows: number; - shell?: "default" | "wsl"; - }): Promise<{ sessionId: string; shell: string }>; - readyLocalTerminal(sessionId: string): Promise; - writeLocalTerminal(sessionId: string, data: string): Promise; - resizeLocalTerminal( - sessionId: string, - cols: number, - rows: number, - ): Promise; - closeLocalTerminal(sessionId: string): Promise; - onLocalTerminalData( - sessionId: string, - callback: (data: string) => void, - ): () => void; - onLocalTerminalExit( - sessionId: string, - callback: (exitCode: number) => void, - ): () => void; - - /** Local disk browsing for the dual-pane file manager (desktop only). */ - localFs?: { - home: () => Promise>; - list: (dirPath: string) => Promise>; - mkdir: ( - parentPath: string, - name: string, - ) => Promise>; - createFile: ( - parentPath: string, - name: string, - ) => Promise>; - rename: ( - oldPath: string, - newName: string, - ) => Promise>; - trash: (paths: string[]) => Promise>; - ensureDir: ( - dirPath: string, - rootPath?: string, - ) => Promise>; - exists: (paths: string[]) => Promise>; - walk: (paths: string[]) => Promise>; - reveal: (targetPath: string) => Promise>; - open: (targetPath: string) => Promise>; - }; - - /** Streamed local<->remote transfers driven by the main process. */ - localTransfer?: { - /** The /plugin-api/ path that serves the streaming routes. */ - setApiPath?: (apiPath: string) => Promise>; - upload: ( - options: LocalUploadRequest, - ) => Promise>; - download: ( - options: LocalDownloadRequest, - ) => Promise>; - cancel: ( - transferId: string, - ) => Promise>; - onProgress: ( - callback: (payload: LocalTransferProgress) => void, - ) => () => void; - }; -} - -export type LocalFsResult = - ({ success: true } & T) | { success: false; error: string; code?: string }; - -export interface LocalFsHomeInfo { - home: string; - separator: string; - platform: string; -} - -export interface LocalFileEntry { - name: string; - path: string; - type: "file" | "directory" | "link" | "other"; - created?: string; - modified?: string; - permissions?: string; - owner?: string; - group?: string; - size: number; - modifiedTimestamp?: number; - linkTarget?: string; - hidden: boolean; -} - -export interface LocalDirectoryListing { - path: string; - parent: string | null; - entries: LocalFileEntry[]; -} - -export interface LocalTrashResult { - trashed: number; - failed: Array<{ path: string; error: string }>; -} - -export interface LocalWalkFile { - localPath: string; - /** Path relative to the drop root; "/"-separated and includes the root's own name. */ - relativePath: string; - size: number; -} - -export interface LocalWalkResult { - files: LocalWalkFile[]; - emptyDirs: string[]; - totalBytes: number; -} - -/** - * Which Termix backend a transfer talks to. The main process resolves the - * actual URL and credentials for the origin; the renderer never supplies them. - */ -export type LocalTransferOrigin = "local" | "remote"; - -export interface LocalUploadRequest { - transferId: string; - origin: LocalTransferOrigin; - fields: Record; - localPath: string; - fileName: string; - deviceId?: string; - /** The renderer's token for the embedded backend (origin "local"); sent as a Bearer header. */ - authToken?: string; -} - -export interface LocalDownloadRequest { - transferId: string; - origin: LocalTransferOrigin; - body: Record; - destPath: string; - /** Selected download folder; destPath must resolve strictly inside it (code EINVAL otherwise). */ - rootPath: string; - expectedSize?: number; - /** Replace an existing file at destPath; otherwise the transfer is refused with code EEXIST. */ - overwrite?: boolean; - deviceId?: string; - /** The renderer's token for the embedded backend (origin "local"); sent as a Bearer header. */ - authToken?: string; -} - -export interface LocalTransferProgress { - transferId: string; - transferred: number; - total?: number; } declare global { diff --git a/packages/plugin-sdk/src/frontend.ts b/packages/plugin-sdk/src/frontend.ts index e67b31324..8f7c06c75 100644 --- a/packages/plugin-sdk/src/frontend.ts +++ b/packages/plugin-sdk/src/frontend.ts @@ -653,8 +653,16 @@ export interface HostBadgeContribution { id: string; when: (host: PluginHostRecord) => boolean; component: ComponentType<{ host: PluginHostRecord }>; + /** Lower comes first. Ties sort by id. */ + order?: number; } +/** The dot or stripe that shows a host's status on its row. */ +export type HostStatusIndicatorContribution = HostBadgeContribution; + +/** A control in the host editor's toolbar. */ +export type HostEditorToolbarItemContribution = HostBadgeContribution; + export interface HostContextMenuItemContribution { id: string; titleKey: string; @@ -838,10 +846,11 @@ export type OnboardingMode = "full" | "partial" | "rerun"; * Where a plugin step sits in onboarding. "setup" comes right after the * appearance steps, for things that need doing before the feature works * (like pointing at a guacd server). "explore" (the default) comes after - * that, "security" after the account security step. Onboarding is for + * that, "security" after the account security step. "start" comes before + * the appearance steps, for choices that shape how the rest looks. Onboarding is for * choices, not for explaining features. */ -export type OnboardingSection = "setup" | "explore" | "security"; +export type OnboardingSection = "start" | "setup" | "explore" | "security"; export interface OnboardingStepProps { /** @@ -884,6 +893,16 @@ export interface OnboardingStepContribution { permission?: string; section?: OnboardingSection; order?: number; + /** + * Exact place in the flow, 1 to 99, instead of a section. Core steps sit at + * 10 (plugins), 20, 25, 30 (preset), 40 (appearance) and 90 (security). + */ + position?: number; + /** + * Older step keys this step took over, like "preset". People who already + * saw one of them are not shown this step again. + */ + replaces?: string[]; when?: (context: { isAdmin: boolean; isDesktop: boolean; @@ -1055,6 +1074,14 @@ export interface TermixApp extends TermixAppInfo { registerHostAction: (action: HostActionContribution) => Disposer; registerHostProtocol: (protocol: HostProtocolContribution) => Disposer; registerHostBadge: (badge: HostBadgeContribution) => Disposer; + /** Draws a host row's status. A layout renders every registered one. */ + registerHostStatusIndicator: ( + indicator: HostStatusIndicatorContribution, + ) => Disposer; + /** Adds a control to the host editor's toolbar. */ + registerHostEditorToolbarItem: ( + item: HostEditorToolbarItemContribution, + ) => Disposer; registerHostContextMenuItem: ( item: HostContextMenuItemContribution, ) => Disposer; diff --git a/packages/plugin-sdk/src/manifest.ts b/packages/plugin-sdk/src/manifest.ts index f1e3b79ca..b02ec8622 100644 --- a/packages/plugin-sdk/src/manifest.ts +++ b/packages/plugin-sdk/src/manifest.ts @@ -37,7 +37,7 @@ export { * "^1.2" needs 1.2 or later). engine.termix is checked against the core * version by the server. */ -export const PLUGIN_API_VERSION = "1.4.0"; +export const PLUGIN_API_VERSION = "1.5.0"; /** The API major, the default engine.api for a new plugin. */ export const SUPPORTED_PLUGIN_API_VERSION = String( @@ -688,6 +688,21 @@ export interface PluginManifest { nativeDependencies?: string[]; /** How a linked desktop treats this plugin. Defaults to "mirror". */ desktop?: PluginDesktopMode; + /** + * "desktop": the plugin only loads on a desktop app's own embedded backend, + * never on a server. It implies desktop "local" and may declare desktop:local. + */ + runtime?: PluginRuntime; +} + +export type PluginRuntime = "desktop"; +export const PLUGIN_RUNTIMES: readonly PluginRuntime[] = ["desktop"]; + +/** The desktop mode a manifest resolves to. */ +export function resolvePluginDesktopMode( + m: Pick, +): PluginDesktopMode { + return m.desktop ?? (m.runtime === "desktop" ? "local" : "mirror"); } export const DEFAULT_BACKEND_ENTRY = "dist/backend.js"; @@ -727,6 +742,7 @@ const ALLOWED_TOP_LEVEL = new Set([ "platforms", "nativeDependencies", "desktop", + "runtime", ]); const ALLOWED_CONTRIBUTES = new Set([ @@ -904,6 +920,21 @@ export function validateManifest(manifest: unknown): string[] { `Field "desktop" must be one of: ${PLUGIN_DESKTOP_MODES.join(", ")}`, ); } + if ( + m.runtime !== undefined && + !PLUGIN_RUNTIMES.includes(m.runtime as PluginRuntime) + ) { + errors.push( + `Field "runtime" must be one of: ${PLUGIN_RUNTIMES.join(", ")}`, + ); + } + if ( + m.runtime === "desktop" && + m.desktop !== undefined && + m.desktop !== "local" + ) { + errors.push(`A "desktop" runtime plugin must use desktop "local"`); + } validateDependencyMap(m.dependencies, "dependencies", errors); validateDependencyMap(m.optionalDependencies, "optionalDependencies", errors); validateProvides(m.provides, errors); diff --git a/packages/plugin-sdk/src/ssh-keys.ts b/packages/plugin-sdk/src/ssh-keys.ts new file mode 100644 index 000000000..ad294c056 --- /dev/null +++ b/packages/plugin-sdk/src/ssh-keys.ts @@ -0,0 +1,122 @@ +/** + * SSH key helpers for plugins that make, check or deploy keys: generate a key + * pair, tell what a key is, derive a public key and check a pair belongs + * together. Pure functions over ssh2, which the host supplies. Core keeps its + * own copy for the connect pipeline. + */ + +import ssh2 from "ssh2"; + +export type SshKeyKind = "ssh-ed25519" | "ssh-rsa" | "ecdsa-sha2-nistp256"; + +export interface GeneratedSshKeyPair { + privateKey: string; + publicKey: string; + keyType: SshKeyKind; +} + +export interface GenerateSshKeyPairOptions { + /** RSA only. 2048 to 8192, default 4096. */ + bits?: number; + passphrase?: string; + comment?: string; +} + +/** Makes a new key pair; the public half is one authorized_keys line. */ +export function generateSshKeyPair( + type: SshKeyKind, + options: GenerateSshKeyPairOptions = {}, +): GeneratedSshKeyPair { + const algorithm = + type === "ssh-rsa" ? "rsa" : type === "ssh-ed25519" ? "ed25519" : "ecdsa"; + if ( + type !== "ssh-rsa" && + type !== "ssh-ed25519" && + type !== "ecdsa-sha2-nistp256" + ) { + throw new Error(`Unsupported key type: ${String(type)}`); + } + const generateOptions: Record = { + comment: options.comment ?? "", + }; + if (options.passphrase) { + generateOptions.passphrase = options.passphrase; + generateOptions.cipher = "aes256-ctr"; + } + if (type === "ssh-rsa") { + const bits = options.bits ?? 4096; + if (!Number.isInteger(bits) || bits < 2048 || bits > 8192) { + throw new Error("RSA keys need 2048 to 8192 bits"); + } + generateOptions.bits = bits; + } + if (type === "ecdsa-sha2-nistp256") generateOptions.bits = 256; + + const pair = ssh2.utils.generateKeyPairSync( + algorithm as "rsa", + generateOptions as never, + ); + return { + privateKey: pair.private, + publicKey: pair.public.trim(), + keyType: type, + }; +} + +function parse(key: string, passphrase?: string) { + const parsed = ssh2.utils.parseKey(key.trim(), passphrase || undefined); + const first = Array.isArray(parsed) ? parsed[0] : parsed; + if (!first || first instanceof Error) { + throw first instanceof Error ? first : new Error("Not a readable SSH key"); + } + return first; +} + +/** The key's algorithm, like "ssh-ed25519", or null when it cannot be read. */ +export function detectSshKeyType( + key: string, + passphrase?: string, +): string | null { + try { + return parse(key, passphrase).type; + } catch { + return null; + } +} + +/** The authorized_keys line for a private key, or null. */ +export function publicKeyFromPrivate( + privateKey: string, + passphrase?: string, +): string | null { + try { + const parsed = parse(privateKey, passphrase); + return `${parsed.type} ${parsed.getPublicSSH().toString("base64")}`; + } catch { + return null; + } +} + +/** Whether a public key line belongs to the private key. */ +export function validateSshKeyPair( + privateKey: string, + publicKey: string, + passphrase?: string, +): { isValid: boolean; error?: string } { + try { + const priv = parse(privateKey, passphrase); + const pub = parse(publicKey); + const same = priv.getPublicSSH().equals(pub.getPublicSSH()); + return same + ? { isValid: true } + : { + isValid: false, + error: "The public key does not match the private key", + }; + } catch (error) { + return { + isValid: false, + error: error instanceof Error ? error.message : String(error), + }; + } +} diff --git a/packages/plugin-sdk/src/testing.ts b/packages/plugin-sdk/src/testing.ts index 4515a62ea..130d5b7cc 100644 --- a/packages/plugin-sdk/src/testing.ts +++ b/packages/plugin-sdk/src/testing.ts @@ -14,7 +14,10 @@ import { PluginCapabilityError, } from "./backend.js"; import type { + PluginAuditEntry, + PluginAuditFilters, PluginContext, + PluginDesktopLocal, PluginDisposables, PluginLogger, } from "./backend.js"; @@ -70,7 +73,10 @@ import type { PluginDatabase } from "./backend.js"; import type { PluginExternalClientRequest, PluginProtocolTarget, + PluginHostProbeTarget, PluginHostStatusEntry, + PluginActivityProvider, + PluginHostStatusProvider, PluginNotification, PluginNotificationChannel, PluginNotifyHub, @@ -101,6 +107,8 @@ export interface FakeContextOptions { settings?: Record; /** Seed for the core settings ctx.settings.readCore can reach. */ coreSettings?: Record; + /** What ctx.files.dataDir() answers. Defaults to /tmp/. */ + dataDir?: string; /** What ctx.ssh.connect and withConnection hand back as the client. */ sshClient?: unknown; /** A real database behind ctx.db, usually createTestDb().database. */ @@ -134,8 +142,18 @@ export interface FakeContextOptions { sshKeyCredentials?: PluginSshKeyCredential[]; /** What ctx.desktop.available() answers. Defaults to false. */ desktopAvailable?: boolean; + /** What ctx.desktop.local is. Calls throw when this is not given. */ + desktopLocal?: PluginDesktopLocal; + /** The stored audit lines ctx.audit.list and exportBatches serve. */ + auditEntries?: PluginAuditEntry[]; /** What ctx.hosts.status.get and check answer, by host id. */ hostStatuses?: Record; + /** What ctx.hosts.probeTargets answers. */ + probeTargets?: PluginHostProbeTarget[]; + /** Host ids ctx.hosts.sessionActive answers true for. */ + activeSessions?: number[]; + /** What ctx.network.tcpProbe and ctx.ssh.probe answer. Defaults to false. */ + reachable?: (target: string | number, port?: number) => boolean; /** The acting user's channels, what ctx.notify.channels answers. */ notificationChannels?: PluginNotificationChannel[]; /** @@ -263,6 +281,10 @@ export interface FakePluginContext { ) => Promise>; /** Everything the plugin registered through ctx.disposables. */ disposals: Array<() => void | Promise>; + /** Providers registered through ctx.hosts.status.provide. */ + hostStatusProviders: PluginHostStatusProvider[]; + /** Every provider passed to ctx.hosts.activity.provide that is still registered. */ + activityProviders: PluginActivityProvider[]; /** Topics emitted through ctx.events.emit, in order. */ emitted: Array<{ topic: string; payload: unknown }>; /** Backing store behind ctx.kv. */ @@ -338,6 +360,8 @@ export interface FakePluginContext { challenges: Map; renewers: number; }; + /** Calls every ctx.audit.subscribe handler, like a line core just accepted. */ + emitAudit: (entry: PluginAuditEntry) => void; /** Every ctx.audit.record entry, in order. */ audits: Array<{ action: string; success: boolean; [key: string]: unknown }>; /** Every ctx.hosts.recordActivity call, in order. */ @@ -491,6 +515,8 @@ export function createFakeContext( const pluginId = options.pluginId ?? "test-plugin"; const disposals: Array<() => void | Promise> = []; const emitted: Array<{ topic: string; payload: unknown }> = []; + const hostStatusProviders: PluginHostStatusProvider[] = []; + const activityProviders: PluginActivityProvider[] = []; const kv = new Map(); const tables: PluginTableDefinition[] = []; const syncEntities: SyncEntityRegistration[] = []; @@ -531,6 +557,7 @@ export function createFakeContext( }; const secretStore = new Map(); const audits: FakePluginContext["audits"] = []; + const auditSubscribers = new Set<(entry: PluginAuditEntry) => void>(); const activities: FakePluginContext["activities"] = []; const trackedSessions: number[] = []; const interactions: FakePluginContext["interactions"] = []; @@ -665,7 +692,7 @@ export function createFakeContext( files: { // A fake path; a test that needs a real folder uses the OS temp dir // itself, not this double. - dataDir: async () => `/tmp/${pluginId}`, + dataDir: async () => options.dataDir ?? `/tmp/${pluginId}`, }, db: options.db @@ -902,6 +929,27 @@ export function createFakeContext( disposables, hosts: { + importMany: async (rows) => { + const created = rows.map(() => nextHostId++); + return { + success: created.length, + updated: 0, + skipped: 0, + failed: 0, + errors: [], + createdHostIds: created, + }; + }, + importSshConfig: async () => ({ + success: 0, + updated: 0, + skipped: 0, + failed: 0, + errors: [], + createdHostIds: [], + }), + exportMany: async () => ({ hosts: [] }), + registerImportNormalizer: () => () => {}, list: async () => [...hostsById.values()], get: async (hostId) => hostsById.get(hostId) ?? null, checkAccess: async (hostId): Promise => { @@ -971,7 +1019,29 @@ export function createFakeContext( recordActivity: async (hostId, type, hostName) => { activities.push({ hostId, type, hostName }); }, + probeTargets: async (filter) => + (options.probeTargets ?? []).filter( + (target) => !filter?.hostIds || filter.hostIds.includes(target.id), + ), + sessionActive: (hostId) => + options.activeSessions?.includes(hostId) ?? false, + activity: { + provide: (provider) => { + activityProviders.push(provider); + return () => { + const at = activityProviders.indexOf(provider); + if (at >= 0) activityProviders.splice(at, 1); + }; + }, + }, status: { + provide: (provider) => { + hostStatusProviders.push(provider); + return () => { + const at = hostStatusProviders.indexOf(provider); + if (at >= 0) hostStatusProviders.splice(at, 1); + }; + }, get: async (hostId) => hostStatuses.get(hostId) ?? null, check: async (hostId) => hostStatuses.get(hostId) ?? null, reportLogin: (hostId, outcome) => { @@ -996,12 +1066,45 @@ export function createFakeContext( }, audit: { + subscribe: (handler) => { + auditSubscribers.add(handler); + return () => { + auditSubscribers.delete(handler); + }; + }, + list: async (listOptions) => { + const matching = filterAuditEntries( + options.auditEntries ?? [], + listOptions?.filters, + ).reverse(); + const offset = listOptions?.offset ?? 0; + return { + entries: matching.slice(offset, offset + (listOptions?.limit ?? 50)), + total: matching.length, + }; + }, + exportBatches: async function* (filters, batchSize) { + const matching = filterAuditEntries( + options.auditEntries ?? [], + filters, + ); + const size = batchSize ?? 500; + for (let at = 0; at < matching.length; at += size) { + yield matching.slice(at, at + size); + } + }, record: async (entry) => { audits.push({ ...entry }); }, }, + network: { + tcpProbe: async (host, port) => options.reachable?.(host, port) ?? false, + }, + ssh: { + probe: async (hostId, probeOptions) => + options.reachable?.(hostId, probeOptions?.port) ?? false, connect: async (host, connectOptions) => { sshConnections.push({ host, options: connectOptions }); return { @@ -1013,6 +1116,16 @@ export function createFakeContext( dispose: () => {}, }; }, + exec: async (host) => { + sshConnections.push({ host }); + return { + stdout: "", + stderr: "", + code: 0, + truncated: false, + timedOut: false, + }; + }, withConnection: async (host, connectOptions, fn) => { sshConnections.push({ host, pool: connectOptions.pool }); return fn(sshClient as never); @@ -1169,6 +1282,7 @@ export function createFakeContext( return { success: true }; }, available: () => options.desktopAvailable ?? false, + local: options.desktopLocal ?? unavailableDesktopLocal(), }, credentials: { @@ -1195,6 +1309,13 @@ export function createFakeContext( credentialReads.push({ hostId, protocol }); return options.protocolTargets?.[`${hostId}:${protocol}`] ?? null; }, + publicKey: async (credentialId) => + (options.sshKeyCredentials ?? []).find((k) => k.id === credentialId) + ?.publicKey ?? null, + resolveKeyRefs: async (key, passphrase) => ({ + key, + passphrase: passphrase ?? undefined, + }), registerSecretResolver: (scheme, resolve) => { auth.secretResolvers.set(scheme, resolve); disposals.push(() => { @@ -1345,6 +1466,8 @@ export function createFakeContext( return errors; }, disposals, + hostStatusProviders, + activityProviders, emitted, kv, tables, @@ -1368,6 +1491,9 @@ export function createFakeContext( tls, secretStore, audits, + emitAudit: (entry) => { + for (const handler of auditSubscribers) handler(entry); + }, activities, trackedSessions, interactions, @@ -1409,6 +1535,8 @@ export interface MockContextOptions { settings?: Record; /** Seed for the core settings readCore can reach. */ coreSettings?: Record; + /** What ctx.files.dataDir() answers. */ + dataDir?: string; /** Acting user returned by currentActor and used by asUser. */ actor?: string; /** What ctx.ssh.connect and withConnection hand back as the client. */ @@ -1434,8 +1562,18 @@ export interface MockContextOptions { sshKeyCredentials?: PluginSshKeyCredential[]; /** What ctx.desktop.available() answers. */ desktopAvailable?: boolean; + /** What ctx.desktop.local is. See FakeContextOptions. */ + desktopLocal?: PluginDesktopLocal; + /** See FakeContextOptions. */ + auditEntries?: PluginAuditEntry[]; /** What ctx.hosts.status answers. See FakeContextOptions. */ hostStatuses?: Record; + /** What ctx.hosts.probeTargets answers. */ + probeTargets?: PluginHostProbeTarget[]; + /** Host ids ctx.hosts.sessionActive answers true for. */ + activeSessions?: number[]; + /** What ctx.network.tcpProbe and ctx.ssh.probe answer. */ + reachable?: (target: string | number, port?: number) => boolean; /** The acting user's channels, what ctx.notify.channels answers. */ notificationChannels?: PluginNotificationChannel[]; /** @@ -1481,6 +1619,63 @@ export interface MockPluginContext extends FakePluginContext { * (users:impersonate on issueUserToken), and users:read on ctx.users. Reading a plugin's own settings is deliberately * ungated. As the SDK grows a member, add its gate here in the same shape. */ +function filterAuditEntries( + entries: PluginAuditEntry[], + filters: PluginAuditFilters | undefined, +): PluginAuditEntry[] { + return entries.filter( + (entry) => + (filters?.userId === undefined || entry.userId === filters.userId) && + (filters?.action === undefined || entry.action === filters.action) && + (filters?.resourceType === undefined || + entry.resourceType === filters.resourceType) && + (filters?.success === undefined || entry.success === filters.success) && + (filters?.startDate === undefined || + entry.timestamp >= filters.startDate) && + (filters?.endDate === undefined || entry.timestamp <= filters.endDate), + ); +} + +function gateDesktopLocal( + target: PluginDesktopLocal, + check: () => void, +): PluginDesktopLocal { + const wrap = (value: unknown): unknown => { + if (typeof value === "function") { + return (...args: unknown[]) => { + check(); + return (value as (...a: unknown[]) => unknown)(...args); + }; + } + if (value && typeof value === "object") { + return new Proxy(value as object, { + get: (obj, key) => wrap((obj as Record)[key]), + }); + } + return value; + }; + return new Proxy(target, { + get: (obj, key) => + key === "available" + ? obj.available + : wrap((obj as unknown as Record)[key]), + }); +} + +function unavailableDesktopLocal(): PluginDesktopLocal { + const fail = (): never => { + throw new Error("ctx.desktop.local needs the desktopLocal test option"); + }; + const deep = (): unknown => + new Proxy(() => fail(), { get: () => deep(), apply: () => fail() }); + return { + available: () => false, + info: fail, + fs: deep(), + pty: deep(), + } as unknown as PluginDesktopLocal; +} + export function createMockCtx( options: MockContextOptions = {}, ): MockPluginContext { @@ -1492,6 +1687,7 @@ export function createMockCtx( actor: options.actor, settings: options.settings, coreSettings: options.coreSettings, + dataDir: options.dataDir, sshClient: options.sshClient, db: options.db, router: options.router, @@ -1504,7 +1700,12 @@ export function createMockCtx( protocolTargets: options.protocolTargets, sshKeyCredentials: options.sshKeyCredentials, desktopAvailable: options.desktopAvailable, + desktopLocal: options.desktopLocal, + auditEntries: options.auditEntries, hostStatuses: options.hostStatuses, + probeTargets: options.probeTargets, + activeSessions: options.activeSessions, + reachable: options.reachable, notificationChannels: options.notificationChannels, fetch: options.fetch, process: options.process, @@ -1650,6 +1851,23 @@ export function createMockCtx( }, hosts: { + importMany: async (rows, importOptions) => { + require("hosts:write"); + if (importOptions?.credentials?.length) require("credentials:write"); + return ctx.hosts.importMany(rows, importOptions); + }, + importSshConfig: async (content, importOptions) => { + require("hosts:write"); + return ctx.hosts.importSshConfig(content, importOptions); + }, + exportMany: async (exportOptions) => { + require("hosts:export"); + return ctx.hosts.exportMany(exportOptions); + }, + registerImportNormalizer: (normalizer) => { + require("hosts:write"); + return ctx.hosts.registerImportNormalizer(normalizer); + }, list: async () => { require("hosts:read"); return ctx.hosts.list(); @@ -1698,7 +1916,26 @@ export function createMockCtx( require("hosts:read"); return ctx.hosts.recordActivity(hostId, type, hostName); }, + probeTargets: async (filter) => { + require("hosts:read"); + return ctx.hosts.probeTargets(filter); + }, + sessionActive: (hostId) => { + require("hosts:read"); + return ctx.hosts.sessionActive(hostId); + }, + activity: { + provide: (provider) => { + require("hosts:activity-provide"); + return ctx.hosts.activity.provide(provider); + }, + }, status: { + provide: (provider) => { + require("hosts:read"); + require("hosts:status-provide"); + return ctx.hosts.status.provide(provider); + }, get: async (hostId) => { require("hosts:read"); return ctx.hosts.status.get(hostId); @@ -1720,11 +1957,20 @@ export function createMockCtx( ssh: { ...ctx.ssh, + probe: async (hostId, probeOptions) => { + require("ssh:connect"); + return ctx.ssh.probe(hostId, probeOptions); + }, connect: async (host, connectOptions) => { require("ssh:connect"); require("credentials:use"); return ctx.ssh.connect(host, connectOptions); }, + exec: async (host, command, execOptions) => { + require("ssh:connect"); + require("credentials:use"); + return ctx.ssh.exec(host, command, execOptions); + }, withConnection: async (host, connectOptions, fn) => { require("ssh:connect"); require("credentials:use"); @@ -1820,6 +2066,22 @@ export function createMockCtx( }, }, + audit: { + record: (entry) => ctx.audit.record(entry), + subscribe: (handler) => { + require("audit:read"); + return ctx.audit.subscribe(handler); + }, + list: async (listOptions) => { + require("audit:read"); + return ctx.audit.list(listOptions); + }, + exportBatches: (filters, batchSize) => { + require("audit:read"); + return ctx.audit.exportBatches(filters, batchSize); + }, + }, + desktop: { openIsolatedWindow: async (request) => { require("desktop:window"); @@ -1830,6 +2092,9 @@ export function createMockCtx( return ctx.desktop.launchExternalClient(request); }, available: () => ctx.desktop.available(), + local: gateDesktopLocal(ctx.desktop.local, () => + require("desktop:local"), + ), }, credentials: { @@ -1851,6 +2116,14 @@ export function createMockCtx( } return ctx.credentials.resolveHostProtocol(hostId, protocol); }, + publicKey: async (credentialId) => { + require("credentials:use"); + return ctx.credentials.publicKey(credentialId); + }, + resolveKeyRefs: async (key, passphrase) => { + require("credentials:read"); + return ctx.credentials.resolveKeyRefs(key, passphrase); + }, registerSecretResolver: (scheme, resolve) => { require("auth:provide"); ctx.credentials.registerSecretResolver(scheme, resolve); @@ -1877,6 +2150,13 @@ export function createMockCtx( return ctx.fetch(url, init); }, + network: { + tcpProbe: async (host, port, probeOptions) => { + require("network:probe"); + return ctx.network.tcpProbe(host, port, probeOptions); + }, + }, + process: { run: async (file, args, runOptions) => { require("process:spawn"); diff --git a/packages/plugin-sdk/ui.api.txt b/packages/plugin-sdk/ui.api.txt index e8e4f12a4..205f955d2 100644 --- a/packages/plugin-sdk/ui.api.txt +++ b/packages/plugin-sdk/ui.api.txt @@ -115,13 +115,11 @@ DropdownMenuSubTrigger (value): ({ className, inset, children, ...props }: Dropd DropdownMenuTrigger (value): ({ ...props }: DropdownMenuTriggerProps & RefAttributes) => Element EditingWidth (type): EditingWidth EmptyState (value): ({ icon: Icon, title, hint, action, className, }: { icon?: LucideIcon; title: string; hint?: string; action?: ReactNode; className?: string; }) => Element -ExportPayload (type): ExportPayload FOLDER_COLORS (value): string[] FONT_SIZES (value): { id: FontSizeId; label: string; }[] Facts (value): ({ className, children, }: { className?: string; children: ReactNode; }) => Element FakeSwitch (value): ({ defaultChecked, disabled, checked, onChange, }: { defaultChecked?: boolean; disabled?: boolean; checked?: boolean; onChange?: (v: boolean) => void; }) => Element Field (value): ({ label, hint, error, aside, htmlFor, className, children, }: { label: string; hint?: ReactNode; error?: string; aside?: ReactNode; htmlFor?: string; className?: string; children: ReactNode; }) => Element -FieldGroup (type): FieldGroup FieldPair (value): ({ children }: { children: ReactNode; }) => Element FolderAccessRule (type): FolderAccessRule FolderIconEl (value): ({ icon, className, style, }: { icon?: string; className?: string; style?: CSSProperties; }) => Element @@ -200,6 +198,7 @@ NumberField (value): ({ label, value, onChange, placeholder, hint, error, min, m OPEN_PLUGINS_EVENT (value): "termix:open-plugins" OpenTabRecord (type): OpenTabRecord PANEL (value): { readonly body: "p-2.5"; readonly gap: "gap-2"; readonly band: "px-3 py-2"; readonly cardHead: "px-3 py-2.5"; readonly header: "h-12.5"; } +PRESETS (value): Record<"simple" | "balanced" | "advanced", UiAreaPreferences> PROMPT_BUTTON (value): "inline-flex h-7 items-center justify-center gap-1.5 border border-border px-3 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" PROMPT_DESTRUCTIVE_BUTTON (value): "inline-flex h-7 items-center justify-center gap-1.5 bg-destructive px-3 text-xs font-medium text-white transition-colors hover:bg-destructive/90 focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" PROMPT_PRIMARY_BUTTON (value): "inline-flex h-7 items-center justify-center gap-1.5 border border-accent-brand/40 px-3 text-xs font-medium text-accent-brand transition-colors hover:bg-accent-brand/10 focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" @@ -344,13 +343,11 @@ adminUpdateUserCredential (value): (targetUserId: string, credentialId: number, adminUpdateUserHost (value): (targetUserId: string, hostId: number, hostData: HostData) => Promise applyAccentColor (value): (colorValue: string) => void applyFontSize (value): (id: FontSizeId) => void -applyPresetSideEffects (value): (preset: "advanced" | "simple" | "balanced") => Promise +applyPresetSideEffects (value): (preset: "simple" | "balanced" | "advanced") => Promise applyUiFont (value): (id: UiFontId) => void assignRoleToUser (value): (userId: string, roleId: number) => Promise<{ success: boolean; }> beginSplitDrag (value): (source: SplitDragSource, x: number, y: number) => void -buildExportPayload (value): (raw: ExportPayload, selected: Set, groups: Set, withCredentials: boolean, pluginSecrets?: PluginSecretKeys) => ExportPayload buildOriginWsUrl (value): ({ origin, localPort, localPath, remotePath, includeJwt, }: { origin: ConnectionOrigin; localPort: number; localPath: string; remotePath: string; includeJwt?: boolean; }) => Promise -bulkImportSSHHosts (value): (hosts: HostData[], overwrite?: boolean, credentials?: Record[]) => Promise<{ message: string; success: number; updated: number; skipped: number; failed: number; errors: string[]; }> bulkUpdateSSHHosts (value): (hostIds: number[], updates: Record) => Promise<{ updated: number; failed: number; errors: string[]; }> canConfigureUpdates (value): (state: DesktopUpdateState) => state is DesktopUpdateState canJoinSplit (value): (tab: Tab) => boolean @@ -379,22 +376,17 @@ deleteOpenTab (value): (instanceId: string) => Promise deleteRole (value): (roleId: number) => Promise<{ success: boolean; }> deleteSSHHost (value): (hostId: number) => Promise> deleteUser (value): (username: string, successorUserId?: string) => Promise> -deployCredentialToHost (value): (credentialId: number, targetHostId: number) => Promise> desktopUpdates (value): () => DesktopUpdatesBridge docsUrl (value): (page?: "dashboard" | "users" | "sessions" | "roles" | "branding" | "database" | "manage" | "settings" | "plugins" | "install" | "cli" | "develop" | "home" | "updating" | "betas" | "envVars" | "https" | "reverseProxy" | "desktopSync" | "interface" | "hosts" | "portKnocking" | "hostDefaults" | "credentials" | "sharing" | "account" | "accountSecurity" | "accountData" | "apiKeys" | "commandPalette" | "interfaceSettings" | "appearance" | "keybindings" | "adminGeneral" | "adminDatabase" | "adminSsl" | "auditLog" | "importExport" | "pluginCatalog" | "communityRegistry" | "pluginRegistries" | "hostRegistry") => string duplicateCredential (value): (credentialId: number, data: Record) => Promise> enabledHostProtocols (value): (host: { pluginSettings?: Record>; }, list?: HostProtocolDef[]) => HostProtocolDef[] endSplitDrag (value): (commit: boolean) => void -exportAllSSHHosts (value): { (): Promise<{ hosts: Host[]; }>; (options: { share: true; }): Promise<{ version: string; exportedAt: string; credentials: Record[]; hosts: Host[]; }>; } filterPaletteItems (value): (items: PaletteItemDef[], query: string, showWhenEmpty?: boolean) => PaletteItemDef[] findCoreHostDefault (value): (key: string) => CoreHostDefault findMatchingKeybinding (value): (e: KeyboardEvent, bindings: CustomKeybinding[]) => CustomKeybinding -generateKeyPair (value): (keyType: "ssh-ed25519" | "ssh-rsa" | "ecdsa-sha2-nistp256", keySize?: number, passphrase?: string) => Promise -generatePublicKeyFromPrivate (value): (privateKey: string, keyPassword?: string) => Promise getActiveSessions (value): () => Promise getAdaptiveResourceBudget (value): (kind: AdaptiveTaskKind) => AdaptiveResourceBudget getApiKeys (value): () => Promise<{ apiKeys: ApiKey[]; }> -getAuditForwarding (value): () => Promise getAuditLogActions (value): () => Promise<{ actions: string[]; }> getAuditLogs (value): (filters?: AuditLogFilters) => Promise getBasePath (value): () => string @@ -463,11 +455,11 @@ groupRailItems (value): (items: RailItemDef[]) => { group: RailGroup; items: Rai hasVisibleFields (value): (fields: PluginSettingsField[]) => boolean hostActionsFor (value): (all: HostActionDef[], host: Host, kind?: "open" | "connect") => HostActionDef[] hostBadgesFor (value): (all: HostBadgeDef[], host: Host) => HostBadgeDef[] -hostKey (value): (host: Record) => string +hostEditorToolbarItemsFor (value): (all: HostBadgeDef[], host: Host) => HostBadgeDef[] hostMenuItemsFor (value): (all: HostContextMenuItemDef[], host: Host) => HostContextMenuItemDef[] hostProtocolFlags (value): (host: { enableSsh?: boolean; pluginSettings?: Record>; }, list?: HostProtocolDef[]) => HostProtocols +hostStatusIndicatorsFor (value): (all: HostBadgeDef[], host: Host) => HostBadgeDef[] hydrateLocalSharedHostAuth (value): (host: T) => Promise -importSSHConfigHosts (value): (content: string, overwrite?: boolean) => Promise<{ message: string; success: number; updated: number; skipped: number; failed: number; errors: string[]; }> invalidateHostsAndStatusCaches (value): () => void invalidateServerStatusCache (value): () => void isCoreRailView (value): (id: string) => boolean @@ -492,7 +484,6 @@ makeUserAdmin (value): (userId: string) => Promise> mapCredentials (value): (res: unknown) => Credential[] markAdaptiveResourceUsed (value): (kind: AdaptiveTaskKind, action: string, now?: number) => void markTabSurfaceUsed (value): (type: TabType) => void -maskSecrets (value): (payload: ExportPayload, pluginSecrets?: PluginSecretKeys) => ExportPayload moveRailItem (value): (items: RailItemDef[], id: string, beforeId: string) => string[] moveSplitDrag (value): (x: number, y: number) => void normalizeLanguageCode (value): (language?: string) => string @@ -503,7 +494,6 @@ parseCustomKeybindings (value): (raw?: string) => CustomKeybinding[] parseServerTime (value): (ts: string) => Date pluginKey (value): { (pluginId: string, key: string): string; (pluginId: string, key: string): string; } pluginName (value): (t: Translator, plugin: { id: string; name: string; }) => string -pluginSecretKeys (value): (summaries: { id: string; contributes?: { settings?: { host?: { fields?: { key: string; secretKeys?: string[]; }[]; }; }; }; }[]) => PluginSecretKeys pluginWsUrl (value): (pluginId: string, path: string, options?: { origin?: ConnectionOrigin; }) => Promise preloadTabSurface (value): (type: TabType) => void previewHostDefaultsLevel (value): (target: HostDefaultsTarget, change: HostDefaultsChange) => Promise<{ changedHosts: number; }> @@ -576,8 +566,6 @@ tabJumpHotkeyKeys (value): () => string[] takePendingManageRequest (value): () => ManageRequest unlinkExternalFromPasswordAccount (value): (userId: string) => Promise<{ success: boolean; message: string; }> unlinkServer (value): (keepData: boolean) => Promise -updateAuditForwarding (value): (url: string, token?: string) => Promise<{ url: string; hasToken: boolean; }> -updateBranding (value): (update: BrandingSettingsUpdate) => Promise updateCredential (value): (credentialId: number, credentialData: Record) => Promise> updateExternalAutoProvision (value): (enabled: boolean) => Promise> updateFolderMetadata (value): (name: string, color?: string, icon?: string, credentialId?: number, localOnly?: boolean) => Promise @@ -613,8 +601,10 @@ useHostActions (value): () => HostActionDef[] useHostBadges (value): () => HostBadgeDef[] useHostContextMenuItems (value): () => HostContextMenuItemDef[] useHostEditorSections (value): () => HostEditorSectionDef[] +useHostEditorToolbarItems (value): () => HostBadgeDef[] useHostProtocols (value): () => HostProtocolDef[] useHostStatus (value): (hostId: number, statusCheckEnabled?: boolean) => StatusValue +useHostStatusIndicators (value): () => HostBadgeDef[] useInstalledPlugins (value): () => PluginSummary[] useIsDefaultsEditor (value): () => boolean useIsMobile (value): () => boolean diff --git a/scripts/check-core-plugin-ids.cjs b/scripts/check-core-plugin-ids.cjs index 1cb66cbba..64bf1fb9c 100644 --- a/scripts/check-core-plugin-ids.cjs +++ b/scripts/check-core-plugin-ids.cjs @@ -32,14 +32,18 @@ const OFFICIAL_IDS = [ "acme-ssl", "ai", "alerts", + "audit-tools", "automations", "classic-layout", "classic-themes", + "credential-tools", "docker", "file-manager", "fleets", "homepage", "host-metrics", + "host-status", + "import-export", "ldap", "network-topology", "opkssh", diff --git a/scripts/check-sdk-ui-exports.cjs b/scripts/check-sdk-ui-exports.cjs index 9016c26c8..6f60f4282 100644 --- a/scripts/check-sdk-ui-exports.cjs +++ b/scripts/check-sdk-ui-exports.cjs @@ -136,13 +136,11 @@ const ALLOWED = [ "DropdownMenuTrigger", "EditingWidth", "EmptyState", - "ExportPayload", "FOLDER_COLORS", "FONT_SIZES", "Facts", "FakeSwitch", "Field", - "FieldGroup", "FieldPair", "FolderAccessRule", "FolderIconEl", @@ -235,6 +233,7 @@ const ALLOWED = [ "PasswordInput", "PermissionCatalogEntry", "PluginComponent", + "PRESETS", "PluginIcon", "PluginSettingsErrors", "PluginSettingsField", @@ -369,9 +368,7 @@ const ALLOWED = [ "applyUiFont", "assignRoleToUser", "beginSplitDrag", - "buildExportPayload", "buildOriginWsUrl", - "bulkImportSSHHosts", "bulkUpdateSSHHosts", "canConfigureUpdates", "canJoinSplit", @@ -400,22 +397,17 @@ const ALLOWED = [ "deleteRole", "deleteSSHHost", "deleteUser", - "deployCredentialToHost", "desktopUpdates", "docsUrl", "duplicateCredential", "enabledHostProtocols", "endSplitDrag", - "exportAllSSHHosts", "filterPaletteItems", "findCoreHostDefault", "findMatchingKeybinding", - "generateKeyPair", - "generatePublicKeyFromPrivate", "getActiveSessions", "getAdaptiveResourceBudget", "getApiKeys", - "getAuditForwarding", "getAuditLogActions", "getAuditLogs", "getBasePath", @@ -484,11 +476,11 @@ const ALLOWED = [ "hasVisibleFields", "hostActionsFor", "hostBadgesFor", - "hostKey", + "hostEditorToolbarItemsFor", "hostMenuItemsFor", "hostProtocolFlags", + "hostStatusIndicatorsFor", "hydrateLocalSharedHostAuth", - "importSSHConfigHosts", "invalidateHostsAndStatusCaches", "invalidateServerStatusCache", "isCoreRailView", @@ -513,7 +505,6 @@ const ALLOWED = [ "mapCredentials", "markAdaptiveResourceUsed", "markTabSurfaceUsed", - "maskSecrets", "moveRailItem", "moveSplitDrag", "normalizeLanguageCode", @@ -524,7 +515,6 @@ const ALLOWED = [ "parseServerTime", "pluginKey", "pluginName", - "pluginSecretKeys", "pluginWsUrl", "preloadTabSurface", "previewHostDefaultsLevel", @@ -597,8 +587,6 @@ const ALLOWED = [ "takePendingManageRequest", "unlinkExternalFromPasswordAccount", "unlinkServer", - "updateAuditForwarding", - "updateBranding", "updateCredential", "updateExternalAutoProvision", "updateFolderMetadata", @@ -633,6 +621,8 @@ const ALLOWED = [ "useHostActions", "useHostBadges", "useHostContextMenuItems", + "useHostEditorToolbarItems", + "useHostStatusIndicators", "useHostEditorSections", "useHostProtocols", "useHostStatus", diff --git a/scripts/electron-backend-ready.test.ts b/scripts/electron-backend-ready.test.ts index 773b544a8..eb12cdca1 100644 --- a/scripts/electron-backend-ready.test.ts +++ b/scripts/electron-backend-ready.test.ts @@ -48,7 +48,11 @@ function start() { writeFileSync: vi.fn(), }, fork: () => child, - app: { isPackaged: true, getVersion: () => "2.9.0" }, + app: { + isPackaged: true, + getVersion: () => "2.9.0", + getPath: () => "/user-data", + }, process: { env: { LOG_LEVEL: "error" } }, isDev: false, appRoot: "/app", diff --git a/scripts/electron-external-editor.test.ts b/scripts/electron-external-editor.test.ts deleted file mode 100644 index dd8862333..000000000 --- a/scripts/electron-external-editor.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { createRequire } from "node:module"; -import { describe, expect, it } from "vitest"; - -const require = createRequire(import.meta.url); -const { isLaunchableFileName } = require("../electron/external-editor.cjs") as { - isLaunchableFileName: (fileName: unknown) => boolean; -}; - -describe("Electron external editor", () => { - it("refuses remote file names the OS would run instead of open", () => { - for (const name of [ - "payload.exe", - "run.BAT", - "script.ps1", - "a.exe. ", - "a.cmd...", - "../../evil.vbs", - "start.command", - "launch.desktop", - "install.sh", - ]) { - expect(isLaunchableFileName(name)).toBe(true); - } - }); - - it("opens ordinary text files", () => { - for (const name of [ - "nginx.conf", - "README.md", - "Dockerfile", - ".bashrc", - "config.json", - "index.ts", - "", - undefined, - ]) { - expect(isLaunchableFileName(name)).toBe(false); - } - }); -}); diff --git a/scripts/electron-security-boundary.test.ts b/scripts/electron-security-boundary.test.ts index 6de78ce7d..90fa9d36b 100644 --- a/scripts/electron-security-boundary.test.ts +++ b/scripts/electron-security-boundary.test.ts @@ -39,12 +39,24 @@ describe("Electron security boundary", () => { } }); - it("launches only the editor saved from a file dialog", () => { - expect(main).not.toContain("fileData?.editorPath"); - expect(main).toContain("readSetting(EXTERNAL_EDITOR_SETTING)"); + it("saves an external editor only if it was chosen in a file dialog", () => { expect(main).toContain("dialogChosenPaths.has(value)"); }); + it("keeps local files, the local terminal and the external editor out of Electron", () => { + for (const channel of [ + "open-external-editor", + "close-external-editor", + "local-terminal-start", + "local-fs:", + "local-transfer:", + "list-local-directory", + ]) { + expect(main).not.toContain(channel); + expect(preload).not.toContain(channel); + } + }); + it("scopes proxy headers and basic auth to the linked origin", () => { expect(main).toContain('"sync-proxy-config"'); expect(main).toContain("linkedServer.applyLinkHeaders("); diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index be0eb86c6..e90043675 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -370,17 +370,6 @@ async function initializeCompleteDatabase(): Promise { FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE ); - 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, diff --git a/src/backend/database/db/performance-indexes.ts b/src/backend/database/db/performance-indexes.ts index 9c0afb1c6..3e0513e10 100644 --- a/src/backend/database/db/performance-indexes.ts +++ b/src/backend/database/db/performance-indexes.ts @@ -98,12 +98,7 @@ export const PERFORMANCE_INDEXES: PerformanceIndex[] = [ columns: "user_id", }, - // Per-user history and file manager surfaces. - { - name: "idx_recent_activity_user_ts", - table: "recent_activity", - columns: "user_id, timestamp", - }, + // Per-user open tabs. { name: "idx_user_open_tabs_user_id", table: "user_open_tabs", diff --git a/src/backend/database/db/schema.mysql.ts b/src/backend/database/db/schema.mysql.ts index 3c90ef39e..f2327efc9 100644 --- a/src/backend/database/db/schema.mysql.ts +++ b/src/backend/database/db/schema.mysql.ts @@ -414,28 +414,6 @@ export const hostDefaults = mysqlTable( ], ); -export const recentActivity = mysqlTable( - "recent_activity", - { - id: int("id").autoincrement().primaryKey(), - userId: varchar("user_id", { length: 255 }) - .notNull() - .references(() => users.id, { onDelete: "cascade" }), - type: text("type").notNull(), - hostId: int("host_id") - .notNull() - .references(() => hosts.id, { onDelete: "cascade" }), - hostName: text("host_name"), - timestamp: varchar("timestamp", { length: 255 }) - .notNull() - .default(sql`(CURRENT_TIMESTAMP)`), - }, - // Always read newest-first for one user, so timestamp follows user_id. - (table) => [ - index("idx_recent_activity_user_ts").on(table.userId, table.timestamp), - ], -); - export const hostAccess = mysqlTable( "host_access", { diff --git a/src/backend/database/db/schema.pg.ts b/src/backend/database/db/schema.pg.ts index 53d62933d..30e1e3aa0 100644 --- a/src/backend/database/db/schema.pg.ts +++ b/src/backend/database/db/schema.pg.ts @@ -414,28 +414,6 @@ export const hostDefaults = pgTable( ], ); -export const recentActivity = pgTable( - "recent_activity", - { - id: serial("id").primaryKey(), - userId: varchar("user_id", { length: 255 }) - .notNull() - .references(() => users.id, { onDelete: "cascade" }), - type: text("type").notNull(), - hostId: integer("host_id") - .notNull() - .references(() => hosts.id, { onDelete: "cascade" }), - hostName: text("host_name"), - timestamp: varchar("timestamp", { length: 255 }) - .notNull() - .default(sql`CURRENT_TIMESTAMP`), - }, - // Always read newest-first for one user, so timestamp follows user_id. - (table) => [ - index("idx_recent_activity_user_ts").on(table.userId, table.timestamp), - ], -); - export const hostAccess = pgTable( "host_access", { diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts index b5c1fb513..afd6e566c 100644 --- a/src/backend/database/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -404,28 +404,6 @@ export const hostDefaults = sqliteTable( ], ); -export const recentActivity = sqliteTable( - "recent_activity", - { - id: integer("id").primaryKey({ autoIncrement: true }), - userId: text("user_id") - .notNull() - .references(() => users.id, { onDelete: "cascade" }), - type: text("type").notNull(), - hostId: integer("host_id") - .notNull() - .references(() => hosts.id, { onDelete: "cascade" }), - hostName: text("host_name"), - timestamp: text("timestamp") - .notNull() - .default(sql`CURRENT_TIMESTAMP`), - }, - // Always read newest-first for one user, so timestamp follows user_id. - (table) => [ - index("idx_recent_activity_user_ts").on(table.userId, table.timestamp), - ], -); - export const hostAccess = sqliteTable( "host_access", { diff --git a/src/backend/database/repositories/factory.ts b/src/backend/database/repositories/factory.ts index 2c5fdb800..4c05f385e 100644 --- a/src/backend/database/repositories/factory.ts +++ b/src/backend/database/repositories/factory.ts @@ -23,7 +23,6 @@ import { PluginPermissionGrantRepository } from "./plugin-permission-grant-repos import { UserAuthRepository } from "./user-auth-repository.js"; import { RbacAccessRepository } from "./rbac-access-repository.js"; import { RbacPermissionRepository } from "./rbac-permission-repository.js"; -import { RecentActivityRepository } from "./recent-activity-repository.js"; import { RoleRepository } from "./role-repository.js"; import { SessionRepository } from "./session-repository.js"; import { CredentialAccessRepository } from "./credential-access-repository.js"; @@ -269,13 +268,6 @@ export function createCurrentRbacAccessRepository(): RbacAccessRepository { ); } -export function createCurrentRecentActivityRepository(): RecentActivityRepository { - return new RecentActivityRepository( - createCurrentRepositoryContext(), - createCurrentRepositoryWriteHook("recent_activity_repository_write"), - ); -} - export function createCurrentRoleRepository(): RoleRepository { return new RoleRepository( createCurrentRepositoryContext(), diff --git a/src/backend/database/repositories/recent-activity-repository.ts b/src/backend/database/repositories/recent-activity-repository.ts deleted file mode 100644 index 30b5fd097..000000000 --- a/src/backend/database/repositories/recent-activity-repository.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { desc, eq, inArray } from "drizzle-orm"; -import { recentActivity } from "../db/schema.js"; -import type { DatabaseContext } from "./database-context.js"; -import { rowsAffected } from "./mutation-result.js"; -import { insertReturning } from "./returning.js"; - -export type RecentActivityRecord = typeof recentActivity.$inferSelect; -export type NewRecentActivityRecord = typeof recentActivity.$inferInsert; - -export class RecentActivityRepository { - constructor( - private readonly context: DatabaseContext, - private readonly onWrite?: () => void | Promise, - ) {} - - async listByUserId( - userId: string, - limit: number, - ): Promise { - return this.context.drizzle - .select() - .from(recentActivity) - .where(eq(recentActivity.userId, userId)) - .orderBy(desc(recentActivity.timestamp)) - .limit(limit); - } - - async create( - activity: NewRecentActivityRecord, - ): Promise { - const rows = await insertReturning(this.context, recentActivity, activity); - - await this.afterWrite(); - return rows[0]; - } - - async trimUserActivity(userId: string, keepCount: number): Promise { - const rows = await this.context.drizzle - .select({ id: recentActivity.id }) - .from(recentActivity) - .where(eq(recentActivity.userId, userId)) - .orderBy(desc(recentActivity.timestamp)); - - const idsToDelete = rows - .slice(keepCount) - .map((row) => row.id) - .filter((id) => typeof id === "number"); - - if (idsToDelete.length === 0) { - return 0; - } - - const result = await this.context.drizzle - .delete(recentActivity) - .where(inArray(recentActivity.id, idsToDelete)); - - if (rowsAffected(result) > 0) { - await this.afterWrite(); - } - - return rowsAffected(result); - } - - async deleteByUserId(userId: string): Promise { - const result = await this.context.drizzle - .delete(recentActivity) - .where(eq(recentActivity.userId, userId)); - - if (rowsAffected(result) > 0) { - await this.afterWrite(); - } - - return rowsAffected(result); - } - - async deleteByHostId(hostId: number): Promise { - const result = await this.context.drizzle - .delete(recentActivity) - .where(eq(recentActivity.hostId, hostId)); - - if (rowsAffected(result) > 0) { - await this.afterWrite(); - } - - return rowsAffected(result); - } - - async deleteByHostIds(hostIds: number[]): Promise { - if (hostIds.length === 0) { - return 0; - } - - const result = await this.context.drizzle - .delete(recentActivity) - .where(inArray(recentActivity.hostId, hostIds)); - - if (rowsAffected(result) > 0) { - await this.afterWrite(); - } - - return rowsAffected(result); - } - - private async afterWrite(): Promise { - await this.onWrite?.(); - } -} diff --git a/src/backend/database/routes/audit-log-routes.ts b/src/backend/database/routes/audit-log-routes.ts index 60dea304b..91273b914 100644 --- a/src/backend/database/routes/audit-log-routes.ts +++ b/src/backend/database/routes/audit-log-routes.ts @@ -5,12 +5,6 @@ import { createCurrentUserRepository, } from "../repositories/factory.js"; import { apiLogger } from "../../utils/logger.js"; -import { exportFilename, toCsv, toNdjson } from "../../utils/audit-export.js"; -import { - logAudit, - getAuditUsername, - getRequestMeta, -} from "../../utils/audit-logger.js"; async function isAdminUser(userId: string | undefined): Promise { if (!userId) return false; @@ -138,125 +132,4 @@ export function registerAuditLogRoutes( .json({ error: "Failed to fetch audit log actions" }); } }); - - /** - * @openapi - * /audit-logs/export: - * get: - * summary: Export audit logs - * description: Streams the full filtered result set as CSV or NDJSON. Accepts the same filters as GET /audit-logs. Admin only. The export is itself audited. - * tags: - * - Audit - * parameters: - * - in: query - * name: format - * schema: { type: string, enum: [csv, ndjson], default: csv } - * - in: query - * name: userId - * schema: { type: string } - * - in: query - * name: action - * schema: { type: string } - * - in: query - * name: resourceType - * schema: { type: string } - * - in: query - * name: success - * schema: { type: string, enum: [true, false] } - * - in: query - * name: startDate - * schema: { type: string, format: date-time } - * - in: query - * name: endDate - * schema: { type: string, format: date-time } - * responses: - * 200: - * description: Audit log file. - * 403: - * description: Not authorized. - * 500: - * description: Failed to export audit logs. - */ - router.get("/audit-logs/export", authenticateJWT, async (req, res) => { - const authReq = req as AuthenticatedRequest; - try { - if (!(await isAdminUser(authReq.userId))) { - return res.status(403).json({ error: "Not authorized" }); - } - - const format = req.query.format === "ndjson" ? "ndjson" : "csv"; - const { userId, action, resourceType, success, startDate, endDate } = - req.query as Record; - const filters = { - userId, - action, - resourceType, - success: - success !== undefined && success !== "" - ? success === "true" - : undefined, - startDate, - endDate, - }; - - res.setHeader( - "Content-Type", - format === "csv" ? "text/csv; charset=utf-8" : "application/x-ndjson", - ); - res.setHeader( - "Content-Disposition", - `attachment; filename="${exportFilename(format, new Date())}"`, - ); - - // Streamed in batches: an export is unbounded by definition, and the - // whole point is to move data out before retention drops it. - const BATCH = 500; - let offset = 0; - let exported = 0; - - for (;;) { - const rows = await createCurrentAuditLogRepository().listForExport({ - filters, - limit: BATCH, - offset, - }); - if (rows.length === 0) break; - - if (format === "csv") { - // Header only on the first batch. - const chunk = toCsv(rows); - res.write( - offset === 0 ? chunk : chunk.slice(chunk.indexOf("\n") + 1), - ); - } else { - res.write(toNdjson(rows)); - } - - exported += rows.length; - offset += rows.length; - if (rows.length < BATCH) break; - } - - res.end(); - - // Reading the whole trail is itself worth recording. - const { ipAddress, userAgent } = getRequestMeta(req); - void logAudit({ - userId: authReq.userId!, - username: await getAuditUsername(authReq.userId!), - action: "export_audit_logs", - resourceType: "audit_log", - details: JSON.stringify({ format, exported, filters }), - ipAddress, - userAgent, - success: true, - }); - } catch (err) { - apiLogger.error("Failed to export audit logs", err); - if (!res.headersSent) { - return res.status(500).json({ error: "Failed to export audit logs" }); - } - res.end(); - } - }); } diff --git a/src/backend/database/routes/branding-routes.ts b/src/backend/database/routes/branding-routes.ts index da30d147a..7be688ac5 100644 --- a/src/backend/database/routes/branding-routes.ts +++ b/src/backend/database/routes/branding-routes.ts @@ -1,98 +1,9 @@ -import type { Request, RequestHandler, Response, Router } from "express"; +import type { Request, Response, Router } from "express"; import { databaseLogger } from "../../utils/logger.js"; import { createCurrentSettingsRepository } from "../repositories/factory.js"; -import { - BRANDING_KEYS, - parseBrandingAppName, - parseBrandingLogoDataUrl, - parseBrandingTagline, - resolveBrandingSettings, -} from "./branding-settings.js"; +import { resolveBrandingSettings } from "./branding-settings.js"; -const PATCHABLE_FIELDS = ["appName", "tagline", "logo"] as const; -type PatchableField = (typeof PATCHABLE_FIELDS)[number]; - -function invalidField(res: Response, field: string): void { - res.status(400).json({ - error: `Invalid value for ${field}`, - code: "BRANDING_SETTINGS_INVALID", - field, - }); -} - -/** - * Validates a PATCH body and returns the settings-table writes/deletes it - * implies, or null after the response has already been failed with a 400. - * `logo: null` clears the custom logo (falls back to the bundled default). - */ -async function buildBrandingWrites( - body: unknown, - res: Response, -): Promise<{ - writes: Array<{ key: string; value: string }>; - deletes: string[]; -} | null> { - if (typeof body !== "object" || body === null || Array.isArray(body)) { - res.status(400).json({ - error: "Invalid request body", - code: "BRANDING_SETTINGS_INVALID", - }); - return null; - } - - for (const field of Object.keys(body)) { - if (!(PATCHABLE_FIELDS as readonly string[]).includes(field)) { - res.status(400).json({ - error: `Unknown setting field: ${field}`, - code: "BRANDING_SETTINGS_UNKNOWN_FIELD", - field, - }); - return null; - } - } - - const input = body as Partial>; - const writes: Array<{ key: string; value: string }> = []; - const deletes: string[] = []; - - if (input.appName !== undefined) { - const appName = parseBrandingAppName(input.appName); - if (appName === null) { - invalidField(res, "appName"); - return null; - } - writes.push({ key: BRANDING_KEYS.appName, value: appName }); - } - - if (input.tagline !== undefined) { - const tagline = parseBrandingTagline(input.tagline); - if (tagline === null) { - invalidField(res, "tagline"); - return null; - } - writes.push({ key: BRANDING_KEYS.tagline, value: tagline }); - } - - if (input.logo !== undefined) { - if (input.logo === null) { - deletes.push(BRANDING_KEYS.logo); - } else { - const logo = await parseBrandingLogoDataUrl(input.logo); - if (logo === null) { - invalidField(res, "logo"); - return null; - } - writes.push({ key: BRANDING_KEYS.logo, value: logo }); - } - } - - return { writes, deletes }; -} - -export function registerBrandingRoutes( - router: Router, - requireAdmin: RequestHandler, -): void { +export function registerBrandingRoutes(router: Router): void { /** * @openapi * /users/branding: @@ -118,48 +29,4 @@ export function registerBrandingRoutes( res.status(500).json({ error: "Failed to load branding settings" }); } }); - - /** - * @openapi - * /users/branding: - * patch: - * summary: Update white-label branding (admin only) - * description: Persists a partial update to appName, tagline and/or logo (a PNG/JPEG/WEBP data URL; pass null to clear the logo). Invalid values are rejected with a 400. - * tags: - * - Users - * responses: - * 200: - * description: Updated effective branding settings. - * 400: - * description: Invalid or unknown setting field. - * 401: - * description: Not authenticated. - * 403: - * description: Admin access required. - * 500: - * description: Failed to save branding settings. - */ - router.patch( - "/branding", - requireAdmin, - async (req: Request, res: Response) => { - const result = await buildBrandingWrites(req.body, res); - if (result === null) return; - - try { - const settings = createCurrentSettingsRepository(); - if (result.writes.length > 0) { - await settings.setMany(result.writes); - } - for (const key of result.deletes) { - await settings.delete(key); - } - const resolved = await resolveBrandingSettings(settings); - res.json(resolved); - } catch (err) { - databaseLogger.error("Failed to save branding settings", err); - res.status(500).json({ error: "Failed to save branding settings" }); - } - }, - ); } diff --git a/src/backend/database/routes/branding-settings.ts b/src/backend/database/routes/branding-settings.ts index 883816587..7a491d036 100644 --- a/src/backend/database/routes/branding-settings.ts +++ b/src/backend/database/routes/branding-settings.ts @@ -1,5 +1,4 @@ -import sharp from "sharp"; -import { databaseLogger } from "../../utils/logger.js"; +import { consumeByName } from "../../plugins/registry.js"; /** * White-label branding: admin-configurable app name, logo and tagline shown @@ -20,25 +19,33 @@ export const BRANDING_KEYS = { } as const; export const BRANDING_DEFAULT_APP_NAME = "Termix"; -const APP_NAME_MAX_LENGTH = 60; -const TAGLINE_MAX_LENGTH = 160; - -/** - * Cap applies to the decoded image bytes, not the base64 text. The settings - * table is fully cached in memory per backend process (see - * settings-cache.ts), so an oversized logo would be permanently resident RAM - * on every instance, not just a one-time upload cost. - */ -export const BRANDING_LOGO_MAX_BYTES = 750 * 1024; -const BRANDING_LOGO_MAX_INPUT_PIXELS = 20_000_000; interface SettingSource { get(key: string): Promise; } +export const BRANDING_PROVIDER_NAME = "brandingProvider"; + +/** A plugin supplies branding by providing ".brandingProvider". */ +export type BrandingProvider = () => + Promise | BrandingSettings; + export async function resolveBrandingSettings( settings: SettingSource, ): Promise { + const provider = consumeByName(BRANDING_PROVIDER_NAME); + if (provider) { + try { + const provided = await provider(); + return { + appName: provided.appName?.trim() || BRANDING_DEFAULT_APP_NAME, + tagline: provided.tagline ?? "", + logo: provided.logo || null, + }; + } catch { + // A broken provider falls back to the stored settings. + } + } const [appName, tagline, logo] = await Promise.all([ settings.get(BRANDING_KEYS.appName), settings.get(BRANDING_KEYS.tagline), @@ -53,74 +60,3 @@ export async function resolveBrandingSettings( } /** Trimmed, 1-60 chars. Falls back to the default when cleared. */ -export function parseBrandingAppName(value: unknown): string | null { - if (typeof value !== "string") return null; - const trimmed = value.trim(); - if (trimmed.length === 0) return BRANDING_DEFAULT_APP_NAME; - if (trimmed.length > APP_NAME_MAX_LENGTH) return null; - return trimmed; -} - -/** Trimmed, 0-160 chars. An empty string means "use the built-in default". */ -export function parseBrandingTagline(value: unknown): string | null { - if (typeof value !== "string") return null; - const trimmed = value.trim(); - if (trimmed.length > TAGLINE_MAX_LENGTH) return null; - return trimmed; -} - -/** - * Validates and re-encodes an uploaded logo. Only raster formats are - * accepted (no SVG), sidestepping SVG-script XSS entirely rather than trying - * to sanitize markup. The image is always re-encoded to PNG through sharp: - * this both proves the payload is a genuine image (not a disguised file - * behind a spoofed data-URL prefix) and strips any embedded metadata. - * - * Returns null on any validation failure; the caller is expected to reject - * the request with a 400 without echoing back details of what was rejected. - */ -export async function parseBrandingLogoDataUrl( - value: unknown, -): Promise { - if (typeof value !== "string") return null; - const match = value.match( - /^data:image\/(png|jpe?g|webp);base64,([a-zA-Z0-9+/]+=*)$/, - ); - if (!match) return null; - - const [, , base64] = match; - let buffer: Buffer; - try { - buffer = Buffer.from(base64, "base64"); - } catch { - return null; - } - if (buffer.length === 0 || buffer.length > BRANDING_LOGO_MAX_BYTES * 4) { - // Reject wildly oversized input before decoding it with sharp; the final - // size check happens below on the re-encoded PNG. - return null; - } - - try { - const source = sharp(buffer, { - failOn: "error", - limitInputPixels: BRANDING_LOGO_MAX_INPUT_PIXELS, - }); - const metadata = await source.metadata(); - if ( - !metadata.format || - !["png", "jpeg", "webp"].includes(metadata.format) - ) { - return null; - } - const normalized = await source.rotate().png().toBuffer(); - if (normalized.length > BRANDING_LOGO_MAX_BYTES) return null; - return `data:image/png;base64,${normalized.toString("base64")}`; - } catch (err) { - databaseLogger.warn("Rejected invalid branding logo upload", { - operation: "branding_logo_invalid", - error: err instanceof Error ? err.message : String(err), - }); - return null; - } -} diff --git a/src/backend/database/routes/credential-deploy-routes.ts b/src/backend/database/routes/credential-deploy-routes.ts deleted file mode 100644 index 1af7c45fd..000000000 --- a/src/backend/database/routes/credential-deploy-routes.ts +++ /dev/null @@ -1,436 +0,0 @@ -import { getErrorMessage } from "../../utils/error-message.js"; -import type { - AuthenticatedRequest, - CredentialBackend, -} from "../../../types/index.js"; -import type { Request, RequestHandler, Response, Router } from "express"; -import type { Client as SshClient } from "ssh2"; -import { createCurrentHostResolutionRepository } from "../repositories/factory.js"; -import { connectHost } from "../../hosts/connect/connect-host.js"; -import type { SshConnectHost } from "../../hosts/connect/types.js"; -import { resolveHostById } from "../../hosts/host-resolver.js"; -import { isSecretReference } from "../../hosts/external-secrets.js"; -import { resolveKeyReferences } from "../../hosts/stored-ssh-key.js"; -import { parseSSHKey } from "../../utils/ssh-key-utils.js"; - -function describeDeployError(err: unknown): string { - const message = getErrorMessage(err, "Connection failed"); - if (message.includes("All configured authentication methods failed")) { - return "Authentication failed. Please check your credentials and ensure the SSH service is running."; - } - if (message.includes("ENOTFOUND") || message.includes("ENOENT")) { - return "Could not resolve hostname or connect to server."; - } - if (message.includes("ECONNREFUSED")) { - return "Connection refused. The server may not be running or the port may be incorrect."; - } - if (message.includes("ETIMEDOUT")) { - return "Connection timed out. Check your network connection and server availability."; - } - if ( - message.includes("authentication failed") || - message.includes("Permission denied") - ) { - return "Authentication failed. Please check your username and password/key."; - } - if (message === "SSH connection timeout") return "Connection timeout"; - return message; -} - -async function deploySSHKeyToHost( - host: SshConnectHost, - userId: string, - credData: CredentialBackend, -): Promise<{ success: boolean; message?: string; error?: string }> { - const publicKey = credData.publicKey as string; - - let conn: SshClient; - try { - ({ client: conn } = await connectHost(host, { - userId, - purpose: "credential-deploy", - timeoutMs: 120000, - })); - } catch (error) { - return { success: false, error: describeDeployError(error) }; - } - - return new Promise((resolve) => { - void (async () => { - try { - await new Promise((resolveCmd, rejectCmd) => { - const cmdTimeout = setTimeout(() => { - rejectCmd(new Error("mkdir command timeout")); - }, 10000); - - conn.exec( - "test -d ~/.ssh || mkdir -p ~/.ssh; chmod 700 ~/.ssh", - (err, stream) => { - if (err) { - clearTimeout(cmdTimeout); - return rejectCmd(err); - } - - stream.on("close", (code) => { - clearTimeout(cmdTimeout); - if (code === 0) { - resolveCmd(); - } else { - rejectCmd( - new Error(`mkdir command failed with code ${code}`), - ); - } - }); - - stream.on("data", () => { - // Ignore output - }); - }, - ); - }); - - const keyExists = await new Promise( - (resolveCheck, rejectCheck) => { - const checkTimeout = setTimeout(() => { - rejectCheck(new Error("Key check timeout")); - }, 5000); - - let actualPublicKey = publicKey; - try { - const parsed = JSON.parse(publicKey); - if (parsed.data) { - actualPublicKey = parsed.data; - } - } catch { - // Ignore parse errors - } - - const keyParts = actualPublicKey.trim().split(" "); - if (keyParts.length < 2) { - clearTimeout(checkTimeout); - return rejectCheck( - new Error( - "Invalid public key format - must contain at least 2 parts", - ), - ); - } - - const keyPattern = keyParts[1]; - if (!/^[A-Za-z0-9+/]+={0,2}$/.test(keyPattern)) { - clearTimeout(checkTimeout); - return rejectCheck(new Error("Invalid public key data")); - } - - conn.exec( - `if [ -f ~/.ssh/authorized_keys ]; then grep -F "${keyPattern}" ~/.ssh/authorized_keys >/dev/null 2>&1; echo $?; else echo 1; fi`, - (err, stream) => { - if (err) { - clearTimeout(checkTimeout); - return rejectCheck(err); - } - - let output = ""; - stream.on("data", (data) => { - output += data.toString(); - }); - - stream.on("close", () => { - clearTimeout(checkTimeout); - const exists = output.trim() === "0"; - resolveCheck(exists); - }); - }, - ); - }, - ); - - if (keyExists) { - conn.end(); - resolve({ success: true, message: "SSH key already deployed" }); - return; - } - - await new Promise((resolveAdd, rejectAdd) => { - const addTimeout = setTimeout(() => { - rejectAdd(new Error("Key add timeout")); - }, 30000); - - let actualPublicKey = publicKey; - try { - const parsed = JSON.parse(publicKey); - if (parsed.data) { - actualPublicKey = parsed.data; - } - } catch { - // Ignore parse errors - } - - const escapedKey = actualPublicKey - .replace(/\\/g, "\\\\") - .replace(/'/g, "'\\''"); - const escapedName = credData.name - .replace(/\\/g, "\\\\") - .replace(/'/g, "'\\''"); - - conn.exec( - `printf '%s\n' '${escapedKey} ${escapedName}@Termix' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys`, - (err, stream) => { - if (err) { - clearTimeout(addTimeout); - return rejectAdd(err); - } - - stream.on("data", () => { - // Consume output - }); - - stream.on("close", (code) => { - clearTimeout(addTimeout); - if (code === 0) { - resolveAdd(); - } else { - rejectAdd( - new Error(`Key deployment failed with code ${code}`), - ); - } - }); - }, - ); - }); - - const verifySuccess = await new Promise( - (resolveVerify, rejectVerify) => { - const verifyTimeout = setTimeout(() => { - rejectVerify(new Error("Key verification timeout")); - }, 5000); - - let actualPublicKey = publicKey; - try { - const parsed = JSON.parse(publicKey); - if (parsed.data) { - actualPublicKey = parsed.data; - } - } catch { - // Ignore parse errors - } - - const keyParts = actualPublicKey.trim().split(" "); - if (keyParts.length < 2) { - clearTimeout(verifyTimeout); - return rejectVerify( - new Error( - "Invalid public key format - must contain at least 2 parts", - ), - ); - } - - const keyPattern = keyParts[1]; - if (!/^[A-Za-z0-9+/]+={0,2}$/.test(keyPattern)) { - clearTimeout(verifyTimeout); - return rejectVerify(new Error("Invalid public key data")); - } - conn.exec( - `grep -F "${keyPattern}" ~/.ssh/authorized_keys >/dev/null 2>&1; echo $?`, - (err, stream) => { - if (err) { - clearTimeout(verifyTimeout); - return rejectVerify(err); - } - - let output = ""; - stream.on("data", (data) => { - output += data.toString(); - }); - - stream.on("close", () => { - clearTimeout(verifyTimeout); - const verified = output.trim() === "0"; - resolveVerify(verified); - }); - }, - ); - }, - ); - - conn.end(); - - if (verifySuccess) { - resolve({ success: true, message: "SSH key deployed successfully" }); - } else { - resolve({ - success: false, - error: "Key deployment verification failed", - }); - } - } catch (error) { - conn.end(); - resolve({ - success: false, - error: getErrorMessage(error, "Deployment failed"), - }); - } - })(); - }); -} - -export function registerCredentialDeployRoutes( - router: Router, - authenticateJWT: RequestHandler, - requireCredentialViewPermission: RequestHandler, - requireHostEditPermission: RequestHandler, - requireDataAccess: RequestHandler, -): void { - /** - * @openapi - * /credentials/{id}/deploy-to-host: - * post: - * summary: Deploy SSH key to a host - * description: Deploys an SSH public key to a target host's authorized_keys file. - * tags: - * - Credentials - * parameters: - * - in: path - * name: id - * required: true - * schema: - * type: integer - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * properties: - * targetHostId: - * type: integer - * responses: - * 200: - * description: SSH key deployed successfully. - * 400: - * description: Credential ID and target host ID are required. - * 401: - * description: Authentication required. - * 404: - * description: Credential or target host not found. - * 500: - * description: Failed to deploy SSH key. - */ - router.post( - "/:id/deploy-to-host", - authenticateJWT, - requireCredentialViewPermission, - requireHostEditPermission, - requireDataAccess, - async (req: Request, res: Response) => { - const id = Array.isArray(req.params.id) - ? req.params.id[0] - : req.params.id; - const credentialId = parseInt(id); - const { targetHostId } = req.body; - - if (!credentialId || !targetHostId) { - return res.status(400).json({ - success: false, - error: "Credential ID and target host ID are required", - }); - } - - try { - const userId = (req as AuthenticatedRequest).userId; - if (!userId) { - return res.status(401).json({ - success: false, - error: "Authentication required", - }); - } - - const repository = createCurrentHostResolutionRepository(); - const credential = await repository.findCredentialByIdForUser( - credentialId, - userId, - ); - - if (!credential) { - return res.status(404).json({ - success: false, - error: "Credential not found", - }); - } - - const credData = credential as unknown as CredentialBackend; - - if (credData.authType !== "key") { - return res.status(400).json({ - success: false, - error: "Only SSH key-based credentials can be deployed", - }); - } - - let publicKey = credData.publicKey; - const storedKey = credData.privateKey || credData.key; - if (!publicKey && isSecretReference(storedKey)) { - // A key kept in a secret source has no stored public key; derive - // it from the resolved key. - const resolved = await resolveKeyReferences( - userId, - storedKey, - (credData as { keyPassword?: string | null }).keyPassword, - ); - publicKey = - parseSSHKey(resolved.key, resolved.passphrase).publicKey || - undefined; - } - if (!publicKey) { - return res.status(400).json({ - success: false, - error: "Public key is required for deployment", - }); - } - // Writing authorized_keys is an owner action, so ownership is checked - // before the shared resolver fills in the credentials. - const hostData = await repository.findHostByIdForUser( - targetHostId, - userId, - ); - - if (!hostData) { - return res.status(404).json({ - success: false, - error: "Target host not found", - }); - } - - const resolvedHost = await resolveHostById(targetHostId, userId); - if (!resolvedHost) { - return res.status(400).json({ - success: false, - error: "Host credential not found", - }); - } - - const deployResult = await deploySSHKeyToHost( - resolvedHost as unknown as SshConnectHost, - userId, - credData, - ); - - if (deployResult.success) { - res.json({ - success: true, - message: deployResult.message || "SSH key deployed successfully", - }); - } else { - res.status(500).json({ - success: false, - error: deployResult.error || "Deployment failed", - }); - } - } catch (error) { - res.status(500).json({ - success: false, - error: getErrorMessage(error, "Failed to deploy SSH key"), - }); - } - }, - ); -} diff --git a/src/backend/database/routes/credential-key-routes.ts b/src/backend/database/routes/credential-key-routes.ts deleted file mode 100644 index 746095d56..000000000 --- a/src/backend/database/routes/credential-key-routes.ts +++ /dev/null @@ -1,519 +0,0 @@ -import { getErrorMessage } from "../../utils/error-message.js"; -import type { Request, RequestHandler, Response, Router } from "express"; -import crypto from "crypto"; -import ssh2Pkg from "ssh2"; -import { authLogger } from "../../utils/logger.js"; -import { - parsePublicKey, - parseSSHKey, - validateKeyPair, -} from "../../utils/ssh-key-utils.js"; -import { resolveKeyReferences } from "../../hosts/stored-ssh-key.js"; -import type { AuthenticatedRequest } from "../../../types/index.js"; - -const { utils: ssh2Utils } = ssh2Pkg; - -function generateSSHKeyPair( - keyType: string, - keySize?: number, - passphrase?: string, -): { - success: boolean; - privateKey?: string; - publicKey?: string; - error?: string; -} { - try { - let ssh2Type = keyType; - const options: { - bits?: number; - passphrase?: string; - cipher?: string; - } = {}; - - if (keyType === "ssh-rsa") { - ssh2Type = "rsa"; - options.bits = keySize || 2048; - } else if (keyType === "ssh-ed25519") { - ssh2Type = "ed25519"; - } else if (keyType === "ecdsa-sha2-nistp256") { - ssh2Type = "ecdsa"; - options.bits = 256; - } - - if (passphrase && passphrase.trim()) { - options.passphrase = passphrase; - options.cipher = "aes128-cbc"; - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const keyPair = ssh2Utils.generateKeyPairSync(ssh2Type as any, options); - - return { - success: true, - privateKey: keyPair.private, - publicKey: keyPair.public, - }; - } catch (error) { - return { - success: false, - error: getErrorMessage(error, "SSH key generation failed"), - }; - } -} - -export function registerCredentialKeyRoutes( - router: Router, - authenticateJWT: RequestHandler, - requireCredentialViewPermission: RequestHandler, - requireDataAccess: RequestHandler, -): void { - /** - * @openapi - * /credentials/detect-key-type: - * post: - * summary: Detect SSH key type - * description: Detects the type of an SSH private key. - * tags: - * - Credentials - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * properties: - * privateKey: - * type: string - * keyPassword: - * type: string - * responses: - * 200: - * description: Key type detection result. - * 400: - * description: Private key is required. - * 500: - * description: Failed to detect key type. - */ - router.post( - "/detect-key-type", - authenticateJWT, - requireCredentialViewPermission, - requireDataAccess, - async (req: Request, res: Response) => { - if (!req.body?.privateKey || typeof req.body.privateKey !== "string") { - return res.status(400).json({ error: "Private key is required" }); - } - - try { - const { key: privateKey, passphrase: keyPassword } = - await resolveKeyReferences( - (req as AuthenticatedRequest).userId, - req.body.privateKey, - req.body.keyPassword, - ); - const keyInfo = parseSSHKey(privateKey, keyPassword); - - const response = { - success: keyInfo.success, - keyType: keyInfo.keyType, - detectedKeyType: keyInfo.keyType, - hasPublicKey: !!keyInfo.publicKey, - error: keyInfo.error || null, - }; - - res.json(response); - } catch (error) { - authLogger.error("Failed to detect key type", error); - res.status(500).json({ - error: getErrorMessage(error, "Failed to detect key type"), - }); - } - }, - ); - - /** - * @openapi - * /credentials/detect-public-key-type: - * post: - * summary: Detect SSH public key type - * description: Detects the type of an SSH public key. - * tags: - * - Credentials - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * properties: - * publicKey: - * type: string - * responses: - * 200: - * description: Key type detection result. - * 400: - * description: Public key is required. - * 500: - * description: Failed to detect public key type. - */ - router.post( - "/detect-public-key-type", - authenticateJWT, - requireCredentialViewPermission, - requireDataAccess, - async (req: Request, res: Response) => { - const { publicKey } = req.body; - - if (!publicKey || typeof publicKey !== "string") { - return res.status(400).json({ error: "Public key is required" }); - } - - try { - const keyInfo = parsePublicKey(publicKey); - - const response = { - success: keyInfo.success, - keyType: keyInfo.keyType, - detectedKeyType: keyInfo.keyType, - error: keyInfo.error || null, - }; - - res.json(response); - } catch (error) { - authLogger.error("Failed to detect public key type", error); - res.status(500).json({ - error: getErrorMessage(error, "Failed to detect public key type"), - }); - } - }, - ); - - /** - * @openapi - * /credentials/validate-key-pair: - * post: - * summary: Validate SSH key pair - * description: Validates if a given SSH private key and public key match. - * tags: - * - Credentials - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * properties: - * privateKey: - * type: string - * publicKey: - * type: string - * keyPassword: - * type: string - * responses: - * 200: - * description: Key pair validation result. - * 400: - * description: Private key and public key are required. - * 500: - * description: Failed to validate key pair. - */ - router.post( - "/validate-key-pair", - authenticateJWT, - requireCredentialViewPermission, - requireDataAccess, - async (req: Request, res: Response) => { - const { privateKey, publicKey, keyPassword } = req.body; - - if (!privateKey || typeof privateKey !== "string") { - return res.status(400).json({ error: "Private key is required" }); - } - - if (!publicKey || typeof publicKey !== "string") { - return res.status(400).json({ error: "Public key is required" }); - } - - try { - const validationResult = validateKeyPair( - privateKey, - publicKey, - keyPassword, - ); - - const response = { - isValid: validationResult.isValid, - privateKeyType: validationResult.privateKeyType, - publicKeyType: validationResult.publicKeyType, - generatedPublicKey: validationResult.generatedPublicKey, - error: validationResult.error || null, - }; - - res.json(response); - } catch (error) { - authLogger.error("Failed to validate key pair", error); - res.status(500).json({ - error: getErrorMessage(error, "Failed to validate key pair"), - }); - } - }, - ); - - /** - * @openapi - * /credentials/generate-key-pair: - * post: - * summary: Generate new SSH key pair - * description: Generates a new SSH key pair. - * tags: - * - Credentials - * requestBody: - * content: - * application/json: - * schema: - * type: object - * properties: - * keyType: - * type: string - * keySize: - * type: integer - * passphrase: - * type: string - * responses: - * 200: - * description: The new key pair. - * 500: - * description: Failed to generate SSH key pair. - */ - router.post( - "/generate-key-pair", - authenticateJWT, - requireCredentialViewPermission, - requireDataAccess, - async (req: Request, res: Response) => { - const { keyType = "ssh-ed25519", keySize = 2048, passphrase } = req.body; - - try { - const result = generateSSHKeyPair(keyType, keySize, passphrase); - - if (result.success && result.privateKey && result.publicKey) { - const response = { - success: true, - privateKey: result.privateKey, - publicKey: result.publicKey, - keyType: keyType, - format: "ssh", - algorithm: keyType, - keySize: keyType === "ssh-rsa" ? keySize : undefined, - curve: keyType === "ecdsa-sha2-nistp256" ? "nistp256" : undefined, - }; - - res.json(response); - } else { - res.status(500).json({ - success: false, - error: result.error || "Failed to generate SSH key pair", - }); - } - } catch (error) { - authLogger.error("Failed to generate key pair", error); - res.status(500).json({ - success: false, - error: getErrorMessage(error, "Failed to generate key pair"), - }); - } - }, - ); - - /** - * @openapi - * /credentials/generate-public-key: - * post: - * summary: Generate public key from private key - * description: Generates a public key from a given private key. - * tags: - * - Credentials - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * properties: - * privateKey: - * type: string - * keyPassword: - * type: string - * responses: - * 200: - * description: The generated public key. - * 400: - * description: Private key is required. - * 500: - * description: Failed to generate public key. - */ - router.post( - "/generate-public-key", - authenticateJWT, - requireCredentialViewPermission, - requireDataAccess, - async (req: Request, res: Response) => { - if (!req.body?.privateKey || typeof req.body.privateKey !== "string") { - return res.status(400).json({ error: "Private key is required" }); - } - - try { - const { key: privateKey, passphrase: keyPassword } = - await resolveKeyReferences( - (req as AuthenticatedRequest).userId, - req.body.privateKey, - req.body.keyPassword, - ); - let privateKeyObj; - const parseAttempts = []; - - try { - privateKeyObj = crypto.createPrivateKey({ - key: privateKey, - passphrase: keyPassword, - }); - } catch (error) { - parseAttempts.push(`Method 1 (with passphrase): ${error.message}`); - } - - if (!privateKeyObj) { - try { - privateKeyObj = crypto.createPrivateKey(privateKey); - } catch (error) { - parseAttempts.push( - `Method 2 (without passphrase): ${error.message}`, - ); - } - } - - if (!privateKeyObj) { - try { - privateKeyObj = crypto.createPrivateKey({ - key: privateKey, - format: "pem", - type: "pkcs8", - }); - } catch (error) { - parseAttempts.push(`Method 3 (PKCS#8): ${error.message}`); - } - } - - if ( - !privateKeyObj && - privateKey.includes("-----BEGIN RSA PRIVATE KEY-----") - ) { - try { - privateKeyObj = crypto.createPrivateKey({ - key: privateKey, - format: "pem", - type: "pkcs1", - }); - } catch (error) { - parseAttempts.push(`Method 4 (PKCS#1): ${error.message}`); - } - } - - if ( - !privateKeyObj && - privateKey.includes("-----BEGIN EC PRIVATE KEY-----") - ) { - try { - privateKeyObj = crypto.createPrivateKey({ - key: privateKey, - format: "pem", - type: "sec1", - }); - } catch (error) { - parseAttempts.push(`Method 5 (SEC1): ${error.message}`); - } - } - - if (!privateKeyObj) { - try { - const keyInfo = parseSSHKey(privateKey, keyPassword); - - if (keyInfo.success && keyInfo.publicKey) { - const publicKeyString = String(keyInfo.publicKey); - return res.json({ - success: true, - publicKey: publicKeyString, - keyType: keyInfo.keyType, - }); - } else { - parseAttempts.push( - `SSH2 fallback: ${keyInfo.error || "No public key generated"}`, - ); - } - } catch (error) { - parseAttempts.push(`SSH2 fallback exception: ${error.message}`); - } - } - - if (!privateKeyObj) { - return res.status(400).json({ - success: false, - error: "Unable to parse private key. Tried multiple formats.", - details: parseAttempts, - }); - } - - const publicKeyObj = crypto.createPublicKey(privateKeyObj); - const publicKeyPem = publicKeyObj.export({ - type: "spki", - format: "pem", - }); - - const publicKeyString = - typeof publicKeyPem === "string" - ? publicKeyPem - : (publicKeyPem as Buffer).toString("utf8"); - - let keyType = "unknown"; - const asymmetricKeyType = privateKeyObj.asymmetricKeyType; - - if (asymmetricKeyType === "rsa") { - keyType = "ssh-rsa"; - } else if (asymmetricKeyType === "ed25519") { - keyType = "ssh-ed25519"; - } else if (asymmetricKeyType === "ec") { - keyType = "ecdsa-sha2-nistp256"; - } - - let finalPublicKey = publicKeyString; - let formatType = "pem"; - - try { - const ssh2PrivateKey = ssh2Utils.parseKey(privateKey, keyPassword); - if (!(ssh2PrivateKey instanceof Error)) { - const publicKeyBuffer = ssh2PrivateKey.getPublicSSH(); - const base64Data = publicKeyBuffer.toString("base64"); - finalPublicKey = `${keyType} ${base64Data}`; - formatType = "ssh"; - } - } catch { - // Ignore validation errors - } - - const response = { - success: true, - publicKey: finalPublicKey, - keyType: keyType, - format: formatType, - }; - - res.json(response); - } catch (error) { - authLogger.error("Failed to generate public key", error); - res.status(500).json({ - success: false, - error: getErrorMessage(error, "Failed to generate public key"), - }); - } - }, - ); -} diff --git a/src/backend/database/routes/credentials.ts b/src/backend/database/routes/credentials.ts index ae1d3777e..f8782c73a 100644 --- a/src/backend/database/routes/credentials.ts +++ b/src/backend/database/routes/credentials.ts @@ -8,8 +8,6 @@ import { authLogger } from "../../utils/logger.js"; import { PermissionManager } from "../../utils/permission-manager.js"; import { AuthManager } from "../../utils/auth-manager.js"; import { parseKeyForStorage } from "../../hosts/stored-ssh-key.js"; -import { registerCredentialKeyRoutes } from "./credential-key-routes.js"; -import { registerCredentialDeployRoutes } from "./credential-deploy-routes.js"; import { registerCredentialBulkRoutes } from "./credential-bulk-routes.js"; import { logAudit, @@ -1211,19 +1209,4 @@ router.put( }, ); -registerCredentialKeyRoutes( - router, - authenticateJWT, - permissionManager.requirePermission("credentials.view"), - requireDataAccess, -); - -registerCredentialDeployRoutes( - router, - authenticateJWT, - permissionManager.requirePermission("credentials.view"), - permissionManager.requirePermission("hosts.edit"), - requireDataAccess, -); - export default router; diff --git a/src/backend/database/routes/dashboard-routes.ts b/src/backend/database/routes/dashboard-routes.ts index 7e609e82a..ffb9debbd 100644 --- a/src/backend/database/routes/dashboard-routes.ts +++ b/src/backend/database/routes/dashboard-routes.ts @@ -3,8 +3,7 @@ import express, { type Request, type Response } from "express"; import { dashboardLogger } from "../../utils/logger.js"; import { AuthManager } from "../../utils/auth-manager.js"; import { DataCrypto } from "../../utils/data-crypto.js"; -import { createCurrentRecentActivityRepository } from "../repositories/factory.js"; -import { recordRecentActivity } from "../../services/recent-activity.js"; +import { recentActivity } from "../../plugins/activity-provider.js"; const router = express.Router(); @@ -99,11 +98,7 @@ router.get( const limit = Number(req.query.limit) || 20; - const activities = - await createCurrentRecentActivityRepository().listByUserId( - userId, - limit, - ); + const activities = await recentActivity.list(userId, limit); res.json(activities); } catch (err) { @@ -169,7 +164,7 @@ router.post( }); } - const result = await recordRecentActivity(userId, { + const result = await recentActivity.record(userId, { type, hostId, hostName, @@ -185,6 +180,11 @@ router.post( message: "Activity already logged recently (rate limited)", }); } + if (result.status === "unavailable") { + return res + .status(404) + .json({ error: "Recent activity is not available" }); + } if (result.status === "denied") { return res .status(404) @@ -222,7 +222,7 @@ router.delete( try { const userId = (req as AuthenticatedRequest).userId; - await createCurrentRecentActivityRepository().deleteByUserId(userId); + await recentActivity.deleteByUserId(userId); dashboardLogger.success("Recent activity cleared", { operation: "reset_recent_activity", diff --git a/src/backend/database/routes/delete-user-data.ts b/src/backend/database/routes/delete-user-data.ts index 03db9b78f..1457579a6 100644 --- a/src/backend/database/routes/delete-user-data.ts +++ b/src/backend/database/routes/delete-user-data.ts @@ -1,4 +1,5 @@ import { authLogger } from "../../utils/logger.js"; +import { recentActivity } from "../../plugins/activity-provider.js"; import { createCurrentApiKeyRepository, createCurrentAuditLogRepository, @@ -9,7 +10,6 @@ import { createCurrentCredentialSidebarPreferenceRepository, createCurrentUiPreferenceRepository, createCurrentOpenTabRepository, - createCurrentRecentActivityRepository, createCurrentRbacAccessRepository, createCurrentRoleRepository, createCurrentSessionRepository, @@ -64,7 +64,7 @@ export async function deleteUserAndRelatedData( await createCurrentSshCredentialUsageRepository().deleteByUserId(userId); - await createCurrentRecentActivityRepository().deleteByUserId(userId); + await recentActivity.deleteByUserId(userId); await createCurrentHostFolderRepository().deleteByUserId(userId); diff --git a/src/backend/database/routes/host-bulk-routes.ts b/src/backend/database/routes/host-bulk-routes.ts index 74814a6b3..c89df15f2 100644 --- a/src/backend/database/routes/host-bulk-routes.ts +++ b/src/backend/database/routes/host-bulk-routes.ts @@ -1,172 +1,22 @@ -import { - prepareHostImports, - remapImportedJumpHosts, -} from "./host-import-order.js"; -import { sshOptionsForWrite } from "../../hosts/ssh-options.js"; -import { - applyDefaultsAfterHostWrites, - applyHostDefaultsToWrite, -} from "../../hosts/defaults/index.js"; import { changeHostOverrides, type OverrideChange, } from "../../hosts/defaults/overrides.js"; import { recompute } from "../../hosts/defaults/recompute.js"; import { splitDefaultKey } from "../../../types/host-defaults.js"; -import { - keepUsableProtocolCredentials, - readProtocolAuthPayload, - writeProtocolAuth, -} from "../../hosts/protocol-auth/protocol-auth.js"; -import { getErrorMessage } from "../../utils/error-message.js"; + import type { AuthenticatedRequest } from "../../../types/index.js"; import type { Request, RequestHandler, Response, Router } from "express"; import { sshLogger } from "../../utils/logger.js"; import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js"; -import { pluginEvents, TOPICS } from "../../plugins/events.js"; -import { - createCurrentCredentialRepository, - createCurrentHostRepository, - createCurrentHostResolutionRepository, - createCurrentHostDefaultsRepository, -} from "../repositories/factory.js"; +import { createCurrentHostRepository } from "../repositories/factory.js"; import { validateParentHostId } from "./host-parent-validation.js"; -import { - listSshAuthProviders, - listSshAuthTypeOwners, -} from "../../hosts/connect/auth-provider-registry.js"; -import { - applyPluginHostImportSettings, - setHostPluginEnabled, -} from "./host-plugin-settings.js"; -import { - isNonEmptyString, - isValidPort, - normalizeImportedHost, -} from "./host-normalizers.js"; -type SSHConfigHost = { - name: string; - hostname?: string; - user?: string; - port?: number; - identityFile?: string; - proxyJump?: string; -}; - -type ShareCredential = { - alias?: unknown; - name?: unknown; - description?: unknown; - folder?: unknown; - tags?: unknown; - authType?: unknown; - username?: unknown; - keyType?: unknown; -}; - -function textValue(value: unknown): string | null { - return typeof value === "string" && value.trim() ? value.trim() : null; -} - -function tagString(value: unknown): string { - if (Array.isArray(value)) { - return value - .map((tag) => textValue(tag)) - .filter((tag): tag is string => !!tag) - .join(","); - } - return textValue(value) || ""; -} - -function normalizeCredentialAuthType(value: unknown): "password" | "key" { - return value === "key" ? "key" : "password"; -} - -export function parseSSHConfig(content: string): SSHConfigHost[] { - const results: SSHConfigHost[] = []; - let current: SSHConfigHost | null = null; - - for (const rawLine of content.split("\n")) { - const line = rawLine.trim(); - if (!line || line.startsWith("#")) continue; - - const match = /^(\S+?)(?:\s*=\s*|\s+)(.+)$/.exec(line); - if (!match) continue; - - const key = match[1].toLowerCase(); - const value = match[2]; - - if (key === "host") { - if (current && current.hostname) results.push(current); - // Skip wildcard patterns - if (value === "*" || value.includes("*") || value.includes("?")) { - current = null; - } else { - current = { name: value }; - } - continue; - } - - if (!current) continue; - - switch (key) { - case "hostname": - current.hostname = value; - break; - case "user": - current.user = value; - break; - case "port": { - const p = Number.parseInt(value, 10); - if (p > 0 && p <= 65535) current.port = p; - break; - } - case "identityfile": - if (!current.identityFile) current.identityFile = value; - break; - case "proxyjump": - current.proxyJump = value; - break; - } - } - - if (current && current.hostname) results.push(current); - - return results; -} - -/** - * Tells status checks and other pollers about hosts an import wrote, the way - * a single host create or update does. - */ -export function notifyImportedHosts( - userId: string, - hostIds: number[], - createdHostIds: number[], -): void { - for (const hostId of createdHostIds) { - pluginEvents.emit(TOPICS.hostCreated, { hostId, userId }); - } - for (const hostId of hostIds) { - pluginEvents.emit(TOPICS.hostUpdated, { hostId, userId }); - } -} - -export function importedHostUsername( - connectionType: string, - authType: unknown, - username: unknown, -): string | null { - if (isNonEmptyString(username)) return username; - if (connectionType !== "ssh" || authType === "credential") return ""; - return null; -} +import { setHostPluginEnabled } from "./host-plugin-settings.js"; export function registerHostBulkRoutes( router: Router, authenticateJWT: RequestHandler, - requireCreatePermission: RequestHandler, requireEditPermission: RequestHandler, requireDataAccess: RequestHandler, ): void { @@ -467,674 +317,6 @@ export function registerHostBulkRoutes( } }, ); - - router.post( - "/bulk-import", - authenticateJWT, - requireCreatePermission, - requireEditPermission, - requireDataAccess, - DatabaseSaveTrigger.batched(async (req: Request, res: Response) => { - const userId = (req as AuthenticatedRequest).userId; - const { - hosts: hostsToImport, - overwrite, - credentials: credentialsToImport, - } = req.body; - - if (!Array.isArray(hostsToImport) || hostsToImport.length === 0) { - return res - .status(400) - .json({ error: "Hosts array is required and must not be empty" }); - } - - if (hostsToImport.length > 100) { - return res - .status(400) - .json({ error: "Maximum 100 hosts allowed per import" }); - } - - let orderedHosts: ReturnType; - try { - orderedHosts = prepareHostImports(hostsToImport); - } catch (error) { - return res.status(400).json({ error: getErrorMessage(error) }); - } - const importedIds = new Map(); - - const results = { - success: 0, - updated: 0, - skipped: 0, - failed: 0, - errors: [] as string[], - }; - - const credentialAliasMap = new Map(); - const addCredentialAlias = (alias: unknown, id: number) => { - const key = textValue(alias); - if (key) credentialAliasMap.set(key.toLowerCase(), id); - }; - - try { - const credentialRepository = createCurrentCredentialRepository(); - const existingCredentials = - await credentialRepository.listDecryptedByUserId(userId); - - for (const credential of existingCredentials) { - addCredentialAlias(credential.name, credential.id as number); - } - - if (Array.isArray(credentialsToImport)) { - for (const rawCredential of credentialsToImport as ShareCredential[]) { - const alias = textValue(rawCredential.alias); - const name = textValue(rawCredential.name) || alias; - if (!alias || !name) continue; - - const existingId = credentialAliasMap.get(name.toLowerCase()); - if (existingId) { - addCredentialAlias(alias, existingId); - continue; - } - - const now = new Date().toISOString(); - const created = await credentialRepository.createEncryptedForUser( - userId, - { - userId, - name, - description: - textValue(rawCredential.description) || - "Imported placeholder. Add the secret before connecting.", - folder: textValue(rawCredential.folder), - tags: tagString(rawCredential.tags), - authType: normalizeCredentialAuthType(rawCredential.authType), - username: textValue(rawCredential.username), - password: null, - key: null, - privateKey: null, - publicKey: null, - keyPassword: null, - keyType: textValue(rawCredential.keyType), - detectedKeyType: null, - usageCount: 0, - lastUsed: null, - createdAt: now, - updatedAt: now, - }, - ); - - const createdCredential = created as Record; - addCredentialAlias(alias, createdCredential.id as number); - addCredentialAlias(name, createdCredential.id as number); - } - } - } catch (error) { - results.errors.push( - `Credential placeholders: ${getErrorMessage(error, "failed to prepare credential aliases")}`, - ); - } - - // Loaded for both modes: overwrite updates a match, skip leaves it alone. - let existingHostMap: Map | undefined; - const hostRepository = createCurrentHostRepository(); - try { - const allHosts = - await createCurrentHostResolutionRepository().findHostsByUserId( - userId, - ); - existingHostMap = new Map(); - for (const h of allHosts) { - const key = `${h.ip}:${h.port}:${h.username}`; - existingHostMap.set(key, { id: h.id as number }); - } - } catch { - existingHostMap = undefined; - } - - const knownAuthTypes = listKnownAuthTypes(); - const writtenHostIds: number[] = []; - const createdHostIds: number[] = []; - for (const { host: hostData, index: i, exportId } of orderedHosts) { - try { - const effectiveConnectionType = hostData.connectionType || "ssh"; - - if ( - effectiveConnectionType === "ssh" && - hostData.authType === "credential" && - !hostData.credentialId && - hostData.credentialAlias - ) { - hostData.credentialId = credentialAliasMap.get( - hostData.credentialAlias.toLowerCase(), - ); - } - - if (!isNonEmptyString(hostData.ip) || !isValidPort(hostData.port)) { - results.failed++; - results.errors.push( - `Host ${i + 1}: Missing required fields (ip, port)`, - ); - continue; - } - - const username = importedHostUsername( - effectiveConnectionType, - hostData.authType, - hostData.username, - ); - if (username === null) { - results.failed++; - results.errors.push( - `Host ${i + 1}: Username required for SSH connections`, - ); - continue; - } - - const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`; - const existing = existingHostMap?.get(lookupKey); - if (existing && !overwrite) { - // Hosts that jump through this one still resolve to the kept host. - if (exportId !== undefined) importedIds.set(exportId, existing.id); - results.skipped++; - continue; - } - - if ( - effectiveConnectionType === "ssh" && - hostData.authType && - !knownAuthTypes.has(hostData.authType) - ) { - results.failed++; - results.errors.push( - `Host ${i + 1}: Invalid authType. Must be one of ${[...knownAuthTypes].join(", ")}`, - ); - continue; - } - - if ( - effectiveConnectionType === "ssh" && - hostData.authType === "password" && - !isNonEmptyString(hostData.password) - ) { - results.failed++; - results.errors.push( - `Host ${i + 1}: Password required for password authentication`, - ); - continue; - } - - if ( - effectiveConnectionType === "ssh" && - hostData.authType === "key" && - !isNonEmptyString(hostData.key) - ) { - results.failed++; - results.errors.push( - `Host ${i + 1}: Key required for key authentication`, - ); - continue; - } - - if ( - effectiveConnectionType === "ssh" && - hostData.authType === "credential" && - !hostData.credentialId - ) { - results.failed++; - results.errors.push( - `Host ${i + 1}: credentialId required for credential authentication`, - ); - continue; - } - - if ( - effectiveConnectionType === "ssh" && - hostData.authType === "credential" && - hostData.credentialId - ) { - const credentialRepository = createCurrentCredentialRepository(); - const cred = await credentialRepository.findByIdForUser( - userId, - hostData.credentialId, - ); - - if (!cred) { - const fallback = await credentialRepository.listByUserId(userId); - - if (fallback.length > 0) { - hostData.credentialId = fallback[0].id; - } else if (isNonEmptyString(hostData.key)) { - hostData.authType = "key"; - hostData.credentialId = undefined; - } else if (isNonEmptyString(hostData.password)) { - hostData.authType = "password"; - hostData.credentialId = undefined; - } else { - results.failed++; - results.errors.push( - `Host ${i + 1}: credentialId ${hostData.credentialId} not found and no fallback credential available`, - ); - continue; - } - } - } - - const jumpHosts = remapImportedJumpHosts( - hostData.jumpHosts, - importedIds, - ); - const sshDataObj: Record = { - userId: userId, - connectionType: effectiveConnectionType, - name: hostData.name || `${username}@${hostData.ip}`, - folder: hostData.folder || "Default", - tags: Array.isArray(hostData.tags) ? hostData.tags.join(",") : "", - ip: hostData.ip, - port: hostData.port, - username, - pin: hostData.pin || false, - sudoPassword: hostData.sudoPassword || null, - jumpHosts: jumpHosts ? JSON.stringify(jumpHosts) : null, - ...importedStatusCheck(hostData as Record), - // A 2.8 export carries these inside terminalConfig. - sshOptions: - sshOptionsForWrite({ - sshOptions: hostData.sshOptions, - terminalConfig: hostData.terminalConfig, - }) ?? null, - forceKeyboardInteractive: hostData.forceKeyboardInteractive - ? "true" - : "false", - notes: hostData.notes || null, - useSocks5: hostData.useSocks5 ? 1 : 0, - socks5Host: hostData.socks5Host || null, - socks5Port: hostData.socks5Port || null, - socks5Username: hostData.socks5Username || null, - socks5Password: hostData.socks5Password || null, - socks5ProxyChain: hostData.socks5ProxyChain - ? JSON.stringify(hostData.socks5ProxyChain) - : null, - portKnockSequence: hostData.portKnockSequence - ? JSON.stringify(hostData.portKnockSequence) - : null, - overrideCredentialUsername: hostData.overrideCredentialUsername - ? 1 - : 0, - enableSsh: hostData.enableSsh ?? effectiveConnectionType === "ssh", - updatedAt: new Date().toISOString(), - }; - - if (effectiveConnectionType !== "ssh") { - sshDataObj.password = hostData.password || null; - sshDataObj.authType = "password"; - sshDataObj.credentialId = null; - sshDataObj.key = null; - sshDataObj.keyPassword = null; - sshDataObj.keyType = null; - } else { - sshDataObj.password = - hostData.authType === "password" ? hostData.password : null; - sshDataObj.authType = hostData.authType || "password"; - sshDataObj.credentialId = - hostData.authType === "credential" ? hostData.credentialId : null; - sshDataObj.key = hostData.authType === "key" ? hostData.key : null; - sshDataObj.keyPassword = - hostData.authType === "key" ? hostData.keyPassword || null : null; - sshDataObj.keyType = - hostData.authType === "key" ? hostData.keyType || "auto" : null; - } - - await withHostDefaults( - userId, - existing?.id ?? null, - sshDataObj, - hostData as Record, - ); - - let savedHostId: number; - if (existing) { - const saved = await hostRepository.updateEncryptedForUser( - userId, - existing.id, - sshDataObj, - ); - if (!saved) throw new Error("Host no longer exists"); - if (exportId !== undefined) importedIds.set(exportId, existing.id); - savedHostId = existing.id; - results.updated++; - } else { - sshDataObj.createdAt = new Date().toISOString(); - const saved = await hostRepository.createEncryptedForUser( - userId, - sshDataObj, - ); - if (exportId !== undefined) importedIds.set(exportId, saved.id); - savedHostId = saved.id; - createdHostIds.push(saved.id); - results.success++; - } - - const protocolAuth = readProtocolAuthPayload( - hostData as Record, - ); - if (protocolAuth) { - await writeProtocolAuth( - userId, - savedHostId, - await keepUsableProtocolCredentials(protocolAuth, userId), - { isOwner: true }, - ); - } - - // Every enabled plugin that declares host-scope settings and - // registered a hostImportNormalizer validates and writes its own - // fields here, so this loop does not need to know which plugins - // exist. See host-plugin-settings.ts. - await applyPluginHostImportSettings( - savedHostId, - hostData as Record, - ); - writtenHostIds.push(savedHostId); - } catch (error) { - results.failed++; - results.errors.push(`Host ${i + 1}: ${getErrorMessage(error)}`); - } - } - - await applyDefaultsAfterHostWrites(writtenHostIds); - notifyImportedHosts(userId, writtenHostIds, createdHostIds); - - res.json({ - message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.skipped} skipped, ${results.failed} failed`, - success: results.success, - updated: results.updated, - skipped: results.skipped, - failed: results.failed, - errors: results.errors, - }); - }), - ); - - /** - * @openapi - * /host/ssh-config-import: - * post: - * summary: Import hosts from an OpenSSH config file - * description: Parses an OpenSSH ~/.ssh/config file and imports the defined hosts. - * tags: - * - SSH - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * required: - * - content - * properties: - * content: - * type: string - * description: Raw text content of the SSH config file. - * overwrite: - * type: boolean - * responses: - * 200: - * description: Import completed. - * 400: - * description: Invalid request body. - */ - router.post( - "/ssh-config-import", - authenticateJWT, - requireCreatePermission, - requireEditPermission, - requireDataAccess, - DatabaseSaveTrigger.batched(async (req: Request, res: Response) => { - const userId = (req as AuthenticatedRequest).userId; - const { content, overwrite } = req.body; - - if (!isNonEmptyString(content)) { - return res.status(400).json({ - error: "content is required and must be a non-empty string", - }); - } - - let parsed: SSHConfigHost[]; - try { - parsed = parseSSHConfig(content); - } catch { - return res - .status(400) - .json({ error: "Failed to parse SSH config file" }); - } - - if (parsed.length === 0) { - return res.status(400).json({ - error: "No valid Host entries found in the SSH config file", - }); - } - - if (parsed.length > 100) { - return res - .status(400) - .json({ error: "Maximum 100 hosts allowed per import" }); - } - - const hostsToImport = parsed.map((h) => ({ - name: h.name, - ip: h.hostname, - port: h.port ?? 22, - username: h.user, - authType: h.identityFile ? "key" : undefined, - connectionType: "ssh", - enableSsh: true, - ...(h.proxyJump - ? { - jumpHosts: [{ host: h.proxyJump, port: 22 }], - } - : {}), - })); - - const results = { - success: 0, - updated: 0, - skipped: 0, - failed: 0, - errors: [] as string[], - }; - - let existingHostMap: Map | undefined; - const hostRepository = createCurrentHostRepository(); - try { - const allHosts = - await createCurrentHostResolutionRepository().findHostsByUserId( - userId, - ); - existingHostMap = new Map(); - for (const h of allHosts) { - const key = `${h.ip}:${h.port}:${h.username}`; - existingHostMap.set(key, { id: h.id as number }); - } - } catch { - existingHostMap = undefined; - } - - const writtenHostIds: number[] = []; - const createdHostIds: number[] = []; - for (let i = 0; i < hostsToImport.length; i++) { - const hostData = normalizeImportedHost( - hostsToImport[i] as Record, - ); - - try { - if (!isNonEmptyString(hostData.ip) || !isValidPort(hostData.port)) { - results.failed++; - results.errors.push( - `Host "${parsed[i].name}": Missing required fields (HostName, Port)`, - ); - continue; - } - - const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`; - const existing = existingHostMap?.get(lookupKey); - if (existing && !overwrite) { - results.skipped++; - continue; - } - - const sshDataObj: Record = { - userId, - connectionType: "ssh", - name: hostData.name || hostData.ip, - folder: "Default", - tags: "", - ip: hostData.ip, - port: hostData.port, - username: hostData.username || null, - authType: hostData.authType || "none", - password: null, - key: null, - keyPassword: null, - keyType: null, - credentialId: null, - pin: false, - sudoPassword: null, - jumpHosts: hostData.jumpHosts - ? JSON.stringify(hostData.jumpHosts) - : null, - statusCheckEnabled: true, - statusCheckInterval: null, - sshOptions: null, - forceKeyboardInteractive: "false", - notes: null, - useSocks5: 0, - socks5Host: null, - socks5Port: null, - socks5Username: null, - socks5Password: null, - socks5ProxyChain: null, - portKnockSequence: null, - overrideCredentialUsername: 0, - enableSsh: true, - updatedAt: new Date().toISOString(), - }; - - await withHostDefaults( - userId, - existing?.id ?? null, - sshDataObj, - hostData as unknown as Record, - ); - - if (existing) { - await hostRepository.updateEncryptedForUser( - userId, - existing.id, - sshDataObj, - ); - writtenHostIds.push(existing.id); - results.updated++; - } else { - sshDataObj.createdAt = new Date().toISOString(); - const saved = await hostRepository.createEncryptedForUser( - userId, - sshDataObj, - ); - writtenHostIds.push(saved.id); - createdHostIds.push(saved.id); - results.success++; - } - } catch (error) { - results.failed++; - results.errors.push( - `Host "${parsed[i].name}": ${getErrorMessage(error)}`, - ); - } - } - - await applyDefaultsAfterHostWrites(writtenHostIds); - notifyImportedHosts(userId, writtenHostIds, createdHostIds); - - res.json({ - message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.skipped} skipped, ${results.failed} failed`, - success: results.success, - updated: results.updated, - skipped: results.skipped, - failed: results.failed, - errors: results.errors, - }); - }), - ); -} - -/** - * Status check fields from an import row. Exports from before 2.9.0 carried - * them inside statsConfig. - */ -function importedStatusCheck(raw: Record): { - statusCheckEnabled: boolean; - statusCheckInterval: number | null; -} { - const legacy = - raw.statsConfig && typeof raw.statsConfig === "object" - ? (raw.statsConfig as Record) - : {}; - const enabled = raw.statusCheckEnabled ?? legacy.statusCheckEnabled; - const useGlobal = legacy.useGlobalStatusInterval !== false; - const interval = - raw.statusCheckInterval ?? (useGlobal ? null : legacy.statusCheckInterval); - const seconds = Number(interval); - return { - statusCheckEnabled: enabled !== false && legacy.disableTcpPing !== true, - statusCheckInterval: - interval != null && Number.isInteger(seconds) && seconds >= 5 - ? seconds - : null, - }; -} - -const BUILTIN_SSH_AUTH_TYPES = [ - "password", - "key", - "credential", - "agent", - "none", -]; - -/** Core's own types plus every type a plugin registers or declares. */ -function listKnownAuthTypes(): Set { - return new Set([ - ...BUILTIN_SSH_AUTH_TYPES, - ...listSshAuthProviders().map((provider) => provider.type), - ...listSshAuthTypeOwners().map((owner) => owner.type), - ]); -} - -/** Fills an imported host's inherited keys and records what it sets itself. */ -async function withHostDefaults( - userId: string, - hostId: number | null, - columns: Record, - body: Record, -): Promise { - const stored = - hostId === null - ? null - : (( - await createCurrentHostDefaultsRepository().listHosts({ - hostIds: [hostId], - }) - )[0] ?? null); - columns.defaultOverrides = JSON.stringify( - await applyHostDefaultsToWrite({ - ownerId: userId, - hostId, - columns, - body, - stored, - }), - ); } /** A resetDefaults body as an override change, or null when there is none. */ diff --git a/src/backend/database/routes/host-folder-routes.ts b/src/backend/database/routes/host-folder-routes.ts index 4ef829549..d0775f577 100644 --- a/src/backend/database/routes/host-folder-routes.ts +++ b/src/backend/database/routes/host-folder-routes.ts @@ -2,11 +2,11 @@ import type { Request, RequestHandler, Response, Router } from "express"; import type { AuthenticatedRequest } from "../../../types/index.js"; import { databaseLogger, sshLogger } from "../../utils/logger.js"; import { pluginEvents, TOPICS } from "../../plugins/events.js"; +import { recentActivity } from "../../plugins/activity-provider.js"; import { createCurrentCredentialRepository, createCurrentHostFolderRepository, createCurrentFolderAccessRepository, - createCurrentRecentActivityRepository, createCurrentRbacAccessRepository, createCurrentSshCredentialUsageRepository, } from "../repositories/factory.js"; @@ -432,9 +432,7 @@ export function registerHostFolderRoutes( hostIds, ); - await createCurrentRecentActivityRepository().deleteByHostIds( - hostIds, - ); + await recentActivity.deleteByHostIds(hostIds); await createCurrentRbacAccessRepository().deleteHostAccessForHosts( hostIds, diff --git a/src/backend/database/routes/host-status-routes.ts b/src/backend/database/routes/host-status-routes.ts index 10bdb139a..46fdc5697 100644 --- a/src/backend/database/routes/host-status-routes.ts +++ b/src/backend/database/routes/host-status-routes.ts @@ -3,13 +3,10 @@ import type { Request, RequestHandler, Response, Router } from "express"; import { PermissionManager } from "../../utils/permission-manager.js"; import { DataCrypto } from "../../utils/data-crypto.js"; import { sshLogger } from "../../utils/logger.js"; -import { createCurrentSettingsRepository } from "../repositories/factory.js"; import { - DEFAULT_STATUS_INTERVAL, - GLOBAL_STATUS_INTERVAL_KEY, - hostStatusService, + hostStatus, type HostStatusEntry, -} from "../../hosts/status/host-status-service.js"; +} from "../../plugins/host-status-provider.js"; interface HostStatusRoutesDeps { authenticateJWT: RequestHandler; @@ -71,7 +68,7 @@ export function registerHostStatusRoutes( return sessionExpired(res); const requested = parseStatusHostIds(req.query.hostIds); - const statuses = await hostStatusService.statusesFor(userId, requested); + const statuses = await hostStatus.statusesFor(userId, requested); // One batched permission check: this is polled every few seconds, and a // per-host check made it linear in host count against the database. @@ -110,15 +107,7 @@ export function registerHostStatusRoutes( authenticateJWT, requireAdmin, async (_req: Request, res: Response) => { - const value = Number( - await createCurrentSettingsRepository().get(GLOBAL_STATUS_INTERVAL_KEY), - ); - res.json({ - statusCheckInterval: - Number.isInteger(value) && value >= 5 - ? value - : DEFAULT_STATUS_INTERVAL, - }); + res.json({ statusCheckInterval: await hostStatus.getIntervalSeconds() }); }, ); @@ -161,11 +150,7 @@ export function registerHostStatusRoutes( .json({ error: "Status check interval must be 5 to 3600 seconds" }); } try { - await createCurrentSettingsRepository().set( - GLOBAL_STATUS_INTERVAL_KEY, - String(seconds), - ); - hostStatusService.retimeAll(); + await hostStatus.setIntervalSeconds(seconds); res.json({ statusCheckInterval: seconds }); } catch (error) { sshLogger.error("Failed to save status check settings", error, { @@ -198,7 +183,7 @@ export function registerHostStatusRoutes( if (DataCrypto.getUserDataKey(userId) === null) { return sessionExpired(res); } - await hostStatusService.refresh(userId); + await hostStatus.refresh(userId); res.json({ message: "Status checks restarted" }); }, ); @@ -247,9 +232,8 @@ export function registerHostStatusRoutes( return res.status(404).json({ error: "Status not available" }); } - await hostStatusService.statusesFor(userId, null); - const entry = - hostStatusService.get(id) ?? (await hostStatusService.check(id)); + await hostStatus.statusesFor(userId, null); + const entry = hostStatus.get(id) ?? (await hostStatus.check(id)); if (!entry) { return res.status(404).json({ error: "Status not available" }); } diff --git a/src/backend/database/routes/host.ts b/src/backend/database/routes/host.ts index 4f6001b82..cd6114d7a 100644 --- a/src/backend/database/routes/host.ts +++ b/src/backend/database/routes/host.ts @@ -7,25 +7,21 @@ import type { AuthenticatedRequest } from "../../../types/index.js"; import express, { type Request, type Response } from "express"; import multer from "multer"; import { sshLogger, databaseLogger } from "../../utils/logger.js"; +import { resolveHostCredentials } from "../../hosts/resolve-host-credentials.js"; import { pluginEvents, TOPICS } from "../../plugins/events.js"; import { AuthManager } from "../../utils/auth-manager.js"; import { PermissionManager } from "../../utils/permission-manager.js"; import { DataCrypto } from "../../utils/data-crypto.js"; import { parseKeyForStorage } from "../../hosts/stored-ssh-key.js"; -import { - pickResolvedPassword, - pickResolvedUsername, -} from "../../hosts/credential-username.js"; +import { pickResolvedPassword } from "../../hosts/credential-username.js"; import { emitInternalEvent } from "../../hosts/internal-events.js"; import { deleteOwnedHost } from "../../hosts/delete-host.js"; import { - createCurrentCredentialRepository, createCurrentRbacAccessRepository, createCurrentRoleRepository, createCurrentHostResolutionRepository, createCurrentHostRepository, createCurrentUserRepository, - createCurrentSharedHostAuthOverrideRepository, createCurrentHostDefaultsRepository, } from "../repositories/factory.js"; import { @@ -41,7 +37,6 @@ import { OWNER_PRIVATE_AUTH_FIELDS, OWNER_PRIVATE_SSH_OPTION_FIELDS, OWNER_PRIVATE_TERMINAL_CONFIG_FIELDS, - hostTerminalExport, sanitizeHostForRecipient, stripSensitiveFields, transformHostResponse, @@ -67,14 +62,8 @@ import { getAuditUsername, getRequestMeta, } from "../../utils/audit-logger.js"; -import type { - HostResolutionCredentialRecord, - HostResolutionHostRecord, -} from "../repositories/host-resolution-repository.js"; -import { - requiresPersonalHostAuthentication, - resolveRecipientSharedHostAuthentication, -} from "../../utils/shared-host-auth-resolver.js"; +import type { HostResolutionCredentialRecord } from "../repositories/host-resolution-repository.js"; + import { rejectSharedCopyWrites } from "../../sync/shared-copy-guard.js"; import { sshOptionsForWrite } from "../../hosts/ssh-options.js"; import { @@ -91,15 +80,11 @@ import { planProtocolAuthWrite, ProtocolAuthWriteError, readProtocolAuthPayload, - toPortableLogins, withProtocolAuth, writeProtocolAuth, type PlannedProtocolAuth, } from "../../hosts/protocol-auth/protocol-auth.js"; -import { - findHostProtocol, - listHostProtocols, -} from "../../hosts/protocol-auth/registry.js"; +import { findHostProtocol } from "../../hosts/protocol-auth/registry.js"; import { mergeStoredSshOptions, parseTerminalConfig, @@ -1688,340 +1673,6 @@ router.get( }, ); -/** - * @openapi - * /host/db/host/{id}/export: - * get: - * summary: Export SSH host - * description: Exports a specific SSH host with decrypted credentials. - * tags: - * - SSH - * parameters: - * - in: path - * name: id - * required: true - * schema: - * type: integer - * responses: - * 200: - * description: The exported SSH host. - * 400: - * description: Invalid userId or hostId. - * 404: - * description: SSH host not found. - * 500: - * description: Failed to export SSH host. - */ -router.get( - "/db/host/:id/export", - authenticateJWT, - requireDataAccess, - async (req: Request, res: Response) => { - const hostId = Array.isArray(req.params.id) - ? req.params.id[0] - : req.params.id; - const userId = (req as AuthenticatedRequest).userId; - - if (!isNonEmptyString(userId) || !hostId) { - return res.status(400).json({ error: "Invalid userId or hostId" }); - } - - try { - const host = - await createCurrentHostResolutionRepository().findHostByIdForUser( - Number(hostId), - userId, - ); - - if (!host) { - return res.status(404).json({ error: "SSH host not found" }); - } - - const resolvedHost = (await resolveHostCredentials(host, userId)) || host; - const hostPluginSettings = ( - await loadHostPluginSettings([Number(hostId)]) - ).get(Number(hostId)); - - const exportedConnectionType = - (resolvedHost.connectionType as string) || "ssh"; - const isRemoteDesktop = exportedConnectionType !== "ssh"; - - const baseExportData = { - exportId: resolvedHost.id, - connectionType: exportedConnectionType, - name: resolvedHost.name, - ip: resolvedHost.ip, - port: resolvedHost.port, - username: resolvedHost.username, - password: resolvedHost.password || null, - folder: resolvedHost.folder, - tags: - typeof resolvedHost.tags === "string" - ? resolvedHost.tags.split(",").filter(Boolean) - : resolvedHost.tags || [], - pin: !!resolvedHost.pin, - notes: resolvedHost.notes || null, - // Every plugin's host settings, secrets redacted, for import to hand back. - pluginSettings: hostPluginSettings ?? {}, - // Each plugin protocol's login, secrets included like the SSH ones. - protocolAuth: toPortableLogins( - await listProtocolLogins(Number(hostId), userId), - ), - }; - - const exportData = isRemoteDesktop - ? baseExportData - : { - ...baseExportData, - authType: resolvedHost.authType, - key: resolvedHost.key || null, - keyPassword: resolvedHost.keyPassword || null, - keyType: resolvedHost.keyType || null, - credentialId: resolvedHost.credentialId || null, - overrideCredentialUsername: - !!resolvedHost.overrideCredentialUsername, - sudoPassword: resolvedHost.sudoPassword || null, - jumpHosts: resolvedHost.jumpHosts - ? JSON.parse(resolvedHost.jumpHosts as string) - : null, - terminalConfig: - hostTerminalExport(resolvedHost).terminalConfig ?? null, - sshOptions: hostTerminalExport(resolvedHost).sshOptions, - forceKeyboardInteractive: - resolvedHost.forceKeyboardInteractive === "true", - useSocks5: !!resolvedHost.useSocks5, - socks5Host: resolvedHost.socks5Host || null, - socks5Port: resolvedHost.socks5Port || null, - socks5Username: resolvedHost.socks5Username || null, - socks5Password: resolvedHost.socks5Password || null, - socks5ProxyChain: resolvedHost.socks5ProxyChain - ? JSON.parse(resolvedHost.socks5ProxyChain as string) - : null, - portKnockSequence: resolvedHost.portKnockSequence - ? JSON.parse(resolvedHost.portKnockSequence as string) - : null, - }; - - sshLogger.success("Host exported with decrypted credentials", { - operation: "host_export", - hostId: parseInt(hostId), - userId, - }); - - res.json(exportData); - } catch (err) { - sshLogger.error("Failed to export SSH host", err, { - operation: "host_export", - hostId: parseInt(hostId), - userId, - }); - res.status(500).json({ error: "Failed to export SSH host" }); - } - }, -); - -/** - * @openapi - * /host/db/hosts/export: - * get: - * summary: Export all SSH hosts - * description: Exports all SSH hosts for the current user. By default credentials are decrypted and embedded. With `share=1`, secrets are omitted and credential-authenticated hosts instead reference a scrubbed `credentials` array by alias, suitable for handing off to another user. - * tags: - * - SSH - * parameters: - * - in: query - * name: share - * required: false - * schema: - * type: string - * description: Set to "1" to export without embedded secrets. - * responses: - * 200: - * description: All exported SSH hosts. - * 400: - * description: Invalid userId. - * 500: - * description: Failed to export SSH hosts. - */ -router.get( - "/db/hosts/export", - authenticateJWT, - requireDataAccess, - async (req: Request, res: Response) => { - const userId = (req as AuthenticatedRequest).userId; - const shareMode = req.query.share === "1" || req.query.share === "true"; - - if (!isNonEmptyString(userId)) { - return res.status(400).json({ error: "Invalid userId" }); - } - - try { - const allHosts = - await createCurrentHostResolutionRepository().findHostsByUserId(userId); - const pluginSettingsByHost = await loadHostPluginSettings( - allHosts.map((h) => h.id as number), - ); - - const exportedHosts = []; - const usedCredentialIds = new Set(); - - for (const host of allHosts) { - const resolvedHost = shareMode - ? host - : (await resolveHostCredentials(host, userId)) || host; - const hostPluginSettings = pluginSettingsByHost.get(host.id as number); - - const exportedConnectionType = - (resolvedHost.connectionType as string) || "ssh"; - const isRemoteDesktop = exportedConnectionType !== "ssh"; - - const baseExportData = { - exportId: resolvedHost.id, - connectionType: exportedConnectionType, - name: resolvedHost.name, - ip: resolvedHost.ip, - port: resolvedHost.port, - username: resolvedHost.username, - password: shareMode ? null : resolvedHost.password || null, - folder: resolvedHost.folder, - tags: - typeof resolvedHost.tags === "string" - ? resolvedHost.tags.split(",").filter(Boolean) - : resolvedHost.tags || [], - pin: !!resolvedHost.pin, - notes: resolvedHost.notes || null, - // Every plugin's host settings, secrets redacted, for import to hand back. - pluginSettings: hostPluginSettings ?? {}, - protocolAuth: shareableLogins( - toPortableLogins( - await listProtocolLogins(host.id as number, userId), - ), - shareMode, - ), - }; - - const exportData = isRemoteDesktop - ? baseExportData - : { - ...baseExportData, - authType: resolvedHost.authType, - key: shareMode ? null : resolvedHost.key || null, - keyPassword: shareMode ? null : resolvedHost.keyPassword || null, - keyType: resolvedHost.keyType || null, - credentialId: resolvedHost.credentialId || null, - overrideCredentialUsername: - !!resolvedHost.overrideCredentialUsername, - sudoPassword: shareMode - ? null - : resolvedHost.sudoPassword || null, - jumpHosts: resolvedHost.jumpHosts - ? JSON.parse(resolvedHost.jumpHosts as string) - : null, - terminalConfig: - hostTerminalExport(resolvedHost).terminalConfig ?? null, - sshOptions: hostTerminalExport(resolvedHost).sshOptions, - forceKeyboardInteractive: - resolvedHost.forceKeyboardInteractive === "true", - useSocks5: !!resolvedHost.useSocks5, - socks5Host: resolvedHost.socks5Host || null, - socks5Port: resolvedHost.socks5Port || null, - socks5Username: resolvedHost.socks5Username || null, - socks5Password: shareMode - ? null - : resolvedHost.socks5Password || null, - socks5ProxyChain: resolvedHost.socks5ProxyChain - ? JSON.parse(resolvedHost.socks5ProxyChain as string) - : null, - }; - - if ( - shareMode && - !isRemoteDesktop && - resolvedHost.authType === "credential" && - resolvedHost.credentialId - ) { - usedCredentialIds.add(resolvedHost.credentialId as number); - } - - exportedHosts.push(exportData); - } - - if (!shareMode) { - sshLogger.success("All hosts exported with decrypted credentials", { - operation: "hosts_export_all", - count: exportedHosts.length, - userId, - }); - - return res.json({ hosts: exportedHosts }); - } - - const exportedCredentials: Record[] = []; - if (usedCredentialIds.size > 0) { - const credentialRepository = createCurrentCredentialRepository(); - const ownedCredentials = - await credentialRepository.listDecryptedByUserId(userId); - const credentialById = new Map( - ownedCredentials.map((credential) => [credential.id, credential]), - ); - - for (const host of exportedHosts as Record[]) { - const credentialId = host.credentialId as number | null; - if (!credentialId) continue; - const credential = credentialById.get(credentialId); - if (!credential) continue; - - host.credentialAlias = credential.name; - - if ( - !exportedCredentials.some( - (entry) => entry.alias === credential.name, - ) - ) { - exportedCredentials.push({ - alias: credential.name, - name: credential.name, - description: credential.description || null, - folder: credential.folder || null, - tags: - typeof credential.tags === "string" - ? credential.tags.split(",").filter(Boolean) - : [], - authType: credential.authType, - username: credential.username || null, - keyType: credential.keyType || null, - }); - } - } - } - - for (const host of exportedHosts as Record[]) { - delete host.credentialId; - } - - sshLogger.success("All hosts exported for sharing without secrets", { - operation: "hosts_export_all_share", - count: exportedHosts.length, - credentialCount: exportedCredentials.length, - userId, - }); - - res.json({ - version: "1", - exportedAt: new Date().toISOString(), - credentials: exportedCredentials, - hosts: exportedHosts, - }); - } catch (err) { - sshLogger.error("Failed to export all SSH hosts", err, { - operation: "hosts_export_all", - userId, - }); - res.status(500).json({ error: "Failed to export SSH hosts" }); - } - }, -); - /** * @openapi * /host/db/host/{id}: @@ -2117,231 +1768,6 @@ router.delete( // the file-manager plugin, under /plugin-api/file-manager/, and command // history to the ssh-terminal plugin, under /plugin-api/ssh-terminal/. -/** - * A share export leaves out every secret and credential link, the same as - * it does for SSH. - */ -function shareableLogins( - logins: ReturnType, - shareMode: boolean, -): ReturnType { - if (!shareMode) return logins; - const out: ReturnType = {}; - for (const [protocol, login] of Object.entries(logins)) { - const declared = findHostProtocol(protocol); - const secret = new Set( - (declared?.credentialFields ?? []) - .filter((field) => field.secret) - .map((field) => field.key), - ); - out[protocol] = { - authType: login.authType === "credential" ? "direct" : login.authType, - credentialId: null, - username: login.username, - password: null, - fields: Object.fromEntries( - Object.entries(login.fields).filter( - ([key]) => declared && !secret.has(key), - ), - ), - }; - } - return out; -} - -async function resolveHostCredentials( - host: Record, - requestingUserId?: string, - /** - * Credentials already fetched for this request, keyed by id. The host list - * preloads them in one query; single-host callers omit it and fall back to - * fetching the one credential they need. - */ - preloadedCredentials?: Map, -): Promise> { - try { - const ownerId = (host.ownerId || host.userId) as string | undefined; - if ( - requestingUserId && - ownerId && - requestingUserId !== ownerId && - typeof host.id === "number" - ) { - const authHost = host as unknown as HostResolutionHostRecord; - const needsPersonalCredential = requiresPersonalHostAuthentication( - authHost, - "ssh", - ); - const baseSshOverrideState = { - required: needsPersonalCredential, - ownerAuthShared: !!host.shareSshAuth, - }; - // Owner auth for the remote desktop protocols is always snapshotted - // for recipients; only their own override credential varies per user. - const authOverrides: Record = { - ssh: baseSshOverrideState, - }; - const overrideCredentialIds = - await createCurrentSharedHostAuthOverrideRepository().listCredentialIds( - host.id, - requestingUserId, - ); - // Whether a plugin protocol is on is its plugin's host setting; the - // client only offers an override for one that is. - for (const { id: protocol } of listHostProtocols()) { - authOverrides[protocol] = { - credentialId: overrideCredentialIds[protocol], - required: false, - ownerAuthShared: true, - }; - } - const recipientHost: Record = { - ...host, - credentialId: null, - password: null, - key: null, - keyPassword: null, - keyType: null, - authOverrides, - }; - - try { - const resolution = await resolveRecipientSharedHostAuthentication( - authHost, - host.id, - requestingUserId, - "ssh", - ); - - if (resolution.source === "personal-override") { - const credential = resolution.credential; - return { - ...recipientHost, - authOverrides: { - ...authOverrides, - ssh: { - credentialId: resolution.credentialId, - required: false, - ownerAuthShared: !!host.shareSshAuth, - }, - }, - authType: - credential.key || credential.privateKey - ? "key" - : credential.password - ? "password" - : "none", - username: credential.username || recipientHost.username, - password: credential.password, - key: credential.privateKey || credential.key, - keyPassword: credential.keyPassword, - keyType: credential.keyType, - }; - } - - if (resolution.source === "owner-shared") { - if (resolution.authType === "agent") { - return { - ...recipientHost, - authOverrides: { - ...authOverrides, - ssh: { - required: false, - ownerAuthShared: true, - }, - }, - authType: "agent", - }; - } - - const sharedAuth = resolution.secret; - if (sharedAuth) { - const resolvedUsername = pickResolvedUsername( - recipientHost.username, - sharedAuth.username, - host.overrideCredentialUsername, - ); - return { - ...recipientHost, - authOverrides: { - ...authOverrides, - ssh: { - required: false, - ownerAuthShared: true, - }, - }, - authType: sharedAuth.key - ? "key" - : sharedAuth.password - ? "password" - : "none", - username: resolvedUsername, - password: sharedAuth.password, - key: sharedAuth.key, - keyPassword: sharedAuth.keyPassword, - keyType: sharedAuth.keyType, - }; - } - } - - if (resolution.source === "secretless") { - return { - ...recipientHost, - authOverrides: { - ...authOverrides, - ssh: { - required: false, - ownerAuthShared: !!host.shareSshAuth, - }, - }, - }; - } - } catch { - // A missing/deleted override or snapshot behaves like unavailable auth. - } - - return recipientHost; - } - - if (host.credentialId && (host.userId || host.ownerId)) { - const credentialId = host.credentialId as number; - const credentialOwnerId = (host.ownerId || host.userId) as string; - - const credential = - preloadedCredentials?.get(credentialId) ?? - (await findUsableCredential(credentialId, credentialOwnerId)); - - if (credential) { - const resolvedHost: Record = { - ...host, - password: pickResolvedPassword(host.password, credential.password), - key: credential.key, - keyPassword: credential.keyPassword, - keyType: credential.keyType, - }; - - const resolvedUsername = pickResolvedUsername( - host.username, - credential.username, - host.overrideCredentialUsername, - ); - if (resolvedUsername !== undefined) { - resolvedHost.username = resolvedUsername; - } - - return resolvedHost; - } - } - - return { ...host }; - } catch (error) { - sshLogger.warn( - `Failed to resolve credentials for host ${host.id}: ${getErrorMessage(error)}`, - ); - return host; - } -} - registerHostFolderRoutes(router, { authenticateJWT, requireViewPermission: permissionManager.requirePermission("hosts.view"), @@ -2355,7 +1781,6 @@ registerHostFolderRoutes(router, { registerHostBulkRoutes( router, authenticateJWT, - permissionManager.requirePermission("hosts.create"), permissionManager.requirePermission("hosts.edit"), requireDataAccess, ); diff --git a/src/backend/database/routes/plugins.ts b/src/backend/database/routes/plugins.ts index 1d11cd32e..dcbf4415f 100644 --- a/src/backend/database/routes/plugins.ts +++ b/src/backend/database/routes/plugins.ts @@ -13,6 +13,7 @@ import { createCurrentPluginRepository, } from "../repositories/factory.js"; import { getPluginRuntime } from "../../plugins/index.js"; +import { resolvePluginDesktopMode } from "@termix-ssh/plugin-sdk/manifest"; import { describePluginFrontend } from "../../plugins/assets.js"; import { isDefaultLookPlugin } from "../../plugins/bundled-index.js"; import { invalidatePluginPermissionCache } from "../../plugins/permissions.js"; @@ -297,7 +298,9 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => { let features: string[] = []; let docs: string | undefined; let env: PluginEnvVar[] = []; - let desktopMode: unknown = loaded?.manifest.desktop; + let desktopMode: unknown = loaded + ? resolvePluginDesktopMode(loaded.manifest) + : undefined; try { const manifest = JSON.parse(record.manifestJson) as { contributes?: unknown; @@ -313,8 +316,12 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => { dependencies?: Record; optionalDependencies?: Record; desktop?: unknown; + runtime?: unknown; }; - desktopMode = loaded?.manifest.desktop ?? manifest?.desktop; + desktopMode = loaded + ? resolvePluginDesktopMode(loaded.manifest) + : (manifest?.desktop ?? + (manifest?.runtime === "desktop" ? "local" : undefined)); contributes = manifest?.contributes ?? null; capabilities = Array.isArray(manifest?.capabilities) ? (manifest.capabilities as string[]) diff --git a/src/backend/database/routes/user-password-reset-routes.ts b/src/backend/database/routes/user-password-reset-routes.ts index 20a60caaf..cd452af26 100644 --- a/src/backend/database/routes/user-password-reset-routes.ts +++ b/src/backend/database/routes/user-password-reset-routes.ts @@ -2,13 +2,13 @@ import type { Router } from "express"; import crypto from "crypto"; import bcrypt from "bcryptjs"; import { nanoid } from "nanoid"; +import { recentActivity } from "../../plugins/activity-provider.js"; import { AuthManager } from "../../utils/auth-manager.js"; import { authLogger } from "../../utils/logger.js"; import { loginRateLimiter } from "../../utils/login-rate-limiter.js"; import { createCurrentCredentialRepository, createCurrentHostRepository, - createCurrentRecentActivityRepository, createCurrentSettingsRepository, createCurrentSshCredentialUsageRepository, createCurrentUserRepository, @@ -68,7 +68,7 @@ export async function resetUserPassword( await userRepository.update(userId, { passwordHash }); await createCurrentSshCredentialUsageRepository().deleteByUserId(userId); - await createCurrentRecentActivityRepository().deleteByUserId(userId); + await recentActivity.deleteByUserId(userId); await createCurrentHostRepository().deleteByUserId(userId); await createCurrentCredentialRepository().deleteByUserId(userId); // A plugin holding this user's data (anything keyed by diff --git a/src/backend/database/routes/user-settings-routes.ts b/src/backend/database/routes/user-settings-routes.ts index b1b74d944..ab09a934c 100644 --- a/src/backend/database/routes/user-settings-routes.ts +++ b/src/backend/database/routes/user-settings-routes.ts @@ -6,11 +6,6 @@ import { setGlobalLogLevel, } from "../../utils/logger.js"; import { logAudit, getRequestMeta } from "../../utils/audit-logger.js"; -import { - AUDIT_FORWARD_TOKEN_SETTING, - AUDIT_FORWARD_URL_ENV, - AUDIT_FORWARD_URL_SETTING, -} from "../../utils/audit-forwarder.js"; import { NOTIFICATION_PRIVATE_ALLOWLIST_KEY, parseNotificationAllowlist, @@ -192,125 +187,6 @@ export function registerUserSettingsRoutes( } }); - /** - * @openapi - * /users/audit-forwarding: - * get: - * summary: Get audit log forwarding settings (admin only) - * tags: - * - Users - * responses: - * 200: - * description: Forwarding target. The token itself is never returned. - */ - router.get("/audit-forwarding", authenticateJWT, async (req, res) => { - const userId = (req as AuthenticatedRequest).userId; - try { - if (!(await getAdminActor(userId))) { - return res.status(403).json({ error: "Not authorized" }); - } - const settingsRepository = createCurrentSettingsRepository(); - const url = - (await settingsRepository.get(AUDIT_FORWARD_URL_SETTING)) ?? ""; - const hasToken = !!(await settingsRepository.get( - AUDIT_FORWARD_TOKEN_SETTING, - )); - res.json({ - url, - hasToken, - envConfigured: !!process.env[AUDIT_FORWARD_URL_ENV]?.trim(), - }); - } catch (err) { - authLogger.error("Failed to get audit forwarding settings", err); - res - .status(500) - .json({ error: "Failed to get audit forwarding settings" }); - } - }); - - /** - * @openapi - * /users/audit-forwarding: - * patch: - * summary: Update audit log forwarding settings (admin only) - * description: Sets the collector URL audit entries are shipped to. An empty URL disables forwarding and clears the stored token. Omitting token keeps the stored one. - * tags: - * - Users - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * properties: - * url: - * type: string - * token: - * type: string - * responses: - * 200: - * description: Setting updated. - * 403: - * description: Not authorized. - */ - router.patch("/audit-forwarding", authenticateJWT, async (req, res) => { - const userId = (req as AuthenticatedRequest).userId; - try { - const actor = await getAdminActor(userId); - if (!actor) { - return res.status(403).json({ error: "Not authorized" }); - } - const { url, token } = req.body ?? {}; - if ( - typeof url !== "string" || - (token !== undefined && typeof token !== "string") - ) { - return res.status(400).json({ error: "url must be a string" }); - } - const trimmedUrl = url.trim(); - if (trimmedUrl && !/^https?:\/\//i.test(trimmedUrl)) { - return res.status(400).json({ error: "url must be http(s)" }); - } - - const settingsRepository = createCurrentSettingsRepository(); - if (!trimmedUrl) { - await settingsRepository.delete(AUDIT_FORWARD_URL_SETTING); - await settingsRepository.delete(AUDIT_FORWARD_TOKEN_SETTING); - } else { - await settingsRepository.set(AUDIT_FORWARD_URL_SETTING, trimmedUrl); - if (token !== undefined) { - if (token.trim()) { - await settingsRepository.set( - AUDIT_FORWARD_TOKEN_SETTING, - token.trim(), - ); - } else { - await settingsRepository.delete(AUDIT_FORWARD_TOKEN_SETTING); - } - } - } - - const { ipAddress, userAgent } = getRequestMeta(req); - await logAudit({ - userId, - username: actor.username ?? userId, - action: "update_audit_forwarding", - resourceType: "setting", - details: JSON.stringify({ enabled: !!trimmedUrl }), - ipAddress, - userAgent, - success: true, - }); - - res.json({ url: trimmedUrl, hasToken: !!token?.trim() }); - } catch (err) { - authLogger.error("Failed to update audit forwarding settings", err); - res - .status(500) - .json({ error: "Failed to update audit forwarding settings" }); - } - }); - /** * GET/PATCH a comma-list of private hosts an outbound feature may reach. * One route pair per private endpoint allowlist, each with its own setting key. diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index f5f464197..efdf931e6 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -1543,7 +1543,7 @@ registerUserSocketTicketRoutes(router, { authenticateJWT, authManager }); registerTlsRoutes(router, authenticateJWT); registerUserApiKeyRoutes(router, requireAdmin); -registerBrandingRoutes(router, requireAdmin); +registerBrandingRoutes(router); registerAuthRoutes(router); registerAuthCompatRoutes(router); diff --git a/src/backend/hosts/delete-host.ts b/src/backend/hosts/delete-host.ts index 3dfc870c7..4a0d98d87 100644 --- a/src/backend/hosts/delete-host.ts +++ b/src/backend/hosts/delete-host.ts @@ -2,10 +2,10 @@ import { createCurrentHostRepository, createCurrentHostResolutionRepository, createCurrentRbacAccessRepository, - createCurrentRecentActivityRepository, createCurrentSshCredentialUsageRepository, } from "../database/repositories/factory.js"; import { pluginEvents, TOPICS } from "../plugins/events.js"; +import { recentActivity } from "../plugins/activity-provider.js"; import { sshLogger } from "../utils/logger.js"; import { emitInternalEvent } from "./internal-events.js"; @@ -33,7 +33,7 @@ export async function deleteOwnedHost( // Plugin rows tied to a host cascade on their refHost() foreign keys. await createCurrentSshCredentialUsageRepository().deleteByHostId(hostId); - await createCurrentRecentActivityRepository().deleteByHostId(hostId); + await recentActivity.deleteByHostId(hostId); await createCurrentRbacAccessRepository().deleteHostAccessForHost(hostId); await createCurrentHostRepository().deleteForUser(userId, hostId); diff --git a/src/backend/hosts/import/host-export.ts b/src/backend/hosts/import/host-export.ts new file mode 100644 index 000000000..6b15a6de2 --- /dev/null +++ b/src/backend/hosts/import/host-export.ts @@ -0,0 +1,213 @@ +import { + createCurrentCredentialRepository, + createCurrentHostResolutionRepository, +} from "../../database/repositories/factory.js"; +import { hostTerminalExport } from "../../database/routes/host-normalizers.js"; +import { loadHostPluginSettings } from "../../database/routes/host-plugin-settings.js"; +import { + listProtocolLogins, + toPortableLogins, +} from "../protocol-auth/protocol-auth.js"; +import { findHostProtocol } from "../protocol-auth/registry.js"; +import { resolveHostCredentials } from "../resolve-host-credentials.js"; + +/** + * A share export leaves out every secret and credential link, the same as + * it does for SSH. + */ +function shareableLogins( + logins: ReturnType, + shareMode: boolean, +): ReturnType { + if (!shareMode) return logins; + const out: ReturnType = {}; + for (const [protocol, login] of Object.entries(logins)) { + const declared = findHostProtocol(protocol); + const secret = new Set( + (declared?.credentialFields ?? []) + .filter((field) => field.secret) + .map((field) => field.key), + ); + out[protocol] = { + authType: login.authType === "credential" ? "direct" : login.authType, + credentialId: null, + username: login.username, + password: null, + fields: Object.fromEntries( + Object.entries(login.fields).filter( + ([key]) => declared && !secret.has(key), + ), + ), + }; + } + return out; +} + +type Row = Record; + +/** One host as the Termix JSON export carries it. */ +async function exportRow( + host: Row, + userId: string, + shareMode: boolean, + pluginSettings: Awaited>, +): Promise<{ data: Row; isRemoteDesktop: boolean; resolved: Row }> { + const resolvedHost = shareMode + ? host + : (await resolveHostCredentials(host, userId)) || host; + const connectionType = (resolvedHost.connectionType as string) || "ssh"; + const isRemoteDesktop = connectionType !== "ssh"; + + const base = { + exportId: resolvedHost.id, + connectionType, + name: resolvedHost.name, + ip: resolvedHost.ip, + port: resolvedHost.port, + username: resolvedHost.username, + password: shareMode ? null : resolvedHost.password || null, + folder: resolvedHost.folder, + tags: + typeof resolvedHost.tags === "string" + ? resolvedHost.tags.split(",").filter(Boolean) + : resolvedHost.tags || [], + pin: !!resolvedHost.pin, + notes: resolvedHost.notes || null, + // Every plugin's host settings, secrets redacted, for import to hand back. + pluginSettings: pluginSettings.get(host.id as number) ?? {}, + // Each plugin protocol's login, secrets included like the SSH ones. + protocolAuth: shareableLogins( + toPortableLogins(await listProtocolLogins(host.id as number, userId)), + shareMode, + ), + }; + + if (isRemoteDesktop) { + return { data: base, isRemoteDesktop, resolved: resolvedHost }; + } + + const terminal = hostTerminalExport(resolvedHost); + const data = { + ...base, + authType: resolvedHost.authType, + key: shareMode ? null : resolvedHost.key || null, + keyPassword: shareMode ? null : resolvedHost.keyPassword || null, + keyType: resolvedHost.keyType || null, + credentialId: resolvedHost.credentialId || null, + overrideCredentialUsername: !!resolvedHost.overrideCredentialUsername, + sudoPassword: shareMode ? null : resolvedHost.sudoPassword || null, + jumpHosts: resolvedHost.jumpHosts + ? JSON.parse(resolvedHost.jumpHosts as string) + : null, + terminalConfig: terminal.terminalConfig ?? null, + sshOptions: terminal.sshOptions, + forceKeyboardInteractive: resolvedHost.forceKeyboardInteractive === "true", + useSocks5: !!resolvedHost.useSocks5, + socks5Host: resolvedHost.socks5Host || null, + socks5Port: resolvedHost.socks5Port || null, + socks5Username: resolvedHost.socks5Username || null, + socks5Password: shareMode ? null : resolvedHost.socks5Password || null, + socks5ProxyChain: resolvedHost.socks5ProxyChain + ? JSON.parse(resolvedHost.socks5ProxyChain as string) + : null, + portKnockSequence: resolvedHost.portKnockSequence + ? JSON.parse(resolvedHost.portKnockSequence as string) + : null, + }; + return { data, isRemoteDesktop, resolved: resolvedHost }; +} + +export interface HostExport { + hosts: Row[]; + /** Share mode only. */ + version?: string; + exportedAt?: string; + credentials?: Row[]; +} + +/** + * All of the user's own hosts. Without shareMode credentials are decrypted and + * embedded. With it every secret is left out and credential logins point at a + * scrubbed credentials list by alias. + */ +export async function exportAllHosts( + userId: string, + shareMode: boolean, + hostIds?: number[], +): Promise { + const owned = + await createCurrentHostResolutionRepository().findHostsByUserId(userId); + const wanted = hostIds ? new Set(hostIds) : null; + const allHosts = wanted + ? owned.filter((host) => wanted.has(host.id as number)) + : owned; + const settings = await loadHostPluginSettings( + allHosts.map((h) => h.id as number), + ); + + const hosts: Row[] = []; + const usedCredentialIds = new Set(); + + for (const host of allHosts) { + const { data, isRemoteDesktop, resolved } = await exportRow( + host as unknown as Row, + userId, + shareMode, + settings, + ); + if ( + shareMode && + !isRemoteDesktop && + resolved.authType === "credential" && + resolved.credentialId + ) { + usedCredentialIds.add(resolved.credentialId as number); + } + hosts.push(data); + } + + if (!shareMode) return { hosts }; + + const credentials: Row[] = []; + if (usedCredentialIds.size > 0) { + const owned = + await createCurrentCredentialRepository().listDecryptedByUserId(userId); + const byId = new Map( + owned.map((credential) => [credential.id, credential]), + ); + + for (const host of hosts) { + const credentialId = host.credentialId as number | null; + if (!credentialId) continue; + const credential = byId.get(credentialId); + if (!credential) continue; + + host.credentialAlias = credential.name; + + if (!credentials.some((entry) => entry.alias === credential.name)) { + credentials.push({ + alias: credential.name, + name: credential.name, + description: credential.description || null, + folder: credential.folder || null, + tags: + typeof credential.tags === "string" + ? credential.tags.split(",").filter(Boolean) + : [], + authType: credential.authType, + username: credential.username || null, + keyType: credential.keyType || null, + }); + } + } + } + + for (const host of hosts) delete host.credentialId; + + return { + version: "1", + exportedAt: new Date().toISOString(), + credentials, + hosts, + }; +} diff --git a/src/backend/hosts/import/host-import.ts b/src/backend/hosts/import/host-import.ts new file mode 100644 index 000000000..340e43200 --- /dev/null +++ b/src/backend/hosts/import/host-import.ts @@ -0,0 +1,829 @@ +import { + prepareHostImports, + remapImportedJumpHosts, +} from "../../database/routes/host-import-order.js"; +import { sshOptionsForWrite } from "../ssh-options.js"; +import { + applyDefaultsAfterHostWrites, + applyHostDefaultsToWrite, +} from "../defaults/index.js"; +import { + keepUsableProtocolCredentials, + readProtocolAuthPayload, + writeProtocolAuth, +} from "../protocol-auth/protocol-auth.js"; +import { getErrorMessage } from "../../utils/error-message.js"; +import { pluginEvents, TOPICS } from "../../plugins/events.js"; +import { + createCurrentCredentialRepository, + createCurrentHostRepository, + createCurrentHostResolutionRepository, + createCurrentHostDefaultsRepository, +} from "../../database/repositories/factory.js"; +import { + listSshAuthProviders, + listSshAuthTypeOwners, +} from "../connect/auth-provider-registry.js"; +import { applyPluginHostImportSettings } from "../../database/routes/host-plugin-settings.js"; +import { + isNonEmptyString, + isValidPort, + normalizeImportedHost, +} from "../../database/routes/host-normalizers.js"; + +/** A request the caller got wrong. Routes answer it with a 400. */ +export class ImportError extends Error { + constructor(message: string) { + super(message); + this.name = "ImportError"; + } +} + +export interface ImportResults { + success: number; + updated: number; + skipped: number; + failed: number; + errors: string[]; + /** Hosts this call created. */ + createdHostIds: number[]; + /** True when atomic was set and a failure undid the creates. */ + rolledBack?: boolean; +} + +export interface BulkImportInput { + hosts: unknown; + overwrite?: unknown; + credentials?: unknown; + /** All or nothing: any failure deletes what this call created. */ + atomic?: boolean; +} + +type SSHConfigHost = { + name: string; + hostname?: string; + user?: string; + port?: number; + identityFile?: string; + proxyJump?: string; +}; + +type ShareCredential = { + alias?: unknown; + name?: unknown; + description?: unknown; + folder?: unknown; + tags?: unknown; + authType?: unknown; + username?: unknown; + keyType?: unknown; +}; + +function textValue(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function tagString(value: unknown): string { + if (Array.isArray(value)) { + return value + .map((tag) => textValue(tag)) + .filter((tag): tag is string => !!tag) + .join(","); + } + return textValue(value) || ""; +} + +function normalizeCredentialAuthType(value: unknown): "password" | "key" { + return value === "key" ? "key" : "password"; +} + +export function parseSSHConfig(content: string): SSHConfigHost[] { + const results: SSHConfigHost[] = []; + let current: SSHConfigHost | null = null; + + for (const rawLine of content.split("\n")) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + + const match = /^(\S+?)(?:\s*=\s*|\s+)(.+)$/.exec(line); + if (!match) continue; + + const key = match[1].toLowerCase(); + const value = match[2]; + + if (key === "host") { + if (current && current.hostname) results.push(current); + // Skip wildcard patterns + if (value === "*" || value.includes("*") || value.includes("?")) { + current = null; + } else { + current = { name: value }; + } + continue; + } + + if (!current) continue; + + switch (key) { + case "hostname": + current.hostname = value; + break; + case "user": + current.user = value; + break; + case "port": { + const p = Number.parseInt(value, 10); + if (p > 0 && p <= 65535) current.port = p; + break; + } + case "identityfile": + if (!current.identityFile) current.identityFile = value; + break; + case "proxyjump": + current.proxyJump = value; + break; + } + } + + if (current && current.hostname) results.push(current); + + return results; +} + +/** + * Tells status checks and other pollers about hosts an import wrote, the way + * a single host create or update does. + */ +export function notifyImportedHosts( + userId: string, + hostIds: number[], + createdHostIds: number[], +): void { + for (const hostId of createdHostIds) { + pluginEvents.emit(TOPICS.hostCreated, { hostId, userId }); + } + for (const hostId of hostIds) { + pluginEvents.emit(TOPICS.hostUpdated, { hostId, userId }); + } +} + +export function importedHostUsername( + connectionType: string, + authType: unknown, + username: unknown, +): string | null { + if (isNonEmptyString(username)) return username; + if (connectionType !== "ssh" || authType === "credential") return ""; + return null; +} + +/** + * Status check fields from an import row. Exports from before 2.9.0 carried + * them inside statsConfig. + */ +function importedStatusCheck(raw: Record): { + statusCheckEnabled: boolean; + statusCheckInterval: number | null; +} { + const legacy = + raw.statsConfig && typeof raw.statsConfig === "object" + ? (raw.statsConfig as Record) + : {}; + const enabled = raw.statusCheckEnabled ?? legacy.statusCheckEnabled; + const useGlobal = legacy.useGlobalStatusInterval !== false; + const interval = + raw.statusCheckInterval ?? (useGlobal ? null : legacy.statusCheckInterval); + const seconds = Number(interval); + return { + statusCheckEnabled: enabled !== false && legacy.disableTcpPing !== true, + statusCheckInterval: + interval != null && Number.isInteger(seconds) && seconds >= 5 + ? seconds + : null, + }; +} + +const BUILTIN_SSH_AUTH_TYPES = [ + "password", + "key", + "credential", + "agent", + "none", +]; + +/** Core's own types plus every type a plugin registers or declares. */ +function listKnownAuthTypes(): Set { + return new Set([ + ...BUILTIN_SSH_AUTH_TYPES, + ...listSshAuthProviders().map((provider) => provider.type), + ...listSshAuthTypeOwners().map((owner) => owner.type), + ]); +} + +/** Fills an imported host's inherited keys and records what it sets itself. */ +async function withHostDefaults( + userId: string, + hostId: number | null, + columns: Record, + body: Record, +): Promise { + const stored = + hostId === null + ? null + : (( + await createCurrentHostDefaultsRepository().listHosts({ + hostIds: [hostId], + }) + )[0] ?? null); + columns.defaultOverrides = JSON.stringify( + await applyHostDefaultsToWrite({ + ownerId: userId, + hostId, + columns, + body, + stored, + }), + ); +} + +export function importSummary(results: ImportResults): string { + return `Import completed: ${results.success} created, ${results.updated} updated, ${results.skipped} skipped, ${results.failed} failed`; +} + +export function importResponse(results: ImportResults) { + return { + message: importSummary(results), + success: results.success, + updated: results.updated, + skipped: results.skipped, + failed: results.failed, + errors: results.errors, + }; +} + +export const MAX_IMPORT_HOSTS = 100; + +/** Deletes what an atomic import created, newest first. */ +async function rollBackCreated( + userId: string, + results: ImportResults, +): Promise { + const repository = createCurrentHostRepository(); + for (const hostId of [...results.createdHostIds].reverse()) { + await repository.deleteForUser(userId, hostId).catch(() => {}); + } + results.rolledBack = true; + results.success = 0; + results.createdHostIds = []; +} + +/** + * The Termix JSON import: shared by POST /host/bulk-import and + * ctx.hosts.importMany. Callers wrap it in DatabaseSaveTrigger.batched. + */ +export async function runBulkImport( + userId: string, + input: BulkImportInput, +): Promise { + const { + hosts: hostsToImport, + overwrite, + credentials: credentialsToImport, + } = input; + if (input.atomic && overwrite) { + throw new ImportError("An atomic import cannot overwrite existing hosts"); + } + + if (!Array.isArray(hostsToImport) || hostsToImport.length === 0) { + throw new ImportError("Hosts array is required and must not be empty"); + } + + if (hostsToImport.length > 100) { + throw new ImportError("Maximum 100 hosts allowed per import"); + } + + let orderedHosts: ReturnType; + try { + orderedHosts = prepareHostImports(hostsToImport); + } catch (error) { + throw new ImportError(getErrorMessage(error)); + } + const importedIds = new Map(); + + const results: ImportResults = { + success: 0, + updated: 0, + skipped: 0, + failed: 0, + errors: [], + createdHostIds: [], + }; + + const credentialAliasMap = new Map(); + const addCredentialAlias = (alias: unknown, id: number) => { + const key = textValue(alias); + if (key) credentialAliasMap.set(key.toLowerCase(), id); + }; + + try { + const credentialRepository = createCurrentCredentialRepository(); + const existingCredentials = + await credentialRepository.listDecryptedByUserId(userId); + + for (const credential of existingCredentials) { + addCredentialAlias(credential.name, credential.id as number); + } + + if (Array.isArray(credentialsToImport)) { + for (const rawCredential of credentialsToImport as ShareCredential[]) { + const alias = textValue(rawCredential.alias); + const name = textValue(rawCredential.name) || alias; + if (!alias || !name) continue; + + const existingId = credentialAliasMap.get(name.toLowerCase()); + if (existingId) { + addCredentialAlias(alias, existingId); + continue; + } + + const now = new Date().toISOString(); + const created = await credentialRepository.createEncryptedForUser( + userId, + { + userId, + name, + description: + textValue(rawCredential.description) || + "Imported placeholder. Add the secret before connecting.", + folder: textValue(rawCredential.folder), + tags: tagString(rawCredential.tags), + authType: normalizeCredentialAuthType(rawCredential.authType), + username: textValue(rawCredential.username), + password: null, + key: null, + privateKey: null, + publicKey: null, + keyPassword: null, + keyType: textValue(rawCredential.keyType), + detectedKeyType: null, + usageCount: 0, + lastUsed: null, + createdAt: now, + updatedAt: now, + }, + ); + + const createdCredential = created as Record; + addCredentialAlias(alias, createdCredential.id as number); + addCredentialAlias(name, createdCredential.id as number); + } + } + } catch (error) { + results.errors.push( + `Credential placeholders: ${getErrorMessage(error, "failed to prepare credential aliases")}`, + ); + } + + // Loaded for both modes: overwrite updates a match, skip leaves it alone. + let existingHostMap: Map | undefined; + const hostRepository = createCurrentHostRepository(); + try { + const allHosts = + await createCurrentHostResolutionRepository().findHostsByUserId(userId); + existingHostMap = new Map(); + for (const h of allHosts) { + const key = `${h.ip}:${h.port}:${h.username}`; + existingHostMap.set(key, { id: h.id as number }); + } + } catch { + existingHostMap = undefined; + } + + const knownAuthTypes = listKnownAuthTypes(); + const writtenHostIds: number[] = []; + const createdHostIds: number[] = []; + for (const { host: hostData, index: i, exportId } of orderedHosts) { + try { + const effectiveConnectionType = hostData.connectionType || "ssh"; + + if ( + effectiveConnectionType === "ssh" && + hostData.authType === "credential" && + !hostData.credentialId && + hostData.credentialAlias + ) { + hostData.credentialId = credentialAliasMap.get( + hostData.credentialAlias.toLowerCase(), + ); + } + + if (!isNonEmptyString(hostData.ip) || !isValidPort(hostData.port)) { + results.failed++; + results.errors.push( + `Host ${i + 1}: Missing required fields (ip, port)`, + ); + continue; + } + + const username = importedHostUsername( + effectiveConnectionType, + hostData.authType, + hostData.username, + ); + if (username === null) { + results.failed++; + results.errors.push( + `Host ${i + 1}: Username required for SSH connections`, + ); + continue; + } + + const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`; + const existing = existingHostMap?.get(lookupKey); + if (existing && !overwrite) { + // Hosts that jump through this one still resolve to the kept host. + if (exportId !== undefined) importedIds.set(exportId, existing.id); + results.skipped++; + continue; + } + + if ( + effectiveConnectionType === "ssh" && + hostData.authType && + !knownAuthTypes.has(hostData.authType) + ) { + results.failed++; + results.errors.push( + `Host ${i + 1}: Invalid authType. Must be one of ${[...knownAuthTypes].join(", ")}`, + ); + continue; + } + + if ( + effectiveConnectionType === "ssh" && + hostData.authType === "password" && + !isNonEmptyString(hostData.password) + ) { + results.failed++; + results.errors.push( + `Host ${i + 1}: Password required for password authentication`, + ); + continue; + } + + if ( + effectiveConnectionType === "ssh" && + hostData.authType === "key" && + !isNonEmptyString(hostData.key) + ) { + results.failed++; + results.errors.push( + `Host ${i + 1}: Key required for key authentication`, + ); + continue; + } + + if ( + effectiveConnectionType === "ssh" && + hostData.authType === "credential" && + !hostData.credentialId + ) { + results.failed++; + results.errors.push( + `Host ${i + 1}: credentialId required for credential authentication`, + ); + continue; + } + + if ( + effectiveConnectionType === "ssh" && + hostData.authType === "credential" && + hostData.credentialId + ) { + const credentialRepository = createCurrentCredentialRepository(); + const cred = await credentialRepository.findByIdForUser( + userId, + hostData.credentialId, + ); + + if (!cred) { + const fallback = await credentialRepository.listByUserId(userId); + + if (fallback.length > 0) { + hostData.credentialId = fallback[0].id; + } else if (isNonEmptyString(hostData.key)) { + hostData.authType = "key"; + hostData.credentialId = undefined; + } else if (isNonEmptyString(hostData.password)) { + hostData.authType = "password"; + hostData.credentialId = undefined; + } else { + results.failed++; + results.errors.push( + `Host ${i + 1}: credentialId ${hostData.credentialId} not found and no fallback credential available`, + ); + continue; + } + } + } + + const jumpHosts = remapImportedJumpHosts(hostData.jumpHosts, importedIds); + const sshDataObj: Record = { + userId: userId, + connectionType: effectiveConnectionType, + name: hostData.name || `${username}@${hostData.ip}`, + folder: hostData.folder || "Default", + tags: Array.isArray(hostData.tags) ? hostData.tags.join(",") : "", + ip: hostData.ip, + port: hostData.port, + username, + pin: hostData.pin || false, + sudoPassword: hostData.sudoPassword || null, + jumpHosts: jumpHosts ? JSON.stringify(jumpHosts) : null, + ...importedStatusCheck(hostData as Record), + // A 2.8 export carries these inside terminalConfig. + sshOptions: + sshOptionsForWrite({ + sshOptions: hostData.sshOptions, + terminalConfig: hostData.terminalConfig, + }) ?? null, + forceKeyboardInteractive: hostData.forceKeyboardInteractive + ? "true" + : "false", + notes: hostData.notes || null, + useSocks5: hostData.useSocks5 ? 1 : 0, + socks5Host: hostData.socks5Host || null, + socks5Port: hostData.socks5Port || null, + socks5Username: hostData.socks5Username || null, + socks5Password: hostData.socks5Password || null, + socks5ProxyChain: hostData.socks5ProxyChain + ? JSON.stringify(hostData.socks5ProxyChain) + : null, + portKnockSequence: hostData.portKnockSequence + ? JSON.stringify(hostData.portKnockSequence) + : null, + overrideCredentialUsername: hostData.overrideCredentialUsername ? 1 : 0, + enableSsh: hostData.enableSsh ?? effectiveConnectionType === "ssh", + updatedAt: new Date().toISOString(), + }; + + if (effectiveConnectionType !== "ssh") { + sshDataObj.password = hostData.password || null; + sshDataObj.authType = "password"; + sshDataObj.credentialId = null; + sshDataObj.key = null; + sshDataObj.keyPassword = null; + sshDataObj.keyType = null; + } else { + sshDataObj.password = + hostData.authType === "password" ? hostData.password : null; + sshDataObj.authType = hostData.authType || "password"; + sshDataObj.credentialId = + hostData.authType === "credential" ? hostData.credentialId : null; + sshDataObj.key = hostData.authType === "key" ? hostData.key : null; + sshDataObj.keyPassword = + hostData.authType === "key" ? hostData.keyPassword || null : null; + sshDataObj.keyType = + hostData.authType === "key" ? hostData.keyType || "auto" : null; + } + + await withHostDefaults( + userId, + existing?.id ?? null, + sshDataObj, + hostData as Record, + ); + + let savedHostId: number; + if (existing) { + const saved = await hostRepository.updateEncryptedForUser( + userId, + existing.id, + sshDataObj, + ); + if (!saved) throw new Error("Host no longer exists"); + if (exportId !== undefined) importedIds.set(exportId, existing.id); + savedHostId = existing.id; + results.updated++; + } else { + sshDataObj.createdAt = new Date().toISOString(); + const saved = await hostRepository.createEncryptedForUser( + userId, + sshDataObj, + ); + if (exportId !== undefined) importedIds.set(exportId, saved.id); + savedHostId = saved.id; + createdHostIds.push(saved.id); + results.success++; + } + + const protocolAuth = readProtocolAuthPayload( + hostData as Record, + ); + if (protocolAuth) { + await writeProtocolAuth( + userId, + savedHostId, + await keepUsableProtocolCredentials(protocolAuth, userId), + { isOwner: true }, + ); + } + + // Every enabled plugin that declares host-scope settings and + // registered a hostImportNormalizer validates and writes its own + // fields here, so this loop does not need to know which plugins + // exist. See host-plugin-settings.ts. + await applyPluginHostImportSettings( + savedHostId, + hostData as Record, + ); + writtenHostIds.push(savedHostId); + } catch (error) { + results.failed++; + results.errors.push(`Host ${i + 1}: ${getErrorMessage(error)}`); + } + } + + results.createdHostIds = createdHostIds; + if (input.atomic && results.failed > 0) { + await rollBackCreated(userId, results); + return results; + } + + await applyDefaultsAfterHostWrites(writtenHostIds); + notifyImportedHosts(userId, writtenHostIds, createdHostIds); + results.createdHostIds = createdHostIds; + return results; +} + +/** The OpenSSH config import, shared by the route and ctx.hosts. */ +export async function runSshConfigImport( + userId: string, + input: { content?: unknown; overwrite?: unknown; atomic?: boolean }, +): Promise { + const { content, overwrite } = input; + if (input.atomic && overwrite) { + throw new ImportError("An atomic import cannot overwrite existing hosts"); + } + + if (!isNonEmptyString(content)) { + throw new ImportError("content is required and must be a non-empty string"); + } + + let parsed: SSHConfigHost[]; + try { + parsed = parseSSHConfig(content); + } catch { + throw new ImportError("Failed to parse SSH config file"); + } + + if (parsed.length === 0) { + throw new ImportError("No valid Host entries found in the SSH config file"); + } + + if (parsed.length > 100) { + throw new ImportError("Maximum 100 hosts allowed per import"); + } + + const hostsToImport = parsed.map((h) => ({ + name: h.name, + ip: h.hostname, + port: h.port ?? 22, + username: h.user, + authType: h.identityFile ? "key" : undefined, + connectionType: "ssh", + enableSsh: true, + ...(h.proxyJump + ? { + jumpHosts: [{ host: h.proxyJump, port: 22 }], + } + : {}), + })); + + const results: ImportResults = { + success: 0, + updated: 0, + skipped: 0, + failed: 0, + errors: [], + createdHostIds: [], + }; + + let existingHostMap: Map | undefined; + const hostRepository = createCurrentHostRepository(); + try { + const allHosts = + await createCurrentHostResolutionRepository().findHostsByUserId(userId); + existingHostMap = new Map(); + for (const h of allHosts) { + const key = `${h.ip}:${h.port}:${h.username}`; + existingHostMap.set(key, { id: h.id as number }); + } + } catch { + existingHostMap = undefined; + } + + const writtenHostIds: number[] = []; + const createdHostIds: number[] = []; + for (let i = 0; i < hostsToImport.length; i++) { + const hostData = normalizeImportedHost( + hostsToImport[i] as Record, + ); + + try { + if (!isNonEmptyString(hostData.ip) || !isValidPort(hostData.port)) { + results.failed++; + results.errors.push( + `Host "${parsed[i].name}": Missing required fields (HostName, Port)`, + ); + continue; + } + + const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`; + const existing = existingHostMap?.get(lookupKey); + if (existing && !overwrite) { + results.skipped++; + continue; + } + + const sshDataObj: Record = { + userId, + connectionType: "ssh", + name: hostData.name || hostData.ip, + folder: "Default", + tags: "", + ip: hostData.ip, + port: hostData.port, + username: hostData.username || null, + authType: hostData.authType || "none", + password: null, + key: null, + keyPassword: null, + keyType: null, + credentialId: null, + pin: false, + sudoPassword: null, + jumpHosts: hostData.jumpHosts + ? JSON.stringify(hostData.jumpHosts) + : null, + statusCheckEnabled: true, + statusCheckInterval: null, + sshOptions: null, + forceKeyboardInteractive: "false", + notes: null, + useSocks5: 0, + socks5Host: null, + socks5Port: null, + socks5Username: null, + socks5Password: null, + socks5ProxyChain: null, + portKnockSequence: null, + overrideCredentialUsername: 0, + enableSsh: true, + updatedAt: new Date().toISOString(), + }; + + await withHostDefaults( + userId, + existing?.id ?? null, + sshDataObj, + hostData as unknown as Record, + ); + + if (existing) { + await hostRepository.updateEncryptedForUser( + userId, + existing.id, + sshDataObj, + ); + writtenHostIds.push(existing.id); + results.updated++; + } else { + sshDataObj.createdAt = new Date().toISOString(); + const saved = await hostRepository.createEncryptedForUser( + userId, + sshDataObj, + ); + writtenHostIds.push(saved.id); + createdHostIds.push(saved.id); + results.success++; + } + } catch (error) { + results.failed++; + results.errors.push( + `Host "${parsed[i].name}": ${getErrorMessage(error)}`, + ); + } + } + + results.createdHostIds = createdHostIds; + if (input.atomic && results.failed > 0) { + await rollBackCreated(userId, results); + return results; + } + + await applyDefaultsAfterHostWrites(writtenHostIds); + notifyImportedHosts(userId, writtenHostIds, createdHostIds); + results.createdHostIds = createdHostIds; + return results; +} diff --git a/src/backend/hosts/resolve-host-credentials.ts b/src/backend/hosts/resolve-host-credentials.ts new file mode 100644 index 000000000..e3250020f --- /dev/null +++ b/src/backend/hosts/resolve-host-credentials.ts @@ -0,0 +1,211 @@ +import { getErrorMessage } from "../utils/error-message.js"; +import { sshLogger } from "../utils/logger.js"; +import { findUsableCredential } from "./usable-credential.js"; +import { + pickResolvedPassword, + pickResolvedUsername, +} from "./credential-username.js"; +import { createCurrentSharedHostAuthOverrideRepository } from "../database/repositories/factory.js"; +import { listHostProtocols } from "./protocol-auth/registry.js"; +import { + requiresPersonalHostAuthentication, + resolveRecipientSharedHostAuthentication, +} from "../utils/shared-host-auth-resolver.js"; +import type { + HostResolutionCredentialRecord, + HostResolutionHostRecord, +} from "../database/repositories/host-resolution-repository.js"; + +/** A host with its credential's secrets filled in for the asking user. */ +export async function resolveHostCredentials( + host: Record, + requestingUserId?: string, + /** + * Credentials already fetched for this request, keyed by id. The host list + * preloads them in one query; single-host callers omit it and fall back to + * fetching the one credential they need. + */ + preloadedCredentials?: Map, +): Promise> { + try { + const ownerId = (host.ownerId || host.userId) as string | undefined; + if ( + requestingUserId && + ownerId && + requestingUserId !== ownerId && + typeof host.id === "number" + ) { + const authHost = host as unknown as HostResolutionHostRecord; + const needsPersonalCredential = requiresPersonalHostAuthentication( + authHost, + "ssh", + ); + const baseSshOverrideState = { + required: needsPersonalCredential, + ownerAuthShared: !!host.shareSshAuth, + }; + // Owner auth for the remote desktop protocols is always snapshotted + // for recipients; only their own override credential varies per user. + const authOverrides: Record = { + ssh: baseSshOverrideState, + }; + const overrideCredentialIds = + await createCurrentSharedHostAuthOverrideRepository().listCredentialIds( + host.id, + requestingUserId, + ); + // Whether a plugin protocol is on is its plugin's host setting; the + // client only offers an override for one that is. + for (const { id: protocol } of listHostProtocols()) { + authOverrides[protocol] = { + credentialId: overrideCredentialIds[protocol], + required: false, + ownerAuthShared: true, + }; + } + const recipientHost: Record = { + ...host, + credentialId: null, + password: null, + key: null, + keyPassword: null, + keyType: null, + authOverrides, + }; + + try { + const resolution = await resolveRecipientSharedHostAuthentication( + authHost, + host.id, + requestingUserId, + "ssh", + ); + + if (resolution.source === "personal-override") { + const credential = resolution.credential; + return { + ...recipientHost, + authOverrides: { + ...authOverrides, + ssh: { + credentialId: resolution.credentialId, + required: false, + ownerAuthShared: !!host.shareSshAuth, + }, + }, + authType: + credential.key || credential.privateKey + ? "key" + : credential.password + ? "password" + : "none", + username: credential.username || recipientHost.username, + password: credential.password, + key: credential.privateKey || credential.key, + keyPassword: credential.keyPassword, + keyType: credential.keyType, + }; + } + + if (resolution.source === "owner-shared") { + if (resolution.authType === "agent") { + return { + ...recipientHost, + authOverrides: { + ...authOverrides, + ssh: { + required: false, + ownerAuthShared: true, + }, + }, + authType: "agent", + }; + } + + const sharedAuth = resolution.secret; + if (sharedAuth) { + const resolvedUsername = pickResolvedUsername( + recipientHost.username, + sharedAuth.username, + host.overrideCredentialUsername, + ); + return { + ...recipientHost, + authOverrides: { + ...authOverrides, + ssh: { + required: false, + ownerAuthShared: true, + }, + }, + authType: sharedAuth.key + ? "key" + : sharedAuth.password + ? "password" + : "none", + username: resolvedUsername, + password: sharedAuth.password, + key: sharedAuth.key, + keyPassword: sharedAuth.keyPassword, + keyType: sharedAuth.keyType, + }; + } + } + + if (resolution.source === "secretless") { + return { + ...recipientHost, + authOverrides: { + ...authOverrides, + ssh: { + required: false, + ownerAuthShared: !!host.shareSshAuth, + }, + }, + }; + } + } catch { + // A missing/deleted override or snapshot behaves like unavailable auth. + } + + return recipientHost; + } + + if (host.credentialId && (host.userId || host.ownerId)) { + const credentialId = host.credentialId as number; + const credentialOwnerId = (host.ownerId || host.userId) as string; + + const credential = + preloadedCredentials?.get(credentialId) ?? + (await findUsableCredential(credentialId, credentialOwnerId)); + + if (credential) { + const resolvedHost: Record = { + ...host, + password: pickResolvedPassword(host.password, credential.password), + key: credential.key, + keyPassword: credential.keyPassword, + keyType: credential.keyType, + }; + + const resolvedUsername = pickResolvedUsername( + host.username, + credential.username, + host.overrideCredentialUsername, + ); + if (resolvedUsername !== undefined) { + resolvedHost.username = resolvedUsername; + } + + return resolvedHost; + } + } + + return { ...host }; + } catch (error) { + sshLogger.warn( + `Failed to resolve credentials for host ${host.id}: ${getErrorMessage(error)}`, + ); + return host; + } +} diff --git a/src/backend/hosts/status/host-status-service.ts b/src/backend/hosts/status/host-status-service.ts deleted file mode 100644 index 96d47b976..000000000 --- a/src/backend/hosts/status/host-status-service.ts +++ /dev/null @@ -1,491 +0,0 @@ -/** - * The host list's status dot. - * - * Every host with status checks on gets a TCP connection to its port on a - * timer. A port that answers is "online", one that does not (twice in a row) - * is "offline". An open session or a working login also counts as online. - * Probing never logs in to the host itself: on RADIUS or Duo backed devices a - * login fires a real 2FA push, every interval. A host behind jump hosts is - * probed through a chain, which does log in to the hops, so hosts that share - * a hop list share one chain (jump-probe-pool.ts). - * - * Polling is demand driven. A user asking for statuses starts polling their - * own hosts; nothing is probed for a user who never opened the app. The first - * request waits briefly for those first checks so it is not empty. - */ - -import { pluginEvents, TOPICS } from "../../plugins/events.js"; -import { hostSessionStatus } from "../host-session-status.js"; -import { sshLogger } from "../../utils/logger.js"; -import { - createCurrentHostResolutionRepository, - getCurrentSettingValue, -} from "../../database/repositories/factory.js"; -import type { HostStatusTargetRow } from "../../database/repositories/host-resolution-repository.js"; -import type { HostStatus } from "./host-status.js"; -import { ConcurrentLimiter } from "./limiter.js"; -import { tcpPing } from "./tcp-ping.js"; -import { JumpProbePool } from "./jump-probe-pool.js"; - -export const GLOBAL_STATUS_INTERVAL_KEY = "global_status_check_interval"; -export const DEFAULT_STATUS_INTERVAL = 30; -/** How long the first request for a user waits for the first checks. */ -const FIRST_CHECK_WAIT_MS = 6_000; -/** A status younger than this is fresh enough for check(). */ -const FRESH_MS = 30_000; - -export interface HostStatusEntry { - status: HostStatus; - lastChecked: string; -} - -export interface HostStatusPayload { - hostId: number; - ownerUserId: string; - status: HostStatus; - previous: HostStatus | null; - /** Anything but offline, the shape automations' trigger already reads. */ - online: boolean; -} - -export interface StatusTarget { - id: number; - userId: string; - ip: string; - port: number; - connectionType: string; - jumpHosts: Array<{ hostId: number }>; - statusCheckEnabled: boolean; - statusCheckInterval: number | null; -} - -type PortResolver = ( - hostId: number, -) => number | undefined | Promise; - -export interface HostStatusDeps { - loadTargets: (filter: { - userId?: string; - hostIds?: number[]; - }) => Promise; - /** Hosts other users shared with this one, directly or through a role. */ - loadSharedHostIds?: (userId: string) => Promise; - ping: (host: string, port: number) => Promise; - pingThroughJumpHosts: ( - target: StatusTarget, - port: number, - ) => Promise; - hasActiveSession?: (hostId: number) => boolean; - globalInterval: () => number; - emit: (payload: HostStatusPayload) => void; - /** Pause before a failed ping is tried again. */ - retryDelayMs?: number; -} - -function parseJumpHosts(raw: string | null): Array<{ hostId: number }> { - if (!raw) return []; - try { - const parsed = JSON.parse(raw); - return Array.isArray(parsed) - ? parsed.filter( - (hop): hop is { hostId: number } => - !!hop && Number.isInteger((hop as { hostId?: unknown }).hostId), - ) - : []; - } catch { - return []; - } -} - -export function toStatusTarget(row: HostStatusTargetRow): StatusTarget { - return { - id: row.id, - userId: row.userId, - ip: row.ip, - port: row.port, - connectionType: row.connectionType || "ssh", - jumpHosts: parseJumpHosts(row.jumpHosts), - statusCheckEnabled: row.statusCheckEnabled !== false, - statusCheckInterval: row.statusCheckInterval ?? null, - }; -} - -const jumpProbes = new JumpProbePool(async (jumpHosts, userId) => { - const { createJumpHostChain } = await import("../jump-host-chain.js"); - return createJumpHostChain(jumpHosts, userId); -}); - -const defaultDeps: HostStatusDeps = { - loadTargets: async (filter) => - ( - await createCurrentHostResolutionRepository().listStatusTargets(filter) - ).map(toStatusTarget), - loadSharedHostIds: async (userId) => { - const { createCurrentRbacAccessRepository, createCurrentRoleRepository } = - await import("../../database/repositories/factory.js"); - const roleIds = await createCurrentRoleRepository().listUserRoleIds(userId); - const entries = - await createCurrentRbacAccessRepository().listVisibleHostAccessEntries( - userId, - roleIds, - ); - return [...new Set(entries.map((entry) => entry.hostId))]; - }, - hasActiveSession: (hostId) => hostSessionStatus.hasActiveSession(hostId), - ping: (host, port) => tcpPing(host, port, 5000), - pingThroughJumpHosts: (target, port) => - jumpProbes.ping(target.jumpHosts, target.userId, target.ip, port, 5000), - globalInterval: () => { - const value = Number(getCurrentSettingValue(GLOBAL_STATUS_INTERVAL_KEY)); - return Number.isInteger(value) && value >= 5 - ? value - : DEFAULT_STATUS_INTERVAL; - }, - emit: (payload) => pluginEvents.emit(TOPICS.hostStatus, payload), -}; - -interface Polled { - target: StatusTarget; - timer?: NodeJS.Timeout; -} - -export class HostStatusService { - private readonly polled = new Map(); - private readonly store = new Map(); - private readonly owners = new Map(); - private readonly inFlight = new Map>(); - private readonly startedUsers = new Set(); - private readonly portResolvers = new Map>(); - private readonly limiter = new ConcurrentLimiter(20); - private unsubscribers: Array<() => void> = []; - - constructor(private readonly deps: HostStatusDeps = defaultDeps) {} - - /** Subscribes to the core events the status depends on. */ - start(): void { - if (this.unsubscribers.length > 0) return; - this.unsubscribers = [ - hostSessionStatus.subscribe((hostId, online) => { - if (online) this.reportLogin(hostId, { ok: true }); - }), - pluginEvents.on(TOPICS.hostUpdated, (payload) => { - const { hostId } = payload as { hostId?: number }; - if (hostId) void this.reload(hostId); - }), - pluginEvents.on(TOPICS.hostDeleted, (payload) => { - const { hostId } = payload as { hostId?: number }; - if (hostId) this.forget(hostId); - }), - ]; - } - - stop(): void { - for (const unsubscribe of this.unsubscribers) unsubscribe(); - this.unsubscribers = []; - for (const hostId of [...this.polled.keys()]) this.stopPolling(hostId); - this.startedUsers.clear(); - } - - get(hostId: number): HostStatusEntry | null { - return this.store.get(hostId) ?? null; - } - - /** - * Statuses for a user's request. With hostIds, polling is brought in line - * with exactly those of their own hosts (the desktop app sends only the - * hosts that live on this backend). Without, their own hosts are started - * once. - */ - async statusesFor( - userId: string, - requestedHostIds: Set | null, - ): Promise> { - const started: Promise[] = []; - if (requestedHostIds !== null) { - await this.reconcile(userId, requestedHostIds, started); - } else if (!this.startedUsers.has(userId)) { - await this.startUser(userId, started); - } - if (started.length > 0) await this.waitFor(started); - return this.store; - } - - /** Drops every timer for a user's hosts and starts them again. */ - async refresh(userId: string): Promise { - const targets = await this.deps.loadTargets({ userId }); - for (const target of targets) this.stopPolling(target.id); - for (const target of targets) this.startPolling(target); - this.startedUsers.add(userId); - } - - /** Re-times every polled host, after the global interval changed. */ - retimeAll(): void { - for (const { target } of [...this.polled.values()]) { - this.startPolling(target, false); - } - } - - /** Checks now unless the last result is fresh. */ - async check(hostId: number): Promise { - const current = this.store.get(hostId); - if (current && Date.now() - Date.parse(current.lastChecked) < FRESH_MS) { - return current; - } - const target = - this.polled.get(hostId)?.target ?? - (await this.deps.loadTargets({ hostIds: [hostId] }))[0]; - if (!target || !target.statusCheckEnabled) return current ?? null; - await this.probe(target); - return this.store.get(hostId) ?? null; - } - - /** - * A working login proves the host is up. A failed one says nothing about - * reachability (a wrong password, another user's login), so it is ignored. - */ - reportLogin(hostId: number, outcome: { ok: boolean }): void { - if (outcome.ok) this.set(hostId, { status: "online" }); - } - - registerPort(connectionType: string, resolve: PortResolver): () => void { - let set = this.portResolvers.get(connectionType); - if (!set) { - set = new Set(); - this.portResolvers.set(connectionType, set); - } - set.add(resolve); - return () => { - set!.delete(resolve); - if (set!.size === 0) this.portResolvers.delete(connectionType); - }; - } - - private async waitFor(started: Promise[]): Promise { - let timer: NodeJS.Timeout | undefined; - await Promise.race([ - Promise.allSettled(started), - new Promise((resolve) => { - timer = setTimeout(resolve, FIRST_CHECK_WAIT_MS); - timer.unref?.(); - }), - ]); - if (timer) clearTimeout(timer); - } - - private track(started: Promise[], target: StatusTarget): void { - const probe = this.startPolling(target); - if (probe) started.push(probe); - } - - private async startUser( - userId: string, - started: Promise[], - ): Promise { - this.startedUsers.add(userId); - const targets = await this.deps.loadTargets({ userId }); - for (const target of targets) { - if (!this.polled.has(target.id)) this.track(started, target); - } - await this.startShared(userId, null, started); - } - - /** - * Hosts shared with the user are checked as their owners, even when no - * owner has asked for statuses since the server started. - */ - private async startShared( - userId: string, - allowed: Set | null, - started: Promise[], - ): Promise { - if (!this.deps.loadSharedHostIds) return; - try { - const shared = (await this.deps.loadSharedHostIds(userId)).filter( - (hostId) => - !this.polled.has(hostId) && (allowed === null || allowed.has(hostId)), - ); - if (shared.length === 0) return; - const targets = await this.deps.loadTargets({ hostIds: shared }); - for (const target of targets) { - if (!this.polled.has(target.id)) this.track(started, target); - } - } catch (error) { - sshLogger.warn("Could not start status checks for shared hosts", { - operation: "host_status_shared", - userId, - error: error instanceof Error ? error.message : String(error), - }); - } - } - - private async reconcile( - userId: string, - allowed: Set, - started: Promise[], - ): Promise { - const targets = await this.deps.loadTargets({ userId }); - for (const target of targets) { - if (!allowed.has(target.id)) { - this.stopPolling(target.id); - this.store.delete(target.id); - continue; - } - if (!this.polled.has(target.id)) this.track(started, target); - } - await this.startShared(userId, allowed, started); - } - - private async reload(hostId: number): Promise { - try { - const [target] = await this.deps.loadTargets({ hostIds: [hostId] }); - if (!target) { - this.forget(hostId); - return; - } - if (this.polled.has(hostId) || this.startedUsers.has(target.userId)) { - this.startPolling(target); - } - } catch (error) { - sshLogger.warn("Could not reload host for status checks", { - operation: "host_status_reload", - hostId, - error: error instanceof Error ? error.message : String(error), - }); - } - } - - private forget(hostId: number): void { - this.stopPolling(hostId); - this.store.delete(hostId); - this.owners.delete(hostId); - } - - private intervalMs(target: StatusTarget): number { - const seconds = target.statusCheckInterval ?? this.deps.globalInterval(); - const intervalMs = Math.max(5, seconds) * 1000; - // Spread hosts so a fleet does not fire on the same second. - const spread = Math.min(intervalMs * 0.2, 15_000); - return ( - intervalMs + ((target.id * 1103515245) % Math.max(1, Math.floor(spread))) - ); - } - - /** Returns the first probe when one was started. */ - private startPolling( - target: StatusTarget, - probeNow = true, - ): Promise | undefined { - this.stopPolling(target.id); - this.owners.set(target.id, target.userId); - if (!target.statusCheckEnabled) { - this.store.delete(target.id); - return undefined; - } - const polled: Polled = { target }; - this.polled.set(target.id, polled); - const first = probeNow ? this.probe(target) : undefined; - polled.timer = setInterval(() => { - const latest = this.polled.get(target.id); - if (latest) void this.probe(latest.target); - }, this.intervalMs(target)); - polled.timer.unref?.(); - return first; - } - - private stopPolling(hostId: number): void { - const polled = this.polled.get(hostId); - if (polled?.timer) clearInterval(polled.timer); - this.polled.delete(hostId); - } - - private probe(target: StatusTarget): Promise { - const running = this.inFlight.get(target.id); - if (running) return running; - const job = this.limiter - .run(() => this.probeNow(target)) - .catch((error) => { - sshLogger.error("Status check failed", error, { - operation: "host_status_probe", - hostId: target.id, - }); - }) - .finally(() => this.inFlight.delete(target.id)); - this.inFlight.set(target.id, job); - return job; - } - - private async portFor(target: StatusTarget): Promise { - if (target.connectionType !== "ssh") { - for (const resolve of this.portResolvers.get(target.connectionType) ?? - []) { - try { - const port = await resolve(target.id); - if (Number.isInteger(port) && port! > 0) return port!; - } catch { - // fall through to the next resolver - } - } - } - return target.port; - } - - private async pingOnce(target: StatusTarget): Promise { - try { - if (this.deps.hasActiveSession?.(target.id)) return true; - const port = await this.portFor(target); - return target.jumpHosts.length > 0 - ? await this.deps.pingThroughJumpHosts(target, port) - : await this.deps.ping(target.ip, port); - } catch { - return false; - } - } - - private async probeNow(target: StatusTarget): Promise { - this.owners.set(target.id, target.userId); - let reachable = await this.pingOnce(target); - if (!reachable) { - // One lost packet should not flip the dot. - const delay = this.deps.retryDelayMs ?? 1000; - if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay)); - reachable = await this.pingOnce(target); - } - this.set(target.id, { status: reachable ? "online" : "offline" }); - } - - private set(hostId: number, entry: Omit) { - const previous = this.store.get(hostId)?.status ?? null; - const next: HostStatusEntry = { - ...entry, - lastChecked: new Date().toISOString(), - }; - this.store.set(hostId, next); - if (previous === next.status) return; - void this.emitChange(hostId, next, previous); - } - - private async emitChange( - hostId: number, - entry: HostStatusEntry, - previous: HostStatus | null, - ): Promise { - let ownerUserId = this.owners.get(hostId); - if (!ownerUserId) { - const [target] = await this.deps - .loadTargets({ hostIds: [hostId] }) - .catch(() => []); - if (!target) return; - ownerUserId = target.userId; - this.owners.set(hostId, ownerUserId); - } - this.deps.emit({ - hostId, - ownerUserId, - status: entry.status, - previous, - online: entry.status === "online", - }); - } -} - -export const hostStatusService = new HostStatusService(); diff --git a/src/backend/hosts/status/host-status.ts b/src/backend/hosts/status/host-status.ts deleted file mode 100644 index 5e045910a..000000000 --- a/src/backend/hosts/status/host-status.ts +++ /dev/null @@ -1,9 +0,0 @@ -export type HostStatus = "online" | "offline"; - -export function isHostKeyVerificationError(error: unknown): boolean { - return ( - error instanceof Error && - (error.message.includes("Host denied (verification failed)") || - error.message.includes("Host key changed")) - ); -} diff --git a/src/backend/hosts/status/limiter.ts b/src/backend/hosts/status/limiter.ts deleted file mode 100644 index c1d1321b8..000000000 --- a/src/backend/hosts/status/limiter.ts +++ /dev/null @@ -1,81 +0,0 @@ -/** - * Limits how many async jobs run at once. Extra callers wait in FIFO order. - */ -export class ConcurrentLimiter { - private active = 0; - private readonly waiters: Array<() => void> = []; - - constructor(private maxConcurrent: number) { - if (maxConcurrent < 1) { - throw new Error("maxConcurrent must be >= 1"); - } - } - - get activeCount(): number { - return this.active; - } - - get pendingCount(): number { - return this.waiters.length; - } - - get limit(): number { - return this.maxConcurrent; - } - - /** - * Changes the ceiling at runtime. - * - * Raising it wakes the waiters the new headroom allows, so a queue that built - * up under the old limit drains at once instead of one job at a time as - * running work finishes. Lowering it never interrupts work already running; - * the new limit simply applies from the next release onward. - */ - setLimit(maxConcurrent: number): void { - if (maxConcurrent < 1) { - throw new Error("maxConcurrent must be >= 1"); - } - this.maxConcurrent = maxConcurrent; - this.releaseWaiters(); - } - - /** - * Wakes as many queued callers as there is now room for. - * - * `woken` counts callers that have been resumed but have not yet reached the - * `active += 1` on the far side of their await. Without it the occupancy - * looks lower than it really is for a microtask, and the loop would release - * past the ceiling. - */ - private releaseWaiters(): void { - while ( - this.active + this.woken < this.maxConcurrent && - this.waiters.length > 0 - ) { - this.woken += 1; - this.waiters.shift()!(); - } - } - - private woken = 0; - - async run(fn: () => Promise): Promise { - // Queue behind anything already woken, so a caller arriving mid-handoff - // cannot jump the queue and push occupancy past the ceiling. - if (this.active + this.woken >= this.maxConcurrent) { - await new Promise((resolve) => { - this.waiters.push(resolve); - }); - // Resumed by a slot handoff; that reservation is now consumed. - this.woken = Math.max(0, this.woken - 1); - } - - this.active += 1; - try { - return await fn(); - } finally { - this.active -= 1; - this.releaseWaiters(); - } - } -} diff --git a/src/backend/plugins/activity-provider.ts b/src/backend/plugins/activity-provider.ts new file mode 100644 index 000000000..27ed80084 --- /dev/null +++ b/src/backend/plugins/activity-provider.ts @@ -0,0 +1,105 @@ +/** + * Recent activity is a provider: core owns the callers (the dashboard routes, + * ctx.hosts.recordActivity, the cleanup when a user or host goes away) and one + * provider owns the storage. A plugin registers with + * ctx.hosts.activity.provide. With none, nothing is recorded and the list is + * empty. Core checks that the user may reach the host before it records. + */ + +export interface ActivityEntry { + id: number; + userId: string; + type: string; + hostId: number; + hostName: string | null; + timestamp: string; +} + +export type RecordActivityResult = + | { status: "logged"; id: number } + | { status: "rate_limited" } + | { status: "invalid_type" } + | { status: "denied" } + | { status: "unavailable" }; + +export interface ActivityProvider { + record( + userId: string, + entry: { type: string; hostId: number; hostName: string }, + ): Promise; + list(userId: string, limit: number): Promise; + deleteByUserId(userId: string): Promise; + deleteByHostIds(hostIds: number[]): Promise; +} + +let plugged: { pluginId: string; provider: ActivityProvider } | null = null; + +export function provideActivity( + pluginId: string, + provider: ActivityProvider, +): () => void { + if (plugged) { + throw new Error( + `Recent activity is already provided by plugin "${plugged.pluginId}"`, + ); + } + const entry = { pluginId, provider }; + plugged = entry; + return () => { + if (plugged === entry) plugged = null; + }; +} + +function active(): ActivityProvider | null { + return plugged?.provider ?? null; +} + +async function canReachHost(userId: string, hostId: number): Promise { + const { + createCurrentHostResolutionRepository, + createCurrentRbacAccessRepository, + createCurrentRoleRepository, + } = await import("../database/repositories/factory.js"); + if ( + await createCurrentHostResolutionRepository().isHostOwnedByUser( + hostId, + userId, + ) + ) { + return true; + } + const roleIds = await createCurrentRoleRepository().listUserRoleIds(userId); + const shared = + await createCurrentRbacAccessRepository().listVisibleHostAccessEntries( + userId, + roleIds, + ); + return shared.some((access) => access.hostId === hostId); +} + +/** What the rest of core calls. */ +export const recentActivity = { + async record( + userId: string, + entry: { type: string; hostId: number; hostName: string }, + ): Promise { + const provider = active(); + if (!provider) return { status: "unavailable" }; + if (!(await canReachHost(userId, entry.hostId))) { + return { status: "denied" }; + } + return provider.record(userId, entry); + }, + async list(userId: string, limit: number): Promise { + return (await active()?.list(userId, limit)) ?? []; + }, + async deleteByUserId(userId: string): Promise { + await active()?.deleteByUserId(userId); + }, + async deleteByHostId(hostId: number): Promise { + await active()?.deleteByHostIds([hostId]); + }, + async deleteByHostIds(hostIds: number[]): Promise { + await active()?.deleteByHostIds(hostIds); + }, +}; diff --git a/src/backend/plugins/ctx-audit.ts b/src/backend/plugins/ctx-audit.ts new file mode 100644 index 000000000..f14eee301 --- /dev/null +++ b/src/backend/plugins/ctx-audit.ts @@ -0,0 +1,143 @@ +/** + * ctx.audit.subscribe, list and exportBatches: reading the audit trail, + * behind audit:read. Writing lines (record) stays in ctx.ts. + */ + +import type { + PluginAudit, + PluginAuditEntry, + PluginAuditFilters, +} from "@termix-ssh/plugin-sdk/backend"; +import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest"; +import { assertCapability, capabilityRefused } from "./permissions.js"; +import type { DisposableBag } from "./disposables.js"; +import { onAuditEntry } from "../utils/audit-hooks.js"; +import type { AuditLogRecord } from "../database/repositories/audit-log-repository.js"; + +type AuditFn = ( + action: string, + details: string, + outcome: { success: boolean; errorMessage?: string }, +) => Promise; + +interface Deps { + manifest: PluginManifest; + bag: DisposableBag; + audit: AuditFn; +} + +const MAX_PAGE = 200; +const MAX_BATCH = 1000; + +function toEntry(row: AuditLogRecord): PluginAuditEntry { + return { + id: row.id, + timestamp: new Date( + row.timestamp as unknown as string | number, + ).toISOString(), + userId: row.userId ?? null, + username: row.username, + action: row.action, + resourceType: row.resourceType, + resourceId: row.resourceId ?? null, + resourceName: row.resourceName ?? null, + details: row.details ?? null, + ipAddress: row.ipAddress ?? null, + userAgent: row.userAgent ?? null, + success: Boolean(row.success), + errorMessage: row.errorMessage ?? null, + }; +} + +export function createPluginAuditReader( + deps: Deps, +): Pick { + const { manifest, bag, audit } = deps; + const require = (action: string) => + assertCapability(manifest.id, "audit:read", manifest.capabilities, action); + + return { + subscribe: (handler) => { + if (!manifest.capabilities.includes("audit:read")) { + throw capabilityRefused(manifest.id, "audit:read", "audit_subscribe"); + } + let remove: (() => void) | null = null; + let forgetBag: (() => void) | null = null; + // Granted state is checked once, then every line is delivered until + // unsubscribed; a revoked grant stops the next subscribe. + void require("audit_subscribe") + .then(async () => { + await audit("audit_subscribe", "listened to the audit log", { + success: true, + }); + remove = onAuditEntry((entry) => { + handler({ + timestamp: entry.timestamp.toISOString(), + userId: entry.userId, + username: entry.username, + action: entry.action, + resourceType: entry.resourceType, + resourceId: entry.resourceId ?? null, + resourceName: entry.resourceName ?? null, + details: entry.details ?? null, + ipAddress: entry.ipAddress ?? null, + userAgent: entry.userAgent ?? null, + success: entry.success, + errorMessage: entry.errorMessage ?? null, + }); + }); + }) + .catch(() => {}); + forgetBag = bag.add(() => remove?.(), "audit subscription"); + return () => { + remove?.(); + remove = null; + forgetBag?.(); + }; + }, + + list: async (options) => { + await require("audit_list"); + const { createCurrentAuditLogRepository } = + await import("../database/repositories/factory.js"); + const limit = Math.min(MAX_PAGE, Math.max(1, options?.limit ?? 50)); + const page = await createCurrentAuditLogRepository().listPage({ + filters: options?.filters ?? {}, + limit, + offset: Math.max(0, options?.offset ?? 0), + }); + await audit("audit_list", "read audit log entries", { success: true }); + return { entries: page.logs.map(toEntry), total: page.total }; + }, + + exportBatches: async function* ( + filters?: PluginAuditFilters, + batchSize?: number, + ) { + await require("audit_export"); + const { createCurrentAuditLogRepository } = + await import("../database/repositories/factory.js"); + const limit = Math.min(MAX_BATCH, Math.max(1, batchSize ?? 500)); + let offset = 0; + let exported = 0; + try { + for (;;) { + const rows = await createCurrentAuditLogRepository().listForExport({ + filters: filters ?? {}, + limit, + offset, + }); + if (rows.length === 0) break; + exported += rows.length; + offset += rows.length; + yield rows.map(toEntry); + if (rows.length < limit) break; + } + } finally { + await audit("audit_export", `exported ${exported} audit log entries`, { + success: true, + }); + } + }, + }; +} diff --git a/src/backend/plugins/ctx-credentials.ts b/src/backend/plugins/ctx-credentials.ts index 8b3519151..cfe5e1532 100644 --- a/src/backend/plugins/ctx-credentials.ts +++ b/src/backend/plugins/ctx-credentials.ts @@ -194,6 +194,74 @@ export function createPluginCredentials({ } }, + publicKey: async (credentialId) => { + const details = `public key of credential ${credentialId}`; + try { + await assertCapability(pluginId, "credentials:use", declared); + const userId = actingUser(); + await requireCorePermission(userId, "credentials.view"); + if (!Number.isInteger(credentialId) || credentialId <= 0) { + throw new Error("A credential id is required"); + } + const { createCurrentHostResolutionRepository } = + await import("../database/repositories/factory.js"); + const credential = + await createCurrentHostResolutionRepository().findCredentialByIdForUser( + credentialId, + userId, + ); + const row = credential as unknown as Record | null; + if (!row || row.authType !== "key") { + await audit("credentials_public_key", details, { success: true }); + return null; + } + let publicKey = + typeof row.publicKey === "string" && row.publicKey.trim() + ? row.publicKey.trim() + : null; + if (!publicKey) { + const stored = str(row.privateKey) || str(row.key); + if (stored) { + const { resolveKeyReferences } = + await import("../hosts/stored-ssh-key.js"); + const resolved = await resolveKeyReferences( + userId, + stored, + typeof row.keyPassword === "string" ? row.keyPassword : null, + ); + publicKey = derivePublicKey(resolved.key, resolved.passphrase); + } + } + await audit("credentials_public_key", details, { success: true }); + return publicKey; + } catch (error) { + await audit("credentials_public_key", details, { + success: false, + errorMessage: errorText(error), + }); + throw error; + } + }, + + resolveKeyRefs: async (key, passphrase) => { + const details = "resolved key references"; + try { + await assertCapability(pluginId, "credentials:read", declared); + const userId = actingUser(); + const { resolveKeyReferences } = + await import("../hosts/stored-ssh-key.js"); + const resolved = await resolveKeyReferences(userId, key, passphrase); + await audit("credentials_resolve_key", details, { success: true }); + return resolved; + } catch (error) { + await audit("credentials_resolve_key", details, { + success: false, + errorMessage: errorText(error), + }); + throw error; + } + }, + registerSecretResolver: (scheme, resolve) => { if (!declared.includes("auth:provide")) { throw capabilityRefused(pluginId, "auth:provide"); diff --git a/src/backend/plugins/ctx-desktop-local.ts b/src/backend/plugins/ctx-desktop-local.ts new file mode 100644 index 000000000..d14231d77 --- /dev/null +++ b/src/backend/plugins/ctx-desktop-local.ts @@ -0,0 +1,259 @@ +/** + * ctx.desktop.local: the user's own machine, behind desktop:local. + * + * Only works on the desktop app's embedded backend. On a server every call + * throws, and available() is false. Path rules live in desktop-local/local-fs. + */ + +import type { + PluginDesktopLocal, + PluginLocalPty, +} from "@termix-ssh/plugin-sdk/backend"; +import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest"; +import { assertCapability } from "./permissions.js"; +import type { DisposableBag } from "./disposables.js"; +import * as localFs from "./desktop-local/local-fs.js"; +import { remoteJson, requestHeaders } from "../sync/client/http.js"; +import { spawnLocalPty } from "./desktop-local/local-pty.js"; + +type AuditFn = ( + action: string, + details: string, + outcome: { success: boolean; errorMessage?: string }, +) => Promise; + +interface Deps { + manifest: PluginManifest; + bag: DisposableBag; + audit: AuditFn; +} + +function isEmbedded(): boolean { + return process.env.ELECTRON_EMBEDDED === "true"; +} + +export function createPluginDesktopLocal(deps: Deps): PluginDesktopLocal { + const { manifest, bag, audit } = deps; + const audited = new Set(); + + const guard = async (action: string, details: string, every = false) => { + await assertCapability( + manifest.id, + "desktop:local", + manifest.capabilities, + action, + ); + if (!isEmbedded()) { + throw new Error( + `Plugin ${manifest.id} used the local machine outside the desktop app`, + ); + } + if (every || !audited.has(action)) { + audited.add(action); + await audit(action, details, { success: true }); + } + }; + + const mainRequest = async (channel: string, path: string) => { + const { requestFromElectronMain } = + await import("../utils/electron-ipc-bridge.js"); + return requestFromElectronMain<{ success: boolean; error?: string }>( + channel, + { path }, + ); + }; + + return { + available: () => + isEmbedded() && manifest.capabilities.includes("desktop:local"), + + pluginFetch: async (origin, subPath, init) => { + await guard("desktop_local_fetch", "called its own routes"); + if ( + typeof subPath !== "string" || + !subPath.startsWith("/") || + subPath.startsWith("//") || + subPath.includes("\\") || + /(^|\/)\.\.?(\/|\?|$)/.test(subPath) + ) { + throw new Error("Invalid plugin route path"); + } + const route = `/plugin-api/${manifest.id}${subPath}`; + const headers: Record = { ...init?.headers }; + for (const name of Object.keys(headers)) { + if (/^(authorization|cookie|host)$/i.test(name)) delete headers[name]; + } + if (origin === "remote") { + const { getLink } = await import("../sync/client/link-store.js"); + const link = await getLink(); + if (!link) throw new Error("This device is not linked to a server"); + const { remoteStreamFetch } = await import("../sync/client/http.js"); + return (await remoteStreamFetch(link, route, { + method: init?.method, + headers, + body: init?.body, + signal: init?.signal, + })) as never; + } + if (origin !== "local") throw new Error("Unknown origin"); + const { getActor } = await import("./actor.js"); + const actor = getActor(); + if (!actor) throw new Error("No acting user"); + const { AuthManager } = await import("../utils/auth-manager.js"); + const session = await AuthManager.getInstance().issuePluginSession( + actor, + { + pluginId: manifest.id, + purpose: "desktop local transfer", + ttlSeconds: 900, + }, + ); + const port = Number(process.env.EMBEDDED_BACKEND_PORT) || 30001; + const { fetch: undiciFetch } = await import("undici"); + return (await undiciFetch(`http://127.0.0.1:${port}${route}`, { + method: init?.method ?? "GET", + headers: { + ...headers, + "X-Electron-App": "true", + Authorization: `Bearer ${session.token}`, + }, + body: init?.body as never, + duplex: "half", + signal: init?.signal, + } as never)) as never; + }, + + info: async () => { + await guard("desktop_local_info", "read local machine info"); + const home = await localFs.homeInfo(); + const { getLink } = await import("../sync/client/link-store.js"); + const link = await getLink(); + return { + ...home, + userDataDir: process.env.ELECTRON_USER_DATA ?? "", + backendPort: Number(process.env.EMBEDDED_BACKEND_PORT) || 30001, + link: link + ? { + serverUrl: link.serverUrl, + token: link.sessionToken, + headers: requestHeaders(link), + allowInvalidCertificate: !!link.allowInvalidCertificate, + } + : null, + }; + }, + + linkJson: async (pathname) => { + await guard("desktop_local_link", "called the linked server"); + const { getLink } = await import("../sync/client/link-store.js"); + const link = await getLink(); + if (!link) + throw new Error("This device is not linked to a Termix server"); + if (!pathname.startsWith("/")) throw new Error("Invalid path"); + return remoteJson( + { + serverUrl: link.serverUrl, + sessionToken: link.sessionToken, + customHeaders: link.customHeaders, + basicAuth: link.basicAuth, + allowInvalidCertificate: link.allowInvalidCertificate, + }, + pathname, + ); + }, + + fs: { + list: async (dirPath) => { + await guard("desktop_local_fs", "used the local file system"); + return localFs.listDirectory(dirPath); + }, + mkdir: async (parentPath, name) => { + await guard("desktop_local_fs", "used the local file system"); + return localFs.makeDirectory(parentPath, name); + }, + createFile: async (parentPath, name) => { + await guard("desktop_local_fs", "used the local file system"); + return localFs.createEmptyFile(parentPath, name); + }, + rename: async (path, newName) => { + await guard("desktop_local_fs", "used the local file system"); + return localFs.renameEntry(path, newName); + }, + ensureDir: async (dirPath, rootPath) => { + await guard("desktop_local_fs", "used the local file system"); + return localFs.ensureDirectory(dirPath, rootPath); + }, + exists: async (paths) => { + await guard("desktop_local_fs", "used the local file system"); + return localFs.existingPaths(paths); + }, + walk: async (rootPaths) => { + await guard("desktop_local_fs", "used the local file system"); + if (!Array.isArray(rootPaths) || rootPaths.length === 0) { + throw new Error("No local paths provided"); + } + return localFs.walkPaths(rootPaths); + }, + trash: async (paths) => { + await guard( + "desktop_local_trash", + `moved ${Array.isArray(paths) ? paths.length : 0} local items to the trash`, + true, + ); + if (!Array.isArray(paths) || paths.length === 0) { + throw new Error("No local paths provided"); + } + const failed: Array<{ path: string; error: string }> = []; + for (const candidate of paths) { + const target = localFs.normalizeLocalPath(candidate); + try { + const result = await mainRequest("shell-trash-item", target); + if (!result?.success) throw new Error(result?.error ?? "Failed"); + } catch (error) { + failed.push({ + path: target, + error: error instanceof Error ? error.message : String(error), + }); + } + } + return { trashed: paths.length - failed.length, failed }; + }, + reveal: async (path) => { + await guard("desktop_local_fs", "used the local file system"); + await mainRequest("shell-show-item", localFs.normalizeLocalPath(path)); + }, + open: async (path) => { + await guard("desktop_local_open", "opened a local path", true); + const result = await mainRequest( + "shell-open-path", + localFs.normalizeLocalPath(path), + ); + if (!result?.success) throw new Error(result?.error ?? "Failed"); + }, + openRead: async (path) => { + await guard("desktop_local_fs", "used the local file system"); + return localFs.openRead(path); + }, + openDownload: async (options) => { + await guard("desktop_local_fs", "used the local file system"); + return localFs.openDownload(options); + }, + }, + + pty: { + spawn: async (options) => { + await guard("desktop_local_pty", "started a local shell", true); + const child: PluginLocalPty = spawnLocalPty(options); + // A shell never outlives the plugin that started it. + bag.add(() => { + try { + child.kill(); + } catch { + // already gone + } + }); + return child; + }, + }, + }; +} diff --git a/src/backend/plugins/ctx-host-import.ts b/src/backend/plugins/ctx-host-import.ts new file mode 100644 index 000000000..64f5528d2 --- /dev/null +++ b/src/backend/plugins/ctx-host-import.ts @@ -0,0 +1,192 @@ +/** + * ctx.hosts.importMany, importSshConfig, exportMany and registerImportNormalizer. + * + * The import and export logic is the same code the host routes run + * (hosts/import/), so a plugin gets the same validation, defaults, protocol + * logins and plugin settings. What this file adds is the capability checks, + * the core permission checks, one batched save and the audit lines. + */ + +import type { + PluginHostExport, + PluginHostImportNormalizer, + PluginHostImportOptions, + PluginHostImportResult, + PluginHosts, +} from "@termix-ssh/plugin-sdk/backend"; +import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest"; +import { assertCapability, capabilityRefused } from "./permissions.js"; +import { getActor } from "./actor.js"; +import * as registry from "./registry.js"; +import type { DisposableBag } from "./disposables.js"; +import { DatabaseSaveTrigger } from "../utils/database-save-trigger.js"; +import { PermissionManager } from "../utils/permission-manager.js"; + +type AuditFn = ( + action: string, + details: string, + outcome: { success: boolean; errorMessage?: string }, +) => Promise; + +interface Deps { + manifest: PluginManifest; + bag?: DisposableBag; + audit: AuditFn; +} + +type ImportApi = Pick< + PluginHosts, + "importMany" | "importSshConfig" | "exportMany" | "registerImportNormalizer" +>; + +function actingUser(): string { + const actor = getActor(); + if (!actor) throw new Error("This call needs an acting user"); + return actor; +} + +async function requirePermissions(userId: string, names: string[]) { + const permissions = PermissionManager.getInstance(); + for (const name of names) { + if (!(await permissions.hasPermission(userId, name))) { + throw new Error(`The acting user lacks the ${name} permission`); + } + } +} + +function toResult( + results: Awaited< + ReturnType + >, +): PluginHostImportResult { + return { + success: results.success, + updated: results.updated, + skipped: results.skipped, + failed: results.failed, + errors: results.errors, + createdHostIds: results.createdHostIds, + ...(results.rolledBack ? { rolledBack: true } : {}), + }; +} + +export function createPluginHostImport({ + manifest, + bag, + audit, +}: Deps): ImportApi { + const pluginId = manifest.id; + const declared = manifest.capabilities; + + const runImport = async ( + action: string, + run: (userId: string) => Promise, + needsCredentials: boolean, + ): Promise => { + try { + await assertCapability(pluginId, "hosts:write", declared, action); + if (needsCredentials) { + await assertCapability(pluginId, "credentials:write", declared, action); + } + const userId = actingUser(); + await requirePermissions(userId, ["hosts.create", "hosts.edit"]); + + const result = await DatabaseSaveTrigger.batched(() => run(userId))(); + await audit( + action, + `${result.success} created, ${result.updated} updated, ${result.skipped} skipped, ${result.failed} failed`, + { success: true }, + ); + return result; + } catch (error) { + await audit(action, "host import", { + success: false, + errorMessage: error instanceof Error ? error.message : String(error), + }); + throw error; + } + }; + + return { + importMany: (hosts, options?: PluginHostImportOptions) => + runImport( + "hosts_import", + async (userId) => { + const { runBulkImport } = + await import("../hosts/import/host-import.js"); + return toResult( + await runBulkImport(userId, { + hosts, + overwrite: options?.overwrite, + credentials: options?.credentials, + atomic: options?.atomic, + }), + ); + }, + Array.isArray(options?.credentials) && options.credentials.length > 0, + ), + + importSshConfig: (content, options) => + runImport( + "hosts_import_ssh_config", + async (userId) => { + const { runSshConfigImport } = + await import("../hosts/import/host-import.js"); + return toResult( + await runSshConfigImport(userId, { + content, + overwrite: options?.overwrite, + atomic: options?.atomic, + }), + ); + }, + false, + ), + + exportMany: async (options): Promise => { + const share = options?.share === true; + try { + await assertCapability( + pluginId, + "hosts:export", + declared, + "hosts_export", + ); + const userId = actingUser(); + const { exportAllHosts } = + await import("../hosts/import/host-export.js"); + const exported = await exportAllHosts(userId, share, options?.hostIds); + await audit( + "export_hosts", + `exported ${exported.hosts.length} hosts${share ? " without secrets" : " with secrets"}`, + { success: true }, + ); + return exported; + } catch (error) { + await audit("export_hosts", share ? "share export" : "full export", { + success: false, + errorMessage: error instanceof Error ? error.message : String(error), + }); + throw error; + } + }, + + registerImportNormalizer: (normalizer: PluginHostImportNormalizer) => { + if (!declared.includes("hosts:write")) { + throw capabilityRefused(pluginId, "hosts:write"); + } + // Same key the import path has always read, so the older + // ctx.registry.provide form keeps working. + const key = `${pluginId}.hostImportNormalizer`; + registry.provide(key, normalizer); + const remove = () => { + registry.revoke(key, normalizer); + }; + const drop = bag?.add(remove, "host import normalizer"); + return () => { + drop?.(); + remove(); + }; + }, + }; +} diff --git a/src/backend/plugins/ctx-hosts.ts b/src/backend/plugins/ctx-hosts.ts index c94ec7e71..6b5c7f14a 100644 --- a/src/backend/plugins/ctx-hosts.ts +++ b/src/backend/plugins/ctx-hosts.ts @@ -26,7 +26,10 @@ import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest"; import { assertCapability, capabilityRefused } from "./permissions.js"; import { hostSessionStatus } from "../hosts/host-session-status.js"; import { getActor } from "./actor.js"; -import { hostStatusService } from "../hosts/status/host-status-service.js"; +import { hostStatus, provideHostStatus } from "./host-status-provider.js"; +import { provideActivity, recentActivity } from "./activity-provider.js"; +import { loadProbeTargets } from "./ctx-probe.js"; +import { createPluginHostImport } from "./ctx-host-import.js"; import { parseSshOptions } from "../hosts/ssh-options.js"; import { hostTerminalExport } from "../database/routes/host-normalizers.js"; import type { DisposableBag } from "./disposables.js"; @@ -171,7 +174,7 @@ export function toRecord( connectionOrigin: origin === "local" || origin === "remote" ? origin : null, sshOptions: hostTerminalExport(host).sshOptions, pluginSettings, - status: Number.isInteger(id) ? hostStatusService.get(id) : null, + status: Number.isInteger(id) ? hostStatus.get(id) : null, localOnly: asFlag(host.localOnly, false), createdAt: (host.createdAt as string | null) ?? null, updatedAt: (host.updatedAt as string | null) ?? null, @@ -389,6 +392,8 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts { }; return { + ...createPluginHostImport({ manifest, bag, audit }), + list: async () => { await requireRead(); const userId = actingUser(); @@ -787,38 +792,100 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts { await requireRead(); const userId = actingUser(); if (!(await actorMayReach(hostId))) return; - const { recordRecentActivity } = - await import("../services/recent-activity.js"); - await recordRecentActivity(userId, { type, hostId, hostName }); + await recentActivity.record(userId, { type, hostId, hostName }); + }, + + probeTargets: async (filter) => { + await requireRead(); + const actor = getActor(); + if (!actor) { + throw new Error("ctx.hosts.probeTargets needs an acting user"); + } + const ids = filter?.hostIds?.filter((id) => Number.isInteger(id)); + return loadProbeTargets(actor, ids); + }, + + sessionActive: (hostId) => { + requireReadSync(); + return hostSessionStatus.hasActiveSession(hostId); + }, + + activity: { + provide: (provider) => { + if (!declared.includes("hosts:activity-provide")) { + throw capabilityRefused(pluginId, "hosts:activity-provide"); + } + const remove = provideActivity( + pluginId, + provider as Parameters[1], + ); + void audit( + "host_activity_provide", + `${pluginId} stores recent activity`, + { + success: true, + }, + ); + const drop = bag?.add(remove, "activity provider"); + return () => { + drop?.(); + remove(); + }; + }, }, // Not audited: pollers call these on every sample. status: { + provide: (provider) => { + requireReadSync(); + if (!declared.includes("hosts:status-provide")) { + throw capabilityRefused(pluginId, "hosts:status-provide"); + } + const remove = provideHostStatus(pluginId, { + start: () => provider.start(), + stop: () => provider.stop(), + get: (hostId) => provider.get(hostId), + statusesFor: (userId, requested) => + provider.statusesFor(userId, requested ? [...requested] : null), + refresh: (userId) => provider.refresh(userId), + check: (hostId) => provider.check(hostId), + reportLogin: (hostId, outcome) => + provider.reportLogin(hostId, outcome), + registerPort: (type, resolve) => provider.registerPort(type, resolve), + getIntervalSeconds: () => provider.getIntervalSeconds(), + setIntervalSeconds: (seconds) => provider.setIntervalSeconds(seconds), + }); + void audit("host_status_provide", `${pluginId} provides host status`, { + success: true, + }); + const drop = bag?.add(remove, "host status provider"); + return () => { + drop?.(); + remove(); + }; + }, get: async (hostId) => { await requireRead(); if (!(await actorMayReach(hostId))) return null; - return hostStatusService.get(hostId); + return hostStatus.get(hostId); }, check: async (hostId) => { await requireRead(); if (!(await actorMayReach(hostId))) return null; - return hostStatusService.check(hostId); + return hostStatus.check(hostId); }, // Synchronous for the same reason as trackSession. reportLogin: (hostId, outcome) => { requireReadSync(); void actorMayReach(hostId) .then((allowed) => { - if (allowed) hostStatusService.reportLogin(hostId, outcome); + if (allowed) hostStatus.reportLogin(hostId, outcome); }) .catch(() => {}); }, registerPort: (connectionType, resolve) => { requireReadSync(); - const unregister = hostStatusService.registerPort( - connectionType, - resolve, - ); + const unregister = hostStatus.registerPort(connectionType, resolve); const drop = bag?.add(unregister, `status port for ${connectionType}`); return () => { drop?.(); diff --git a/src/backend/plugins/ctx-probe.ts b/src/backend/plugins/ctx-probe.ts new file mode 100644 index 000000000..24a73cd48 --- /dev/null +++ b/src/backend/plugins/ctx-probe.ts @@ -0,0 +1,172 @@ +/** + * Reachability for status providers: ctx.network.tcpProbe, ctx.ssh.probe and + * ctx.hosts.probeTargets. Nothing here ever logs in to the probed host. + */ + +import type { + PluginHostProbeTarget, + PluginNetwork, +} from "@termix-ssh/plugin-sdk/backend"; +import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest"; +import { assertCapability } from "./permissions.js"; +import { getActor } from "./actor.js"; +import { tcpPing } from "./probe/tcp-ping.js"; +import { sharedJumpProbes } from "./probe/jump-probe-pool.js"; +import type { HostStatusTargetRow } from "../database/repositories/host-resolution-repository.js"; + +type AuditFn = ( + action: string, + details: string, + outcome: { success: boolean; errorMessage?: string }, +) => Promise; + +const MIN_TIMEOUT_MS = 500; +const MAX_TIMEOUT_MS = 15_000; +const DEFAULT_TIMEOUT_MS = 5000; + +function clampTimeout(value?: number): number { + const n = Number(value); + if (!Number.isFinite(n)) return DEFAULT_TIMEOUT_MS; + return Math.min(MAX_TIMEOUT_MS, Math.max(MIN_TIMEOUT_MS, n)); +} + +function validPort(port: number): boolean { + return Number.isInteger(port) && port > 0 && port <= 65535; +} + +function parseJumpHosts(raw: string | null): Array<{ hostId: number }> { + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) + ? parsed.filter( + (hop): hop is { hostId: number } => + !!hop && Number.isInteger((hop as { hostId?: unknown }).hostId), + ) + : []; + } catch { + return []; + } +} + +function toTarget(row: HostStatusTargetRow): PluginHostProbeTarget { + return { + id: row.id, + ownerUserId: row.userId, + ip: row.ip, + port: row.port, + connectionType: row.connectionType || "ssh", + jumpHosts: parseJumpHosts(row.jumpHosts), + statusCheckEnabled: row.statusCheckEnabled !== false, + statusCheckInterval: row.statusCheckInterval ?? null, + }; +} + +export function createPluginNetwork(deps: { + manifest: PluginManifest; + audit: AuditFn; +}): PluginNetwork { + const { manifest, audit } = deps; + let audited = false; + return { + tcpProbe: async (host, port, options) => { + await assertCapability( + manifest.id, + "network:probe", + manifest.capabilities, + "network_tcp_probe", + ); + if (typeof host !== "string" || !host.trim() || host.length > 255) { + throw new Error("A host name or address is required"); + } + if (!validPort(port)) + throw new Error("A port from 1 to 65535 is required"); + if (!audited) { + audited = true; + await audit("network_tcp_probe", "checked if TCP ports answer", { + success: true, + }); + } + return tcpPing(host.trim(), port, clampTimeout(options?.timeoutMs)); + }, + }; +} + +/** The hosts the actor owns or was shared, narrowed to ones they may connect to. */ +export async function loadProbeTargets( + actor: string, + hostIds?: number[], +): Promise { + const { + createCurrentHostResolutionRepository, + createCurrentRbacAccessRepository, + createCurrentRoleRepository, + } = await import("../database/repositories/factory.js"); + const { PermissionManager } = await import("../utils/permission-manager.js"); + const hosts = createCurrentHostResolutionRepository(); + + const own = await hosts.listStatusTargets({ + userId: actor, + hostIds, + }); + const ownIds = new Set(own.map((row) => row.id)); + + const roleIds = await createCurrentRoleRepository().listUserRoleIds(actor); + const entries = + await createCurrentRbacAccessRepository().listVisibleHostAccessEntries( + actor, + roleIds, + ); + let sharedIds = [...new Set(entries.map((entry) => entry.hostId))].filter( + (id) => !ownIds.has(id) && (!hostIds || hostIds.includes(id)), + ); + if (sharedIds.length > 0) { + const allowed = + await PermissionManager.getInstance().filterAccessibleHostIds( + actor, + sharedIds, + ); + sharedIds = sharedIds.filter((id) => allowed.has(id)); + } + const shared = + sharedIds.length > 0 + ? await hosts.listStatusTargets({ hostIds: sharedIds }) + : []; + return [...own, ...shared].map(toTarget); +} + +export async function probeHost( + hostId: number, + options?: { port?: number; timeoutMs?: number }, +): Promise { + const actor = getActor(); + if (actor) { + const { PermissionManager } = + await import("../utils/permission-manager.js"); + const access = await PermissionManager.getInstance().canAccessHost( + actor, + hostId, + "connect", + ); + if (!access.hasAccess) return false; + } + const { createCurrentHostResolutionRepository } = + await import("../database/repositories/factory.js"); + const [row] = await createCurrentHostResolutionRepository().listStatusTargets( + { hostIds: [hostId] }, + ); + if (!row) return false; + const target = toTarget(row); + const port = options?.port ?? target.port; + if (!validPort(port)) throw new Error("A port from 1 to 65535 is required"); + const timeout = clampTimeout(options?.timeoutMs); + return target.jumpHosts.length > 0 + ? sharedJumpProbes.ping( + target.jumpHosts, + target.ownerUserId, + target.ip, + port, + timeout, + ) + : tcpPing(target.ip, port, timeout); +} diff --git a/src/backend/plugins/ctx-ssh-auth.ts b/src/backend/plugins/ctx-ssh-auth.ts index 36d64401c..afe0af6b1 100644 --- a/src/backend/plugins/ctx-ssh-auth.ts +++ b/src/backend/plugins/ctx-ssh-auth.ts @@ -26,6 +26,8 @@ import { getActor, getActorSessionId } from "./actor.js"; import type { DisposableBag } from "./disposables.js"; import { redactHostSecrets } from "./ctx-hosts.js"; import { issueUserToken } from "./ctx-users.js"; +import { hostStatus } from "./host-status-provider.js"; +import { probeHost } from "./ctx-probe.js"; import { getSshAuthProvider, listSshAuthProviders, @@ -224,11 +226,7 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh { const reportLogin = (host: number | PluginSshHost) => { const hostId = typeof host === "number" ? host : lookup(host)?.host.id; if (!Number.isInteger(hostId) || hostId <= 0) return; - void import("../hosts/status/host-status-service.js") - .then(({ hostStatusService }) => - hostStatusService.reportLogin(hostId, { ok: true }), - ) - .catch(() => {}); + hostStatus.reportLogin(hostId, { ok: true }); }; /** Checks, connects and audits one new connection. */ @@ -296,6 +294,11 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh { }`; return { + probe: async (hostId, probeOptions) => { + await assertCapability(pluginId, "ssh:connect", declared); + if (!Number.isInteger(hostId) || hostId <= 0) return false; + return probeHost(hostId, probeOptions); + }, connect: async (host, options) => { const connection = await connectOnce(host, options); return { @@ -306,6 +309,33 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh { }; }, + exec: async (host, command, execOptions) => { + const connection = await connectOnce(host, { + ...execOptions, + purpose: execOptions?.purpose ?? "exec", + timeoutMs: undefined, + }); + const details = `${describeHost(host)}: command run`; + try { + const { runExec } = await import("./ctx-ssh-exec.js"); + const result = await runExec( + connection.client as never, + command, + execOptions, + ); + await audit("ssh_exec", details, { success: true }); + return result; + } catch (error) { + await audit("ssh_exec", details, { + success: false, + errorMessage: error instanceof Error ? error.message : String(error), + }); + throw error; + } finally { + connection.dispose(); + } + }, + withConnection: async (host, options, fn) => { await checkSsh(true); const { resolveConnectHost } = diff --git a/src/backend/plugins/ctx-ssh-exec.ts b/src/backend/plugins/ctx-ssh-exec.ts new file mode 100644 index 000000000..96493c46c --- /dev/null +++ b/src/backend/plugins/ctx-ssh-exec.ts @@ -0,0 +1,123 @@ +/** + * Runs one command on an open ssh2 client for ctx.ssh.exec, with a time limit + * and caps on how much output is kept. + */ + +import type { PluginSshExecOptions } from "@termix-ssh/plugin-sdk/backend"; + +export const EXEC_DEFAULT_TIMEOUT_MS = 30_000; +export const EXEC_MAX_TIMEOUT_MS = 10 * 60_000; +export const EXEC_DEFAULT_MAX_OUTPUT = 256 * 1024; +export const EXEC_HARD_MAX_OUTPUT = 4 * 1024 * 1024; +export const EXEC_MAX_COMMAND_LENGTH = 64 * 1024; + +interface ExecStream { + on: (event: string, listener: (...args: never[]) => void) => unknown; + stderr: { on: (event: string, listener: (chunk: Buffer) => void) => unknown }; + write: (data: string) => unknown; + end: (data?: string) => unknown; + close: () => void; +} + +export interface ExecClient { + exec: ( + command: string, + callback: (error: Error | undefined, stream: ExecStream) => void, + ) => unknown; +} + +export interface ExecResult { + stdout: string; + stderr: string; + /** Null when the command was killed by a signal or hit the time limit. */ + code: number | null; + truncated: boolean; + timedOut: boolean; +} + +export function runExec( + client: ExecClient, + command: string, + options: PluginSshExecOptions = {}, +): Promise { + if (typeof command !== "string" || command.trim() === "") { + return Promise.reject(new Error("A command is required")); + } + if (command.length > EXEC_MAX_COMMAND_LENGTH) { + return Promise.reject(new Error("The command is too long")); + } + const timeoutMs = Math.min( + EXEC_MAX_TIMEOUT_MS, + Math.max(1000, options.timeoutMs ?? EXEC_DEFAULT_TIMEOUT_MS), + ); + const maxOutput = Math.min( + EXEC_HARD_MAX_OUTPUT, + Math.max(1024, options.maxOutputBytes ?? EXEC_DEFAULT_MAX_OUTPUT), + ); + + return new Promise((resolve, reject) => { + let settled = false; + let timedOut = false; + let truncated = false; + let stdout = ""; + let stderr = ""; + let timer: NodeJS.Timeout | null = null; + + const finish = (code: number | null) => { + if (settled) return; + settled = true; + if (timer) clearTimeout(timer); + resolve({ stdout, stderr, code, truncated, timedOut }); + }; + + const append = (current: string, chunk: Buffer): string => { + const room = maxOutput - Buffer.byteLength(current); + if (room <= 0) { + truncated = true; + return current; + } + if (chunk.length > room) { + truncated = true; + return current + chunk.subarray(0, room).toString("utf8"); + } + return current + chunk.toString("utf8"); + }; + + client.exec(command, (error, stream) => { + if (error) { + if (!settled) { + settled = true; + reject(error); + } + return; + } + timer = setTimeout(() => { + timedOut = true; + try { + stream.close(); + } catch { + // already closed + } + finish(null); + }, timeoutMs); + + stream.on("data", ((chunk: Buffer) => { + stdout = append(stdout, chunk); + }) as never); + stream.stderr.on("data", (chunk: Buffer) => { + stderr = append(stderr, chunk); + }); + stream.on("close", ((code: number | null | undefined) => { + finish(typeof code === "number" ? code : null); + }) as never); + stream.on("error", ((streamError: Error) => { + if (!settled) { + settled = true; + if (timer) clearTimeout(timer); + reject(streamError); + } + }) as never); + if (options.stdin !== undefined) stream.end(options.stdin); + }); + }); +} diff --git a/src/backend/plugins/ctx.ts b/src/backend/plugins/ctx.ts index eac8156fc..9696c2350 100644 --- a/src/backend/plugins/ctx.ts +++ b/src/backend/plugins/ctx.ts @@ -22,6 +22,9 @@ import { pluginLogger } from "../utils/logger.js"; import { getRequestBaseUrlWithForceHTTPS } from "../utils/request-origin.js"; import { pluginEvents } from "./events.js"; +import { createPluginDesktopLocal } from "./ctx-desktop-local.js"; +import { createPluginAuditReader } from "./ctx-audit.js"; +import { createPluginNetwork } from "./ctx-probe.js"; import * as registry from "./registry.js"; import * as serviceRegistry from "./service-registry.js"; import type { ServiceRegistration } from "./service-registry.js"; @@ -950,6 +953,7 @@ export function createPluginContext( bag: handle.bag, }), fetch: createPluginFetch({ manifest, audit: auditCall }), + network: createPluginNetwork({ manifest, audit: auditCall }), process: createPluginProcess({ manifest, bag: handle.bag, @@ -964,6 +968,11 @@ export function createPluginContext( openIsolatedWindow: (request) => desktopOpenIsolatedWindow(request), launchExternalClient: (request) => desktopLaunchExternalClient(request), available: () => isElectronIpcAvailable(), + local: createPluginDesktopLocal({ + manifest, + bag: handle.bag, + audit: auditCall, + }), }, credentials: createPluginCredentials({ @@ -995,6 +1004,11 @@ export function createPluginContext( // Auditing must never break the caller. } }, + ...createPluginAuditReader({ + manifest, + bag: handle.bag, + audit: auditCall, + }), }, /** diff --git a/src/backend/plugins/desktop-local/local-fs.ts b/src/backend/plugins/desktop-local/local-fs.ts new file mode 100644 index 000000000..6b622fe7f --- /dev/null +++ b/src/backend/plugins/desktop-local/local-fs.ts @@ -0,0 +1,572 @@ +/** + * Local filesystem helpers for plugins on the desktop app's embedded backend. + * Ported from the old Electron local-files module. The path rules are the + * security part: a download can never leave the folder the user picked, links + * inside it are refused, and a download never replaces a file by accident. + */ + +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import type { + PluginLocalDirEntry, + PluginLocalDownload, + PluginLocalWalkResult, +} from "@termix-ssh/plugin-sdk/backend"; + +export class LocalFileError extends Error { + code: string; + constructor(code: string, message: string) { + super(message); + this.name = "LocalFileError"; + this.code = code; + } +} + +type PathImpl = typeof path; + +export function normalizeLocalPath(candidate: unknown): string { + if (typeof candidate !== "string" || !path.isAbsolute(candidate)) { + throw new Error("A local absolute path is required"); + } + return path.normalize(candidate); +} + +// A single path segment: no separators, not "." / "..", no NULs. +export function requireEntryName(name: unknown, what: string): string { + const safeName = String(name || "").trim(); + if ( + !safeName || + safeName === "." || + safeName === ".." || + /[/\\\0]/.test(safeName) + ) { + throw new Error(`Invalid ${what}`); + } + return safeName; +} + +// pathImpl is injectable so the Windows rules can be tested on any OS. +export function assertWithinRoot( + rootPath: string, + candidate: string, + pathImpl: PathImpl = path, +): string { + if (typeof rootPath !== "string" || !rootPath.trim()) { + throw new LocalFileError("EINVAL", "A download folder is required"); + } + const root = pathImpl.normalize(pathImpl.resolve(rootPath)); + const target = pathImpl.normalize(pathImpl.resolve(candidate)); + const rel = pathImpl.relative(root, target); + const escapes = + rel === "" || + rel === ".." || + rel.startsWith(`..${pathImpl.sep}`) || + pathImpl.isAbsolute(rel) || + rel.split(pathImpl.sep).some((seg) => seg === ".." || seg === ""); + if (escapes) { + throw new LocalFileError( + "EINVAL", + `"${pathImpl.basename(candidate)}" would be written outside the selected folder`, + ); + } + return target; +} + +// Resolve a deliberately selected root once, but never follow links supplied +// as descendants of a downloaded tree. +export async function prepareDownloadPath( + rootPath: string, + candidate: string, + directory = false, +): Promise<{ root: string; path: string }> { + const target = assertWithinRoot(rootPath, normalizeLocalPath(candidate)); + const selected = path.resolve(rootPath); + const realRoot = await fsp.realpath(selected); + if (!(await fsp.stat(realRoot)).isDirectory()) { + throw new LocalFileError("EINVAL", "The download root is not a directory"); + } + const parts = path.relative(selected, target).split(path.sep); + let current = realRoot; + for (let i = 0; i < parts.length; i++) { + current = path.join(current, parts[i]); + const needsDirectory = directory || i < parts.length - 1; + if (needsDirectory) { + await fsp.mkdir(current).catch((error: NodeJS.ErrnoException) => { + if (error.code !== "EEXIST") throw error; + }); + } + const stat = await fsp + .lstat(current) + .catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT" && !needsDirectory) return null; + throw error; + }); + if (stat?.isSymbolicLink()) { + throw new LocalFileError( + "EINVAL", + "Downloads cannot follow links inside the selected folder", + ); + } + if (needsDirectory && !stat?.isDirectory()) { + throw new LocalFileError( + "ENOTDIR", + "A download parent is not a directory", + ); + } + } + return { root: realRoot, path: current }; +} + +export async function pathExists(target: string): Promise { + try { + await fsp.lstat(target); + return true; + } catch { + return false; + } +} + +async function describeEntry( + dirPath: string, + dirent: fs.Dirent, +): Promise { + const entryPath = path.join(dirPath, dirent.name); + let type: PluginLocalDirEntry["type"] = "file"; + let size = 0; + let modifiedTimestamp: number | undefined; + let linkTarget: string | undefined; + + try { + if (dirent.isSymbolicLink()) { + type = "link"; + try { + linkTarget = await fsp.readlink(entryPath); + } catch { + // dangling or unreadable link + } + try { + const target = await fsp.stat(entryPath); + if (target.isDirectory()) type = "directory"; + size = target.size; + modifiedTimestamp = target.mtimeMs; + } catch { + const own = await fsp.lstat(entryPath); + modifiedTimestamp = own.mtimeMs; + } + } else if (dirent.isDirectory()) { + type = "directory"; + modifiedTimestamp = (await fsp.stat(entryPath)).mtimeMs; + } else { + const stat = await fsp.stat(entryPath); + size = stat.size; + modifiedTimestamp = stat.mtimeMs; + } + } catch { + // Unreadable entry: still list it so the user sees it exists. + } + + return { + name: dirent.name, + path: entryPath, + type, + size, + modifiedTimestamp, + linkTarget, + hidden: dirent.name.startsWith("."), + }; +} + +export async function listDirectory(dirPath: string): Promise<{ + path: string; + parent: string | null; + entries: PluginLocalDirEntry[]; +}> { + const resolved = normalizeLocalPath(dirPath); + const dirents = await fsp.readdir(resolved, { withFileTypes: true }); + const entries = await Promise.all( + dirents.map((dirent) => describeEntry(resolved, dirent)), + ); + const parent = path.dirname(resolved); + return { + path: resolved, + parent: parent === resolved ? null : parent, + entries, + }; +} + +// Expands dropped local paths into a flat file list with paths relative to the +// drop root, plus empty directories. +export async function walkPaths( + rootPaths: string[], +): Promise { + const files: PluginLocalWalkResult["files"] = []; + const emptyDirs: string[] = []; + let totalBytes = 0; + + async function walkDir(absDir: string, relDir: string): Promise { + const dirents = await fsp.readdir(absDir, { withFileTypes: true }); + if (dirents.length === 0) { + emptyDirs.push(relDir); + return; + } + for (const dirent of dirents) { + const abs = path.join(absDir, dirent.name); + const rel = `${relDir}/${dirent.name}`; + if (dirent.isDirectory()) { + await walkDir(abs, rel); + } else if (dirent.isFile()) { + const stat = await fsp.stat(abs); + files.push({ localPath: abs, relativePath: rel, size: stat.size }); + totalBytes += stat.size; + } else if (dirent.isSymbolicLink()) { + try { + const stat = await fsp.stat(abs); + if (stat.isFile()) { + files.push({ localPath: abs, relativePath: rel, size: stat.size }); + totalBytes += stat.size; + } + } catch { + // dangling link: skip + } + } + } + } + + for (const rootPath of rootPaths) { + const abs = normalizeLocalPath(rootPath); + const stat = await fsp.stat(abs); + const name = path.basename(abs); + if (stat.isDirectory()) { + await walkDir(abs, name); + } else if (stat.isFile()) { + files.push({ localPath: abs, relativePath: name, size: stat.size }); + totalBytes += stat.size; + } + } + + return { files, emptyDirs, totalBytes }; +} + +export async function homeInfo(): Promise<{ + home: string; + separator: string; + platform: NodeJS.Platform; +}> { + return { + home: os.homedir(), + separator: path.sep, + platform: process.platform, + }; +} + +export async function makeDirectory( + parentPath: string, + name: string, +): Promise { + const target = path.join( + normalizeLocalPath(parentPath), + requireEntryName(name, "folder name"), + ); + await fsp.mkdir(target); + return target; +} + +export async function createEmptyFile( + parentPath: string, + name: string, +): Promise { + const target = path.join( + normalizeLocalPath(parentPath), + requireEntryName(name, "file name"), + ); + // "wx" fails if the file already exists rather than truncating it. + await fsp.writeFile(target, "", { flag: "wx" }); + return target; +} + +// Exclusive rename: a destination that appeared meanwhile is never replaced. +export async function renameLocalPath( + source: string, + target: string, +): Promise { + if (source === target) return; + try { + await fsp.lstat(target); + throw new LocalFileError( + "EEXIST", + "A file or folder with that name already exists", + ); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + if ((await fsp.lstat(source)).isDirectory()) { + await fsp.rename(source, target); + return; + } + await fsp.link(source, target); + await fsp.unlink(source); +} + +export async function renameEntry( + oldPath: string, + newName: string, +): Promise { + const source = normalizeLocalPath(oldPath); + const safeName = requireEntryName(newName, "name"); + const target = path.join(path.dirname(source), safeName); + if (target === source) return source; + if (await pathExists(target)) { + throw new LocalFileError("EEXIST", `"${safeName}" already exists`); + } + await renameLocalPath(source, target); + return target; +} + +export async function ensureDirectory( + dirPath: string, + rootPath?: string, +): Promise { + let target = normalizeLocalPath(dirPath); + if (rootPath !== undefined) { + target = (await prepareDownloadPath(rootPath, target, true)).path; + } else { + await fsp.mkdir(target, { recursive: true }); + } + return target; +} + +export async function existingPaths(paths: string[]): Promise { + if (!Array.isArray(paths)) throw new Error("Expected a list of paths"); + const existing: string[] = []; + for (const candidate of paths) { + const target = normalizeLocalPath(candidate); + if (await pathExists(target)) existing.push(target); + } + return existing; +} + +export async function openRead( + localPath: string, +): Promise<{ stream: fs.ReadStream; size: number }> { + const absPath = normalizeLocalPath(localPath); + const stat = await fsp.stat(absPath); + if (!stat.isFile()) throw new Error("Only regular files can be uploaded"); + return { + stream: fs.createReadStream(absPath, { highWaterMark: 1024 * 1024 }), + size: stat.size, + }; +} + +// Destinations with a download in flight, so two transfers never race onto +// the same file. +const activeDestinations = new Set(); + +function transferToken(transferId: string): string { + return ( + String(transferId) + .replace(/[^A-Za-z0-9_-]/g, "") + .slice(0, 48) || "transfer" + ); +} + +export interface PublishFs { + rename: (from: string, to: string) => Promise; + link: (from: string, to: string) => Promise; + copyFileExcl: (from: string, to: string) => Promise; + rm: (target: string) => Promise; + lstat: (target: string) => Promise; +} + +export const defaultPublishFs: PublishFs = Object.freeze({ + rename: (from, to) => fsp.rename(from, to), + link: (from, to) => fsp.link(from, to), + copyFileExcl: (from, to) => + fsp.copyFile(from, to, fs.constants.COPYFILE_EXCL), + rm: (target) => fsp.rm(target, { force: true }), + lstat: (target) => fsp.lstat(target), +}); + +// link() is atomic and fails with EEXIST if the name is taken; filesystems +// without hard links fall back to an exclusive copy. +async function publishExclusive( + io: PublishFs, + sourcePath: string, + absDest: string, +): Promise { + const exists = () => + new LocalFileError("EEXIST", `"${path.basename(absDest)}" already exists`); + try { + await io.link(sourcePath, absDest); + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code === "EEXIST") throw exists(); + try { + await io.copyFileExcl(sourcePath, absDest); + } catch (copyError) { + if ((copyError as NodeJS.ErrnoException)?.code === "EEXIST") { + throw exists(); + } + throw copyError; + } + } + await io.rm(sourcePath); +} + +// rename() onto an existing file is not portable (it commonly fails on +// Windows), so move the old file aside, publish exclusively, then drop it. +async function replaceExisting( + io: PublishFs, + partialPath: string, + absDest: string, + transferId: string, +): Promise { + let current: fs.Stats; + try { + current = await io.lstat(absDest); + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code === "ENOENT") { + await publishExclusive(io, partialPath, absDest); + return; + } + throw error; + } + if (current.isDirectory()) { + throw new LocalFileError( + "EISDIR", + `"${path.basename(absDest)}" is a folder and cannot be replaced by a file`, + ); + } + + const asidePath = `${absDest}.${transferToken(transferId)}.termix-replaced`; + try { + await io.rename(absDest, asidePath); + } catch (error) { + const code = (error as NodeJS.ErrnoException)?.code; + if (code === "EPERM" || code === "EBUSY" || code === "EACCES") { + throw new LocalFileError( + "EBUSY", + `"${path.basename(absDest)}" is in use and could not be replaced`, + ); + } + throw error; + } + + try { + await publishExclusive(io, partialPath, absDest); + } catch (error) { + await io.rm(absDest).catch(() => {}); + await io.rename(asidePath, absDest).catch(() => {}); + throw error; + } + + try { + await io.rm(asidePath); + } catch { + await new Promise((resolve) => setTimeout(resolve, 100)); + await io.rm(asidePath).catch(() => {}); + } +} + +export async function publishDownload( + partialPath: string, + absDest: string, + overwrite: boolean, + transferId: string, + io: PublishFs = defaultPublishFs, +): Promise { + if (overwrite) { + await replaceExisting(io, partialPath, absDest, transferId); + return; + } + await publishExclusive(io, partialPath, absDest); +} + +// Bytes go to a transfer-unique temp sibling first and are published under the +// real name only on success, so a failed transfer never leaves a truncated file. +export async function openDownload( + options: { + rootPath: string; + destPath: string; + transferId: string; + overwrite?: boolean; + }, + io: PublishFs = defaultPublishFs, +): Promise { + const { rootPath, destPath, transferId } = options; + if (!transferId || !destPath) throw new Error("Missing download parameters"); + + const destination = await prepareDownloadPath(rootPath, destPath); + const absDest = destination.path; + if (activeDestinations.has(absDest)) { + throw new LocalFileError( + "EBUSY", + `"${path.basename(absDest)}" is already being downloaded`, + ); + } + activeDestinations.add(absDest); + + const partialPath = `${path.join(destination.root, path.basename(absDest))}.${transferToken( + transferId, + )}.termix-part`; + let released = false; + const release = () => { + if (released) return; + released = true; + activeDestinations.delete(absDest); + }; + + try { + if (options.overwrite !== true && (await pathExists(absDest))) { + throw new LocalFileError( + "EEXIST", + `"${path.basename(absDest)}" already exists`, + ); + } + // "wx": the temp name is ours alone; refuse to reuse a stale one. + const stream = fs.createWriteStream(partialPath, { flags: "wx" }); + // A write error must not crash the backend. publish() reports it. + let streamError: Error | null = null; + stream.on("error", (error) => { + streamError = error; + }); + + return { + path: absDest, + stream, + publish: async (publishOptions) => { + try { + if (streamError) throw streamError; + await new Promise((resolve, reject) => { + if (stream.writableFinished) return resolve(); + stream.once("error", reject); + stream.end(() => resolve()); + }); + if (streamError) throw streamError; + await prepareDownloadPath(destination.root, absDest); + await publishDownload( + partialPath, + absDest, + (publishOptions?.overwrite ?? options.overwrite) === true, + transferId, + io, + ); + return absDest; + } catch (error) { + await fsp.rm(partialPath, { force: true }).catch(() => {}); + throw error; + } finally { + release(); + } + }, + abort: async () => { + stream.destroy(); + await fsp.rm(partialPath, { force: true }).catch(() => {}); + release(); + }, + }; + } catch (error) { + release(); + throw error; + } +} diff --git a/src/backend/plugins/desktop-local/local-pty.ts b/src/backend/plugins/desktop-local/local-pty.ts new file mode 100644 index 000000000..cc42be248 --- /dev/null +++ b/src/backend/plugins/desktop-local/local-pty.ts @@ -0,0 +1,167 @@ +/** + * Local shells for plugins on the desktop app's embedded backend. Ported from + * the old Electron local terminal handlers. + */ + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; +import type { PluginLocalPty } from "@termix-ssh/plugin-sdk/backend"; + +export function resolveLocalShell( + platform: NodeJS.Platform, + requestedShell?: string, + env: NodeJS.ProcessEnv = process.env, + exists: (file: string) => boolean = fs.existsSync, +): { file: string; args: string[] } { + if (platform === "win32") { + if (requestedShell === "wsl") return { file: "wsl.exe", args: [] }; + return { + file: env.TERMIX_LOCAL_SHELL || "powershell.exe", + args: ["-NoLogo"], + }; + } + + // A SHELL left pointing at an uninstalled shell makes the spawn fail. + const fallbacks = + platform === "darwin" + ? ["/bin/zsh", "/bin/bash", "/bin/sh"] + : ["/bin/bash", "/bin/sh"]; + const usable = (file?: string): file is string => + !!file && (!path.isAbsolute(file) || exists(file)); + const file = + [env.TERMIX_LOCAL_SHELL, env.SHELL, ...fallbacks].find(usable) || + fallbacks[0]; + return { file, args: ["-l"] }; +} + +/** + * node-pty starts every shell through its spawn-helper binary, and a packaged + * copy that lost its execute bit fails with "posix_spawnp failed". Returns how + * many helpers were fixed. + */ +export function repairSpawnHelpers(ptyDir: string, fsImpl = fs): number { + // A universal macOS build ships app-arm64.asar and app-x64.asar. + const dir = ptyDir.replace( + /\b(app(?:-[a-z0-9]+)?)\.asar(?!\.unpacked)/, + "$1.asar.unpacked", + ); + const candidates = [ + path.join(dir, "build", "Release", "spawn-helper"), + path.join( + dir, + "prebuilds", + `${process.platform}-${process.arch}`, + "spawn-helper", + ), + ]; + let fixed = 0; + for (const helper of candidates) { + try { + const mode = fsImpl.statSync(helper).mode; + if ((mode & 0o111) === 0) { + fsImpl.chmodSync(helper, mode | 0o755); + fixed += 1; + } + } catch { + // Missing or read-only; nothing to fix here. + } + } + return fixed; +} + +interface NodePty { + spawn: ( + file: string, + args: string[], + options: Record, + ) => { + pid: number; + write: (data: string) => void; + resize: (cols: number, rows: number) => void; + kill: () => void; + onData: (cb: (data: string) => void) => { dispose: () => void }; + onExit: (cb: (e: { exitCode: number }) => void) => { dispose: () => void }; + }; +} + +const requireFromHere = createRequire(import.meta.url); + +function loadNodePty(): NodePty { + try { + return requireFromHere("node-pty") as NodePty; + } catch { + throw new Error("The local terminal is not available in this build"); + } +} + +export const MAX_PTY_COLS = 500; +export const MAX_PTY_ROWS = 300; + +export function spawnLocalPty(options?: { + cols?: number; + rows?: number; + shell?: string; +}): PluginLocalPty { + const pty = loadNodePty(); + const cols = Math.min(MAX_PTY_COLS, Math.max(2, Number(options?.cols) || 80)); + const rows = Math.min(MAX_PTY_ROWS, Math.max(1, Number(options?.rows) || 24)); + const shellConfig = resolveLocalShell(process.platform, options?.shell); + + const start = () => + pty.spawn(shellConfig.file, shellConfig.args, { + name: "xterm-256color", + cols, + rows, + cwd: os.homedir(), + env: { ...process.env, TERM: "xterm-256color", COLORTERM: "truecolor" }, + }); + + let child: ReturnType; + try { + child = start(); + } catch (error) { + const ptyDir = path.dirname( + requireFromHere.resolve("node-pty/package.json"), + ); + if ( + !String((error as Error)?.message).includes("posix_spawnp") || + repairSpawnHelpers(ptyDir) === 0 + ) { + throw error; + } + child = start(); + } + + return { + pid: child.pid, + shell: shellConfig.file, + write: (data) => { + if (typeof data === "string" && data.length <= 64 * 1024) { + child.write(data); + } + }, + resize: (c, r) => { + if ( + Number.isInteger(c) && + Number.isInteger(r) && + c >= 2 && + c <= MAX_PTY_COLS && + r >= 1 && + r <= MAX_PTY_ROWS + ) { + child.resize(c, r); + } + }, + kill: () => child.kill(), + onData: (handler) => { + const sub = child.onData(handler); + return () => sub.dispose(); + }, + onExit: (handler) => { + const sub = child.onExit(({ exitCode }) => handler(exitCode)); + return () => sub.dispose(); + }, + }; +} diff --git a/src/backend/plugins/events.ts b/src/backend/plugins/events.ts index 9751be450..263aa60da 100644 --- a/src/backend/plugins/events.ts +++ b/src/backend/plugins/events.ts @@ -22,7 +22,7 @@ export const TOPICS = { hostSessionStatus: "host.session.status", /** A generic named internal event (host_deleted, user_login, ...). */ internalEvent: "internal.event", - /** A host's status dot changed (hosts/status/host-status-service.ts). */ + /** A host's status dot changed (the host status provider). */ hostStatus: "host.status", /** A host's key was accepted again after it changed. */ hostKeyUpdated: "host.key.updated", diff --git a/src/backend/plugins/host-status-provider.ts b/src/backend/plugins/host-status-provider.ts new file mode 100644 index 000000000..2b6b00f2d --- /dev/null +++ b/src/backend/plugins/host-status-provider.ts @@ -0,0 +1,149 @@ +/** + * Host status is a provider: core owns the interface and the callers (the + * status routes, the sync feed, ctx.hosts.status), and one provider fills it. + * A plugin registers with ctx.hosts.status.provide. With none, every status + * is unknown. + */ + +export type ProviderHostStatus = "online" | "reachable" | "offline"; + +export interface HostStatusEntry { + status: ProviderHostStatus; + lastChecked: string; +} + +export type HostPortResolver = ( + hostId: number, +) => number | undefined | Promise; + +export interface HostStatusProvider { + start(): void; + stop(): void; + get(hostId: number): HostStatusEntry | null; + /** All known statuses after bringing polling in line with the request. */ + statusesFor( + userId: string, + requestedHostIds: Set | null, + ): Promise>; + refresh(userId: string): Promise; + check(hostId: number): Promise; + reportLogin(hostId: number, outcome: { ok: boolean }): void; + registerPort(connectionType: string, resolve: HostPortResolver): () => void; + getIntervalSeconds(): Promise; + setIntervalSeconds(seconds: number): Promise; +} + +let builtin: HostStatusProvider | null = null; +let plugged: { pluginId: string; provider: HostStatusProvider } | null = null; +let running = false; +const ports = new Map(); +const portDrops = new Map void>(); + +function active(): HostStatusProvider | null { + return plugged?.provider ?? builtin; +} + +function attachPorts(provider: HostStatusProvider): void { + for (const [key, { type, resolve }] of ports) { + portDrops.set(key, provider.registerPort(type, resolve)); + } +} + +function detachPorts(): void { + for (const drop of portDrops.values()) drop(); + portDrops.clear(); +} + +function swap(next: () => void): void { + const before = active(); + if (running) before?.stop(); + detachPorts(); + next(); + const after = active(); + if (after) attachPorts(after); + if (running) after?.start(); +} + +/** A fallback provider. Core registers none; tests use it. */ +export function registerBuiltinHostStatusProvider( + provider: HostStatusProvider | null, +): void { + swap(() => { + builtin = provider; + }); +} + +/** Returns the remover. A second provider is refused. */ +export function provideHostStatus( + pluginId: string, + provider: HostStatusProvider, +): () => void { + if (plugged) { + throw new Error( + `Host status is already provided by plugin "${plugged.pluginId}"`, + ); + } + const entry = { pluginId, provider }; + swap(() => { + plugged = entry; + }); + return () => { + if (plugged !== entry) return; + swap(() => { + plugged = null; + }); + }; +} + +export function hostStatusProviderId(): string | null { + return plugged?.pluginId ?? null; +} + +/** What the rest of core calls. */ +export const hostStatus = { + start(): void { + running = true; + active()?.start(); + }, + stop(): void { + running = false; + active()?.stop(); + }, + get(hostId: number): HostStatusEntry | null { + return active()?.get(hostId) ?? null; + }, + async statusesFor( + userId: string, + requestedHostIds: Set | null, + ): Promise> { + return (await active()?.statusesFor(userId, requestedHostIds)) ?? new Map(); + }, + async refresh(userId: string): Promise { + await active()?.refresh(userId); + }, + async check(hostId: number): Promise { + return (await active()?.check(hostId)) ?? null; + }, + reportLogin(hostId: number, outcome: { ok: boolean }): void { + active()?.reportLogin(hostId, outcome); + }, + /** Kept across a provider change, so a plugin's port stays registered. */ + registerPort(connectionType: string, resolve: HostPortResolver): () => void { + const key = Symbol(connectionType); + ports.set(key, { type: connectionType, resolve }); + const provider = active(); + if (provider) + portDrops.set(key, provider.registerPort(connectionType, resolve)); + return () => { + ports.delete(key); + portDrops.get(key)?.(); + portDrops.delete(key); + }; + }, + async getIntervalSeconds(): Promise { + return (await active()?.getIntervalSeconds()) ?? 30; + }, + async setIntervalSeconds(seconds: number): Promise { + await active()?.setIntervalSeconds(seconds); + }, +}; diff --git a/src/backend/plugins/loader.ts b/src/backend/plugins/loader.ts index 7fad700c0..455b6c902 100644 --- a/src/backend/plugins/loader.ts +++ b/src/backend/plugins/loader.ts @@ -656,6 +656,15 @@ export class PluginLoader { if (plugin.state === "active" || plugin.state === "activating") return; + if ( + plugin.manifest.runtime === "desktop" && + process.env.ELECTRON_EMBEDDED !== "true" + ) { + plugin.state = "blocked"; + plugin.lastError = "only runs in the desktop app"; + return; + } + plugin.state = "activating"; plugin.lastError = null; @@ -719,8 +728,12 @@ export class PluginLoader { bundled: plugin.source === "bundled" || plugin.signedTier === "official", }); - // A new table may be where core data is waiting to move. - if (applied.length > 0) { + // A new table, or a plugin that keeps its state in settings, may be + // where core data is waiting to move. Every move is idempotent. + if ( + applied.length > 0 || + plugin.manifest.contributes?.settings?.admin?.length + ) { const { runPluginDataMoves } = await import("../upgrade/plugin-data-moves.js"); await runPluginDataMoves(); diff --git a/src/backend/hosts/status/jump-probe-pool.ts b/src/backend/plugins/probe/jump-probe-pool.ts similarity index 90% rename from src/backend/hosts/status/jump-probe-pool.ts rename to src/backend/plugins/probe/jump-probe-pool.ts index 15cd744f1..28e488b55 100644 --- a/src/backend/hosts/status/jump-probe-pool.ts +++ b/src/backend/plugins/probe/jump-probe-pool.ts @@ -97,3 +97,10 @@ export class JumpProbePool { if (this.entries.get(key) === entry) this.entries.delete(key); } } + +/** One pool for core's checks and for plugins probing through ctx. */ +export const sharedJumpProbes = new JumpProbePool(async (jumpHosts, userId) => { + const { createJumpHostChain } = + await import("../../hosts/jump-host-chain.js"); + return createJumpHostChain(jumpHosts, userId); +}); diff --git a/src/backend/hosts/status/tcp-ping.ts b/src/backend/plugins/probe/tcp-ping.ts similarity index 100% rename from src/backend/hosts/status/tcp-ping.ts rename to src/backend/plugins/probe/tcp-ping.ts diff --git a/src/backend/plugins/registry.ts b/src/backend/plugins/registry.ts index 050f57511..ed438ffb6 100644 --- a/src/backend/plugins/registry.ts +++ b/src/backend/plugins/registry.ts @@ -41,6 +41,18 @@ export function revoke(key: string, value?: unknown): boolean { return providers.delete(key); } +/** + * The provider under ".", for a well-known name core + * reads without knowing which plugin supplies it. First match wins. + */ +export function consumeByName(name: string): T | undefined { + const suffix = `.${name}`; + for (const [key, value] of providers) { + if (key.endsWith(suffix)) return value as T; + } + return undefined; +} + export function has(key: string): boolean { return providers.has(key); } diff --git a/src/backend/plugins/settings.ts b/src/backend/plugins/settings.ts index f55c523e3..0f9c0c073 100644 --- a/src/backend/plugins/settings.ts +++ b/src/backend/plugins/settings.ts @@ -44,6 +44,10 @@ export type { PluginSettingsScope }; export const CORE_SETTINGS_ALLOWLIST: readonly string[] = [ "app_name", "notification_private_endpoint_allowlist", + // Read once by the branding provider to keep a pre-plugin install's branding. + "branding_app_name", + "branding_tagline", + "branding_logo", ]; type ChangeListener = (value: unknown) => void; diff --git a/src/backend/services/recent-activity.ts b/src/backend/services/recent-activity.ts deleted file mode 100644 index bdc0728a2..000000000 --- a/src/backend/services/recent-activity.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { - createCurrentHostResolutionRepository, - createCurrentRbacAccessRepository, - createCurrentRecentActivityRepository, - createCurrentRoleRepository, -} from "../database/repositories/factory.js"; -import { dashboardLogger } from "../utils/logger.js"; - -const RATE_LIMIT_MS = 1000; -const activityRateLimiter = new Map(); - -/** - * Activity types are whatever the recording plugin calls them (each tab - * declares the types it reopens), so only the shape is checked here. - */ -function isActivityType(type: unknown): type is string { - return typeof type === "string" && /^[a-z][a-z0-9_]{0,31}$/.test(type); -} - -export type RecordActivityResult = - | { status: "logged"; id: number } - | { status: "rate_limited" } - | { status: "invalid_type" } - | { status: "denied" }; - -/** - * Adds one recent activity entry: the dashboard's /activity/log route and - * ctx.hosts.recordActivity both come through here, so the rate limit and the - * access rule are the same for both. - */ -export async function recordRecentActivity( - userId: string, - entry: { type: string; hostId: number; hostName: string }, -): Promise { - const { type, hostId, hostName } = entry; - if (!isActivityType(type)) { - return { status: "invalid_type" }; - } - - const rateLimitKey = `${userId}:${hostId}:${type}`; - const now = Date.now(); - const lastLogged = activityRateLimiter.get(rateLimitKey); - if (lastLogged && now - lastLogged < RATE_LIMIT_MS) { - return { status: "rate_limited" }; - } - activityRateLimiter.set(rateLimitKey, now); - - if (activityRateLimiter.size > 10000) { - for (const [key, timestamp] of activityRateLimiter.entries()) { - if (now - timestamp > RATE_LIMIT_MS * 2) activityRateLimiter.delete(key); - } - } - - const isOwnedHost = - await createCurrentHostResolutionRepository().isHostOwnedByUser( - hostId, - userId, - ); - if (!isOwnedHost) { - const roleIds = await createCurrentRoleRepository().listUserRoleIds(userId); - const sharedHosts = - await createCurrentRbacAccessRepository().listVisibleHostAccessEntries( - userId, - roleIds, - ); - if (!sharedHosts.some((access) => access.hostId === hostId)) { - return { status: "denied" }; - } - } - - const result = await createCurrentRecentActivityRepository().create({ - userId, - type, - hostId, - hostName, - }); - - // Best-effort trim; a failure here must not fail the log itself. - try { - await createCurrentRecentActivityRepository().trimUserActivity(userId, 100); - } catch (trimErr) { - dashboardLogger.warn("Failed to trim recent_activity (non-fatal)", { - operation: "trim_recent_activity", - userId, - error: trimErr instanceof Error ? trimErr.message : String(trimErr), - }); - } - - return { status: "logged", id: result.id }; -} diff --git a/src/backend/starter.ts b/src/backend/starter.ts index 70ab554aa..d827e3f43 100644 --- a/src/backend/starter.ts +++ b/src/backend/starter.ts @@ -200,9 +200,9 @@ async function provisionLocalDesktopUserIfNeeded(): Promise { const { runCoreBootMigrations } = await import("./boot.js"); await runCoreBootMigrations(); - const { hostStatusService } = - await import("./hosts/status/host-status-service.js"); - hostStatusService.start(); + // Status comes from whichever plugin provides it; none means unknown. + const { hostStatus } = await import("./plugins/host-status-provider.js"); + hostStatus.start(); // Counted before the desktop user is provisioned, so a first desktop // run counts as a fresh install too. @@ -226,7 +226,7 @@ async function provisionLocalDesktopUserIfNeeded(): Promise { // Plugins serve their routes under /plugin-api// and /plugin-ws// // through ctx on activate, and start their own schedulers there, so // disabling one answers 503 instead of leaving a dead import here. The - // dashboard's own uptime and recent-activity routes are core and are + // dashboard's uptime and recent-activity routes are core forwarders and are // mounted on the main server in database.ts. // Initialize log level from database settings diff --git a/src/backend/sync/client/http.ts b/src/backend/sync/client/http.ts index 2d5448248..7b55541f2 100644 --- a/src/backend/sync/client/http.ts +++ b/src/backend/sync/client/http.ts @@ -200,6 +200,38 @@ export async function remoteFetch( return response; } +export interface RemoteStreamRequest { + method?: string; + headers?: Record; + /** A string, a buffer or a Node stream. */ + body?: unknown; + signal?: AbortSignal; +} + +/** + * Sends a request with the link's proxy headers and certificate rules and a + * body that may be a stream. The response body is left unread for the caller. + */ +export async function remoteStreamFetch( + target: RemoteTarget, + pathname: string, + request: RemoteStreamRequest = {}, +) { + const headers = { ...request.headers, ...requestHeaders(target) }; + try { + return await undiciFetch(remoteUrl(target.serverUrl, pathname), { + method: request.method ?? "GET", + headers, + body: request.body as never, + duplex: "half", + dispatcher: dispatcherFor(target), + signal: request.signal, + } as never); + } catch (error) { + throw describeNetworkError(error); + } +} + /** Sends a request and parses a JSON answer from Termix. */ export async function remoteJson( target: RemoteTarget, diff --git a/src/backend/sync/client/plugins.ts b/src/backend/sync/client/plugins.ts index ad841b539..74e9445b5 100644 --- a/src/backend/sync/client/plugins.ts +++ b/src/backend/sync/client/plugins.ts @@ -11,7 +11,10 @@ import fs from "fs"; import path from "path"; import semver from "semver"; -import type { PluginDesktopMode } from "@termix-ssh/plugin-sdk/manifest"; +import { + resolvePluginDesktopMode, + type PluginDesktopMode, +} from "@termix-ssh/plugin-sdk/manifest"; import { syncLogger } from "../../utils/logger.js"; import { sendCoreAlert } from "../../notify/core-notify.js"; import { @@ -227,7 +230,9 @@ async function mirrorOne(link: SyncLink, plugin: RemotePlugin): Promise { } const { loader } = getPluginRuntime(); let local = loader.get(plugin.id); - const mode = local?.manifest.desktop ?? plugin.desktop ?? "mirror"; + const mode = local + ? resolvePluginDesktopMode(local.manifest) + : (plugin.desktop ?? "mirror"); if (mode === "server") { if (local && (await isEnabledLocally(plugin.id))) { @@ -328,7 +333,7 @@ export async function mirrorPlugins(link: SyncLink): Promise { // A plugin the server no longer has is switched off here. Its data stays. for (const local of getPluginRuntime().loader.list()) { if (remoteIds.has(local.id) || local.source !== "user") continue; - if ((local.manifest.desktop ?? "mirror") !== "mirror") continue; + if (resolvePluginDesktopMode(local.manifest) !== "mirror") continue; if (await isEnabledLocally(local.id)) { await setPluginEnabled(local.id, false).catch(() => {}); } @@ -341,7 +346,7 @@ export async function mirrorPlugins(link: SyncLink): Promise { */ export function isServerManaged(pluginId: string): boolean { const local = getPluginRuntime().loader.get(pluginId); - const mode = local?.manifest.desktop ?? "mirror"; + const mode = local ? resolvePluginDesktopMode(local.manifest) : "mirror"; if (mode !== "mirror") return mode === "server"; return local?.source !== "user" || vouchedHere(local); } diff --git a/src/backend/sync/server/routes.ts b/src/backend/sync/server/routes.ts index fb1639d54..c3aba2349 100644 --- a/src/backend/sync/server/routes.ts +++ b/src/backend/sync/server/routes.ts @@ -9,6 +9,7 @@ import { syncLogger } from "../../utils/logger.js"; import { getLocalVersion } from "../../utils/app-version.js"; import { getEntity, listEntities } from "../../plugins/sync-registry.js"; import { getPluginRuntime } from "../../plugins/index.js"; +import { resolvePluginDesktopMode } from "@termix-ssh/plugin-sdk/manifest"; import { createCurrentSessionRepository, createCurrentSettingsRepository, @@ -447,13 +448,13 @@ router.get( async (req: Request, res: Response) => { const userId = userOf(req); try { - const { hostStatusService } = - await import("../../hosts/status/host-status-service.js"); + const { hostStatus } = + await import("../../plugins/host-status-provider.js"); const { PermissionManager } = await import("../../utils/permission-manager.js"); const { createCurrentHostRepository } = await import("../../database/repositories/factory.js"); - const statuses = await hostStatusService.statusesFor(userId, null); + const statuses = await hostStatus.statusesFor(userId, null); const entries = [...statuses.entries()]; const allowed = await PermissionManager.getInstance().filterAccessibleHostIds( @@ -516,7 +517,7 @@ router.get( artifact: !!plugin.artifact, enabled: records.get(plugin.id)?.state === "enabled", active: plugin.state === "active", - desktop: plugin.manifest.desktop ?? "mirror", + desktop: resolvePluginDesktopMode(plugin.manifest), syncEntities: plugin.manifest.contributes?.syncEntities ?? [], granted: (await grants.listByPlugin(plugin.id)).map( (grant) => grant.capability, diff --git a/src/backend/tests/database/repositories/recent-activity-repository.test.ts b/src/backend/tests/database/repositories/recent-activity-repository.test.ts deleted file mode 100644 index 08087947d..000000000 --- a/src/backend/tests/database/repositories/recent-activity-repository.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { sql } from "drizzle-orm"; -import { afterEach, describe, expect, it } from "vitest"; -import { TestSqliteDatabase } from "./test-support.js"; -import { RecentActivityRepository } from "../../../database/repositories/recent-activity-repository.js"; - -describe("RecentActivityRepository", () => { - let adapter: TestSqliteDatabase | null = null; - - afterEach(async () => { - if (adapter) { - await adapter.close(); - adapter = null; - } - }); - - async function createRepository( - onWrite?: () => void | Promise, - ): Promise<{ - repository: RecentActivityRepository; - }> { - adapter = new TestSqliteDatabase(); - const context = await adapter.connect(); - await adapter.exec(` - INSERT INTO users (id, username, password_hash) - VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); - INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type) - VALUES (1, 'user-1', 'one', '10.0.0.1', 22, 'root', 'password'), (2, 'user-1', 'two', '10.0.0.1', 22, 'root', 'password'), (3, 'user-2', 'other', '10.0.0.1', 22, 'root', 'password'); - INSERT INTO recent_activity (id, user_id, type, host_id, host_name, timestamp) - VALUES - (1, 'user-1', 'connect', 1, 'one', '2026-06-26T00:00:00.000Z'), - (2, 'user-1', 'disconnect', 2, 'two', '2026-06-26T00:01:00.000Z'), - (3, 'user-2', 'connect', 3, 'other', '2026-06-26T00:02:00.000Z'); - `); - - return { - repository: new RecentActivityRepository(context, onWrite), - }; - } - - it("lists, creates, and trims recent activity", async () => { - let writeCount = 0; - const { repository } = await createRepository(() => { - writeCount += 1; - }); - - expect( - (await repository.listByUserId("user-1", 2)).map((row) => row.id), - ).toEqual([2, 1]); - - const created = await repository.create({ - userId: "user-1", - type: "terminal", - hostId: 1, - hostName: "one", - timestamp: "2026-06-26T00:03:00.000Z", - }); - expect(created).toMatchObject({ - userId: "user-1", - type: "terminal", - hostId: 1, - }); - - expect(await repository.trimUserActivity("user-1", 2)).toBe(1); - expect( - await adapter!.query( - sql`SELECT id FROM recent_activity WHERE user_id = 'user-1' ORDER BY timestamp DESC`, - ), - ).toEqual([{ id: created.id }, { id: 2 }]); - expect(writeCount).toBe(2); - }); - - it("deletes activity by user id and only triggers writes for changed rows", async () => { - let writeCount = 0; - const { repository: repo } = await createRepository(() => { - writeCount += 1; - }); - - expect(await repo.deleteByUserId("missing")).toBe(0); - expect(writeCount).toBe(0); - - expect(await repo.deleteByUserId("user-1")).toBe(2); - expect(writeCount).toBe(1); - expect(await repo.deleteByUserId("user-1")).toBe(0); - expect(writeCount).toBe(1); - }); - - it("deletes activity by host id and host id list", async () => { - let writeCount = 0; - const { repository: repo } = await createRepository(() => { - writeCount += 1; - }); - - expect(await repo.deleteByHostId(1)).toBe(1); - expect(await repo.deleteByHostId(1)).toBe(0); - expect(await repo.deleteByHostIds([])).toBe(0); - expect(await repo.deleteByHostIds([2, 3])).toBe(2); - expect(writeCount).toBe(2); - }); -}); diff --git a/src/backend/tests/database/routes/branding-routes.test.ts b/src/backend/tests/database/routes/branding-routes.test.ts index f9f55e95e..b8f47ded2 100644 --- a/src/backend/tests/database/routes/branding-routes.test.ts +++ b/src/backend/tests/database/routes/branding-routes.test.ts @@ -1,5 +1,4 @@ -import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import sharp from "sharp"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import express, { type NextFunction, type Request, @@ -19,15 +18,6 @@ vi.mock("../../../utils/logger.js", () => ({ vi.mock("../../../database/repositories/factory.js", () => ({ createCurrentSettingsRepository: () => ({ get: async (key: string) => state.settings[key] ?? null, - set: async (key: string, value: string) => { - state.settings[key] = value; - }, - setMany: async (writes: Array<{ key: string; value: string }>) => { - for (const write of writes) state.settings[write.key] = write.value; - }, - delete: async (key: string) => { - delete state.settings[key]; - }, }), })); @@ -42,25 +32,17 @@ interface RouteLayer { }; } -/** Runs a route's full middleware chain (e.g. requireAdmin, then the handler). */ -function handlersFor( - router: express.Router, - pathName: string, - method: string, -): RequestHandler[] { - const layer = (router as unknown as { stack: RouteLayer[] }).stack.find( - (candidate) => - candidate.route?.path === pathName && candidate.route.methods[method], - ); - if (!layer) throw new Error(`Route not registered: ${method} ${pathName}`); - return layer.route!.stack.map((entry) => entry.handle); +function routesOf(router: express.Router) { + return (router as unknown as { stack: RouteLayer[] }).stack + .filter((layer) => layer.route) + .map((layer) => ({ + path: layer.route!.path, + methods: Object.keys(layer.route!.methods), + handlers: layer.route!.stack.map((entry) => entry.handle), + })); } -async function invokeChain( - handlers: RequestHandler[], - body: unknown = {}, -): Promise<{ statusCode: number; body: unknown }> { - const req = { body, headers: {} } as unknown as Request; +async function call(handler: RequestHandler) { const result = { statusCode: 200, body: null as unknown }; const res = { status(code: number) { @@ -72,67 +54,34 @@ async function invokeChain( return this; }, } as unknown as Response; - - for (const handler of handlers) { - let proceeded = false; - await handler(req, res, ((err?: unknown) => { - if (err) throw err; - proceeded = true; - }) as NextFunction); - if (!proceeded) return result; - } + await handler( + { body: {}, headers: {} } as Request, + res, + (() => {}) as NextFunction, + ); return result; } -/** Mimics the real requireAdmin: 401 with no session, 403 for a non-admin. */ -function fakeRequireAdmin( - outcome: "unauthenticated" | "not-admin" | "admin", -): RequestHandler { - return (_req, res, next) => { - if (outcome === "unauthenticated") { - res.status(401).json({ error: "Missing authentication token" }); - return; - } - if (outcome === "not-admin") { - res.status(403).json({ error: "Not authorized" }); - return; - } - // Must return (not just call) next() so invokeChain's await actually - // waits for the downstream async handler to finish before returning. - return next(); - }; -} - -function buildRouter(requireAdmin: RequestHandler) { - const router = express.Router(); - registerBrandingRoutes(router, requireAdmin); - return router; -} - -let pngDataUrl: string; - -beforeAll(async () => { - const buffer = await sharp({ - create: { width: 4, height: 4, channels: 3, background: "#112233" }, - }) - .png() - .toBuffer(); - pngDataUrl = `data:image/png;base64,${buffer.toString("base64")}`; -}); - beforeEach(() => { state.settings = {}; }); describe("GET /users/branding", () => { - it("succeeds with no auth at all, since the route registers no auth handler", async () => { - // requireAdmin is only ever wired onto PATCH below; GET's own handler chain - // has exactly one entry, proving the public route never runs an admin gate. - const router = buildRouter(fakeRequireAdmin("unauthenticated")); - const handlers = handlersFor(router, "/branding", "get"); - expect(handlers).toHaveLength(1); + it("is the only branding route and has no auth handler", () => { + const router = express.Router(); + registerBrandingRoutes(router); + const routes = routesOf(router); - const response = await invokeChain(handlers); + expect(routes).toHaveLength(1); + expect(routes[0].path).toBe("/branding"); + expect(routes[0].methods).toEqual(["get"]); + expect(routes[0].handlers).toHaveLength(1); + }); + + it("answers defaults when nothing is set", async () => { + const router = express.Router(); + registerBrandingRoutes(router); + const response = await call(routesOf(router)[0].handlers[0]); expect(response.statusCode).toBe(200); expect(response.body).toEqual({ @@ -142,126 +91,19 @@ describe("GET /users/branding", () => { }); }); - it("reflects persisted branding values", async () => { - state.settings.branding_app_name = "Acme Ops"; - state.settings.branding_tagline = "Ship it"; - state.settings.branding_logo = pngDataUrl; - const router = buildRouter(fakeRequireAdmin("admin")); + it("keeps serving the stored branding of an older install", async () => { + state.settings = { + branding_app_name: "Acme", + branding_tagline: "Ops", + }; + const router = express.Router(); + registerBrandingRoutes(router); + const response = await call(routesOf(router)[0].handlers[0]); - const response = await invokeChain(handlersFor(router, "/branding", "get")); - - expect(response.statusCode).toBe(200); expect(response.body).toEqual({ - appName: "Acme Ops", - tagline: "Ship it", - logo: pngDataUrl, + appName: "Acme", + tagline: "Ops", + logo: null, }); }); }); - -describe("PATCH /users/branding", () => { - it("rejects an unauthenticated request and persists nothing", async () => { - const router = buildRouter(fakeRequireAdmin("unauthenticated")); - - const response = await invokeChain( - handlersFor(router, "/branding", "patch"), - { appName: "Hijacked" }, - ); - - expect(response.statusCode).toBe(401); - expect(state.settings).toEqual({}); - }); - - it("rejects a non-admin request and persists nothing", async () => { - const router = buildRouter(fakeRequireAdmin("not-admin")); - - const response = await invokeChain( - handlersFor(router, "/branding", "patch"), - { appName: "Hijacked" }, - ); - - expect(response.statusCode).toBe(403); - expect(state.settings).toEqual({}); - }); - - it("persists only the allowed fields for an admin request", async () => { - const router = buildRouter(fakeRequireAdmin("admin")); - - const response = await invokeChain( - handlersFor(router, "/branding", "patch"), - { appName: "Acme Ops", tagline: "Ship it", logo: pngDataUrl }, - ); - - expect(response.statusCode).toBe(200); - expect(state.settings.branding_app_name).toBe("Acme Ops"); - expect(state.settings.branding_tagline).toBe("Ship it"); - expect(state.settings.branding_logo).toMatch(/^data:image\/png;base64,/); - expect(response.body).toMatchObject({ - appName: "Acme Ops", - tagline: "Ship it", - }); - }); - - it("rejects unknown fields without writing anything", async () => { - const router = buildRouter(fakeRequireAdmin("admin")); - - const response = await invokeChain( - handlersFor(router, "/branding", "patch"), - { theme: "dark" }, - ); - - expect(response.statusCode).toBe(400); - expect(response.body).toMatchObject({ - code: "BRANDING_SETTINGS_UNKNOWN_FIELD", - }); - expect(state.settings).toEqual({}); - }); - - it("rejects an app name over the length limit", async () => { - const router = buildRouter(fakeRequireAdmin("admin")); - - const response = await invokeChain( - handlersFor(router, "/branding", "patch"), - { appName: "x".repeat(61) }, - ); - - expect(response.statusCode).toBe(400); - expect(response.body).toMatchObject({ - code: "BRANDING_SETTINGS_INVALID", - field: "appName", - }); - expect(state.settings).toEqual({}); - }); - - it("rejects malformed/non-raster logo data without persisting it", async () => { - const router = buildRouter(fakeRequireAdmin("admin")); - - const response = await invokeChain( - handlersFor(router, "/branding", "patch"), - { - logo: `data:image/png;base64,${Buffer.from("not an image").toString("base64")}`, - }, - ); - - expect(response.statusCode).toBe(400); - expect(response.body).toMatchObject({ - code: "BRANDING_SETTINGS_INVALID", - field: "logo", - }); - expect(state.settings).toEqual({}); - }); - - it("clears the logo when patched with null", async () => { - state.settings.branding_logo = pngDataUrl; - const router = buildRouter(fakeRequireAdmin("admin")); - - const response = await invokeChain( - handlersFor(router, "/branding", "patch"), - { logo: null }, - ); - - expect(response.statusCode).toBe(200); - expect(state.settings.branding_logo).toBeUndefined(); - expect(response.body).toMatchObject({ logo: null }); - }); -}); diff --git a/src/backend/tests/database/routes/branding-settings.test.ts b/src/backend/tests/database/routes/branding-settings.test.ts index bbed21e7c..2df5bdc92 100644 --- a/src/backend/tests/database/routes/branding-settings.test.ts +++ b/src/backend/tests/database/routes/branding-settings.test.ts @@ -1,149 +1,17 @@ -import { beforeAll, describe, expect, it, vi } from "vitest"; -import sharp from "sharp"; +import { describe, expect, it, vi } from "vitest"; vi.mock("../../../utils/logger.js", () => ({ authLogger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() }, databaseLogger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() }, })); -const { - BRANDING_DEFAULT_APP_NAME, - BRANDING_LOGO_MAX_BYTES, - parseBrandingAppName, - parseBrandingLogoDataUrl, - parseBrandingTagline, - resolveBrandingSettings, -} = await import("../../../database/routes/branding-settings.js"); +const { BRANDING_DEFAULT_APP_NAME, resolveBrandingSettings } = + await import("../../../database/routes/branding-settings.js"); function settingSource(values: Record) { return { get: async (key: string) => values[key] ?? null }; } -async function toDataUrl( - buffer: Buffer, - mime: "image/png" | "image/jpeg" | "image/webp", -): Promise { - return `data:${mime};base64,${buffer.toString("base64")}`; -} - -let pngBuffer: Buffer; -let jpegBuffer: Buffer; -let webpBuffer: Buffer; -let oversizedPngBuffer: Buffer; - -beforeAll(async () => { - const image = sharp({ - create: { width: 4, height: 4, channels: 3, background: "#336699" }, - }); - pngBuffer = await image.clone().png().toBuffer(); - jpegBuffer = await image.clone().jpeg().toBuffer(); - webpBuffer = await image.clone().webp().toBuffer(); - oversizedPngBuffer = await sharp({ - create: { width: 1200, height: 1200, channels: 4, background: "#abcdef" }, - }) - .png({ compressionLevel: 0 }) - .toBuffer(); -}); - -describe("parseBrandingAppName", () => { - it("trims and accepts a normal name", () => { - expect(parseBrandingAppName(" My Company ")).toBe("My Company"); - }); - - it("falls back to the default when cleared", () => { - expect(parseBrandingAppName("")).toBe(BRANDING_DEFAULT_APP_NAME); - expect(parseBrandingAppName(" ")).toBe(BRANDING_DEFAULT_APP_NAME); - }); - - it("rejects names over 60 characters", () => { - expect(parseBrandingAppName("x".repeat(61))).toBeNull(); - }); - - it("rejects non-string values", () => { - expect(parseBrandingAppName(42)).toBeNull(); - expect(parseBrandingAppName(null)).toBeNull(); - }); -}); - -describe("parseBrandingTagline", () => { - it("trims and accepts a normal tagline", () => { - expect(parseBrandingTagline(" Ship faster ")).toBe("Ship faster"); - }); - - it("accepts an empty tagline (means: use the default)", () => { - expect(parseBrandingTagline("")).toBe(""); - expect(parseBrandingTagline(" ")).toBe(""); - }); - - it("rejects taglines over 160 characters", () => { - expect(parseBrandingTagline("x".repeat(161))).toBeNull(); - }); - - it("rejects non-string values", () => { - expect(parseBrandingTagline(42)).toBeNull(); - }); -}); - -describe("parseBrandingLogoDataUrl", () => { - it("accepts a PNG data URL and re-encodes it to PNG", async () => { - const result = await parseBrandingLogoDataUrl( - await toDataUrl(pngBuffer, "image/png"), - ); - expect(result).not.toBeNull(); - expect(result!.startsWith("data:image/png;base64,")).toBe(true); - const decoded = Buffer.from(result!.split(",")[1]!, "base64"); - expect(decoded.subarray(1, 4).toString()).toBe("PNG"); - }); - - it("accepts a JPEG data URL and normalizes it to PNG", async () => { - const result = await parseBrandingLogoDataUrl( - await toDataUrl(jpegBuffer, "image/jpeg"), - ); - expect(result).not.toBeNull(); - expect(result!.startsWith("data:image/png;base64,")).toBe(true); - }); - - it("accepts a WEBP data URL and normalizes it to PNG", async () => { - const result = await parseBrandingLogoDataUrl( - await toDataUrl(webpBuffer, "image/webp"), - ); - expect(result).not.toBeNull(); - expect(result!.startsWith("data:image/png;base64,")).toBe(true); - }); - - it("rejects a non-image/SVG mime prefix outright", async () => { - const svg = Buffer.from('', "utf8").toString( - "base64", - ); - const result = await parseBrandingLogoDataUrl( - `data:image/svg+xml;base64,${svg}`, - ); - expect(result).toBeNull(); - }); - - it("rejects a value that isn't a data URL at all", async () => { - expect(await parseBrandingLogoDataUrl("not-a-data-url")).toBeNull(); - expect(await parseBrandingLogoDataUrl(undefined)).toBeNull(); - }); - - it("rejects a spoofed data URL whose bytes aren't a real image", async () => { - const fake = Buffer.from("definitely not an image").toString("base64"); - const result = await parseBrandingLogoDataUrl( - `data:image/png;base64,${fake}`, - ); - expect(result).toBeNull(); - }); - - it("rejects an image that normalizes over the size cap", async () => { - // Sanity check the fixture is actually large enough to exceed the cap. - expect(oversizedPngBuffer.length).toBeGreaterThan(BRANDING_LOGO_MAX_BYTES); - const result = await parseBrandingLogoDataUrl( - await toDataUrl(oversizedPngBuffer, "image/png"), - ); - expect(result).toBeNull(); - }); -}); - describe("resolveBrandingSettings", () => { it("returns built-in defaults when nothing is persisted", async () => { const settings = await resolveBrandingSettings(settingSource({})); @@ -169,3 +37,34 @@ describe("resolveBrandingSettings", () => { }); }); }); + +describe("branding provider", () => { + it("uses a plugin's branding provider and falls back when it throws", async () => { + const registry = await import("../../../plugins/registry.js"); + const provider = async () => ({ + appName: " Acme ", + tagline: "Hello", + logo: "data:image/png;base64,AAAA", + }); + registry.provide("demo.brandingProvider", provider); + try { + await expect(resolveBrandingSettings(settingSource({}))).resolves.toEqual( + { + appName: "Acme", + tagline: "Hello", + logo: "data:image/png;base64,AAAA", + }, + ); + + registry.revoke("demo.brandingProvider"); + registry.provide("demo.brandingProvider", async () => { + throw new Error("broken"); + }); + await expect( + resolveBrandingSettings(settingSource({ branding_app_name: "Stored" })), + ).resolves.toMatchObject({ appName: "Stored" }); + } finally { + registry.clearRegistry(); + } + }); +}); diff --git a/src/backend/tests/database/routes/credentials-secret-reference.test.ts b/src/backend/tests/database/routes/credentials-secret-reference.test.ts index 4f27a6943..7b7a2e9ee 100644 --- a/src/backend/tests/database/routes/credentials-secret-reference.test.ts +++ b/src/backend/tests/database/routes/credentials-secret-reference.test.ts @@ -52,12 +52,6 @@ vi.mock("../../../utils/logger.js", () => { }; return { authLogger: log, sshLogger: log, databaseLogger: log }; }); -vi.mock("../../../database/routes/credential-key-routes.js", () => ({ - registerCredentialKeyRoutes: () => {}, -})); -vi.mock("../../../database/routes/credential-deploy-routes.js", () => ({ - registerCredentialDeployRoutes: () => {}, -})); vi.mock("../../../database/routes/credential-bulk-routes.js", () => ({ registerCredentialBulkRoutes: () => {}, })); diff --git a/src/backend/tests/database/routes/dashboard-routes.test.ts b/src/backend/tests/database/routes/dashboard-routes.test.ts index 234fabc57..0dce85d2c 100644 --- a/src/backend/tests/database/routes/dashboard-routes.test.ts +++ b/src/backend/tests/database/routes/dashboard-routes.test.ts @@ -34,15 +34,12 @@ vi.mock("../../../utils/data-crypto.js", () => ({ }, })); -vi.mock("../../../database/repositories/factory.js", () => ({ - createCurrentRecentActivityRepository: () => ({ - listByUserId: async () => state.activity, +vi.mock("../../../plugins/activity-provider.js", () => ({ + recentActivity: { + list: async () => state.activity, deleteByUserId: async () => {}, - }), -})); - -vi.mock("../../../services/recent-activity.js", () => ({ - recordRecentActivity: async () => state.recordResult, + record: async () => state.recordResult, + }, })); const { default: dashboardRoutes } = diff --git a/src/backend/tests/database/routes/host-bulk-routes.test.ts b/src/backend/tests/database/routes/host-bulk-routes.test.ts index 969120fa1..5912294bb 100644 --- a/src/backend/tests/database/routes/host-bulk-routes.test.ts +++ b/src/backend/tests/database/routes/host-bulk-routes.test.ts @@ -3,7 +3,7 @@ import { importedHostUsername, notifyImportedHosts, parseSSHConfig, -} from "../../../database/routes/host-bulk-routes.js"; +} from "../../../hosts/import/host-import.js"; import { pluginEvents, TOPICS } from "../../../plugins/events.js"; describe("parseSSHConfig", () => { diff --git a/src/backend/tests/database/routes/host-import-terminal-settings.test.ts b/src/backend/tests/database/routes/host-import-terminal-settings.test.ts index f61ff5649..1e796a7ec 100644 --- a/src/backend/tests/database/routes/host-import-terminal-settings.test.ts +++ b/src/backend/tests/database/routes/host-import-terminal-settings.test.ts @@ -1,5 +1,5 @@ import { beforeEach, expect, it, vi } from "vitest"; -import type { Router, RequestHandler, Request, Response } from "express"; +import type { RequestHandler, Request, Response } from "express"; const mocks = vi.hoisted(() => ({ create: vi.fn(), @@ -35,23 +35,35 @@ vi.mock("../../../database/repositories/factory.js", () => ({ findHostsByUserId: mocks.list, }), })); -import { registerHostBulkRoutes } from "../../../database/routes/host-bulk-routes.js"; -import { buildExportPayload } from "../../../../ui/sidebar/host-export-payload"; +import { + ImportError, + importResponse, + runBulkImport, + runSshConfigImport, +} from "../../../hosts/import/host-import.js"; import { pluginEvents, TOPICS } from "../../../plugins/events.js"; -let handler: RequestHandler; -let sshConfigHandler: RequestHandler; -const router = { - post: (path: string, ...handlers: RequestHandler[]) => { - if (path === "/bulk-import") handler = handlers.at(-1)!; - if (path === "/ssh-config-import") sshConfigHandler = handlers.at(-1)!; - }, - put: () => {}, - patch: () => {}, - delete: () => {}, -} as unknown as Router; -const pass: RequestHandler = (_req, _res, next) => next(); -registerHostBulkRoutes(router, pass, pass, pass, pass); +// The same wrapping the plugin's routes give the shared import code. +const wrap = + (run: typeof runBulkImport): RequestHandler => + async (req, res) => { + try { + const body = (req as unknown as { body: never }).body; + res.json( + importResponse( + await run((req as unknown as { userId: string }).userId, body), + ), + ); + } catch (error) { + if (error instanceof ImportError) { + res.status(400).json({ error: error.message }); + return; + } + throw error; + } + }; +const handler = wrap(runBulkImport); +const sshConfigHandler = wrap(runSshConfigImport as typeof runBulkImport); const host = { ip: "192.0.2.1", port: 22, @@ -76,12 +88,7 @@ it.each([false, true])( "carries plugin host settings through export selection and import (overwrite=%s)", async (overwrite) => { if (overwrite) mocks.list.mockResolvedValue([{ ...host, id: 19 }]); - const payload = buildExportPayload( - { hosts: [host] }, - null, - new Set(["featureFlags", "advanced"]), - false, - ); + const payload = { hosts: [host] }; expect(payload.hosts[0]).toMatchObject(host); const json = vi.fn(); await handler( @@ -130,12 +137,7 @@ it.each([20, "20"])( { ...host, ip: "192.0.2.2", exportId: 20 }, ], }; - const payload = buildExportPayload( - raw, - null, - new Set(["jumpHosts"]), - false, - ); + const payload = raw; const res = await importHosts(payload.hosts); expect(res.json).toHaveBeenCalledWith( expect.objectContaining({ success: 2, failed: 0 }), diff --git a/src/backend/tests/database/routes/host-status-routes.test.ts b/src/backend/tests/database/routes/host-status-routes.test.ts index 4611b210e..7f68c9d95 100644 --- a/src/backend/tests/database/routes/host-status-routes.test.ts +++ b/src/backend/tests/database/routes/host-status-routes.test.ts @@ -15,7 +15,8 @@ const service = vi.hoisted(() => ({ get: vi.fn(), check: vi.fn(), refresh: vi.fn(), - retimeAll: vi.fn(), + getIntervalSeconds: vi.fn(), + setIntervalSeconds: vi.fn(), })); vi.mock("../../../utils/logger.js", () => ({ @@ -35,18 +36,8 @@ vi.mock("../../../utils/permission-manager.js", () => ({ }), }, })); -vi.mock("../../../database/repositories/factory.js", () => ({ - createCurrentSettingsRepository: () => ({ - get: async (key: string) => state.settings[key] ?? null, - set: async (key: string, value: string) => { - state.settings[key] = value; - }, - }), -})); -vi.mock("../../../hosts/status/host-status-service.js", () => ({ - DEFAULT_STATUS_INTERVAL: 60, - GLOBAL_STATUS_INTERVAL_KEY: "global_status_check_interval", - hostStatusService: service, +vi.mock("../../../plugins/host-status-provider.js", () => ({ + hostStatus: service, })); const { registerHostStatusRoutes, parseStatusHostIds } = @@ -83,6 +74,8 @@ async function call(method: string, path: string, body?: unknown) { beforeEach(() => { state.settings = {}; + service.getIntervalSeconds.mockResolvedValue(60); + service.setIntervalSeconds.mockResolvedValue(undefined); state.locked = false; state.accessible = new Set([1, 2]); state.statuses = new Map([ @@ -149,14 +142,13 @@ describe("status settings", () => { }); }); - it("saves a new interval and re-times the checks", async () => { + it("saves a new interval through the provider", async () => { await start(); const { status } = await call("PUT", "/host/status/settings", { statusCheckInterval: 90, }); expect(status).toBe(200); - expect(state.settings.global_status_check_interval).toBe("90"); - expect(service.retimeAll).toHaveBeenCalledOnce(); + expect(service.setIntervalSeconds).toHaveBeenCalledWith(90); }); it("rejects an interval out of range", async () => { diff --git a/src/backend/tests/database/routes/rbac-route-coverage.test.ts b/src/backend/tests/database/routes/rbac-route-coverage.test.ts index c12df3696..b00825c38 100644 --- a/src/backend/tests/database/routes/rbac-route-coverage.test.ts +++ b/src/backend/tests/database/routes/rbac-route-coverage.test.ts @@ -24,14 +24,12 @@ describe("RBAC coverage for split host routers", () => { it("gates bulk mutations by action and data-access permissions", () => { const router = express.Router(); const authenticate = middleware(); - const requireCreate = middleware(); const requireEdit = middleware(); const requireDataAccess = middleware(); registerHostBulkRoutes( router, authenticate, - requireCreate, requireEdit, requireDataAccess, ); @@ -46,14 +44,6 @@ describe("RBAC coverage for split host routers", () => { requireEdit, requireDataAccess, ]); - for (const path of ["/bulk-import", "/ssh-config-import"]) { - expect(handlers(router, "post", path).slice(0, 4)).toEqual([ - authenticate, - requireCreate, - requireEdit, - requireDataAccess, - ]); - } }); it("gates folder reads and mutations with their matching permissions", () => { diff --git a/src/backend/tests/database/routes/user-password-reset-routes.test.ts b/src/backend/tests/database/routes/user-password-reset-routes.test.ts index dfecc6a19..516415d5f 100644 --- a/src/backend/tests/database/routes/user-password-reset-routes.test.ts +++ b/src/backend/tests/database/routes/user-password-reset-routes.test.ts @@ -28,11 +28,18 @@ vi.mock("../../../database/repositories/factory.js", () => ({ }), createCurrentSettingsRepository: () => ({}), createCurrentSshCredentialUsageRepository: deletingRepo("usage"), - createCurrentRecentActivityRepository: deletingRepo("activity"), createCurrentHostRepository: deletingRepo("hosts"), createCurrentCredentialRepository: deletingRepo("credentials"), })); +vi.mock("../../../plugins/activity-provider.js", () => ({ + recentActivity: { + deleteByUserId: async (userId: string) => { + calls.deletedFor.push(`activity:${userId}`); + }, + }, +})); + vi.mock("../../../plugins/events.js", () => ({ pluginEvents: { emit: (_topic: string, payload: { userId: string }) => { diff --git a/src/backend/tests/electron/c2s-auth.test.ts b/src/backend/tests/electron/c2s-auth.test.ts deleted file mode 100644 index 429490b5d..000000000 --- a/src/backend/tests/electron/c2s-auth.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { createRequire } from "node:module"; -import { EventEmitter } from "node:events"; -import { afterEach, describe, expect, it, vi } from "vitest"; -const require = createRequire(import.meta.url); -const { createC2SAuthBridge } = require("../../../../electron/c2s-auth.cjs"); - -function setup() { - const sender = Object.assign(new EventEmitter(), { - send: vi.fn(), - isDestroyed: () => false, - }); - const ws = Object.assign(new EventEmitter(), { - readyState: 1, - send: vi.fn(), - close: vi.fn(() => { - ws.readyState = 3; - ws.emit("close"); - }), - }); - let answer!: ( - event: { sender: unknown }, - id: string, - value: unknown, - ) => boolean; - const attach = createC2SAuthBridge( - { - handle: (_name: string, fn: typeof answer) => { - answer = fn; - }, - }, - () => ({ isDestroyed: () => false, webContents: sender }), - ); - attach(ws, "dynamic"); - const prompt = () => - ws.emit( - "message", - Buffer.from( - JSON.stringify({ - type: "auth-prompt", - requestId: "server-challenge", - request: { kind: "totp", prompt: "Code:", retry: false }, - }), - ), - false, - ); - return { sender, ws, answer, prompt }; -} -afterEach(() => vi.useRealTimers()); -describe("desktop tunnel prompt bridge", () => { - it("binds answers to the renderer and challenge, and rejects replay", () => { - const { sender, ws, answer, prompt } = setup(); - prompt(); - const id = sender.send.mock.calls[0][1].id; - expect(answer({ sender: {} }, id, "123456")).toBe(false); - expect(answer({ sender }, "unknown", "123456")).toBe(false); - expect(answer({ sender }, id, { secret: "bad" })).toBe(false); - expect(ws.send).not.toHaveBeenCalled(); - expect(answer({ sender }, id, "123456")).toBe(true); - expect(JSON.parse(ws.send.mock.calls[0][0])).toEqual({ - type: "auth-response", - requestId: "server-challenge", - answer: "123456", - }); - expect(answer({ sender }, id, "again")).toBe(false); - expect(sender.listenerCount("destroyed")).toBe(0); - ws.close(); - }); - it.each(["close", "reload", "timeout"])( - "clears a pending dialog on %s", - (reason) => { - vi.useFakeTimers(); - const { sender, ws, answer, prompt } = setup(); - prompt(); - const id = sender.send.mock.calls[0][1].id; - if (reason === "close") ws.close(); - else if (reason === "reload") sender.emit("did-start-loading"); - else vi.advanceTimersByTime(60_000); - expect(sender.send).toHaveBeenLastCalledWith("c2s-auth-prompt", { - id, - closed: true, - }); - expect(answer({ sender }, id, "late")).toBe(false); - expect(ws.close).toHaveBeenCalled(); - }, - ); -}); diff --git a/src/backend/tests/electron/c2s-runtime.test.ts b/src/backend/tests/electron/c2s-runtime.test.ts deleted file mode 100644 index a65c224a7..000000000 --- a/src/backend/tests/electron/c2s-runtime.test.ts +++ /dev/null @@ -1,238 +0,0 @@ -import { EventEmitter } from "node:events"; -import { readFileSync } from "node:fs"; -import vm from "node:vm"; -import { describe, expect, it, vi } from "vitest"; - -const main = readFileSync( - new URL("../../../../electron/main.cjs", import.meta.url), - "utf8", -); -const section = (from: string, to: string) => - main.slice(main.indexOf(from), main.indexOf(to, main.indexOf(from))); -function setup() { - const sockets: Socket[] = []; - class Socket extends EventEmitter { - readyState = 1; - send = vi.fn(); - close = vi.fn(() => { - if (this.readyState === 3) return; - this.readyState = 3; - this.emit("close"); - }); - constructor() { - super(); - sockets.push(this); - queueMicrotask(() => this.emit("open")); - } - ready() { - this.emit( - "message", - Buffer.from(JSON.stringify({ type: "ready" })), - false, - ); - } - } - const runtimes = new Map(); - let incoming!: (socket: EventEmitter) => void; - const server = Object.assign(new EventEmitter(), { - listen: (_options: unknown, ready: () => void) => ready(), - close: vi.fn((done?: () => void) => done?.()), - }); - const headers = vi.fn(async () => ({})); - const dynamic = vi.fn(); - const context = vm.createContext({ - setTimeout, - clearTimeout, - WebSocket: Socket, - crypto: { randomUUID: () => "runtime-token" }, - net: { - createServer: (callback: typeof incoming) => { - incoming = callback; - return server; - }, - }, - c2sTunnelRuntimes: runtimes, - normalizeC2STunnelAddresses: (t: unknown) => t, - resolveC2SRemoteSourceHost: async (t: unknown) => t, - getC2STunnelName: (t: { name: string }) => t.name, - getC2SLocalAddress: () => "127.0.0.1", - getC2SRemoteAddress: () => "127.0.0.1", - getC2SRelayUrl: () => "wss://server/relay", - getC2SRelayHeaders: headers, - getWebSocketOptions: (_url: string, options: unknown) => options, - attachC2SAuth: vi.fn(), - keepC2SSocketAlive: vi.fn(), - logToFile: vi.fn(), - emitC2STunnelStatuses: vi.fn(), - checkLocalPortAvailable: async () => ({ available: true }), - handleC2SDynamicConnection: dynamic, - setC2STunnelStatus: (name: string, status: unknown) => { - runtimes.get(name).status = status; - }, - setC2STunnelError: (name: string, error: string) => { - runtimes.get(name).status = { connected: false, reason: error }; - }, - createC2SFailure: (error: string) => ({ success: false, error }), - normalizeC2SErrorMessage: (message: string) => message, - C2S_REMOTE_SESSION_EXPIRED_ERROR: "expired", - }); - vm.runInContext( - section("async function openC2SRelay(", "async function testC2SRelay(") + - section("async function testC2SRelay(", "async function testC2STunnel(") + - section("async function startC2STunnel(", "function stopAllC2STunnels("), - context, - ); - return { - context, - runtimes, - sockets, - server, - headers, - dynamic, - incoming: () => incoming, - }; -} - -describe("client tunnel runtime authentication", () => { - it("holds preflight open, shares its runtime identity and token, and releases it when stopped", async () => { - const { context, runtimes, sockets, server, headers, dynamic, incoming } = - setup(); - const tunnel = { - name: "socks", - mode: "dynamic", - sourceHostId: 7, - sourcePort: 1080, - }; - await context.startC2STunnel(tunnel, 0, "local-auth"); - await vi.waitFor(() => expect(sockets).toHaveLength(1)); - const premature = Object.assign(new EventEmitter(), { destroy: vi.fn() }); - incoming()(premature); - expect(premature.destroy).toHaveBeenCalledOnce(); - expect(headers).toHaveBeenCalledWith("wss://server/relay", "local-auth"); - expect(JSON.parse(sockets[0].send.mock.calls[0][0])).toMatchObject({ - keepAlive: true, - tunnelConfig: { sessionId: "runtime-token" }, - }); - sockets[0].ready(); - await vi.waitFor(() => - expect(runtimes.get("socks").status.connected).toBe(true), - ); - expect(sockets[0].close).not.toHaveBeenCalled(); - const client = Object.assign(new EventEmitter(), { destroy: vi.fn() }); - incoming()(client); - expect(dynamic).toHaveBeenCalledWith( - expect.objectContaining({ sessionId: "runtime-token" }), - client, - "local-auth", - ); - await context.stopC2STunnel("socks"); - expect(sockets[0].close).toHaveBeenCalledOnce(); - expect(client.destroy).toHaveBeenCalledOnce(); - expect(server.close).toHaveBeenCalledOnce(); - expect(runtimes.size).toBe(0); - }); - - it("does not retain a socket for a standalone test or a stopped runtime", async () => { - const { context, sockets } = setup(); - const test = context.testC2SRelay({ name: "test" }); - await vi.waitFor(() => expect(sockets).toHaveLength(1)); - sockets[0].ready(); - expect(await test).toEqual({ success: true }); - expect(sockets[0].close).toHaveBeenCalledOnce(); - expect( - await context.testC2SRelay({ name: "stopped" }, null, null, null, {}), - ).toEqual({ success: false, error: "Tunnel stopped" }); - expect(sockets).toHaveLength(1); - }); - - it("keeps the relay's reason when it closes after an error", async () => { - const { context, runtimes, sockets } = setup(); - const runtime = { status: { connected: true } }; - runtimes.set("web", runtime); - const test = context.testC2SRelay( - { name: "web" }, - "127.0.0.1", - 8443, - null, - runtime, - ); - await vi.waitFor(() => expect(sockets).toHaveLength(1)); - sockets[0].emit( - "message", - Buffer.from( - JSON.stringify({ - type: "error", - error: "Target 127.0.0.1:8443 refused", - }), - ), - false, - ); - sockets[0].close(); - expect(await test).toMatchObject({ success: false }); - expect(runtimes.get("web").status.reason).toBe( - "Target 127.0.0.1:8443 refused", - ); - }); - - it("keeps the tunnel connected when one of its connections fails", async () => { - const { context, runtimes, sockets } = setup(); - runtimes.set("web", { status: { connected: true, status: "CONNECTED" } }); - const local = () => - Object.assign(new EventEmitter(), { - destroy: vi.fn(), - write: vi.fn(), - }); - - const first = local(); - await context.openC2SRelay({ name: "web" }, "127.0.0.1", 8006, first); - await vi.waitFor(() => expect(sockets).toHaveLength(1)); - first.emit("error", new Error("ECONNRESET")); - sockets[0].emit( - "message", - Buffer.from(JSON.stringify({ type: "error", error: "refused" })), - false, - ); - expect(runtimes.get("web").status.connected).toBe(true); - - await context.openC2SRelay({ name: "web" }, "127.0.0.1", 8006, local()); - await vi.waitFor(() => expect(sockets).toHaveLength(2)); - sockets[1].emit( - "message", - Buffer.from(JSON.stringify({ type: "error", error: "expired" })), - false, - ); - expect(runtimes.get("web").status).toMatchObject({ - connected: false, - reason: "expired", - }); - }); -}); - -describe("client tunnel socket keepalive", () => { - it("pings an open socket until it closes", () => { - vi.useFakeTimers(); - try { - const context = vm.createContext({ - setInterval, - clearInterval, - WebSocket: { OPEN: 1 }, - }); - vm.runInContext( - section("function keepC2SSocketAlive(", "// C2S (client-to-server)"), - context, - ); - const ws = Object.assign(new EventEmitter(), { - readyState: 1, - ping: vi.fn(), - }); - context.keepC2SSocketAlive(ws); - vi.advanceTimersByTime(50_000); - expect(ws.ping).toHaveBeenCalledTimes(2); - ws.emit("close"); - vi.advanceTimersByTime(50_000); - expect(ws.ping).toHaveBeenCalledTimes(2); - } finally { - vi.useRealTimers(); - } - }); -}); diff --git a/src/backend/tests/electron/linked-certificate.test.ts b/src/backend/tests/electron/linked-certificate.test.ts index 87bab23f6..67f48fc20 100644 --- a/src/backend/tests/electron/linked-certificate.test.ts +++ b/src/backend/tests/electron/linked-certificate.test.ts @@ -14,7 +14,7 @@ const source = fs.readFileSync( ); const tls = source.slice( source.indexOf("const INSECURE_MODE_VALUES"), - source.indexOf("function httpFetch("), + source.indexOf('if (process.platform === "linux") {'), ); const ORIGIN = "https://termix.example"; @@ -30,10 +30,7 @@ function load(link: Record | null) { vm.runInContext(tls, context); return context as unknown as { isInvalidCertificateAllowedForUrl: (url: string) => boolean; - getWebSocketOptions: ( - url: string, - options?: Record, - ) => Record; + getTlsVerificationOptions: (url: string) => Record; }; } @@ -41,8 +38,7 @@ describe("linked server certificate exception", () => { it("covers WebSockets to the linked server", () => { const main = load({ origin: ORIGIN, allowInvalidCertificate: true }); expect(main.isInvalidCertificateAllowedForUrl(TERMINAL)).toBe(true); - expect(main.getWebSocketOptions(TERMINAL, { headers: {} })).toEqual({ - headers: {}, + expect(main.getTlsVerificationOptions(TERMINAL)).toEqual({ rejectUnauthorized: false, }); }); @@ -58,7 +54,7 @@ describe("linked server certificate exception", () => { it("keeps verifying when the link does not allow it", () => { const main = load({ origin: ORIGIN, allowInvalidCertificate: false }); - expect(main.getWebSocketOptions(TERMINAL)).toEqual({ + expect(main.getTlsVerificationOptions(TERMINAL)).toEqual({ rejectUnauthorized: true, }); }); diff --git a/src/backend/tests/electron/local-collection-limit.test.ts b/src/backend/tests/electron/local-collection-limit.test.ts deleted file mode 100644 index 6a310d3f0..000000000 --- a/src/backend/tests/electron/local-collection-limit.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import vm from "node:vm"; -import { describe, expect, it } from "vitest"; - -const source = fs.readFileSync( - new URL("../../../../electron/main.cjs", import.meta.url), - "utf8", -); -const collector = source.slice( - source.indexOf("function collectLocalFilesFromPath("), - source.indexOf('ipcMain.handle("get-local-home-directory"'), -); -const handler = source.slice( - source.indexOf('ipcMain.handle("collect-local-files"'), - source.indexOf('ipcMain.handle("create-local-folder"'), -); - -describe("local collection limit", () => { - it.each([9999, 10000, 10001])( - "reports truncation only when %s files exceed the limit", - (count) => { - let collect: ( - _event: unknown, - paths: string[], - ) => { success: boolean; files: unknown[]; truncated: boolean }; - vm.runInNewContext(collector + handler, { - fs: { - lstatSync: (p: string) => ({ - isSymbolicLink: () => false, - isFile: () => p !== "/root", - isDirectory: () => p === "/root", - size: 1, - birthtime: new Date(0), - mtime: new Date(0), - }), - readdirSync: () => - Array.from({ length: count }, (_, i) => `file${i}`), - }, - path: path.posix, - normalizeRelativeLocalPath: (p: string) => p, - ipcMain: { - handle: (_name: string, callback: typeof collect) => { - collect = callback; - }, - }, - }); - const result = collect!(null, ["/root"]); - expect(result.success).toBe(true); - expect(result.files).toHaveLength(Math.min(10000, count)); - expect(result.truncated).toBe(count > 10000); - }, - ); -}); diff --git a/src/backend/tests/electron/local-files.test.ts b/src/backend/tests/electron/local-files.test.ts deleted file mode 100644 index 5999ad547..000000000 --- a/src/backend/tests/electron/local-files.test.ts +++ /dev/null @@ -1,1166 +0,0 @@ -import { createRequire } from "node:module"; -import http from "node:http"; -import fs from "node:fs"; -import fsp from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import crypto from "node:crypto"; -import { EventEmitter } from "node:events"; -import { - afterAll, - afterEach, - beforeAll, - beforeEach, - describe, - expect, - it, -} from "vitest"; - -const require = createRequire(import.meta.url); - -type PublishFs = { - rename: (from: string, to: string) => Promise; - link: (from: string, to: string) => Promise; - copyFileExcl: (from: string, to: string) => Promise; - rm: (target: string) => Promise; - lstat: (target: string) => Promise; -}; - -const localFiles = require("../../../../electron/local-files.cjs") as { - assertWithinRoot: ( - rootPath: string, - candidate: string, - pathImpl?: typeof path, - ) => string; - publishDownload: ( - partialPath: string, - absDest: string, - overwrite: boolean, - transferId: string, - io?: PublishFs, - ) => Promise; - defaultPublishFs: PublishFs; - IPC: Record; - TRANSFER_ROUTES: Record; - createTargetResolver: (deps: { - localBaseUrl?: string; - getLinkedServer?: () => { - serverUrl?: string; - token?: string | null; - } | null; - }) => (req: { origin?: unknown; route?: unknown; deviceId?: unknown }) => { - url: string; - headers: Record; - }; - createLocalFileHandlers: (deps: { - net: unknown; - shell: unknown; - getLinkedServer?: () => { - serverUrl?: string; - token?: string | null; - } | null; - localBaseUrl?: string; - publishFs?: PublishFs; - }) => Record< - string, - ( - event: unknown, - ...args: unknown[] - ) => Promise< - { success: boolean; error?: string; code?: string } & Record< - string, - unknown - > - > - >; -}; - -// Minimal stand-in for Electron's net.request built on Node http, exposing -// the subset of the ClientRequest API local-files.cjs uses. -/** Header names the bridge tried to set on any request, lower-cased. */ -const headersSetByBridge: string[] = []; - -class FakeClientRequest extends EventEmitter { - private req: http.ClientRequest; - chunkedEncoding = false; - constructor(opts: { method: string; url: string }) { - super(); - const u = new URL(opts.url); - this.req = http.request({ - method: opts.method, - hostname: u.hostname, - port: u.port, - path: u.pathname, - }); - this.req.on("response", (res) => this.emit("response", res)); - this.req.on("error", (e) => this.emit("error", e)); - } - setHeader(k: string, v: string) { - headersSetByBridge.push(k.toLowerCase()); - this.req.setHeader(k, v); - } - write(chunk: Buffer | string, cb?: () => void) { - return this.req.write(chunk, cb); - } - end(chunk?: Buffer | string) { - this.req.end(chunk); - } - abort() { - this.req.destroy(); - this.emit("abort"); - } -} - -const fakeNet = { - request: (opts: { method: string; url: string }) => - new FakeClientRequest(opts), -}; - -const fakeEvent = { - sender: { session: null, isDestroyed: () => false, send: () => {} }, -}; - -// A backend stand-in: records what it receives and serves a fixed payload on -// the download route (optionally slowly, to exercise concurrency). -function startBackend( - payload: Buffer, - opts: { delayMs?: number; failAll?: boolean } = {}, -) { - const seen: Array<{ url: string; headers: http.IncomingHttpHeaders }> = []; - const server = http.createServer((req, res) => { - seen.push({ url: req.url || "", headers: req.headers }); - let body = ""; - req.on("data", (d) => (body += d)); - req.on("end", () => { - if (!opts.failAll && req.url?.endsWith("/downloadFileStream")) { - // Headers go out immediately so the client opens its temp file; - // the body may be delayed to keep the transfer in flight. - res.writeHead(200, { "Content-Length": payload.length }); - res.flushHeaders(); - const send = () => res.end(payload); - if (opts.delayMs) setTimeout(send, opts.delayMs); - else send(); - return; - } - res.statusCode = 404; - res.end(JSON.stringify({ error: `no route ${req.url}` })); - }); - }); - return new Promise<{ - url: string; - seen: typeof seen; - close: () => Promise; - }>((resolve) => { - server.listen(0, "127.0.0.1", () => { - const address = server.address() as { port: number }; - resolve({ - url: `http://127.0.0.1:${address.port}/plugin-api/file-manager`, - seen, - close: () => new Promise((r) => server.close(() => r())), - }); - }); - }); -} - -describe("local-files transfer target resolution", () => { - const resolve = localFiles.createTargetResolver({ - localBaseUrl: "http://127.0.0.1:30001/plugin-api/file-manager", - apiPath: "/plugin-api/file-manager", - getLinkedServer: () => ({ - serverUrl: "https://termix.example.com/", - token: "remote-jwt", - }), - }); - - it("only reaches the two file-manager streaming routes on the local backend", () => { - expect(resolve({ origin: "local", route: "uploadFileStream" })).toEqual({ - url: "http://127.0.0.1:30001/plugin-api/file-manager/uploadFileStream", - headers: { "X-Electron-App": "true" }, - }); - expect(resolve({ origin: "local", route: "downloadFileStream" }).url).toBe( - "http://127.0.0.1:30001/plugin-api/file-manager/downloadFileStream", - ); - }); - - it("derives the remote target from the linked server and attaches its session itself", () => { - const target = resolve({ origin: "remote", route: "downloadFileStream" }); - expect(target.url).toBe( - "https://termix.example.com/plugin-api/file-manager/downloadFileStream", - ); - expect(target.headers.Authorization).toBe("Bearer remote-jwt"); - }); - - it("refuses unknown origins, routes, and off-origin URLs smuggled as either", () => { - expect(() => - resolve({ origin: "https://evil.example", route: "uploadFileStream" }), - ).toThrow(/Unknown transfer origin/); - expect(() => resolve({ origin: "local", route: "/../admin" })).toThrow( - /Unknown transfer route/, - ); - expect(() => - resolve({ origin: "local", route: "http://evil.example/x" }), - ).toThrow(/Unknown transfer route/); - expect(() => resolve({})).toThrow(/Unknown transfer route/); - }); - - it("refuses a remote origin when the device is not linked or the server is not http(s)", () => { - const unconfigured = localFiles.createTargetResolver({ - getLinkedServer: () => null, - }); - expect(() => - unconfigured({ origin: "remote", route: "uploadFileStream" }), - ).toThrow(/not linked/); - const bogus = localFiles.createTargetResolver({ - getLinkedServer: () => ({ serverUrl: "file:///etc/passwd" }), - }); - expect(() => - bogus({ origin: "remote", route: "uploadFileStream" }), - ).toThrow(/must use http or https/); - }); - - it("accepts only a well-formed device id and never arbitrary headers", () => { - expect( - resolve({ - origin: "local", - route: "uploadFileStream", - deviceId: "dev_1.2:3-x", - }).headers["X-Termix-Device-ID"], - ).toBe("dev_1.2:3-x"); - expect(() => - resolve({ - origin: "local", - route: "uploadFileStream", - deviceId: "x\r\nHost: evil", - }), - ).toThrow(/Invalid device id/); - const headers = resolve({ - origin: "local", - route: "uploadFileStream", - }).headers; - expect(Object.keys(headers)).toEqual(["X-Electron-App"]); - }); - - it("sends the renderer's local token as a Bearer header so transfers outlive the jwt cookie", () => { - const jwt = "eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOiJ1MSJ9.c2ln-_X"; - const target = resolve({ - origin: "local", - route: "uploadFileStream", - authToken: jwt, - }); - expect(target.headers.Authorization).toBe(`Bearer ${jwt}`); - expect(target.url).toBe( - "http://127.0.0.1:30001/plugin-api/file-manager/uploadFileStream", - ); - // Absent or empty: no header at all (the session cookie is the fallback). - for (const authToken of [undefined, null, ""]) { - expect( - resolve({ origin: "local", route: "downloadFileStream", authToken }) - .headers.Authorization, - ).toBeUndefined(); - } - }); - - it("refuses anything but a compact JWT as the local token", () => { - for (const authToken of [ - "tmx_apikey", - "a.b", - "x\r\nX-Injected: 1", - "Bearer abc.def.ghi", - { toString: () => "a.b.c" }, - "a.b.".padEnd(9000, "c"), - ]) { - expect(() => - resolve({ origin: "local", route: "uploadFileStream", authToken }), - ).toThrow(/Invalid auth token/); - } - }); - - it("never lets the renderer's token replace the main process's Remote Sync JWT", () => { - const target = resolve({ - origin: "remote", - route: "uploadFileStream", - authToken: "aaa.bbb.ccc", - }); - expect(target.headers.Authorization).toBe("Bearer remote-jwt"); - }); -}); - -describe("local-files download boundary", () => { - let root: string; - const payload = crypto.randomBytes(64 * 1024 + 7); - let backend: Awaited>; - let handlers: ReturnType; - - beforeAll(async () => { - backend = await startBackend(payload); - handlers = localFiles.createLocalFileHandlers({ - net: fakeNet, - shell: {}, - localBaseUrl: backend.url, - getLinkedServer: () => null, - }); - }); - afterAll(() => backend.close()); - beforeEach(async () => { - root = await fsp.mkdtemp(path.join(os.tmpdir(), "termix-local-files-")); - }); - afterEach(() => fsp.rm(root, { recursive: true, force: true })); - - const download = ( - dest: string, - extra: Record = {}, - transferId = `t-${Math.random().toString(36).slice(2)}`, - ) => - handlers[localFiles.IPC.DOWNLOAD](fakeEvent, { - transferId, - origin: "local", - body: { sessionId: "1", path: "/remote/file.bin" }, - destPath: dest, - rootPath: root, - ...extra, - }); - - it("rejects existing linked parents for downloads and directory creation", async () => { - const selected = path.join(root, "selected"); - const outside = path.join(root, "outside"); - await fsp.mkdir(selected); - await fsp.mkdir(outside); - await fsp.symlink(outside, path.join(selected, "linked"), "junction"); - const result = await download(path.join(selected, "linked", "file.bin"), { - rootPath: selected, - }); - expect(result.success).toBe(false); - const mkdir = await handlers[localFiles.IPC.ENSURE_DIR]( - fakeEvent, - path.join(selected, "linked", "new"), - selected, - ); - expect(mkdir.success).toBe(false); - expect(await fsp.readdir(outside)).toEqual([]); - }); - - it("allows an explicitly selected linked root", async () => { - const actual = path.join(root, "actual"); - const selected = path.join(root, "selected"); - await fsp.mkdir(actual); - await fsp.symlink(actual, selected, "junction"); - const result = await download(path.join(selected, "nested", "file.bin"), { - rootPath: selected, - }); - expect(result.success).toBe(true); - expect(await fsp.readFile(path.join(actual, "nested", "file.bin"))).toEqual( - payload, - ); - }); - - it("ignores renderer-supplied url/headers and talks to the resolved backend only", async () => { - const dest = path.join(root, "a.bin"); - const result = await download(dest, { - url: "http://evil.example/steal", - headers: { Authorization: "Bearer leaked", Cookie: "jwt=leaked" }, - }); - expect(result.success).toBe(true); - const last = backend.seen[backend.seen.length - 1]; - expect(last.url).toBe("/plugin-api/file-manager/downloadFileStream"); - expect(last.headers.authorization).toBeUndefined(); - expect(last.headers.cookie).toBeUndefined(); - expect(last.headers["x-electron-app"]).toBe("true"); - expect((await fsp.readFile(dest)).equals(payload)).toBe(true); - }); - - it("never sets headers Electron's net module forbids (the request would fail with ERR_INVALID_ARGUMENT)", async () => { - headersSetByBridge.length = 0; - const result = await download(path.join(root, "b.bin")); - expect(result.success).toBe(true); - // Electron computes Content-Length from the buffered body itself and - // rejects requests that set it (or any other restricted header) by hand. - const restricted = [ - "content-length", - "host", - "trailer", - "te", - "upgrade", - "cookie2", - "keep-alive", - "transfer-encoding", - ]; - expect(headersSetByBridge.filter((h) => restricted.includes(h))).toEqual( - [], - ); - // The JSON body still reached the backend intact. - const last = backend.seen[backend.seen.length - 1]; - expect(last.headers["content-type"]).toBe("application/json"); - }); - - it("refuses to replace an existing file by default and leaves it untouched", async () => { - const dest = path.join(root, "keep.bin"); - await fsp.writeFile(dest, "original"); - const requestsBefore = backend.seen.length; - - const result = await download(dest); - expect(result.success).toBe(false); - expect(result.code).toBe("EEXIST"); - expect(await fsp.readFile(dest, "utf8")).toBe("original"); - // Refused before any network traffic. - expect(backend.seen.length).toBe(requestsBefore); - expect(await fsp.readdir(root)).toEqual(["keep.bin"]); - }); - - it("replaces an existing file only when overwrite is explicitly requested", async () => { - const dest = path.join(root, "replace.bin"); - await fsp.writeFile(dest, "original"); - const result = await download(dest, { overwrite: true }); - expect(result.success).toBe(true); - expect((await fsp.readFile(dest)).equals(payload)).toBe(true); - expect(await fsp.readdir(root)).toEqual(["replace.bin"]); - }); - - it("refuses to publish over a file that appeared while the download was running", async () => { - const slow = await startBackend(payload, { delayMs: 300 }); - const slowHandlers = localFiles.createLocalFileHandlers({ - net: fakeNet, - shell: {}, - localBaseUrl: slow.url, - }); - try { - const dest = path.join(root, "race.bin"); - const pending = slowHandlers[localFiles.IPC.DOWNLOAD](fakeEvent, { - transferId: "race-1", - origin: "local", - body: {}, - destPath: dest, - rootPath: root, - }); - await new Promise((r) => setTimeout(r, 100)); - await fsp.writeFile(dest, "someone else wrote this"); - const result = await pending; - expect(result.success).toBe(false); - expect(result.code).toBe("EEXIST"); - expect(await fsp.readFile(dest, "utf8")).toBe("someone else wrote this"); - expect(await fsp.readdir(root)).toEqual(["race.bin"]); - } finally { - await slow.close(); - } - }); - - it("rejects a destination parent replaced by a link during download", async () => { - const selected = path.join(root, "selected"); - const nested = path.join(selected, "nested"); - const outside = path.join(root, "outside"); - await fsp.mkdir(nested, { recursive: true }); - await fsp.mkdir(outside); - const slow = await startBackend(payload, { delayMs: 300 }); - const slowHandlers = localFiles.createLocalFileHandlers({ - net: fakeNet, - shell: {}, - localBaseUrl: slow.url, - }); - try { - const pending = slowHandlers[localFiles.IPC.DOWNLOAD](fakeEvent, { - transferId: "parent-race", - origin: "local", - body: {}, - rootPath: selected, - destPath: path.join(nested, "file.bin"), - }); - await new Promise((resolve) => setTimeout(resolve, 100)); - await fsp.rename(nested, path.join(selected, "original")); - await fsp.symlink(outside, nested, "junction"); - const result = await pending; - expect(result.success).toBe(false); - expect(await fsp.readdir(outside)).toEqual([]); - expect((await fsp.readdir(selected)).sort()).toEqual([ - "nested", - "original", - ]); - } finally { - await slow.close(); - } - }); - - it("uses a transfer-unique temp file and rejects a concurrent download to the same destination", async () => { - const slow = await startBackend(payload, { delayMs: 300 }); - const slowHandlers = localFiles.createLocalFileHandlers({ - net: fakeNet, - shell: {}, - localBaseUrl: slow.url, - }); - try { - const dest = path.join(root, "same.bin"); - const first = slowHandlers[localFiles.IPC.DOWNLOAD](fakeEvent, { - transferId: "first", - origin: "local", - body: {}, - destPath: dest, - rootPath: root, - }); - await new Promise((r) => setTimeout(r, 50)); - const second = await slowHandlers[localFiles.IPC.DOWNLOAD](fakeEvent, { - transferId: "second", - origin: "local", - body: {}, - destPath: dest, - rootPath: root, - }); - expect(second.success).toBe(false); - expect(second.code).toBe("EBUSY"); - - // While the first is in flight its partial carries the transfer id. - const partials = (await fsp.readdir(root)).filter((n) => - n.endsWith(".termix-part"), - ); - expect(partials).toEqual(["same.bin.first.termix-part"]); - - const result = await first; - expect(result.success).toBe(true); - expect((await fsp.readFile(dest)).equals(payload)).toBe(true); - expect(await fsp.readdir(root)).toEqual(["same.bin"]); - } finally { - await slow.close(); - } - }); - - it("runs many downloads at once, cancels one without disturbing the others, and leaves no partials", async () => { - const slow = await startBackend(payload, { delayMs: 250 }); - const slowHandlers = localFiles.createLocalFileHandlers({ - net: fakeNet, - shell: {}, - localBaseUrl: slow.url, - }); - try { - const dests = Array.from({ length: 5 }, (_, i) => - path.join(root, "many", `part${i}`, `file${i}.bin`), - ); - const pending = dests.map((dest, i) => - slowHandlers[localFiles.IPC.DOWNLOAD](fakeEvent, { - transferId: `many-${i}`, - origin: "local", - body: { sessionId: "1", path: "/remote/file.bin" }, - destPath: dest, - rootPath: root, - }), - ); - await new Promise((r) => setTimeout(r, 60)); - const cancelled = await slowHandlers[localFiles.IPC.CANCEL]( - fakeEvent, - "many-2", - ); - expect(cancelled.success).toBe(true); - const results = await Promise.all(pending); - results.forEach((r, i) => { - if (i === 2) expect(r.success).toBe(false); - else expect(r.success).toBe(true); - }); - // Partials live in the selected root while in flight; none may remain. - expect(await fsp.readdir(root)).toEqual(["many"]); - for (const [i, dest] of dests.entries()) { - if (i === 2) { - expect(await fsp.readdir(path.dirname(dest))).toEqual([]); - } else { - expect((await fsp.readFile(dest)).equals(payload)).toBe(true); - expect(await fsp.readdir(path.dirname(dest))).toEqual([ - `file${i}.bin`, - ]); - } - } - } finally { - slow.close(); - } - }); - - it("allows two downloads of different files to run side by side", async () => { - const [a, b] = await Promise.all([ - download(path.join(root, "one.bin")), - download(path.join(root, "two.bin")), - ]); - expect(a.success && b.success).toBe(true); - expect((await fsp.readdir(root)).sort()).toEqual(["one.bin", "two.bin"]); - }); - - it("cleans up its partial file and never creates the destination on a backend error", async () => { - const failingBackend = await startBackend(payload, { failAll: true }); - const failing = localFiles.createLocalFileHandlers({ - net: fakeNet, - shell: {}, - localBaseUrl: failingBackend.url, - }); - try { - const bad = await failing[localFiles.IPC.DOWNLOAD](fakeEvent, { - transferId: "err", - origin: "local", - body: {}, - destPath: path.join(root, "never.bin"), - rootPath: root, - }); - expect(bad.success).toBe(false); - expect(bad.error).toMatch(/no route/); - expect(fs.existsSync(path.join(root, "never.bin"))).toBe(false); - expect(await fsp.readdir(root)).toEqual([]); - } finally { - await failingBackend.close(); - } - }); - - it("reports which destinations already exist for the collision prompt", async () => { - const present = path.join(root, "present.txt"); - await fsp.writeFile(present, "x"); - const result = await handlers[localFiles.IPC.EXISTS](fakeEvent, [ - present, - path.join(root, "absent.txt"), - ]); - expect(result.success).toBe(true); - expect(result.existing).toEqual([present]); - }); -}); - -// Simulates the Windows rename contract on top of the real filesystem: -// rename() onto a name that already exists fails instead of replacing it. -// Every rename is recorded so a test can prove the swap never relied on -// rename-over-existing. -function windowsLikeFs( - overrides: Partial = {}, -): PublishFs & { renames: Array<[string, string]> } { - const base = localFiles.defaultPublishFs; - const renames: Array<[string, string]> = []; - return { - ...base, - renames, - rename: async (from, to) => { - renames.push([from, to]); - if (fs.existsSync(to)) { - throw Object.assign(new Error(`EEXIST: file already exists, rename`), { - code: "EEXIST", - }); - } - await base.rename(from, to); - }, - ...overrides, - }; -} - -describe("download destination containment", () => { - const win = path.win32; - const root = "C:\\Downloads\\selected"; - - it("accepts ordinary nested destinations on Windows", () => { - expect( - localFiles.assertWithinRoot(root, "C:\\Downloads\\selected\\a.txt", win), - ).toBe("C:\\Downloads\\selected\\a.txt"); - expect( - localFiles.assertWithinRoot( - root, - "C:\\Downloads\\selected\\docs\\2026\\report.pdf", - win, - ), - ).toBe("C:\\Downloads\\selected\\docs\\2026\\report.pdf"); - // case-insensitive drive/dir comparison, like the filesystem - expect( - localFiles.assertWithinRoot(root, "c:\\downloads\\SELECTED\\b.txt", win), - ).toBe("c:\\downloads\\SELECTED\\b.txt"); - }); - - it("rejects backslash traversal that normalises out of the root on Windows", () => { - // The reviewer's reproduction: selected\..\outside.txt -> Downloads\outside.txt - expect(() => - localFiles.assertWithinRoot( - root, - "C:\\Downloads\\selected\\..\\outside.txt", - win, - ), - ).toThrow(/outside the selected folder/); - expect(() => - localFiles.assertWithinRoot( - root, - "C:\\Downloads\\selected\\sub\\..\\..\\..\\x.txt", - win, - ), - ).toThrow(/outside the selected folder/); - // the root itself is not a valid file destination - expect(() => localFiles.assertWithinRoot(root, root, win)).toThrow( - /outside the selected folder/, - ); - }); - - it("rejects absolute, drive-qualified and UNC destinations on Windows", () => { - for (const dest of [ - "C:\\Windows\\evil.dll", - "D:\\Downloads\\selected\\x.txt", - "\\\\server\\share\\x.txt", - "C:\\Downloads\\selected-other\\x.txt", - ]) { - expect(() => localFiles.assertWithinRoot(root, dest, win)).toThrow( - /outside the selected folder/, - ); - } - }); - - it("requires a root and rejects POSIX traversal too", () => { - expect(() => localFiles.assertWithinRoot("", "/tmp/x", path.posix)).toThrow( - /download folder is required/, - ); - expect(() => - localFiles.assertWithinRoot( - "/home/max/dl", - "/home/max/dl/../x", - path.posix, - ), - ).toThrow(/outside the selected folder/); - expect( - localFiles.assertWithinRoot( - "/home/max/dl", - "/home/max/dl/..\\x", - path.posix, - ), - ).toBe("/home/max/dl/..\\x"); - }); - - it("refuses a download whose destination escapes the root, before any network or disk write", async () => { - const payload = crypto.randomBytes(1024); - const backend = await startBackend(payload); - const tmp = await fsp.mkdtemp(path.join(os.tmpdir(), "termix-contain-")); - const selected = path.join(tmp, "selected"); - await fsp.mkdir(selected); - try { - const handlers = localFiles.createLocalFileHandlers({ - net: fakeNet, - shell: {}, - localBaseUrl: backend.url, - }); - const requestsBefore = backend.seen.length; - const result = await handlers[localFiles.IPC.DOWNLOAD](fakeEvent, { - transferId: "escape-1", - origin: "local", - body: { sessionId: "1", path: "/remote/file.bin" }, - destPath: path.join(selected, "..", "outside.txt"), - rootPath: selected, - }); - expect(result.success).toBe(false); - expect(result.code).toBe("EINVAL"); - expect(backend.seen.length).toBe(requestsBefore); - expect(await fsp.readdir(tmp)).toEqual(["selected"]); - - // and the same for the directory skeleton - const dir = await handlers[localFiles.IPC.ENSURE_DIR]( - fakeEvent, - path.join(selected, "..", "escaped-dir"), - selected, - ); - expect(dir.success).toBe(false); - expect(dir.code).toBe("EINVAL"); - expect(await fsp.readdir(tmp)).toEqual(["selected"]); - - // a missing root is refused as well - const noRoot = await handlers[localFiles.IPC.DOWNLOAD](fakeEvent, { - transferId: "escape-2", - origin: "local", - body: { sessionId: "1", path: "/remote/file.bin" }, - destPath: path.join(selected, "ok.bin"), - }); - expect(noRoot.success).toBe(false); - expect(noRoot.code).toBe("EINVAL"); - expect(await fsp.readdir(selected)).toEqual([]); - } finally { - backend.close(); - await fsp.rm(tmp, { recursive: true, force: true }); - } - }); -}); - -describe("local-files replace primitive (Windows-safe overwrite)", () => { - let root: string; - beforeEach(async () => { - root = await fsp.mkdtemp(path.join(os.tmpdir(), "termix-replace-")); - }); - afterEach(() => fsp.rm(root, { recursive: true, force: true })); - - const seed = async (name: string, existing: string, fresh: Buffer) => { - const dest = path.join(root, name); - const partial = `${dest}.t1.termix-part`; - await fsp.writeFile(dest, existing); - await fsp.writeFile(partial, fresh); - return { dest, partial }; - }; - - it("replaces an existing file without renaming onto an occupied name", async () => { - const fresh = crypto.randomBytes(4096); - const { dest, partial } = await seed("swap.bin", "original", fresh); - const io = windowsLikeFs(); - - await localFiles.publishDownload(partial, dest, true, "t1", io); - - expect((await fsp.readFile(dest)).equals(fresh)).toBe(true); - expect(await fsp.readdir(root)).toEqual(["swap.bin"]); - // Every rename targeted a name that was free at the time. - expect(io.renames.length).toBeGreaterThan(0); - for (const [, to] of io.renames) { - expect(to === dest || to.endsWith(".termix-replaced")).toBe(true); - } - }); - - it("restores the original byte-for-byte when publishing the new contents fails", async () => { - const fresh = crypto.randomBytes(1024); - const { dest, partial } = await seed("restore.bin", "original", fresh); - const denied = () => - Promise.reject( - Object.assign(new Error("EACCES: permission denied"), { - code: "EACCES", - }), - ); - const io = windowsLikeFs({ link: denied, copyFileExcl: denied }); - - await expect( - localFiles.publishDownload(partial, dest, true, "t1", io), - ).rejects.toMatchObject({ code: "EACCES" }); - - expect(await fsp.readFile(dest, "utf8")).toBe("original"); - // Only the original and the caller-owned partial remain: no aside copy. - expect((await fsp.readdir(root)).sort()).toEqual( - ["restore.bin", "restore.bin.t1.termix-part"].sort(), - ); - }); - - it("reports EBUSY and touches nothing when the existing file cannot be moved aside (open on Windows)", async () => { - const fresh = crypto.randomBytes(1024); - const { dest, partial } = await seed("locked.bin", "original", fresh); - const io = windowsLikeFs({ - rename: () => - Promise.reject( - Object.assign(new Error("EPERM: operation not permitted"), { - code: "EPERM", - }), - ), - }); - - await expect( - localFiles.publishDownload(partial, dest, true, "t1", io), - ).rejects.toMatchObject({ code: "EBUSY" }); - - expect(await fsp.readFile(dest, "utf8")).toBe("original"); - expect((await fsp.readFile(partial)).equals(fresh)).toBe(true); - }); - - it("refuses to replace a folder with a file", async () => { - const dest = path.join(root, "folder"); - await fsp.mkdir(dest); - await fsp.writeFile(path.join(dest, "inner.txt"), "keep"); - const partial = `${dest}.t1.termix-part`; - await fsp.writeFile(partial, "new"); - - await expect( - localFiles.publishDownload(partial, dest, true, "t1", windowsLikeFs()), - ).rejects.toMatchObject({ code: "EISDIR" }); - expect(await fsp.readFile(path.join(dest, "inner.txt"), "utf8")).toBe( - "keep", - ); - }); - - it("falls back to an exclusive publish when the file to replace has disappeared", async () => { - const dest = path.join(root, "gone.bin"); - const partial = `${dest}.t1.termix-part`; - await fsp.writeFile(partial, "new"); - - await localFiles.publishDownload( - partial, - dest, - true, - "t1", - windowsLikeFs(), - ); - expect(await fsp.readFile(dest, "utf8")).toBe("new"); - expect(await fsp.readdir(root)).toEqual(["gone.bin"]); - }); - - it("works end to end through the download handler on a Windows-like filesystem", async () => { - const payload = crypto.randomBytes(16 * 1024 + 3); - const backend = await startBackend(payload); - try { - const io = windowsLikeFs(); - const handlers = localFiles.createLocalFileHandlers({ - net: fakeNet, - shell: {}, - localBaseUrl: backend.url, - publishFs: io, - }); - const dest = path.join(root, "e2e.bin"); - await fsp.writeFile(dest, "original"); - - const result = await handlers[localFiles.IPC.DOWNLOAD](fakeEvent, { - transferId: "e2e-1", - origin: "local", - body: { sessionId: "1", path: "/remote/file.bin" }, - destPath: dest, - rootPath: root, - overwrite: true, - }); - - expect(result.success).toBe(true); - expect((await fsp.readFile(dest)).equals(payload)).toBe(true); - expect(await fsp.readdir(root)).toEqual(["e2e.bin"]); - for (const [, to] of io.renames) { - expect(to === dest || to.endsWith(".termix-replaced")).toBe(true); - } - } finally { - backend.close(); - } - }); -}); - -describe("local-files upload boundary", () => { - it("streams the file as multipart to the resolved backend and ignores renderer url/headers", async () => { - const Busboy = require("busboy") as (opts: { - headers: http.IncomingHttpHeaders; - }) => NodeJS.EventEmitter & NodeJS.WritableStream; - const received: { - fields: Record; - fileName?: string; - hash?: string; - url?: string; - headers?: http.IncomingHttpHeaders; - } = { fields: {} }; - - const server = http.createServer((req, res) => { - received.url = req.url; - received.headers = req.headers; - const bb = Busboy({ headers: req.headers }); - bb.on("field", (name: string, value: string) => { - received.fields[name] = value; - }); - bb.on( - "file", - ( - _name: string, - stream: NodeJS.ReadableStream, - info: { filename: string }, - ) => { - received.fileName = info.filename; - const hash = crypto.createHash("sha256"); - stream.on("data", (d: Buffer) => hash.update(d)); - stream.on("end", () => { - received.hash = hash.digest("hex"); - }); - }, - ); - bb.on("close", () => { - res.setHeader("Content-Type", "application/json"); - res.end(JSON.stringify({ message: "ok" })); - }); - req.pipe(bb); - }); - await new Promise((r) => server.listen(0, "127.0.0.1", () => r())); - const port = (server.address() as { port: number }).port; - - const root = await fsp.mkdtemp(path.join(os.tmpdir(), "termix-upload-")); - const payload = crypto.randomBytes(3 * 1024 * 1024 + 11); - const localPath = path.join(root, "big.bin"); - await fsp.writeFile(localPath, payload); - - try { - const handlers = localFiles.createLocalFileHandlers({ - net: fakeNet, - shell: {}, - localBaseUrl: `http://127.0.0.1:${port}/plugin-api/file-manager`, - }); - const result = await handlers[localFiles.IPC.UPLOAD](fakeEvent, { - transferId: "up-1", - origin: "local", - fields: { sessionId: "42", path: "/home/ubuntu" }, - localPath, - fileName: "big renamed.bin", - url: "http://evil.example/exfil", - headers: { Authorization: "Bearer leaked" }, - }); - expect(result.success).toBe(true); - expect(received.url).toBe("/plugin-api/file-manager/uploadFileStream"); - expect(received.headers?.authorization).toBeUndefined(); - expect(received.fields).toEqual({ - sessionId: "42", - path: "/home/ubuntu", - }); - expect(received.fileName).toBe("big renamed.bin"); - expect(received.hash).toBe( - crypto.createHash("sha256").update(payload).digest("hex"), - ); - } finally { - await new Promise((r) => server.close(() => r())); - await fsp.rm(root, { recursive: true, force: true }); - } - }); - - it("puts the renderer's local token on the wire as a Bearer header (cookie no longer required)", async () => { - const seen: http.IncomingHttpHeaders[] = []; - const server = http.createServer((req, res) => { - seen.push(req.headers); - req.resume(); - req.on("end", () => { - // The real backend answers 401 "Missing authentication token" when - // neither cookie nor Bearer header is present. - if (!req.headers.authorization) { - res.statusCode = 401; - res.end(JSON.stringify({ error: "Missing authentication token" })); - return; - } - res.end(JSON.stringify({ message: "ok" })); - }); - }); - await new Promise((r) => server.listen(0, "127.0.0.1", () => r())); - const port = (server.address() as { port: number }).port; - const root = await fsp.mkdtemp(path.join(os.tmpdir(), "termix-upload-")); - const localPath = path.join(root, "small.txt"); - await fsp.writeFile(localPath, "hello"); - const jwt = "eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOiJ1MSJ9.c2ln"; - try { - const handlers = localFiles.createLocalFileHandlers({ - net: fakeNet, - shell: {}, - localBaseUrl: `http://127.0.0.1:${port}/plugin-api/file-manager`, - }); - const withoutToken = await handlers[localFiles.IPC.UPLOAD](fakeEvent, { - transferId: "up-auth-0", - origin: "local", - fields: { sessionId: "42", path: "/home/ubuntu" }, - localPath, - fileName: "small.txt", - }); - expect(withoutToken.success).toBe(false); - expect(withoutToken.error).toMatch(/Missing authentication token/); - - const withToken = await handlers[localFiles.IPC.UPLOAD](fakeEvent, { - transferId: "up-auth-1", - origin: "local", - authToken: jwt, - fields: { sessionId: "42", path: "/home/ubuntu" }, - localPath, - fileName: "small.txt", - headers: { Authorization: "Bearer leaked" }, - }); - expect(withToken.success).toBe(true); - expect(seen[1]?.authorization).toBe(`Bearer ${jwt}`); - expect(seen[1]?.["x-electron-app"]).toBe("true"); - - const malformed = await handlers[localFiles.IPC.UPLOAD](fakeEvent, { - transferId: "up-auth-2", - origin: "local", - authToken: "not a token\r\nX-Injected: 1", - fields: { sessionId: "42", path: "/home/ubuntu" }, - localPath, - fileName: "small.txt", - }); - expect(malformed.success).toBe(false); - expect(malformed.error).toMatch(/Invalid auth token/); - expect(seen.length).toBe(2); // the malformed one never reached the network - } finally { - await new Promise((r) => server.close(() => r())); - await fsp.rm(root, { recursive: true, force: true }); - } - }); - - it("keeps every body intact when several uploads run at once and cancels only the requested one", async () => { - const Busboy = require("busboy") as (opts: { - headers: http.IncomingHttpHeaders; - }) => NodeJS.EventEmitter & NodeJS.WritableStream; - const received = new Map(); // fileName -> sha256 - let inFlight = 0; - let peak = 0; - const server = http.createServer((req, res) => { - inFlight += 1; - peak = Math.max(peak, inFlight); - const bb = Busboy({ headers: req.headers }); - let name = ""; - const hash = crypto.createHash("sha256"); - bb.on( - "file", - ( - _n: string, - stream: NodeJS.ReadableStream, - info: { filename: string }, - ) => { - name = info.filename; - stream.on("data", (d: Buffer) => hash.update(d)); - }, - ); - bb.on("close", () => { - // Hold the response a little so requests genuinely overlap. - setTimeout(() => { - inFlight -= 1; - received.set(name, hash.digest("hex")); - res.setHeader("Content-Type", "application/json"); - res.end(JSON.stringify({ message: "ok" })); - }, 120); - }); - req.on("aborted", () => { - inFlight -= 1; - }); - req.pipe(bb); - }); - await new Promise((r) => server.listen(0, "127.0.0.1", () => r())); - const port = (server.address() as { port: number }).port; - const root = await fsp.mkdtemp(path.join(os.tmpdir(), "termix-par-up-")); - const files = await Promise.all( - Array.from({ length: 6 }, async (_, i) => { - const payload = crypto.randomBytes(256 * 1024 + i); - const localPath = path.join(root, `f${i}.bin`); - await fsp.writeFile(localPath, payload); - return { - i, - localPath, - sha: crypto.createHash("sha256").update(payload).digest("hex"), - }; - }), - ); - try { - const handlers = localFiles.createLocalFileHandlers({ - net: fakeNet, - shell: {}, - localBaseUrl: `http://127.0.0.1:${port}/plugin-api/file-manager`, - }); - const results = await Promise.all( - files.map((f) => - handlers[localFiles.IPC.UPLOAD](fakeEvent, { - transferId: `par-up-${f.i}`, - origin: "local", - fields: { sessionId: "1", path: "/remote" }, - localPath: f.localPath, - fileName: `f${f.i}.bin`, - }), - ), - ); - expect(results.every((r) => r.success)).toBe(true); - expect(peak).toBeGreaterThan(1); - for (const f of files) expect(received.get(`f${f.i}.bin`)).toBe(f.sha); - } finally { - await new Promise((r) => server.close(() => r())); - await fsp.rm(root, { recursive: true, force: true }); - } - }); - - it("refuses an upload whose origin is not one of the two Termix backends", async () => { - const handlers = localFiles.createLocalFileHandlers({ - net: fakeNet, - shell: {}, - localBaseUrl: "http://127.0.0.1:1/plugin-api/file-manager", - }); - const result = await handlers[localFiles.IPC.UPLOAD](fakeEvent, { - transferId: "up-2", - origin: "http://evil.example", - fields: {}, - localPath: __filename, - fileName: "x", - }); - expect(result.success).toBe(false); - expect(result.error).toMatch(/Unknown transfer origin/); - }); -}); - -describe("local-files transfer API path", () => { - it("accepts only a /plugin-api/ path from the plugin", () => { - const set = ( - localFiles as unknown as { - setTransferApiPath: (path: unknown) => void; - } - ).setTransferApiPath; - expect(() => set("/plugin-api/file-manager")).not.toThrow(); - for (const bad of ["/users", "/plugin-api/../users", "https://x", 3]) { - expect(() => set(bad)).toThrow(/Invalid transfer API path/); - } - }); -}); diff --git a/src/backend/tests/electron/native-rdp.test.ts b/src/backend/tests/electron/native-rdp.test.ts deleted file mode 100644 index 1ad2aa9a2..000000000 --- a/src/backend/tests/electron/native-rdp.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { createRequire } from "node:module"; -import { describe, expect, it } from "vitest"; - -const require = createRequire(import.meta.url); -const { buildRdpFile, validateNativeRdpOptions } = - require("../../../../electron/native-rdp.cjs") as { - buildRdpFile: (options: Record) => string; - validateNativeRdpOptions: (options: Record) => unknown; - }; - -describe("native Windows RDP", () => { - it("creates a credential-prompting RDP file without a password", () => { - const result = buildRdpFile({ - host: "server.example.com", - port: 3390, - username: "alice", - domain: "EXAMPLE", - password: "must-not-be-written", - }); - - expect(result).toContain("full address:s:server.example.com:3390"); - expect(result).toContain("username:s:EXAMPLE\\alice"); - expect(result).toContain("prompt for credentials:i:1"); - expect(result).not.toContain("must-not-be-written"); - }); - - it("rejects unsafe host and port values", () => { - expect(() => - validateNativeRdpOptions({ host: "bad\r\nvalue" }), - ).not.toThrow(); - expect(() => validateNativeRdpOptions({ host: "server/path" })).toThrow(); - expect(() => - validateNativeRdpOptions({ host: "server", port: 70000 }), - ).toThrow(); - }); -}); diff --git a/src/backend/tests/electron/rename-local-path.test.ts b/src/backend/tests/electron/rename-local-path.test.ts deleted file mode 100644 index ce39727ce..000000000 --- a/src/backend/tests/electron/rename-local-path.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { createRequire } from "node:module"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -const { renameLocalPath } = createRequire(import.meta.url)( - "../../../../electron/rename-local-path.cjs", -); -let root: string; -beforeEach(() => { - root = fs.mkdtempSync(path.join(os.tmpdir(), "termix-rename-")); -}); -afterEach(() => fs.rmSync(root, { recursive: true, force: true })); -describe("local rename", () => { - it("preserves both files on a name collision", () => { - const a = path.join(root, "a"), - b = path.join(root, "b"); - fs.writeFileSync(a, "source"); - fs.writeFileSync(b, "destination"); - expect(() => renameLocalPath(a, b)).toThrow(/already exists/); - expect(fs.readFileSync(a, "utf8")).toBe("source"); - expect(fs.readFileSync(b, "utf8")).toBe("destination"); - }); - it("refuses to replace even an empty destination directory", () => { - const a = path.join(root, "a"), - b = path.join(root, "b"); - fs.mkdirSync(a); - fs.mkdirSync(b); - expect(() => renameLocalPath(a, b)).toThrow(/already exists/); - expect(fs.readdirSync(root).sort()).toEqual(["a", "b"]); - }); - it("renames a file and handles an unchanged name", () => { - const a = path.join(root, "a"), - b = path.join(root, "b"); - fs.writeFileSync(a, "source"); - renameLocalPath(a, a); - renameLocalPath(a, b); - expect(fs.existsSync(a)).toBe(false); - expect(fs.readFileSync(b, "utf8")).toBe("source"); - }); -}); diff --git a/src/backend/tests/hosts/import/host-export.test.ts b/src/backend/tests/hosts/import/host-export.test.ts new file mode 100644 index 000000000..137a427bd --- /dev/null +++ b/src/backend/tests/hosts/import/host-export.test.ts @@ -0,0 +1,147 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ + hosts: [] as Record[], + credentials: [] as Record[], + resolve: vi.fn(), +})); + +vi.mock("../../../database/repositories/factory.js", () => ({ + createCurrentHostResolutionRepository: () => ({ + findHostsByUserId: async () => state.hosts, + findHostByIdForUser: async (id: number) => + state.hosts.find((host) => host.id === id) ?? null, + }), + createCurrentCredentialRepository: () => ({ + listDecryptedByUserId: async () => state.credentials, + }), +})); +vi.mock("../../../database/routes/host-normalizers.js", () => ({ + hostTerminalExport: () => ({ terminalConfig: null, sshOptions: null }), +})); +vi.mock("../../../database/routes/host-plugin-settings.js", () => ({ + loadHostPluginSettings: async () => new Map(), +})); +vi.mock("../../../hosts/protocol-auth/protocol-auth.js", () => ({ + listProtocolLogins: async () => [], + toPortableLogins: () => ({ + rdp: { + authType: "credential", + credentialId: 4, + username: "bob", + password: "rdp-secret", + fields: { domain: "corp", token: "t" }, + }, + }), +})); +vi.mock("../../../hosts/protocol-auth/registry.js", () => ({ + findHostProtocol: () => ({ + credentialFields: [{ key: "token", secret: true }, { key: "domain" }], + }), +})); +vi.mock("../../../hosts/resolve-host-credentials.js", () => ({ + resolveHostCredentials: state.resolve, +})); + +import { exportAllHosts } from "../../../hosts/import/host-export.js"; + +const sshHost = { + id: 1, + connectionType: "ssh", + name: "web", + ip: "192.0.2.1", + port: 22, + username: "alice", + password: "pw", + key: "KEY", + keyPassword: "kp", + sudoPassword: "sp", + socks5Password: "s5", + authType: "credential", + credentialId: 7, + folder: "Prod", + tags: "a,b", + jumpHosts: JSON.stringify([{ hostId: 2 }]), + forceKeyboardInteractive: "true", +}; + +beforeEach(() => { + state.hosts = [{ ...sshHost }]; + state.credentials = [ + { id: 7, name: "deploy", authType: "key", username: "alice", tags: "x" }, + ]; + state.resolve.mockReset().mockImplementation(async (host) => ({ + ...host, + password: "resolved-pw", + key: "RESOLVED-KEY", + })); +}); + +describe("exportAllHosts", () => { + it("includes decrypted secrets without share mode", async () => { + const result = await exportAllHosts("u1", false); + expect(result.credentials).toBeUndefined(); + expect(result.hosts[0]).toMatchObject({ + exportId: 1, + password: "resolved-pw", + key: "RESOLVED-KEY", + sudoPassword: "sp", + credentialId: 7, + tags: ["a", "b"], + jumpHosts: [{ hostId: 2 }], + forceKeyboardInteractive: true, + }); + expect(state.resolve).toHaveBeenCalled(); + }); + + it("leaves out every secret and credential id in share mode", async () => { + const result = await exportAllHosts("u1", true); + const [host] = result.hosts; + expect(state.resolve).not.toHaveBeenCalled(); + expect(host).toMatchObject({ + password: null, + key: null, + keyPassword: null, + sudoPassword: null, + socks5Password: null, + credentialAlias: "deploy", + }); + expect(host).not.toHaveProperty("credentialId"); + expect(result.credentials).toEqual([ + expect.objectContaining({ alias: "deploy", authType: "key" }), + ]); + expect(JSON.stringify(result)).not.toMatch(/rdp-secret|"t"/); + expect(host.protocolAuth).toMatchObject({ + rdp: { + authType: "direct", + credentialId: null, + password: null, + fields: { domain: "corp" }, + }, + }); + expect(result.version).toBe("1"); + }); + + it("exports a remote desktop host without SSH fields", async () => { + state.hosts = [{ ...sshHost, connectionType: "rdp" }]; + const result = await exportAllHosts("u1", false); + expect(result.hosts[0]).not.toHaveProperty("authType"); + expect(result.hosts[0]).toHaveProperty("protocolAuth"); + }); +}); + +describe("exportAllHosts with hostIds", () => { + it("exports only the asked hosts the user owns", async () => { + state.hosts = [ + { ...sshHost, id: 1 }, + { ...sshHost, id: 2 }, + ]; + const result = await exportAllHosts("u1", false, [2, 99]); + expect(result.hosts.map((host) => host.exportId)).toEqual([2]); + }); + + it("returns nothing for an id the user does not own", async () => { + const result = await exportAllHosts("u1", false, [99]); + expect(result.hosts).toEqual([]); + }); +}); diff --git a/src/backend/tests/hosts/import/host-import.test.ts b/src/backend/tests/hosts/import/host-import.test.ts new file mode 100644 index 000000000..bccea9ec5 --- /dev/null +++ b/src/backend/tests/hosts/import/host-import.test.ts @@ -0,0 +1,134 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + create: vi.fn(), + update: vi.fn(), + remove: vi.fn(), + list: vi.fn(), + applyDefaults: vi.fn(async () => {}), +})); +vi.mock("../../../database/routes/host-plugin-settings.js", () => ({ + applyPluginHostImportSettings: vi.fn(), + setHostPluginEnabled: vi.fn(), +})); +vi.mock("../../../hosts/defaults/index.js", () => ({ + applyHostDefaultsToWrite: async () => ({ core: [] }), + applyDefaultsAfterHostWrite: async () => {}, + applyDefaultsAfterHostWrites: mocks.applyDefaults, +})); +vi.mock("../../../database/repositories/factory.js", () => ({ + createCurrentHostDefaultsRepository: () => ({ listHosts: async () => [] }), + createCurrentCredentialRepository: () => ({ + listDecryptedByUserId: async () => [], + }), + createCurrentHostRepository: () => ({ + createEncryptedForUser: mocks.create, + updateEncryptedForUser: mocks.update, + deleteForUser: mocks.remove, + }), + createCurrentHostResolutionRepository: () => ({ + findHostsByUserId: mocks.list, + }), +})); + +import { + ImportError, + runBulkImport, + runSshConfigImport, +} from "../../../hosts/import/host-import.js"; +import { pluginEvents, TOPICS } from "../../../plugins/events.js"; + +const good = { + ip: "192.0.2.1", + port: 22, + username: "alice", + authType: "none", +}; + +beforeEach(() => { + vi.clearAllMocks(); + mocks.list.mockResolvedValue([]); + mocks.remove.mockResolvedValue({ syncId: null }); +}); + +describe("runBulkImport", () => { + it("creates hosts, returns their ids and tells listeners", async () => { + mocks.create.mockResolvedValueOnce({ id: 11 }); + const created: unknown[] = []; + const off = pluginEvents.on(TOPICS.hostCreated, (p) => created.push(p)); + try { + const result = await runBulkImport("u1", { hosts: [good] }); + expect(result).toMatchObject({ success: 1, failed: 0 }); + expect(result.createdHostIds).toEqual([11]); + expect(mocks.applyDefaults).toHaveBeenCalledWith([11]); + } finally { + off(); + } + expect(created).toEqual([{ hostId: 11, userId: "u1" }]); + }); + + it("refuses an empty list and more than 100 hosts", async () => { + await expect(runBulkImport("u1", { hosts: [] })).rejects.toBeInstanceOf( + ImportError, + ); + await expect( + runBulkImport("u1", { + hosts: Array.from({ length: 101 }, () => ({ ...good })), + }), + ).rejects.toThrow(/Maximum 100/); + }); + + it("keeps the rows that worked when atomic is off", async () => { + mocks.create.mockResolvedValueOnce({ id: 11 }); + const result = await runBulkImport("u1", { + hosts: [good, { ...good, ip: "" }], + }); + expect(result).toMatchObject({ success: 1, failed: 1 }); + expect(mocks.remove).not.toHaveBeenCalled(); + }); + + it("deletes what it created and stays quiet when atomic and a row fails", async () => { + mocks.create + .mockResolvedValueOnce({ id: 11 }) + .mockResolvedValueOnce({ id: 12 }); + const events: unknown[] = []; + const off = pluginEvents.on(TOPICS.hostCreated, (p) => events.push(p)); + try { + const result = await runBulkImport("u1", { + hosts: [good, { ...good, ip: "192.0.2.2" }, { ...good, ip: "" }], + atomic: true, + }); + expect(result.rolledBack).toBe(true); + expect(result.success).toBe(0); + expect(result.createdHostIds).toEqual([]); + expect(result.failed).toBe(1); + } finally { + off(); + } + expect(mocks.remove.mock.calls.map((call) => call[1])).toEqual([12, 11]); + expect(mocks.applyDefaults).not.toHaveBeenCalled(); + expect(events).toEqual([]); + }); + + it("refuses overwrite together with atomic", async () => { + await expect( + runBulkImport("u1", { hosts: [good], overwrite: true, atomic: true }), + ).rejects.toThrow(/cannot overwrite/); + }); +}); + +describe("runSshConfigImport", () => { + const config = "Host web\n HostName 192.0.2.9\n User bob\n"; + + it("imports Host blocks", async () => { + mocks.create.mockResolvedValueOnce({ id: 31 }); + const result = await runSshConfigImport("u1", { content: config }); + expect(result).toMatchObject({ success: 1, createdHostIds: [31] }); + }); + + it("rejects empty content", async () => { + await expect( + runSshConfigImport("u1", { content: " " }), + ).rejects.toBeInstanceOf(ImportError); + }); +}); diff --git a/src/backend/tests/hosts/status/host-status-service.test.ts b/src/backend/tests/hosts/status/host-status-service.test.ts deleted file mode 100644 index 0d3eb1d49..000000000 --- a/src/backend/tests/hosts/status/host-status-service.test.ts +++ /dev/null @@ -1,312 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -vi.mock("../../../database/repositories/factory.js", () => ({ - createCurrentHostResolutionRepository: vi.fn(), - getCurrentSettingValue: vi.fn(() => null), -})); -vi.mock("../../../utils/logger.js", () => ({ - sshLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, - pluginLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, -})); - -const { HostStatusService, toStatusTarget } = - await import("../../../hosts/status/host-status-service.js"); -const { hostSessionStatus } = - await import("../../../hosts/host-session-status.js"); -const { pluginEvents, TOPICS } = await import("../../../plugins/events.js"); - -type Target = ReturnType; - -function target(id: number, extra: Partial = {}): Target { - return { - id, - userId: "owner", - ip: `10.0.0.${id}`, - port: 22, - connectionType: "ssh", - jumpHosts: [], - statusCheckEnabled: true, - statusCheckInterval: null, - ...extra, - }; -} - -function setup( - targets: Target[], - reachable = true, - shared: Record = {}, -) { - const emitted: unknown[] = []; - const ping = vi.fn(async () => reachable); - const pingThroughJumpHosts = vi.fn(async () => reachable); - const loadTargets = vi.fn( - async (filter: { userId?: string; hostIds?: number[] }) => - targets.filter( - (t) => - (!filter.userId || t.userId === filter.userId) && - (!filter.hostIds || filter.hostIds.includes(t.id)), - ), - ); - const service = new HostStatusService({ - loadTargets, - loadSharedHostIds: async (userId) => shared[userId] ?? [], - ping, - pingThroughJumpHosts, - hasActiveSession: (id) => hostSessionStatus.hasActiveSession(id), - globalInterval: () => 60, - emit: (payload) => emitted.push(payload), - retryDelayMs: 0, - }); - return { service, emitted, ping, pingThroughJumpHosts, loadTargets }; -} - -const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); - -let active: InstanceType | null = null; - -beforeEach(() => { - vi.useFakeTimers({ toFake: ["setInterval", "clearInterval"] }); -}); - -afterEach(() => { - active?.stop(); - active = null; - pluginEvents.clear(); - vi.useRealTimers(); -}); - -describe("HostStatusService", () => { - it.each([{ jumpHosts: [] }, { jumpHosts: [{ hostId: 9 }] }])( - "skips probes while a session is open, including jump hosts: %j", - async ({ jumpHosts }) => { - const { service, ping, pingThroughJumpHosts } = setup([ - target(7, { jumpHosts }), - ]); - active = service; - const close = hostSessionStatus.register(7); - try { - await service.statusesFor("owner", null); - await flush(); - await vi.advanceTimersByTimeAsync(60_000); - await flush(); - expect(service.get(7)?.status).toBe("online"); - expect(ping).not.toHaveBeenCalled(); - expect(pingThroughJumpHosts).not.toHaveBeenCalled(); - close(); - await vi.advanceTimersByTimeAsync(60_000); - await flush(); - expect( - jumpHosts.length ? pingThroughJumpHosts : ping, - ).toHaveBeenCalledOnce(); - } finally { - close(); - } - }, - ); - - it("starts a user's own hosts and reports them online", async () => { - const { service, emitted, ping } = setup([target(1), target(2)]); - active = service; - - await service.statusesFor("owner", null); - await flush(); - - expect(ping).toHaveBeenCalledWith("10.0.0.1", 22); - expect(service.get(1)?.status).toBe("online"); - expect(emitted).toContainEqual({ - hostId: 1, - ownerUserId: "owner", - status: "online", - previous: null, - online: true, - }); - }); - - it("checks a host shared with the user before its owner ever asks", async () => { - const { service, ping } = setup([target(5)], true, { recipient: [5] }); - active = service; - - await service.statusesFor("recipient", null); - await flush(); - - expect(ping).toHaveBeenCalledWith("10.0.0.5", 22); - expect(service.get(5)?.status).toBe("online"); - }); - - it("only starts the shared hosts a desktop request names", async () => { - const { service, ping } = setup([target(5), target(6)], true, { - recipient: [5, 6], - }); - active = service; - - await service.statusesFor("recipient", new Set([6])); - await flush(); - - expect(ping).toHaveBeenCalledWith("10.0.0.6", 22); - expect(ping).not.toHaveBeenCalledWith("10.0.0.5", 22); - }); - - it("reports offline for a host that does not answer", async () => { - const { service } = setup([target(1)], false); - active = service; - await service.statusesFor("owner", null); - await flush(); - expect(service.get(1)?.status).toBe("offline"); - }); - - it("never checks a host with status checks off", async () => { - const { service, ping } = setup([target(1, { statusCheckEnabled: false })]); - active = service; - await service.statusesFor("owner", null); - await flush(); - expect(ping).not.toHaveBeenCalled(); - expect(service.get(1)).toBeNull(); - }); - - it("goes through the jump hosts when a host has them", async () => { - const { service, ping, pingThroughJumpHosts } = setup([ - target(1, { jumpHosts: [{ hostId: 9 }] }), - ]); - active = service; - await service.statusesFor("owner", null); - await flush(); - expect(ping).not.toHaveBeenCalled(); - expect(pingThroughJumpHosts).toHaveBeenCalledOnce(); - }); - - it("asks a registered plugin for a protocol's port", async () => { - const { service, ping } = setup([target(1, { connectionType: "rdp" })]); - active = service; - service.registerPort("rdp", async () => 3390); - await service.statusesFor("owner", null); - await flush(); - expect(ping).toHaveBeenCalledWith("10.0.0.1", 3390); - }); - - it("falls back to the host's port when no plugin knows the protocol", async () => { - const { service, ping } = setup([ - target(1, { connectionType: "rdp", port: 3389 }), - ]); - active = service; - await service.statusesFor("owner", null); - await flush(); - expect(ping).toHaveBeenCalledWith("10.0.0.1", 3389); - }); - - it("ignores a failed login and counts a working one as online", async () => { - const { service, emitted } = setup([target(1)]); - active = service; - await service.statusesFor("owner", null); - - const before = emitted.length; - service.reportLogin(1, { ok: false }); - expect(service.get(1)?.status).toBe("online"); - expect(emitted).toHaveLength(before); - }); - - it("marks a host online when a terminal session opens", async () => { - const { service } = setup([target(1)], false); - active = service; - service.start(); - await service.statusesFor("owner", null); - expect(service.get(1)?.status).toBe("offline"); - - const release = hostSessionStatus.register(1); - expect(service.get(1)?.status).toBe("online"); - release(); - }); - - it("tries a failed ping again before reporting offline", async () => { - const { service, ping } = setup([target(1)]); - active = service; - ping.mockResolvedValueOnce(false); - await service.statusesFor("owner", null); - expect(ping).toHaveBeenCalledTimes(2); - expect(service.get(1)?.status).toBe("online"); - }); - - it("answers the first request with the first checks done", async () => { - const { service } = setup([target(1), target(2)]); - active = service; - const statuses = await service.statusesFor("owner", null); - expect(statuses.get(1)?.status).toBe("online"); - expect(statuses.get(2)?.status).toBe("online"); - }); - - it("only checks the requested hosts on the desktop app", async () => { - const { service, ping } = setup([target(1), target(2)]); - active = service; - await service.statusesFor("owner", new Set([2])); - await flush(); - expect(ping).toHaveBeenCalledTimes(1); - expect(ping).toHaveBeenCalledWith("10.0.0.2", 22); - }); - - it("checks again on its interval", async () => { - const { service, ping } = setup([target(1, { statusCheckInterval: 10 })]); - active = service; - await service.statusesFor("owner", null); - await flush(); - expect(ping).toHaveBeenCalledTimes(1); - - vi.advanceTimersByTime(15_000); - await flush(); - expect(ping).toHaveBeenCalledTimes(2); - }); - - it("checks a host on demand unless the last result is fresh", async () => { - const { service, ping } = setup([target(1)]); - active = service; - expect(await service.check(1)).toMatchObject({ status: "online" }); - expect(await service.check(1)).toMatchObject({ status: "online" }); - expect(ping).toHaveBeenCalledTimes(1); - }); - - it("forgets a deleted host", async () => { - const { service } = setup([target(1)]); - active = service; - service.start(); - await service.statusesFor("owner", null); - await flush(); - - pluginEvents.emit(TOPICS.hostDeleted, { hostId: 1 }); - expect(service.get(1)).toBeNull(); - }); - - it("never logs in to check a host", async () => { - // The only transports are the two ping functions: nothing here can - // authenticate, so a RADIUS or Duo backed host gets no 2FA push. - const { service, ping, pingThroughJumpHosts } = setup([target(1)]); - active = service; - await service.statusesFor("owner", null); - await flush(); - expect(ping.mock.calls[0]).toEqual(["10.0.0.1", 22]); - expect(pingThroughJumpHosts).not.toHaveBeenCalled(); - }); -}); - -describe("toStatusTarget", () => { - it("parses the jump hosts and defaults the connection type", () => { - expect( - toStatusTarget({ - id: 1, - userId: "u", - ip: "h", - port: 22, - connectionType: "", - jumpHosts: '[{"hostId":3},{"bad":1}]', - statusCheckEnabled: false, - statusCheckInterval: 30, - }), - ).toEqual({ - id: 1, - userId: "u", - ip: "h", - port: 22, - connectionType: "ssh", - jumpHosts: [{ hostId: 3 }], - statusCheckEnabled: false, - statusCheckInterval: 30, - }); - }); -}); diff --git a/src/backend/tests/hosts/status/host-status.test.ts b/src/backend/tests/hosts/status/host-status.test.ts deleted file mode 100644 index 1d67f7a12..000000000 --- a/src/backend/tests/hosts/status/host-status.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { isHostKeyVerificationError } from "../../../hosts/status/host-status.js"; - -describe("isHostKeyVerificationError", () => { - it.each([ - "Host denied (verification failed)", - "Host key changed - please connect via Terminal to verify the new key", - ])("classifies %s", (message) => { - expect(isHostKeyVerificationError(new Error(message))).toBe(true); - }); - - it("does not classify ordinary authentication failures", () => { - expect(isHostKeyVerificationError(new Error("Permission denied"))).toBe( - false, - ); - }); -}); diff --git a/src/backend/tests/hosts/status/limiter.test.ts b/src/backend/tests/hosts/status/limiter.test.ts deleted file mode 100644 index 313226c8c..000000000 --- a/src/backend/tests/hosts/status/limiter.test.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { ConcurrentLimiter } from "../../../hosts/status/limiter.js"; - -describe("ConcurrentLimiter", () => { - it("never exceeds max concurrent runners", async () => { - const limiter = new ConcurrentLimiter(2); - let peak = 0; - let current = 0; - - const job = async () => { - current += 1; - peak = Math.max(peak, current); - await new Promise((r) => setTimeout(r, 30)); - current -= 1; - }; - - await Promise.all([ - limiter.run(job), - limiter.run(job), - limiter.run(job), - limiter.run(job), - ]); - - expect(peak).toBeLessThanOrEqual(2); - expect(limiter.activeCount).toBe(0); - expect(limiter.pendingCount).toBe(0); - }); - - it("runs waiters in FIFO order after a slot frees", async () => { - const limiter = new ConcurrentLimiter(1); - const order: number[] = []; - - const first = limiter.run(async () => { - order.push(1); - await new Promise((r) => setTimeout(r, 20)); - }); - const second = limiter.run(async () => { - order.push(2); - }); - const third = limiter.run(async () => { - order.push(3); - }); - - await Promise.all([first, second, third]); - expect(order).toEqual([1, 2, 3]); - }); - - it("rejects invalid maxConcurrent", () => { - expect(() => new ConcurrentLimiter(0)).toThrow(/maxConcurrent/); - }); - - describe("setLimit", () => { - it("releases queued waiters as soon as the ceiling is raised", async () => { - const limiter = new ConcurrentLimiter(1); - let running = 0; - let peak = 0; - const release: Array<() => void> = []; - - const job = () => - limiter.run(async () => { - running += 1; - peak = Math.max(peak, running); - await new Promise((r) => release.push(r)); - running -= 1; - }); - - const jobs = [job(), job(), job(), job()]; - await new Promise((r) => setTimeout(r, 10)); - expect(peak).toBe(1); - expect(limiter.pendingCount).toBe(3); - - // Widening must drain the backlog without waiting for the running job. - limiter.setLimit(4); - await new Promise((r) => setTimeout(r, 10)); - expect(peak).toBe(4); - expect(limiter.pendingCount).toBe(0); - - release.forEach((fn) => fn()); - await Promise.all(jobs); - expect(limiter.activeCount).toBe(0); - }); - - it("does not over-release beyond the new ceiling", async () => { - const limiter = new ConcurrentLimiter(1); - let running = 0; - let peak = 0; - const release: Array<() => void> = []; - // Later waves of woken jobs enqueue their own resolvers, so draining has - // to keep going until nothing is left rather than flushing a snapshot. - const drain = async () => { - while (release.length > 0) { - release.splice(0).forEach((fn) => fn()); - await new Promise((r) => setTimeout(r, 5)); - } - }; - - const job = () => - limiter.run(async () => { - running += 1; - peak = Math.max(peak, running); - await new Promise((r) => release.push(r)); - running -= 1; - }); - - const jobs = [job(), job(), job(), job(), job()]; - await new Promise((r) => setTimeout(r, 10)); - - limiter.setLimit(3); - await new Promise((r) => setTimeout(r, 10)); - expect(peak).toBe(3); - expect(limiter.pendingCount).toBe(2); - - await drain(); - await Promise.all(jobs); - expect(limiter.activeCount).toBe(0); - expect(limiter.pendingCount).toBe(0); - }); - - it("lets running work finish when the ceiling shrinks", async () => { - const limiter = new ConcurrentLimiter(4); - let running = 0; - let peak = 0; - const release: Array<() => void> = []; - const drain = async () => { - while (release.length > 0) { - release.splice(0).forEach((fn) => fn()); - await new Promise((r) => setTimeout(r, 5)); - } - }; - - const job = () => - limiter.run(async () => { - running += 1; - peak = Math.max(peak, running); - await new Promise((r) => release.push(r)); - running -= 1; - }); - - const jobs = [job(), job(), job(), job(), job(), job()]; - await new Promise((r) => setTimeout(r, 10)); - expect(peak).toBe(4); - - // Shrinking never kills in-flight work; it applies to later releases. - limiter.setLimit(2); - expect(limiter.activeCount).toBe(4); - - await drain(); - await Promise.all(jobs); - expect(limiter.activeCount).toBe(0); - expect(limiter.pendingCount).toBe(0); - // The two queued jobs ran only after the shrink, so they never pushed - // occupancy back up to the old width. - expect(peak).toBe(4); - }); - - it("rejects an invalid new limit", () => { - const limiter = new ConcurrentLimiter(2); - expect(() => limiter.setLimit(0)).toThrow(/maxConcurrent/); - expect(limiter.limit).toBe(2); - }); - }); -}); diff --git a/src/backend/tests/plugins/activity-provider.test.ts b/src/backend/tests/plugins/activity-provider.test.ts new file mode 100644 index 000000000..d7e82f4d3 --- /dev/null +++ b/src/backend/tests/plugins/activity-provider.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it, vi } from "vitest"; + +const access = vi.hoisted(() => ({ owned: true, shared: [] as number[] })); + +vi.mock("../../database/repositories/factory.js", () => ({ + createCurrentHostResolutionRepository: () => ({ + isHostOwnedByUser: async () => access.owned, + }), + createCurrentRoleRepository: () => ({ listUserRoleIds: async () => [] }), + createCurrentRbacAccessRepository: () => ({ + listVisibleHostAccessEntries: async () => + access.shared.map((hostId) => ({ hostId })), + }), +})); + +import { + provideActivity, + recentActivity, + type ActivityProvider, +} from "../../plugins/activity-provider.js"; + +function fake(): ActivityProvider { + return { + record: vi.fn(async () => ({ status: "logged" as const, id: 1 })), + list: vi.fn(async () => []), + deleteByUserId: vi.fn(async () => {}), + deleteByHostIds: vi.fn(async () => {}), + }; +} + +const entry = { type: "ssh", hostId: 1, hostName: "h" }; + +describe("recent activity provider", () => { + it("is unavailable with no provider", async () => { + expect(await recentActivity.record("u", entry)).toEqual({ + status: "unavailable", + }); + expect(await recentActivity.list("u", 5)).toEqual([]); + await expect(recentActivity.deleteByUserId("u")).resolves.toBeUndefined(); + await expect(recentActivity.deleteByHostId(7)).resolves.toBeUndefined(); + }); + + it("routes to the plugin and back to nothing when it goes", async () => { + const plugin = fake(); + const remove = provideActivity("homepage", plugin); + await recentActivity.record("u", entry); + await recentActivity.deleteByHostId(7); + expect(plugin.record).toHaveBeenCalledOnce(); + expect(plugin.deleteByHostIds).toHaveBeenCalledWith([7]); + + remove(); + expect(await recentActivity.list("u", 5)).toEqual([]); + expect(plugin.list).not.toHaveBeenCalled(); + }); + + it("checks host access before the provider records", async () => { + const plugin = fake(); + const remove = provideActivity("homepage", plugin); + + access.owned = false; + access.shared = []; + expect(await recentActivity.record("u", entry)).toEqual({ + status: "denied", + }); + expect(plugin.record).not.toHaveBeenCalled(); + + access.shared = [1]; + expect((await recentActivity.record("u", entry)).status).toBe("logged"); + remove(); + access.owned = true; + }); + + it("refuses a second provider", () => { + const remove = provideActivity("one", fake()); + expect(() => provideActivity("two", fake())).toThrow( + /already provided by plugin "one"/, + ); + remove(); + }); +}); diff --git a/src/backend/tests/plugins/ctx-audit.test.ts b/src/backend/tests/plugins/ctx-audit.test.ts index f28aaedc7..1e9ef3b9c 100644 --- a/src/backend/tests/plugins/ctx-audit.test.ts +++ b/src/backend/tests/plugins/ctx-audit.test.ts @@ -1,112 +1,159 @@ /** - * ctx.audit.record: a plugin writes an audit line under its own action name, - * and the user it names is always the runtime's actor, never one the plugin - * picked. + * ctx.audit.subscribe, list and exportBatches: audit:read gates reading, and + * subscribers see lines after they are accepted without delaying the writer. */ import { beforeEach, describe, expect, it, vi } from "vitest"; -const auditEntries: Array> = []; +const grants = new Map(); +const rows = vi.hoisted(() => ({ list: [] as Array> })); vi.mock("../../database/repositories/factory.js", () => ({ createCurrentPluginPermissionGrantRepository: () => ({ - listByPlugin: async () => [], + listByPlugin: async (pluginId: string) => + (grants.get(pluginId) ?? []).map((capability) => ({ + pluginId, + capability, + })), }), -})); - -vi.mock("../../utils/audit-logger.js", () => ({ - logAudit: async (entry: Record) => { - auditEntries.push(entry); - }, + createCurrentAuditLogRepository: () => ({ + listPage: async ({ limit }: { limit: number }) => ({ + logs: rows.list.slice(0, limit), + total: rows.list.length, + }), + listForExport: async ({ + limit, + offset, + }: { + limit: number; + offset: number; + }) => rows.list.slice(offset, offset + limit), + create: async () => {}, + }), + createCurrentUserRepository: () => ({ findById: async () => null }), })); import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js"; -import { runAsActor } from "../../plugins/actor.js"; +import { invalidatePluginPermissionCache } from "../../plugins/permissions.js"; +import { emitAuditEntry } from "../../utils/audit-hooks.js"; import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest"; -function contextFor(pluginId: string) { +function contextFor(pluginId: string, capabilities: string[]) { const manifest = { id: pluginId, - name: "Audit Fixture", + name: pluginId, version: "1.0.0", description: "", author: { name: "test" }, license: "MIT", category: "Productivity", - engine: { termix: ">=2.9.0", api: "1" }, - capabilities: [], + engine: { termix: ">=26.10.3", api: "1" }, + capabilities, } as PluginManifest; const handle = createPluginHandle(pluginId, { activate: () => {} }); - return createPluginContext(manifest, handle); + return { ctx: createPluginContext(manifest, handle), handle }; } +const line = (id: number) => ({ + id, + timestamp: new Date(1_700_000_000_000 + id), + userId: null, + username: "root", + action: "login", + resourceType: "user", + resourceId: null, + resourceName: null, + details: null, + ipAddress: null, + userAgent: null, + success: true, + errorMessage: null, +}); + beforeEach(() => { - auditEntries.length = 0; + grants.clear(); + invalidatePluginPermissionCache(); + rows.list = [line(1), line(2), line(3)]; }); -describe("ctx.audit.record", () => { - it("writes the plugin's action for the acting user", async () => { - const ctx = contextFor("audit-fixture"); - await runAsActor("user-3", "request", () => - ctx.audit.record({ - action: "ssh_connect", - resourceType: "host", - resourceId: "12", - resourceName: "root@10.0.0.1:22", - success: true, - }), +describe("ctx.audit reading", () => { + it("refuses list and export without audit:read", async () => { + grants.set("demo", []); + const { ctx } = contextFor("demo", []); + + await expect(ctx.audit.list()).rejects.toThrow(/audit:read/); + await expect(ctx.audit.exportBatches().next()).rejects.toThrow( + /audit:read/, ); - expect(auditEntries).toEqual([ - expect.objectContaining({ - userId: "user-3", - action: "ssh_connect", - resourceType: "host", - resourceId: "12", - resourceName: "root@10.0.0.1:22", - details: "via plugin audit-fixture", - success: true, - }), - ]); + expect(() => ctx.audit.subscribe(() => {})).toThrow(/audit:read/); }); - it("falls back to the plugin as the resource and system as the user", async () => { - const ctx = contextFor("audit-fixture"); - await ctx.audit.record({ action: "cleanup", success: false }); - expect(auditEntries[0]).toMatchObject({ + it("lists stored lines", async () => { + grants.set("demo", ["audit:read"]); + const { ctx } = contextFor("demo", ["audit:read"]); + + const page = await ctx.audit.list({ limit: 2 }); + expect(page.total).toBe(3); + expect(page.entries.map((entry) => entry.id)).toEqual([1, 2]); + expect(page.entries[0].timestamp).toMatch(/^20\d\d-/); + }); + + it("exports in batches", async () => { + grants.set("demo", ["audit:read"]); + const { ctx } = contextFor("demo", ["audit:read"]); + + const sizes: number[] = []; + for await (const batch of ctx.audit.exportBatches({}, 2)) { + sizes.push(batch.length); + } + expect(sizes).toEqual([2, 1]); + }); + + it("delivers new lines to a subscriber until it unsubscribes", async () => { + grants.set("demo", ["audit:read"]); + const { ctx } = contextFor("demo", ["audit:read"]); + const seen: string[] = []; + + const stop = ctx.audit.subscribe((entry) => seen.push(entry.action)); + await new Promise((resolve) => setTimeout(resolve, 20)); + + emitAuditEntry({ userId: null, - username: "system", - resourceType: "plugin", - resourceId: "audit-fixture", - resourceName: "Audit Fixture", - success: false, - }); - }); -}); - -describe("ctx.audit.record with a request", () => { - it("records the request's IP address and user agent", async () => { - const ctx = contextFor("audit-fixture"); - await ctx.audit.record({ - action: "collab_room_end", + username: "root", + action: "one", + resourceType: "x", success: true, - request: { - ip: "203.0.113.4", - headers: { "user-agent": "TestAgent/1.0" }, - }, }); - expect(auditEntries[0]).toMatchObject({ - ipAddress: "203.0.113.4", - userAgent: "TestAgent/1.0", + await new Promise((resolve) => setTimeout(resolve, 5)); + stop(); + emitAuditEntry({ + userId: null, + username: "root", + action: "two", + resourceType: "x", + success: true, }); + await new Promise((resolve) => setTimeout(resolve, 5)); + + expect(seen).toEqual(["one"]); }); - it("falls back to the socket address on an upgrade request", async () => { - const ctx = contextFor("audit-fixture"); - await ctx.audit.record({ - action: "collab_join", + it("stops delivering when the plugin deactivates", async () => { + grants.set("demo", ["audit:read"]); + const { ctx, handle } = contextFor("demo", ["audit:read"]); + const seen: string[] = []; + ctx.audit.subscribe((entry) => seen.push(entry.action)); + await new Promise((resolve) => setTimeout(resolve, 20)); + + await handle.bag.disposeAll(); + emitAuditEntry({ + userId: null, + username: "root", + action: "late", + resourceType: "x", success: true, - request: { headers: {}, socket: { remoteAddress: "198.51.100.9" } }, }); - expect(auditEntries[0]).toMatchObject({ ipAddress: "198.51.100.9" }); + await new Promise((resolve) => setTimeout(resolve, 5)); + expect(seen).toEqual([]); }); }); diff --git a/src/backend/tests/plugins/ctx-credentials-keys.test.ts b/src/backend/tests/plugins/ctx-credentials-keys.test.ts new file mode 100644 index 000000000..42622564c --- /dev/null +++ b/src/backend/tests/plugins/ctx-credentials-keys.test.ts @@ -0,0 +1,179 @@ +/** + * ctx.credentials.publicKey and resolveKeyRefs, and the capability gate on + * ctx.ssh.exec. + */ + +import { beforeEach, describe, expect, it, vi } from "vitest"; +import ssh2 from "ssh2"; + +const grants = new Map(); +const auditEntries: Array> = []; +const state = vi.hoisted(() => ({ + permissions: new Set(["credentials.view"]), + credential: null as Record | null, + resolve: vi.fn(), +})); + +vi.mock("../../database/repositories/factory.js", () => ({ + createCurrentPluginPermissionGrantRepository: () => ({ + listByPlugin: async (pluginId: string) => + (grants.get(pluginId) ?? []).map((capability) => ({ + pluginId, + capability, + })), + }), + createCurrentHostResolutionRepository: () => ({ + findCredentialByIdForUser: async () => state.credential, + }), +})); +vi.mock("../../utils/audit-logger.js", () => ({ + logAudit: async (entry: Record) => { + auditEntries.push(entry); + }, +})); +vi.mock("../../utils/permission-manager.js", () => ({ + PermissionManager: { + getInstance: () => ({ + hasPermission: async (_user: string, name: string) => + state.permissions.has(name), + }), + }, +})); +vi.mock("../../hosts/stored-ssh-key.js", () => ({ + resolveKeyReferences: state.resolve, +})); + +import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js"; +import { invalidatePluginPermissionCache } from "../../plugins/permissions.js"; +import { runAsActor } from "../../plugins/actor.js"; +import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest"; + +function contextFor(capabilities: string[]) { + const manifest = { + id: "demo", + name: "demo", + version: "1.0.0", + description: "", + author: { name: "test" }, + license: "MIT", + category: "Productivity", + engine: { termix: ">=26.10.3", api: "1" }, + capabilities, + } as PluginManifest; + return createPluginContext( + manifest, + createPluginHandle("demo", { activate: () => {} }), + ); +} + +const asUser = (fn: () => Promise) => runAsActor("user-1", "request", fn); + +const pair = ssh2.utils.generateKeyPairSync("ed25519"); + +beforeEach(() => { + grants.clear(); + auditEntries.length = 0; + invalidatePluginPermissionCache(); + state.permissions = new Set(["credentials.view"]); + state.credential = null; + state.resolve.mockReset(); +}); + +describe("ctx.credentials.publicKey", () => { + it("refuses without credentials:use", async () => { + grants.set("demo", []); + const ctx = contextFor([]); + await expect(asUser(() => ctx.credentials.publicKey(1))).rejects.toThrow( + /credentials:use/, + ); + }); + + it("refuses when the user lacks credentials.view", async () => { + grants.set("demo", ["credentials:use"]); + state.permissions = new Set(); + const ctx = contextFor(["credentials:use"]); + await expect(asUser(() => ctx.credentials.publicKey(1))).rejects.toThrow( + /credentials.view/, + ); + expect(auditEntries.at(-1)).toMatchObject({ success: false }); + }); + + it("returns null for a missing credential or one that is not a key", async () => { + grants.set("demo", ["credentials:use"]); + const ctx = contextFor(["credentials:use"]); + expect(await asUser(() => ctx.credentials.publicKey(1))).toBeNull(); + state.credential = { id: 1, authType: "password" }; + expect(await asUser(() => ctx.credentials.publicKey(1))).toBeNull(); + }); + + it("returns the stored public key", async () => { + grants.set("demo", ["credentials:use"]); + state.credential = { + id: 1, + authType: "key", + publicKey: " ssh-ed25519 AAA ", + }; + const ctx = contextFor(["credentials:use"]); + expect(await asUser(() => ctx.credentials.publicKey(1))).toBe( + "ssh-ed25519 AAA", + ); + }); + + it("derives it from the private key behind a reference", async () => { + grants.set("demo", ["credentials:use"]); + state.credential = { id: 1, authType: "key", key: "op://vault/key" }; + state.resolve.mockResolvedValue({ key: pair.private }); + const ctx = contextFor(["credentials:use"]); + const result = await asUser(() => ctx.credentials.publicKey(1)); + expect(result).toMatch(/^ssh-ed25519 /); + expect(state.resolve).toHaveBeenCalledWith( + "user-1", + "op://vault/key", + null, + ); + }); + + it("rejects a bad id", async () => { + grants.set("demo", ["credentials:use"]); + const ctx = contextFor(["credentials:use"]); + await expect(asUser(() => ctx.credentials.publicKey(0))).rejects.toThrow( + /credential id/, + ); + }); +}); + +describe("ctx.credentials.resolveKeyRefs", () => { + it("refuses with only credentials:use", async () => { + grants.set("demo", ["credentials:use"]); + const ctx = contextFor(["credentials:use"]); + await expect( + asUser(() => ctx.credentials.resolveKeyRefs("op://a")), + ).rejects.toThrow(/credentials:read/); + expect(state.resolve).not.toHaveBeenCalled(); + }); + + it("resolves for the acting user and audits", async () => { + grants.set("demo", ["credentials:read"]); + state.resolve.mockResolvedValue({ key: "KEY", passphrase: "PW" }); + const ctx = contextFor(["credentials:read"]); + const result = await asUser(() => + ctx.credentials.resolveKeyRefs("op://a", "op://b"), + ); + expect(result).toEqual({ key: "KEY", passphrase: "PW" }); + expect(state.resolve).toHaveBeenCalledWith("user-1", "op://a", "op://b"); + expect(auditEntries.at(-1)).toMatchObject({ + action: "plugin_credentials_resolve_key", + success: true, + }); + }); +}); + +describe("ctx.ssh.exec", () => { + it("refuses without ssh:connect and credentials:use", async () => { + grants.set("demo", []); + const ctx = contextFor([]); + await expect(asUser(() => ctx.ssh.exec(1, "id"))).rejects.toThrow( + /ssh:connect/, + ); + }); +}); diff --git a/src/backend/tests/plugins/ctx-desktop-local.test.ts b/src/backend/tests/plugins/ctx-desktop-local.test.ts new file mode 100644 index 000000000..6e959c524 --- /dev/null +++ b/src/backend/tests/plugins/ctx-desktop-local.test.ts @@ -0,0 +1,248 @@ +/** + * ctx.desktop.local: desktop:local is checked and audited, and nothing + * touches the machine unless the backend runs embedded in the desktop app. + */ + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const grants = new Map(); +const auditEntries: Array> = []; + +vi.mock("../../database/repositories/factory.js", () => ({ + createCurrentPluginPermissionGrantRepository: () => ({ + listByPlugin: async (pluginId: string) => + (grants.get(pluginId) ?? []).map((capability) => ({ + pluginId, + capability, + })), + }), +})); + +vi.mock("../../utils/audit-logger.js", () => ({ + logAudit: async (entry: Record) => { + auditEntries.push(entry); + }, +})); + +const bridge = vi.hoisted(() => ({ + requestFromElectronMain: vi.fn(async () => ({ success: true })), +})); +vi.mock("../../utils/electron-ipc-bridge.js", () => ({ + isElectronIpcAvailable: () => process.env.ELECTRON_EMBEDDED === "true", + requestFromElectronMain: bridge.requestFromElectronMain, +})); + +vi.mock("../../sync/client/link-store.js", () => ({ + getLink: async () => ({ + serverUrl: "https://termix.example.test", + sessionToken: "token", + }), +})); + +import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js"; +import { invalidatePluginPermissionCache } from "../../plugins/permissions.js"; +import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest"; + +function contextFor(pluginId: string, capabilities: string[]) { + const manifest = { + id: pluginId, + name: pluginId, + version: "1.0.0", + description: "", + author: { name: "test" }, + license: "MIT", + category: "Productivity", + engine: { termix: ">=26.10.3", api: "1" }, + capabilities, + } as PluginManifest; + const handle = createPluginHandle(pluginId, { activate: () => {} }); + return createPluginContext(manifest, handle); +} + +let dir: string; +const previous = process.env.ELECTRON_EMBEDDED; + +beforeEach(() => { + grants.clear(); + auditEntries.length = 0; + invalidatePluginPermissionCache(); + bridge.requestFromElectronMain.mockClear(); + process.env.ELECTRON_EMBEDDED = "true"; + dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-local-")); +}); + +afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + if (previous === undefined) delete process.env.ELECTRON_EMBEDDED; + else process.env.ELECTRON_EMBEDDED = previous; +}); + +describe("ctx.desktop.local", () => { + it("refuses without desktop:local and audits the refusal", async () => { + grants.set("demo", []); + const ctx = contextFor("demo", []); + + await expect(ctx.desktop.local.fs.list(dir)).rejects.toThrow( + /desktop:local/, + ); + expect(auditEntries.at(-1)).toMatchObject({ success: false }); + }); + + it("refuses when granted but not declared", async () => { + grants.set("demo", ["desktop:local"]); + const ctx = contextFor("demo", []); + + await expect(ctx.desktop.local.fs.list(dir)).rejects.toThrow( + /desktop:local/, + ); + }); + + it("refuses outside the desktop app even with the capability", async () => { + grants.set("demo", ["desktop:local"]); + process.env.ELECTRON_EMBEDDED = "false"; + const ctx = contextFor("demo", ["desktop:local"]); + + expect(ctx.desktop.local.available()).toBe(false); + await expect(ctx.desktop.local.fs.list(dir)).rejects.toThrow(/desktop app/); + }); + + it("lists and audits once per action", async () => { + grants.set("demo", ["desktop:local"]); + fs.writeFileSync(path.join(dir, "a.txt"), "hi"); + const ctx = contextFor("demo", ["desktop:local"]); + + const first = await ctx.desktop.local.fs.list(dir); + await ctx.desktop.local.fs.list(dir); + + expect(first.entries.map((entry) => entry.name)).toEqual(["a.txt"]); + expect( + auditEntries.filter( + (entry) => entry.action === "plugin_desktop_local_fs", + ), + ).toHaveLength(1); + }); + + it("rejects relative paths and unsafe names", async () => { + grants.set("demo", ["desktop:local"]); + const ctx = contextFor("demo", ["desktop:local"]); + + await expect(ctx.desktop.local.fs.list("relative/path")).rejects.toThrow( + /absolute/, + ); + await expect(ctx.desktop.local.fs.mkdir(dir, "../evil")).rejects.toThrow( + /Invalid/, + ); + }); + + it("never renames onto an existing name", async () => { + grants.set("demo", ["desktop:local"]); + fs.writeFileSync(path.join(dir, "a"), "1"); + fs.writeFileSync(path.join(dir, "b"), "2"); + const ctx = contextFor("demo", ["desktop:local"]); + + await expect( + ctx.desktop.local.fs.rename(path.join(dir, "a"), "b"), + ).rejects.toThrow(/already exists/); + expect(fs.readFileSync(path.join(dir, "b"), "utf8")).toBe("2"); + }); + + it("keeps downloads inside the chosen folder and publishes on success", async () => { + grants.set("demo", ["desktop:local"]); + const ctx = contextFor("demo", ["desktop:local"]); + + await expect( + ctx.desktop.local.fs.openDownload({ + rootPath: dir, + destPath: path.join(dir, "..", "escape.txt"), + transferId: "t1", + }), + ).rejects.toThrow(/outside/); + + const download = await ctx.desktop.local.fs.openDownload({ + rootPath: dir, + destPath: path.join(dir, "ok.txt"), + transferId: "t2", + }); + download.stream.write("data"); + expect(await download.publish()).toBe(path.join(dir, "ok.txt")); + expect(fs.readFileSync(path.join(dir, "ok.txt"), "utf8")).toBe("data"); + expect(fs.readdirSync(dir)).toEqual(["ok.txt"]); + }); + + it("removes the partial file when a download is aborted", async () => { + grants.set("demo", ["desktop:local"]); + const ctx = contextFor("demo", ["desktop:local"]); + + const download = await ctx.desktop.local.fs.openDownload({ + rootPath: dir, + destPath: path.join(dir, "gone.txt"), + transferId: "t3", + }); + download.stream.write("partial"); + await download.abort(); + expect(fs.readdirSync(dir)).toEqual([]); + }); + + it("sends trash and open through Electron main and audits each call", async () => { + grants.set("demo", ["desktop:local"]); + const ctx = contextFor("demo", ["desktop:local"]); + const target = path.join(dir, "x.txt"); + fs.writeFileSync(target, "x"); + + await expect(ctx.desktop.local.fs.trash([target])).resolves.toEqual({ + trashed: 1, + failed: [], + }); + expect(bridge.requestFromElectronMain).toHaveBeenCalledWith( + "shell-trash-item", + { path: target }, + ); + + await ctx.desktop.local.fs.open(target); + await ctx.desktop.local.fs.open(target); + expect( + auditEntries.filter( + (entry) => entry.action === "plugin_desktop_local_open", + ), + ).toHaveLength(2); + }); + + it("only calls the plugin's own routes", async () => { + grants.set("demo", ["desktop:local"]); + const ctx = contextFor("demo", ["desktop:local"]); + + for (const bad of [ + "relative", + "//evil.example/x", + "/../other", + "/a/../../b", + String.raw`/a\b`, + ]) { + await expect(ctx.desktop.local.pluginFetch("local", bad)).rejects.toThrow( + /Invalid plugin route path/, + ); + } + await expect( + ctx.desktop.local.pluginFetch("elsewhere" as never, "/ok"), + ).rejects.toThrow(/Unknown origin|No acting user/); + }); + + it("reports the link and the embedded backend port", async () => { + grants.set("demo", ["desktop:local"]); + process.env.EMBEDDED_BACKEND_PORT = "31234"; + const ctx = contextFor("demo", ["desktop:local"]); + + const info = await ctx.desktop.local.info(); + expect(info.backendPort).toBe(31234); + expect(info.link).toMatchObject({ + serverUrl: "https://termix.example.test", + token: "token", + allowInvalidCertificate: false, + }); + expect(info.link?.headers.Cookie).toBe("jwt=token"); + delete process.env.EMBEDDED_BACKEND_PORT; + }); +}); diff --git a/src/backend/tests/plugins/ctx-host-import.test.ts b/src/backend/tests/plugins/ctx-host-import.test.ts new file mode 100644 index 000000000..fa1952a2c --- /dev/null +++ b/src/backend/tests/plugins/ctx-host-import.test.ts @@ -0,0 +1,264 @@ +/** + * ctx.hosts.importMany, importSshConfig, exportMany, registerImportNormalizer: + * capability checks, core permission checks, audit lines and the registry key. + */ + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const grants = new Map(); +const auditEntries: Array> = []; +const state = vi.hoisted(() => ({ + permissions: new Set(["hosts.create", "hosts.edit"]), +})); +const importer = vi.hoisted(() => ({ + runBulkImport: vi.fn(), + runSshConfigImport: vi.fn(), + exportAllHosts: vi.fn(), +})); + +vi.mock("../../database/repositories/factory.js", () => ({ + createCurrentPluginPermissionGrantRepository: () => ({ + listByPlugin: async (pluginId: string) => + (grants.get(pluginId) ?? []).map((capability) => ({ + pluginId, + capability, + })), + }), +})); +vi.mock("../../utils/audit-logger.js", () => ({ + logAudit: async (entry: Record) => { + auditEntries.push(entry); + }, +})); +vi.mock("../../utils/permission-manager.js", () => ({ + PermissionManager: { + getInstance: () => ({ + hasPermission: async (_user: string, name: string) => + state.permissions.has(name), + }), + }, +})); +vi.mock("../../hosts/import/host-import.js", () => ({ + runBulkImport: importer.runBulkImport, + runSshConfigImport: importer.runSshConfigImport, +})); +vi.mock("../../hosts/import/host-export.js", () => ({ + exportAllHosts: importer.exportAllHosts, +})); +vi.mock("../../utils/database-save-trigger.js", () => ({ + DatabaseSaveTrigger: { batched: (fn: () => unknown) => fn }, +})); + +import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js"; +import { invalidatePluginPermissionCache } from "../../plugins/permissions.js"; +import { runAsActor } from "../../plugins/actor.js"; +import * as registry from "../../plugins/registry.js"; +import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest"; + +function contextFor(pluginId: string, capabilities: string[]) { + const manifest = { + id: pluginId, + name: pluginId, + version: "1.0.0", + description: "", + author: { name: "test" }, + license: "MIT", + category: "Productivity", + engine: { termix: ">=26.10.3", api: "1" }, + capabilities, + } as PluginManifest; + const handle = createPluginHandle(pluginId, { activate: () => {} }); + return { ctx: createPluginContext(manifest, handle), handle }; +} + +const asUser = (fn: () => Promise) => runAsActor("user-1", "request", fn); + +const outcome = { + success: 1, + updated: 0, + skipped: 0, + failed: 0, + errors: [], + createdHostIds: [5], +}; + +beforeEach(() => { + grants.clear(); + auditEntries.length = 0; + invalidatePluginPermissionCache(); + state.permissions = new Set(["hosts.create", "hosts.edit"]); + importer.runBulkImport.mockReset().mockResolvedValue(outcome); + importer.runSshConfigImport.mockReset().mockResolvedValue(outcome); + importer.exportAllHosts.mockReset().mockResolvedValue({ hosts: [{ id: 1 }] }); +}); + +describe("ctx.hosts.importMany", () => { + it("refuses without hosts:write", async () => { + grants.set("demo", []); + const { ctx } = contextFor("demo", []); + await expect( + asUser(() => ctx.hosts.importMany([{ ip: "a" }])), + ).rejects.toThrow(/hosts:write/); + expect(importer.runBulkImport).not.toHaveBeenCalled(); + }); + + it("needs credentials:write only when credentials are passed", async () => { + grants.set("demo", ["hosts:write"]); + const { ctx } = contextFor("demo", ["hosts:write"]); + await expect( + asUser(() => ctx.hosts.importMany([{ ip: "a" }])), + ).resolves.toMatchObject({ success: 1 }); + await expect( + asUser(() => + ctx.hosts.importMany([{ ip: "a" }], { credentials: [{ alias: "k" }] }), + ), + ).rejects.toThrow(/credentials:write/); + }); + + it("refuses when the acting user lacks the core permissions", async () => { + grants.set("demo", ["hosts:write"]); + state.permissions = new Set(["hosts.create"]); + const { ctx } = contextFor("demo", ["hosts:write"]); + await expect( + asUser(() => ctx.hosts.importMany([{ ip: "a" }])), + ).rejects.toThrow(/hosts.edit/); + expect(importer.runBulkImport).not.toHaveBeenCalled(); + expect(auditEntries.at(-1)).toMatchObject({ + action: "plugin_hosts_import", + success: false, + }); + }); + + it("needs an acting user", async () => { + grants.set("demo", ["hosts:write"]); + const { ctx } = contextFor("demo", ["hosts:write"]); + await expect(ctx.hosts.importMany([{ ip: "a" }])).rejects.toThrow( + /acting user/, + ); + }); + + it("passes the options through and audits the counts", async () => { + grants.set("demo", ["hosts:write"]); + const { ctx } = contextFor("demo", ["hosts:write"]); + const result = await asUser(() => + ctx.hosts.importMany([{ ip: "a" }], { atomic: true }), + ); + expect(result.createdHostIds).toEqual([5]); + expect(importer.runBulkImport).toHaveBeenCalledWith( + "user-1", + expect.objectContaining({ atomic: true, hosts: [{ ip: "a" }] }), + ); + expect(auditEntries.at(-1)).toMatchObject({ + action: "plugin_hosts_import", + success: true, + }); + }); + + it("surfaces the importer's request errors and audits the failure", async () => { + grants.set("demo", ["hosts:write"]); + importer.runBulkImport.mockRejectedValue(new Error("Maximum 100 hosts")); + const { ctx } = contextFor("demo", ["hosts:write"]); + await expect(asUser(() => ctx.hosts.importMany([]))).rejects.toThrow( + /Maximum 100/, + ); + expect(auditEntries.at(-1)).toMatchObject({ success: false }); + }); +}); + +describe("ctx.hosts.importSshConfig", () => { + it("refuses without hosts:write and runs with it", async () => { + grants.set("demo", []); + const { ctx } = contextFor("demo", []); + await expect( + asUser(() => ctx.hosts.importSshConfig("Host a")), + ).rejects.toThrow(/hosts:write/); + + grants.set("demo", ["hosts:write"]); + invalidatePluginPermissionCache(); + const { ctx: allowed } = contextFor("demo", ["hosts:write"]); + await asUser(() => allowed.hosts.importSshConfig("Host a")); + expect(importer.runSshConfigImport).toHaveBeenCalledWith( + "user-1", + expect.objectContaining({ content: "Host a" }), + ); + }); +}); + +describe("ctx.hosts.exportMany", () => { + it("refuses with only hosts:write and audits the refusal", async () => { + grants.set("demo", ["hosts:write"]); + const { ctx } = contextFor("demo", ["hosts:write"]); + await expect(asUser(() => ctx.hosts.exportMany())).rejects.toThrow( + /hosts:export/, + ); + expect(importer.exportAllHosts).not.toHaveBeenCalled(); + expect(auditEntries.at(-1)).toMatchObject({ success: false }); + }); + + it("exports with secrets by default and audits it", async () => { + grants.set("demo", ["hosts:export"]); + const { ctx } = contextFor("demo", ["hosts:export"]); + await expect(asUser(() => ctx.hosts.exportMany())).resolves.toEqual({ + hosts: [{ id: 1 }], + }); + expect(importer.exportAllHosts).toHaveBeenCalledWith( + "user-1", + false, + undefined, + ); + expect(auditEntries.at(-1)).toMatchObject({ + action: "plugin_export_hosts", + success: true, + }); + expect(String(auditEntries.at(-1)?.details)).toMatch(/with secrets/); + }); + + it("passes share mode through", async () => { + grants.set("demo", ["hosts:export"]); + const { ctx } = contextFor("demo", ["hosts:export"]); + await asUser(() => ctx.hosts.exportMany({ share: true })); + expect(importer.exportAllHosts).toHaveBeenCalledWith( + "user-1", + true, + undefined, + ); + expect(String(auditEntries.at(-1)?.details)).toMatch(/without secrets/); + }); + + it("passes the host ids filter through", async () => { + grants.set("demo", ["hosts:export"]); + const { ctx } = contextFor("demo", ["hosts:export"]); + await asUser(() => ctx.hosts.exportMany({ hostIds: [4, 5] })); + expect(importer.exportAllHosts).toHaveBeenCalledWith( + "user-1", + false, + [4, 5], + ); + }); +}); + +describe("ctx.hosts.registerImportNormalizer", () => { + it("needs hosts:write", () => { + const { ctx } = contextFor("demo", []); + expect(() => ctx.hosts.registerImportNormalizer(() => null)).toThrow( + /hosts:write/, + ); + }); + + it("registers under the key the import path reads and removes it on deactivate", async () => { + const { ctx, handle } = contextFor("demo", ["hosts:write"]); + const normalizer = () => ({ a: 1 }); + ctx.hosts.registerImportNormalizer(normalizer); + expect(registry.consume("demo.hostImportNormalizer")).toBe(normalizer); + + await handle.bag.disposeAll(); + expect(registry.consume("demo.hostImportNormalizer")).toBeUndefined(); + }); + + it("returns a remover", () => { + const { ctx } = contextFor("demo", ["hosts:write"]); + const remove = ctx.hosts.registerImportNormalizer(() => null); + remove(); + expect(registry.consume("demo.hostImportNormalizer")).toBeUndefined(); + }); +}); diff --git a/src/backend/tests/plugins/ctx-hosts.test.ts b/src/backend/tests/plugins/ctx-hosts.test.ts index e9d40c753..b5e8f4215 100644 --- a/src/backend/tests/plugins/ctx-hosts.test.ts +++ b/src/backend/tests/plugins/ctx-hosts.test.ts @@ -110,14 +110,14 @@ vi.mock("../../database/repositories/factory.js", () => ({ listDecryptedByUserId: async () => h.ownedHosts, }), })); -vi.mock("../../services/recent-activity.js", () => ({ - recordRecentActivity: async ( - userId: string, - entry: Record, - ) => { - h.activities.push({ userId, ...entry }); - return { status: "logged", id: 1 }; +vi.mock("../../plugins/activity-provider.js", () => ({ + recentActivity: { + record: async (userId: string, entry: Record) => { + h.activities.push({ userId, ...entry }); + return { status: "logged", id: 1 }; + }, }, + provideActivity: vi.fn(() => () => {}), })); vi.mock("../../hosts/host-session-status.js", () => ({ hostSessionStatus: { @@ -127,8 +127,9 @@ vi.mock("../../hosts/host-session-status.js", () => ({ }, }, })); -vi.mock("../../hosts/status/host-status-service.js", () => ({ - hostStatusService: { +vi.mock("../../plugins/host-status-provider.js", () => ({ + provideHostStatus: vi.fn(() => () => {}), + hostStatus: { get: (hostId: number) => hostId === 4 ? { status: "online", lastChecked: "t" } : null, check: async (hostId: number) => ({ diff --git a/src/backend/tests/plugins/ctx-probe.test.ts b/src/backend/tests/plugins/ctx-probe.test.ts new file mode 100644 index 000000000..34e46c993 --- /dev/null +++ b/src/backend/tests/plugins/ctx-probe.test.ts @@ -0,0 +1,182 @@ +/** + * ctx.network.tcpProbe, ctx.ssh.probe and ctx.hosts.probeTargets: gated by + * capability, and only ever offered hosts the actor may connect to. + */ + +import net from "node:net"; +import type { AddressInfo } from "node:net"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const grants = new Map(); +const state = vi.hoisted(() => ({ + owned: [] as Array>, + shared: [] as Array>, + sharedIds: [] as number[], + allowed: new Set(), +})); + +vi.mock("../../database/repositories/factory.js", () => ({ + createCurrentPluginPermissionGrantRepository: () => ({ + listByPlugin: async (pluginId: string) => + (grants.get(pluginId) ?? []).map((capability) => ({ + pluginId, + capability, + })), + }), + createCurrentHostResolutionRepository: () => ({ + listStatusTargets: async (filter: { + userId?: string; + hostIds?: number[]; + }) => { + const rows = filter.userId ? state.owned : state.shared; + return rows.filter( + (row) => !filter.hostIds || filter.hostIds.includes(row.id as number), + ); + }, + }), + createCurrentRoleRepository: () => ({ listUserRoleIds: async () => [] }), + createCurrentRbacAccessRepository: () => ({ + listVisibleHostAccessEntries: async () => + state.sharedIds.map((hostId) => ({ hostId })), + }), +})); +vi.mock("../../utils/permission-manager.js", () => ({ + PermissionManager: { + getInstance: () => ({ + filterAccessibleHostIds: async (_user: string, ids: number[]) => + new Set(ids.filter((id) => state.allowed.has(id))), + canAccessHost: async (_user: string, id: number) => ({ + hasAccess: state.allowed.has(id), + }), + }), + }, +})); +vi.mock("../../utils/audit-logger.js", () => ({ logAudit: async () => {} })); + +import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js"; +import { invalidatePluginPermissionCache } from "../../plugins/permissions.js"; +import { runAsActor } from "../../plugins/actor.js"; +import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest"; + +function contextFor(capabilities: string[]) { + const manifest = { + id: "demo", + name: "demo", + version: "1.0.0", + description: "", + author: { name: "test" }, + license: "MIT", + category: "Productivity", + engine: { termix: ">=26.10.3", api: "1" }, + capabilities, + } as PluginManifest; + return createPluginContext( + manifest, + createPluginHandle("demo", { activate: () => {} }), + ); +} + +const row = (id: number, userId: string) => ({ + id, + userId, + ip: "127.0.0.1", + port: 22, + connectionType: "ssh", + jumpHosts: null, + statusCheckEnabled: true, + statusCheckInterval: null, +}); + +let server: net.Server; +let port = 0; + +beforeEach(async () => { + grants.clear(); + invalidatePluginPermissionCache(); + state.owned = [row(1, "alice")]; + state.shared = [row(2, "bob"), row(3, "bob")]; + state.sharedIds = [2, 3]; + state.allowed = new Set([2]); + server = net.createServer((socket) => socket.destroy()); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + port = (server.address() as AddressInfo).port; +}); + +afterEach(() => { + server.close(); +}); + +describe("ctx.network.tcpProbe", () => { + it("refuses without network:probe", async () => { + grants.set("demo", []); + await expect( + contextFor([]).network.tcpProbe("127.0.0.1", port), + ).rejects.toThrow(/network:probe/); + }); + + it("reports an open port and a closed one", async () => { + grants.set("demo", ["network:probe"]); + const ctx = contextFor(["network:probe"]); + expect(await ctx.network.tcpProbe("127.0.0.1", port)).toBe(true); + await new Promise((resolve) => server.close(() => resolve())); + expect( + await ctx.network.tcpProbe("127.0.0.1", port, { timeoutMs: 500 }), + ).toBe(false); + }); + + it("rejects a bad port", async () => { + grants.set("demo", ["network:probe"]); + await expect( + contextFor(["network:probe"]).network.tcpProbe("127.0.0.1", 0), + ).rejects.toThrow(/port/); + }); +}); + +describe("ctx.hosts.probeTargets", () => { + it("refuses without hosts:read", async () => { + grants.set("demo", []); + await expect(contextFor([]).hosts.probeTargets()).rejects.toThrow( + /hosts:read/, + ); + }); + + it("needs an acting user", async () => { + grants.set("demo", ["hosts:read"]); + await expect( + contextFor(["hosts:read"]).hosts.probeTargets(), + ).rejects.toThrow(/acting user/); + }); + + it("returns own hosts and only shared hosts the actor may connect to", async () => { + grants.set("demo", ["hosts:read"]); + const ctx = contextFor(["hosts:read"]); + const targets = await runAsActor("alice", "request", () => + ctx.hosts.probeTargets(), + ); + expect(targets.map((target) => target.id)).toEqual([1, 2]); + expect(targets[1].ownerUserId).toBe("bob"); + }); +}); + +describe("ctx.ssh.probe", () => { + it("refuses without ssh:connect", async () => { + grants.set("demo", []); + await expect(contextFor([]).ssh.probe(1)).rejects.toThrow(/ssh:connect/); + }); + + it("answers false for a host the actor cannot connect to", async () => { + grants.set("demo", ["ssh:connect"]); + const ctx = contextFor(["ssh:connect"]); + expect(await runAsActor("alice", "request", () => ctx.ssh.probe(3))).toBe( + false, + ); + }); + + it("probes a reachable host directly", async () => { + grants.set("demo", ["ssh:connect"]); + const ctx = contextFor(["ssh:connect"]); + expect( + await runAsActor("alice", "request", () => ctx.ssh.probe(2, { port })), + ).toBe(true); + }); +}); diff --git a/src/backend/tests/plugins/ctx-ssh-auth.test.ts b/src/backend/tests/plugins/ctx-ssh-auth.test.ts index 205a47c62..88e46f36c 100644 --- a/src/backend/tests/plugins/ctx-ssh-auth.test.ts +++ b/src/backend/tests/plugins/ctx-ssh-auth.test.ts @@ -104,8 +104,8 @@ vi.mock("../../hosts/host-resolver.js", () => ({ }), resolveHostBySyncId: async () => null, })); -vi.mock("../../hosts/status/host-status-service.js", () => ({ - hostStatusService: { +vi.mock("../../plugins/host-status-provider.js", () => ({ + hostStatus: { reportLogin: (hostId: number, outcome: unknown) => h.logins.push({ hostId, outcome }), }, diff --git a/src/backend/tests/plugins/ctx-ssh-exec.test.ts b/src/backend/tests/plugins/ctx-ssh-exec.test.ts new file mode 100644 index 000000000..ba03753a9 --- /dev/null +++ b/src/backend/tests/plugins/ctx-ssh-exec.test.ts @@ -0,0 +1,104 @@ +import { EventEmitter } from "node:events"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { runExec, type ExecClient } from "../../plugins/ctx-ssh-exec.js"; + +function fakeClient( + script: (stream: FakeStream) => void, + execError?: Error, +): ExecClient & { commands: string[] } { + const commands: string[] = []; + return { + commands, + exec: (command, callback) => { + commands.push(command); + if (execError) return callback(execError, undefined as never); + const stream = new FakeStream(); + callback(undefined, stream as never); + script(stream); + }, + }; +} + +class FakeStream extends EventEmitter { + stderr = new EventEmitter(); + written: string[] = []; + closed = false; + write(data: string) { + this.written.push(data); + } + end(data?: string) { + if (data !== undefined) this.written.push(data); + } + close() { + this.closed = true; + } +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("runExec", () => { + it("collects output and the exit code", async () => { + const client = fakeClient((stream) => { + stream.emit("data", Buffer.from("out")); + stream.stderr.emit("data", Buffer.from("err")); + stream.emit("close", 3); + }); + await expect(runExec(client, "echo hi")).resolves.toEqual({ + stdout: "out", + stderr: "err", + code: 3, + truncated: false, + timedOut: false, + }); + expect(client.commands).toEqual(["echo hi"]); + }); + + it("keeps only maxOutputBytes and says so", async () => { + const client = fakeClient((stream) => { + stream.emit("data", Buffer.alloc(3000, "a")); + stream.emit("close", 0); + }); + const result = await runExec(client, "yes", { maxOutputBytes: 1024 }); + expect(result.stdout).toHaveLength(1024); + expect(result.truncated).toBe(true); + }); + + it("writes stdin and closes it", async () => { + let seen: FakeStream | undefined; + const client = fakeClient((stream) => { + seen = stream; + stream.emit("close", 0); + }); + await runExec(client, "cat >> f", { stdin: "key\n" }); + expect(seen?.written).toEqual(["key\n"]); + }); + + it("stops a command that runs past the time limit", async () => { + vi.useFakeTimers(); + let seen: FakeStream | undefined; + const client = fakeClient((stream) => { + seen = stream; + }); + const pending = runExec(client, "sleep 999", { timeoutMs: 2000 }); + await vi.advanceTimersByTimeAsync(2100); + const result = await pending; + expect(result).toMatchObject({ code: null, timedOut: true }); + expect(seen?.closed).toBe(true); + }); + + it("rejects when the exec itself fails", async () => { + const client = fakeClient(() => {}, new Error("channel open failure")); + await expect(runExec(client, "ls")).rejects.toThrow(/channel open/); + }); + + it("rejects an empty or oversized command before running it", async () => { + const client = fakeClient(() => {}); + await expect(runExec(client, " ")).rejects.toThrow(/required/); + await expect(runExec(client, "x".repeat(70_000))).rejects.toThrow( + /too long/, + ); + expect(client.commands).toEqual([]); + }); +}); diff --git a/src/backend/tests/plugins/desktop-local-fs.test.ts b/src/backend/tests/plugins/desktop-local-fs.test.ts new file mode 100644 index 000000000..d04d96087 --- /dev/null +++ b/src/backend/tests/plugins/desktop-local-fs.test.ts @@ -0,0 +1,483 @@ +/** + * The local file helpers behind ctx.desktop.local.fs. Ported from the old + * Electron local-files tests: path containment, link refusal, publish and + * replace semantics, and the exclusive rename. + */ + +import crypto from "node:crypto"; +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + assertWithinRoot, + createEmptyFile, + defaultPublishFs, + ensureDirectory, + existingPaths, + listDirectory, + makeDirectory, + openDownload, + prepareDownloadPath, + publishDownload, + renameEntry, + renameLocalPath, + walkPaths, + type PublishFs, +} from "../../plugins/desktop-local/local-fs.js"; + +let root: string; +beforeEach(async () => { + root = await fsp.mkdtemp(path.join(os.tmpdir(), "termix-desktop-local-")); +}); +afterEach(() => fsp.rm(root, { recursive: true, force: true })); + +function windowsLikeFs( + overrides: Partial = {}, +): PublishFs & { renames: Array<[string, string]> } { + const renames: Array<[string, string]> = []; + return { + ...defaultPublishFs, + renames, + rename: async (from, to) => { + renames.push([from, to]); + if (fs.existsSync(to)) { + throw Object.assign(new Error("EEXIST: file already exists, rename"), { + code: "EEXIST", + }); + } + await defaultPublishFs.rename(from, to); + }, + ...overrides, + }; +} + +describe("download destination containment", () => { + const win = path.win32; + const winRoot = "C:\\Downloads\\selected"; + + it("accepts ordinary nested destinations on Windows", () => { + expect( + assertWithinRoot(winRoot, "C:\\Downloads\\selected\\a.txt", win), + ).toBe("C:\\Downloads\\selected\\a.txt"); + expect( + assertWithinRoot( + winRoot, + "C:\\Downloads\\selected\\docs\\2026\\report.pdf", + win, + ), + ).toBe("C:\\Downloads\\selected\\docs\\2026\\report.pdf"); + expect( + assertWithinRoot(winRoot, "c:\\downloads\\SELECTED\\b.txt", win), + ).toBe("c:\\downloads\\SELECTED\\b.txt"); + }); + + it("rejects backslash traversal that normalises out of the root on Windows", () => { + expect(() => + assertWithinRoot( + winRoot, + "C:\\Downloads\\selected\\..\\outside.txt", + win, + ), + ).toThrow(/outside the selected folder/); + expect(() => + assertWithinRoot( + winRoot, + "C:\\Downloads\\selected\\sub\\..\\..\\..\\x.txt", + win, + ), + ).toThrow(/outside the selected folder/); + expect(() => assertWithinRoot(winRoot, winRoot, win)).toThrow( + /outside the selected folder/, + ); + }); + + it("rejects absolute, drive-qualified and UNC destinations on Windows", () => { + for (const dest of [ + "C:\\Windows\\evil.dll", + "D:\\Downloads\\selected\\x.txt", + "\\\\server\\share\\x.txt", + "C:\\Downloads\\selected-other\\x.txt", + ]) { + expect(() => assertWithinRoot(winRoot, dest, win)).toThrow( + /outside the selected folder/, + ); + } + }); + + it("requires a root and rejects POSIX traversal too", () => { + expect(() => assertWithinRoot("", "/tmp/x", path.posix)).toThrow( + /download folder is required/, + ); + expect(() => + assertWithinRoot("/home/max/dl", "/home/max/dl/../x", path.posix), + ).toThrow(/outside the selected folder/); + expect( + assertWithinRoot("/home/max/dl", "/home/max/dl/..\\x", path.posix), + ).toBe("/home/max/dl/..\\x"); + }); + + it("refuses an escaping download before any disk write", async () => { + const selected = path.join(root, "selected"); + await fsp.mkdir(selected); + await expect( + openDownload({ + rootPath: selected, + destPath: path.join(selected, "..", "outside.txt"), + transferId: "e1", + }), + ).rejects.toMatchObject({ code: "EINVAL" }); + await expect( + ensureDirectory(path.join(selected, "..", "escaped-dir"), selected), + ).rejects.toMatchObject({ code: "EINVAL" }); + await expect( + openDownload({ + rootPath: "", + destPath: path.join(selected, "ok.bin"), + transferId: "e2", + }), + ).rejects.toMatchObject({ code: "EINVAL" }); + expect(await fsp.readdir(root)).toEqual(["selected"]); + expect(await fsp.readdir(selected)).toEqual([]); + }); +}); + +describe("links inside a download tree", () => { + it("rejects existing linked parents for downloads and directory creation", async () => { + const selected = path.join(root, "selected"); + const outside = path.join(root, "outside"); + await fsp.mkdir(selected); + await fsp.mkdir(outside); + await fsp.symlink(outside, path.join(selected, "linked"), "junction"); + + await expect( + openDownload({ + rootPath: selected, + destPath: path.join(selected, "linked", "file.bin"), + transferId: "l1", + }), + ).rejects.toThrow(/cannot follow links/); + await expect( + ensureDirectory(path.join(selected, "linked", "new"), selected), + ).rejects.toThrow(/cannot follow links/); + expect(await fsp.readdir(outside)).toEqual([]); + }); + + it("allows an explicitly selected linked root", async () => { + const actual = path.join(root, "actual"); + const selected = path.join(root, "selected"); + await fsp.mkdir(actual); + await fsp.symlink(actual, selected, "junction"); + + const download = await openDownload({ + rootPath: selected, + destPath: path.join(selected, "nested", "file.bin"), + transferId: "l2", + }); + download.stream.write("data"); + await download.publish(); + expect( + await fsp.readFile(path.join(actual, "nested", "file.bin"), "utf8"), + ).toBe("data"); + }); + + it("rejects a destination parent replaced by a link before publish", async () => { + const selected = path.join(root, "selected"); + const nested = path.join(selected, "nested"); + const outside = path.join(root, "outside"); + await fsp.mkdir(nested, { recursive: true }); + await fsp.mkdir(outside); + + const download = await openDownload({ + rootPath: selected, + destPath: path.join(nested, "file.bin"), + transferId: "l3", + }); + download.stream.write("data"); + await fsp.rename(nested, path.join(selected, "original")); + await fsp.symlink(outside, nested, "junction"); + + await expect(download.publish()).rejects.toThrow(/cannot follow links/); + expect(await fsp.readdir(outside)).toEqual([]); + }); + + it("prepareDownloadPath refuses a root that is not a directory", async () => { + const file = path.join(root, "file"); + await fsp.writeFile(file, "x"); + await expect( + prepareDownloadPath(file, path.join(file, "child")), + ).rejects.toBeTruthy(); + }); +}); + +describe("downloads", () => { + it("refuses to replace an existing file by default", async () => { + const dest = path.join(root, "keep.bin"); + await fsp.writeFile(dest, "original"); + await expect( + openDownload({ rootPath: root, destPath: dest, transferId: "d1" }), + ).rejects.toMatchObject({ code: "EEXIST" }); + expect(await fsp.readFile(dest, "utf8")).toBe("original"); + expect(await fsp.readdir(root)).toEqual(["keep.bin"]); + }); + + it("replaces an existing file only when overwrite is requested", async () => { + const dest = path.join(root, "replace.bin"); + await fsp.writeFile(dest, "original"); + const download = await openDownload({ + rootPath: root, + destPath: dest, + transferId: "d2", + overwrite: true, + }); + download.stream.write("fresh"); + await download.publish(); + expect(await fsp.readFile(dest, "utf8")).toBe("fresh"); + expect(await fsp.readdir(root)).toEqual(["replace.bin"]); + }); + + it("refuses to publish over a file that appeared while it ran", async () => { + const dest = path.join(root, "race.bin"); + const download = await openDownload({ + rootPath: root, + destPath: dest, + transferId: "d3", + }); + download.stream.write("mine"); + await fsp.writeFile(dest, "someone else wrote this"); + await expect(download.publish()).rejects.toMatchObject({ code: "EEXIST" }); + expect(await fsp.readFile(dest, "utf8")).toBe("someone else wrote this"); + expect(await fsp.readdir(root)).toEqual(["race.bin"]); + }); + + it("uses a transfer-unique partial and rejects a concurrent download to the same destination", async () => { + const dest = path.join(root, "same.bin"); + const first = await openDownload({ + rootPath: root, + destPath: dest, + transferId: "first", + }); + await expect( + openDownload({ rootPath: root, destPath: dest, transferId: "second" }), + ).rejects.toMatchObject({ code: "EBUSY" }); + expect( + (await fsp.readdir(root)).filter((n) => n.endsWith(".termix-part")), + ).toEqual(["same.bin.first.termix-part"]); + + first.stream.write("done"); + await first.publish(); + expect(await fsp.readdir(root)).toEqual(["same.bin"]); + + // The destination is free again once the first one finished. + const again = await openDownload({ + rootPath: root, + destPath: path.join(root, "same2.bin"), + transferId: "third", + }); + await again.abort(); + }); + + it("allows two downloads of different files side by side and aborts one cleanly", async () => { + const a = await openDownload({ + rootPath: root, + destPath: path.join(root, "a.bin"), + transferId: "a", + }); + const b = await openDownload({ + rootPath: root, + destPath: path.join(root, "b.bin"), + transferId: "b", + }); + a.stream.write("A"); + b.stream.write("B"); + await a.abort(); + await b.publish(); + expect(await fsp.readdir(root)).toEqual(["b.bin"]); + // Abort after publish is harmless. + await b.abort(); + expect(await fsp.readdir(root)).toEqual(["b.bin"]); + }); +}); + +describe("replace primitive (Windows-safe overwrite)", () => { + const seed = async (name: string, existing: string, fresh: Buffer) => { + const dest = path.join(root, name); + const partial = `${dest}.t1.termix-part`; + await fsp.writeFile(dest, existing); + await fsp.writeFile(partial, fresh); + return { dest, partial }; + }; + + it("replaces an existing file without renaming onto an occupied name", async () => { + const fresh = crypto.randomBytes(4096); + const { dest, partial } = await seed("swap.bin", "original", fresh); + const io = windowsLikeFs(); + + await publishDownload(partial, dest, true, "t1", io); + + expect((await fsp.readFile(dest)).equals(fresh)).toBe(true); + expect(await fsp.readdir(root)).toEqual(["swap.bin"]); + expect(io.renames.length).toBeGreaterThan(0); + for (const [, to] of io.renames) { + expect(to === dest || to.endsWith(".termix-replaced")).toBe(true); + } + }); + + it("restores the original byte for byte when publishing fails", async () => { + const fresh = crypto.randomBytes(1024); + const { dest, partial } = await seed("restore.bin", "original", fresh); + const denied = () => + Promise.reject( + Object.assign(new Error("EACCES: permission denied"), { + code: "EACCES", + }), + ); + const io = windowsLikeFs({ link: denied, copyFileExcl: denied }); + + await expect( + publishDownload(partial, dest, true, "t1", io), + ).rejects.toMatchObject({ code: "EACCES" }); + + expect(await fsp.readFile(dest, "utf8")).toBe("original"); + expect((await fsp.readdir(root)).sort()).toEqual( + ["restore.bin", "restore.bin.t1.termix-part"].sort(), + ); + }); + + it("reports EBUSY and touches nothing when the file cannot be moved aside", async () => { + const fresh = crypto.randomBytes(1024); + const { dest, partial } = await seed("locked.bin", "original", fresh); + const io = windowsLikeFs({ + rename: () => + Promise.reject( + Object.assign(new Error("EPERM: operation not permitted"), { + code: "EPERM", + }), + ), + }); + + await expect( + publishDownload(partial, dest, true, "t1", io), + ).rejects.toMatchObject({ code: "EBUSY" }); + + expect(await fsp.readFile(dest, "utf8")).toBe("original"); + expect((await fsp.readFile(partial)).equals(fresh)).toBe(true); + }); + + it("refuses to replace a folder with a file", async () => { + const dest = path.join(root, "folder"); + await fsp.mkdir(dest); + await fsp.writeFile(path.join(dest, "inner.txt"), "keep"); + const partial = `${dest}.t1.termix-part`; + await fsp.writeFile(partial, "new"); + + await expect( + publishDownload(partial, dest, true, "t1", windowsLikeFs()), + ).rejects.toMatchObject({ code: "EISDIR" }); + expect(await fsp.readFile(path.join(dest, "inner.txt"), "utf8")).toBe( + "keep", + ); + }); + + it("falls back to an exclusive publish when the file to replace is gone", async () => { + const dest = path.join(root, "gone.bin"); + const partial = `${dest}.t1.termix-part`; + await fsp.writeFile(partial, "new"); + + await publishDownload(partial, dest, true, "t1", windowsLikeFs()); + expect(await fsp.readFile(dest, "utf8")).toBe("new"); + expect(await fsp.readdir(root)).toEqual(["gone.bin"]); + }); +}); + +describe("local rename", () => { + it("preserves both files on a name collision", async () => { + const a = path.join(root, "a"); + const b = path.join(root, "b"); + await fsp.writeFile(a, "source"); + await fsp.writeFile(b, "destination"); + await expect(renameLocalPath(a, b)).rejects.toThrow(/already exists/); + expect(await fsp.readFile(a, "utf8")).toBe("source"); + expect(await fsp.readFile(b, "utf8")).toBe("destination"); + }); + + it("refuses to replace even an empty destination directory", async () => { + const a = path.join(root, "a"); + const b = path.join(root, "b"); + await fsp.mkdir(a); + await fsp.mkdir(b); + await expect(renameLocalPath(a, b)).rejects.toThrow(/already exists/); + expect((await fsp.readdir(root)).sort()).toEqual(["a", "b"]); + }); + + it("renames a file and handles an unchanged name", async () => { + const a = path.join(root, "a"); + const b = path.join(root, "b"); + await fsp.writeFile(a, "source"); + await renameLocalPath(a, a); + await renameLocalPath(a, b); + expect(fs.existsSync(a)).toBe(false); + expect(await fsp.readFile(b, "utf8")).toBe("source"); + }); + + it("renameEntry only takes a plain name in the same folder", async () => { + const a = path.join(root, "a"); + await fsp.writeFile(a, "x"); + await expect(renameEntry(a, "../escape")).rejects.toThrow(/Invalid/); + await expect(renameEntry(a, "")).rejects.toThrow(/Invalid/); + expect(await renameEntry(a, "c")).toBe(path.join(root, "c")); + }); +}); + +describe("listing and creating", () => { + it("lists entries with type, size and hidden flag", async () => { + await fsp.mkdir(path.join(root, "dir")); + await fsp.writeFile(path.join(root, "f.txt"), "abc"); + await fsp.writeFile(path.join(root, ".hidden"), ""); + const listing = await listDirectory(root); + const byName = Object.fromEntries(listing.entries.map((e) => [e.name, e])); + expect(byName["dir"].type).toBe("directory"); + expect(byName["f.txt"]).toMatchObject({ type: "file", size: 3 }); + expect(byName[".hidden"].hidden).toBe(true); + expect(listing.parent).toBe(path.dirname(root)); + }); + + it("rejects relative paths", async () => { + await expect(listDirectory("relative")).rejects.toThrow(/absolute/); + }); + + it("creates folders and files without replacing anything", async () => { + expect(await makeDirectory(root, "new")).toBe(path.join(root, "new")); + await expect(makeDirectory(root, "new")).rejects.toBeTruthy(); + expect(await createEmptyFile(root, "a.txt")).toBe(path.join(root, "a.txt")); + await fsp.writeFile(path.join(root, "a.txt"), "keep"); + await expect(createEmptyFile(root, "a.txt")).rejects.toBeTruthy(); + expect(await fsp.readFile(path.join(root, "a.txt"), "utf8")).toBe("keep"); + }); + + it("reports which paths exist", async () => { + await fsp.writeFile(path.join(root, "yes"), ""); + expect( + await existingPaths([path.join(root, "yes"), path.join(root, "no")]), + ).toEqual([path.join(root, "yes")]); + }); + + it("walks dropped paths into a flat list with empty folders", async () => { + await fsp.mkdir(path.join(root, "tree", "empty"), { recursive: true }); + await fsp.mkdir(path.join(root, "tree", "sub")); + await fsp.writeFile(path.join(root, "tree", "sub", "a.txt"), "12345"); + await fsp.writeFile(path.join(root, "single.txt"), "xy"); + + const result = await walkPaths([ + path.join(root, "tree"), + path.join(root, "single.txt"), + ]); + expect(result.files.map((f) => f.relativePath).sort()).toEqual([ + "single.txt", + "tree/sub/a.txt", + ]); + expect(result.emptyDirs).toEqual(["tree/empty"]); + expect(result.totalBytes).toBe(7); + }); +}); diff --git a/src/backend/tests/electron/local-shell.test.ts b/src/backend/tests/plugins/desktop-local-pty.test.ts similarity index 73% rename from src/backend/tests/electron/local-shell.test.ts rename to src/backend/tests/plugins/desktop-local-pty.test.ts index 32a44a6fc..b3f3149c4 100644 --- a/src/backend/tests/electron/local-shell.test.ts +++ b/src/backend/tests/plugins/desktop-local-pty.test.ts @@ -1,23 +1,9 @@ -import { createRequire } from "node:module"; +import path from "node:path"; import { describe, expect, it } from "vitest"; - -const require = createRequire(import.meta.url); -const { resolveLocalShell, repairSpawnHelpers } = - require("../../../../electron/local-shell.cjs") as { - resolveLocalShell: ( - platform: NodeJS.Platform, - requestedShell?: string, - env?: NodeJS.ProcessEnv, - exists?: (file: string) => boolean, - ) => { file: string; args: string[] }; - repairSpawnHelpers: ( - ptyDir: string, - fsImpl: { - statSync: (file: string) => { mode: number }; - chmodSync: (file: string, mode: number) => void; - }, - ) => number; - }; +import { + repairSpawnHelpers, + resolveLocalShell, +} from "../../plugins/desktop-local/local-pty.js"; describe("resolveLocalShell", () => { it("starts the default WSL distribution without PowerShell arguments", () => { @@ -74,9 +60,9 @@ describe("repairSpawnHelpers", () => { "/Applications/Termix.app/Contents/Resources/app.asar/node_modules/node-pty"; const unpacked = dir.replace("app.asar", "app.asar.unpacked"); const release = `${unpacked}/build/Release/spawn-helper`; - modes.set(release.split("/").join(require("node:path").sep), 0o644); + modes.set(release.split("/").join(path.sep), 0o644); modes.set(release, 0o644); - expect(repairSpawnHelpers(dir, fsImpl)).toBe(1); + expect(repairSpawnHelpers(dir, fsImpl as never)).toBe(1); expect(chmods[0][1] & 0o111).not.toBe(0); expect(chmods[0][0]).toContain("app.asar.unpacked"); }); @@ -88,11 +74,11 @@ describe("repairSpawnHelpers", () => { const fsImpl = { statSync: () => ({ mode: 0o644 }), chmodSync: (file: string) => { - chmods.push(file.split(require("node:path").sep).join("/")); + chmods.push(file.split(path.sep).join("/")); }, }; const dir = `/Applications/Termix.app/Contents/Resources/${asar}/node_modules/node-pty`; - repairSpawnHelpers(dir, fsImpl); + repairSpawnHelpers(dir, fsImpl as never); expect(chmods[0]).toContain(`/${asar}.unpacked/node_modules/node-pty/`); }, ); diff --git a/src/backend/tests/plugins/host-status-provider.test.ts b/src/backend/tests/plugins/host-status-provider.test.ts new file mode 100644 index 000000000..e8cc34df7 --- /dev/null +++ b/src/backend/tests/plugins/host-status-provider.test.ts @@ -0,0 +1,99 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + hostStatus, + provideHostStatus, + registerBuiltinHostStatusProvider, + type HostStatusProvider, +} from "../../plugins/host-status-provider.js"; + +function fakeProvider(label: string): HostStatusProvider & { + ports: string[]; +} { + const ports: string[] = []; + return { + ports, + start: vi.fn(), + stop: vi.fn(), + get: (hostId) => ({ status: "online", lastChecked: `${label}:${hostId}` }), + statusesFor: async () => new Map(), + refresh: vi.fn(async () => {}), + check: async (hostId) => ({ + status: "offline", + lastChecked: `${label}:${hostId}`, + }), + reportLogin: vi.fn(), + registerPort: (type) => { + ports.push(`+${type}`); + return () => ports.push(`-${type}`); + }, + getIntervalSeconds: async () => 45, + setIntervalSeconds: vi.fn(async () => {}), + }; +} + +beforeEach(() => { + hostStatus.stop(); + registerBuiltinHostStatusProvider(null); +}); + +describe("host status provider", () => { + it("answers unknown when nothing provides status", async () => { + expect(hostStatus.get(1)).toBeNull(); + expect((await hostStatus.statusesFor("u", null)).size).toBe(0); + expect(await hostStatus.check(1)).toBeNull(); + expect(await hostStatus.getIntervalSeconds()).toBe(30); + }); + + it("uses the built-in until a plugin provides", () => { + const builtin = fakeProvider("builtin"); + registerBuiltinHostStatusProvider(builtin); + expect(hostStatus.get(1)?.lastChecked).toBe("builtin:1"); + + const plugin = fakeProvider("plugin"); + const remove = provideHostStatus("host-status", plugin); + expect(hostStatus.get(1)?.lastChecked).toBe("plugin:1"); + + remove(); + expect(hostStatus.get(1)?.lastChecked).toBe("builtin:1"); + }); + + it("refuses a second provider", () => { + const remove = provideHostStatus("one", fakeProvider("one")); + expect(() => provideHostStatus("two", fakeProvider("two"))).toThrow( + /already provided by plugin "one"/, + ); + remove(); + }); + + it("hands lifecycle over when the provider changes", () => { + const builtin = fakeProvider("builtin"); + registerBuiltinHostStatusProvider(builtin); + hostStatus.start(); + expect(builtin.start).toHaveBeenCalledTimes(1); + + const plugin = fakeProvider("plugin"); + const remove = provideHostStatus("host-status", plugin); + expect(builtin.stop).toHaveBeenCalledTimes(1); + expect(plugin.start).toHaveBeenCalledTimes(1); + + remove(); + expect(plugin.stop).toHaveBeenCalledTimes(1); + expect(builtin.start).toHaveBeenCalledTimes(2); + }); + + it("keeps registered ports across a provider change", () => { + const builtin = fakeProvider("builtin"); + registerBuiltinHostStatusProvider(builtin); + const drop = hostStatus.registerPort("rdp", () => 3389); + expect(builtin.ports).toEqual(["+rdp"]); + + const plugin = fakeProvider("plugin"); + const remove = provideHostStatus("host-status", plugin); + expect(builtin.ports).toEqual(["+rdp", "-rdp"]); + expect(plugin.ports).toEqual(["+rdp"]); + + drop(); + expect(plugin.ports).toEqual(["+rdp", "-rdp"]); + remove(); + }); +}); diff --git a/src/backend/tests/plugins/manifest.test.ts b/src/backend/tests/plugins/manifest.test.ts index b56c09347..5a3ed608e 100644 --- a/src/backend/tests/plugins/manifest.test.ts +++ b/src/backend/tests/plugins/manifest.test.ts @@ -1078,3 +1078,25 @@ describe("layouts and themes", () => { expect(has('duplicates "dusk"')).toBe(true); }); }); + +describe("manifest runtime", () => { + it("accepts a desktop runtime plugin", () => { + expect( + validateManifest( + base({ runtime: "desktop", capabilities: ["desktop:local"] }), + ), + ).toEqual([]); + }); + + it("rejects an unknown runtime", () => { + expect(validateManifest(base({ runtime: "cloud" })).join()).toMatch( + /runtime/, + ); + }); + + it("rejects a desktop runtime that is mirrored or server only", () => { + expect( + validateManifest(base({ runtime: "desktop", desktop: "mirror" })).join(), + ).toMatch(/local/); + }); +}); diff --git a/src/backend/tests/plugins/migrations.test.ts b/src/backend/tests/plugins/migrations.test.ts index 79afde684..b36c109ab 100644 --- a/src/backend/tests/plugins/migrations.test.ts +++ b/src/backend/tests/plugins/migrations.test.ts @@ -200,6 +200,10 @@ describe("assertOwnedTables", () => { const owners = Object.entries(LEGACY_TABLE_OWNERS); expect(new Set(owners.map(([table]) => table)).size).toBe(owners.length); }); + + it("lets the homepage plugin adopt recent_activity", () => { + expect(LEGACY_TABLE_OWNERS.recent_activity).toBe("homepage"); + }); }); describe("applyPluginMigrations", () => { diff --git a/src/backend/tests/hosts/status/jump-probe-pool.test.ts b/src/backend/tests/plugins/probe/jump-probe-pool.test.ts similarity index 98% rename from src/backend/tests/hosts/status/jump-probe-pool.test.ts rename to src/backend/tests/plugins/probe/jump-probe-pool.test.ts index 8d5164834..77919f10b 100644 --- a/src/backend/tests/hosts/status/jump-probe-pool.test.ts +++ b/src/backend/tests/plugins/probe/jump-probe-pool.test.ts @@ -1,6 +1,6 @@ import { EventEmitter } from "node:events"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { JumpProbePool } from "../../../hosts/status/jump-probe-pool.js"; +import { JumpProbePool } from "../../../plugins/probe/jump-probe-pool.js"; function fakeClient(reachable = true) { const client = Object.assign(new EventEmitter(), { diff --git a/src/backend/tests/hosts/status/tcp-ping.test.ts b/src/backend/tests/plugins/probe/tcp-ping.test.ts similarity index 95% rename from src/backend/tests/hosts/status/tcp-ping.test.ts rename to src/backend/tests/plugins/probe/tcp-ping.test.ts index c9b69f56b..97d3dcf88 100644 --- a/src/backend/tests/hosts/status/tcp-ping.test.ts +++ b/src/backend/tests/plugins/probe/tcp-ping.test.ts @@ -1,6 +1,6 @@ import type { Client } from "ssh2"; import { describe, expect, it, vi } from "vitest"; -import { tcpPingThroughJumpHost } from "../../../hosts/status/tcp-ping.js"; +import { tcpPingThroughJumpHost } from "../../../plugins/probe/tcp-ping.js"; describe("tcpPingThroughJumpHost", () => { it("reports the final destination online when forwarding succeeds", async () => { diff --git a/src/backend/tests/plugins/sdk-ssh-keys.test.ts b/src/backend/tests/plugins/sdk-ssh-keys.test.ts new file mode 100644 index 000000000..c49c59113 --- /dev/null +++ b/src/backend/tests/plugins/sdk-ssh-keys.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { + detectSshKeyType, + generateSshKeyPair, + publicKeyFromPrivate, + validateSshKeyPair, +} from "../../../../packages/plugin-sdk/src/ssh-keys.js"; + +describe("plugin SDK ssh-keys", () => { + it("generates a usable ed25519 pair", () => { + const pair = generateSshKeyPair("ssh-ed25519", { comment: "test" }); + expect(pair.publicKey).toMatch(/^ssh-ed25519 /); + expect(detectSshKeyType(pair.privateKey)).toBe("ssh-ed25519"); + expect(detectSshKeyType(pair.publicKey)).toBe("ssh-ed25519"); + expect(validateSshKeyPair(pair.privateKey, pair.publicKey)).toEqual({ + isValid: true, + }); + }); + + it("derives the public key and ignores the comment when matching", () => { + const pair = generateSshKeyPair("ecdsa-sha2-nistp256"); + const derived = publicKeyFromPrivate(pair.privateKey); + expect(derived).toMatch(/^ecdsa-sha2-nistp256 /); + expect( + validateSshKeyPair(pair.privateKey, `${derived} someone@host`).isValid, + ).toBe(true); + }); + + it("generates RSA with a minimum size", () => { + expect(() => generateSshKeyPair("ssh-rsa", { bits: 512 })).toThrow(/2048/); + const pair = generateSshKeyPair("ssh-rsa", { bits: 2048 }); + expect(detectSshKeyType(pair.privateKey)).toBe("ssh-rsa"); + }); + + it("works with a passphrase and fails without it", () => { + const pair = generateSshKeyPair("ssh-ed25519", { passphrase: "secret" }); + expect(publicKeyFromPrivate(pair.privateKey)).toBeNull(); + expect(publicKeyFromPrivate(pair.privateKey, "secret")).toBe( + pair.publicKey.split(" ").slice(0, 2).join(" "), + ); + }); + + it("flags a pair that does not belong together", () => { + const a = generateSshKeyPair("ssh-ed25519"); + const b = generateSshKeyPair("ssh-ed25519"); + const result = validateSshKeyPair(a.privateKey, b.publicKey); + expect(result.isValid).toBe(false); + expect(result.error).toMatch(/match/); + }); + + it("reports garbage as unreadable", () => { + expect(detectSshKeyType("not a key")).toBeNull(); + expect(publicKeyFromPrivate("not a key")).toBeNull(); + expect(validateSshKeyPair("x", "y").isValid).toBe(false); + }); + + it("rejects an unknown key type", () => { + expect(() => generateSshKeyPair("ssh-dss" as never)).toThrow(/Unsupported/); + }); +}); diff --git a/src/backend/tests/plugins/security/ctx-guards.test.ts b/src/backend/tests/plugins/security/ctx-guards.test.ts index b0838c38c..bd189dbee 100644 --- a/src/backend/tests/plugins/security/ctx-guards.test.ts +++ b/src/backend/tests/plugins/security/ctx-guards.test.ts @@ -79,6 +79,7 @@ const UNGATED: Record = { "audited as plugin_as_user (identical successful repeats coalesced for 10 minutes)", currentActor: "reads the actor, cannot set it", "desktop.available": "a yes/no about the environment", + "desktop.local.available": "a yes/no about the environment", "http.baseUrl": "reads the request's public origin", "ssh.poolKey": "a string built from a host the plugin already holds", "ssh.dropPooled": "closes only this plugin's own pooled connections", @@ -162,7 +163,15 @@ describe("every privileged ctx member is guarded", () => { let caught: unknown; await runAsActor(ACTOR, "request", async () => { try { - await member.fn(...PLACEHOLDER_ARGS); + const result = await member.fn(...PLACEHOLDER_ARGS); + // An async generator only runs its guard on the first next(). + if ( + result && + typeof (result as AsyncGenerator).next === "function" && + Symbol.asyncIterator in (result as object) + ) { + await (result as AsyncGenerator).next(); + } } catch (error) { caught = error; } diff --git a/src/backend/tests/upgrade/audit-forwarding-migration.test.ts b/src/backend/tests/upgrade/audit-forwarding-migration.test.ts new file mode 100644 index 000000000..3f57a2671 --- /dev/null +++ b/src/backend/tests/upgrade/audit-forwarding-migration.test.ts @@ -0,0 +1,144 @@ +/** + * The audit forwarding settings migration. + * + * An upgrade must keep forwarding working: the collector URL and token have to + * reach the audit-tools plugin's settings (token encrypted) and be gone from + * the core settings table. Running it twice must not clobber anything. + */ + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +interface Row { + scope: string; + scopeId: string | null; + key: string; + value: string | null; + encrypted: boolean; +} + +const state = vi.hoisted(() => ({ + pluginRows: [] as Row[], + core: new Map(), + pluginInstalled: true, +})); + +vi.mock("../../database/repositories/factory.js", () => ({ + createCurrentPluginRepository: () => ({ + findById: async (id: string) => + state.pluginInstalled && id === "audit-tools" ? { id } : null, + }), + createCurrentPluginSettingsRepository: () => ({ + get: async ( + _pluginId: string, + scope: string, + scopeId: string | null, + key: string, + ) => + state.pluginRows.find( + (row) => + row.scope === scope && row.scopeId === scopeId && row.key === key, + ) ?? null, + set: async ( + _pluginId: string, + scope: string, + scopeId: string | null, + key: string, + value: string | null, + encrypted = false, + ) => { + state.pluginRows = state.pluginRows.filter( + (row) => !(row.scope === scope && row.key === key), + ); + state.pluginRows.push({ scope, scopeId, key, value, encrypted }); + }, + }), + createCurrentSettingsRepository: () => ({ + get: async (key: string) => state.core.get(key) ?? null, + delete: async (key: string) => { + state.core.delete(key); + }, + }), +})); + +vi.mock("../../utils/system-secret-crypto.js", () => ({ + encryptSystemSecret: async (value: string) => `enc(${value})`, +})); + +import { runAuditForwardingMigration } from "../../upgrade/audit-forwarding-migration.js"; + +const row = (key: string) => + state.pluginRows.find((entry) => entry.key === key); + +beforeEach(() => { + state.pluginRows = []; + state.core = new Map(); + state.pluginInstalled = true; +}); + +describe("audit forwarding migration", () => { + it("does nothing until the plugin exists", async () => { + state.pluginInstalled = false; + state.core.set("audit_log_forward_url", "https://siem.test/in"); + + expect(await runAuditForwardingMigration()).toEqual({ + url: false, + token: false, + }); + expect(state.core.get("audit_log_forward_url")).toBe( + "https://siem.test/in", + ); + }); + + it("copies the url and encrypts the token, then clears core", async () => { + state.core.set("audit_log_forward_url", " https://siem.test/in "); + state.core.set("audit_log_forward_token", "secret"); + + expect(await runAuditForwardingMigration()).toEqual({ + url: true, + token: true, + }); + + expect(JSON.parse(row("url")!.value!)).toBe("https://siem.test/in"); + expect(row("url")!.encrypted).toBe(false); + expect(JSON.parse(row("token")!.value!)).toBe("enc(secret)"); + expect(row("token")!.encrypted).toBe(true); + expect(state.core.size).toBe(0); + }); + + it("is safe to run twice", async () => { + state.core.set("audit_log_forward_url", "https://siem.test/in"); + state.core.set("audit_log_forward_token", "secret"); + + await runAuditForwardingMigration(); + const again = await runAuditForwardingMigration(); + + expect(again).toEqual({ url: false, token: false }); + expect(state.pluginRows).toHaveLength(2); + }); + + it("keeps what the plugin already has", async () => { + state.pluginRows.push({ + scope: "admin", + scopeId: null, + key: "url", + value: JSON.stringify("https://new.test/in"), + encrypted: false, + }); + state.core.set("audit_log_forward_url", "https://old.test/in"); + + const result = await runAuditForwardingMigration(); + + expect(result.url).toBe(false); + expect(JSON.parse(row("url")!.value!)).toBe("https://new.test/in"); + expect(state.core.has("audit_log_forward_url")).toBe(false); + }); + + it("drops a stray token when there is no url", async () => { + state.core.set("audit_log_forward_token", "orphan"); + + await runAuditForwardingMigration(); + + expect(state.core.has("audit_log_forward_token")).toBe(false); + expect(state.pluginRows).toHaveLength(0); + }); +}); diff --git a/src/backend/tests/upgrade/host-status-settings-migration.test.ts b/src/backend/tests/upgrade/host-status-settings-migration.test.ts new file mode 100644 index 000000000..bb920cfb8 --- /dev/null +++ b/src/backend/tests/upgrade/host-status-settings-migration.test.ts @@ -0,0 +1,86 @@ +/** + * The status check interval moves into the host-status plugin once. A value + * the plugin already holds is kept, and running it twice changes nothing. + */ + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const h = vi.hoisted(() => ({ + core: new Map(), + plugin: new Map(), + installed: true, +})); + +vi.mock("../../utils/logger.js", () => { + const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; + return { databaseLogger: log }; +}); +vi.mock("../../database/repositories/factory.js", () => ({ + createCurrentPluginRepository: () => ({ + findById: async (id: string) => (h.installed ? { id } : null), + }), + createCurrentSettingsRepository: () => ({ + get: async (key: string) => h.core.get(key) ?? null, + }), + createCurrentPluginSettingsRepository: () => ({ + get: async (_p: string, _s: string, _id: string | null, key: string) => + h.plugin.has(key) ? { value: h.plugin.get(key) } : null, + set: async ( + _p: string, + _s: string, + _id: string | null, + key: string, + value: string, + ) => { + h.plugin.set(key, value); + }, + }), +})); + +const { runHostStatusSettingsMigration } = + await import("../../upgrade/host-status-settings-migration.js"); + +beforeEach(() => { + h.core.clear(); + h.plugin.clear(); + h.installed = true; +}); + +describe("host status settings migration", () => { + it("copies the interval into the plugin", async () => { + h.core.set("global_status_check_interval", "90"); + expect(await runHostStatusSettingsMigration()).toBe(90); + expect(h.plugin.get("intervalSeconds")).toBe("90"); + }); + + it("keeps a value the plugin already has", async () => { + h.core.set("global_status_check_interval", "90"); + h.plugin.set("intervalSeconds", "45"); + expect(await runHostStatusSettingsMigration()).toBeNull(); + expect(h.plugin.get("intervalSeconds")).toBe("45"); + }); + + it("does nothing twice", async () => { + h.core.set("global_status_check_interval", "120"); + await runHostStatusSettingsMigration(); + expect(await runHostStatusSettingsMigration()).toBeNull(); + expect(h.plugin.get("intervalSeconds")).toBe("120"); + }); + + it.each(["abc", "4", "7200", "1.5", ""])( + "ignores the invalid value %j", + async (value) => { + h.core.set("global_status_check_interval", value); + expect(await runHostStatusSettingsMigration()).toBeNull(); + expect(h.plugin.has("intervalSeconds")).toBe(false); + }, + ); + + it("does nothing without a stored interval or the plugin", async () => { + expect(await runHostStatusSettingsMigration()).toBeNull(); + h.core.set("global_status_check_interval", "90"); + h.installed = false; + expect(await runHostStatusSettingsMigration()).toBeNull(); + expect(h.plugin.size).toBe(0); + }); +}); diff --git a/src/backend/tests/utils/audit-export.test.ts b/src/backend/tests/utils/audit-export.test.ts deleted file mode 100644 index 9e31342e6..000000000 --- a/src/backend/tests/utils/audit-export.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - escapeCsvField, - exportFilename, - toCsv, - toNdjson, -} from "../../utils/audit-export.js"; -import type { AuditLogRecord } from "../../database/repositories/audit-log-repository.js"; - -function entry(overrides: Partial = {}): AuditLogRecord { - return { - id: 1, - userId: "u-1", - username: "alice", - action: "delete_host", - resourceType: "host", - resourceId: "9", - resourceName: "prod-db", - details: null, - ipAddress: "203.0.113.9", - userAgent: "Mozilla/5.0", - success: true, - errorMessage: null, - timestamp: "2026-07-28 10:00:00", - ...overrides, - } as AuditLogRecord; -} - -describe("escapeCsvField", () => { - it("leaves plain values alone", () => { - expect(escapeCsvField("prod-db")).toBe("prod-db"); - expect(escapeCsvField(42)).toBe("42"); - expect(escapeCsvField(true)).toBe("true"); - }); - - it("renders null and undefined as empty", () => { - expect(escapeCsvField(null)).toBe(""); - expect(escapeCsvField(undefined)).toBe(""); - }); - - it("quotes and doubles embedded quotes", () => { - expect(escapeCsvField('say "hi"')).toBe('"say ""hi"""'); - }); - - it("quotes values containing commas or newlines", () => { - expect(escapeCsvField("a,b")).toBe('"a,b"'); - expect(escapeCsvField("line1\nline2")).toBe('"line1\nline2"'); - }); - - it("neutralises spreadsheet formulas", () => { - // An audit entry can carry an attacker-chosen resource name; without this - // the exported file executes it when opened. - expect(escapeCsvField("=1+1")).toBe("'=1+1"); - expect(escapeCsvField("+SUM(A1)")).toBe("'+SUM(A1)"); - expect(escapeCsvField("-2+3")).toBe("'-2+3"); - expect(escapeCsvField("@import")).toBe("'@import"); - }); - - it("still quotes a formula that also contains a comma", () => { - expect(escapeCsvField("=A1,B2")).toBe(`"'=A1,B2"`); - }); -}); - -describe("toCsv", () => { - it("writes a header even with no rows", () => { - expect(toCsv([])).toBe( - "id,timestamp,username,userId,action,resourceType,resourceId,resourceName,success,ipAddress,userAgent,errorMessage,details\n", - ); - }); - - it("writes one line per entry in column order", () => { - const lines = toCsv([entry(), entry({ id: 2, username: "bob" })]) - .trim() - .split("\n"); - - expect(lines).toHaveLength(3); - expect( - lines[1].startsWith("1,2026-07-28 10:00:00,alice,u-1,delete_host"), - ).toBe(true); - expect(lines[2].startsWith("2,")).toBe(true); - }); - - it("keeps a detached entry readable", () => { - const line = toCsv([entry({ userId: null })]) - .trim() - .split("\n")[1]; - - // username survives so the row still names who acted. - expect(line).toContain("alice"); - expect(line.split(",")[3]).toBe(""); - }); -}); - -describe("toNdjson", () => { - it("emits one parseable object per line", () => { - const out = toNdjson([entry(), entry({ id: 2 })]); - const parsed = out - .trim() - .split("\n") - .map((line) => JSON.parse(line)); - - expect(parsed).toHaveLength(2); - expect(parsed[0].action).toBe("delete_host"); - expect(parsed[1].id).toBe(2); - }); - - it("returns nothing for an empty set", () => { - expect(toNdjson([])).toBe(""); - }); -}); - -describe("exportFilename", () => { - it("is filesystem-safe and carries the timestamp", () => { - const name = exportFilename("csv", new Date("2026-07-28T10:11:12.000Z")); - - expect(name).toBe("termix-audit-2026-07-28-10-11-12.csv"); - expect(name).not.toMatch(/[:\s]/); - }); - - it("uses the ndjson extension for the streaming format", () => { - expect(exportFilename("ndjson", new Date("2026-07-28T10:11:12.000Z"))).toBe( - "termix-audit-2026-07-28-10-11-12.ndjson", - ); - }); -}); diff --git a/src/backend/tests/utils/audit-forwarder.test.ts b/src/backend/tests/utils/audit-forwarder.test.ts deleted file mode 100644 index bd15e1d11..000000000 --- a/src/backend/tests/utils/audit-forwarder.test.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { describe, expect, it, vi, beforeEach } from "vitest"; - -const safeFetch = vi.hoisted(() => vi.fn()); -const logs = vi.hoisted(() => ({ info: vi.fn(), warn: vi.fn() })); - -vi.mock("../../database/repositories/factory.js", () => ({ - getCurrentSettingValue: () => null, -})); -vi.mock("../../utils/safe-outbound-fetch.js", () => ({ - safeOutboundFetch: safeFetch, -})); -vi.mock("../../utils/logger.js", () => ({ databaseLogger: logs })); - -const { - auditForwardTarget, - forwardAuditEntry, - forwardPayload, - resetAuditForwarderState, - AUDIT_FORWARD_URL_ENV, - AUDIT_FORWARD_TOKEN_ENV, -} = await import("../../utils/audit-forwarder.js"); - -const ENTRY = { - userId: "u-1", - username: "alice", - action: "delete_host", - resourceType: "host", - resourceId: "9", - success: true, - ipAddress: "203.0.113.9", -}; - -const NOW = new Date("2026-07-28T10:00:00.000Z"); - -beforeEach(() => { - safeFetch.mockReset(); - logs.info.mockReset(); - logs.warn.mockReset(); - resetAuditForwarderState(); -}); - -describe("auditForwardTarget", () => { - it("is off unless a URL is configured", () => { - expect(auditForwardTarget({})).toBeNull(); - expect(auditForwardTarget({ [AUDIT_FORWARD_URL_ENV]: " " })).toBeNull(); - }); - - it("carries an optional bearer token", () => { - expect( - auditForwardTarget({ [AUDIT_FORWARD_URL_ENV]: "https://siem/ingest" }), - ).toEqual({ url: "https://siem/ingest" }); - - expect( - auditForwardTarget({ - [AUDIT_FORWARD_URL_ENV]: "https://siem/ingest", - [AUDIT_FORWARD_TOKEN_ENV]: "secret", - }), - ).toEqual({ url: "https://siem/ingest", token: "secret" }); - }); -}); - -describe("forwardPayload", () => { - it("matches the export shape, with absent fields as null", () => { - expect(forwardPayload(ENTRY, NOW)).toEqual({ - timestamp: "2026-07-28T10:00:00.000Z", - userId: "u-1", - username: "alice", - action: "delete_host", - resourceType: "host", - resourceId: "9", - resourceName: null, - success: true, - ipAddress: "203.0.113.9", - userAgent: null, - errorMessage: null, - details: null, - }); - }); -}); - -describe("forwardAuditEntry", () => { - const env = { [AUDIT_FORWARD_URL_ENV]: "https://siem.example/ingest" }; - - it("does nothing when forwarding is not configured", async () => { - await expect(forwardAuditEntry(ENTRY, NOW, {})).resolves.toBe(false); - expect(safeFetch).not.toHaveBeenCalled(); - }); - - it("posts one NDJSON line through the SSRF-checked fetch", async () => { - safeFetch.mockResolvedValueOnce({ ok: true, status: 200 }); - - await expect(forwardAuditEntry(ENTRY, NOW, env)).resolves.toBe(true); - - const [url, init] = safeFetch.mock.calls[0]; - expect(url).toBe("https://siem.example/ingest"); - expect(init.method).toBe("POST"); - expect(init.headers["Content-Type"]).toBe("application/x-ndjson"); - expect(init.headers.Authorization).toBeUndefined(); - expect(JSON.parse(init.body.trim()).action).toBe("delete_host"); - expect(init.body.endsWith("\n")).toBe(true); - }); - - it("sends the bearer token when one is set", async () => { - safeFetch.mockResolvedValueOnce({ ok: true, status: 200 }); - - await forwardAuditEntry(ENTRY, NOW, { - ...env, - [AUDIT_FORWARD_TOKEN_ENV]: "secret", - }); - - expect(safeFetch.mock.calls[0][1].headers.Authorization).toBe( - "Bearer secret", - ); - }); - - it("reports a rejected delivery without throwing", async () => { - safeFetch.mockResolvedValueOnce({ ok: false, status: 503 }); - - await expect(forwardAuditEntry(ENTRY, NOW, env)).resolves.toBe(false); - expect(logs.warn).toHaveBeenCalledWith( - "Failed to forward audit entry", - expect.objectContaining({ reason: "collector returned 503" }), - ); - }); - - it("swallows transport errors, a dead SIEM must not break auditing", async () => { - safeFetch.mockRejectedValueOnce(new Error("ECONNREFUSED")); - - await expect(forwardAuditEntry(ENTRY, NOW, env)).resolves.toBe(false); - expect(logs.warn).toHaveBeenCalledWith( - "Failed to forward audit entry", - expect.objectContaining({ reason: "ECONNREFUSED" }), - ); - }); - - it("stops repeating itself once the collector is persistently down", async () => { - safeFetch.mockResolvedValue({ ok: false, status: 500 }); - - for (let i = 0; i < 8; i++) { - await forwardAuditEntry(ENTRY, NOW, env); - } - - // 5 per-entry warnings, then one suppression notice, not 8. - const perEntry = logs.warn.mock.calls.filter( - (call) => call[0] === "Failed to forward audit entry", - ); - expect(perEntry).toHaveLength(5); - expect( - logs.warn.mock.calls.some((call) => - String(call[0]).includes("suppressing further messages"), - ), - ).toBe(true); - // It keeps trying regardless. - expect(safeFetch).toHaveBeenCalledTimes(8); - }); - - it("announces recovery after a suppressed outage", async () => { - safeFetch.mockResolvedValue({ ok: false, status: 500 }); - for (let i = 0; i < 6; i++) await forwardAuditEntry(ENTRY, NOW, env); - - safeFetch.mockResolvedValueOnce({ ok: true, status: 200 }); - await forwardAuditEntry(ENTRY, NOW, env); - - expect(logs.info).toHaveBeenCalledWith( - "Audit forwarding recovered", - expect.objectContaining({ operation: "audit_forward_recovered" }), - ); - }); -}); diff --git a/src/backend/upgrade/audit-forwarding-migration.ts b/src/backend/upgrade/audit-forwarding-migration.ts new file mode 100644 index 000000000..2ee36969d --- /dev/null +++ b/src/backend/upgrade/audit-forwarding-migration.ts @@ -0,0 +1,87 @@ +/** + * Moves the audit forwarding collector URL and token out of the core settings + * table into the audit-tools plugin's admin settings. The token is stored + * encrypted with the installation key, like any plugin secret setting, and the + * core copies are removed once the plugin has its own. + * + * Idempotent: a value the plugin already has is kept, and a core value is only + * removed after the plugin holds a copy. Does nothing until the plugin row + * exists (plugin_settings has a foreign key to plugins). + */ + +import { databaseLogger } from "../utils/logger.js"; +import { + createCurrentPluginRepository, + createCurrentPluginSettingsRepository, + createCurrentSettingsRepository, +} from "../database/repositories/factory.js"; +import { encryptSystemSecret } from "../utils/system-secret-crypto.js"; + +const PLUGIN_ID = "audit-tools"; +const URL_SETTING = "audit_log_forward_url"; +const TOKEN_SETTING = "audit_log_forward_token"; + +export interface AuditForwardingMigrationResult { + url: boolean; + token: boolean; +} + +export async function runAuditForwardingMigration(): Promise { + const result: AuditForwardingMigrationResult = { url: false, token: false }; + + try { + if (!(await createCurrentPluginRepository().findById(PLUGIN_ID))) { + return result; + } + } catch { + return result; + } + + const core = createCurrentSettingsRepository(); + const plugin = createCurrentPluginSettingsRepository(); + const legacyUrl = (await core.get(URL_SETTING))?.trim(); + const legacyToken = (await core.get(TOKEN_SETTING))?.trim(); + + const has = async (key: string) => { + const row = await plugin.get(PLUGIN_ID, "admin", null, key); + return !!row && row.value !== null && row.value !== '""'; + }; + + if (legacyUrl) { + if (!(await has("url"))) { + await plugin.set( + PLUGIN_ID, + "admin", + null, + "url", + JSON.stringify(legacyUrl), + ); + result.url = true; + } + if (legacyToken && !(await has("token"))) { + await plugin.set( + PLUGIN_ID, + "admin", + null, + "token", + JSON.stringify(await encryptSystemSecret(legacyToken)), + true, + ); + result.token = true; + } + } + + // Safe to drop now: the plugin holds the values, or never had a collector. + if (legacyUrl ? await has("url") : true) { + if (legacyUrl) await core.delete(URL_SETTING); + if (legacyToken && (await has("token"))) await core.delete(TOKEN_SETTING); + else if (!legacyUrl && legacyToken) await core.delete(TOKEN_SETTING); + } + + if (result.url || result.token) { + databaseLogger.info("Moved audit forwarding settings into audit-tools", { + operation: "audit_forwarding_migration", + }); + } + return result; +} diff --git a/src/backend/upgrade/host-status-settings-migration.ts b/src/backend/upgrade/host-status-settings-migration.ts new file mode 100644 index 000000000..fdd2028b5 --- /dev/null +++ b/src/backend/upgrade/host-status-settings-migration.ts @@ -0,0 +1,64 @@ +/** + * Moves the global status check interval into the host-status plugin, before + * it activates, so its first checks use the interval an admin chose instead of + * the plugin's 30 second default. + * + * Idempotent: a value the plugin already has is kept. + */ + +import { databaseLogger } from "../utils/logger.js"; +import { + createCurrentPluginRepository, + createCurrentPluginSettingsRepository, + createCurrentSettingsRepository, +} from "../database/repositories/factory.js"; + +const PLUGIN_ID = "host-status"; +const LEGACY_KEY = "global_status_check_interval"; + +function seconds(value: unknown): number | null { + const parsed = Number(value); + return Number.isInteger(parsed) && parsed >= 5 && parsed <= 3600 + ? parsed + : null; +} + +export async function runHostStatusSettingsMigration(): Promise { + try { + const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID); + if (!plugin) return null; + + const settings = createCurrentPluginSettingsRepository(); + const existing = await settings.get( + PLUGIN_ID, + "admin", + null, + "intervalSeconds", + ); + if (existing && existing.value !== null) return null; + + const legacy = seconds( + await createCurrentSettingsRepository().get(LEGACY_KEY), + ); + if (legacy === null) return null; + + await settings.set( + PLUGIN_ID, + "admin", + null, + "intervalSeconds", + JSON.stringify(legacy), + ); + databaseLogger.info("Moved the status check interval into the plugin", { + operation: "host_status_settings_migration", + intervalSeconds: legacy, + }); + return legacy; + } catch (error) { + databaseLogger.warn("Host status settings migration failed", { + operation: "host_status_settings_migration", + error: error instanceof Error ? error.message : String(error), + }); + return null; + } +} diff --git a/src/backend/upgrade/plugin-data-moves.ts b/src/backend/upgrade/plugin-data-moves.ts index 558b8d48f..2b2f80f7a 100644 --- a/src/backend/upgrade/plugin-data-moves.ts +++ b/src/backend/upgrade/plugin-data-moves.ts @@ -10,6 +10,8 @@ import { runOpksshConfigMigration } from "./opkssh-config-migration.js"; import { runTermixIdentityCaMigration } from "./termix-identity-ca-migration.js"; import { runSessionRecordingDataMigration } from "./session-recording-data-migration.js"; import { runNotificationChannelMigration } from "./notification-channel-migration.js"; +import { runHostStatusSettingsMigration } from "./host-status-settings-migration.js"; +import { runAuditForwardingMigration } from "./audit-forwarding-migration.js"; export async function runPluginDataMoves(): Promise { await runLdapProviderMigration(); @@ -19,4 +21,8 @@ export async function runPluginDataMoves(): Promise { await runSessionRecordingDataMigration(); // Automations name channels by id, so they are in place before it starts. await runNotificationChannelMigration(); + // The plugin forwards from its own settings, so they are in place first. + await runAuditForwardingMigration(); + // Sets the check interval, which the plugin reads when it activates. + await runHostStatusSettingsMigration(); } diff --git a/src/backend/utils/audit-export.ts b/src/backend/utils/audit-export.ts deleted file mode 100644 index 5f84891c6..000000000 --- a/src/backend/utils/audit-export.ts +++ /dev/null @@ -1,70 +0,0 @@ -import type { AuditLogRecord } from "../database/repositories/audit-log-repository.js"; - -/** Column order for CSV export; also the header row. */ -const COLUMNS = [ - "id", - "timestamp", - "username", - "userId", - "action", - "resourceType", - "resourceId", - "resourceName", - "success", - "ipAddress", - "userAgent", - "errorMessage", - "details", -] as const; - -/** - * RFC 4180 field escaping. - * - * The leading-character guard is not part of RFC 4180: a field starting with - * `=`, `+`, `-` or `@` is treated as a formula by spreadsheet software, so an - * audit entry containing an attacker-chosen resource name could execute on - * open. Prefixing with a single quote neutralises that while keeping the value - * readable. - */ -export function escapeCsvField(value: unknown): string { - if (value === null || value === undefined) return ""; - - let text = typeof value === "boolean" ? String(value) : String(value); - if (/^[=+\-@\t\r]/.test(text)) { - text = `'${text}`; - } - - if (/[",\n\r]/.test(text)) { - return `"${text.replace(/"/g, '""')}"`; - } - return text; -} - -export function toCsv(rows: AuditLogRecord[]): string { - const lines = [COLUMNS.join(",")]; - for (const row of rows) { - lines.push( - COLUMNS.map((column) => - escapeCsvField((row as Record)[column]), - ).join(","), - ); - } - // Trailing newline so the file ends cleanly when appended to or concatenated. - return `${lines.join("\n")}\n`; -} - -/** - * Newline-delimited JSON: one entry per line, which is what log shippers and - * SIEM bulk endpoints expect, and which streams without holding the whole set. - */ -export function toNdjson(rows: AuditLogRecord[]): string { - return ( - rows.map((row) => JSON.stringify(row)).join("\n") + - (rows.length ? "\n" : "") - ); -} - -export function exportFilename(format: "csv" | "ndjson", now: Date): string { - const stamp = now.toISOString().slice(0, 19).replace(/[:T]/g, "-"); - return `termix-audit-${stamp}.${format === "csv" ? "csv" : "ndjson"}`; -} diff --git a/src/backend/utils/audit-forwarder.ts b/src/backend/utils/audit-forwarder.ts deleted file mode 100644 index a48da2a4b..000000000 --- a/src/backend/utils/audit-forwarder.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { safeOutboundFetch } from "./safe-outbound-fetch.js"; -import { getCurrentSettingValue } from "../database/repositories/factory.js"; -import { databaseLogger } from "./logger.js"; -import type { AuditLogParams } from "./audit-logger.js"; - -export const AUDIT_FORWARD_URL_ENV = "AUDIT_LOG_FORWARD_URL"; -export const AUDIT_FORWARD_TOKEN_ENV = "AUDIT_LOG_FORWARD_TOKEN"; -export const AUDIT_FORWARD_URL_SETTING = "audit_log_forward_url"; -export const AUDIT_FORWARD_TOKEN_SETTING = "audit_log_forward_token"; - -/** - * Settings-table read that tolerates running before the database is up (unit - * tests, early startup): no readable setting behaves like an unset one. - */ -function readForwardSetting(key: string): string | null { - try { - return getCurrentSettingValue(key)?.trim() || null; - } catch { - return null; - } -} - -/** - * How many consecutive failures before the forwarder stops complaining on every - * entry. It keeps trying, this only throttles the log noise, and it reports - * again once delivery recovers. - */ -const QUIET_AFTER_FAILURES = 5; - -let consecutiveFailures = 0; -let quietened = false; - -export interface AuditForwardTarget { - url: string; - token?: string; -} - -export function auditForwardTarget( - env: NodeJS.ProcessEnv = process.env, -): AuditForwardTarget | null { - // An admin-entered collector wins over the deployment env var, so the UI - // can configure forwarding - and its token - without a restart. - const settingUrl = readForwardSetting(AUDIT_FORWARD_URL_SETTING); - const url = settingUrl ?? env[AUDIT_FORWARD_URL_ENV]?.trim(); - if (!url) return null; - const token = settingUrl - ? readForwardSetting(AUDIT_FORWARD_TOKEN_SETTING) - : env[AUDIT_FORWARD_TOKEN_ENV]?.trim(); - return token ? { url, token } : { url }; -} - -/** The wire shape: one JSON object per entry, matching the export's NDJSON. */ -export function forwardPayload( - entry: AuditLogParams, - now: Date, -): Record { - return { - timestamp: now.toISOString(), - userId: entry.userId, - username: entry.username, - action: entry.action, - resourceType: entry.resourceType, - resourceId: entry.resourceId ?? null, - resourceName: entry.resourceName ?? null, - success: entry.success, - ipAddress: entry.ipAddress ?? null, - userAgent: entry.userAgent ?? null, - errorMessage: entry.errorMessage ?? null, - details: entry.details ?? null, - }; -} - -/** Exposed for tests; forwarding state is process-wide otherwise. */ -export function resetAuditForwarderState(): void { - consecutiveFailures = 0; - quietened = false; -} - -/** - * Ships one entry to the configured collector. - * - * Never throws and never blocks the audited operation: a SIEM being unreachable - * must not stop Termix from recording locally, which stays the source of truth. - * Delivery goes through safeOutboundFetch so a misconfigured URL cannot be used - * to probe the internal network. - */ -export async function forwardAuditEntry( - entry: AuditLogParams, - now: Date = new Date(), - env: NodeJS.ProcessEnv = process.env, -): Promise { - const target = auditForwardTarget(env); - if (!target) return false; - - try { - const response = await safeOutboundFetch(target.url, { - method: "POST", - headers: { - "Content-Type": "application/x-ndjson", - ...(target.token ? { Authorization: `Bearer ${target.token}` } : {}), - }, - body: `${JSON.stringify(forwardPayload(entry, now))}\n`, - signal: AbortSignal.timeout(5000), - }); - - if (!response.ok) { - noteFailure(`collector returned ${response.status}`, entry.action); - return false; - } - - noteSuccess(); - return true; - } catch (error) { - noteFailure( - error instanceof Error ? error.message : String(error), - entry.action, - ); - return false; - } -} - -function noteFailure(reason: string, action: string): void { - consecutiveFailures++; - - if (quietened) return; - - databaseLogger.warn("Failed to forward audit entry", { - operation: "audit_forward_failed", - action, - reason, - consecutiveFailures, - }); - - if (consecutiveFailures >= QUIET_AFTER_FAILURES) { - quietened = true; - databaseLogger.warn( - `Audit forwarding has failed ${consecutiveFailures} times; suppressing further messages until it recovers`, - { operation: "audit_forward_suppressed" }, - ); - } -} - -function noteSuccess(): void { - if (quietened) { - databaseLogger.info("Audit forwarding recovered", { - operation: "audit_forward_recovered", - afterFailures: consecutiveFailures, - }); - } - consecutiveFailures = 0; - quietened = false; -} diff --git a/src/backend/utils/audit-hooks.ts b/src/backend/utils/audit-hooks.ts new file mode 100644 index 000000000..00b15ee96 --- /dev/null +++ b/src/backend/utils/audit-hooks.ts @@ -0,0 +1,32 @@ +import type { AuditLogParams } from "./audit-logger.js"; + +export interface AuditHookEntry extends AuditLogParams { + timestamp: Date; +} + +type AuditHook = (entry: AuditHookEntry) => void; + +const hooks = new Set(); + +/** Registers a listener for accepted audit lines. Returns the remover. */ +export function onAuditEntry(hook: AuditHook): () => void { + hooks.add(hook); + return () => { + hooks.delete(hook); + }; +} + +/** Never throws and never waits: a hook cannot delay the audited action. */ +export function emitAuditEntry(params: AuditLogParams): void { + if (hooks.size === 0) return; + const entry: AuditHookEntry = { ...params, timestamp: new Date() }; + for (const hook of hooks) { + queueMicrotask(() => { + try { + hook(entry); + } catch { + // a plugin hook must not break auditing + } + }); + } +} diff --git a/src/backend/utils/audit-logger.ts b/src/backend/utils/audit-logger.ts index 1cc0d1228..eef352fa6 100644 --- a/src/backend/utils/audit-logger.ts +++ b/src/backend/utils/audit-logger.ts @@ -1,5 +1,5 @@ import type { Request } from "express"; -import { forwardAuditEntry } from "./audit-forwarder.js"; +import { emitAuditEntry } from "./audit-hooks.js"; import { createCurrentAuditLogRepository, createCurrentUserRepository, @@ -78,9 +78,9 @@ function isCoalescedRoutine(params: AuditLogParams): boolean { export async function logAudit(params: AuditLogParams): Promise { if (isCoalescedRoutine(params)) return; - // Local storage is the source of truth and runs first; forwarding is a copy - // and must never delay or fail the audited operation. - void forwardAuditEntry(params).catch(() => {}); + // Hooks (plugins copying the log elsewhere) never delay or fail the + // audited operation. + emitAuditEntry(params); try { await createCurrentAuditLogRepository().create({ diff --git a/src/main.tsx b/src/main.tsx index 008b0486f..a0d5df84e 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -304,9 +304,6 @@ function App() { ]); await enterApp(); })(); - if (isElectron()) { - window.electronAPI?.startC2SAutoStartTunnels?.().catch(() => {}); - } } function handleLogout(options?: LogoutOptions) { diff --git a/src/types/onboarding.ts b/src/types/onboarding.ts index fcfdee4f2..4c962196d 100644 --- a/src/types/onboarding.ts +++ b/src/types/onboarding.ts @@ -12,8 +12,6 @@ export const CORE_ONBOARDING_STEPS = { plugins: 1, "desktop-sync": 1, "desktop-updates": 1, - preset: 1, - appearance: 1, security: 1, } as const; diff --git a/src/ui/api/audit-log-api.ts b/src/ui/api/audit-log-api.ts index 786ddb868..21fca344e 100644 --- a/src/ui/api/audit-log-api.ts +++ b/src/ui/api/audit-log-api.ts @@ -105,33 +105,3 @@ export async function getAuditLogActions(): Promise<{ actions: string[] }> { handleApiError(error, "fetch audit log actions"); } } - -export interface AuditForwardingSettings { - url: string; - hasToken: boolean; - envConfigured: boolean; -} - -export async function getAuditForwarding(): Promise { - try { - const response = await authApi.get("/users/audit-forwarding"); - return response.data; - } catch (error) { - throw handleApiError(error, "get audit forwarding settings"); - } -} - -export async function updateAuditForwarding( - url: string, - token?: string, -): Promise<{ url: string; hasToken: boolean }> { - try { - const response = await authApi.patch("/users/audit-forwarding", { - url, - token, - }); - return response.data; - } catch (error) { - throw handleApiError(error, "update audit forwarding settings"); - } -} diff --git a/src/ui/api/credentials-api.ts b/src/ui/api/credentials-api.ts index 9f1d711d7..4942b74da 100644 --- a/src/ui/api/credentials-api.ts +++ b/src/ui/api/credentials-api.ts @@ -336,113 +336,3 @@ export async function reorderCredentials( throw error; } } - -export async function detectKeyType( - privateKey: string, - keyPassword?: string, -): Promise> { - try { - const response = await authApi.post("/credentials/detect-key-type", { - privateKey, - keyPassword, - }); - return response.data; - } catch (error) { - throw handleApiError(error, "detect key type"); - } -} - -export async function detectPublicKeyType( - publicKey: string, -): Promise> { - try { - const response = await authApi.post("/credentials/detect-public-key-type", { - publicKey, - }); - return response.data; - } catch (error) { - throw handleApiError(error, "detect public key type"); - } -} - -export async function validateKeyPair( - privateKey: string, - publicKey: string, - keyPassword?: string, -): Promise> { - try { - const response = await authApi.post("/credentials/validate-key-pair", { - privateKey, - publicKey, - keyPassword, - }); - return response.data; - } catch (error) { - throw handleApiError(error, "validate key pair"); - } -} - -export interface GeneratedPublicKey { - success?: boolean; - publicKey?: string; - error?: string; -} - -export async function generatePublicKeyFromPrivate( - privateKey: string, - keyPassword?: string, -): Promise { - try { - const response = await authApi.post("/credentials/generate-public-key", { - privateKey, - keyPassword, - }); - return response.data; - } catch (error) { - throw handleApiError(error, "generate public key from private key"); - } -} - -export interface GeneratedKeyPair { - success: boolean; - privateKey?: string; - publicKey?: string; - keyType?: string; - format?: string; - algorithm?: string; - keySize?: number; - curve?: string; - error?: string; -} - -export async function generateKeyPair( - keyType: "ssh-ed25519" | "ssh-rsa" | "ecdsa-sha2-nistp256", - keySize?: number, - passphrase?: string, -): Promise { - try { - const response = await authApi.post("/credentials/generate-key-pair", { - keyType, - keySize, - passphrase, - }); - return response.data; - } catch (error) { - throw handleApiError(error, "generate SSH key pair"); - } -} - -export async function deployCredentialToHost( - credentialId: number, - targetHostId: number, -): Promise> { - try { - const response = await authApi.post( - `/credentials/${credentialId}/deploy-to-host`, - { targetHostId }, - ); - return response.data; - } catch (error) { - throw handleApiError(error, "deploy credential to host"); - } -} diff --git a/src/ui/api/settings-api.ts b/src/ui/api/settings-api.ts index 414273af6..2e06e4059 100644 --- a/src/ui/api/settings-api.ts +++ b/src/ui/api/settings-api.ts @@ -54,12 +54,6 @@ export interface BrandingSettings { logo: string | null; } -export interface BrandingSettingsUpdate { - appName?: string; - tagline?: string; - logo?: string | null; -} - /** Public endpoint -- no auth required, so the login screen can read it. */ export async function getBranding(): Promise { try { @@ -69,14 +63,3 @@ export async function getBranding(): Promise { handleApiError(error, "fetch branding settings"); } } - -export async function updateBranding( - update: BrandingSettingsUpdate, -): Promise { - try { - const response = await authApi.patch("/users/branding", update); - return response.data; - } catch (error) { - handleApiError(error, "update branding settings"); - } -} diff --git a/src/ui/api/ssh-host-management-api.ts b/src/ui/api/ssh-host-management-api.ts index 0ebbfb4b3..7e3daf8ae 100644 --- a/src/ui/api/ssh-host-management-api.ts +++ b/src/ui/api/ssh-host-management-api.ts @@ -70,54 +70,6 @@ export async function updateSSHHost( } } -export async function bulkImportSSHHosts( - hosts: SSHHostData[], - overwrite = false, - credentials?: Record[], -): Promise<{ - message: string; - success: number; - updated: number; - skipped: number; - failed: number; - errors: string[]; -}> { - try { - const response = await sshHostApi.post("/bulk-import", { - hosts, - overwrite, - ...(credentials ? { credentials } : {}), - }); - invalidateHostsAndStatusCaches(); - return response.data; - } catch (error) { - handleApiError(error, "bulk import SSH hosts"); - } -} - -export async function importSSHConfigHosts( - content: string, - overwrite = false, -): Promise<{ - message: string; - success: number; - updated: number; - skipped: number; - failed: number; - errors: string[]; -}> { - try { - const response = await sshHostApi.post("/ssh-config-import", { - content, - overwrite, - }); - invalidateHostsAndStatusCaches(); - return response.data; - } catch (error) { - handleApiError(error, "import SSH config hosts"); - } -} - export async function bulkUpdateSSHHosts( hostIds: number[], updates: Record, @@ -167,44 +119,6 @@ export async function getSSHHostById(hostId: number): Promise { } } -export async function exportSSHHostWithCredentials( - hostId: number, -): Promise { - try { - const response = await sshHostApi.get(`/db/host/${hostId}/export`); - return response.data; - } catch (error) { - handleApiError(error, "export SSH host with credentials"); - } -} - -export function exportAllSSHHosts(): Promise<{ - hosts: SSHHost[]; -}>; -export function exportAllSSHHosts(options: { share: true }): Promise<{ - version: string; - exportedAt: string; - credentials: Record[]; - hosts: SSHHost[]; -}>; -export async function exportAllSSHHosts(options?: { - share?: boolean; -}): Promise<{ - version?: string; - exportedAt?: string; - credentials?: Record[]; - hosts: SSHHost[]; -}> { - try { - const response = await sshHostApi.get( - options?.share ? "/db/hosts/export?share=1" : "/db/hosts/export", - ); - return response.data; - } catch (error) { - handleApiError(error, "export all SSH hosts"); - } -} - // ============================================================================ // PROXY CONNECTIVITY TEST // ============================================================================ diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index e68937cf7..390501cf8 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -213,11 +213,6 @@ "pluginsSomeFailed": "Some plugins could not be changed. You can fix them later from the Plugins tab. Press Next to continue.", "pluginsApplyFailed": "Your choices could not be saved. Try again, or continue and change them later from the Plugins tab.", "pluginsLoadFailed": "The plugin list could not be loaded. You can choose plugins later from the Plugins tab.", - "presetTitle": "How much do you want on screen?", - "presetIntro": "Choose how much shows on screen.", - "presetChangeLater": "You can switch presets or change any single setting later in Settings, under Interface.", - "appearanceTitle": "Make it yours", - "appearanceIntro": "Pick a theme, an accent color, a font and your language. Changes show right away, and you can change them later in Settings.", "securityTitle": "Secure your account", "securityIntro": "Add a passkey or an authenticator app to your account. You can also do this later in Settings, under Security.", "desktopTitle": "How do you want to use Termix?", @@ -279,10 +274,7 @@ "retry": "Retry", "refresh": "Refresh", "optional": "Optional", - "downloadSample": "Download Sample", "failedToDeleteHost": "Failed to delete {{name}}", - "importSkipExisting": "Import (skip existing)", - "importSSHConfig": "Import from SSH config", "connectionDetails": "Connection Details", "ssh": "SSH", "telnet": "Telnet", @@ -501,10 +493,7 @@ "noFolder": "(No folder)", "deleteSelected": "Delete", "exitSelection": "Exit selection", - "importSkip": "Import (skip existing)", - "importOverwrite": "Import (overwrite)", "collapseBtn": "Collapse", - "importExportBtn": "Import / Export", "hostStatusesRefreshed": "Host statuses refreshed", "failedToRefreshHosts": "Failed to refresh hosts", "movedHostTo": "Moved {{host}} to \"{{folder}}\"", @@ -669,35 +658,6 @@ "addCredentialBtn2": "Add Credential", "checkingHostStatuses": "Checking host statuses...", "pinnedSection": "Pinned", - "hostsExported": "Hosts exported successfully", - "export": { - "menuItem": "Export...", - "title": "Export hosts", - "scope": "Scope", - "scopeAll": "All", - "scopeSelected": "Selected", - "searchHosts": "Search hosts...", - "include": "Include", - "groupConnection": "Connection", - "groupCredentials": "Credentials", - "groupNotes": "Notes", - "groupTags": "Tags & pin", - "groupProxy": "Proxy", - "groupJumpHosts": "Jump hosts", - "groupFeatureFlags": "Plugin settings", - "groupAdvanced": "Advanced config", - "preview": "Preview", - "moreHosts_one": "... {{count}} more host", - "moreHosts_other": "... {{count}} more hosts", - "summary": "{{selected}} of {{total}} hosts", - "credentialsIncluded": "credentials included", - "credentialsExcluded": "credentials excluded", - "noneSelected": "No hosts selected", - "cancel": "Cancel", - "confirm": "Export", - "fetchFailed": "Failed to load hosts for export", - "bulkButton": "Export" - }, "sampleDownloaded": "Sample file downloaded", "failedToDeleteCredential2": "Failed to delete credential", "noFolderOption": "(No folder)", @@ -860,11 +820,7 @@ "editorUpdate": "Update Host", "editorAdd": "Add Host", "speedSearch": "Search hosts. Arrow keys to select, Enter to connect, Esc to close", - "speedSearchList": "Host list. Type or press / to search.", - "importNoHosts": "No hosts found in the file", - "importTooMany": "You can import up to {{max}} hosts at once", - "importSummary": "Import done: {{imported}} imported, {{updated}} updated, {{failed}} failed", - "importHostsFailed": "Failed to import hosts" + "speedSearchList": "Host list. Type or press / to search." }, "splitScreen": { "title": "Split screen", @@ -1196,6 +1152,10 @@ "title": "Access serial and USB devices", "consequence": "It can open a physical serial port on the machine running Termix." }, + "hosts:export": { + "title": "Export hosts with their secrets", + "consequence": "Can read your hosts in full, including saved passwords and keys, and hand them out. Every export is written to the audit log." + }, "hosts:write": { "title": "Create and change hosts", "consequence": "It can add hosts and edit the ones you already have." @@ -1240,10 +1200,26 @@ "title": "Read the audit log", "consequence": "It can see who did what and when." }, + "network:probe": { + "title": "Check if ports answer", + "consequence": "Can open a plain connection to any address from the server, to see if it is reachable." + }, + "hosts:status-provide": { + "title": "Provide host status", + "consequence": "Becomes the source of the online and offline dots for every host." + }, + "hosts:activity-provide": { + "title": "Store recent activity", + "consequence": "Becomes the store for the recent activity list of every user." + }, "desktop:window": { "title": "Open desktop windows", "consequence": "Only in the Termix desktop app." }, + "desktop:local": { + "title": "Use this computer", + "consequence": "Can read and change files, run a shell and open local ports on this computer. Only in the Termix desktop app." + }, "users:impersonate": { "title": "Act as other users", "consequence": "Runs background work with another user's access, such as scheduled jobs. Every use is written to the audit log." diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index 3b7e52ec9..121e1b675 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -877,14 +877,10 @@ export { getSSHHosts, createSSHHost, updateSSHHost, - bulkImportSSHHosts, - importSSHConfigHosts, bulkUpdateSSHHosts, reorderSSHHosts, deleteSSHHost, getSSHHostById, - exportSSHHostWithCredentials, - exportAllSSHHosts, testProxyConnection, } from "@/api/ssh-host-management-api"; @@ -1280,12 +1276,6 @@ export { deleteAllHostsInFolder, renameCredentialFolder, reorderCredentials, - detectKeyType, - detectPublicKeyType, - validateKeyPair, - generatePublicKeyFromPrivate, - generateKeyPair, - deployCredentialToHost, } from "@/api/credentials-api"; // ============================================================================ diff --git a/src/ui/onboarding/onboarding-flow.ts b/src/ui/onboarding/onboarding-flow.ts index 6434e6a1a..572cf09fb 100644 --- a/src/ui/onboarding/onboarding-flow.ts +++ b/src/ui/onboarding/onboarding-flow.ts @@ -20,12 +20,22 @@ export function isStepRelevant( return true; } +/** A step that took over older keys counts those as seen at version 1. */ +function effectiveSeen( + step: OnboardingStepDef, + seen: Record, +): number { + const own = seen[step.id] ?? 0; + if (own > 0 || !step.replaces) return own; + return step.replaces.some((key) => (seen[key] ?? 0) > 0) ? 1 : 0; +} + export function isStepPending( step: OnboardingStepDef, seen: Record, ctx: OnboardingCtx, ): boolean { - if ((seen[step.id] ?? 0) < step.version) return true; + if (effectiveSeen(step, seen) < step.version) return true; try { return step.forcePending?.(ctx) === true; } catch { diff --git a/src/ui/onboarding/onboarding-registry.ts b/src/ui/onboarding/onboarding-registry.ts index 0317e2a3d..04667bdc1 100644 --- a/src/ui/onboarding/onboarding-registry.ts +++ b/src/ui/onboarding/onboarding-registry.ts @@ -42,22 +42,33 @@ export interface OnboardingStepDef { forcePending?: (ctx: OnboardingCtx) => boolean; /** Skip stays hidden until this step has been confirmed. */ required?: boolean; + /** Older step keys this one took over; seeing one counts as seeing this. */ + replaces?: string[]; } /** Where each section of plugin steps sits among the core steps. */ export const SECTION_POSITION: Record = { + start: 25, setup: 50, explore: 80, security: 95, }; +/** An explicit position stays inside the flow, before the last core step. */ +const MIN_POSITION = 1; +const MAX_POSITION = 99; + export const onboardingSteps = createRegistry(); /** Plugin steps sort inside their section by order, then key. */ export function sectionPosition( section: OnboardingSection | undefined, order = 0, + position?: number, ): number { + if (typeof position === "number" && Number.isFinite(position)) { + return Math.max(MIN_POSITION, Math.min(MAX_POSITION, position)); + } const base = SECTION_POSITION[section ?? "explore"]; return base + Math.max(-1, Math.min(1, order / 1000)) * 0.4; } diff --git a/src/ui/onboarding/onboarding-steps.ts b/src/ui/onboarding/onboarding-steps.ts index 626e9e1e1..3776a5219 100644 --- a/src/ui/onboarding/onboarding-steps.ts +++ b/src/ui/onboarding/onboarding-steps.ts @@ -1,15 +1,6 @@ -import { - LayoutTemplate, - Monitor, - MonitorDown, - Palette, - Puzzle, - Shield, -} from "lucide-react"; +import { Monitor, MonitorDown, Puzzle, Shield } from "lucide-react"; import { CORE_ONBOARDING_STEPS } from "@/types/onboarding"; import { PluginsStep } from "./steps/PluginsStep"; -import { PresetStep } from "./steps/PresetStep"; -import { AppearanceStep } from "./steps/AppearanceStep"; import { SecurityStep } from "./steps/SecurityStep"; import { DesktopSyncStep } from "./steps/DesktopSyncStep"; import { DesktopUpdatesStep } from "./steps/DesktopUpdatesStep"; @@ -58,24 +49,6 @@ export const CORE_STEPS: OnboardingStepDef[] = [ // App Store builds have no updater. isRelevant: (ctx) => ctx.isDesktop && desktopUpdates() !== null, }, - { - id: "preset", - version: CORE_ONBOARDING_STEPS.preset, - titleKey: "onboarding.presetTitle", - icon: LayoutTemplate, - Component: PresetStep, - audience: "all", - position: 30, - }, - { - id: "appearance", - version: CORE_ONBOARDING_STEPS.appearance, - titleKey: "onboarding.appearanceTitle", - icon: Palette, - Component: AppearanceStep, - audience: "all", - position: 40, - }, { id: "security", version: CORE_ONBOARDING_STEPS.security, diff --git a/src/ui/onboarding/steps/AppearanceStep.tsx b/src/ui/onboarding/steps/AppearanceStep.tsx deleted file mode 100644 index b539700cc..000000000 --- a/src/ui/onboarding/steps/AppearanceStep.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import type { ReactNode } from "react"; -import { useTranslation } from "react-i18next"; -import { - AccentPicker, - FontSizePicker, - LanguageSelect, - ThemeGrid, - UiFontSelect, - useAppearanceSettings, -} from "@/settings/appearance/appearance-controls"; - -function Field({ - label, - hint, - children, -}: { - label: string; - hint?: string; - children: ReactNode; -}) { - return ( -
- - {label} - - {children} - {hint && ( - {hint} - )} -
- ); -} - -/** Every change applies right away, so the page itself is the preview. */ -export function AppearanceStep() { - const { t } = useTranslation(); - const appearance = useAppearanceSettings(); - - return ( -
-

- {t("onboarding.appearanceIntro")} -

- - - - - - - - - -
- - - - - - -
- - - - -
- ); -} diff --git a/src/ui/onboarding/steps/PresetStep.tsx b/src/ui/onboarding/steps/PresetStep.tsx deleted file mode 100644 index 83db0f054..000000000 --- a/src/ui/onboarding/steps/PresetStep.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { Gauge, Sparkles, Wrench } from "lucide-react"; -import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext"; -import { applyPresetSideEffects } from "@/lib/apply-ui-preset"; -import { PRESETS, type UiPreset } from "@/types/ui-preferences"; - -const CHOICES: Exclude[] = [ - "simple", - "balanced", - "advanced", -]; - -const ICONS = { simple: Sparkles, balanced: Gauge, advanced: Wrench } as const; - -/** - * A miniature of the host list at the given preset. Deliberately drawn rather - * than rendering the real component: the point is to show the shape difference - * (how many rows fit, how much detail each carries) at a glance. - */ -function HostRowPreview({ preset }: { preset: Exclude }) { - const { hostList } = PRESETS[preset]; - const rows = hostList.density === "compact" ? 5 : 3; - - return ( -
- {Array.from({ length: rows }).map((_, i) => ( -
- {hostList.showStatusStripes && ( -
- )} -
-
- {hostList.density === "comfortable" && ( -
- )} - {hostList.showTags && ( -
-
-
-
- )} - {hostList.showResourceBars && ( -
-
-
-
- )} -
-
- ))} -
- ); -} - -export function PresetStep() { - const { t } = useTranslation(); - const ctx = useUiPreferencesContext(); - const selected = ctx?.preferences.preset ?? "balanced"; - - return ( -
-

- {t("onboarding.presetIntro")} -

- -
- {CHOICES.map((preset) => { - const Icon = ICONS[preset]; - const active = selected === preset; - return ( - - ); - })} -
- -

- {t("onboarding.presetChangeLater")} -

-
- ); -} diff --git a/src/ui/plugin-host/app.ts b/src/ui/plugin-host/app.ts index a82af22ca..0d8b963c7 100644 --- a/src/ui/plugin-host/app.ts +++ b/src/ui/plugin-host/app.ts @@ -18,6 +18,8 @@ import { import { registerHostAction, registerHostBadge, + registerHostEditorToolbarItem, + registerHostStatusIndicator, registerHostContextMenuItem, } from "@/sidebar/host-contributions"; import { @@ -335,6 +337,31 @@ export function createPluginApp( pluginId, when: hostCallback(badge.when) as never, component: scoped(badge.component) as never, + order: badge.order, + }), + ); + }, + + registerHostStatusIndicator(indicator) { + return track( + registerHostStatusIndicator({ + id: indicator.id, + pluginId, + when: hostCallback(indicator.when) as never, + component: scoped(indicator.component) as never, + order: indicator.order, + }), + ); + }, + + registerHostEditorToolbarItem(item) { + return track( + registerHostEditorToolbarItem({ + id: item.id, + pluginId, + when: hostCallback(item.when) as never, + component: scoped(item.component) as never, + order: item.order, }), ); }, @@ -525,7 +552,8 @@ export function createPluginApp( permission: step.permission ? resolvePluginPermission(pluginId, step.permission) : undefined, - position: sectionPosition(step.section, step.order), + position: sectionPosition(step.section, step.order, step.position), + replaces: step.replaces, isRelevant: when ? (ctx) => when({ diff --git a/src/ui/plugin-host/sdk-ui.ts b/src/ui/plugin-host/sdk-ui.ts index 1c9d6e894..84e9f69dc 100644 --- a/src/ui/plugin-host/sdk-ui.ts +++ b/src/ui/plugin-host/sdk-ui.ts @@ -361,14 +361,6 @@ export { APP_VERSION } from "@/lib/app-version"; // The palette's own input has to share the Command it sits in, so cmdk is // the shell's copy. export { Command as CommandPrimitive } from "cmdk"; -export { - buildExportPayload, - hostKey, - maskSecrets, - pluginSecretKeys, - type ExportPayload, - type FieldGroup, -} from "@/sidebar/host-export-payload"; export { type AuthOverrideProtocol, SSH_AUTH_PROTOCOL, @@ -416,6 +408,7 @@ export type { SSHHostData, } from "@/types"; export { + PRESETS, UI_AREA_KEYS, type UiAreaKey, type UiPreset, @@ -437,10 +430,8 @@ export type { export { type AuditLog, type AuditLogFilters, - getAuditForwarding, getAuditLogActions, getAuditLogs, - updateAuditForwarding, } from "@/api/audit-log-api"; export { getUserSecondFactors, @@ -498,11 +489,7 @@ export { shareCredential, type ShareTarget, } from "@/api/rbac-api"; -export { - type BrandingSettings, - getBranding, - updateBranding, -} from "@/api/settings-api"; +export { type BrandingSettings, getBranding } from "@/api/settings-api"; export { getSyncConflicts, getSyncErrors, @@ -645,7 +632,6 @@ export { adminUpdateUserHost, type ApiKey, assignRoleToUser, - bulkImportSSHHosts, bulkUpdateSSHHosts, changePassword, createApiKey, @@ -661,11 +647,7 @@ export { deleteRole, deleteSSHHost, deleteUser, - deployCredentialToHost, duplicateCredential, - exportAllSSHHosts, - generateKeyPair, - generatePublicKeyFromPrivate, getActiveSessions, getApiKeys, getCredentialDetails, @@ -689,7 +671,6 @@ export { getUserInfo, getUserList, getUserRoles, - importSSHConfigHosts, type LinkedAccountInfo, linkExternalToPasswordAccount, logoutUser, @@ -816,12 +797,16 @@ export { type HostActionDef, hostActionsFor, hostBadgesFor, + hostEditorToolbarItemsFor, hostMenuItemsFor, + hostStatusIndicatorsFor, listHostActions, runHostAction, useHostActions, useHostBadges, useHostContextMenuItems, + useHostEditorToolbarItems, + useHostStatusIndicators, } from "@/sidebar/host-contributions"; export { useEditorDefaultKeys } from "@/sidebar/host-defaults/catalog"; export { diff --git a/src/ui/sidebar/host-contributions.ts b/src/ui/sidebar/host-contributions.ts index 40086d4e6..39d58b8ca 100644 --- a/src/ui/sidebar/host-contributions.ts +++ b/src/ui/sidebar/host-contributions.ts @@ -45,6 +45,12 @@ export interface HostBadgeDef { order?: number; } +/** The dot or stripe on a host row that shows its status. */ +export type HostStatusIndicatorDef = HostBadgeDef; + +/** A button in the host editor's toolbar. */ +export type HostEditorToolbarItemDef = HostBadgeDef; + export interface HostContextMenuItemDef { id: string; pluginId?: string; @@ -57,6 +63,9 @@ export interface HostContextMenuItemDef { const actions = createRegistry(byOrderThenId); const badges = createRegistry(byOrderThenId); +const statusIndicators = createRegistry(byOrderThenId); +const editorToolbarItems = + createRegistry(byOrderThenId); const menuItems = createRegistry(byOrderThenId); export const registerHostAction = actions.register; @@ -69,6 +78,10 @@ export function actionOrder(action: HostActionDef): number { export const listHostActions = actions.list; export const registerHostBadge = badges.register; export const useHostBadges = badges.useList; +export const registerHostStatusIndicator = statusIndicators.register; +export const useHostStatusIndicators = statusIndicators.useList; +export const registerHostEditorToolbarItem = editorToolbarItems.register; +export const useHostEditorToolbarItems = editorToolbarItems.useList; export const registerHostContextMenuItem = menuItems.register; export const useHostContextMenuItems = menuItems.useList; @@ -105,6 +118,20 @@ export function hostBadgesFor(all: HostBadgeDef[], host: Host): HostBadgeDef[] { return all.filter((badge) => safeWhen(badge.when, host)); } +export function hostStatusIndicatorsFor( + all: HostStatusIndicatorDef[], + host: Host, +): HostStatusIndicatorDef[] { + return all.filter((item) => safeWhen(item.when, host)); +} + +export function hostEditorToolbarItemsFor( + all: HostEditorToolbarItemDef[], + host: Host, +): HostEditorToolbarItemDef[] { + return all.filter((item) => safeWhen(item.when, host)); +} + export function hostMenuItemsFor( all: HostContextMenuItemDef[], host: Host, @@ -125,5 +152,7 @@ export function runHostAction( export function resetHostContributions(): void { actions.reset(); badges.reset(); + statusIndicators.reset(); + editorToolbarItems.reset(); menuItems.reset(); } diff --git a/src/ui/sidebar/host-export-payload.ts b/src/ui/sidebar/host-export-payload.ts deleted file mode 100644 index ac92f7802..000000000 --- a/src/ui/sidebar/host-export-payload.ts +++ /dev/null @@ -1,225 +0,0 @@ -export type FieldGroup = - | "connection" - | "notes" - | "tags" - | "proxy" - | "jumpHosts" - | "featureFlags" - | "advanced"; - -export interface ExportPayload { - version?: string; - exportedAt?: string; - credentials?: Record[]; - hosts: Record[]; -} - -const FIELD_GROUP_KEYS: Record = { - connection: [ - "connectionType", - "name", - "ip", - "port", - "username", - "folder", - "domain", - "security", - "ignoreCert", - ], - notes: ["notes"], - tags: ["tags", "pin"], - proxy: [ - "useSocks5", - "socks5Host", - "socks5Port", - "socks5Username", - "socks5ProxyChain", - ], - jumpHosts: ["jumpHosts"], - // Every plugin's host settings, enable switches included. - featureFlags: ["pluginSettings", "forceKeyboardInteractive"], - advanced: [ - "statusCheckEnabled", - "statusCheckInterval", - "terminalConfig", - "sshOptions", - ], -}; - -export const SECRET_KEYS = [ - "password", - "key", - "keyPassword", - "sudoPassword", - "socks5Password", -]; - -const CREDENTIAL_KEYS = [ - ...SECRET_KEYS, - "authType", - "keyType", - "credentialAlias", - "credentialId", - "overrideCredentialUsername", -]; - -const TUPLE_KEYS = ["name", "ip", "port", "username", "connectionType"]; - -/** - * Keys inside a plugin's JSON host settings that hold secrets, per plugin and - * field, from each manifest's `secretKeys`. - */ -export type PluginSecretKeys = Record>; - -/** Reads the secret keys the plugins declare in their host settings. */ -export function pluginSecretKeys( - summaries: Array<{ - id: string; - contributes?: { - settings?: { - host?: { fields?: Array<{ key: string; secretKeys?: string[] }> }; - }; - } | null; - }>, -): PluginSecretKeys { - const result: PluginSecretKeys = {}; - for (const summary of summaries) { - for (const field of summary.contributes?.settings?.host?.fields ?? []) { - if (!field.secretKeys?.length) continue; - (result[summary.id] ??= {})[field.key] = field.secretKeys; - } - } - return result; -} - -const NESTED_SECRET_ARRAYS: { container: string; field: string }[] = [ - { container: "socks5ProxyChain", field: "password" }, -]; - -/** Rewrites each declared secret inside a plugin's JSON host settings. */ -function mapPluginSecrets( - pluginSettings: unknown, - secrets: PluginSecretKeys, - replace: (value: unknown) => unknown, -): unknown { - if (!pluginSettings || typeof pluginSettings !== "object") { - return pluginSettings; - } - const perPlugin: Record = {}; - for (const [pluginId, values] of Object.entries( - pluginSettings as Record, - )) { - const copy = { ...(values as Record) }; - for (const [field, keys] of Object.entries(secrets[pluginId] ?? {})) { - const blob = copy[field]; - if (!blob || typeof blob !== "object") continue; - const record = { ...(blob as Record) }; - for (const key of keys) { - if (key in record) record[key] = replace(record[key]); - } - copy[field] = record; - } - perPlugin[pluginId] = copy; - } - return perPlugin; -} - -export function hostKey(host: Record): string { - return JSON.stringify(TUPLE_KEYS.map((k) => String(host[k] ?? ""))); -} - -export function buildExportPayload( - raw: ExportPayload, - selected: Set | null, - groups: Set, - withCredentials: boolean, - pluginSecrets: PluginSecretKeys = {}, -): ExportPayload { - const allowed = new Set([ - "exportId", - ...CREDENTIAL_KEYS, - ...FIELD_GROUP_KEYS.connection, - ]); - for (const group of groups) { - for (const key of FIELD_GROUP_KEYS[group]) allowed.add(key); - } - - const hosts = (raw.hosts ?? []) - .filter((host) => selected === null || selected.has(hostKey(host))) - .map((host) => { - const shaped: Record = {}; - for (const [key, value] of Object.entries(host)) { - if (allowed.has(key)) shaped[key] = value; - } - if (!withCredentials) { - shaped.pluginSettings = mapPluginSecrets( - shaped.pluginSettings, - pluginSecrets, - () => null, - ); - for (const { container, field } of NESTED_SECRET_ARRAYS) { - const arr = shaped[container]; - if (Array.isArray(arr)) { - shaped[container] = arr.map((entry) => - entry && typeof entry === "object" - ? { ...(entry as Record), [field]: null } - : entry, - ); - } - } - } - return shaped; - }); - - const result: ExportPayload = { ...raw, hosts }; - - if (raw.credentials) { - const used = new Set( - hosts.map((host) => host.credentialAlias).filter(Boolean), - ); - result.credentials = raw.credentials.filter((entry) => - used.has(entry.alias), - ); - } - - return result; -} - -export function maskSecrets( - payload: ExportPayload, - pluginSecrets: PluginSecretKeys = {}, -): ExportPayload { - return { - ...payload, - hosts: payload.hosts.map((host) => { - const masked = { ...host }; - for (const key of SECRET_KEYS) { - if ( - masked[key] !== undefined && - masked[key] !== null && - masked[key] !== "" - ) { - masked[key] = ""; - } - } - masked.pluginSettings = mapPluginSecrets( - masked.pluginSettings, - pluginSecrets, - (value) => (value ? "" : value), - ); - for (const { container, field } of NESTED_SECRET_ARRAYS) { - const arr = masked[container]; - if (Array.isArray(arr)) { - masked[container] = arr.map((entry) => { - if (entry && typeof entry === "object") { - const record = entry as Record; - if (record[field]) return { ...record, [field]: "" }; - } - return entry; - }); - } - } - return masked; - }), - }; -} diff --git a/src/ui/tests/onboarding/onboarding-flow.test.ts b/src/ui/tests/onboarding/onboarding-flow.test.ts index c38b98e45..c88d048ca 100644 --- a/src/ui/tests/onboarding/onboarding-flow.test.ts +++ b/src/ui/tests/onboarding/onboarding-flow.test.ts @@ -172,3 +172,42 @@ describe("mergeStepDefs", () => { expect(sectionPosition("security")).toBeLessThan(100); }); }); + +describe("start section and explicit position", () => { + it("puts the start section before the setup section", () => { + expect(sectionPosition("start")).toBe(25); + expect(sectionPosition("start")).toBeLessThan(sectionPosition("setup")); + }); + + it("uses an explicit position over the section and clamps it", () => { + expect(sectionPosition("explore", 0, 30)).toBe(30); + expect(sectionPosition("explore", 0, -5)).toBe(1); + expect(sectionPosition("explore", 0, 500)).toBe(99); + }); +}); + +describe("replaced steps", () => { + const replacing = step("layout:preset", 30, { + pluginId: "layout", + replaces: ["preset"], + }); + + it("is not pending for someone who saw the step it replaces", () => { + const flow = computeFlow([replacing], { preset: 1 }, ctx()); + expect(flow).toEqual([]); + }); + + it("is pending for someone who saw neither", () => { + expect(ids(computeFlow([replacing], {}, ctx()))).toEqual(["layout:preset"]); + }); + + it("is pending again when its own version goes up", () => { + const bumped = { ...replacing, version: 2 }; + expect(ids(computeFlow([bumped], { preset: 1 }, ctx()))).toEqual([ + "layout:preset", + ]); + expect( + computeFlow([bumped], { preset: 1, "layout:preset": 2 }, ctx()), + ).toEqual([]); + }); +}); diff --git a/src/ui/tests/onboarding/onboarding-steps.test.ts b/src/ui/tests/onboarding/onboarding-steps.test.ts index 1b084c437..70315b20c 100644 --- a/src/ui/tests/onboarding/onboarding-steps.test.ts +++ b/src/ui/tests/onboarding/onboarding-steps.test.ts @@ -47,15 +47,13 @@ describe("CORE_STEPS", () => { } }); - it("is only choices: preset and appearance for everyone", () => { - expect(ids(ctx())).toEqual(["preset", "appearance"]); + it("has no core step for everyone: look choices come from the layout", () => { + expect(ids(ctx())).toEqual([]); }); it("opens on the plugin picker for an admin on a fresh install", () => { expect(ids(ctx({ isAdmin: true, pluginSetupPending: true }))).toEqual([ "plugins", - "preset", - "appearance", ]); }); @@ -86,11 +84,7 @@ describe("CORE_STEPS", () => { }); it("asks how the desktop app is used only on the desktop", () => { - expect(ids(ctx({ isDesktop: true }))).toEqual([ - "desktop-sync", - "preset", - "appearance", - ]); + expect(ids(ctx({ isDesktop: true }))).toEqual(["desktop-sync"]); }); it("asks how the desktop app updates when it has an updater", () => { @@ -100,8 +94,6 @@ describe("CORE_STEPS", () => { expect(ids(ctx({ isDesktop: true }))).toEqual([ "desktop-sync", "desktop-updates", - "preset", - "appearance", ]); expect(ids(ctx())).not.toContain("desktop-updates"); } finally { @@ -110,11 +102,7 @@ describe("CORE_STEPS", () => { }); it("offers account security only when there is something to set up", () => { - expect(ids(ctx({ canEnrollSecondFactor: true }))).toEqual([ - "preset", - "appearance", - "security", - ]); + expect(ids(ctx({ canEnrollSecondFactor: true }))).toEqual(["security"]); }); it("skips account security on the desktop, which signs in by itself", () => { @@ -127,8 +115,6 @@ describe("CORE_STEPS", () => { describe("onboarding translations", () => { it("has the copy the stage and the steps show", () => { for (const key of [ - "presetIntro", - "appearanceIntro", "securityIntro", "pluginsIntro", "pluginsRemoveNote", diff --git a/src/ui/tests/sidebar/host-contributions.test.ts b/src/ui/tests/sidebar/host-contributions.test.ts new file mode 100644 index 000000000..108f74e09 --- /dev/null +++ b/src/ui/tests/sidebar/host-contributions.test.ts @@ -0,0 +1,59 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + hostEditorToolbarItemsFor, + hostStatusIndicatorsFor, + registerHostBadge, + registerHostEditorToolbarItem, + registerHostStatusIndicator, + resetHostContributions, + type HostBadgeDef, +} from "@/sidebar/host-contributions"; +import type { Host } from "@/types/ui-types"; + +const Noop = () => null; +const host = { id: 1 } as unknown as Host; + +function def(id: string, extra: Partial = {}): HostBadgeDef { + return { id, when: () => true, component: Noop, ...extra }; +} + +afterEach(() => resetHostContributions()); + +describe("host status indicators and editor toolbar items", () => { + it("filters indicators by their when and survives a throwing one", () => { + registerHostStatusIndicator(def("a")); + registerHostStatusIndicator(def("b", { when: () => false })); + registerHostStatusIndicator( + def("c", { + when: () => { + throw new Error("boom"); + }, + }), + ); + const all = [ + def("a"), + def("b", { when: () => false }), + def("c", { + when: () => { + throw new Error("boom"); + }, + }), + ]; + expect(hostStatusIndicatorsFor(all, host).map((i) => i.id)).toEqual(["a"]); + }); + + it("filters toolbar items by their when", () => { + const all = [def("x"), def("y", { when: () => false })]; + expect(hostEditorToolbarItemsFor(all, host).map((i) => i.id)).toEqual([ + "x", + ]); + }); + + it("keeps the registries separate from badges", () => { + const stopBadge = registerHostBadge(def("badge")); + const stopItem = registerHostEditorToolbarItem(def("item")); + stopBadge(); + stopItem(); + expect(stopBadge).toBeTypeOf("function"); + }); +}); diff --git a/src/ui/tests/sidebar/host-export-payload.test.ts b/src/ui/tests/sidebar/host-export-payload.test.ts deleted file mode 100644 index 094df47f8..000000000 --- a/src/ui/tests/sidebar/host-export-payload.test.ts +++ /dev/null @@ -1,323 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - buildExportPayload, - hostKey, - maskSecrets, - SECRET_KEYS, - type ExportPayload, - type FieldGroup, - pluginSecretKeys, -} from "../../sidebar/host-export-payload"; - -// What the remote-desktop manifest declares for its guacamoleConfig field. -const RDP_SECRETS = { - "remote-desktop": { guacamoleConfig: ["gateway-password"] }, -}; - -const ALL_GROUPS = new Set([ - "connection", - "notes", - "tags", - "proxy", - "jumpHosts", - "featureFlags", - "advanced", -]); - -function shareRaw(): ExportPayload { - return { - version: "1", - exportedAt: "2026-07-27T00:00:00.000Z", - credentials: [ - { alias: "prod-admin", name: "prod-admin", username: "root" }, - { alias: "orphan", name: "orphan", username: "nobody" }, - ], - hosts: [ - { - connectionType: "ssh", - name: "web", - ip: "10.0.0.1", - port: 22, - username: "deploy", - folder: "prod", - password: null, - key: null, - notes: "primary", - tags: ["a"], - pin: true, - jumpHosts: [{ hostId: 3 }], - credentialAlias: "prod-admin", - authType: "credential", - }, - { - connectionType: "ssh", - name: "db", - ip: "10.0.0.2", - port: 22, - username: "deploy", - folder: "prod", - password: null, - notes: "database", - tags: [], - pin: false, - credentialAlias: "orphan", - authType: "credential", - }, - ], - }; -} - -function gatewayConfig(host: Record): Record { - const plugins = host.pluginSettings as Record< - string, - Record - >; - return plugins["remote-desktop"].guacamoleConfig as Record; -} - -function rdpRaw(): ExportPayload { - return { - hosts: [ - { - connectionType: "rdp", - name: "desk", - ip: "10.0.0.9", - port: 3389, - username: "admin", - password: null, - pluginSettings: { - "remote-desktop": { - guacamoleConfig: { - "gateway-hostname": "gw.example.com", - "gateway-password": "gw-secret", - }, - }, - }, - }, - ], - }; -} - -function socks5Raw(): ExportPayload { - return { - hosts: [ - { - connectionType: "ssh", - name: "jump", - ip: "10.0.0.5", - port: 22, - username: "admin", - password: null, - useSocks5: true, - socks5ProxyChain: [ - { - host: "proxy1", - port: 1080, - type: 5, - username: "u1", - password: "proxy-secret-1", - }, - { host: "proxy2", port: 1080, type: 5, password: "proxy-secret-2" }, - ], - }, - ], - }; -} - -describe("hostKey", () => { - it("distinguishes hosts differing in any tuple field", () => { - const a = { - name: "x", - ip: "1", - port: 22, - username: "u", - connectionType: "ssh", - }; - expect(hostKey(a)).toBe(hostKey({ ...a })); - expect(hostKey(a)).not.toBe(hostKey({ ...a, port: 2222 })); - expect(hostKey(a)).not.toBe(hostKey({ ...a, connectionType: "rdp" })); - }); -}); - -describe("buildExportPayload", () => { - it("includes every host when selection is null", () => { - const out = buildExportPayload(shareRaw(), null, ALL_GROUPS, false); - expect(out.hosts.map((h) => h.name)).toEqual(["web", "db"]); - }); - - it("includes only the selected hosts", () => { - const raw = shareRaw(); - const selected = new Set([hostKey(raw.hosts[0])]); - const out = buildExportPayload(raw, selected, ALL_GROUPS, false); - expect(out.hosts.map((h) => h.name)).toEqual(["web"]); - }); - - it("drops keys belonging to disabled groups", () => { - const out = buildExportPayload( - shareRaw(), - null, - new Set(["connection"]), - false, - ); - expect(out.hosts[0].name).toBe("web"); - expect(out.hosts[0]).not.toHaveProperty("notes"); - expect(out.hosts[0]).not.toHaveProperty("tags"); - expect(out.hosts[0]).not.toHaveProperty("jumpHosts"); - }); - - it("prunes credentials whose only referencing host was deselected", () => { - const raw = shareRaw(); - const selected = new Set([hostKey(raw.hosts[0])]); - const out = buildExportPayload(raw, selected, ALL_GROUPS, false); - expect(out.credentials?.map((c) => c.alias)).toEqual(["prod-admin"]); - }); - - it("keeps credential metadata regardless of enabled groups", () => { - const out = buildExportPayload( - shareRaw(), - null, - new Set(["connection"]), - false, - ); - expect(out.hosts[0].credentialAlias).toBe("prod-admin"); - expect(out.hosts[0].authType).toBe("credential"); - }); - - it("never emits a secret value when the source has none", () => { - const out = buildExportPayload(shareRaw(), null, ALL_GROUPS, false); - for (const host of out.hosts) { - for (const secret of SECRET_KEYS) { - expect(host[secret] ?? null).toBeNull(); - } - } - }); - - it("selects hosts identical across all tuple fields together", () => { - const raw = shareRaw(); - raw.hosts[1] = { ...raw.hosts[0], notes: "different" }; - const selected = new Set([hostKey(raw.hosts[0])]); - const out = buildExportPayload(raw, selected, ALL_GROUPS, false); - expect(out.hosts).toHaveLength(2); - }); - - it("passes a real secret through untouched when credentials are included", () => { - const raw: ExportPayload = { - hosts: [{ name: "web", ip: "1", port: 22, password: "hunter2" }], - }; - const out = buildExportPayload( - raw, - null, - new Set(["connection"]), - true, - ); - expect(out.hosts[0].password).toBe("hunter2"); - }); - - it("nulls the guacamole gateway password when credentials are excluded", () => { - const out = buildExportPayload( - rdpRaw(), - null, - ALL_GROUPS, - false, - RDP_SECRETS, - ); - const config = gatewayConfig(out.hosts[0]); - expect(config["gateway-password"]).toBeNull(); - expect(config["gateway-hostname"]).toBe("gw.example.com"); - }); - - it("keeps the guacamole gateway password when credentials are included", () => { - const out = buildExportPayload(rdpRaw(), null, ALL_GROUPS, true); - const config = gatewayConfig(out.hosts[0]); - expect(config["gateway-password"]).toBe("gw-secret"); - }); - - it("does not mutate the source payload when nulling nested secrets", () => { - const raw = rdpRaw(); - buildExportPayload(raw, null, ALL_GROUPS, false, RDP_SECRETS); - const config = gatewayConfig(raw.hosts[0]); - expect(config["gateway-password"]).toBe("gw-secret"); - }); - - it("nulls socks5 proxy chain passwords when credentials are excluded", () => { - const out = buildExportPayload(socks5Raw(), null, ALL_GROUPS, false); - const chain = out.hosts[0].socks5ProxyChain as Record[]; - expect(chain[0].password).toBeNull(); - expect(chain[1].password).toBeNull(); - expect(chain[0].host).toBe("proxy1"); - }); - - it("keeps socks5 proxy chain passwords when credentials are included", () => { - const out = buildExportPayload(socks5Raw(), null, ALL_GROUPS, true); - const chain = out.hosts[0].socks5ProxyChain as Record[]; - expect(chain[0].password).toBe("proxy-secret-1"); - expect(chain[1].password).toBe("proxy-secret-2"); - }); - - it("does not mutate the source payload when nulling socks5 proxy chain passwords", () => { - const raw = socks5Raw(); - buildExportPayload(raw, null, ALL_GROUPS, false); - const chain = raw.hosts[0].socks5ProxyChain as Record[]; - expect(chain[0].password).toBe("proxy-secret-1"); - expect(chain[1].password).toBe("proxy-secret-2"); - }); -}); - -describe("maskSecrets", () => { - it("replaces present secret values and leaves nulls alone", () => { - const raw: ExportPayload = { - hosts: [{ name: "web", password: "hunter2", key: null }], - }; - const out = maskSecrets(raw); - expect(out.hosts[0].password).toBe(""); - expect(out.hosts[0].key).toBeNull(); - }); - - it("leaks no source secret into its output", () => { - const raw: ExportPayload = { - hosts: [{ name: "web", password: "hunter2", sudoPassword: "s3cret" }], - }; - expect(JSON.stringify(maskSecrets(raw))).not.toContain("hunter2"); - expect(JSON.stringify(maskSecrets(raw))).not.toContain("s3cret"); - }); - - it("masks the guacamole gateway password", () => { - const out = maskSecrets(rdpRaw(), RDP_SECRETS); - const config = gatewayConfig(out.hosts[0]); - expect(config["gateway-password"]).toBe(""); - expect(config["gateway-hostname"]).toBe("gw.example.com"); - expect(JSON.stringify(out)).not.toContain("gw-secret"); - }); - - it("masks socks5 proxy chain passwords", () => { - const out = maskSecrets(socks5Raw()); - const chain = out.hosts[0].socks5ProxyChain as Record[]; - expect(chain[0].password).toBe(""); - expect(chain[1].password).toBe(""); - expect(JSON.stringify(out)).not.toContain("proxy-secret-1"); - expect(JSON.stringify(out)).not.toContain("proxy-secret-2"); - }); -}); - -describe("pluginSecretKeys", () => { - it("reads secretKeys off each plugin's host fields", () => { - expect( - pluginSecretKeys([ - { - id: "remote-desktop", - contributes: { - settings: { - host: { - fields: [ - { key: "guacamoleConfig", secretKeys: ["gateway-password"] }, - { key: "enableRdp" }, - ], - }, - }, - }, - }, - { id: "docker", contributes: null }, - ]), - ).toEqual(RDP_SECRETS); - }); -}); diff --git a/src/ui/tests/types/onboarding.test.ts b/src/ui/tests/types/onboarding.test.ts index 13c235fc6..cfc7a7e7c 100644 --- a/src/ui/tests/types/onboarding.test.ts +++ b/src/ui/tests/types/onboarding.test.ts @@ -39,8 +39,14 @@ describe("onboarding seen map", () => { it("legacy seen covers every core step at launch", () => { // Steps added later stay out so upgraded users still see them. + // preset and appearance moved to a plugin but stay in LEGACY_SEEN. const addedLater = ["desktop-updates"]; - expect(Object.keys(LEGACY_SEEN).sort()).toEqual( + const movedOut = ["preset", "appearance"]; + expect( + Object.keys(LEGACY_SEEN) + .filter((key) => !movedOut.includes(key)) + .sort(), + ).toEqual( Object.keys(CORE_ONBOARDING_STEPS) .filter((key) => !addedLater.includes(key)) .sort(),