From 98d367d7e2022db93a9fa48f4b06b141f6849162 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Thu, 24 Sep 2026 19:28:19 -0500 Subject: [PATCH] feat: move opkssh and warpgate into plugins (C3) --- docker/Dockerfile | 7 +- docker/entrypoint.sh | 21 +- docker/nginx-https.conf | 34 - docker/nginx.conf | 32 - drizzle/mysql/0053_magical_groot.sql | 5 + drizzle/mysql/meta/0053_snapshot.json | 5311 +++++++++++++++++ drizzle/mysql/meta/_journal.json | 7 + drizzle/postgres/0054_cloudy_galactus.sql | 5 + drizzle/postgres/meta/0054_snapshot.json | 5189 ++++++++++++++++ drizzle/postgres/meta/_journal.json | 7 + drizzle/sqlite/0050_clear_white_queen.sql | 5 + drizzle/sqlite/meta/0050_snapshot.json | 5015 ++++++++++++++++ drizzle/sqlite/meta/_journal.json | 7 + package-lock.json | 28 +- package.json | 2 - packages/plugin-sdk/ARCHITECTURE.md | 191 +- packages/plugin-sdk/FINISH-LIST.md | 54 +- packages/plugin-sdk/package.json | 4 + .../plugin-sdk/schema/manifest.schema.json | 4 + packages/plugin-sdk/src/backend.ts | 132 +- packages/plugin-sdk/src/manifest.ts | 14 +- .../plugin-sdk/src/ssh-certs.ts | 115 +- packages/plugin-sdk/src/testing.ts | 117 +- plugins/docker/locales/en.json | 2 +- plugins/docker/locales/translated/af_ZA.json | 1 - plugins/docker/locales/translated/ar_SA.json | 1 - plugins/docker/locales/translated/bg_BG.json | 1 - plugins/docker/locales/translated/bn_BD.json | 1 - plugins/docker/locales/translated/ca_ES.json | 1 - plugins/docker/locales/translated/cs_CZ.json | 1 - plugins/docker/locales/translated/da_DK.json | 1 - plugins/docker/locales/translated/de_DE.json | 1 - plugins/docker/locales/translated/el_GR.json | 1 - plugins/docker/locales/translated/es_ES.json | 1 - plugins/docker/locales/translated/fi_FI.json | 1 - plugins/docker/locales/translated/fr_FR.json | 1 - plugins/docker/locales/translated/he_IL.json | 1 - plugins/docker/locales/translated/hi_IN.json | 1 - plugins/docker/locales/translated/hu_HU.json | 1 - plugins/docker/locales/translated/id_ID.json | 1 - plugins/docker/locales/translated/it_IT.json | 1 - plugins/docker/locales/translated/ja_JP.json | 1 - plugins/docker/locales/translated/ko_KR.json | 1 - plugins/docker/locales/translated/nl_NL.json | 1 - plugins/docker/locales/translated/no_NO.json | 1 - plugins/docker/locales/translated/pl_PL.json | 1 - plugins/docker/locales/translated/pt_BR.json | 1 - plugins/docker/locales/translated/pt_PT.json | 1 - plugins/docker/locales/translated/ro_RO.json | 1 - plugins/docker/locales/translated/ru_RU.json | 1 - plugins/docker/locales/translated/sr_SP.json | 1 - plugins/docker/locales/translated/sv_SE.json | 1 - plugins/docker/locales/translated/th_TH.json | 1 - plugins/docker/locales/translated/tr_TR.json | 1 - plugins/docker/locales/translated/uk_UA.json | 1 - plugins/docker/locales/translated/vi_VN.json | 1 - plugins/docker/locales/translated/zh_CN.json | 1 - plugins/docker/locales/translated/zh_TW.json | 1 - plugins/docker/src/backend/connect.ts | 29 +- plugins/docker/src/backend/routes.ts | 14 +- plugins/docker/src/backend/sessions.ts | 2 +- plugins/docker/src/frontend/DockerManager.tsx | 39 +- plugins/docker/src/frontend/docker-api.ts | 11 +- plugins/docker/tests/backend/routes.test.ts | 21 +- plugins/file-manager/locales/en.json | 2 +- .../locales/translated/af_ZA.json | 1 - .../locales/translated/ar_SA.json | 1 - .../locales/translated/bg_BG.json | 1 - .../locales/translated/bn_BD.json | 1 - .../locales/translated/ca_ES.json | 1 - .../locales/translated/cs_CZ.json | 1 - .../locales/translated/da_DK.json | 1 - .../locales/translated/de_DE.json | 1 - .../locales/translated/el_GR.json | 1 - .../locales/translated/es_ES.json | 1 - .../locales/translated/fi_FI.json | 1 - .../locales/translated/fr_FR.json | 1 - .../locales/translated/he_IL.json | 1 - .../locales/translated/hi_IN.json | 1 - .../locales/translated/hu_HU.json | 1 - .../locales/translated/id_ID.json | 1 - .../locales/translated/it_IT.json | 1 - .../locales/translated/ja_JP.json | 1 - .../locales/translated/ko_KR.json | 1 - .../locales/translated/nl_NL.json | 1 - .../locales/translated/no_NO.json | 1 - .../locales/translated/pl_PL.json | 1 - .../locales/translated/pt_BR.json | 1 - .../locales/translated/pt_PT.json | 1 - .../locales/translated/ro_RO.json | 1 - .../locales/translated/ru_RU.json | 1 - .../locales/translated/sr_SP.json | 1 - .../locales/translated/sv_SE.json | 1 - .../locales/translated/th_TH.json | 1 - .../locales/translated/tr_TR.json | 1 - .../locales/translated/uk_UA.json | 1 - .../locales/translated/vi_VN.json | 1 - .../locales/translated/zh_CN.json | 1 - .../locales/translated/zh_TW.json | 1 - plugins/file-manager/src/backend/index.ts | 59 +- plugins/file-manager/src/backend/session.ts | 2 +- plugins/file-manager/src/backend/ssh.ts | 2 +- .../file-manager/src/frontend/FileManager.tsx | 106 +- .../src/frontend/FileManagerDialogs.tsx | 43 +- .../frontend/api/ssh-file-operations-api.ts | 25 +- .../src/frontend/file-manager-types.ts | 5 +- plugins/opkssh/README.md | 34 + plugins/opkssh/locales/en.json | 29 + plugins/opkssh/locales/translated/af_ZA.json | 19 + plugins/opkssh/locales/translated/ar_SA.json | 19 + plugins/opkssh/locales/translated/bg_BG.json | 19 + plugins/opkssh/locales/translated/bn_BD.json | 19 + plugins/opkssh/locales/translated/ca_ES.json | 19 + plugins/opkssh/locales/translated/cs_CZ.json | 19 + plugins/opkssh/locales/translated/da_DK.json | 19 + plugins/opkssh/locales/translated/de_DE.json | 19 + plugins/opkssh/locales/translated/el_GR.json | 19 + plugins/opkssh/locales/translated/es_ES.json | 19 + plugins/opkssh/locales/translated/fi_FI.json | 19 + plugins/opkssh/locales/translated/fr_FR.json | 19 + plugins/opkssh/locales/translated/he_IL.json | 19 + plugins/opkssh/locales/translated/hi_IN.json | 19 + plugins/opkssh/locales/translated/hu_HU.json | 19 + plugins/opkssh/locales/translated/id_ID.json | 19 + plugins/opkssh/locales/translated/it_IT.json | 19 + plugins/opkssh/locales/translated/ja_JP.json | 19 + plugins/opkssh/locales/translated/ko_KR.json | 19 + plugins/opkssh/locales/translated/nl_NL.json | 19 + plugins/opkssh/locales/translated/no_NO.json | 19 + plugins/opkssh/locales/translated/pl_PL.json | 19 + plugins/opkssh/locales/translated/pt_BR.json | 19 + plugins/opkssh/locales/translated/pt_PT.json | 19 + plugins/opkssh/locales/translated/ro_RO.json | 19 + plugins/opkssh/locales/translated/ru_RU.json | 19 + plugins/opkssh/locales/translated/sr_SP.json | 19 + plugins/opkssh/locales/translated/sv_SE.json | 19 + plugins/opkssh/locales/translated/th_TH.json | 19 + plugins/opkssh/locales/translated/tr_TR.json | 19 + plugins/opkssh/locales/translated/uk_UA.json | 19 + plugins/opkssh/locales/translated/vi_VN.json | 19 + plugins/opkssh/locales/translated/zh_CN.json | 19 + plugins/opkssh/locales/translated/zh_TW.json | 19 + plugins/opkssh/manifest.json | 51 + .../mysql/0001_adopt_opkssh_tokens.sql | 20 + .../postgres/0001_adopt_opkssh_tokens.sql | 21 + plugins/opkssh/migrations/snapshot.json | 64 + .../sqlite/0001_adopt_opkssh_tokens.sql | 21 + plugins/opkssh/package.json | 19 + plugins/opkssh/src/backend/auth-session.ts | 447 ++ plugins/opkssh/src/backend/binary.ts | 77 + plugins/opkssh/src/backend/config.ts | 161 + .../opkssh/src/backend/html.ts | 43 +- plugins/opkssh/src/backend/index.ts | 47 + plugins/opkssh/src/backend/provider.ts | 88 + plugins/opkssh/src/backend/routes.ts | 622 ++ plugins/opkssh/src/backend/tables.ts | 48 + plugins/opkssh/src/backend/token-store.ts | 142 + .../opkssh/src/frontend/OpksshAuthEditor.tsx | 28 + .../opkssh/src/frontend/OpksshDialog.tsx | 41 +- plugins/opkssh/src/frontend/OpksshOverlay.tsx | 202 + .../src/frontend/RedirectUriSetting.tsx | 54 + plugins/opkssh/src/frontend/index.tsx | 25 + .../opkssh/tests/backend/helpers-pure.test.ts | 110 + plugins/opkssh/tests/backend/helpers.ts | 193 + plugins/opkssh/tests/backend/opkssh.test.ts | 341 ++ .../opkssh/tests/frontend/activate.test.tsx | 128 + plugins/opkssh/tsconfig.json | 4 + plugins/opkssh/vitest.config.ts | 3 + plugins/ssh-terminal/locales/en.json | 2 - .../locales/translated/af_ZA.json | 2 - .../locales/translated/ar_SA.json | 2 - .../locales/translated/bg_BG.json | 2 - .../locales/translated/bn_BD.json | 2 - .../locales/translated/ca_ES.json | 2 - .../locales/translated/cs_CZ.json | 2 - .../locales/translated/da_DK.json | 2 - .../locales/translated/de_DE.json | 2 - .../locales/translated/el_GR.json | 2 - .../locales/translated/es_ES.json | 2 - .../locales/translated/fi_FI.json | 2 - .../locales/translated/fr_FR.json | 2 - .../locales/translated/he_IL.json | 2 - .../locales/translated/hi_IN.json | 2 - .../locales/translated/hu_HU.json | 2 - .../locales/translated/id_ID.json | 2 - .../locales/translated/it_IT.json | 2 - .../locales/translated/ja_JP.json | 2 - .../locales/translated/ko_KR.json | 2 - .../locales/translated/nl_NL.json | 2 - .../locales/translated/no_NO.json | 2 - .../locales/translated/pl_PL.json | 2 - .../locales/translated/pt_BR.json | 2 - .../locales/translated/pt_PT.json | 2 - .../locales/translated/ro_RO.json | 2 - .../locales/translated/ru_RU.json | 2 - .../locales/translated/sr_SP.json | 2 - .../locales/translated/sv_SE.json | 2 - .../locales/translated/th_TH.json | 2 - .../locales/translated/tr_TR.json | 2 - .../locales/translated/uk_UA.json | 2 - .../locales/translated/vi_VN.json | 2 - .../locales/translated/zh_CN.json | 2 - .../locales/translated/zh_TW.json | 2 - .../src/backend/keyboard-prompt.ts | 61 +- .../src/backend/terminal-socket.ts | 332 +- .../src/frontend/terminal/Terminal.tsx | 308 +- .../src/frontend/terminal/terminal-slots.ts | 9 +- .../tests/backend/keyboard-prompt.test.ts | 30 +- plugins/warpgate/locales/en.json | 10 + .../warpgate/locales/translated/af_ZA.json | 10 + .../warpgate/locales/translated/ar_SA.json | 10 + .../warpgate/locales/translated/bg_BG.json | 10 + .../warpgate/locales/translated/bn_BD.json | 10 + .../warpgate/locales/translated/ca_ES.json | 10 + .../warpgate/locales/translated/cs_CZ.json | 10 + .../warpgate/locales/translated/da_DK.json | 10 + .../warpgate/locales/translated/de_DE.json | 10 + .../warpgate/locales/translated/el_GR.json | 10 + .../warpgate/locales/translated/es_ES.json | 10 + .../warpgate/locales/translated/fi_FI.json | 10 + .../warpgate/locales/translated/fr_FR.json | 10 + .../warpgate/locales/translated/he_IL.json | 10 + .../warpgate/locales/translated/hi_IN.json | 10 + .../warpgate/locales/translated/hu_HU.json | 10 + .../warpgate/locales/translated/id_ID.json | 10 + .../warpgate/locales/translated/it_IT.json | 10 + .../warpgate/locales/translated/ja_JP.json | 10 + .../warpgate/locales/translated/ko_KR.json | 10 + .../warpgate/locales/translated/nl_NL.json | 10 + .../warpgate/locales/translated/no_NO.json | 10 + .../warpgate/locales/translated/pl_PL.json | 10 + .../warpgate/locales/translated/pt_BR.json | 10 + .../warpgate/locales/translated/pt_PT.json | 10 + .../warpgate/locales/translated/ro_RO.json | 10 + .../warpgate/locales/translated/ru_RU.json | 10 + .../warpgate/locales/translated/sr_SP.json | 10 + .../warpgate/locales/translated/sv_SE.json | 10 + .../warpgate/locales/translated/th_TH.json | 10 + .../warpgate/locales/translated/tr_TR.json | 10 + .../warpgate/locales/translated/uk_UA.json | 10 + .../warpgate/locales/translated/vi_VN.json | 10 + .../warpgate/locales/translated/zh_CN.json | 10 + .../warpgate/locales/translated/zh_TW.json | 10 + plugins/warpgate/manifest.json | 38 + plugins/warpgate/package.json | 15 + plugins/warpgate/src/backend/detect.ts | 37 + plugins/warpgate/src/backend/host-import.ts | 14 + plugins/warpgate/src/backend/index.ts | 22 + plugins/warpgate/src/frontend/index.tsx | 5 + .../warpgate/tests/backend/warpgate.test.ts | 113 + plugins/warpgate/tsconfig.json | 4 + plugins/warpgate/vitest.config.ts | 3 + public/sw.js | 5 +- src/backend/auth/legacy-providers.ts | 202 +- src/backend/database/db/index.ts | 49 - src/backend/database/db/schema.mysql.ts | 32 - src/backend/database/db/schema.pg.ts | 32 - src/backend/database/db/schema.ts | 32 - src/backend/database/repositories/factory.ts | 8 - .../repositories/opkssh-token-repository.ts | 126 - .../database/routes/delete-user-data.ts | 5 +- .../database/routes/host-compat-routes.ts | 36 + .../database/routes/host-normalizers.ts | 1 - .../database/routes/host-opkssh-routes.ts | 841 --- .../database/routes/host-step-ca-routes.ts | 10 +- src/backend/database/routes/host.ts | 153 +- .../hosts/connect/auth-provider-registry.ts | 6 + .../hosts/connect/builtin-providers.ts | 13 +- .../hosts/connect/keyboard-interactive.ts | 9 +- src/backend/hosts/connect/types.ts | 38 +- src/backend/hosts/file-manager/index.ts | 6 +- src/backend/hosts/host-resolver.ts | 15 + src/backend/hosts/opkssh-auth.ts | 937 --- src/backend/hosts/step-ca-auth.ts | 92 +- src/backend/hosts/vault-signer-auth.ts | 4 +- src/backend/plugins/ctx-process.ts | 212 + src/backend/plugins/ctx-ssh-auth.ts | 38 + src/backend/plugins/ctx.ts | 6 + src/backend/plugins/http.ts | 8 +- src/backend/starter.ts | 24 +- .../opkssh-token-repository.test.ts | 105 - .../routes/host-compat-routes.test.ts | 51 + .../tests/hosts/connect/pipeline.test.ts | 85 +- src/backend/tests/hosts/step-ca-auth.test.ts | 34 +- src/backend/tests/plugins/ctx-process.test.ts | 197 + .../tests/plugins/ctx-ssh-auth.test.ts | 97 + src/backend/tests/plugins/http.test.ts | 18 + .../tests/utils/opkssh-binary-manager.test.ts | 97 - .../utils/opkssh-config-migration.test.ts | 96 + .../utils/warpgate-settings-migration.test.ts | 161 + .../opkssh-config-migration.ts | 95 + .../crypto-migration/plugin-data-moves.ts | 2 + .../warpgate-settings-migration.ts | 131 + src/backend/utils/field-crypto.ts | 1 - src/backend/utils/opkssh-binary-manager.ts | 353 -- src/types/index.ts | 2 - src/types/ui-types.ts | 1 - src/ui/auth/legacy-auth-ui.tsx | 16 - src/ui/locales/en.json | 21 +- src/ui/locales/translated/af_ZA.json | 21 +- src/ui/locales/translated/ar_SA.json | 21 +- src/ui/locales/translated/bg_BG.json | 21 +- src/ui/locales/translated/bn_BD.json | 21 +- src/ui/locales/translated/ca_ES.json | 21 +- src/ui/locales/translated/cs_CZ.json | 21 +- src/ui/locales/translated/da_DK.json | 21 +- src/ui/locales/translated/de_DE.json | 21 +- src/ui/locales/translated/el_GR.json | 21 +- src/ui/locales/translated/es_ES.json | 21 +- src/ui/locales/translated/fi_FI.json | 21 +- src/ui/locales/translated/fr_FR.json | 21 +- src/ui/locales/translated/he_IL.json | 21 +- src/ui/locales/translated/hi_IN.json | 21 +- src/ui/locales/translated/hu_HU.json | 21 +- src/ui/locales/translated/id_ID.json | 21 +- src/ui/locales/translated/it_IT.json | 21 +- src/ui/locales/translated/ja_JP.json | 21 +- src/ui/locales/translated/ko_KR.json | 21 +- src/ui/locales/translated/nl_NL.json | 21 +- src/ui/locales/translated/no_NO.json | 21 +- src/ui/locales/translated/pl_PL.json | 21 +- src/ui/locales/translated/pt_BR.json | 21 +- src/ui/locales/translated/pt_PT.json | 21 +- src/ui/locales/translated/ro_RO.json | 21 +- src/ui/locales/translated/ru_RU.json | 21 +- src/ui/locales/translated/sr_SP.json | 21 +- src/ui/locales/translated/sv_SE.json | 21 +- src/ui/locales/translated/th_TH.json | 21 +- src/ui/locales/translated/tr_TR.json | 21 +- src/ui/locales/translated/uk_UA.json | 21 +- src/ui/locales/translated/vi_VN.json | 21 +- src/ui/locales/translated/zh_CN.json | 21 +- src/ui/locales/translated/zh_TW.json | 21 +- src/ui/plugin-host/sdk-ui.ts | 3 +- src/ui/sidebar/HostEditorData.ts | 2 - ...gateDialog.tsx => BrowserSignInDialog.tsx} | 48 +- 336 files changed, 22380 insertions(+), 4746 deletions(-) create mode 100644 drizzle/mysql/0053_magical_groot.sql create mode 100644 drizzle/mysql/meta/0053_snapshot.json create mode 100644 drizzle/postgres/0054_cloudy_galactus.sql create mode 100644 drizzle/postgres/meta/0054_snapshot.json create mode 100644 drizzle/sqlite/0050_clear_white_queen.sql create mode 100644 drizzle/sqlite/meta/0050_snapshot.json rename src/backend/hosts/opkssh-cert-auth.ts => packages/plugin-sdk/src/ssh-certs.ts (73%) create mode 100644 plugins/opkssh/README.md create mode 100644 plugins/opkssh/locales/en.json create mode 100644 plugins/opkssh/locales/translated/af_ZA.json create mode 100644 plugins/opkssh/locales/translated/ar_SA.json create mode 100644 plugins/opkssh/locales/translated/bg_BG.json create mode 100644 plugins/opkssh/locales/translated/bn_BD.json create mode 100644 plugins/opkssh/locales/translated/ca_ES.json create mode 100644 plugins/opkssh/locales/translated/cs_CZ.json create mode 100644 plugins/opkssh/locales/translated/da_DK.json create mode 100644 plugins/opkssh/locales/translated/de_DE.json create mode 100644 plugins/opkssh/locales/translated/el_GR.json create mode 100644 plugins/opkssh/locales/translated/es_ES.json create mode 100644 plugins/opkssh/locales/translated/fi_FI.json create mode 100644 plugins/opkssh/locales/translated/fr_FR.json create mode 100644 plugins/opkssh/locales/translated/he_IL.json create mode 100644 plugins/opkssh/locales/translated/hi_IN.json create mode 100644 plugins/opkssh/locales/translated/hu_HU.json create mode 100644 plugins/opkssh/locales/translated/id_ID.json create mode 100644 plugins/opkssh/locales/translated/it_IT.json create mode 100644 plugins/opkssh/locales/translated/ja_JP.json create mode 100644 plugins/opkssh/locales/translated/ko_KR.json create mode 100644 plugins/opkssh/locales/translated/nl_NL.json create mode 100644 plugins/opkssh/locales/translated/no_NO.json create mode 100644 plugins/opkssh/locales/translated/pl_PL.json create mode 100644 plugins/opkssh/locales/translated/pt_BR.json create mode 100644 plugins/opkssh/locales/translated/pt_PT.json create mode 100644 plugins/opkssh/locales/translated/ro_RO.json create mode 100644 plugins/opkssh/locales/translated/ru_RU.json create mode 100644 plugins/opkssh/locales/translated/sr_SP.json create mode 100644 plugins/opkssh/locales/translated/sv_SE.json create mode 100644 plugins/opkssh/locales/translated/th_TH.json create mode 100644 plugins/opkssh/locales/translated/tr_TR.json create mode 100644 plugins/opkssh/locales/translated/uk_UA.json create mode 100644 plugins/opkssh/locales/translated/vi_VN.json create mode 100644 plugins/opkssh/locales/translated/zh_CN.json create mode 100644 plugins/opkssh/locales/translated/zh_TW.json create mode 100644 plugins/opkssh/manifest.json create mode 100644 plugins/opkssh/migrations/mysql/0001_adopt_opkssh_tokens.sql create mode 100644 plugins/opkssh/migrations/postgres/0001_adopt_opkssh_tokens.sql create mode 100644 plugins/opkssh/migrations/snapshot.json create mode 100644 plugins/opkssh/migrations/sqlite/0001_adopt_opkssh_tokens.sql create mode 100644 plugins/opkssh/package.json create mode 100644 plugins/opkssh/src/backend/auth-session.ts create mode 100644 plugins/opkssh/src/backend/binary.ts create mode 100644 plugins/opkssh/src/backend/config.ts rename src/backend/database/routes/opkssh-html.ts => plugins/opkssh/src/backend/html.ts (85%) create mode 100644 plugins/opkssh/src/backend/index.ts create mode 100644 plugins/opkssh/src/backend/provider.ts create mode 100644 plugins/opkssh/src/backend/routes.ts create mode 100644 plugins/opkssh/src/backend/tables.ts create mode 100644 plugins/opkssh/src/backend/token-store.ts create mode 100644 plugins/opkssh/src/frontend/OpksshAuthEditor.tsx rename src/ui/ssh/dialogs/OPKSSHDialog.tsx => plugins/opkssh/src/frontend/OpksshDialog.tsx (84%) create mode 100644 plugins/opkssh/src/frontend/OpksshOverlay.tsx create mode 100644 plugins/opkssh/src/frontend/RedirectUriSetting.tsx create mode 100644 plugins/opkssh/src/frontend/index.tsx create mode 100644 plugins/opkssh/tests/backend/helpers-pure.test.ts create mode 100644 plugins/opkssh/tests/backend/helpers.ts create mode 100644 plugins/opkssh/tests/backend/opkssh.test.ts create mode 100644 plugins/opkssh/tests/frontend/activate.test.tsx create mode 100644 plugins/opkssh/tsconfig.json create mode 100644 plugins/opkssh/vitest.config.ts create mode 100644 plugins/warpgate/locales/en.json create mode 100644 plugins/warpgate/locales/translated/af_ZA.json create mode 100644 plugins/warpgate/locales/translated/ar_SA.json create mode 100644 plugins/warpgate/locales/translated/bg_BG.json create mode 100644 plugins/warpgate/locales/translated/bn_BD.json create mode 100644 plugins/warpgate/locales/translated/ca_ES.json create mode 100644 plugins/warpgate/locales/translated/cs_CZ.json create mode 100644 plugins/warpgate/locales/translated/da_DK.json create mode 100644 plugins/warpgate/locales/translated/de_DE.json create mode 100644 plugins/warpgate/locales/translated/el_GR.json create mode 100644 plugins/warpgate/locales/translated/es_ES.json create mode 100644 plugins/warpgate/locales/translated/fi_FI.json create mode 100644 plugins/warpgate/locales/translated/fr_FR.json create mode 100644 plugins/warpgate/locales/translated/he_IL.json create mode 100644 plugins/warpgate/locales/translated/hi_IN.json create mode 100644 plugins/warpgate/locales/translated/hu_HU.json create mode 100644 plugins/warpgate/locales/translated/id_ID.json create mode 100644 plugins/warpgate/locales/translated/it_IT.json create mode 100644 plugins/warpgate/locales/translated/ja_JP.json create mode 100644 plugins/warpgate/locales/translated/ko_KR.json create mode 100644 plugins/warpgate/locales/translated/nl_NL.json create mode 100644 plugins/warpgate/locales/translated/no_NO.json create mode 100644 plugins/warpgate/locales/translated/pl_PL.json create mode 100644 plugins/warpgate/locales/translated/pt_BR.json create mode 100644 plugins/warpgate/locales/translated/pt_PT.json create mode 100644 plugins/warpgate/locales/translated/ro_RO.json create mode 100644 plugins/warpgate/locales/translated/ru_RU.json create mode 100644 plugins/warpgate/locales/translated/sr_SP.json create mode 100644 plugins/warpgate/locales/translated/sv_SE.json create mode 100644 plugins/warpgate/locales/translated/th_TH.json create mode 100644 plugins/warpgate/locales/translated/tr_TR.json create mode 100644 plugins/warpgate/locales/translated/uk_UA.json create mode 100644 plugins/warpgate/locales/translated/vi_VN.json create mode 100644 plugins/warpgate/locales/translated/zh_CN.json create mode 100644 plugins/warpgate/locales/translated/zh_TW.json create mode 100644 plugins/warpgate/manifest.json create mode 100644 plugins/warpgate/package.json create mode 100644 plugins/warpgate/src/backend/detect.ts create mode 100644 plugins/warpgate/src/backend/host-import.ts create mode 100644 plugins/warpgate/src/backend/index.ts create mode 100644 plugins/warpgate/src/frontend/index.tsx create mode 100644 plugins/warpgate/tests/backend/warpgate.test.ts create mode 100644 plugins/warpgate/tsconfig.json create mode 100644 plugins/warpgate/vitest.config.ts delete mode 100644 src/backend/database/repositories/opkssh-token-repository.ts create mode 100644 src/backend/database/routes/host-compat-routes.ts delete mode 100644 src/backend/database/routes/host-opkssh-routes.ts delete mode 100644 src/backend/hosts/opkssh-auth.ts create mode 100644 src/backend/plugins/ctx-process.ts delete mode 100644 src/backend/tests/database/repositories/opkssh-token-repository.test.ts create mode 100644 src/backend/tests/database/routes/host-compat-routes.test.ts create mode 100644 src/backend/tests/plugins/ctx-process.test.ts delete mode 100644 src/backend/tests/utils/opkssh-binary-manager.test.ts create mode 100644 src/backend/tests/utils/opkssh-config-migration.test.ts create mode 100644 src/backend/tests/utils/warpgate-settings-migration.test.ts create mode 100644 src/backend/utils/crypto-migration/opkssh-config-migration.ts create mode 100644 src/backend/utils/crypto-migration/warpgate-settings-migration.ts delete mode 100644 src/backend/utils/opkssh-binary-manager.ts rename src/ui/ssh/dialogs/{WarpgateDialog.tsx => BrowserSignInDialog.tsx} (74%) diff --git a/docker/Dockerfile b/docker/Dockerfile index 256294122..bb1fc9eb7 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -88,6 +88,7 @@ FROM node:26-slim WORKDIR /app ENV DATA_DIR=/app/data \ + OPKSSH_BUNDLED_DIR=/app/opkssh-bundled \ PORT=8080 \ NODE_ENV=production \ POSTHOG_API_KEY=phc_xM8UznirsFxUkGE68gH4jzeqevf4kh76wGw7Ci7hH2dd @@ -95,9 +96,9 @@ ENV DATA_DIR=/app/data \ RUN apt-get update && apt-get install -y nginx gettext-base openssl ca-certificates gosu wget certbot python3-certbot-dns-cloudflare && \ update-ca-certificates && \ rm -rf /var/lib/apt/lists/* && \ - mkdir -p /app/data /app/uploads /app/data/.opk /app/nginx /tmp/nginx && \ + mkdir -p /app/data /app/uploads /app/nginx /tmp/nginx && \ chown -R node:node /app /tmp/nginx && \ - chmod 755 /app/data /app/uploads /app/data/.opk /app/nginx /tmp/nginx + chmod 755 /app/data /app/uploads /app/nginx /tmp/nginx COPY docker/nginx.conf /app/nginx/nginx.conf.template COPY docker/nginx-https.conf /app/nginx/nginx-https.conf.template @@ -112,6 +113,8 @@ COPY --chown=node:node --from=backend-builder /app/dist/plugins ./dist/plugins # Target of node_modules/@termix/plugin-sdk, which core and the plugins import. COPY --chown=node:node --from=backend-builder /app/packages/plugin-sdk/package.json ./packages/plugin-sdk/package.json COPY --chown=node:node --from=backend-builder /app/packages/plugin-sdk/dist ./packages/plugin-sdk/dist +# Pre-baked OPKSSH binary. The opkssh plugin uses it when its checksum +# matches, so an offline install never downloads one. COPY --chown=node:node --from=opkssh-downloader /opkssh /app/opkssh-bundled COPY --chown=node:node package.json ./ # Schema for Postgres and MySQL. Unused by the default SQLite deployment, which diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 853e4bbc6..b27299820 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -73,31 +73,16 @@ if [ "$ENABLE_SSL" = "true" ] && [ "$PORT" = "$SSL_PORT" ]; then sed -i '/# BEGIN HTTP_REDIRECT_SERVER/,/# END HTTP_REDIRECT_SERVER/d' /tmp/nginx/nginx.conf fi -mkdir -p /app/data /app/uploads /app/data/.opk /app/data/acme-webroot/.well-known/acme-challenge -chmod 755 /app/data /app/uploads /app/data/.opk 2>/dev/null || true +mkdir -p /app/data /app/uploads /app/data/acme-webroot/.well-known/acme-challenge +chmod 755 /app/data /app/uploads 2>/dev/null || true if [ -w /app/data ]; then echo "Data directory is writable" else - echo "WARNING: Data directory is not writable. OPKSSH may fail." + echo "WARNING: Data directory is not writable. Plugins that keep files (OPKSSH, recordings) may fail." ls -ld /app/data fi -if [ -w /app/data/.opk ]; then - echo "OPKSSH directory is writable" -else - echo "WARNING: OPKSSH directory is not writable. OPKSSH authentication will fail." - ls -ld /app/data/.opk -fi - -OPKSSH_DIR="${DATA_DIR:-/app/data}/opkssh" -if [ ! -d "$OPKSSH_DIR" ]; then - echo "OPKSSH binary directory not found at $OPKSSH_DIR" - echo "OPKSSH will be installed from the bundled copy on first use (falls back to downloading if unavailable)." -else - echo "OPKSSH binary directory found at $OPKSSH_DIR" -fi - if [ "$ENABLE_SSL" = "true" ]; then echo "Checking SSL certificate configuration..." mkdir -p /app/data/ssl diff --git a/docker/nginx-https.conf b/docker/nginx-https.conf index c7084ce51..5c37bce8e 100644 --- a/docker/nginx-https.conf +++ b/docker/nginx-https.conf @@ -482,40 +482,6 @@ http { proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } - location ~ ^/host/opkssh-chooser(/.*)?$ { - proxy_pass http://127.0.0.1:30001/host/opkssh-chooser$1$is_args$args; - proxy_http_version 1.1; - proxy_set_header Host $http_host; - proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host; - proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; - - proxy_cache_bypass 1; - proxy_no_cache 1; - add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; - add_header X-Content-Type-Options nosniff always; - add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always; - } - - location ~ ^/host/opkssh-callback(/.*)?$ { - proxy_pass http://127.0.0.1:30001/host/opkssh-callback$1$is_args$args; - proxy_http_version 1.1; - proxy_set_header Host $http_host; - proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host; - proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; - - proxy_cache_bypass 1; - proxy_no_cache 1; - add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; - add_header X-Content-Type-Options nosniff always; - add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always; - } - location /host/ { proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; diff --git a/docker/nginx.conf b/docker/nginx.conf index b4c41ff06..3440684ee 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -462,38 +462,6 @@ http { proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } - location ~ ^/host/opkssh-chooser(/.*)?$ { - proxy_pass http://127.0.0.1:30001/host/opkssh-chooser$1$is_args$args; - proxy_http_version 1.1; - proxy_set_header Host $http_host; - proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host; - proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; - - proxy_cache_bypass 1; - proxy_no_cache 1; - add_header X-Content-Type-Options nosniff always; - add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always; - } - - location ~ ^/host/opkssh-callback(/.*)?$ { - proxy_pass http://127.0.0.1:30001/host/opkssh-callback$1$is_args$args; - proxy_http_version 1.1; - proxy_set_header Host $http_host; - proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host; - proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; - - proxy_cache_bypass 1; - proxy_no_cache 1; - add_header X-Content-Type-Options nosniff always; - add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always; - } - location /host/ { proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; diff --git a/drizzle/mysql/0053_magical_groot.sql b/drizzle/mysql/0053_magical_groot.sql new file mode 100644 index 000000000..97f3f7e7b --- /dev/null +++ b/drizzle/mysql/0053_magical_groot.sql @@ -0,0 +1,5 @@ +-- opkssh_tokens moves to the opkssh plugin's adopted table at activation, +-- and ssh_data.use_warpgate is copied into the warpgate plugin's host +-- settings by a boot migration after this runs. Dropping either here would +-- lose the data first. +SELECT 1; \ No newline at end of file diff --git a/drizzle/mysql/meta/0053_snapshot.json b/drizzle/mysql/meta/0053_snapshot.json new file mode 100644 index 000000000..9826fcf97 --- /dev/null +++ b/drizzle/mysql/meta/0053_snapshot.json @@ -0,0 +1,5311 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "eabd98bd-d70d-4ec1-967a-0738e74cd55c", + "prevId": "ee1565c4-881c-45f8-b084-d32e36893cea", + "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": "varchar(255)", + "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": "varchar(255)", + "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": "varchar(255)", + "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": {} + }, + "dismissed_alerts": { + "name": "dismissed_alerts", + "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 + }, + "alert_id": { + "name": "alert_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dismissed_at": { + "name": "dismissed_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_dismissed_alerts_user_id": { + "name": "idx_dismissed_alerts_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "dismissed_alerts_user_id_users_id_fk": { + "name": "dismissed_alerts_user_id_users_id_fk", + "tableFrom": "dismissed_alerts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "dismissed_alerts_id": { + "name": "dismissed_alerts_id", + "columns": [ + "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": "varchar(255)", + "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_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": "varchar(255)", + "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": "varchar(255)", + "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 + }, + "autostart_password": { + "name": "autostart_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "autostart_key": { + "name": "autostart_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "autostart_key_password": { + "name": "autostart_key_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 + }, + "vault_profile_id": { + "name": "vault_profile_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enable_terminal": { + "name": "enable_terminal", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "enable_session_logging": { + "name": "enable_session_logging", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "allow_session_sharing": { + "name": "allow_session_sharing", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "enable_command_history": { + "name": "enable_command_history", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "enable_tunnel": { + "name": "enable_tunnel", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "tunnel_connections": { + "name": "tunnel_connections", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "jump_hosts": { + "name": "jump_hosts", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enable_file_manager": { + "name": "enable_file_manager", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "scp_legacy": { + "name": "scp_legacy", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "enable_web_ui": { + "name": "enable_web_ui", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "enable_tmux_monitor": { + "name": "enable_tmux_monitor", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "enable_terminal_toolbar": { + "name": "enable_terminal_toolbar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "show_terminal_in_sidebar": { + "name": "show_terminal_in_sidebar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "show_file_manager_in_sidebar": { + "name": "show_file_manager_in_sidebar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "show_tunnel_in_sidebar": { + "name": "show_tunnel_in_sidebar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "show_docker_in_sidebar": { + "name": "show_docker_in_sidebar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "show_server_stats_in_sidebar": { + "name": "show_server_stats_in_sidebar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "default_path": { + "name": "default_path", + "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 + }, + "web_ui_config": { + "name": "web_ui_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_config": { + "name": "terminal_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quick_actions": { + "name": "quick_actions", + "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 + }, + "rdp_credential_id": { + "name": "rdp_credential_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rdp_user": { + "name": "rdp_user", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rdp_password": { + "name": "rdp_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rdp_domain": { + "name": "rdp_domain", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vnc_credential_id": { + "name": "vnc_credential_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vnc_password": { + "name": "vnc_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vnc_user": { + "name": "vnc_user", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "telnet_user": { + "name": "telnet_user", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "telnet_password": { + "name": "telnet_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "telnet_credential_id": { + "name": "telnet_credential_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rdp_auth_type": { + "name": "rdp_auth_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vnc_auth_type": { + "name": "vnc_auth_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "telnet_auth_type": { + "name": "telnet_auth_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "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 + }, + "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" + }, + "ssh_data_vault_profile_id_vault_profiles_id_fk": { + "name": "ssh_data_vault_profile_id_vault_profiles_id_fk", + "tableFrom": "ssh_data", + "tableTo": "vault_profiles", + "columnsFrom": [ + "vault_profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_rdp_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_rdp_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "rdp_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_vnc_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_vnc_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "vnc_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_telnet_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_telnet_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "telnet_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": {} + }, + "notification_channels": { + "name": "notification_channels", + "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": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "notification_channels_user_id_users_id_fk": { + "name": "notification_channels_user_id_users_id_fk", + "tableFrom": "notification_channels", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "notification_channels_id": { + "name": "notification_channels_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "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 + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('aggregate-telemetry')" + }, + "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": "varchar(255)", + "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": "varchar(255)", + "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": "varchar(255)", + "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 + }, + "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": "varchar(255)", + "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": {} + }, + "recent_activity": { + "name": "recent_activity", + "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 + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_name": { + "name": "host_name", + "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_recent_activity_user_ts": { + "name": "idx_recent_activity_user_ts", + "columns": [ + "user_id", + "timestamp" + ], + "isUnique": false + } + }, + "foreignKeys": { + "recent_activity_user_id_users_id_fk": { + "name": "recent_activity_user_id_users_id_fk", + "tableFrom": "recent_activity", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "recent_activity_host_id_ssh_data_id_fk": { + "name": "recent_activity_host_id_ssh_data_id_fk", + "tableFrom": "recent_activity", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "recent_activity_id": { + "name": "recent_activity_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 + }, + "oidc_sub": { + "name": "oidc_sub", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oidc_sid": { + "name": "oidc_sid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sso_provider_id": { + "name": "sso_provider_id", + "type": "int", + "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 + }, + "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": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "folder": { + "name": "folder", + "type": "varchar(255)", + "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 + }, + "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": "varchar(255)", + "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 + }, + "credential_id": { + "name": "credential_id", + "type": "int", + "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 + }, + "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" + }, + "ssh_folders_credential_id_ssh_credentials_id_fk": { + "name": "ssh_folders_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_folders", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "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_tombstones": { + "name": "sync_tombstones", + "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": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "sync_tombstones_user_id_users_id_fk": { + "name": "sync_tombstones_user_id_users_id_fk", + "tableFrom": "sync_tombstones", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sync_tombstones_id": { + "name": "sync_tombstones_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "termix_identities": { + "name": "termix_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 + }, + "handle": { + "name": "handle", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "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": { + "termix_identities_user_id_users_id_fk": { + "name": "termix_identities_user_id_users_id_fk", + "tableFrom": "termix_identities", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "termix_identities_id": { + "name": "termix_identities_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "termix_identities_user_id_unique": { + "name": "termix_identities_user_id_unique", + "columns": [ + "user_id" + ] + }, + "termix_identities_handle_unique": { + "name": "termix_identities_handle_unique", + "columns": [ + "handle" + ] + } + }, + "checkConstraint": {} + }, + "termix_identity_ca": { + "name": "termix_identity_ca", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "identity_id": { + "name": "identity_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "validity_days": { + "name": "validity_days", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 90 + }, + "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": { + "termix_identity_ca_identity_id_termix_identities_id_fk": { + "name": "termix_identity_ca_identity_id_termix_identities_id_fk", + "tableFrom": "termix_identity_ca", + "tableTo": "termix_identities", + "columnsFrom": [ + "identity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "termix_identity_ca_user_id_users_id_fk": { + "name": "termix_identity_ca_user_id_users_id_fk", + "tableFrom": "termix_identity_ca", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "termix_identity_ca_id": { + "name": "termix_identity_ca_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "termix_identity_ca_identity_id_unique": { + "name": "termix_identity_ca_identity_id_unique", + "columns": [ + "identity_id" + ] + } + }, + "checkConstraint": {} + }, + "termix_identity_keys": { + "name": "termix_identity_keys", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "identity_id": { + "name": "identity_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "algorithm": { + "name": "algorithm", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('manual')" + }, + "credential_id": { + "name": "credential_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "termix_identity_keys_identity_id_termix_identities_id_fk": { + "name": "termix_identity_keys_identity_id_termix_identities_id_fk", + "tableFrom": "termix_identity_keys", + "tableTo": "termix_identities", + "columnsFrom": [ + "identity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "termix_identity_keys_user_id_users_id_fk": { + "name": "termix_identity_keys_user_id_users_id_fk", + "tableFrom": "termix_identity_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "termix_identity_keys_credential_id_ssh_credentials_id_fk": { + "name": "termix_identity_keys_credential_id_ssh_credentials_id_fk", + "tableFrom": "termix_identity_keys", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "termix_identity_keys_id": { + "name": "termix_identity_keys_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": "varchar(255)", + "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_autocomplete": { + "name": "command_autocomplete", + "type": "boolean", + "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 + }, + "confirm_snippet_execution": { + "name": "confirm_snippet_execution", + "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_themes": { + "name": "custom_themes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "custom_keybindings": { + "name": "custom_keybindings", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_defaults": { + "name": "terminal_defaults", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_macros": { + "name": "terminal_macros", + "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": "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": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_oidc": { + "name": "is_oidc", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "oidc_identifier": { + "name": "oidc_identifier", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sso_provider_id": { + "name": "sso_provider_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "issuer_url": { + "name": "issuer_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "authorization_url": { + "name": "authorization_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_url": { + "name": "token_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "identifier_path": { + "name": "identifier_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name_path": { + "name": "name_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "('openid email profile')" + }, + "registered_at": { + "name": "registered_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "donation_modal_dismissed": { + "name": "donation_modal_dismissed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "vault_profiles": { + "name": "vault_profiles", + "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": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "folder": { + "name": "folder", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vault_addr": { + "name": "vault_addr", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "vault_namespace": { + "name": "vault_namespace", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oidc_mount": { + "name": "oidc_mount", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oidc_role": { + "name": "oidc_role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ssh_mount": { + "name": "ssh_mount", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ssh_role": { + "name": "ssh_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "valid_principals": { + "name": "valid_principals", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "shared": { + "name": "shared", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sync_id": { + "name": "sync_id", + "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)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "vault_profiles_user_id_users_id_fk": { + "name": "vault_profiles_user_id_users_id_fk", + "tableFrom": "vault_profiles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "vault_profiles_id": { + "name": "vault_profiles_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "vault_profiles_sync_id_unique": { + "name": "vault_profiles_sync_id_unique", + "columns": [ + "sync_id" + ] + } + }, + "checkConstraint": {} + }, + "vault_tokens": { + "name": "vault_tokens", + "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 + }, + "profile_id": { + "name": "profile_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ssh_cert": { + "name": "ssh_cert", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "private_key": { + "name": "private_key", + "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": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_vault_tokens_user_profile": { + "name": "idx_vault_tokens_user_profile", + "columns": [ + "user_id", + "profile_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "vault_tokens_user_id_users_id_fk": { + "name": "vault_tokens_user_id_users_id_fk", + "tableFrom": "vault_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "vault_tokens_profile_id_vault_profiles_id_fk": { + "name": "vault_tokens_profile_id_vault_profiles_id_fk", + "tableFrom": "vault_tokens", + "tableTo": "vault_profiles", + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "vault_tokens_id": { + "name": "vault_tokens_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 6ed16ea1c..bf5595790 100644 --- a/drizzle/mysql/meta/_journal.json +++ b/drizzle/mysql/meta/_journal.json @@ -372,6 +372,13 @@ "when": 1790287990810, "tag": "0052_illegal_gladiator", "breakpoints": true + }, + { + "idx": 53, + "version": "5", + "when": 1790295071302, + "tag": "0053_magical_groot", + "breakpoints": true } ] } \ No newline at end of file diff --git a/drizzle/postgres/0054_cloudy_galactus.sql b/drizzle/postgres/0054_cloudy_galactus.sql new file mode 100644 index 000000000..97f3f7e7b --- /dev/null +++ b/drizzle/postgres/0054_cloudy_galactus.sql @@ -0,0 +1,5 @@ +-- opkssh_tokens moves to the opkssh plugin's adopted table at activation, +-- and ssh_data.use_warpgate is copied into the warpgate plugin's host +-- settings by a boot migration after this runs. Dropping either here would +-- lose the data first. +SELECT 1; \ No newline at end of file diff --git a/drizzle/postgres/meta/0054_snapshot.json b/drizzle/postgres/meta/0054_snapshot.json new file mode 100644 index 000000000..990415b01 --- /dev/null +++ b/drizzle/postgres/meta/0054_snapshot.json @@ -0,0 +1,5189 @@ +{ + "id": "9363d178-2170-4694-bec9-7e891171c861", + "prevId": "728c9348-9ca1-47d7-990d-310e56d1c4b7", + "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": "varchar(255)", + "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": "varchar(255)", + "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": "varchar(255)", + "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.dismissed_alerts": { + "name": "dismissed_alerts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "alert_id": { + "name": "alert_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dismissed_at": { + "name": "dismissed_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_dismissed_alerts_user_id": { + "name": "idx_dismissed_alerts_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "dismissed_alerts_user_id_users_id_fk": { + "name": "dismissed_alerts_user_id_users_id_fk", + "tableFrom": "dismissed_alerts", + "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": "varchar(255)", + "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_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": "varchar(255)", + "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": "varchar(255)", + "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 + }, + "autostart_password": { + "name": "autostart_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "autostart_key": { + "name": "autostart_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "autostart_key_password": { + "name": "autostart_key_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 + }, + "vault_profile_id": { + "name": "vault_profile_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "enable_terminal": { + "name": "enable_terminal", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_session_logging": { + "name": "enable_session_logging", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "allow_session_sharing": { + "name": "allow_session_sharing", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_command_history": { + "name": "enable_command_history", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_tunnel": { + "name": "enable_tunnel", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "tunnel_connections": { + "name": "tunnel_connections", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "jump_hosts": { + "name": "jump_hosts", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enable_file_manager": { + "name": "enable_file_manager", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "scp_legacy": { + "name": "scp_legacy", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_web_ui": { + "name": "enable_web_ui", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_tmux_monitor": { + "name": "enable_tmux_monitor", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_terminal_toolbar": { + "name": "enable_terminal_toolbar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_terminal_in_sidebar": { + "name": "show_terminal_in_sidebar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_file_manager_in_sidebar": { + "name": "show_file_manager_in_sidebar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "show_tunnel_in_sidebar": { + "name": "show_tunnel_in_sidebar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "show_docker_in_sidebar": { + "name": "show_docker_in_sidebar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "show_server_stats_in_sidebar": { + "name": "show_server_stats_in_sidebar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "default_path": { + "name": "default_path", + "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 + }, + "web_ui_config": { + "name": "web_ui_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "terminal_config": { + "name": "terminal_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quick_actions": { + "name": "quick_actions", + "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 + }, + "rdp_credential_id": { + "name": "rdp_credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "rdp_user": { + "name": "rdp_user", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rdp_password": { + "name": "rdp_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rdp_domain": { + "name": "rdp_domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vnc_credential_id": { + "name": "vnc_credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "vnc_password": { + "name": "vnc_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vnc_user": { + "name": "vnc_user", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telnet_user": { + "name": "telnet_user", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telnet_password": { + "name": "telnet_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telnet_credential_id": { + "name": "telnet_credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "rdp_auth_type": { + "name": "rdp_auth_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vnc_auth_type": { + "name": "vnc_auth_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telnet_auth_type": { + "name": "telnet_auth_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "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" + }, + "ssh_data_vault_profile_id_vault_profiles_id_fk": { + "name": "ssh_data_vault_profile_id_vault_profiles_id_fk", + "tableFrom": "ssh_data", + "tableTo": "vault_profiles", + "columnsFrom": [ + "vault_profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_rdp_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_rdp_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "rdp_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_vnc_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_vnc_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "vnc_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_telnet_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_telnet_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "telnet_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.notification_channels": { + "name": "notification_channels", + "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": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "notification_channels_user_id_users_id_fk": { + "name": "notification_channels_user_id_users_id_fk", + "tableFrom": "notification_channels", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "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 + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'aggregate-telemetry'" + }, + "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": "varchar(255)", + "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": "varchar(255)", + "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 + }, + "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": "varchar(255)", + "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.recent_activity": { + "name": "recent_activity", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "host_name": { + "name": "host_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timestamp": { + "name": "timestamp", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_recent_activity_user_ts": { + "name": "idx_recent_activity_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": {} + } + }, + "foreignKeys": { + "recent_activity_user_id_users_id_fk": { + "name": "recent_activity_user_id_users_id_fk", + "tableFrom": "recent_activity", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "recent_activity_host_id_ssh_data_id_fk": { + "name": "recent_activity_host_id_ssh_data_id_fk", + "tableFrom": "recent_activity", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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 + }, + "oidc_sub": { + "name": "oidc_sub", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oidc_sid": { + "name": "oidc_sid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sso_provider_id": { + "name": "sso_provider_id", + "type": "integer", + "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 + }, + "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": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder": { + "name": "folder", + "type": "varchar(255)", + "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 + }, + "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": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "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 + }, + "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" + }, + "ssh_folders_credential_id_ssh_credentials_id_fk": { + "name": "ssh_folders_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_folders", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "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_tombstones": { + "name": "sync_tombstones", + "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": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "sync_tombstones_user_id_users_id_fk": { + "name": "sync_tombstones_user_id_users_id_fk", + "tableFrom": "sync_tombstones", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.termix_identities": { + "name": "termix_identities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "handle": { + "name": "handle", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "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": { + "termix_identities_user_id_users_id_fk": { + "name": "termix_identities_user_id_users_id_fk", + "tableFrom": "termix_identities", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "termix_identities_user_id_unique": { + "name": "termix_identities_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + }, + "termix_identities_handle_unique": { + "name": "termix_identities_handle_unique", + "nullsNotDistinct": false, + "columns": [ + "handle" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.termix_identity_ca": { + "name": "termix_identity_ca", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "identity_id": { + "name": "identity_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "validity_days": { + "name": "validity_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 90 + }, + "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": { + "termix_identity_ca_identity_id_termix_identities_id_fk": { + "name": "termix_identity_ca_identity_id_termix_identities_id_fk", + "tableFrom": "termix_identity_ca", + "tableTo": "termix_identities", + "columnsFrom": [ + "identity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "termix_identity_ca_user_id_users_id_fk": { + "name": "termix_identity_ca_user_id_users_id_fk", + "tableFrom": "termix_identity_ca", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "termix_identity_ca_identity_id_unique": { + "name": "termix_identity_ca_identity_id_unique", + "nullsNotDistinct": false, + "columns": [ + "identity_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.termix_identity_keys": { + "name": "termix_identity_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "identity_id": { + "name": "identity_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "algorithm": { + "name": "algorithm", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "termix_identity_keys_identity_id_termix_identities_id_fk": { + "name": "termix_identity_keys_identity_id_termix_identities_id_fk", + "tableFrom": "termix_identity_keys", + "tableTo": "termix_identities", + "columnsFrom": [ + "identity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "termix_identity_keys_user_id_users_id_fk": { + "name": "termix_identity_keys_user_id_users_id_fk", + "tableFrom": "termix_identity_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "termix_identity_keys_credential_id_ssh_credentials_id_fk": { + "name": "termix_identity_keys_credential_id_ssh_credentials_id_fk", + "tableFrom": "termix_identity_keys", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "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": "varchar(255)", + "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_autocomplete": { + "name": "command_autocomplete", + "type": "boolean", + "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 + }, + "confirm_snippet_execution": { + "name": "confirm_snippet_execution", + "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_themes": { + "name": "custom_themes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_keybindings": { + "name": "custom_keybindings", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "terminal_defaults": { + "name": "terminal_defaults", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "terminal_macros": { + "name": "terminal_macros", + "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": "text", + "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 + }, + "is_oidc": { + "name": "is_oidc", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "oidc_identifier": { + "name": "oidc_identifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sso_provider_id": { + "name": "sso_provider_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "issuer_url": { + "name": "issuer_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorization_url": { + "name": "authorization_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_url": { + "name": "token_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "identifier_path": { + "name": "identifier_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name_path": { + "name": "name_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'openid email profile'" + }, + "registered_at": { + "name": "registered_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "donation_modal_dismissed": { + "name": "donation_modal_dismissed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vault_profiles": { + "name": "vault_profiles", + "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": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder": { + "name": "folder", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vault_addr": { + "name": "vault_addr", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vault_namespace": { + "name": "vault_namespace", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oidc_mount": { + "name": "oidc_mount", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oidc_role": { + "name": "oidc_role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ssh_mount": { + "name": "ssh_mount", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ssh_role": { + "name": "ssh_role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "valid_principals": { + "name": "valid_principals", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "shared": { + "name": "shared", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "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": { + "vault_profiles_user_id_users_id_fk": { + "name": "vault_profiles_user_id_users_id_fk", + "tableFrom": "vault_profiles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "vault_profiles_sync_id_unique": { + "name": "vault_profiles_sync_id_unique", + "nullsNotDistinct": false, + "columns": [ + "sync_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vault_tokens": { + "name": "vault_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "profile_id": { + "name": "profile_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "ssh_cert": { + "name": "ssh_cert", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "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": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "last_used": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_vault_tokens_user_profile": { + "name": "idx_vault_tokens_user_profile", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "vault_tokens_user_id_users_id_fk": { + "name": "vault_tokens_user_id_users_id_fk", + "tableFrom": "vault_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "vault_tokens_profile_id_vault_profiles_id_fk": { + "name": "vault_tokens_profile_id_vault_profiles_id_fk", + "tableFrom": "vault_tokens", + "tableTo": "vault_profiles", + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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 12e4d1813..7bfadc151 100644 --- a/drizzle/postgres/meta/_journal.json +++ b/drizzle/postgres/meta/_journal.json @@ -379,6 +379,13 @@ "when": 1790287990260, "tag": "0053_keen_thanos", "breakpoints": true + }, + { + "idx": 54, + "version": "7", + "when": 1790295070561, + "tag": "0054_cloudy_galactus", + "breakpoints": true } ] } \ No newline at end of file diff --git a/drizzle/sqlite/0050_clear_white_queen.sql b/drizzle/sqlite/0050_clear_white_queen.sql new file mode 100644 index 000000000..97f3f7e7b --- /dev/null +++ b/drizzle/sqlite/0050_clear_white_queen.sql @@ -0,0 +1,5 @@ +-- opkssh_tokens moves to the opkssh plugin's adopted table at activation, +-- and ssh_data.use_warpgate is copied into the warpgate plugin's host +-- settings by a boot migration after this runs. Dropping either here would +-- lose the data first. +SELECT 1; \ No newline at end of file diff --git a/drizzle/sqlite/meta/0050_snapshot.json b/drizzle/sqlite/meta/0050_snapshot.json new file mode 100644 index 000000000..4ae0cfec4 --- /dev/null +++ b/drizzle/sqlite/meta/0050_snapshot.json @@ -0,0 +1,5015 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "a7a9b425-14aa-4f3a-bc25-8a3be6d25728", + "prevId": "52c3f87e-a998-45b1-abf6-89c819508144", + "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": {} + }, + "dismissed_alerts": { + "name": "dismissed_alerts", + "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 + }, + "alert_id": { + "name": "alert_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dismissed_at": { + "name": "dismissed_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_dismissed_alerts_user_id": { + "name": "idx_dismissed_alerts_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "dismissed_alerts_user_id_users_id_fk": { + "name": "dismissed_alerts_user_id_users_id_fk", + "tableFrom": "dismissed_alerts", + "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_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 + }, + "autostart_password": { + "name": "autostart_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "autostart_key": { + "name": "autostart_key", + "type": "text(8192)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "autostart_key_password": { + "name": "autostart_key_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 + }, + "vault_profile_id": { + "name": "vault_profile_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enable_terminal": { + "name": "enable_terminal", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "enable_session_logging": { + "name": "enable_session_logging", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "allow_session_sharing": { + "name": "allow_session_sharing", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "enable_command_history": { + "name": "enable_command_history", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "enable_tunnel": { + "name": "enable_tunnel", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "tunnel_connections": { + "name": "tunnel_connections", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "jump_hosts": { + "name": "jump_hosts", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enable_file_manager": { + "name": "enable_file_manager", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "scp_legacy": { + "name": "scp_legacy", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "enable_web_ui": { + "name": "enable_web_ui", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "enable_tmux_monitor": { + "name": "enable_tmux_monitor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "enable_terminal_toolbar": { + "name": "enable_terminal_toolbar", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "show_terminal_in_sidebar": { + "name": "show_terminal_in_sidebar", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "show_file_manager_in_sidebar": { + "name": "show_file_manager_in_sidebar", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "show_tunnel_in_sidebar": { + "name": "show_tunnel_in_sidebar", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "show_docker_in_sidebar": { + "name": "show_docker_in_sidebar", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "show_server_stats_in_sidebar": { + "name": "show_server_stats_in_sidebar", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "default_path": { + "name": "default_path", + "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 + }, + "web_ui_config": { + "name": "web_ui_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_config": { + "name": "terminal_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quick_actions": { + "name": "quick_actions", + "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 + }, + "rdp_credential_id": { + "name": "rdp_credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rdp_user": { + "name": "rdp_user", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rdp_password": { + "name": "rdp_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rdp_domain": { + "name": "rdp_domain", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vnc_credential_id": { + "name": "vnc_credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vnc_password": { + "name": "vnc_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vnc_user": { + "name": "vnc_user", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "telnet_user": { + "name": "telnet_user", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "telnet_password": { + "name": "telnet_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "telnet_credential_id": { + "name": "telnet_credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rdp_auth_type": { + "name": "rdp_auth_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vnc_auth_type": { + "name": "vnc_auth_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "telnet_auth_type": { + "name": "telnet_auth_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "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 + }, + "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" + }, + "ssh_data_vault_profile_id_vault_profiles_id_fk": { + "name": "ssh_data_vault_profile_id_vault_profiles_id_fk", + "tableFrom": "ssh_data", + "tableTo": "vault_profiles", + "columnsFrom": [ + "vault_profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_rdp_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_rdp_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "rdp_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_vnc_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_vnc_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "vnc_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_telnet_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_telnet_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "telnet_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notification_channels": { + "name": "notification_channels", + "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 + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "notification_channels_user_id_users_id_fk": { + "name": "notification_channels_user_id_users_id_fk", + "tableFrom": "notification_channels", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "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 + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'aggregate-telemetry'" + }, + "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 + }, + "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": {} + }, + "recent_activity": { + "name": "recent_activity", + "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 + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_name": { + "name": "host_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_recent_activity_user_ts": { + "name": "idx_recent_activity_user_ts", + "columns": [ + "user_id", + "timestamp" + ], + "isUnique": false + } + }, + "foreignKeys": { + "recent_activity_user_id_users_id_fk": { + "name": "recent_activity_user_id_users_id_fk", + "tableFrom": "recent_activity", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "recent_activity_host_id_ssh_data_id_fk": { + "name": "recent_activity_host_id_ssh_data_id_fk", + "tableFrom": "recent_activity", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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 + }, + "oidc_sub": { + "name": "oidc_sub", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oidc_sid": { + "name": "oidc_sid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sso_provider_id": { + "name": "sso_provider_id", + "type": "integer", + "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 + }, + "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 + }, + "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 + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "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 + }, + "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" + }, + "ssh_folders_credential_id_ssh_credentials_id_fk": { + "name": "ssh_folders_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_folders", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sync_tombstones": { + "name": "sync_tombstones", + "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 + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "sync_tombstones_user_id_users_id_fk": { + "name": "sync_tombstones_user_id_users_id_fk", + "tableFrom": "sync_tombstones", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "termix_identities": { + "name": "termix_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 + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "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": { + "termix_identities_user_id_unique": { + "name": "termix_identities_user_id_unique", + "columns": [ + "user_id" + ], + "isUnique": true + }, + "termix_identities_handle_unique": { + "name": "termix_identities_handle_unique", + "columns": [ + "handle" + ], + "isUnique": true + } + }, + "foreignKeys": { + "termix_identities_user_id_users_id_fk": { + "name": "termix_identities_user_id_users_id_fk", + "tableFrom": "termix_identities", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "termix_identity_ca": { + "name": "termix_identity_ca", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "identity_id": { + "name": "identity_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text(4096)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "private_key": { + "name": "private_key", + "type": "text(8192)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "validity_days": { + "name": "validity_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 90 + }, + "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": { + "termix_identity_ca_identity_id_unique": { + "name": "termix_identity_ca_identity_id_unique", + "columns": [ + "identity_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "termix_identity_ca_identity_id_termix_identities_id_fk": { + "name": "termix_identity_ca_identity_id_termix_identities_id_fk", + "tableFrom": "termix_identity_ca", + "tableTo": "termix_identities", + "columnsFrom": [ + "identity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "termix_identity_ca_user_id_users_id_fk": { + "name": "termix_identity_ca_user_id_users_id_fk", + "tableFrom": "termix_identity_ca", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "termix_identity_keys": { + "name": "termix_identity_keys", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "identity_id": { + "name": "identity_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text(8192)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "algorithm": { + "name": "algorithm", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'manual'" + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "termix_identity_keys_identity_id_termix_identities_id_fk": { + "name": "termix_identity_keys_identity_id_termix_identities_id_fk", + "tableFrom": "termix_identity_keys", + "tableTo": "termix_identities", + "columnsFrom": [ + "identity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "termix_identity_keys_user_id_users_id_fk": { + "name": "termix_identity_keys_user_id_users_id_fk", + "tableFrom": "termix_identity_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "termix_identity_keys_credential_id_ssh_credentials_id_fk": { + "name": "termix_identity_keys_credential_id_ssh_credentials_id_fk", + "tableFrom": "termix_identity_keys", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "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_autocomplete": { + "name": "command_autocomplete", + "type": "integer", + "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 + }, + "confirm_snippet_execution": { + "name": "confirm_snippet_execution", + "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_themes": { + "name": "custom_themes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "custom_keybindings": { + "name": "custom_keybindings", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_defaults": { + "name": "terminal_defaults", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_macros": { + "name": "terminal_macros", + "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 + }, + "is_oidc": { + "name": "is_oidc", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "oidc_identifier": { + "name": "oidc_identifier", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sso_provider_id": { + "name": "sso_provider_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "issuer_url": { + "name": "issuer_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "authorization_url": { + "name": "authorization_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_url": { + "name": "token_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "identifier_path": { + "name": "identifier_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name_path": { + "name": "name_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'openid email profile'" + }, + "registered_at": { + "name": "registered_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "donation_modal_dismissed": { + "name": "donation_modal_dismissed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "vault_profiles": { + "name": "vault_profiles", + "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 + }, + "vault_addr": { + "name": "vault_addr", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "vault_namespace": { + "name": "vault_namespace", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oidc_mount": { + "name": "oidc_mount", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oidc_role": { + "name": "oidc_role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ssh_mount": { + "name": "ssh_mount", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ssh_role": { + "name": "ssh_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "valid_principals": { + "name": "valid_principals", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "shared": { + "name": "shared", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sync_id": { + "name": "sync_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": { + "vault_profiles_sync_id_unique": { + "name": "vault_profiles_sync_id_unique", + "columns": [ + "sync_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "vault_profiles_user_id_users_id_fk": { + "name": "vault_profiles_user_id_users_id_fk", + "tableFrom": "vault_profiles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "vault_tokens": { + "name": "vault_tokens", + "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 + }, + "profile_id": { + "name": "profile_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ssh_cert": { + "name": "ssh_cert", + "type": "text(8192)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "private_key": { + "name": "private_key", + "type": "text(8192)", + "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": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_vault_tokens_user_profile": { + "name": "idx_vault_tokens_user_profile", + "columns": [ + "user_id", + "profile_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "vault_tokens_user_id_users_id_fk": { + "name": "vault_tokens_user_id_users_id_fk", + "tableFrom": "vault_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "vault_tokens_profile_id_vault_profiles_id_fk": { + "name": "vault_tokens_profile_id_vault_profiles_id_fk", + "tableFrom": "vault_tokens", + "tableTo": "vault_profiles", + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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 e3e9ca81a..ff7b5e4a5 100644 --- a/drizzle/sqlite/meta/_journal.json +++ b/drizzle/sqlite/meta/_journal.json @@ -351,6 +351,13 @@ "when": 1790287989611, "tag": "0049_glamorous_hemingway", "breakpoints": true + }, + { + "idx": 50, + "version": "6", + "when": 1790295069807, + "tag": "0050_clear_white_queen", + "breakpoints": true } ] } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 3670bcbca..f649c6fa5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,7 +29,6 @@ "drizzle-orm": "^0.45.2", "express": "^5.2.1", "guacamole-lite": "^1.2.0", - "js-yaml": "^5.2.3", "jsonwebtoken": "^9.0.3", "jszip": "^3.10.2", "motion": "^13.1.1", @@ -89,7 +88,6 @@ "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "@types/guacamole-common-js": "^1.5.5", - "@types/js-yaml": "^4.0.9", "@types/jsonwebtoken": "^9.0.10", "@types/multer": "^2.2.0", "@types/node": "^26.2.0", @@ -7003,6 +7001,10 @@ "resolved": "plugins/network-topology", "link": true }, + "node_modules/@termix-plugin/opkssh": { + "resolved": "plugins/opkssh", + "link": true + }, "node_modules/@termix-plugin/proxmox": { "resolved": "plugins/proxmox", "link": true @@ -7059,6 +7061,10 @@ "resolved": "plugins/wake-on-lan", "link": true }, + "node_modules/@termix-plugin/warpgate": { + "resolved": "plugins/warpgate", + "link": true + }, "node_modules/@termix-plugin/web-endpoint": { "resolved": "plugins/web-endpoint", "link": true @@ -18632,6 +18638,17 @@ "@termix/plugin-sdk": "*" } }, + "plugins/opkssh": { + "name": "@termix-plugin/opkssh", + "version": "1.0.0", + "dependencies": { + "js-yaml": "^5.2.3" + }, + "devDependencies": { + "@termix/plugin-sdk": "*", + "@types/js-yaml": "^4.0.9" + } + }, "plugins/proxmox": { "name": "@termix-plugin/proxmox", "version": "1.0.0", @@ -18749,6 +18766,13 @@ "@termix/plugin-sdk": "*" } }, + "plugins/warpgate": { + "name": "@termix-plugin/warpgate", + "version": "1.0.0", + "devDependencies": { + "@termix/plugin-sdk": "*" + } + }, "plugins/web-endpoint": { "name": "@termix-plugin/web-endpoint", "version": "1.0.0", diff --git a/package.json b/package.json index 3f4cab937..1e77cb5dc 100644 --- a/package.json +++ b/package.json @@ -71,7 +71,6 @@ "drizzle-orm": "^0.45.2", "express": "^5.2.1", "guacamole-lite": "^1.2.0", - "js-yaml": "^5.2.3", "jsonwebtoken": "^9.0.3", "jszip": "^3.10.2", "motion": "^13.1.1", @@ -131,7 +130,6 @@ "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "@types/guacamole-common-js": "^1.5.5", - "@types/js-yaml": "^4.0.9", "@types/jsonwebtoken": "^9.0.10", "@types/multer": "^2.2.0", "@types/node": "^26.2.0", diff --git a/packages/plugin-sdk/ARCHITECTURE.md b/packages/plugin-sdk/ARCHITECTURE.md index 0e03c7669..47102bd8b 100644 --- a/packages/plugin-sdk/ARCHITECTURE.md +++ b/packages/plugin-sdk/ARCHITECTURE.md @@ -38,6 +38,7 @@ dependencies. Entry points: | `@termix/plugin-sdk/capabilities` | The capability catalog | | `@termix/plugin-sdk/settings` | Settings field types and the shared value validator | | `@termix/plugin-sdk/host-commands` | Platform detection, package manager commands, sudo elevation - pure helpers over an ssh2 `Client` a plugin got from `ctx.ssh` | +| `@termix/plugin-sdk/ssh-certs` | `applyCertificateAuth()`: OpenSSH certificate auth on an ssh2 client, for plugins that issue short-lived certificates | | `@termix/plugin-sdk/testing` | Test helpers: `createMockCtx()`, `createTestDb()` and more | Plugins never import from `src/` or `@/`, and core never imports from @@ -217,7 +218,9 @@ handler)` for a socket. Core runs the same middleware in front of every plugin: audited when the router is registered and logged on every request, because "this plugin opened a hole in auth" should be findable later. It matches the full path, with `:param` segments allowed, so declaring `/webhook/:token` - public cannot open `/webhook/:token/anything`. + public cannot open `/webhook/:token/anything`. A trailing `/*` is the one + deliberate prefix (**C3**): `/chooser/:id/*` opens that path and everything + below it, for a page the plugin proxies whose assets it cannot list. 2. **The actor**, from the user core authenticated, never from the request body. 3. **An enabled check** returning 503 while the plugin is disabled. That is a different fact from 404 ("no such plugin") and a caller can act on it. @@ -476,7 +479,10 @@ bridge core installs. Slots core owns: `terminal.toolbar`, `terminal.toolbarStatus`, `terminal.sidePanel`, `terminal.overlay` (declared by ssh-terminal; **B9** renamed `terminal.dock` to `terminal.sidePanel` and added the status slot, -where host-metrics puts its CPU, memory and disk bars), `onboarding.steps`, `onboarding.features`, +where host-metrics puts its CPU, memory and disk bars; **C3** gave overlay +components `send(type, data)` for the session socket and `connectPayload()`, +the data an `_auth_completed` needs to reconnect, so a sign-in +flow such as OPKSSH's lives entirely in its plugin), `onboarding.steps`, `onboarding.features`, `onboarding.workflow`, `hosts.importMenu`, `hosts.panel`, `proxmox.hostEditor`, `shell.overlay`, `dashboard.hostMetrics`, `homepage.hostMetrics` and `dashboard.secondaryView`. **B16** added the middle two, component slots in @@ -577,12 +583,15 @@ tooltip, sheet, section-card, metric-card, charts, the card grid, `SnippetVariablesDialog`, `FullScreenAppWrapper`, the connection log context, `TOTPDialog` (its `MFAPromptMode` type lives in `@termix/plugin-sdk/frontend` since **C1**, so the shell spells no `"totp"`), -`SSHAuthDialog`, `WarpgateDialog`, `PassphraseDialog`, -`OPKSSHDialog` (until opkssh moves in Phase C), `HostKeyVerificationDialog`, +`SSHAuthDialog`, `BrowserSignInDialog`, `PassphraseDialog`, +`HostKeyVerificationDialog`, `useTabs`/`useTabsSafe`, `ActionSlot`, `ComponentSlot`, `PluginComponent` and `FOLDER_COLORS` (the colour swatches folders and workspaces pick from). **B10** added `popover`, `scroll-area` and `skeleton` for the tmux-monitor -plugin's session tree and its new-session/kill popovers. +plugin's session tree and its new-session/kill popovers. **C3** removed +`OPKSSHDialog` (the opkssh plugin draws its own) and replaced `WarpgateDialog` +with `BrowserSignInDialog` (`label`, `url`, `code`), the one dialog every +transport shows for a keyboard-interactive browser round. **B9** added what a terminal-like surface needs from the shell: `cn`, `useConfirmation`, `useIsMobile`, `runAdaptivePolling`; the terminal look @@ -999,7 +1008,7 @@ auto-session and trusted devices, plus the base SSH auth types password, key, stored credential, agent and none. Every other login method, second factor and SSH auth method is a plugin through `ctx.auth`. -In 2.9.0, OPKSSH, Step-CA, Vault, Tailscale and Warpgate still live in core. +In 2.9.0, Step-CA and Vault still live in core. They register through the same interfaces from `src/backend/auth/legacy-providers.ts` and `src/ui/auth/legacy-auth-ui.tsx` with `pluginId: "core"`. Phase C moves each one into its plugin by moving its @@ -1089,6 +1098,50 @@ Nothing else in core branches on those type names. live in `database/routes/auth-compat-routes.ts` and only forward to the registered methods, so with the plugins off they answer 404. +**C3** moved OPKSSH and Warpgate out: + +- `opkssh` registers the `opkssh` SSH auth type. It adopts `opkssh_tokens` as + `p_opkssh_tokens`, the certificate and key sealed with `ctx.secrets.seal`. + Rows 2.8 wrote were encrypted with the user's data key, which a plugin cannot + open: they read as expired and the user signs in once, which is what a + cached certificate is for (at most 24 hours old). +- The binary comes from `ctx.process.ensureBinary`: the copy the Docker image + bakes into `/app/opkssh-bundled` (`OPKSSH_BUNDLED_DIR`) when its checksum + matches, else the one in the plugin's data folder, else a download pinned to + the release's SHA-256. `opkssh login` runs through `ctx.process.run` and is + killed on cancel, close and deactivate. The config lives at + `/plugins/opkssh/config.yml`; `redirect_uris` there are still the + localhost listener candidates only, and the public callback still goes to + OPKSSH as `--remote-redirect-uri`. +- The chooser and callback pages are public plugin routes + (`/plugin-api/opkssh/chooser/:id/*`, `/callback`, `/callback/:id/*`). + New installs register `/plugin-api/opkssh/callback` with their + identity provider. `utils/crypto-migration/opkssh-config-migration.ts` + (run from `plugin-data-moves.ts`) copies a 2.8 `.opk/config.yml` in and + turns on the admin setting `legacyCallback`, so an upgraded install keeps + sending `/host/opkssh-callback`, which core answers with a 307 to the + plugin (`database/routes/host-compat-routes.ts`). The admin page shows the + URI to register. +- The sign-in dialog is a `terminal.overlay` contribution. The terminal knows + no sign-in by name: it forwards `_start_auth`, `_cancel` and + `_auth_completed` to `ctx.ssh` for any provider. +- `warpgate` registers a keyboard-interactive handler + (`ctx.auth.registerKeyboardInteractiveHandler`) and the host setting + `useWarpgate`, which `warpgate-settings-migration.ts` fills from + `ssh_data.use_warpgate` (and from hosts still on auth type `warpgate`). + The column is out of `schema.ts`; its drizzle drop is a no-op. +- A handler's browser round is the generic `browser` decision + (`{ id, label, url, code, instructions }`). The terminal sends it as + `_auth_required` and takes `_auth_continue`, so Warpgate keeps its + 2.8 message names for Termix-Mobile; the file manager and docker answer + `requires_browser_sign_in` and continue on `connect-browser-sign-in`. +- `@termix/plugin-sdk/ssh-certs` (`applyCertificateAuth`) replaces core's + `opkssh-cert-auth.ts`: opkssh, core key auth with a CA certificate, and the + Step-CA and Vault providers still in core use it. +- Step-CA no longer shares anything with OPKSSH: its interaction is + `stepca`, its messages `stepca_*`, and it keeps issued certificates in + memory until it becomes a plugin. + #### One SSH connect pipeline Every SSH connection, core's and plugins', goes through @@ -1107,8 +1160,8 @@ Every SSH connection, core's and plugins', goes through stored password for password prompts and nothing else. Keyboard-interactive rounds are classified once (`classifyKeyboardInteractive`): -an interceptor first (Warpgate), then push MFA, TOTP, and plain input. The -transport decides how to ask. +a plugin's keyboard-interactive handler first, then push MFA, TOTP, and plain +input. The transport decides how to ask. A host whose `authType` has no provider fails with `This host uses , which needs the plugin` (the plugin is found @@ -1159,6 +1212,28 @@ instead draw its own editor with `app.registerSshAuthEditor`. A transport that can show a browser step sends `_auth_required` and later calls the provider's `startInteraction` for `_start_auth`. +A server that speaks its own prompt style on any host, whatever its auth type +(an SSH bastion asking for a browser approval), is a keyboard-interactive +handler instead (**C3**): + +```ts +ctx.auth.registerKeyboardInteractiveHandler({ + id: "gate", // listed in contributes.auth.keyboardInteractive + label: "Gate", // shown in the sign-in dialog title + detect: (round, host, settings) => + /gate/i.test(round.name) + ? { kind: "browser", url, code, instructions: round.instructions } + : null, + autoAnswerPasswords: (host, settings) => settings.enabled === true, +}); +``` + +`settings` is the plugin's own host settings for the host, defaults filled +in: the host resolver puts every plugin's host settings on the connect host +(`pluginSettings`) because both hooks run synchronously inside ssh2's +callback, and the handler only sees its own slice. Needs `auth:provide`; the +declaration is checked at registration, and deactivate removes the handler. + #### Login methods and second factors ```ts @@ -1427,6 +1502,7 @@ not do. "loginMethods": ["corp-sso"], "secondFactors": ["pin"], "secretSchemes": ["op"], // "://..." secret references this plugin resolves + "keyboardInteractive": ["gate"], // keyboard-interactive handler ids }, "settings": { "admin": [ @@ -1594,6 +1670,8 @@ deciding, not the mechanism. - A capability is checked where the privileged thing happens, not at activation. Activation-time checks say what a plugin might do; call-time checks say what it did. +- `process:spawn` is checked by `ctx.process` (**C3**); before that it was + only a statement of intent. - `events:core` is the exception that proves the shape: without it a plugin may only emit under `plugin..`, because otherwise it could publish `host.status` and drive the automations engine as though core had. @@ -1604,42 +1682,44 @@ deciding, not the mechanism. Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`. -| Member | Capability | Status | -| ------------------------------------------------ | ------------------------------------ | ----------------------- | -| `ctx.pluginId`, `ctx.manifest` | none | **A1** | -| `ctx.log.*` | none | **A1** | -| `ctx.events.emit` / `.on` | `events:core` for core topics | **A1** | -| `ctx.kv.get/set/delete/list` | `kv:own` | **A1** | -| `ctx.files.dataDir` | `files:own` | **B13** | -| `ctx.registry.*` | none | **A1** | -| `ctx.services.provide` / `.get` / `.providers` | per-service RBAC | **A1**, named **B12** | -| `ctx.secrets.offer` / `.withdraw` / `.getShared` | per-secret RBAC | **A1** | -| `ctx.secrets.get` / `.set` / `.delete` | `secrets:own` | **B18** | -| `ctx.secrets.seal` / `.unseal` | `secrets:own` | **C1** | -| `ctx.disposables.add` | none | **A1** | -| `ctx.asUser(userId, fn)` | none, always audited | **A1** | -| `ctx.currentActor()` | none | **A1** | -| `ctx.db.define` / `.client` / `.refs` | `db:own` | **A3** | -| `ctx.db.persist` / `.dialect` | `db:own` (persist only) | **A9**, lazy **B16** | -| `ctx.sync.registerEntity` | none | **A3** | -| `ctx.http.router` / `ctx.ws.route` / `.upgrade` | `network:serve` | **A4** | -| `ctx.http.baseUrl(req)` | none | **C2** | -| `ctx.rbac.has` / `.hasFor` / `.require` | own permissions only | **A5** | -| `ctx.capabilities.has` / `.require` | the capability itself | **B11** | -| `ctx.hosts.*` | `hosts:read` / `hosts:write` | **B4**, **B5**, **B18** | -| `ctx.hosts.status.*` | `hosts:read` | **B16** | -| `ctx.ssh.*` | `ssh:connect`, `credentials:use` | **A8** | -| `ctx.settings.*` | `settings:read-core` (readCore only) | **A6** | -| `ctx.notify.channels` / `.send` | `notify:send` | **B17** | -| `ctx.auth.*` | `auth:provide` | **A8** | -| `ctx.auth.completeRedirectLogin` and the rest | `auth:provide` | **C2** | -| `ctx.desktop.openIsolatedWindow` | `desktop:window` | **B8** | -| `ctx.desktop.launchNativeRdp` / `.available` | `desktop:window` (launch only) | **B14** | -| `ctx.credentials.resolveHostProtocol` | `credentials:read` | **B14** | -| `ctx.credentials.registerSecretResolver` | `auth:provide` | **B20** | -| `ctx.audit.record` | none, the actor is the runtime's | **B9** | -| `ctx.schedule.every` / `.after` | none | **B16** | -| `ctx.fetch` | `network:outbound` | **B17**, signal **B18** | +| Member | Capability | Status | +| ------------------------------------------------ | -------------------------------------------------- | ----------------------- | +| `ctx.pluginId`, `ctx.manifest` | none | **A1** | +| `ctx.log.*` | none | **A1** | +| `ctx.events.emit` / `.on` | `events:core` for core topics | **A1** | +| `ctx.kv.get/set/delete/list` | `kv:own` | **A1** | +| `ctx.files.dataDir` | `files:own` | **B13** | +| `ctx.registry.*` | none | **A1** | +| `ctx.services.provide` / `.get` / `.providers` | per-service RBAC | **A1**, named **B12** | +| `ctx.secrets.offer` / `.withdraw` / `.getShared` | per-secret RBAC | **A1** | +| `ctx.secrets.get` / `.set` / `.delete` | `secrets:own` | **B18** | +| `ctx.secrets.seal` / `.unseal` | `secrets:own` | **C1** | +| `ctx.disposables.add` | none | **A1** | +| `ctx.asUser(userId, fn)` | none, always audited | **A1** | +| `ctx.currentActor()` | none | **A1** | +| `ctx.db.define` / `.client` / `.refs` | `db:own` | **A3** | +| `ctx.db.persist` / `.dialect` | `db:own` (persist only) | **A9**, lazy **B16** | +| `ctx.sync.registerEntity` | none | **A3** | +| `ctx.http.router` / `ctx.ws.route` / `.upgrade` | `network:serve` | **A4** | +| `ctx.http.baseUrl(req)` | none | **C2** | +| `ctx.rbac.has` / `.hasFor` / `.require` | own permissions only | **A5** | +| `ctx.capabilities.has` / `.require` | the capability itself | **B11** | +| `ctx.hosts.*` | `hosts:read` / `hosts:write` | **B4**, **B5**, **B18** | +| `ctx.hosts.status.*` | `hosts:read` | **B16** | +| `ctx.ssh.*` | `ssh:connect`, `credentials:use` | **A8** | +| `ctx.settings.*` | `settings:read-core` (readCore only) | **A6** | +| `ctx.notify.channels` / `.send` | `notify:send` | **B17** | +| `ctx.auth.*` | `auth:provide` | **A8** | +| `ctx.auth.completeRedirectLogin` and the rest | `auth:provide` | **C2** | +| `ctx.desktop.openIsolatedWindow` | `desktop:window` | **B8** | +| `ctx.desktop.launchNativeRdp` / `.available` | `desktop:window` (launch only) | **B14** | +| `ctx.credentials.resolveHostProtocol` | `credentials:read` | **B14** | +| `ctx.credentials.registerSecretResolver` | `auth:provide` | **B20** | +| `ctx.audit.record` | none, the actor is the runtime's | **B9** | +| `ctx.schedule.every` / `.after` | none | **B16** | +| `ctx.fetch` | `network:outbound` | **B17**, signal **B18** | +| `ctx.process.run` / `.ensureBinary` | `process:spawn` (+ `network:outbound` to download) | **C3** | +| `ctx.auth.registerKeyboardInteractiveHandler` | `auth:provide` | **C3** | **B11** added `ctx.capabilities.has(capability)` / `.require(capability)`, a generic check for a capability no other ctx member wraps. Unlike every other @@ -1670,11 +1750,11 @@ connect pipeline instead of importing ssh2 helpers from core: - `prepare(host, { client, purpose })`, `openTransport`, `classifyKeyboardInteractive` and `autoResponses` are the lower level for a transport with its own prompt flow (**B6**'s interactive file-manager - connect with its TOTP/Warpgate parking flow). + connect with its TOTP and browser sign-in parking flow). - **B15** showed the higher level covers a multi-request HTTP handshake too: the Docker panel passes `connect` a `prompt` channel whose `ask` answers the - waiting request with what to ask (a code, a Warpgate sign-in, credentials) - and resolves when the next request (`connect-totp`, `connect-warpgate`) + waiting request with what to ask (a code, a browser sign-in, credentials) + and resolves when the next request (`connect-totp`, `connect-browser-sign-in`) brings the answer. A retry TOTP round re-asks instead of failing. docker has no auth code of its own left. - `requiresSecret(authType)` and `supportsBackground(authType)` ask the @@ -1796,6 +1876,19 @@ resolution (the owner's shared snapshot or their own override), never the owner's raw secret; no connect access, or no host, is `null`. Every call is audited as `plugin_credentials_read`, allowed or refused. +**C3** added `ctx.process`, for a plugin that ships a program (opkssh): + +- `run(file, args, { env, cwd, timeoutMs })` starts it without a shell and + returns `{ pid, onStdout, onStderr, exited, kill }`. Output arrives as UTF-8 + text. Every program still running is killed on deactivate. Audited as + `process_run`. +- `ensureBinary({ name, version, url, sha256, prebuilt })` returns the path of + a verified executable: the first `prebuilt` path whose SHA-256 matches (a + copy baked into the Docker image, used in place), else + `/plugins//bin/` when it matches, else a download over + https (redirects followed, `network:outbound` checked only then), written + atomically with mode 755. A mismatch throws and keeps nothing. + **B20** added `ctx.credentials.registerSecretResolver(scheme, resolve)`, behind `auth:provide` like `ctx.auth`'s registrations, for a plugin that expands `"://..."` references in a host's secret fields into the @@ -2151,6 +2244,7 @@ upgrade on all three engines, and the checks in step 15 are green. | `ctx.http.router`, `ctx.ws.*` | `network:serve` | | `ctx.ssh.*` | `ssh:connect`, `credentials:use` | | `ctx.auth.*` | `auth:provide` | + | `ctx.process.*` | `process:spawn` | | `ctx.settings.readCore` | `settings:read-core` | | `ctx.events.emit` outside `plugin..` | `events:core` | | anything registered through `app` | `ui:surface` | @@ -2425,6 +2519,7 @@ Then, with the app running: The bundled plugins predate the SDK, apart from workspaces (A9), snippets (B2), remote-desktop (B14), docker (B15), host-metrics (B16), automations (B17), ai (B18), homepage (B19), totp and webauthn (C1), sso and ldap (C2), +opkssh and warpgate (C3), which import nothing from core. The others still reach core by relative path (`../../../../src/backend/...`), which an esbuild plugin, `packages/plugin-sdk/cli/lib/legacy-core-imports.mjs`, keeps out of the bundle @@ -2502,4 +2597,4 @@ Known specifics: owns no module state, and its guacamole-lite server is built in `activate` with its own signal handlers removed (guacamole-lite installs SIGTERM and SIGINT handlers in its constructor). guacd is always an external service, - so there is no `ctx.process`; its address is an admin setting. + so it does not use `ctx.process`; its address is an admin setting. diff --git a/packages/plugin-sdk/FINISH-LIST.md b/packages/plugin-sdk/FINISH-LIST.md index cce02c2d5..c43775f16 100644 --- a/packages/plugin-sdk/FINISH-LIST.md +++ b/packages/plugin-sdk/FINISH-LIST.md @@ -168,10 +168,6 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite `setHostAutoTmux`, `getCookie`) and the connection helpers. They belong on the typed host bridge, which is also where B7's `app.logActivity` line above lands. Owner: D1. -- **B9 (ssh-terminal):** `OPKSSHDialog` is exported from - `@termix/plugin-sdk/ui` so the terminal can show the opkssh sign-in. It - leaves the ui entry when opkssh becomes a plugin that draws its own UI in - `terminal.overlay`. Owner: Phase C (opkssh). - **B9 (host-metrics):** the terminal toolbar's CPU/memory/disk bars (`TerminalMetricsStatus`) poll the local backend only; on the desktop app a host that lives on a connected remote server shows no bars. Same fix as the @@ -487,6 +483,47 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite (`h.pluginSettings?.["web-endpoint"]`). The file is untouched since before C2. Owner: D0. +- **C3 (opkssh), for the Step-CA step:** Step-CA is still core and no longer + shares OPKSSH's pieces. Its interaction is `stepca` and its socket messages + `stepca_*`, but nothing draws them yet: until its plugin contributes a + `terminal.overlay` (the opkssh plugin's `OpksshOverlay` and `OpksshDialog` + are the model), a Step-CA host asks for a sign-in the terminal cannot show. + Its issued certificates are cached in memory (`step-ca-auth.ts`), so they + are lost on restart; its plugin should own a table the way opkssh owns + `p_opkssh_tokens`. Step-CA certificates 2.8 cached in `opkssh_tokens` stay + in the adopted `p_opkssh_tokens`, unused, until they expire. Owner: the + Phase C step that moves Step-CA. +- **C3 (opkssh):** `database/routes/host-compat-routes.ts` answers the 2.8 + redirect URI `/host/opkssh-callback` with a 307 to the plugin and so names + the plugin's path. Keep it until identity providers have moved to + `/plugin-api/opkssh/callback` (the admin page shows it, and the + `legacyCallback` setting switches over). Owner: 3.0.0. +- **C3 (opkssh/warpgate):** `ssh_data.use_warpgate` and the `.opk` folder + under `DATA_DIR` stay on disk, unused (the drizzle drop is `SELECT 1;`, the + config is copied, not moved). Remove them in 3.0.0. Owner: 3.0.0. +- **C3 (opkssh):** the Termix docs still describe `DATA_DIR/.opk/config.yml` + and `/host/opkssh-callback`. They need the new config path + (`DATA_DIR/plugins/opkssh/config.yml`), the new redirect URI and the + `legacyCallback` setting. Owner: D0 (docs repo). +- **C3 (warpgate):** Termix-Mobile's file manager and docker clients call + `/ssh/connect-warpgate` and read `requiresWarpgate`; the plugins answer + `connect-browser-sign-in` and `requires_browser_sign_in` now (both clients + were already on paths B6 and B15 moved). The terminal socket keeps + `warpgate_auth_required` / `warpgate_auth_continue`. Owner: D1, with the + rest of the mobile client changes. +- **C3:** `host-bulk-routes.ts` still validates imported auth types against a + fixed list that names plugin types (`opkssh`, `stepca`, `tailscale`, + `vault`), and the host create/update routes no longer accept a + `useWarpgate` field inline (the host editor sends plugin settings). The + bulk list should come from the registered providers plus + `contributes.auth.sshAuthTypes`. Owner: D1. +- **Pre-existing, found in C3:** the Docker `opkssh-downloader` stage's + `OPKSSH_VERSION` build arg is not passed on at runtime, so building with + another version gives a prebaked binary the plugin's pinned checksum + rejects (it then downloads the pinned version instead). Pass + `OPKSSH_VERSION` and the matching `OPKSSH_SHA256` as `ENV` in the final + stage. Owner: D0. + ## Manual checks after 2.9.0 - SSH terminal: password auth, key auth (and an encrypted key's passphrase @@ -572,3 +609,12 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite sign-in; a second factor after an external login with the admin setting on and off; disable each plugin and its buttons go away and its URLs stop signing anyone in, then enable it again. +- OPKSSH and Warpgate: on a 2.8 Docker install with OPKSSH set up, the config + shows up under the plugin, the admin page shows the old redirect URI and a + terminal sign-in to an OPKSSH host works through the provider chooser + without touching the identity provider; switch to the new URI, register + it and sign in again; a second connect within 24 hours skips the browser; + an offline Docker install uses the baked binary; disable the opkssh plugin + and the host says it needs it. A Warpgate host's toggle is on in the host + editor's Plugins group after the upgrade, and the terminal, file manager + and docker each show the sign-in dialog and connect after it. diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index cdc1a7303..b2fc07256 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -56,6 +56,10 @@ "types": "./dist/host-commands.d.ts", "default": "./dist/host-commands.js" }, + "./ssh-certs": { + "types": "./dist/ssh-certs.d.ts", + "default": "./dist/ssh-certs.js" + }, "./tsconfig.plugin.json": "./tsconfig.plugin.json", "./schema/manifest.schema.json": "./schema/manifest.schema.json" }, diff --git a/packages/plugin-sdk/schema/manifest.schema.json b/packages/plugin-sdk/schema/manifest.schema.json index 83f0a979c..86677850f 100644 --- a/packages/plugin-sdk/schema/manifest.schema.json +++ b/packages/plugin-sdk/schema/manifest.schema.json @@ -296,6 +296,10 @@ "secretSchemes": { "type": "array", "items": { "type": "string", "pattern": "^[a-z][a-z0-9-]{0,63}$" } + }, + "keyboardInteractive": { + "type": "array", + "items": { "type": "string", "pattern": "^[a-z][a-z0-9-]{0,63}$" } } } }, diff --git a/packages/plugin-sdk/src/backend.ts b/packages/plugin-sdk/src/backend.ts index cc83f700f..f882b1142 100644 --- a/packages/plugin-sdk/src/backend.ts +++ b/packages/plugin-sdk/src/backend.ts @@ -281,7 +281,9 @@ export type PluginMiddleware = ( export interface PluginRouterOptions { /** * Paths served without authentication, relative to the plugin's mount point - * and matched exactly ("/callback", not "/plugin-api//callback"). + * and matched exactly ("/callback", not "/plugin-api//callback"). A + * ":name" segment matches one segment, and a trailing "/*" matches any + * number of further segments, for a proxied page ("/chooser/:id/*"). * * For the handful of routes an unauthenticated third party has to reach: an * OIDC callback, an inbound webhook. Every entry is audited when the router @@ -775,26 +777,69 @@ export interface PluginKeyboardInteractivePrompt { echo?: boolean; } +/** + * A round the user finishes in a browser: open a URL, confirm a code there, + * then continue. Transports show one generic dialog for it and name their + * messages after `id` (the terminal sends "_auth_required" and waits for + * "_auth_continue"). + */ +export interface PluginBrowserSignInRound { + kind: "browser"; + /** The keyboard-interactive handler that claimed the round. */ + id: string; + /** Product name for the dialog title. */ + label: string; + url: string | null; + /** A code to compare in the browser, or "N/A". */ + code: string; + instructions: string; +} + export type PluginKeyboardInteractiveDecision = | { kind: "auto"; responses: string[] } - | { - kind: "warpgate"; - url: string | null; - securityKey: string; - instructions: string; - } + | PluginBrowserSignInRound | { kind: "totp"; promptIndex: number } | { kind: "input"; promptIndex: number; isPush: boolean }; export type PluginSshPromptRequest = | { kind: "totp"; prompt: string; retry: boolean } | { kind: "input"; prompt: string; echo: boolean; isPush: boolean } - | { - kind: "warpgate"; - url: string | null; - securityKey: string; + | PluginBrowserSignInRound; + +/** What a keyboard-interactive handler's detect may return. */ +export type PluginKeyboardInteractiveDetection = + | Exclude + | Omit; + +/** + * Claims keyboard-interactive rounds on any host, whatever its auth type, + * for a server that speaks its own prompt style (a bastion asking for a + * browser sign-in). Handlers run before core's TOTP and push detection. + * + * `settings` is this plugin's host-scope settings for the host, with manifest + * defaults filled in. Both hooks are synchronous; they run inside ssh2's + * keyboard-interactive callback. + */ +export interface PluginKeyboardInteractiveHandler { + /** Listed in contributes.auth.keyboardInteractive. */ + id: string; + /** Product name shown when a browser round is claimed. */ + label: string; + detect: ( + round: { + name: string; instructions: string; - }; + prompts: PluginKeyboardInteractivePrompt[]; + }, + host: PluginSshHost, + settings: Record, + ) => PluginKeyboardInteractiveDetection | null; + /** Answer password prompts with the stored password instead of asking. */ + autoAnswerPasswords?: ( + host: PluginSshHost, + settings: Record, + ) => boolean; +} /** Answers keyboard-interactive prompts. Resolve null to give up. */ export interface PluginSshPromptChannel { @@ -1213,6 +1258,10 @@ export interface PluginSecondFactor { */ export interface PluginAuth { registerSshAuthProvider: (provider: PluginSshAuthProvider) => void; + /** A host-independent keyboard-interactive handler. */ + registerKeyboardInteractiveHandler: ( + handler: PluginKeyboardInteractiveHandler, + ) => void; registerLoginMethod: (method: PluginLoginMethod) => void; registerSecondFactor: (factor: PluginSecondFactor) => void; /** @@ -1459,7 +1508,8 @@ export type PluginFetch = ( * hardware (a serial port, a USB device) is the first caller. Core cannot * mediate that access the way it mediates ctx.ssh or ctx.db, so the * capability here is a declared, reviewable, audited statement of intent - * rather than a technical gate, matching process:spawn. + * rather than a technical gate. Running programs has its own member, + * ctx.process. */ export interface PluginCapabilities { /** Whether this plugin currently holds `capability`. Not audited: for deciding whether to offer something, not for gating an action. */ @@ -1468,6 +1518,60 @@ export interface PluginCapabilities { require: (capability: string) => Promise; } +export interface PluginProcessOptions { + /** Added to the server's environment. */ + env?: Record; + cwd?: string; + /** Kills the program with SIGKILL after this long. */ + timeoutMs?: number; +} + +/** A running program started with ctx.process.run. */ +export interface PluginProcessHandle { + readonly pid: number | undefined; + /** Output as UTF-8 text, in the chunks it arrives in. */ + onStdout: (listener: (chunk: string) => void) => void; + onStderr: (listener: (chunk: string) => void) => void; + /** Settles when the program ends, or rejects when it cannot start. */ + readonly exited: Promise<{ code: number | null; signal: string | null }>; + kill: (signal?: "SIGTERM" | "SIGKILL") => void; +} + +/** A program a plugin downloads and runs, pinned to one checksum. */ +export interface PluginBinarySpec { + /** File name under the plugin's data dir, e.g. "opkssh-linux-amd64". */ + name: string; + version: string; + /** https URL of the release asset. Redirects are followed. */ + url: string; + /** Hex SHA-256 the file must have. */ + sha256: string; + /** + * Paths checked before downloading, such as a copy baked into the Docker + * image. One whose checksum matches is used in place. + */ + prebuilt?: readonly string[]; +} + +/** + * Programs on the Termix server. Needs process:spawn. Every program still + * running is killed on deactivate. + */ +export interface PluginProcess { + /** Starts `file` with `args`, no shell. Audited. */ + run: ( + file: string, + args: readonly string[], + options?: PluginProcessOptions, + ) => Promise; + /** + * Returns the path of a verified executable: a matching prebuilt copy, the + * copy downloaded earlier, or a fresh download (which also needs + * network:outbound). Throws when the checksum does not match. + */ + ensureBinary: (spec: PluginBinarySpec) => Promise; +} + /** * Timers owned by the plugin. Every timer is cleared on deactivate, a run that * throws is logged instead of crashing the server, and a repeating job never @@ -1527,6 +1631,8 @@ export interface PluginContext { readonly notify: PluginNotify; /** Outbound HTTP through core's SSRF guard. Needs network:outbound. */ readonly fetch: PluginFetch; + /** Programs on the Termix server. Needs process:spawn. */ + readonly process: PluginProcess; /** * Runs `fn` with `userId` as the acting user, for background work that has diff --git a/packages/plugin-sdk/src/manifest.ts b/packages/plugin-sdk/src/manifest.ts index 02780c595..ee11eda5d 100644 --- a/packages/plugin-sdk/src/manifest.ts +++ b/packages/plugin-sdk/src/manifest.ts @@ -259,6 +259,8 @@ export interface PluginAuthContribution { secondFactors?: string[]; /** Schemes this plugin resolves for "://..." secret references, e.g. "op". */ secretSchemes?: string[]; + /** Keyboard-interactive handler ids, e.g. "warpgate". */ + keyboardInteractive?: string[]; } export interface PluginContributions { @@ -792,7 +794,13 @@ function validateAuthContribution(value: unknown, errors: string[]): void { } rejectUnknown( value, - ["sshAuthTypes", "loginMethods", "secondFactors", "secretSchemes"], + [ + "sshAuthTypes", + "loginMethods", + "secondFactors", + "secretSchemes", + "keyboardInteractive", + ], "contributes.auth", errors, ); @@ -801,6 +809,7 @@ function validateAuthContribution(value: unknown, errors: string[]): void { "loginMethods", "secondFactors", "secretSchemes", + "keyboardInteractive", ]) { const list = value[key]; if (list === undefined) continue; @@ -1351,7 +1360,8 @@ export function parseManifest(raw: unknown): ParsedManifest { (auth?.sshAuthTypes?.length ?? 0) + (auth?.loginMethods?.length ?? 0) + (auth?.secondFactors?.length ?? 0) + - (auth?.secretSchemes?.length ?? 0) > + (auth?.secretSchemes?.length ?? 0) + + (auth?.keyboardInteractive?.length ?? 0) > 0; if (contributesAuth && !manifest.capabilities.includes("auth:provide")) { errors.push( diff --git a/src/backend/hosts/opkssh-cert-auth.ts b/packages/plugin-sdk/src/ssh-certs.ts similarity index 73% rename from src/backend/hosts/opkssh-cert-auth.ts rename to packages/plugin-sdk/src/ssh-certs.ts index e9aae0b26..41e349e18 100644 --- a/src/backend/hosts/opkssh-cert-auth.ts +++ b/packages/plugin-sdk/src/ssh-certs.ts @@ -1,12 +1,14 @@ -import { getErrorMessage } from "../utils/error-message.js"; -// SSH certificate authentication workarounds for ssh2. -// ssh2 doesn't support OpenSSH cert auth natively — this module grafts -// the certificate onto the parsed key, wraps ECDSA signing to convert -// DER → SSH wire format, and patches Protocol.authPK to use the base -// algorithm in the signature wrapper (required by OpenSSH's sshkey_check_sigtype). -// -// setupOPKSSHCertAuth: for OPKSSH-issued ephemeral certificates (no passphrase) -// setupCACertAuth: for user-managed CA-signed -cert.pub files (passphrase supported) +/** + * OpenSSH certificate auth for ssh2, which has no native support for it. + * + * Grafts the certificate onto the parsed key, converts ECDSA signatures from + * DER to SSH wire format, and patches Protocol.authPK so the signature + * wrapper carries the base algorithm (OpenSSH's sshkey_check_sigtype wants + * that). Pure: no core state, it only touches the config and client it gets. + * + * Used by core key auth with a CA-signed certificate and by every plugin that + * issues short-lived certificates (opkssh, step-ca, vault). + */ import type { AnyAuthMethod, @@ -17,11 +19,6 @@ import type { PublicKeyAuthMethod, } from "ssh2"; -interface OPKSSHToken { - privateKey: string; - sshCert: string; -} - type SignCallback = ( data: Buffer, callback: (signature: Buffer) => void, @@ -34,7 +31,7 @@ interface ParsedPrivateKey { [key: symbol]: unknown; } -interface OPKSSHProtocol { +interface PatchedProtocol { authPK: ( user: string, pubKey: ParsedPrivateKey, @@ -58,19 +55,18 @@ interface OPKSSHProtocol { }; } -type OPKSSHClient = Client & { - _protocol?: OPKSSHProtocol; +type PatchedClient = Client & { + _protocol?: PatchedProtocol; }; -type OPKSSHNextAuthHandler = ( +type NextAuthHandler = ( authInfo: AuthenticationType | AnyAuthMethod | false, ) => void; -// ── Internal implementation ────────────────────────────────────────────────── // Grafts an OpenSSH certificate onto an already-parsed private key object and // patches the ssh2 client so that certificate-based publickey auth succeeds. -async function _applyCertToConnection( +async function applyCertToConnection( config: ConnectConfig, client: Client, privKey: ParsedPrivateKey, @@ -136,7 +132,7 @@ async function _applyCertToConnection( _partialSuccess: boolean, callback, ) => { - const next = callback as OPKSSHNextAuthHandler; + const next = callback as NextAuthHandler; if ( !certAuthAttempted && (!methodsLeft || methodsLeft.includes("publickey")) @@ -157,7 +153,7 @@ async function _applyCertToConnection( // wrapper algorithm for cert types. const baseAlgo = certType.replace(/-cert-v\d+@openssh\.com$/, ""); const origConnect = client.connect.bind(client); - const patchedClient = client as OPKSSHClient; + const patchedClient = client as PatchedClient; patchedClient.connect = (cfg: ConnectConfig) => { const connectedClient = origConnect(cfg); const proto = patchedClient._protocol; @@ -185,7 +181,7 @@ async function _applyCertToConnection( const baseAlgoLen = Buffer.byteLength(baseAlgo); const pubKeyLen = pubSSH.length; - // Build data to sign (uses cert algo — matches server verification) + // Build data to sign (uses the cert algo, which the server verifies against) const sigDataLen = 4 + sesLen + @@ -296,16 +292,23 @@ async function _applyCertToConnection( }; } -// ── Public API ─────────────────────────────────────────────────────────────── +export interface CertificateCredentials { + /** OpenSSH or PEM private key. */ + privateKey: Buffer | string; + /** The certificate line, e.g. "ssh-ed25519-cert-v01@openssh.com AAAA...". */ + certificate: string; + passphrase?: string; +} /** - * Set up OPKSSH certificate authentication on an ssh2 Client. - * The OPKSSH private key is assumed to be unencrypted (no passphrase). + * Sets up certificate publickey auth on an ssh2 client. Call before + * `client.connect(config)`; it sets `config.authHandler` and + * `config.username`. */ -export async function setupOPKSSHCertAuth( +export async function applyCertificateAuth( config: ConnectConfig, client: Client, - token: OPKSSHToken, + credentials: CertificateCredentials, username: string, ): Promise { const { createRequire } = await import("node:module"); @@ -314,56 +317,24 @@ export async function setupOPKSSHCertAuth( utils: { parseKey }, } = esmRequire("ssh2"); - // Store username in config so the authHandler can access it (config as Record).username = username; - const parsed = parseKey(Buffer.from(token.privateKey)); + const keyBuf = Buffer.isBuffer(credentials.privateKey) + ? credentials.privateKey + : Buffer.from(credentials.privateKey, "utf8"); + const parsed = credentials.passphrase + ? parseKey(keyBuf, credentials.passphrase) + : parseKey(keyBuf); + if (parsed instanceof Error || !parsed) { - throw new Error("Failed to parse OPKSSH private key"); + const reason = parsed instanceof Error ? parsed.message : "unknown error"; + throw new Error( + `Failed to parse private key for certificate auth: ${reason}`, + ); } const privKey = ( Array.isArray(parsed) ? parsed[0] : parsed ) as ParsedPrivateKey; - await _applyCertToConnection(config, client, privKey, token.sshCert); -} - -/** - * Set up CA-signed certificate authentication on an ssh2 Client. - * Supports passphrase-protected private keys. - * The cert content is the full text of the -cert.pub file - * (e.g. "ssh-ed25519-cert-v01@openssh.com AAAA..."). - */ -export async function setupCACertAuth( - config: ConnectConfig, - client: Client, - privateKey: Buffer | string, - certPublicKey: string, - username: string, - passphrase?: string, -): Promise { - const { createRequire } = await import("node:module"); - const esmRequire = createRequire(import.meta.url); - const { - utils: { parseKey }, - } = esmRequire("ssh2"); - - // Store username in config so the authHandler can access it - (config as Record).username = username; - - const keyBuf = Buffer.isBuffer(privateKey) - ? privateKey - : Buffer.from(privateKey, "utf8"); - - const parsed = passphrase ? parseKey(keyBuf, passphrase) : parseKey(keyBuf); - - if (parsed instanceof Error || !parsed) { - const errMsg = getErrorMessage(parsed, "unknown error"); - throw new Error(`Failed to parse private key for CA cert auth: ${errMsg}`); - } - const privKey = ( - Array.isArray(parsed) ? parsed[0] : parsed - ) as ParsedPrivateKey; - - await _applyCertToConnection(config, client, privKey, certPublicKey); + await applyCertToConnection(config, client, privKey, credentials.certificate); } diff --git a/packages/plugin-sdk/src/testing.ts b/packages/plugin-sdk/src/testing.ts index 767db317f..e40681724 100644 --- a/packages/plugin-sdk/src/testing.ts +++ b/packages/plugin-sdk/src/testing.ts @@ -68,6 +68,10 @@ import type { PluginNotification, PluginNotificationChannel, PluginFetchInit, + PluginBinarySpec, + PluginKeyboardInteractiveHandler, + PluginProcessHandle, + PluginProcessOptions, } from "./backend.js"; /** A timer registered through ctx.schedule, run by hand with runScheduled. */ @@ -127,6 +131,11 @@ export interface FakeContextOptions { * reaches the network by accident. */ fetch?: (url: string, init?: PluginFetchInit) => Promise; + /** + * Answers ctx.process. Without run every ctx.process.run rejects; without + * ensureBinary it answers "/tmp//bin/". + */ + process?: FakeProcessOptions; /** * Makes ctx.auth.recordEnrollment refuse the way core's policy does (trusted * proxy login on, password login off), with this message and a 409. @@ -140,8 +149,55 @@ export interface FakeContextOptions { loginAttemptLimit?: number; } +export interface FakeProcessOptions { + run?: ( + file: string, + args: readonly string[], + options?: PluginProcessOptions, + ) => PluginProcessHandle | Promise; + ensureBinary?: (spec: PluginBinarySpec) => Promise; +} + +/** A ctx.process handle a test drives by hand. */ +export interface FakeProcess extends PluginProcessHandle { + emitStdout: (chunk: string) => void; + emitStderr: (chunk: string) => void; + exit: (code: number | null, signal?: string | null) => void; + killed: string[]; +} + +export function createFakeProcess(pid = 4242): FakeProcess { + const stdout: Array<(chunk: string) => void> = []; + const stderr: Array<(chunk: string) => void> = []; + const killed: string[] = []; + let resolveExit: (value: { + code: number | null; + signal: string | null; + }) => void = () => {}; + const exited = new Promise<{ code: number | null; signal: string | null }>( + (resolve) => { + resolveExit = resolve; + }, + ); + return { + pid, + onStdout: (listener) => void stdout.push(listener), + onStderr: (listener) => void stderr.push(listener), + exited, + kill: (signal = "SIGTERM") => { + killed.push(signal); + resolveExit({ code: null, signal }); + }, + emitStdout: (chunk) => stdout.forEach((listener) => listener(chunk)), + emitStderr: (chunk) => stderr.forEach((listener) => listener(chunk)), + exit: (code, signal = null) => resolveExit({ code, signal }), + killed, + }; +} + export interface FakeAuthRegistrations { sshAuthProviders: PluginSshAuthProvider[]; + keyboardInteractiveHandlers: PluginKeyboardInteractiveHandler[]; loginMethods: PluginLoginMethod[]; secondFactors: PluginSecondFactor[]; /** ":" for every recorded enrolment. */ @@ -227,6 +283,14 @@ export interface FakePluginContext { secretStore: Map; /** Every ctx.fetch call, in order. */ fetches: Array<{ url: string; init?: PluginFetchInit }>; + /** Every ctx.process.run call, in order. */ + processRuns: Array<{ + file: string; + args: readonly string[]; + options?: PluginProcessOptions; + }>; + /** Every ctx.process.ensureBinary call, in order. */ + binaries: PluginBinarySpec[]; /** Every ctx.audit.record entry, in order. */ audits: Array<{ action: string; success: boolean; [key: string]: unknown }>; /** Every ctx.hosts.recordActivity call, in order. */ @@ -330,6 +394,8 @@ export function createFakeContext( const credentialReads: FakePluginContext["credentialReads"] = []; const notifications: FakePluginContext["notifications"] = []; const fetches: FakePluginContext["fetches"] = []; + const processRuns: FakePluginContext["processRuns"] = []; + const binaries: FakePluginContext["binaries"] = []; const secretStore = new Map(); const audits: FakePluginContext["audits"] = []; const activities: FakePluginContext["activities"] = []; @@ -371,6 +437,7 @@ export function createFakeContext( const registryProviders = new Map(); const auth: FakeAuthRegistrations = { sshAuthProviders: [], + keyboardInteractiveHandlers: [], loginMethods: [], secondFactors: [], enrollments: new Set(), @@ -811,13 +878,20 @@ export function createFakeContext( requiresSecret: (authType) => ["password", "key", "credential", "agent"].includes(authType), supportsBackground: (authType) => - !["none", "opkssh", "stepca"].includes(authType), + !["none", "stepca"].includes(authType) && + !auth.sshAuthProviders.some( + (provider) => + provider.type === authType && provider.supportsBackground === false, + ), }, auth: { registerSshAuthProvider: (provider) => { auth.sshAuthProviders.push(provider); }, + registerKeyboardInteractiveHandler: (handler) => { + auth.keyboardInteractiveHandlers.push(handler); + }, registerLoginMethod: (method) => { auth.loginMethods.push(method); }, @@ -927,6 +1001,23 @@ export function createFakeContext( return options.fetch(url, init); }, + process: { + run: async (file, args, runOptions) => { + processRuns.push({ file, args, options: runOptions }); + if (!options.process?.run) { + throw new Error("ctx.process.run is not stubbed"); + } + return options.process.run(file, args, runOptions); + }, + ensureBinary: async (spec) => { + binaries.push(spec); + if (options.process?.ensureBinary) { + return options.process.ensureBinary(spec); + } + return `/tmp/${pluginId}/bin/${spec.name}`; + }, + }, + asUser: async (userId, fn) => { const previous = actor; actor = userId; @@ -960,6 +1051,8 @@ export function createFakeContext( credentialReads, notifications, fetches, + processRuns, + binaries, secretStore, audits, activities, @@ -1024,6 +1117,8 @@ export interface MockContextOptions { */ fetch?: (url: string, init?: PluginFetchInit) => Promise; /** See FakeContextOptions. */ + process?: FakeProcessOptions; + /** See FakeContextOptions. */ refuseEnrollment?: string; /** See FakeContextOptions. */ baseUrl?: string; @@ -1046,8 +1141,8 @@ export interface MockPluginContext extends FakePluginContext { * ctx.db, network:serve on ctx.http and ctx.ws, events:core on emitting a * topic outside the plugin's own namespace, settings:read-core on * ctx.settings.readCore, ssh:connect plus credentials:use on ctx.ssh, - * notify:send on ctx.notify, network:outbound on ctx.fetch, and - * auth:provide on ctx.auth. Reading a plugin's own settings is deliberately + * notify:send on ctx.notify, network:outbound on ctx.fetch, process:spawn on + * ctx.process, and auth:provide on ctx.auth. Reading a plugin's own settings is deliberately * ungated. As the SDK grows a member, add its gate here in the same shape. */ export function createMockCtx( @@ -1073,6 +1168,7 @@ export function createMockCtx( hostStatuses: options.hostStatuses, notificationChannels: options.notificationChannels, fetch: options.fetch, + process: options.process, refuseEnrollment: options.refuseEnrollment, baseUrl: options.baseUrl, linkedUsers: options.linkedUsers, @@ -1309,6 +1405,10 @@ export function createMockCtx( require("auth:provide"); ctx.auth.registerSshAuthProvider(provider); }, + registerKeyboardInteractiveHandler: (handler) => { + require("auth:provide"); + ctx.auth.registerKeyboardInteractiveHandler(handler); + }, registerLoginMethod: (method) => { require("auth:provide"); ctx.auth.registerLoginMethod(method); @@ -1388,6 +1488,17 @@ export function createMockCtx( return ctx.fetch(url, init); }, + process: { + run: async (file, args, runOptions) => { + require("process:spawn"); + return ctx.process.run(file, args, runOptions); + }, + ensureBinary: async (spec) => { + require("process:spawn"); + return ctx.process.ensureBinary(spec); + }, + }, + secrets: { ...ctx.secrets, get: async (key) => { diff --git a/plugins/docker/locales/en.json b/plugins/docker/locales/en.json index 279051a95..ef2064ee7 100644 --- a/plugins/docker/locales/en.json +++ b/plugins/docker/locales/en.json @@ -78,7 +78,7 @@ "containerMustBeRunning": "Container must be running to access console", "verificationCodePrompt": "Enter verification code", "totpVerificationFailed": "TOTP verification failed. Please try again.", - "warpgateVerificationFailed": "Warpgate authentication failed. Please try again.", + "browserSignInFailed": "Sign-in failed. Please try again.", "connectedTo": "Connected to {{containerName}}", "disconnected": "Disconnected", "consoleError": "Console error", diff --git a/plugins/docker/locales/translated/af_ZA.json b/plugins/docker/locales/translated/af_ZA.json index 0fc94b13b..c56143f1c 100644 --- a/plugins/docker/locales/translated/af_ZA.json +++ b/plugins/docker/locales/translated/af_ZA.json @@ -73,7 +73,6 @@ "containerMustBeRunning": "Die houer moet loop om toegang tot die konsole te verkry", "verificationCodePrompt": "Voer verifikasiekode in", "totpVerificationFailed": "TOTP-verifikasie het misluk. Probeer asseblief weer.", - "warpgateVerificationFailed": "Warpgate-verifikasie het misluk. Probeer asseblief weer.", "connectedTo": "Gekoppel aan {{containerName}}", "disconnected": "Ontkoppel", "consoleError": "Konsolefout", diff --git a/plugins/docker/locales/translated/ar_SA.json b/plugins/docker/locales/translated/ar_SA.json index 987421790..fa0c39f6e 100644 --- a/plugins/docker/locales/translated/ar_SA.json +++ b/plugins/docker/locales/translated/ar_SA.json @@ -73,7 +73,6 @@ "containerMustBeRunning": "يجب أن تكون الحاوية قيد التشغيل للوصول إلى وحدة التحكم", "verificationCodePrompt": "أدخل رمز التحقق", "totpVerificationFailed": "فشل التحقق من TOTP. يرجى المحاولة مرة أخرى.", - "warpgateVerificationFailed": "فشلت مصادقة Warpgate. يرجى المحاولة مرة أخرى.", "connectedTo": "متصل بـ {{containerName}}", "disconnected": "تم قطع الاتصال", "consoleError": "خطأ في وحدة التحكم", diff --git a/plugins/docker/locales/translated/bg_BG.json b/plugins/docker/locales/translated/bg_BG.json index e5d7a69d8..28bb1bd56 100644 --- a/plugins/docker/locales/translated/bg_BG.json +++ b/plugins/docker/locales/translated/bg_BG.json @@ -73,7 +73,6 @@ "containerMustBeRunning": "Контейнерът трябва да е работещ, за да има достъп до конзолата", "verificationCodePrompt": "Въведете код за потвърждение", "totpVerificationFailed": "Проверката на TOTP не бе успешна. Моля, опитайте отново.", - "warpgateVerificationFailed": "Удостоверяването на Warpgate не бе успешно. Моля, опитайте отново.", "connectedTo": "Свързано с {{containerName}}", "disconnected": "Изключен", "consoleError": "Грешка в конзолата", diff --git a/plugins/docker/locales/translated/bn_BD.json b/plugins/docker/locales/translated/bn_BD.json index ca0541922..f1edad4b7 100644 --- a/plugins/docker/locales/translated/bn_BD.json +++ b/plugins/docker/locales/translated/bn_BD.json @@ -73,7 +73,6 @@ "containerMustBeRunning": "কনসোল অ্যাক্সেস করতে হলে কন্টেইনারটি অবশ্যই চালু থাকতে হবে।", "verificationCodePrompt": "যাচাইকরণ কোড প্রবেশ করান", "totpVerificationFailed": "TOTP যাচাইকরণ ব্যর্থ হয়েছে। অনুগ্রহ করে আবার চেষ্টা করুন।", - "warpgateVerificationFailed": "ওয়ার্পগেট প্রমাণীকরণ ব্যর্থ হয়েছে। অনুগ্রহ করে আবার চেষ্টা করুন।", "connectedTo": "{{containerName}} এর সাথে সংযুক্ত", "disconnected": "বিচ্ছিন্ন", "consoleError": "কনসোল ত্রুটি", diff --git a/plugins/docker/locales/translated/ca_ES.json b/plugins/docker/locales/translated/ca_ES.json index 2e880a1f5..6b6c60414 100644 --- a/plugins/docker/locales/translated/ca_ES.json +++ b/plugins/docker/locales/translated/ca_ES.json @@ -73,7 +73,6 @@ "containerMustBeRunning": "El contenidor ha d'estar en execució per accedir a la consola.", "verificationCodePrompt": "Introduïu el codi de verificació", "totpVerificationFailed": "La verificació TOTP ha fallat. Torna-ho a provar.", - "warpgateVerificationFailed": "L'autenticació de Warpgate ha fallat. Torna-ho a intentar.", "connectedTo": "Connectat a {{containerName}}", "disconnected": "Desconnectat", "consoleError": "Error de consola", diff --git a/plugins/docker/locales/translated/cs_CZ.json b/plugins/docker/locales/translated/cs_CZ.json index 6ab0c84b1..644ae1161 100644 --- a/plugins/docker/locales/translated/cs_CZ.json +++ b/plugins/docker/locales/translated/cs_CZ.json @@ -73,7 +73,6 @@ "containerMustBeRunning": "Pro přístup ke konzoli musí být kontejner spuštěný.", "verificationCodePrompt": "Zadejte ověřovací kód", "totpVerificationFailed": "Ověření TOTP se nezdařilo. Zkuste to prosím znovu.", - "warpgateVerificationFailed": "Ověření Warpgate se nezdařilo. Zkuste to prosím znovu.", "connectedTo": "Připojeno k {{containerName}}", "disconnected": "Odpojeno", "consoleError": "Chyba konzole", diff --git a/plugins/docker/locales/translated/da_DK.json b/plugins/docker/locales/translated/da_DK.json index 314d987fc..ce5643037 100644 --- a/plugins/docker/locales/translated/da_DK.json +++ b/plugins/docker/locales/translated/da_DK.json @@ -73,7 +73,6 @@ "containerMustBeRunning": "Containeren skal køre for at få adgang til konsollen", "verificationCodePrompt": "Indtast bekræftelseskode", "totpVerificationFailed": "TOTP-bekræftelse mislykkedes. Prøv igen.", - "warpgateVerificationFailed": "Warpgate-godkendelse mislykkedes. Prøv igen.", "connectedTo": "Forbundet til {{containerName}}", "disconnected": "Afbrudt", "consoleError": "Konsolfejl", diff --git a/plugins/docker/locales/translated/de_DE.json b/plugins/docker/locales/translated/de_DE.json index 360d204bb..6c049d4e2 100644 --- a/plugins/docker/locales/translated/de_DE.json +++ b/plugins/docker/locales/translated/de_DE.json @@ -73,7 +73,6 @@ "containerMustBeRunning": "Der Container muss ausgeführt werden, um auf die Konsole zuzugreifen", "verificationCodePrompt": "Bestätigungscode eingeben", "totpVerificationFailed": "TOTP-Überprüfung fehlgeschlagen. Bitte versuchen Sie es erneut.", - "warpgateVerificationFailed": "Warpgate-Authentifizierung fehlgeschlagen. Bitte versuchen Sie es erneut.", "connectedTo": "Verbunden mit {{containerName}}", "disconnected": "Getrennt", "consoleError": "Konsolenfehler", diff --git a/plugins/docker/locales/translated/el_GR.json b/plugins/docker/locales/translated/el_GR.json index 077a46d95..42c5ae4de 100644 --- a/plugins/docker/locales/translated/el_GR.json +++ b/plugins/docker/locales/translated/el_GR.json @@ -73,7 +73,6 @@ "containerMustBeRunning": "Το κοντέινερ πρέπει να εκτελείται για πρόσβαση στην κονσόλα", "verificationCodePrompt": "Εισαγάγετε τον κωδικό επαλήθευσης", "totpVerificationFailed": "Η επαλήθευση TOTP απέτυχε. Δοκιμάστε ξανά.", - "warpgateVerificationFailed": "Ο έλεγχος ταυτότητας Warpgate απέτυχε. Δοκιμάστε ξανά.", "connectedTo": "Συνδεδεμένο με {{containerName}}", "disconnected": "Ασύνδετος", "consoleError": "Σφάλμα κονσόλας", diff --git a/plugins/docker/locales/translated/es_ES.json b/plugins/docker/locales/translated/es_ES.json index f34e6e325..ae87d08fc 100644 --- a/plugins/docker/locales/translated/es_ES.json +++ b/plugins/docker/locales/translated/es_ES.json @@ -73,7 +73,6 @@ "containerMustBeRunning": "El contenedor debe estar en ejecución para acceder a la consola", "verificationCodePrompt": "Introduzca el código de verificación", "totpVerificationFailed": "La verificación TOTP falló. Inténtelo de nuevo.", - "warpgateVerificationFailed": "La autenticación Warpgate falló. Inténtelo de nuevo.", "connectedTo": "Conectado a {{containerName}}", "disconnected": "Desconectado", "consoleError": "Error de consola", diff --git a/plugins/docker/locales/translated/fi_FI.json b/plugins/docker/locales/translated/fi_FI.json index db7817049..3c03edc78 100644 --- a/plugins/docker/locales/translated/fi_FI.json +++ b/plugins/docker/locales/translated/fi_FI.json @@ -73,7 +73,6 @@ "containerMustBeRunning": "Kontin on oltava käynnissä, jotta konsoliin pääsee käsiksi.", "verificationCodePrompt": "Syötä vahvistuskoodi", "totpVerificationFailed": "TOTP-vahvistus epäonnistui. Yritä uudelleen.", - "warpgateVerificationFailed": "Warpgate-todennus epäonnistui. Yritä uudelleen.", "connectedTo": "Yhdistetty kohteeseen {{containerName}}", "disconnected": "Yhteys katkennut", "consoleError": "Konsolivirhe", diff --git a/plugins/docker/locales/translated/fr_FR.json b/plugins/docker/locales/translated/fr_FR.json index 3f3c90e9c..931a11678 100644 --- a/plugins/docker/locales/translated/fr_FR.json +++ b/plugins/docker/locales/translated/fr_FR.json @@ -73,7 +73,6 @@ "containerMustBeRunning": "Le conteneur doit être en cours d'exécution pour accéder à la console", "verificationCodePrompt": "Saisissez le code de vérification", "totpVerificationFailed": "La vérification TOTP a échoué. Veuillez réessayer.", - "warpgateVerificationFailed": "L'authentification Warpgate a échoué. Veuillez réessayer.", "connectedTo": "Connecté à {{containerName}}", "disconnected": "Déconnecté", "consoleError": "Erreur de la console", diff --git a/plugins/docker/locales/translated/he_IL.json b/plugins/docker/locales/translated/he_IL.json index 0e6e792c5..e063da67d 100644 --- a/plugins/docker/locales/translated/he_IL.json +++ b/plugins/docker/locales/translated/he_IL.json @@ -73,7 +73,6 @@ "containerMustBeRunning": "יש לפעול על מנת לגשת למסוף.", "verificationCodePrompt": "הזן קוד אימות", "totpVerificationFailed": "אימות TOTP נכשל. אנא נסה שוב.", - "warpgateVerificationFailed": "אימות Warpgate נכשל. אנא נסה שוב.", "connectedTo": "מחובר אל {{containerName}}", "disconnected": "מְנוּתָק", "consoleError": "שגיאת קונסולה", diff --git a/plugins/docker/locales/translated/hi_IN.json b/plugins/docker/locales/translated/hi_IN.json index 519961105..331c1fbc0 100644 --- a/plugins/docker/locales/translated/hi_IN.json +++ b/plugins/docker/locales/translated/hi_IN.json @@ -73,7 +73,6 @@ "containerMustBeRunning": "कंसोल तक पहुंचने के लिए कंटेनर चालू होना चाहिए", "verificationCodePrompt": "सत्यापन कोड दर्ज करें", "totpVerificationFailed": "TOTP सत्यापन विफल। कृपया पुनः प्रयास करें।", - "warpgateVerificationFailed": "Warpgate प्रमाणीकरण विफल। कृपया पुनः प्रयास करें।", "connectedTo": "{{containerName}} से जुड़ा", "disconnected": "डिस्कनेक्टेड", "consoleError": "कंसोल त्रुटि", diff --git a/plugins/docker/locales/translated/hu_HU.json b/plugins/docker/locales/translated/hu_HU.json index 90d649572..7a5c4b20e 100644 --- a/plugins/docker/locales/translated/hu_HU.json +++ b/plugins/docker/locales/translated/hu_HU.json @@ -73,7 +73,6 @@ "containerMustBeRunning": "A konténernek futnia kell a konzol eléréséhez", "verificationCodePrompt": "Írja be az ellenőrző kódot", "totpVerificationFailed": "A TOTP ellenőrzése sikertelen. Próbáld újra.", - "warpgateVerificationFailed": "A Warpgate hitelesítése sikertelen. Próbáld újra.", "connectedTo": "Csatlakoztatva ehhez: {{containerName}}", "disconnected": "Szétkapcsolt", "consoleError": "Konzolhiba", diff --git a/plugins/docker/locales/translated/id_ID.json b/plugins/docker/locales/translated/id_ID.json index c32fe2d02..52c9d3583 100644 --- a/plugins/docker/locales/translated/id_ID.json +++ b/plugins/docker/locales/translated/id_ID.json @@ -73,7 +73,6 @@ "containerMustBeRunning": "Kontainer harus berjalan agar dapat mengakses konsol.", "verificationCodePrompt": "Masukkan kode verifikasi", "totpVerificationFailed": "Verifikasi TOTP gagal. Silakan coba lagi.", - "warpgateVerificationFailed": "Autentikasi Warpgate gagal. Silakan coba lagi.", "connectedTo": "Terhubung ke {{containerName}}", "disconnected": "Terputus", "consoleError": "Kesalahan konsol", diff --git a/plugins/docker/locales/translated/it_IT.json b/plugins/docker/locales/translated/it_IT.json index c437fc338..4f6c78139 100644 --- a/plugins/docker/locales/translated/it_IT.json +++ b/plugins/docker/locales/translated/it_IT.json @@ -73,7 +73,6 @@ "containerMustBeRunning": "Il container deve essere in esecuzione per accedere alla console", "verificationCodePrompt": "Inserisci il codice di verifica", "totpVerificationFailed": "Verifica TOTP fallita. Riprova.", - "warpgateVerificationFailed": "Autenticazione Warpgate fallita. Riprova.", "connectedTo": "Connesso a {{containerName}}", "disconnected": "Disconnesso", "consoleError": "Errore console", diff --git a/plugins/docker/locales/translated/ja_JP.json b/plugins/docker/locales/translated/ja_JP.json index 332d69537..506c017f4 100644 --- a/plugins/docker/locales/translated/ja_JP.json +++ b/plugins/docker/locales/translated/ja_JP.json @@ -73,7 +73,6 @@ "containerMustBeRunning": "コンソールにアクセスするにはコンテナが実行中である必要があります", "verificationCodePrompt": "認証コードを入力してください", "totpVerificationFailed": "TOTP認証に失敗しました。もう一度お試しください。", - "warpgateVerificationFailed": "Warpgate認証に失敗しました。もう一度お試しください。", "connectedTo": "{{containerName}}に接続しました", "disconnected": "切断されました", "consoleError": "コンソールエラー", diff --git a/plugins/docker/locales/translated/ko_KR.json b/plugins/docker/locales/translated/ko_KR.json index 3800135d1..8574921c7 100644 --- a/plugins/docker/locales/translated/ko_KR.json +++ b/plugins/docker/locales/translated/ko_KR.json @@ -73,7 +73,6 @@ "containerMustBeRunning": "컨테이너가 실행 중이어야 콘솔에 접근할 수 있습니다", "verificationCodePrompt": "인증 코드를 입력하세요", "totpVerificationFailed": "TOTP 인증에 실패했습니다. 다시 시도하세요.", - "warpgateVerificationFailed": "Warpgate 인증에 실패했습니다. 다시 시도하세요.", "connectedTo": "{{containerName}}에 연결됨", "disconnected": "연결 끊김", "consoleError": "콘솔 오류", diff --git a/plugins/docker/locales/translated/nl_NL.json b/plugins/docker/locales/translated/nl_NL.json index af8fdcf58..5d5c1d2f3 100644 --- a/plugins/docker/locales/translated/nl_NL.json +++ b/plugins/docker/locales/translated/nl_NL.json @@ -73,7 +73,6 @@ "containerMustBeRunning": "De container moet actief zijn om toegang te krijgen tot de console.", "verificationCodePrompt": "Voer de verificatiecode in.", "totpVerificationFailed": "TOTP-verificatie mislukt. Probeer het opnieuw.", - "warpgateVerificationFailed": "Authenticatie via Warpgate is mislukt. Probeer het opnieuw.", "connectedTo": "Verbonden met {{containerName}}", "disconnected": "Verbinding verbroken", "consoleError": "Consolefout", diff --git a/plugins/docker/locales/translated/no_NO.json b/plugins/docker/locales/translated/no_NO.json index c8af84512..d43b58508 100644 --- a/plugins/docker/locales/translated/no_NO.json +++ b/plugins/docker/locales/translated/no_NO.json @@ -73,7 +73,6 @@ "containerMustBeRunning": "Containeren må kjøre for å få tilgang til konsollen", "verificationCodePrompt": "Skriv inn bekreftelseskoden", "totpVerificationFailed": "TOTP-verifisering mislyktes. Prøv på nytt.", - "warpgateVerificationFailed": "Warpgate-autentisering mislyktes. Prøv på nytt.", "connectedTo": "Koblet til {{containerName}}", "disconnected": "Frakoblet", "consoleError": "Konsollfeil", diff --git a/plugins/docker/locales/translated/pl_PL.json b/plugins/docker/locales/translated/pl_PL.json index 35dd84d29..7f459e472 100644 --- a/plugins/docker/locales/translated/pl_PL.json +++ b/plugins/docker/locales/translated/pl_PL.json @@ -73,7 +73,6 @@ "containerMustBeRunning": "Aby uzyskać dostęp do konsoli, kontener musi być uruchomiony", "verificationCodePrompt": "Wprowadź kod weryfikacyjny", "totpVerificationFailed": "Weryfikacja TOTP nie powiodła się. Spróbuj ponownie.", - "warpgateVerificationFailed": "Uwierzytelnianie Warpgate nie powiodło się. Spróbuj ponownie.", "connectedTo": "Połączono z {{containerName}}", "disconnected": "Rozłączono", "consoleError": "Błąd konsoli", diff --git a/plugins/docker/locales/translated/pt_BR.json b/plugins/docker/locales/translated/pt_BR.json index 8ada980d9..476b3dc58 100644 --- a/plugins/docker/locales/translated/pt_BR.json +++ b/plugins/docker/locales/translated/pt_BR.json @@ -73,7 +73,6 @@ "containerMustBeRunning": "O contêiner precisa estar em execução para acessar o console.", "verificationCodePrompt": "Insira o código de verificação", "totpVerificationFailed": "A verificação TOTP falhou. Tente novamente.", - "warpgateVerificationFailed": "A autenticação no portal de guerra falhou. Por favor, tente novamente.", "connectedTo": "Conectado a {{containerName}}", "disconnected": "Desconectado", "consoleError": "Erro no console", diff --git a/plugins/docker/locales/translated/pt_PT.json b/plugins/docker/locales/translated/pt_PT.json index a72b1b55f..a4e875d40 100644 --- a/plugins/docker/locales/translated/pt_PT.json +++ b/plugins/docker/locales/translated/pt_PT.json @@ -73,7 +73,6 @@ "containerMustBeRunning": "O contentor tem de estar em execução para aceder à consola", "verificationCodePrompt": "Introduza o código de verificação", "totpVerificationFailed": "A verificação TOTP falhou. Por favor, tente novamente.", - "warpgateVerificationFailed": "A autenticação Warpgate falhou. Por favor, tente novamente.", "connectedTo": "Ligado a {{containerName}}", "disconnected": "Desligado", "consoleError": "Erro da consola", diff --git a/plugins/docker/locales/translated/ro_RO.json b/plugins/docker/locales/translated/ro_RO.json index 2c8c9384b..467be7b47 100644 --- a/plugins/docker/locales/translated/ro_RO.json +++ b/plugins/docker/locales/translated/ro_RO.json @@ -73,7 +73,6 @@ "containerMustBeRunning": "Containerul trebuie să ruleze pentru a accesa consola.", "verificationCodePrompt": "Introduceți codul de verificare", "totpVerificationFailed": "Verificarea TOTP a eșuat. Vă rugăm să încercați din nou.", - "warpgateVerificationFailed": "Autentificarea Warpgate a eșuat. Vă rugăm să încercați din nou.", "connectedTo": "Conectat la {{containerName}}", "disconnected": "Deconectat", "consoleError": "Eroare consolă", diff --git a/plugins/docker/locales/translated/ru_RU.json b/plugins/docker/locales/translated/ru_RU.json index 1b11e1af9..c7af28c18 100644 --- a/plugins/docker/locales/translated/ru_RU.json +++ b/plugins/docker/locales/translated/ru_RU.json @@ -73,7 +73,6 @@ "containerMustBeRunning": "Для доступа к консоли контейнер должен быть запущен", "verificationCodePrompt": "Введите код подтверждения", "totpVerificationFailed": "Ошибка проверки TOTP. Попробуйте еще раз.", - "warpgateVerificationFailed": "Ошибка аутентификации Warpgate. Попробуйте еще раз.", "connectedTo": "Подключено к {{containerName}}", "disconnected": "Отключено", "consoleError": "Ошибка консоли", diff --git a/plugins/docker/locales/translated/sr_SP.json b/plugins/docker/locales/translated/sr_SP.json index 72911733a..c545b4218 100644 --- a/plugins/docker/locales/translated/sr_SP.json +++ b/plugins/docker/locales/translated/sr_SP.json @@ -73,7 +73,6 @@ "containerMustBeRunning": "Контејнер мора бити покренут да би се приступило конзоли", "verificationCodePrompt": "Унесите верификациони код", "totpVerificationFailed": "Верификација TOTP-а није успела. Молимо покушајте поново.", - "warpgateVerificationFailed": "Аутентификација преко Ворпгејта није успела. Молимо покушајте поново.", "connectedTo": "Повезано са {{containerName}}", "disconnected": "Искључено", "consoleError": "Грешка конзоле", diff --git a/plugins/docker/locales/translated/sv_SE.json b/plugins/docker/locales/translated/sv_SE.json index 0d2b02ee5..b79586510 100644 --- a/plugins/docker/locales/translated/sv_SE.json +++ b/plugins/docker/locales/translated/sv_SE.json @@ -73,7 +73,6 @@ "containerMustBeRunning": "Behållaren måste köras för att komma åt konsolen", "verificationCodePrompt": "Ange verifieringskod", "totpVerificationFailed": "TOTP-verifieringen misslyckades. Försök igen.", - "warpgateVerificationFailed": "Warpgate-autentiseringen misslyckades. Försök igen.", "connectedTo": "Ansluten till {{containerName}}", "disconnected": "Osammanhängande", "consoleError": "Konsolfel", diff --git a/plugins/docker/locales/translated/th_TH.json b/plugins/docker/locales/translated/th_TH.json index ca109591a..aa2863717 100644 --- a/plugins/docker/locales/translated/th_TH.json +++ b/plugins/docker/locales/translated/th_TH.json @@ -73,7 +73,6 @@ "containerMustBeRunning": "ต้องเปิดใช้งานคอนเทนเนอร์ก่อนจึงจะสามารถเข้าถึงคอนโซลได้", "verificationCodePrompt": "ป้อนรหัสยืนยัน", "totpVerificationFailed": "การตรวจสอบ TOTP ล้มเหลว โปรดลองอีกครั้ง", - "warpgateVerificationFailed": "การตรวจสอบสิทธิ์ผ่านวาร์ปไม่สำเร็จ โปรดลองอีกครั้ง", "connectedTo": "เชื่อมต่อกับ {{containerName}}", "disconnected": "ตัดการเชื่อมต่อ", "consoleError": "ข้อผิดพลาดของคอนโซล", diff --git a/plugins/docker/locales/translated/tr_TR.json b/plugins/docker/locales/translated/tr_TR.json index 9488e1b0f..09c7db79a 100644 --- a/plugins/docker/locales/translated/tr_TR.json +++ b/plugins/docker/locales/translated/tr_TR.json @@ -73,7 +73,6 @@ "containerMustBeRunning": "Konsola erişmek için konteyner çalışıyor olmalıdır", "verificationCodePrompt": "Doğrulama kodunu girin", "totpVerificationFailed": "TOTP doğrulaması başarısız oldu. Lütfen tekrar deneyin.", - "warpgateVerificationFailed": "Warpgate kimlik doğrulaması başarısız oldu. Lütfen tekrar deneyin.", "connectedTo": "{{containerName}} ile bağlantı kuruldu", "disconnected": "Bağlantı kesildi", "consoleError": "Konsol hatası", diff --git a/plugins/docker/locales/translated/uk_UA.json b/plugins/docker/locales/translated/uk_UA.json index ca5309da8..940c27fb3 100644 --- a/plugins/docker/locales/translated/uk_UA.json +++ b/plugins/docker/locales/translated/uk_UA.json @@ -73,7 +73,6 @@ "containerMustBeRunning": "Для доступу до консолі контейнер має бути запущений", "verificationCodePrompt": "Введіть код підтвердження", "totpVerificationFailed": "Не вдалося перевірити TOTP. Спробуйте ще раз.", - "warpgateVerificationFailed": "Помилка автентифікації Warpgate. Будь ласка, спробуйте ще раз.", "connectedTo": "Підключено до {{containerName}}", "disconnected": "Відключено", "consoleError": "Помилка консолі", diff --git a/plugins/docker/locales/translated/vi_VN.json b/plugins/docker/locales/translated/vi_VN.json index 616b6054f..015f456cd 100644 --- a/plugins/docker/locales/translated/vi_VN.json +++ b/plugins/docker/locales/translated/vi_VN.json @@ -73,7 +73,6 @@ "containerMustBeRunning": "Container phải đang chạy để truy cập bảng điều khiển", "verificationCodePrompt": "Nhập mã xác minh", "totpVerificationFailed": "Xác minh TOTP thất bại. Vui lòng thử lại.", - "warpgateVerificationFailed": "Xác thực Warpgate thất bại. Vui lòng thử lại.", "connectedTo": "Đã kết nối đến {{containerName}}", "disconnected": "Đã ngắt kết nối", "consoleError": "Lỗi bảng điều khiển", diff --git a/plugins/docker/locales/translated/zh_CN.json b/plugins/docker/locales/translated/zh_CN.json index 6a572a12b..f7bb97f76 100644 --- a/plugins/docker/locales/translated/zh_CN.json +++ b/plugins/docker/locales/translated/zh_CN.json @@ -73,7 +73,6 @@ "containerMustBeRunning": "访问控制台需要容器处于运行状态", "verificationCodePrompt": "输入验证码", "totpVerificationFailed": "TOTP 验证失败,请重试。", - "warpgateVerificationFailed": "Warpgate 认证失败,请重试。", "connectedTo": "已连接到 {{containerName}}", "disconnected": "已断开连接", "consoleError": "控制台错误", diff --git a/plugins/docker/locales/translated/zh_TW.json b/plugins/docker/locales/translated/zh_TW.json index 75f80ae7d..7e35542c0 100644 --- a/plugins/docker/locales/translated/zh_TW.json +++ b/plugins/docker/locales/translated/zh_TW.json @@ -73,7 +73,6 @@ "containerMustBeRunning": "容器必須在執行中才能存取主控台", "verificationCodePrompt": "請輸入驗證碼", "totpVerificationFailed": "TOTP 驗證失敗,請再試一次。", - "warpgateVerificationFailed": "Warpgate 驗證失敗,請再試一次。", "connectedTo": "已連線至 {{containerName}}", "disconnected": "已中斷連線", "consoleError": "主控台錯誤", diff --git a/plugins/docker/src/backend/connect.ts b/plugins/docker/src/backend/connect.ts index 2a59635bb..89a582af1 100644 --- a/plugins/docker/src/backend/connect.ts +++ b/plugins/docker/src/backend/connect.ts @@ -2,9 +2,9 @@ * The Docker panel's interactive connect. * * Core's pipeline does the handshake; this only bridges its prompt channel to - * HTTP. A connect that needs a person (a TOTP code, a Warpgate sign-in, a + * HTTP. A connect that needs a person (a TOTP code, a browser sign-in, a * password the host does not store) answers the request that is waiting with - * what to ask, and the next request (connect-totp, connect-warpgate) carries + * what to ask, and the next request (connect-totp, connect-browser-sign-in) carries * the answer back and waits for whatever comes after it: the session, another * prompt, or an error. */ @@ -31,10 +31,10 @@ export interface ConnectStep { } const TOTP_WAIT_MS = 3 * 60 * 1000; -const WARPGATE_WAIT_MS = 5 * 60 * 1000; +const BROWSER_SIGN_IN_WAIT_MS = 5 * 60 * 1000; const STEP_WAIT_MS = 90 * 1000; // Covers the longest a person may take on a prompt, plus the handshake. -const CONNECT_TIMEOUT_MS = WARPGATE_WAIT_MS + 30 * 1000; +const CONNECT_TIMEOUT_MS = BROWSER_SIGN_IN_WAIT_MS + 30 * 1000; const PASSWORD_PROMPT = /password/i; @@ -47,7 +47,7 @@ export class PendingConnect { expiresAt = Date.now() + TOTP_WAIT_MS; /** What the open prompt expects, or null when nothing is being asked. */ - awaiting: "totp" | "warpgate" | null = null; + awaiting: "totp" | "browser" | null = null; abandoned = false; private waiter: ((step: ConnectStep) => void) | null = null; private queued: ConnectStep | null = null; @@ -87,7 +87,7 @@ export class PendingConnect { }); } - ask(kind: "totp" | "warpgate", waitMs: number): Promise { + ask(kind: "totp" | "browser", waitMs: number): Promise { this.awaiting = kind; this.expiresAt = Date.now() + waitMs; return new Promise((resolve) => { @@ -203,22 +203,23 @@ export async function startConnect( switch (request.kind) { case "totp": return parkTotp(request.prompt, request.retry ? { retry: true } : {}); - case "warpgate": { + case "browser": { logs.push( connectionLog( "info", "docker_auth", - "Warpgate authentication required", + `${request.label} sign-in required`, ), ); - const answer = pending.ask("warpgate", WARPGATE_WAIT_MS); + const answer = pending.ask("browser", BROWSER_SIGN_IN_WAIT_MS); pending.emit({ status: 200, body: { - requires_warpgate: true, + requires_browser_sign_in: true, sessionId, + label: request.label, url: request.url, - securityKey: request.securityKey, + code: request.code, connectionLogs: logs, }, }); @@ -378,7 +379,7 @@ export async function answerConnect( sessions: DockerSessions, sessionId: string, userId: string, - kind: "totp" | "warpgate", + kind: "totp" | "browser", value: string, ): Promise { const pending = sessions.getPending(sessionId); @@ -393,8 +394,8 @@ export async function answerConnect( status: 400, body: { error: - kind === "warpgate" - ? "Session is not a Warpgate session" + kind === "browser" + ? "Session is not waiting for a browser sign-in" : "Session is not waiting for a code", }, }; diff --git a/plugins/docker/src/backend/routes.ts b/plugins/docker/src/backend/routes.ts index 812e79f33..b031d6f99 100644 --- a/plugins/docker/src/backend/routes.ts +++ b/plugins/docker/src/backend/routes.ts @@ -104,7 +104,7 @@ export function registerRoutes(router: Router, { ctx, sessions, log }: Deps) { * /plugin-api/docker/ssh/connect: * post: * summary: Establish SSH session for Docker - * description: Opens an SSH session to a host for Docker operations. When the host asks for a code, a Warpgate sign-in or a password, the reply says so and the session waits for connect-totp or connect-warpgate. + * description: Opens an SSH session to a host for Docker operations. When the host asks for a code, a browser sign-in or a password, the reply says so and the session waits for connect-totp or connect-browser-sign-in. * tags: * - Docker * requestBody: @@ -311,10 +311,10 @@ export function registerRoutes(router: Router, { ctx, sessions, log }: Deps) { /** * @openapi - * /plugin-api/docker/ssh/connect-warpgate: + * /plugin-api/docker/ssh/connect-browser-sign-in: * post: - * summary: Complete Warpgate authentication - * description: Continues a connect after the user finished the Warpgate sign-in in the browser. + * summary: Continue after a browser sign-in + * description: Continues a connect after the user finished signing in in the browser (an SSH gateway's approval, for example). * tags: * - Docker * requestBody: @@ -332,13 +332,13 @@ export function registerRoutes(router: Router, { ctx, sessions, log }: Deps) { * 200: * description: Connected, or another prompt. * 400: - * description: Session ID required, or the session is not a Warpgate session. + * description: Session ID required, or the session is not waiting for a browser sign-in. * 404: * description: The connect expired. * 408: * description: The host did not answer in time. */ - router.post("/ssh/connect-warpgate", async (req, res) => { + router.post("/ssh/connect-browser-sign-in", async (req, res) => { const userId = userOf(res); if (!userId) return; const { sessionId } = (req.body ?? {}) as Record; @@ -349,7 +349,7 @@ export function registerRoutes(router: Router, { ctx, sessions, log }: Deps) { sessions, sessionId, userId, - "warpgate", + "browser", "", ); return res.status(step.status).json(step.body); diff --git a/plugins/docker/src/backend/sessions.ts b/plugins/docker/src/backend/sessions.ts index 4ee6dc247..14486731e 100644 --- a/plugins/docker/src/backend/sessions.ts +++ b/plugins/docker/src/backend/sessions.ts @@ -20,7 +20,7 @@ export interface DockerSession { cancelIdle?: () => void; } -/** A connect still waiting on a person (TOTP, Warpgate). */ +/** A connect still waiting on a person (TOTP, a browser sign-in). */ export interface PendingEntry { userId: string; hostId: number; diff --git a/plugins/docker/src/frontend/DockerManager.tsx b/plugins/docker/src/frontend/DockerManager.tsx index 9fe9b4231..65296f044 100644 --- a/plugins/docker/src/frontend/DockerManager.tsx +++ b/plugins/docker/src/frontend/DockerManager.tsx @@ -26,7 +26,7 @@ import { Select2, Separator, TOTPDialog, - WarpgateDialog, + BrowserSignInDialog, logActivity, useAdaptivePolling, useAreaPreferences, @@ -57,12 +57,13 @@ interface DockerManagerProps { } interface PromptState { - kind: "totp" | "warpgate"; + kind: "totp" | "browser"; sessionId: string; prompt?: string; isPassword?: boolean; url?: string; - securityKey?: string; + code?: string; + label?: string; } function newSessionId(): string { @@ -193,12 +194,13 @@ function DockerManagerInner({ ) => { if (result.connectionLogs) setLogs(result.connectionLogs); - if (result.requires_warpgate) { + if (result.requires_browser_sign_in) { setPrompt({ - kind: "warpgate", + kind: "browser", sessionId: sid, url: result.url || "", - securityKey: result.securityKey || "", + code: result.code || "", + label: result.label || "", }); return; } @@ -357,15 +359,19 @@ function DockerManagerInner({ } }; - const handleWarpgateContinue = async () => { - if (!prompt || prompt.kind !== "warpgate") return; + const handleBrowserSignInContinue = async () => { + if (!prompt || prompt.kind !== "browser") return; const sid = prompt.sessionId; setPrompt(null); setIsConnecting(true); try { - await applyConnectResult(sid, await docker.continueWarpgate(sid), false); + await applyConnectResult( + sid, + await docker.continueBrowserSignIn(sid), + false, + ); } catch (error) { - reportFailure(error, t("docker.warpgateVerificationFailed")); + reportFailure(error, t("docker.browserSignInFailed")); } finally { setIsConnecting(false); } @@ -379,7 +385,7 @@ function DockerManagerInner({ closeTab(); }; - const handleWarpgateOpenUrl = () => { + const handleBrowserSignInOpenUrl = () => { if (prompt?.url) window.open(prompt.url, "_blank", "noopener,noreferrer"); }; @@ -475,13 +481,14 @@ function DockerManagerInner({ onSubmit={handleTotpSubmit} onCancel={handlePromptCancel} /> - {currentHost && ( - call("complete Warpgate authentication", () => - api.post("/ssh/connect-warpgate", { sessionId }), + continueBrowserSignIn: (sessionId: string) => + call("continue after a browser sign-in", () => + api.post("/ssh/connect-browser-sign-in", { sessionId }), ), disconnect: (sessionId: string) => diff --git a/plugins/docker/tests/backend/routes.test.ts b/plugins/docker/tests/backend/routes.test.ts index cb0fc6f7b..5a76e4754 100644 --- a/plugins/docker/tests/backend/routes.test.ts +++ b/plugins/docker/tests/backend/routes.test.ts @@ -44,7 +44,7 @@ describe("docker routes", () => { const calls: Array<[string, string]> = [ ["POST", "/ssh/connect"], ["POST", "/ssh/connect-totp"], - ["POST", "/ssh/connect-warpgate"], + ["POST", "/ssh/connect-browser-sign-in"], ["POST", "/ssh/disconnect"], ["POST", "/ssh/keepalive"], ["GET", "/ssh/status?sessionId=s1"], @@ -204,29 +204,32 @@ describe("docker routes", () => { expect((await server.request("GET", "/containers/s1")).status).toBe(200); }); - it("continues a Warpgate sign-in and refuses a code for it", async () => { + it("continues a browser sign-in and refuses a code for it", async () => { server = await startServer(); promptingConnect(server, async (ask) => { await ask({ - kind: "warpgate", - url: "https://warpgate.example/login", - securityKey: "KEY", + kind: "browser", + id: "gateway", + label: "Gateway", + url: "https://gateway.example/login", + code: "KEY", instructions: "", }); }); const first = await connect(server); expect(first.body).toMatchObject({ - requires_warpgate: true, - url: "https://warpgate.example/login", - securityKey: "KEY", + requires_browser_sign_in: true, + label: "Gateway", + url: "https://gateway.example/login", + code: "KEY", }); const wrongKind = await server.request("POST", "/ssh/connect-totp", { body: { sessionId: "s1", totpCode: "1" }, }); expect(wrongKind.status).toBe(400); - const done = await server.request("POST", "/ssh/connect-warpgate", { + const done = await server.request("POST", "/ssh/connect-browser-sign-in", { body: { sessionId: "s1" }, }); expect(done.body.success).toBe(true); diff --git a/plugins/file-manager/locales/en.json b/plugins/file-manager/locales/en.json index 0b4b9b7f6..a4dcb1e4c 100644 --- a/plugins/file-manager/locales/en.json +++ b/plugins/file-manager/locales/en.json @@ -316,7 +316,7 @@ "connecting": "Connecting...", "connectedSuccessfully": "Connected successfully", "totpVerificationFailed": "TOTP verification failed", - "warpgateVerificationFailed": "Warpgate authentication failed", + "browserSignInFailed": "Sign-in failed. Please try again.", "authenticationFailed": "Authentication failed", "incorrectPassphrase": "Incorrect passphrase. Please try again.", "verificationCodePrompt": "Verification code:", diff --git a/plugins/file-manager/locales/translated/af_ZA.json b/plugins/file-manager/locales/translated/af_ZA.json index 29ef2325d..bd728f810 100644 --- a/plugins/file-manager/locales/translated/af_ZA.json +++ b/plugins/file-manager/locales/translated/af_ZA.json @@ -290,7 +290,6 @@ "connecting": "Verbind...", "connectedSuccessfully": "Suksesvol gekoppel", "totpVerificationFailed": "TOTP-verifikasie het misluk", - "warpgateVerificationFailed": "Warpgate-verifikasie het misluk", "authenticationFailed": "Verifikasie het misluk", "incorrectPassphrase": "Verkeerde wagwoordfrase. Probeer asseblief weer.", "verificationCodePrompt": "Verifikasiekode:", diff --git a/plugins/file-manager/locales/translated/ar_SA.json b/plugins/file-manager/locales/translated/ar_SA.json index 49ffc930b..2fb01dafc 100644 --- a/plugins/file-manager/locales/translated/ar_SA.json +++ b/plugins/file-manager/locales/translated/ar_SA.json @@ -290,7 +290,6 @@ "connecting": "جارٍ الاتصال...", "connectedSuccessfully": "تم الاتصال بنجاح", "totpVerificationFailed": "فشل التحقق من TOTP", - "warpgateVerificationFailed": "فشلت مصادقة Warpgate", "authenticationFailed": "فشلت المصادقة", "incorrectPassphrase": "عبارة المرور غير صحيحة. يرجى المحاولة مرة أخرى.", "verificationCodePrompt": "رمز التحقق:", diff --git a/plugins/file-manager/locales/translated/bg_BG.json b/plugins/file-manager/locales/translated/bg_BG.json index 6b453431c..2c4cdab1f 100644 --- a/plugins/file-manager/locales/translated/bg_BG.json +++ b/plugins/file-manager/locales/translated/bg_BG.json @@ -290,7 +290,6 @@ "connecting": "Свързване...", "connectedSuccessfully": "Свързаването е осъществено успешно", "totpVerificationFailed": "Проверката на TOTP не бе успешна", - "warpgateVerificationFailed": "Удостоверяването на Warpgate не беше успешно", "authenticationFailed": "Удостоверяването не бе успешно", "incorrectPassphrase": "Неправилна парола. Моля, опитайте отново.", "verificationCodePrompt": "Код за потвърждение:", diff --git a/plugins/file-manager/locales/translated/bn_BD.json b/plugins/file-manager/locales/translated/bn_BD.json index fd169f6a5..0765755ec 100644 --- a/plugins/file-manager/locales/translated/bn_BD.json +++ b/plugins/file-manager/locales/translated/bn_BD.json @@ -290,7 +290,6 @@ "connecting": "সংযোগ স্থাপন...", "connectedSuccessfully": "সফলভাবে সংযুক্ত হয়েছে", "totpVerificationFailed": "TOTP যাচাইকরণ ব্যর্থ হয়েছে", - "warpgateVerificationFailed": "ওয়ার্পগেট প্রমাণীকরণ ব্যর্থ হয়েছে", "authenticationFailed": "প্রমাণীকরণ ব্যর্থ হয়েছে", "incorrectPassphrase": "ভুল পাসফ্রেজ। অনুগ্রহ করে আবার চেষ্টা করুন।", "verificationCodePrompt": "যাচাইকরণ কোড:", diff --git a/plugins/file-manager/locales/translated/ca_ES.json b/plugins/file-manager/locales/translated/ca_ES.json index 229c95ebb..149459863 100644 --- a/plugins/file-manager/locales/translated/ca_ES.json +++ b/plugins/file-manager/locales/translated/ca_ES.json @@ -290,7 +290,6 @@ "connecting": "Connectant...", "connectedSuccessfully": "Connexió correcta", "totpVerificationFailed": "La verificació TOTP ha fallat", - "warpgateVerificationFailed": "L'autenticació de Warpgate ha fallat", "authenticationFailed": "L'autenticació ha fallat", "incorrectPassphrase": "Contrasenya incorrecta. Si us plau, torneu-ho a intentar.", "verificationCodePrompt": "Codi de verificació:", diff --git a/plugins/file-manager/locales/translated/cs_CZ.json b/plugins/file-manager/locales/translated/cs_CZ.json index a6ae45cbd..216ab4e72 100644 --- a/plugins/file-manager/locales/translated/cs_CZ.json +++ b/plugins/file-manager/locales/translated/cs_CZ.json @@ -290,7 +290,6 @@ "connecting": "Spojovací...", "connectedSuccessfully": "Úspěšné připojení", "totpVerificationFailed": "Ověření TOTP se nezdařilo", - "warpgateVerificationFailed": "Ověření Warpgate selhalo", "authenticationFailed": "Ověření se nezdařilo", "incorrectPassphrase": "Nesprávné heslo. Zkuste to prosím znovu.", "verificationCodePrompt": "Ověřovací kód:", diff --git a/plugins/file-manager/locales/translated/da_DK.json b/plugins/file-manager/locales/translated/da_DK.json index d1d436168..ab0aa353e 100644 --- a/plugins/file-manager/locales/translated/da_DK.json +++ b/plugins/file-manager/locales/translated/da_DK.json @@ -290,7 +290,6 @@ "connecting": "Forbinder...", "connectedSuccessfully": "Forbindelsen er oprettet", "totpVerificationFailed": "TOTP-bekræftelse mislykkedes", - "warpgateVerificationFailed": "Warpgate-godkendelse mislykkedes", "authenticationFailed": "Godkendelse mislykkedes", "incorrectPassphrase": "Forkert adgangskode. Prøv igen.", "verificationCodePrompt": "Bekræftelseskode:", diff --git a/plugins/file-manager/locales/translated/de_DE.json b/plugins/file-manager/locales/translated/de_DE.json index f05b2c527..51deceaba 100644 --- a/plugins/file-manager/locales/translated/de_DE.json +++ b/plugins/file-manager/locales/translated/de_DE.json @@ -290,7 +290,6 @@ "connecting": "Verbindung wird hergestellt...", "connectedSuccessfully": "Erfolgreich verbunden", "totpVerificationFailed": "TOTP-Überprüfung fehlgeschlagen", - "warpgateVerificationFailed": "Warpgate-Authentifizierung fehlgeschlagen", "authenticationFailed": "Authentifizierung fehlgeschlagen", "incorrectPassphrase": "Falsche Passphrase. Bitte versuchen Sie es erneut.", "verificationCodePrompt": "Verifizierungscode:", diff --git a/plugins/file-manager/locales/translated/el_GR.json b/plugins/file-manager/locales/translated/el_GR.json index 235b1a8da..1c490e578 100644 --- a/plugins/file-manager/locales/translated/el_GR.json +++ b/plugins/file-manager/locales/translated/el_GR.json @@ -290,7 +290,6 @@ "connecting": "Συνδετικός...", "connectedSuccessfully": "Η σύνδεση ολοκληρώθηκε με επιτυχία", "totpVerificationFailed": "Η επαλήθευση TOTP απέτυχε.", - "warpgateVerificationFailed": "Η πιστοποίηση Warpgate απέτυχε.", "authenticationFailed": "Η επαλήθευση ταυτότητας απέτυχε", "incorrectPassphrase": "Λανθασμένη φράση πρόσβασης. Δοκιμάστε ξανά.", "verificationCodePrompt": "Κωδικός επαλήθευσης:", diff --git a/plugins/file-manager/locales/translated/es_ES.json b/plugins/file-manager/locales/translated/es_ES.json index 6ca422054..6279b5634 100644 --- a/plugins/file-manager/locales/translated/es_ES.json +++ b/plugins/file-manager/locales/translated/es_ES.json @@ -290,7 +290,6 @@ "connecting": "Conectando...", "connectedSuccessfully": "Conectado correctamente", "totpVerificationFailed": "Error en la verificación TOTP", - "warpgateVerificationFailed": "Error de autenticación de Warpgate", "authenticationFailed": "Error de autenticación", "incorrectPassphrase": "Frase de contraseña incorrecta. Inténtelo de nuevo.", "verificationCodePrompt": "Código de verificación:", diff --git a/plugins/file-manager/locales/translated/fi_FI.json b/plugins/file-manager/locales/translated/fi_FI.json index 733cd3eeb..e90dab147 100644 --- a/plugins/file-manager/locales/translated/fi_FI.json +++ b/plugins/file-manager/locales/translated/fi_FI.json @@ -290,7 +290,6 @@ "connecting": "Yhdistetään...", "connectedSuccessfully": "Yhteys onnistui", "totpVerificationFailed": "TOTP-vahvistus epäonnistui", - "warpgateVerificationFailed": "Warpgate-todennus epäonnistui", "authenticationFailed": "Todennus epäonnistui", "incorrectPassphrase": "Virheellinen salasana. Yritä uudelleen.", "verificationCodePrompt": "Vahvistuskoodi:", diff --git a/plugins/file-manager/locales/translated/fr_FR.json b/plugins/file-manager/locales/translated/fr_FR.json index 2c5d4947b..09c27b65b 100644 --- a/plugins/file-manager/locales/translated/fr_FR.json +++ b/plugins/file-manager/locales/translated/fr_FR.json @@ -290,7 +290,6 @@ "connecting": "Connexion en cours...", "connectedSuccessfully": "Connecté avec succès", "totpVerificationFailed": "Échec de la vérification TOTP", - "warpgateVerificationFailed": "Échec de l'authentification Warpgate", "authenticationFailed": "Échec de l'authentification", "incorrectPassphrase": "Phrase secrète incorrecte. Veuillez réessayer.", "verificationCodePrompt": "Code de vérification :", diff --git a/plugins/file-manager/locales/translated/he_IL.json b/plugins/file-manager/locales/translated/he_IL.json index abc4faac7..dca4788de 100644 --- a/plugins/file-manager/locales/translated/he_IL.json +++ b/plugins/file-manager/locales/translated/he_IL.json @@ -290,7 +290,6 @@ "connecting": "מְקַשֵׁר...", "connectedSuccessfully": "התחבר בהצלחה", "totpVerificationFailed": "אימות TOTP נכשל", - "warpgateVerificationFailed": "אימות Warpgate נכשל", "authenticationFailed": "האימות נכשל", "incorrectPassphrase": "סיסמה שגויה. אנא נסה שוב.", "verificationCodePrompt": "קוד אימות:", diff --git a/plugins/file-manager/locales/translated/hi_IN.json b/plugins/file-manager/locales/translated/hi_IN.json index 3c5fcf6f9..24de69459 100644 --- a/plugins/file-manager/locales/translated/hi_IN.json +++ b/plugins/file-manager/locales/translated/hi_IN.json @@ -290,7 +290,6 @@ "connecting": "कनेक्ट हो रहा है...", "connectedSuccessfully": "सफलतापूर्वक कनेक्ट हुआ", "totpVerificationFailed": "TOTP सत्यापन विफल", - "warpgateVerificationFailed": "Warpgate सत्यापन विफल", "authenticationFailed": "प्रमाणीकरण विफल", "incorrectPassphrase": "गलत पासफ़्रेज़। कृपया पुनः प्रयास करें।", "verificationCodePrompt": "सत्यापन कोड:", diff --git a/plugins/file-manager/locales/translated/hu_HU.json b/plugins/file-manager/locales/translated/hu_HU.json index b9d57cc6e..8c4f7e387 100644 --- a/plugins/file-manager/locales/translated/hu_HU.json +++ b/plugins/file-manager/locales/translated/hu_HU.json @@ -290,7 +290,6 @@ "connecting": "Kapcsolódás...", "connectedSuccessfully": "Sikeres csatlakozás", "totpVerificationFailed": "TOTP-ellenőrzés sikertelen", - "warpgateVerificationFailed": "Warpgate hitelesítés sikertelen", "authenticationFailed": "Hitelesítés sikertelen", "incorrectPassphrase": "Hibás jelszó. Kérjük, próbálja újra.", "verificationCodePrompt": "Ellenőrző kód:", diff --git a/plugins/file-manager/locales/translated/id_ID.json b/plugins/file-manager/locales/translated/id_ID.json index 892857ea8..9f9a271a2 100644 --- a/plugins/file-manager/locales/translated/id_ID.json +++ b/plugins/file-manager/locales/translated/id_ID.json @@ -290,7 +290,6 @@ "connecting": "Menghubungkan...", "connectedSuccessfully": "Terhubung berhasil", "totpVerificationFailed": "Verifikasi TOTP gagal", - "warpgateVerificationFailed": "Autentikasi Warpgate gagal", "authenticationFailed": "Autentikasi gagal", "incorrectPassphrase": "Kata sandi salah. Silakan coba lagi.", "verificationCodePrompt": "Kode verifikasi:", diff --git a/plugins/file-manager/locales/translated/it_IT.json b/plugins/file-manager/locales/translated/it_IT.json index 9aa53904e..6a5e14ef0 100644 --- a/plugins/file-manager/locales/translated/it_IT.json +++ b/plugins/file-manager/locales/translated/it_IT.json @@ -290,7 +290,6 @@ "connecting": "Connessione in corso...", "connectedSuccessfully": "Connesso con successo", "totpVerificationFailed": "Verifica TOTP non riuscita", - "warpgateVerificationFailed": "Autenticazione Warpgate non riuscita", "authenticationFailed": "Autenticazione non riuscita", "incorrectPassphrase": "Frase di accesso non corretta. Riprova.", "verificationCodePrompt": "Codice di verifica:", diff --git a/plugins/file-manager/locales/translated/ja_JP.json b/plugins/file-manager/locales/translated/ja_JP.json index 310c66e36..259a18255 100644 --- a/plugins/file-manager/locales/translated/ja_JP.json +++ b/plugins/file-manager/locales/translated/ja_JP.json @@ -290,7 +290,6 @@ "connecting": "接続中...", "connectedSuccessfully": "接続しました", "totpVerificationFailed": "TOTP検証に失敗しました", - "warpgateVerificationFailed": "Warpgate認証に失敗しました", "authenticationFailed": "認証に失敗しました", "incorrectPassphrase": "パスフレーズが間違っています。もう一度お試しください。", "verificationCodePrompt": "認証コード:", diff --git a/plugins/file-manager/locales/translated/ko_KR.json b/plugins/file-manager/locales/translated/ko_KR.json index 3be2ccd62..adad7a78a 100644 --- a/plugins/file-manager/locales/translated/ko_KR.json +++ b/plugins/file-manager/locales/translated/ko_KR.json @@ -290,7 +290,6 @@ "connecting": "연결 중...", "connectedSuccessfully": "연결 성공", "totpVerificationFailed": "TOTP 인증 실패", - "warpgateVerificationFailed": "Warpgate 인증 실패", "authenticationFailed": "인증 실패", "incorrectPassphrase": "잘못된 암호입니다. 다시 시도하세요.", "verificationCodePrompt": "인증 코드:", diff --git a/plugins/file-manager/locales/translated/nl_NL.json b/plugins/file-manager/locales/translated/nl_NL.json index a27ef3f7a..fcb2e6b36 100644 --- a/plugins/file-manager/locales/translated/nl_NL.json +++ b/plugins/file-manager/locales/translated/nl_NL.json @@ -290,7 +290,6 @@ "connecting": "Verbinden...", "connectedSuccessfully": "Verbinding succesvol tot stand gebracht.", "totpVerificationFailed": "TOTP-verificatie mislukt", - "warpgateVerificationFailed": "Warpgate-authenticatie mislukt", "authenticationFailed": "Authenticatie mislukt", "incorrectPassphrase": "Onjuiste wachtzin. Probeer het opnieuw.", "verificationCodePrompt": "Verificatiecode:", diff --git a/plugins/file-manager/locales/translated/no_NO.json b/plugins/file-manager/locales/translated/no_NO.json index bfd609f0e..d9e276860 100644 --- a/plugins/file-manager/locales/translated/no_NO.json +++ b/plugins/file-manager/locales/translated/no_NO.json @@ -290,7 +290,6 @@ "connecting": "Kobler til...", "connectedSuccessfully": "Tilkoblet", "totpVerificationFailed": "TOTP-verifisering mislyktes", - "warpgateVerificationFailed": "Warpgate-autentisering mislyktes", "authenticationFailed": "Autentisering mislyktes", "incorrectPassphrase": "Feil passordfrase. Prøv på nytt.", "verificationCodePrompt": "Verifiseringskode:", diff --git a/plugins/file-manager/locales/translated/pl_PL.json b/plugins/file-manager/locales/translated/pl_PL.json index 56d07f4a7..94483c3da 100644 --- a/plugins/file-manager/locales/translated/pl_PL.json +++ b/plugins/file-manager/locales/translated/pl_PL.json @@ -290,7 +290,6 @@ "connecting": "Łączenie...", "connectedSuccessfully": "Połączono pomyślnie", "totpVerificationFailed": "Weryfikacja TOTP nie powiodła się", - "warpgateVerificationFailed": "Uwierzytelnianie Warpgate nie powiodło się", "authenticationFailed": "Uwierzytelnianie nie powiodło się", "incorrectPassphrase": "Nieprawidłowe hasło klucza. Spróbuj ponownie.", "verificationCodePrompt": "Kod weryfikacyjny:", diff --git a/plugins/file-manager/locales/translated/pt_BR.json b/plugins/file-manager/locales/translated/pt_BR.json index f5cf7a188..917bb8889 100644 --- a/plugins/file-manager/locales/translated/pt_BR.json +++ b/plugins/file-manager/locales/translated/pt_BR.json @@ -290,7 +290,6 @@ "connecting": "Conectando...", "connectedSuccessfully": "Conexão estabelecida com sucesso.", "totpVerificationFailed": "A verificação TOTP falhou.", - "warpgateVerificationFailed": "A autenticação do portal de guerra falhou.", "authenticationFailed": "A autenticação falhou.", "incorrectPassphrase": "Senha incorreta. Tente novamente.", "verificationCodePrompt": "Código de verificação:", diff --git a/plugins/file-manager/locales/translated/pt_PT.json b/plugins/file-manager/locales/translated/pt_PT.json index 6eca084e0..d519594a7 100644 --- a/plugins/file-manager/locales/translated/pt_PT.json +++ b/plugins/file-manager/locales/translated/pt_PT.json @@ -290,7 +290,6 @@ "connecting": "A ligar...", "connectedSuccessfully": "Ligado com sucesso", "totpVerificationFailed": "Falha na verificação TOTP", - "warpgateVerificationFailed": "Falha na autenticação Warpgate", "authenticationFailed": "Falha na autenticação", "incorrectPassphrase": "Frase-passe incorreta. Tente novamente.", "verificationCodePrompt": "Código de verificação:", diff --git a/plugins/file-manager/locales/translated/ro_RO.json b/plugins/file-manager/locales/translated/ro_RO.json index f09ce35a5..e43d17671 100644 --- a/plugins/file-manager/locales/translated/ro_RO.json +++ b/plugins/file-manager/locales/translated/ro_RO.json @@ -290,7 +290,6 @@ "connecting": "Se conectează...", "connectedSuccessfully": "Conectare reușită", "totpVerificationFailed": "Verificarea TOTP a eșuat", - "warpgateVerificationFailed": "Autentificarea Warpgate a eșuat", "authenticationFailed": "Autentificarea a eșuat", "incorrectPassphrase": "Parolă incorectă. Vă rugăm să încercați din nou.", "verificationCodePrompt": "Cod de verificare:", diff --git a/plugins/file-manager/locales/translated/ru_RU.json b/plugins/file-manager/locales/translated/ru_RU.json index 8e959f731..ca642727a 100644 --- a/plugins/file-manager/locales/translated/ru_RU.json +++ b/plugins/file-manager/locales/translated/ru_RU.json @@ -290,7 +290,6 @@ "connecting": "Подключение...", "connectedSuccessfully": "Подключено успешно", "totpVerificationFailed": "Ошибка проверки TOTP", - "warpgateVerificationFailed": "Ошибка аутентификации Warpgate", "authenticationFailed": "Ошибка аутентификации", "incorrectPassphrase": "Неверная парольная фраза. Попробуйте еще раз.", "verificationCodePrompt": "Код подтверждения:", diff --git a/plugins/file-manager/locales/translated/sr_SP.json b/plugins/file-manager/locales/translated/sr_SP.json index 15c708588..83730f20d 100644 --- a/plugins/file-manager/locales/translated/sr_SP.json +++ b/plugins/file-manager/locales/translated/sr_SP.json @@ -290,7 +290,6 @@ "connecting": "Повезивање...", "connectedSuccessfully": "Успешно повезано", "totpVerificationFailed": "Верификација TOTP-а није успела", - "warpgateVerificationFailed": "Аутентификација Варпгејта није успела", "authenticationFailed": "Аутентификација није успела", "incorrectPassphrase": "Нетачна лозинка. Молимо покушајте поново.", "verificationCodePrompt": "Верификациони код:", diff --git a/plugins/file-manager/locales/translated/sv_SE.json b/plugins/file-manager/locales/translated/sv_SE.json index ededda1fa..43e96d4b7 100644 --- a/plugins/file-manager/locales/translated/sv_SE.json +++ b/plugins/file-manager/locales/translated/sv_SE.json @@ -290,7 +290,6 @@ "connecting": "Ansluter...", "connectedSuccessfully": "Ansluten", "totpVerificationFailed": "TOTP-verifiering misslyckades", - "warpgateVerificationFailed": "Warpgate-autentisering misslyckades", "authenticationFailed": "Autentiseringen misslyckades", "incorrectPassphrase": "Felaktigt lösenord. Försök igen.", "verificationCodePrompt": "Verifieringskod:", diff --git a/plugins/file-manager/locales/translated/th_TH.json b/plugins/file-manager/locales/translated/th_TH.json index 49e71e2ae..e549f0ad2 100644 --- a/plugins/file-manager/locales/translated/th_TH.json +++ b/plugins/file-manager/locales/translated/th_TH.json @@ -290,7 +290,6 @@ "connecting": "กำลังเชื่อมต่อ...", "connectedSuccessfully": "เชื่อมต่อสำเร็จแล้ว", "totpVerificationFailed": "การตรวจสอบ TOTP ล้มเหลว", - "warpgateVerificationFailed": "การตรวจสอบสิทธิ์วาร์ปไม่สำเร็จ", "authenticationFailed": "การตรวจสอบสิทธิ์ล้มเหลว", "incorrectPassphrase": "รหัสผ่านไม่ถูกต้อง โปรดลองอีกครั้ง", "verificationCodePrompt": "รหัสยืนยัน:", diff --git a/plugins/file-manager/locales/translated/tr_TR.json b/plugins/file-manager/locales/translated/tr_TR.json index d49c32e49..a44a3057b 100644 --- a/plugins/file-manager/locales/translated/tr_TR.json +++ b/plugins/file-manager/locales/translated/tr_TR.json @@ -290,7 +290,6 @@ "connecting": "Bağlanıyor...", "connectedSuccessfully": "Bağlantı başarıyla kuruldu", "totpVerificationFailed": "TOTP doğrulaması başarısız oldu", - "warpgateVerificationFailed": "Warpgate kimlik doğrulaması başarısız oldu", "authenticationFailed": "Kimlik doğrulama başarısız oldu", "incorrectPassphrase": "Yanlış parola. Lütfen tekrar deneyin.", "verificationCodePrompt": "Doğrulama kodu:", diff --git a/plugins/file-manager/locales/translated/uk_UA.json b/plugins/file-manager/locales/translated/uk_UA.json index 2bba2fda1..741d214a6 100644 --- a/plugins/file-manager/locales/translated/uk_UA.json +++ b/plugins/file-manager/locales/translated/uk_UA.json @@ -290,7 +290,6 @@ "connecting": "Підключення...", "connectedSuccessfully": "Підключено успішно", "totpVerificationFailed": "Не вдалося перевірити TOTP", - "warpgateVerificationFailed": "Автентифікація Warpgate не вдалася", "authenticationFailed": "Помилка автентифікації", "incorrectPassphrase": "Неправильна парольна фраза. Спробуйте ще раз.", "verificationCodePrompt": "Код підтвердження:", diff --git a/plugins/file-manager/locales/translated/vi_VN.json b/plugins/file-manager/locales/translated/vi_VN.json index c95ae42c8..ac1a94559 100644 --- a/plugins/file-manager/locales/translated/vi_VN.json +++ b/plugins/file-manager/locales/translated/vi_VN.json @@ -290,7 +290,6 @@ "connecting": "Đang kết nối...", "connectedSuccessfully": "Đã kết nối thành công", "totpVerificationFailed": "Xác minh TOTP thất bại", - "warpgateVerificationFailed": "Xác thực Warpgate thất bại", "authenticationFailed": "Xác thực thất bại", "incorrectPassphrase": "Cụm mật khẩu không đúng. Vui lòng thử lại.", "verificationCodePrompt": "Mã xác minh:", diff --git a/plugins/file-manager/locales/translated/zh_CN.json b/plugins/file-manager/locales/translated/zh_CN.json index a182b99cf..47bc073dc 100644 --- a/plugins/file-manager/locales/translated/zh_CN.json +++ b/plugins/file-manager/locales/translated/zh_CN.json @@ -290,7 +290,6 @@ "connecting": "连接中...", "connectedSuccessfully": "连接成功", "totpVerificationFailed": "TOTP 验证失败", - "warpgateVerificationFailed": "Warpgate 认证失败", "authenticationFailed": "认证失败", "incorrectPassphrase": "密码错误。请重试。", "verificationCodePrompt": "验证码:", diff --git a/plugins/file-manager/locales/translated/zh_TW.json b/plugins/file-manager/locales/translated/zh_TW.json index eccc24e6c..e6aee1374 100644 --- a/plugins/file-manager/locales/translated/zh_TW.json +++ b/plugins/file-manager/locales/translated/zh_TW.json @@ -290,7 +290,6 @@ "connecting": "連線中...", "connectedSuccessfully": "已成功連線", "totpVerificationFailed": "TOTP 驗證失敗", - "warpgateVerificationFailed": "Warpgate 驗證失敗", "authenticationFailed": "驗證失敗", "incorrectPassphrase": "密碼片語錯誤。請再試一次。", "verificationCodePrompt": "驗證碼:", diff --git a/plugins/file-manager/src/backend/index.ts b/plugins/file-manager/src/backend/index.ts index 25432ad97..cb24c760c 100644 --- a/plugins/file-manager/src/backend/index.ts +++ b/plugins/file-manager/src/backend/index.ts @@ -200,7 +200,7 @@ export async function activate(ctx: PluginContext) { ); } - // Pending TOTP/Warpgate parking sessions expire on their own; sweep stale + // Pending TOTP and browser sign-in parking sessions expire on their own; sweep stale // ones so a session left mid-handshake doesn't leak an open ssh2 Client. const pendingSweep = setInterval(() => { const now = Date.now(); @@ -386,7 +386,7 @@ export async function activate(ctx: PluginContext) { if ( req.path === "/connect" || req.path === "/connect-totp" || - req.path === "/connect-warpgate" + req.path === "/connect-browser-sign-in" ) { return next(); } @@ -463,7 +463,7 @@ export async function activate(ctx: PluginContext) { * type: array * responses: * 200: - * description: SSH connection established, or requires TOTP/Warpgate authentication. + * description: SSH connection established, or requires TOTP or a browser sign-in. * 400: * description: Missing required parameters or invalid SSH key format. * 401: @@ -983,7 +983,7 @@ export async function activate(ctx: PluginContext) { finish: (responses: string[]) => void, prompts: Array<{ prompt: string; echo: boolean }>, promptIndex: number, - isWarpgate = false, + isBrowserSignIn = false, ) => { pendingTOTPSessions[sessionId] = { client, @@ -1000,7 +1000,7 @@ export async function activate(ctx: PluginContext) { totpPromptIndex: promptIndex, resolvedPassword: resolvedCredentials.password, totpAttempts: 0, - ...(isWarpgate ? { isWarpgate: true } : {}), + ...(isBrowserSignIn ? { isBrowserSignIn: true } : {}), }; }; @@ -1025,23 +1025,24 @@ export async function activate(ctx: PluginContext) { return; } - if (decision.kind === "warpgate") { + if (decision.kind === "browser") { if (responseSent) return; responseSent = true; connectionLogs.push( createConnectionLog( "info", "sftp_auth", - "Warpgate authentication required", + `${decision.label} sign-in required`, { url: decision.url }, ), ); parkForAnswer(finish, prompts, -1, true); res.json({ - requires_warpgate: true, + requires_browser_sign_in: true, sessionId, + label: decision.label, url: decision.url, - securityKey: decision.securityKey, + code: decision.code, connectionLogs, }); return; @@ -1347,10 +1348,10 @@ export async function activate(ctx: PluginContext) { /** * @openapi - * /plugin-api/file-manager/connect-warpgate: + * /plugin-api/file-manager/connect-browser-sign-in: * post: - * summary: Complete Warpgate authentication - * description: Submits empty response to complete Warpgate authentication after user completes browser auth. + * summary: Continue after a browser sign-in + * description: Answers the parked keyboard-interactive round once the user finished signing in in the browser (an SSH gateway's approval, for example). * tags: * - File Manager * requestBody: @@ -1366,15 +1367,15 @@ export async function activate(ctx: PluginContext) { * type: string * responses: * 200: - * description: Warpgate authentication completed successfully. + * description: Signed in, SSH connection established. * 401: * description: Authentication failed or unauthorized. * 404: - * description: Warpgate session expired. + * description: Sign-in session expired. * 408: - * description: Warpgate session timeout. + * description: Sign-in session timeout. */ - app.post("/connect-warpgate", async (req, res) => { + app.post("/connect-browser-sign-in", async (req, res) => { const { sessionId } = req.body; const userId = ctx.currentActor(); @@ -1389,12 +1390,12 @@ export async function activate(ctx: PluginContext) { if (!session) { return res .status(404) - .json({ error: "Warpgate session expired. Please reconnect." }); + .json({ error: "Sign-in session expired. Please reconnect." }); } - if (!session.isWarpgate) { + if (!session.isBrowserSignIn) { return res .status(400) - .json({ error: "Session is not a Warpgate session" }); + .json({ error: "Session is not waiting for a browser sign-in" }); } if (Date.now() - session.createdAt > 300000) { delete pendingTOTPSessions[sessionId]; @@ -1405,7 +1406,7 @@ export async function activate(ctx: PluginContext) { } return res .status(408) - .json({ error: "Warpgate session timeout. Please reconnect." }); + .json({ error: "Sign-in session timeout. Please reconnect." }); } let responseSent = false; @@ -1413,7 +1414,7 @@ export async function activate(ctx: PluginContext) { if (!responseSent) { responseSent = true; delete pendingTOTPSessions[sessionId]; - res.status(408).json({ error: "Warpgate verification timeout" }); + res.status(408).json({ error: "Sign-in verification timeout" }); } }, 60000); @@ -1440,7 +1441,7 @@ export async function activate(ctx: PluginContext) { res.json({ status: "success", - message: "Warpgate verified, SSH connection established", + message: "Signed in, SSH connection established", }); if (session.hostId && session.userId) { @@ -1460,9 +1461,7 @@ export async function activate(ctx: PluginContext) { responseSent = true; clearTimeout(responseTimeout); delete pendingTOTPSessions[sessionId]; - res - .status(401) - .json({ status: "error", message: "Warpgate authentication failed" }); + res.status(401).json({ status: "error", message: "Sign-in failed" }); }); session.finish([""]); @@ -1567,12 +1566,10 @@ export async function activate(ctx: PluginContext) { } const session = sshSessions[sessionId]; if (!session || !session.isConnected) { - return res - .status(400) - .json({ - error: "SSH session not found or not connected", - connected: false, - }); + return res.status(400).json({ + error: "SSH session not found or not connected", + connected: false, + }); } if (!userId || !verifySessionOwnership(session, userId)) { return res.status(403).json({ error: "Session access denied" }); diff --git a/plugins/file-manager/src/backend/session.ts b/plugins/file-manager/src/backend/session.ts index cc8c7df29..954c2f470 100644 --- a/plugins/file-manager/src/backend/session.ts +++ b/plugins/file-manager/src/backend/session.ts @@ -57,7 +57,7 @@ export interface PendingTOTPSession { totpPromptIndex?: number; resolvedPassword?: string; totpAttempts: number; - isWarpgate?: boolean; + isBrowserSignIn?: boolean; } export function execWithSudo( diff --git a/plugins/file-manager/src/backend/ssh.ts b/plugins/file-manager/src/backend/ssh.ts index 5c9f1e7ba..4111d6bc7 100644 --- a/plugins/file-manager/src/backend/ssh.ts +++ b/plugins/file-manager/src/backend/ssh.ts @@ -38,7 +38,7 @@ export function connectSsh( /** * Lower-level primitives for the interactive /connect route, which drives its - * own ssh2 Client through the TOTP/Warpgate parking flow rather than letting + * own ssh2 Client through the TOTP and browser sign-in parking flow rather than letting * ctx.ssh.connect finish the handshake itself. */ export function prepareSsh( diff --git a/plugins/file-manager/src/frontend/FileManager.tsx b/plugins/file-manager/src/frontend/FileManager.tsx index a28cc441e..221982625 100644 --- a/plugins/file-manager/src/frontend/FileManager.tsx +++ b/plugins/file-manager/src/frontend/FileManager.tsx @@ -59,7 +59,7 @@ import { moveSSHItem, connectSSH, verifySSHTOTP, - verifySSHWarpgate, + verifySSHBrowserSignIn, getSSHStatus, keepSSHAlive, identifySSHSymlink, @@ -199,12 +199,13 @@ function FileManagerContent({ const [totpRequired, setTotpRequired] = useState(false); const [totpSessionId, setTotpSessionId] = useState(null); const [totpPrompt, setTotpPrompt] = useState(""); - const [warpgateRequired, setWarpgateRequired] = useState(false); - const [warpgateSessionId, setWarpgateSessionId] = useState( - null, - ); - const [warpgateUrl, setWarpgateUrl] = useState(""); - const [warpgateSecurityKey, setWarpgateSecurityKey] = useState(""); + const [browserSignInRequired, setBrowserSignInRequired] = useState(false); + const [browserSignInSessionId, setBrowserSignInSessionId] = useState< + string | null + >(null); + const [browserSignInUrl, setBrowserSignInUrl] = useState(""); + const [browserSignInCode, setBrowserSignInCode] = useState(""); + const [browserSignInLabel, setBrowserSignInLabel] = useState(""); const [showAuthDialog, setShowAuthDialog] = useState(false); const [authDialogReason, setAuthDialogReason] = useState< "no_keyboard" | "auth_failed" | "timeout" @@ -421,7 +422,7 @@ function FileManagerContent({ !!currentHost && !sshSessionId && !totpRequired && - !warpgateRequired && + !browserSignInRequired && !showAuthDialog && !showPassphraseDialog, autoStart: false, @@ -627,11 +628,12 @@ function FileManagerContent({ socks5ProxyChain: currentHost.socks5ProxyChain, }); - if (result?.requires_warpgate) { - setWarpgateRequired(true); - setWarpgateSessionId(sessionId); - setWarpgateUrl(result.url || ""); - setWarpgateSecurityKey(result.securityKey || "N/A"); + if (result?.requires_browser_sign_in) { + setBrowserSignInRequired(true); + setBrowserSignInSessionId(sessionId); + setBrowserSignInUrl(result.url || ""); + setBrowserSignInCode(result.code || "N/A"); + setBrowserSignInLabel(result.label || ""); setIsLoading(false); return; } @@ -698,11 +700,14 @@ function FileManagerContent({ setIsLoading(false); return; } - if (sshError.requires_warpgate) { - setWarpgateRequired(true); - setWarpgateSessionId(sshError.sessionId || currentHost.id.toString()); - setWarpgateUrl(sshError.url || ""); - setWarpgateSecurityKey(sshError.securityKey || "N/A"); + if (sshError.requires_browser_sign_in) { + setBrowserSignInRequired(true); + setBrowserSignInSessionId( + sshError.sessionId || currentHost.id.toString(), + ); + setBrowserSignInUrl(sshError.url || ""); + setBrowserSignInCode(sshError.code || "N/A"); + setBrowserSignInLabel(sshError.label || ""); setIsLoading(false); return; } @@ -2660,23 +2665,26 @@ function FileManagerContent({ if (onClose) onClose(); } - async function handleWarpgateContinue() { - if (!warpgateSessionId) return; + async function handleBrowserSignInContinue() { + if (!browserSignInSessionId) return; try { setIsLoading(true); - const result = await verifySSHWarpgate(warpgateSessionId); + const result = await verifySSHBrowserSignIn(browserSignInSessionId); if (result?.status === "success") { - setWarpgateRequired(false); - setWarpgateUrl(""); - setWarpgateSecurityKey(""); - setSshSessionId(warpgateSessionId); - setWarpgateSessionId(null); + setBrowserSignInRequired(false); + setBrowserSignInUrl(""); + setBrowserSignInCode(""); + setSshSessionId(browserSignInSessionId); + setBrowserSignInSessionId(null); connectRetryRef.current.markConnected(); try { - const response = await listSSHFiles(warpgateSessionId, currentPath); + const response = await listSSHFiles( + browserSignInSessionId, + currentPath, + ); const files = Array.isArray(response) ? response : response?.files || []; @@ -2691,24 +2699,24 @@ function FileManagerContent({ } } } catch (error: unknown) { - console.error("Warpgate verification failed:", error); - toast.error(t("fileManager.warpgateVerificationFailed")); + console.error("Browser sign-in failed:", error); + toast.error(t("fileManager.browserSignInFailed")); } finally { setIsLoading(false); } } - function handleWarpgateCancel() { - setWarpgateRequired(false); - setWarpgateUrl(""); - setWarpgateSecurityKey(""); - setWarpgateSessionId(null); + function handleBrowserSignInCancel() { + setBrowserSignInRequired(false); + setBrowserSignInUrl(""); + setBrowserSignInCode(""); + setBrowserSignInSessionId(null); if (onClose) onClose(); } - function handleWarpgateOpenUrl() { - if (warpgateUrl) { - window.open(warpgateUrl, "_blank", "noopener,noreferrer"); + function handleBrowserSignInOpenUrl() { + if (browserSignInUrl) { + window.open(browserSignInUrl, "_blank", "noopener,noreferrer"); } } @@ -2745,11 +2753,12 @@ function FileManagerContent({ socks5ProxyChain: currentHost.socks5ProxyChain, }); - if (result?.requires_warpgate) { - setWarpgateRequired(true); - setWarpgateSessionId(sessionId); - setWarpgateUrl(result.url || ""); - setWarpgateSecurityKey(result.securityKey || "N/A"); + if (result?.requires_browser_sign_in) { + setBrowserSignInRequired(true); + setBrowserSignInSessionId(sessionId); + setBrowserSignInUrl(result.url || ""); + setBrowserSignInCode(result.code || "N/A"); + setBrowserSignInLabel(result.label || ""); setIsLoading(false); return; } @@ -3626,12 +3635,13 @@ function FileManagerContent({ totpPrompt={totpPrompt} handleTotpSubmit={handleTotpSubmit} handleTotpCancel={handleTotpCancel} - warpgateRequired={warpgateRequired} - warpgateUrl={warpgateUrl} - warpgateSecurityKey={warpgateSecurityKey} - handleWarpgateContinue={handleWarpgateContinue} - handleWarpgateCancel={handleWarpgateCancel} - handleWarpgateOpenUrl={handleWarpgateOpenUrl} + browserSignInRequired={browserSignInRequired} + browserSignInUrl={browserSignInUrl} + browserSignInCode={browserSignInCode} + browserSignInLabel={browserSignInLabel} + handleBrowserSignInContinue={handleBrowserSignInContinue} + handleBrowserSignInCancel={handleBrowserSignInCancel} + handleBrowserSignInOpenUrl={handleBrowserSignInOpenUrl} currentHost={currentHost} showAuthDialog={showAuthDialog} authDialogReason={authDialogReason} diff --git a/plugins/file-manager/src/frontend/FileManagerDialogs.tsx b/plugins/file-manager/src/frontend/FileManagerDialogs.tsx index 1c108790c..3898c4f5e 100644 --- a/plugins/file-manager/src/frontend/FileManagerDialogs.tsx +++ b/plugins/file-manager/src/frontend/FileManagerDialogs.tsx @@ -2,7 +2,7 @@ import type { FileItem, SSHHost } from "@/types/index"; import { TOTPDialog, SSHAuthDialog, - WarpgateDialog, + BrowserSignInDialog, } from "@termix/plugin-sdk/ui"; import { PermissionsDialog } from "./components/PermissionsDialog.tsx"; import { CompressDialog } from "./components/CompressDialog.tsx"; @@ -17,12 +17,13 @@ type FileManagerDialogsProps = { totpPrompt: string; handleTotpSubmit: (code: string) => void | Promise; handleTotpCancel: () => void; - warpgateRequired: boolean; - warpgateUrl: string; - warpgateSecurityKey: string; - handleWarpgateContinue: () => void | Promise; - handleWarpgateCancel: () => void; - handleWarpgateOpenUrl: () => void; + browserSignInRequired: boolean; + browserSignInUrl: string; + browserSignInCode: string; + browserSignInLabel: string; + handleBrowserSignInContinue: () => void | Promise; + handleBrowserSignInCancel: () => void; + handleBrowserSignInOpenUrl: () => void; currentHost: SSHHost | null; showAuthDialog: boolean; authDialogReason: "no_keyboard" | "auth_failed" | "timeout"; @@ -49,12 +50,13 @@ export function FileManagerDialogs({ totpPrompt, handleTotpSubmit, handleTotpCancel, - warpgateRequired, - warpgateUrl, - warpgateSecurityKey, - handleWarpgateContinue, - handleWarpgateCancel, - handleWarpgateOpenUrl, + browserSignInRequired, + browserSignInUrl, + browserSignInCode, + browserSignInLabel, + handleBrowserSignInContinue, + handleBrowserSignInCancel, + handleBrowserSignInOpenUrl, currentHost, showAuthDialog, authDialogReason, @@ -85,13 +87,14 @@ export function FileManagerDialogs({ backgroundColor="var(--bg-canvas)" /> - diff --git a/plugins/file-manager/src/frontend/api/ssh-file-operations-api.ts b/plugins/file-manager/src/frontend/api/ssh-file-operations-api.ts index 134c496a9..453e88bb1 100644 --- a/plugins/file-manager/src/frontend/api/ssh-file-operations-api.ts +++ b/plugins/file-manager/src/frontend/api/ssh-file-operations-api.ts @@ -30,11 +30,12 @@ type ConnectErrorResponse = { message?: string; connectionLogs?: ApiConnectionLog[]; requires_totp?: boolean; - requires_warpgate?: boolean; + requires_browser_sign_in?: boolean; sessionId?: string; prompt?: string; url?: string; - securityKey?: string; + code?: string; + label?: string; status?: string; reason?: string; }; @@ -48,11 +49,12 @@ export interface SSHConnectResult { status?: string; reason?: "timeout" | "no_keyboard" | "auth_failed"; requires_totp?: boolean; - requires_warpgate?: boolean; + requires_browser_sign_in?: boolean; sessionId?: string; prompt?: string; url?: string; - securityKey?: string; + code?: string; + label?: string; } function triggerBlobDownload(blob: Blob, fileName: string): void { @@ -125,12 +127,13 @@ export async function connectSSH( prompt: data.prompt, }); } - if (data.requires_warpgate) { + if (data.requires_browser_sign_in) { Object.assign(errorWithLogs, { - requires_warpgate: true, + requires_browser_sign_in: true, sessionId: data.sessionId, url: data.url, - securityKey: data.securityKey, + code: data.code, + label: data.label, }); } if (data.status === "auth_required") { @@ -176,17 +179,17 @@ export async function verifySSHTOTP( } } -export async function verifySSHWarpgate( +export async function verifySSHBrowserSignIn( sessionId: string, ): Promise> { try { const response = await getFileManagerApiForSession(sessionId).post( - "/ssh/connect-warpgate", + "/ssh/connect-browser-sign-in", { sessionId }, ); return response.data; } catch (error) { - handleApiError(error, "verify SSH Warpgate"); + handleApiError(error, "verify SSH browser sign-in"); } } @@ -1020,7 +1023,7 @@ export async function ensureSSHSessionForHost( if ( result?.requires_totp || - result?.requires_warpgate || + result?.requires_browser_sign_in || result?.status === "auth_required" ) { return { state: "auth_required", sessionId }; diff --git a/plugins/file-manager/src/frontend/file-manager-types.ts b/plugins/file-manager/src/frontend/file-manager-types.ts index f8baf2347..c2b1f7327 100644 --- a/plugins/file-manager/src/frontend/file-manager-types.ts +++ b/plugins/file-manager/src/frontend/file-manager-types.ts @@ -16,11 +16,12 @@ export type ConnectionLogPayload = Omit; export type SSHConnectionError = Error & { connectionLogs?: ConnectionLogPayload[]; requires_totp?: boolean; - requires_warpgate?: boolean; + requires_browser_sign_in?: boolean; sessionId?: string; prompt?: string; url?: string; - securityKey?: string; + code?: string; + label?: string; status?: string; reason?: "no_keyboard" | "auth_failed" | "timeout"; }; diff --git a/plugins/opkssh/README.md b/plugins/opkssh/README.md new file mode 100644 index 000000000..17517a1a9 --- /dev/null +++ b/plugins/opkssh/README.md @@ -0,0 +1,34 @@ +# OPKSSH + +Adds the `opkssh` SSH auth type: Termix signs you in with your identity +provider in the browser, gets a short-lived SSH certificate from +[OpenPubkey SSH](https://github.com/openpubkey/opkssh), and caches it for 24 +hours. + +## Config + +`/plugins/opkssh/config.yml`, OPKSSH's own config format. A +template is written there the first time someone signs in without one. An +install upgraded from 2.8 has its old `/.opk/config.yml` copied +here. + +`redirect_uris` in that file are OPKSSH's local listener on the Termix +server and must be localhost (or left out). The public callback is passed +to OPKSSH as `--remote-redirect-uri`; register it with your identity +provider. The plugin's admin settings page shows it: +`/plugin-api/opkssh/callback`, or `/host/opkssh-callback` +while "Use the old redirect URI" is on. + +## Binary + +The plugin runs `opkssh` pinned to one release and its SHA-256. It looks, in +order, at: + +1. `OPKSSH_BUNDLED_DIR/` (default `/opkssh-bundled`). The Docker + image bakes the binary into `/app/opkssh-bundled`, so offline installs + never download it. +2. `/plugins/opkssh/bin/`, a copy downloaded earlier. +3. A download from the GitHub release. + +Any copy whose checksum does not match is ignored. `OPKSSH_VERSION` picks +another release; `OPKSSH_SHA256` must then give its checksum. diff --git a/plugins/opkssh/locales/en.json b/plugins/opkssh/locales/en.json new file mode 100644 index 000000000..2e87c7423 --- /dev/null +++ b/plugins/opkssh/locales/en.json @@ -0,0 +1,29 @@ +{ + "editor": { + "label": "OPKSSH", + "description": "Sign in to this host using your identity provider instead of a password or key. Requires OPKSSH set up on the server.", + "docs": "View docs" + }, + "dialog": { + "title": "OPKSSH Authentication Required", + "description": "Complete authentication in your browser to continue. This session will remain valid for 24 hours.", + "openBrowser": "Open Browser to Authenticate", + "waiting": "Waiting for authentication in browser...", + "authenticating": "Processing authentication...", + "signInWith": "Sign in with {{provider}}" + }, + "errors": { + "authFailed": "Authentication failed. Please check your credentials and try again.", + "timeout": "Authentication timed out. Please try again." + }, + "settings": { + "redirectUri": { + "label": "Redirect URI", + "description": "Register this URL as an allowed redirect URI with every identity provider in your OPKSSH config." + }, + "legacyCallback": { + "label": "Use the old redirect URI", + "description": "Keep sending /host/opkssh-callback, the redirect URI used before 2.9. Turn this off once your identity providers allow the new one." + } + } +} diff --git a/plugins/opkssh/locales/translated/af_ZA.json b/plugins/opkssh/locales/translated/af_ZA.json new file mode 100644 index 000000000..ec4a314d0 --- /dev/null +++ b/plugins/opkssh/locales/translated/af_ZA.json @@ -0,0 +1,19 @@ +{ + "editor": { + "label": "OPKSSH", + "description": "Meld aan by hierdie gasheer met jou identiteitsverskaffer in plaas van 'n wagwoord of sleutel. Vereis dat OPKSSH op die bediener opgestel is.", + "docs": "Bekyk dokumente" + }, + "dialog": { + "title": "OPKSSH-verifikasie vereis", + "description": "Voltooi verifikasie in jou blaaier om voort te gaan. Hierdie sessie sal vir 24 uur geldig bly.", + "openBrowser": "Maak blaaier oop om te verifieer", + "waiting": "Wag vir verifikasie in blaaier...", + "authenticating": "Verwerk verifikasie...", + "signInWith": "Teken aan met {{provider}}" + }, + "errors": { + "authFailed": "Verifikasie het misluk. Kontroleer asseblief jou geloofsbriewe en probeer weer.", + "timeout": "Verifikasie het verstryk. Probeer asseblief weer." + } +} diff --git a/plugins/opkssh/locales/translated/ar_SA.json b/plugins/opkssh/locales/translated/ar_SA.json new file mode 100644 index 000000000..5662a7625 --- /dev/null +++ b/plugins/opkssh/locales/translated/ar_SA.json @@ -0,0 +1,19 @@ +{ + "editor": { + "label": "OPKSSH", + "description": "تسجيل الدخول إلى هذا المضيف باستخدام مزود الهوية بدلاً من كلمة المرور أو المفتاح. يتطلب إعداد OPKSSH على الخادم.", + "docs": "عرض المستندات" + }, + "dialog": { + "title": "مطلوب مصادقة OPKSSH", + "description": "أكمل المصادقة في المتصفح للمتابعة. تبقى هذه الجلسة صالحة لمدة 24 ساعة.", + "openBrowser": "افتح المتصفح للمصادقة", + "waiting": "في انتظار المصادقة في المتصفح...", + "authenticating": "جارٍ معالجة المصادقة...", + "signInWith": "تسجيل الدخول باستخدام {{provider}}" + }, + "errors": { + "authFailed": "فشلت المصادقة. يرجى التحقق من بيانات الاعتماد والمحاولة مرة أخرى.", + "timeout": "انتهت مهلة المصادقة. يرجى المحاولة مرة أخرى." + } +} diff --git a/plugins/opkssh/locales/translated/bg_BG.json b/plugins/opkssh/locales/translated/bg_BG.json new file mode 100644 index 000000000..cd4aff9b2 --- /dev/null +++ b/plugins/opkssh/locales/translated/bg_BG.json @@ -0,0 +1,19 @@ +{ + "editor": { + "label": "ОПКСШ", + "description": "Влезте в този хост, използвайки вашия доставчик на самоличност вместо парола или ключ. Изисква се OPKSSH, настроен на сървъра.", + "docs": "Преглед на документи" + }, + "dialog": { + "title": "Изисква се удостоверяване на OPKSSH", + "description": "За да продължите, завършете удостоверяването в браузъра си. Тази сесия ще остане валидна 24 часа.", + "openBrowser": "Отворете браузъра за удостоверяване", + "waiting": "Чака се удостоверяване в браузъра...", + "authenticating": "Обработва се удостоверяване...", + "signInWith": "Влезте с {{provider}}" + }, + "errors": { + "authFailed": "Удостоверяването не бе успешно. Моля, проверете идентификационните си данни и опитайте отново.", + "timeout": "Времето за изчакване на удостоверяването изтече. Моля, опитайте отново." + } +} diff --git a/plugins/opkssh/locales/translated/bn_BD.json b/plugins/opkssh/locales/translated/bn_BD.json new file mode 100644 index 000000000..1c1f5cdc8 --- /dev/null +++ b/plugins/opkssh/locales/translated/bn_BD.json @@ -0,0 +1,19 @@ +{ + "editor": { + "label": "OPKSSH", + "description": "পাসওয়ার্ড বা কী-এর পরিবর্তে আপনার আইডেন্টিটি প্রোভাইডার ব্যবহার করে এই হোস্টে সাইন ইন করুন। এর জন্য সার্ভারে OPKSSH সেট আপ করা আবশ্যক।", + "docs": "নথি দেখুন" + }, + "dialog": { + "title": "OPKSSH প্রমাণীকরণ আবশ্যক", + "description": "চালিয়ে যাওয়ার জন্য আপনার ব্রাউজারে প্রমাণীকরণ সম্পন্ন করুন। এই সেশনটি ২৪ ঘণ্টার জন্য বৈধ থাকবে।", + "openBrowser": "প্রমাণীকরণের জন্য ব্রাউজার খুলুন", + "waiting": "ব্রাউজারে প্রমাণীকরণের জন্য অপেক্ষা করা হচ্ছে...", + "authenticating": "প্রমাণীকরণ প্রক্রিয়া চলছে...", + "signInWith": "{{provider}} দিয়ে সাইন ইন করুন" + }, + "errors": { + "authFailed": "প্রমাণীকরণ ব্যর্থ হয়েছে। অনুগ্রহ করে আপনার পরিচয়পত্র যাচাই করে আবার চেষ্টা করুন।", + "timeout": "প্রমাণীকরণের সময়সীমা শেষ হয়ে গেছে। অনুগ্রহ করে আবার চেষ্টা করুন।" + } +} diff --git a/plugins/opkssh/locales/translated/ca_ES.json b/plugins/opkssh/locales/translated/ca_ES.json new file mode 100644 index 000000000..237ad32e0 --- /dev/null +++ b/plugins/opkssh/locales/translated/ca_ES.json @@ -0,0 +1,19 @@ +{ + "editor": { + "label": "OPKSSH", + "description": "Inicieu la sessió en aquest amfitrió amb el vostre proveïdor d'identitat en comptes d'una contrasenya o clau. Requereix que OPKSSH estigui configurat al servidor.", + "docs": "Veure documents" + }, + "dialog": { + "title": "Cal autenticació OPKSSH", + "description": "Completeu l'autenticació al navegador per continuar. Aquesta sessió serà vàlida durant 24 hores.", + "openBrowser": "Obre el navegador per autenticar-te", + "waiting": "Esperant l'autenticació al navegador...", + "authenticating": "Processant l'autenticació...", + "signInWith": "Inicia la sessió amb {{provider}}" + }, + "errors": { + "authFailed": "L'autenticació ha fallat. Si us plau, comproveu les vostres credencials i torneu-ho a intentar.", + "timeout": "S'ha esgotat el temps d'autenticació. Torna-ho a provar." + } +} diff --git a/plugins/opkssh/locales/translated/cs_CZ.json b/plugins/opkssh/locales/translated/cs_CZ.json new file mode 100644 index 000000000..939ff7f65 --- /dev/null +++ b/plugins/opkssh/locales/translated/cs_CZ.json @@ -0,0 +1,19 @@ +{ + "editor": { + "label": "OPKSSH", + "description": "Přihlaste se k tomuto hostiteli pomocí svého poskytovatele identity namísto hesla nebo klíče. Vyžaduje nastavení OPKSSH na serveru.", + "docs": "Zobrazit dokumenty" + }, + "dialog": { + "title": "Vyžadováno ověřování OPKSSH", + "description": "Pro pokračování dokončete ověření ve vašem prohlížeči. Tato relace bude platná 24 hodin.", + "openBrowser": "Otevřít prohlížeč pro ověření", + "waiting": "Čekání na ověření v prohlížeči...", + "authenticating": "Zpracování ověřování...", + "signInWith": "Přihlásit se pomocí {{provider}}" + }, + "errors": { + "authFailed": "Ověření se nezdařilo. Zkontrolujte prosím své přihlašovací údaje a zkuste to znovu.", + "timeout": "Časový limit ověření vypršel. Zkuste to prosím znovu." + } +} diff --git a/plugins/opkssh/locales/translated/da_DK.json b/plugins/opkssh/locales/translated/da_DK.json new file mode 100644 index 000000000..f79ba777e --- /dev/null +++ b/plugins/opkssh/locales/translated/da_DK.json @@ -0,0 +1,19 @@ +{ + "editor": { + "label": "OPKSSH", + "description": "Log ind på denne vært med din identitetsudbyder i stedet for en adgangskode eller nøgle. Kræver OPKSSH-opsætning på serveren.", + "docs": "Se dokumenter" + }, + "dialog": { + "title": "OPKSSH-godkendelse påkrævet", + "description": "Fuldfør godkendelsen i din browser for at fortsætte. Denne session forbliver gyldig i 24 timer.", + "openBrowser": "Åbn browseren for at godkende", + "waiting": "Venter på godkendelse i browseren...", + "authenticating": "Behandler godkendelse...", + "signInWith": "Log ind med {{provider}}" + }, + "errors": { + "authFailed": "Godkendelse mislykkedes. Tjek dine loginoplysninger, og prøv igen.", + "timeout": "Godkendelsen udløb. Prøv igen." + } +} diff --git a/plugins/opkssh/locales/translated/de_DE.json b/plugins/opkssh/locales/translated/de_DE.json new file mode 100644 index 000000000..ee7ef02aa --- /dev/null +++ b/plugins/opkssh/locales/translated/de_DE.json @@ -0,0 +1,19 @@ +{ + "editor": { + "label": "OPKSSH", + "description": "Melden Sie sich mit Ihrem Identitätsanbieter an diesem Host an, anstatt mit einem Passwort oder Schlüssel. Erfordert, dass OPKSSH auf dem Server eingerichtet ist.", + "docs": "Dokumentation anzeigen" + }, + "dialog": { + "title": "OPKSSH-Authentifizierung erforderlich", + "description": "Schließen Sie die Authentifizierung in Ihrem Browser ab, um fortzufahren. Diese Sitzung bleibt 24 Stunden lang gültig.", + "openBrowser": "Browser zum Authentifizieren öffnen", + "waiting": "Warten auf Authentifizierung im Browser...", + "authenticating": "Authentifizierung wird verarbeitet...", + "signInWith": "Anmelden mit {{provider}}" + }, + "errors": { + "authFailed": "Authentifizierung fehlgeschlagen. Bitte überprüfen Sie Ihre Anmeldeinformationen und versuchen Sie es erneut.", + "timeout": "Authentifizierung abgelaufen. Bitte versuchen Sie es erneut." + } +} diff --git a/plugins/opkssh/locales/translated/el_GR.json b/plugins/opkssh/locales/translated/el_GR.json new file mode 100644 index 000000000..cb756b130 --- /dev/null +++ b/plugins/opkssh/locales/translated/el_GR.json @@ -0,0 +1,19 @@ +{ + "editor": { + "label": "OPKSSH", + "description": "Συνδεθείτε σε αυτόν τον κεντρικό υπολογιστή χρησιμοποιώντας την υπηρεσία παροχής ταυτότητας αντί για κωδικό πρόσβασης ή κλειδί. Απαιτείται η ρύθμιση του OPKSSH στον διακομιστή.", + "docs": "Προβολή εγγράφων" + }, + "dialog": { + "title": "Απαιτείται έλεγχος ταυτότητας OPKSSH", + "description": "Ολοκληρώστε τον έλεγχο ταυτότητας στο πρόγραμμα περιήγησής σας για να συνεχίσετε. Αυτή η περίοδος σύνδεσης θα παραμείνει έγκυρη για 24 ώρες.", + "openBrowser": "Άνοιγμα προγράμματος περιήγησης για έλεγχο ταυτότητας", + "waiting": "Αναμονή για έλεγχο ταυτότητας στο πρόγραμμα περιήγησης...", + "authenticating": "Επεξεργασία ελέγχου ταυτότητας...", + "signInWith": "Συνδεθείτε με {{provider}}" + }, + "errors": { + "authFailed": "Ο έλεγχος ταυτότητας απέτυχε. Ελέγξτε τα διαπιστευτήριά σας και προσπαθήστε ξανά.", + "timeout": "Το χρονικό όριο ελέγχου ταυτότητας έληξε. Δοκιμάστε ξανά." + } +} diff --git a/plugins/opkssh/locales/translated/es_ES.json b/plugins/opkssh/locales/translated/es_ES.json new file mode 100644 index 000000000..6e6209101 --- /dev/null +++ b/plugins/opkssh/locales/translated/es_ES.json @@ -0,0 +1,19 @@ +{ + "editor": { + "label": "OPKSSH", + "description": "Inicie sesión en este host usando su proveedor de identidad en lugar de una contraseña o clave. Requiere OPKSSH configurado en el servidor.", + "docs": "Ver documentación" + }, + "dialog": { + "title": "Autenticación OPKSSH requerida", + "description": "Completa la autenticación en tu navegador para continuar. Esta sesión seguirá siendo válida durante 24 horas.", + "openBrowser": "Abrir navegador para autenticarse", + "waiting": "Esperando autenticación en el navegador...", + "authenticating": "Procesando autenticación...", + "signInWith": "Iniciar sesión con {{provider}}" + }, + "errors": { + "authFailed": "La autenticación falló. Revisa tus credenciales e inténtalo de nuevo.", + "timeout": "La autenticación expiró. Inténtalo de nuevo." + } +} diff --git a/plugins/opkssh/locales/translated/fi_FI.json b/plugins/opkssh/locales/translated/fi_FI.json new file mode 100644 index 000000000..071eadf03 --- /dev/null +++ b/plugins/opkssh/locales/translated/fi_FI.json @@ -0,0 +1,19 @@ +{ + "editor": { + "label": "OPKSSH", + "description": "Kirjaudu tähän isäntään käyttämällä identiteetintarjoajaasi salasanan tai avaimen sijaan. Edellyttää OPKSSH:n määrittämistä palvelimelle.", + "docs": "Näytä dokumentit" + }, + "dialog": { + "title": "OPKSSH-todennus vaaditaan", + "description": "Suorita todennus selaimessasi jatkaaksesi. Tämä istunto on voimassa 24 tuntia.", + "openBrowser": "Avaa selain todennusta varten", + "waiting": "Odotetaan selaimen todennusta...", + "authenticating": "Todennusta käsitellään...", + "signInWith": "Kirjaudu sisään tunnuksella {{provider}}" + }, + "errors": { + "authFailed": "Todennus epäonnistui. Tarkista tunnistetietosi ja yritä uudelleen.", + "timeout": "Todennus aikakatkaistiin. Yritä uudelleen." + } +} diff --git a/plugins/opkssh/locales/translated/fr_FR.json b/plugins/opkssh/locales/translated/fr_FR.json new file mode 100644 index 000000000..147231b93 --- /dev/null +++ b/plugins/opkssh/locales/translated/fr_FR.json @@ -0,0 +1,19 @@ +{ + "editor": { + "label": "OPKSSH", + "description": "Connectez-vous à cet hôte en utilisant votre fournisseur d'identité au lieu d'un mot de passe ou d'une clé. Nécessite OPKSSH configuré sur le serveur.", + "docs": "Voir la documentation" + }, + "dialog": { + "title": "Authentification OPKSSH requise", + "description": "Terminez l'authentification dans votre navigateur pour continuer. Cette session restera valide pendant 24 heures.", + "openBrowser": "Ouvrir le navigateur pour s'authentifier", + "waiting": "En attente de l'authentification dans le navigateur...", + "authenticating": "Traitement de l'authentification...", + "signInWith": "Se connecter avec {{provider}}" + }, + "errors": { + "authFailed": "Échec de l'authentification. Veuillez vérifier vos identifiants et réessayer.", + "timeout": "L'authentification a expiré. Veuillez réessayer." + } +} diff --git a/plugins/opkssh/locales/translated/he_IL.json b/plugins/opkssh/locales/translated/he_IL.json new file mode 100644 index 000000000..d3e0722a7 --- /dev/null +++ b/plugins/opkssh/locales/translated/he_IL.json @@ -0,0 +1,19 @@ +{ + "editor": { + "label": "אופקש", + "description": "היכנס למארח זה באמצעות ספק הזהות שלך במקום סיסמה או מפתח. נדרשת הגדרת OPKSSH בשרת.", + "docs": "הצג מסמכים" + }, + "dialog": { + "title": "נדרש אימות OPKSSH", + "description": "השלם את האימות בדפדפן שלך כדי להמשיך. סשן זה יישאר תקף למשך 24 שעות.", + "openBrowser": "פתח את הדפדפן כדי לאמת", + "waiting": "ממתין לאימות בדפדפן...", + "authenticating": "מעבד אימות...", + "signInWith": "התחבר באמצעות {{provider}}" + }, + "errors": { + "authFailed": "האימות נכשל. אנא בדוק את פרטי הגישה שלך ונסה שוב.", + "timeout": "הזמן שהוקצב לאימות הסתיים. אנא נסה שוב." + } +} diff --git a/plugins/opkssh/locales/translated/hi_IN.json b/plugins/opkssh/locales/translated/hi_IN.json new file mode 100644 index 000000000..41373417f --- /dev/null +++ b/plugins/opkssh/locales/translated/hi_IN.json @@ -0,0 +1,19 @@ +{ + "editor": { + "label": "OPKSSH", + "description": "इस होस्ट में पासवर्ड या कुंजी के बजाय अपने पहचान प्रदाता का उपयोग करके साइन इन करें। सर्वर पर OPKSSH सेटअप आवश्यक है।", + "docs": "दस्तावेज़ देखें" + }, + "dialog": { + "title": "OPKSSH प्रमाणीकरण आवश्यक है", + "description": "जारी रखने के लिए अपने ब्राउज़र में प्रमाणीकरण पूरा करें। यह सत्र 24 घंटे के लिए मान्य रहेगा।", + "openBrowser": "प्रमाणीकरण के लिए ब्राउज़र खोलें", + "waiting": "ब्राउज़र में प्रमाणीकरण की प्रतीक्षा हो रही है...", + "authenticating": "प्रमाणीकरण प्रक्रिया में है...", + "signInWith": "{{provider}} से साइन इन करें" + }, + "errors": { + "authFailed": "प्रमाणीकरण विफल। कृपया अपने क्रेडेंशियल जाँचें और पुनः प्रयास करें।", + "timeout": "प्रमाणीकरण का समय समाप्त हो गया। कृपया पुनः प्रयास करें।" + } +} diff --git a/plugins/opkssh/locales/translated/hu_HU.json b/plugins/opkssh/locales/translated/hu_HU.json new file mode 100644 index 000000000..12da5401e --- /dev/null +++ b/plugins/opkssh/locales/translated/hu_HU.json @@ -0,0 +1,19 @@ +{ + "editor": { + "label": "OPKSSH", + "description": "Jelentkezzen be erre a gazdagépre az identitásszolgáltatójával jelszó vagy kulcs helyett. OPKSSH beállítását igényli a szerveren.", + "docs": "Dokumentumok megtekintése" + }, + "dialog": { + "title": "OPKSSH hitelesítés szükséges", + "description": "A folytatáshoz végezze el a hitelesítést a böngészőjében. Ez a munkamenet 24 órán át érvényes marad.", + "openBrowser": "Böngésző megnyitása a hitelesítéshez", + "waiting": "Várakozás a böngésző hitelesítésére...", + "authenticating": "Hitelesítés feldolgozása...", + "signInWith": "Bejelentkezés {{provider}} felhasználónévvel" + }, + "errors": { + "authFailed": "Sikertelen hitelesítés. Kérjük, ellenőrizze a hitelesítő adatait, és próbálja újra.", + "timeout": "A hitelesítés időtúllépést okozott. Kérjük, próbálja újra." + } +} diff --git a/plugins/opkssh/locales/translated/id_ID.json b/plugins/opkssh/locales/translated/id_ID.json new file mode 100644 index 000000000..bcb00d38b --- /dev/null +++ b/plugins/opkssh/locales/translated/id_ID.json @@ -0,0 +1,19 @@ +{ + "editor": { + "label": "OPKSSH", + "description": "Masuk ke host ini menggunakan penyedia identitas Anda, bukan kata sandi atau kunci. Membutuhkan OPKSSH yang terpasang di server.", + "docs": "Lihat dokumen" + }, + "dialog": { + "title": "Otentikasi OPKSSH Diperlukan", + "description": "Lakukan otentikasi di browser Anda untuk melanjutkan. Sesi ini akan tetap berlaku selama 24 jam.", + "openBrowser": "Buka Browser untuk Melakukan Otentikasi", + "waiting": "Menunggu otentikasi di browser...", + "authenticating": "Memproses otentikasi...", + "signInWith": "Masuk dengan {{provider}}" + }, + "errors": { + "authFailed": "Autentikasi gagal. Harap periksa kredensial Anda dan coba lagi.", + "timeout": "Autentikasi habis waktu. Silakan coba lagi." + } +} diff --git a/plugins/opkssh/locales/translated/it_IT.json b/plugins/opkssh/locales/translated/it_IT.json new file mode 100644 index 000000000..8861c7fde --- /dev/null +++ b/plugins/opkssh/locales/translated/it_IT.json @@ -0,0 +1,19 @@ +{ + "editor": { + "label": "OPKSSH", + "description": "Accedi a questo host usando il tuo provider di identità invece di una password o chiave. Richiede OPKSSH configurato sul server.", + "docs": "Visualizza documentazione" + }, + "dialog": { + "title": "Autenticazione OPKSSH richiesta", + "description": "Completa l'autenticazione nel browser per continuare. Questa sessione rimarrà valida per 24 ore.", + "openBrowser": "Apri il browser per autenticarti", + "waiting": "In attesa dell'autenticazione nel browser...", + "authenticating": "Autenticazione in corso...", + "signInWith": "Accedi con {{provider}}" + }, + "errors": { + "authFailed": "Autenticazione fallita. Controlla le credenziali e riprova.", + "timeout": "Autenticazione scaduta. Riprova." + } +} diff --git a/plugins/opkssh/locales/translated/ja_JP.json b/plugins/opkssh/locales/translated/ja_JP.json new file mode 100644 index 000000000..be382e31d --- /dev/null +++ b/plugins/opkssh/locales/translated/ja_JP.json @@ -0,0 +1,19 @@ +{ + "editor": { + "label": "OPKSSH", + "description": "パスワードや鍵の代わりに、IDプロバイダーを使用してこのホストにサインインします。サーバーにOPKSSHのセットアップが必要です。", + "docs": "ドキュメントを表示" + }, + "dialog": { + "title": "OPKSSH認証が必要です", + "description": "ブラウザで認証を完了して続行してください。このセッションは24時間有効です。", + "openBrowser": "ブラウザを開いて認証", + "waiting": "ブラウザでの認証を待機中...", + "authenticating": "認証を処理中...", + "signInWith": "{{provider}} でサインイン" + }, + "errors": { + "authFailed": "認証に失敗しました。資格情報を確認して再試行してください。", + "timeout": "認証がタイムアウトしました。もう一度お試しください。" + } +} diff --git a/plugins/opkssh/locales/translated/ko_KR.json b/plugins/opkssh/locales/translated/ko_KR.json new file mode 100644 index 000000000..7d1f54f09 --- /dev/null +++ b/plugins/opkssh/locales/translated/ko_KR.json @@ -0,0 +1,19 @@ +{ + "editor": { + "label": "OPKSSH", + "description": "비밀번호나 키 대신 ID 공급자로 이 호스트에 로그인합니다. 서버에 OPKSSH가 설정되어 있어야 합니다.", + "docs": "문서 보기" + }, + "dialog": { + "title": "OPKSSH 인증 필요", + "description": "브라우저에서 인증을 완료하여 계속 진행하세요. 이 세션은 24시간 동안 유지됩니다.", + "openBrowser": "브라우저로 인증하기", + "waiting": "브라우저에서 인증 대기 중...", + "authenticating": "인증 처리 중...", + "signInWith": "{{provider}}로 로그인" + }, + "errors": { + "authFailed": "인증에 실패했습니다. 자격 증명을 확인하고 다시 시도하세요.", + "timeout": "인증 시간이 초과되었습니다. 다시 시도하세요." + } +} diff --git a/plugins/opkssh/locales/translated/nl_NL.json b/plugins/opkssh/locales/translated/nl_NL.json new file mode 100644 index 000000000..ab76954f0 --- /dev/null +++ b/plugins/opkssh/locales/translated/nl_NL.json @@ -0,0 +1,19 @@ +{ + "editor": { + "label": "OPKSSH", + "description": "Meld u aan bij deze host met uw identiteitsprovider in plaats van een wachtwoord of sleutel. Hiervoor moet OPKSSH op de server geconfigureerd zijn.", + "docs": "Documenten bekijken" + }, + "dialog": { + "title": "OPKSSH-authenticatie vereist", + "description": "Voltooi de authenticatie in uw browser om verder te gaan. Deze sessie blijft 24 uur geldig.", + "openBrowser": "Open de browser om te authenticeren.", + "waiting": "Wachten op authenticatie in de browser...", + "authenticating": "Authenticatie wordt verwerkt...", + "signInWith": "Aanmelden met {{provider}}" + }, + "errors": { + "authFailed": "Authenticatie mislukt. Controleer uw inloggegevens en probeer het opnieuw.", + "timeout": "De authenticatie is mislukt vanwege een time-out. Probeer het opnieuw." + } +} diff --git a/plugins/opkssh/locales/translated/no_NO.json b/plugins/opkssh/locales/translated/no_NO.json new file mode 100644 index 000000000..b54b421ba --- /dev/null +++ b/plugins/opkssh/locales/translated/no_NO.json @@ -0,0 +1,19 @@ +{ + "editor": { + "label": "OPKSSH", + "description": "Logg på denne verten med identitetsleverandøren din i stedet for et passord eller en nøkkel. Krever at OPKSSH er konfigurert på serveren.", + "docs": "Vis dokumenter" + }, + "dialog": { + "title": "OPKSSH-autentisering kreves", + "description": "Fullfør autentiseringen i nettleseren din for å fortsette. Denne økten vil være gyldig i 24 timer.", + "openBrowser": "Åpne nettleseren for å autentisere", + "waiting": "Venter på autentisering i nettleseren...", + "authenticating": "Behandler autentisering ...", + "signInWith": "Logg inn med {{provider}}" + }, + "errors": { + "authFailed": "Autentiseringen mislyktes. Sjekk legitimasjonen din og prøv på nytt.", + "timeout": "Autentiseringen ble tidsavbrutt. Prøv på nytt." + } +} diff --git a/plugins/opkssh/locales/translated/pl_PL.json b/plugins/opkssh/locales/translated/pl_PL.json new file mode 100644 index 000000000..cbd41c311 --- /dev/null +++ b/plugins/opkssh/locales/translated/pl_PL.json @@ -0,0 +1,19 @@ +{ + "editor": { + "label": "OPKSSH", + "description": "Zaloguj się do tego hosta, używając swojego dostawcy tożsamości, a nie hasła lub klucza. Wymagane jest skonfigurowanie OPKSSH na serwerze.", + "docs": "Zobacz dokumentację" + }, + "dialog": { + "title": "Wymagane uwierzytelnienie OPKSSH", + "description": "Aby kontynuować, dokończ uwierzytelnianie w przeglądarce. Ta sesja będzie ważna przez 24 godziny.", + "openBrowser": "Otwórz przeglądarkę, aby uwierzytelnić", + "waiting": "Oczekiwanie na uwierzytelnienie w przeglądarce...", + "authenticating": "Przetwarzanie uwierzytelniania...", + "signInWith": "Zaloguj się za pomocą {{provider}}" + }, + "errors": { + "authFailed": "Uwierzytelnienie nie powiodło się. Sprawdź poświadczenia i spróbuj ponownie.", + "timeout": "Upłynął limit czasu uwierzytelniania. Spróbuj ponownie." + } +} diff --git a/plugins/opkssh/locales/translated/pt_BR.json b/plugins/opkssh/locales/translated/pt_BR.json new file mode 100644 index 000000000..4ce01fd5b --- /dev/null +++ b/plugins/opkssh/locales/translated/pt_BR.json @@ -0,0 +1,19 @@ +{ + "editor": { + "label": "OPKSSH", + "description": "Faça login neste host usando seu provedor de identidade em vez de uma senha ou chave. Requer a configuração do OPKSSH no servidor.", + "docs": "Ver documentos" + }, + "dialog": { + "title": "Autenticação OPKSSH necessária", + "description": "Para continuar, complete a autenticação no seu navegador. Esta sessão permanecerá válida por 24 horas.", + "openBrowser": "Abra o navegador para autenticar.", + "waiting": "Aguardando autenticação no navegador...", + "authenticating": "Processando autenticação...", + "signInWith": "Faça login com {{provider}}" + }, + "errors": { + "authFailed": "A autenticação falhou. Verifique suas credenciais e tente novamente.", + "timeout": "A autenticação expirou. Tente novamente." + } +} diff --git a/plugins/opkssh/locales/translated/pt_PT.json b/plugins/opkssh/locales/translated/pt_PT.json new file mode 100644 index 000000000..ce63db0cf --- /dev/null +++ b/plugins/opkssh/locales/translated/pt_PT.json @@ -0,0 +1,19 @@ +{ + "editor": { + "label": "OPKSSH", + "description": "Inicie sessão neste anfitrião usando o seu fornecedor de identidade em vez de uma palavra-passe ou chave. Requer que o OPKSSH esteja configurado no servidor.", + "docs": "Ver documentação" + }, + "dialog": { + "title": "Autenticação OPKSSH necessária", + "description": "Conclua a autenticação no seu navegador para continuar. Esta sessão permanecerá válida por 24 horas.", + "openBrowser": "Abrir navegador para autenticar", + "waiting": "A aguardar autenticação no navegador...", + "authenticating": "A processar autenticação...", + "signInWith": "Iniciar sessão com {{provider}}" + }, + "errors": { + "authFailed": "A autenticação falhou. Verifique as suas credenciais e tente novamente.", + "timeout": "A autenticação expirou. Tente novamente." + } +} diff --git a/plugins/opkssh/locales/translated/ro_RO.json b/plugins/opkssh/locales/translated/ro_RO.json new file mode 100644 index 000000000..156d9d04b --- /dev/null +++ b/plugins/opkssh/locales/translated/ro_RO.json @@ -0,0 +1,19 @@ +{ + "editor": { + "label": "OPKSSH", + "description": "Conectați-vă la această gazdă folosind furnizorul de identitate în loc de o parolă sau o cheie. Necesită configurarea OPKSSH pe server.", + "docs": "Vizualizați documentele" + }, + "dialog": { + "title": "Autentificare OPKSSH necesară", + "description": "Finalizați autentificarea în browser pentru a continua. Această sesiune va rămâne valabilă timp de 24 de ore.", + "openBrowser": "Deschideți browserul pentru autentificare", + "waiting": "Se așteaptă autentificarea în browser...", + "authenticating": "Se procesează autentificarea...", + "signInWith": "Conectați-vă cu {{provider}}" + }, + "errors": { + "authFailed": "Autentificarea a eșuat. Verificați acreditările și încercați din nou.", + "timeout": "Autentificarea a expirat. Vă rugăm să încercați din nou." + } +} diff --git a/plugins/opkssh/locales/translated/ru_RU.json b/plugins/opkssh/locales/translated/ru_RU.json new file mode 100644 index 000000000..7bbfb3376 --- /dev/null +++ b/plugins/opkssh/locales/translated/ru_RU.json @@ -0,0 +1,19 @@ +{ + "editor": { + "label": "OPKSSH", + "description": "Входите на этот хост через своего провайдера идентификации вместо пароля или ключа. Требуется настроенный OPKSSH на сервере.", + "docs": "Документация" + }, + "dialog": { + "title": "Требуется аутентификация OPKSSH", + "description": "Завершите аутентификацию в браузере, чтобы продолжить. Сеанс будет действителен в течение 24 часов.", + "openBrowser": "Открыть браузер для аутентификации", + "waiting": "Ожидание аутентификации в браузере...", + "authenticating": "Обработка аутентификации...", + "signInWith": "Войти через {{provider}}" + }, + "errors": { + "authFailed": "Аутентификация не удалась. Проверьте учётные данные и попробуйте снова.", + "timeout": "Время аутентификации истекло. Попробуйте снова." + } +} diff --git a/plugins/opkssh/locales/translated/sr_SP.json b/plugins/opkssh/locales/translated/sr_SP.json new file mode 100644 index 000000000..80c9f3bb1 --- /dev/null +++ b/plugins/opkssh/locales/translated/sr_SP.json @@ -0,0 +1,19 @@ +{ + "editor": { + "label": "ОПКШ", + "description": "Пријавите се на овај хост користећи свог добављача идентитета уместо лозинке или кључа. Потребно је подешавање OPKSSH-а на серверу.", + "docs": "Прикажи документе" + }, + "dialog": { + "title": "Потребна је аутентификација OPKSSH", + "description": "Завршите аутентификацију у прегледачу да бисте наставили. Ова сесија ће остати важећа 24 сата.", + "openBrowser": "Отворите прегледач за аутентификацију", + "waiting": "Чекање аутентификације у прегледачу...", + "authenticating": "Обрада аутентификације...", + "signInWith": "Пријавите се са {{provider}}" + }, + "errors": { + "authFailed": "Аутентификација није успела. Проверите своје акредитиве и покушајте поново.", + "timeout": "Временско ограничење за аутентификацију је истекло. Молимо покушајте поново." + } +} diff --git a/plugins/opkssh/locales/translated/sv_SE.json b/plugins/opkssh/locales/translated/sv_SE.json new file mode 100644 index 000000000..038b2a398 --- /dev/null +++ b/plugins/opkssh/locales/translated/sv_SE.json @@ -0,0 +1,19 @@ +{ + "editor": { + "label": "OPKSSH", + "description": "Logga in på den här värden med din identitetsleverantör istället för ett lösenord eller en nyckel. Kräver att OPKSSH är konfigurerat på servern.", + "docs": "Visa dokument" + }, + "dialog": { + "title": "OPKSSH-autentisering krävs", + "description": "Slutför autentiseringen i din webbläsare för att fortsätta. Denna session kommer att vara giltig i 24 timmar.", + "openBrowser": "Öppna webbläsaren för att autentisera", + "waiting": "Väntar på autentisering i webbläsaren...", + "authenticating": "Bearbetar autentisering...", + "signInWith": "Logga in med {{provider}}" + }, + "errors": { + "authFailed": "Autentiseringen misslyckades. Kontrollera dina inloggningsuppgifter och försök igen.", + "timeout": "Autentiseringen har gått ut. Försök igen." + } +} diff --git a/plugins/opkssh/locales/translated/th_TH.json b/plugins/opkssh/locales/translated/th_TH.json new file mode 100644 index 000000000..a3126f930 --- /dev/null +++ b/plugins/opkssh/locales/translated/th_TH.json @@ -0,0 +1,19 @@ +{ + "editor": { + "label": "โอพีเคเอสเอช", + "description": "เข้าสู่ระบบโฮสต์นี้โดยใช้ผู้ให้บริการยืนยันตัวตนของคุณแทนรหัสผ่านหรือคีย์ จำเป็นต้องตั้งค่า OPKSSH บนเซิร์ฟเวอร์ก่อน", + "docs": "ดูเอกสาร" + }, + "dialog": { + "title": "ต้องใช้การตรวจสอบสิทธิ์ OPKSSH", + "description": "โปรดยืนยันตัวตนในเบราว์เซอร์ของคุณเพื่อดำเนินการต่อ เซสชันนี้จะมีอายุใช้งาน 24 ชั่วโมง", + "openBrowser": "เปิดเบราว์เซอร์เพื่อยืนยันตัวตน", + "waiting": "กำลังรอการยืนยันตัวตนในเบราว์เซอร์...", + "authenticating": "กำลังตรวจสอบสิทธิ์...", + "signInWith": "ลงชื่อเข้าใช้ด้วย {{provider}}" + }, + "errors": { + "authFailed": "การตรวจสอบสิทธิ์ล้มเหลว โปรดตรวจสอบข้อมูลประจำตัวของคุณและลองอีกครั้ง", + "timeout": "การตรวจสอบสิทธิ์หมดเวลา โปรดลองอีกครั้ง" + } +} diff --git a/plugins/opkssh/locales/translated/tr_TR.json b/plugins/opkssh/locales/translated/tr_TR.json new file mode 100644 index 000000000..c58f6abdd --- /dev/null +++ b/plugins/opkssh/locales/translated/tr_TR.json @@ -0,0 +1,19 @@ +{ + "editor": { + "label": "OPKSSH", + "description": "Parola veya anahtar yerine kimlik sağlayıcınızı kullanarak bu sunucuda oturum açın. Sunucuda OPKSSH kurulumu gerektirir.", + "docs": "Dokümanları görüntüle" + }, + "dialog": { + "title": "OPKSSH Kimlik Doğrulaması Gerekli", + "description": "Devam etmek için tarayıcıda kimlik doğrulamasını tamamlayın. Bu oturum 24 saat geçerli olacak.", + "openBrowser": "Kimlik Doğrulaması için Tarayıcı Aç", + "waiting": "Tarayıcıda kimlik doğrulaması bekleniyor...", + "authenticating": "Kimlik doğrulaması işleniyor...", + "signInWith": "{{provider}} ile oturum aç" + }, + "errors": { + "authFailed": "Kimlik doğrulaması başarısız oldu. Lütfen kimlik bilgilerinizi kontrol edip tekrar deneyin.", + "timeout": "Kimlik doğrulaması zaman aşımına uğradı. Lütfen tekrar deneyin." + } +} diff --git a/plugins/opkssh/locales/translated/uk_UA.json b/plugins/opkssh/locales/translated/uk_UA.json new file mode 100644 index 000000000..92b21ff86 --- /dev/null +++ b/plugins/opkssh/locales/translated/uk_UA.json @@ -0,0 +1,19 @@ +{ + "editor": { + "label": "ОПКШ", + "description": "Увійдіть на цей хост, використовуючи свого постачальника ідентифікаційних даних замість пароля чи ключа. Потрібно налаштувати OPKSSH на сервері.", + "docs": "Переглянути документи" + }, + "dialog": { + "title": "Потрібна автентифікація OPKSSH", + "description": "Щоб продовжити, завершіть автентифікацію у вашому браузері. Цей сеанс залишатиметься дійсним протягом 24 годин.", + "openBrowser": "Відкрийте браузер для автентифікації", + "waiting": "Очікування автентифікації у браузері...", + "authenticating": "Обробка автентифікації...", + "signInWith": "Увійти за допомогою {{provider}}" + }, + "errors": { + "authFailed": "Помилка автентифікації. Перевірте свої облікові дані та спробуйте ще раз.", + "timeout": "Час очікування автентифікації минув. Спробуйте ще раз." + } +} diff --git a/plugins/opkssh/locales/translated/vi_VN.json b/plugins/opkssh/locales/translated/vi_VN.json new file mode 100644 index 000000000..b8a30beea --- /dev/null +++ b/plugins/opkssh/locales/translated/vi_VN.json @@ -0,0 +1,19 @@ +{ + "editor": { + "label": "OPKSSH", + "description": "Đăng nhập vào máy chủ này bằng nhà cung cấp danh tính thay vì mật khẩu hoặc khóa. Yêu cầu OPKSSH được thiết lập trên máy chủ.", + "docs": "Xem tài liệu" + }, + "dialog": { + "title": "Yêu cầu xác thực OPKSSH", + "description": "Hoàn tất xác thực trên trình duyệt để tiếp tục. Phiên làm việc này sẽ có hiệu lực trong 24 giờ.", + "openBrowser": "Mở trình duyệt để xác thực", + "waiting": "Đang chờ xác thực trên trình duyệt...", + "authenticating": "Đang xử lý xác thực...", + "signInWith": "Đăng nhập bằng {{provider}}" + }, + "errors": { + "authFailed": "Xác thực thất bại. Vui lòng kiểm tra thông tin đăng nhập và thử lại.", + "timeout": "Xác thực đã hết thời gian chờ. Vui lòng thử lại." + } +} diff --git a/plugins/opkssh/locales/translated/zh_CN.json b/plugins/opkssh/locales/translated/zh_CN.json new file mode 100644 index 000000000..c616d736b --- /dev/null +++ b/plugins/opkssh/locales/translated/zh_CN.json @@ -0,0 +1,19 @@ +{ + "editor": { + "label": "OPKSSH", + "description": "使用身份提供商登录此主机,而非密码或密钥。需要在服务器上设置 OPKSSH。", + "docs": "查看文档" + }, + "dialog": { + "title": "需要 OPKSSH 认证", + "description": "在浏览器中完成认证以继续。此会话将在 24 小时内有效。", + "openBrowser": "打开浏览器进行认证", + "waiting": "等待浏览器认证...", + "authenticating": "正在处理认证...", + "signInWith": "使用 {{provider}} 登录" + }, + "errors": { + "authFailed": "认证失败,请检查凭据并重试。", + "timeout": "认证超时,请重试。" + } +} diff --git a/plugins/opkssh/locales/translated/zh_TW.json b/plugins/opkssh/locales/translated/zh_TW.json new file mode 100644 index 000000000..677847ac4 --- /dev/null +++ b/plugins/opkssh/locales/translated/zh_TW.json @@ -0,0 +1,19 @@ +{ + "editor": { + "label": "OPKSSH", + "description": "使用您的識別提供者而非密碼或金鑰來登入此主機。需要在伺服器上設定 OPKSSH。", + "docs": "檢視文件" + }, + "dialog": { + "title": "需要 OPKSSH 驗證", + "description": "請在瀏覽器中完成驗證以繼續。此工作階段將持續有效 24 小時。", + "openBrowser": "開啟瀏覽器以進行驗證", + "waiting": "正在等待瀏覽器中的驗證…", + "authenticating": "正在處理驗證…", + "signInWith": "使用 {{provider}} 登入" + }, + "errors": { + "authFailed": "驗證失敗。請檢查您的憑證並再試一次。", + "timeout": "驗證逾時。請再試一次。" + } +} diff --git a/plugins/opkssh/manifest.json b/plugins/opkssh/manifest.json new file mode 100644 index 000000000..ce88ce586 --- /dev/null +++ b/plugins/opkssh/manifest.json @@ -0,0 +1,51 @@ +{ + "id": "opkssh", + "name": "OPKSSH", + "version": "1.0.0", + "description": "Sign in to hosts with short-lived SSH certificates from OpenPubkey SSH, using your identity provider in the browser.", + "author": { + "name": "Termix" + }, + "license": "MIT", + "repository": "https://github.com/Termix-SSH/Termix", + "category": "Access & Security", + "icon": "ShieldCheck", + "engine": { + "termix": ">=2.9.0", + "api": "1" + }, + "capabilities": [ + "db:own", + "files:own", + "secrets:own", + "hosts:read", + "process:spawn", + "network:outbound", + "network:serve", + "auth:provide", + "ui:surface" + ], + "contributes": { + "auth": { + "sshAuthTypes": ["opkssh"] + }, + "settings": { + "admin": [ + { + "key": "redirectUri", + "type": "custom", + "component": "redirectUri" + }, + { + "key": "legacyCallback", + "type": "boolean", + "labelKey": "settings.legacyCallback.label", + "descriptionKey": "settings.legacyCallback.description", + "default": false + } + ] + } + }, + "locales": "locales", + "platforms": ["linux", "win32", "darwin"] +} diff --git a/plugins/opkssh/migrations/mysql/0001_adopt_opkssh_tokens.sql b/plugins/opkssh/migrations/mysql/0001_adopt_opkssh_tokens.sql new file mode 100644 index 000000000..64ee26671 --- /dev/null +++ b/plugins/opkssh/migrations/mysql/0001_adopt_opkssh_tokens.sql @@ -0,0 +1,20 @@ +-- opkssh 0001: adopt_opkssh_tokens +-- Generated by termix-plugin migrations. Review before committing. + +CREATE TABLE IF NOT EXISTS `opkssh_tokens` ( + `id` int AUTO_INCREMENT PRIMARY KEY, + `user_id` varchar(255) NOT NULL, + `host_id` int NOT NULL, + `ssh_cert` text NOT NULL, + `private_key` text NOT NULL, + `email` text, + `sub` text, + `issuer` text, + `audience` text, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `expires_at` varchar(255) NOT NULL, + `last_used` text, + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`host_id`) REFERENCES `ssh_data` (`id`) ON DELETE CASCADE +); +RENAME TABLE `opkssh_tokens` TO `p_opkssh_tokens`; diff --git a/plugins/opkssh/migrations/postgres/0001_adopt_opkssh_tokens.sql b/plugins/opkssh/migrations/postgres/0001_adopt_opkssh_tokens.sql new file mode 100644 index 000000000..8c8a02df6 --- /dev/null +++ b/plugins/opkssh/migrations/postgres/0001_adopt_opkssh_tokens.sql @@ -0,0 +1,21 @@ +-- opkssh 0001: adopt_opkssh_tokens +-- Generated by termix-plugin migrations. Review before committing. + +CREATE TABLE IF NOT EXISTS "opkssh_tokens" ( + "id" serial PRIMARY KEY, + "user_id" varchar(255) NOT NULL, + "host_id" integer NOT NULL, + "ssh_cert" text NOT NULL, + "private_key" text NOT NULL, + "email" text, + "sub" text, + "issuer" text, + "audience" text, + "created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expires_at" varchar(255) NOT NULL, + "last_used" text, + FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE, + FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE +); +CREATE UNIQUE INDEX IF NOT EXISTS "idx_opkssh_tokens_user_host" ON "opkssh_tokens" ("user_id", "host_id"); +ALTER TABLE "opkssh_tokens" RENAME TO "p_opkssh_tokens"; diff --git a/plugins/opkssh/migrations/snapshot.json b/plugins/opkssh/migrations/snapshot.json new file mode 100644 index 000000000..5372b563a --- /dev/null +++ b/plugins/opkssh/migrations/snapshot.json @@ -0,0 +1,64 @@ +{ + "version": 1, + "tables": { + "tokens": { + "columns": { + "id": { + "type": "id", + "primaryKey": true, + "notNull": true + }, + "userId": { + "type": "refUser", + "length": 255, + "notNull": true + }, + "hostId": { + "type": "refHost", + "notNull": true + }, + "sshCert": { + "type": "text", + "notNull": true + }, + "privateKey": { + "type": "text", + "notNull": true + }, + "email": { + "type": "text" + }, + "sub": { + "type": "text" + }, + "issuer": { + "type": "text" + }, + "audience": { + "type": "text" + }, + "createdAt": { + "type": "text", + "notNull": true, + "defaultNow": true + }, + "expiresAt": { + "type": "varchar", + "length": 255, + "notNull": true + }, + "lastUsed": { + "type": "text" + } + }, + "indexes": [ + { + "name": "idx_opkssh_tokens_user_host", + "columns": ["userId", "hostId"], + "unique": true + } + ], + "adopts": "opkssh_tokens" + } + } +} diff --git a/plugins/opkssh/migrations/sqlite/0001_adopt_opkssh_tokens.sql b/plugins/opkssh/migrations/sqlite/0001_adopt_opkssh_tokens.sql new file mode 100644 index 000000000..65e8e3794 --- /dev/null +++ b/plugins/opkssh/migrations/sqlite/0001_adopt_opkssh_tokens.sql @@ -0,0 +1,21 @@ +-- opkssh 0001: adopt_opkssh_tokens +-- Generated by termix-plugin migrations. Review before committing. + +CREATE TABLE IF NOT EXISTS "opkssh_tokens" ( + "id" integer PRIMARY KEY AUTOINCREMENT, + "user_id" text NOT NULL, + "host_id" integer NOT NULL, + "ssh_cert" text NOT NULL, + "private_key" text NOT NULL, + "email" text, + "sub" text, + "issuer" text, + "audience" text, + "created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expires_at" text NOT NULL, + "last_used" text, + FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE, + FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE +); +CREATE UNIQUE INDEX IF NOT EXISTS "idx_opkssh_tokens_user_host" ON "opkssh_tokens" ("user_id", "host_id"); +ALTER TABLE "opkssh_tokens" RENAME TO "p_opkssh_tokens"; diff --git a/plugins/opkssh/package.json b/plugins/opkssh/package.json new file mode 100644 index 000000000..58c381aa6 --- /dev/null +++ b/plugins/opkssh/package.json @@ -0,0 +1,19 @@ +{ + "name": "@termix-plugin/opkssh", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "build": "termix-plugin build", + "test": "termix-plugin test", + "typecheck": "tsc -p tsconfig.json", + "validate": "termix-plugin validate" + }, + "dependencies": { + "js-yaml": "^5.2.3" + }, + "devDependencies": { + "@termix/plugin-sdk": "*", + "@types/js-yaml": "^4.0.9" + } +} diff --git a/plugins/opkssh/src/backend/auth-session.ts b/plugins/opkssh/src/backend/auth-session.ts new file mode 100644 index 000000000..20b2fc27a --- /dev/null +++ b/plugins/opkssh/src/backend/auth-session.ts @@ -0,0 +1,447 @@ +import { randomUUID } from "node:crypto"; +import path from "node:path"; +import type { + PluginContext, + PluginProcessHandle, +} from "@termix/plugin-sdk/backend"; +import { + CONFIG_FILE, + checkConfig, + DOCS_URL, + OPKSSH_DOCS_URL, + validateRedirectUris, +} from "./config.js"; +import type { CertificateIdentity, TokenStore } from "./token-store.js"; + +/** How long OPKSSH may wait for the browser sign-in. */ +export const AUTH_TIMEOUT_MS = 60 * 1000; + +/** Where the identity provider sends the browser back, before 2.9. */ +export const LEGACY_CALLBACK_PATH = "/host/opkssh-callback"; +/** The plugin's own routes, relative to the install's base URL. */ +export const PLUGIN_PATH = "/plugin-api/opkssh"; +export const CALLBACK_PATH = `${PLUGIN_PATH}/callback`; + +/** The terminal socket, as far as a sign-in needs it. */ +export interface SignInSocket { + send: (data: string) => void; + on: (event: "close", listener: () => void) => void; +} + +export interface AuthSession { + requestId: string; + userId: string; + hostId: number; + /** OPKSSH's chooser listener on this server. */ + localPort: number; + /** OPKSSH's login-callback listener on this server. */ + callbackPort: number; + remoteRedirectUri: string; + providers: Array<{ alias: string; issuer: string }>; + status: "starting" | "waiting_for_auth" | "authenticating" | "completed"; + socket: SignInSocket; + output: string; + privateKey: string; + sshCert: string; + identity: CertificateIdentity; + process: PluginProcessHandle | null; + timeout: ReturnType | null; + closed: boolean; +} + +export interface StartRequest { + userId: string; + hostId: number; + socket: SignInSocket; + requestOrigin: string; +} + +export type AuthSessions = ReturnType; + +function send(socket: SignInSocket, message: Record): void { + try { + socket.send(JSON.stringify(message)); + } catch { + // socket already gone + } +} + +const CHOOSER_PATTERN = + /(?:Opening browser to|Open your browser to:)\s*http:\/\/(?:localhost|127\.0\.0\.1):(\d+)\/chooser/; +const CALLBACK_PORT_PATTERN = + /listening on http:\/\/(?:127\.0\.0\.1|localhost):(\d+)\//; +const PRIVATE_KEY_PATTERN = + /(-----BEGIN OPENSSH PRIVATE KEY-----[\s\S]*?-----END OPENSSH PRIVATE KEY-----)/; +const CERT_PATTERN = + /((?:ecdsa-sha2-nistp256|ssh-rsa|ssh-ed25519)-cert-v01@openssh\.com\s+[A-Za-z0-9+/=]+)/; +const IDENTITY_PATTERN = + /Email, sub, issuer, audience:\s*\n?\s*([^\s]+)\s+([^\s]+)\s+([^\s]+)\s+([^\s]+)/; +const GENERIC_REDIRECT_ERRORS = [ + "redirect_uri", + "redirect uri", + "invalid redirect", + "no matching redirect", + "allowed redirect", + "mismatching redirection", +]; + +/** + * OPKSSH sign-ins in flight: one `opkssh login` process per session, whose + * chooser and callback listeners the routes proxy. Created per activation. + */ +export function createAuthSessions( + ctx: PluginContext, + deps: { + tokens: TokenStore; + binaryPath: () => Promise; + }, +) { + const sessions = new Map(); + const oauthStates = new Map(); + + async function configPath(): Promise { + return path.join(await ctx.files.dataDir(), CONFIG_FILE); + } + + async function callbackPath(): Promise { + const legacy = await ctx.settings.get("legacyCallback"); + return legacy ? LEGACY_CALLBACK_PATH : CALLBACK_PATH; + } + + async function end(requestId: string): Promise { + const session = sessions.get(requestId); + if (!session || session.closed) return; + session.closed = true; + if (session.timeout) clearTimeout(session.timeout); + for (const [state, id] of oauthStates) { + if (id === requestId) oauthStates.delete(state); + } + const child = session.process; + if (child) { + child.kill("SIGTERM"); + const forced = setTimeout(() => child.kill("SIGKILL"), 3000); + await child.exited.catch(() => undefined); + clearTimeout(forced); + } + sessions.delete(requestId); + } + + async function complete(session: AuthSession): Promise { + if (!session.privateKey.includes("BEGIN OPENSSH PRIVATE KEY")) { + send(session.socket, { + type: "opkssh_error", + requestId: session.requestId, + error: "Failed to extract valid private key from OPKSSH output", + }); + return; + } + if (!/-cert-v01@openssh\.com/.test(session.sshCert)) { + send(session.socket, { + type: "opkssh_error", + requestId: session.requestId, + error: "Failed to extract valid SSH certificate from OPKSSH output", + }); + return; + } + session.status = "completed"; + try { + const expiresAt = await deps.tokens.save( + session.userId, + session.hostId, + { sshCert: session.sshCert, privateKey: session.privateKey }, + session.identity, + ); + send(session.socket, { + type: "opkssh_completed", + requestId: session.requestId, + expiresAt, + }); + } catch (error) { + ctx.log.error( + `Failed to store OPKSSH certificate: ${error instanceof Error ? error.message : String(error)}`, + ); + send(session.socket, { + type: "opkssh_error", + requestId: session.requestId, + error: "Failed to store authentication token", + }); + } + await end(session.requestId); + } + + function handleOutput(session: AuthSession, chunk: string): void { + if (session.closed) return; + session.output += chunk; + + const chooser = session.output.match(CHOOSER_PATTERN); + if (chooser && session.status === "starting") { + session.localPort = parseInt(chooser[1], 10); + session.status = "waiting_for_auth"; + const origin = session.remoteRedirectUri.slice( + 0, + session.remoteRedirectUri.length - + (session.remoteRedirectUri.endsWith(LEGACY_CALLBACK_PATH) + ? LEGACY_CALLBACK_PATH.length + : CALLBACK_PATH.length), + ); + send(session.socket, { + type: "opkssh_status", + requestId: session.requestId, + stage: "chooser", + url: `${origin}${PLUGIN_PATH}/chooser/${session.requestId}`, + providers: session.providers, + message: "Please authenticate in your browser", + }); + } + + const callbackPort = session.output.match(CALLBACK_PORT_PATTERN); + if (callbackPort && !session.callbackPort) { + session.callbackPort = parseInt(callbackPort[1], 10); + } + + if (chunk.includes("BEGIN OPENSSH PRIVATE KEY")) { + session.status = "authenticating"; + send(session.socket, { + type: "opkssh_status", + requestId: session.requestId, + stage: "authenticating", + message: "Processing authentication...", + }); + } + + const key = session.output.match(PRIVATE_KEY_PATTERN); + if (key) session.privateKey = key[1].trim(); + const cert = session.output.match(CERT_PATTERN); + if (cert) session.sshCert = cert[1].trim(); + const identity = session.output.match(IDENTITY_PATTERN); + if (identity) { + session.identity = { + email: identity[1], + sub: identity[2], + issuer: identity[3], + audience: identity[4], + }; + } + + if ( + session.privateKey && + session.sshCert && + session.status !== "completed" + ) { + void complete(session); + } + } + + function handleStderr(session: AuthSession, stderr: string): void { + if ( + stderr.includes("Opening browser to") || + stderr.includes("Open your browser to:") || + stderr.includes("listening on") + ) { + handleOutput(session, stderr); + } + + const lower = stderr.toLowerCase(); + const configError = (error: string, instructions: string) => { + send(session.socket, { + type: "opkssh_config_error", + requestId: session.requestId, + error, + instructions, + }); + void end(session.requestId); + }; + + if (lower.includes("redirecturi must be localhost")) { + ctx.log.warn("OPKSSH rejected a non-localhost entry in redirect_uris"); + configError( + `OPKSSH rejected the local callback URI: every entry in 'redirect_uris' must be localhost.\n\n` + + `OPKSSH output:\n${stderr.trim()}\n\n` + + `The 'redirect_uris' config field is OPKSSH's LOCAL listener, not the public Termix callback. ` + + `Remove any non-localhost entries from redirect_uris (or delete the whole block to use OPKSSH's ` + + `defaults of :3000, :10001, :11110). Register the public Termix callback URL with your OAuth ` + + `provider instead, Termix passes it to OPKSSH automatically via --remote-redirect-uri.`, + `See documentation: ${DOCS_URL}`, + ); + return; + } + + if (GENERIC_REDIRECT_ERRORS.some((needle) => lower.includes(needle))) { + ctx.log.warn("OPKSSH reported a redirect_uri error"); + configError( + `OPKSSH or the OAuth provider rejected the redirect URI.\n\n` + + `Computed Termix callback URI (sent to provider): ${session.remoteRedirectUri}\n\n` + + `OPKSSH output:\n${stderr.trim()}\n\n` + + `Register '${session.remoteRedirectUri}' as an authorized redirect URI with your OAuth provider. ` + + `Also confirm any 'redirect_uris' in your OPKSSH config contain ONLY localhost URLs.`, + `See documentation: ${DOCS_URL}`, + ); + return; + } + + if ( + stderr.includes("provider not found") || + stderr.includes("config error") || + stderr.includes("invalid config") || + stderr.includes("config not found") + ) { + configError( + "OPKSSH configuration error. Please verify your config file contains valid OIDC provider settings.", + `See documentation: ${OPKSSH_DOCS_URL}`, + ); + return; + } + + if ( + !stderr.includes('exec: "xdg-open"') && + (stderr.includes("bind: address already in use") || + stderr.includes("error logging in") || + stderr.includes("failed to start")) + ) { + void end(session.requestId); + } + } + + return { + get: (requestId: string) => sessions.get(requestId), + + registerOAuthState(state: string, requestId: string): void { + oauthStates.set(state, requestId); + }, + + /** Finds the session an identity provider callback belongs to. */ + takeByOAuthState(state: string): AuthSession | undefined { + const requestId = oauthStates.get(state); + if (!requestId) return undefined; + oauthStates.delete(state); + return sessions.get(requestId); + }, + + callbackPath, + + async start(request: StartRequest): Promise { + const { socket } = request; + let config; + try { + config = await checkConfig(await configPath()); + } catch (error) { + send(socket, { + type: "opkssh_error", + error: `OPKSSH directory initialization failed: ${error instanceof Error ? error.message : String(error)}`, + }); + return ""; + } + if (!config.ok) { + send(socket, { + type: "opkssh_config_error", + requestId: "", + error: config.error, + instructions: config.error, + }); + return ""; + } + + const remoteRedirectUri = `${request.requestOrigin}${await callbackPath()}`; + const redirects = validateRedirectUris( + config.providers, + remoteRedirectUri, + ); + if (redirects.ok === false) { + send(socket, { + type: "opkssh_config_error", + requestId: "", + error: redirects.message, + instructions: redirects.message, + }); + return ""; + } + + const requestId = randomUUID(); + const session: AuthSession = { + requestId, + userId: request.userId, + hostId: request.hostId, + localPort: 0, + callbackPort: 0, + remoteRedirectUri, + providers: config.providers.map(({ alias, issuer }) => ({ + alias, + issuer, + })), + status: "starting", + socket, + output: "", + privateKey: "", + sshCert: "", + identity: {}, + process: null, + timeout: null, + closed: false, + }; + sessions.set(requestId, session); + + try { + const binary = await deps.binaryPath(); + const child = await ctx.process.run(binary, [ + "login", + "--print-key", + "--disable-browser-open", + `--config-path=${config.configPath}`, + `--remote-redirect-uri=${remoteRedirectUri}`, + ]); + session.process = child; + session.timeout = setTimeout(() => { + send(socket, { type: "opkssh_timeout", requestId }); + void end(requestId); + }, AUTH_TIMEOUT_MS); + socket.on("close", () => void end(requestId)); + + child.onStdout((chunk) => handleOutput(session, chunk)); + child.onStderr((chunk) => handleStderr(session, chunk)); + child.exited.then( + ({ code }) => { + if ( + code !== 0 && + session.status !== "completed" && + !session.closed + ) { + send(socket, { + type: "opkssh_error", + requestId, + error: `OPKSSH process exited with code ${code}`, + }); + } + void end(requestId); + }, + (error: unknown) => { + send(socket, { + type: "opkssh_error", + requestId, + error: `OPKSSH process error: ${error instanceof Error ? error.message : String(error)}`, + }); + void end(requestId); + }, + ); + return requestId; + } catch (error) { + sessions.delete(requestId); + ctx.log.error( + `Failed to start OPKSSH sign-in: ${error instanceof Error ? error.message : String(error)}`, + ); + send(socket, { + type: "opkssh_error", + requestId, + error: `Failed to start OPKSSH authentication: ${error instanceof Error ? error.message : String(error)}`, + }); + return ""; + } + }, + + cancel: (requestId: string) => end(requestId), + + /** Ends every session, for deactivate. */ + async closeAll(): Promise { + await Promise.all([...sessions.keys()].map((id) => end(id))); + }, + }; +} diff --git a/plugins/opkssh/src/backend/binary.ts b/plugins/opkssh/src/backend/binary.ts new file mode 100644 index 000000000..bf6195a7a --- /dev/null +++ b/plugins/opkssh/src/backend/binary.ts @@ -0,0 +1,77 @@ +import path from "node:path"; +import type { PluginBinarySpec } from "@termix/plugin-sdk/backend"; + +export const DEFAULT_VERSION = "v0.16.0"; + +const DEFAULT_CHECKSUMS: Record = { + "opkssh-linux-amd64": + "c018c3e7baf98612b923e742dd87be38650bf61e3b755fb2bc90de177568b1bf", + "opkssh-linux-arm64": + "9dd10c2b6ce99cde18e52c054877ca014134b291fd82afe71741c68db4f83d44", + "opkssh-osx-amd64": + "e1ccddb4a73c7dd24e0677e9c933462b954dde9a151fcd96c8ee7ba83bc3f146", + "opkssh-osx-arm64": + "be279812cc4d44a28f8cb6eef4b13515fae16b6d00ae61e21630e0c061b02cbd", + "opkssh-windows-amd64.exe": + "db8991ceaac7ac224b704510ca6fba2114998291d4283de4bc2d3b8efa66ad07", + "opkssh-windows-arm64.exe": + "c35352dc2d12ef3775b280aa85dc1e97ff90cd8ad4beb62c3e2357fdf80ba5af", +}; + +const OS_NAMES: Record = { + linux: "linux", + darwin: "osx", + win32: "windows", +}; + +const ARCH_NAMES: Record = { + x64: "amd64", + arm64: "arm64", +}; + +/** The release asset name for this server, e.g. "opkssh-linux-amd64". */ +export function binaryName( + platform: string = process.platform, + arch: string = process.arch, +): string { + const os = OS_NAMES[platform] ?? platform; + const cpu = ARCH_NAMES[arch] ?? arch; + return `opkssh-${os}-${cpu}${platform === "win32" ? ".exe" : ""}`; +} + +/** + * What ctx.process.ensureBinary needs for OPKSSH. + * + * The pinned version is trusted by its built-in checksums. OPKSSH_VERSION + * picks another release, and then OPKSSH_SHA256 must give its checksum. + * + * The Docker image bakes the binary into /app/opkssh-bundled (the + * opkssh-downloader stage), which is checked first so an offline install + * never downloads. OPKSSH_BUNDLED_DIR points somewhere else. + */ +export function binarySpec( + env: NodeJS.ProcessEnv = process.env, + platform: string = process.platform, + arch: string = process.arch, + cwd: string = process.cwd(), +): PluginBinarySpec { + const name = binaryName(platform, arch); + const version = env.OPKSSH_VERSION || DEFAULT_VERSION; + const sha256 = + version === DEFAULT_VERSION + ? DEFAULT_CHECKSUMS[name] + : env.OPKSSH_SHA256?.trim().toLowerCase(); + if (!sha256 || !/^[0-9a-f]{64}$/.test(sha256)) { + throw new Error( + `OPKSSH ${version} has no trusted SHA-256 checksum for ${name}`, + ); + } + const bundledDir = env.OPKSSH_BUNDLED_DIR || path.join(cwd, "opkssh-bundled"); + return { + name, + version, + sha256, + url: `https://github.com/openpubkey/opkssh/releases/download/${version}/${name}`, + prebuilt: [path.join(bundledDir, name)], + }; +} diff --git a/plugins/opkssh/src/backend/config.ts b/plugins/opkssh/src/backend/config.ts new file mode 100644 index 000000000..2ecf6dea6 --- /dev/null +++ b/plugins/opkssh/src/backend/config.ts @@ -0,0 +1,161 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { load as loadYaml } from "js-yaml"; + +export const CONFIG_FILE = "config.yml"; + +const DOCS_URL = "https://docs.termix.site/features/authentication/opkssh"; +const OPKSSH_DOCS_URL = + "https://github.com/openpubkey/opkssh/blob/main/docs/config.md"; + +const TEMPLATE = ` +# OPKSSH Configuration +# OPKSSH Documentation: ${OPKSSH_DOCS_URL} +# Termix Documentation: ${DOCS_URL} +`; + +export interface ProviderInfo { + alias: string; + issuer: string; + redirectUris: string[]; +} + +export type ConfigCheck = + | { ok: true; configPath: string; providers: ProviderInfo[] } + | { ok: false; configPath: string; error: string }; + +function dockerHint(): string { + const dataDir = process.env.DATA_DIR; + return dataDir && dataDir.startsWith("/app") + ? "\n\nDocker: Ensure /app/data is mounted as a volume with write permissions for node:node user." + : ""; +} + +/** Providers with an alias and issuer; issuers lose their scheme. */ +export function parseProviders(content: string): ProviderInfo[] { + const parsed = loadYaml(content) as { + providers?: Array<{ + alias?: unknown; + issuer?: unknown; + redirect_uris?: unknown; + }>; + } | null; + if (!parsed?.providers || !Array.isArray(parsed.providers)) return []; + return parsed.providers + .filter( + (p) => typeof p?.alias === "string" && typeof p?.issuer === "string", + ) + .map((p) => ({ + alias: p.alias as string, + issuer: (p.issuer as string).replace(/^https?:\/\//, ""), + redirectUris: Array.isArray(p.redirect_uris) + ? p.redirect_uris.filter((u): u is string => typeof u === "string") + : [], + })); +} + +/** + * Reads the config, writing a template when there is none. The file must + * have a providers section with at least one active provider. + */ +export async function checkConfig(configPath: string): Promise { + let content: string; + try { + content = await fs.readFile(configPath, "utf8"); + } catch { + try { + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile(configPath, TEMPLATE, "utf8"); + } catch { + // Reported below either way. + } + return { + ok: false, + configPath, + error: `OPKSSH configuration not found. A template config file has been created at:\n${configPath}\n\nPlease edit this file and configure your OIDC provider (Google, GitHub, Microsoft, etc.).\nSee documentation: ${OPKSSH_DOCS_URL}${dockerHint()}`, + }; + } + + if (!content.includes("providers:")) { + return { + ok: false, + configPath, + error: `OPKSSH configuration is missing 'providers' section. Please edit the config file at:\n${configPath}`, + }; + } + + const hasActiveProvider = content.split("\n").some((line) => { + const trimmed = line.trim(); + return trimmed.startsWith("- alias:") || trimmed.startsWith("issuer:"); + }); + if (!hasActiveProvider) { + return { + ok: false, + configPath, + error: `OPKSSH configuration has no active providers. Please edit the config file at:\n${configPath}\n\nUncomment and configure at least one OIDC provider.\nSee documentation: ${OPKSSH_DOCS_URL}${dockerHint()}`, + }; + } + + let providers: ProviderInfo[] = []; + try { + providers = parseProviders(content); + } catch { + // OPKSSH reports a broken file itself when it starts. + } + return { ok: true, configPath, providers }; +} + +function isLocalHost(host: string): boolean { + const bare = host.replace(/^\[|\]$/g, ""); + return ( + bare === "localhost" || + bare === "127.0.0.1" || + bare === "::1" || + bare === "0:0:0:0:0:0:0:1" || + bare.startsWith("localhost:") || + bare.startsWith("127.0.0.1:") + ); +} + +/** + * OPKSSH's redirect_uris are the candidate ports for its own callback + * listener on this server, so they must be localhost. The public URL the + * identity provider redirects to is passed separately as + * --remote-redirect-uri; it never goes in this field. + */ +export function validateRedirectUris( + providers: ProviderInfo[], + callbackUrl: string, +): { ok: true } | { ok: false; message: string } { + const issues: string[] = []; + for (const provider of providers) { + const nonLocal = provider.redirectUris.filter((uri) => { + try { + return !isLocalHost(new URL(uri).hostname); + } catch { + return true; + } + }); + if (nonLocal.length > 0) { + issues.push( + `Provider '${provider.alias}': non-localhost entries in redirect_uris: ${nonLocal.join(", ")}`, + ); + } + } + if (issues.length === 0) return { ok: true }; + return { + ok: false, + message: + `OPKSSH configuration error: 'redirect_uris' must only contain localhost URLs.\n\n` + + `${issues.join("\n")}\n\n` + + `This field is OPKSSH's local callback listener, it must be localhost (or omitted to use ` + + `the defaults http://localhost:3000/login-callback, :10001, :11110). ` + + `The public Termix callback URL is supplied automatically by Termix via --remote-redirect-uri; ` + + `you do not put it here. Register the public Termix URL with your OAuth provider instead ` + + `(${callbackUrl}).\n\n` + + `Fix: remove the non-localhost entries above, or delete the whole 'redirect_uris' block to use defaults.\n\n` + + `Docs: ${DOCS_URL}`, + }; +} + +export { DOCS_URL, OPKSSH_DOCS_URL }; diff --git a/src/backend/database/routes/opkssh-html.ts b/plugins/opkssh/src/backend/html.ts similarity index 85% rename from src/backend/database/routes/opkssh-html.ts rename to plugins/opkssh/src/backend/html.ts index f865edfd3..2c0248883 100644 --- a/src/backend/database/routes/opkssh-html.ts +++ b/plugins/opkssh/src/backend/html.ts @@ -1,5 +1,3 @@ -import { sshLogger } from "../../utils/logger.js"; - export function escapeHtml(value: string): string { return value .replace(/&/g, "&") @@ -65,7 +63,7 @@ type OpksshErrorPageOptions = { statusCode?: number; }; -export function renderOpksshErrorPage(opts: OpksshErrorPageOptions): string { +export function renderErrorPage(opts: OpksshErrorPageOptions): string { const title = escapeHtml(opts.title); const heading = escapeHtml(opts.heading); const message = escapeHtml(opts.message); @@ -97,7 +95,6 @@ export function renderOpksshErrorPage(opts: OpksshErrorPageOptions): string { text-align: center; background: #27272a; padding: 3rem 2rem; - border-radius: 0.625rem; border: 1px solid rgba(255, 255, 255, 0.1); max-width: 720px; width: 100%; @@ -126,7 +123,6 @@ export function renderOpksshErrorPage(opts: OpksshErrorPageOptions): string { padding: 0.875rem 1rem; background: #0f0f11; border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 0.5rem; white-space: pre-wrap; word-break: break-word; overflow-x: auto; @@ -150,14 +146,13 @@ export function renderOpksshErrorPage(opts: OpksshErrorPageOptions): string { `; } -export function rewriteOPKSSHHtml( - html: string, - requestId: string, - routePrefix: "opkssh-chooser" | "opkssh-callback", -): string { - const basePath = `/host/${routePrefix}/${requestId}`; +/** + * Points every URL in a page OPKSSH served at its proxied path, so the + * browser keeps talking to Termix instead of this server's localhost. + * `basePath` is the proxied prefix, e.g. "/plugin-api/opkssh/chooser/". + */ +export function rewriteOpksshHtml(html: string, basePath: string): string { const localHostPattern = "(?:localhost|127\\.0\\.0\\.1)"; - const attrPatterns = ["action", "href", "src", "formaction"]; for (const attr of attrPatterns) { html = html.replace( @@ -245,36 +240,12 @@ export function rewriteOPKSSHHtml( const baseTag = ``; if (html.includes("]*>/i, baseTag); } else if (html.includes("")) { - sshLogger.info("Inserting base tag into head", { - operation: "opkssh_html_rewrite_base_tag_insert", - requestId, - basePath, - }); html = html.replace(//i, `${baseTag}`); } else { - sshLogger.warn("No tag found, wrapping HTML", { - operation: "opkssh_html_rewrite_no_head", - requestId, - htmlLength: html.length, - htmlPreview: html.substring(0, 200), - }); html = `${baseTag}${html}`; } - sshLogger.info("HTML rewrite complete", { - operation: "opkssh_html_rewrite_complete", - requestId, - routePrefix, - hasBaseTag: html.includes(" | null = null; + const binaryPath = () => { + if (!binary) { + binary = ctx.process.ensureBinary(binarySpec()).catch((error) => { + binary = null; + throw error; + }); + } + return binary; + }; + binaryPath().catch((error) => + ctx.log.warn( + `OPKSSH binary is not available yet: ${error instanceof Error ? error.message : String(error)}`, + ), + ); + + const sessions = createAuthSessions(ctx, { tokens, binaryPath }); + ctx.disposables.add(() => sessions.closeAll()); + + ctx.auth.registerSshAuthProvider(createOpksshProvider(ctx, tokens, sessions)); + + registerRoutes( + ctx.http.router({ public: PUBLIC_PATHS }), + ctx, + sessions, + tokens, + ); +} + +export async function deactivate() { + // Everything above was registered through ctx and is disposed by core. +} diff --git a/plugins/opkssh/src/backend/provider.ts b/plugins/opkssh/src/backend/provider.ts new file mode 100644 index 000000000..07ef5bcf5 --- /dev/null +++ b/plugins/opkssh/src/backend/provider.ts @@ -0,0 +1,88 @@ +import type { + PluginContext, + PluginSshAuthProvider, +} from "@termix/plugin-sdk/backend"; +import { applyCertificateAuth } from "@termix/plugin-sdk/ssh-certs"; +import type { AuthSessions, SignInSocket } from "./auth-session.js"; +import type { TokenStore } from "./token-store.js"; + +export const AUTH_TYPE = "opkssh"; + +const REQUIRED_MESSAGE = + "OPKSSH authentication required. Please open a Terminal connection to this host first to complete browser-based authentication. Your session will be cached for 24 hours."; + +const AUTH_FAILED_PATTERN = /All configured authentication methods failed/i; + +/** + * Hosts with auth type "opkssh" connect with a cached certificate. Without + * one, the terminal is asked for a browser sign-in ("opkssh_auth_required"), + * which startInteraction runs. + */ +export function createOpksshProvider( + ctx: PluginContext, + tokens: TokenStore, + sessions: AuthSessions, +): PluginSshAuthProvider { + return { + type: AUTH_TYPE, + labelKey: "hosts.filterAuthOpkssh", + needsUserInteraction: true, + supportsBackground: false, + interaction: AUTH_TYPE, + prepare: async (config, host, env) => { + const certificate = await tokens.get(env.userId, env.hostId); + if (!certificate) { + env.log("info", "No valid certificate found, requesting sign-in"); + return { + status: "interaction-required", + interaction: AUTH_TYPE, + message: REQUIRED_MESSAGE, + flag: "requiresOPKSSHAuth", + }; + } + try { + await applyCertificateAuth( + config as never, + env.client as never, + { + privateKey: certificate.privateKey, + certificate: certificate.sshCert, + }, + host.username, + ); + } catch (error) { + return { + status: "error", + code: "failed", + message: + "OPKSSH authentication failed: " + + (error instanceof Error ? error.message : String(error)), + }; + } + env.log("info", "Using cached SSH certificate"); + return { status: "ready" }; + }, + onAuthFailed: (_host, env, context) => { + if (!AUTH_FAILED_PATTERN.test(context.error.message)) return undefined; + ctx.log.warn("OPKSSH authentication failed, forgetting the certificate"); + void tokens.remove(env.userId, env.hostId).catch(() => undefined); + return { + status: "interaction-required", + interaction: AUTH_TYPE, + message: + "OPKSSH authentication failed or expired. Please authenticate again.", + }; + }, + startInteraction: async (request) => { + await sessions.start({ + userId: request.userId, + hostId: request.hostId, + socket: request.socket as SignInSocket, + requestOrigin: request.requestOrigin, + }); + }, + cancelInteraction: async (request) => { + if (request.requestId) await sessions.cancel(request.requestId); + }, + }; +} diff --git a/plugins/opkssh/src/backend/routes.ts b/plugins/opkssh/src/backend/routes.ts new file mode 100644 index 000000000..f9f1ec0f3 --- /dev/null +++ b/plugins/opkssh/src/backend/routes.ts @@ -0,0 +1,622 @@ +import axios from "axios"; +import type { Request, Response, Router } from "express"; +import type { PluginContext } from "@termix/plugin-sdk/backend"; +import { + normalizeSelectOpParam, + renderErrorPage, + rewriteOpksshHtml, +} from "./html.js"; +import { + PLUGIN_PATH, + type AuthSession, + type AuthSessions, +} from "./auth-session.js"; +import type { TokenStore } from "./token-store.js"; + +/** + * The browser reaches these without a Termix session: the sign-in may finish + * in a different browser, and the identity provider redirects to /callback. + * Each is matched to its sign-in by the request id, the OAuth state or the + * opkssh_request_id cookie. + */ +export const PUBLIC_PATHS = [ + "/chooser/:requestId", + "/chooser/:requestId/*", + "/callback", + "/callback/:requestId", + "/callback/:requestId/*", +]; + +const REQUEST_COOKIE = "opkssh_request_id"; +const MAX_HOPS = 4; + +function isLocalHostname(host: string): boolean { + const bare = host.split(":")[0]; + return bare === "127.0.0.1" || bare === "localhost" || bare === "[::1]"; +} + +function readCookie(req: Request, name: string): string | undefined { + const header = req.headers.cookie; + if (!header) return undefined; + for (const part of header.split(";")) { + const [key, ...rest] = part.trim().split("="); + if (key === name) return decodeURIComponent(rest.join("=")); + } + return undefined; +} + +function param(value: string | string[] | undefined): string { + return Array.isArray(value) ? (value[0] ?? "") : (value ?? ""); +} + +export function registerRoutes( + router: Router, + ctx: PluginContext, + sessions: AuthSessions, + tokens: TokenStore, +): void { + /** The plugin's public path prefix on this install, base path included. */ + const prefix = (req: Request): string => { + const base = new URL(ctx.http.baseUrl(req)).pathname.replace(/\/$/, ""); + return `${base}${PLUGIN_PATH}`; + }; + + const pathAfter = (req: Request, marker: string): string => { + const full = req.originalUrl || req.url; + const index = full.indexOf(marker); + return index === -1 ? "" : full.slice(index + marker.length); + }; + + const sessionNotFound = (res: Response, requestId: string) => { + res.status(404).send( + renderErrorPage({ + title: "Session Not Found", + heading: "Session Not Found", + message: + "This authentication session has expired or is invalid. Please close this window and try again.", + requestId, + }), + ); + }; + + async function actingUserWithAccess( + req: Request, + res: Response, + ): Promise<{ userId: string; hostId: number } | null> { + const userId = ctx.currentActor(); + const hostId = Number(param(req.params.hostId)); + if (!userId) { + res.status(401).json({ error: "Authentication required" }); + return null; + } + if (!Number.isInteger(hostId) || hostId <= 0) { + res.status(400).json({ error: "Invalid host id" }); + return null; + } + const access = await ctx.hosts.checkAccess(hostId, "connect"); + if (!access.hasAccess) { + res.status(404).json({ error: "Host not found" }); + return null; + } + return { userId, hostId }; + } + + /** + * @openapi + * /plugin-api/opkssh/token/{hostId}: + * get: + * summary: Get the cached OPKSSH certificate status for a host + * description: Whether the current user has a valid cached certificate for this host, and when it expires. + * tags: [OPKSSH] + * parameters: + * - name: hostId + * in: path + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: Certificate status. + * 400: + * description: Invalid host id. + * 404: + * description: Host not found or not accessible. + */ + router.get("/token/:hostId", async (req: Request, res: Response) => { + const target = await actingUserWithAccess(req, res); + if (!target) return; + res.json(await tokens.status(target.userId, target.hostId)); + }); + + /** + * @openapi + * /plugin-api/opkssh/token/{hostId}: + * delete: + * summary: Forget the cached OPKSSH certificate for a host + * description: The next connection to the host asks for a browser sign-in again. + * tags: [OPKSSH] + * parameters: + * - name: hostId + * in: path + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: Certificate removed, or there was none. + * 400: + * description: Invalid host id. + * 404: + * description: Host not found or not accessible. + */ + router.delete("/token/:hostId", async (req: Request, res: Response) => { + const target = await actingUserWithAccess(req, res); + if (!target) return; + await tokens.remove(target.userId, target.hostId); + res.json({ success: true }); + }); + + /** + * Follows OPKSSH's /select hops until it names the identity provider: + * /select to /select/ on the chooser, then /login on the callback listener, + * then the provider's authorize URL. + */ + async function proxySelect( + req: Request, + res: Response, + session: AuthSession, + targetPath: string, + ): Promise { + const requestId = session.requestId; + const rawQs = targetPath.includes("?") + ? targetPath.slice(targetPath.indexOf("?")) + : ""; + let qs = rawQs; + if (rawQs) { + try { + const params = new URLSearchParams(rawQs.replace(/^\?/, "")); + const rawOp = params.get("op"); + if (rawOp) { + const mapped = normalizeSelectOpParam(rawOp, session.providers); + if (mapped !== rawOp) { + params.set("op", mapped); + qs = `?${params.toString()}`; + } + } + } catch { + // keep the raw query + } + } + + const chooserHost = `127.0.0.1:${session.localPort}`; + const startUrl = `http://${chooserHost}/select/${qs}`; + + const fetchUpstream = async (url: string) => { + let hostHeader = chooserHost; + try { + hostHeader = new URL(url).host; + } catch { + // keep the chooser host + } + const response = await axios({ + method: "GET", + url, + maxRedirects: 0, + validateStatus: () => true, + timeout: 10000, + responseType: "text", + transformResponse: (value) => value, + headers: { host: hostHeader }, + }); + const location = response.headers["location"]; + const contentType = response.headers["content-type"]; + return { + status: response.status, + location: typeof location === "string" ? location : undefined, + contentType: typeof contentType === "string" ? contentType : "", + body: + typeof response.data === "string" + ? response.data + : String(response.data ?? ""), + targetUrl: url, + }; + }; + + try { + let response = await fetchUpstream(startUrl); + for (let hop = 0; hop < MAX_HOPS; hop++) { + if (response.status < 300 || response.status >= 400) break; + const location = response.location; + if (!location) break; + + if (location.startsWith("/")) { + let currentHost = chooserHost; + try { + currentHost = new URL(response.targetUrl).host; + } catch { + // keep the chooser host + } + response = await fetchUpstream(`http://${currentHost}${location}`); + continue; + } + + if (/^https?:\/\//i.test(location)) { + const parsed = new URL(location); + if (isLocalHostname(parsed.host)) { + if (!session.callbackPort) { + const port = parseInt(parsed.port, 10); + if (!Number.isNaN(port)) session.callbackPort = port; + } + // Send the browser through the proxy so it gets the state cookie + // OPKSSH sets on /login; following it here would swallow it. + res.redirect( + 302, + `${prefix(req)}/chooser/${requestId}${parsed.pathname}${parsed.search}`, + ); + return; + } + } + break; + } + + let external: URL | null = null; + if ( + response.status >= 300 && + response.status < 400 && + response.location && + /^https?:\/\//i.test(response.location) + ) { + try { + const parsed = new URL(response.location); + if (!isLocalHostname(parsed.host)) external = parsed; + } catch { + external = null; + } + } + + if (external) { + const state = external.searchParams.get("state"); + if (state) sessions.registerOAuthState(state, requestId); + res.redirect(302, external.toString()); + return; + } + + const bodyPreview = response.body.slice(0, 512); + ctx.log.error( + `OPKSSH /select did not produce an OAuth redirect (status ${response.status})`, + ); + res.status(502).send( + renderErrorPage({ + title: "OPKSSH error", + heading: "Failed to get OAuth redirect", + message: + "OPKSSH did not return an external OAuth provider URL. " + + "This typically indicates a configuration mismatch between the provider's redirect_uris " + + "and the Termix callback path. Check the server log for the OPKSSH response body.", + details: [ + `Upstream: ${response.targetUrl}`, + `Status: ${response.status}`, + response.location ? `Location: ${response.location}` : "", + `Content-Type: ${response.contentType || "(none)"}`, + "", + bodyPreview + ? `Body (first 512 chars):\n${bodyPreview}` + : "Body: (empty)", + ] + .filter((line, index) => line || index === 4) + .join("\n"), + requestId, + }), + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + ctx.log.error(`Error proxying OPKSSH /select: ${message}`); + res.status(502).send( + renderErrorPage({ + title: "OPKSSH error", + heading: "Failed to reach OPKSSH service", + message: + "Termix could not connect to the local OPKSSH authentication service. " + + "The OPKSSH process may have exited or is not listening yet.", + details: `Upstream: ${startUrl}\nError: ${message}`, + requestId, + }), + ); + } + } + + /** + * @openapi + * /plugin-api/opkssh/chooser/{requestId}: + * get: + * summary: Proxy the OPKSSH provider chooser page and its resources + * description: Public. Serves OPKSSH's local chooser for one sign-in, rewriting its URLs to this path. Any subpath is proxied too. + * tags: [OPKSSH] + * parameters: + * - name: requestId + * in: path + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Chooser page content. + * 302: + * description: Redirect to the identity provider or the callback listener. + * 404: + * description: Sign-in not found. + * 502: + * description: OPKSSH did not answer as expected. + */ + router.use("/chooser/:requestId", async (req: Request, res: Response) => { + const requestId = param(req.params.requestId); + const session = sessions.get(requestId); + if (!session) { + sessionNotFound(res, requestId); + return; + } + if (!session.localPort) { + res.status(500).send( + renderErrorPage({ + title: "Error", + heading: "Authentication Error", + message: + "Failed to load authentication page. OPKSSH process may not be ready yet. Please try again.", + requestId, + }), + ); + return; + } + + const base = prefix(req); + const chooserBase = `${base}/chooser/${requestId}`; + const callbackBase = `${base}/callback/${requestId}`; + const targetPath = pathAfter(req, `/chooser/${requestId}`) || "/chooser"; + + if (targetPath.startsWith("/select")) { + await proxySelect(req, res, session, targetPath); + return; + } + + // The browser is sent here for OPKSSH's login listener so it receives + // the listener's cookies. + const onCallbackListener = + /^\/login(?:\?|$)/.test(targetPath) || + /^\/login-callback(?:\?|$)/.test(targetPath); + const upstreamPort = + onCallbackListener && session.callbackPort + ? session.callbackPort + : session.localPort; + + try { + const response = await axios({ + method: req.method, + url: `http://127.0.0.1:${upstreamPort}${targetPath}`, + headers: { ...req.headers, host: `127.0.0.1:${upstreamPort}` }, + data: req.body, + timeout: 10000, + validateStatus: () => true, + maxRedirects: 0, + responseType: "arraybuffer", + }); + + for (const [key, value] of Object.entries(response.headers)) { + const lower = key.toLowerCase(); + if (lower === "transfer-encoding") continue; + if (lower === "location") { + const location = String(value); + if (location.startsWith("/")) { + res.setHeader(key, `${chooserBase}${location}`); + continue; + } + const local = location.match( + /^http:\/\/(?:localhost|127\.0\.0\.1):(\d+)(\/.*)?$/, + ); + if (local) { + const port = parseInt(local[1], 10); + const path = local[2] || "/"; + const toCallback = + (session.callbackPort && port === session.callbackPort) || + (port !== session.localPort && + (path.includes("login") || path.includes("callback"))); + res.setHeader( + key, + `${toCallback ? callbackBase : chooserBase}${path}`, + ); + continue; + } + try { + const state = new URL(location).searchParams.get("state"); + if (state) sessions.registerOAuthState(state, requestId); + } catch { + // not a URL + } + res.setHeader(key, location); + } else if (lower === "set-cookie") { + // Scope OPKSSH's cookies to the proxied callback, where the state + // cookie set by /login has to arrive. + const cookies = Array.isArray(value) ? value : [String(value)]; + res.setHeader( + key, + cookies.map((cookie) => + /;\s*path=/i.test(cookie) + ? cookie + .replace(/;\s*domain=[^;]*/gi, "") + .replace(/;\s*path=[^;]*/gi, `; Path=${base}/callback/`) + : `${cookie.replace(/;\s*domain=[^;]*/gi, "")}; Path=${base}/callback/`, + ), + ); + } else if (value !== undefined) { + res.setHeader(key, value as string | string[]); + } + } + + // Survives the identity provider round trip, for when OPKSSH + // redirects with script and no state was captured. + res.cookie(REQUEST_COOKIE, requestId, { + path: `${base}/`, + httpOnly: true, + sameSite: "lax", + maxAge: 5 * 60 * 1000, + }); + + const contentType = String(response.headers["content-type"] || ""); + const data = Buffer.from(response.data as ArrayBuffer); + if (contentType.includes("text/html")) { + res + .status(response.status) + .send(rewriteOpksshHtml(data.toString("utf-8"), chooserBase)); + } else { + res.status(response.status).send(data); + } + } catch (error) { + ctx.log.error( + `Error proxying OPKSSH chooser: ${error instanceof Error ? error.message : String(error)}`, + ); + res.status(500).send( + renderErrorPage({ + title: "Error", + heading: "Error", + message: "Failed to load authentication page. Please try again.", + requestId, + }), + ); + } + }); + + /** + * @openapi + * /plugin-api/opkssh/callback: + * get: + * summary: OAuth callback from the identity provider for an OPKSSH sign-in + * description: Public. The redirect URI registered with the identity provider. Finds the sign-in by OAuth state or the opkssh_request_id cookie and forwards to its proxied callback listener. + * tags: [OPKSSH] + * responses: + * 302: + * description: Forwarded to the sign-in's callback listener. + * 401: + * description: No sign-in matches this callback. + * 503: + * description: OPKSSH's callback listener is not ready yet. + */ + router.get("/callback", (req: Request, res: Response) => { + const base = prefix(req); + const state = typeof req.query.state === "string" ? req.query.state : ""; + let session = state ? sessions.takeByOAuthState(state) : undefined; + if (!session) { + const cookieId = readCookie(req, REQUEST_COOKIE); + if (cookieId) { + session = sessions.get(cookieId); + if (session) res.clearCookie(REQUEST_COOKIE, { path: `${base}/` }); + } + } + if (!session) { + ctx.log.warn("OPKSSH callback matched no sign-in"); + res + .status(401) + .send("Authentication callback failed: unable to identify session"); + return; + } + if (!session.callbackPort) { + res.status(503).send("OPKSSH callback listener not ready yet"); + return; + } + const query = req.url.includes("?") + ? req.url.slice(req.url.indexOf("?")) + : ""; + res.redirect(302, `${base}/callback/${session.requestId}${query}`); + }); + + /** + * @openapi + * /plugin-api/opkssh/callback/{requestId}: + * get: + * summary: Proxy OPKSSH's local callback listener for one sign-in + * description: Public. Forwards to OPKSSH's login-callback listener, which prints the key and certificate once the code exchange succeeds. Any subpath is proxied too. + * tags: [OPKSSH] + * parameters: + * - name: requestId + * in: path + * required: true + * schema: + * type: string + * responses: + * 200: + * description: The listener's page. + * 404: + * description: Sign-in not found. + * 500: + * description: The listener is not ready. + */ + router.use("/callback/:requestId", async (req: Request, res: Response) => { + const requestId = param(req.params.requestId); + const session = sessions.get(requestId); + if (!session) { + sessionNotFound(res, requestId); + return; + } + if (!session.callbackPort) { + res.status(500).send( + renderErrorPage({ + title: "Error", + heading: "Callback Error", + message: + "OPKSSH callback listener not ready. Please try authenticating again.", + requestId, + }), + ); + return; + } + + const callbackBase = `${prefix(req)}/callback/${requestId}`; + const after = pathAfter(req, `/callback/${requestId}`); + // The listener serves /login-callback whatever the public path was. + const targetPath = + after === "" || after.startsWith("?") ? `/login-callback${after}` : after; + + try { + const response = await axios({ + method: req.method, + url: `http://127.0.0.1:${session.callbackPort}${targetPath}`, + headers: { ...req.headers, host: `127.0.0.1:${session.callbackPort}` }, + data: req.body, + timeout: 10000, + validateStatus: () => true, + maxRedirects: 0, + responseType: "arraybuffer", + }); + + for (const [key, value] of Object.entries(response.headers)) { + const lower = key.toLowerCase(); + if (lower === "transfer-encoding" || value === undefined) continue; + if (lower === "location" && String(value).startsWith("/")) { + res.setHeader(key, `${callbackBase}${String(value)}`); + } else { + res.setHeader(key, value as string | string[]); + } + } + + const contentType = String(response.headers["content-type"] || ""); + const data = Buffer.from(response.data as ArrayBuffer); + if (contentType.includes("text/html")) { + res + .status(response.status) + .send(rewriteOpksshHtml(data.toString("utf-8"), callbackBase)); + } else { + res.status(response.status).send(data); + } + } catch (error) { + ctx.log.error( + `Error handling OPKSSH callback: ${error instanceof Error ? error.message : String(error)}`, + ); + res.status(500).send( + renderErrorPage({ + title: "Error", + heading: "Error", + message: "An unexpected error occurred. Please try again.", + requestId, + }), + ); + } + }); +} diff --git a/plugins/opkssh/src/backend/tables.ts b/plugins/opkssh/src/backend/tables.ts new file mode 100644 index 000000000..4404a6983 --- /dev/null +++ b/plugins/opkssh/src/backend/tables.ts @@ -0,0 +1,48 @@ +import { + adoptLegacyTable, + defineTable, + id, + refHost, + refUser, + text, + varchar, +} from "@termix/plugin-sdk/db"; + +/** + * One cached certificate per user and host. Adopted from core's + * opkssh_tokens, so the column and index names are the legacy ones. + * + * sshCert and privateKey are sealed with ctx.secrets.seal. Rows written by + * 2.8 were encrypted with the user's data key instead; they do not unseal and + * are treated as expired, which costs one browser sign-in. + */ +export const tokens = adoptLegacyTable( + "opkssh_tokens", + defineTable( + "tokens", + { + id: id(), + userId: refUser(), + hostId: refHost(), + sshCert: text().notNull(), + privateKey: text().notNull(), + email: text(), + sub: text(), + issuer: text(), + audience: text(), + createdAt: text().notNull().defaultNow(), + expiresAt: varchar().notNull(), + lastUsed: text(), + }, + { + uniques: [ + { + name: "idx_opkssh_tokens_user_host", + columns: ["userId", "hostId"], + }, + ], + }, + ), +); + +export const tables = [tokens]; diff --git a/plugins/opkssh/src/backend/token-store.ts b/plugins/opkssh/src/backend/token-store.ts new file mode 100644 index 000000000..125cdaf70 --- /dev/null +++ b/plugins/opkssh/src/backend/token-store.ts @@ -0,0 +1,142 @@ +import { and, eq } from "drizzle-orm"; +import type { PluginContext } from "@termix/plugin-sdk/backend"; + +/* eslint-disable @typescript-eslint/no-explicit-any */ +// ctx.db.define hands the table back untyped and the drizzle handle is the +// server's own, so both are typed at this module's edge. +type Table = any; +type Drizzle = any; +/* eslint-enable @typescript-eslint/no-explicit-any */ + +/** How long a certificate is cached after sign-in. */ +export const TOKEN_LIFETIME_MS = 24 * 60 * 60 * 1000; + +export interface StoredCertificate { + sshCert: string; + privateKey: string; +} + +export interface CertificateIdentity { + email?: string; + sub?: string; + issuer?: string; + audience?: string; +} + +export interface TokenStatus { + exists: boolean; + expiresAt?: string; + email?: string | null; +} + +export type TokenStore = ReturnType; + +export function createTokenStore(ctx: PluginContext, table: Table) { + const client = () => ctx.db.client(); + const byUserAndHost = (userId: string, hostId: number) => + and(eq(table.userId, userId), eq(table.hostId, hostId)); + + async function find(userId: string, hostId: number) { + const rows = await ( + await client() + ) + .select() + .from(table) + .where(byUserAndHost(userId, hostId)) + .limit(1); + return rows[0] ?? null; + } + + async function remove(userId: string, hostId: number): Promise { + const existing = await find(userId, hostId); + if (!existing) return false; + await (await client()).delete(table).where(byUserAndHost(userId, hostId)); + await ctx.db.persist(); + return true; + } + + function expired(row: { expiresAt: string }): boolean { + const expiresAt = new Date(row.expiresAt).getTime(); + return !Number.isFinite(expiresAt) || expiresAt <= Date.now(); + } + + return { + async save( + userId: string, + hostId: number, + certificate: StoredCertificate, + identity: CertificateIdentity, + ): Promise { + const expiresAt = new Date(Date.now() + TOKEN_LIFETIME_MS).toISOString(); + const values = { + sshCert: await ctx.secrets.seal(certificate.sshCert), + privateKey: await ctx.secrets.seal(certificate.privateKey), + email: identity.email ?? null, + sub: identity.sub ?? null, + issuer: identity.issuer ?? null, + audience: identity.audience ?? null, + createdAt: new Date().toISOString(), + expiresAt, + lastUsed: null, + }; + if (await find(userId, hostId)) { + await ( + await client() + ) + .update(table) + .set(values) + .where(byUserAndHost(userId, hostId)); + } else { + await ( + await client() + ) + .insert(table) + .values({ userId, hostId, ...values }); + } + await ctx.db.persist(); + return expiresAt; + }, + + /** + * The usable certificate, or null. An expired row, or one that does not + * unseal (written by 2.8), is deleted. + */ + async get( + userId: string, + hostId: number, + ): Promise { + const row = await find(userId, hostId); + if (!row) return null; + if (expired(row)) { + await remove(userId, hostId); + return null; + } + const sshCert = await ctx.secrets.unseal(row.sshCert); + const privateKey = await ctx.secrets.unseal(row.privateKey); + if (!sshCert || !privateKey) { + await remove(userId, hostId); + return null; + } + await ( + await client() + ) + .update(table) + .set({ lastUsed: new Date().toISOString() }) + .where(byUserAndHost(userId, hostId)); + await ctx.db.persist({ lazy: true }); + return { sshCert, privateKey }; + }, + + async status(userId: string, hostId: number): Promise { + const row = await find(userId, hostId); + if (!row) return { exists: false }; + if (expired(row)) { + await remove(userId, hostId); + return { exists: false }; + } + return { exists: true, expiresAt: row.expiresAt, email: row.email }; + }, + + remove, + }; +} diff --git a/plugins/opkssh/src/frontend/OpksshAuthEditor.tsx b/plugins/opkssh/src/frontend/OpksshAuthEditor.tsx new file mode 100644 index 000000000..719af72cc --- /dev/null +++ b/plugins/opkssh/src/frontend/OpksshAuthEditor.tsx @@ -0,0 +1,28 @@ +import { useTranslation } from "@termix/plugin-sdk/frontend"; + +const DOCS_URL = "https://docs.termix.site/features/authentication/opkssh"; + +/** The host editor's "opkssh" auth method: nothing to fill in, just a pointer. */ +export function OpksshAuthEditor() { + const { t } = useTranslation(); + return ( +
+
+ + {t("editor.label")} + + + {t("editor.docs")} + +
+

+ {t("editor.description")} +

+
+ ); +} diff --git a/src/ui/ssh/dialogs/OPKSSHDialog.tsx b/plugins/opkssh/src/frontend/OpksshDialog.tsx similarity index 84% rename from src/ui/ssh/dialogs/OPKSSHDialog.tsx rename to plugins/opkssh/src/frontend/OpksshDialog.tsx index 66bbdf3af..66b8be8bd 100644 --- a/src/ui/ssh/dialogs/OPKSSHDialog.tsx +++ b/plugins/opkssh/src/frontend/OpksshDialog.tsx @@ -1,25 +1,23 @@ -import React from "react"; -import { Button } from "@/components/button.tsx"; import { Shield, ExternalLink, Loader2, AlertCircle } from "lucide-react"; -import { useTranslation } from "react-i18next"; +import { useTranslation } from "@termix/plugin-sdk/frontend"; +import { Button } from "@termix/plugin-sdk/ui"; -interface OPKSSHDialogProps { - isOpen: boolean; +export type SignInStage = + "chooser" | "waiting" | "authenticating" | "completed" | "error"; + +interface OpksshDialogProps { authUrl: string; - requestId: string; - stage: "chooser" | "waiting" | "authenticating" | "completed" | "error"; + stage: SignInStage; error?: string; providers?: Array<{ alias: string; issuer: string }>; - /** Issuer name shown in the title; defaults to OPKSSH. */ - label?: string; onCancel: () => void; onOpenUrl: () => void; onSelectProvider?: (alias: string) => void; backgroundColor?: string; } -export function OPKSSHDialog({ - isOpen, +/** The browser sign-in the terminal shows while OPKSSH waits. */ +export function OpksshDialog({ authUrl, stage, error, @@ -28,16 +26,13 @@ export function OPKSSHDialog({ onOpenUrl, onSelectProvider, backgroundColor, - label, -}: OPKSSHDialogProps) { +}: OpksshDialogProps) { const { t } = useTranslation(); - if (!isOpen) return null; - return (
@@ -45,14 +40,12 @@ export function OPKSSHDialog({

- {label - ? t("terminal.certAuthRequired", { provider: label }) - : t("terminal.opksshAuthRequired")} + {t("dialog.title")}

{stage === "chooser" && (

- {t("terminal.opksshAuthDescription")} + {t("dialog.description")}

)}
@@ -70,7 +63,7 @@ export function OPKSSHDialog({ className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 rounded-none text-[10px] font-bold uppercase tracking-widest w-full flex items-center gap-2" > - {t("terminal.opksshSignInWith", { + {t("dialog.signInWith", { provider: provider.alias.charAt(0).toUpperCase() + provider.alias.slice(1), @@ -86,7 +79,7 @@ export function OPKSSHDialog({ className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 rounded-none text-[10px] font-bold uppercase tracking-widest w-full flex items-center gap-2" > - {t("terminal.opksshOpenBrowser")} + {t("dialog.openBrowser")} ) : null}
@@ -108,8 +101,8 @@ export function OPKSSHDialog({

{stage === "waiting" - ? t("terminal.opksshWaitingForAuth") - : t("terminal.opksshAuthenticating")} + ? t("dialog.waiting") + : t("dialog.authenticating")}

diff --git a/plugins/opkssh/src/frontend/OpksshOverlay.tsx b/plugins/opkssh/src/frontend/OpksshOverlay.tsx new file mode 100644 index 000000000..ce18064e3 --- /dev/null +++ b/plugins/opkssh/src/frontend/OpksshOverlay.tsx @@ -0,0 +1,202 @@ +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "@termix/plugin-sdk/frontend"; +import { OpksshDialog, type SignInStage } from "./OpksshDialog"; + +/** What the ssh-terminal plugin hands a "terminal.overlay" component. */ +export interface TerminalOverlayProps { + host: { id: number | string; [key: string]: unknown } | undefined; + backgroundColor?: string; + subscribe: ( + listener: (message: { type: string; [key: string]: unknown }) => void, + ) => () => void; + holdConnectTimeout: (held: boolean) => void; + fail: (message: string) => void; + disconnect: () => void; + send: (type: string, data?: unknown) => void; + connectPayload: () => Record; +} + +interface DialogState { + authUrl: string; + requestId: string; + stage: SignInStage; + error?: string; + providers?: Array<{ alias: string; issuer: string }>; +} + +/** How long the sign-in dialog waits before giving up on the connection. */ +const DIALOG_TIMEOUT_MS = 300_000; + +/** + * Runs the OPKSSH browser sign-in for a terminal: asks the server to start + * it, shows the provider chooser, and reconnects once the certificate is + * stored. + */ +export function OpksshOverlay({ + subscribe, + holdConnectTimeout, + fail, + disconnect, + send, + connectPayload, + backgroundColor, +}: TerminalOverlayProps) { + const { t } = useTranslation(); + const [dialog, setDialog] = useState(null); + const timerRef = useRef | null>(null); + const heldRef = useRef(false); + // A second "auth required" after a sign-in means the certificate did not work. + const failedRef = useRef(false); + + useEffect(() => { + const clearTimer = () => { + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = null; + }; + const hold = (held: boolean) => { + if (heldRef.current === held) return; + heldRef.current = held; + holdConnectTimeout(held); + }; + const showError = (requestId: unknown, error: string) => { + clearTimer(); + setDialog((current) => + current + ? { ...current, stage: "error", error } + : { + authUrl: "", + requestId: typeof requestId === "string" ? requestId : "", + stage: "error", + error, + }, + ); + }; + + const unsubscribe = subscribe((message) => { + switch (message.type) { + case "opkssh_auth_required": + hold(true); + if (failedRef.current) { + clearTimer(); + setDialog(null); + hold(false); + fail(t("errors.authFailed")); + } else { + failedRef.current = true; + send("opkssh_start_auth", { hostId: message.hostId }); + } + break; + case "opkssh_status": + if (message.stage === "chooser") { + setDialog({ + authUrl: typeof message.url === "string" ? message.url : "", + requestId: + typeof message.requestId === "string" ? message.requestId : "", + stage: "chooser", + providers: Array.isArray(message.providers) + ? (message.providers as DialogState["providers"]) + : undefined, + }); + clearTimer(); + timerRef.current = setTimeout(() => { + setDialog(null); + hold(false); + disconnect(); + }, DIALOG_TIMEOUT_MS); + } else { + setDialog((current) => + current + ? { ...current, stage: message.stage as SignInStage } + : null, + ); + } + break; + case "opkssh_completed": + clearTimer(); + setDialog(null); + send("opkssh_auth_completed", connectPayload()); + break; + case "opkssh_error": + failedRef.current = true; + showError(message.requestId, String(message.error ?? "")); + break; + case "opkssh_timeout": + failedRef.current = true; + showError(message.requestId, t("errors.timeout")); + break; + case "opkssh_config_error": + showError( + message.requestId, + String(message.instructions || message.error || ""), + ); + break; + case "connected": + case "sessionAttached": + failedRef.current = false; + hold(false); + break; + case "session_closed": + clearTimer(); + setDialog(null); + hold(false); + break; + } + }); + + return () => { + unsubscribe(); + clearTimer(); + if (heldRef.current) { + heldRef.current = false; + holdConnectTimeout(false); + } + }; + }, [ + subscribe, + holdConnectTimeout, + fail, + disconnect, + send, + connectPayload, + t, + ]); + + if (!dialog) return null; + + const close = () => { + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = null; + if (dialog.requestId) { + send("opkssh_cancel", { requestId: dialog.requestId }); + } + setDialog(null); + if (heldRef.current) { + heldRef.current = false; + holdConnectTimeout(false); + } + }; + + return ( + { + window.open(dialog.authUrl, "_blank"); + }} + onSelectProvider={(alias) => { + if (!dialog.authUrl) return; + window.open( + `${dialog.authUrl}/select?op=${encodeURIComponent(alias)}`, + "_blank", + ); + setDialog((current) => + current ? { ...current, stage: "waiting" } : null, + ); + }} + backgroundColor={backgroundColor} + /> + ); +} diff --git a/plugins/opkssh/src/frontend/RedirectUriSetting.tsx b/plugins/opkssh/src/frontend/RedirectUriSetting.tsx new file mode 100644 index 000000000..73ffbc014 --- /dev/null +++ b/plugins/opkssh/src/frontend/RedirectUriSetting.tsx @@ -0,0 +1,54 @@ +import { toast } from "sonner"; +import { + useTranslation, + type SettingsComponentProps, +} from "@termix/plugin-sdk/frontend"; +import { Button, Input, copyToClipboard } from "@termix/plugin-sdk/ui"; + +const CALLBACK_PATH = "plugin-api/opkssh/callback"; +const LEGACY_CALLBACK_PATH = "host/opkssh-callback"; + +/** The public callback URI to register with the identity provider. */ +export function redirectUri( + legacy: boolean, + baseUri = document.baseURI, +): string { + return new URL( + legacy ? LEGACY_CALLBACK_PATH : CALLBACK_PATH, + baseUri, + ).toString(); +} + +export function RedirectUriSetting({ values }: SettingsComponentProps) { + const { t } = useTranslation(); + const uri = redirectUri(values.legacyCallback === true); + + return ( +
+ + {t("settings.redirectUri.label")} + +
+ + +
+

+ {t("settings.redirectUri.description")} +

+
+ ); +} diff --git a/plugins/opkssh/src/frontend/index.tsx b/plugins/opkssh/src/frontend/index.tsx new file mode 100644 index 000000000..eb8b4542c --- /dev/null +++ b/plugins/opkssh/src/frontend/index.tsx @@ -0,0 +1,25 @@ +import type { ComponentType } from "react"; +import type { TermixApp } from "@termix/plugin-sdk/frontend"; +import { OpksshAuthEditor } from "./OpksshAuthEditor"; +import { OpksshOverlay } from "./OpksshOverlay"; +import { RedirectUriSetting } from "./RedirectUriSetting"; + +export function activate(app: TermixApp): void { + app.registerSshAuthEditor({ + authType: "opkssh", + titleKey: "hosts.filterAuthOpkssh", + component: OpksshAuthEditor, + }); + + // The browser sign-in while a terminal connects to an OPKSSH host. + app.registerSlotContribution("terminal.overlay", { + actionId: "opkssh.signIn", + titleKey: "dialog.title", + kind: "component", + component: OpksshOverlay as unknown as ComponentType< + Record + >, + }); + + app.registerSettingsComponent("redirectUri", RedirectUriSetting); +} diff --git a/plugins/opkssh/tests/backend/helpers-pure.test.ts b/plugins/opkssh/tests/backend/helpers-pure.test.ts new file mode 100644 index 000000000..9395b88a1 --- /dev/null +++ b/plugins/opkssh/tests/backend/helpers-pure.test.ts @@ -0,0 +1,110 @@ +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { + binaryName, + binarySpec, + DEFAULT_VERSION, +} from "../../src/backend/binary.js"; +import { + parseProviders, + validateRedirectUris, +} from "../../src/backend/config.js"; +import { + normalizeSelectOpParam, + rewriteOpksshHtml, +} from "../../src/backend/html.js"; + +describe("config", () => { + it("reads providers and strips the issuer scheme", () => { + expect( + parseProviders(`providers: + - alias: google + issuer: https://accounts.google.com + redirect_uris: + - http://localhost:3000/login-callback + - alias: broken +`), + ).toEqual([ + { + alias: "google", + issuer: "accounts.google.com", + redirectUris: ["http://localhost:3000/login-callback"], + }, + ]); + }); + + it("accepts localhost redirect_uris only, naming the public callback", () => { + const callback = "https://termix.test/plugin-api/opkssh/callback"; + expect( + validateRedirectUris( + [ + { + alias: "a", + issuer: "x", + redirectUris: ["http://127.0.0.1:10001/login-callback"], + }, + ], + callback, + ), + ).toEqual({ ok: true }); + const refused = validateRedirectUris( + [{ alias: "a", issuer: "x", redirectUris: [callback] }], + callback, + ); + expect(refused.ok).toBe(false); + expect(refused.ok === false && refused.message).toContain(callback); + }); +}); + +describe("html", () => { + it("sends the page's links through the proxied path", () => { + const html = rewriteOpksshHtml( + 'g
', + "/plugin-api/opkssh/chooser/req-1", + ); + expect(html).toContain( + 'href="/plugin-api/opkssh/chooser/req-1/select?op=google"', + ); + expect(html).toContain('action="/plugin-api/opkssh/chooser/req-1/login"'); + expect(html).toContain(''); + }); + + it("maps a provider alias to the name OPKSSH's chooser uses", () => { + const providers = [{ alias: "work", issuer: "accounts.google.com" }]; + expect(normalizeSelectOpParam("work", providers)).toBe("google"); + expect(normalizeSelectOpParam("google", providers)).toBe("google"); + expect(normalizeSelectOpParam("other", providers)).toBe("other"); + }); +}); + +describe("binary", () => { + it("names the release asset per platform", () => { + expect(binaryName("linux", "x64")).toBe("opkssh-linux-amd64"); + expect(binaryName("darwin", "arm64")).toBe("opkssh-osx-arm64"); + expect(binaryName("win32", "x64")).toBe("opkssh-windows-amd64.exe"); + }); + + it("pins the default release and checks the Docker copy first", () => { + const spec = binarySpec({}, "linux", "x64", "/app"); + expect(spec).toMatchObject({ + name: "opkssh-linux-amd64", + version: DEFAULT_VERSION, + url: `https://github.com/openpubkey/opkssh/releases/download/${DEFAULT_VERSION}/opkssh-linux-amd64`, + prebuilt: [path.join("/app", "opkssh-bundled", "opkssh-linux-amd64")], + }); + expect(spec.sha256).toMatch(/^[0-9a-f]{64}$/); + expect( + binarySpec({ OPKSSH_BUNDLED_DIR: "/srv/opk" }, "linux", "x64").prebuilt, + ).toEqual([path.join("/srv/opk", "opkssh-linux-amd64")]); + }); + + it("needs a checksum for any other release", () => { + expect(() => binarySpec({ OPKSSH_VERSION: "v9" }, "linux", "x64")).toThrow( + /no trusted SHA-256/, + ); + const sha = "a".repeat(64); + expect( + binarySpec({ OPKSSH_VERSION: "v9", OPKSSH_SHA256: sha }, "linux", "x64"), + ).toMatchObject({ version: "v9", sha256: sha }); + }); +}); diff --git a/plugins/opkssh/tests/backend/helpers.ts b/plugins/opkssh/tests/backend/helpers.ts new file mode 100644 index 000000000..4c2396c15 --- /dev/null +++ b/plugins/opkssh/tests/backend/helpers.ts @@ -0,0 +1,193 @@ +import http from "node:http"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import type { AddressInfo } from "node:net"; +import { fileURLToPath } from "node:url"; +import express, { type Router } from "express"; +import { + createFakeProcess, + createMockCtx, + createTestDb, + type FakeProcess, + type MockPluginContext, + type TestDb, +} from "@termix/plugin-sdk/testing"; +import type { + PluginHostSummary, + PluginSshAuthProvider, +} from "@termix/plugin-sdk/backend"; +import type { PluginManifest } from "@termix/plugin-sdk/manifest"; +import manifestJson from "../../manifest.json"; + +export const pluginDir = fileURLToPath(new URL("../..", import.meta.url)); +export const manifest = manifestJson as unknown as PluginManifest; + +/** Core's 2.8 opkssh_tokens, as db/index.ts created it. */ +export const LEGACY_DDL = ` + CREATE TABLE IF NOT EXISTS opkssh_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + host_id INTEGER NOT NULL, + ssh_cert TEXT NOT NULL, + private_key TEXT NOT NULL, + email TEXT, + sub TEXT, + issuer TEXT, + audience TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TEXT NOT NULL, + last_used TEXT, + UNIQUE(user_id, host_id), + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE + ); +`; + +export const PROVIDERS_CONFIG = `providers: + - alias: google + issuer: https://accounts.google.com + client_id: abc + scopes: openid email +`; + +/** A fake terminal socket that records what it was sent. */ +export function fakeSocket() { + const sent: Array> = []; + const closers: Array<() => void> = []; + return { + sent, + socket: { + send: (data: string) => sent.push(JSON.parse(data)), + on: (_event: "close", listener: () => void) => closers.push(listener), + }, + close: () => closers.forEach((listener) => listener()), + }; +} + +export interface TestServer { + db: TestDb; + mock: MockPluginContext; + dataDir: string; + processes: FakeProcess[]; + provider: () => PluginSshAuthProvider; + request: ( + method: string, + path: string, + options?: { user?: string; headers?: Record }, + // Route bodies vary per test; asserting on them is the point. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ) => Promise<{ status: number; body: any; location: string | null }>; + close: () => Promise; +} + +export async function startServer( + options: { + capabilities?: string[]; + hosts?: PluginHostSummary[]; + settings?: Record; + before?: (sqlite: TestDb["sqlite"]) => void; + } = {}, +): Promise { + const db = await createTestDb(pluginDir, { before: options.before }); + db.sqlite + .prepare("INSERT OR IGNORE INTO users (id) VALUES (?)") + .run("user-1"); + db.sqlite.prepare("INSERT OR IGNORE INTO ssh_data (id) VALUES (?)").run(7); + const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "opkssh-")); + const processes: FakeProcess[] = []; + + let router: Router | null = null; + const mock = createMockCtx({ + pluginId: manifest.id, + manifest, + capabilities: options.capabilities ?? manifest.capabilities, + db: db.database, + router: () => (router = express.Router()), + hosts: options.hosts ?? [ + { + id: 7, + userId: "user-1", + name: "box", + ip: "10.0.0.7", + port: 22, + username: "root", + } as PluginHostSummary, + ], + settings: options.settings, + baseUrl: "https://termix.test", + process: { + run: () => { + const child = createFakeProcess(); + processes.push(child); + return child; + }, + ensureBinary: async (spec) => `/opt/${spec.name}`, + }, + }); + // The plugin's data folder, somewhere the test can write. + (mock.ctx as unknown as { files: unknown }).files = { + dataDir: async () => dataDir, + }; + + const { activate } = await import("../../src/backend/index.js"); + await activate(mock.ctx); + + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + mock.setActor(req.header("x-test-user") || undefined); + next(); + }); + app.use((req, res, next) => router!(req, res, next)); + + const server = http.createServer(app); + await new Promise((resolve) => server.listen(0, resolve)); + const { port } = server.address() as AddressInfo; + + return { + db, + mock, + dataDir, + processes, + provider: () => + mock.auth.sshAuthProviders.find( + (provider) => provider.type === "opkssh", + )!, + async request(method, requestPath, { user = "user-1", headers = {} } = {}) { + const response = await fetch(`http://127.0.0.1:${port}${requestPath}`, { + method, + redirect: "manual", + headers: { ...(user ? { "x-test-user": user } : {}), ...headers }, + }); + const text = await response.text(); + let parsed: unknown = null; + try { + parsed = text ? JSON.parse(text) : null; + } catch { + parsed = text; + } + return { + status: response.status, + body: parsed, + location: response.headers.get("location"), + }; + }, + async close() { + await new Promise((resolve) => server.close(() => resolve())); + db.close(); + await fs.rm(dataDir, { recursive: true, force: true }); + }, + }; +} + +export const env = (hostId = 7) => ({ + client: {}, + userId: "user-1", + hostId, + purpose: "terminal", + interactive: true, + log: () => {}, +}); + +export const HOST = { id: 7, ip: "10.0.0.7", port: 22, username: "root" }; diff --git a/plugins/opkssh/tests/backend/opkssh.test.ts b/plugins/opkssh/tests/backend/opkssh.test.ts new file mode 100644 index 000000000..1b7ff22b0 --- /dev/null +++ b/plugins/opkssh/tests/backend/opkssh.test.ts @@ -0,0 +1,341 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { PluginCapabilityError } from "@termix/plugin-sdk/backend"; +import { createMockCtx, createTestDb } from "@termix/plugin-sdk/testing"; +import { + HOST, + LEGACY_DDL, + PROVIDERS_CONFIG, + env, + fakeSocket, + manifest, + pluginDir, + startServer, + type TestServer, +} from "./helpers.js"; + +const certs = vi.hoisted(() => ({ applyCertificateAuth: vi.fn() })); +vi.mock("@termix/plugin-sdk/ssh-certs", () => certs); + +const KEY = `-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAA +-----END OPENSSH PRIVATE KEY-----`; +const CERT = "ssh-ed25519-cert-v01@openssh.com AAAAC3NzaC1lZDI1NTE5"; + +let server: TestServer | null = null; + +beforeEach(() => { + certs.applyCertificateAuth.mockReset(); +}); + +afterEach(async () => { + await server?.close(); + server = null; +}); + +async function writeConfig(content = PROVIDERS_CONFIG) { + await fs.writeFile(path.join(server!.dataDir, "config.yml"), content); +} + +/** Starts a sign-in and returns its request id and what the socket saw. */ +async function startSignIn(origin = "https://termix.test") { + const socket = fakeSocket(); + await server!.provider().startInteraction!({ + userId: "user-1", + hostId: 7, + host: { name: "box", ip: "10.0.0.7", username: "root" }, + socket: socket.socket, + requestOrigin: origin, + payload: {}, + }); + return socket; +} + +describe("adopting opkssh_tokens", () => { + it("keeps the 2.8 rows and the unique index", async () => { + const db = await createTestDb(pluginDir, { + before: (sqlite) => { + sqlite.exec("INSERT INTO users (id) VALUES ('user-1')"); + sqlite.exec("INSERT INTO ssh_data (id) VALUES (7)"); + sqlite.exec(LEGACY_DDL); + sqlite + .prepare( + "INSERT INTO opkssh_tokens (user_id, host_id, ssh_cert, private_key, expires_at) VALUES (?, ?, ?, ?, ?)", + ) + .run("user-1", 7, "cert", "key", "2099-01-01T00:00:00.000Z"); + }, + }); + const rows = db.sqlite.prepare("SELECT * FROM p_opkssh_tokens").all(); + expect(rows).toHaveLength(1); + expect(() => + db.sqlite + .prepare( + "INSERT INTO p_opkssh_tokens (user_id, host_id, ssh_cert, private_key, expires_at) VALUES (?, ?, ?, ?, ?)", + ) + .run("user-1", 7, "c", "k", "2099-01-01T00:00:00.000Z"), + ).toThrow(/UNIQUE/); + expect( + db.sqlite + .prepare("SELECT name FROM sqlite_master WHERE name = 'opkssh_tokens'") + .get(), + ).toBeUndefined(); + db.close(); + }); + + it("creates the table on a fresh install", async () => { + const db = await createTestDb(pluginDir); + expect( + db.sqlite + .prepare( + "SELECT name FROM sqlite_master WHERE name = 'p_opkssh_tokens'", + ) + .get(), + ).toBeTruthy(); + db.close(); + }); +}); + +describe("activate", () => { + it("registers the opkssh auth type and its routes", async () => { + server = await startServer(); + expect(server.provider()).toMatchObject({ + type: "opkssh", + interaction: "opkssh", + supportsBackground: false, + }); + expect(server.mock.httpRouters).toHaveLength(1); + expect(server.mock.binaries[0]?.name).toMatch(/^opkssh-/); + }); + + it.each(["db:own", "auth:provide", "network:serve"])( + "fails closed without %s", + async (capability) => { + const mock = createMockCtx({ + pluginId: manifest.id, + manifest, + capabilities: manifest.capabilities.filter((c) => c !== capability), + db: (await createTestDb(pluginDir)).database, + }); + const { activate } = await import("../../src/backend/index.js"); + await expect(activate(mock.ctx)).rejects.toThrow(PluginCapabilityError); + }, + ); +}); + +describe("the opkssh provider", () => { + it("asks for a sign-in without a cached certificate", async () => { + server = await startServer(); + const outcome = await server.provider().prepare({}, HOST, env()); + expect(outcome).toMatchObject({ + status: "interaction-required", + interaction: "opkssh", + flag: "requiresOPKSSHAuth", + }); + }); + + it("connects with the certificate a sign-in stored", async () => { + server = await startServer(); + await writeConfig(); + await startSignIn(); + server.processes[0].emitStdout(`${KEY}\n${CERT}\n`); + await vi.waitFor(() => + expect( + server!.db.sqlite.prepare("SELECT * FROM p_opkssh_tokens").all(), + ).toHaveLength(1), + ); + + const config: Record = {}; + const outcome = await server.provider().prepare(config, HOST, env()); + expect(outcome).toEqual({ status: "ready" }); + expect(certs.applyCertificateAuth).toHaveBeenCalledWith( + config, + {}, + { privateKey: KEY, certificate: CERT }, + "root", + ); + const row = server.db.sqlite + .prepare("SELECT ssh_cert FROM p_opkssh_tokens") + .get() as { ssh_cert: string }; + expect(row.ssh_cert).not.toContain("cert-v01"); + }); + + it("treats a 2.8 row it cannot unseal as expired", async () => { + server = await startServer({ + before: (sqlite) => { + sqlite.exec("INSERT INTO users (id) VALUES ('user-1')"); + sqlite.exec("INSERT INTO ssh_data (id) VALUES (7)"); + sqlite.exec(LEGACY_DDL); + sqlite + .prepare( + "INSERT INTO opkssh_tokens (user_id, host_id, ssh_cert, private_key, expires_at) VALUES (?, ?, ?, ?, ?)", + ) + .run("user-1", 7, "v2:old", "v2:old", "2099-01-01T00:00:00.000Z"); + }, + }); + const outcome = await server.provider().prepare({}, HOST, env()); + expect(outcome.status).toBe("interaction-required"); + expect( + server.db.sqlite.prepare("SELECT * FROM p_opkssh_tokens").all(), + ).toHaveLength(0); + }); + + it("forgets the certificate when the server refuses it", async () => { + server = await startServer(); + await writeConfig(); + await startSignIn(); + server.processes[0].emitStdout(`${KEY}\n${CERT}\n`); + await vi.waitFor(() => + expect( + server!.db.sqlite.prepare("SELECT * FROM p_opkssh_tokens").all(), + ).toHaveLength(1), + ); + const followUp = server.provider().onAuthFailed!(HOST, env(), { + error: new Error("All configured authentication methods failed"), + retries: 0, + canRetry: true, + methodNotAvailable: false, + }); + expect(followUp).toMatchObject({ status: "interaction-required" }); + await vi.waitFor(() => + expect( + server!.db.sqlite.prepare("SELECT * FROM p_opkssh_tokens").all(), + ).toHaveLength(0), + ); + }); +}); + +describe("the browser sign-in", () => { + it("runs opkssh with the plugin's config and the public callback", async () => { + server = await startServer(); + await writeConfig(); + const socket = await startSignIn(); + const run = server.mock.processRuns[0]; + expect(run.file).toMatch(/opkssh-/); + expect(run.args).toContain( + `--config-path=${path.join(server.dataDir, "config.yml")}`, + ); + expect(run.args).toContain( + "--remote-redirect-uri=https://termix.test/plugin-api/opkssh/callback", + ); + + server.processes[0].emitStderr( + "Opening browser to http://localhost:4567/chooser\n", + ); + const status = socket.sent.find((m) => m.type === "opkssh_status"); + expect(status).toMatchObject({ + stage: "chooser", + url: expect.stringMatching( + /^https:\/\/termix\.test\/plugin-api\/opkssh\/chooser\/[\w-]+$/, + ), + }); + }); + + it("keeps the old callback for an install upgraded from 2.8", async () => { + server = await startServer({ settings: { legacyCallback: true } }); + await writeConfig(); + await startSignIn(); + expect(server.mock.processRuns[0].args).toContain( + "--remote-redirect-uri=https://termix.test/host/opkssh-callback", + ); + }); + + it("writes a template and reports a missing config", async () => { + server = await startServer(); + const socket = await startSignIn(); + expect(socket.sent[0]).toMatchObject({ type: "opkssh_config_error" }); + expect( + await fs.readFile(path.join(server.dataDir, "config.yml"), "utf8"), + ).toContain("OPKSSH Configuration"); + expect(server.mock.processRuns).toHaveLength(0); + }); + + it("refuses redirect_uris that are not localhost", async () => { + server = await startServer(); + await writeConfig( + `${PROVIDERS_CONFIG} redirect_uris:\n - https://termix.example/host/opkssh-callback\n`, + ); + const socket = await startSignIn(); + expect(socket.sent[0]).toMatchObject({ type: "opkssh_config_error" }); + expect(String(socket.sent[0].error)).toMatch(/must only contain localhost/); + }); + + it("kills the process when the terminal goes away", async () => { + server = await startServer(); + await writeConfig(); + const socket = await startSignIn(); + socket.close(); + await vi.waitFor(() => + expect(server!.processes[0].killed).toContain("SIGTERM"), + ); + }); +}); + +describe("routes", () => { + it("reports and forgets a cached certificate", async () => { + server = await startServer(); + expect((await server.request("GET", "/token/7")).body).toEqual({ + exists: false, + }); + await writeConfig(); + await startSignIn(); + server.processes[0].emitStdout(`${KEY}\n${CERT}\n`); + await vi.waitFor(async () => + expect((await server!.request("GET", "/token/7")).body.exists).toBe(true), + ); + expect((await server.request("DELETE", "/token/7")).status).toBe(200); + expect((await server.request("GET", "/token/7")).body.exists).toBe(false); + }); + + it("refuses a host the user cannot reach, and anonymous callers", async () => { + server = await startServer(); + expect((await server.request("GET", "/token/99")).status).toBe(404); + expect((await server.request("GET", "/token/7", { user: "" })).status).toBe( + 401, + ); + }); + + it("forwards the identity provider's callback to the sign-in it belongs to", async () => { + server = await startServer(); + await writeConfig(); + const socket = await startSignIn(); + server.processes[0].emitStderr( + "Opening browser to http://localhost:4567/chooser\n", + ); + const requestId = String( + socket.sent.find((m) => m.type === "opkssh_status")?.requestId, + ); + const cookie = { cookie: `opkssh_request_id=${requestId}` }; + + // OPKSSH's callback listener is not up yet. + expect( + ( + await server.request("GET", "/callback?code=x", { + user: "", + headers: cookie, + }) + ).status, + ).toBe(503); + + server.processes[0].emitStderr("listening on http://127.0.0.1:5555/\n"); + const forwarded = await server.request("GET", "/callback?code=x&state=s", { + user: "", + headers: cookie, + }); + expect(forwarded.status).toBe(302); + expect(forwarded.location).toBe( + `/plugin-api/opkssh/callback/${requestId}?code=x&state=s`, + ); + + expect( + (await server.request("GET", "/callback?code=x", { user: "" })).status, + ).toBe(401); + }); + + it("answers an unknown sign-in with a not-found page", async () => { + server = await startServer(); + const response = await server.request("GET", "/chooser/nope", { user: "" }); + expect(response.status).toBe(404); + expect(String(response.body)).toContain("Session Not Found"); + }); +}); diff --git a/plugins/opkssh/tests/frontend/activate.test.tsx b/plugins/opkssh/tests/frontend/activate.test.tsx new file mode 100644 index 000000000..e9760b226 --- /dev/null +++ b/plugins/opkssh/tests/frontend/activate.test.tsx @@ -0,0 +1,128 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { + renderWithApp, + type RenderedPluginApp, +} from "@termix/plugin-sdk/testing"; +import type { PluginManifest } from "@termix/plugin-sdk/manifest"; +import * as plugin from "../../src/frontend/index"; +import { OpksshOverlay } from "../../src/frontend/OpksshOverlay"; +import { redirectUri } from "../../src/frontend/RedirectUriSetting"; +import manifestJson from "../../manifest.json"; +import locales from "../../locales/en.json"; + +vi.mock("@termix/plugin-sdk/frontend", async (importOriginal) => ({ + ...(await importOriginal()), + useTranslation: () => ({ t: (key: string) => key }), +})); + +const manifest = manifestJson as unknown as PluginManifest; + +let rendered: RenderedPluginApp | null = null; + +afterEach(async () => { + await rendered?.deactivate(); + rendered = null; +}); + +describe("opkssh activate", () => { + it("registers the auth editor, the terminal overlay and the settings field", async () => { + rendered = await renderWithApp(plugin, { manifest, locales }); + expect(rendered.registered.settingsComponents()).toEqual(["redirectUri"]); + expect(rendered.registered.slot("terminal.overlay")).toEqual([ + "opkssh.signIn", + ]); + await rendered.deactivate(); + expect(rendered.registered.settingsComponents()).toEqual([]); + expect(rendered.registered.slot("terminal.overlay")).toEqual([]); + rendered = null; + }); +}); + +describe("redirectUri", () => { + it("names the plugin callback, or the 2.8 one", () => { + expect(redirectUri(false, "https://t.example/app/")).toBe( + "https://t.example/app/plugin-api/opkssh/callback", + ); + expect(redirectUri(true, "https://t.example/")).toBe( + "https://t.example/host/opkssh-callback", + ); + }); +}); + +describe("OpksshOverlay", () => { + function setup() { + let listener: (message: { + type: string; + [key: string]: unknown; + }) => void = () => {}; + const props = { + host: { id: 7 }, + subscribe: vi.fn((next: typeof listener) => { + listener = next; + return () => {}; + }), + holdConnectTimeout: vi.fn(), + fail: vi.fn(), + disconnect: vi.fn(), + send: vi.fn(), + connectPayload: vi.fn(() => ({ hostId: 7, cols: 80, rows: 24 })), + }; + render(); + const emit = (message: { type: string; [key: string]: unknown }) => + act(() => listener(message)); + return { props, emit }; + } + + it("starts the sign-in once, then reports a second request as a failure", () => { + const { props, emit } = setup(); + emit({ type: "opkssh_auth_required", hostId: 7 }); + expect(props.send).toHaveBeenCalledWith("opkssh_start_auth", { hostId: 7 }); + expect(props.holdConnectTimeout).toHaveBeenCalledWith(true); + + emit({ type: "opkssh_auth_required", hostId: 7 }); + expect(props.fail).toHaveBeenCalledWith("errors.authFailed"); + }); + + it("shows the chooser, cancels it, and reconnects once signed in", () => { + const { props, emit } = setup(); + emit({ type: "opkssh_auth_required", hostId: 7 }); + emit({ + type: "opkssh_status", + stage: "chooser", + requestId: "req-1", + url: "https://termix.test/plugin-api/opkssh/chooser/req-1", + providers: [{ alias: "google", issuer: "accounts.google.com" }], + }); + expect(screen.getByText("dialog.title")).toBeTruthy(); + + emit({ type: "opkssh_completed", requestId: "req-1" }); + expect(props.send).toHaveBeenCalledWith("opkssh_auth_completed", { + hostId: 7, + cols: 80, + rows: 24, + }); + expect(screen.queryByText("dialog.title")).toBeNull(); + + emit({ + type: "opkssh_status", + stage: "chooser", + requestId: "req-2", + url: "https://termix.test/plugin-api/opkssh/chooser/req-2", + }); + fireEvent.click(screen.getByText("common.cancel")); + expect(props.send).toHaveBeenCalledWith("opkssh_cancel", { + requestId: "req-2", + }); + }); + + it("shows a config error", () => { + const { emit } = setup(); + emit({ + type: "opkssh_config_error", + requestId: "", + error: "OPKSSH configuration not found.", + }); + expect(screen.getByText("OPKSSH configuration not found.")).toBeTruthy(); + }); +}); diff --git a/plugins/opkssh/tsconfig.json b/plugins/opkssh/tsconfig.json new file mode 100644 index 000000000..7b35314c3 --- /dev/null +++ b/plugins/opkssh/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "@termix/plugin-sdk/tsconfig.plugin.json", + "include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"] +} diff --git a/plugins/opkssh/vitest.config.ts b/plugins/opkssh/vitest.config.ts new file mode 100644 index 000000000..31ba004fc --- /dev/null +++ b/plugins/opkssh/vitest.config.ts @@ -0,0 +1,3 @@ +import { pluginVitestConfig } from "@termix/plugin-sdk/vitest-preset"; + +export default pluginVitestConfig(import.meta.url); diff --git a/plugins/ssh-terminal/locales/en.json b/plugins/ssh-terminal/locales/en.json index f8dd05e08..64440cc63 100644 --- a/plugins/ssh-terminal/locales/en.json +++ b/plugins/ssh-terminal/locales/en.json @@ -40,8 +40,6 @@ "linkDialogTitle": "Open Link", "maxReconnectAttemptsReached": "Maximum reconnection attempts reached", "noHostSelected": "No host selected", - "opksshAuthFailed": "Authentication failed. Please check your credentials and try again.", - "opksshTimeout": "Authentication timed out. Please try again.", "passwordPromptFillTitle": "Fill the saved password into this prompt?", "reconnect": "Reconnect", "reconnected": "Reconnected successfully", diff --git a/plugins/ssh-terminal/locales/translated/af_ZA.json b/plugins/ssh-terminal/locales/translated/af_ZA.json index e1303c2af..74344a3d4 100644 --- a/plugins/ssh-terminal/locales/translated/af_ZA.json +++ b/plugins/ssh-terminal/locales/translated/af_ZA.json @@ -40,8 +40,6 @@ "linkDialogTitle": "Maak skakel oop", "maxReconnectAttemptsReached": "Maksimum herverbindingspogings bereik", "noHostSelected": "Geen gasheer gekies nie", - "opksshAuthFailed": "Verifikasie het misluk. Kontroleer asseblief jou geloofsbriewe en probeer weer.", - "opksshTimeout": "Verifikasie het verstryk. Probeer asseblief weer.", "passwordPromptFillTitle": "Vul die gestoorde wagwoord in hierdie aanwysingsprompt in?", "reconnect": "Herkoppel", "reconnected": "Herverbind suksesvol", diff --git a/plugins/ssh-terminal/locales/translated/ar_SA.json b/plugins/ssh-terminal/locales/translated/ar_SA.json index c5f9ce602..472366f8d 100644 --- a/plugins/ssh-terminal/locales/translated/ar_SA.json +++ b/plugins/ssh-terminal/locales/translated/ar_SA.json @@ -40,8 +40,6 @@ "linkDialogTitle": "فتح الرابط", "maxReconnectAttemptsReached": "تم الوصول إلى الحد الأقصى لمحاولات إعادة الاتصال", "noHostSelected": "لم يتم تحديد مضيف", - "opksshAuthFailed": "فشلت المصادقة. يرجى التحقق من بيانات الاعتماد والمحاولة مرة أخرى.", - "opksshTimeout": "انتهت مهلة المصادقة. يرجى المحاولة مرة أخرى.", "passwordPromptFillTitle": "هل تريد ملء كلمة المرور المحفوظة في هذه النافذة؟", "reconnect": "إعادة الاتصال", "reconnected": "تمت إعادة الاتصال بنجاح", diff --git a/plugins/ssh-terminal/locales/translated/bg_BG.json b/plugins/ssh-terminal/locales/translated/bg_BG.json index ff78a0f2d..d56a7dfc0 100644 --- a/plugins/ssh-terminal/locales/translated/bg_BG.json +++ b/plugins/ssh-terminal/locales/translated/bg_BG.json @@ -40,8 +40,6 @@ "linkDialogTitle": "Отвори линка", "maxReconnectAttemptsReached": "Достигнат е максималният брой опити за повторно свързване", "noHostSelected": "Няма избран хост", - "opksshAuthFailed": "Удостоверяването не бе успешно. Моля, проверете идентификационните си данни и опитайте отново.", - "opksshTimeout": "Времето за изчакване на удостоверяването изтече. Моля, опитайте отново.", "passwordPromptFillTitle": "Да се попълни ли запазената парола в този ред?", "reconnect": "Свържете се отново", "reconnected": "Успешно повторно свързване", diff --git a/plugins/ssh-terminal/locales/translated/bn_BD.json b/plugins/ssh-terminal/locales/translated/bn_BD.json index d5b687e2b..84b03e9db 100644 --- a/plugins/ssh-terminal/locales/translated/bn_BD.json +++ b/plugins/ssh-terminal/locales/translated/bn_BD.json @@ -40,8 +40,6 @@ "linkDialogTitle": "লিঙ্ক খুলুন", "maxReconnectAttemptsReached": "পুনঃসংযোগের সর্বোচ্চ প্রচেষ্টা শেষ হয়েছে", "noHostSelected": "কোন হোস্ট নির্বাচন করা হয়নি", - "opksshAuthFailed": "প্রমাণীকরণ ব্যর্থ হয়েছে। অনুগ্রহ করে আপনার পরিচয়পত্র যাচাই করে আবার চেষ্টা করুন।", - "opksshTimeout": "প্রমাণীকরণের সময়সীমা শেষ হয়ে গেছে। অনুগ্রহ করে আবার চেষ্টা করুন।", "passwordPromptFillTitle": "সংরক্ষিত পাসওয়ার্ডটি এই প্রম্পটে পূরণ করুন?", "reconnect": "পুনরায় সংযোগ করুন", "reconnected": "সফলভাবে পুনরায় সংযুক্ত হয়েছে", diff --git a/plugins/ssh-terminal/locales/translated/ca_ES.json b/plugins/ssh-terminal/locales/translated/ca_ES.json index 7475835af..9762f9887 100644 --- a/plugins/ssh-terminal/locales/translated/ca_ES.json +++ b/plugins/ssh-terminal/locales/translated/ca_ES.json @@ -40,8 +40,6 @@ "linkDialogTitle": "Obre l'enllaç", "maxReconnectAttemptsReached": "S'ha arribat al màxim d'intents de reconnexió", "noHostSelected": "No s'ha seleccionat cap amfitrió", - "opksshAuthFailed": "L'autenticació ha fallat. Si us plau, comproveu les vostres credencials i torneu-ho a intentar.", - "opksshTimeout": "S'ha esgotat el temps d'autenticació. Torna-ho a provar.", "passwordPromptFillTitle": "Voleu introduir la contrasenya desada en aquesta sol·licitud?", "reconnect": "Reconnecta", "reconnected": "S'ha reconnectat correctament", diff --git a/plugins/ssh-terminal/locales/translated/cs_CZ.json b/plugins/ssh-terminal/locales/translated/cs_CZ.json index 6c17fd181..2cfe30e9f 100644 --- a/plugins/ssh-terminal/locales/translated/cs_CZ.json +++ b/plugins/ssh-terminal/locales/translated/cs_CZ.json @@ -40,8 +40,6 @@ "linkDialogTitle": "Otevřít odkaz", "maxReconnectAttemptsReached": "Dosažen maximální počet pokusů o opětovné připojení", "noHostSelected": "Nebyl vybrán žádný hostitel", - "opksshAuthFailed": "Ověření se nezdařilo. Zkontrolujte prosím své přihlašovací údaje a zkuste to znovu.", - "opksshTimeout": "Časový limit ověření vypršel. Zkuste to prosím znovu.", "passwordPromptFillTitle": "Vyplňte uložené heslo do tohoto řádku?", "reconnect": "Znovu se připojte", "reconnected": "Úspěšné opětovné připojení", diff --git a/plugins/ssh-terminal/locales/translated/da_DK.json b/plugins/ssh-terminal/locales/translated/da_DK.json index 3a8bc1725..5b274da4e 100644 --- a/plugins/ssh-terminal/locales/translated/da_DK.json +++ b/plugins/ssh-terminal/locales/translated/da_DK.json @@ -40,8 +40,6 @@ "linkDialogTitle": "Åbn link", "maxReconnectAttemptsReached": "Maksimalt antal forsøg på genoprettelse nået", "noHostSelected": "Ingen vært valgt", - "opksshAuthFailed": "Godkendelse mislykkedes. Tjek dine loginoplysninger, og prøv igen.", - "opksshTimeout": "Godkendelsen udløb. Prøv igen.", "passwordPromptFillTitle": "Udfyld den gemte adgangskode i denne prompt?", "reconnect": "Genopret forbindelse", "reconnected": "Genoprettet forbindelse", diff --git a/plugins/ssh-terminal/locales/translated/de_DE.json b/plugins/ssh-terminal/locales/translated/de_DE.json index ff868dfbb..8c3fce5e3 100644 --- a/plugins/ssh-terminal/locales/translated/de_DE.json +++ b/plugins/ssh-terminal/locales/translated/de_DE.json @@ -40,8 +40,6 @@ "linkDialogTitle": "Link öffnen", "maxReconnectAttemptsReached": "Maximale Wiederverbindungsversuche erreicht", "noHostSelected": "Kein Host ausgewählt", - "opksshAuthFailed": "Authentifizierung fehlgeschlagen. Bitte überprüfen Sie Ihre Anmeldeinformationen und versuchen Sie es erneut.", - "opksshTimeout": "Authentifizierung abgelaufen. Bitte versuchen Sie es erneut.", "passwordPromptFillTitle": "Gespeichertes Passwort in diese Eingabeaufforderung einfügen?", "reconnect": "Erneut verbinden", "reconnected": "Erfolgreich wieder verbunden", diff --git a/plugins/ssh-terminal/locales/translated/el_GR.json b/plugins/ssh-terminal/locales/translated/el_GR.json index 127e370db..e6483b28d 100644 --- a/plugins/ssh-terminal/locales/translated/el_GR.json +++ b/plugins/ssh-terminal/locales/translated/el_GR.json @@ -40,8 +40,6 @@ "linkDialogTitle": "Άνοιγμα συνδέσμου", "maxReconnectAttemptsReached": "Επιτεύχθηκε ο μέγιστος αριθμός προσπαθειών επανασύνδεσης", "noHostSelected": "Δεν έχει επιλεγεί κεντρικός υπολογιστής", - "opksshAuthFailed": "Ο έλεγχος ταυτότητας απέτυχε. Ελέγξτε τα διαπιστευτήριά σας και προσπαθήστε ξανά.", - "opksshTimeout": "Το χρονικό όριο ελέγχου ταυτότητας έληξε. Δοκιμάστε ξανά.", "passwordPromptFillTitle": "Συμπληρώστε τον αποθηκευμένο κωδικό πρόσβασης σε αυτήν την ερώτηση;", "reconnect": "Επανασύνδεση", "reconnected": "Επανασύνδεση με επιτυχία", diff --git a/plugins/ssh-terminal/locales/translated/es_ES.json b/plugins/ssh-terminal/locales/translated/es_ES.json index 2dc0c7c51..35d22f03b 100644 --- a/plugins/ssh-terminal/locales/translated/es_ES.json +++ b/plugins/ssh-terminal/locales/translated/es_ES.json @@ -40,8 +40,6 @@ "linkDialogTitle": "Abrir enlace", "maxReconnectAttemptsReached": "Número máximo de intentos de reconexión alcanzado", "noHostSelected": "Ningún host seleccionado", - "opksshAuthFailed": "La autenticación falló. Revisa tus credenciales e inténtalo de nuevo.", - "opksshTimeout": "La autenticación expiró. Inténtalo de nuevo.", "passwordPromptFillTitle": "¿Rellenar este campo con la contraseña guardada?", "reconnect": "Reconectar", "reconnected": "Reconectado correctamente", diff --git a/plugins/ssh-terminal/locales/translated/fi_FI.json b/plugins/ssh-terminal/locales/translated/fi_FI.json index 8b4b014f1..e0c76fd08 100644 --- a/plugins/ssh-terminal/locales/translated/fi_FI.json +++ b/plugins/ssh-terminal/locales/translated/fi_FI.json @@ -40,8 +40,6 @@ "linkDialogTitle": "Avaa linkki", "maxReconnectAttemptsReached": "Yhteyden muodostamisen uudelleenyritysten enimmäismäärä saavutettu", "noHostSelected": "Ei valittua isäntää", - "opksshAuthFailed": "Todennus epäonnistui. Tarkista tunnistetietosi ja yritä uudelleen.", - "opksshTimeout": "Todennus aikakatkaistiin. Yritä uudelleen.", "passwordPromptFillTitle": "Täytä tallennettu salasana tähän kehotteeseen?", "reconnect": "Yhdistä uudelleen", "reconnected": "Yhdistäminen uudelleen onnistui", diff --git a/plugins/ssh-terminal/locales/translated/fr_FR.json b/plugins/ssh-terminal/locales/translated/fr_FR.json index 86ce76cb7..ad56f84ed 100644 --- a/plugins/ssh-terminal/locales/translated/fr_FR.json +++ b/plugins/ssh-terminal/locales/translated/fr_FR.json @@ -40,8 +40,6 @@ "linkDialogTitle": "Ouvrir le lien", "maxReconnectAttemptsReached": "Nombre maximal de tentatives de reconnexion atteint", "noHostSelected": "Aucun hôte sélectionné", - "opksshAuthFailed": "Échec de l'authentification. Veuillez vérifier vos identifiants et réessayer.", - "opksshTimeout": "L'authentification a expiré. Veuillez réessayer.", "passwordPromptFillTitle": "Remplir ce champ avec le mot de passe enregistré ?", "reconnect": "Reconnecter", "reconnected": "Reconnexion réussie", diff --git a/plugins/ssh-terminal/locales/translated/he_IL.json b/plugins/ssh-terminal/locales/translated/he_IL.json index c834126a3..fdbe0851b 100644 --- a/plugins/ssh-terminal/locales/translated/he_IL.json +++ b/plugins/ssh-terminal/locales/translated/he_IL.json @@ -40,8 +40,6 @@ "linkDialogTitle": "פתח קישור", "maxReconnectAttemptsReached": "הגעת למספר המקסימלי של ניסיונות חיבור מחדש", "noHostSelected": "לא נבחר מארח", - "opksshAuthFailed": "האימות נכשל. אנא בדוק את פרטי הגישה שלך ונסה שוב.", - "opksshTimeout": "הזמן שהוקצב לאימות הסתיים. אנא נסה שוב.", "passwordPromptFillTitle": "למלא את הסיסמה השמורה בשורת הפקודה הזו?", "reconnect": "התחבר מחדש", "reconnected": "התחבר מחדש בהצלחה", diff --git a/plugins/ssh-terminal/locales/translated/hi_IN.json b/plugins/ssh-terminal/locales/translated/hi_IN.json index 4b7d72673..dcb259247 100644 --- a/plugins/ssh-terminal/locales/translated/hi_IN.json +++ b/plugins/ssh-terminal/locales/translated/hi_IN.json @@ -40,8 +40,6 @@ "linkDialogTitle": "लिंक खोलें", "maxReconnectAttemptsReached": "अधिकतम पुनः कनेक्ट प्रयास पूर्ण हो गए", "noHostSelected": "कोई होस्ट चयनित नहीं", - "opksshAuthFailed": "प्रमाणीकरण विफल। कृपया अपने क्रेडेंशियल जाँचें और पुनः प्रयास करें।", - "opksshTimeout": "प्रमाणीकरण का समय समाप्त हो गया। कृपया पुनः प्रयास करें।", "passwordPromptFillTitle": "क्या सहेजे गए पासवर्ड को इस प्रांप्ट में भरें?", "reconnect": "पुनः कनेक्ट करें", "reconnected": "पुनः कनेक्ट हुआ", diff --git a/plugins/ssh-terminal/locales/translated/hu_HU.json b/plugins/ssh-terminal/locales/translated/hu_HU.json index f3a9b86e2..e6cc51646 100644 --- a/plugins/ssh-terminal/locales/translated/hu_HU.json +++ b/plugins/ssh-terminal/locales/translated/hu_HU.json @@ -40,8 +40,6 @@ "linkDialogTitle": "Link megnyitása", "maxReconnectAttemptsReached": "Elérte az újracsatlakozási kísérletek maximális számát", "noHostSelected": "Nincs kiválasztva gazdagép", - "opksshAuthFailed": "Sikertelen hitelesítés. Kérjük, ellenőrizze a hitelesítő adatait, és próbálja újra.", - "opksshTimeout": "A hitelesítés időtúllépést okozott. Kérjük, próbálja újra.", "passwordPromptFillTitle": "Írja be a mentett jelszót ebbe a mezőbe?", "reconnect": "Újracsatlakozás", "reconnected": "Sikeresen újrakapcsolódott", diff --git a/plugins/ssh-terminal/locales/translated/id_ID.json b/plugins/ssh-terminal/locales/translated/id_ID.json index 6aaeddd7c..4552ffcb3 100644 --- a/plugins/ssh-terminal/locales/translated/id_ID.json +++ b/plugins/ssh-terminal/locales/translated/id_ID.json @@ -40,8 +40,6 @@ "linkDialogTitle": "Buka Tautan", "maxReconnectAttemptsReached": "Upaya penyambungan kembali maksimum telah tercapai.", "noHostSelected": "Tidak ada pembawa acara yang dipilih.", - "opksshAuthFailed": "Autentikasi gagal. Harap periksa kredensial Anda dan coba lagi.", - "opksshTimeout": "Autentikasi habis waktu. Silakan coba lagi.", "passwordPromptFillTitle": "Masukkan kata sandi yang tersimpan ke dalam kolom ini?", "reconnect": "Terhubung kembali", "reconnected": "Berhasil terhubung kembali", diff --git a/plugins/ssh-terminal/locales/translated/it_IT.json b/plugins/ssh-terminal/locales/translated/it_IT.json index 818ca57a5..fe7972d2a 100644 --- a/plugins/ssh-terminal/locales/translated/it_IT.json +++ b/plugins/ssh-terminal/locales/translated/it_IT.json @@ -40,8 +40,6 @@ "linkDialogTitle": "Apri collegamento", "maxReconnectAttemptsReached": "Raggiunto il numero massimo di tentativi di riconnessione", "noHostSelected": "Nessun host selezionato", - "opksshAuthFailed": "Autenticazione fallita. Controlla le credenziali e riprova.", - "opksshTimeout": "Autenticazione scaduta. Riprova.", "passwordPromptFillTitle": "Inserire la password salvata in questa richiesta?", "reconnect": "Riconnetti", "reconnected": "Riconnesso con successo", diff --git a/plugins/ssh-terminal/locales/translated/ja_JP.json b/plugins/ssh-terminal/locales/translated/ja_JP.json index 60f1b8568..84e7d9e30 100644 --- a/plugins/ssh-terminal/locales/translated/ja_JP.json +++ b/plugins/ssh-terminal/locales/translated/ja_JP.json @@ -40,8 +40,6 @@ "linkDialogTitle": "リンクを開く", "maxReconnectAttemptsReached": "再接続の最大試行回数に達しました", "noHostSelected": "ホストが選択されていません", - "opksshAuthFailed": "認証に失敗しました。資格情報を確認して再試行してください。", - "opksshTimeout": "認証がタイムアウトしました。もう一度お試しください。", "passwordPromptFillTitle": "保存されたパスワードをこのプロンプトに入力しますか?", "reconnect": "再接続", "reconnected": "再接続完了", diff --git a/plugins/ssh-terminal/locales/translated/ko_KR.json b/plugins/ssh-terminal/locales/translated/ko_KR.json index 9bb4dd713..9dd804185 100644 --- a/plugins/ssh-terminal/locales/translated/ko_KR.json +++ b/plugins/ssh-terminal/locales/translated/ko_KR.json @@ -40,8 +40,6 @@ "linkDialogTitle": "링크 열기", "maxReconnectAttemptsReached": "최대 재연결 시도 횟수에 도달했습니다.", "noHostSelected": "선택된 호스트가 없습니다.", - "opksshAuthFailed": "인증에 실패했습니다. 자격 증명을 확인하고 다시 시도하세요.", - "opksshTimeout": "인증 시간이 초과되었습니다. 다시 시도하세요.", "passwordPromptFillTitle": "저장된 비밀번호를 이 프롬프트에 입력하시겠습니까?", "reconnect": "재연결", "reconnected": "재연결 성공", diff --git a/plugins/ssh-terminal/locales/translated/nl_NL.json b/plugins/ssh-terminal/locales/translated/nl_NL.json index ec20f1d56..b91b759c9 100644 --- a/plugins/ssh-terminal/locales/translated/nl_NL.json +++ b/plugins/ssh-terminal/locales/translated/nl_NL.json @@ -40,8 +40,6 @@ "linkDialogTitle": "Open link", "maxReconnectAttemptsReached": "Maximaal aantal herverbindingspogingen bereikt", "noHostSelected": "Geen host geselecteerd", - "opksshAuthFailed": "Authenticatie mislukt. Controleer uw inloggegevens en probeer het opnieuw.", - "opksshTimeout": "De authenticatie is mislukt vanwege een time-out. Probeer het opnieuw.", "passwordPromptFillTitle": "Voer het opgeslagen wachtwoord in op dit veld.", "reconnect": "Opnieuw verbinding maken", "reconnected": "De verbinding is succesvol hersteld.", diff --git a/plugins/ssh-terminal/locales/translated/no_NO.json b/plugins/ssh-terminal/locales/translated/no_NO.json index 4477569d4..2be596ebf 100644 --- a/plugins/ssh-terminal/locales/translated/no_NO.json +++ b/plugins/ssh-terminal/locales/translated/no_NO.json @@ -40,8 +40,6 @@ "linkDialogTitle": "Åpne lenke", "maxReconnectAttemptsReached": "Maksimalt antall forsøk på å koble til igjen er nådd", "noHostSelected": "Ingen vert valgt", - "opksshAuthFailed": "Autentiseringen mislyktes. Sjekk legitimasjonen din og prøv på nytt.", - "opksshTimeout": "Autentiseringen ble tidsavbrutt. Prøv på nytt.", "passwordPromptFillTitle": "Skriv inn det lagrede passordet i denne meldingen?", "reconnect": "Koble til igjen", "reconnected": "Tilkoblet på nytt", diff --git a/plugins/ssh-terminal/locales/translated/pl_PL.json b/plugins/ssh-terminal/locales/translated/pl_PL.json index 681abbd68..9faf1fadf 100644 --- a/plugins/ssh-terminal/locales/translated/pl_PL.json +++ b/plugins/ssh-terminal/locales/translated/pl_PL.json @@ -40,8 +40,6 @@ "linkDialogTitle": "Otwórz link", "maxReconnectAttemptsReached": "Osiągnięto maksymalną liczbę prób ponownego połączenia", "noHostSelected": "Nie wybrano hosta", - "opksshAuthFailed": "Uwierzytelnienie nie powiodło się. Sprawdź poświadczenia i spróbuj ponownie.", - "opksshTimeout": "Upłynął limit czasu uwierzytelniania. Spróbuj ponownie.", "passwordPromptFillTitle": "Czy wpisać zapisane hasło w tym monicie?", "reconnect": "Połącz ponownie", "reconnected": "Połączono ponownie pomyślnie", diff --git a/plugins/ssh-terminal/locales/translated/pt_BR.json b/plugins/ssh-terminal/locales/translated/pt_BR.json index 1b8c1a56a..914607d3e 100644 --- a/plugins/ssh-terminal/locales/translated/pt_BR.json +++ b/plugins/ssh-terminal/locales/translated/pt_BR.json @@ -40,8 +40,6 @@ "linkDialogTitle": "Abrir link", "maxReconnectAttemptsReached": "Número máximo de tentativas de reconexão atingido", "noHostSelected": "Nenhum host selecionado", - "opksshAuthFailed": "A autenticação falhou. Verifique suas credenciais e tente novamente.", - "opksshTimeout": "A autenticação expirou. Tente novamente.", "passwordPromptFillTitle": "Digite a senha salva neste campo?", "reconnect": "Reconectar", "reconnected": "Reconectado com sucesso", diff --git a/plugins/ssh-terminal/locales/translated/pt_PT.json b/plugins/ssh-terminal/locales/translated/pt_PT.json index 7be1ab9b2..abee07b33 100644 --- a/plugins/ssh-terminal/locales/translated/pt_PT.json +++ b/plugins/ssh-terminal/locales/translated/pt_PT.json @@ -40,8 +40,6 @@ "linkDialogTitle": "Abrir Ligação", "maxReconnectAttemptsReached": "Número máximo de tentativas de restabelecimento de ligação atingido", "noHostSelected": "Nenhum host selecionado", - "opksshAuthFailed": "A autenticação falhou. Verifique as suas credenciais e tente novamente.", - "opksshTimeout": "A autenticação expirou. Tente novamente.", "passwordPromptFillTitle": "Preencher a palavra-passe guardada neste pedido?", "reconnect": "Reconectar", "reconnected": "Ligação restabelecida com sucesso", diff --git a/plugins/ssh-terminal/locales/translated/ro_RO.json b/plugins/ssh-terminal/locales/translated/ro_RO.json index 6f0b45195..3fd28d6c4 100644 --- a/plugins/ssh-terminal/locales/translated/ro_RO.json +++ b/plugins/ssh-terminal/locales/translated/ro_RO.json @@ -40,8 +40,6 @@ "linkDialogTitle": "Deschideți linkul", "maxReconnectAttemptsReached": "S-a atins numărul maxim de încercări de reconectare", "noHostSelected": "Nicio gazdă selectată", - "opksshAuthFailed": "Autentificarea a eșuat. Verificați acreditările și încercați din nou.", - "opksshTimeout": "Autentificarea a expirat. Vă rugăm să încercați din nou.", "passwordPromptFillTitle": "Introduceți parola salvată în această solicitare?", "reconnect": "Reconectați-vă", "reconnected": "Reconectare cu succes", diff --git a/plugins/ssh-terminal/locales/translated/ru_RU.json b/plugins/ssh-terminal/locales/translated/ru_RU.json index 0e53456fb..0136d34ac 100644 --- a/plugins/ssh-terminal/locales/translated/ru_RU.json +++ b/plugins/ssh-terminal/locales/translated/ru_RU.json @@ -40,8 +40,6 @@ "linkDialogTitle": "Открыть ссылку", "maxReconnectAttemptsReached": "Достигнуто максимальное количество попыток переподключения", "noHostSelected": "Не выбран хост", - "opksshAuthFailed": "Аутентификация не удалась. Проверьте учётные данные и попробуйте снова.", - "opksshTimeout": "Время аутентификации истекло. Попробуйте снова.", "passwordPromptFillTitle": "Вставить сохраненный пароль в этот запрос?", "reconnect": "Переподключиться", "reconnected": "Успешно переподключено", diff --git a/plugins/ssh-terminal/locales/translated/sr_SP.json b/plugins/ssh-terminal/locales/translated/sr_SP.json index 937a89efd..655d8ab56 100644 --- a/plugins/ssh-terminal/locales/translated/sr_SP.json +++ b/plugins/ssh-terminal/locales/translated/sr_SP.json @@ -40,8 +40,6 @@ "linkDialogTitle": "Отвори линк", "maxReconnectAttemptsReached": "Достигнут је максималан број покушаја поновног повезивања", "noHostSelected": "Није изабран домаћин", - "opksshAuthFailed": "Аутентификација није успела. Проверите своје акредитиве и покушајте поново.", - "opksshTimeout": "Временско ограничење за аутентификацију је истекло. Молимо покушајте поново.", "passwordPromptFillTitle": "Унесите сачувану лозинку у овај промпт?", "reconnect": "Поново се повежите", "reconnected": "Поново повезано успешно", diff --git a/plugins/ssh-terminal/locales/translated/sv_SE.json b/plugins/ssh-terminal/locales/translated/sv_SE.json index ebcb6aed2..6ccfc623d 100644 --- a/plugins/ssh-terminal/locales/translated/sv_SE.json +++ b/plugins/ssh-terminal/locales/translated/sv_SE.json @@ -40,8 +40,6 @@ "linkDialogTitle": "Öppna länk", "maxReconnectAttemptsReached": "Maximalt antal återanslutningsförsök uppnådda", "noHostSelected": "Ingen värd vald", - "opksshAuthFailed": "Autentiseringen misslyckades. Kontrollera dina inloggningsuppgifter och försök igen.", - "opksshTimeout": "Autentiseringen har gått ut. Försök igen.", "passwordPromptFillTitle": "Fyll i det sparade lösenordet i den här frågan?", "reconnect": "Återanslut", "reconnected": "Återansluten lyckades", diff --git a/plugins/ssh-terminal/locales/translated/th_TH.json b/plugins/ssh-terminal/locales/translated/th_TH.json index 9fbb74767..52a389635 100644 --- a/plugins/ssh-terminal/locales/translated/th_TH.json +++ b/plugins/ssh-terminal/locales/translated/th_TH.json @@ -40,8 +40,6 @@ "linkDialogTitle": "เปิดลิงก์", "maxReconnectAttemptsReached": "จำนวนครั้งการเชื่อมต่อใหม่สูงสุดครบแล้ว", "noHostSelected": "ไม่ได้เลือกโฮสต์", - "opksshAuthFailed": "การตรวจสอบสิทธิ์ล้มเหลว โปรดตรวจสอบข้อมูลประจำตัวของคุณและลองอีกครั้ง", - "opksshTimeout": "การตรวจสอบสิทธิ์หมดเวลา โปรดลองอีกครั้ง", "passwordPromptFillTitle": "กรอกรหัสผ่านที่บันทึกไว้ลงในช่องนี้ใช่หรือไม่?", "reconnect": "เชื่อมต่อใหม่", "reconnected": "เชื่อมต่อสำเร็จแล้ว", diff --git a/plugins/ssh-terminal/locales/translated/tr_TR.json b/plugins/ssh-terminal/locales/translated/tr_TR.json index 083c21dea..55eff8eb4 100644 --- a/plugins/ssh-terminal/locales/translated/tr_TR.json +++ b/plugins/ssh-terminal/locales/translated/tr_TR.json @@ -40,8 +40,6 @@ "linkDialogTitle": "Bağlantıyı Aç", "maxReconnectAttemptsReached": "Maksimum yeniden bağlanma denemesine ulaşıldı", "noHostSelected": "Hiçbir sunucu seçilmedi", - "opksshAuthFailed": "Kimlik doğrulaması başarısız oldu. Lütfen kimlik bilgilerinizi kontrol edip tekrar deneyin.", - "opksshTimeout": "Kimlik doğrulaması zaman aşımına uğradı. Lütfen tekrar deneyin.", "passwordPromptFillTitle": "Kayıtlı parola bu isteme doldurulsun mu?", "reconnect": "Yeniden Bağlan", "reconnected": "Başarıyla yeniden bağlandı", diff --git a/plugins/ssh-terminal/locales/translated/uk_UA.json b/plugins/ssh-terminal/locales/translated/uk_UA.json index cac5de71c..8ba161377 100644 --- a/plugins/ssh-terminal/locales/translated/uk_UA.json +++ b/plugins/ssh-terminal/locales/translated/uk_UA.json @@ -40,8 +40,6 @@ "linkDialogTitle": "Відкрити посилання", "maxReconnectAttemptsReached": "Досягнуто максимальної кількості спроб повторного підключення", "noHostSelected": "Хост не вибрано", - "opksshAuthFailed": "Помилка автентифікації. Перевірте свої облікові дані та спробуйте ще раз.", - "opksshTimeout": "Час очікування автентифікації минув. Спробуйте ще раз.", "passwordPromptFillTitle": "Введіть збережений пароль у цей запит?", "reconnect": "Знову підключитися", "reconnected": "Успішно відновлено підключення", diff --git a/plugins/ssh-terminal/locales/translated/vi_VN.json b/plugins/ssh-terminal/locales/translated/vi_VN.json index 5f6c04b72..440c2bcc9 100644 --- a/plugins/ssh-terminal/locales/translated/vi_VN.json +++ b/plugins/ssh-terminal/locales/translated/vi_VN.json @@ -40,8 +40,6 @@ "linkDialogTitle": "Mở liên kết", "maxReconnectAttemptsReached": "Đã đạt số lần kết nối lại tối đa", "noHostSelected": "Chưa chọn máy chủ", - "opksshAuthFailed": "Xác thực thất bại. Vui lòng kiểm tra thông tin đăng nhập và thử lại.", - "opksshTimeout": "Xác thực đã hết thời gian chờ. Vui lòng thử lại.", "passwordPromptFillTitle": "Điền mật khẩu đã lưu vào dòng nhắc này?", "reconnect": "Kết nối lại", "reconnected": "Đã kết nối lại thành công", diff --git a/plugins/ssh-terminal/locales/translated/zh_CN.json b/plugins/ssh-terminal/locales/translated/zh_CN.json index bc2299e8e..4a63b38ca 100644 --- a/plugins/ssh-terminal/locales/translated/zh_CN.json +++ b/plugins/ssh-terminal/locales/translated/zh_CN.json @@ -40,8 +40,6 @@ "linkDialogTitle": "打开链接", "maxReconnectAttemptsReached": "已达到最大重连尝试次数", "noHostSelected": "未选择主机", - "opksshAuthFailed": "认证失败,请检查凭据并重试。", - "opksshTimeout": "认证超时,请重试。", "passwordPromptFillTitle": "将保存的密码填入此提示?", "reconnect": "重新连接", "reconnected": "重新连接成功", diff --git a/plugins/ssh-terminal/locales/translated/zh_TW.json b/plugins/ssh-terminal/locales/translated/zh_TW.json index c9e27cdec..63cda0985 100644 --- a/plugins/ssh-terminal/locales/translated/zh_TW.json +++ b/plugins/ssh-terminal/locales/translated/zh_TW.json @@ -40,8 +40,6 @@ "linkDialogTitle": "開啟連結", "maxReconnectAttemptsReached": "已達最大重新連線嘗試次數", "noHostSelected": "未選取主機", - "opksshAuthFailed": "驗證失敗。請檢查您的憑證並再試一次。", - "opksshTimeout": "驗證逾時。請再試一次。", "passwordPromptFillTitle": "將已儲存的密碼填入此提示?", "reconnect": "重新連線", "reconnected": "已成功重新連線", diff --git a/plugins/ssh-terminal/src/backend/keyboard-prompt.ts b/plugins/ssh-terminal/src/backend/keyboard-prompt.ts index 95f0054c2..5408fc944 100644 --- a/plugins/ssh-terminal/src/backend/keyboard-prompt.ts +++ b/plugins/ssh-terminal/src/backend/keyboard-prompt.ts @@ -3,12 +3,13 @@ * * Core's classifier (ctx.ssh.classifyKeyboardInteractive) decides what a * round is; this keeps the terminal's message protocol (totp_required, - * totp_retry, password_required, warpgate_auth_required) and its timeouts - * exactly as the client expects. + * totp_retry, password_required, "_auth_required" for a browser + * sign-in round) and its timeouts exactly as the client expects. */ import type { WebSocket } from "ws"; import type { + PluginBrowserSignInRound, PluginKeyboardInteractivePrompt, PluginSsh, PluginSshHost, @@ -40,7 +41,6 @@ interface ResolvedCredentials { } interface HostFlags { - useWarpgate?: boolean; [key: string]: unknown; } @@ -54,15 +54,16 @@ interface AuthContext { keyboardInteractiveResponded: boolean; keyboardInteractiveFinish: ((responses: string[]) => void) | null; totpPromptSent: boolean; - warpgateAuthPromptSent: boolean; + /** The handler whose browser round is waiting for "_auth_continue". */ + browserSignInId: string | null; totpTimeout: NodeJS.Timeout | null; - warpgateAuthTimeout: NodeJS.Timeout | null; + browserSignInTimeout: NodeJS.Timeout | null; totpAttempts: number; } const TOTP_TIMEOUT_MS = 180000; const PUSH_TIMEOUT_MS = 300000; -const WARPGATE_TIMEOUT_MS = 300000; +const BROWSER_SIGN_IN_TIMEOUT_MS = 300000; export class SSHAuthManager { public context: AuthContext; @@ -101,8 +102,8 @@ export class SSHAuthManager { case "auto": finish(decision.responses); return; - case "warpgate": - this.handleWarpgate(decision, finish); + case "browser": + this.handleBrowserSignIn(decision, finish); return; case "totp": this.handleTotp( @@ -124,40 +125,48 @@ export class SSHAuthManager { } } - private handleWarpgate( - decision: { url: string | null; securityKey: string; instructions: string }, + /** + * A round finished in a browser. The message is named after the handler + * that claimed it (Termix-Mobile listens for these names), and the client + * answers with "_auth_continue". + */ + private handleBrowserSignIn( + round: PluginBrowserSignInRound, finish: (responses: string[]) => void, ): void { this.context.keyboardInteractiveFinish = () => { finish([""]); }; - this.context.warpgateAuthPromptSent = true; - this.sendLog("auth", "info", "Warpgate authentication required"); + this.context.browserSignInId = round.id; + this.sendLog("auth", "info", `${round.label} sign-in required`); this.context.ws.send( JSON.stringify({ - type: "warpgate_auth_required", - url: decision.url, - securityKey: decision.securityKey, - instructions: decision.instructions, + type: `${round.id}_auth_required`, + kind: "browser", + label: round.label, + url: round.url, + securityKey: round.code, + instructions: round.instructions, }), ); - this.context.warpgateAuthTimeout = setTimeout(() => { + this.context.browserSignInTimeout = setTimeout(() => { if (this.context.keyboardInteractiveFinish) { this.context.keyboardInteractiveFinish = null; - this.context.warpgateAuthPromptSent = false; - this.context.log.warn("Warpgate authentication timeout", { - operation: "warpgate_timeout", + this.context.browserSignInId = null; + this.context.log.warn("Browser sign-in timeout", { + operation: "browser_sign_in_timeout", + handler: round.id, hostId: this.context.hostId, }); this.context.ws.send( JSON.stringify({ type: "error", - message: "Warpgate authentication timeout. Please reconnect.", + message: `${round.label} sign-in timed out. Please reconnect.`, }), ); } - }, WARPGATE_TIMEOUT_MS); + }, BROWSER_SIGN_IN_TIMEOUT_MS); } private handleTotp( @@ -289,13 +298,13 @@ export class SSHAuthManager { clearTimeout(this.context.totpTimeout); this.context.totpTimeout = null; } - if (this.context.warpgateAuthTimeout) { - clearTimeout(this.context.warpgateAuthTimeout); - this.context.warpgateAuthTimeout = null; + if (this.context.browserSignInTimeout) { + clearTimeout(this.context.browserSignInTimeout); + this.context.browserSignInTimeout = null; } this.context.keyboardInteractiveFinish = null; this.context.totpPromptSent = false; - this.context.warpgateAuthPromptSent = false; + this.context.browserSignInId = null; this.context.keyboardInteractiveResponded = false; } } diff --git a/plugins/ssh-terminal/src/backend/terminal-socket.ts b/plugins/ssh-terminal/src/backend/terminal-socket.ts index cbb3335e9..6435aa57a 100644 --- a/plugins/ssh-terminal/src/backend/terminal-socket.ts +++ b/plugins/ssh-terminal/src/backend/terminal-socket.ts @@ -64,7 +64,6 @@ interface ConnectToHostData { credentialId?: number; userId?: string; forceKeyboardInteractive?: boolean; - useWarpgate?: boolean; jumpHosts?: Array<{ hostId: number }>; useSocks5?: boolean; socks5Host?: string; @@ -341,8 +340,9 @@ export function createTerminalSocket(deps: TerminalSocketDeps) { let isCleaningUp = false; let isShellInitializing = false; let isDuplicateConnDiscarded = false; - let warpgateAuthPromptSent = false; - let warpgateAuthTimeout: NodeJS.Timeout | null = null; + // The keyboard-interactive handler whose browser sign-in is pending. + let browserSignInId: string | null = null; + let browserSignInTimeout: NodeJS.Timeout | null = null; let isAwaitingAuthCredentials = false; let wsAlive = true; @@ -418,7 +418,7 @@ export function createTerminalSocket(deps: TerminalSocketDeps) { keyboardInteractiveResponded = false; keyboardInteractiveFinish = null; totpPromptSent = false; - warpgateAuthPromptSent = false; + browserSignInId = null; } ws.on( @@ -907,19 +907,6 @@ export function createTerminalSocket(deps: TerminalSocketDeps) { break; } - case "warpgate_auth_continue": { - if (keyboardInteractiveFinish) { - if (warpgateAuthTimeout) { - clearTimeout(warpgateAuthTimeout); - warpgateAuthTimeout = null; - } - keyboardInteractiveFinish([""]); - keyboardInteractiveFinish = null; - warpgateAuthPromptSent = false; - } - break; - } - case "reconnect_with_credentials": { const credentialsData = data as { cols: number; @@ -1004,178 +991,6 @@ export function createTerminalSocket(deps: TerminalSocketDeps) { break; } - case "opkssh_start_auth": { - const opksshData = data as { hostId: number }; - try { - await startAuthInteraction("opkssh", opksshData.hostId, data); - } catch (error) { - sshLogger.error("Failed to start OPKSSH auth", error, { - operation: "opkssh_start_auth_error", - userId, - hostId: opksshData.hostId, - }); - ws.send( - JSON.stringify({ - type: "opkssh_error", - requestId: "", - error: - error instanceof PluginSshInteractionError - ? error.message - : "Failed to start OPKSSH authentication", - }), - ); - } - break; - } - - case "opkssh_cancel": { - const cancelData = data as { requestId: string }; - try { - await ctx.ssh.cancelInteraction("opkssh", { - requestId: String(cancelData?.requestId ?? ""), - }); - resetConnectionState(); - } catch (error) { - sshLogger.error("Failed to cancel OPKSSH auth", error, { - operation: "opkssh_cancel_error", - userId, - }); - } - break; - } - - case "opkssh_browser_opened": { - break; - } - - case "opkssh_auth_completed": { - const completedData = data as { - hostId: number; - cols?: number; - rows?: number; - hostConfig?: ConnectToHostData["hostConfig"]; - }; - - resetConnectionState(); - - const reconnectConfig: ConnectToHostData = { - cols: completedData.cols || 80, - rows: completedData.rows || 24, - hostConfig: - completedData.hostConfig || - ({ - id: completedData.hostId, - ip: "", - port: 22, - username: "", - userId, - } as ConnectToHostData["hostConfig"]), - }; - - handleConnectToHost(reconnectConfig).catch((error) => { - sshLogger.error( - "Failed to reconnect after OPKSSH auth", - error, - { - operation: "opkssh_reconnect_error", - userId, - hostId: completedData.hostId, - }, - ); - ws.send( - JSON.stringify({ - type: "error", - message: - "Failed to connect after authentication: " + - getErrorMessage(error), - }), - ); - }); - break; - } - - case "vault_start_auth": { - const vaultData = data as { hostId: number }; - try { - await startAuthInteraction("vault", vaultData.hostId, data); - } catch (error) { - sshLogger.error("Failed to start Vault auth", error, { - operation: "vault_start_auth_error", - userId, - hostId: vaultData.hostId, - }); - ws.send( - JSON.stringify({ - type: "vault_error", - hostId: vaultData.hostId, - error: getErrorMessage( - error, - "Failed to start Vault authentication", - ), - }), - ); - } - break; - } - - case "vault_cancel": { - const cancelData = data as { hostId: number }; - try { - await ctx.ssh.cancelInteraction("vault", { - hostId: cancelData.hostId, - }); - resetConnectionState(); - } catch (error) { - sshLogger.error("Failed to cancel Vault auth", error, { - operation: "vault_cancel_error", - userId, - }); - } - break; - } - - case "vault_auth_completed": { - const completedData = data as { - hostId: number; - cols?: number; - rows?: number; - hostConfig?: ConnectToHostData["hostConfig"]; - }; - - resetConnectionState(); - - const reconnectConfig: ConnectToHostData = { - cols: completedData.cols || 80, - rows: completedData.rows || 24, - hostConfig: - completedData.hostConfig || - ({ - id: completedData.hostId, - ip: "", - port: 22, - username: "", - userId, - } as ConnectToHostData["hostConfig"]), - }; - - handleConnectToHost(reconnectConfig).catch((error) => { - sshLogger.error("Failed to reconnect after Vault auth", error, { - operation: "vault_reconnect_error", - userId, - hostId: completedData.hostId, - }); - ws.send( - JSON.stringify({ - type: "error", - message: - "Failed to connect after authentication: " + - getErrorMessage(error), - }), - ); - }); - break; - } - case "collab_subscribe": { const { roomId } = (data ?? {}) as { roomId?: string }; if (typeof roomId !== "string" || !roomId) break; @@ -1296,6 +1111,7 @@ export function createTerminalSocket(deps: TerminalSocketDeps) { } default: + if (await handleSignInMessage(type, data)) break; sshLogger.warn("Unknown message type received", { operation: "websocket_message_unknown_type", userId, @@ -1340,6 +1156,122 @@ export function createTerminalSocket(deps: TerminalSocketDeps) { }); } + /** + * Messages a sign-in's UI sends: "_start_auth", "_cancel" + * and "_auth_completed" for a provider's browser step, and + * "_auth_continue" for a pending keyboard-interactive browser + * round. False for anything else. + */ + async function handleSignInMessage( + type: string, + data: unknown, + ): Promise { + const match = + /^([a-z0-9-]+)_(start_auth|cancel|auth_completed|auth_continue)$/.exec( + type, + ); + if (!match) return false; + const [, name, action] = match; + const payload = (data ?? {}) as Record; + const hostId = Number(payload.hostId); + + if (action === "start_auth") { + try { + await startAuthInteraction(name, hostId, payload); + } catch (error) { + sshLogger.error("Failed to start sign-in", error, { + operation: "sign_in_start_error", + interaction: name, + userId, + hostId, + }); + ws.send( + JSON.stringify({ + type: `${name}_error`, + requestId: "", + hostId, + error: + error instanceof PluginSshInteractionError + ? error.message + : getErrorMessage(error, "Failed to start authentication"), + }), + ); + } + return true; + } + + if (action === "cancel") { + try { + await ctx.ssh.cancelInteraction(name, { + requestId: + typeof payload.requestId === "string" + ? payload.requestId + : undefined, + hostId: Number.isInteger(hostId) ? hostId : undefined, + }); + resetConnectionState(); + } catch (error) { + sshLogger.error("Failed to cancel sign-in", error, { + operation: "sign_in_cancel_error", + interaction: name, + userId, + }); + } + return true; + } + + if (action === "auth_continue") { + if (name === browserSignInId && keyboardInteractiveFinish) { + if (browserSignInTimeout) { + clearTimeout(browserSignInTimeout); + browserSignInTimeout = null; + } + keyboardInteractiveFinish([""]); + keyboardInteractiveFinish = null; + browserSignInId = null; + } + return true; + } + + const completed = payload as { + hostId: number; + cols?: number; + rows?: number; + hostConfig?: ConnectToHostData["hostConfig"]; + }; + resetConnectionState(); + const reconnectConfig: ConnectToHostData = { + cols: completed.cols || 80, + rows: completed.rows || 24, + hostConfig: + completed.hostConfig || + ({ + id: completed.hostId, + ip: "", + port: 22, + username: "", + userId, + } as ConnectToHostData["hostConfig"]), + }; + handleConnectToHost(reconnectConfig).catch((error) => { + sshLogger.error("Failed to reconnect after sign-in", error, { + operation: "sign_in_reconnect_error", + interaction: name, + userId, + hostId: completed.hostId, + }); + ws.send( + JSON.stringify({ + type: "error", + message: + "Failed to connect after authentication: " + + getErrorMessage(error), + }), + ); + }); + return true; + } + async function handleConnectToHost(data: ConnectToHostData) { const { hostConfig, initialPath, executeCommand, tmuxAttachSession } = data; @@ -2360,7 +2292,7 @@ export function createTerminalSocket(deps: TerminalSocketDeps) { port, username, authType: resolvedCredentials.authType, - warpgateAuthPromptSent, + browserSignInId, isKeyboardInteractive, hasKeyboardInteractiveFinish: !!keyboardInteractiveFinish, keyboardInteractiveResponded, @@ -2680,9 +2612,9 @@ export function createTerminalSocket(deps: TerminalSocketDeps) { keyboardInteractiveResponded, keyboardInteractiveFinish, totpPromptSent, - warpgateAuthPromptSent, + browserSignInId, totpTimeout, - warpgateAuthTimeout, + browserSignInTimeout, totpAttempts: 0, }); @@ -2709,7 +2641,9 @@ export function createTerminalSocket(deps: TerminalSocketDeps) { resolvedCredentials as unknown as Parameters< typeof sshAuthManager.handleKeyboardInteractive >[5], - hostConfig, + // The resolved host carries the plugin host settings that + // keyboard-interactive handlers read. + connectTarget ?? hostConfig, ); isKeyboardInteractive = @@ -2719,10 +2653,9 @@ export function createTerminalSocket(deps: TerminalSocketDeps) { keyboardInteractiveFinish = sshAuthManager.context.keyboardInteractiveFinish; totpPromptSent = sshAuthManager.context.totpPromptSent; - warpgateAuthPromptSent = - sshAuthManager.context.warpgateAuthPromptSent; + browserSignInId = sshAuthManager.context.browserSignInId; totpTimeout = sshAuthManager.context.totpTimeout; - warpgateAuthTimeout = sshAuthManager.context.warpgateAuthTimeout; + browserSignInTimeout = sshAuthManager.context.browserSignInTimeout; }, ), ); @@ -2759,7 +2692,6 @@ export function createTerminalSocket(deps: TerminalSocketDeps) { keyType: resolvedCredentials.keyType, certPublicKey: resolvedCredentials.certPublicKey, forceKeyboardInteractive: hostConfig.forceKeyboardInteractive, - useWarpgate: hostConfig.useWarpgate, terminalConfig: hostConfig.terminalConfig, jumpHosts: hostConfig.jumpHosts, useSocks5: hostConfig.useSocks5, @@ -2946,9 +2878,9 @@ export function createTerminalSocket(deps: TerminalSocketDeps) { totpTimeout = null; } - if (warpgateAuthTimeout) { - clearTimeout(warpgateAuthTimeout); - warpgateAuthTimeout = null; + if (browserSignInTimeout) { + clearTimeout(browserSignInTimeout); + browserSignInTimeout = null; } sshStream = null; diff --git a/plugins/ssh-terminal/src/frontend/terminal/Terminal.tsx b/plugins/ssh-terminal/src/frontend/terminal/Terminal.tsx index ce4646b6c..bdd4168a2 100644 --- a/plugins/ssh-terminal/src/frontend/terminal/Terminal.tsx +++ b/plugins/ssh-terminal/src/frontend/terminal/Terminal.tsx @@ -88,8 +88,7 @@ import { TOTPDialog, SSHAuthDialog, PassphraseDialog, - WarpgateDialog, - OPKSSHDialog, + BrowserSignInDialog, HostKeyVerificationDialog, DEFAULT_TERMINAL_CONFIG, TERMINAL_FONTS, @@ -314,22 +313,15 @@ const TerminalInner = forwardRef( >("no_keyboard"); const [showPassphraseDialog, setShowPassphraseDialog] = useState(false); const [, setKeyboardInteractiveDetected] = useState(false); - const [warpgateAuthRequired, setWarpgateAuthRequired] = useState(false); - const [warpgateAuthUrl, setWarpgateAuthUrl] = useState(""); - const [warpgateSecurityKey, setWarpgateSecurityKey] = useState(""); - const warpgateTimeoutRef = useRef(null); - - const [opksshDialog, setOpksshDialog] = useState<{ - isOpen: boolean; - authUrl: string; - requestId: string; - stage: "chooser" | "waiting" | "authenticating" | "completed" | "error"; - error?: string; - providers?: Array<{ alias: string; issuer: string }>; - /** Which issuer is asking (OPKSSH by default, "Step CA", ...). */ - label?: string; + // A keyboard-interactive round finished in a browser, named after the + // handler that claimed it (see "_auth_required"). + const [browserSignIn, setBrowserSignIn] = useState<{ + id: string; + label: string; + url: string; + code: string; } | null>(null); - const opksshTimeoutRef = useRef(null); + const browserSignInTimeoutRef = useRef(null); // Overlays contributed by plugins (a Tailscale check, say) hear the // session's messages and may hold the connect timeout while they wait. @@ -347,7 +339,6 @@ const TerminalInner = forwardRef( } }; - const opksshFailedRef = useRef(false); const currentHostIdRef = useRef(null); const currentHostConfigRef = useRef(null); @@ -839,8 +830,19 @@ const TerminalInner = forwardRef( webSocketRef.current?.close(); }, disconnect: () => webSocketRef.current?.close(), + send: (type, data) => { + if (webSocketRef.current?.readyState === WebSocket.OPEN) { + webSocketRef.current.send(JSON.stringify({ type, data })); + } + }, + connectPayload: () => ({ + hostId: currentHostIdRef.current, + cols: terminal?.cols || 80, + rows: terminal?.rows || 24, + hostConfig: currentHostConfigRef.current, + }), }), - [host, backgroundColor, updateConnectionError], + [host, backgroundColor, updateConnectionError, terminal], ); const activityLoggingRef = useRef(false); @@ -1087,38 +1089,33 @@ const TerminalInner = forwardRef( if (onClose) onClose(); } - function handleWarpgateContinue() { - if (webSocketRef.current) { - if (warpgateTimeoutRef.current) { - clearTimeout(warpgateTimeoutRef.current); - warpgateTimeoutRef.current = null; - } - webSocketRef.current.send( - JSON.stringify({ - type: "warpgate_auth_continue", - data: {}, - }), - ); - setWarpgateAuthRequired(false); - setWarpgateAuthUrl(""); - setWarpgateSecurityKey(""); + function clearBrowserSignIn() { + if (browserSignInTimeoutRef.current) { + clearTimeout(browserSignInTimeoutRef.current); + browserSignInTimeoutRef.current = null; } + setBrowserSignIn(null); } - function handleWarpgateCancel() { - if (warpgateTimeoutRef.current) { - clearTimeout(warpgateTimeoutRef.current); - warpgateTimeoutRef.current = null; - } - setWarpgateAuthRequired(false); - setWarpgateAuthUrl(""); - setWarpgateSecurityKey(""); + function handleBrowserSignInContinue() { + if (!browserSignIn || !webSocketRef.current) return; + webSocketRef.current.send( + JSON.stringify({ + type: `${browserSignIn.id}_auth_continue`, + data: {}, + }), + ); + clearBrowserSignIn(); + } + + function handleBrowserSignInCancel() { + clearBrowserSignIn(); if (onClose) onClose(); } - function handleWarpgateOpenUrl() { - if (warpgateAuthUrl) { - window.open(warpgateAuthUrl, "_blank", "noopener,noreferrer"); + function handleBrowserSignInOpenUrl() { + if (browserSignIn?.url) { + window.open(browserSignIn.url, "_blank", "noopener,noreferrer"); } } @@ -1356,9 +1353,9 @@ const TerminalInner = forwardRef( clearTimeout(totpTimeoutRef.current); totpTimeoutRef.current = null; } - if (warpgateTimeoutRef.current) { - clearTimeout(warpgateTimeoutRef.current); - warpgateTimeoutRef.current = null; + if (browserSignInTimeoutRef.current) { + clearTimeout(browserSignInTimeoutRef.current); + browserSignInTimeoutRef.current = null; } if (webSocketRef.current?.readyState === WebSocket.OPEN) { webSocketRef.current.send(JSON.stringify({ type: "disconnect" })); @@ -1912,7 +1909,6 @@ const TerminalInner = forwardRef( updateConnectionError(errorMessage); setIsConnecting(false); } else if (msg.type === "connected") { - opksshFailedRef.current = false; vaultFailedRef.current = false; wasConnectedRef.current = true; setIsConnected(true); @@ -2084,51 +2080,30 @@ const TerminalInner = forwardRef( }, isPush ? 300000 : 180000, ); - } else if (msg.type === "warpgate_auth_required") { - setWarpgateAuthRequired(true); - setWarpgateAuthUrl(msg.url || ""); - setWarpgateSecurityKey(msg.securityKey || "N/A"); + } else if ( + msg.kind === "browser" && + typeof msg.type === "string" && + msg.type.endsWith("_auth_required") + ) { + setBrowserSignIn({ + id: msg.type.slice(0, -"_auth_required".length), + label: typeof msg.label === "string" ? msg.label : "", + url: msg.url || "", + code: msg.securityKey || "", + }); if (connectionTimeoutRef.current) { clearTimeout(connectionTimeoutRef.current); connectionTimeoutRef.current = null; } - if (warpgateTimeoutRef.current) { - clearTimeout(warpgateTimeoutRef.current); + if (browserSignInTimeoutRef.current) { + clearTimeout(browserSignInTimeoutRef.current); } - warpgateTimeoutRef.current = setTimeout(() => { - setWarpgateAuthRequired(false); + browserSignInTimeoutRef.current = setTimeout(() => { + setBrowserSignIn(null); if (webSocketRef.current) { webSocketRef.current.close(); } }, 300000); - } else if (msg.type === "opkssh_auth_required") { - if (connectionTimeoutRef.current) { - clearTimeout(connectionTimeoutRef.current); - connectionTimeoutRef.current = null; - } - if (opksshFailedRef.current) { - setOpksshDialog(null); - if (opksshTimeoutRef.current) { - clearTimeout(opksshTimeoutRef.current); - opksshTimeoutRef.current = null; - } - updateConnectionError(t("terminal.opksshAuthFailed")); - addLog({ - type: "error", - stage: "auth", - message: t("terminal.opksshAuthFailed"), - }); - } else { - opksshFailedRef.current = true; - if (webSocketRef.current) { - webSocketRef.current.send( - JSON.stringify({ - type: "opkssh_start_auth", - data: { hostId: msg.hostId }, - }), - ); - } - } } else if (msg.type === "vault_auth_required") { if (connectionTimeoutRef.current) { clearTimeout(connectionTimeoutRef.current); @@ -2209,98 +2184,6 @@ const TerminalInner = forwardRef( } setVaultDialog({ stage: "error", error: msg.error }); setIsConnecting(false); - } else if (msg.type === "opkssh_status") { - if (connectionErrorRef.current) return; - if (msg.stage === "chooser") { - setOpksshDialog({ - isOpen: true, - authUrl: msg.url || "", - requestId: msg.requestId || "", - stage: "chooser", - providers: msg.providers, - label: typeof msg.label === "string" ? msg.label : undefined, - }); - if (opksshTimeoutRef.current) { - clearTimeout(opksshTimeoutRef.current); - } - opksshTimeoutRef.current = setTimeout(() => { - setOpksshDialog(null); - if (webSocketRef.current) { - webSocketRef.current.close(); - } - }, 300000); - } else { - setOpksshDialog((prev) => - prev ? { ...prev, stage: msg.stage } : null, - ); - } - } else if (msg.type === "opkssh_completed") { - if (opksshTimeoutRef.current) { - clearTimeout(opksshTimeoutRef.current); - opksshTimeoutRef.current = null; - } - setOpksshDialog(null); - if (webSocketRef.current && terminal) { - webSocketRef.current.send( - JSON.stringify({ - type: "opkssh_auth_completed", - data: { - hostId: currentHostIdRef.current, - cols: terminal.cols || 80, - rows: terminal.rows || 24, - hostConfig: currentHostConfigRef.current, - }, - }), - ); - } - } else if (msg.type === "opkssh_error") { - if (connectionErrorRef.current) return; - opksshFailedRef.current = true; - if (opksshDialog) { - setOpksshDialog((prev) => - prev ? { ...prev, stage: "error", error: msg.error } : null, - ); - } else { - setOpksshDialog({ - isOpen: true, - authUrl: "", - requestId: msg.requestId || "", - stage: "error", - error: msg.error, - }); - } - setIsConnecting(false); - } else if (msg.type === "opkssh_timeout") { - if (connectionErrorRef.current) return; - opksshFailedRef.current = true; - if (opksshDialog) { - setOpksshDialog((prev) => - prev - ? { - ...prev, - stage: "error", - error: t("terminal.opksshTimeout"), - } - : null, - ); - } else { - setOpksshDialog({ - isOpen: true, - authUrl: "", - requestId: msg.requestId || "", - stage: "error", - error: t("terminal.opksshTimeout"), - }); - } - setIsConnecting(false); - } else if (msg.type === "opkssh_config_error") { - setOpksshDialog({ - isOpen: true, - authUrl: "", - requestId: msg.requestId || "", - stage: "error", - error: msg.instructions || msg.error, - }); } else if (msg.type === "keyboard_interactive_available") { setKeyboardInteractiveDetected(true); setIsConnecting(false); @@ -2357,7 +2240,6 @@ const TerminalInner = forwardRef( } } else if (msg.type === "sessionAttached") { isAttachingSessionRef.current = false; - opksshFailedRef.current = false; vaultFailedRef.current = false; wasConnectedRef.current = true; setIsConnected(true); @@ -3985,71 +3867,17 @@ const TerminalInner = forwardRef( backgroundColor={backgroundColor} /> - - {opksshDialog?.isOpen && ( - { - if (webSocketRef.current) { - webSocketRef.current.send( - JSON.stringify({ - type: "opkssh_cancel", - data: { requestId: opksshDialog.requestId }, - }), - ); - } - setOpksshDialog(null); - if (opksshTimeoutRef.current) { - clearTimeout(opksshTimeoutRef.current); - opksshTimeoutRef.current = null; - } - }} - onOpenUrl={() => { - window.open(opksshDialog.authUrl, "_blank"); - if (webSocketRef.current) { - webSocketRef.current.send( - JSON.stringify({ - type: "opkssh_browser_opened", - data: { requestId: opksshDialog.requestId }, - }), - ); - } - }} - onSelectProvider={(alias) => { - if (!opksshDialog.authUrl) return; - const selectUrl = `${opksshDialog.authUrl}/select?op=${encodeURIComponent(alias)}`; - window.open(selectUrl, "_blank"); - if (webSocketRef.current) { - webSocketRef.current.send( - JSON.stringify({ - type: "opkssh_browser_opened", - data: { requestId: opksshDialog.requestId }, - }), - ); - } - setOpksshDialog((prev) => - prev ? { ...prev, stage: "waiting" } : null, - ); - }} - backgroundColor={backgroundColor} - /> - )} - void; /** Closes the session's connection. */ disconnect: () => void; + /** Sends a message on the session's socket, as { type, data }. */ + send: (type: string, data?: unknown) => void; + /** + * What the server needs to connect again after a sign-in finished: send it + * as the data of "_auth_completed". + */ + connectPayload: () => Record; } diff --git a/plugins/ssh-terminal/tests/backend/keyboard-prompt.test.ts b/plugins/ssh-terminal/tests/backend/keyboard-prompt.test.ts index d9d832bec..94aebefee 100644 --- a/plugins/ssh-terminal/tests/backend/keyboard-prompt.test.ts +++ b/plugins/ssh-terminal/tests/backend/keyboard-prompt.test.ts @@ -27,11 +27,14 @@ function classify( }, host: PluginSshHost, ): PluginKeyboardInteractiveDecision { - if (/warpgate/i.test(round.name)) { + // Stands in for a plugin's keyboard-interactive handler. + if (/gateway sign-in/i.test(round.name)) { return { - kind: "warpgate", + kind: "browser", + id: "gateway", + label: "Gateway", url: round.instructions.match(/https?:\/\/\S+/)?.[0] ?? null, - securityKey: round.instructions.match(/Security key: (\S+)/)?.[1] ?? "", + code: round.instructions.match(/Security key: (\S+)/)?.[1] ?? "", instructions: round.instructions, }; } @@ -72,9 +75,9 @@ function createManager() { keyboardInteractiveResponded: false, keyboardInteractiveFinish: null, totpPromptSent: false, - warpgateAuthPromptSent: false, + browserSignInId: null, totpTimeout: null, - warpgateAuthTimeout: null, + browserSignInTimeout: null, totpAttempts: 0, }); return { manager, sent }; @@ -258,13 +261,13 @@ describe("SSHAuthManager.handleKeyboardInteractive", () => { expect(finish).toHaveBeenCalledWith(["push"]); }); - it("routes Warpgate prompts to the warpgate flow, not the generic path", () => { + it("sends a claimed browser round under its handler's name", () => { const { manager, sent } = createManager(); const finish = vi.fn(); manager.handleKeyboardInteractive( - "Warpgate Authentication", - "Visit https://warpgate.example.com/auth to continue. Security key: AB12", + "Gateway sign-in", + "Visit https://gateway.example.com/auth to continue. Security key: AB12", "", [{ prompt: "Press enter once done: ", echo: true }], finish, @@ -277,16 +280,19 @@ describe("SSHAuthManager.handleKeyboardInteractive", () => { data: { stage: "auth", level: "info", - message: "Warpgate authentication required", + message: "Gateway sign-in required", }, }, { - type: "warpgate_auth_required", - url: "https://warpgate.example.com/auth", + type: "gateway_auth_required", + kind: "browser", + label: "Gateway", + url: "https://gateway.example.com/auth", securityKey: "AB12", instructions: - "Visit https://warpgate.example.com/auth to continue. Security key: AB12", + "Visit https://gateway.example.com/auth to continue. Security key: AB12", }, ]); + expect(manager.context.browserSignInId).toBe("gateway"); }); }); diff --git a/plugins/warpgate/locales/en.json b/plugins/warpgate/locales/en.json new file mode 100644 index 000000000..abe73d48a --- /dev/null +++ b/plugins/warpgate/locales/en.json @@ -0,0 +1,10 @@ +{ + "settings": { + "host": { + "useWarpgate": { + "label": "Warpgate Gateway", + "description": "This host connects through a Warpgate SSH proxy. Termix will handle the browser-based approval flow automatically after authenticating." + } + } + } +} diff --git a/plugins/warpgate/locales/translated/af_ZA.json b/plugins/warpgate/locales/translated/af_ZA.json new file mode 100644 index 000000000..fb5aeb7b6 --- /dev/null +++ b/plugins/warpgate/locales/translated/af_ZA.json @@ -0,0 +1,10 @@ +{ + "settings": { + "host": { + "useWarpgate": { + "label": "Warpgate-poort", + "description": "Hierdie gasheer koppel deur 'n Warpgate SSH-instaanbediener. Termix sal die blaaiergebaseerde goedkeuringsvloei outomaties hanteer na verifikasie." + } + } + } +} diff --git a/plugins/warpgate/locales/translated/ar_SA.json b/plugins/warpgate/locales/translated/ar_SA.json new file mode 100644 index 000000000..cd28e71db --- /dev/null +++ b/plugins/warpgate/locales/translated/ar_SA.json @@ -0,0 +1,10 @@ +{ + "settings": { + "host": { + "useWarpgate": { + "label": "بوابة Warpgate", + "description": "يتصل هذا المضيف عبر وكيل SSH من Warpgate. سيتولى Termix عملية الموافقة عبر المتصفح تلقائيًا بعد المصادقة." + } + } + } +} diff --git a/plugins/warpgate/locales/translated/bg_BG.json b/plugins/warpgate/locales/translated/bg_BG.json new file mode 100644 index 000000000..e2679957e --- /dev/null +++ b/plugins/warpgate/locales/translated/bg_BG.json @@ -0,0 +1,10 @@ +{ + "settings": { + "host": { + "useWarpgate": { + "label": "Порталът на Уорпгейт", + "description": "Този хост се свързва чрез Warpgate SSH прокси. Termix ще обработи процеса на одобрение, базиран на браузър, автоматично след удостоверяване." + } + } + } +} diff --git a/plugins/warpgate/locales/translated/bn_BD.json b/plugins/warpgate/locales/translated/bn_BD.json new file mode 100644 index 000000000..0ad61eb95 --- /dev/null +++ b/plugins/warpgate/locales/translated/bn_BD.json @@ -0,0 +1,10 @@ +{ + "settings": { + "host": { + "useWarpgate": { + "label": "ওয়ার্পগেট গেটওয়ে", + "description": "এই হোস্টটি একটি ওয়ার্পগেট এসএসএইচ প্রক্সির মাধ্যমে সংযুক্ত হয়। প্রমাণীকরণের পর টার্মিক্স স্বয়ংক্রিয়ভাবে ব্রাউজার-ভিত্তিক অনুমোদন প্রক্রিয়াটি পরিচালনা করবে।" + } + } + } +} diff --git a/plugins/warpgate/locales/translated/ca_ES.json b/plugins/warpgate/locales/translated/ca_ES.json new file mode 100644 index 000000000..2c42158dd --- /dev/null +++ b/plugins/warpgate/locales/translated/ca_ES.json @@ -0,0 +1,10 @@ +{ + "settings": { + "host": { + "useWarpgate": { + "label": "Porta de Warpgate", + "description": "Aquest amfitrió es connecta a través d'un proxy SSH Warpgate. Termix gestionarà automàticament el flux d'aprovació basat en navegador després de l'autenticació." + } + } + } +} diff --git a/plugins/warpgate/locales/translated/cs_CZ.json b/plugins/warpgate/locales/translated/cs_CZ.json new file mode 100644 index 000000000..6d89004d2 --- /dev/null +++ b/plugins/warpgate/locales/translated/cs_CZ.json @@ -0,0 +1,10 @@ +{ + "settings": { + "host": { + "useWarpgate": { + "label": "Brána Warpgate", + "description": "Tento hostitel se připojuje přes proxy Warpgate SSH. Termix po ověření automaticky zpracuje proces schvalování v prohlížeči." + } + } + } +} diff --git a/plugins/warpgate/locales/translated/da_DK.json b/plugins/warpgate/locales/translated/da_DK.json new file mode 100644 index 000000000..30724ff40 --- /dev/null +++ b/plugins/warpgate/locales/translated/da_DK.json @@ -0,0 +1,10 @@ +{ + "settings": { + "host": { + "useWarpgate": { + "label": "Warpgate Gateway", + "description": "Denne vært opretter forbindelse via en Warpgate SSH-proxy. Termix håndterer den browserbaserede godkendelsesproces automatisk efter godkendelse." + } + } + } +} diff --git a/plugins/warpgate/locales/translated/de_DE.json b/plugins/warpgate/locales/translated/de_DE.json new file mode 100644 index 000000000..994c20274 --- /dev/null +++ b/plugins/warpgate/locales/translated/de_DE.json @@ -0,0 +1,10 @@ +{ + "settings": { + "host": { + "useWarpgate": { + "label": "Warpgate Gateway", + "description": "Dieser Host verbindet sich über einen Warpgate SSH-Proxy. Termix übernimmt nach der Authentifizierung automatisch den browserbasierten Genehmigungsablauf." + } + } + } +} diff --git a/plugins/warpgate/locales/translated/el_GR.json b/plugins/warpgate/locales/translated/el_GR.json new file mode 100644 index 000000000..9c9390fb1 --- /dev/null +++ b/plugins/warpgate/locales/translated/el_GR.json @@ -0,0 +1,10 @@ +{ + "settings": { + "host": { + "useWarpgate": { + "label": "Πύλη Warpgate", + "description": "Αυτός ο κεντρικός υπολογιστής συνδέεται μέσω ενός διακομιστή μεσολάβησης Warpgate SSH. Το Termix θα χειριστεί αυτόματα τη ροή έγκρισης που βασίζεται σε πρόγραμμα περιήγησης μετά τον έλεγχο ταυτότητας." + } + } + } +} diff --git a/plugins/warpgate/locales/translated/es_ES.json b/plugins/warpgate/locales/translated/es_ES.json new file mode 100644 index 000000000..f20f2b35d --- /dev/null +++ b/plugins/warpgate/locales/translated/es_ES.json @@ -0,0 +1,10 @@ +{ + "settings": { + "host": { + "useWarpgate": { + "label": "Warpgate Gateway", + "description": "Este host se conecta a través de un proxy SSH de Warpgate. Termix gestionará el flujo de aprobación basado en navegador automáticamente después de autenticarse." + } + } + } +} diff --git a/plugins/warpgate/locales/translated/fi_FI.json b/plugins/warpgate/locales/translated/fi_FI.json new file mode 100644 index 000000000..29b54b86c --- /dev/null +++ b/plugins/warpgate/locales/translated/fi_FI.json @@ -0,0 +1,10 @@ +{ + "settings": { + "host": { + "useWarpgate": { + "label": "Warpgate-yhdyskäytävä", + "description": "Tämä isäntä muodostaa yhteyden Warpgate SSH -välityspalvelimen kautta. Termix käsittelee selainpohjaisen hyväksyntäprosessin automaattisesti todennuksen jälkeen." + } + } + } +} diff --git a/plugins/warpgate/locales/translated/fr_FR.json b/plugins/warpgate/locales/translated/fr_FR.json new file mode 100644 index 000000000..f9afb1919 --- /dev/null +++ b/plugins/warpgate/locales/translated/fr_FR.json @@ -0,0 +1,10 @@ +{ + "settings": { + "host": { + "useWarpgate": { + "label": "Warpgate Gateway", + "description": "Cet hôte se connecte via un proxy SSH Warpgate. Termix gérera automatiquement le flux d'approbation basé sur le navigateur après l'authentification." + } + } + } +} diff --git a/plugins/warpgate/locales/translated/he_IL.json b/plugins/warpgate/locales/translated/he_IL.json new file mode 100644 index 000000000..f387b9b2c --- /dev/null +++ b/plugins/warpgate/locales/translated/he_IL.json @@ -0,0 +1,10 @@ +{ + "settings": { + "host": { + "useWarpgate": { + "label": "שער וורפגייט", + "description": "מארח זה מתחבר דרך פרוקסי Warpgate SSH. Termix יטפל בזרימת האישור מבוססת הדפדפן באופן אוטומטי לאחר האימות." + } + } + } +} diff --git a/plugins/warpgate/locales/translated/hi_IN.json b/plugins/warpgate/locales/translated/hi_IN.json new file mode 100644 index 000000000..5cf1261a5 --- /dev/null +++ b/plugins/warpgate/locales/translated/hi_IN.json @@ -0,0 +1,10 @@ +{ + "settings": { + "host": { + "useWarpgate": { + "label": "Warpgate गेटवे", + "description": "यह होस्ट Warpgate SSH प्रॉक्सी के माध्यम से कनेक्ट होता है। Termix प्रमाणीकरण के बाद ब्राउज़र-आधारित अनुमोदन प्रवाह को स्वचालित रूप से संभालेगा।" + } + } + } +} diff --git a/plugins/warpgate/locales/translated/hu_HU.json b/plugins/warpgate/locales/translated/hu_HU.json new file mode 100644 index 000000000..a9c8dbcc4 --- /dev/null +++ b/plugins/warpgate/locales/translated/hu_HU.json @@ -0,0 +1,10 @@ +{ + "settings": { + "host": { + "useWarpgate": { + "label": "Warpgate átjáró", + "description": "Ez a gazdagép egy Warpgate SSH proxyn keresztül csatlakozik. A Termix a hitelesítés után automatikusan kezeli a böngészőalapú jóváhagyási folyamatot." + } + } + } +} diff --git a/plugins/warpgate/locales/translated/id_ID.json b/plugins/warpgate/locales/translated/id_ID.json new file mode 100644 index 000000000..4e45eac2b --- /dev/null +++ b/plugins/warpgate/locales/translated/id_ID.json @@ -0,0 +1,10 @@ +{ + "settings": { + "host": { + "useWarpgate": { + "label": "Gerbang Warpgate", + "description": "Host ini terhubung melalui proxy SSH Warpgate. Termix akan menangani alur persetujuan berbasis browser secara otomatis setelah otentikasi." + } + } + } +} diff --git a/plugins/warpgate/locales/translated/it_IT.json b/plugins/warpgate/locales/translated/it_IT.json new file mode 100644 index 000000000..c95aed3a5 --- /dev/null +++ b/plugins/warpgate/locales/translated/it_IT.json @@ -0,0 +1,10 @@ +{ + "settings": { + "host": { + "useWarpgate": { + "label": "Warpgate Gateway", + "description": "Questo host si connette tramite un proxy SSH Warpgate. Termix gestirà automaticamente il flusso di approvazione basato su browser dopo l'autenticazione." + } + } + } +} diff --git a/plugins/warpgate/locales/translated/ja_JP.json b/plugins/warpgate/locales/translated/ja_JP.json new file mode 100644 index 000000000..87658172c --- /dev/null +++ b/plugins/warpgate/locales/translated/ja_JP.json @@ -0,0 +1,10 @@ +{ + "settings": { + "host": { + "useWarpgate": { + "label": "Warpgateゲートウェイ", + "description": "このホストはWarpgate SSHプロキシ経由で接続します。認証後、Termixがブラウザベースの承認フローを自動的に処理します。" + } + } + } +} diff --git a/plugins/warpgate/locales/translated/ko_KR.json b/plugins/warpgate/locales/translated/ko_KR.json new file mode 100644 index 000000000..a28d193c0 --- /dev/null +++ b/plugins/warpgate/locales/translated/ko_KR.json @@ -0,0 +1,10 @@ +{ + "settings": { + "host": { + "useWarpgate": { + "label": "Warpgate 게이트웨이", + "description": "이 호스트는 Warpgate SSH 프록시를 통해 연결됩니다. Termix는 인증 후 브라우저 기반 승인 과정을 자동으로 처리합니다." + } + } + } +} diff --git a/plugins/warpgate/locales/translated/nl_NL.json b/plugins/warpgate/locales/translated/nl_NL.json new file mode 100644 index 000000000..4d8443d82 --- /dev/null +++ b/plugins/warpgate/locales/translated/nl_NL.json @@ -0,0 +1,10 @@ +{ + "settings": { + "host": { + "useWarpgate": { + "label": "Warpgate-poort", + "description": "Deze host maakt verbinding via een Warpgate SSH-proxy. Termix handelt de browsergebaseerde goedkeuringsprocedure automatisch af na authenticatie." + } + } + } +} diff --git a/plugins/warpgate/locales/translated/no_NO.json b/plugins/warpgate/locales/translated/no_NO.json new file mode 100644 index 000000000..dbae88451 --- /dev/null +++ b/plugins/warpgate/locales/translated/no_NO.json @@ -0,0 +1,10 @@ +{ + "settings": { + "host": { + "useWarpgate": { + "label": "Warpgate-porten", + "description": "Denne verten kobler seg til via en Warpgate SSH-proxy. Termix håndterer den nettleserbaserte godkjenningsflyten automatisk etter autentisering." + } + } + } +} diff --git a/plugins/warpgate/locales/translated/pl_PL.json b/plugins/warpgate/locales/translated/pl_PL.json new file mode 100644 index 000000000..dbc67d397 --- /dev/null +++ b/plugins/warpgate/locales/translated/pl_PL.json @@ -0,0 +1,10 @@ +{ + "settings": { + "host": { + "useWarpgate": { + "label": "Brama Warpgate", + "description": "Ten host łączy się przez proxy SSH Warpgate. Termix automatycznie obsłuży proces zatwierdzania w przeglądarce po uwierzytelnieniu." + } + } + } +} diff --git a/plugins/warpgate/locales/translated/pt_BR.json b/plugins/warpgate/locales/translated/pt_BR.json new file mode 100644 index 000000000..b5ec96e92 --- /dev/null +++ b/plugins/warpgate/locales/translated/pt_BR.json @@ -0,0 +1,10 @@ +{ + "settings": { + "host": { + "useWarpgate": { + "label": "Portal de Dobra", + "description": "Este host se conecta através de um proxy SSH Warpgate. O Termix cuidará automaticamente do fluxo de aprovação baseado em navegador após a autenticação." + } + } + } +} diff --git a/plugins/warpgate/locales/translated/pt_PT.json b/plugins/warpgate/locales/translated/pt_PT.json new file mode 100644 index 000000000..7efc559f4 --- /dev/null +++ b/plugins/warpgate/locales/translated/pt_PT.json @@ -0,0 +1,10 @@ +{ + "settings": { + "host": { + "useWarpgate": { + "label": "Warpgate Gateway", + "description": "Este anfitrião liga-se através de um proxy SSH Warpgate. O Termix irá gerir automaticamente o fluxo de aprovação baseado no navegador após a autenticação." + } + } + } +} diff --git a/plugins/warpgate/locales/translated/ro_RO.json b/plugins/warpgate/locales/translated/ro_RO.json new file mode 100644 index 000000000..56a632814 --- /dev/null +++ b/plugins/warpgate/locales/translated/ro_RO.json @@ -0,0 +1,10 @@ +{ + "settings": { + "host": { + "useWarpgate": { + "label": "Poarta Warpgate", + "description": "Această gazdă se conectează printr-un proxy SSH Warpgate. Termix va gestiona automat fluxul de aprobare bazat pe browser după autentificare." + } + } + } +} diff --git a/plugins/warpgate/locales/translated/ru_RU.json b/plugins/warpgate/locales/translated/ru_RU.json new file mode 100644 index 000000000..9973251fa --- /dev/null +++ b/plugins/warpgate/locales/translated/ru_RU.json @@ -0,0 +1,10 @@ +{ + "settings": { + "host": { + "useWarpgate": { + "label": "Шлюз Warpgate", + "description": "Этот хост подключается через SSH-прокси Warpgate. Termix автоматически обработает процесс подтверждения в браузере после аутентификации." + } + } + } +} diff --git a/plugins/warpgate/locales/translated/sr_SP.json b/plugins/warpgate/locales/translated/sr_SP.json new file mode 100644 index 000000000..23af8ddc4 --- /dev/null +++ b/plugins/warpgate/locales/translated/sr_SP.json @@ -0,0 +1,10 @@ +{ + "settings": { + "host": { + "useWarpgate": { + "label": "Капија Варпгејта", + "description": "Овај хост се повезује преко Warpgate SSH проксија. Termix ће аутоматски обрадити ток одобрења заснован на прегледачу након аутентификације." + } + } + } +} diff --git a/plugins/warpgate/locales/translated/sv_SE.json b/plugins/warpgate/locales/translated/sv_SE.json new file mode 100644 index 000000000..de960ff35 --- /dev/null +++ b/plugins/warpgate/locales/translated/sv_SE.json @@ -0,0 +1,10 @@ +{ + "settings": { + "host": { + "useWarpgate": { + "label": "Warpgate Gateway", + "description": "Denna värd ansluter via en Warpgate SSH-proxy. Termix hanterar det webbläsarbaserade godkännandeflödet automatiskt efter autentisering." + } + } + } +} diff --git a/plugins/warpgate/locales/translated/th_TH.json b/plugins/warpgate/locales/translated/th_TH.json new file mode 100644 index 000000000..6b4953967 --- /dev/null +++ b/plugins/warpgate/locales/translated/th_TH.json @@ -0,0 +1,10 @@ +{ + "settings": { + "host": { + "useWarpgate": { + "label": "ประตูวาร์ปเกต", + "description": "โฮสต์นี้เชื่อมต่อผ่านพร็อกซี SSH ของ Warpgate Termix จะจัดการขั้นตอนการอนุมัติผ่านเบราว์เซอร์โดยอัตโนมัติหลังจากยืนยันตัวตนแล้ว" + } + } + } +} diff --git a/plugins/warpgate/locales/translated/tr_TR.json b/plugins/warpgate/locales/translated/tr_TR.json new file mode 100644 index 000000000..af7d8ced9 --- /dev/null +++ b/plugins/warpgate/locales/translated/tr_TR.json @@ -0,0 +1,10 @@ +{ + "settings": { + "host": { + "useWarpgate": { + "label": "Warpgate Ağ Geçidi", + "description": "Bu sunucu bir Warpgate SSH proxy'si üzerinden bağlanır. Termix, kimlik doğrulamasından sonra tarayıcı tabanlı onay akışını otomatik olarak yönetir." + } + } + } +} diff --git a/plugins/warpgate/locales/translated/uk_UA.json b/plugins/warpgate/locales/translated/uk_UA.json new file mode 100644 index 000000000..a02a2db3c --- /dev/null +++ b/plugins/warpgate/locales/translated/uk_UA.json @@ -0,0 +1,10 @@ +{ + "settings": { + "host": { + "useWarpgate": { + "label": "Брама Варпгейту", + "description": "Цей хост підключається через проксі-сервер Warpgate SSH. Termix автоматично обробить процес затвердження на основі браузера після автентифікації." + } + } + } +} diff --git a/plugins/warpgate/locales/translated/vi_VN.json b/plugins/warpgate/locales/translated/vi_VN.json new file mode 100644 index 000000000..d5b5309c7 --- /dev/null +++ b/plugins/warpgate/locales/translated/vi_VN.json @@ -0,0 +1,10 @@ +{ + "settings": { + "host": { + "useWarpgate": { + "label": "Cổng Warpgate", + "description": "Máy chủ này kết nối thông qua proxy SSH Warpgate. Termix sẽ tự động xử lý luồng phê duyệt trên trình duyệt sau khi xác thực." + } + } + } +} diff --git a/plugins/warpgate/locales/translated/zh_CN.json b/plugins/warpgate/locales/translated/zh_CN.json new file mode 100644 index 000000000..43dec3974 --- /dev/null +++ b/plugins/warpgate/locales/translated/zh_CN.json @@ -0,0 +1,10 @@ +{ + "settings": { + "host": { + "useWarpgate": { + "label": "Warpgate 网关", + "description": "此主机通过 Warpgate SSH 代理连接。Termix 将在认证后自动处理基于浏览器的审批流程。" + } + } + } +} diff --git a/plugins/warpgate/locales/translated/zh_TW.json b/plugins/warpgate/locales/translated/zh_TW.json new file mode 100644 index 000000000..49fe4b1c3 --- /dev/null +++ b/plugins/warpgate/locales/translated/zh_TW.json @@ -0,0 +1,10 @@ +{ + "settings": { + "host": { + "useWarpgate": { + "label": "Warpgate 閘道", + "description": "此主機透過 Warpgate SSH 代理連線。Termix 會在驗證後自動處理瀏覽器核准流程。" + } + } + } +} diff --git a/plugins/warpgate/manifest.json b/plugins/warpgate/manifest.json new file mode 100644 index 000000000..766430160 --- /dev/null +++ b/plugins/warpgate/manifest.json @@ -0,0 +1,38 @@ +{ + "id": "warpgate", + "name": "Warpgate", + "version": "1.0.0", + "description": "Connect through a Warpgate SSH bastion: its browser approval shows as a sign-in dialog, and the stored password answers its password prompt.", + "author": { + "name": "Termix" + }, + "license": "MIT", + "repository": "https://github.com/Termix-SSH/Termix", + "category": "Access & Security", + "icon": "DoorOpen", + "engine": { + "termix": ">=2.9.0", + "api": "1" + }, + "capabilities": ["auth:provide"], + "contributes": { + "auth": { + "keyboardInteractive": ["warpgate"] + }, + "settings": { + "host": { + "fields": [ + { + "key": "useWarpgate", + "type": "boolean", + "labelKey": "settings.host.useWarpgate.label", + "descriptionKey": "settings.host.useWarpgate.description", + "default": false + } + ] + } + } + }, + "locales": "locales", + "platforms": ["linux", "win32", "darwin"] +} diff --git a/plugins/warpgate/package.json b/plugins/warpgate/package.json new file mode 100644 index 000000000..2d2bef118 --- /dev/null +++ b/plugins/warpgate/package.json @@ -0,0 +1,15 @@ +{ + "name": "@termix-plugin/warpgate", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "build": "termix-plugin build", + "test": "termix-plugin test", + "typecheck": "tsc -p tsconfig.json", + "validate": "termix-plugin validate" + }, + "devDependencies": { + "@termix/plugin-sdk": "*" + } +} diff --git a/plugins/warpgate/src/backend/detect.ts b/plugins/warpgate/src/backend/detect.ts new file mode 100644 index 000000000..10df595e4 --- /dev/null +++ b/plugins/warpgate/src/backend/detect.ts @@ -0,0 +1,37 @@ +import type { + PluginKeyboardInteractiveDetection, + PluginKeyboardInteractivePrompt, +} from "@termix/plugin-sdk/backend"; + +const WARPGATE_PATTERN = /warpgate\s+authentication/i; +const URL_PATTERN = /https?:\/\/[^\s\n]+/i; +const SECURITY_KEY_PATTERN = + /security key[:\s]+([a-z0-9](?:\s+[a-z0-9]){3}|[a-z0-9]{4})/i; + +/** + * Warpgate asks for its browser approval as a keyboard-interactive round + * that names itself and carries the sign-in URL and a security key. + */ +export function detectWarpgateRound(round: { + name: string; + instructions: string; + prompts: PluginKeyboardInteractivePrompt[]; +}): PluginKeyboardInteractiveDetection | null { + const texts = round.prompts.map((prompt) => prompt.prompt); + const isWarpgate = + WARPGATE_PATTERN.test(round.name) || + WARPGATE_PATTERN.test(round.instructions) || + texts.some((text) => WARPGATE_PATTERN.test(text)); + if (!isWarpgate) return null; + + const fullText = `${round.name}\n${round.instructions}\n${texts.join("\n")}`; + const url = fullText.match(URL_PATTERN); + if (!url) return null; + const key = fullText.match(SECURITY_KEY_PATTERN); + return { + kind: "browser", + url: url[0], + code: key ? key[1] : "N/A", + instructions: round.instructions, + }; +} diff --git a/plugins/warpgate/src/backend/host-import.ts b/plugins/warpgate/src/backend/host-import.ts new file mode 100644 index 000000000..3b5960d1a --- /dev/null +++ b/plugins/warpgate/src/backend/host-import.ts @@ -0,0 +1,14 @@ +/** + * Registered as "warpgate.hostImportNormalizer" so core's host import keeps + * the flag. Reads a Termix export's pluginSettings.warpgate first, then the + * useWarpgate host field exports had before 2.9.0. + */ +export function hostImportNormalizer( + raw: Record, +): Record | null { + const pluginSettings = raw.pluginSettings as + Record | undefined> | undefined; + const value = pluginSettings?.warpgate?.useWarpgate ?? raw.useWarpgate; + if (value === undefined || value === null) return null; + return { useWarpgate: value === true || value === 1 || value === "true" }; +} diff --git a/plugins/warpgate/src/backend/index.ts b/plugins/warpgate/src/backend/index.ts new file mode 100644 index 000000000..94a6080d2 --- /dev/null +++ b/plugins/warpgate/src/backend/index.ts @@ -0,0 +1,22 @@ +import type { PluginContext } from "@termix/plugin-sdk/backend"; +import { detectWarpgateRound } from "./detect.js"; +import { hostImportNormalizer } from "./host-import.js"; + +export const HANDLER_ID = "warpgate"; + +export async function activate(ctx: PluginContext) { + ctx.auth.registerKeyboardInteractiveHandler({ + id: HANDLER_ID, + label: "Warpgate", + detect: (round) => detectWarpgateRound(round), + // Warpgate asks for the password before its browser round; answer it + // silently so the only thing the user sees is the sign-in dialog. + autoAnswerPasswords: (_host, settings) => settings.useWarpgate === true, + }); + + ctx.registry.provide("warpgate.hostImportNormalizer", hostImportNormalizer); +} + +export async function deactivate() { + // Registered through ctx, so core removes it. +} diff --git a/plugins/warpgate/src/frontend/index.tsx b/plugins/warpgate/src/frontend/index.tsx new file mode 100644 index 000000000..35faaedb3 --- /dev/null +++ b/plugins/warpgate/src/frontend/index.tsx @@ -0,0 +1,5 @@ +import type { TermixApp } from "@termix/plugin-sdk/frontend"; + +// The host setting is drawn by core's schema form and the sign-in dialog is +// the transports' own, so there is nothing to register. +export function activate(_app: TermixApp): void {} diff --git a/plugins/warpgate/tests/backend/warpgate.test.ts b/plugins/warpgate/tests/backend/warpgate.test.ts new file mode 100644 index 000000000..c672521b6 --- /dev/null +++ b/plugins/warpgate/tests/backend/warpgate.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vitest"; +import { PluginCapabilityError } from "@termix/plugin-sdk/backend"; +import { createMockCtx } from "@termix/plugin-sdk/testing"; +import type { PluginManifest } from "@termix/plugin-sdk/manifest"; +import manifestJson from "../../manifest.json"; +import { activate } from "../../src/backend/index.js"; +import { detectWarpgateRound } from "../../src/backend/detect.js"; +import { hostImportNormalizer } from "../../src/backend/host-import.js"; + +const manifest = manifestJson as unknown as PluginManifest; +const HOST = { id: 1, ip: "10.0.0.1", port: 22, username: "root" }; + +async function register() { + const mock = createMockCtx({ + pluginId: manifest.id, + manifest, + capabilities: manifest.capabilities, + }); + await activate(mock.ctx); + return mock.auth.keyboardInteractiveHandlers[0]; +} + +describe("detectWarpgateRound", () => { + it("claims a Warpgate round with its URL and security key", () => { + expect( + detectWarpgateRound({ + name: "Warpgate authentication", + instructions: + "Open https://gate.example/@warpgate#/login/abc to continue. Security key: a b c d", + prompts: [{ prompt: "Press Enter when done: " }], + }), + ).toEqual({ + kind: "browser", + url: "https://gate.example/@warpgate#/login/abc", + code: "a b c d", + instructions: + "Open https://gate.example/@warpgate#/login/abc to continue. Security key: a b c d", + }); + }); + + it("finds the marker in a prompt too, and says N/A without a key", () => { + expect( + detectWarpgateRound({ + name: "", + instructions: "", + prompts: [ + { prompt: "Warpgate authentication: visit https://gate.example/x" }, + ], + }), + ).toMatchObject({ url: "https://gate.example/x", code: "N/A" }); + }); + + it("leaves other rounds, and a Warpgate round without a URL, alone", () => { + expect( + detectWarpgateRound({ + name: "", + instructions: "", + prompts: [{ prompt: "Verification code:" }], + }), + ).toBeNull(); + expect( + detectWarpgateRound({ + name: "Warpgate authentication", + instructions: "", + prompts: [], + }), + ).toBeNull(); + }); +}); + +describe("the warpgate handler", () => { + it("registers under its id and label", async () => { + const handler = await register(); + expect(handler).toMatchObject({ id: "warpgate", label: "Warpgate" }); + }); + + it("answers password prompts itself only on hosts with the setting on", async () => { + const handler = await register(); + expect(handler.autoAnswerPasswords!(HOST, { useWarpgate: true })).toBe( + true, + ); + expect(handler.autoAnswerPasswords!(HOST, { useWarpgate: false })).toBe( + false, + ); + expect(handler.autoAnswerPasswords!(HOST, {})).toBe(false); + }); + + it("fails closed without auth:provide", async () => { + const mock = createMockCtx({ + pluginId: manifest.id, + manifest, + capabilities: [], + }); + await expect(activate(mock.ctx)).rejects.toThrow(PluginCapabilityError); + }); +}); + +describe("hostImportNormalizer", () => { + it("reads the plugin's own settings first, then the 2.8 host field", () => { + expect( + hostImportNormalizer({ + pluginSettings: { warpgate: { useWarpgate: true } }, + }), + ).toEqual({ useWarpgate: true }); + expect(hostImportNormalizer({ useWarpgate: 1 })).toEqual({ + useWarpgate: true, + }); + expect(hostImportNormalizer({ useWarpgate: false })).toEqual({ + useWarpgate: false, + }); + expect(hostImportNormalizer({ name: "x" })).toBeNull(); + }); +}); diff --git a/plugins/warpgate/tsconfig.json b/plugins/warpgate/tsconfig.json new file mode 100644 index 000000000..7b35314c3 --- /dev/null +++ b/plugins/warpgate/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "@termix/plugin-sdk/tsconfig.plugin.json", + "include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"] +} diff --git a/plugins/warpgate/vitest.config.ts b/plugins/warpgate/vitest.config.ts new file mode 100644 index 000000000..31ba004fc --- /dev/null +++ b/plugins/warpgate/vitest.config.ts @@ -0,0 +1,3 @@ +import { pluginVitestConfig } from "@termix/plugin-sdk/vitest-preset"; + +export default pluginVitestConfig(import.meta.url); diff --git a/public/sw.js b/public/sw.js index 76d07a32e..2ddb64980 100644 --- a/public/sw.js +++ b/public/sw.js @@ -52,9 +52,10 @@ self.addEventListener("fetch", (event) => { return; } + // API routes, including sign-in pages proxied through plugin routes. if ( - url.pathname.startsWith("/host/opkssh-chooser/") || - url.pathname.startsWith("/host/opkssh-callback/") + url.pathname.startsWith("/plugin-api/") || + url.pathname.startsWith("/host/") ) { return; } diff --git a/src/backend/auth/legacy-providers.ts b/src/backend/auth/legacy-providers.ts index b999b5e20..bc499b239 100644 --- a/src/backend/auth/legacy-providers.ts +++ b/src/backend/auth/legacy-providers.ts @@ -8,112 +8,86 @@ import { getErrorMessage } from "../utils/error-message.js"; import { sshLogger } from "../utils/logger.js"; -import { - registerKeyboardInteractiveInterceptor, - registerSshAuthProvider, -} from "../hosts/connect/auth-provider-registry.js"; +import { applyCertificateAuth } from "@termix/plugin-sdk/ssh-certs"; +import { registerSshAuthProvider } from "../hosts/connect/auth-provider-registry.js"; import type { SshAuthProvider, SshConnectHost, } from "../hosts/connect/types.js"; -const OPKSSH_REQUIRED_MESSAGE = - "OPKSSH authentication required. Please open a Terminal connection to this host first to complete browser-based authentication. Your session will be cached for 24 hours."; +const STEPCA_REQUIRED_MESSAGE = + "Step CA authentication required. Please open a Terminal connection to this host first to complete browser-based authentication."; const VAULT_REQUIRED_MESSAGE = "Vault SSH signer authentication required. Please open a Terminal connection first."; const AUTH_FAILED_PATTERN = /All configured authentication methods failed/i; -function issuedCertificateProvider( - type: "opkssh" | "stepca", - labelKey: string, -): SshAuthProvider { - return { - type, - pluginId: "core", - labelKey, - needsUserInteraction: true, - supportsBackground: false, - interaction: "opkssh", - prepare: async (config, host, env) => { - const { getOPKSSHToken } = await import("../hosts/opkssh-auth.js"); - const token = await getOPKSSHToken(env.userId, env.hostId); - if (!token) { - env.log("info", "No valid certificate found, requesting sign-in"); - return { - status: "interaction-required", - interaction: "opkssh", - message: OPKSSH_REQUIRED_MESSAGE, - flag: "requiresOPKSSHAuth", - }; - } - try { - const { setupOPKSSHCertAuth } = - await import("../hosts/opkssh-cert-auth.js"); - await setupOPKSSHCertAuth(config, env.client, token, host.username); - } catch (error) { - return { - status: "error", - code: "failed", - message: "OPKSSH authentication failed: " + getErrorMessage(error), - }; - } - env.log("info", "Using cached SSH certificate"); - return { status: "ready" }; - }, - onAuthFailed: (_host, env, context) => { - if (!AUTH_FAILED_PATTERN.test(context.error.message)) return; - sshLogger.warn("OPKSSH authentication failed - invalidating token", { - operation: "opkssh_auth_failed", - hostId: env.hostId, - userId: env.userId, - }); - void import("../hosts/opkssh-auth.js") - .then(({ invalidateOPKSSHToken }) => - invalidateOPKSSHToken(env.userId, env.hostId, "SSH auth failed"), - ) - .catch(() => {}); +const stepCaProvider: SshAuthProvider = { + type: "stepca", + pluginId: "core", + labelKey: "hosts.filterAuthStepca", + needsUserInteraction: true, + supportsBackground: false, + interaction: "stepca", + prepare: async (config, host, env) => { + const { getStepCaCert } = await import("../hosts/step-ca-auth.js"); + const cert = getStepCaCert(env.userId, env.hostId); + if (!cert) { + env.log("info", "No valid certificate found, requesting sign-in"); return { status: "interaction-required", - interaction: "opkssh", - message: - "OPKSSH authentication failed or expired. Please authenticate again.", + interaction: "stepca", + message: STEPCA_REQUIRED_MESSAGE, }; - }, - startInteraction: async (request) => { - if (type === "stepca") { - const { startStepCaAuth } = await import("../hosts/step-ca-auth.js"); - await startStepCaAuth( - request.userId, - request.hostId, - request.host.username, - request.socket, - request.requestOrigin, - ); - return; - } - const { startOPKSSHAuth } = await import("../hosts/opkssh-auth.js"); - await startOPKSSHAuth( - request.userId, - request.hostId, - request.host.name || request.host.ip, - request.socket, - request.requestOrigin, + } + try { + await applyCertificateAuth( + config, + env.client, + { privateKey: cert.privateKey, certificate: cert.sshCert }, + host.username, ); - }, - cancelInteraction: async (request) => { - if (!request.requestId) return; - if (type === "stepca") { - const { cancelStepCaAuth } = await import("../hosts/step-ca-auth.js"); - cancelStepCaAuth(request.requestId); - return; - } - const { cancelAuthSession } = await import("../hosts/opkssh-auth.js"); - cancelAuthSession(request.requestId); - }, - }; -} + } catch (error) { + return { + status: "error", + code: "failed", + message: "Step CA authentication failed: " + getErrorMessage(error), + }; + } + env.log("info", "Using cached SSH certificate"); + return { status: "ready" }; + }, + onAuthFailed: (_host, env, context) => { + if (!AUTH_FAILED_PATTERN.test(context.error.message)) return; + void import("../hosts/step-ca-auth.js") + .then(({ invalidateStepCaCert }) => + invalidateStepCaCert(env.userId, env.hostId), + ) + .catch(() => {}); + return { + status: "interaction-required", + interaction: "stepca", + message: + "Step CA authentication failed or expired. Please authenticate again.", + }; + }, + startInteraction: async (request) => { + const { startStepCaAuth } = await import("../hosts/step-ca-auth.js"); + await startStepCaAuth( + request.userId, + request.hostId, + request.host.username, + request.socket, + request.requestOrigin, + ); + }, + cancelInteraction: async (request) => { + if (!request.requestId) return; + const { cancelStepCaAuth } = await import("../hosts/step-ca-auth.js"); + cancelStepCaAuth(request.requestId); + }, +}; function vaultProfileId(host: SshConnectHost): number | undefined { return host.vaultProfile?.id ?? undefined; @@ -149,12 +123,10 @@ const vaultProvider: SshAuthProvider = { }; } try { - const { setupOPKSSHCertAuth } = - await import("../hosts/opkssh-cert-auth.js"); - await setupOPKSSHCertAuth( + await applyCertificateAuth( config, env.client, - { privateKey: cert.privateKey, sshCert: cert.sshCert }, + { privateKey: cert.privateKey, certificate: cert.sshCert }, host.username, ); } catch (error) { @@ -214,52 +186,14 @@ const vaultProvider: SshAuthProvider = { }, }; -const WARPGATE_PATTERN = /warpgate\s+authentication/i; - let sshRegistered = false; export function registerLegacySshAuthProviders(): void { if (sshRegistered) return; sshRegistered = true; - registerSshAuthProvider( - issuedCertificateProvider("opkssh", "hosts.filterAuthOpkssh"), - ); - registerSshAuthProvider( - issuedCertificateProvider("stepca", "hosts.filterAuthStepca"), - ); + registerSshAuthProvider(stepCaProvider); registerSshAuthProvider(vaultProvider); - - // Warpgate is a flag on a host, not an auth type: it shows up as a - // keyboard-interactive round with a sign-in URL. - registerKeyboardInteractiveInterceptor({ - id: "warpgate", - pluginId: "core", - detect: ({ name, instructions, prompts }) => { - const texts = prompts.map((p) => p.prompt); - const isWarpgate = - WARPGATE_PATTERN.test(name) || - WARPGATE_PATTERN.test(instructions) || - texts.some((p) => WARPGATE_PATTERN.test(p)); - if (!isWarpgate) return null; - - const fullText = `${name}\n${instructions}\n${texts.join("\n")}`; - const urlMatch = fullText.match(/https?:\/\/[^\s\n]+/i); - if (!urlMatch) return null; - const keyMatch = fullText.match( - /security key[:\s]+([a-z0-9](?:\s+[a-z0-9]){3}|[a-z0-9]{4})/i, - ); - return { - kind: "warpgate", - url: urlMatch[0], - securityKey: keyMatch ? keyMatch[1] : "N/A", - instructions, - }; - }, - // Warpgate asks for the password before its browser round; answer it - // silently so the only thing the user sees is the Warpgate dialog. - autoAnswerPasswords: (host) => host.useWarpgate === true, - }); } /** Test helper. */ diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index 89bb8bafa..ead5cf75e 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -1420,7 +1420,6 @@ const migrateSchema = () => { { column: "telnet_password", sql: "ALTER TABLE ssh_data ADD COLUMN telnet_password TEXT" }, { column: "rdp_credential_id", sql: "ALTER TABLE ssh_data ADD COLUMN rdp_credential_id INTEGER REFERENCES ssh_credentials(id) ON DELETE SET NULL" }, { column: "vnc_credential_id", sql: "ALTER TABLE ssh_data ADD COLUMN vnc_credential_id INTEGER REFERENCES ssh_credentials(id) ON DELETE SET NULL" }, - { column: "use_warpgate", sql: "ALTER TABLE ssh_data ADD COLUMN use_warpgate INTEGER NOT NULL DEFAULT 0" }, { column: "telnet_credential_id", sql: "ALTER TABLE ssh_data ADD COLUMN telnet_credential_id INTEGER REFERENCES ssh_credentials(id) ON DELETE SET NULL" }, { column: "rdp_auth_type", sql: "ALTER TABLE ssh_data ADD COLUMN rdp_auth_type TEXT" }, { column: "vnc_auth_type", sql: "ALTER TABLE ssh_data ADD COLUMN vnc_auth_type TEXT" }, @@ -1479,23 +1478,6 @@ const migrateSchema = () => { }); } - // Migrate legacy authType="warpgate" hosts to useWarpgate=1 with authType="none" - try { - const result = sqlite - .prepare("UPDATE ssh_data SET use_warpgate = 1, auth_type = 'none' WHERE auth_type = 'warpgate'") - .run(); - if (result.changes > 0) { - databaseLogger.info(`Migrated ${result.changes} host(s) from authType='warpgate' to useWarpgate=true`, { - operation: "warpgate_auth_migration", - }); - } - } catch (e) { - databaseLogger.warn("Failed to migrate legacy warpgate authType hosts", { - operation: "warpgate_auth_migration", - error: e, - }); - } - // Copy unencrypted username/domain into protocol-specific columns for old guac hosts. // Passwords are handled via the legacy field name fallback in lazy-field-encryption.ts. const usernameDomainBackfills = [ @@ -1597,37 +1579,6 @@ const migrateSchema = () => { }); } - try { - sqlite.prepare("SELECT id FROM opkssh_tokens LIMIT 1").get(); - } catch { - try { - sqlite.exec(` - CREATE TABLE IF NOT EXISTS opkssh_tokens ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - host_id INTEGER NOT NULL, - ssh_cert TEXT NOT NULL, - private_key TEXT NOT NULL, - email TEXT, - sub TEXT, - issuer TEXT, - audience TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - expires_at TEXT NOT NULL, - last_used TEXT, - UNIQUE(user_id, host_id), - FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, - FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE - ); - `); - } catch (createError) { - databaseLogger.warn("Failed to create opkssh_tokens table", { - operation: "schema_migration", - error: createError, - }); - } - } - try { sqlite.prepare("SELECT id FROM vault_profiles LIMIT 1").get(); } catch { diff --git a/src/backend/database/db/schema.mysql.ts b/src/backend/database/db/schema.mysql.ts index 87bd29dd1..605e2c68f 100644 --- a/src/backend/database/db/schema.mysql.ts +++ b/src/backend/database/db/schema.mysql.ts @@ -177,7 +177,6 @@ export const hosts = mysqlTable( // never been manually reordered; falls back to name sort in that case. sortOrder: int("sort_order"), authType: text("auth_type").notNull(), - useWarpgate: boolean("use_warpgate").notNull().default(false), shareSshAuth: boolean("share_ssh_auth") .notNull() .default(false), @@ -661,37 +660,6 @@ export const auditLogs = mysqlTable( ], ); -export const opksshTokens = mysqlTable( - "opkssh_tokens", - { - id: int("id").autoincrement().primaryKey(), - userId: varchar("user_id", { length: 255 }) - .notNull() - .references(() => users.id, { onDelete: "cascade" }), - hostId: int("host_id") - .notNull() - .references(() => hosts.id, { onDelete: "cascade" }), - - sshCert: text("ssh_cert").notNull(), - privateKey: text("private_key").notNull(), - - email: text("email"), - sub: text("sub"), - issuer: text("issuer"), - audience: text("audience"), - - createdAt: text("created_at") - .notNull() - .default(sql`(CURRENT_TIMESTAMP)`), - expiresAt: varchar("expires_at", { length: 255 }).notNull(), - lastUsed: text("last_used"), - }, - // Declared inline in the production DDL as UNIQUE(...), but never here, - // so the generated Postgres and MySQL schemas allowed duplicates the - // SQLite deployment forbids — and the upsert had nothing to conflict on. - (table) => [uniqueIndex("idx_opkssh_tokens_user_host").on(table.userId, table.hostId)], -); - // Vault SSH signer profiles. These hold ONLY non-secret connection settings and // are intended to be shared across users (shared === true makes a profile // visible to every user on the server). Each user authenticates to Vault via an diff --git a/src/backend/database/db/schema.pg.ts b/src/backend/database/db/schema.pg.ts index b094d9810..f37ad417c 100644 --- a/src/backend/database/db/schema.pg.ts +++ b/src/backend/database/db/schema.pg.ts @@ -178,7 +178,6 @@ export const hosts = pgTable( // never been manually reordered; falls back to name sort in that case. sortOrder: integer("sort_order"), authType: text("auth_type").notNull(), - useWarpgate: boolean("use_warpgate").notNull().default(false), shareSshAuth: boolean("share_ssh_auth") .notNull() .default(false), @@ -662,37 +661,6 @@ export const auditLogs = pgTable( ], ); -export const opksshTokens = pgTable( - "opkssh_tokens", - { - id: serial("id").primaryKey(), - userId: varchar("user_id", { length: 255 }) - .notNull() - .references(() => users.id, { onDelete: "cascade" }), - hostId: integer("host_id") - .notNull() - .references(() => hosts.id, { onDelete: "cascade" }), - - sshCert: text("ssh_cert").notNull(), - privateKey: text("private_key").notNull(), - - email: text("email"), - sub: text("sub"), - issuer: text("issuer"), - audience: text("audience"), - - createdAt: text("created_at") - .notNull() - .default(sql`CURRENT_TIMESTAMP`), - expiresAt: varchar("expires_at", { length: 255 }).notNull(), - lastUsed: text("last_used"), - }, - // Declared inline in the production DDL as UNIQUE(...), but never here, - // so the generated Postgres and MySQL schemas allowed duplicates the - // SQLite deployment forbids — and the upsert had nothing to conflict on. - (table) => [uniqueIndex("idx_opkssh_tokens_user_host").on(table.userId, table.hostId)], -); - // Vault SSH signer profiles. These hold ONLY non-secret connection settings and // are intended to be shared across users (shared === true makes a profile // visible to every user on the server). Each user authenticates to Vault via an diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts index 190eec362..dc283451d 100644 --- a/src/backend/database/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -168,7 +168,6 @@ export const hosts = sqliteTable( // never been manually reordered; falls back to name sort in that case. sortOrder: integer("sort_order"), authType: text("auth_type").notNull(), - useWarpgate: integer("use_warpgate", { mode: "boolean" }).notNull().default(false), shareSshAuth: integer("share_ssh_auth", { mode: "boolean" }) .notNull() .default(false), @@ -654,37 +653,6 @@ export const auditLogs = sqliteTable( ], ); -export const opksshTokens = sqliteTable( - "opkssh_tokens", - { - id: integer("id").primaryKey({ autoIncrement: true }), - userId: text("user_id") - .notNull() - .references(() => users.id, { onDelete: "cascade" }), - hostId: integer("host_id") - .notNull() - .references(() => hosts.id, { onDelete: "cascade" }), - - sshCert: text("ssh_cert", { length: 8192 }).notNull(), - privateKey: text("private_key", { length: 8192 }).notNull(), - - email: text("email"), - sub: text("sub"), - issuer: text("issuer"), - audience: text("audience"), - - createdAt: text("created_at") - .notNull() - .default(sql`CURRENT_TIMESTAMP`), - expiresAt: text("expires_at").notNull(), - lastUsed: text("last_used"), - }, - // Declared inline in the production DDL as UNIQUE(...), but never here, - // so the generated Postgres and MySQL schemas allowed duplicates the - // SQLite deployment forbids — and the upsert had nothing to conflict on. - (table) => [uniqueIndex("idx_opkssh_tokens_user_host").on(table.userId, table.hostId)], -); - // Vault SSH signer profiles. These hold ONLY non-secret connection settings and // are intended to be shared across users (shared === true makes a profile // visible to every user on the server). Each user authenticates to Vault via an diff --git a/src/backend/database/repositories/factory.ts b/src/backend/database/repositories/factory.ts index 580c11b1a..ae3132ed5 100644 --- a/src/backend/database/repositories/factory.ts +++ b/src/backend/database/repositories/factory.ts @@ -15,7 +15,6 @@ import { HostSidebarPreferenceRepository } from "./host-sidebar-preference-repos import { CredentialSidebarPreferenceRepository } from "./credential-sidebar-preference-repository.js"; import { UiPreferenceRepository } from "./ui-preference-repository.js"; import { OpenTabRepository } from "./open-tab-repository.js"; -import { OpksshTokenRepository } from "./opkssh-token-repository.js"; import { PluginRepository } from "./plugin-repository.js"; import { PluginStorageRepository } from "./plugin-storage-repository.js"; import { PluginSettingsRepository } from "./plugin-settings-repository.js"; @@ -227,13 +226,6 @@ export function createCurrentOpenTabRepository(): OpenTabRepository { ); } -export function createCurrentOpksshTokenRepository(): OpksshTokenRepository { - return new OpksshTokenRepository( - createCurrentRepositoryContext(), - createCurrentRepositoryWriteHook("opkssh_token_repository_write"), - ); -} - export function createCurrentPluginRepository(): PluginRepository { return new PluginRepository( createCurrentRepositoryContext(), diff --git a/src/backend/database/repositories/opkssh-token-repository.ts b/src/backend/database/repositories/opkssh-token-repository.ts deleted file mode 100644 index e802ef887..000000000 --- a/src/backend/database/repositories/opkssh-token-repository.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { and, eq } from "drizzle-orm"; -import { opksshTokens } from "../db/schema.js"; -import type { DatabaseContext } from "./database-context.js"; -import { rowsAffected } from "./mutation-result.js"; -import { upsert } from "./returning.js"; - -export type OpksshTokenRecord = typeof opksshTokens.$inferSelect; - -export interface OpksshTokenUpsertInput { - userId: string; - hostId: number; - sshCert: string; - privateKey: string; - email?: string | null; - sub?: string | null; - issuer?: string | null; - audience?: string | null; - expiresAt: string; - createdAt?: string; -} - -export class OpksshTokenRepository { - constructor( - private readonly context: DatabaseContext, - private readonly onWrite?: () => void | Promise, - ) {} - - async upsert(input: OpksshTokenUpsertInput): Promise { - const createdAt = input.createdAt ?? new Date().toISOString(); - - await upsert( - this.context, - opksshTokens, - { - userId: input.userId, - hostId: input.hostId, - sshCert: input.sshCert, - privateKey: input.privateKey, - email: input.email, - sub: input.sub, - issuer: input.issuer, - audience: input.audience, - expiresAt: input.expiresAt, - }, - { - target: [opksshTokens.userId, opksshTokens.hostId], - set: { - sshCert: input.sshCert, - privateKey: input.privateKey, - email: input.email, - sub: input.sub, - issuer: input.issuer, - audience: input.audience, - expiresAt: input.expiresAt, - createdAt, - }, - }, - ); - - await this.afterWrite(); - } - - async findByUserAndHost( - userId: string, - hostId: number, - ): Promise { - const rows = await this.context.drizzle - .select() - .from(opksshTokens) - .where( - and(eq(opksshTokens.userId, userId), eq(opksshTokens.hostId, hostId)), - ) - .limit(1); - - return rows[0] ?? null; - } - - async updateLastUsed( - userId: string, - hostId: number, - lastUsed = new Date().toISOString(), - ): Promise { - const result = await this.context.drizzle - .update(opksshTokens) - .set({ lastUsed }) - .where( - and(eq(opksshTokens.userId, userId), eq(opksshTokens.hostId, hostId)), - ); - - if (rowsAffected(result) > 0) { - await this.afterWrite(); - } - - return rowsAffected(result) > 0; - } - - async deleteByUserAndHost(userId: string, hostId: number): Promise { - const result = await this.context.drizzle - .delete(opksshTokens) - .where( - and(eq(opksshTokens.userId, userId), eq(opksshTokens.hostId, hostId)), - ); - - if (rowsAffected(result) > 0) { - await this.afterWrite(); - } - - return rowsAffected(result) > 0; - } - - async deleteByUserId(userId: string): Promise { - const result = await this.context.drizzle - .delete(opksshTokens) - .where(eq(opksshTokens.userId, userId)); - - if (rowsAffected(result) > 0) { - await this.afterWrite(); - } - - return rowsAffected(result); - } - - private async afterWrite(): Promise { - await this.onWrite?.(); - } -} diff --git a/src/backend/database/routes/delete-user-data.ts b/src/backend/database/routes/delete-user-data.ts index dcf8a9910..5327c2d27 100644 --- a/src/backend/database/routes/delete-user-data.ts +++ b/src/backend/database/routes/delete-user-data.ts @@ -10,7 +10,6 @@ import { createCurrentHostSidebarPreferenceRepository, createCurrentCredentialSidebarPreferenceRepository, createCurrentUiPreferenceRepository, - createCurrentOpksshTokenRepository, createCurrentOpenTabRepository, createCurrentRecentActivityRepository, createCurrentRbacAccessRepository, @@ -97,9 +96,9 @@ export async function deleteUserAndRelatedData( // secret_sources cascade on the user's refUser() foreign key, as the // homepage and secret-sources plugins' adopted tables. The secret // source's token in ctx.secrets is cleaned up generically below, with - // every other plugin_settings row for this user. + // every other plugin_settings row for this user. The opkssh plugin's + // cached certificates cascade the same way. - await createCurrentOpksshTokenRepository().deleteByUserId(userId); await createCurrentVaultTokenRepository().deleteByUserId(userId); await createCurrentVaultProfileRepository().deleteByUserId(userId); await createCurrentTermixIdentityCaRepository().deleteByUserId(userId); diff --git a/src/backend/database/routes/host-compat-routes.ts b/src/backend/database/routes/host-compat-routes.ts new file mode 100644 index 000000000..07ad6dedf --- /dev/null +++ b/src/backend/database/routes/host-compat-routes.ts @@ -0,0 +1,36 @@ +/** + * The 2.8 URLs under /host that something outside Termix still depends on: + * the OPKSSH redirect URI registered with identity providers. It forwards to + * the plugin route that replaced it, so with the plugin off it signs nobody + * in. + */ + +import type { Request, Router } from "express"; +import { getRequestBasePath } from "../../utils/request-origin.js"; + +/** A path on this install, keeping the query string. */ +function localUrl(req: Request, path: string): string { + const query = req.originalUrl.indexOf("?"); + return `${getRequestBasePath(req)}${path}${ + query >= 0 ? req.originalUrl.slice(query) : "" + }`; +} + +export function registerHostAuthCompatRoutes(router: Router): void { + /** + * @openapi + * /host/opkssh-callback: + * get: + * summary: OPKSSH callback (2.8 URL) + * description: The redirect URI OPKSSH identity providers were set up with before 2.9. Redirects to /plugin-api/opkssh/callback with the same subpath and query, so existing provider configurations keep working. + * tags: + * - Auth + * responses: + * 307: + * description: Redirect to the opkssh plugin's callback. + */ + router.all(["/opkssh-callback", "/opkssh-callback/*rest"], (req, res) => { + const rest = req.path.slice("/opkssh-callback".length); + res.redirect(307, localUrl(req, `/plugin-api/opkssh/callback${rest}`)); + }); +} diff --git a/src/backend/database/routes/host-normalizers.ts b/src/backend/database/routes/host-normalizers.ts index 63c08e045..b5168a9a8 100644 --- a/src/backend/database/routes/host-normalizers.ts +++ b/src/backend/database/routes/host-normalizers.ts @@ -462,7 +462,6 @@ export function transformHostResponse( ? JSON.parse(host.terminalConfig as string) : undefined, forceKeyboardInteractive: host.forceKeyboardInteractive === "true", - useWarpgate: !!host.useWarpgate, socks5ProxyChain: host.socks5ProxyChain ? JSON.parse(host.socks5ProxyChain as string) : [], diff --git a/src/backend/database/routes/host-opkssh-routes.ts b/src/backend/database/routes/host-opkssh-routes.ts deleted file mode 100644 index 8ad2b4f27..000000000 --- a/src/backend/database/routes/host-opkssh-routes.ts +++ /dev/null @@ -1,841 +0,0 @@ -import type { Router, Request, Response } from "express"; -import { sshLogger } from "../../utils/logger.js"; -import { - normalizeSelectOpParam, - renderOpksshErrorPage, - rewriteOPKSSHHtml, -} from "./opkssh-html.js"; - -export function registerHostOpksshRoutes(router: Router): void { - /** - * @openapi - * /host/opkssh-chooser/{requestId}: - * get: - * summary: Proxy OPKSSH provider chooser page and all related resources - * tags: [SSH] - * parameters: - * - name: requestId - * in: path - * required: true - * schema: - * type: string - * description: Authentication request ID - * responses: - * 200: - * description: Chooser page content - * 404: - * description: Session not found - * 500: - * description: Proxy error - */ - - router.use( - "/opkssh-chooser/:requestId", - async (req: Request, res: Response) => { - const requestId = Array.isArray(req.params.requestId) - ? req.params.requestId[0] - : req.params.requestId; - - const fullPath = req.originalUrl || req.url; - const pathAfterRequestIdTemp = - fullPath.split(`/host/opkssh-chooser/${requestId}`)[1] || ""; - - sshLogger.info("OPKSSH chooser proxy request", { - operation: "opkssh_chooser_proxy_request", - requestId, - url: req.url, - originalUrl: req.originalUrl, - fullPath, - pathAfterRequestId: pathAfterRequestIdTemp, - method: req.method, - }); - - try { - const { getActiveAuthSession, registerOAuthState } = - await import("../../hosts/opkssh-auth.js"); - const session = getActiveAuthSession(requestId); - - if (!session) { - sshLogger.error("Session not found for chooser request", { - operation: "opkssh_chooser_session_not_found", - requestId, - }); - res.status(404).send( - renderOpksshErrorPage({ - title: "Session Not Found", - heading: "Session Not Found", - message: "This authentication session has expired or is invalid.", - requestId, - }), - ); - return; - } - - const axios = (await import("axios")).default; - - const fullPath = req.originalUrl || req.url; - const pathAfterRequestId = - fullPath.split(`/host/opkssh-chooser/${requestId}`)[1] || ""; - const targetPath = pathAfterRequestId || "/chooser"; - - if (!session.localPort || session.localPort === 0) { - sshLogger.error("OPKSSH session has no local port", { - operation: "opkssh_chooser_proxy", - requestId, - sessionStatus: session.status, - }); - res.status(500).send( - renderOpksshErrorPage({ - title: "Error", - heading: "Authentication Error", - message: - "Failed to load authentication page. OPKSSH process may not be ready yet. Please try again.", - requestId, - }), - ); - return; - } - - // /select on OPKSSH's chooser redirects (possibly via multiple local hops) to the - // external OAuth provider URL. The hops we may see: - // 1. /select -> /select/ (Go ServeMux canonicalization, same chooser port) - // 2. /select/?op=ALIAS -> http://localhost:CALLBACK_PORT/login (OPKSSH's separate callback listener) - // 3. /login on the callback listener -> https:///authorize?... (external OAuth URL) - if (targetPath.startsWith("/select")) { - const selectaxios = (await import("axios")).default; - const rawQs = targetPath.includes("?") - ? targetPath.slice(targetPath.indexOf("?")) - : ""; - - let qs = rawQs; - let opMappedFrom: string | undefined; - if (rawQs) { - try { - const params = new URLSearchParams(rawQs.replace(/^\?/, "")); - const rawOp = params.get("op"); - if (rawOp) { - const mappedOp = normalizeSelectOpParam( - rawOp, - session.providers || [], - ); - if (mappedOp !== rawOp) { - params.set("op", mappedOp); - qs = `?${params.toString()}`; - opMappedFrom = rawOp; - } - } - } catch { - /* keep rawQs if parsing fails */ - } - } - - const chooserHost = `127.0.0.1:${session.localPort}`; - const startUrl = `http://${chooserHost}/select/${qs}`; - - sshLogger.info("Proxying OPKSSH /select", { - operation: "opkssh_select_proxy", - requestId, - targetUrl: startUrl, - opMappedFrom, - }); - - const isLocalHostname = (host: string): boolean => { - const bare = host.split(":")[0]; - return ( - bare === "127.0.0.1" || bare === "localhost" || bare === "[::1]" - ); - }; - - interface UpstreamResponse { - status: number; - location?: string; - contentType: string; - body: string; - targetUrl: string; - elapsedMs: number; - } - - const fetchUpstream = async ( - url: string, - ): Promise => { - const started = Date.now(); - let hostHeader = chooserHost; - try { - hostHeader = new URL(url).host; - } catch { - /* fall back to chooser host */ - } - const r = await selectaxios({ - method: "GET", - url, - maxRedirects: 0, - validateStatus: () => true, - timeout: 10000, - responseType: "text", - transformResponse: (v) => v, - headers: { host: hostHeader }, - }); - const locHeader = r.headers["location"]; - const location = Array.isArray(locHeader) - ? locHeader[0] - : locHeader; - const ctHeader = r.headers["content-type"]; - const ctRaw = Array.isArray(ctHeader) ? ctHeader[0] : ctHeader; - const contentType = typeof ctRaw === "string" ? ctRaw : ""; - const body = - typeof r.data === "string" ? r.data : String(r.data ?? ""); - return { - status: r.status, - location: typeof location === "string" ? location : undefined, - contentType, - body, - targetUrl: url, - elapsedMs: Date.now() - started, - }; - }; - - const logResponse = (response: UpstreamResponse): void => { - sshLogger.info("OPKSSH /select upstream response", { - operation: "opkssh_select_upstream_response", - requestId, - targetUrl: response.targetUrl, - status: response.status, - location: response.location, - contentType: response.contentType, - elapsedMs: response.elapsedMs, - bodyPreview: response.body.slice(0, 256), - }); - }; - - const MAX_HOPS = 4; - - try { - let response = await fetchUpstream(startUrl); - logResponse(response); - - for (let hop = 0; hop < MAX_HOPS; hop++) { - if ( - response.status < 300 || - response.status >= 400 || - !response.location - ) { - break; - } - const loc = response.location; - - // Relative path: resolve against the current upstream. - if (loc.startsWith("/")) { - let currentHost = chooserHost; - try { - currentHost = new URL(response.targetUrl).host; - } catch { - /* keep default */ - } - response = await fetchUpstream(`http://${currentHost}${loc}`); - logResponse(response); - continue; - } - - // Absolute URL: if it points to a localhost OPKSSH endpoint, capture - // the port. Then redirect the BROWSER to the proxied path so that - // Set-Cookie headers from OPKSSH's /login handler reach the browser - // directly — following them server-side would swallow the cookie. - if (/^https?:\/\//i.test(loc)) { - try { - const parsed = new URL(loc); - if (isLocalHostname(parsed.host)) { - // Capture callback listener port if not yet known. - if (!session.callbackPort) { - const port = parseInt(parsed.port, 10); - if (!Number.isNaN(port)) { - session.callbackPort = port; - sshLogger.info( - "Captured OPKSSH callback listener port from /select redirect", - { - operation: "opkssh_select_callback_port_detected", - requestId, - callbackPort: port, - }, - ); - } - } - // Redirect browser through the chooser proxy so it can receive - // the state cookie that OPKSSH sets on /login. - const browserPath = `/host/opkssh-chooser/${requestId}${parsed.pathname}${parsed.search}`; - sshLogger.info( - "Redirecting browser to OPKSSH callback listener via proxy", - { - operation: "opkssh_select_browser_redirect_to_login", - requestId, - browserPath, - callbackPort: session.callbackPort, - }, - ); - res.redirect(302, browserPath); - return; - } - // External OAuth provider URL — done, handled below. - break; - } catch { - break; - } - } - - break; - } - - const isExternalRedirect = - response.status >= 300 && - response.status < 400 && - !!response.location && - /^https?:\/\//i.test(response.location) && - (() => { - try { - return !isLocalHostname( - new URL(response.location as string).host, - ); - } catch { - return false; - } - })(); - - if (isExternalRedirect) { - const oauthUrl = response.location as string; - try { - const parsed = new URL(oauthUrl); - const oauthState = parsed.searchParams.get("state"); - if (oauthState) registerOAuthState(oauthState, requestId); - } catch { - /* already validated above */ - } - sshLogger.info( - "OPKSSH /select redirecting browser to OAuth provider", - { - operation: "opkssh_select_redirect", - requestId, - oauthUrl, - }, - ); - res.redirect(302, oauthUrl); - return; - } - - const bodyPreview = response.body.slice(0, 512); - const detailLines = [ - `Upstream: ${response.targetUrl}`, - `Status: ${response.status}`, - response.location ? `Location: ${response.location}` : undefined, - `Content-Type: ${response.contentType || "(none)"}`, - `Elapsed: ${response.elapsedMs}ms`, - "", - bodyPreview - ? `Body (first 512 chars):\n${bodyPreview}` - : "Body: (empty)", - ].filter(Boolean) as string[]; - - sshLogger.error( - "OPKSSH /select did not produce an OAuth redirect", - { - operation: "opkssh_select_no_oauth_redirect", - requestId, - status: response.status, - location: response.location, - contentType: response.contentType, - bodyPreview, - }, - ); - - res.status(502).send( - renderOpksshErrorPage({ - title: "OPKSSH error", - heading: "Failed to get OAuth redirect", - message: - "OPKSSH did not return an external OAuth provider URL. " + - "This typically indicates a configuration mismatch between the provider's redirect_uris " + - "and the Termix callback path. Check the server log for the OPKSSH response body.", - details: detailLines.join("\n"), - requestId, - }), - ); - } catch (err) { - sshLogger.error("Error proxying OPKSSH /select", err, { - operation: "opkssh_select_proxy_error", - requestId, - targetUrl: startUrl, - }); - const errMsg = err instanceof Error ? err.message : String(err); - res.status(502).send( - renderOpksshErrorPage({ - title: "OPKSSH error", - heading: "Failed to reach OPKSSH service", - message: - "Termix could not connect to the local OPKSSH authentication service. " + - "The OPKSSH process may have exited or is not listening yet.", - details: `Upstream: ${startUrl}\nError: ${errMsg}`, - requestId, - }), - ); - } - return; - } - - // Paths served by the callback listener, not the chooser. - // The browser is redirected here so it receives Set-Cookie from OPKSSH. - const isCallbackListenerPath = - targetPath === "/login" || - targetPath.startsWith("/login?") || - targetPath === "/login-callback" || - targetPath.startsWith("/login-callback?"); - - const upstreamPort = - isCallbackListenerPath && session.callbackPort - ? session.callbackPort - : session.localPort; - - const targetUrl = `http://127.0.0.1:${upstreamPort}${targetPath}`; - - sshLogger.info("Proxying to OPKSSH chooser", { - operation: "opkssh_chooser_proxy_request_to_opkssh", - requestId, - targetUrl, - upstreamPort, - targetPath, - }); - - const response = await axios({ - method: req.method, - url: targetUrl, - headers: { - ...req.headers, - host: `127.0.0.1:${upstreamPort}`, - }, - data: req.body, - timeout: 10000, - validateStatus: () => true, - maxRedirects: 0, - responseType: "arraybuffer", - }); - - sshLogger.info("OPKSSH chooser response received", { - operation: "opkssh_chooser_proxy_response", - requestId, - statusCode: response.status, - contentType: response.headers["content-type"], - contentLength: response.headers["content-length"], - hasLocation: !!response.headers.location, - }); - - Object.entries(response.headers).forEach(([key, value]) => { - if (key.toLowerCase() === "transfer-encoding") { - return; - } - if (key.toLowerCase() === "location") { - const location = value as string; - if (location.startsWith("/")) { - res.setHeader( - key, - `/host/opkssh-chooser/${requestId}${location}`, - ); - } else { - const localhostMatch = location.match( - /^http:\/\/(?:localhost|127\.0\.0\.1):(\d+)(\/.*)?$/, - ); - if (localhostMatch) { - const port = parseInt(localhostMatch[1], 10); - const path = localhostMatch[2] || "/"; - if (session.callbackPort && port === session.callbackPort) { - res.setHeader( - key, - `/host/opkssh-callback/${requestId}${path}`, - ); - } else if (port === session.localPort) { - res.setHeader( - key, - `/host/opkssh-chooser/${requestId}${path}`, - ); - } else { - const isCallback = - path.includes("login") || path.includes("callback"); - const prefix = isCallback - ? "opkssh-callback" - : "opkssh-chooser"; - res.setHeader(key, `/host/${prefix}/${requestId}${path}`); - } - } else { - // External redirect (e.g. to OIDC provider) — capture OAuth state for session binding - try { - const redirectUrl = new URL(location); - const oauthState = redirectUrl.searchParams.get("state"); - if (oauthState) { - registerOAuthState(oauthState, requestId); - } - } catch { - // Not a valid URL, skip state capture - } - res.setHeader(key, value as string); - } - } - } else if (key.toLowerCase() === "set-cookie") { - // Rewrite cookies from OPKSSH's internal listener so they are scoped - // to the Termix proxy path instead of OPKSSH's internal path. - // The state cookie set by /login must survive to /login-callback. - const cookies = Array.isArray(value) ? value : [value as string]; - const rewritten = cookies.map((cookie) => { - return cookie - .replace(/;\s*domain=[^;]*/gi, "") - .replace(/;\s*path=[^;]*/gi, "; Path=/host/opkssh-callback/") - .concat( - cookie.match(/;\s*path=/i) - ? "" - : "; Path=/host/opkssh-callback/", - ); - }); - res.setHeader(key, rewritten); - } else { - res.setHeader(key, value as string); - } - }); - - // Set a cookie to correlate this browser with the requestId. - // OAuth state capture from Location headers only works for 3xx redirects; - // if OPKSSH redirects via JavaScript, the state is never registered. - // This cookie survives the OIDC round-trip and identifies the session on callback. - res.cookie("opkssh_request_id", requestId, { - path: "/host/", - httpOnly: true, - sameSite: "lax", - maxAge: 5 * 60 * 1000, - }); - - const contentType = String(response.headers["content-type"] || ""); - if (contentType.includes("text/html")) { - const html = rewriteOPKSSHHtml( - response.data.toString("utf-8"), - requestId, - "opkssh-chooser", - ); - res.status(response.status).send(html); - } else { - res.status(response.status).send(response.data); - } - } catch (error) { - sshLogger.error("Error proxying OPKSSH chooser", error, { - operation: "opkssh_chooser_proxy_error", - requestId, - }); - res.status(500).send( - renderOpksshErrorPage({ - title: "Error", - heading: "Error", - message: "Failed to load authentication page. Please try again.", - requestId, - }), - ); - } - }, - ); - - /** - * @openapi - * /host/opkssh-callback: - * get: - * summary: Static OAuth callback from OIDC provider for OPKSSH authentication - * tags: [SSH] - * responses: - * 200: - * description: Callback processed successfully - * 404: - * description: No active authentication session found - * 500: - * description: Authentication failed - */ - router.get("/opkssh-callback", async (req: Request, res: Response) => { - try { - sshLogger.info("OAuth callback received", { - operation: "opkssh_static_callback_received", - host: req.headers.host, - }); - - const { - getUserIdFromRequest, - getActiveSessionsForUser, - getActiveAuthSession, - getRequestIdByOAuthState, - clearOAuthState, - } = await import("../../hosts/opkssh-auth.js"); - - const userId = await getUserIdFromRequest({ - cookies: req.cookies, - headers: req.headers as Record, - }); - - sshLogger.info("User ID resolved", { - operation: "opkssh_callback_user_lookup", - userId: userId || "null", - hasCookies: !!req.cookies?.jwt, - cookieKeys: Object.keys(req.cookies || {}), - }); - - let userSessions: Awaited> = - []; - - if (userId) { - userSessions = getActiveSessionsForUser(userId); - } else { - // No JWT cookie (e.g. OAuth redirect landed in external browser). - // Try to find the correct session via the OAuth state parameter. - const oauthState = req.query.state as string | undefined; - - if (oauthState) { - const mappedRequestId = getRequestIdByOAuthState(oauthState); - if (mappedRequestId) { - const mappedSession = getActiveAuthSession(mappedRequestId); - if (mappedSession) { - userSessions = [mappedSession]; - clearOAuthState(oauthState); - sshLogger.info("Resolved session via OAuth state parameter", { - operation: "opkssh_callback_state_lookup", - requestId: mappedRequestId, - }); - } - } - } - - // Fallback: use the opkssh_request_id cookie set by the chooser proxy. - // State capture only works for 3xx redirects; if OPKSSH redirects via - // JavaScript in the HTML, the state is never registered in the map. - if (userSessions.length === 0) { - const cookieRequestId = req.cookies?.opkssh_request_id; - if (cookieRequestId) { - const cookieSession = getActiveAuthSession(cookieRequestId); - if (cookieSession) { - userSessions = [cookieSession]; - res.clearCookie("opkssh_request_id", { path: "/host/" }); - sshLogger.info("Resolved session via opkssh_request_id cookie", { - operation: "opkssh_callback_cookie_lookup", - requestId: cookieRequestId, - }); - } - } - } - - if (userSessions.length === 0) { - sshLogger.warn( - "OAuth callback with no JWT, no matching state, and no session cookie", - { - operation: "opkssh_callback_no_session_match", - hasState: !!oauthState, - hasCookie: !!req.cookies?.opkssh_request_id, - }, - ); - res - .status(401) - .send("Authentication callback failed: unable to identify session"); - return; - } - } - - sshLogger.info("Active sessions for user", { - operation: "opkssh_callback_session_lookup", - userId, - sessionCount: userSessions.length, - sessions: userSessions.map((s) => ({ - requestId: s.requestId, - status: s.status, - hasCallbackPort: !!s.callbackPort, - callbackPort: s.callbackPort, - hasLocalPort: !!s.localPort, - localPort: s.localPort, - })), - }); - - if (userSessions.length === 0) { - sshLogger.error("No active sessions for callback", { - operation: "opkssh_callback_no_sessions", - userId, - }); - res.status(404).send("No active authentication session found"); - return; - } - - const session = userSessions[userSessions.length - 1]; - - if (!session.callbackPort) { - sshLogger.error("Session callback port not ready", { - operation: "opkssh_callback_port_not_ready", - userId, - requestId: session.requestId, - sessionStatus: session.status, - hasLocalPort: !!session.localPort, - }); - res.status(503).send("OPKSSH callback listener not ready yet"); - return; - } - - const queryString = req.url.includes("?") - ? req.url.substring(req.url.indexOf("?")) - : ""; - // OPKSSH's internal callback listener handles `/login-callback` regardless of the - // path used in --remote-redirect-uri. The dynamic route below defaults to that path. - const redirectUrl = `/host/opkssh-callback/${session.requestId}${queryString}`; - - sshLogger.info("Redirecting OAuth callback to dynamic route", { - operation: "opkssh_static_callback_redirect", - userId, - requestId: session.requestId, - callbackPort: session.callbackPort, - queryParams: Object.keys(req.query), - redirectUrl, - }); - - res.redirect(302, redirectUrl); - } catch (error) { - sshLogger.error("Error handling OPKSSH static callback", error, { - operation: "opkssh_static_callback_error", - url: req.url, - originalUrl: req.originalUrl, - }); - res.status(500).send("Authentication callback failed"); - } - }); - - /** - * @openapi - * /host/opkssh-callback/{requestId}: - * get: - * summary: OAuth callback from OIDC provider for OPKSSH authentication (handles all sub-paths) - * tags: [SSH] - * parameters: - * - name: requestId - * in: path - * required: true - * schema: - * type: string - * description: Authentication request ID - * responses: - * 200: - * description: Callback processed successfully - * 404: - * description: Invalid authentication session - * 500: - * description: Authentication failed - */ - router.use( - "/opkssh-callback/:requestId", - async (req: Request, res: Response) => { - const requestId = Array.isArray(req.params.requestId) - ? req.params.requestId[0] - : req.params.requestId; - - try { - const { getActiveAuthSession } = - await import("../../hosts/opkssh-auth.js"); - const session = getActiveAuthSession(requestId); - - if (!session) { - res.status(404).send( - renderOpksshErrorPage({ - title: "Session Not Found", - heading: "Session Not Found", - message: - "Authentication session expired or invalid. Please close this window and try again.", - requestId, - }), - ); - return; - } - - const axios = (await import("axios")).default; - const fullPath = req.originalUrl || req.url; - const pathAfterRequestId = - fullPath.split(`/host/opkssh-callback/${requestId}`)[1] || ""; - // pathAfterRequestId may be "", "?query=...", "/subpath", or "/subpath?query=..." - // OPKSSH's internal listener serves /login-callback, so when no sub-path is present - // (query-only or empty), prepend it. - const targetPath = - pathAfterRequestId === "" || pathAfterRequestId.startsWith("?") - ? `/login-callback${pathAfterRequestId}` - : pathAfterRequestId; - - if (!session.callbackPort || session.callbackPort === 0) { - sshLogger.error("OPKSSH callback session has no callback port", { - operation: "opkssh_callback_proxy", - requestId, - sessionStatus: session.status, - }); - res.status(500).send( - renderOpksshErrorPage({ - title: "Error", - heading: "Callback Error", - message: - "OPKSSH callback listener not ready. Please try authenticating again.", - requestId, - }), - ); - return; - } - - const targetUrl = `http://127.0.0.1:${session.callbackPort}${targetPath}`; - - const response = await axios({ - method: req.method, - url: targetUrl, - headers: { - ...req.headers, - host: `127.0.0.1:${session.callbackPort}`, - }, - data: req.body, - timeout: 10000, - validateStatus: () => true, - maxRedirects: 0, - responseType: "arraybuffer", - }); - - Object.entries(response.headers).forEach(([key, value]) => { - if (key.toLowerCase() === "transfer-encoding") { - return; - } - if (key.toLowerCase() === "location") { - const location = value as string; - if (location.startsWith("/")) { - res.setHeader( - key, - `/host/opkssh-callback/${requestId}${location}`, - ); - } else { - res.setHeader(key, value as string); - } - } else { - res.setHeader(key, value as string); - } - }); - - const contentType = String(response.headers["content-type"] || ""); - if (contentType.includes("text/html")) { - const html = rewriteOPKSSHHtml( - response.data.toString("utf-8"), - requestId, - "opkssh-callback", - ); - res.status(response.status).send(html); - } else { - res.status(response.status).send(response.data); - } - } catch (error) { - sshLogger.error("Error handling OPKSSH OAuth callback", error, { - operation: "opkssh_oauth_callback_error", - requestId, - }); - - res.status(500).send( - renderOpksshErrorPage({ - title: "Error", - heading: "Error", - message: "An unexpected error occurred. Please try again.", - requestId, - }), - ); - } - }, - ); -} diff --git a/src/backend/database/routes/host-step-ca-routes.ts b/src/backend/database/routes/host-step-ca-routes.ts index 16e9cf8dc..164236e37 100644 --- a/src/backend/database/routes/host-step-ca-routes.ts +++ b/src/backend/database/routes/host-step-ca-routes.ts @@ -1,5 +1,13 @@ import type { Request, Response, Router } from "express"; -import { escapeHtml } from "./opkssh-html.js"; + +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} function resultPage(ok: boolean, message: string): string { return `Termix diff --git a/src/backend/database/routes/host.ts b/src/backend/database/routes/host.ts index b16543d48..1f4b08e80 100644 --- a/src/backend/database/routes/host.ts +++ b/src/backend/database/routes/host.ts @@ -18,7 +18,6 @@ import { emitInternalEvent } from "../../hosts/internal-events.js"; import { deleteOwnedHost } from "../../hosts/delete-host.js"; import { createCurrentCredentialRepository, - createCurrentOpksshTokenRepository, createCurrentRbacAccessRepository, createCurrentRoleRepository, createCurrentHostResolutionRepository, @@ -46,7 +45,7 @@ import { withHostPluginSettings, } from "./host-plugin-settings.js"; import { validateParentHostId } from "./host-parent-validation.js"; -import { registerHostOpksshRoutes } from "./host-opkssh-routes.js"; +import { registerHostAuthCompatRoutes } from "./host-compat-routes.js"; import { registerHostStepCaRoutes } from "./host-step-ca-routes.js"; import { registerHostFolderRoutes } from "./host-folder-routes.js"; import { registerHostAutostartRoutes } from "./host-autostart-routes.js"; @@ -183,7 +182,6 @@ router.post( password, authMethod, authType, - useWarpgate, shareSshAuth, credentialId, vaultProfileId, @@ -306,7 +304,6 @@ router.post( port, username: effectiveUsername, authType: effectiveAuthType, - useWarpgate: useWarpgate ? 1 : 0, shareSshAuth: shareSshAuth === true ? 1 : 0, credentialId: credentialId || null, vaultProfileId: @@ -860,7 +857,6 @@ router.put( password, authMethod, authType, - useWarpgate, shareSshAuth, credentialId, vaultProfileId, @@ -984,7 +980,6 @@ router.put( port, username: effectiveUsername, authType: effectiveAuthType, - useWarpgate: useWarpgate ? 1 : 0, shareSshAuth: shareSshAuth === true ? 1 : 0, credentialId: credentialId || null, vaultProfileId: @@ -2665,151 +2660,7 @@ registerHostAutostartRoutes(router, { requireDataAccess, }); -/** - * @openapi - * /host/opkssh/token/{hostId}: - * get: - * summary: Get OPKSSH token status for a host - * tags: [SSH] - * security: - * - bearerAuth: [] - * parameters: - * - name: hostId - * in: path - * required: true - * schema: - * type: integer - * description: Host ID - * responses: - * 200: - * description: Token status retrieved successfully - * content: - * application/json: - * schema: - * type: object - * properties: - * exists: - * type: boolean - * description: Whether a valid token exists - * expiresAt: - * type: string - * format: date-time - * description: Token expiration timestamp - * email: - * type: string - * description: User email from OIDC identity - * 404: - * description: No valid token found - * 500: - * description: Internal server error - */ -router.get( - "/ssh/opkssh/token/:hostId", - authenticateJWT, - permissionManager.requirePermission("hosts.view"), - requireDataAccess, - requireDataAccess, - async (req: AuthenticatedRequest, res: Response) => { - const userId = req.userId; - const hostId = parseInt( - Array.isArray(req.params.hostId) - ? req.params.hostId[0] - : req.params.hostId, - ); - - if (!userId || isNaN(hostId)) { - return res.status(400).json({ error: "Invalid request" }); - } - - try { - const opksshTokenRepository = createCurrentOpksshTokenRepository(); - const tokenData = await opksshTokenRepository.findByUserAndHost( - userId, - hostId, - ); - - if (!tokenData) { - return res.status(404).json({ exists: false }); - } - - const expiresAt = new Date(tokenData.expiresAt); - - if (expiresAt < new Date()) { - await opksshTokenRepository.deleteByUserAndHost(userId, hostId); - return res.status(404).json({ exists: false }); - } - - res.json({ - exists: true, - expiresAt: tokenData.expiresAt, - email: tokenData.email, - }); - } catch (error) { - sshLogger.error("Error retrieving OPKSSH token status", error, { - operation: "opkssh_token_status_error", - userId, - hostId, - }); - res.status(500).json({ error: "Internal server error" }); - } - }, -); - -/** - * @openapi - * /host/opkssh/token/{hostId}: - * delete: - * summary: Delete OPKSSH token for a host - * tags: [SSH] - * security: - * - bearerAuth: [] - * parameters: - * - name: hostId - * in: path - * required: true - * schema: - * type: integer - * description: Host ID - * responses: - * 200: - * description: Token deleted successfully - * 500: - * description: Internal server error - */ -router.delete( - "/ssh/opkssh/token/:hostId", - authenticateJWT, - permissionManager.requirePermission("hosts.edit"), - requireDataAccess, - requireDataAccess, - async (req: AuthenticatedRequest, res: Response) => { - const userId = req.userId; - const hostId = parseInt( - Array.isArray(req.params.hostId) - ? req.params.hostId[0] - : req.params.hostId, - ); - - if (!userId || isNaN(hostId)) { - return res.status(400).json({ error: "Invalid request" }); - } - - try { - const { deleteOPKSSHToken } = await import("../../hosts/opkssh-auth.js"); - await deleteOPKSSHToken(userId, hostId); - res.json({ success: true }); - } catch (error) { - sshLogger.error("Error deleting OPKSSH token", error, { - operation: "opkssh_token_delete_error", - userId, - hostId, - }); - res.status(500).json({ error: "Internal server error" }); - } - }, -); - -registerHostOpksshRoutes(router); +registerHostAuthCompatRoutes(router); registerHostStepCaRoutes(router); registerHostNetworkRoutes(router, { diff --git a/src/backend/hosts/connect/auth-provider-registry.ts b/src/backend/hosts/connect/auth-provider-registry.ts index e9160b195..168bc5d2e 100644 --- a/src/backend/hosts/connect/auth-provider-registry.ts +++ b/src/backend/hosts/connect/auth-provider-registry.ts @@ -102,6 +102,12 @@ export function listCredentialTypes(): string[] { export function registerKeyboardInteractiveInterceptor( interceptor: KeyboardInteractiveInterceptor, ): () => void { + const existing = interceptors.get(interceptor.id); + if (existing && existing.pluginId !== interceptor.pluginId) { + throw new Error( + `Keyboard-interactive handler "${interceptor.id}" is already provided by ${existing.pluginId}`, + ); + } interceptors.set(interceptor.id, interceptor); return () => { if (interceptors.get(interceptor.id) === interceptor) { diff --git a/src/backend/hosts/connect/builtin-providers.ts b/src/backend/hosts/connect/builtin-providers.ts index 4f7cb57bf..e04ae33f9 100644 --- a/src/backend/hosts/connect/builtin-providers.ts +++ b/src/backend/hosts/connect/builtin-providers.ts @@ -104,14 +104,17 @@ async function prepareKey( const certPublicKey = host.certPublicKey; if (certPublicKey && certPublicKey.trim()) { try { - const { setupCACertAuth } = await import("../opkssh-cert-auth.js"); - await setupCACertAuth( + const { applyCertificateAuth } = + await import("@termix/plugin-sdk/ssh-certs"); + await applyCertificateAuth( config, env.client, - config.privateKey as Buffer, - certPublicKey, + { + privateKey: config.privateKey as Buffer, + certificate: certPublicKey, + passphrase, + }, host.username, - passphrase, ); env.log("info", "Using SSH key authentication with CA certificate"); return { status: "ready" }; diff --git a/src/backend/hosts/connect/keyboard-interactive.ts b/src/backend/hosts/connect/keyboard-interactive.ts index 814c96a6a..598a3dc6d 100644 --- a/src/backend/hosts/connect/keyboard-interactive.ts +++ b/src/backend/hosts/connect/keyboard-interactive.ts @@ -154,13 +154,8 @@ export function createPromptKeyboardInteractiveHandler( switch (decision.kind) { case "auto": return decision.responses; - case "warpgate": { - await channel.ask({ - kind: "warpgate", - url: decision.url, - securityKey: decision.securityKey, - instructions: decision.instructions, - }); + case "browser": { + await channel.ask(decision); return prompts.map(() => ""); } case "totp": { diff --git a/src/backend/hosts/connect/types.ts b/src/backend/hosts/connect/types.ts index e0edae1d1..2601c7b22 100644 --- a/src/backend/hosts/connect/types.ts +++ b/src/backend/hosts/connect/types.ts @@ -35,7 +35,11 @@ export interface SshConnectHost { keyPassword?: string | null; keyType?: string | null; certPublicKey?: string | null; - useWarpgate?: boolean | null; + /** + * Host-scope plugin settings by plugin id, filled in by the resolver so + * synchronous hooks (keyboard-interactive handlers) can read them. + */ + pluginSettings?: Record> | null; forceKeyboardInteractive?: boolean | null; vaultProfile?: { id?: number | null } | null; terminalConfig?: Record | null; @@ -234,21 +238,30 @@ export interface KeyboardInteractivePrompt { echo?: boolean; } +/** + * A round finished in a browser: open the URL, compare the code, continue. + * `id` names the handler that claimed it; transports name their messages + * after it. + */ +export interface BrowserSignInRound { + kind: "browser"; + id: string; + label: string; + url: string | null; + code: string; + instructions: string; +} + /** What to do with one keyboard-interactive round. */ export type KeyboardInteractiveDecision = | { kind: "auto"; responses: string[] } - | { - kind: "warpgate"; - url: string | null; - securityKey: string; - instructions: string; - } + | BrowserSignInRound | { kind: "totp"; promptIndex: number } | { kind: "input"; promptIndex: number; isPush: boolean }; /** - * Interceptors run before the built-in classifier. A provider that owns a - * prompt style (Warpgate today) returns a decision; everyone else returns null. + * Interceptors run before the built-in classifier. A plugin that owns a + * prompt style returns a decision; everyone else returns null. */ export interface KeyboardInteractiveInterceptor { id: string; @@ -281,9 +294,4 @@ export interface SshPromptChannel { export type SshPromptRequest = | { kind: "totp"; prompt: string; retry: boolean } | { kind: "input"; prompt: string; echo: boolean; isPush: boolean } - | { - kind: "warpgate"; - url: string | null; - securityKey: string; - instructions: string; - }; + | BrowserSignInRound; diff --git a/src/backend/hosts/file-manager/index.ts b/src/backend/hosts/file-manager/index.ts index d4b5b7edc..d9d03998a 100644 --- a/src/backend/hosts/file-manager/index.ts +++ b/src/backend/hosts/file-manager/index.ts @@ -1159,14 +1159,14 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { return; } - if (decision.kind === "warpgate") { + if (decision.kind === "browser") { if (responseSent) return; responseSent = true; connectionLogs.push( createConnectionLog( "info", "sftp_auth", - "Warpgate authentication required", + `${decision.label} sign-in required`, { url: decision.url }, ), ); @@ -1175,7 +1175,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { requires_warpgate: true, sessionId, url: decision.url, - securityKey: decision.securityKey, + securityKey: decision.code, connectionLogs, }); return; diff --git a/src/backend/hosts/host-resolver.ts b/src/backend/hosts/host-resolver.ts index 731ab819e..b927eb210 100644 --- a/src/backend/hosts/host-resolver.ts +++ b/src/backend/hosts/host-resolver.ts @@ -252,6 +252,21 @@ export async function resolveHostById( } } + // Keyboard-interactive handlers run synchronously mid-handshake and can + // only read their host settings from here. + try { + const { loadHostPluginSettings } = + await import("../database/routes/host-plugin-settings.js"); + const settings = (await loadHostPluginSettings([hostId])).get(hostId); + if (settings) host.pluginSettings = settings; + } catch (e) { + sshLogger.warn("Failed to load plugin settings for host", { + operation: "host_resolver_plugin_settings", + hostId, + error: getErrorMessage(e, "Unknown"), + }); + } + return host as unknown as SSHHost; } diff --git a/src/backend/hosts/opkssh-auth.ts b/src/backend/hosts/opkssh-auth.ts deleted file mode 100644 index 634b93d6a..000000000 --- a/src/backend/hosts/opkssh-auth.ts +++ /dev/null @@ -1,937 +0,0 @@ -import { getErrorMessage } from "../utils/error-message.js"; -import { spawn, ChildProcess } from "child_process"; -import { randomUUID } from "crypto"; -import { WebSocket } from "ws"; -import { OPKSSHBinaryManager } from "../utils/opkssh-binary-manager.js"; -import { sshLogger } from "../utils/logger.js"; -import { createCurrentOpksshTokenRepository } from "../database/repositories/factory.js"; -import { DataCrypto } from "../utils/data-crypto.js"; -import { FieldCrypto } from "../utils/field-crypto.js"; -import { promises as fs } from "fs"; -import path from "path"; -import axios from "axios"; -import { load as loadYaml } from "js-yaml"; - -const AUTH_TIMEOUT = 60 * 1000; - -export const OPKSSH_CALLBACK_PATH = "/host/opkssh-callback"; - -interface OPKSSHAuthSession { - requestId: string; - userId: string; - hostId: number; - hostname: string; - process: ChildProcess; - localPort: number; - callbackPort: number; - remoteRedirectUri: string; - providers: Array<{ alias: string; issuer: string }>; - status: - "starting" | "waiting_for_auth" | "authenticating" | "completed" | "error"; - ws: WebSocket; - stdoutBuffer: string; - privateKeyBuffer: string; - sshCertBuffer: string; - identity: { - email?: string; - sub?: string; - issuer?: string; - audience?: string; - }; - createdAt: Date; - approvalTimeout: NodeJS.Timeout; - cleanup: () => Promise; -} - -const activeAuthSessions = new Map(); -const oauthStateToRequestId = new Map(); -const cleanupInProgress = new Set(); - -function getOPKConfigPath(): string { - const dataDir = - process.env.DATA_DIR || path.join(process.cwd(), "db", "data"); - return path.join(dataDir, ".opk", "config.yml"); -} - -async function ensureOPKConfigDir(): Promise { - const configPath = getOPKConfigPath(); - const configDir = path.dirname(configPath); - await fs.mkdir(configDir, { recursive: true }); -} - -async function createTemplateConfig(): Promise { - const configPath = getOPKConfigPath(); - const template = ` -# OPKSSH Configuration -# OPKSSH Documentation: https://github.com/openpubkey/opkssh/blob/main/docs/config.md -# Termix Documentation: https://docs.termix.site/features/authentication/opkssh -`; - - try { - await ensureOPKConfigDir(); - await fs.writeFile(configPath, template, "utf8"); - sshLogger.info(`Created template OPKSSH config at ${configPath}`); - } catch (error) { - sshLogger.warn("Failed to create template OPKSSH config", error); - } -} - -interface ProviderRedirectInfo { - alias: string; - issuer: string; - redirectUris: string[]; -} - -async function checkOPKConfigExists(): Promise<{ - exists: boolean; - error?: string; - configPath?: string; - providers?: ProviderRedirectInfo[]; -}> { - const configPath = getOPKConfigPath(); - const isDocker = - !!process.env.DATA_DIR && process.env.DATA_DIR.startsWith("/app"); - const dockerHint = isDocker - ? "\n\nDocker: Ensure /app/data is mounted as a volume with write permissions for node:node user." - : ""; - - try { - const content = await fs.readFile(configPath, "utf8"); - - if (!content.includes("providers:")) { - return { - exists: false, - configPath, - error: `OPKSSH configuration is missing 'providers' section. Please edit the config file at:\n${configPath}\n\n.`, - }; - } - - const lines = content.split("\n"); - - const hasUncommentedProvider = lines.some((line) => { - const trimmed = line.trim(); - return ( - trimmed.startsWith("- alias:") || - (trimmed.startsWith("issuer:") && !line.trimStart().startsWith("#")) - ); - }); - - if (!hasUncommentedProvider) { - return { - exists: false, - configPath, - error: `OPKSSH configuration has no active providers. Please edit the config file at:\n${configPath}\n\nUncomment and configure at least one OIDC provider.\nSee documentation: https://github.com/openpubkey/opkssh/blob/main/docs/config.md${dockerHint}`, - }; - } - - let providers: ProviderRedirectInfo[] = []; - try { - const parsed = loadYaml(content) as { - providers?: Array<{ - alias?: string; - issuer?: string; - redirect_uris?: string[]; - }>; - }; - if (parsed?.providers && Array.isArray(parsed.providers)) { - providers = parsed.providers - .filter( - ( - p, - ): p is { - alias: string; - issuer: string; - redirect_uris?: string[]; - } => typeof p.alias === "string" && typeof p.issuer === "string", - ) - .map((p) => ({ - alias: p.alias, - issuer: p.issuer.replace(/^https?:\/\//, ""), - redirectUris: Array.isArray(p.redirect_uris) - ? p.redirect_uris.filter( - (u): u is string => typeof u === "string", - ) - : [], - })); - } - } catch (e) { - sshLogger.warn("Failed to parse OPKSSH config for providers", { - operation: "opkssh_config_parse_providers_error", - error: e, - }); - } - - return { exists: true, configPath, providers }; - } catch { - await createTemplateConfig(); - return { - exists: false, - configPath, - error: `OPKSSH configuration not found. A template config file has been created at:\n${configPath}\n\nPlease edit this file and configure your OIDC provider (Google, GitHub, Microsoft, etc.).\nSee documentation: https://github.com/openpubkey/opkssh/blob/main/docs/config.md${dockerHint}`, - }; - } -} - -// OPKSSH's `redirect_uris` field lists candidate LOCAL ports for the callback listener -// that OPKSSH binds on the host running the binary. The openpubkey library enforces these -// must be localhost, a non-localhost entry causes ECONNRESET on /select/ at runtime. -// The publicly registered OAuth redirect URI is what Termix passes via --remote-redirect-uri -// (derived from request origin); users do NOT put that URL in this config field. -function validateRedirectUrisAreLocalhost( - providers: ProviderRedirectInfo[], -): { ok: true } | { ok: false; message: string } { - const isLocalHost = (host: string): boolean => { - const bare = host.replace(/^\[|\]$/g, ""); - return ( - bare === "localhost" || - bare === "127.0.0.1" || - bare === "::1" || - bare === "0:0:0:0:0:0:0:1" || - bare.startsWith("localhost:") || - bare.startsWith("127.0.0.1:") - ); - }; - - const issues: string[] = []; - for (const p of providers) { - const uris = p.redirectUris || []; - if (uris.length === 0) continue; - const nonLocal = uris.filter((u) => { - try { - return !isLocalHost(new URL(u).hostname); - } catch { - return true; - } - }); - if (nonLocal.length > 0) { - issues.push( - `Provider '${p.alias}': non-localhost entries in redirect_uris: ${nonLocal.join(", ")}`, - ); - } - } - - if (issues.length > 0) { - return { - ok: false, - message: - `OPKSSH configuration error: 'redirect_uris' must only contain localhost URLs.\n\n` + - `${issues.join("\n")}\n\n` + - `This field is OPKSSH's local callback listener, it must be localhost (or omitted to use ` + - `the defaults http://localhost:3000/login-callback, :10001, :11110). ` + - `The public Termix callback URL is supplied automatically by Termix via --remote-redirect-uri; ` + - `you do not put it here. Register the PUBLIC Termix URL with your OAuth provider instead ` + - `(e.g. https://your-domain${OPKSSH_CALLBACK_PATH}).\n\n` + - `Fix: remove the non-localhost entries above, or delete the whole 'redirect_uris' block to use defaults.\n\n` + - `Docs: https://docs.termix.site/features/authentication/opkssh`, - }; - } - - return { ok: true }; -} - -export async function startOPKSSHAuth( - userId: string, - hostId: number, - hostname: string, - ws: WebSocket, - requestOrigin: string, -): Promise { - try { - await ensureOPKConfigDir(); - const configDir = path.dirname(getOPKConfigPath()); - await fs.access(configDir, fs.constants.R_OK | fs.constants.W_OK); - } catch (error) { - sshLogger.error("OPKSSH directory not accessible", error); - const isDocker = - !!process.env.DATA_DIR && process.env.DATA_DIR.startsWith("/app"); - const dockerHint = isDocker - ? "\n\nDocker: Ensure /app/data is mounted as a volume with write permissions for node:node user." - : ""; - ws.send( - JSON.stringify({ - type: "opkssh_error", - error: `OPKSSH directory initialization failed: ${error.message}${dockerHint}`, - }), - ); - return ""; - } - - const configCheck = await checkOPKConfigExists(); - if (!configCheck.exists) { - ws.send( - JSON.stringify({ - type: "opkssh_config_error", - requestId: "", - error: configCheck.error, - instructions: configCheck.error, - }), - ); - return ""; - } - - const redirectValidation = validateRedirectUrisAreLocalhost( - configCheck.providers || [], - ); - if (redirectValidation.ok === false) { - sshLogger.warn("OPKSSH config redirect_uris validation failed", { - operation: "opkssh_config_redirect_uris_not_localhost", - configPath: configCheck.configPath, - }); - ws.send( - JSON.stringify({ - type: "opkssh_config_error", - requestId: "", - error: redirectValidation.message, - instructions: redirectValidation.message, - }), - ); - return ""; - } - - const requestId = randomUUID(); - const remoteRedirectUri = `${requestOrigin}${OPKSSH_CALLBACK_PATH}`; - - sshLogger.info("Starting OPKSSH auth session", { - operation: "opkssh_start_auth_remote_redirect_uri", - requestId, - userId, - hostId, - requestOrigin, - remoteRedirectUri, - providerAliases: (configCheck.providers || []).map((p) => p.alias), - }); - - const session: Partial = { - requestId, - userId, - hostId, - hostname, - localPort: 0, - callbackPort: 0, - remoteRedirectUri, - providers: configCheck.providers || [], - status: "starting", - ws, - stdoutBuffer: "", - privateKeyBuffer: "", - sshCertBuffer: "", - identity: {}, - createdAt: new Date(), - }; - - try { - const binaryPath = OPKSSHBinaryManager.getBinaryPath(); - const configPath = getOPKConfigPath(); - - const args = [ - "login", - "--print-key", - "--disable-browser-open", - `--config-path=${configPath}`, - `--remote-redirect-uri=${remoteRedirectUri}`, - ]; - - const opksshProcess = spawn(binaryPath, args, { - stdio: ["ignore", "pipe", "pipe"], - env: { - ...process.env, - }, - }); - session.process = opksshProcess; - - const cleanup = async () => { - await cleanupAuthSession(requestId); - }; - session.cleanup = cleanup; - - const timeout = setTimeout(async () => { - sshLogger.warn(`OPKSSH auth timeout for session ${requestId}`); - ws.send( - JSON.stringify({ - type: "opkssh_timeout", - requestId, - }), - ); - await cleanup(); - }, AUTH_TIMEOUT); - - session.approvalTimeout = timeout; - - ws.on("close", () => { - cleanup(); - }); - - activeAuthSessions.set(requestId, session as OPKSSHAuthSession); - - opksshProcess.stdout?.on("data", (data) => { - const output = data.toString(); - handleOPKSSHOutput(requestId, output); - }); - - opksshProcess.stderr?.on("data", async (data) => { - const stderr = data.toString(); - - if ( - stderr.includes("Opening browser to") || - stderr.includes("Open your browser to:") - ) { - handleOPKSSHOutput(requestId, stderr); - } - - if (stderr.includes("listening on")) { - handleOPKSSHOutput(requestId, stderr); - } - - const lowerStderr = stderr.toLowerCase(); - - // OPKSSH's openpubkey library rejects non-localhost `redirect_uris` at runtime - // with the distinctive message "redirectURI must be localhost". Surface that - // directly with actionable guidance. - if (lowerStderr.includes("redirecturi must be localhost")) { - sshLogger.warn("OPKSSH rejected non-localhost entry in redirect_uris", { - operation: "opkssh_stderr_redirect_uris_not_localhost", - requestId, - remoteRedirectUri, - stderrSnippet: stderr.slice(0, 500), - }); - ws.send( - JSON.stringify({ - type: "opkssh_config_error", - requestId, - error: - `OPKSSH rejected the local callback URI: every entry in 'redirect_uris' must be localhost.\n\n` + - `OPKSSH output:\n${stderr.trim()}\n\n` + - `The 'redirect_uris' config field is OPKSSH's LOCAL listener — it is not the public Termix callback. ` + - `Remove any non-localhost entries from redirect_uris (or delete the whole block to use OPKSSH's ` + - `defaults of :3000, :10001, :11110). Register the public Termix callback URL with your OAuth ` + - `provider instead, Termix passes it to OPKSSH automatically via --remote-redirect-uri.`, - instructions: - "See documentation: https://docs.termix.site/features/authentication/opkssh", - }), - ); - await cleanup(); - return; - } - - // Generic redirect-uri/mismatch errors (OAuth provider side, OPKSSH config side, etc.) - const genericRedirectIndicators = [ - "redirect_uri", - "redirect uri", - "invalid redirect", - "no matching redirect", - "allowed redirect", - "mismatching redirection", - ]; - const hasGenericRedirectError = genericRedirectIndicators.some((s) => - lowerStderr.includes(s), - ); - - if (hasGenericRedirectError) { - sshLogger.warn("OPKSSH stderr reported redirect_uri error", { - operation: "opkssh_stderr_redirect_uri_error", - requestId, - remoteRedirectUri, - stderrSnippet: stderr.slice(0, 500), - }); - ws.send( - JSON.stringify({ - type: "opkssh_config_error", - requestId, - error: - `OPKSSH or the OAuth provider rejected the redirect URI.\n\n` + - `Computed Termix callback URI (sent to provider): ${remoteRedirectUri}\n\n` + - `OPKSSH output:\n${stderr.trim()}\n\n` + - `Register '${remoteRedirectUri}' as an authorized redirect URI with your OAuth provider ` + - `(e.g. in Google Cloud Console → OAuth client). ` + - `Also confirm any 'redirect_uris' in your OPKSSH config contain ONLY localhost URLs.`, - instructions: - "See documentation: https://docs.termix.site/features/authentication/opkssh", - }), - ); - await cleanup(); - return; - } - - if ( - stderr.includes("provider not found") || - stderr.includes("config error") || - stderr.includes("invalid config") || - stderr.includes("config not found") - ) { - ws.send( - JSON.stringify({ - type: "opkssh_config_error", - requestId, - error: - "OPKSSH configuration error. Please verify your config file contains valid OIDC provider settings.", - instructions: - "See documentation: https://github.com/openpubkey/opkssh/blob/main/docs/config.md", - }), - ); - cleanup(); - } - - if ( - stderr.includes("level=error") || - stderr.includes("Error:") || - stderr.includes("failed") - ) { - const isXdgOpenError = stderr.includes('exec: "xdg-open"'); - if (!isXdgOpenError) { - if ( - stderr.includes("bind: address already in use") || - stderr.includes("error logging in") || - stderr.includes("failed to start") - ) { - await cleanup(); - } - } - } - }); - - opksshProcess.on("error", (error) => { - ws.send( - JSON.stringify({ - type: "opkssh_error", - requestId, - error: `OPKSSH process error: ${error.message}`, - }), - ); - cleanup(); - }); - - opksshProcess.on("exit", (code) => { - if (code !== 0 && session.status !== "completed") { - ws.send( - JSON.stringify({ - type: "opkssh_error", - requestId, - error: `OPKSSH process exited with code ${code}`, - }), - ); - } - cleanup(); - }); - - return requestId; - } catch (error) { - sshLogger.error(`Failed to start OPKSSH auth session`, error); - ws.send( - JSON.stringify({ - type: "opkssh_error", - requestId, - error: `Failed to start OPKSSH authentication: ${getErrorMessage(error)}`, - }), - ); - return ""; - } -} - -function handleOPKSSHOutput(requestId: string, output: string): void { - const session = activeAuthSessions.get(requestId); - if (!session) { - return; - } - - session.stdoutBuffer += output; - - const chooserUrlMatch = session.stdoutBuffer.match( - /(?:Opening browser to|Open your browser to:)\s*http:\/\/(?:localhost|127\.0\.0\.1):(\d+)\/chooser/, - ); - if (chooserUrlMatch && session.status === "starting") { - const actualPort = parseInt(chooserUrlMatch[1], 10); - const localChooserUrl = `http://127.0.0.1:${actualPort}/chooser`; - - session.localPort = actualPort; - - // No port rewrite any more: the terminal socket used to live on 30002, - // separate from the HTTP API, so a redirect URI derived from it pointed at - // the wrong server in direct dev mode. The terminal rides 30001 now. - const baseUrl = session.remoteRedirectUri.replace( - /\/host\/opkssh-callback$/, - "", - ); - const proxiedChooserUrl = `${baseUrl}/host/opkssh-chooser/${requestId}`; - - session.status = "waiting_for_auth"; - session.ws.send( - JSON.stringify({ - type: "opkssh_status", - requestId, - stage: "chooser", - url: proxiedChooserUrl, - providers: session.providers, - localUrl: localChooserUrl, - message: "Please authenticate in your browser", - }), - ); - } - - const callbackPortMatch = session.stdoutBuffer.match( - /listening on http:\/\/(?:127\.0\.0\.1|localhost):(\d+)\//, - ); - if (callbackPortMatch && !session.callbackPort) { - session.callbackPort = parseInt(callbackPortMatch[1], 10); - } - - if (output.includes("BEGIN OPENSSH PRIVATE KEY")) { - session.status = "authenticating"; - session.ws.send( - JSON.stringify({ - type: "opkssh_status", - requestId, - stage: "authenticating", - message: "Processing authentication...", - }), - ); - } - - const privateKeyMatch = session.stdoutBuffer.match( - /(-----BEGIN OPENSSH PRIVATE KEY-----[\s\S]*?-----END OPENSSH PRIVATE KEY-----)/, - ); - if (privateKeyMatch) { - session.privateKeyBuffer = privateKeyMatch[1].trim(); - } - - const certMatch = session.stdoutBuffer.match( - /(ecdsa-sha2-nistp256-cert-v01@openssh\.com\s+[A-Za-z0-9+/=]+|ssh-rsa-cert-v01@openssh\.com\s+[A-Za-z0-9+/=]+|ssh-ed25519-cert-v01@openssh\.com\s+[A-Za-z0-9+/=]+)/, - ); - if (certMatch) { - session.sshCertBuffer = certMatch[1].trim(); - } - - const identityMatch = session.stdoutBuffer.match( - /Email, sub, issuer, audience:\s*\n?\s*([^\s]+)\s+([^\s]+)\s+([^\s]+)\s+([^\s]+)/, - ); - if (identityMatch) { - session.identity = { - email: identityMatch[1], - sub: identityMatch[2], - issuer: identityMatch[3], - audience: identityMatch[4], - }; - } - - if (session.privateKeyBuffer && session.sshCertBuffer) { - if (!session.privateKeyBuffer.includes("BEGIN OPENSSH PRIVATE KEY")) { - sshLogger.error(`Invalid private key extracted [${requestId}]`, { - bufferPrefix: session.privateKeyBuffer.substring(0, 50), - }); - session.ws.send( - JSON.stringify({ - type: "opkssh_error", - requestId, - error: "Failed to extract valid private key from OPKSSH output", - }), - ); - return; - } - - if (!session.sshCertBuffer.match(/-cert-v01@openssh\.com/)) { - sshLogger.error(`Invalid SSH certificate extracted [${requestId}]`, { - bufferPrefix: session.sshCertBuffer.substring(0, 50), - }); - session.ws.send( - JSON.stringify({ - type: "opkssh_error", - requestId, - error: "Failed to extract valid SSH certificate from OPKSSH output", - }), - ); - return; - } - - storeOPKSSHToken(session); - } -} - -async function storeOPKSSHToken(session: OPKSSHAuthSession): Promise { - try { - const expiresAt = new Date(); - expiresAt.setHours(expiresAt.getHours() + 24); - - const userDataKey = DataCrypto.getUserDataKey(session.userId); - if (!userDataKey) { - throw new Error("User data key not found"); - } - - const tokenId = `opkssh-${session.userId}-${session.hostId}`; - - const encryptedCert = FieldCrypto.encryptField( - session.sshCertBuffer, - userDataKey, - tokenId, - "ssh_cert", - ); - const encryptedKey = FieldCrypto.encryptField( - session.privateKeyBuffer, - userDataKey, - tokenId, - "private_key", - ); - - await createCurrentOpksshTokenRepository().upsert({ - userId: session.userId, - hostId: session.hostId, - sshCert: encryptedCert, - privateKey: encryptedKey, - email: session.identity.email, - sub: session.identity.sub, - issuer: session.identity.issuer, - audience: session.identity.audience, - expiresAt: expiresAt.toISOString(), - }); - - session.status = "completed"; - session.ws.send( - JSON.stringify({ - type: "opkssh_completed", - requestId: session.requestId, - expiresAt: expiresAt.toISOString(), - }), - ); - - await session.cleanup(); - } catch (error) { - sshLogger.error( - `Failed to store OPKSSH token for session ${session.requestId}`, - error, - ); - session.ws.send( - JSON.stringify({ - type: "opkssh_error", - requestId: session.requestId, - error: "Failed to store authentication token", - }), - ); - await session.cleanup(); - } -} - -export async function getOPKSSHToken( - userId: string, - hostId: number, -): Promise<{ sshCert: string; privateKey: string } | null> { - try { - const repository = createCurrentOpksshTokenRepository(); - const tokenData = await repository.findByUserAndHost(userId, hostId); - - if (!tokenData) { - return null; - } - - const expiresAt = new Date(tokenData.expiresAt); - - if (expiresAt < new Date()) { - await repository.deleteByUserAndHost(userId, hostId); - return null; - } - - const userDataKey = DataCrypto.getUserDataKey(userId); - if (!userDataKey) { - throw new Error("User data key not found"); - } - - const tokenId = `opkssh-${userId}-${hostId}`; - const decryptedCert = FieldCrypto.decryptField( - tokenData.sshCert, - userDataKey, - tokenId, - "ssh_cert", - ); - const decryptedKey = FieldCrypto.decryptField( - tokenData.privateKey, - userDataKey, - tokenId, - "private_key", - ); - - await repository.updateLastUsed(userId, hostId); - - return { - sshCert: decryptedCert, - privateKey: decryptedKey, - }; - } catch (error) { - sshLogger.error(`Failed to retrieve OPKSSH token`, error); - return null; - } -} - -export async function deleteOPKSSHToken( - userId: string, - hostId: number, -): Promise { - await createCurrentOpksshTokenRepository().deleteByUserAndHost( - userId, - hostId, - ); -} - -export async function invalidateOPKSSHToken( - userId: string, - hostId: number, - reason: string, -): Promise { - try { - await createCurrentOpksshTokenRepository().deleteByUserAndHost( - userId, - hostId, - ); - } catch (error) { - sshLogger.error(`Failed to invalidate OPKSSH token`, { - userId, - hostId, - reason, - error, - }); - } -} - -export async function handleOAuthCallback( - requestId: string, - queryString: string, -): Promise<{ success: boolean; message?: string }> { - const session = activeAuthSessions.get(requestId); - - if (!session) { - return { success: false, message: "Invalid authentication session" }; - } - - try { - const callbackUrl = `http://127.0.0.1:${session.localPort}/login-callback?${queryString}`; - await axios.get(callbackUrl, { - timeout: 10000, - validateStatus: () => true, - }); - return { success: true }; - } catch { - session.ws.send( - JSON.stringify({ - type: "opkssh_error", - requestId, - error: "Failed to complete authentication", - }), - ); - await session.cleanup(); - return { success: false, message: "Authentication failed" }; - } -} - -async function cleanupAuthSession(requestId: string): Promise { - if (cleanupInProgress.has(requestId)) { - return; - } - - cleanupInProgress.add(requestId); - - try { - const session = activeAuthSessions.get(requestId); - if (!session) { - cleanupInProgress.delete(requestId); - return; - } - - if (session.approvalTimeout) { - clearTimeout(session.approvalTimeout); - } - - if (session.process) { - try { - session.process.kill("SIGTERM"); - await new Promise((resolve) => { - const killTimeout = setTimeout(() => { - if (session.process && !session.process.killed) { - session.process.kill("SIGKILL"); - } - resolve(); - }, 3000); - - session.process.once("exit", () => { - clearTimeout(killTimeout); - resolve(); - }); - }); - - await new Promise((resolve) => setTimeout(resolve, 1000)); - } catch (killError) { - sshLogger.warn( - `Failed to kill OPKSSH process for session ${requestId}`, - killError, - ); - } - } - - // Clean up any OAuth state mappings for this session - for (const [state, reqId] of oauthStateToRequestId.entries()) { - if (reqId === requestId) { - oauthStateToRequestId.delete(state); - } - } - - activeAuthSessions.delete(requestId); - } finally { - cleanupInProgress.delete(requestId); - } -} - -export function cancelAuthSession(requestId: string): void { - const session = activeAuthSessions.get(requestId); - if (session) { - session.cleanup(); - } -} - -export function getActiveAuthSession( - requestId: string, -): OPKSSHAuthSession | undefined { - return activeAuthSessions.get(requestId); -} - -export function getActiveSessionsForUser(userId: string): OPKSSHAuthSession[] { - const sessions: OPKSSHAuthSession[] = []; - for (const session of activeAuthSessions.values()) { - if (session.userId === userId) { - sessions.push(session); - } - } - return sessions; -} - -export function getActiveSessionsAll(): OPKSSHAuthSession[] { - return Array.from(activeAuthSessions.values()); -} - -export function registerOAuthState(state: string, requestId: string): void { - oauthStateToRequestId.set(state, requestId); -} - -export function getRequestIdByOAuthState(state: string): string | undefined { - return oauthStateToRequestId.get(state); -} - -export function clearOAuthState(state: string): void { - oauthStateToRequestId.delete(state); -} - -export async function getUserIdFromRequest(req: { - cookies?: Record; - headers: Record; -}): Promise { - try { - const { AuthManager } = await import("../utils/auth-manager.js"); - const authManager = AuthManager.getInstance(); - - const token = - req.cookies?.jwt || req.headers.authorization?.replace("Bearer ", ""); - if (!token) { - return null; - } - - const decoded = await authManager.verifyJWTToken(token); - if (!decoded?.userId || decoded.pendingTOTP) return null; - return decoded.userId; - } catch { - return null; - } -} diff --git a/src/backend/hosts/step-ca-auth.ts b/src/backend/hosts/step-ca-auth.ts index b3ce0bc8e..fd49d0269 100644 --- a/src/backend/hosts/step-ca-auth.ts +++ b/src/backend/hosts/step-ca-auth.ts @@ -2,12 +2,7 @@ import { randomBytes } from "crypto"; import type { WebSocket } from "ws"; import { sshLogger } from "../utils/logger.js"; import { getErrorMessage } from "../utils/error-message.js"; -import { DataCrypto } from "../utils/data-crypto.js"; -import { FieldCrypto } from "../utils/field-crypto.js"; -import { - createCurrentOpksshTokenRepository, - createCurrentSettingsRepository, -} from "../database/repositories/factory.js"; +import { createCurrentSettingsRepository } from "../database/repositories/factory.js"; import { readStepCaPrivateAllowlist } from "../utils/step-ca-egress.js"; import { stepCaRuntime, @@ -31,9 +26,8 @@ import { /** * Step CA (smallstep) SSH user certificates through its OIDC provisioner. * - * Mirrors the OPKSSH flow and reuses its storage, WS messages and connect - * path; the only difference is how the certificate is obtained: no binary, - * just the CA's HTTP API plus one OIDC redirect back to Termix. + * The CA's HTTP API plus one OIDC redirect back to Termix. Issued + * certificates are kept in memory until they expire. */ export const STEP_CA_CALLBACK_PATH = "/host/step-ca-callback"; @@ -87,6 +81,37 @@ interface StepCaAuthSession { const sessions = new Map(); +interface IssuedCertificate { + sshCert: string; + privateKey: string; + expiresAt: number; +} + +const issued = new Map(); + +function certKey(userId: string, hostId: number): string { + return `${userId}:${hostId}`; +} + +/** The cached certificate for this user and host, while it is valid. */ +export function getStepCaCert( + userId: string, + hostId: number, +): { sshCert: string; privateKey: string } | null { + const key = certKey(userId, hostId); + const cert = issued.get(key); + if (!cert) return null; + if (cert.expiresAt <= Date.now()) { + issued.delete(key); + return null; + } + return { sshCert: cert.sshCert, privateKey: cert.privateKey }; +} + +export function invalidateStepCaCert(userId: string, hostId: number): void { + issued.delete(certKey(userId, hostId)); +} + function send(ws: WebSocket, message: object): void { try { ws.send(JSON.stringify(message)); @@ -112,7 +137,7 @@ export async function startStepCaAuth( const settings = await readStepCaSettings(); if (!settings) { send(ws, { - type: "opkssh_config_error", + type: "stepca_config_error", requestId: "", error: "Step CA is not configured. An administrator must set the CA URL, root fingerprint and OIDC provisioner under Admin Settings.", @@ -162,7 +187,7 @@ export async function startStepCaAuth( timeout: setTimeout(() => { const current = sessions.get(state); if (!current || current.completed || current.processing) return; - send(ws, { type: "opkssh_timeout", requestId: state }); + send(ws, { type: "stepca_timeout", requestId: state }); endSession(current); }, AUTH_TIMEOUT_MS), }; @@ -184,7 +209,7 @@ export async function startStepCaAuth( }); send(ws, { - type: "opkssh_status", + type: "stepca_status", requestId: state, stage: "chooser", label: "Step CA", @@ -205,7 +230,7 @@ export async function startStepCaAuth( hostId, }); send(ws, { - type: "opkssh_error", + type: "stepca_error", requestId: state, error: `Step CA: ${getErrorMessage(error)}`, }); @@ -221,9 +246,8 @@ export function cancelStepCaAuth(requestId: string): boolean { /** * Finishes the flow once the identity provider redirects back: exchanges - * the code, has the CA sign the key, stores the certificate the way OPKSSH - * does (same table, same encryption, same token id) and tells the terminal - * to reconnect. + * the code, has the CA sign the key, caches the certificate and tells the + * terminal to reconnect. */ export async function completeStepCaAuth( query: StepCaCallbackQuery, @@ -262,7 +286,7 @@ async function finishStepCaAuth( if (query.error || !query.code) { const message = query.error_description || query.error || "Sign-in failed"; send(session.ws, { - type: "opkssh_error", + type: "stepca_error", requestId: session.state, error: `Step CA: ${message}`, }); @@ -272,7 +296,7 @@ async function finishStepCaAuth( try { send(session.ws, { - type: "opkssh_status", + type: "stepca_status", requestId: session.state, stage: "authenticating", }); @@ -320,35 +344,15 @@ async function finishStepCaAuth( } const expiresAt = certificateInfo.validBefore; - const userDataKey = DataCrypto.getUserDataKey(session.userId); - if (!userDataKey) throw new Error("User data key not found"); - // Same token id as OPKSSH: getOPKSSHToken decrypts with it. - const tokenId = `opkssh-${session.userId}-${session.hostId}`; - await createCurrentOpksshTokenRepository().upsert({ - userId: session.userId, - hostId: session.hostId, - sshCert: FieldCrypto.encryptField( - certificate, - userDataKey, - tokenId, - "ssh_cert", - ), - privateKey: FieldCrypto.encryptField( - session.keyPair.privateKeyPem, - userDataKey, - tokenId, - "private_key", - ), - email, - sub: typeof claims.sub === "string" ? claims.sub : undefined, - issuer: typeof claims.iss === "string" ? claims.iss : undefined, - audience: typeof claims.aud === "string" ? claims.aud : undefined, - expiresAt: expiresAt.toISOString(), + issued.set(certKey(session.userId, session.hostId), { + sshCert: certificate, + privateKey: session.keyPair.privateKeyPem, + expiresAt: expiresAt.getTime(), }); session.completed = true; send(session.ws, { - type: "opkssh_completed", + type: "stepca_completed", requestId: session.state, expiresAt: expiresAt.toISOString(), }); @@ -362,7 +366,7 @@ async function finishStepCaAuth( }); const message = getErrorMessage(error); send(session.ws, { - type: "opkssh_error", + type: "stepca_error", requestId: session.state, error: `Step CA: ${message}`, }); diff --git a/src/backend/hosts/vault-signer-auth.ts b/src/backend/hosts/vault-signer-auth.ts index 4e0eba2f4..05a092914 100644 --- a/src/backend/hosts/vault-signer-auth.ts +++ b/src/backend/hosts/vault-signer-auth.ts @@ -1,6 +1,6 @@ // HashiCorp Vault SSH signer authentication. // -// Flow (mirrors the OPKSSH subsystem, but Vault-driven over HTTP): +// Flow (Vault-driven over HTTP): // 1. Generate an ephemeral SSH keypair (never persisted long-term). // 2. The user authenticates to Vault via an interactive OIDC flow // (auth//oidc/auth_url -> browser -> auth//oidc/callback), @@ -8,7 +8,7 @@ // 3. Vault's SSH secrets engine signs the ephemeral public key // (/sign/) -> short-lived OpenSSH certificate. // 4. The ephemeral private key + certificate are cached per-user (encrypted) -// until the certificate expires, then used to connect via setupOPKSSHCertAuth. +// until the certificate expires, then used to connect via applyCertificateAuth. // // No Vault tokens, AppRole secrets, or long-lived private keys are ever stored. // Vault connection SETTINGS live in shareable vault_profiles rows. diff --git a/src/backend/plugins/ctx-process.ts b/src/backend/plugins/ctx-process.ts new file mode 100644 index 000000000..9c5cbf395 --- /dev/null +++ b/src/backend/plugins/ctx-process.ts @@ -0,0 +1,212 @@ +/** + * ctx.process: programs on the Termix server, behind process:spawn. + * + * run starts a program without a shell and kills it on deactivate. + * ensureBinary gives a plugin a verified executable pinned to one SHA-256: + * a prebuilt copy (baked into the Docker image), the copy it downloaded + * before, or a fresh download. + */ + +import { spawn } from "node:child_process"; +import { createHash, randomUUID } from "node:crypto"; +import { createReadStream } from "node:fs"; +import fs from "node:fs/promises"; +import path from "node:path"; +import type { + PluginBinarySpec, + PluginProcess, + PluginProcessHandle, +} from "@termix/plugin-sdk/backend"; +import type { PluginManifest } from "@termix/plugin-sdk/manifest"; +import { assertCapability } from "./permissions.js"; +import type { DisposableBag } from "./disposables.js"; + +type AuditFn = ( + action: string, + details: string, + outcome: { success: boolean; errorMessage?: string }, +) => Promise; + +interface Deps { + manifest: PluginManifest; + bag: DisposableBag; + audit: AuditFn; + /** Where downloaded binaries go. Defaults to /plugins//bin. */ + binDir?: () => string; + /** Test seam for the download. */ + fetch?: typeof fetch; +} + +const SHA256_PATTERN = /^[a-f0-9]{64}$/; +const SAFE_NAME = /^[A-Za-z0-9._-]+$/; + +async function sha256Of(file: string): Promise { + try { + const hash = createHash("sha256"); + for await (const chunk of createReadStream(file)) hash.update(chunk); + return hash.digest("hex"); + } catch { + return null; + } +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export function createPluginProcess(deps: Deps): PluginProcess { + const pluginId = deps.manifest.id; + const declared = deps.manifest.capabilities; + const binDir = + deps.binDir ?? + (() => + path.join( + process.env.DATA_DIR ?? "./db/data", + "plugins", + pluginId, + "bin", + )); + const doFetch = deps.fetch ?? fetch; + + const audited = async ( + action: string, + details: string, + fn: () => Promise, + ): Promise => { + try { + await assertCapability(pluginId, "process:spawn", declared); + const result = await fn(); + await deps.audit(action, details, { success: true }); + return result; + } catch (error) { + await deps.audit(action, details, { + success: false, + errorMessage: errorMessage(error), + }); + throw error; + } + }; + + const run: PluginProcess["run"] = (file, args, options = {}) => + audited("process_run", `ran ${path.basename(file)}`, async () => { + const child = spawn(file, [...args], { + cwd: options.cwd, + env: { ...process.env, ...(options.env ?? {}) }, + stdio: ["ignore", "pipe", "pipe"], + shell: false, + }); + + let running = true; + const kill = (signal: "SIGTERM" | "SIGKILL" = "SIGTERM") => { + if (!running) return; + try { + child.kill(signal); + } catch { + // already gone + } + }; + const untrack = deps.bag.add( + () => kill("SIGKILL"), + `process ${path.basename(file)}`, + ); + + const timer = + options.timeoutMs && options.timeoutMs > 0 + ? setTimeout(() => kill("SIGKILL"), options.timeoutMs) + : null; + timer?.unref(); + + const exited = new Promise<{ + code: number | null; + signal: string | null; + }>((resolve, reject) => { + child.once("error", (error) => { + running = false; + if (timer) clearTimeout(timer); + untrack(); + reject(error); + }); + child.once("close", (code, signal) => { + running = false; + if (timer) clearTimeout(timer); + untrack(); + resolve({ code, signal }); + }); + }); + // A caller that never awaits exited must not see an unhandled rejection. + exited.catch(() => {}); + + child.stdout?.setEncoding("utf8"); + child.stderr?.setEncoding("utf8"); + + const handle: PluginProcessHandle = { + pid: child.pid, + onStdout: (listener) => { + child.stdout?.on("data", listener); + }, + onStderr: (listener) => { + child.stderr?.on("data", listener); + }, + exited, + kill, + }; + return handle; + }); + + const download = async ( + spec: PluginBinarySpec, + target: string, + ): Promise => { + await assertCapability(pluginId, "network:outbound", declared); + const url = new URL(spec.url); + if (url.protocol !== "https:") { + throw new Error("Binary downloads must use https"); + } + const response = await doFetch(url, { + redirect: "follow", + headers: { "User-Agent": "Termix" }, + }); + if (!response.ok || !response.body) { + throw new Error(`Download failed with HTTP ${response.status}`); + } + const data = Buffer.from(await response.arrayBuffer()); + const actual = createHash("sha256").update(data).digest("hex"); + if (actual !== spec.sha256.toLowerCase()) { + throw new Error( + `Checksum mismatch for ${spec.name}: expected ${spec.sha256}, got ${actual}`, + ); + } + const temp = `${target}.download-${randomUUID()}`; + await fs.writeFile(temp, data, { mode: 0o755 }); + await fs.rename(temp, target); + }; + + const ensureBinary: PluginProcess["ensureBinary"] = (spec) => + audited( + "process_ensure_binary", + `${spec.name} ${spec.version}`, + async () => { + if (!SAFE_NAME.test(spec.name)) { + throw new Error(`Invalid binary name "${spec.name}"`); + } + const expected = spec.sha256.toLowerCase(); + if (!SHA256_PATTERN.test(expected)) { + throw new Error(`Invalid SHA-256 for ${spec.name}`); + } + + for (const candidate of spec.prebuilt ?? []) { + if ((await sha256Of(candidate)) === expected) return candidate; + } + + const dir = binDir(); + await fs.mkdir(dir, { recursive: true }); + const target = path.join(dir, spec.name); + if ((await sha256Of(target)) === expected) return target; + + await download(spec, target); + return target; + }, + ); + + return { run, ensureBinary }; +} diff --git a/src/backend/plugins/ctx-ssh-auth.ts b/src/backend/plugins/ctx-ssh-auth.ts index dc46861fe..b4fddc84d 100644 --- a/src/backend/plugins/ctx-ssh-auth.ts +++ b/src/backend/plugins/ctx-ssh-auth.ts @@ -522,6 +522,44 @@ export function createPluginAuth({ manifest, bag, audit }: Deps): PluginAuth { record("auth_register_ssh", provider.type); }, + registerKeyboardInteractiveHandler: (handler) => { + requireDeclared( + contributes.keyboardInteractive, + handler.id, + "keyboardInteractive", + ); + // Synchronous hooks inside ssh2's callback, so only the declaration is + // checked, like ctx.http.router. Deactivate removes the handler. + const settingsFor = (host: SshConnectHost): Record => + host.pluginSettings?.[pluginId] ?? {}; + ensureCoreSshAuthProviders(); + const dispose = registerKeyboardInteractiveInterceptor({ + id: handler.id, + pluginId, + detect: (round, host) => { + const detected = handler.detect( + round, + host as PluginSshHost, + settingsFor(host), + ); + if (!detected) return null; + if (detected.kind === "browser") { + return { ...detected, id: handler.id, label: handler.label }; + } + return detected; + }, + autoAnswerPasswords: handler.autoAnswerPasswords + ? (host) => + handler.autoAnswerPasswords!( + host as PluginSshHost, + settingsFor(host), + ) === true + : undefined, + }); + bag.add(dispose, `keyboard-interactive handler "${handler.id}"`); + record("auth_register_keyboard_interactive", handler.id); + }, + registerLoginMethod: (method) => { requireDeclared(contributes.loginMethods, method.id, "loginMethods"); { diff --git a/src/backend/plugins/ctx.ts b/src/backend/plugins/ctx.ts index ead7417c8..83075079c 100644 --- a/src/backend/plugins/ctx.ts +++ b/src/backend/plugins/ctx.ts @@ -56,6 +56,7 @@ import { createPluginAuth, createPluginSsh } from "./ctx-ssh-auth.js"; import { createPluginHosts } from "./ctx-hosts.js"; import { createPluginSchedule } from "./schedule.js"; import { createPluginFetch, createPluginNotify } from "./ctx-notify.js"; +import { createPluginProcess } from "./ctx-process.js"; import { createPluginCredentials } from "./ctx-credentials.js"; import { isElectronIpcAvailable } from "../utils/electron-ipc-bridge.js"; @@ -821,6 +822,11 @@ export function createPluginContext( ssh: createPluginSsh({ manifest, bag: handle.bag, audit: auditCall }), notify: createPluginNotify({ manifest, audit: auditCall }), fetch: createPluginFetch({ manifest, audit: auditCall }), + process: createPluginProcess({ + manifest, + bag: handle.bag, + audit: auditCall, + }), auth: createPluginAuth({ manifest, bag: handle.bag, audit: auditCall }), desktop: { diff --git a/src/backend/plugins/http.ts b/src/backend/plugins/http.ts index cec8f117c..969c414b1 100644 --- a/src/backend/plugins/http.ts +++ b/src/backend/plugins/http.ts @@ -88,16 +88,20 @@ function normalizePublicPath(path: string): string { * to be public are usually keyed by a token ("/webhook/:token"). A parameter * matches exactly one segment and never a slash, so ":token" cannot swallow * the rest of the path and turn one declared route into a prefix hole. + * + * A trailing "/*" is the one deliberate prefix: it matches the path itself and + * anything below it, for a proxied page that loads its own subpaths. */ function compilePublicPath(path: string): RegExp { - const escaped = path + const prefix = path.endsWith("/*"); + const escaped = (prefix ? path.slice(0, -2) : path) .split("/") .map((segment) => { if (segment.startsWith(":")) return "[^/]+"; return segment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }) .join("/"); - return new RegExp(`^${escaped}$`); + return new RegExp(prefix ? `^${escaped}(?:/.*)?$` : `^${escaped}$`); } /** diff --git a/src/backend/starter.ts b/src/backend/starter.ts index d2ffa10c2..e90de2048 100644 --- a/src/backend/starter.ts +++ b/src/backend/starter.ts @@ -268,26 +268,6 @@ async function provisionLocalDesktopUserIfNeeded(): Promise { await provisionLocalDesktopUserIfNeeded(); } - import("./utils/opkssh-binary-manager.js").then( - ({ OPKSSHBinaryManager }) => { - OPKSSHBinaryManager.ensureBinary().catch((error) => { - const dataDir = - process.env.DATA_DIR || path.join(process.cwd(), "db", "data"); - systemLogger.warn( - "Failed to initialize OPKSSH binary - OPKSSH authentication will not be available", - { - operation: "opkssh_binary_init_failed", - error: getErrorMessage(error), - stack: error instanceof Error ? error.stack : undefined, - platform: process.platform, - arch: process.arch, - dataDir, - }, - ); - }); - }, - ); - const { serverReady } = await import("./database/database.js"); await serverReady; @@ -393,6 +373,10 @@ async function provisionLocalDesktopUserIfNeeded(): Promise { await import("./utils/crypto-migration/wake-on-lan-settings-migration.js"); await runWakeOnLanSettingsMigration(); + const { runWarpgateSettingsMigration } = + await import("./utils/crypto-migration/warpgate-settings-migration.js"); + await runWarpgateSettingsMigration(); + const { runSecretSourcesTokenMigration } = await import("./utils/crypto-migration/secret-sources-token-migration.js"); await runSecretSourcesTokenMigration(); diff --git a/src/backend/tests/database/repositories/opkssh-token-repository.test.ts b/src/backend/tests/database/repositories/opkssh-token-repository.test.ts deleted file mode 100644 index de7efde33..000000000 --- a/src/backend/tests/database/repositories/opkssh-token-repository.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { afterEach, describe, expect, it } from "vitest"; -import { TestSqliteDatabase } from "./test-support.js"; -import { OpksshTokenRepository } from "../../../database/repositories/opkssh-token-repository.js"; - -describe("OpksshTokenRepository", () => { - let adapter: TestSqliteDatabase | null = null; - - afterEach(async () => { - if (adapter) { - await adapter.close(); - adapter = null; - } - }); - - async function createRepository( - onWrite?: () => void | Promise, - ): Promise { - 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 opkssh_tokens ( - user_id, host_id, ssh_cert, private_key, email, expires_at - ) - VALUES - ('user-1', 1, 'cert-1', 'key-1', 'alice@example.com', '2099-01-01T00:00:00.000Z'), - ('user-1', 2, 'cert-2', 'key-2', 'alice2@example.com', '2099-01-01T00:00:00.000Z'), - ('user-2', 3, 'cert-3', 'key-3', 'bob@example.com', '2099-01-01T00:00:00.000Z'); - `); - - return new OpksshTokenRepository(context, onWrite); - } - - it("finds and upserts a token by user and host", async () => { - let writeCount = 0; - const repo = await createRepository(() => { - writeCount += 1; - }); - - const existing = await repo.findByUserAndHost("user-1", 1); - expect(existing?.sshCert).toBe("cert-1"); - - await repo.upsert({ - userId: "user-1", - hostId: 1, - sshCert: "new-cert", - privateKey: "new-key", - email: "new@example.com", - sub: "sub", - issuer: "issuer", - audience: "aud", - expiresAt: "2099-02-01T00:00:00.000Z", - createdAt: "2026-01-01T00:00:00.000Z", - }); - - const updated = await repo.findByUserAndHost("user-1", 1); - expect(updated).toMatchObject({ - sshCert: "new-cert", - privateKey: "new-key", - email: "new@example.com", - sub: "sub", - issuer: "issuer", - audience: "aud", - expiresAt: "2099-02-01T00:00:00.000Z", - createdAt: "2026-01-01T00:00:00.000Z", - }); - expect(writeCount).toBe(1); - }); - - it("updates last-used and deletes one token", async () => { - let writeCount = 0; - const repo = await createRepository(() => { - writeCount += 1; - }); - - expect( - await repo.updateLastUsed("user-1", 1, "2026-01-02T00:00:00.000Z"), - ).toBe(true); - expect(await repo.updateLastUsed("missing", 1)).toBe(false); - expect((await repo.findByUserAndHost("user-1", 1))?.lastUsed).toBe( - "2026-01-02T00:00:00.000Z", - ); - - expect(await repo.deleteByUserAndHost("user-1", 1)).toBe(true); - expect(await repo.deleteByUserAndHost("user-1", 1)).toBe(false); - expect(await repo.findByUserAndHost("user-1", 1)).toBeNull(); - expect(writeCount).toBe(2); - }); - - it("deletes tokens by user id", async () => { - let writeCount = 0; - const repo = await createRepository(() => { - writeCount += 1; - }); - - expect(await repo.deleteByUserId("user-1")).toBe(2); - expect(await repo.deleteByUserId("user-1")).toBe(0); - expect(await repo.findByUserAndHost("user-1", 1)).toBeNull(); - expect(await repo.findByUserAndHost("user-2", 3)).not.toBeNull(); - expect(writeCount).toBe(1); - }); -}); diff --git a/src/backend/tests/database/routes/host-compat-routes.test.ts b/src/backend/tests/database/routes/host-compat-routes.test.ts new file mode 100644 index 000000000..b32392f5b --- /dev/null +++ b/src/backend/tests/database/routes/host-compat-routes.test.ts @@ -0,0 +1,51 @@ +/** + * The OPKSSH redirect URI identity providers were registered with before + * 2.9 must keep reaching the plugin's callback, query intact. + */ + +import http from "node:http"; +import type { AddressInfo } from "node:net"; +import express from "express"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +const { registerHostAuthCompatRoutes } = + await import("../../../database/routes/host-compat-routes.js"); + +let server: http.Server; +let base: string; + +beforeEach(async () => { + const router = express.Router(); + registerHostAuthCompatRoutes(router); + const app = express(); + app.use("/host", router); + server = http.createServer(app); + await new Promise((resolve) => server.listen(0, resolve)); + base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; +}); + +afterEach(async () => { + await new Promise((resolve) => server.close(() => resolve())); +}); + +describe("the old OPKSSH callback", () => { + it("forwards to the plugin's callback with the query", async () => { + const response = await fetch( + `${base}/host/opkssh-callback?code=abc&state=xyz`, + { redirect: "manual" }, + ); + expect(response.status).toBe(307); + expect(response.headers.get("location")).toBe( + "/plugin-api/opkssh/callback?code=abc&state=xyz", + ); + }); + + it("keeps a subpath", async () => { + const response = await fetch(`${base}/host/opkssh-callback/req-1/done`, { + redirect: "manual", + }); + expect(response.headers.get("location")).toBe( + "/plugin-api/opkssh/callback/req-1/done", + ); + }); +}); diff --git a/src/backend/tests/hosts/connect/pipeline.test.ts b/src/backend/tests/hosts/connect/pipeline.test.ts index 0ab1f79ba..9b5670b04 100644 --- a/src/backend/tests/hosts/connect/pipeline.test.ts +++ b/src/backend/tests/hosts/connect/pipeline.test.ts @@ -18,10 +18,9 @@ vi.mock("ssh2", async (importOriginal) => { const mocks = vi.hoisted(() => ({ verifier: () => {}, createHostVerifier: vi.fn(), - getOPKSSHToken: vi.fn(), - invalidateOPKSSHToken: vi.fn(), - setupOPKSSHCertAuth: vi.fn(), - setupCACertAuth: vi.fn(), + getStepCaCert: vi.fn(), + invalidateStepCaCert: vi.fn(), + applyCertificateAuth: vi.fn(), getVaultCert: vi.fn(), deleteVaultCert: vi.fn(), applyAgentAuth: vi.fn(), @@ -41,13 +40,12 @@ vi.mock("../../../hosts/host-key-verifier.js", () => ({ createHostVerifier: mocks.createHostVerifier, }, })); -vi.mock("../../../hosts/opkssh-auth.js", () => ({ - getOPKSSHToken: mocks.getOPKSSHToken, - invalidateOPKSSHToken: mocks.invalidateOPKSSHToken, +vi.mock("../../../hosts/step-ca-auth.js", () => ({ + getStepCaCert: mocks.getStepCaCert, + invalidateStepCaCert: mocks.invalidateStepCaCert, })); -vi.mock("../../../hosts/opkssh-cert-auth.js", () => ({ - setupOPKSSHCertAuth: mocks.setupOPKSSHCertAuth, - setupCACertAuth: mocks.setupCACertAuth, +vi.mock("@termix/plugin-sdk/ssh-certs", () => ({ + applyCertificateAuth: mocks.applyCertificateAuth, })); vi.mock("../../../hosts/vault-signer-auth.js", () => ({ getVaultCert: mocks.getVaultCert, @@ -135,12 +133,9 @@ beforeEach(() => { config.agent = "agent-object"; return { socketPath: "/tmp/agent.sock" }; }); - mocks.setupOPKSSHCertAuth.mockImplementation(async (config) => { + mocks.applyCertificateAuth.mockImplementation(async (config) => { config.authHandler = "cert-handler"; }); - mocks.setupCACertAuth.mockImplementation(async (config) => { - config.authHandler = "ca-handler"; - }); }); describe("buildConnectConfig per auth type", () => { @@ -186,7 +181,7 @@ describe("buildConnectConfig per auth type", () => { expect(Buffer.isBuffer(config.privateKey)).toBe(true); expect(config.passphrase).toBe("secret"); expect(config.password).toBe("fallback"); - expect(mocks.setupCACertAuth).not.toHaveBeenCalled(); + expect(mocks.applyCertificateAuth).not.toHaveBeenCalled(); }); it("key with a CA certificate grafts the certificate", async () => { @@ -198,12 +193,15 @@ describe("buildConnectConfig per auth type", () => { certPublicKey: "ssh-ed25519-cert-v01@openssh.com AAAA", }), ); - expect(mocks.setupCACertAuth).toHaveBeenCalledOnce(); - expect(config.authHandler).toBe("ca-handler"); + expect(mocks.applyCertificateAuth).toHaveBeenCalledOnce(); + expect(mocks.applyCertificateAuth.mock.calls[0][2]).toMatchObject({ + certificate: "ssh-ed25519-cert-v01@openssh.com AAAA", + }); + expect(config.authHandler).toBe("cert-handler"); }); it("key with a failing CA certificate falls back to key only", async () => { - mocks.setupCACertAuth.mockRejectedValueOnce(new Error("bad cert")); + mocks.applyCertificateAuth.mockRejectedValueOnce(new Error("bad cert")); const { outcome } = await build( host({ authType: "key", key: plainKey, certPublicKey: "junk" }), ); @@ -275,27 +273,28 @@ describe("buildConnectConfig per auth type", () => { expect(config.tryKeyboard).toBe(true); }); - it("opkssh without a cached token asks for a sign-in", async () => { - mocks.getOPKSSHToken.mockResolvedValueOnce(null); - const { outcome } = await build(host({ authType: "opkssh" })); + // OPKSSH is a plugin-registered SSH auth provider now: see + // plugins/opkssh/tests/backend/provider.test.ts. + + it("stepca without a cached certificate asks for a sign-in", async () => { + mocks.getStepCaCert.mockReturnValueOnce(null); + const { outcome } = await build(host({ authType: "stepca" })); expect(outcome).toMatchObject({ status: "interaction-required", - interaction: "opkssh", + interaction: "stepca", }); - expect(mocks.getOPKSSHToken).toHaveBeenCalledWith("user-1", 7); + expect(mocks.getStepCaCert).toHaveBeenCalledWith("user-1", 7); }); - it("opkssh and stepca use the cached certificate", async () => { - for (const authType of ["opkssh", "stepca"]) { - mocks.getOPKSSHToken.mockResolvedValueOnce({ - privateKey: "k", - sshCert: "c", - }); - const { config, outcome } = await build(host({ authType })); - expect(outcome.status).toBe("ready"); - expect(config.authHandler).toBe("cert-handler"); - } - expect(mocks.setupOPKSSHCertAuth).toHaveBeenCalledTimes(2); + it("stepca uses the cached certificate", async () => { + mocks.getStepCaCert.mockReturnValueOnce({ + privateKey: "k", + sshCert: "c", + }); + const { config, outcome } = await build(host({ authType: "stepca" })); + expect(outcome.status).toBe("ready"); + expect(config.authHandler).toBe("cert-handler"); + expect(mocks.applyCertificateAuth).toHaveBeenCalledOnce(); }); it("vault needs a profile, then a cached certificate", async () => { @@ -466,18 +465,18 @@ describe("connectHost", () => { }); it("throws the auth outcome instead of connecting", async () => { - mocks.getOPKSSHToken.mockResolvedValueOnce(null); - const attempt = connectHost(host({ authType: "opkssh" }), { + mocks.getStepCaCert.mockReturnValue(null); + const attempt = connectHost(host({ authType: "stepca" }), { userId: "user-1", purpose: "tmux", }); await expect(attempt).rejects.toBeInstanceOf(SshConnectError); await expect( - connectHost(host({ authType: "opkssh" }), { + connectHost(host({ authType: "stepca" }), { userId: "user-1", purpose: "tmux", }).catch((error) => error.code), - ).resolves.toBe("opkssh-required"); + ).resolves.toBe("stepca-required"); }); it("goes through the jump chain and forwards to the target", async () => { @@ -629,22 +628,18 @@ describe("connectHost", () => { it("lets the provider react to an auth failure", async () => { FakeSshClient.nextBehaviour = ["auth-fail"]; - mocks.getOPKSSHToken.mockResolvedValueOnce({ + mocks.getStepCaCert.mockReturnValueOnce({ privateKey: "k", sshCert: "c", }); await expect( - connectHost(host({ authType: "opkssh" }), { + connectHost(host({ authType: "stepca" }), { userId: "user-1", purpose: "fleet", }), ).rejects.toThrow(/All configured authentication methods failed/); await vi.waitFor(() => - expect(mocks.invalidateOPKSSHToken).toHaveBeenCalledWith( - "user-1", - 7, - "SSH auth failed", - ), + expect(mocks.invalidateStepCaCert).toHaveBeenCalledWith("user-1", 7), ); }); diff --git a/src/backend/tests/hosts/step-ca-auth.test.ts b/src/backend/tests/hosts/step-ca-auth.test.ts index d3ffc4feb..1e4530e70 100644 --- a/src/backend/tests/hosts/step-ca-auth.test.ts +++ b/src/backend/tests/hosts/step-ca-auth.test.ts @@ -3,7 +3,6 @@ import type { WebSocket } from "ws"; const state = vi.hoisted(() => ({ sent: [] as Array>, - upsert: vi.fn(async () => undefined), parseInfo: { publicKeyLine: "ssh-ed25519 TESTKEY", principals: ["alice"], @@ -19,15 +18,6 @@ const state = vi.hoisted(() => ({ vi.mock("../../utils/logger.js", () => ({ sshLogger: { error: vi.fn(), warn: vi.fn(), info: vi.fn() }, })); -vi.mock("../../utils/data-crypto.js", () => ({ - DataCrypto: { getUserDataKey: () => Buffer.alloc(32, 1) }, -})); -vi.mock("../../utils/field-crypto.js", () => ({ - FieldCrypto: { - encryptField: (value: string, _key: Buffer, _id: string, field: string) => - `${field}:${value}`, - }, -})); vi.mock("../../database/repositories/factory.js", () => ({ createCurrentSettingsRepository: () => ({ get: async (key: string) => @@ -37,7 +27,6 @@ vi.mock("../../database/repositories/factory.js", () => ({ step_ca_provisioner: "oidc", })[key], }), - createCurrentOpksshTokenRepository: () => ({ upsert: state.upsert }), })); vi.mock("../../utils/step-ca-egress.js", () => ({ readStepCaPrivateAllowlist: async () => [], @@ -92,8 +81,13 @@ vi.mock("../../hosts/step-ca-runtime.js", () => ({ }, })); -const { cancelStepCaAuth, completeStepCaAuth, startStepCaAuth } = - await import("../../hosts/step-ca-auth.js"); +const { + cancelStepCaAuth, + completeStepCaAuth, + getStepCaCert, + invalidateStepCaCert, + startStepCaAuth, +} = await import("../../hosts/step-ca-auth.js"); function jwt(payload: object): string { return `x.${Buffer.from(JSON.stringify(payload)).toString("base64url")}.y`; @@ -124,7 +118,7 @@ async function start(): Promise<{ requestId: string; nonce: string }> { beforeEach(() => { state.sent.length = 0; - state.upsert.mockClear(); + invalidateStepCaCert("user-1", 7); state.runtimeComplete.mockClear(); state.command = null; state.remoteResult = null; @@ -147,9 +141,11 @@ describe("Step CA authentication", () => { ok: true, message: "Signed in. You can close this window.", }); - expect(state.upsert).toHaveBeenCalledWith( - expect.objectContaining({ userId: "user-1", hostId: 7 }), - ); + expect(getStepCaCert("user-1", 7)).toEqual({ + sshCert: "CERT", + privateKey: "PRIVATE", + }); + expect(state.sent.at(-1)).toMatchObject({ type: "stepca_completed" }); }); it("rejects a token without the requested nonce", async () => { @@ -159,7 +155,7 @@ describe("Step CA authentication", () => { const result = await completeStepCaAuth({ state: requestId, code: "code" }); expect(result.ok).toBe(false); expect(result.message).toMatch(/does not match/); - expect(state.upsert).not.toHaveBeenCalled(); + expect(getStepCaCert("user-1", 7)).toBeNull(); }); it("routes a callback through Redis to the instance holding the WebSocket", async () => { @@ -170,7 +166,7 @@ describe("Step CA authentication", () => { await vi.waitFor(() => expect(state.runtimeComplete).toHaveBeenCalled(), { timeout: 2000, }); - expect(state.upsert).toHaveBeenCalled(); + expect(getStepCaCert("user-1", 7)).not.toBeNull(); }); it("returns a result produced by another instance", async () => { diff --git a/src/backend/tests/plugins/ctx-process.test.ts b/src/backend/tests/plugins/ctx-process.test.ts new file mode 100644 index 000000000..139774e92 --- /dev/null +++ b/src/backend/tests/plugins/ctx-process.test.ts @@ -0,0 +1,197 @@ +import { createHash } from "node:crypto"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const h = vi.hoisted(() => ({ granted: new Set() })); + +vi.mock("../../plugins/permissions.js", async () => { + const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend"); + return { + assertCapability: async ( + pluginId: string, + capability: string, + declared: readonly string[], + ) => { + if (!declared.includes(capability) || !h.granted.has(capability)) { + throw new PluginCapabilityError(pluginId, capability); + } + }, + }; +}); + +const { createPluginProcess } = await import("../../plugins/ctx-process.js"); +const { DisposableBag } = await import("../../plugins/disposables.js"); +const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend"); + +const DECLARED = ["process:spawn", "network:outbound"]; + +function manifest(capabilities: string[] = DECLARED) { + return { + id: "fixture", + name: "Fixture", + version: "1.0.0", + capabilities, + } as never; +} + +function sha256(data: string): string { + return createHash("sha256").update(data).digest("hex"); +} + +let dir: string; +let audits: Array<{ action: string; success: boolean }>; + +function create( + options: { capabilities?: string[]; fetch?: typeof fetch } = {}, +) { + const bag = new DisposableBag("fixture"); + const process = createPluginProcess({ + manifest: manifest(options.capabilities), + bag, + audit: async (action, _details, outcome) => { + audits.push({ action, success: outcome.success }); + }, + binDir: () => path.join(dir, "bin"), + fetch: options.fetch, + }); + return { process, bag }; +} + +beforeEach(async () => { + h.granted = new Set(DECLARED); + audits = []; + dir = await fs.mkdtemp(path.join(os.tmpdir(), "ctx-process-")); +}); + +afterEach(async () => { + await fs.rm(dir, { recursive: true, force: true }); +}); + +describe("ctx.process.run", () => { + it("runs a program and streams its output", async () => { + const { process: proc } = create(); + const child = await proc.run(globalThis.process.execPath, [ + "-e", + "process.stdout.write('hello'); process.stderr.write('oops')", + ]); + let out = ""; + let err = ""; + child.onStdout((chunk) => (out += chunk)); + child.onStderr((chunk) => (err += chunk)); + await expect(child.exited).resolves.toEqual({ code: 0, signal: null }); + expect(out).toBe("hello"); + expect(err).toBe("oops"); + expect(audits).toContainEqual({ action: "process_run", success: true }); + }); + + it("refuses without process:spawn, and audits the refusal", async () => { + h.granted = new Set(); + const { process: proc } = create(); + await expect( + proc.run(globalThis.process.execPath, ["-e", ""]), + ).rejects.toBeInstanceOf(PluginCapabilityError); + expect(audits).toContainEqual({ action: "process_run", success: false }); + }); + + it("kills what is still running on deactivate", async () => { + const { process: proc, bag } = create(); + const child = await proc.run(globalThis.process.execPath, [ + "-e", + "setInterval(() => {}, 1000)", + ]); + await bag.disposeAll(); + const exit = await child.exited; + expect(exit.code === null || exit.signal !== null).toBe(true); + }); +}); + +describe("ctx.process.ensureBinary", () => { + const contents = "#!/bin/sh\necho hi\n"; + + it("uses a prebuilt copy whose checksum matches, without downloading", async () => { + const prebuilt = path.join(dir, "prebuilt-tool"); + await fs.writeFile(prebuilt, contents); + const fetch = vi.fn(); + const { process: proc } = create({ fetch: fetch as never }); + await expect( + proc.ensureBinary({ + name: "tool", + version: "1", + url: "https://example.test/tool", + sha256: sha256(contents), + prebuilt: [path.join(dir, "missing"), prebuilt], + }), + ).resolves.toBe(prebuilt); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("downloads once, verifies it and reuses the copy", async () => { + const fetch = vi.fn(async () => new Response(contents)); + const { process: proc } = create({ fetch: fetch as never }); + const spec = { + name: "tool", + version: "1", + url: "https://example.test/tool", + sha256: sha256(contents), + }; + const first = await proc.ensureBinary(spec); + expect(first).toBe(path.join(dir, "bin", "tool")); + expect(await fs.readFile(first, "utf8")).toBe(contents); + await proc.ensureBinary(spec); + expect(fetch).toHaveBeenCalledOnce(); + }); + + it("refuses a download whose checksum does not match", async () => { + const fetch = vi.fn(async () => new Response("tampered")); + const { process: proc } = create({ fetch: fetch as never }); + await expect( + proc.ensureBinary({ + name: "tool", + version: "1", + url: "https://example.test/tool", + sha256: sha256(contents), + prebuilt: [], + }), + ).rejects.toThrow(/Checksum mismatch/); + await expect(fs.access(path.join(dir, "bin", "tool"))).rejects.toThrow(); + }); + + it("needs network:outbound only to download", async () => { + h.granted = new Set(["process:spawn"]); + const { process: proc } = create({ + fetch: vi.fn(async () => new Response(contents)) as never, + }); + await expect( + proc.ensureBinary({ + name: "tool", + version: "1", + url: "https://example.test/tool", + sha256: sha256(contents), + }), + ).rejects.toBeInstanceOf(PluginCapabilityError); + }); + + it("refuses plain http and odd file names", async () => { + const { process: proc } = create({ + fetch: vi.fn(async () => new Response(contents)) as never, + }); + await expect( + proc.ensureBinary({ + name: "tool", + version: "1", + url: "http://example.test/tool", + sha256: sha256(contents), + }), + ).rejects.toThrow(/https/); + await expect( + proc.ensureBinary({ + name: "../tool", + version: "1", + url: "https://example.test/tool", + sha256: sha256(contents), + }), + ).rejects.toThrow(/Invalid binary name/); + }); +}); diff --git a/src/backend/tests/plugins/ctx-ssh-auth.test.ts b/src/backend/tests/plugins/ctx-ssh-auth.test.ts index 30e3d013c..a9f8dcef5 100644 --- a/src/backend/tests/plugins/ctx-ssh-auth.test.ts +++ b/src/backend/tests/plugins/ctx-ssh-auth.test.ts @@ -104,6 +104,8 @@ const { createPluginAuth, createPluginSsh } = const { DisposableBag } = await import("../../plugins/disposables.js"); const { getSshAuthProvider } = await import("../../hosts/connect/auth-provider-registry.js"); +const { classifyKeyboardInteractive } = + await import("../../hosts/connect/keyboard-interactive.js"); const { getLoginMethod, getSecondFactor } = await import("../../auth/registry.js"); const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend"); @@ -341,6 +343,101 @@ describe("ctx.auth", () => { expect(getSshAuthProvider("corp")).toBeUndefined(); }); + it("registers a keyboard-interactive handler that sees only its own host settings", async () => { + const bag = new DisposableBag("fixture"); + const auth = createPluginAuth({ + manifest: manifest(["auth:provide"], { keyboardInteractive: ["gate"] }), + bag, + audit: vi.fn(async () => {}), + }); + const seen: Array> = []; + auth.registerKeyboardInteractiveHandler({ + id: "gate", + label: "Gate", + detect: (round, _host, settings) => { + seen.push(settings); + return /gate/i.test(round.name) + ? { + kind: "browser", + url: "https://gate", + code: "AB12", + instructions: "", + } + : null; + }, + autoAnswerPasswords: (_host, settings) => settings.on === true, + }); + + const target = { + id: 1, + ip: "10.0.0.1", + port: 22, + username: "root", + password: "pw", + pluginSettings: { fixture: { on: true }, other: { secret: "x" } }, + }; + expect( + classifyKeyboardInteractive( + { name: "Gate sign-in", instructions: "", prompts: [] }, + target, + ), + ).toEqual({ + kind: "browser", + id: "gate", + label: "Gate", + url: "https://gate", + code: "AB12", + instructions: "", + }); + expect(seen[0]).toEqual({ on: true }); + expect( + classifyKeyboardInteractive( + { + name: "", + instructions: "", + prompts: [{ prompt: "Password:", echo: false }], + }, + target, + ), + ).toEqual({ kind: "auto", responses: ["pw"] }); + + await bag.disposeAll(); + expect( + classifyKeyboardInteractive( + { name: "Gate sign-in", instructions: "", prompts: [] }, + target, + ).kind, + ).not.toBe("browser"); + }); + + it("refuses a keyboard-interactive handler that is not declared", () => { + const auth = createPluginAuth({ + manifest: manifest(["auth:provide"], { keyboardInteractive: ["gate"] }), + bag: new DisposableBag("fixture"), + audit: vi.fn(async () => {}), + }); + expect(() => + auth.registerKeyboardInteractiveHandler({ + id: "other", + label: "Other", + detect: () => null, + }), + ).toThrow(/contributes\.auth\.keyboardInteractive/); + + const undeclared = createPluginAuth({ + manifest: manifest([], { keyboardInteractive: ["gate"] }), + bag: new DisposableBag("fixture"), + audit: vi.fn(async () => {}), + }); + expect(() => + undeclared.registerKeyboardInteractiveHandler({ + id: "gate", + label: "Gate", + detect: () => null, + }), + ).toThrow(PluginCapabilityError); + }); + it("registers login methods and second factors under the plugin, and records enrolment", async () => { h.granted = new Set(["auth:provide"]); const bag = new DisposableBag("fixture"); diff --git a/src/backend/tests/plugins/http.test.ts b/src/backend/tests/plugins/http.test.ts index 85dbe15c9..eb3029b95 100644 --- a/src/backend/tests/plugins/http.test.ts +++ b/src/backend/tests/plugins/http.test.ts @@ -234,6 +234,24 @@ describe("ctx.http authentication", () => { expect(response.status).toBe(401); }); + it("opens everything below a trailing /* and nothing beside it", async () => { + const router = http.createPluginRouter({ + manifest: manifest(), + options: { public: ["/chooser/:id/*"] }, + reportError: () => {}, + }); + router.use("/chooser", (_req, res) => res.json({ ok: true })); + router.get("/chooserx/a", (_req, res) => res.json({ ok: true })); + + const base = await startServer(); + const root = `${base}/plugin-api/sample-plugin`; + + expect((await fetch(`${root}/chooser/abc`)).status).toBe(200); + expect((await fetch(`${root}/chooser/abc/select/x`)).status).toBe(200); + expect((await fetch(`${root}/chooser`)).status).toBe(401); + expect((await fetch(`${root}/chooserx/a`)).status).toBe(401); + }); + it("gives the plugin the user core authenticated, not one the caller named", async () => { const router = http.createPluginRouter({ manifest: manifest(), diff --git a/src/backend/tests/utils/opkssh-binary-manager.test.ts b/src/backend/tests/utils/opkssh-binary-manager.test.ts deleted file mode 100644 index a58f5756c..000000000 --- a/src/backend/tests/utils/opkssh-binary-manager.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import crypto from "crypto"; -import { promises as fs } from "fs"; -import os from "os"; -import path from "path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { OPKSSHBinaryManager } from "../../utils/opkssh-binary-manager.js"; - -// Mirrors the manager's own platform/arch mapping so the test's fake release -// asset matches whatever OS and architecture actually run the suite. -const OS_MAP: Record = { - win32: "windows", - linux: "linux", - darwin: "osx", -}; -const ARCH_MAP: Record = { - x64: "amd64", - arm64: "arm64", -}; -const mappedOs = OS_MAP[process.platform] ?? process.platform; -const mappedArch = ARCH_MAP[process.arch] ?? process.arch; -const binaryName = - process.platform === "win32" - ? `opkssh-${mappedOs}-${mappedArch}.exe` - : `opkssh-${mappedOs}-${mappedArch}`; -let dataDir: string; - -beforeEach(async () => { - dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "termix-opkssh-")); - process.env.DATA_DIR = dataDir; - process.env.OPKSSH_VERSION = "v-test"; -}); - -afterEach(async () => { - vi.unstubAllGlobals(); - delete process.env.DATA_DIR; - delete process.env.OPKSSH_VERSION; - delete process.env.OPKSSH_SHA256; - await fs.rm(dataDir, { recursive: true, force: true }); -}); - -function mockRelease(binary: Buffer): void { - vi.stubGlobal( - "fetch", - vi - .fn() - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - tag_name: "v-test", - assets: [ - { name: binaryName, browser_download_url: "https://asset.test" }, - ], - }), - { status: 200 }, - ), - ) - .mockResolvedValueOnce(new Response(binary, { status: 200 })), - ); -} - -describe("OPKSSHBinaryManager download integrity", () => { - it("installs a release only after its configured checksum matches", async () => { - const binary = Buffer.from("verified-opkssh-binary"); - process.env.OPKSSH_SHA256 = crypto - .createHash("sha256") - .update(binary) - .digest("hex"); - mockRelease(binary); - - await OPKSSHBinaryManager.downloadBinary(); - - const installDir = path.join(dataDir, "opkssh"); - expect(await fs.readFile(path.join(installDir, binaryName))).toEqual( - binary, - ); - expect( - await fs.readFile(path.join(installDir, "version.txt"), "utf8"), - ).toBe("v-test"); - expect( - await fs.readFile(path.join(installDir, "checksum.txt"), "utf8"), - ).toBe(process.env.OPKSSH_SHA256); - }); - - it("rejects a mismatched release without replacing the installed binary", async () => { - const installDir = path.join(dataDir, "opkssh"); - const binaryPath = path.join(installDir, binaryName); - await fs.mkdir(installDir, { recursive: true }); - await fs.writeFile(binaryPath, "known-good"); - process.env.OPKSSH_SHA256 = "0".repeat(64); - mockRelease(Buffer.from("tampered")); - - await expect(OPKSSHBinaryManager.downloadBinary()).rejects.toThrow( - "checksum mismatch", - ); - expect(await fs.readFile(binaryPath, "utf8")).toBe("known-good"); - }); -}); diff --git a/src/backend/tests/utils/opkssh-config-migration.test.ts b/src/backend/tests/utils/opkssh-config-migration.test.ts new file mode 100644 index 000000000..2098c08be --- /dev/null +++ b/src/backend/tests/utils/opkssh-config-migration.test.ts @@ -0,0 +1,96 @@ +/** + * The OPKSSH config move. A 2.8 install's config must reach the opkssh + * plugin's folder, the old file must stay for a downgrade, and the install + * must keep the redirect URI its identity providers know. Running it twice + * must not overwrite anything. + */ + +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ + settings: new Map(), + pluginInstalled: true, +})); + +vi.mock("../../database/repositories/factory.js", () => ({ + createCurrentPluginRepository: () => ({ + findById: async (id: string) => + state.pluginInstalled && id === "opkssh" ? { id } : null, + }), + createCurrentPluginSettingsRepository: () => ({ + get: async (_p: string, _s: string, _id: string | null, key: string) => + state.settings.has(key) ? { key, value: state.settings.get(key) } : null, + set: async ( + _p: string, + _s: string, + _id: string | null, + key: string, + value: string | null, + ) => { + state.settings.set(key, value); + }, + }), +})); + +vi.mock("../../utils/logger.js", () => ({ + databaseLogger: { info: vi.fn(), warn: vi.fn() }, +})); + +import { runOpksshConfigMigration } from "../../utils/crypto-migration/opkssh-config-migration.js"; + +let dataDir: string; +const legacy = () => path.join(dataDir, ".opk", "config.yml"); +const target = () => path.join(dataDir, "plugins", "opkssh", "config.yml"); + +beforeEach(async () => { + state.settings = new Map(); + state.pluginInstalled = true; + dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "opkssh-config-")); +}); + +afterEach(async () => { + await fs.rm(dataDir, { recursive: true, force: true }); +}); + +describe("runOpksshConfigMigration", () => { + it("copies the config, keeps the old one and the old redirect URI, once", async () => { + await fs.mkdir(path.dirname(legacy()), { recursive: true }); + await fs.writeFile(legacy(), "providers:\n - alias: google\n"); + + expect(await runOpksshConfigMigration(dataDir)).toEqual({ + copied: true, + legacyCallback: true, + }); + expect(await fs.readFile(target(), "utf8")).toContain("alias: google"); + expect(await fs.readFile(legacy(), "utf8")).toContain("alias: google"); + expect(state.settings.get("legacyCallback")).toBe("true"); + + await fs.writeFile(target(), "providers:\n - alias: edited\n"); + state.settings.set("legacyCallback", "false"); + expect(await runOpksshConfigMigration(dataDir)).toEqual({ + copied: false, + legacyCallback: false, + }); + expect(await fs.readFile(target(), "utf8")).toContain("alias: edited"); + expect(state.settings.get("legacyCallback")).toBe("false"); + }); + + it("does nothing on an install that never set up OPKSSH", async () => { + expect(await runOpksshConfigMigration(dataDir)).toEqual({ + copied: false, + legacyCallback: false, + }); + expect(state.settings.size).toBe(0); + }); + + it("waits for the plugin row", async () => { + state.pluginInstalled = false; + await fs.mkdir(path.dirname(legacy()), { recursive: true }); + await fs.writeFile(legacy(), "providers: []\n"); + await runOpksshConfigMigration(dataDir); + await expect(fs.access(target())).rejects.toThrow(); + }); +}); diff --git a/src/backend/tests/utils/warpgate-settings-migration.test.ts b/src/backend/tests/utils/warpgate-settings-migration.test.ts new file mode 100644 index 000000000..58ec7f0a4 --- /dev/null +++ b/src/backend/tests/utils/warpgate-settings-migration.test.ts @@ -0,0 +1,161 @@ +/** + * The Warpgate host settings migration. + * + * Every host with use_warpgate on, whatever shape the engine returned it in, + * and every pre-2.7 host still on auth type "warpgate", must come through + * into the warpgate plugin's useWarpgate host setting. Running it twice must + * change nothing. + */ + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ + pluginRows: [] as Array<{ + scope: string; + scopeId: string | null; + key: string; + value: string | null; + }>, + hostRows: [] as Array>, + statements: [] as string[], + pluginInstalled: true, + columnGone: false, +})); + +vi.mock("../../database/repositories/factory.js", () => ({ + createCurrentPluginRepository: () => ({ + findById: async (id: string) => + state.pluginInstalled && id === "warpgate" ? { 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, + ) => { + state.pluginRows.push({ scope, scopeId, key, value }); + }, + }), +})); + +vi.mock("../../utils/crypto-migration/raw-rows.js", () => ({ + selectRows: async (query: { queryChunks?: unknown[] }) => { + const text = JSON.stringify(query); + if (text.includes("use_warpgate") && state.columnGone) { + throw new Error("no such column: use_warpgate"); + } + return state.hostRows.filter( + (row) => !text.includes("WHERE") || row.auth_type === "warpgate", + ); + }, + runStatement: async (query: unknown) => { + const text = JSON.stringify(query); + state.statements.push(text); + const id = (query as { queryChunks: unknown[] }).queryChunks.find( + (chunk) => typeof chunk === "number", + ); + const row = state.hostRows.find((candidate) => candidate.id === id); + if (row) row.auth_type = "none"; + }, +})); + +vi.mock("../../utils/logger.js", () => ({ + databaseLogger: { info: vi.fn(), warn: vi.fn() }, +})); + +import { + runWarpgateSettingsMigration, + usesWarpgate, +} from "../../utils/crypto-migration/warpgate-settings-migration.js"; + +const enabled = (hostId: number) => + state.pluginRows.some( + (row) => + row.scopeId === String(hostId) && + row.key === "useWarpgate" && + row.value === "true", + ); + +beforeEach(() => { + state.pluginRows = []; + state.hostRows = []; + state.statements = []; + state.pluginInstalled = true; + state.columnGone = false; +}); + +describe("usesWarpgate", () => { + it("reads the flag in every shape the engines return", () => { + for (const flag of [true, 1, "1", "true"]) { + expect( + usesWarpgate({ id: 1, use_warpgate: flag, auth_type: "none" }), + ).toBe(true); + } + for (const flag of [false, 0, "0", null]) { + expect( + usesWarpgate({ id: 1, use_warpgate: flag, auth_type: "none" }), + ).toBe(false); + } + expect(usesWarpgate({ id: 1, auth_type: "warpgate" })).toBe(true); + }); +}); + +describe("runWarpgateSettingsMigration", () => { + it("moves every flagged host and fixes the old auth type, and a second run changes nothing", async () => { + state.hostRows = [ + { id: 1, use_warpgate: 1, auth_type: "password" }, + { id: 2, use_warpgate: 0, auth_type: "key" }, + { id: 3, use_warpgate: true, auth_type: "none" }, + { id: 4, use_warpgate: 0, auth_type: "warpgate" }, + ]; + + const first = await runWarpgateSettingsMigration(); + expect(first).toEqual({ + hostsMoved: 3, + hostsSkipped: 0, + authTypesFixed: 1, + }); + expect(enabled(1)).toBe(true); + expect(enabled(2)).toBe(false); + expect(enabled(3)).toBe(true); + expect(enabled(4)).toBe(true); + expect(state.hostRows[3].auth_type).toBe("none"); + + const rows = state.pluginRows.length; + const second = await runWarpgateSettingsMigration(); + // Host 4 is on auth type "none" now, so only the flagged two are seen. + expect(second).toEqual({ + hostsMoved: 0, + hostsSkipped: 2, + authTypesFixed: 0, + }); + expect(state.pluginRows).toHaveLength(rows); + }); + + it("still fixes old auth types on an install without the column", async () => { + state.columnGone = true; + state.hostRows = [{ id: 5, auth_type: "warpgate" }]; + await runWarpgateSettingsMigration(); + expect(enabled(5)).toBe(true); + expect(state.hostRows[0].auth_type).toBe("none"); + }); + + it("waits for the plugin row", async () => { + state.pluginInstalled = false; + state.hostRows = [{ id: 1, use_warpgate: 1, auth_type: "password" }]; + await runWarpgateSettingsMigration(); + expect(state.pluginRows).toHaveLength(0); + }); +}); diff --git a/src/backend/utils/crypto-migration/opkssh-config-migration.ts b/src/backend/utils/crypto-migration/opkssh-config-migration.ts new file mode 100644 index 000000000..a26f84e72 --- /dev/null +++ b/src/backend/utils/crypto-migration/opkssh-config-migration.ts @@ -0,0 +1,95 @@ +/** + * Moves a 2.8 OPKSSH setup into the opkssh plugin. + * + * The config file moves from DATA_DIR/.opk/config.yml to the plugin's own + * folder (DATA_DIR/plugins/opkssh/config.yml). The old file is copied, not + * moved, so a downgrade still finds it. An install that had a config also + * keeps sending the pre-2.9 redirect URI (/host/opkssh-callback), because + * that is the one registered with its identity providers: the plugin's + * legacyCallback admin setting is turned on. + * + * Idempotent: an existing target file or setting is never overwritten. + */ + +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { databaseLogger } from "../logger.js"; +import { + createCurrentPluginRepository, + createCurrentPluginSettingsRepository, +} from "../../database/repositories/factory.js"; + +const PLUGIN_ID = "opkssh"; + +export interface OpksshConfigMigrationResult { + copied: boolean; + legacyCallback: boolean; +} + +async function exists(file: string): Promise { + try { + await fs.access(file); + return true; + } catch { + return false; + } +} + +export async function runOpksshConfigMigration( + dataDir: string = process.env.DATA_DIR || + path.join(process.cwd(), "db", "data"), +): Promise { + const result: OpksshConfigMigrationResult = { + copied: false, + legacyCallback: false, + }; + + const legacyConfig = path.join(dataDir, ".opk", "config.yml"); + if (!(await exists(legacyConfig))) return result; + + try { + const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID); + if (!plugin) return result; + } catch { + return result; + } + + try { + const target = path.join(dataDir, "plugins", PLUGIN_ID, "config.yml"); + if (!(await exists(target))) { + await fs.mkdir(path.dirname(target), { recursive: true }); + await fs.copyFile(legacyConfig, target); + result.copied = true; + } + + const settings = createCurrentPluginSettingsRepository(); + const current = await settings.get( + PLUGIN_ID, + "admin", + null, + "legacyCallback", + ); + if (!current) { + await settings.set( + PLUGIN_ID, + "admin", + null, + "legacyCallback", + JSON.stringify(true), + ); + result.legacyCallback = true; + } + } catch (error) { + databaseLogger.warn("OPKSSH config migration failed", { + operation: "opkssh_config_migration", + error: error instanceof Error ? error.message : String(error), + }); + } + + if (result.copied) { + databaseLogger.info("Copied the OPKSSH config into the opkssh plugin", { + operation: "opkssh_config_migration", + }); + } + return result; +} diff --git a/src/backend/utils/crypto-migration/plugin-data-moves.ts b/src/backend/utils/crypto-migration/plugin-data-moves.ts index 213569eaa..dc8e6c750 100644 --- a/src/backend/utils/crypto-migration/plugin-data-moves.ts +++ b/src/backend/utils/crypto-migration/plugin-data-moves.ts @@ -6,7 +6,9 @@ */ import { runLdapProviderMigration } from "./ldap-provider-migration.js"; +import { runOpksshConfigMigration } from "./opkssh-config-migration.js"; export async function runPluginDataMoves(): Promise { await runLdapProviderMigration(); + await runOpksshConfigMigration(); } diff --git a/src/backend/utils/crypto-migration/warpgate-settings-migration.ts b/src/backend/utils/crypto-migration/warpgate-settings-migration.ts new file mode 100644 index 000000000..aaf53cb20 --- /dev/null +++ b/src/backend/utils/crypto-migration/warpgate-settings-migration.ts @@ -0,0 +1,131 @@ +/** + * Moves each host's Warpgate flag out of ssh_data and into the warpgate + * plugin's useWarpgate host setting. Hosts from before 2.7 that still carry + * auth_type "warpgate" get the setting too, and auth type "none", which is + * what that auth type meant. + * + * The column stays in the database, unused: core's drizzle migrations run + * before this does, so dropping it there would lose the data first. + * + * Idempotent: a host that already has the setting is skipped, so this is + * safe on every boot. + */ + +import { sql } from "drizzle-orm"; +import { databaseLogger } from "../logger.js"; +import { + createCurrentPluginRepository, + createCurrentPluginSettingsRepository, +} from "../../database/repositories/factory.js"; +import { runStatement, selectRows } from "./raw-rows.js"; + +const PLUGIN_ID = "warpgate"; +const SETTING_KEY = "useWarpgate"; + +interface LegacyHostRow { + id: number; + use_warpgate?: number | boolean | string | null; + auth_type: string | null; +} + +export function usesWarpgate(row: LegacyHostRow): boolean { + const flag = row.use_warpgate; + return ( + flag === true || + flag === 1 || + flag === "1" || + flag === "true" || + row.auth_type === "warpgate" + ); +} + +export interface WarpgateSettingsMigrationResult { + hostsMoved: number; + hostsSkipped: number; + authTypesFixed: number; +} + +async function legacyRows(): Promise { + try { + // Raw SQL: schema.ts no longer declares use_warpgate. + return await selectRows( + sql`SELECT id, use_warpgate, auth_type FROM ssh_data`, + ); + } catch { + // A fresh install never had the column. + try { + return await selectRows( + sql`SELECT id, auth_type FROM ssh_data WHERE auth_type = 'warpgate'`, + ); + } catch { + return []; + } + } +} + +export async function runWarpgateSettingsMigration(): Promise { + const result: WarpgateSettingsMigrationResult = { + hostsMoved: 0, + hostsSkipped: 0, + authTypesFixed: 0, + }; + + // plugin_settings has a foreign key to plugins: nothing to migrate into + // until the plugin row exists. + try { + const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID); + if (!plugin) return result; + } catch { + return result; + } + + const pluginSettings = createCurrentPluginSettingsRepository(); + + for (const row of await legacyRows()) { + if (!usesWarpgate(row)) continue; + const scopeId = String(row.id); + try { + const existing = await pluginSettings.get( + PLUGIN_ID, + "host", + scopeId, + SETTING_KEY, + ); + if (existing) { + result.hostsSkipped++; + } else { + await pluginSettings.set( + PLUGIN_ID, + "host", + scopeId, + SETTING_KEY, + JSON.stringify(true), + ); + result.hostsMoved++; + } + if (row.auth_type === "warpgate") { + await runStatement( + sql`UPDATE ssh_data SET auth_type = 'none' WHERE id = ${row.id}`, + ); + result.authTypesFixed++; + } + } catch (error) { + databaseLogger.warn( + "Warpgate host settings migration failed for a host", + { + operation: "warpgate_settings_migration", + hostId: row.id, + error: error instanceof Error ? error.message : String(error), + }, + ); + } + } + + if (result.hostsMoved > 0) { + databaseLogger.info( + `Moved the Warpgate flag for ${result.hostsMoved} host(s) into plugin settings`, + { operation: "warpgate_settings_migration" }, + ); + } + return result; +} diff --git a/src/backend/utils/field-crypto.ts b/src/backend/utils/field-crypto.ts index 9ff60f35c..dc2ea890e 100644 --- a/src/backend/utils/field-crypto.ts +++ b/src/backend/utils/field-crypto.ts @@ -39,7 +39,6 @@ class FieldCrypto { // Channel configs hold ntfy tokens, webhook auth headers and Discord // webhook URLs, which are credentials like any other. notification_channels: new Set(["config"]), - opkssh_tokens: new Set(["sshCert", "privateKey"]), termix_identity_ca: new Set(["privateKey"]), vault_tokens: new Set(["sshCert", "privateKey"]), }; diff --git a/src/backend/utils/opkssh-binary-manager.ts b/src/backend/utils/opkssh-binary-manager.ts deleted file mode 100644 index aa0a9b8d4..000000000 --- a/src/backend/utils/opkssh-binary-manager.ts +++ /dev/null @@ -1,353 +0,0 @@ -import { getErrorMessage } from "./error-message.js"; -import { createWriteStream, promises as fs } from "fs"; -import crypto from "crypto"; -import path from "path"; -import { pipeline } from "stream/promises"; -import { systemLogger } from "./logger.js"; - -const OPKSSH_REPO = "openpubkey/opkssh"; -const DEFAULT_OPKSSH_VERSION = "v0.16.0"; -const DEFAULT_CHECKSUMS: Record = { - "opkssh-linux-amd64": - "c018c3e7baf98612b923e742dd87be38650bf61e3b755fb2bc90de177568b1bf", - "opkssh-linux-arm64": - "9dd10c2b6ce99cde18e52c054877ca014134b291fd82afe71741c68db4f83d44", - "opkssh-osx-amd64": - "e1ccddb4a73c7dd24e0677e9c933462b954dde9a151fcd96c8ee7ba83bc3f146", - "opkssh-osx-arm64": - "be279812cc4d44a28f8cb6eef4b13515fae16b6d00ae61e21630e0c061b02cbd", - "opkssh-windows-amd64.exe": - "db8991ceaac7ac224b704510ca6fba2114998291d4283de4bc2d3b8efa66ad07", - "opkssh-windows-arm64.exe": - "c35352dc2d12ef3775b280aa85dc1e97ff90cd8ad4beb62c3e2357fdf80ba5af", -}; - -function getBinaryDir(): string { - const dataDir = - process.env.DATA_DIR || path.join(process.cwd(), "db", "data"); - return path.join(dataDir, "opkssh"); -} - -function getVersionFile(): string { - return path.join(getBinaryDir(), "version.txt"); -} - -function getChecksumFile(): string { - return path.join(getBinaryDir(), "checksum.txt"); -} - -function getTrustedRelease(binaryName: string): { - version: string; - checksum: string; -} { - const version = process.env.OPKSSH_VERSION || DEFAULT_OPKSSH_VERSION; - const configuredChecksum = process.env.OPKSSH_SHA256?.trim().toLowerCase(); - const checksum = - version === DEFAULT_OPKSSH_VERSION - ? DEFAULT_CHECKSUMS[binaryName] - : configuredChecksum; - if (!checksum || !/^[0-9a-f]{64}$/.test(checksum)) { - throw new Error( - `OPKSSH ${version} has no trusted SHA-256 checksum for ${binaryName}`, - ); - } - return { version, checksum }; -} - -async function sha256File(filePath: string): Promise { - const contents = await fs.readFile(filePath); - return crypto.createHash("sha256").update(contents).digest("hex"); -} - -async function verifyChecksum( - filePath: string, - expectedChecksum: string, -): Promise { - const actualChecksum = await sha256File(filePath); - if (actualChecksum !== expectedChecksum) { - throw new Error(`OPKSSH checksum mismatch for ${path.basename(filePath)}`); - } -} - -async function replaceBinary( - temporaryPath: string, - binaryPath: string, -): Promise { - const backupPath = `${binaryPath}.previous`; - let backedUp = false; - try { - await fs.rename(binaryPath, backupPath); - backedUp = true; - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; - } - - try { - await fs.rename(temporaryPath, binaryPath); - if (backedUp) await fs.rm(backupPath, { force: true }); - } catch (error) { - if (backedUp) await fs.rename(backupPath, binaryPath); - throw error; - } -} - -function getBundledDir(): string { - return ( - process.env.OPKSSH_BUNDLED_DIR || path.join(process.cwd(), "opkssh-bundled") - ); -} - -interface GitHubAsset { - name: string; - browser_download_url: string; -} - -interface GitHubRelease { - tag_name: string; - assets: GitHubAsset[]; -} - -export class OPKSSHBinaryManager { - private static binaryPath: string | null = null; - - static async ensureBinary(): Promise { - if (this.binaryPath) { - return this.binaryPath; - } - - const binaryName = this.getBinaryName(); - const expectedPath = path.join(getBinaryDir(), binaryName); - - try { - await fs.access(expectedPath); - const storedChecksum = (await fs.readFile(getChecksumFile(), "utf8")) - .trim() - .toLowerCase(); - await verifyChecksum(expectedPath, storedChecksum); - const needsUpdate = await this.checkForUpdate(); - if (needsUpdate) { - systemLogger.info("Newer OPKSSH version available, updating...", { - operation: "opkssh_binary_update_start", - }); - await this.downloadBinary(); - } - - this.binaryPath = expectedPath; - return expectedPath; - } catch { - const usedBundled = await this.useBundledBinary(expectedPath); - if (usedBundled) { - this.binaryPath = expectedPath; - return expectedPath; - } - - systemLogger.info("OPKSSH binary not found, downloading...", { - operation: "opkssh_binary_download_start", - }); - await this.downloadBinary(); - this.binaryPath = expectedPath; - return expectedPath; - } - } - - private static async useBundledBinary( - expectedPath: string, - ): Promise { - const binaryName = this.getBinaryName(); - const bundledPath = path.join(getBundledDir(), binaryName); - const { version, checksum } = getTrustedRelease(binaryName); - - try { - await fs.access(bundledPath); - await verifyChecksum(bundledPath, checksum); - await fs.mkdir(getBinaryDir(), { recursive: true }); - await fs.copyFile(bundledPath, expectedPath); - await fs.chmod(expectedPath, 0o755); - await fs.writeFile(getChecksumFile(), checksum, "utf8"); - - await fs.writeFile(getVersionFile(), version, "utf8"); - - systemLogger.info("Using bundled OPKSSH binary", { - operation: "opkssh_binary_bundled_used", - path: expectedPath, - }); - return true; - } catch { - return false; - } - } - - static async downloadBinary(): Promise { - try { - await fs.mkdir(getBinaryDir(), { recursive: true }); - - const release = await this.getLatestRelease(); - - const asset = this.findMatchingAsset(release.assets); - if (!asset) { - throw new Error( - `No matching OPKSSH binary found for platform ${process.platform} ${process.arch}`, - ); - } - - const binaryName = this.getBinaryName(); - const binaryPath = path.join(getBinaryDir(), binaryName); - const temporaryPath = `${binaryPath}.download-${crypto.randomUUID()}`; - const { checksum } = getTrustedRelease(binaryName); - - const response = await fetch(asset.browser_download_url); - if (!response.ok) { - throw new Error(`Failed to download: ${response.statusText}`); - } - - if (!response.body) { - throw new Error("Response body is null"); - } - - try { - const fileStream = createWriteStream(temporaryPath, { mode: 0o600 }); - await pipeline( - response.body as unknown as NodeJS.ReadableStream, - fileStream, - ); - await verifyChecksum(temporaryPath, checksum); - await fs.chmod(temporaryPath, 0o755); - await replaceBinary(temporaryPath, binaryPath); - } catch (error) { - await fs.rm(temporaryPath, { force: true }); - throw error; - } - - await fs.writeFile(getVersionFile(), release.tag_name, "utf8"); - await fs.writeFile(getChecksumFile(), checksum, "utf8"); - - systemLogger.info( - `OPKSSH binary downloaded successfully to ${binaryPath}`, - { - operation: "opkssh_binary_download_complete", - path: binaryPath, - version: release.tag_name, - }, - ); - } catch (error) { - systemLogger.error("Failed to download OPKSSH binary", error, { - operation: "opkssh_binary_download_error", - }); - throw error; - } - } - - static getBinaryPath(): string { - if (!this.binaryPath) { - throw new Error( - "OPKSSH binary not initialized. Call ensureBinary() first.", - ); - } - return this.binaryPath; - } - - private static async checkForUpdate(): Promise { - try { - let localVersion: string | null = null; - try { - localVersion = await fs.readFile(getVersionFile(), "utf8"); - localVersion = localVersion.trim(); - } catch { - return true; - } - - const latestVersion = getTrustedRelease(this.getBinaryName()).version; - - if (localVersion !== latestVersion) { - return true; - } - - return false; - } catch (error) { - systemLogger.warn("Failed to check for OPKSSH updates", { - operation: "opkssh_update_check_failed", - error: getErrorMessage(error), - }); - return false; - } - } - - private static async getLatestRelease(): Promise { - const { version } = getTrustedRelease(this.getBinaryName()); - const url = `https://api.github.com/repos/${OPKSSH_REPO}/releases/tags/${encodeURIComponent(version)}`; - const response = await fetch(url, { - headers: { - "User-Agent": "Termix", - }, - }); - - if (!response.ok) { - throw new Error(`Failed to fetch release info: ${response.statusText}`); - } - - return (await response.json()) as GitHubRelease; - } - - private static findMatchingAsset(assets: GitHubAsset[]): GitHubAsset | null { - const platform = process.platform; - const arch = process.arch; - - const osMap: Record = { - win32: "windows", - linux: "linux", - darwin: "osx", - }; - - const archMap: Record = { - x64: "amd64", - arm64: "arm64", - }; - - const mappedOs = osMap[platform]; - const mappedArch = archMap[arch]; - - if (!mappedOs || !mappedArch) { - return null; - } - - const patterns = [ - `opkssh-${mappedOs}-${mappedArch}.exe`, - `opkssh-${mappedOs}-${mappedArch}`, - `opkssh_${mappedOs}_${mappedArch}.exe`, - `opkssh_${mappedOs}_${mappedArch}`, - ]; - - for (const pattern of patterns) { - const asset = assets.find( - (a) => a.name.toLowerCase() === pattern.toLowerCase(), - ); - if (asset) { - return asset; - } - } - - return null; - } - - private static getBinaryName(): string { - const platform = process.platform; - const arch = process.arch; - - const osMap: Record = { - win32: "windows", - linux: "linux", - darwin: "osx", - }; - - const archMap: Record = { - x64: "amd64", - arm64: "arm64", - }; - - const mappedOs = osMap[platform] || platform; - const mappedArch = archMap[arch] || arch; - - const extension = platform === "win32" ? ".exe" : ""; - return `opkssh-${mappedOs}-${mappedArch}${extension}`; - } -} diff --git a/src/types/index.ts b/src/types/index.ts index 460d6d85b..a1478d329 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -139,7 +139,6 @@ export type Host = { | "tailscale" | "agent" | "vault"; - useWarpgate?: boolean; shareSshAuth?: boolean; password?: string; key?: string; @@ -293,7 +292,6 @@ export interface HostData { | "tailscale" | "agent" | "vault"; - useWarpgate?: boolean; shareSshAuth?: boolean; password?: string; key?: File | string | null; diff --git a/src/types/ui-types.ts b/src/types/ui-types.ts index f47c3db36..057ad41be 100644 --- a/src/types/ui-types.ts +++ b/src/types/ui-types.ts @@ -33,7 +33,6 @@ export type Host = { | "tailscale" | "vault" | "agent"; - useWarpgate?: boolean; shareSshAuth?: boolean; credentialId?: string; vaultProfileId?: string; diff --git a/src/ui/auth/legacy-auth-ui.tsx b/src/ui/auth/legacy-auth-ui.tsx index e8ad8a16f..37d5e35b9 100644 --- a/src/ui/auth/legacy-auth-ui.tsx +++ b/src/ui/auth/legacy-auth-ui.tsx @@ -37,16 +37,6 @@ function CertificateAuthInfo({ ); } -function OpksshAuthEditor() { - return ( - - ); -} - function StepCaAuthEditor() { return ( void; onCancel: () => void; onOpenUrl: () => void; backgroundColor?: string; } -export function WarpgateDialog({ +/** + * A keyboard-interactive round finished in a browser: open the URL, compare + * the code there, then continue. + */ +export function BrowserSignInDialog({ isOpen, + label, url, - securityKey, + code, onContinue, onCancel, onOpenUrl, backgroundColor, -}: WarpgateDialogProps) { +}: BrowserSignInDialogProps) { const { t } = useTranslation(); const [copied, setCopied] = useState(false); @@ -44,7 +52,7 @@ export function WarpgateDialog({ return (
@@ -52,25 +60,27 @@ export function WarpgateDialog({

- {t("terminal.warpgateAuthRequired")} + {t("terminal.browserSignInRequired", { provider: label })}

-
-

- {t("terminal.warpgateSecurityKey")} -

-
-
- {securityKey} + {code && code !== "N/A" && ( +
+

+ {t("terminal.browserSignInCode")} +

+
+
+ {code} +
-
+ )}

- {t("terminal.warpgateAuthUrl")} + {t("terminal.browserSignInUrl")}

- {t("terminal.warpgateContinue")} + {t("terminal.browserSignInContinue")}