release-2.9.0 (#1532)

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

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

* Fix/backend optional sharp (#1455)

Fix/backend optional sharp

* feat: issue #1218 (#1218)

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

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

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

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

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

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

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

* feat: issue #1264 (#1264)

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

* feat: 2FA With FortiToken (#1288)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: sudo password not autofilling (#1248)

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

* fix: node-MainThread (#1300)

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

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

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

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

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

* chore: increment ver

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

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

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


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

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

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

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

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

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

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


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

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

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

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

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

Updates `@testing-library/user-event` from 14.6.5 to 14.6.7
- [Release notes](https://github.com/testing-library/user-event/releases)
- [Changelog](https://github.com/testing-library/user-event/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/user-event/compare/v14.6.5...v14.6.7)

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

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

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

Updates `eslint-plugin-react-refresh` from 0.5.4 to 0.5.7
- [Release notes](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/releases)
- [Changelog](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/compare/v0.5.4...v0.5.7)

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

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

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

---
updated-dependencies:
- dependency-name: "@codemirror/commands"
  dependency-version: 6.11.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@codemirror/search"
  dependency-version: 6.7.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@codemirror/view"
  dependency-version: 6.43.12
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@testing-library/dom"
  dependency-version: 10.4.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@testing-library/react"
  dependency-version: 16.3.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@testing-library/user-event"
  dependency-version: 14.6.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@types/ssh2"
  dependency-version: 1.15.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@vitejs/plugin-react"
  dependency-version: 6.1.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: cytoscape
  dependency-version: 3.34.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: eslint-plugin-react-refresh
  dependency-version: 0.5.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: i18next
  dependency-version: 26.4.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: prettier
  dependency-version: 3.9.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: react-i18next
  dependency-version: 17.0.14
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
...

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

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

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

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


Updates `@tanstack/react-virtual` from 3.14.10 to 3.14.13
- [Release notes](https://github.com/TanStack/virtual/releases)
- [Changelog](https://github.com/TanStack/virtual/blob/main/packages/react-virtual/CHANGELOG.md)
- [Commits](https://github.com/TanStack/virtual/commits/@tanstack/react-virtual@3.14.13/packages/react-virtual)

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

Updates `jose` from 6.2.9 to 6.2.12
- [Release notes](https://github.com/panva/jose/releases)
- [Changelog](https://github.com/panva/jose/blob/main/CHANGELOG.md)
- [Commits](https://github.com/panva/jose/compare/v6.2.9...v6.2.12)

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

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

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

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

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

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

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

* feat: add allowlisted host view for plugins

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

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

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

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

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

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

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

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

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

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

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

fix: stop plugin workers seeing plaintext credentials in error messages

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

  Authentication failed for root using password <SECRET>

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

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

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

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

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

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

fix: correct the nginx plugin-api location regex

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* feat: move fleets into a first-party plugin

* feat: move automations into a first-party plugin

* feat: remove legacy alert rules, superseded by automations

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

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

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

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

* feat: move workspaces into a first-party plugin

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

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

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

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

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

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

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

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

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

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

* feat: rebuild workspaces as the reference plugin

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

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

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

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

* feat: add the snippets plugin (B2)

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

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

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

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

* chore: regenerate drizzle migration journals for B5

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

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

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

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

* feat: convert file manager into a plugin

* feat: convert ssh tunnels into a plugin

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

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

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

* feat: convert serial console into the serial plugin

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

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

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

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

* feat: finish the docker plugin (B15)

* feat: finish the automations plugin (B17)

* feat: finish the ai plugin (B18)

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

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

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

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

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

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

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

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

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

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

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

* chore: lock the plugin boundary and remove 2.9.0 leftovers

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

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

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

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

* feat: improve UI loading time and jitter

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

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

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

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

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

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

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

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

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

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

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

* test(mysql): refresh reviewed plugin upgrade SQL

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

* fix(hosts): normalize imported jump host references

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

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

* test(mysql): refresh reviewed plugin upgrade SQL

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

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

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

* test(mysql): refresh reviewed plugin upgrade SQL

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

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

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

* test(mysql): refresh reviewed plugin upgrade SQL

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

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

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

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

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(dashboard): reuse translated host status labels

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

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

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

* test(mysql): refresh reviewed plugin upgrade SQL

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

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

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

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

* test(mysql): refresh reviewed plugin upgrade SQL

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

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

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

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

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

* test(mysql): refresh reviewed plugin upgrade SQL

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

* fix: keep rounded-full circular in production builds

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix(homepage): check allowlisted private service reachability

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

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

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

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

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

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

* chore: simplify plugin readmes, changelogs and manifest descriptions

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

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

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

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

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

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

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

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

* feat: add plugin sdk build to beta release

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

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

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

* chore: untrack plugin sdk ARCHITECTURE.md

* chore: remove references to plugin sdk ARCHITECTURE.md

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

* feat: add descriptions to plugin host enable toggles

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

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

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

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

* test(credentials): avoid flaky Ed25519 key generation

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

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

* test(credentials): avoid flaky Ed25519 key generation

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

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

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

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

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

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

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

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

* feat: improve quick connect system using plugin api

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

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

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

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

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

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

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

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

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

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

* feat: redesign user host defaults system

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* feat: bring back the alert feed homepage widget

* feat: pick target terminals for snippets again

* feat: share button on connection tabs again

* feat: recent servers list when linking the desktop app

* feat: list the fixed app shortcuts in keyboard shortcuts

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

* fix: translate leftover hardcoded ui text

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

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

* chore: openapi docs for host metrics manager routes

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

* fix: declare terminal.listSessions use for snippet targets

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

* feat: restore the alert feed homepage widget

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

* fix: docker container logs use the base background color

* chore: fix dependabot alerts and apply safe npm upgrades

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

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

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

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

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

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

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

* fix: add description to the web endpoint enable switch

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

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

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

* fix: remove duplicate top border on split screen menu

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

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

* fix: onboarding rerunning for existing users after upgrading

* fix: fleet detail buttons overflowing the sidebar

* fix: session log player scrolling off screen horizontally

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

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

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

* chore: update release notes

* fix: remote desktop tabs reconnecting on every tab switch

* fix: build plugin sdk before linting in release workflow

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

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

* chore: sync Crowdin translations for 2.9.0

---------

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

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
# Changelog
## 1.0.0
First release, bundled with Termix 2.9.0.
Adds the Vault login type. Termix signs you in to Vault with OIDC in the browser and gets a short-lived SSH certificate from Vault's SSH secrets engine.
+38
View File
@@ -0,0 +1,38 @@
# HashiCorp Vault
Connect to hosts with short-lived SSH certificates from HashiCorp Vault, after signing in to Vault with OIDC.
## Features
- Adds the Vault login type to the host editor.
- Signs you in to Vault with OIDC in the browser.
- Has Vault's SSH secrets engine sign a fresh key and keeps the certificate until it expires.
- Never stores a Vault token, AppRole secret or long-lived key.
## Setup
A profile holds the Vault address and namespace, the OIDC mount and role, the SSH secrets mount and signer role, the valid principals and the key type. Pick Vault as the login type in the host editor, then choose or create a profile there. Profiles sync between the desktop app and a server.
## Settings
### Admin
- Redirect URI: add `<base URL>/plugin-api/vault/oidc/callback` to `allowed_redirect_uris` in each Vault OIDC role.
- Use the old redirect URI: keep sending the 2.8 URI `<base URL>/vault/oidc/callback`. Upgraded installs keep this on until you turn it off.
### Host
- Vault signer profile: the profile this host uses.
## Permissions
- `vault.use`: Sign in to hosts with Vault. Admins and users have it by default.
- `vault.share`: Share Vault profiles with everyone. Only admins have it by default.
## Development
```bash
npm run build # build into dist/
npm run test # run this plugin's tests
npm run typecheck # type-check this plugin
```
+69
View File
@@ -0,0 +1,69 @@
{
"editor": {
"profile": "Vault Signer Profile",
"selectProfile": "Select a Vault profile...",
"hint": "Settings come from the shared profile; you'll sign in to Vault via OIDC when you connect. No secrets are stored.",
"docs": "View docs",
"sharedProfile": "{{name}} (shared)"
},
"profiles": {
"new": "New profile",
"manage": "Manage Vault profiles",
"name": "Friendly Name",
"vaultAddr": "Vault Address",
"namespace": "Namespace",
"oidcMount": "OIDC Auth Mount",
"oidcRole": "OIDC Role",
"sshMount": "SSH Secrets Mount",
"sshRole": "SSH Signer Role",
"validPrincipals": "Valid Principals",
"keyType": "Ephemeral Key Type",
"shareWithAll": "Share with all users",
"create": "Create profile",
"created": "Vault profile created",
"saved": "Vault profile saved",
"deleted": "Vault profile deleted",
"saveFailed": "Failed to save Vault profile",
"deleteFailed": "Failed to delete Vault profile",
"save": "Save profile",
"validationError": "Name, Vault address and SSH signer role are required",
"none": "No Vault profiles yet.",
"sharedBadge": "shared",
"edit": "Edit",
"delete": "Delete"
},
"hosts": {
"filterAuthVault": "Vault"
},
"dialog": {
"title": "Vault sign-in required",
"description": "A window has opened to sign in to HashiCorp Vault. Complete the sign-in there; this connection will continue automatically.",
"failed": "Vault authentication failed. Please try again.",
"reopen": "Reopen sign-in window"
},
"permissions": {
"use": {
"title": "Use Vault SSH signing",
"description": "Create Vault signer profiles and connect to hosts that use Vault."
},
"share": {
"title": "Share Vault profiles",
"description": "Mark a Vault signer profile as shared so every user can pick it."
}
},
"settings": {
"redirectUri": {
"label": "Redirect URI",
"description": "Add this URL to allowed_redirect_uris in each Vault OIDC role that Termix signs in with."
},
"legacyCallback": {
"label": "Use the old redirect URI",
"description": "Keep sending /vault/oidc/callback, the redirect URI used before 2.9. Turn this off once your Vault roles allow the new one."
},
"host": {
"profileId": {
"label": "Vault signer profile"
}
}
}
}
@@ -0,0 +1,69 @@
{
"editor": {
"profile": "Kluis Ondertekenaar Profiel",
"selectProfile": "Kies 'n kluisprofiel...",
"hint": "Instellings kom van die gedeelde profiel; jy sal by Vault aanmeld via OIDC wanneer jy koppel. Geen geheime word gestoor nie.",
"docs": "Bekyk dokumente",
"sharedProfile": "{{name}} (gedeel)"
},
"profiles": {
"new": "Nuwe profiel",
"manage": "Bestuur Vault-profiele",
"name": "Vriendelike Naam",
"vaultAddr": "Kluisadres",
"namespace": "Naamruimte",
"oidcMount": "OIDC-magtigingsmontering",
"oidcRole": "OIDC-rol",
"sshMount": "SSH-geheime berg",
"sshRole": "SSH-ondertekenaarrol",
"validPrincipals": "Geldige Prinsipale",
"keyType": "Efemere Sleuteltipe",
"shareWithAll": "Deel met alle gebruikers",
"create": "Skep profiel",
"created": "Kluisprofiel geskep",
"saved": "Kluisprofiel gestoor",
"deleted": "Kluisprofiel uitgevee",
"saveFailed": "Kon nie kluisprofiel stoor nie",
"deleteFailed": "Kon nie kluisprofiel uitvee nie",
"save": "Stoor profiel",
"validationError": "Naam, kluisadres en SSH-ondertekenaarsrol word vereis",
"none": "Geen Vault-profiele nog nie.",
"sharedBadge": "gedeel",
"edit": "Wysig",
"delete": "Vee uit"
},
"hosts": {
"filterAuthVault": "Kluis"
},
"dialog": {
"title": "Kluisaanmelding vereis",
"description": "'n Venster het oopgemaak om by HashiCorp Vault aan te meld. Voltooi die aanmelding daar; hierdie verbinding sal outomaties voortgaan.",
"failed": "Kluisverifikasie het misluk. Probeer asseblief weer.",
"reopen": "Maak aanmeldvenster weer oop"
},
"permissions": {
"use": {
"title": "Gebruik Vault SSH-ondertekening",
"description": "Skep Vault-ondertekenaarsprofiele en koppel aan gashere wat Vault gebruik."
},
"share": {
"title": "Deel Vault-profiele",
"description": "Merk 'n kluisondertekenaarsprofiel as gedeel sodat elke gebruiker dit kan kies."
}
},
"settings": {
"redirectUri": {
"label": "Herlei URI",
"description": "Voeg hierdie URL by allowed_redirect_uris in elke Vault OIDC-rol waarmee Termix aanmeld."
},
"legacyCallback": {
"label": "Gebruik die ou aanstuur-URI",
"description": "Hou aan om /vault/oidc/callback te stuur, die aanstuur-URI wat voor 2.9 gebruik is. Skakel dit af sodra jou Vault-rolle die nuwe een toelaat."
},
"host": {
"profileId": {
"label": "Kluisondertekenaarprofiel"
}
}
}
}
@@ -0,0 +1,69 @@
{
"editor": {
"profile": "ملف توقيع Vault",
"selectProfile": "اختيار ملف توقيع Vault...",
"hint": "تأتي الإعدادات من الملف المشترك؛ ستقوم بتسجيل الدخول إلى Vault عبر OIDC عند الاتصال. لا يتم تخزين أي أسرار.",
"docs": "عرض المستندات",
"sharedProfile": "{{name}} (مشترك)"
},
"profiles": {
"new": "ملف جديد",
"manage": "إدارة ملفات Vault",
"name": "الاسم المألوف",
"vaultAddr": "عنوان Vault",
"namespace": "مساحة الاسم",
"oidcMount": "تثبيت مصادقة OIDC",
"oidcRole": "دور OIDC",
"sshMount": "تثبيت أسرار SSH",
"sshRole": "دور موقع SSH",
"validPrincipals": "المستخدمون المسموح بهم",
"keyType": "نوع المفتاح المؤقت",
"shareWithAll": "مشاركة مع جميع المستخدمين",
"create": "إنشاء ملف تعريف",
"created": "تم إنشاء ملف تعريف Vault",
"saved": "تم حفظ ملف تعريف Vault",
"deleted": "تم حذف ملف تعريف Vault",
"saveFailed": "فشل حفظ ملف تعريف Vault",
"deleteFailed": "فشل حذف ملف تعريف Vault",
"save": "حفظ ملف التعريف",
"validationError": "الاسم وعنوان Vault ودور الموقّع SSH مطلوب",
"none": "لا توجد ملفات تعريف Vault بعد.",
"sharedBadge": "مشترك",
"edit": "تحرير",
"delete": "حذف"
},
"hosts": {
"filterAuthVault": "قبو"
},
"dialog": {
"title": "مطلوب تسجيل الدخول إلى Vault",
"description": "تم فتح نافذة لتسجيل الدخول إلى HashiCorp Vault. أكمل تسجيل الدخول هناك؛ سيستمر هذا الاتصال تلقائيًا.",
"failed": "فشلت مصادقة Vault. يرجى المحاولة مرة أخرى.",
"reopen": "أعد فتح نافذة تسجيل الدخول"
},
"permissions": {
"use": {
"title": "استخدم توقيع SSH الخاص بـ Vault",
"description": "أنشئ ملفات تعريف التوقيع الخاصة بـ Vault وقم بالاتصال بالمضيفين الذين يستخدمون Vault."
},
"share": {
"title": "ملفات تعريف Share Vault",
"description": "قم بتحديد ملف تعريف مُوقِّع Vault على أنه مُشترك حتى يتمكن كل مستخدم من اختياره."
}
},
"settings": {
"redirectUri": {
"label": "عنوان إعادة التوجيه",
"description": "أضف عنوان URL هذا إلى allowed_redirect_uris في كل دور Vault OIDC الذي يسجل Termix الدخول به."
},
"legacyCallback": {
"label": "استخدم عنوان URI القديم لإعادة التوجيه",
"description": "استمر في إرسال /vault/oidc/callback، وهو عنوان إعادة التوجيه المستخدم قبل الإصدار 2.9. قم بإيقاف تشغيل هذا بمجرد أن تسمح أدوار Vault الخاصة بك بالعنوان الجديد."
},
"host": {
"profileId": {
"label": "ملف تعريف مُوقِّع الخزينة"
}
}
}
}
@@ -0,0 +1,69 @@
{
"editor": {
"profile": "Профил на подписващ в трезора",
"selectProfile": "Изберете профил в трезора...",
"hint": "Настройките идват от споделения профил; ще влезете в Vault чрез OIDC, когато се свържете. Не се съхраняват тайни.",
"docs": "Преглед на документи",
"sharedProfile": "{{name}} (споделено)"
},
"profiles": {
"new": "Нов профил",
"manage": "Управление на профили в хранилището",
"name": "Приятелско име",
"vaultAddr": "Адрес на трезора",
"namespace": "Именно пространство",
"oidcMount": "OIDC Authentication Mount",
"oidcRole": "Роля на OIDC",
"sshMount": "SSH Secrets Mount",
"sshRole": "Роля на SSH подписващ",
"validPrincipals": "Валидни принципали",
"keyType": "Тип ефимерен ключ",
"shareWithAll": "Споделяне с всички потребители",
"create": "Създаване на профил",
"created": "Профилът на трезора е създаден",
"saved": "Профилът на трезора е запазен",
"deleted": "Профилът в трезора е изтрит",
"saveFailed": "Запазването на профила в Трезора не бе успешно",
"deleteFailed": "Изтриването на профила в Vault не бе успешно",
"save": "Запазване на профил",
"validationError": "Име, адрес на трезора и роля на SSH подписващ са задължителни",
"none": "Все още няма профили в Трезора.",
"sharedBadge": "споделено",
"edit": "Редактиране",
"delete": "Изтриване"
},
"hosts": {
"filterAuthVault": "Трезор"
},
"dialog": {
"title": "Изисква се влизане в трезора",
"description": "Отвори се прозорец за влизане в HashiCorp Vault. Завършете влизането там; тази връзка ще продължи автоматично.",
"failed": "Удостоверяването на трезора не бе успешно. Моля, опитайте отново.",
"reopen": "Отваряне отново на прозореца за вход"
},
"permissions": {
"use": {
"title": "Използване на SSH подписване в Vault",
"description": "Създайте профили на подписващи в Vault и се свържете с хостове, които използват Vault."
},
"share": {
"title": "Споделяне на профили в Трезора",
"description": "Маркирайте профил на подписващ в Vault като споделен, за да може всеки потребител да го избере."
}
},
"settings": {
"redirectUri": {
"label": "URI адрес за пренасочване",
"description": "Добавете този URL адрес към allowed_redirect_uris във всяка OIDC роля на Vault, с която Termix влиза."
},
"legacyCallback": {
"label": "Използвайте стария URI адрес за пренасочване",
"description": "Продължавайте да изпращате /vault/oidc/callback, URI адресът за пренасочване, използван преди версия 2.9. Изключете го, след като ролите ви в Vault позволят новия адрес."
},
"host": {
"profileId": {
"label": "Профил на подписващ в трезора"
}
}
}
}
@@ -0,0 +1,69 @@
{
"editor": {
"profile": "Vault Signer Profile",
"selectProfile": "Select a Vault profile...",
"hint": "Settings come from the shared profile; you'll sign in to Vault via OIDC when you connect. No secrets are stored.",
"docs": "View docs",
"sharedProfile": "{{name}} (shared)"
},
"profiles": {
"new": "New profile",
"manage": "Manage Vault profiles",
"name": "Friendly Name",
"vaultAddr": "Vault Address",
"namespace": "Namespace",
"oidcMount": "OIDC Auth Mount",
"oidcRole": "OIDC Role",
"sshMount": "SSH Secrets Mount",
"sshRole": "SSH Signer Role",
"validPrincipals": "Valid Principals",
"keyType": "Ephemeral Key Type",
"shareWithAll": "Share with all users",
"create": "Create profile",
"created": "Vault profile created",
"saved": "Vault profile saved",
"deleted": "Vault profile deleted",
"saveFailed": "Failed to save Vault profile",
"deleteFailed": "Failed to delete Vault profile",
"save": "Save profile",
"validationError": "Name, Vault address and SSH signer role are required",
"none": "No Vault profiles yet.",
"sharedBadge": "ভাগ করা হয়েছে",
"edit": "Edit",
"delete": "Delete"
},
"hosts": {
"filterAuthVault": "Vault"
},
"dialog": {
"title": "Vault sign-in required",
"description": "A window has opened to sign in to HashiCorp Vault. Complete the sign-in there; this connection will continue automatically.",
"failed": "Vault authentication failed. Please try again.",
"reopen": "Reopen sign-in window"
},
"permissions": {
"use": {
"title": "Use Vault SSH signing",
"description": "Create Vault signer profiles and connect to hosts that use Vault."
},
"share": {
"title": "Share Vault profiles",
"description": "Mark a Vault signer profile as shared so every user can pick it."
}
},
"settings": {
"redirectUri": {
"label": "Redirect URI",
"description": "Add this URL to allowed_redirect_uris in each Vault OIDC role that Termix signs in with."
},
"legacyCallback": {
"label": "Use the old redirect URI",
"description": "Keep sending /vault/oidc/callback, the redirect URI used before 2.9. Turn this off once your Vault roles allow the new one."
},
"host": {
"profileId": {
"label": "Vault signer profile"
}
}
}
}
@@ -0,0 +1,69 @@
{
"editor": {
"profile": "Perfil del signant de la caixa forta",
"selectProfile": "Seleccioneu un perfil de Vault...",
"hint": "La configuració prové del perfil compartit; iniciaràs la sessió a Vault mitjançant OIDC quan et connectis. No s'emmagatzemen secrets.",
"docs": "Veure documents",
"sharedProfile": "{{name}} (compartit)"
},
"profiles": {
"new": "Nou perfil",
"manage": "Gestiona els perfils de Vault",
"name": "Nom amigable",
"vaultAddr": "Adreça de la caixa forta",
"namespace": "Espai de noms",
"oidcMount": "Muntatge d'autenticació OIDC",
"oidcRole": "Rol de l'OIDC",
"sshMount": "Muntanya de Secrets SSH",
"sshRole": "Rol de signant SSH",
"validPrincipals": "Principals vàlids",
"keyType": "Tipus de clau efímera",
"shareWithAll": "Comparteix amb tots els usuaris",
"create": "Crea un perfil",
"created": "Perfil de la caixa forta creat",
"saved": "Perfil de la caixa forta desat",
"deleted": "Perfil de la caixa forta suprimit",
"saveFailed": "No s'ha pogut desar el perfil de Vault",
"deleteFailed": "No s'ha pogut suprimir el perfil de Vault",
"save": "Desa el perfil",
"validationError": "El nom, l'adreça de la caixa forta i el rol de signant SSH són obligatoris.",
"none": "Encara no hi ha perfils de Vault.",
"sharedBadge": "compartit",
"edit": "Edita",
"delete": "Suprimeix"
},
"hosts": {
"filterAuthVault": "Volta"
},
"dialog": {
"title": "Cal iniciar sessió a la caixa forta",
"description": "S'ha obert una finestra per iniciar la sessió a HashiCorp Vault. Completeu l'inici de sessió allà; aquesta connexió continuarà automàticament.",
"failed": "L'autenticació de la caixa forta ha fallat. Torna-ho a provar.",
"reopen": "Torna a obrir la finestra d'inici de sessió"
},
"permissions": {
"use": {
"title": "Utilitza la signatura SSH de Vault",
"description": "Crea perfils de signant del Vault i connecta't a amfitrions que utilitzen el Vault."
},
"share": {
"title": "Comparteix els perfils de Vault",
"description": "Marca un perfil de signant de Vault com a compartit perquè tots els usuaris el puguin triar."
}
},
"settings": {
"redirectUri": {
"label": "URI de redirecció",
"description": "Afegiu aquesta URL a allowed_redirect_uris a cada rol OIDC de Vault amb què Termix inicia la sessió."
},
"legacyCallback": {
"label": "Utilitza l'URI de redirecció antiga",
"description": "Continua enviant /vault/oidc/callback, l'URI de redirecció que s'utilitzava abans de la versió 2.9. Desactiva-ho un cop els rols de Vault permetin el nou."
},
"host": {
"profileId": {
"label": "Perfil del signant de la caixa forta"
}
}
}
}
@@ -0,0 +1,69 @@
{
"editor": {
"profile": "Profil podepisujícího trezoru",
"selectProfile": "Vyberte profil úložiště...",
"hint": "Nastavení pocházejí ze sdíleného profilu; po připojení se do úložiště přihlásíte přes OIDC. Žádné tajné kódy se neukládají.",
"docs": "Zobrazit dokumenty",
"sharedProfile": "{{name}} (sdílené)"
},
"profiles": {
"new": "Nový profil",
"manage": "Správa profilů úložiště",
"name": "Přátelský název",
"vaultAddr": "Adresa trezoru",
"namespace": "Jmenný prostor",
"oidcMount": "Připojení autorizace OIDC",
"oidcRole": "Role OIDC",
"sshMount": "SSH tajemství Mount",
"sshRole": "Role SSH podepisujícího",
"validPrincipals": "Platné principy",
"keyType": "Typ dočasného klíče",
"shareWithAll": "Sdílet se všemi uživateli",
"create": "Vytvořit profil",
"created": "Profil úložiště vytvořen",
"saved": "Profil trezoru uložen",
"deleted": "Profil trezoru smazán",
"saveFailed": "Uložení profilu úložiště se nezdařilo.",
"deleteFailed": "Profil v úložišti se nepodařilo smazat.",
"save": "Uložit profil",
"validationError": "Jméno, adresa trezoru a role SSH signatáře jsou povinné.",
"none": "Zatím žádné profily v úložišti Vault.",
"sharedBadge": "sdílené",
"edit": "Upravit",
"delete": "Vymazat"
},
"hosts": {
"filterAuthVault": "Klenba"
},
"dialog": {
"title": "Vyžaduje se přihlášení do trezoru",
"description": "Otevřelo se okno pro přihlášení do HashiCorp Vault. Dokončete přihlášení tam; toto připojení bude automaticky pokračovat.",
"failed": "Ověření v trezoru se nezdařilo. Zkuste to prosím znovu.",
"reopen": "Znovu otevřít přihlašovací okno"
},
"permissions": {
"use": {
"title": "Používejte podepisování přes SSH v úložišti",
"description": "Vytvořte profily podepisujících v aplikaci Vault a připojte se k hostitelům, kteří ji používají."
},
"share": {
"title": "Sdílení profilů úložiště",
"description": "Označte profil podepisujícího v úložišti Vault jako sdílený, aby si ho mohl vybrat každý uživatel."
}
},
"settings": {
"redirectUri": {
"label": "URI přesměrování",
"description": "Přidejte tuto URL adresu do allowed_redirect_uris v každé roli OIDC úložiště, se kterou se Termix přihlašuje."
},
"legacyCallback": {
"label": "Použít starý identifikátor URI pro přesměrování",
"description": "Pokračujte v odesílání /vault/oidc/callback, což je URI pro přesměrování používané před verzí 2.9. Jakmile vaše role v úložišti povolí použití nového URI, vypněte tuto funkci."
},
"host": {
"profileId": {
"label": "Profil podepisujícího v trezoru"
}
}
}
}
@@ -0,0 +1,69 @@
{
"editor": {
"profile": "Profil for underskriver af arkiv",
"selectProfile": "Vælg en Vault-profil...",
"hint": "Indstillingerne kommer fra den delte profil; du logger ind på Vault via OIDC, når du opretter forbindelse. Der gemmes ingen hemmeligheder.",
"docs": "Se dokumenter",
"sharedProfile": "{{name}} (delt)"
},
"profiles": {
"new": "Ny profil",
"manage": "Administrer Vault-profiler",
"name": "Venligt navn",
"vaultAddr": "Adresse i boksen",
"namespace": "Navnerum",
"oidcMount": "OIDC-godkendelsesmontering",
"oidcRole": "OIDC-rolle",
"sshMount": "SSH-hemmeligheder monteres",
"sshRole": "SSH-underskriverrolle",
"validPrincipals": "Gyldige principper",
"keyType": "Kortvarig nøgletype",
"shareWithAll": "Del med alle brugere",
"create": "Opret profil",
"created": "Vault-profil oprettet",
"saved": "Vault-profil gemt",
"deleted": "Vault-profil slettet",
"saveFailed": "Kunne ikke gemme Vault-profilen",
"deleteFailed": "Kunne ikke slette Vault-profilen",
"save": "Gem profil",
"validationError": "Navn, Vault-adresse og SSH-underskriverrolle er påkrævet",
"none": "Ingen Vault-profiler endnu.",
"sharedBadge": "delt",
"edit": "Redigere",
"delete": "Slet"
},
"hosts": {
"filterAuthVault": "Hvælving"
},
"dialog": {
"title": "Login til Vault kræves",
"description": "Et vindue er åbnet, hvor du kan logge ind på HashiCorp Vault. Fuldfør loginprocessen der; forbindelsen fortsætter automatisk.",
"failed": "Godkendelse af arkiv mislykkedes. Prøv igen.",
"reopen": "Genåbn loginvinduet"
},
"permissions": {
"use": {
"title": "Brug Vault SSH-signering",
"description": "Opret Vault-underskriverprofiler, og opret forbindelse til værter, der bruger Vault."
},
"share": {
"title": "Del Vault-profiler",
"description": "Marker en Vault-underskriverprofil som delt, så alle brugere kan vælge den."
}
},
"settings": {
"redirectUri": {
"label": "Omdirigerings-URI",
"description": "Tilføj denne URL til allowed_redirect_uris i hver Vault OIDC-rolle, som Termix logger ind med."
},
"legacyCallback": {
"label": "Brug den gamle omdirigerings-URI",
"description": "Fortsæt med at sende /vault/oidc/callback, den omdirigerings-URI, der blev brugt før 2.9. Slå dette fra, når dine Vault-roller tillader den nye."
},
"host": {
"profileId": {
"label": "Profil for underskriver af Vault"
}
}
}
}
@@ -0,0 +1,69 @@
{
"editor": {
"profile": "Vault-Signer-Profil",
"selectProfile": "Vault-Profil auswählen...",
"hint": "Die Einstellungen stammen aus dem freigegebenen Profil; Sie melden sich beim Verbinden per OIDC bei Vault an. Es werden keine Geheimnisse gespeichert.",
"docs": "Dokumentation ansehen",
"sharedProfile": "{{name}} (gemeinsam genutzt)"
},
"profiles": {
"new": "Neues Profil",
"manage": "Vault-Profile verwalten",
"name": "Anzeigename",
"vaultAddr": "Vault-Adresse",
"namespace": "Namespace",
"oidcMount": "OIDC-Auth-Mount",
"oidcRole": "OIDC-Rolle",
"sshMount": "SSH-Secrets-Mount",
"sshRole": "SSH-Signer-Rolle",
"validPrincipals": "Gültige Principals",
"keyType": "Ephemerer Schlüsseltyp",
"shareWithAll": "Mit allen Benutzern teilen",
"create": "Profil erstellen",
"created": "Vault-Profil erstellt",
"saved": "Vault-Profil gespeichert",
"deleted": "Vault-Profil gelöscht",
"saveFailed": "Fehler beim Speichern des Vault-Profils",
"deleteFailed": "Fehler beim Löschen des Vault-Profils",
"save": "Profil speichern",
"validationError": "Name, Vault-Adresse und SSH-Signer-Rolle sind erforderlich",
"none": "Noch keine Vault-Profile.",
"sharedBadge": "geteilt",
"edit": "Bearbeiten",
"delete": "Löschen"
},
"hosts": {
"filterAuthVault": "Gewölbe"
},
"dialog": {
"title": "Vault-Anmeldung erforderlich",
"description": "Ein Fenster zur Anmeldung bei HashiCorp Vault wurde geöffnet. Schließen Sie die Anmeldung dort ab; diese Verbindung wird automatisch fortgesetzt.",
"failed": "Vault-Authentifizierung fehlgeschlagen. Bitte versuchen Sie es erneut.",
"reopen": "Anmeldefenster erneut öffnen"
},
"permissions": {
"use": {
"title": "Vault-SSH-Signierung verwenden",
"description": "Erstellen Sie Vault-Signaturprofile und stellen Sie eine Verbindung zu Hosts her, die Vault verwenden."
},
"share": {
"title": "Vault-Profile teilen",
"description": "Markieren Sie ein Vault-Signaturprofil als freigegeben, damit es jeder Benutzer auswählen kann."
}
},
"settings": {
"redirectUri": {
"label": "Umleitungs-URI",
"description": "Fügen Sie diese URL in jeder Vault OIDC-Rolle, mit der sich Termix anmeldet, zu allowed_redirect_uris hinzu."
},
"legacyCallback": {
"label": "Verwenden Sie die alte Umleitungs-URI.",
"description": "Senden Sie weiterhin /vault/oidc/callback, die Umleitungs-URI, die vor Version 2.9 verwendet wurde. Deaktivieren Sie diese Funktion, sobald Ihre Vault-Rollen die Verwendung der neuen URI zulassen."
},
"host": {
"profileId": {
"label": "Vault-Signer-Profil"
}
}
}
}
@@ -0,0 +1,69 @@
{
"editor": {
"profile": "Προφίλ Υπογράφοντος Vault",
"selectProfile": "Επιλέξτε ένα προφίλ Vault...",
"hint": "Οι ρυθμίσεις προέρχονται από το κοινόχρηστο προφίλ. Θα συνδεθείτε στο Vault μέσω OIDC όταν συνδεθείτε. Δεν αποθηκεύονται μυστικά.",
"docs": "Προβολή εγγράφων",
"sharedProfile": "{{name}} (κοινόχρηστο)"
},
"profiles": {
"new": "Νέο προφίλ",
"manage": "Διαχείριση προφίλ Vault",
"name": "Φιλικό όνομα",
"vaultAddr": "Διεύθυνση θησαυροφυλακίου",
"namespace": "Χώρος ονομάτων",
"oidcMount": "Μοντάζ ελέγχου ταυτότητας OIDC",
"oidcRole": "Ρόλος OIDC",
"sshMount": "SSH Secrets Mount",
"sshRole": "Ρόλος Υπογράφοντος SSH",
"validPrincipals": "Έγκυρες Αρχές",
"keyType": "Εφήμερος Τύπος Κλειδιού",
"shareWithAll": "Κοινοποίηση με όλους τους χρήστες",
"create": "Δημιουργία προφίλ",
"created": "Δημιουργήθηκε προφίλ Vault",
"saved": "Το προφίλ του Vault αποθηκεύτηκε",
"deleted": "Το προφίλ Vault διαγράφηκε",
"saveFailed": "Αποτυχία αποθήκευσης προφίλ Vault",
"deleteFailed": "Αποτυχία διαγραφής προφίλ Vault",
"save": "Αποθήκευση προφίλ",
"validationError": "Απαιτούνται όνομα, διεύθυνση vault και ρόλος υπογράφοντος SSH",
"none": "Δεν υπάρχουν ακόμη προφίλ Vault.",
"sharedBadge": "κοινόχρηστο",
"edit": "Εκδίδω",
"delete": "Διαγράφω"
},
"hosts": {
"filterAuthVault": "Θόλος"
},
"dialog": {
"title": "Απαιτείται σύνδεση στο Vault",
"description": "Έχει ανοίξει ένα παράθυρο για να συνδεθείτε στο HashiCorp Vault. Ολοκληρώστε τη σύνδεση εκεί. Αυτή η σύνδεση θα συνεχιστεί αυτόματα.",
"failed": "Ο έλεγχος ταυτότητας του vault απέτυχε. Δοκιμάστε ξανά.",
"reopen": "Επανάνοιγμα παραθύρου σύνδεσης"
},
"permissions": {
"use": {
"title": "Χρήση υπογραφής SSH του Vault",
"description": "Δημιουργήστε προφίλ υπογραφής Vault και συνδεθείτε με κεντρικούς υπολογιστές που χρησιμοποιούν το Vault."
},
"share": {
"title": "Κοινή χρήση προφίλ Vault",
"description": "Επισημάνετε ένα προφίλ υπογραφής Vault ως κοινόχρηστο, ώστε να μπορεί να το επιλέξει κάθε χρήστης."
}
},
"settings": {
"redirectUri": {
"label": "URI ανακατεύθυνσης",
"description": "Προσθέστε αυτήν τη διεύθυνση URL στο allowed_redirect_uris σε κάθε ρόλο Vault OIDC με τον οποίο συνδέεται το Termix."
},
"legacyCallback": {
"label": "Χρησιμοποιήστε το παλιό URI ανακατεύθυνσης",
"description": "Συνεχίστε να στέλνετε το /vault/oidc/callback, το URI ανακατεύθυνσης που χρησιμοποιήθηκε πριν από την έκδοση 2.9. Απενεργοποιήστε αυτήν τη λειτουργία μόλις οι ρόλοι Vault σας επιτρέψουν το νέο."
},
"host": {
"profileId": {
"label": "Προφίλ υπογράφοντος θησαυροφυλακίου"
}
}
}
}
@@ -0,0 +1,69 @@
{
"editor": {
"profile": "Perfil de firmante de Vault",
"selectProfile": "Seleccionar un perfil de Vault...",
"hint": "La configuración proviene del perfil compartido; se iniciará sesión en Vault a través de OIDC al conectarse. No se almacenan secretos.",
"docs": "Ver documentación",
"sharedProfile": "{{name}} (compartido)"
},
"profiles": {
"new": "Nuevo perfil",
"manage": "Administrar perfiles de Vault",
"name": "Nombre descriptivo",
"vaultAddr": "Dirección de Vault",
"namespace": "Espacio de nombres",
"oidcMount": "Montaje de autenticación OIDC",
"oidcRole": "Rol OIDC",
"sshMount": "Montaje de secretos SSH",
"sshRole": "Rol de firmante SSH",
"validPrincipals": "Principals válidos",
"keyType": "Tipo de clave efímera",
"shareWithAll": "Compartir con todos los usuarios",
"create": "Crear perfil",
"created": "Perfil de Vault creado",
"saved": "Perfil de Vault guardado",
"deleted": "Perfil de Vault eliminado",
"saveFailed": "Error al guardar el perfil de Vault",
"deleteFailed": "Error al eliminar el perfil de Vault",
"save": "Guardar perfil",
"validationError": "Se requieren el nombre, la dirección de Vault y el rol de firmante SSH",
"none": "Aún no hay perfiles de Vault.",
"sharedBadge": "compartido",
"edit": "Editar",
"delete": "Eliminar"
},
"hosts": {
"filterAuthVault": "Bóveda"
},
"dialog": {
"title": "Inicio de sesión en Vault requerido",
"description": "Se ha abierto una ventana para iniciar sesión en HashiCorp Vault. Completa el inicio de sesión allí; esta conexión continuará automáticamente.",
"failed": "La autenticación de Vault falló. Inténtalo de nuevo.",
"reopen": "Reabrir ventana de inicio de sesión"
},
"permissions": {
"use": {
"title": "Utilice la firma SSH de Vault.",
"description": "Cree perfiles de firmante de Vault y conéctese a los hosts que utilizan Vault."
},
"share": {
"title": "Compartir perfiles de bóveda",
"description": "Marca un perfil de firmante de Vault como compartido para que todos los usuarios puedan seleccionarlo."
}
},
"settings": {
"redirectUri": {
"label": "URI de redirección",
"description": "Agregue esta URL a allowed_redirect_uris en cada rol OIDC de Vault con el que Termix inicie sesión."
},
"legacyCallback": {
"label": "Utilice la URI de redireccionamiento antigua",
"description": "Sigue enviando /vault/oidc/callback, la URI de redireccionamiento que se usaba antes de la versión 2.9. Desactívala una vez que tus roles de Vault permitan la nueva."
},
"host": {
"profileId": {
"label": "Perfil del firmante de Vault"
}
}
}
}
@@ -0,0 +1,69 @@
{
"editor": {
"profile": "Varaston allekirjoittajan profiili",
"selectProfile": "Valitse holviprofiili...",
"hint": "Asetukset tulevat jaetusta profiilista; kirjaudut Holviin OIDC:n kautta, kun muodostat yhteyden. Salaisuuksia ei tallenneta.",
"docs": "Näytä dokumentit",
"sharedProfile": "{{name}} (jaettu)"
},
"profiles": {
"new": "Uusi profiili",
"manage": "Hallinnoi holviprofiileja",
"name": "Ystävällinen nimi",
"vaultAddr": "Holvin osoite",
"namespace": "Nimiavaruus",
"oidcMount": "OIDC-todennusliitos",
"oidcRole": "OIDC-rooli",
"sshMount": "SSH-salaisuuksien kiinnitys",
"sshRole": "SSH-allekirjoittajan rooli",
"validPrincipals": "Voimassa olevat pääasialliset",
"keyType": "Lyhytaikainen avaintyyppi",
"shareWithAll": "Jaa kaikkien käyttäjien kanssa",
"create": "Luo profiili",
"created": "Holvin profiili luotu",
"saved": "Holvin profiili tallennettu",
"deleted": "Holvin profiili poistettu",
"saveFailed": "Holvin profiilin tallentaminen epäonnistui",
"deleteFailed": "Holviprofiilin poistaminen epäonnistui",
"save": "Tallenna profiili",
"validationError": "Nimi, holvin osoite ja SSH-allekirjoittajan rooli vaaditaan",
"none": "Ei vielä holviprofiileja.",
"sharedBadge": "jaettu",
"edit": "Muokata",
"delete": "Poistaa"
},
"hosts": {
"filterAuthVault": "Holvi"
},
"dialog": {
"title": "Holviin kirjautuminen vaaditaan",
"description": "Ikkuna HashiCorp-holviin kirjautumista varten on avautunut. Kirjaudu sisään siinä. Yhteys jatkuu automaattisesti.",
"failed": "Holvin todennus epäonnistui. Yritä uudelleen.",
"reopen": "Avaa kirjautumisikkuna uudelleen"
},
"permissions": {
"use": {
"title": "Käytä Vaultin SSH-allekirjoitusta",
"description": "Luo Vault-allekirjoittajaprofiileja ja muodosta yhteys Vaultia käyttäviin isäntiin."
},
"share": {
"title": "Jaa holviprofiileja",
"description": "Merkitse Vault-allekirjoittajan profiili jaetuksi, jotta jokainen käyttäjä voi valita sen."
}
},
"settings": {
"redirectUri": {
"label": "Uudelleenohjauksen URI",
"description": "Lisää tämä URL-osoite allowed_redirect_uris-kohtaan jokaisessa Vault OIDC -roolissa, jolla Termix kirjautuu sisään."
},
"legacyCallback": {
"label": "Käytä vanhaa uudelleenohjaus-URI:ta",
"description": "Lähetä edelleen /vault/oidc/callback-osoitetta, joka on ennen versiota 2.9 käytetty uudelleenohjaus-URI. Poista tämä käytöstä, kun Vault-roolisi sallivat uuden osoitteen."
},
"host": {
"profileId": {
"label": "Varaston allekirjoittajan profiili"
}
}
}
}
@@ -0,0 +1,69 @@
{
"editor": {
"profile": "Profil de signature Vault",
"selectProfile": "Sélectionner un profil Vault...",
"hint": "Les paramètres proviennent du profil partagé ; vous vous connecterez à Vault via OIDC lors de la connexion. Aucun secret n'est stocké.",
"docs": "Documentation",
"sharedProfile": "{{name}} (partagé)"
},
"profiles": {
"new": "Nouveau profil",
"manage": "Gérer les profils Vault",
"name": "Nom convivial",
"vaultAddr": "Adresse Vault",
"namespace": "Espace de noms",
"oidcMount": "Point de montage d'authentification OIDC",
"oidcRole": "Rôle OIDC",
"sshMount": "Point de montage des secrets SSH",
"sshRole": "Rôle de signature SSH",
"validPrincipals": "Principaux valides",
"keyType": "Type de clé éphémère",
"shareWithAll": "Partager avec tous les utilisateurs",
"create": "Créer un profil",
"created": "Profil Vault créé",
"saved": "Profil Vault enregistré",
"deleted": "Profil Vault supprimé",
"saveFailed": "Échec de l'enregistrement du profil Vault",
"deleteFailed": "Échec de la suppression du profil Vault",
"save": "Enregistrer le profil",
"validationError": "Le nom, l'adresse Vault et le rôle de signataire SSH sont requis",
"none": "Aucun profil Vault pour le moment",
"sharedBadge": "partagé",
"edit": "Modifier",
"delete": "Supprimer"
},
"hosts": {
"filterAuthVault": "Sauter"
},
"dialog": {
"title": "Connexion à Vault requise",
"description": "Une fenêtre s'est ouverte pour vous connecter à HashiCorp Vault. Terminez la connexion dans cette fenêtre ; cette connexion continuera automatiquement.",
"failed": "Échec de l'authentification Vault. Veuillez réessayer.",
"reopen": "Rouvrir la fenêtre de connexion"
},
"permissions": {
"use": {
"title": "Utiliser la signature SSH Vault",
"description": "Créez des profils de signataire Vault et connectez-vous aux hôtes qui utilisent Vault."
},
"share": {
"title": "Profils Share Vault",
"description": "Marquez un profil de signataire Vault comme partagé afin que chaque utilisateur puisse le sélectionner."
}
},
"settings": {
"redirectUri": {
"label": "URI de redirection",
"description": "Ajoutez cette URL à allowed_redirect_uris dans chaque rôle OIDC Vault avec lequel Termix se connecte."
},
"legacyCallback": {
"label": "Utilisez l'ancienne URI de redirection",
"description": "Continuez à envoyer /vault/oidc/callback, l'URI de redirection utilisée avant la version 2.9. Désactivez-la une fois que vos rôles Vault autoriseront la nouvelle."
},
"host": {
"profileId": {
"label": "Profil du signataire Vault"
}
}
}
}
@@ -0,0 +1,69 @@
{
"editor": {
"profile": "פרופיל חותם בכספת",
"selectProfile": "בחר פרופיל כספת...",
"hint": "ההגדרות מגיעות מהפרופיל המשותף; תיכנס לכספת דרך OIDC כשתתחבר. לא נשמרים סודות.",
"docs": "הצג מסמכים",
"sharedProfile": "{{name}} (שותף)"
},
"profiles": {
"new": "פרופיל חדש",
"manage": "ניהול פרופילי כספת",
"name": "שם ידידותי",
"vaultAddr": "כתובת הכספת",
"namespace": "מרחב השמות",
"oidcMount": "אישור OIDC",
"oidcRole": "תפקיד OIDC",
"sshMount": "סודות SSH מאוורר",
"sshRole": "תפקיד חותם SSH",
"validPrincipals": "עקרונות תקפים",
"keyType": "סוג מפתח זמני",
"shareWithAll": "שתף עם כל המשתמשים",
"create": "צור פרופיל",
"created": "פרופיל הכספת נוצר",
"saved": "פרופיל הכספת נשמר",
"deleted": "פרופיל הכספת נמחק",
"saveFailed": "שמירת פרופיל הכספת נכשלה",
"deleteFailed": "נכשלה מחיקת פרופיל הכספת",
"save": "שמור פרופיל",
"validationError": "שם, כתובת כספת ותפקיד חתימה ב-SSH נדרשים",
"none": "אין עדיין פרופילים בכספת.",
"sharedBadge": "מְשׁוּתָף",
"edit": "לַעֲרוֹך",
"delete": "לִמְחוֹק"
},
"hosts": {
"filterAuthVault": "קִמרוֹן"
},
"dialog": {
"title": "נדרשת כניסה לכספת",
"description": "נפתח חלון לכניסה לכספת HashiCorp. השלם את תהליך הכניסה שם; חיבור זה ימשיך אוטומטית.",
"failed": "אימות הכספת נכשל. אנא נסה שוב.",
"reopen": "פתח מחדש את חלון הכניסה"
},
"permissions": {
"use": {
"title": "השתמש בחתימת SSH של Vault",
"description": "צור פרופילי חותם של Vault והתחבר למארחים המשתמשים ב-Vault."
},
"share": {
"title": "שיתוף פרופילי כספת",
"description": "סמן פרופיל של חותם בכספת כמשותף כדי שכל משתמש יוכל לבחור אותו."
}
},
"settings": {
"redirectUri": {
"label": "כתובת URL להפניה מחדש",
"description": "הוסף כתובת URL זו ל- allowed_redirect_uris בכל תפקיד OIDC של Vault שאיתו Termix מתחבר."
},
"legacyCallback": {
"label": "השתמש ב-URI להפניה הישן",
"description": "המשך לשלוח את /vault/oidc/callback, URI ההפניה שהיה בשימוש לפני גרסה 2.9. כבה אפשרות זו לאחר שתפקידי הכספת שלך יאפשרו את ה-URI החדש."
},
"host": {
"profileId": {
"label": "פרופיל חותם בכספת"
}
}
}
}
@@ -0,0 +1,69 @@
{
"editor": {
"profile": "Vault साइनर प्रोफ़ाइल",
"selectProfile": "एक Vault प्रोफ़ाइल चुनें...",
"hint": "सेटिंग साझा प्रोफ़ाइल से आती हैं; कनेक्ट करते समय आप OIDC के माध्यम से Vault में साइन इन करेंगे। कोई सीक्रेट संग्रहीत नहीं किए जाते।",
"docs": "दस्तावेज़ देखें",
"sharedProfile": "{{name}} (shared)"
},
"profiles": {
"new": "नई प्रोफ़ाइल",
"manage": "Vault प्रोफ़ाइल प्रबंधित करें",
"name": "अनुकूल नाम",
"vaultAddr": "Vault पता",
"namespace": "नेमस्पेस",
"oidcMount": "OIDC ऑथ माउंट",
"oidcRole": "OIDC रोल",
"sshMount": "SSH सीक्रेट माउंट",
"sshRole": "SSH साइनर रोल",
"validPrincipals": "मान्य प्रिंसिपल",
"keyType": "एफेमरल कुंजी प्रकार",
"shareWithAll": "सभी उपयोगकर्ताओं के साथ साझा करें",
"create": "प्रोफ़ाइल बनाएँ",
"created": "Vault प्रोफ़ाइल बनाई गई",
"saved": "Vault प्रोफ़ाइल सहेजी गई",
"deleted": "Vault प्रोफ़ाइल हटाई गई",
"saveFailed": "Vault प्रोफ़ाइल सहेजने में विफल",
"deleteFailed": "Vault प्रोफ़ाइल हटाने में विफल",
"save": "प्रोफ़ाइल सहेजें",
"validationError": "नाम, Vault पता और SSH हस्ताक्षरकर्ता भूमिका आवश्यक हैं",
"none": "अभी तक कोई Vault प्रोफ़ाइल नहीं है।",
"sharedBadge": "साझा",
"edit": "संपादित करें",
"delete": "हटाएँ"
},
"hosts": {
"filterAuthVault": "Vault"
},
"dialog": {
"title": "Vault साइन-इन आवश्यक है",
"description": "HashiCorp Vault में साइन इन करने के लिए एक विंडो खुल गई है। वहाँ साइन-इन पूरा करें; यह कनेक्शन स्वचालित रूप से जारी रहेगा।",
"failed": "Vault प्रमाणीकरण विफल। कृपया पुनः प्रयास करें।",
"reopen": "साइन-इन विंडो फिर से खोलें"
},
"permissions": {
"use": {
"title": "Use Vault SSH signing",
"description": "Create Vault signer profiles and connect to hosts that use Vault."
},
"share": {
"title": "Share Vault profiles",
"description": "Mark a Vault signer profile as shared so every user can pick it."
}
},
"settings": {
"redirectUri": {
"label": "Redirect URI",
"description": "Add this URL to allowed_redirect_uris in each Vault OIDC role that Termix signs in with."
},
"legacyCallback": {
"label": "Use the old redirect URI",
"description": "Keep sending /vault/oidc/callback, the redirect URI used before 2.9. Turn this off once your Vault roles allow the new one."
},
"host": {
"profileId": {
"label": "Vault signer profile"
}
}
}
}
@@ -0,0 +1,69 @@
{
"editor": {
"profile": "Vault aláírói profil",
"selectProfile": "Válasszon ki egy Vault profilt...",
"hint": "A beállítások a megosztott profilból származnak; a csatlakozáskor OIDC-n keresztül fog bejelentkezni a Vaultba. A rendszer nem tárol titkos adatokat.",
"docs": "Dokumentumok megtekintése",
"sharedProfile": "{{name}} (megosztva)"
},
"profiles": {
"new": "Új profil",
"manage": "Vault profilok kezelése",
"name": "Barátságos név",
"vaultAddr": "Trezor címe",
"namespace": "Névtér",
"oidcMount": "OIDC hitelesítési csatolás",
"oidcRole": "OIDC szerepkör",
"sshMount": "SSH titkok felfűzése",
"sshRole": "SSH aláíró szerepkör",
"validPrincipals": "Érvényes megbízók",
"keyType": "Efemer kulcstípus",
"shareWithAll": "Megosztás az összes felhasználóval",
"create": "Profil létrehozása",
"created": "Létrehozva a trezor profilja",
"saved": "A trezor profilja mentve",
"deleted": "Tárolóprofil törölve",
"saveFailed": "Nem sikerült menteni a Vault profilját",
"deleteFailed": "Nem sikerült törölni a Vault profilját",
"save": "Profil mentése",
"validationError": "Név, trezor címe és SSH aláírói szerepkör megadása kötelező.",
"none": "Még nincsenek Vault-profilok.",
"sharedBadge": "megosztott",
"edit": "Szerkesztés",
"delete": "Töröl"
},
"hosts": {
"filterAuthVault": "Boltozat"
},
"dialog": {
"title": "Bejelentkezés szükséges a széfbe",
"description": "Megnyílt egy ablak a HashiCorp Vaultba való bejelentkezéshez. Fejezze be a bejelentkezést ott; a kapcsolat automatikusan folytatódik.",
"failed": "A trezor hitelesítése sikertelen. Próbálja újra.",
"reopen": "Bejelentkezési ablak újranyitása"
},
"permissions": {
"use": {
"title": "Vault SSH aláírás használata",
"description": "Hozzon létre Vault aláírói profilokat, és csatlakozzon a Vaultot használó gazdagépekhez."
},
"share": {
"title": "Vault profilok megosztása",
"description": "Jelöljön meg egy Vault aláírói profilt megosztottként, hogy minden felhasználó kiválaszthassa."
}
},
"settings": {
"redirectUri": {
"label": "Átirányítási URI",
"description": "Adja hozzá ezt az URL-címet az allowed_redirect_uris paraméterhez minden olyan Vault OIDC szerepkörben, amellyel a Termix bejelentkezik."
},
"legacyCallback": {
"label": "Használja a régi átirányítási URI-t",
"description": "Továbbra is küldje el a /vault/oidc/callback átirányítási URI-t, amely a 2.9 előtt használt. Kapcsolja ki ezt, amint a Vault szerepkörei engedélyezik az újat."
},
"host": {
"profileId": {
"label": "Vault aláírói profil"
}
}
}
}
@@ -0,0 +1,69 @@
{
"editor": {
"profile": "Vault Signer Profile",
"selectProfile": "Select a Vault profile...",
"hint": "Settings come from the shared profile; you'll sign in to Vault via OIDC when you connect. No secrets are stored.",
"docs": "View docs",
"sharedProfile": "{{name}} (shared)"
},
"profiles": {
"new": "New profile",
"manage": "Manage Vault profiles",
"name": "Friendly Name",
"vaultAddr": "Vault Address",
"namespace": "Namespace",
"oidcMount": "OIDC Auth Mount",
"oidcRole": "OIDC Role",
"sshMount": "SSH Secrets Mount",
"sshRole": "SSH Signer Role",
"validPrincipals": "Valid Principals",
"keyType": "Ephemeral Key Type",
"shareWithAll": "Share with all users",
"create": "Create profile",
"created": "Vault profile created",
"saved": "Vault profile saved",
"deleted": "Vault profile deleted",
"saveFailed": "Failed to save Vault profile",
"deleteFailed": "Failed to delete Vault profile",
"save": "Save profile",
"validationError": "Name, Vault address and SSH signer role are required",
"none": "No Vault profiles yet.",
"sharedBadge": "dibagikan",
"edit": "Edit",
"delete": "Delete"
},
"hosts": {
"filterAuthVault": "Vault"
},
"dialog": {
"title": "Vault sign-in required",
"description": "A window has opened to sign in to HashiCorp Vault. Complete the sign-in there; this connection will continue automatically.",
"failed": "Vault authentication failed. Please try again.",
"reopen": "Reopen sign-in window"
},
"permissions": {
"use": {
"title": "Use Vault SSH signing",
"description": "Create Vault signer profiles and connect to hosts that use Vault."
},
"share": {
"title": "Share Vault profiles",
"description": "Mark a Vault signer profile as shared so every user can pick it."
}
},
"settings": {
"redirectUri": {
"label": "Redirect URI",
"description": "Add this URL to allowed_redirect_uris in each Vault OIDC role that Termix signs in with."
},
"legacyCallback": {
"label": "Use the old redirect URI",
"description": "Keep sending /vault/oidc/callback, the redirect URI used before 2.9. Turn this off once your Vault roles allow the new one."
},
"host": {
"profileId": {
"label": "Vault signer profile"
}
}
}
}
@@ -0,0 +1,69 @@
{
"editor": {
"profile": "Profilo firmatario Vault",
"selectProfile": "Seleziona un profilo Vault...",
"hint": "Le impostazioni provengono dal profilo condiviso; effettuerai l'accesso a Vault tramite OIDC quando ti connetti. Nessun segreto è memorizzato.",
"docs": "Vedi documenti",
"sharedProfile": "{{name}} (condiviso)"
},
"profiles": {
"new": "Nuovo profilo",
"manage": "Gestisci profili Vault",
"name": "Nome descrittivo",
"vaultAddr": "Indirizzo Vault",
"namespace": "Namespace",
"oidcMount": "Mount di autenticazione OIDC",
"oidcRole": "Ruolo OIDC",
"sshMount": "Mount dei segreti SSH",
"sshRole": "Ruolo firmatario SSH",
"validPrincipals": "Principal validi",
"keyType": "Tipo di chiave effimera",
"shareWithAll": "Condividi con tutti gli utenti",
"create": "Crea profilo",
"created": "Profilo Vault creato",
"saved": "Profilo Vault salvato",
"deleted": "Profilo Vault eliminato",
"saveFailed": "Salvataggio profilo Vault non riuscito",
"deleteFailed": "Eliminazione profilo Vault non riuscita",
"save": "Salva profilo",
"validationError": "Nome, indirizzo Vault e ruolo di firma SSH sono obbligatori",
"none": "Nessun profilo Vault ancora.",
"sharedBadge": "condiviso",
"edit": "Modifica",
"delete": "Elimina"
},
"hosts": {
"filterAuthVault": "Volta"
},
"dialog": {
"title": "Accesso a Vault richiesto",
"description": "Una finestra si è aperta per accedere a HashiCorp Vault. Completa l'accesso lì; la connessione continuerà automaticamente.",
"failed": "Autenticazione Vault fallita. Riprova.",
"reopen": "Riapri la finestra di accesso"
},
"permissions": {
"use": {
"title": "Utilizzare la firma SSH di Vault",
"description": "Crea profili di firmatari Vault e connettiti agli host che utilizzano Vault."
},
"share": {
"title": "Condividi i profili di Vault",
"description": "Contrassegna il profilo di un firmatario di Vault come condiviso in modo che ogni utente possa selezionarlo."
}
},
"settings": {
"redirectUri": {
"label": "URI di reindirizzamento",
"description": "Aggiungi questo URL a allowed_redirect_uris in ogni ruolo OIDC di Vault con cui Termix effettua l'accesso."
},
"legacyCallback": {
"label": "Utilizzare il vecchio URI di reindirizzamento",
"description": "Continua a inviare /vault/oidc/callback, l'URI di reindirizzamento utilizzato prima della versione 2.9. Disattivalo non appena i tuoi ruoli Vault consentiranno il nuovo URI."
},
"host": {
"profileId": {
"label": "Profilo del firmatario della cassaforte"
}
}
}
}
@@ -0,0 +1,69 @@
{
"editor": {
"profile": "Vault 署名者プロファイル",
"selectProfile": "Vault プロファイルを選択...",
"hint": "設定は共有プロファイルから取得され、接続時に OIDC で Vault にサインインします。シークレットは保存されません。",
"docs": "ドキュメントを見る",
"sharedProfile": "{{name}} (共有)"
},
"profiles": {
"new": "新規プロファイル",
"manage": "Vault プロファイルの管理",
"name": "表示名",
"vaultAddr": "Vault アドレス",
"namespace": "名前空間",
"oidcMount": "OIDC 認証マウント",
"oidcRole": "OIDC ロール",
"sshMount": "SSH シークレットマウント",
"sshRole": "SSH 署名者ロール",
"validPrincipals": "有効なプリンシパル",
"keyType": "エフェメラルキータイプ",
"shareWithAll": "すべてのユーザーと共有",
"create": "プロファイルを作成",
"created": "Vaultプロファイルが作成されました",
"saved": "Vaultプロファイルが保存されました",
"deleted": "Vaultプロファイルが削除されました",
"saveFailed": "Vaultプロファイルの保存に失敗しました",
"deleteFailed": "Vaultプロファイルの削除に失敗しました",
"save": "プロファイルを保存",
"validationError": "名前、Vaultアドレス、SSH署名ロールは必須です",
"none": "まだVaultプロファイルがありません。",
"sharedBadge": "共有",
"edit": "編集",
"delete": "削除"
},
"hosts": {
"filterAuthVault": "金庫"
},
"dialog": {
"title": "Vault サインインが必要です",
"description": "HashiCorp Vaultへのサインイン用のウィンドウが開きました。そこでサインインを完了すると、この接続は自動的に続行されます。",
"failed": "Vault 認証に失敗しました。もう一度お試しください。",
"reopen": "サインインウィンドウを再表示"
},
"permissions": {
"use": {
"title": "Vault SSH署名を使用する",
"description": "Vault署名者プロファイルを作成し、Vaultを使用しているホストに接続します。"
},
"share": {
"title": "Vault プロファイルを共有する",
"description": "Vault署名者プロファイルを共有としてマークすると、すべてのユーザーが選択できるようになります。"
}
},
"settings": {
"redirectUri": {
"label": "リダイレクトURI",
"description": "Termixがサインインする各Vault OIDCロールのallowed_redirect_urisに、このURLを追加してください。"
},
"legacyCallback": {
"label": "古いリダイレクトURIを使用する",
"description": "バージョン2.9以前に使用されていたリダイレクトURIである/vault/oidc/callbackを送信し続けてください。Vaultロールで新しいURIが許可されたら、この設定を無効にしてください。"
},
"host": {
"profileId": {
"label": "Vault署名者プロフィール"
}
}
}
}
@@ -0,0 +1,69 @@
{
"editor": {
"profile": "Vault 서명자 프로필",
"selectProfile": "Vault 프로필 선택...",
"hint": "설정은 공유 프로필에서 가져옵니다. 연결 시 OIDC를 통해 Vault에 로그인하게 됩니다. 비밀은 저장되지 않습니다.",
"docs": "문서 보기",
"sharedProfile": "{{name}} (공유됨)"
},
"profiles": {
"new": "새 프로필",
"manage": "Vault 프로필 관리",
"name": "친숙한 이름",
"vaultAddr": "Vault 주소",
"namespace": "네임스페이스",
"oidcMount": "OIDC 인증 마운트",
"oidcRole": "OIDC 역할",
"sshMount": "SSH 시크릿 마운트",
"sshRole": "SSH 서명자 역할",
"validPrincipals": "유효한 프린시펄",
"keyType": "임시 키 유형",
"shareWithAll": "모든 사용자와 공유",
"create": "프로필 생성",
"created": "Vault 프로필 생성됨",
"saved": "Vault 프로필 저장됨",
"deleted": "Vault 프로필 삭제됨",
"saveFailed": "Vault 프로필 저장 실패",
"deleteFailed": "Vault 프로필 삭제 실패",
"save": "프로필 저장",
"validationError": "이름, Vault 주소 및 SSH 서명자 역할이 필요합니다.",
"none": "아직 Vault 프로필이 없습니다.",
"sharedBadge": "공유",
"edit": "편집",
"delete": "삭제"
},
"hosts": {
"filterAuthVault": "둥근 천장"
},
"dialog": {
"title": "Vault 로그인 필요",
"description": "HashiCorp Vault 로그인을 위한 창이 열렸습니다. 해당 창에서 로그인을 완료하면 연결이 자동으로 계속됩니다.",
"failed": "Vault 인증에 실패했습니다. 다시 시도하세요.",
"reopen": "로그인 창 다시 열기"
},
"permissions": {
"use": {
"title": "Vault SSH 서명을 사용하세요.",
"description": "Vault 서명자 프로필을 생성하고 Vault를 사용하는 호스트에 연결합니다."
},
"share": {
"title": "Share Vault 프로필",
"description": "Vault 서명자 프로필을 공유로 표시하면 모든 사용자가 해당 프로필을 선택할 수 있습니다."
}
},
"settings": {
"redirectUri": {
"label": "리디렉션 URI",
"description": "Termix가 로그인하는 각 Vault OIDC 역할의 allowed_redirect_uris에 이 URL을 추가하세요."
},
"legacyCallback": {
"label": "이전 리디렉션 URI를 사용하세요",
"description": "2.9 이전 버전에서 사용했던 리디렉션 URI인 /vault/oidc/callback을 계속 전송하세요. Vault 역할에서 새 URI를 허용하면 이 기능을 비활성화하십시오."
},
"host": {
"profileId": {
"label": "Vault 서명자 프로필"
}
}
}
}
@@ -0,0 +1,69 @@
{
"editor": {
"profile": "Vault Signer Profile",
"selectProfile": "Select a Vault profile...",
"hint": "Settings come from the shared profile; you'll sign in to Vault via OIDC when you connect. No secrets are stored.",
"docs": "View docs",
"sharedProfile": "{{name}} (shared)"
},
"profiles": {
"new": "New profile",
"manage": "Manage Vault profiles",
"name": "Friendly Name",
"vaultAddr": "Vault Address",
"namespace": "Namespace",
"oidcMount": "OIDC Auth Mount",
"oidcRole": "OIDC Role",
"sshMount": "SSH Secrets Mount",
"sshRole": "SSH Signer Role",
"validPrincipals": "Valid Principals",
"keyType": "Ephemeral Key Type",
"shareWithAll": "Share with all users",
"create": "Create profile",
"created": "Vault profile created",
"saved": "Vault profile saved",
"deleted": "Vault profile deleted",
"saveFailed": "Failed to save Vault profile",
"deleteFailed": "Failed to delete Vault profile",
"save": "Save profile",
"validationError": "Name, Vault address and SSH signer role are required",
"none": "No Vault profiles yet.",
"sharedBadge": "shared",
"edit": "Edit",
"delete": "Delete"
},
"hosts": {
"filterAuthVault": "Vault"
},
"dialog": {
"title": "Vault sign-in required",
"description": "A window has opened to sign in to HashiCorp Vault. Complete the sign-in there; this connection will continue automatically.",
"failed": "Vault authentication failed. Please try again.",
"reopen": "Reopen sign-in window"
},
"permissions": {
"use": {
"title": "Use Vault SSH signing",
"description": "Create Vault signer profiles and connect to hosts that use Vault."
},
"share": {
"title": "Share Vault profiles",
"description": "Mark a Vault signer profile as shared so every user can pick it."
}
},
"settings": {
"redirectUri": {
"label": "Redirect URI",
"description": "Add this URL to allowed_redirect_uris in each Vault OIDC role that Termix signs in with."
},
"legacyCallback": {
"label": "Use the old redirect URI",
"description": "Keep sending /vault/oidc/callback, the redirect URI used before 2.9. Turn this off once your Vault roles allow the new one."
},
"host": {
"profileId": {
"label": "Vault signer profile"
}
}
}
}
@@ -0,0 +1,69 @@
{
"editor": {
"profile": "Vault Signer Profile",
"selectProfile": "Select a Vault profile...",
"hint": "Settings come from the shared profile; you'll sign in to Vault via OIDC when you connect. No secrets are stored.",
"docs": "View docs",
"sharedProfile": "{{name}} (shared)"
},
"profiles": {
"new": "New profile",
"manage": "Manage Vault profiles",
"name": "Friendly Name",
"vaultAddr": "Vault Address",
"namespace": "Namespace",
"oidcMount": "OIDC Auth Mount",
"oidcRole": "OIDC Role",
"sshMount": "SSH Secrets Mount",
"sshRole": "SSH Signer Role",
"validPrincipals": "Valid Principals",
"keyType": "Ephemeral Key Type",
"shareWithAll": "Share with all users",
"create": "Create profile",
"created": "Vault profile created",
"saved": "Vault profile saved",
"deleted": "Vault profile deleted",
"saveFailed": "Failed to save Vault profile",
"deleteFailed": "Failed to delete Vault profile",
"save": "Save profile",
"validationError": "Name, Vault address and SSH signer role are required",
"none": "No Vault profiles yet.",
"sharedBadge": "shared",
"edit": "Rediger",
"delete": "Slett"
},
"hosts": {
"filterAuthVault": "Vault"
},
"dialog": {
"title": "Vault sign-in required",
"description": "A window has opened to sign in to HashiCorp Vault. Complete the sign-in there; this connection will continue automatically.",
"failed": "Vault authentication failed. Please try again.",
"reopen": "Reopen sign-in window"
},
"permissions": {
"use": {
"title": "Use Vault SSH signing",
"description": "Create Vault signer profiles and connect to hosts that use Vault."
},
"share": {
"title": "Share Vault profiles",
"description": "Mark a Vault signer profile as shared so every user can pick it."
}
},
"settings": {
"redirectUri": {
"label": "Redirect URI",
"description": "Add this URL to allowed_redirect_uris in each Vault OIDC role that Termix signs in with."
},
"legacyCallback": {
"label": "Use the old redirect URI",
"description": "Keep sending /vault/oidc/callback, the redirect URI used before 2.9. Turn this off once your Vault roles allow the new one."
},
"host": {
"profileId": {
"label": "Vault signer profile"
}
}
}
}
@@ -0,0 +1,69 @@
{
"editor": {
"profile": "Profil podpisujący Vault",
"selectProfile": "Wybierz profil Vault...",
"hint": "Ustawienia pochodzą z profilu współdzielonego. Po połączeniu zalogujesz się do Vault przez OIDC. Nie są przechowywane żadne sekrety.",
"docs": "Zobacz dokumentację",
"sharedProfile": "{{name}} (udostępnione)"
},
"profiles": {
"new": "Nowy profil",
"manage": "Zarządzaj profilami Vault",
"name": "Przyjazna nazwa",
"vaultAddr": "Adres Vault",
"namespace": "Przestrzeń nazw",
"oidcMount": "Mount uwierzytelniania OIDC",
"oidcRole": "Rola OIDC",
"sshMount": "Mount sekretów SSH",
"sshRole": "Rola podpisująca SSH",
"validPrincipals": "Dozwoleni użytkownicy (principals)",
"keyType": "Typ klucza efemerycznego",
"shareWithAll": "Udostępnij wszystkim użytkownikom",
"create": "Utwórz profil",
"created": "Utworzono profil Vault",
"saved": "Zapisano profil Vault",
"deleted": "Usunięto profil Vault",
"saveFailed": "Nie udało się zapisać profilu Vault",
"deleteFailed": "Nie udało się usunąć profilu Vault",
"save": "Zapisz profil",
"validationError": "Wymagane są nazwa, adres Vault i rola podpisująca SSH",
"none": "Brak profili Vault.",
"sharedBadge": "współdzielony",
"edit": "Edytuj",
"delete": "Usuń"
},
"hosts": {
"filterAuthVault": "Vault"
},
"dialog": {
"title": "Wymagane logowanie do Vault",
"description": "Otworzyło się okno logowania do HashiCorp Vault. Dokończ logowanie, a połączenie zostanie nawiązane automatycznie.",
"failed": "Uwierzytelnienie w Vault nie powiodło się. Spróbuj ponownie.",
"reopen": "Otwórz ponownie okno logowania"
},
"permissions": {
"use": {
"title": "Use Vault SSH signing",
"description": "Create Vault signer profiles and connect to hosts that use Vault."
},
"share": {
"title": "Share Vault profiles",
"description": "Mark a Vault signer profile as shared so every user can pick it."
}
},
"settings": {
"redirectUri": {
"label": "Redirect URI",
"description": "Add this URL to allowed_redirect_uris in each Vault OIDC role that Termix signs in with."
},
"legacyCallback": {
"label": "Use the old redirect URI",
"description": "Keep sending /vault/oidc/callback, the redirect URI used before 2.9. Turn this off once your Vault roles allow the new one."
},
"host": {
"profileId": {
"label": "Vault signer profile"
}
}
}
}
@@ -0,0 +1,69 @@
{
"editor": {
"profile": "Vault Signer Profile",
"selectProfile": "Select a Vault profile...",
"hint": "Settings come from the shared profile; you'll sign in to Vault via OIDC when you connect. No secrets are stored.",
"docs": "Documentação",
"sharedProfile": "{{name}} (shared)"
},
"profiles": {
"new": "New profile",
"manage": "Manage Vault profiles",
"name": "Nome Amigável",
"vaultAddr": "Vault Address",
"namespace": "Namespace",
"oidcMount": "OIDC Auth Mount",
"oidcRole": "OIDC Role",
"sshMount": "SSH Secrets Mount",
"sshRole": "SSH Signer Role",
"validPrincipals": "Valid Principals",
"keyType": "Ephemeral Key Type",
"shareWithAll": "Share with all users",
"create": "Create profile",
"created": "Vault profile created",
"saved": "Vault profile saved",
"deleted": "Vault profile deleted",
"saveFailed": "Failed to save Vault profile",
"deleteFailed": "Failed to delete Vault profile",
"save": "Save profile",
"validationError": "Name, Vault address and SSH signer role are required",
"none": "No Vault profiles yet.",
"sharedBadge": "shared",
"edit": "Editar",
"delete": "Apagar"
},
"hosts": {
"filterAuthVault": "Vault"
},
"dialog": {
"title": "Vault sign-in required",
"description": "A window has opened to sign in to HashiCorp Vault. Complete the sign-in there; this connection will continue automatically.",
"failed": "Vault authentication failed. Please try again.",
"reopen": "Reopen sign-in window"
},
"permissions": {
"use": {
"title": "Use Vault SSH signing",
"description": "Create Vault signer profiles and connect to hosts that use Vault."
},
"share": {
"title": "Share Vault profiles",
"description": "Mark a Vault signer profile as shared so every user can pick it."
}
},
"settings": {
"redirectUri": {
"label": "Redirect URI",
"description": "Add this URL to allowed_redirect_uris in each Vault OIDC role that Termix signs in with."
},
"legacyCallback": {
"label": "Use the old redirect URI",
"description": "Keep sending /vault/oidc/callback, the redirect URI used before 2.9. Turn this off once your Vault roles allow the new one."
},
"host": {
"profileId": {
"label": "Vault signer profile"
}
}
}
}
@@ -0,0 +1,69 @@
{
"editor": {
"profile": "Perfil de assinatura Vault",
"selectProfile": "Selecionar um perfil Vault...",
"hint": "As definições provêm do perfil partilhado; ao ligar, iniciará sessão no Vault via OIDC. Nenhum segredo é armazenado.",
"docs": "Ver docs",
"sharedProfile": "{{name}} (shared)"
},
"profiles": {
"new": "Novo perfil",
"manage": "Gerir perfis Vault",
"name": "Nome Amigável",
"vaultAddr": "Endereço Vault",
"namespace": "Namespace",
"oidcMount": "Mount de autenticação OIDC",
"oidcRole": "Função OIDC",
"sshMount": "Mount de segredos SSH",
"sshRole": "Função de assinatura SSH",
"validPrincipals": "Principais válidos",
"keyType": "Tipo de chave efémera",
"shareWithAll": "Partilhar com todos os utilizadores",
"create": "Criar perfil",
"created": "Perfil Vault criado",
"saved": "Perfil Vault guardado",
"deleted": "Perfil Vault eliminado",
"saveFailed": "Falha ao guardar perfil Vault",
"deleteFailed": "Falha ao eliminar perfil Vault",
"save": "Guardar perfil",
"validationError": "Nome, endereço Vault e função de signatário SSH são obrigatórios",
"none": "Ainda sem perfis Vault.",
"sharedBadge": "partilhado",
"edit": "Editar",
"delete": "Eliminar"
},
"hosts": {
"filterAuthVault": "Vault"
},
"dialog": {
"title": "Início de sessão no Vault necessário",
"description": "Foi aberta uma janela para iniciar sessão no HashiCorp Vault. Conclua o início de sessão aí; esta ligação continuará automaticamente.",
"failed": "A autenticação no Vault falhou. Tente novamente.",
"reopen": "Reabrir janela de início de sessão"
},
"permissions": {
"use": {
"title": "Use Vault SSH signing",
"description": "Create Vault signer profiles and connect to hosts that use Vault."
},
"share": {
"title": "Share Vault profiles",
"description": "Mark a Vault signer profile as shared so every user can pick it."
}
},
"settings": {
"redirectUri": {
"label": "Redirect URI",
"description": "Add this URL to allowed_redirect_uris in each Vault OIDC role that Termix signs in with."
},
"legacyCallback": {
"label": "Use the old redirect URI",
"description": "Keep sending /vault/oidc/callback, the redirect URI used before 2.9. Turn this off once your Vault roles allow the new one."
},
"host": {
"profileId": {
"label": "Vault signer profile"
}
}
}
}
@@ -0,0 +1,69 @@
{
"editor": {
"profile": "Profilul semnatarului din seif",
"selectProfile": "Selectați un profil Vault...",
"hint": "Setările provin din profilul partajat; te vei conecta la Vault prin OIDC când te conectezi. Nu sunt stocate secrete.",
"docs": "Vizualizați documentele",
"sharedProfile": "{{name}} (partajat)"
},
"profiles": {
"new": "Profil nou",
"manage": "Gestionați profilurile din seif",
"name": "Nume prietenos",
"vaultAddr": "Adresă seif",
"namespace": "Spațiu de nume",
"oidcMount": "Montare autentificare OIDC",
"oidcRole": "Rolul OIDC",
"sshMount": "Secretele SSH Mount",
"sshRole": "Rolul semnatarului SSH",
"validPrincipals": "Principali validi",
"keyType": "Tip de cheie efemeră",
"shareWithAll": "Partajați cu toți utilizatorii",
"create": "Creați un profil",
"created": "Profilul seifului a fost creat",
"saved": "Profilul seifului a fost salvat",
"deleted": "Profilul seifului a fost șters",
"saveFailed": "Nu s-a putut salva profilul din Seif",
"deleteFailed": "Nu s-a putut șterge profilul Vault",
"save": "Salvați profilul",
"validationError": "Numele, adresa seifului și rolul semnatarului SSH sunt obligatorii.",
"none": "Încă nu există profiluri Vault.",
"sharedBadge": "partajat",
"edit": "Edita",
"delete": "Şterge"
},
"hosts": {
"filterAuthVault": "Seif"
},
"dialog": {
"title": "Este necesară conectarea la seif",
"description": "S-a deschis o fereastră pentru a vă conecta la HashiCorp Vault. Finalizați conectarea acolo; această conexiune va continua automat.",
"failed": "Autentificarea în seif a eșuat. Vă rugăm să încercați din nou.",
"reopen": "Redeschideți fereastra de conectare"
},
"permissions": {
"use": {
"title": "Folosește semnarea SSH în Vault",
"description": "Creați profiluri de semnatar Vault și conectați-vă la gazde care utilizează Vault."
},
"share": {
"title": "Partajați profiluri Vault",
"description": "Marcați un profil de semnatar Vault ca partajat, astfel încât fiecare utilizator să îl poată alege."
}
},
"settings": {
"redirectUri": {
"label": "URI de redirecționare",
"description": "Adăugați această adresă URL la allowed_redirect_uris în fiecare rol OIDC din Vault cu care se conectează Termix."
},
"legacyCallback": {
"label": "Folosește vechiul URI de redirecționare",
"description": "Continuă să trimiți /vault/oidc/callback, URI-ul de redirecționare folosit înainte de versiunea 2.9. Dezactivează această opțiune odată ce rolurile din Vault îl permit pe cel nou."
},
"host": {
"profileId": {
"label": "Profilul semnatarului din seif"
}
}
}
}
@@ -0,0 +1,69 @@
{
"editor": {
"profile": "Профиль подписчика Vault",
"selectProfile": "Выберите профиль Vault...",
"hint": "Настройки берутся из общего профиля; при подключении вы войдёте в Vault через OIDC. Секреты не хранятся.",
"docs": "Документация",
"sharedProfile": "{{name}} (shared)"
},
"profiles": {
"new": "Новый профиль",
"manage": "Управление профилями Vault",
"name": "Дружественное имя",
"vaultAddr": "Адрес Vault",
"namespace": "Пространство имён",
"oidcMount": "Монтирование аутентификации OIDC",
"oidcRole": "Роль OIDC",
"sshMount": "Монтирование секретов SSH",
"sshRole": "Роль подписчика SSH",
"validPrincipals": "Допустимые принципалы",
"keyType": "Тип эфемерного ключа",
"shareWithAll": "Общий доступ для всех пользователей",
"create": "Создать профиль",
"created": "Профиль Vault создан",
"saved": "Профиль Vault сохранён",
"deleted": "Профиль Vault удалён",
"saveFailed": "Не удалось сохранить профиль Vault",
"deleteFailed": "Не удалось удалить профиль Vault",
"save": "Сохранить профиль",
"validationError": "Требуются имя, адрес Vault и роль SSH подписанта",
"none": "Пока нет профилей Vault.",
"sharedBadge": "общий",
"edit": "Редактировать",
"delete": "Удалить"
},
"hosts": {
"filterAuthVault": "Vault"
},
"dialog": {
"title": "Требуется вход в Vault",
"description": "Открыто окно для входа в HashiCorp Vault. Завершите вход там; подключение продолжится автоматически.",
"failed": "Аутентификация Vault не удалась. Попробуйте снова.",
"reopen": "Повторно открыть окно входа"
},
"permissions": {
"use": {
"title": "Use Vault SSH signing",
"description": "Create Vault signer profiles and connect to hosts that use Vault."
},
"share": {
"title": "Share Vault profiles",
"description": "Mark a Vault signer profile as shared so every user can pick it."
}
},
"settings": {
"redirectUri": {
"label": "Redirect URI",
"description": "Add this URL to allowed_redirect_uris in each Vault OIDC role that Termix signs in with."
},
"legacyCallback": {
"label": "Use the old redirect URI",
"description": "Keep sending /vault/oidc/callback, the redirect URI used before 2.9. Turn this off once your Vault roles allow the new one."
},
"host": {
"profileId": {
"label": "Vault signer profile"
}
}
}
}
@@ -0,0 +1,69 @@
{
"editor": {
"profile": "Vault Signer Profile",
"selectProfile": "Select a Vault profile...",
"hint": "Settings come from the shared profile; you'll sign in to Vault via OIDC when you connect. No secrets are stored.",
"docs": "View docs",
"sharedProfile": "{{name}} (shared)"
},
"profiles": {
"new": "New profile",
"manage": "Manage Vault profiles",
"name": "Friendly Name",
"vaultAddr": "Vault Address",
"namespace": "Namespace",
"oidcMount": "OIDC Auth Mount",
"oidcRole": "OIDC Role",
"sshMount": "SSH Secrets Mount",
"sshRole": "SSH Signer Role",
"validPrincipals": "Valid Principals",
"keyType": "Ephemeral Key Type",
"shareWithAll": "Share with all users",
"create": "Create profile",
"created": "Vault profile created",
"saved": "Vault profile saved",
"deleted": "Vault profile deleted",
"saveFailed": "Failed to save Vault profile",
"deleteFailed": "Failed to delete Vault profile",
"save": "Save profile",
"validationError": "Name, Vault address and SSH signer role are required",
"none": "No Vault profiles yet.",
"sharedBadge": "shared",
"edit": "Edit",
"delete": "Delete"
},
"hosts": {
"filterAuthVault": "Vault"
},
"dialog": {
"title": "Vault sign-in required",
"description": "A window has opened to sign in to HashiCorp Vault. Complete the sign-in there; this connection will continue automatically.",
"failed": "Vault authentication failed. Please try again.",
"reopen": "Reopen sign-in window"
},
"permissions": {
"use": {
"title": "Use Vault SSH signing",
"description": "Create Vault signer profiles and connect to hosts that use Vault."
},
"share": {
"title": "Share Vault profiles",
"description": "Mark a Vault signer profile as shared so every user can pick it."
}
},
"settings": {
"redirectUri": {
"label": "Redirect URI",
"description": "Add this URL to allowed_redirect_uris in each Vault OIDC role that Termix signs in with."
},
"legacyCallback": {
"label": "Use the old redirect URI",
"description": "Keep sending /vault/oidc/callback, the redirect URI used before 2.9. Turn this off once your Vault roles allow the new one."
},
"host": {
"profileId": {
"label": "Vault signer profile"
}
}
}
}
@@ -0,0 +1,69 @@
{
"editor": {
"profile": "Vault Signer Profile",
"selectProfile": "Select a Vault profile...",
"hint": "Settings come from the shared profile; you'll sign in to Vault via OIDC when you connect. No secrets are stored.",
"docs": "View docs",
"sharedProfile": "{{name}} (shared)"
},
"profiles": {
"new": "New profile",
"manage": "Manage Vault profiles",
"name": "Friendly Name",
"vaultAddr": "Vault Address",
"namespace": "Namespace",
"oidcMount": "OIDC Auth Mount",
"oidcRole": "OIDC Role",
"sshMount": "SSH Secrets Mount",
"sshRole": "SSH Signer Role",
"validPrincipals": "Valid Principals",
"keyType": "Ephemeral Key Type",
"shareWithAll": "Share with all users",
"create": "Create profile",
"created": "Vault profile created",
"saved": "Vault profile saved",
"deleted": "Vault profile deleted",
"saveFailed": "Failed to save Vault profile",
"deleteFailed": "Failed to delete Vault profile",
"save": "Save profile",
"validationError": "Name, Vault address and SSH signer role are required",
"none": "No Vault profiles yet.",
"sharedBadge": "shared",
"edit": "Edit",
"delete": "Delete"
},
"hosts": {
"filterAuthVault": "Vault"
},
"dialog": {
"title": "Vault sign-in required",
"description": "A window has opened to sign in to HashiCorp Vault. Complete the sign-in there; this connection will continue automatically.",
"failed": "Vault authentication failed. Please try again.",
"reopen": "Reopen sign-in window"
},
"permissions": {
"use": {
"title": "Use Vault SSH signing",
"description": "Create Vault signer profiles and connect to hosts that use Vault."
},
"share": {
"title": "Share Vault profiles",
"description": "Mark a Vault signer profile as shared so every user can pick it."
}
},
"settings": {
"redirectUri": {
"label": "Redirect URI",
"description": "Add this URL to allowed_redirect_uris in each Vault OIDC role that Termix signs in with."
},
"legacyCallback": {
"label": "Use the old redirect URI",
"description": "Keep sending /vault/oidc/callback, the redirect URI used before 2.9. Turn this off once your Vault roles allow the new one."
},
"host": {
"profileId": {
"label": "Vault signer profile"
}
}
}
}
@@ -0,0 +1,69 @@
{
"editor": {
"profile": "Vault Signer Profile",
"selectProfile": "Select a Vault profile...",
"hint": "Settings come from the shared profile; you'll sign in to Vault via OIDC when you connect. No secrets are stored.",
"docs": "View docs",
"sharedProfile": "{{name}} (shared)"
},
"profiles": {
"new": "New profile",
"manage": "Manage Vault profiles",
"name": "Friendly Name",
"vaultAddr": "Vault Address",
"namespace": "Namespace",
"oidcMount": "OIDC Auth Mount",
"oidcRole": "OIDC Role",
"sshMount": "SSH Secrets Mount",
"sshRole": "SSH Signer Role",
"validPrincipals": "Valid Principals",
"keyType": "Ephemeral Key Type",
"shareWithAll": "Share with all users",
"create": "Create profile",
"created": "Vault profile created",
"saved": "Vault profile saved",
"deleted": "Vault profile deleted",
"saveFailed": "Failed to save Vault profile",
"deleteFailed": "Failed to delete Vault profile",
"save": "Save profile",
"validationError": "Name, Vault address and SSH signer role are required",
"none": "No Vault profiles yet.",
"sharedBadge": "shared",
"edit": "Edit",
"delete": "Delete"
},
"hosts": {
"filterAuthVault": "Vault"
},
"dialog": {
"title": "Vault sign-in required",
"description": "A window has opened to sign in to HashiCorp Vault. Complete the sign-in there; this connection will continue automatically.",
"failed": "Vault authentication failed. Please try again.",
"reopen": "Reopen sign-in window"
},
"permissions": {
"use": {
"title": "Use Vault SSH signing",
"description": "Create Vault signer profiles and connect to hosts that use Vault."
},
"share": {
"title": "Share Vault profiles",
"description": "Mark a Vault signer profile as shared so every user can pick it."
}
},
"settings": {
"redirectUri": {
"label": "Redirect URI",
"description": "Add this URL to allowed_redirect_uris in each Vault OIDC role that Termix signs in with."
},
"legacyCallback": {
"label": "Use the old redirect URI",
"description": "Keep sending /vault/oidc/callback, the redirect URI used before 2.9. Turn this off once your Vault roles allow the new one."
},
"host": {
"profileId": {
"label": "Vault signer profile"
}
}
}
}
@@ -0,0 +1,69 @@
{
"editor": {
"profile": "Vault İmzalayıcı Profili",
"selectProfile": "Bir Vault profili seçin...",
"hint": "Ayarlar paylaşılan profilden gelir; bağlandığınızda OIDC ile Vault'ta oturum açarsınız. Hiçbir gizli bilgi saklanmaz.",
"docs": "Belgelere bak",
"sharedProfile": "{{name}} (shared)"
},
"profiles": {
"new": "Yeni profil",
"manage": "Vault Profillerini Yönet",
"name": "Gösterim Adı",
"vaultAddr": "Vault Adresi",
"namespace": "Ad Alanı",
"oidcMount": "OIDC Kimlik Doğrulama Bağlama",
"oidcRole": "OIDC Rolü",
"sshMount": "SSH Gizli Dizin Bağlama",
"sshRole": "SSH İmzalayıcı Rolü",
"validPrincipals": "Geçerli Asıllar",
"keyType": "Geçici Anahtar Türü",
"shareWithAll": "Tüm Kullanıcılarla Paylaş",
"create": "Profil oluştur",
"created": "Vault profili oluşturuldu",
"saved": "Vault profili kaydedildi",
"deleted": "Vault profili silindi",
"saveFailed": "Vault profili kaydedilemedi",
"deleteFailed": "Vault profili silinemedi",
"save": "Profili kaydet",
"validationError": "Ad, Vault adresi ve SSH imzalama rolü gereklidir",
"none": "Henüz Vault profili yok.",
"sharedBadge": "paylaşılan",
"edit": "Düzenle",
"delete": "Sil"
},
"hosts": {
"filterAuthVault": "Vault"
},
"dialog": {
"title": "Vault Oturum Açması Gerekli",
"description": "HashiCorp Vault'ta oturum açmak için bir pencere açıldı. Orada oturum açmayı tamamlayın; bu bağlantı otomatik olarak devam edecek.",
"failed": "Vault kimlik doğrulaması başarısız oldu. Lütfen tekrar deneyin.",
"reopen": "Oturum açma penceresini yeniden aç"
},
"permissions": {
"use": {
"title": "Use Vault SSH signing",
"description": "Create Vault signer profiles and connect to hosts that use Vault."
},
"share": {
"title": "Share Vault profiles",
"description": "Mark a Vault signer profile as shared so every user can pick it."
}
},
"settings": {
"redirectUri": {
"label": "Redirect URI",
"description": "Add this URL to allowed_redirect_uris in each Vault OIDC role that Termix signs in with."
},
"legacyCallback": {
"label": "Use the old redirect URI",
"description": "Keep sending /vault/oidc/callback, the redirect URI used before 2.9. Turn this off once your Vault roles allow the new one."
},
"host": {
"profileId": {
"label": "Vault signer profile"
}
}
}
}
@@ -0,0 +1,69 @@
{
"editor": {
"profile": "Vault Signer Profile",
"selectProfile": "Select a Vault profile...",
"hint": "Settings come from the shared profile; you'll sign in to Vault via OIDC when you connect. No secrets are stored.",
"docs": "View docs",
"sharedProfile": "{{name}} (shared)"
},
"profiles": {
"new": "New profile",
"manage": "Manage Vault profiles",
"name": "Friendly Name",
"vaultAddr": "Vault Address",
"namespace": "Namespace",
"oidcMount": "OIDC Auth Mount",
"oidcRole": "OIDC Role",
"sshMount": "SSH Secrets Mount",
"sshRole": "SSH Signer Role",
"validPrincipals": "Valid Principals",
"keyType": "Ephemeral Key Type",
"shareWithAll": "Share with all users",
"create": "Create profile",
"created": "Vault profile created",
"saved": "Vault profile saved",
"deleted": "Vault profile deleted",
"saveFailed": "Failed to save Vault profile",
"deleteFailed": "Failed to delete Vault profile",
"save": "Save profile",
"validationError": "Name, Vault address and SSH signer role are required",
"none": "No Vault profiles yet.",
"sharedBadge": "shared",
"edit": "Edit",
"delete": "Delete"
},
"hosts": {
"filterAuthVault": "Vault"
},
"dialog": {
"title": "Vault sign-in required",
"description": "A window has opened to sign in to HashiCorp Vault. Complete the sign-in there; this connection will continue automatically.",
"failed": "Vault authentication failed. Please try again.",
"reopen": "Reopen sign-in window"
},
"permissions": {
"use": {
"title": "Use Vault SSH signing",
"description": "Create Vault signer profiles and connect to hosts that use Vault."
},
"share": {
"title": "Share Vault profiles",
"description": "Mark a Vault signer profile as shared so every user can pick it."
}
},
"settings": {
"redirectUri": {
"label": "Redirect URI",
"description": "Add this URL to allowed_redirect_uris in each Vault OIDC role that Termix signs in with."
},
"legacyCallback": {
"label": "Use the old redirect URI",
"description": "Keep sending /vault/oidc/callback, the redirect URI used before 2.9. Turn this off once your Vault roles allow the new one."
},
"host": {
"profileId": {
"label": "Vault signer profile"
}
}
}
}
@@ -0,0 +1,69 @@
{
"editor": {
"profile": "Hồ sơ người ký Vault",
"selectProfile": "Chọn hồ sơ Vault...",
"hint": "Cài đặt từ hồ sơ dùng chung; bạn sẽ đăng nhập Vault qua OIDC khi kết nối. Không lưu bí mật.",
"docs": "Xem tài liệu",
"sharedProfile": "{{name}} (shared)"
},
"profiles": {
"new": "Hồ sơ mới",
"manage": "Quản lý hồ sơ Vault",
"name": "Tên hiển thị",
"vaultAddr": "Địa chỉ Vault",
"namespace": "Không gian tên",
"oidcMount": "Điểm gắn xác thực OIDC",
"oidcRole": "Vai trò OIDC",
"sshMount": "Điểm gắn bí mật SSH",
"sshRole": "Vai trò ký SSH",
"validPrincipals": "Các danh tính hợp lệ",
"keyType": "Loại khóa tạm thời",
"shareWithAll": "Chia sẻ với tất cả người dùng",
"create": "Tạo hồ sơ",
"created": "Đã tạo hồ sơ Vault",
"saved": "Đã lưu hồ sơ Vault",
"deleted": "Đã xóa hồ sơ Vault",
"saveFailed": "Không thể lưu hồ sơ Vault",
"deleteFailed": "Không thể xóa hồ sơ Vault",
"save": "Lưu hồ sơ",
"validationError": "Tên, địa chỉ Vault và vai trò ký SSH là bắt buộc",
"none": "Chưa có hồ sơ Vault nào.",
"sharedBadge": "được chia sẻ",
"edit": "Chỉnh sửa",
"delete": "Xóa"
},
"hosts": {
"filterAuthVault": "Vault"
},
"dialog": {
"title": "Yêu cầu đăng nhập Vault",
"description": "Một cửa sổ đã mở để đăng nhập vào HashiCorp Vault. Hoàn tất đăng nhập ở đó; kết nối này sẽ tự động tiếp tục.",
"failed": "Xác thực Vault thất bại. Vui lòng thử lại.",
"reopen": "Mở lại cửa sổ đăng nhập"
},
"permissions": {
"use": {
"title": "Use Vault SSH signing",
"description": "Create Vault signer profiles and connect to hosts that use Vault."
},
"share": {
"title": "Share Vault profiles",
"description": "Mark a Vault signer profile as shared so every user can pick it."
}
},
"settings": {
"redirectUri": {
"label": "Redirect URI",
"description": "Add this URL to allowed_redirect_uris in each Vault OIDC role that Termix signs in with."
},
"legacyCallback": {
"label": "Use the old redirect URI",
"description": "Keep sending /vault/oidc/callback, the redirect URI used before 2.9. Turn this off once your Vault roles allow the new one."
},
"host": {
"profileId": {
"label": "Vault signer profile"
}
}
}
}
@@ -0,0 +1,69 @@
{
"editor": {
"profile": "Vault 签名者配置文件",
"selectProfile": "选择 Vault 配置文件...",
"hint": "设置来自共享配置文件;连接时将使用 OIDC 登录 Vault。不会存储任何机密。",
"docs": "查看文档",
"sharedProfile": "{{name}}(共享)"
},
"profiles": {
"new": "新建配置文件",
"manage": "管理 Vault 配置文件",
"name": "友好名称",
"vaultAddr": "Vault 地址",
"namespace": "命名空间",
"oidcMount": "OIDC 认证挂载点",
"oidcRole": "OIDC 角色",
"sshMount": "SSH 机密挂载点",
"sshRole": "SSH 签名者角色",
"validPrincipals": "有效主体",
"keyType": "临时密钥类型",
"shareWithAll": "与所有用户共享",
"create": "创建配置文件",
"created": "Vault 配置文件已创建",
"saved": "Vault 配置文件已保存",
"deleted": "Vault 配置文件已删除",
"saveFailed": "保存 Vault 配置文件失败",
"deleteFailed": "删除 Vault 配置文件失败",
"save": "保存配置文件",
"validationError": "名称、Vault 地址和 SSH 签名者角色为必填项",
"none": "暂无 Vault 配置文件。",
"sharedBadge": "已共享",
"edit": "编辑",
"delete": "删除"
},
"hosts": {
"filterAuthVault": "Vault"
},
"dialog": {
"title": "需要 Vault 登录",
"description": "已打开窗口以登录 HashiCorp Vault。在该处完成登录;此连接将自动继续。",
"failed": "Vault 认证失败,请重试。",
"reopen": "重新打开登录窗口"
},
"permissions": {
"use": {
"title": "Use Vault SSH signing",
"description": "Create Vault signer profiles and connect to hosts that use Vault."
},
"share": {
"title": "Share Vault profiles",
"description": "Mark a Vault signer profile as shared so every user can pick it."
}
},
"settings": {
"redirectUri": {
"label": "Redirect URI",
"description": "Add this URL to allowed_redirect_uris in each Vault OIDC role that Termix signs in with."
},
"legacyCallback": {
"label": "Use the old redirect URI",
"description": "Keep sending /vault/oidc/callback, the redirect URI used before 2.9. Turn this off once your Vault roles allow the new one."
},
"host": {
"profileId": {
"label": "Vault signer profile"
}
}
}
}
@@ -0,0 +1,69 @@
{
"editor": {
"profile": "Vault 簽署者設定檔",
"selectProfile": "選取 Vault 設定檔...",
"hint": "設定來自共用設定檔;連線時您將透過 OIDC 登入 Vault。不會儲存任何機密。",
"docs": "檢視文件",
"sharedProfile": "{{name}} (shared)"
},
"profiles": {
"new": "新增設定檔",
"manage": "管理 Vault 設定檔",
"name": "易記名稱",
"vaultAddr": "Vault 位址",
"namespace": "命名空間",
"oidcMount": "OIDC 驗證掛載",
"oidcRole": "OIDC 角色",
"sshMount": "SSH 機密掛載",
"sshRole": "SSH 簽署者角色",
"validPrincipals": "有效主體",
"keyType": "暫時金鑰類型",
"shareWithAll": "與所有使用者共用",
"create": "建立設定檔",
"created": "Vault 設定檔已建立",
"saved": "Vault 設定檔已儲存",
"deleted": "Vault 設定檔已刪除",
"saveFailed": "無法儲存 Vault 設定檔",
"deleteFailed": "無法刪除 Vault 設定檔",
"save": "儲存設定檔",
"validationError": "名稱、Vault 位址和 SSH 簽署者角色為必填",
"none": "尚無 Vault 設定檔。",
"sharedBadge": "已共用",
"edit": "編輯",
"delete": "刪除"
},
"hosts": {
"filterAuthVault": "Vault"
},
"dialog": {
"title": "需要 Vault 登入",
"description": "已開啟視窗以登入 HashiCorp Vault。在該處完成登入;此連線將自動繼續。",
"failed": "Vault 驗證失敗。請再試一次。",
"reopen": "重新開啟登入視窗"
},
"permissions": {
"use": {
"title": "Use Vault SSH signing",
"description": "Create Vault signer profiles and connect to hosts that use Vault."
},
"share": {
"title": "Share Vault profiles",
"description": "Mark a Vault signer profile as shared so every user can pick it."
}
},
"settings": {
"redirectUri": {
"label": "Redirect URI",
"description": "Add this URL to allowed_redirect_uris in each Vault OIDC role that Termix signs in with."
},
"legacyCallback": {
"label": "Use the old redirect URI",
"description": "Keep sending /vault/oidc/callback, the redirect URI used before 2.9. Turn this off once your Vault roles allow the new one."
},
"host": {
"profileId": {
"label": "Vault signer profile"
}
}
}
}
+84
View File
@@ -0,0 +1,84 @@
{
"id": "vault",
"name": "HashiCorp Vault",
"version": "1.0.0",
"description": "Connect to hosts with short-lived SSH certificates from HashiCorp Vault, after signing in to Vault with OIDC.",
"author": {
"name": "Termix"
},
"license": "MIT",
"repository": "https://github.com/Termix-SSH/Termix",
"category": "Access & Security",
"icon": "KeyRound",
"engine": {
"termix": ">=2.9.0",
"api": "1"
},
"capabilities": [
"db:own",
"secrets:own",
"network:outbound",
"network:serve",
"auth:provide",
"ui:surface"
],
"contributes": {
"auth": {
"sshAuthTypes": ["vault"]
},
"permissions": [
{
"name": "use",
"titleKey": "permissions.use.title",
"descriptionKey": "permissions.use.description",
"defaultRoles": ["admin", "user"]
},
{
"name": "share",
"titleKey": "permissions.share.title",
"descriptionKey": "permissions.share.description",
"defaultRoles": ["admin"]
}
],
"settings": {
"admin": [
{
"key": "redirectUri",
"type": "custom",
"component": "redirectUri"
},
{
"key": "legacyCallback",
"type": "boolean",
"labelKey": "settings.legacyCallback.label",
"descriptionKey": "settings.legacyCallback.description",
"default": false
}
],
"host": {
"fields": [
{
"key": "profileId",
"type": "number",
"labelKey": "settings.host.profileId.label",
"hidden": true,
"ownerOnly": true,
"defaultLevels": ["user", "folder"]
}
]
}
},
"http": {
"legacyRedirects": [
{
"from": "/vault/oidc/callback",
"to": "/oidc/callback"
}
]
},
"syncEntities": ["vaultProfiles"],
"uses": ["terminal.overlay"]
},
"locales": "locales",
"platforms": ["linux", "win32", "darwin"]
}
@@ -0,0 +1,37 @@
-- vault 0001: adopt_vault_tables
-- Generated by termix-plugin migrations. Review before committing.
CREATE TABLE IF NOT EXISTS `vault_profiles` (
`id` int AUTO_INCREMENT PRIMARY KEY,
`user_id` varchar(255) NOT NULL,
`name` text NOT NULL,
`description` text,
`folder` text,
`tags` text,
`vault_addr` text NOT NULL,
`vault_namespace` text,
`oidc_mount` text,
`oidc_role` text,
`ssh_mount` text,
`ssh_role` text NOT NULL,
`valid_principals` text,
`key_type` text,
`shared` boolean NOT NULL DEFAULT false,
`sync_id` varchar(255) UNIQUE,
`created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
`updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
);
RENAME TABLE `vault_profiles` TO `p_vault_profiles`;
CREATE TABLE IF NOT EXISTS `vault_tokens` (
`id` int AUTO_INCREMENT PRIMARY KEY,
`user_id` varchar(255) NOT NULL,
`profile_id` int NOT NULL,
`ssh_cert` text NOT NULL,
`private_key` text NOT NULL,
`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
);
RENAME TABLE `vault_tokens` TO `p_vault_tokens`;
@@ -0,0 +1,38 @@
-- vault 0001: adopt_vault_tables
-- Generated by termix-plugin migrations. Review before committing.
CREATE TABLE IF NOT EXISTS "vault_profiles" (
"id" serial PRIMARY KEY,
"user_id" varchar(255) NOT NULL,
"name" text NOT NULL,
"description" text,
"folder" text,
"tags" text,
"vault_addr" text NOT NULL,
"vault_namespace" text,
"oidc_mount" text,
"oidc_role" text,
"ssh_mount" text,
"ssh_role" text NOT NULL,
"valid_principals" text,
"key_type" text,
"shared" boolean NOT NULL DEFAULT false,
"sync_id" varchar(255) UNIQUE,
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
);
ALTER TABLE "vault_profiles" RENAME TO "p_vault_profiles";
CREATE TABLE IF NOT EXISTS "vault_tokens" (
"id" serial PRIMARY KEY,
"user_id" varchar(255) NOT NULL,
"profile_id" integer NOT NULL,
"ssh_cert" text NOT NULL,
"private_key" text NOT NULL,
"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
);
CREATE UNIQUE INDEX IF NOT EXISTS "idx_vault_tokens_user_profile" ON "vault_tokens" ("user_id", "profile_id");
ALTER TABLE "vault_tokens" RENAME TO "p_vault_tokens";
+127
View File
@@ -0,0 +1,127 @@
{
"version": 1,
"tables": {
"profiles": {
"columns": {
"id": {
"type": "id",
"primaryKey": true,
"notNull": true
},
"userId": {
"type": "refUser",
"length": 255,
"notNull": true
},
"name": {
"type": "text",
"notNull": true
},
"description": {
"type": "text"
},
"folder": {
"type": "text"
},
"tags": {
"type": "text"
},
"vaultAddr": {
"type": "text",
"notNull": true
},
"vaultNamespace": {
"type": "text"
},
"oidcMount": {
"type": "text"
},
"oidcRole": {
"type": "text"
},
"sshMount": {
"type": "text"
},
"sshRole": {
"type": "text",
"notNull": true
},
"validPrincipals": {
"type": "text"
},
"keyType": {
"type": "text"
},
"shared": {
"type": "boolean",
"notNull": true,
"defaultValue": false
},
"syncId": {
"type": "varchar",
"length": 255,
"unique": true
},
"createdAt": {
"type": "text",
"notNull": true,
"defaultNow": true
},
"updatedAt": {
"type": "text",
"notNull": true,
"defaultNow": true
}
},
"indexes": [],
"adopts": "vault_profiles"
},
"tokens": {
"columns": {
"id": {
"type": "id",
"primaryKey": true,
"notNull": true
},
"userId": {
"type": "refUser",
"length": 255,
"notNull": true
},
"profileId": {
"type": "integer",
"notNull": true
},
"sshCert": {
"type": "text",
"notNull": true
},
"privateKey": {
"type": "text",
"notNull": true
},
"createdAt": {
"type": "text",
"notNull": true,
"defaultNow": true
},
"expiresAt": {
"type": "varchar",
"length": 255,
"notNull": true
},
"lastUsed": {
"type": "text"
}
},
"indexes": [
{
"name": "idx_vault_tokens_user_profile",
"columns": ["userId", "profileId"],
"unique": true
}
],
"adopts": "vault_tokens"
}
}
}
@@ -0,0 +1,38 @@
-- vault 0001: adopt_vault_tables
-- Generated by termix-plugin migrations. Review before committing.
CREATE TABLE IF NOT EXISTS "vault_profiles" (
"id" integer PRIMARY KEY AUTOINCREMENT,
"user_id" text NOT NULL,
"name" text NOT NULL,
"description" text,
"folder" text,
"tags" text,
"vault_addr" text NOT NULL,
"vault_namespace" text,
"oidc_mount" text,
"oidc_role" text,
"ssh_mount" text,
"ssh_role" text NOT NULL,
"valid_principals" text,
"key_type" text,
"shared" integer NOT NULL DEFAULT 0,
"sync_id" text UNIQUE,
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
);
ALTER TABLE "vault_profiles" RENAME TO "p_vault_profiles";
CREATE TABLE IF NOT EXISTS "vault_tokens" (
"id" integer PRIMARY KEY AUTOINCREMENT,
"user_id" text NOT NULL,
"profile_id" integer NOT NULL,
"ssh_cert" text NOT NULL,
"private_key" text NOT NULL,
"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
);
CREATE UNIQUE INDEX IF NOT EXISTS "idx_vault_tokens_user_profile" ON "vault_tokens" ("user_id", "profile_id");
ALTER TABLE "vault_tokens" RENAME TO "p_vault_tokens";
+34
View File
@@ -0,0 +1,34 @@
{
"name": "@termix-plugin/vault",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -p .",
"validate": "termix-plugin validate"
},
"dependencies": {
"lucide-react": "^1.33.0"
},
"devDependencies": {
"@termix/plugin-sdk": "^1.0.0",
"@testing-library/dom": "^10.4.2",
"@testing-library/react": "^16.3.3",
"@types/node": "^26.2.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"better-sqlite3": "^13.0.3",
"jsdom": "^30.0.1",
"vitest": "^4.1.11"
},
"peerDependencies": {
"@termix/plugin-sdk": "^1.0.0",
"drizzle-orm": "^0.45.2",
"express": "^5.2.1",
"react": "^19.2.8",
"sonner": "^2.0.8",
"ssh2": "^1.17.0"
}
}
+206
View File
@@ -0,0 +1,206 @@
import type { PluginContext } from "@termix/plugin-sdk/backend";
import type { TokenStore } from "./token-store.js";
import {
completeVaultOidc,
generateEphemeralKeyPair,
signWithVault,
startVaultOidc,
type VaultProfileConfig,
} from "./vault-client.js";
/** The redirect URI 2.8 had Vault roles allow. */
export const LEGACY_CALLBACK_PATH = "/vault/oidc/callback";
export const CALLBACK_PATH = "/plugin-api/vault/oidc/callback";
const AUTH_TIMEOUT_MS = 5 * 60 * 1000;
/** 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 StartRequest {
userId: string;
hostId: number;
profile: VaultProfileConfig;
socket: SignInSocket;
requestOrigin: string;
}
export interface CallbackResult {
ok: boolean;
message: string;
}
interface Session {
state: string;
userId: string;
hostId: number;
profile: VaultProfileConfig;
clientNonce: string;
privateKey: string;
publicKey: string;
socket: SignInSocket;
timeout: ReturnType<typeof setTimeout>;
}
function send(socket: SignInSocket, message: Record<string, unknown>): void {
try {
socket.send(JSON.stringify(message));
} catch {
// socket already gone
}
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
export type AuthSessions = ReturnType<typeof createAuthSessions>;
/**
* Vault OIDC sign-ins in flight, keyed by the state Vault put in its auth
* URL so the unauthenticated browser callback finds its way back. Created
* per activation; closeAll runs on deactivate.
*/
export function createAuthSessions(ctx: PluginContext, tokens: TokenStore) {
const sessions = new Map<string, Session>();
function end(session: Session): void {
clearTimeout(session.timeout);
sessions.delete(session.state);
}
async function callbackPath(): Promise<string> {
const legacy = await ctx.settings.get<boolean>("legacyCallback");
return legacy ? LEGACY_CALLBACK_PATH : CALLBACK_PATH;
}
return {
/** Asks Vault for an auth URL and sends it to the terminal. */
async start(request: StartRequest): Promise<void> {
try {
const keyPair = generateEphemeralKeyPair(request.profile.keyType);
const redirectUri = `${request.requestOrigin}${await callbackPath()}`;
const { authUrl, state, clientNonce } = await startVaultOidc(
ctx.fetch,
request.profile,
redirectUri,
);
const existing = sessions.get(state);
if (existing) end(existing);
const session: Session = {
state,
userId: request.userId,
hostId: request.hostId,
profile: request.profile,
clientNonce,
privateKey: keyPair.privateKey,
publicKey: keyPair.publicKey,
socket: request.socket,
timeout: setTimeout(() => {
const current = sessions.get(state);
if (current) end(current);
}, AUTH_TIMEOUT_MS),
};
sessions.set(state, session);
request.socket.on?.("close", () => {
const current = sessions.get(state);
if (current) end(current);
});
send(request.socket, {
type: "vault_auth_url",
hostId: request.hostId,
requestId: state,
url: authUrl,
});
} catch (error) {
const message = errorMessage(error);
ctx.log.error(`Failed to start Vault authentication: ${message}`);
send(request.socket, {
type: "vault_error",
hostId: request.hostId,
error: message,
});
}
},
/**
* The identity provider's redirect, via Vault: exchanges the code for a
* Vault token, has Vault sign the ephemeral key and caches the result.
*/
async complete(state: string, code: string): Promise<CallbackResult> {
const session = sessions.get(state);
if (!session) {
return {
ok: false,
message: "This sign-in request is no longer active.",
};
}
end(session);
try {
const token = await completeVaultOidc(ctx.fetch, session.profile, {
state,
code,
clientNonce: session.clientNonce,
});
const signed = await signWithVault(
ctx.fetch,
session.profile,
token,
session.publicKey,
);
const expiresAt = await tokens.save(
session.userId,
session.profile.id,
session.privateKey,
signed,
);
ctx.log.info(
`Vault signed a certificate for host ${session.hostId} with profile ${session.profile.id}`,
);
send(session.socket, {
type: "vault_completed",
hostId: session.hostId,
requestId: state,
expiresAt,
});
return {
ok: true,
message: "You can close this window and return to Termix.",
};
} catch (error) {
const message = errorMessage(error);
ctx.log.error(`Vault sign-in failed: ${message}`);
send(session.socket, {
type: "vault_error",
hostId: session.hostId,
requestId: state,
error: message,
});
return { ok: false, message };
}
},
/** Cancels by request id, or every sign-in the user has for a host. */
cancel(userId: string, hostId?: number, requestId?: string): void {
for (const session of [...sessions.values()]) {
if (session.userId !== userId) continue;
if (
requestId ? session.state === requestId : session.hostId === hostId
) {
end(session);
}
}
},
closeAll(): void {
for (const session of [...sessions.values()]) end(session);
},
};
}
+54
View File
@@ -0,0 +1,54 @@
import type { Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { profiles as profilesTable, tokens as tokensTable } from "./tables.js";
import { createProfileStore, SYNC_ENTITY } from "./profile-store.js";
import { createTokenStore } from "./token-store.js";
import { createAuthSessions } from "./auth-session.js";
import { createVaultProvider } from "./provider.js";
import { PUBLIC_PATHS, registerRoutes } from "./routes.js";
export async function activate(ctx: PluginContext) {
const profilesDb = await ctx.db.define(profilesTable);
const tokensDb = await ctx.db.define(tokensTable);
const profiles = createProfileStore(ctx, profilesDb, tokensDb);
const tokens = createTokenStore(ctx, tokensDb);
const sessions = createAuthSessions(ctx, tokens);
ctx.disposables.add(() => sessions.closeAll());
ctx.auth.registerSshAuthProvider(
createVaultProvider(ctx, profiles, tokens, sessions),
);
// Same wire name as 2.8, where core synced it. Hosts point at a profile
// through the profileId host setting, not a column, so nothing references it.
ctx.sync.registerEntity({
type: SYNC_ENTITY,
table: profilesDb,
order: 20,
});
// A host's profileId is a local row id; remote sync carries the profile's
// syncId instead, which is the same on both sides.
ctx.registry.provide("vault.hostSettingsSync", {
exportValue: async (key: string, value: unknown) =>
key === "profileId" && typeof value === "number"
? ((await profiles.findById(value))?.syncId ?? null)
: value,
importValue: async (key: string, value: unknown) =>
key === "profileId" && typeof value === "string"
? ((await profiles.findBySyncId(value))?.id ?? null)
: value,
});
registerRoutes(
ctx,
ctx.http.router<Router>({ public: PUBLIC_PATHS }),
profiles,
sessions,
);
}
export async function deactivate() {
// Everything above was registered through ctx and is disposed by core.
}
+139
View File
@@ -0,0 +1,139 @@
import { randomUUID } from "node:crypto";
import { desc, eq, or } from "drizzle-orm";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import type { VaultProfileConfig } from "./vault-client.js";
/* 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 */
export const SYNC_ENTITY = "vaultProfiles";
export interface ProfileRow {
id: number;
userId: string;
name: string;
description: string | null;
folder: string | null;
tags: string | null;
vaultAddr: string;
vaultNamespace: string | null;
oidcMount: string | null;
oidcRole: string | null;
sshMount: string | null;
sshRole: string;
validPrincipals: string | null;
keyType: string | null;
shared: boolean | number;
syncId: string | null;
createdAt: string;
updatedAt: string;
}
export type ProfileInput = Partial<
Omit<ProfileRow, "id" | "userId" | "syncId" | "createdAt" | "updatedAt">
>;
export function toConfig(row: ProfileRow): VaultProfileConfig {
return {
id: row.id,
vaultAddr: row.vaultAddr,
vaultNamespace: row.vaultNamespace ?? null,
oidcMount: row.oidcMount ?? null,
oidcRole: row.oidcRole ?? null,
sshMount: row.sshMount ?? null,
sshRole: row.sshRole,
validPrincipals: row.validPrincipals ?? null,
keyType: row.keyType ?? null,
};
}
export type ProfileStore = ReturnType<typeof createProfileStore>;
export function createProfileStore(
ctx: PluginContext,
table: Table,
tokensTable: Table,
) {
const client = () => ctx.db.client<Drizzle>();
async function findById(id: number): Promise<ProfileRow | null> {
const rows = await (
await client()
)
.select()
.from(table)
.where(eq(table.id, id))
.limit(1);
return rows[0] ?? null;
}
async function findBySyncId(syncId: string): Promise<ProfileRow | null> {
const rows = await (
await client()
)
.select()
.from(table)
.where(eq(table.syncId, syncId))
.limit(1);
return rows[0] ?? null;
}
return {
findById,
findBySyncId,
/** The user's own profiles plus every shared one. */
async listVisible(userId: string): Promise<ProfileRow[]> {
return (await client())
.select()
.from(table)
.where(or(eq(table.userId, userId), eq(table.shared, true)))
.orderBy(desc(table.updatedAt));
},
async create(
userId: string,
input: ProfileInput & {
name: string;
vaultAddr: string;
sshRole: string;
},
): Promise<ProfileRow> {
const syncId = randomUUID();
const now = new Date().toISOString();
await (await client()).insert(table).values({
...input,
userId,
shared: input.shared ? true : false,
syncId,
createdAt: now,
updatedAt: now,
});
await ctx.db.persist();
return (await findBySyncId(syncId)) as ProfileRow;
},
async update(id: number, input: ProfileInput): Promise<ProfileRow | null> {
await (
await client()
)
.update(table)
.set({ ...input, updatedAt: new Date().toISOString() })
.where(eq(table.id, id));
await ctx.db.persist();
return findById(id);
},
/** Deletes the profile, its cached certificates and records a tombstone. */
async remove(row: ProfileRow): Promise<void> {
const db = await client();
await db.delete(tokensTable).where(eq(tokensTable.profileId, row.id));
await db.delete(table).where(eq(table.id, row.id));
await ctx.db.persist();
},
};
}
+124
View File
@@ -0,0 +1,124 @@
import type {
PluginContext,
PluginSshAuthProvider,
PluginSshHost,
} from "@termix/plugin-sdk/backend";
import { applyCertificateAuth } from "@termix/plugin-sdk/ssh-certs";
import type { AuthSessions, SignInSocket } from "./auth-session.js";
import {
toConfig,
type ProfileRow,
type ProfileStore,
} from "./profile-store.js";
import type { TokenStore } from "./token-store.js";
export const AUTH_TYPE = "vault";
const REQUIRED_MESSAGE =
"Vault SSH signer authentication required. Please open a Terminal connection first.";
const AUTH_FAILED_PATTERN = /All configured authentication methods failed/i;
/** The profile a host points at, from its host settings. */
export async function profileForHost(
ctx: PluginContext,
profiles: ProfileStore,
hostId: number,
): Promise<ProfileRow | null> {
const raw = await ctx.settings.getHost<number | string>(hostId, "profileId");
const id = Number(raw);
if (!Number.isInteger(id) || id <= 0) return null;
return profiles.findById(id);
}
/**
* Hosts with auth type "vault" connect with a certificate Vault signed for
* the host's profile. Without one, the terminal is asked for a browser
* sign-in ("vault_auth_required"), which startInteraction runs.
*/
export function createVaultProvider(
ctx: PluginContext,
profiles: ProfileStore,
tokens: TokenStore,
sessions: AuthSessions,
): PluginSshAuthProvider {
return {
type: AUTH_TYPE,
labelKey: "hosts.filterAuthVault",
needsUserInteraction: true,
supportsBackground: false,
interaction: AUTH_TYPE,
prepare: async (config, host: PluginSshHost, env) => {
const profile = await profileForHost(ctx, profiles, host.id);
if (!profile) {
return {
status: "error",
code: "failed",
message: "Host has no Vault signer profile configured",
};
}
const cert = await tokens.get(env.userId, profile.id);
if (!cert) {
env.log(
"info",
"No valid Vault certificate found, requesting authentication",
);
return {
status: "interaction-required",
interaction: AUTH_TYPE,
message: REQUIRED_MESSAGE,
flag: "requiresVaultAuth",
};
}
try {
await applyCertificateAuth(
config as never,
env.client as never,
{ privateKey: cert.privateKey, certificate: cert.sshCert },
host.username,
);
} catch (error) {
return {
status: "error",
code: "failed",
message:
"Vault SSH signer authentication failed: " +
(error instanceof Error ? error.message : String(error)),
};
}
env.log("info", "Using cached Vault-signed certificate");
return { status: "ready" };
},
onAuthFailed: (host, env, context) => {
if (!AUTH_FAILED_PATTERN.test(context.error.message)) return undefined;
ctx.log.warn("Vault certificate authentication failed, forgetting it");
void profileForHost(ctx, profiles, host.id)
.then((profile) =>
profile ? tokens.remove(env.userId, profile.id) : undefined,
)
.catch(() => undefined);
return {
status: "interaction-required",
interaction: AUTH_TYPE,
message:
"Vault authentication failed or expired. Please authenticate again.",
};
},
startInteraction: async (request) => {
const profile = await profileForHost(ctx, profiles, request.hostId);
if (!profile) {
throw new Error("No Vault signer profile configured for this host");
}
await sessions.start({
userId: request.userId,
hostId: request.hostId,
profile: toConfig(profile),
socket: request.socket as SignInSocket,
requestOrigin: request.requestOrigin,
});
},
cancelInteraction: (request) => {
sessions.cancel(request.userId, request.hostId, request.requestId);
},
};
}
+337
View File
@@ -0,0 +1,337 @@
import type { Request, Response, Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import type { AuthSessions } from "./auth-session.js";
import type {
ProfileInput,
ProfileRow,
ProfileStore,
} from "./profile-store.js";
/**
* Vault sends the browser back here without a Termix session: the sign-in is
* matched by the state Vault issued.
*/
export const PUBLIC_PATHS = ["/oidc/callback"];
export function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
export function resultPage(ok: boolean, message: string): string {
return `<!doctype html><html><head><meta charset="utf-8"><title>Termix</title>
<style>body{font-family:system-ui,sans-serif;background:#111;color:#eee;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
main{max-width:28rem;padding:2rem;border:1px solid #333;background:#181818}h1{font-size:1.1rem;margin:0 0 .5rem}p{margin:0;color:#aaa}</style></head>
<body><main><h1>${ok ? "Vault sign-in complete" : "Vault sign-in failed"}</h1><p>${escapeHtml(message)}</p></main>
${ok ? "<script>setTimeout(function(){window.close()},1500)</script>" : ""}</body></html>`;
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
function optional(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function formatProfile(row: ProfileRow, userId: string) {
return {
id: row.id,
name: row.name,
description: row.description,
folder: row.folder,
tags: row.tags ? row.tags.split(",").filter(Boolean) : [],
vaultAddr: row.vaultAddr,
vaultNamespace: row.vaultNamespace,
oidcMount: row.oidcMount,
oidcRole: row.oidcRole,
sshMount: row.sshMount,
sshRole: row.sshRole,
validPrincipals: row.validPrincipals,
keyType: row.keyType,
shared: !!row.shared,
owned: row.userId === userId,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
function parseId(req: Request): number | null {
const id = Number.parseInt(String(req.params.id), 10);
return Number.isFinite(id) ? id : null;
}
export function registerRoutes(
ctx: PluginContext,
router: Router,
profiles: ProfileStore,
sessions: AuthSessions,
): void {
const userId = () => ctx.currentActor() as string;
/**
* @openapi
* /plugin-api/vault/oidc/callback:
* get:
* summary: Vault OIDC callback
* description: Public. The redirect URI a Vault OIDC role allows. Matches the sign-in by the state Vault issued, has Vault sign the ephemeral key and tells the terminal to reconnect. The 2.8 URI /vault/oidc/callback redirects here.
* tags: [Vault]
* parameters:
* - in: query
* name: state
* schema:
* type: string
* - in: query
* name: code
* schema:
* type: string
* - in: query
* name: error
* schema:
* type: string
* responses:
* 200:
* description: Signed in; an HTML page that closes itself.
* 400:
* description: The sign-in failed or is no longer active.
*/
router.get("/oidc/callback", async (req: Request, res: Response) => {
const state = typeof req.query.state === "string" ? req.query.state : "";
const code = typeof req.query.code === "string" ? req.query.code : "";
const error = typeof req.query.error === "string" ? req.query.error : "";
const reply = (ok: boolean, message: string) =>
res
.status(ok ? 200 : 400)
.type("html")
.send(resultPage(ok, message));
if (error) return reply(false, `Vault returned: ${error}`);
if (!state || !code) return reply(false, "Missing state or code.");
const result = await sessions.complete(state, code);
return reply(result.ok, result.message);
});
router.use("/profiles", ctx.rbac.require("use") as never);
/**
* @openapi
* /plugin-api/vault/profiles:
* get:
* summary: List Vault profiles
* description: The caller's own Vault signer profiles and every shared one. Needs vault.use.
* tags: [Vault]
* responses:
* 200:
* description: Array of Vault profile objects.
* 403:
* description: Missing vault.use.
*/
router.get("/profiles", async (_req: Request, res: Response) => {
const rows = await profiles.listVisible(userId());
res.json(rows.map((row) => formatProfile(row, userId())));
});
/**
* @openapi
* /plugin-api/vault/profiles:
* post:
* summary: Create a Vault profile
* description: Creates a Vault signer profile owned by the caller. Needs vault.use, and vault.share to mark it shared.
* tags: [Vault]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [name, vaultAddr, sshRole]
* properties:
* name:
* type: string
* vaultAddr:
* type: string
* vaultNamespace:
* type: string
* oidcMount:
* type: string
* oidcRole:
* type: string
* sshMount:
* type: string
* sshRole:
* type: string
* validPrincipals:
* type: string
* keyType:
* type: string
* shared:
* type: boolean
* responses:
* 201:
* description: The created profile.
* 400:
* description: Missing required fields.
* 403:
* description: Missing vault.use, or vault.share for a shared profile.
*/
router.post("/profiles", async (req: Request, res: Response) => {
const body = (req.body ?? {}) as Record<string, unknown>;
if (
!isNonEmptyString(body.name) ||
!isNonEmptyString(body.vaultAddr) ||
!isNonEmptyString(body.sshRole)
) {
return res
.status(400)
.json({ error: "name, vaultAddr and sshRole are required" });
}
const shared = !!body.shared;
if (shared && !(await ctx.rbac.has("share"))) {
return res
.status(403)
.json({ error: "You are not allowed to share Vault profiles" });
}
const row = await profiles.create(userId(), {
name: body.name.trim(),
description: optional(body.description),
folder: optional(body.folder),
tags: Array.isArray(body.tags)
? body.tags.join(",")
: typeof body.tags === "string"
? body.tags
: "",
vaultAddr: body.vaultAddr.trim(),
vaultNamespace: optional(body.vaultNamespace),
oidcMount: optional(body.oidcMount),
oidcRole: optional(body.oidcRole),
sshMount: optional(body.sshMount),
sshRole: body.sshRole.trim(),
validPrincipals: optional(body.validPrincipals),
keyType: optional(body.keyType),
shared,
});
res.status(201).json(formatProfile(row, userId()));
});
/**
* @openapi
* /plugin-api/vault/profiles/{id}:
* put:
* summary: Update a Vault profile
* description: Only the owner may edit a profile. Needs vault.use, and vault.share to mark it shared.
* tags: [Vault]
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* requestBody:
* content:
* application/json:
* schema:
* type: object
* responses:
* 200:
* description: The updated profile.
* 400:
* description: Invalid profile id.
* 403:
* description: Not the owner, or sharing without vault.share.
* 404:
* description: Profile not found.
*/
router.put("/profiles/:id", async (req: Request, res: Response) => {
const id = parseId(req);
if (id === null)
return res.status(400).json({ error: "Invalid profile id" });
const existing = await profiles.findById(id);
if (!existing) return res.status(404).json({ error: "Profile not found" });
if (existing.userId !== userId()) {
return res
.status(403)
.json({ error: "Only the owner can edit this profile" });
}
const body = (req.body ?? {}) as Record<string, unknown>;
const fields: ProfileInput = {};
if (isNonEmptyString(body.name)) fields.name = body.name.trim();
if (body.description !== undefined)
fields.description = optional(body.description);
if (body.folder !== undefined) fields.folder = optional(body.folder);
if (body.tags !== undefined) {
fields.tags = Array.isArray(body.tags)
? body.tags.join(",")
: typeof body.tags === "string"
? body.tags
: "";
}
if (isNonEmptyString(body.vaultAddr))
fields.vaultAddr = body.vaultAddr.trim();
if (body.vaultNamespace !== undefined)
fields.vaultNamespace = optional(body.vaultNamespace);
if (body.oidcMount !== undefined)
fields.oidcMount = optional(body.oidcMount);
if (body.oidcRole !== undefined) fields.oidcRole = optional(body.oidcRole);
if (body.sshMount !== undefined) fields.sshMount = optional(body.sshMount);
if (isNonEmptyString(body.sshRole)) fields.sshRole = body.sshRole.trim();
if (body.validPrincipals !== undefined)
fields.validPrincipals = optional(body.validPrincipals);
if (body.keyType !== undefined) fields.keyType = optional(body.keyType);
if (body.shared !== undefined) {
if (body.shared && !(await ctx.rbac.has("share"))) {
return res
.status(403)
.json({ error: "You are not allowed to share Vault profiles" });
}
fields.shared = !!body.shared;
}
const updated = await profiles.update(id, fields);
if (!updated) return res.status(404).json({ error: "Profile not found" });
res.json(formatProfile(updated, userId()));
});
/**
* @openapi
* /plugin-api/vault/profiles/{id}:
* delete:
* summary: Delete a Vault profile
* description: Deletes a profile and every certificate cached for it. Only the owner may delete it. Needs vault.use.
* tags: [Vault]
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Deleted.
* 400:
* description: Invalid profile id.
* 403:
* description: Not the owner.
* 404:
* description: Profile not found.
*/
router.delete("/profiles/:id", async (req: Request, res: Response) => {
const id = parseId(req);
if (id === null)
return res.status(400).json({ error: "Invalid profile id" });
const existing = await profiles.findById(id);
if (!existing) return res.status(404).json({ error: "Profile not found" });
if (existing.userId !== userId()) {
return res
.status(403)
.json({ error: "Only the owner can delete this profile" });
}
await profiles.remove(existing);
res.json({ success: true });
});
}
+74
View File
@@ -0,0 +1,74 @@
import {
adoptLegacyTable,
boolean,
defineTable,
id,
integer,
refUser,
text,
varchar,
} from "@termix/plugin-sdk/db";
/**
* Vault signer profiles: connection settings only, no secrets. A shared
* profile is visible to every user. Adopted from core's vault_profiles.
*/
export const profiles = adoptLegacyTable(
"vault_profiles",
defineTable("profiles", {
id: id(),
userId: refUser(),
name: text().notNull(),
description: text(),
folder: text(),
tags: text(),
vaultAddr: text().notNull(),
vaultNamespace: text(),
oidcMount: text(),
oidcRole: text(),
sshMount: text(),
sshRole: text().notNull(),
validPrincipals: text(),
keyType: text(),
shared: boolean().notNull().default(false),
syncId: varchar().unique(),
createdAt: text().notNull().defaultNow(),
updatedAt: text().notNull().defaultNow(),
}),
);
/**
* One signed certificate and its ephemeral key per user and profile, sealed
* with ctx.secrets.seal. Adopted from core's vault_tokens. Rows 2.8 wrote were
* encrypted with the user's data key; they do not unseal and are dropped.
*
* profileId has no foreign key on a fresh install (the SDK has no builder
* for one to the plugin's own table), so deleting a profile deletes its
* tokens in code. Upgraded installs keep 2.8's cascading key.
*/
export const tokens = adoptLegacyTable(
"vault_tokens",
defineTable(
"tokens",
{
id: id(),
userId: refUser(),
profileId: integer().notNull(),
sshCert: text().notNull(),
privateKey: text().notNull(),
createdAt: text().notNull().defaultNow(),
expiresAt: varchar().notNull(),
lastUsed: text(),
},
{
uniques: [
{
name: "idx_vault_tokens_user_profile",
columns: ["userId", "profileId"],
},
],
},
),
);
export const tables = [profiles, tokens];
+114
View File
@@ -0,0 +1,114 @@
import { and, eq } from "drizzle-orm";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { parseCertValidBefore } from "./vault-client.js";
/* eslint-disable @typescript-eslint/no-explicit-any */
type Table = any;
type Drizzle = any;
/* eslint-enable @typescript-eslint/no-explicit-any */
// Sign again once a certificate is this close to expiring.
const EXPIRY_SKEW_MS = 60 * 1000;
// When the certificate's expiry cannot be read.
const FALLBACK_TTL_MS = 5 * 60 * 1000;
export interface CachedCertificate {
privateKey: string;
sshCert: string;
}
export type TokenStore = ReturnType<typeof createTokenStore>;
export function createTokenStore(ctx: PluginContext, table: Table) {
const client = () => ctx.db.client<Drizzle>();
const match = (userId: string, profileId: number) =>
and(eq(table.userId, userId), eq(table.profileId, profileId));
async function find(userId: string, profileId: number) {
const rows = await (
await client()
)
.select()
.from(table)
.where(match(userId, profileId))
.limit(1);
return rows[0] ?? null;
}
async function remove(userId: string, profileId: number): Promise<void> {
if (!(await find(userId, profileId))) return;
await (await client()).delete(table).where(match(userId, profileId));
await ctx.db.persist();
}
return {
/** Stores a signed certificate and returns when it expires. */
async save(
userId: string,
profileId: number,
privateKey: string,
sshCert: string,
): Promise<string> {
const validBefore = parseCertValidBefore(sshCert);
const expiresAt = new Date(
validBefore > 0 ? validBefore * 1000 : Date.now() + FALLBACK_TTL_MS,
).toISOString();
const values = {
sshCert: await ctx.secrets.seal(sshCert),
privateKey: await ctx.secrets.seal(privateKey),
createdAt: new Date().toISOString(),
expiresAt,
lastUsed: null,
};
if (await find(userId, profileId)) {
await (
await client()
)
.update(table)
.set(values)
.where(match(userId, profileId));
} else {
await (
await client()
)
.insert(table)
.values({ userId, profileId, ...values });
}
await ctx.db.persist();
return expiresAt;
},
/** A usable certificate, or null. Expired or unreadable rows are deleted. */
async get(
userId: string,
profileId: number,
): Promise<CachedCertificate | null> {
const row = await find(userId, profileId);
if (!row) return null;
const expiresAt = new Date(row.expiresAt).getTime();
if (
!Number.isFinite(expiresAt) ||
expiresAt - EXPIRY_SKEW_MS < Date.now()
) {
await remove(userId, profileId);
return null;
}
const sshCert = await ctx.secrets.unseal(row.sshCert);
const privateKey = await ctx.secrets.unseal(row.privateKey);
if (!sshCert || !privateKey) {
await remove(userId, profileId);
return null;
}
await (
await client()
)
.update(table)
.set({ lastUsed: new Date().toISOString() })
.where(match(userId, profileId));
await ctx.db.persist({ lazy: true });
return { sshCert, privateKey };
},
remove,
};
}
+286
View File
@@ -0,0 +1,286 @@
import crypto from "node:crypto";
import ssh2Pkg from "ssh2";
import type { PluginFetch } from "@termix/plugin-sdk/backend";
const { utils: ssh2Utils } = ssh2Pkg;
/**
* Vault's HTTP API for SSH signing: an OIDC login for a short-lived Vault
* token, then the SSH secrets engine signs an ephemeral public key. No Vault
* token or long-lived key is ever stored.
*/
export interface VaultProfileConfig {
id: number;
vaultAddr: string;
vaultNamespace?: string | null;
oidcMount?: string | null;
oidcRole?: string | null;
sshMount?: string | null;
sshRole: string;
validPrincipals?: string | null;
keyType?: string | null;
}
export interface EphemeralKeyPair {
privateKey: string;
publicKey: string;
}
/* eslint-disable @typescript-eslint/no-explicit-any */
type VaultJson = any;
/* eslint-enable @typescript-eslint/no-explicit-any */
export function normalizeAddr(addr: string): string {
return addr.trim().replace(/\/+$/, "");
}
export function trimMount(
mount: string | null | undefined,
fallback: string,
): string {
return (mount?.trim() || fallback).replace(/^\/+|\/+$/g, "");
}
function vaultHeaders(profile: VaultProfileConfig): Record<string, string> {
const headers: Record<string, string> = {};
if (profile.vaultNamespace?.trim()) {
headers["X-Vault-Namespace"] = profile.vaultNamespace.trim();
}
return headers;
}
/**
* The profile's own Vault server may be private: someone configured it by
* name, which 2.8 allowed too. Nothing else private is reachable.
*/
export function allowedHosts(profile: VaultProfileConfig): string[] {
try {
return [new URL(normalizeAddr(profile.vaultAddr)).hostname];
} catch {
return [];
}
}
async function vaultRequest(
fetch: PluginFetch,
profile: VaultProfileConfig,
url: string,
method: "GET" | "POST",
headers: Record<string, string>,
body?: Record<string, unknown>,
): Promise<VaultJson> {
let response: Response;
try {
response = await fetch(url, {
method,
headers: {
...(body ? { "Content-Type": "application/json" } : {}),
...headers,
},
body: body ? JSON.stringify(body) : undefined,
allowPrivateHosts: allowedHosts(profile),
});
} catch (e) {
throw new Error(
`Failed to reach Vault at ${url}: ${
e instanceof Error ? e.message : String(e)
}`,
);
}
const text = await response.text();
let json: VaultJson;
if (text) {
try {
json = JSON.parse(text);
} catch {
// not JSON
}
}
if (!response.ok) {
const errs =
json && Array.isArray(json.errors) && json.errors.length
? json.errors.join("; ")
: text || `HTTP ${response.status}`;
throw new Error(`Vault request failed (${response.status}): ${errs}`);
}
return json;
}
/** An ephemeral SSH keypair in OpenSSH format. */
export function generateEphemeralKeyPair(
keyType?: string | null,
): EphemeralKeyPair {
let type: "ed25519" | "rsa" | "ecdsa" = "ed25519";
const options: { bits?: number } = {};
switch ((keyType || "ssh-ed25519").trim()) {
case "ssh-rsa":
type = "rsa";
options.bits = 4096;
break;
case "ecdsa-sha2-nistp256":
type = "ecdsa";
options.bits = 256;
break;
default:
type = "ed25519";
}
const pair = ssh2Utils.generateKeyPairSync(type as never, options as never);
return { privateKey: pair.private, publicKey: pair.public };
}
/**
* Starts a Vault OIDC login. `redirectUri` must be allowed by the Vault
* role's allowed_redirect_uris and by the identity provider.
*/
export async function startVaultOidc(
fetch: PluginFetch,
profile: VaultProfileConfig,
redirectUri: string,
): Promise<{ authUrl: string; state: string; clientNonce: string }> {
const addr = normalizeAddr(profile.vaultAddr);
const mount = trimMount(profile.oidcMount, "oidc");
const clientNonce = crypto.randomBytes(20).toString("hex");
const json = await vaultRequest(
fetch,
profile,
`${addr}/v1/auth/${mount}/oidc/auth_url`,
"POST",
vaultHeaders(profile),
{
role: profile.oidcRole?.trim() || "",
redirect_uri: redirectUri,
client_nonce: clientNonce,
},
);
const authUrl: string | undefined = json?.data?.auth_url;
if (!authUrl) {
throw new Error("Vault did not return an OIDC auth_url");
}
// Vault puts its own state in the auth_url; the callback is matched on it.
let state = "";
try {
state = new URL(authUrl).searchParams.get("state") || "";
} catch {
const m = authUrl.match(/[?&]state=([^&]+)/);
state = m ? decodeURIComponent(m[1]) : "";
}
if (!state) {
throw new Error("Could not determine OIDC state from Vault auth_url");
}
return { authUrl, state, clientNonce };
}
/** Completes the OIDC callback and returns a short-lived Vault token. */
export async function completeVaultOidc(
fetch: PluginFetch,
profile: VaultProfileConfig,
params: { state: string; code: string; clientNonce: string },
): Promise<string> {
const addr = normalizeAddr(profile.vaultAddr);
const mount = trimMount(profile.oidcMount, "oidc");
const url = new URL(`${addr}/v1/auth/${mount}/oidc/callback`);
url.searchParams.set("state", params.state);
url.searchParams.set("code", params.code);
url.searchParams.set("client_nonce", params.clientNonce);
const json = await vaultRequest(
fetch,
profile,
url.toString(),
"GET",
vaultHeaders(profile),
);
const token: string | undefined = json?.auth?.client_token;
if (!token) {
throw new Error("Vault OIDC callback did not return a client token");
}
return token;
}
/** Signs an SSH public key with Vault, returning the OpenSSH certificate line. */
export async function signWithVault(
fetch: PluginFetch,
profile: VaultProfileConfig,
vaultToken: string,
publicKey: string,
): Promise<string> {
const addr = normalizeAddr(profile.vaultAddr);
const mount = trimMount(profile.sshMount, "ssh-client-signer");
const body: Record<string, unknown> = {
public_key: publicKey.trim(),
cert_type: "user",
};
if (profile.validPrincipals?.trim()) {
body.valid_principals = profile.validPrincipals.trim();
}
const json = await vaultRequest(
fetch,
profile,
`${addr}/v1/${mount}/sign/${encodeURIComponent(profile.sshRole.trim())}`,
"POST",
{ ...vaultHeaders(profile), "X-Vault-Token": vaultToken },
body,
);
const signedKey: string | undefined = json?.data?.signed_key;
if (!signedKey) {
throw new Error("Vault sign response did not include a signed_key");
}
return signedKey.trim();
}
/**
* The "valid before" Unix timestamp of an OpenSSH certificate, or 0 when it
* cannot be parsed (the caller then uses a short fallback).
*/
export function parseCertValidBefore(signedKey: string): number {
try {
const parts = signedKey.trim().split(/\s+/);
if (parts.length < 2) return 0;
const certType = parts[0];
const blob = Buffer.from(parts[1], "base64");
let pos = 0;
const skipString = (): void => {
const len = blob.readUInt32BE(pos);
pos += 4 + len;
};
const readUint64 = (): number => {
const hi = blob.readUInt32BE(pos);
const lo = blob.readUInt32BE(pos + 4);
pos += 8;
return hi * 0x100000000 + lo;
};
skipString(); // format id
skipString(); // nonce
let keyFields: number;
if (certType.startsWith("ssh-ed25519")) keyFields = 1;
else if (certType.startsWith("ecdsa-sha2-")) keyFields = 2;
else if (certType.startsWith("ssh-rsa")) keyFields = 2;
else if (certType.startsWith("ssh-dss")) keyFields = 4;
else if (certType.startsWith("sk-ssh-ed25519")) keyFields = 2;
else if (certType.startsWith("sk-ecdsa-sha2-")) keyFields = 3;
else return 0;
for (let i = 0; i < keyFields; i++) skipString();
readUint64(); // serial
pos += 4; // type
skipString(); // key id
skipString(); // valid principals
readUint64(); // valid after
return readUint64();
} catch {
return 0;
}
}
@@ -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/vault/oidc/callback";
const LEGACY_CALLBACK_PATH = "vault/oidc/callback";
/** The redirect URI to allow in the Vault OIDC role. */
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 (
<div className="flex flex-col gap-2">
<span className="text-xs font-medium">
{t("settings.redirectUri.label")}
</span>
<div className="flex gap-2">
<Input
value={uri}
readOnly
className="min-w-0 flex-1 rounded-none font-mono text-xs"
/>
<Button
type="button"
variant="outline"
className="rounded-none"
onClick={async () => {
if (await copyToClipboard(uri)) toast.success(t("common.copied"));
else toast.error(t("common.copyFailed"));
}}
>
{t("common.copy")}
</Button>
</div>
<p className="text-[10px] text-muted-foreground">
{t("settings.redirectUri.description")}
</p>
</div>
);
}
@@ -0,0 +1,108 @@
import { useCallback, useEffect, useState } from "react";
import {
usePermission,
usePluginApi,
useTranslation,
type SshAuthEditorProps,
} from "@termix/plugin-sdk/frontend";
import { Select2 } from "@termix/plugin-sdk/ui";
import { VaultProfileManager } from "./VaultProfileManager";
import type { VaultProfile } from "./types";
const PLUGIN_ID = "vault";
const DOCS_URL = "https://docs.termix.site/features/authentication/vault";
type PluginSettingsBag = Record<string, Record<string, unknown>>;
/**
* The host editor's "vault" auth method: pick a signer profile, and manage
* profiles in place. The choice is the profileId host setting, which the
* editor saves with the rest of the host's plugin settings.
*/
export function VaultAuthEditor({ form, setField }: SshAuthEditorProps) {
const { t } = useTranslation();
const api = usePluginApi();
const canUse = usePermission("use");
const [profiles, setProfiles] = useState<VaultProfile[]>([]);
const [showManager, setShowManager] = useState(false);
const bag = (form.pluginSettings as PluginSettingsBag | undefined) ?? {};
const profileId = bag[PLUGIN_ID]?.profileId;
const reload = useCallback(() => {
if (!canUse) return;
api
.get<VaultProfile[]>("/profiles")
.then(({ data }) => setProfiles(Array.isArray(data) ? data : []))
.catch(() => setProfiles([]));
}, [api, canUse]);
useEffect(() => {
reload();
}, [reload]);
const select = (value: string) => {
setField("pluginSettings", {
...bag,
[PLUGIN_ID]: {
...(bag[PLUGIN_ID] ?? {}),
profileId: value ? Number(value) : null,
},
});
};
return (
<>
<div className="flex flex-col gap-1.5 col-span-2">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("editor.profile")}
</label>
<Select2
value={profileId != null ? String(profileId) : ""}
onChange={(e) => select(e.target.value)}
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="">{t("editor.selectProfile")}</option>
{profiles.map((profile) => (
<option key={profile.id} value={String(profile.id)}>
{profile.shared
? t("editor.sharedProfile", { name: profile.name })
: profile.name}
</option>
))}
</Select2>
<div className="flex items-center justify-between">
<p className="text-[10px] text-muted-foreground">
{t("editor.hint")}
</p>
<div className="flex items-center gap-2 shrink-0">
<a
href={DOCS_URL}
target="_blank"
rel="noreferrer"
className="text-[10px] text-accent-brand hover:underline"
>
{t("editor.docs")}
</a>
{canUse && (
<button
type="button"
className="text-[10px] text-accent-brand hover:text-accent-brand/80"
onClick={() => setShowManager((shown) => !shown)}
>
{t("profiles.manage")}
</button>
)}
</div>
</div>
</div>
{showManager && (
<VaultProfileManager
profiles={profiles}
onChanged={reload}
onClose={() => setShowManager(false)}
/>
)}
</>
);
}
+245
View File
@@ -0,0 +1,245 @@
import { useEffect, useRef, useState } from "react";
import { AlertCircle, KeyRound, Loader2 } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { Button } from "@termix/plugin-sdk/ui";
/** 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<string, unknown>;
}
interface DialogState {
stage: "waiting" | "error";
error?: string;
hostId?: unknown;
requestId?: string;
}
/** How long the dialog waits before giving up on the connection. */
const DIALOG_TIMEOUT_MS = 300_000;
const footerButton =
"rounded-none text-[10px] font-bold uppercase tracking-widest";
/**
* Runs the Vault OIDC sign-in for a terminal: asks the server to start it,
* opens Vault's auth URL in a popup, and reconnects once Vault has signed
* the certificate.
*/
export function VaultOverlay({
subscribe,
holdConnectTimeout,
fail,
disconnect,
send,
connectPayload,
backgroundColor,
}: TerminalOverlayProps) {
const { t } = useTranslation();
const [dialog, setDialog] = useState<DialogState | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const popupRef = useRef<Window | 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 closePopup = () => {
try {
popupRef.current?.close();
} catch {
// already closed
}
popupRef.current = null;
};
const hold = (held: boolean) => {
if (heldRef.current === held) return;
heldRef.current = held;
holdConnectTimeout(held);
};
const unsubscribe = subscribe((message) => {
switch (message.type) {
case "vault_auth_required":
hold(true);
if (failedRef.current) {
clearTimer();
hold(false);
setDialog({ stage: "error", error: t("dialog.failed") });
fail(t("dialog.failed"));
} else {
failedRef.current = true;
send("vault_start_auth", { hostId: message.hostId });
}
break;
case "vault_auth_url":
try {
popupRef.current = window.open(
String(message.url ?? ""),
"termix-vault-oidc",
"width=540,height=720",
);
} catch {
popupRef.current = null;
}
setDialog({
stage: "waiting",
hostId: message.hostId,
requestId:
typeof message.requestId === "string"
? message.requestId
: undefined,
});
clearTimer();
timerRef.current = setTimeout(() => {
setDialog(null);
hold(false);
disconnect();
}, DIALOG_TIMEOUT_MS);
break;
case "vault_completed":
clearTimer();
closePopup();
setDialog(null);
send("vault_auth_completed", connectPayload());
break;
case "vault_error":
failedRef.current = true;
clearTimer();
closePopup();
hold(false);
setDialog({ stage: "error", error: String(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;
try {
popupRef.current?.close();
} catch {
// already closed
}
if (dialog.stage === "waiting") {
send("vault_cancel", {
hostId: dialog.hostId,
requestId: dialog.requestId,
});
}
setDialog(null);
if (heldRef.current) {
heldRef.current = false;
holdConnectTimeout(false);
}
};
return (
<div className="absolute inset-0 flex items-center justify-center z-500 animate-in fade-in duration-200">
<div
className="absolute inset-0 bg-canvas"
style={{ backgroundColor: backgroundColor || undefined }}
/>
<div className="bg-card border border-border w-full max-w-md mx-4 relative z-10">
<div className="p-4 border-b border-border">
<div className="flex items-center gap-2">
<KeyRound className="size-4 text-accent-brand" />
<h3 className="text-xs font-bold uppercase tracking-widest">
{dialog.stage === "error"
? t("dialog.failed")
: t("dialog.title")}
</h3>
</div>
</div>
<div className="p-4 flex flex-col gap-4">
{dialog.stage === "waiting" ? (
<div className="flex items-center gap-3 py-2">
<Loader2 className="size-4 animate-spin text-accent-brand shrink-0" />
<p className="text-xs text-muted-foreground">
{t("dialog.description")}
</p>
</div>
) : (
<div className="flex items-start gap-3 p-3 border border-destructive/20 bg-destructive/10">
<AlertCircle className="size-4 text-destructive shrink-0 mt-0.5" />
<p className="text-xs text-destructive/90 whitespace-pre-wrap break-words">
{dialog.error || t("dialog.failed")}
</p>
</div>
)}
<div className="flex justify-end gap-2">
{dialog.stage === "waiting" && (
<Button
type="button"
variant="outline"
className={footerButton}
onClick={() => {
try {
popupRef.current?.focus();
} catch {
// popup gone
}
}}
>
{t("dialog.reopen")}
</Button>
)}
<Button
type="button"
variant="ghost"
className={footerButton}
onClick={close}
>
{dialog.stage === "error"
? t("common.close")
: t("common.cancel")}
</Button>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,286 @@
import { useState } from "react";
import { Pencil, Plus, Trash2, X } from "lucide-react";
import {
usePermission,
usePluginApi,
useToast,
useTranslation,
} from "@termix/plugin-sdk/frontend";
import { Button, Input, Select2 } from "@termix/plugin-sdk/ui";
import {
errorMessage,
type VaultProfile,
type VaultProfilePayload,
} from "./types";
type FormState = {
id?: number;
name: string;
vaultAddr: string;
vaultNamespace: string;
oidcMount: string;
oidcRole: string;
sshMount: string;
sshRole: string;
validPrincipals: string;
keyType: string;
shared: boolean;
};
type TextKey = Exclude<keyof FormState, "id" | "shared">;
const emptyForm: FormState = {
name: "",
vaultAddr: "",
vaultNamespace: "",
oidcMount: "oidc",
oidcRole: "",
sshMount: "ssh-client-signer",
sshRole: "",
validPrincipals: "",
keyType: "ssh-ed25519",
shared: false,
};
function toForm(p: VaultProfile): FormState {
return {
id: p.id,
name: p.name,
vaultAddr: p.vaultAddr,
vaultNamespace: p.vaultNamespace ?? "",
oidcMount: p.oidcMount ?? "oidc",
oidcRole: p.oidcRole ?? "",
sshMount: p.sshMount ?? "ssh-client-signer",
sshRole: p.sshRole,
validPrincipals: p.validPrincipals ?? "",
keyType: p.keyType ?? "ssh-ed25519",
shared: p.shared,
};
}
const blank = (value: string) => value.trim() || null;
/** Create, edit and delete the caller's Vault profiles. */
export function VaultProfileManager({
profiles,
onChanged,
onClose,
}: {
profiles: VaultProfile[];
onChanged: () => void;
onClose: () => void;
}) {
const { t } = useTranslation();
const api = usePluginApi();
const toast = useToast();
const canShare = usePermission("share");
const [form, setForm] = useState<FormState | null>(null);
const [saving, setSaving] = useState(false);
const setField = <K extends keyof FormState>(key: K, value: FormState[K]) =>
setForm((current) => (current ? { ...current, [key]: value } : current));
const handleSave = async () => {
if (!form) return;
if (!form.name.trim() || !form.vaultAddr.trim() || !form.sshRole.trim()) {
toast.error(t("profiles.validationError"));
return;
}
setSaving(true);
try {
const payload: VaultProfilePayload = {
name: form.name.trim(),
vaultAddr: form.vaultAddr.trim(),
vaultNamespace: blank(form.vaultNamespace),
oidcMount: blank(form.oidcMount),
oidcRole: blank(form.oidcRole),
sshMount: blank(form.sshMount),
sshRole: form.sshRole.trim(),
validPrincipals: blank(form.validPrincipals),
keyType: blank(form.keyType),
shared: canShare && form.shared,
};
if (form.id) {
await api.put(`/profiles/${form.id}`, payload);
toast.success(t("profiles.saved"));
} else {
await api.post("/profiles", payload);
toast.success(t("profiles.created"));
}
setForm(null);
onChanged();
} catch (error) {
toast.error(errorMessage(error, t("profiles.saveFailed")));
} finally {
setSaving(false);
}
};
const handleDelete = async (profile: VaultProfile) => {
try {
await api.delete(`/profiles/${profile.id}`);
toast.success(t("profiles.deleted"));
onChanged();
} catch (error) {
toast.error(errorMessage(error, t("profiles.deleteFailed")));
}
};
const field = (label: string, key: TextKey, placeholder?: string) => (
<div className="flex flex-col gap-1">
<label className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
{label}
</label>
<Input
className="h-8 text-xs rounded-none"
placeholder={placeholder}
value={form?.[key] ?? ""}
onChange={(e) => setField(key, e.target.value)}
/>
</div>
);
return (
<div className="flex flex-col gap-3 col-span-2 border border-border bg-muted/20 p-3">
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("profiles.manage")}
</span>
<button
type="button"
aria-label={t("common.close")}
onClick={onClose}
className="text-muted-foreground hover:text-foreground"
>
<X className="size-3.5" />
</button>
</div>
{!form && (
<>
<div className="flex flex-col divide-y divide-border/50">
{profiles.length === 0 && (
<span className="text-[11px] text-muted-foreground py-1">
{t("profiles.none")}
</span>
)}
{profiles.map((profile) => (
<div
key={profile.id}
className="flex items-center justify-between py-1.5"
>
<div className="flex flex-col min-w-0">
<span className="text-xs text-foreground truncate">
{profile.name}
{profile.shared && (
<span className="ml-1 text-[9px] text-accent-brand">
{t("profiles.sharedBadge")}
</span>
)}
</span>
<span className="text-[10px] text-muted-foreground truncate">
{profile.vaultAddr}
</span>
</div>
{profile.owned && (
<div className="flex items-center gap-1 shrink-0">
<button
type="button"
title={t("profiles.edit")}
onClick={() => setForm(toForm(profile))}
className="size-7 flex items-center justify-center text-muted-foreground/60 hover:text-foreground hover:bg-muted-foreground/10"
>
<Pencil className="size-3.5" />
</button>
<button
type="button"
title={t("profiles.delete")}
onClick={() => handleDelete(profile)}
className="size-7 flex items-center justify-center text-muted-foreground/50 hover:text-destructive hover:bg-destructive/10"
>
<Trash2 className="size-3.5" />
</button>
</div>
)}
</div>
))}
</div>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 text-[10px] self-start rounded-none"
onClick={() => setForm({ ...emptyForm })}
>
<Plus className="size-3 mr-1" /> {t("profiles.new")}
</Button>
</>
)}
{form && (
<div className="flex flex-col gap-2">
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{field(t("profiles.name"), "name", "Production Vault")}
{field(t("profiles.vaultAddr"), "vaultAddr", "https://vault:8200")}
{field(t("profiles.namespace"), "vaultNamespace", "admin")}
{field(t("profiles.oidcMount"), "oidcMount", "oidc")}
{field(t("profiles.oidcRole"), "oidcRole", "default")}
{field(t("profiles.sshMount"), "sshMount", "ssh-client-signer")}
{field(t("profiles.sshRole"), "sshRole", "my-role")}
{field(
t("profiles.validPrincipals"),
"validPrincipals",
"root,deploy",
)}
</div>
<div className="flex flex-col gap-1">
<label className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
{t("profiles.keyType")}
</label>
<Select2
value={form.keyType}
onChange={(e) => setField("keyType", e.target.value)}
className="flex h-8 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="ssh-ed25519">Ed25519</option>
<option value="ecdsa-sha2-nistp256">ECDSA (nistp256)</option>
<option value="ssh-rsa">RSA (4096)</option>
</Select2>
</div>
{canShare && (
<label className="flex items-center gap-2 text-xs text-foreground">
<input
type="checkbox"
checked={form.shared}
onChange={(e) => setField("shared", e.target.checked)}
/>
{t("profiles.shareWithAll")}
</label>
)}
<div className="flex justify-end gap-2">
<Button
type="button"
variant="ghost"
size="sm"
className="rounded-none"
onClick={() => setForm(null)}
disabled={saving}
>
{t("common.cancel")}
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="rounded-none border-accent-brand/40 text-accent-brand"
onClick={handleSave}
disabled={saving}
>
{form.id ? t("profiles.save") : t("profiles.create")}
</Button>
</div>
</div>
)}
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
import type { ComponentType } from "react";
import type { TermixApp } from "@termix/plugin-sdk/frontend";
import { VaultAuthEditor } from "./VaultAuthEditor";
import { VaultOverlay } from "./VaultOverlay";
import { RedirectUriSetting } from "./RedirectUriSetting";
export function activate(app: TermixApp): void {
app.registerSshAuthEditor({
authType: "vault",
titleKey: "hosts.filterAuthVault",
component: VaultAuthEditor,
});
// The Vault sign-in while a terminal connects to a Vault host.
app.registerSlotContribution("terminal.overlay", {
actionId: "vault.signIn",
titleKey: "dialog.title",
kind: "component",
component: VaultOverlay as unknown as ComponentType<
Record<string, unknown>
>,
});
app.registerSettingsComponent("redirectUri", RedirectUriSetting);
}
export function deactivate(): void {
// Registrations through app are disposed by core.
}
+34
View File
@@ -0,0 +1,34 @@
/** A Vault signer profile as GET /profiles returns it. */
export interface VaultProfile {
id: number;
name: string;
vaultAddr: string;
vaultNamespace: string | null;
oidcMount: string | null;
oidcRole: string | null;
sshMount: string | null;
sshRole: string;
validPrincipals: string | null;
keyType: string | null;
shared: boolean;
owned: boolean;
}
export interface VaultProfilePayload {
name: string;
vaultAddr: string;
vaultNamespace: string | null;
oidcMount: string | null;
oidcRole: string | null;
sshMount: string | null;
sshRole: string;
validPrincipals: string | null;
keyType: string | null;
shared: boolean;
}
export function errorMessage(error: unknown, fallback: string): string {
const data = (error as { response?: { data?: { error?: unknown } } })
?.response?.data;
return typeof data?.error === "string" ? data.error : fallback;
}
+174
View File
@@ -0,0 +1,174 @@
import http from "node:http";
import type { AddressInfo } from "node:net";
import { fileURLToPath } from "node:url";
import express, { type Router } from "express";
import {
createMockCtx,
createTestDb,
type MockPluginContext,
type TestDb,
type TestDbOptions,
} from "@termix/plugin-sdk/testing";
import type {
PluginFetchInit,
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;
export const VAULT_ADDR = "https://vault.internal:8200";
export const SIGNED_CERT = "ssh-ed25519-cert-v01@openssh.com AAAAsigned";
/** A fake Vault answering the three calls the plugin makes. */
export function createFakeVault(
options: { signStatus?: number; state?: string } = {},
) {
const calls: Array<{ url: string; init?: PluginFetchInit }> = [];
const state = options.state ?? "vault-state-1";
const json = (status: number, body: unknown) =>
new Response(JSON.stringify(body), { status });
const fetch = async (url: string, init?: PluginFetchInit) => {
calls.push({ url, init });
const { pathname } = new URL(url);
if (pathname === "/v1/auth/oidc/oidc/auth_url") {
return json(200, {
data: {
auth_url: `https://idp.test/authorize?client_id=termix&state=${state}`,
},
});
}
if (pathname === "/v1/auth/oidc/oidc/callback") {
return json(200, { auth: { client_token: "hvs.token" } });
}
if (pathname.startsWith("/v1/ssh-client-signer/sign/")) {
if (options.signStatus && options.signStatus !== 200) {
return json(options.signStatus, { errors: ["permission denied"] });
}
return json(200, { data: { signed_key: SIGNED_CERT } });
}
return json(404, { errors: ["not found"] });
};
return { fetch, calls, state };
}
/** A fake terminal socket that records what it was sent. */
export function fakeSocket() {
const sent: Array<Record<string, unknown>> = [];
return {
sent,
socket: { send: (data: string) => sent.push(JSON.parse(data)) },
};
}
export interface TestServer {
db: TestDb;
mock: MockPluginContext;
provider: () => PluginSshAuthProvider;
request: (
method: string,
path: string,
options?: { user?: string; body?: unknown },
// 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; text: string }>;
close: () => Promise<void>;
}
/**
* The plugin activated against a real in-memory database, its router mounted
* the way core mounts it: JSON parsed and the acting user taken from the
* request.
*/
export async function startServer(
options: {
permissions?: string[];
settings?: Record<string, unknown>;
fetch?: (url: string, init?: PluginFetchInit) => Promise<Response>;
before?: TestDbOptions["before"];
} = {},
): Promise<TestServer> {
const db = await createTestDb(pluginDir, { before: options.before });
for (const user of ["user-1", "user-2"]) {
db.sqlite.prepare("INSERT OR IGNORE INTO users (id) VALUES (?)").run(user);
}
db.sqlite.prepare("INSERT OR IGNORE INTO ssh_data (id) VALUES (?)").run(7);
let router: Router | null = null;
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities,
db: db.database,
router: () => (router = express.Router()),
permissions: options.permissions,
settings: options.settings,
fetch: options.fetch,
actor: "user-1",
});
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<void>((resolve) => server.listen(0, resolve));
const { port } = server.address() as AddressInfo;
return {
db,
mock,
provider: () =>
mock.auth.sshAuthProviders.find((provider) => provider.type === "vault")!,
async request(method, path, { user = "user-1", body } = {}) {
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
method,
redirect: "manual",
headers: {
"x-test-user": user,
...(body !== undefined ? { "content-type": "application/json" } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await response.text();
let parsed: unknown = null;
try {
parsed = text ? JSON.parse(text) : null;
} catch {
parsed = null;
}
return { status: response.status, body: parsed, text };
},
async close() {
await new Promise<void>((resolve) => server.close(() => resolve()));
db.close();
},
};
}
export const env = (userId = "user-1", hostId = 7) => ({
client: {},
userId,
hostId,
purpose: "terminal",
interactive: true,
log: () => {},
});
export const HOST = { id: 7, ip: "10.0.0.7", port: 22, username: "root" };
export const PROFILE = {
name: "Prod",
vaultAddr: VAULT_ADDR,
sshRole: "ops",
oidcRole: "dev",
};
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { validateManifest } from "@termix/plugin-sdk/manifest";
import manifest from "../../manifest.json";
// Old URLs something outside Termix still calls keep reaching the plugin.
const redirects = manifest.contributes.http.legacyRedirects as unknown[];
describe("legacy URLs", () => {
it("declares them in a manifest that validates", () => {
expect(validateManifest(manifest)).toEqual([]);
});
it("redirects /vault/oidc/callback, which Vault OIDC roles allowed before 2.9", () => {
expect(redirects).toContainEqual({
from: "/vault/oidc/callback",
to: "/oidc/callback",
});
});
});
@@ -0,0 +1,121 @@
import { afterEach, describe, expect, it } from "vitest";
import { createTestDb, type TestDb } from "@termix/plugin-sdk/testing";
import { pluginDir } from "./helpers";
// vault_profiles and vault_tokens as core's SQLite bootstrap created them
// before 2.9.0, including the sync_id column added later.
const LEGACY_DDL = `
CREATE TABLE vault_profiles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT,
folder TEXT,
tags TEXT,
vault_addr TEXT NOT NULL,
vault_namespace TEXT,
oidc_mount TEXT,
oidc_role TEXT,
ssh_mount TEXT,
ssh_role TEXT NOT NULL,
valid_principals TEXT,
key_type TEXT,
shared INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
sync_id TEXT,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
CREATE TABLE vault_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
profile_id INTEGER NOT NULL,
ssh_cert TEXT NOT NULL,
private_key TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at TEXT NOT NULL,
last_used TEXT,
UNIQUE(user_id, profile_id),
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (profile_id) REFERENCES vault_profiles (id) ON DELETE CASCADE
);
`;
let db: TestDb | null = null;
afterEach(() => {
db?.close();
db = null;
});
const tableExists = (name: string) =>
!!db!.sqlite
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
.get(name);
describe("adopting vault_profiles and vault_tokens", () => {
it("keeps every existing row when the legacy tables are there", async () => {
db = await createTestDb(pluginDir, {
before: (sqlite) => {
sqlite.exec("INSERT INTO users (id) VALUES ('user-1'), ('user-2')");
sqlite.exec(LEGACY_DDL);
sqlite
.prepare(
"INSERT INTO vault_profiles (id, user_id, name, vault_addr, ssh_role, shared, sync_id) VALUES (?, ?, ?, ?, ?, ?, ?)",
)
.run(4, "user-1", "Prod", "https://vault:8200", "ops", 1, "sync-a");
sqlite
.prepare(
"INSERT INTO vault_tokens (user_id, profile_id, ssh_cert, private_key, expires_at) VALUES (?, ?, ?, ?, ?)",
)
.run("user-2", 4, "cert", "key", "2999-01-01T00:00:00.000Z");
},
});
expect(tableExists("vault_profiles")).toBe(false);
expect(tableExists("vault_tokens")).toBe(false);
expect(db.sqlite.prepare("SELECT * FROM p_vault_profiles").all()).toEqual([
expect.objectContaining({
id: 4,
user_id: "user-1",
name: "Prod",
shared: 1,
sync_id: "sync-a",
}),
]);
expect(db.sqlite.prepare("SELECT * FROM p_vault_tokens").all()).toEqual([
expect.objectContaining({ user_id: "user-2", profile_id: 4 }),
]);
// The 2.8 foreign key follows the rename, so deleting a profile still
// takes its cached certificates with it.
db.sqlite.exec("PRAGMA foreign_keys = ON");
db.sqlite.prepare("DELETE FROM p_vault_profiles WHERE id = 4").run();
expect(
db.sqlite.prepare("SELECT COUNT(*) AS n FROM p_vault_tokens").get(),
).toEqual({ n: 0 });
});
it("creates both tables on a fresh install", async () => {
db = await createTestDb(pluginDir);
expect(tableExists("p_vault_profiles")).toBe(true);
expect(tableExists("p_vault_tokens")).toBe(true);
expect(
db.sqlite
.prepare(
"SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'idx_vault_tokens_user_profile'",
)
.get(),
).toBeTruthy();
});
it("keeps one certificate per user and profile", async () => {
db = await createTestDb(pluginDir);
db.sqlite.exec("INSERT INTO users (id) VALUES ('user-1')");
const insert = db.sqlite.prepare(
"INSERT INTO p_vault_tokens (user_id, profile_id, ssh_cert, private_key, expires_at) VALUES ('user-1', 1, 'c', 'k', 'x')",
);
insert.run();
expect(() => insert.run()).toThrow(/UNIQUE/);
});
});
@@ -0,0 +1,216 @@
import { describe, it, expect, beforeAll } from "vitest";
import { execFileSync } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import ssh2Pkg from "ssh2";
import type { PluginFetchInit } from "@termix/plugin-sdk/backend";
import {
allowedHosts,
completeVaultOidc,
generateEphemeralKeyPair,
parseCertValidBefore,
signWithVault,
startVaultOidc,
type VaultProfileConfig,
} from "../../src/backend/vault-client.js";
const { utils: ssh2Utils } = ssh2Pkg;
describe("generateEphemeralKeyPair", () => {
for (const keyType of [
"ssh-ed25519",
"ecdsa-sha2-nistp256",
"ssh-rsa",
] as const) {
it(`generates a parseable ${keyType} keypair`, () => {
const pair = generateEphemeralKeyPair(keyType);
expect(pair.privateKey).toContain("BEGIN OPENSSH PRIVATE KEY");
expect(pair.publicKey.split(/\s+/)[0]).toBe(keyType);
expect(ssh2Utils.parseKey(pair.privateKey) instanceof Error).toBe(false);
expect(ssh2Utils.parseKey(pair.publicKey) instanceof Error).toBe(false);
});
}
it("defaults to ed25519 for unknown key types", () => {
const pair = generateEphemeralKeyPair("nonsense");
expect(pair.publicKey.startsWith("ssh-ed25519")).toBe(true);
});
});
describe("parseCertValidBefore", () => {
let cert = "";
let signedAt = 0;
let haveSshKeygen = true;
beforeAll(() => {
const dir = mkdtempSync(path.join(os.tmpdir(), "vault-cert-test-"));
try {
for (const name of ["ca", "user"]) {
execFileSync("ssh-keygen", [
"-t",
"ed25519",
"-f",
`${dir}/${name}`,
"-N",
"",
"-q",
]);
}
signedAt = Math.floor(Date.now() / 1000);
execFileSync("ssh-keygen", [
"-s",
`${dir}/ca`,
"-I",
"test-id",
"-n",
"root",
"-V",
"+60m",
`${dir}/user.pub`,
]);
cert = readFileSync(`${dir}/user-cert.pub`, "utf8").trim();
} catch {
haveSshKeygen = false;
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("reads valid_before from a real ssh-keygen certificate", () => {
if (!haveSshKeygen) return;
const validBefore = parseCertValidBefore(cert);
expect(validBefore).toBeGreaterThan(signedAt + 3300);
expect(validBefore).toBeLessThan(signedAt + 3900);
});
it("returns 0 for malformed input", () => {
expect(parseCertValidBefore("")).toBe(0);
expect(parseCertValidBefore("not-a-cert")).toBe(0);
expect(parseCertValidBefore("ssh-ed25519 AAAAnotbase64!!")).toBe(0);
});
});
describe("the Vault HTTP flow", () => {
const profile: VaultProfileConfig = {
id: 1,
vaultAddr: "https://vault.example.com:8200/",
vaultNamespace: "team-a",
oidcMount: "oidc",
oidcRole: "developer",
sshMount: "ssh-client-signer",
sshRole: "my-role",
validPrincipals: "root,deploy",
keyType: "ssh-ed25519",
};
function fakeFetch(respond: () => { status?: number; body: unknown }) {
const calls: Array<{ url: string; init?: PluginFetchInit }> = [];
const fetch = async (url: string, init?: PluginFetchInit) => {
calls.push({ url, init });
const { status = 200, body } = respond();
return new Response(JSON.stringify(body), { status });
};
return { fetch, calls };
}
it("allows only the profile's own Vault host past the private address guard", () => {
expect(allowedHosts(profile)).toEqual(["vault.example.com"]);
expect(allowedHosts({ ...profile, vaultAddr: "not a url" })).toEqual([]);
});
it("startVaultOidc posts auth_url and reads the state", async () => {
const { fetch, calls } = fakeFetch(() => ({
body: {
data: {
auth_url:
"https://idp.example.com/authorize?client_id=x&state=ST-abc123&nonce=n",
},
},
}));
const result = await startVaultOidc(
fetch,
profile,
"https://termix/plugin-api/vault/oidc/callback",
);
expect(result.state).toBe("ST-abc123");
expect(result.clientNonce).toMatch(/^[0-9a-f]{40}$/);
expect(calls[0].url).toBe(
"https://vault.example.com:8200/v1/auth/oidc/oidc/auth_url",
);
expect(calls[0].init?.method).toBe("POST");
expect(calls[0].init?.headers?.["X-Vault-Namespace"]).toBe("team-a");
expect(JSON.parse(String(calls[0].init?.body))).toMatchObject({
role: "developer",
redirect_uri: "https://termix/plugin-api/vault/oidc/callback",
client_nonce: result.clientNonce,
});
});
it("completeVaultOidc returns the client token", async () => {
const { fetch, calls } = fakeFetch(() => ({
body: { auth: { client_token: "hvs.TESTTOKEN" } },
}));
const token = await completeVaultOidc(fetch, profile, {
state: "ST-abc123",
code: "auth-code",
clientNonce: "nonce123",
});
expect(token).toBe("hvs.TESTTOKEN");
const url = new URL(calls[0].url);
expect(url.pathname).toBe("/v1/auth/oidc/oidc/callback");
expect(url.searchParams.get("state")).toBe("ST-abc123");
expect(url.searchParams.get("code")).toBe("auth-code");
expect(url.searchParams.get("client_nonce")).toBe("nonce123");
expect(calls[0].init?.method).toBe("GET");
});
it("signWithVault posts the public key and returns signed_key", async () => {
const { fetch, calls } = fakeFetch(() => ({
body: {
data: { signed_key: "ssh-ed25519-cert-v01@openssh.com AAAAcert" },
},
}));
const cert = await signWithVault(
fetch,
profile,
"hvs.TESTTOKEN",
"ssh-ed25519 AAAApub comment",
);
expect(cert).toBe("ssh-ed25519-cert-v01@openssh.com AAAAcert");
expect(calls[0].url).toBe(
"https://vault.example.com:8200/v1/ssh-client-signer/sign/my-role",
);
expect(calls[0].init?.headers?.["X-Vault-Token"]).toBe("hvs.TESTTOKEN");
expect(JSON.parse(String(calls[0].init?.body))).toMatchObject({
public_key: "ssh-ed25519 AAAApub comment",
cert_type: "user",
valid_principals: "root,deploy",
});
});
it("surfaces Vault error messages", async () => {
const { fetch } = fakeFetch(() => ({
status: 400,
body: { errors: ["role not found", "permission denied"] },
}));
await expect(
signWithVault(fetch, profile, "tok", "ssh-ed25519 AAAA"),
).rejects.toThrow(/role not found; permission denied/);
});
it("reports an unreachable Vault", async () => {
const fetch = async () => {
throw new Error("refused");
};
await expect(
startVaultOidc(fetch, profile, "https://termix/cb"),
).rejects.toThrow(/Failed to reach Vault/);
});
});
+463
View File
@@ -0,0 +1,463 @@
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,
PROFILE,
SIGNED_CERT,
VAULT_ADDR,
createFakeVault,
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);
let server: TestServer | null = null;
beforeEach(() => {
certs.applyCertificateAuth.mockReset();
});
afterEach(async () => {
await server?.close();
server = null;
});
/** Creates a profile as user-1 and points host 7 at it. */
async function profileOnHost(options: { shared?: boolean } = {}) {
const created = await server!.request("POST", "/profiles", {
body: { ...PROFILE, shared: options.shared ?? false },
});
expect(created.status).toBe(201);
await server!.mock.ctx.settings.setHost(7, "profileId", created.body.id);
return created.body as { id: number };
}
async function startSignIn(userId = "user-1") {
const socket = fakeSocket();
await server!.provider().startInteraction!({
userId,
hostId: 7,
host: { name: "box", ip: HOST.ip, username: HOST.username },
socket: socket.socket,
requestOrigin: "https://termix.test",
payload: {},
});
return socket;
}
describe("activate", () => {
it("registers the vault auth type, the sync entity and a public callback", async () => {
server = await startServer();
const provider = server.provider();
expect(provider).toMatchObject({
type: "vault",
interaction: "vault",
needsUserInteraction: true,
supportsBackground: false,
});
expect(server.mock.syncEntities).toEqual([
expect.objectContaining({ type: "vaultProfiles", order: 20 }),
]);
expect(server.mock.httpRouters[0]?.public).toEqual(["/oidc/callback"]);
});
// secrets:own and network:outbound are checked when used, ui:surface by the
// frontend.
it.each(["db:own", "network:serve", "auth:provide"])(
"fails closed without %s",
async (capability) => {
const db = await createTestDb(pluginDir);
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities.filter((c) => c !== capability),
db: db.database,
router: () => ({ get: () => {}, use: () => {} }),
});
const { activate } = await import("../../src/backend/index.js");
await expect(activate(mock.ctx)).rejects.toThrow(PluginCapabilityError);
db.close();
},
);
});
describe("profiles", () => {
it("creates, lists, updates and deletes a profile", async () => {
server = await startServer();
const created = await server.request("POST", "/profiles", {
body: { ...PROFILE, vaultNamespace: " team ", tags: ["a", "b"] },
});
expect(created.status).toBe(201);
expect(created.body).toMatchObject({
name: "Prod",
vaultAddr: VAULT_ADDR,
vaultNamespace: "team",
sshRole: "ops",
tags: ["a", "b"],
shared: false,
owned: true,
});
const listed = await server.request("GET", "/profiles");
expect(listed.body).toHaveLength(1);
const updated = await server.request(
"PUT",
`/profiles/${created.body.id}`,
{ body: { name: "Staging", keyType: "ssh-rsa" } },
);
expect(updated.status).toBe(200);
expect(updated.body).toMatchObject({ name: "Staging", keyType: "ssh-rsa" });
const deleted = await server.request(
"DELETE",
`/profiles/${created.body.id}`,
);
expect(deleted.status).toBe(200);
expect((await server.request("GET", "/profiles")).body).toEqual([]);
});
it("drops cached certificates on delete", async () => {
server = await startServer();
const { id } = await profileOnHost();
server.db.sqlite
.prepare(
"INSERT INTO p_vault_tokens (user_id, profile_id, ssh_cert, private_key, expires_at) VALUES (?, ?, ?, ?, ?)",
)
.run("user-1", id, "c", "k", "2999-01-01T00:00:00.000Z");
await server.request("DELETE", `/profiles/${id}`);
expect(
server.db.sqlite
.prepare("SELECT COUNT(*) AS n FROM p_vault_tokens")
.get(),
).toEqual({ n: 0 });
});
it("requires name, address and signer role", async () => {
server = await startServer();
const response = await server.request("POST", "/profiles", {
body: { name: "x" },
});
expect(response.status).toBe(400);
});
it("shows other users only shared profiles, and only the owner may change one", async () => {
server = await startServer();
const own = await server.request("POST", "/profiles", { body: PROFILE });
const shared = await server.request("POST", "/profiles", {
body: { ...PROFILE, name: "Shared", shared: true },
});
const seen = await server.request("GET", "/profiles", { user: "user-2" });
expect(seen.body.map((p: { name: string }) => p.name)).toEqual(["Shared"]);
expect(seen.body[0].owned).toBe(false);
for (const id of [own.body.id, shared.body.id]) {
const put = await server.request("PUT", `/profiles/${id}`, {
user: "user-2",
body: { name: "mine" },
});
expect(put.status).toBe(403);
const del = await server.request("DELETE", `/profiles/${id}`, {
user: "user-2",
});
expect(del.status).toBe(403);
}
});
it("needs the share permission to share a profile", async () => {
server = await startServer({ permissions: ["vault.use"] });
const create = await server.request("POST", "/profiles", {
body: { ...PROFILE, shared: true },
});
expect(create.status).toBe(403);
const own = await server.request("POST", "/profiles", { body: PROFILE });
expect(own.status).toBe(201);
const share = await server.request("PUT", `/profiles/${own.body.id}`, {
body: { shared: true },
});
expect(share.status).toBe(403);
const unshare = await server.request("PUT", `/profiles/${own.body.id}`, {
body: { shared: false },
});
expect(unshare.status).toBe(200);
});
it("refuses every profile route without the use permission", async () => {
server = await startServer({ permissions: [] });
for (const [method, path] of [
["GET", "/profiles"],
["POST", "/profiles"],
["PUT", "/profiles/1"],
["DELETE", "/profiles/1"],
]) {
const response = await server.request(method, path, {
body: method === "GET" ? undefined : PROFILE,
});
expect(response.status, `${method} ${path}`).toBe(403);
}
});
it("answers 404 for a profile that does not exist", async () => {
server = await startServer();
expect(
(await server.request("PUT", "/profiles/99", { body: {} })).status,
).toBe(404);
expect((await server.request("DELETE", "/profiles/99")).status).toBe(404);
expect((await server.request("DELETE", "/profiles/abc")).status).toBe(400);
});
});
describe("signing a certificate", () => {
it("runs the OIDC flow against Vault and stores the certificate sealed", async () => {
const vault = createFakeVault();
server = await startServer({ fetch: vault.fetch });
const { id } = await profileOnHost();
const socket = await startSignIn();
expect(socket.sent).toEqual([
expect.objectContaining({
type: "vault_auth_url",
hostId: 7,
requestId: vault.state,
url: expect.stringContaining("https://idp.test/authorize"),
}),
]);
const authUrlCall = vault.calls[0];
expect(JSON.parse(String(authUrlCall.init?.body))).toMatchObject({
role: "dev",
redirect_uri: "https://termix.test/plugin-api/vault/oidc/callback",
});
expect(authUrlCall.init?.allowPrivateHosts).toEqual(["vault.internal"]);
const callback = await server.request(
"GET",
`/oidc/callback?state=${vault.state}&code=abc`,
{ user: "" },
);
expect(callback.status).toBe(200);
expect(callback.text).toContain("Vault sign-in complete");
expect(socket.sent.at(-1)).toMatchObject({
type: "vault_completed",
hostId: 7,
});
const signCall = vault.calls.find((call) => call.url.includes("/sign/"));
expect(signCall?.url).toBe(`${VAULT_ADDR}/v1/ssh-client-signer/sign/ops`);
expect(signCall?.init?.headers?.["X-Vault-Token"]).toBe("hvs.token");
const row = server.db.sqlite
.prepare("SELECT * FROM p_vault_tokens WHERE profile_id = ?")
.get(id) as { ssh_cert: string; private_key: string };
expect(row.ssh_cert).not.toBe(SIGNED_CERT);
expect(await server.mock.ctx.secrets.unseal(row.ssh_cert)).toBe(
SIGNED_CERT,
);
expect(await server.mock.ctx.secrets.unseal(row.private_key)).toContain(
"OPENSSH PRIVATE KEY",
);
const outcome = await server.provider().prepare({}, HOST, env());
expect(outcome).toEqual({ status: "ready" });
expect(certs.applyCertificateAuth).toHaveBeenCalledWith(
{},
{},
expect.objectContaining({ certificate: SIGNED_CERT }),
"root",
);
});
it("answers a second use of the same callback with an error", async () => {
const vault = createFakeVault();
server = await startServer({ fetch: vault.fetch });
await profileOnHost();
await startSignIn();
const path = `/oidc/callback?state=${vault.state}&code=abc`;
expect((await server.request("GET", path)).status).toBe(200);
expect((await server.request("GET", path)).status).toBe(400);
});
it("reports Vault refusing to sign", async () => {
const vault = createFakeVault({ signStatus: 403 });
server = await startServer({ fetch: vault.fetch });
await profileOnHost();
const socket = await startSignIn();
const callback = await server.request(
"GET",
`/oidc/callback?state=${vault.state}&code=abc`,
);
expect(callback.status).toBe(400);
expect(socket.sent.at(-1)).toMatchObject({
type: "vault_error",
error: expect.stringContaining("permission denied"),
});
});
it("passes an identity provider error through", async () => {
server = await startServer();
const callback = await server.request(
"GET",
"/oidc/callback?error=access_denied",
);
expect(callback.status).toBe(400);
expect(callback.text).toContain("access_denied");
});
it("sends the 2.8 redirect URI while legacyCallback is on", async () => {
const vault = createFakeVault();
server = await startServer({
fetch: vault.fetch,
settings: { legacyCallback: true },
});
await profileOnHost();
await startSignIn();
expect(JSON.parse(String(vault.calls[0].init?.body)).redirect_uri).toBe(
"https://termix.test/vault/oidc/callback",
);
});
it("lets a pending sign-in be cancelled", async () => {
const vault = createFakeVault();
server = await startServer({ fetch: vault.fetch });
await profileOnHost();
await startSignIn();
await server.provider().cancelInteraction!({ userId: "user-1", hostId: 7 });
const callback = await server.request(
"GET",
`/oidc/callback?state=${vault.state}&code=abc`,
);
expect(callback.status).toBe(400);
});
it("refuses to start without a profile on the host", async () => {
server = await startServer();
await expect(startSignIn()).rejects.toThrow(/No Vault signer profile/);
});
});
describe("the vault provider", () => {
it("fails without a profile on the host", async () => {
server = await startServer();
expect(await server.provider().prepare({}, HOST, env())).toMatchObject({
status: "error",
message: "Host has no Vault signer profile configured",
});
});
it("asks for a sign-in without a certificate", async () => {
server = await startServer();
await profileOnHost();
expect(await server.provider().prepare({}, HOST, env())).toEqual({
status: "interaction-required",
interaction: "vault",
message: expect.any(String),
flag: "requiresVaultAuth",
});
});
it("uses a profile another user shared with the host's owner", async () => {
server = await startServer();
const shared = await server.request("POST", "/profiles", {
user: "user-2",
body: { ...PROFILE, shared: true },
});
await server.mock.ctx.settings.setHost(7, "profileId", shared.body.id);
expect((await server.provider().prepare({}, HOST, env())).status).toBe(
"interaction-required",
);
});
it("drops a certificate 2.8 cached with the user's data key", async () => {
server = await startServer();
const { id } = await profileOnHost();
server.db.sqlite
.prepare(
"INSERT INTO p_vault_tokens (user_id, profile_id, ssh_cert, private_key, expires_at) VALUES (?, ?, ?, ?, ?)",
)
.run(
"user-1",
id,
"v1:dek-cipher",
"v1:dek-cipher",
"2999-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 COUNT(*) AS n FROM p_vault_tokens")
.get(),
).toEqual({ n: 0 });
});
it("forgets an expired certificate", async () => {
server = await startServer();
const { id } = await profileOnHost();
const seal = server.mock.ctx.secrets.seal;
server.db.sqlite
.prepare(
"INSERT INTO p_vault_tokens (user_id, profile_id, ssh_cert, private_key, expires_at) VALUES (?, ?, ?, ?, ?)",
)
.run(
"user-1",
id,
await seal("c"),
await seal("k"),
"2000-01-01T00:00:00.000Z",
);
expect((await server.provider().prepare({}, HOST, env())).status).toBe(
"interaction-required",
);
expect(
server.db.sqlite
.prepare("SELECT COUNT(*) AS n FROM p_vault_tokens")
.get(),
).toEqual({ n: 0 });
});
it("drops the certificate when the host rejects it", async () => {
const vault = createFakeVault();
server = await startServer({ fetch: vault.fetch });
await profileOnHost();
await startSignIn();
await server.request("GET", `/oidc/callback?state=${vault.state}&code=abc`);
const outcome = server.provider().onAuthFailed!(HOST, env(), {
error: new Error("All configured authentication methods failed"),
retries: 0,
canRetry: false,
methodNotAvailable: false,
});
expect(outcome).toMatchObject({ status: "interaction-required" });
await vi.waitFor(() =>
expect(
server!.db.sqlite
.prepare("SELECT COUNT(*) AS n FROM p_vault_tokens")
.get(),
).toEqual({ n: 0 }),
);
});
it("ignores other auth failures", async () => {
server = await startServer();
expect(
server.provider().onAuthFailed!(HOST, env(), {
error: new Error("connection reset"),
retries: 0,
canRetry: false,
methodNotAvailable: false,
}),
).toBeUndefined();
});
});
@@ -0,0 +1,268 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
act,
fireEvent,
render,
screen,
waitFor,
} 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 { VaultOverlay } from "../../src/frontend/VaultOverlay";
import { VaultAuthEditor } from "../../src/frontend/VaultAuthEditor";
import { redirectUri } from "../../src/frontend/RedirectUriSetting";
import manifestJson from "../../manifest.json";
import locales from "../../locales/en.json";
const stubs = vi.hoisted(() => ({
permissions: new Set<string>(["use", "share"]),
api: {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
patch: vi.fn(),
},
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() },
}));
vi.mock("@termix/plugin-sdk/frontend", async (importOriginal) => ({
...(await importOriginal<typeof import("@termix/plugin-sdk/frontend")>()),
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) =>
options?.name ? `${key}:${options.name}` : key,
}),
usePluginApi: () => stubs.api,
usePermission: (permission: string) => stubs.permissions.has(permission),
useToast: () => stubs.toast,
}));
const manifest = manifestJson as unknown as PluginManifest;
let rendered: RenderedPluginApp | null = null;
beforeEach(() => {
stubs.permissions = new Set(["use", "share"]);
for (const fn of Object.values(stubs.api)) fn.mockReset();
stubs.api.get.mockResolvedValue({ data: [] });
});
afterEach(async () => {
await rendered?.deactivate();
rendered = null;
});
describe("vault activate", () => {
it("registers the terminal overlay and the settings field, and removes them on deactivate", async () => {
rendered = await renderWithApp(plugin, { manifest, locales });
expect(rendered.registered.settingsComponents()).toEqual(["redirectUri"]);
expect(rendered.registered.slot("terminal.overlay")).toEqual([
"vault.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/vault/oidc/callback",
);
expect(redirectUri(true, "https://t.example/")).toBe(
"https://t.example/vault/oidc/callback",
);
});
});
describe("VaultAuthEditor", () => {
const profiles = [
{
id: 3,
name: "Prod",
vaultAddr: "https://v",
sshRole: "r",
shared: false,
owned: true,
},
{
id: 4,
name: "Team",
vaultAddr: "https://v",
sshRole: "r",
shared: true,
owned: false,
},
];
it("lists the profiles and stores the choice as the profileId host setting", async () => {
stubs.api.get.mockResolvedValue({ data: profiles });
const setField = vi.fn();
render(
<VaultAuthEditor
form={{ pluginSettings: { other: { x: 1 } } }}
setField={setField}
/>,
);
await waitFor(() => expect(screen.getByText("Prod")).toBeTruthy());
expect(stubs.api.get).toHaveBeenCalledWith("/profiles");
expect(screen.getByText("editor.sharedProfile:Team")).toBeTruthy();
fireEvent.change(screen.getByRole("combobox"), { target: { value: "4" } });
expect(setField).toHaveBeenCalledWith("pluginSettings", {
other: { x: 1 },
vault: { profileId: 4 },
});
});
it("shows the saved profile", async () => {
stubs.api.get.mockResolvedValue({ data: profiles });
render(
<VaultAuthEditor
form={{ pluginSettings: { vault: { profileId: 3 } } }}
setField={vi.fn()}
/>,
);
await waitFor(() =>
expect((screen.getByRole("combobox") as HTMLSelectElement).value).toBe(
"3",
),
);
});
it("creates a profile from the manager, shared only with the share permission", async () => {
stubs.api.post.mockResolvedValue({ data: {} });
render(<VaultAuthEditor form={{}} setField={vi.fn()} />);
fireEvent.click(screen.getByText("profiles.manage"));
fireEvent.click(screen.getByText("profiles.new"));
const inputs = screen.getAllByRole("textbox");
fireEvent.change(inputs[0], { target: { value: "Prod" } });
fireEvent.change(inputs[1], { target: { value: "https://vault:8200" } });
fireEvent.change(inputs[6], { target: { value: "ops" } });
fireEvent.click(screen.getByRole("checkbox"));
await act(async () => {
fireEvent.click(screen.getByText("profiles.create"));
});
expect(stubs.api.post).toHaveBeenCalledWith(
"/profiles",
expect.objectContaining({
name: "Prod",
vaultAddr: "https://vault:8200",
sshRole: "ops",
shared: true,
}),
);
});
it("hides sharing without the share permission and managing without use", async () => {
stubs.permissions = new Set(["use"]);
const { unmount } = render(
<VaultAuthEditor form={{}} setField={vi.fn()} />,
);
fireEvent.click(screen.getByText("profiles.manage"));
fireEvent.click(screen.getByText("profiles.new"));
expect(screen.queryByRole("checkbox")).toBeNull();
unmount();
stubs.permissions = new Set();
stubs.api.get.mockClear();
render(<VaultAuthEditor form={{}} setField={vi.fn()} />);
expect(screen.queryByText("profiles.manage")).toBeNull();
expect(stubs.api.get).not.toHaveBeenCalled();
});
});
describe("VaultOverlay", () => {
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(<VaultOverlay {...props} />);
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: "vault_auth_required", hostId: 7 });
expect(props.send).toHaveBeenCalledWith("vault_start_auth", { hostId: 7 });
expect(props.holdConnectTimeout).toHaveBeenCalledWith(true);
emit({ type: "vault_auth_required", hostId: 7 });
expect(props.fail).toHaveBeenCalledWith("dialog.failed");
});
it("opens Vault's sign-in, waits, and reconnects once signed", () => {
const close = vi.fn();
const open = vi
.spyOn(window, "open")
.mockReturnValue({ close, focus: vi.fn() } as unknown as Window);
const { props, emit } = setup();
emit({ type: "vault_auth_required", hostId: 7 });
emit({
type: "vault_auth_url",
hostId: 7,
requestId: "st-1",
url: "https://idp.test/authorize",
});
expect(open).toHaveBeenCalledWith(
"https://idp.test/authorize",
"termix-vault-oidc",
"width=540,height=720",
);
expect(screen.getByText("dialog.description")).toBeTruthy();
emit({ type: "vault_completed", hostId: 7 });
expect(close).toHaveBeenCalled();
expect(props.send).toHaveBeenCalledWith("vault_auth_completed", {
hostId: 7,
cols: 80,
rows: 24,
});
expect(screen.queryByText("dialog.title")).toBeNull();
open.mockRestore();
});
it("cancels a pending sign-in", () => {
const open = vi.spyOn(window, "open").mockReturnValue(null);
const { props, emit } = setup();
emit({
type: "vault_auth_url",
hostId: 7,
requestId: "st-2",
url: "https://idp.test/authorize",
});
fireEvent.click(screen.getByText("common.cancel"));
expect(props.send).toHaveBeenCalledWith("vault_cancel", {
hostId: 7,
requestId: "st-2",
});
open.mockRestore();
});
it("shows Vault's error", () => {
const { emit } = setup();
emit({ type: "vault_error", hostId: 7, error: "permission denied" });
expect(screen.getByText("permission denied")).toBeTruthy();
});
});
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "@termix/plugin-sdk/tsconfig.plugin.json",
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"]
}
+3
View File
@@ -0,0 +1,3 @@
import { pluginVitestConfig } from "@termix/plugin-sdk/vitest-preset";
export default pluginVitestConfig(import.meta.url);