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

This commit is contained in:
LukeGus committed 2026-09-24 19:55:28 -05:00
1 parent 98d367d7e2
commit b027717c46
128 files changed
+3766 -1742

No files matched your search

+14 -1
View File
@@ -37,7 +37,6 @@
"nanoid": "^6.0.1", "nanoid": "^6.0.1",
"node-pty": "^1.1.0", "node-pty": "^1.1.0",
"pg": "^8.23.0", "pg": "^8.23.0",
"redis": "^6.2.1",
"semver": "^7.8.5", "semver": "^7.8.5",
"sharp": "^0.35.4", "sharp": "^0.35.4",
"socks": "^2.8.10", "socks": "^2.8.10",
@@ -7041,6 +7040,10 @@
"resolved": "plugins/sso", "resolved": "plugins/sso",
"link": true "link": true
}, },
"node_modules/@termix-plugin/step-ca": {
"resolved": "plugins/step-ca",
"link": true
},
"node_modules/@termix-plugin/tailscale": { "node_modules/@termix-plugin/tailscale": {
"resolved": "plugins/tailscale", "resolved": "plugins/tailscale",
"link": true "link": true
@@ -18725,6 +18728,16 @@
"@termix/plugin-sdk": "*" "@termix/plugin-sdk": "*"
} }
}, },
"plugins/step-ca": {
"name": "@termix-plugin/step-ca",
"version": "1.0.0",
"dependencies": {
"redis": "^6.2.1"
},
"devDependencies": {
"@termix/plugin-sdk": "*"
}
},
"plugins/tailscale": { "plugins/tailscale": {
"name": "@termix-plugin/tailscale", "name": "@termix-plugin/tailscale",
"version": "1.0.0", "version": "1.0.0",
-1
View File
@@ -79,7 +79,6 @@
"nanoid": "^6.0.1", "nanoid": "^6.0.1",
"node-pty": "^1.1.0", "node-pty": "^1.1.0",
"pg": "^8.23.0", "pg": "^8.23.0",
"redis": "^6.2.1",
"semver": "^7.8.5", "semver": "^7.8.5",
"sharp": "^0.35.4", "sharp": "^0.35.4",
"socks": "^2.8.10", "socks": "^2.8.10",
+34 -13
View File
@@ -1008,11 +1008,11 @@ auto-session and trusted devices, plus the base SSH auth types password, key,
stored credential, agent and none. Every other login method, second factor and stored credential, agent and none. Every other login method, second factor and
SSH auth method is a plugin through `ctx.auth`. SSH auth method is a plugin through `ctx.auth`.
In 2.9.0, Step-CA and Vault still live in core. In 2.9.0, Vault still lives in core. It registers through the same
They register through the same interfaces from interface from `src/backend/auth/legacy-providers.ts` with
`src/backend/auth/legacy-providers.ts` and `src/ui/auth/legacy-auth-ui.tsx` `pluginId: "core"`. Its Phase C step moves its block out of that file; D1
with `pluginId: "core"`. Phase C moves each one into its plugin by moving its deletes it. **C4** moved Step-CA out and deleted
block out of those two files; D1 deletes them. `src/ui/auth/legacy-auth-ui.tsx`, which had no other editor left.
Nothing else in core branches on those type names. Nothing else in core branches on those type names.
**C1** moved TOTP and passkeys out: **C1** moved TOTP and passkeys out:
@@ -1136,11 +1136,28 @@ Nothing else in core branches on those type names.
2.8 message names for Termix-Mobile; the file manager and docker answer 2.8 message names for Termix-Mobile; the file manager and docker answer
`requires_browser_sign_in` and continue on `connect-browser-sign-in`. `requires_browser_sign_in` and continue on `connect-browser-sign-in`.
- `@termix/plugin-sdk/ssh-certs` (`applyCertificateAuth`) replaces core's - `@termix/plugin-sdk/ssh-certs` (`applyCertificateAuth`) replaces core's
`opkssh-cert-auth.ts`: opkssh, core key auth with a CA certificate, and the `opkssh-cert-auth.ts`: opkssh, step-ca, core key auth with a CA
Step-CA and Vault providers still in core use it. certificate, and the Vault provider still in core use it.
- Step-CA no longer shares anything with OPKSSH: its interaction is
`stepca`, its messages `stepca_*`, and it keeps issued certificates in **C4** moved Step-CA out:
memory until it becomes a plugin.
- `step-ca` registers the `stepca` SSH auth provider. Termix never runs the
`step` binary: the plugin talks to the CA's HTTPS API and the identity
provider through `ctx.fetch` (`network:outbound`), using its `tls` option
to bootstrap the CA root by fingerprint and pin it afterwards.
- Issued certificates live in its own table, `p_step_ca_certs`, sealed with
`ctx.secrets.seal`, until the certificate's `validBefore`. 2.8 cached them
in `opkssh_tokens`; those are left to expire.
- The CA URL, fingerprint, provisioner and private endpoint list are admin
settings. `step-ca-settings-migration.ts` copies the 2.8 `step_ca_*`
settings rows in and turns on `legacyCallback` when a CA was configured,
so an upgraded install keeps sending `<base>/host/step-ca-callback`, which
core answers with a 307 to `/plugin-api/step-ca/callback`.
- With `REDIS_URL` set, a callback that lands on another instance is handed
to the one that started the sign-in through Redis
(`TERMIX_STEP_CA_REDIS_PREFIX`), as in 2.8.
- The sign-in dialog is a `terminal.overlay` contribution speaking
`stepca_*`.
#### One SSH connect pipeline #### One SSH connect pipeline
@@ -1683,7 +1700,7 @@ deciding, not the mechanism.
Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`. Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`.
| Member | Capability | Status | | Member | Capability | Status |
| ------------------------------------------------ | -------------------------------------------------- | ----------------------- | | ------------------------------------------------ | -------------------------------------------------- | ----------------------------------- |
| `ctx.pluginId`, `ctx.manifest` | none | **A1** | | `ctx.pluginId`, `ctx.manifest` | none | **A1** |
| `ctx.log.*` | none | **A1** | | `ctx.log.*` | none | **A1** |
| `ctx.events.emit` / `.on` | `events:core` for core topics | **A1** | | `ctx.events.emit` / `.on` | `events:core` for core topics | **A1** |
@@ -1717,7 +1734,7 @@ Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`.
| `ctx.credentials.registerSecretResolver` | `auth:provide` | **B20** | | `ctx.credentials.registerSecretResolver` | `auth:provide` | **B20** |
| `ctx.audit.record` | none, the actor is the runtime's | **B9** | | `ctx.audit.record` | none, the actor is the runtime's | **B9** |
| `ctx.schedule.every` / `.after` | none | **B16** | | `ctx.schedule.every` / `.after` | none | **B16** |
| `ctx.fetch` | `network:outbound` | **B17**, signal **B18** | | `ctx.fetch` | `network:outbound` | **B17**, signal **B18**, tls **C4** |
| `ctx.process.run` / `.ensureBinary` | `process:spawn` (+ `network:outbound` to download) | **C3** | | `ctx.process.run` / `.ensureBinary` | `process:spawn` (+ `network:outbound` to download) | **C3** |
| `ctx.auth.registerKeyboardInteractiveHandler` | `auth:provide` | **C3** | | `ctx.auth.registerKeyboardInteractiveHandler` | `auth:provide` | **C3** |
@@ -1956,6 +1973,10 @@ notification)` delivers to the ones among them that are theirs and
`network:outbound`, audited as `plugin_fetch`. Automations' HTTP step `network:outbound`, audited as `plugin_fetch`. Automations' HTTP step
passes the admin allowlist it reads through `ctx.settings.readCore` passes the admin allowlist it reads through `ctx.settings.readCore`
(`notification_private_endpoint_allowlist` joined `CORE_SETTINGS_ALLOWLIST`). (`notification_private_endpoint_allowlist` joined `CORE_SETTINGS_ALLOWLIST`).
**C4** added `tls: { ca, rejectUnauthorized }` for a private CA: `ca`
trusts a PEM bundle, and `rejectUnauthorized: false` is for the one request
that fetches a CA root by fingerprint, which the caller then checks
(step-ca, like `step ca bootstrap`).
**B18** added three members for the AI assistant: **B18** added three members for the AI assistant:
@@ -2519,7 +2540,7 @@ Then, with the app running:
The bundled plugins predate the SDK, apart from workspaces (A9), snippets The bundled plugins predate the SDK, apart from workspaces (A9), snippets
(B2), remote-desktop (B14), docker (B15), host-metrics (B16), automations (B2), remote-desktop (B14), docker (B15), host-metrics (B16), automations
(B17), ai (B18), homepage (B19), totp and webauthn (C1), sso and ldap (C2), (B17), ai (B18), homepage (B19), totp and webauthn (C1), sso and ldap (C2),
opkssh and warpgate (C3), opkssh and warpgate (C3), step-ca (C4),
which import nothing from core. The others still reach core by relative which import nothing from core. The others still reach core by relative
path (`../../../../src/backend/...`), which an esbuild plugin, path (`../../../../src/backend/...`), which an esbuild plugin,
`packages/plugin-sdk/cli/lib/legacy-core-imports.mjs`, keeps out of the bundle `packages/plugin-sdk/cli/lib/legacy-core-imports.mjs`, keeps out of the bundle
+21 -15
View File
@@ -483,21 +483,13 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
(`h.pluginSettings?.["web-endpoint"]`). The file is untouched since before (`h.pluginSettings?.["web-endpoint"]`). The file is untouched since before
C2. Owner: D0. C2. Owner: D0.
- **C3 (opkssh), for the Step-CA step:** Step-CA is still core and no longer - **C3 (opkssh), C4 (step-ca):** `database/routes/host-compat-routes.ts`
shares OPKSSH's pieces. Its interaction is `stepca` and its socket messages answers the 2.8 redirect URIs `/host/opkssh-callback` and
`stepca_*`, but nothing draws them yet: until its plugin contributes a `/host/step-ca-callback` with a 307 to the plugins and so names their
`terminal.overlay` (the opkssh plugin's `OpksshOverlay` and `OpksshDialog` paths. Keep it until identity providers have moved to
are the model), a Step-CA host asks for a sign-in the terminal cannot show. `/plugin-api/opkssh/callback` and `/plugin-api/step-ca/callback` (each
Its issued certificates are cached in memory (`step-ca-auth.ts`), so they plugin's admin page shows it, and its `legacyCallback` setting switches
are lost on restart; its plugin should own a table the way opkssh owns over). Owner: 3.0.0.
`p_opkssh_tokens`. Step-CA certificates 2.8 cached in `opkssh_tokens` stay
in the adopted `p_opkssh_tokens`, unused, until they expire. Owner: the
Phase C step that moves Step-CA.
- **C3 (opkssh):** `database/routes/host-compat-routes.ts` answers the 2.8
redirect URI `/host/opkssh-callback` with a 307 to the plugin and so names
the plugin's path. Keep it until identity providers have moved to
`/plugin-api/opkssh/callback` (the admin page shows it, and the
`legacyCallback` setting switches over). Owner: 3.0.0.
- **C3 (opkssh/warpgate):** `ssh_data.use_warpgate` and the `.opk` folder - **C3 (opkssh/warpgate):** `ssh_data.use_warpgate` and the `.opk` folder
under `DATA_DIR` stay on disk, unused (the drizzle drop is `SELECT 1;`, the under `DATA_DIR` stay on disk, unused (the drizzle drop is `SELECT 1;`, the
config is copied, not moved). Remove them in 3.0.0. Owner: 3.0.0. config is copied, not moved). Remove them in 3.0.0. Owner: 3.0.0.
@@ -517,6 +509,20 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
`useWarpgate` field inline (the host editor sends plugin settings). The `useWarpgate` field inline (the host editor sends plugin settings). The
bulk list should come from the registered providers plus bulk list should come from the registered providers plus
`contributes.auth.sshAuthTypes`. Owner: D1. `contributes.auth.sshAuthTypes`. Owner: D1.
- **C4 (step-ca):** the 2.8 `step_ca_url`, `step_ca_fingerprint`,
`step_ca_provisioner` and `step_ca_private_endpoint_allowlist` rows stay in
core `settings`, unused, after `step-ca-settings-migration.ts` copies them
(kept for one release so a downgrade works). Delete them in 3.0.0.
Owner: 3.0.0.
- **C4 (step-ca):** the admin fields are plain settings now, so a bad CA URL
or fingerprint is reported when someone signs in (in the terminal dialog)
instead of when the admin saves, as the 2.8 route did. A settings
validation hook for plugins would bring the save-time check back.
Owner: D0.
- **C4 (step-ca):** the Termix docs still describe Step CA under Admin
Settings and the `/host/step-ca-callback` redirect URI. They need the
plugin settings page, the new redirect URI and the `legacyCallback`
setting. Owner: D0 (docs repo).
- **Pre-existing, found in C3:** the Docker `opkssh-downloader` stage's - **Pre-existing, found in C3:** the Docker `opkssh-downloader` stage's
`OPKSSH_VERSION` build arg is not passed on at runtime, so building with `OPKSSH_VERSION` build arg is not passed on at runtime, so building with
another version gives a prebaked binary the plugin's pinned checksum another version gives a prebaked binary the plugin's pinned checksum
+6
View File
@@ -1490,6 +1490,12 @@ export interface PluginFetchInit {
allowPrivateHosts?: readonly string[]; allowPrivateHosts?: readonly string[];
/** Aborts the request, and a streamed body, when the caller gives up. */ /** Aborts the request, and a streamed body, when the caller gives up. */
signal?: AbortSignal; signal?: AbortSignal;
/**
* TLS for a private CA. `ca` trusts this PEM bundle instead of the system
* store. `rejectUnauthorized: false` skips verification, only for fetching
* a CA root by fingerprint, where the caller checks the answer itself.
*/
tls?: { ca?: string; rejectUnauthorized?: boolean };
} }
/** /**
+1 -1
View File
@@ -878,7 +878,7 @@ export function createFakeContext(
requiresSecret: (authType) => requiresSecret: (authType) =>
["password", "key", "credential", "agent"].includes(authType), ["password", "key", "credential", "agent"].includes(authType),
supportsBackground: (authType) => supportsBackground: (authType) =>
!["none", "stepca"].includes(authType) && authType !== "none" &&
!auth.sshAuthProviders.some( !auth.sshAuthProviders.some(
(provider) => (provider) =>
provider.type === authType && provider.supportsBackground === false, provider.type === authType && provider.supportsBackground === false,
+16
View File
@@ -0,0 +1,16 @@
# Changelog
## 1.0.0
Bundled with Termix 2.9.0.
- Step CA sign-in moved out of core. The callback is now
`/plugin-api/step-ca/callback`; the 2.8 URI `/host/step-ca-callback`
still works and upgraded installs keep sending it until an admin turns
off "Use the old redirect URI".
- The CA URL, fingerprint, provisioner and private endpoint list moved from
Admin Settings into this plugin's settings.
- Issued certificates are stored encrypted in the plugin's own table, so
they survive a restart. Certificates cached by 2.8 are not carried over;
the next connection asks for one sign-in.
- The terminal shows the sign-in dialog for Step CA hosts again.
+31
View File
@@ -0,0 +1,31 @@
# Step CA
Adds the `stepca` SSH auth type: Termix signs you in with the identity
provider behind a [smallstep step-ca](https://smallstep.com/docs/step-ca/)
OIDC provisioner, has the CA sign a fresh key, and keeps the short-lived SSH
certificate until it expires.
Termix never runs the `step` binary. It talks to the CA's HTTPS API
(`/root/<fingerprint>`, `/provisioners`, `/1.0/ssh/sign`) and to the identity
provider directly, through core's outbound request guard.
## Settings
Admin settings, under Settings > Plugins > Step CA:
- **CA URL**, **Root fingerprint** and **OIDC provisioner name**. Leave all
three empty to turn Step CA off.
- **Allowed private Step CA hosts**: the CA and, if it is internal, the
identity provider. Anything private that is not listed is refused.
- **Redirect URI**: register it with the identity provider:
`<base URL>/plugin-api/step-ca/callback`, or
`<base URL>/host/step-ca-callback` while "Use the old redirect URI" is on.
An install upgraded from 2.8 has its Step CA settings copied in and keeps
the old redirect URI turned on.
## Several instances
With `REDIS_URL` set, a callback that reaches another Termix instance is
handed to the one that started the sign-in. `TERMIX_STEP_CA_REDIS_PREFIX`
changes the key prefix (default `termix:step-ca`).
+48
View File
@@ -0,0 +1,48 @@
{
"hosts": {
"filterAuthStepca": "Step CA"
},
"editor": {
"label": "Step CA",
"description": "Sign in through your identity provider and let your Step CA issue a short-lived SSH certificate for this host. The CA URL, root fingerprint and OIDC provisioner are set by an administrator in the Step CA plugin settings.",
"docs": "View docs"
},
"dialog": {
"title": "Step CA Authentication Required",
"description": "Complete authentication in your browser to continue. The certificate stays valid for as long as your CA allows.",
"openBrowser": "Open Browser to Authenticate",
"waiting": "Waiting for authentication in browser...",
"authenticating": "Processing authentication..."
},
"errors": {
"authFailed": "Authentication failed. Please check your credentials and try again.",
"timeout": "Authentication timed out. Please try again."
},
"settings": {
"caUrl": {
"label": "CA URL",
"description": "The https URL of your smallstep CA. Leave the CA URL, fingerprint and provisioner empty to turn Step CA off.",
"placeholder": "https://ca.internal:9000"
},
"fingerprint": {
"label": "Root fingerprint (SHA-256)",
"description": "The root certificate fingerprint, as shown by step ca bootstrap."
},
"provisioner": {
"label": "OIDC provisioner name",
"description": "The name of the CA's OIDC provisioner. Then choose \"Step CA\" as a host's authentication type."
},
"privateEndpoints": {
"label": "Allowed private Step CA hosts",
"description": "Private hosts the Step CA certificate flow may contact: the CA itself and, if internal, your identity provider. Separate them with commas or new lines."
},
"redirectUri": {
"label": "Redirect URI",
"description": "Register this URL as an allowed redirect URI with the identity provider behind the OIDC provisioner."
},
"legacyCallback": {
"label": "Use the old redirect URI",
"description": "Keep sending /host/step-ca-callback, the redirect URI used before 2.9. Turn this off once your identity provider allows the new one."
}
}
}
@@ -0,0 +1,31 @@
{
"hosts": {
"filterAuthStepca": "Stap CA"
},
"editor": {
"label": "Stap CA",
"description": "Meld aan deur jou identiteitsverskaffer en laat jou Step CA 'n kortstondige SSH-sertifikaat vir hierdie gasheer uitreik. Die CA-URL, wortelvingerafdruk en OIDC-voorsiening word deur 'n administrateur onder Admin-instellings ingestel.",
"docs": "Bekyk dokumente"
},
"dialog": {
"openBrowser": "Maak blaaier oop om te verifieer",
"waiting": "Wag vir verifikasie in blaaier...",
"authenticating": "Verwerk verifikasie..."
},
"errors": {
"authFailed": "Verifikasie het misluk. Kontroleer asseblief jou geloofsbriewe en probeer weer.",
"timeout": "Verifikasie het verstryk. Probeer asseblief weer."
},
"settings": {
"fingerprint": {
"label": "Wortelvingerafdruk (SHA-256)"
},
"provisioner": {
"label": "OIDC-voorsieningsnaam"
},
"privateEndpoints": {
"label": "Toegelate private Step CA-gashere",
"description": "Privaat gashere, die Step CA-sertifikaatvloei, mag kontak maak met: die CA self en, indien intern, jou identiteitsverskaffer. Skei hulle met kommas."
}
}
}
@@ -0,0 +1,31 @@
{
"hosts": {
"filterAuthStepca": "الخطوة ج"
},
"editor": {
"label": "الخطوة ج",
"description": "سجّل الدخول عبر موفر الهوية الخاص بك، ودع جهة إصدار الشهادات المعتمدة (SA) التابعة لـ Step CA تُصدر شهادة SSH مؤقتة لهذا المضيف. يتم تحديد عنوان URL الخاص بجهة إصدار الشهادات المعتمدة، وبصمة الجذر، وموفر OIDC بواسطة مسؤول النظام ضمن إعدادات المسؤول.",
"docs": "عرض المستندات"
},
"dialog": {
"openBrowser": "افتح المتصفح للمصادقة",
"waiting": "في انتظار المصادقة في المتصفح...",
"authenticating": "جارٍ معالجة المصادقة..."
},
"errors": {
"authFailed": "فشلت المصادقة. يرجى التحقق من بيانات الاعتماد والمحاولة مرة أخرى.",
"timeout": "انتهت مهلة المصادقة. يرجى المحاولة مرة أخرى."
},
"settings": {
"fingerprint": {
"label": "بصمة الجذر (SHA-256)"
},
"provisioner": {
"label": "اسم موفر OIDC"
},
"privateEndpoints": {
"label": "مضيفو شهادات الخطوة الخاصة المسموح بها",
"description": "قد تتصل جهات الاستضافة الخاصة بعملية شهادة Step CA بما يلي: جهة إصدار الشهادات نفسها، وإذا كانت داخلية، مزود الهوية الخاص بك. افصل بينهما بفواصل."
}
}
}
@@ -0,0 +1,31 @@
{
"hosts": {
"filterAuthStepca": "Стъпка CA"
},
"editor": {
"label": "Стъпка CA",
"description": "Влезте чрез вашия доставчик на самоличност и позволете на вашия Step CA да издаде краткотраен SSH сертификат за този хост. URL адресът на CA, root пръстовият отпечатък и OIDC provisioner се задават от администратор в „Настройки на администратора“.",
"docs": "Преглед на документи"
},
"dialog": {
"openBrowser": "Отворете браузъра за удостоверяване",
"waiting": "Чака се удостоверяване в браузъра...",
"authenticating": "Обработва се удостоверяване..."
},
"errors": {
"authFailed": "Удостоверяването не бе успешно. Моля, проверете идентификационните си данни и опитайте отново.",
"timeout": "Времето за изчакване на удостоверяването изтече. Моля, опитайте отново."
},
"settings": {
"fingerprint": {
"label": "Коренен пръстов отпечатък (SHA-256)"
},
"provisioner": {
"label": "Име на доставчика на OIDC"
},
"privateEndpoints": {
"label": "Разрешени частни хостове на Step CA",
"description": "Частни хостове, с които потокът от сертификати на Step CA може да се свърже: самия CA и, ако е вътрешен, вашият доставчик на идентичност. Разделете ги със запетаи."
}
}
}
@@ -0,0 +1,31 @@
{
"hosts": {
"filterAuthStepca": "স্টেপ সিএ"
},
"editor": {
"label": "স্টেপ সিএ",
"description": "আপনার আইডেন্টিটি প্রোভাইডারের মাধ্যমে সাইন ইন করুন এবং আপনার স্টেপ সিএ-কে এই হোস্টের জন্য একটি স্বল্পস্থায়ী এসএসএইচ সার্টিফিকেট ইস্যু করতে দিন। সিএ ইউআরএল, রুট ফিঙ্গারপ্রিন্ট এবং ওআইডিসি প্রোভিশনার একজন অ্যাডমিনিস্ট্রেটর দ্বারা অ্যাডমিন সেটিংস-এর অধীনে সেট করা হয়।",
"docs": "নথি দেখুন"
},
"dialog": {
"openBrowser": "প্রমাণীকরণের জন্য ব্রাউজার খুলুন",
"waiting": "ব্রাউজারে প্রমাণীকরণের জন্য অপেক্ষা করা হচ্ছে...",
"authenticating": "প্রমাণীকরণ প্রক্রিয়া চলছে..."
},
"errors": {
"authFailed": "প্রমাণীকরণ ব্যর্থ হয়েছে। অনুগ্রহ করে আপনার পরিচয়পত্র যাচাই করে আবার চেষ্টা করুন।",
"timeout": "প্রমাণীকরণের সময়সীমা শেষ হয়ে গেছে। অনুগ্রহ করে আবার চেষ্টা করুন।"
},
"settings": {
"fingerprint": {
"label": "রুট ফিঙ্গারপ্রিন্ট (SHA-256)"
},
"provisioner": {
"label": "OIDC সরবরাহকারীর নাম"
},
"privateEndpoints": {
"label": "ব্যক্তিগত স্টেপ সিএ হোস্টদের অনুমতি দেওয়া হয়েছে",
"description": "স্টেপ সিএ সার্টিফিকেট ফ্লো যেসব প্রাইভেট হোস্টের সাথে যোগাযোগ করতে পারে, সেগুলো হলো: স্বয়ং সিএ এবং, যদি অভ্যন্তরীণ হয়, আপনার আইডেন্টিটি প্রোভাইডার। এদেরকে কমা দিয়ে আলাদা করুন।"
}
}
}
@@ -0,0 +1,31 @@
{
"hosts": {
"filterAuthStepca": "Pas CA"
},
"editor": {
"label": "Pas CA",
"description": "Inicieu la sessió a través del vostre proveïdor d'identitat i deixeu que la vostra Step CA emeti un certificat SSH de curta durada per a aquest amfitrió. L'URL de la CA, l'empremta digital arrel i el proveïdor OIDC els estableix un administrador a Configuració de l'administrador.",
"docs": "Veure documents"
},
"dialog": {
"openBrowser": "Obre el navegador per autenticar-te",
"waiting": "Esperant l'autenticació al navegador...",
"authenticating": "Processant l'autenticació..."
},
"errors": {
"authFailed": "L'autenticació ha fallat. Si us plau, comproveu les vostres credencials i torneu-ho a intentar.",
"timeout": "S'ha esgotat el temps d'autenticació. Torna-ho a provar."
},
"settings": {
"fingerprint": {
"label": "Empremta digital arrel (SHA-256)"
},
"provisioner": {
"label": "Nom del proveïdor OIDC"
},
"privateEndpoints": {
"label": "Amfitrions privats permesos de Step CA",
"description": "Amfitrions privats amb els quals el flux de certificats de la CA de Step pot contactar: la mateixa CA i, si és intern, el vostre proveïdor d'identitat. Separeu-los amb comes."
}
}
}
@@ -0,0 +1,31 @@
{
"hosts": {
"filterAuthStepca": "Krok CA"
},
"editor": {
"label": "Krok CA",
"description": "Přihlaste se prostřednictvím svého poskytovatele identity a nechte svou certifikační autoritu Step CA vydat krátkodobý SSH certifikát pro tohoto hostitele. URL certifikační autority, otisk kořenového serveru a zřizovatel OIDC nastavuje administrátor v Nastavení správce.",
"docs": "Zobrazit dokumenty"
},
"dialog": {
"openBrowser": "Otevřít prohlížeč pro ověření",
"waiting": "Čekání na ověření v prohlížeči...",
"authenticating": "Zpracování ověřování..."
},
"errors": {
"authFailed": "Ověření se nezdařilo. Zkontrolujte prosím své přihlašovací údaje a zkuste to znovu.",
"timeout": "Časový limit ověření vypršel. Zkuste to prosím znovu."
},
"settings": {
"fingerprint": {
"label": "Kořenový otisk (SHA-256)"
},
"provisioner": {
"label": "Název zřizovatele OIDC"
},
"privateEndpoints": {
"label": "Povolení soukromí hostitelé Step CA",
"description": "Soukromí hostitelé, které může tok certifikátů Step CA kontaktovat: samotná CA a v případě interní certifikace také váš poskytovatel identity. Oddělte je čárkami."
}
}
}
@@ -0,0 +1,31 @@
{
"hosts": {
"filterAuthStepca": "Trin CA"
},
"editor": {
"label": "Trin CA",
"description": "Log ind via din identitetsudbyder, og lad din Step CA udstede et kortvarigt SSH-certifikat til denne vært. CA-URL'en, root-fingeraftrykket og OIDC-provisioneren indstilles af en administrator under administratorindstillinger.",
"docs": "Se dokumenter"
},
"dialog": {
"openBrowser": "Åbn browseren for at godkende",
"waiting": "Venter på godkendelse i browseren...",
"authenticating": "Behandler godkendelse..."
},
"errors": {
"authFailed": "Godkendelse mislykkedes. Tjek dine loginoplysninger, og prøv igen.",
"timeout": "Godkendelsen udløb. Prøv igen."
},
"settings": {
"fingerprint": {
"label": "Rodfingeraftryk (SHA-256)"
},
"provisioner": {
"label": "OIDC-provisioneringsnavn"
},
"privateEndpoints": {
"label": "Tilladte private Step CA-værter",
"description": "Private værter, som Step CA-certifikatflowet kan kontakte: selve CA'en og, hvis den er intern, din identitetsudbyder. Adskil dem med kommaer."
}
}
}
@@ -0,0 +1,31 @@
{
"hosts": {
"filterAuthStepca": "Schritt CA"
},
"editor": {
"label": "Schritt CA",
"description": "Melden Sie sich über Ihren Identitätsanbieter an und lassen Sie Ihre Step-Zertifizierungsstelle ein kurzlebiges SSH-Zertifikat für diesen Host ausstellen. Die URL der Zertifizierungsstelle, der Root-Fingerabdruck und der OIDC-Provisioner werden von einem Administrator unter „Admin-Einstellungen“ festgelegt.",
"docs": "Dokumentation anzeigen"
},
"dialog": {
"openBrowser": "Browser zum Authentifizieren öffnen",
"waiting": "Warten auf Authentifizierung im Browser...",
"authenticating": "Authentifizierung wird verarbeitet..."
},
"errors": {
"authFailed": "Authentifizierung fehlgeschlagen. Bitte überprüfen Sie Ihre Anmeldeinformationen und versuchen Sie es erneut.",
"timeout": "Authentifizierung abgelaufen. Bitte versuchen Sie es erneut."
},
"settings": {
"fingerprint": {
"label": "Root-Fingerabdruck (SHA-256)"
},
"provisioner": {
"label": "OIDC-Provisionername"
},
"privateEndpoints": {
"label": "Zulässige private Step CA-Hosts",
"description": "Private Hosts, mit denen der Step CA-Zertifikatsfluss in Kontakt treten kann, kontaktieren möglicherweise die Zertifizierungsstelle selbst und, falls intern, Ihren Identitätsanbieter. Trennen Sie diese durch Kommas."
}
}
}
@@ -0,0 +1,31 @@
{
"hosts": {
"filterAuthStepca": "Βήμα CA"
},
"editor": {
"label": "Βήμα CA",
"description": "Συνδεθείτε μέσω του παρόχου ταυτότητας και αφήστε την Step CA σας να εκδώσει ένα βραχύβιο πιστοποιητικό SSH για αυτόν τον κεντρικό υπολογιστή. Η διεύθυνση URL CA, το δακτυλικό αποτύπωμα root και ο πάροχος OIDC ορίζονται από έναν διαχειριστή στις Ρυθμίσεις διαχειριστή.",
"docs": "Προβολή εγγράφων"
},
"dialog": {
"openBrowser": "Άνοιγμα προγράμματος περιήγησης για έλεγχο ταυτότητας",
"waiting": "Αναμονή για έλεγχο ταυτότητας στο πρόγραμμα περιήγησης...",
"authenticating": "Επεξεργασία ελέγχου ταυτότητας..."
},
"errors": {
"authFailed": "Ο έλεγχος ταυτότητας απέτυχε. Ελέγξτε τα διαπιστευτήριά σας και προσπαθήστε ξανά.",
"timeout": "Το χρονικό όριο ελέγχου ταυτότητας έληξε. Δοκιμάστε ξανά."
},
"settings": {
"fingerprint": {
"label": "Δακτυλικό αποτύπωμα ρίζας (SHA-256)"
},
"provisioner": {
"label": "Όνομα παρόχου OIDC"
},
"privateEndpoints": {
"label": "Επιτρεπόμενοι ιδιωτικοί κεντρικοί υπολογιστές Step CA",
"description": "Οι ιδιωτικοί κεντρικοί υπολογιστές της ροής πιστοποιητικών Step CA ενδέχεται να επικοινωνήσουν με: την ίδια την CA και, εάν είναι εσωτερική, τον πάροχο ταυτότητάς σας. Διαχωρίστε τους με κόμματα."
}
}
}
@@ -0,0 +1,31 @@
{
"hosts": {
"filterAuthStepca": "Paso CA"
},
"editor": {
"label": "Paso CA",
"description": "Inicie sesión a través de su proveedor de identidad y permita que su CA de Step emita un certificado SSH de corta duración para este host. La URL de la CA, la huella digital raíz y el proveedor OIDC se configuran mediante un administrador en la sección de Configuración de administración.",
"docs": "Ver documentación"
},
"dialog": {
"openBrowser": "Abrir navegador para autenticarse",
"waiting": "Esperando autenticación en el navegador...",
"authenticating": "Procesando autenticación..."
},
"errors": {
"authFailed": "La autenticación falló. Revisa tus credenciales e inténtalo de nuevo.",
"timeout": "La autenticación expiró. Inténtalo de nuevo."
},
"settings": {
"fingerprint": {
"label": "Huella digital de la raíz (SHA-256)"
},
"provisioner": {
"label": "nombre del proveedor OIDC"
},
"privateEndpoints": {
"label": "Hosts de CA de paso privado permitidos",
"description": "Los hosts privados con los que puede contactar el flujo de certificados de Step CA son: la propia CA y, si es interna, su proveedor de identidad. Sepárelos con comas."
}
}
}
@@ -0,0 +1,31 @@
{
"hosts": {
"filterAuthStepca": "Vaihe CA"
},
"editor": {
"label": "Vaihe CA",
"description": "Kirjaudu sisään identiteetintarjoajasi kautta ja anna vaiheen varmentajan myöntää lyhytaikainen SSH-varmenne tälle isännälle. Varmentajan URL-osoitteen, pääkäyttäjän sormenjäljen ja OIDC-varmenteen määrittäjän määrittää järjestelmänvalvojan asetuksissa.",
"docs": "Näytä dokumentit"
},
"dialog": {
"openBrowser": "Avaa selain todennusta varten",
"waiting": "Odotetaan selaimen todennusta...",
"authenticating": "Todennusta käsitellään..."
},
"errors": {
"authFailed": "Todennus epäonnistui. Tarkista tunnistetietosi ja yritä uudelleen.",
"timeout": "Todennus aikakatkaistiin. Yritä uudelleen."
},
"settings": {
"fingerprint": {
"label": "Juurisormenjälki (SHA-256)"
},
"provisioner": {
"label": "OIDC-palveluntarjoajan nimi"
},
"privateEndpoints": {
"label": "Sallitut yksityiset Step CA -isännät",
"description": "Yksityiset isännöitsijät, joilla Step CA -varmennetyönkulku voi ottaa yhteyttä: itse CA:han ja, jos kyseessä on sisäinen varmenne, identiteetintarjoajaasi. Erota ne pilkuilla."
}
}
}
@@ -0,0 +1,31 @@
{
"hosts": {
"filterAuthStepca": "Étape CA"
},
"editor": {
"label": "Étape CA",
"description": "Connectez-vous via votre fournisseur d'identité et laissez votre autorité de certification Step CA émettre un certificat SSH temporaire pour cet hôte. L'URL de l'autorité de certification, l'empreinte racine et le provisionneur OIDC sont configurés par un administrateur dans les paramètres d'administration.",
"docs": "Voir la documentation"
},
"dialog": {
"openBrowser": "Ouvrir le navigateur pour s'authentifier",
"waiting": "En attente de l'authentification dans le navigateur...",
"authenticating": "Traitement de l'authentification..."
},
"errors": {
"authFailed": "Échec de l'authentification. Veuillez vérifier vos identifiants et réessayer.",
"timeout": "L'authentification a expiré. Veuillez réessayer."
},
"settings": {
"fingerprint": {
"label": "Empreinte racine (SHA-256)"
},
"provisioner": {
"label": "Nom du fournisseur OIDC"
},
"privateEndpoints": {
"label": "Hôtes d'autorité de certification privés autorisés",
"description": "Les hôtes privés pour lesquels le flux de certificats Step CA peut être sollicité peuvent contacter : l’autorité de certification elle-même et, si elle est interne, votre fournisseur d’identité. Séparez-les par des virgules."
}
}
}
@@ -0,0 +1,31 @@
{
"hosts": {
"filterAuthStepca": "שלב CA"
},
"editor": {
"label": "שלב CA",
"description": "היכנס דרך ספק הזהויות שלך ותן ל-Step CA שלך להנפיק אישור SSH קצר מועד עבור מארח זה. כתובת ה-URL של CA, טביעת האצבע של הבסיס וספק ה-OIDC מוגדרים על ידי מנהל מערכת תחת הגדרות מנהל מערכת.",
"docs": "הצג מסמכים"
},
"dialog": {
"openBrowser": "פתח את הדפדפן כדי לאמת",
"waiting": "ממתין לאימות בדפדפן...",
"authenticating": "מעבד אימות..."
},
"errors": {
"authFailed": "האימות נכשל. אנא בדוק את פרטי הגישה שלך ונסה שוב.",
"timeout": "הזמן שהוקצב לאימות הסתיים. אנא נסה שוב."
},
"settings": {
"fingerprint": {
"label": "טביעת אצבע של שורש (SHA-256)"
},
"provisioner": {
"label": "שם ספק OIDC"
},
"privateEndpoints": {
"label": "מארחים פרטיים של Step CA מורשים",
"description": "מארחים פרטיים, זרימת האישורים של Step CA עשויה ליצור קשר עם: רשות האישורים עצמה, ואם היא פנימית, עם ספק הזהויות שלך. הפרידו ביניהם באמצעות פסיקים."
}
}
}
@@ -0,0 +1,31 @@
{
"hosts": {
"filterAuthStepca": "स्टेप सीए"
},
"editor": {
"label": "स्टेप सीए",
"description": "अपने आइडेंटिटी प्रोवाइडर के माध्यम से साइन इन करें और अपने स्टेप सीए को इस होस्ट के लिए एक अल्पकालिक एसएसएच प्रमाणपत्र जारी करने दें। सीए यूआरएल, रूट फिंगरप्रिंट और ओआईडीसी प्रोविजनर को एडमिनिस्ट्रेटर द्वारा एडमिन सेटिंग्स के अंतर्गत सेट किया जाता है।",
"docs": "दस्तावेज़ देखें"
},
"dialog": {
"openBrowser": "प्रमाणीकरण के लिए ब्राउज़र खोलें",
"waiting": "ब्राउज़र में प्रमाणीकरण की प्रतीक्षा हो रही है...",
"authenticating": "प्रमाणीकरण प्रक्रिया में है..."
},
"errors": {
"authFailed": "प्रमाणीकरण विफल। कृपया अपने क्रेडेंशियल जाँचें और पुनः प्रयास करें।",
"timeout": "प्रमाणीकरण का समय समाप्त हो गया। कृपया पुनः प्रयास करें।"
},
"settings": {
"fingerprint": {
"label": "रूट फिंगरप्रिंट (SHA-256)"
},
"provisioner": {
"label": "OIDC प्रोविज़नर नाम"
},
"privateEndpoints": {
"label": "निजी स्टेप सीए होस्ट की अनुमति है",
"description": "निजी होस्ट, स्टेप सीए प्रमाणपत्र प्रवाह के दौरान निम्नलिखित से संपर्क कर सकते हैं: सीए स्वयं और, यदि आंतरिक है, तो आपका पहचान प्रदाता। इन्हें अल्पविराम से अलग करें।"
}
}
}
@@ -0,0 +1,31 @@
{
"hosts": {
"filterAuthStepca": "CA lépés"
},
"editor": {
"label": "CA lépés",
"description": "Jelentkezzen be az identitásszolgáltatóján keresztül, és hagyja, hogy a lépéses hitelesítésszolgáltatója rövid életű SSH-tanúsítványt állítson ki ehhez a gazdagéphez. A hitelesítésszolgáltató URL-címét, a root ujjlenyomatát és az OIDC-kiszolgálót a rendszergazda állítja be az Adminisztrátori beállítások alatt.",
"docs": "Dokumentumok megtekintése"
},
"dialog": {
"openBrowser": "Böngésző megnyitása a hitelesítéshez",
"waiting": "Várakozás a böngésző hitelesítésére...",
"authenticating": "Hitelesítés feldolgozása..."
},
"errors": {
"authFailed": "Sikertelen hitelesítés. Kérjük, ellenőrizze a hitelesítő adatait, és próbálja újra.",
"timeout": "A hitelesítés időtúllépést okozott. Kérjük, próbálja újra."
},
"settings": {
"fingerprint": {
"label": "Gyökér ujjlenyomat (SHA-256)"
},
"provisioner": {
"label": "OIDC-szolgáltató neve"
},
"privateEndpoints": {
"label": "Engedélyezett privát Step CA-hosztok",
"description": "A Step CA tanúsítványfolyamat által érintett privát hosztok a következőkkel vehetik fel a kapcsolatot: maga a CA, és ha belső, akkor az identitásszolgáltató. Vesszővel válassza el őket."
}
}
}
@@ -0,0 +1,31 @@
{
"hosts": {
"filterAuthStepca": "Langkah CA"
},
"editor": {
"label": "Langkah CA",
"description": "Masuk melalui penyedia identitas Anda dan biarkan Step CA Anda menerbitkan sertifikat SSH berumur pendek untuk host ini. URL CA, sidik jari root, dan penyedia OIDC diatur oleh administrator di bawah Pengaturan Admin.",
"docs": "Lihat dokumen"
},
"dialog": {
"openBrowser": "Buka Browser untuk Melakukan Otentikasi",
"waiting": "Menunggu otentikasi di browser...",
"authenticating": "Memproses otentikasi..."
},
"errors": {
"authFailed": "Autentikasi gagal. Harap periksa kredensial Anda dan coba lagi.",
"timeout": "Autentikasi habis waktu. Silakan coba lagi."
},
"settings": {
"fingerprint": {
"label": "Sidik jari akar (SHA-256)"
},
"provisioner": {
"label": "Nama penyedia OIDC"
},
"privateEndpoints": {
"label": "Host CA Langkah pribadi yang diizinkan",
"description": "Host privat yang dapat dihubungi oleh alur sertifikat Step CA adalah: CA itu sendiri dan, jika internal, penyedia identitas Anda. Pisahkan mereka dengan koma."
}
}
}
@@ -0,0 +1,31 @@
{
"hosts": {
"filterAuthStepca": "Passo CA"
},
"editor": {
"label": "Passo CA",
"description": "Accedi tramite il tuo provider di identità e lascia che la tua CA Step emetta un certificato SSH temporaneo per questo host. L'URL della CA, l'impronta digitale della radice e il provisioner OIDC vengono impostati da un amministratore nelle Impostazioni di amministrazione.",
"docs": "Visualizza documentazione"
},
"dialog": {
"openBrowser": "Apri il browser per autenticarti",
"waiting": "In attesa dell'autenticazione nel browser...",
"authenticating": "Autenticazione in corso..."
},
"errors": {
"authFailed": "Autenticazione fallita. Controlla le credenziali e riprova.",
"timeout": "Autenticazione scaduta. Riprova."
},
"settings": {
"fingerprint": {
"label": "Impronta digitale radice (SHA-256)"
},
"provisioner": {
"label": "Nome del fornitore OIDC"
},
"privateEndpoints": {
"label": "Host Step CA privati consentiti",
"description": "Gli host privati che utilizzano il flusso di certificati Step CA potrebbero contattare: la CA stessa e, se interna, il provider di identità. Separarli con delle virgole."
}
}
}
@@ -0,0 +1,31 @@
{
"hosts": {
"filterAuthStepca": "ステップCA"
},
"editor": {
"label": "ステップCA",
"description": "IDプロバイダー経由でサインインし、Step CAにこのホスト用の有効期限の短いSSH証明書を発行させてください。CAのURL、ルートフィンガープリント、およびOIDCプロビジョナーは、管理者が「管理設定」で設定します。",
"docs": "ドキュメントを表示"
},
"dialog": {
"openBrowser": "ブラウザを開いて認証",
"waiting": "ブラウザでの認証を待機中...",
"authenticating": "認証を処理中..."
},
"errors": {
"authFailed": "認証に失敗しました。資格情報を確認して再試行してください。",
"timeout": "認証がタイムアウトしました。もう一度お試しください。"
},
"settings": {
"fingerprint": {
"label": "ルートフィンガープリント(SHA-256)"
},
"provisioner": {
"label": "OIDCプロビジョナー名"
},
"privateEndpoints": {
"label": "許可されたプライベートStep CAホスト",
"description": "プライベートホストの場合、Step CA 証明書フローは、認証局自体、および内部の場合は ID プロバイダーと連絡を取ることができます。これらはカンマで区切ってください。"
}
}
}
@@ -0,0 +1,31 @@
{
"hosts": {
"filterAuthStepca": "스텝 CA"
},
"editor": {
"label": "스텝 CA",
"description": "ID 공급자를 통해 로그인하고 Step CA에서 이 호스트에 대한 단기 SSH 인증서를 발급받으세요. CA URL, 루트 지문 및 OIDC 프로비저너는 관리자가 관리자 설정에서 설정합니다.",
"docs": "문서 보기"
},
"dialog": {
"openBrowser": "브라우저로 인증하기",
"waiting": "브라우저에서 인증 대기 중...",
"authenticating": "인증 처리 중..."
},
"errors": {
"authFailed": "인증에 실패했습니다. 자격 증명을 확인하고 다시 시도하세요.",
"timeout": "인증 시간이 초과되었습니다. 다시 시도하세요."
},
"settings": {
"fingerprint": {
"label": "루트 지문(SHA-256)"
},
"provisioner": {
"label": "OIDC 프로비저너 이름"
},
"privateEndpoints": {
"label": "허용된 개인 Step CA 호스트",
"description": "Step CA 인증서 흐름이 접촉할 수 있는 개인 호스트는 CA 자체와 (내부 호스트인 경우) ID 공급자입니다. 이들을 쉼표로 구분하세요."
}
}
}
@@ -0,0 +1,31 @@
{
"hosts": {
"filterAuthStepca": "Stap CA"
},
"editor": {
"label": "Stap CA",
"description": "Meld u aan via uw identiteitsprovider en laat uw Step CA een kortstondig SSH-certificaat voor deze host uitgeven. De CA-URL, root-fingerprint en OIDC-provisioner worden door een beheerder ingesteld onder Beheerinstellingen.",
"docs": "Documenten bekijken"
},
"dialog": {
"openBrowser": "Open de browser om te authenticeren.",
"waiting": "Wachten op authenticatie in de browser...",
"authenticating": "Authenticatie wordt verwerkt..."
},
"errors": {
"authFailed": "Authenticatie mislukt. Controleer uw inloggegevens en probeer het opnieuw.",
"timeout": "De authenticatie is mislukt vanwege een time-out. Probeer het opnieuw."
},
"settings": {
"fingerprint": {
"label": "Root-vingerafdruk (SHA-256)"
},
"provisioner": {
"label": "Naam van de OIDC-leverancier"
},
"privateEndpoints": {
"label": "Toegestane privé Step CA-hosts",
"description": "De private hosts waarmee de Step CA-certificaatstroom contact kan opnemen, zijn: de certificeringsinstantie zelf en, indien intern, uw identiteitsprovider. Scheid deze met komma's."
}
}
}
@@ -0,0 +1,31 @@
{
"hosts": {
"filterAuthStepca": "Trinn CA"
},
"editor": {
"label": "Trinn CA",
"description": "Logg på via identitetsleverandøren din, og la Step CA utstede et kortvarig SSH-sertifikat for denne verten. CA-URL-en, rotfingeravtrykket og OIDC-provisioneren angis av en administrator under Administratorinnstillinger.",
"docs": "Vis dokumenter"
},
"dialog": {
"openBrowser": "Åpne nettleseren for å autentisere",
"waiting": "Venter på autentisering i nettleseren...",
"authenticating": "Behandler autentisering ..."
},
"errors": {
"authFailed": "Autentiseringen mislyktes. Sjekk legitimasjonen din og prøv på nytt.",
"timeout": "Autentiseringen ble tidsavbrutt. Prøv på nytt."
},
"settings": {
"fingerprint": {
"label": "Rotfingeravtrykk (SHA-256)"
},
"provisioner": {
"label": "OIDC-provisionernavn"
},
"privateEndpoints": {
"label": "Tillatte private Step CA-verter",
"description": "Private verter som Step CA-sertifikatflyten kan kontakte: selve CA-en og, hvis intern, identitetsleverandøren din. Skill dem med komma."
}
}
}
@@ -0,0 +1,31 @@
{
"hosts": {
"filterAuthStepca": "Krok CA"
},
"editor": {
"label": "Krok CA",
"description": "Zaloguj się za pomocą swojego dostawcy tożsamości i pozwól, aby urząd certyfikacji Step CA wydał tymczasowy certyfikat SSH dla tego hosta. Adres URL urzędu certyfikacji, odcisk palca głównego i dostawca OIDC są ustawiane przez administratora w Ustawieniach administratora.",
"docs": "Zobacz dokumentację"
},
"dialog": {
"openBrowser": "Otwórz przeglądarkę, aby uwierzytelnić",
"waiting": "Oczekiwanie na uwierzytelnienie w przeglądarce...",
"authenticating": "Przetwarzanie uwierzytelniania..."
},
"errors": {
"authFailed": "Uwierzytelnienie nie powiodło się. Sprawdź poświadczenia i spróbuj ponownie.",
"timeout": "Upłynął limit czasu uwierzytelniania. Spróbuj ponownie."
},
"settings": {
"fingerprint": {
"label": "Odcisk palca głównego (SHA-256)"
},
"provisioner": {
"label": "Nazwa dostawcy OIDC"
},
"privateEndpoints": {
"label": "Dozwolone prywatne hosty Step CA",
"description": "Prywatni hosty, z których przepływ certyfikatów Step CA może się kontaktować: z samym CA oraz, jeśli jest wewnętrzny, z Twoim dostawcą tożsamości. Rozdziel je przecinkami."
}
}
}
@@ -0,0 +1,31 @@
{
"hosts": {
"filterAuthStepca": "Etapa CA"
},
"editor": {
"label": "Etapa CA",
"description": "Faça login através do seu provedor de identidade e permita que a sua Autoridade Certificadora (CA) Step emita um certificado SSH de curta duração para este host. O URL da CA, a impressão digital da raiz e o provisionador OIDC são definidos por um administrador em Configurações de Administrador.",
"docs": "Ver documentos"
},
"dialog": {
"openBrowser": "Abra o navegador para autenticar.",
"waiting": "Aguardando autenticação no navegador...",
"authenticating": "Processando autenticação..."
},
"errors": {
"authFailed": "A autenticação falhou. Verifique suas credenciais e tente novamente.",
"timeout": "A autenticação expirou. Tente novamente."
},
"settings": {
"fingerprint": {
"label": "Impressão digital raiz (SHA-256)"
},
"provisioner": {
"label": "nome do provisionador OIDC"
},
"privateEndpoints": {
"label": "Hosts CA privados permitidos",
"description": "Para hosts privados, o fluxo de certificados da Step CA pode contatar: a própria CA e, se interna, seu provedor de identidade. Separe-os com vírgulas."
}
}
}
@@ -0,0 +1,31 @@
{
"hosts": {
"filterAuthStepca": "Etapa CA"
},
"editor": {
"label": "Etapa CA",
"description": "Faça login através do seu fornecedor de identidade e permita que a sua Autoridade Certificadora (CA) Step emita um certificado SSH de curta duração para este host. O URL da AC, a impressão digital da raiz e o provisionador OIDC são definidos por um administrador em Definições de Administrador.",
"docs": "Ver documentação"
},
"dialog": {
"openBrowser": "Abrir navegador para autenticar",
"waiting": "A aguardar autenticação no navegador...",
"authenticating": "A processar autenticação..."
},
"errors": {
"authFailed": "A autenticação falhou. Verifique as suas credenciais e tente novamente.",
"timeout": "A autenticação expirou. Tente novamente."
},
"settings": {
"fingerprint": {
"label": "Impressão digital de raiz (SHA-256)"
},
"provisioner": {
"label": "nome do provisionador OIDC"
},
"privateEndpoints": {
"label": "Hosts CA privados permitidos",
"description": "Para hosts privados, o fluxo de certificados da Step CA pode contactar: a própria CA e, se for interna, o seu fornecedor de identidade. Separe-os com vírgulas."
}
}
}
@@ -0,0 +1,31 @@
{
"hosts": {
"filterAuthStepca": "Pasul CA"
},
"editor": {
"label": "Pasul CA",
"description": "Conectați-vă prin intermediul furnizorului de identitate și permiteți autorității de certificare Step CA să emită un certificat SSH de scurtă durată pentru această gazdă. Adresa URL a autorității de certificare, amprenta rădăcină și furnizorul OIDC sunt setate de un administrator în Setări administrator.",
"docs": "Vizualizați documentele"
},
"dialog": {
"openBrowser": "Deschideți browserul pentru autentificare",
"waiting": "Se așteaptă autentificarea în browser...",
"authenticating": "Se procesează autentificarea..."
},
"errors": {
"authFailed": "Autentificarea a eșuat. Verificați acreditările și încercați din nou.",
"timeout": "Autentificarea a expirat. Vă rugăm să încercați din nou."
},
"settings": {
"fingerprint": {
"label": "Amprentă digitală rădăcină (SHA-256)"
},
"provisioner": {
"label": "Numele furnizorului OIDC"
},
"privateEndpoints": {
"label": "Gazde private permise de Step CA",
"description": "Gazde private pe care fluxul de certificate Step CA le poate contacta: CA-ul în sine și, dacă este intern, furnizorul dvs. de identitate. Separați-le prin virgule."
}
}
}
@@ -0,0 +1,31 @@
{
"hosts": {
"filterAuthStepca": "Шаг CA"
},
"editor": {
"label": "Шаг CA",
"description": "Войдите в систему через своего поставщика идентификации и позвольте вашему центру сертификации Step CA выдать краткосрочный SSH-сертификат для этого хоста. URL-адрес центра сертификации, отпечаток корневого сертификата и средство подготовки OIDC задаются администратором в разделе «Настройки администратора».",
"docs": "Документация"
},
"dialog": {
"openBrowser": "Открыть браузер для аутентификации",
"waiting": "Ожидание аутентификации в браузере...",
"authenticating": "Обработка аутентификации..."
},
"errors": {
"authFailed": "Аутентификация не удалась. Проверьте учётные данные и попробуйте снова.",
"timeout": "Время аутентификации истекло. Попробуйте снова."
},
"settings": {
"fingerprint": {
"label": "Отпечаток корневого ключа (SHA-256)"
},
"provisioner": {
"label": "имя поставщика OIDC"
},
"privateEndpoints": {
"label": "Разрешены частные хосты Step CA",
"description": "Частные хосты, для которых выполняется процедура сертификации Step CA, могут связаться с: самим центром сертификации и, если он внутренний, с вашим поставщиком идентификации. Разделяйте их запятыми."
}
}
}
@@ -0,0 +1,31 @@
{
"hosts": {
"filterAuthStepca": "Корак CA"
},
"editor": {
"label": "Корак CA",
"description": "Пријавите се преко свог добављача идентитета и дозволите свом Step CA да изда краткотрајни SSH сертификат за овај хост. CA URL, root отисак прста и OIDC provisioner подешава администратор у подешавањима администратора.",
"docs": "Прикажи документе"
},
"dialog": {
"openBrowser": "Отворите прегледач за аутентификацију",
"waiting": "Чекање аутентификације у прегледачу...",
"authenticating": "Обрада аутентификације..."
},
"errors": {
"authFailed": "Аутентификација није успела. Проверите своје акредитиве и покушајте поново.",
"timeout": "Временско ограничење за аутентификацију је истекло. Молимо покушајте поново."
},
"settings": {
"fingerprint": {
"label": "Отисак коренског идентификатора (SHA-256)"
},
"provisioner": {
"label": "Име OIDC провизора"
},
"privateEndpoints": {
"label": "Дозвољени приватни хостови Step CA",
"description": "Приватни хостови које ток сертификата Step CA може контактирати: сам CA и, ако је интерно, ваш добављач идентитета. Раздвојите их зарезима."
}
}
}
@@ -0,0 +1,31 @@
{
"hosts": {
"filterAuthStepca": "Steg CA"
},
"editor": {
"label": "Steg CA",
"description": "Logga in via din identitetsleverantör och låt din Step CA utfärda ett kortlivat SSH-certifikat för den här värden. CA-URL:en, rotfingeravtrycket och OIDC-provisionern ställs in av en administratör under administratörsinställningar.",
"docs": "Visa dokument"
},
"dialog": {
"openBrowser": "Öppna webbläsaren för att autentisera",
"waiting": "Väntar på autentisering i webbläsaren...",
"authenticating": "Bearbetar autentisering..."
},
"errors": {
"authFailed": "Autentiseringen misslyckades. Kontrollera dina inloggningsuppgifter och försök igen.",
"timeout": "Autentiseringen har gått ut. Försök igen."
},
"settings": {
"fingerprint": {
"label": "Rotfingeravtryck (SHA-256)"
},
"provisioner": {
"label": "OIDC-provisioneringsnamn"
},
"privateEndpoints": {
"label": "Tillåtna privata Step CA-värdar",
"description": "Privata värdar som Step CA-certifikatflödet kan kontakta: själva CA:n och, om den är intern, din identitetsleverantör. Separera dem med kommatecken."
}
}
}
@@ -0,0 +1,31 @@
{
"hosts": {
"filterAuthStepca": "ขั้นตอน CA"
},
"editor": {
"label": "ขั้นตอน CA",
"description": "ลงชื่อเข้าใช้ผ่านผู้ให้บริการยืนยันตัวตนของคุณ และให้ Step CA ออกใบรับรอง SSH ที่มีอายุสั้นสำหรับโฮสต์นี้ URL ของ CA, ลายนิ้วมือรูท และตัวจัดเตรียม OIDC จะถูกตั้งค่าโดยผู้ดูแลระบบภายใต้การตั้งค่าผู้ดูแลระบบ",
"docs": "ดูเอกสาร"
},
"dialog": {
"openBrowser": "เปิดเบราว์เซอร์เพื่อยืนยันตัวตน",
"waiting": "กำลังรอการยืนยันตัวตนในเบราว์เซอร์...",
"authenticating": "กำลังตรวจสอบสิทธิ์..."
},
"errors": {
"authFailed": "การตรวจสอบสิทธิ์ล้มเหลว โปรดตรวจสอบข้อมูลประจำตัวของคุณและลองอีกครั้ง",
"timeout": "การตรวจสอบสิทธิ์หมดเวลา โปรดลองอีกครั้ง"
},
"settings": {
"fingerprint": {
"label": "ลายนิ้วมือราก (SHA-256)"
},
"provisioner": {
"label": "ชื่อผู้จัดเตรียม OIDC"
},
"privateEndpoints": {
"label": "อนุญาตให้โฮสต์ Step CA ส่วนตัว",
"description": "สำหรับโฮสต์ส่วนตัว กระบวนการขอใบรับรอง Step CA อาจติดต่อกับ: CA เอง และหากเป็นโฮสต์ภายใน ก็อาจติดต่อกับผู้ให้บริการยืนยันตัวตนของคุณด้วย คั่นด้วยเครื่องหมายจุลภาค"
}
}
}
@@ -0,0 +1,31 @@
{
"hosts": {
"filterAuthStepca": "Adım CA"
},
"editor": {
"label": "Adım CA",
"description": "Kimlik sağlayıcınız aracılığıyla oturum açın ve Step CA'nızın bu sunucu için kısa süreli bir SSH sertifikası düzenlemesine izin verin. CA URL'si, kök parmak izi ve OIDC sağlayıcısı, yönetici tarafından Yönetici Ayarları altında belirlenir.",
"docs": "Dokümanları görüntüle"
},
"dialog": {
"openBrowser": "Kimlik Doğrulaması için Tarayıcı Aç",
"waiting": "Tarayıcıda kimlik doğrulaması bekleniyor...",
"authenticating": "Kimlik doğrulaması işleniyor..."
},
"errors": {
"authFailed": "Kimlik doğrulaması başarısız oldu. Lütfen kimlik bilgilerinizi kontrol edip tekrar deneyin.",
"timeout": "Kimlik doğrulaması zaman aşımına uğradı. Lütfen tekrar deneyin."
},
"settings": {
"fingerprint": {
"label": "Kök parmak izi (SHA-256)"
},
"provisioner": {
"label": "OIDC tedarikçi adı"
},
"privateEndpoints": {
"label": "Özel Step CA sunucularına izin verildi.",
"description": "Özel sunucular, Step CA sertifika akışı kapsamında şu kuruluşlarla iletişime geçebilir: CA'nın kendisi ve (dahili ise) kimlik sağlayıcınız. Bunları virgülle ayırın."
}
}
}
@@ -0,0 +1,31 @@
{
"hosts": {
"filterAuthStepca": "Крок CA"
},
"editor": {
"label": "Крок CA",
"description": "Увійдіть через свого постачальника ідентифікаційних даних і дозвольте вашому центру сертифікації Step CA видати короткочасний SSH-сертифікат для цього хоста. URL-адресу центру сертифікації, кореневий відбиток і OIDC-провайдер встановлює адміністратор у розділі «Налаштування адміністратора».",
"docs": "Переглянути документи"
},
"dialog": {
"openBrowser": "Відкрийте браузер для автентифікації",
"waiting": "Очікування автентифікації у браузері...",
"authenticating": "Обробка автентифікації..."
},
"errors": {
"authFailed": "Помилка автентифікації. Перевірте свої облікові дані та спробуйте ще раз.",
"timeout": "Час очікування автентифікації минув. Спробуйте ще раз."
},
"settings": {
"fingerprint": {
"label": "Кореневий відбиток (SHA-256)"
},
"provisioner": {
"label": "Ім'я постачальника OIDC"
},
"privateEndpoints": {
"label": "Дозволені приватні хости Step CA",
"description": "Приватні хости, з якими може зв’язатися потік сертифікатів Step CA: сам CA та, якщо внутрішній, ваш постачальник ідентифікаційних даних. Розділяйте їх комами."
}
}
}
@@ -0,0 +1,31 @@
{
"hosts": {
"filterAuthStepca": "Bước CA"
},
"editor": {
"label": "Bước CA",
"description": "Đăng nhập thông qua nhà cung cấp danh tính của bạn và cho phép Step CA cấp chứng chỉ SSH có thời hạn ngắn cho máy chủ này. URL CA, dấu vân tay gốc và trình cung cấp OIDC được quản trị viên thiết lập trong Cài đặt quản trị.",
"docs": "Xem tài liệu"
},
"dialog": {
"openBrowser": "Mở trình duyệt để xác thực",
"waiting": "Đang chờ xác thực trên trình duyệt...",
"authenticating": "Đang xử lý xác thực..."
},
"errors": {
"authFailed": "Xác thực thất bại. Vui lòng kiểm tra thông tin đăng nhập và thử lại.",
"timeout": "Xác thực đã hết thời gian chờ. Vui lòng thử lại."
},
"settings": {
"fingerprint": {
"label": "Dấu vân tay gốc (SHA-256)"
},
"provisioner": {
"label": "Tên trình cung cấp OIDC"
},
"privateEndpoints": {
"label": "Cho phép máy chủ Step CA riêng tư",
"description": "Các máy chủ riêng mà quy trình chứng chỉ CA từng bước có thể liên hệ: chính CA và, nếu là máy chủ nội bộ, nhà cung cấp danh tính của bạn. Phân tách chúng bằng dấu phẩy."
}
}
}
@@ -0,0 +1,31 @@
{
"hosts": {
"filterAuthStepca": "步骤 CA"
},
"editor": {
"label": "步骤 CA",
"description": "通过您的身份提供商登录,并让您的 Step CA 为此主机颁发一个短期 SSH 证书。CA URL、根指纹和 OIDC 配置器由管理员在“管理设置”中设置。",
"docs": "查看文档"
},
"dialog": {
"openBrowser": "打开浏览器进行认证",
"waiting": "等待浏览器认证...",
"authenticating": "正在处理认证..."
},
"errors": {
"authFailed": "认证失败,请检查凭据并重试。",
"timeout": "认证超时,请重试。"
},
"settings": {
"fingerprint": {
"label": "根指纹(SHA-256)"
},
"provisioner": {
"label": "OIDC配置器名称"
},
"privateEndpoints": {
"label": "允许的私有步骤 CA 主机",
"description": "私有主机在执行步骤 CA 证书流程时可能会联系:CA 本身,以及(如果是内部)您的身份提供商。请用逗号分隔它们。"
}
}
}
@@ -0,0 +1,31 @@
{
"hosts": {
"filterAuthStepca": "步驟 CA"
},
"editor": {
"label": "步驟 CA",
"description": "透過您的身分提供者登錄,並讓您的 Step CA 為此主機頒發短期 SSH 憑證。 CA URL、根指紋和 OIDC 配置器由管理員在「管理設定」中設定。",
"docs": "檢視文件"
},
"dialog": {
"openBrowser": "開啟瀏覽器以進行驗證",
"waiting": "正在等待瀏覽器中的驗證…",
"authenticating": "正在處理驗證…"
},
"errors": {
"authFailed": "驗證失敗。請檢查您的憑證並再試一次。",
"timeout": "驗證逾時。請再試一次。"
},
"settings": {
"fingerprint": {
"label": "根指紋(SHA-256)"
},
"provisioner": {
"label": "OIDC配置器名稱"
},
"privateEndpoints": {
"label": "允許的私有步驟 CA 主機",
"description": "私有主機在執行步驟 CA 憑證流程時可能會聯絡:CA 本身,以及(如果是內部)您的身分提供者。請用逗號分隔它們。"
}
}
}
+77
View File
@@ -0,0 +1,77 @@
{
"id": "step-ca",
"name": "Step CA",
"version": "1.0.0",
"description": "Sign in to hosts with short-lived SSH certificates from a smallstep step-ca server, using its OIDC provisioner in the browser. Termix calls the CA's HTTPS API directly and never runs the step binary.",
"author": {
"name": "Termix"
},
"license": "MIT",
"repository": "https://github.com/Termix-SSH/Termix",
"category": "Access & Security",
"icon": "ShieldCheck",
"engine": {
"termix": ">=2.9.0",
"api": "1"
},
"capabilities": [
"db:own",
"secrets:own",
"network:outbound",
"network:serve",
"auth:provide",
"ui:surface"
],
"contributes": {
"auth": {
"sshAuthTypes": ["stepca"]
},
"settings": {
"admin": [
{
"key": "caUrl",
"type": "string",
"labelKey": "settings.caUrl.label",
"descriptionKey": "settings.caUrl.description",
"placeholderKey": "settings.caUrl.placeholder",
"default": ""
},
{
"key": "fingerprint",
"type": "string",
"labelKey": "settings.fingerprint.label",
"descriptionKey": "settings.fingerprint.description",
"default": ""
},
{
"key": "provisioner",
"type": "string",
"labelKey": "settings.provisioner.label",
"descriptionKey": "settings.provisioner.description",
"default": ""
},
{
"key": "privateEndpoints",
"type": "textarea",
"labelKey": "settings.privateEndpoints.label",
"descriptionKey": "settings.privateEndpoints.description",
"default": ""
},
{
"key": "redirectUri",
"type": "custom",
"component": "redirectUri"
},
{
"key": "legacyCallback",
"type": "boolean",
"labelKey": "settings.legacyCallback.label",
"descriptionKey": "settings.legacyCallback.description",
"default": false
}
]
}
},
"locales": "locales",
"platforms": ["linux", "win32", "darwin"]
}
@@ -0,0 +1,16 @@
-- step-ca 0001: create_certs
-- Generated by termix-plugin migrations. Review before committing.
CREATE TABLE IF NOT EXISTS `p_step_ca_certs` (
`id` int AUTO_INCREMENT PRIMARY KEY,
`user_id` varchar(255) NOT NULL,
`host_id` int NOT NULL,
`ssh_cert` text NOT NULL,
`private_key` text NOT NULL,
`email` text,
`created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
`expires_at` varchar(255) NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
FOREIGN KEY (`host_id`) REFERENCES `ssh_data` (`id`) ON DELETE CASCADE
);
CREATE UNIQUE INDEX `idx_step_ca_certs_user_host` ON `p_step_ca_certs` (`user_id`, `host_id`);
@@ -0,0 +1,16 @@
-- step-ca 0001: create_certs
-- Generated by termix-plugin migrations. Review before committing.
CREATE TABLE IF NOT EXISTS "p_step_ca_certs" (
"id" serial PRIMARY KEY,
"user_id" varchar(255) NOT NULL,
"host_id" integer NOT NULL,
"ssh_cert" text NOT NULL,
"private_key" text NOT NULL,
"email" text,
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expires_at" varchar(255) NOT NULL,
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE,
FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS "idx_step_ca_certs_user_host" ON "p_step_ca_certs" ("user_id", "host_id");
+51
View File
@@ -0,0 +1,51 @@
{
"version": 1,
"tables": {
"certs": {
"columns": {
"id": {
"type": "id",
"primaryKey": true,
"notNull": true
},
"userId": {
"type": "refUser",
"length": 255,
"notNull": true
},
"hostId": {
"type": "refHost",
"notNull": true
},
"sshCert": {
"type": "text",
"notNull": true
},
"privateKey": {
"type": "text",
"notNull": true
},
"email": {
"type": "text"
},
"createdAt": {
"type": "text",
"notNull": true,
"defaultNow": true
},
"expiresAt": {
"type": "varchar",
"length": 255,
"notNull": true
}
},
"indexes": [
{
"name": "idx_step_ca_certs_user_host",
"columns": ["userId", "hostId"],
"unique": true
}
]
}
}
}
@@ -0,0 +1,16 @@
-- step-ca 0001: create_certs
-- Generated by termix-plugin migrations. Review before committing.
CREATE TABLE IF NOT EXISTS "p_step_ca_certs" (
"id" integer PRIMARY KEY AUTOINCREMENT,
"user_id" text NOT NULL,
"host_id" integer NOT NULL,
"ssh_cert" text NOT NULL,
"private_key" text NOT NULL,
"email" text,
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expires_at" text NOT NULL,
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE,
FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS "idx_step_ca_certs_user_host" ON "p_step_ca_certs" ("user_id", "host_id");
+18
View File
@@ -0,0 +1,18 @@
{
"name": "@termix-plugin/step-ca",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -p tsconfig.json",
"validate": "termix-plugin validate"
},
"dependencies": {
"redis": "^6.2.1"
},
"devDependencies": {
"@termix/plugin-sdk": "*"
}
}
+378
View File
@@ -0,0 +1,378 @@
import { randomBytes } from "node:crypto";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import type { CertStore } from "./cert-store.js";
import type { CallbackQuery, CallbackResult, Runtime } from "./runtime.js";
import {
buildAuthorizationUrl,
createPkce,
decodeJwtClaims,
discoverOidcEndpoints,
exchangeCodeForIdToken,
fetchRootCertificate,
findOidcProvisioner,
generateSshKeyPair,
parseSshCertificate,
signSshCertificate,
type StepCaTarget,
} from "./client.js";
/** The redirect URI 2.8 registered with identity providers. */
export const LEGACY_CALLBACK_PATH = "/host/step-ca-callback";
export const CALLBACK_PATH = "/plugin-api/step-ca/callback";
const AUTH_TIMEOUT_MS = 5 * 60 * 1000;
const REMOTE_CALLBACK_WAIT_MS = 30_000;
const REMOTE_POLL_MS = 250;
/** 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;
username: string;
socket: SignInSocket;
requestOrigin: string;
}
interface Session {
state: string;
userId: string;
hostId: number;
username: string;
socket: SignInSocket;
target: StepCaTarget;
rootPem: string;
clientId: string;
clientSecret?: string;
tokenEndpoint: string;
redirectUri: string;
codeVerifier: string;
nonce: string;
keyPair: { publicKeyLine: string; privateKeyPem: string };
timeout: ReturnType<typeof setTimeout>;
commandPoll: ReturnType<typeof setInterval> | null;
processing: boolean;
completed: boolean;
}
export interface StepCaSettings {
caUrl: string;
fingerprint: string;
provisioner: string;
privateEndpoints: string[];
}
/** Comma or newline separated hosts, lower-cased. */
export function parseHostList(raw: unknown): string[] {
if (typeof raw !== "string") return [];
return raw
.split(/[\s,]+/)
.map((entry) => entry.trim().toLowerCase())
.filter(Boolean);
}
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>;
/**
* Step CA sign-ins in flight: the OIDC authorization-code flow against the
* CA's provisioner, then the CA signs a fresh key. Created per activation.
*/
export function createAuthSessions(
ctx: PluginContext,
deps: { certs: CertStore; runtime: Runtime },
) {
const sessions = new Map<string, Session>();
async function readSettings(): Promise<StepCaSettings | null> {
const [caUrl, fingerprint, provisioner, privateEndpoints] =
await Promise.all([
ctx.settings.get<string>("caUrl"),
ctx.settings.get<string>("fingerprint"),
ctx.settings.get<string>("provisioner"),
ctx.settings.get<string>("privateEndpoints"),
]);
if (!caUrl?.trim() || !fingerprint?.trim() || !provisioner?.trim()) {
return null;
}
return {
caUrl: caUrl.trim(),
fingerprint: fingerprint.trim(),
provisioner: provisioner.trim(),
privateEndpoints: parseHostList(privateEndpoints),
};
}
async function callbackPath(): Promise<string> {
const legacy = await ctx.settings.get<boolean>("legacyCallback");
return legacy ? LEGACY_CALLBACK_PATH : CALLBACK_PATH;
}
function end(session: Session, removeRuntime = true): void {
clearTimeout(session.timeout);
if (session.commandPoll) clearInterval(session.commandPoll);
sessions.delete(session.state);
if (removeRuntime) void deps.runtime.remove(session.state);
}
async function finish(
session: Session,
query: CallbackQuery,
removeRuntime = true,
): Promise<CallbackResult> {
if (query.error || !query.code) {
const message =
query.error_description || query.error || "Sign-in failed";
send(session.socket, {
type: "stepca_error",
requestId: session.state,
error: `Step CA: ${message}`,
});
end(session, removeRuntime);
return { ok: false, message };
}
try {
send(session.socket, {
type: "stepca_status",
requestId: session.state,
stage: "authenticating",
});
const idToken = await exchangeCodeForIdToken({
fetch: ctx.fetch,
tokenEndpoint: session.tokenEndpoint,
clientId: session.clientId,
clientSecret: session.clientSecret,
code: query.code,
redirectUri: session.redirectUri,
codeVerifier: session.codeVerifier,
allowedPrivateHosts: session.target.allowedPrivateHosts,
});
const claims = decodeJwtClaims(idToken);
if (claims.nonce !== session.nonce) {
throw new Error("The identity token does not match this sign-in");
}
const email = typeof claims.email === "string" ? claims.email : undefined;
const certificate = await signSshCertificate(
session.target,
session.rootPem,
{
publicKeyLine: session.keyPair.publicKeyLine,
ott: idToken,
principals: [session.username],
keyId: email ?? session.username,
},
);
const info = parseSshCertificate(certificate);
if (info.publicKeyLine !== session.keyPair.publicKeyLine) {
throw new Error("The CA returned a certificate for a different key");
}
if (!info.principals.includes(session.username)) {
throw new Error(
"The CA certificate does not include the host username",
);
}
const now = Date.now();
if (
info.validBefore.getTime() <= now ||
info.validAfter.getTime() > now + 60_000
) {
throw new Error(
"The CA returned a certificate outside its validity window",
);
}
await deps.certs.save(
session.userId,
session.hostId,
{ sshCert: certificate, privateKey: session.keyPair.privateKeyPem },
info.validBefore,
email,
);
session.completed = true;
send(session.socket, {
type: "stepca_completed",
requestId: session.state,
expiresAt: info.validBefore.toISOString(),
});
end(session, removeRuntime);
return { ok: true, message: "Signed in. You can close this window." };
} catch (error) {
const message = errorMessage(error);
ctx.log.error(`Step CA certificate issuance failed: ${message}`);
send(session.socket, {
type: "stepca_error",
requestId: session.state,
error: `Step CA: ${message}`,
});
end(session, removeRuntime);
return { ok: false, message };
}
}
return {
async start(request: StartRequest): Promise<void> {
const settings = await readSettings();
if (!settings) {
send(request.socket, {
type: "stepca_config_error",
requestId: "",
error:
"Step CA is not configured. An administrator must set the CA URL, root fingerprint and OIDC provisioner in the Step CA plugin settings.",
});
return;
}
const state = randomBytes(24).toString("base64url");
try {
const target: StepCaTarget = {
fetch: ctx.fetch,
caUrl: settings.caUrl,
fingerprint: settings.fingerprint,
allowedPrivateHosts: settings.privateEndpoints,
};
const rootPem = await fetchRootCertificate(target);
const provisioner = await findOidcProvisioner(
target,
rootPem,
settings.provisioner,
);
const endpoints = await discoverOidcEndpoints(
ctx.fetch,
provisioner.configurationEndpoint,
target.allowedPrivateHosts,
);
const pkce = createPkce();
const nonce = randomBytes(16).toString("base64url");
const redirectUri = `${request.requestOrigin}${await callbackPath()}`;
const session: Session = {
state,
userId: request.userId,
hostId: request.hostId,
username: request.username,
socket: request.socket,
target,
rootPem,
clientId: provisioner.clientID,
clientSecret: provisioner.clientSecret,
tokenEndpoint: endpoints.tokenEndpoint,
redirectUri,
codeVerifier: pkce.verifier,
nonce,
keyPair: generateSshKeyPair(),
processing: false,
completed: false,
commandPoll: null,
timeout: setTimeout(() => {
const current = sessions.get(state);
if (!current || current.completed || current.processing) return;
send(request.socket, { type: "stepca_timeout", requestId: state });
end(current);
}, AUTH_TIMEOUT_MS),
};
sessions.set(state, session);
await deps.runtime.register(state);
// Another instance may receive the callback and queue it for us.
session.commandPoll = setInterval(() => {
if (session.processing || session.completed) return;
void deps.runtime.takeCommand(state).then(async (query) => {
if (!query || session.processing || session.completed) return;
session.processing = true;
const result = await finish(session, query, false);
await deps.runtime.complete(state, result);
});
}, REMOTE_POLL_MS);
session.commandPoll.unref?.();
request.socket.on("close", () => {
const current = sessions.get(state);
if (current && !current.completed) end(current);
});
send(request.socket, {
type: "stepca_status",
requestId: state,
stage: "chooser",
url: buildAuthorizationUrl({
authorizationEndpoint: endpoints.authorizationEndpoint,
clientId: provisioner.clientID,
redirectUri,
state,
nonce,
codeChallenge: pkce.challenge,
}),
});
} catch (error) {
ctx.log.error(
`Failed to start Step CA authentication: ${errorMessage(error)}`,
);
send(request.socket, {
type: "stepca_error",
requestId: state,
error: `Step CA: ${errorMessage(error)}`,
});
}
},
cancel(requestId: string): boolean {
const session = sessions.get(requestId);
if (!session) return false;
end(session);
return true;
},
/**
* The identity provider's redirect: exchanges the code, has the CA sign
* the key and stores the certificate. A sign-in another instance started
* is handed to it through the runtime.
*/
async complete(query: CallbackQuery): Promise<CallbackResult> {
const session = query.state ? sessions.get(query.state) : undefined;
if (!session) {
if (!query.state || !(await deps.runtime.submit(query.state, query))) {
return {
ok: false,
message: "This sign-in request is no longer active.",
};
}
const deadline = Date.now() + REMOTE_CALLBACK_WAIT_MS;
while (Date.now() < deadline) {
const result = await deps.runtime.takeResult(query.state);
if (result) return result;
await new Promise((resolve) => setTimeout(resolve, REMOTE_POLL_MS));
}
return {
ok: false,
message: "The Termix instance handling this sign-in did not respond.",
};
}
if (session.processing || session.completed) {
return { ok: false, message: "This sign-in request was already used." };
}
session.processing = true;
return finish(session, query);
},
closeAll(): void {
for (const session of [...sessions.values()]) end(session);
},
};
}
+96
View File
@@ -0,0 +1,96 @@
import { and, eq } from "drizzle-orm";
import type { PluginContext } from "@termix/plugin-sdk/backend";
/* eslint-disable @typescript-eslint/no-explicit-any */
// ctx.db.define hands the table back untyped and the drizzle handle is the
// server's own, so both are typed at this module's edge.
type Table = any;
type Drizzle = any;
/* eslint-enable @typescript-eslint/no-explicit-any */
export interface IssuedCertificate {
sshCert: string;
privateKey: string;
}
export type CertStore = ReturnType<typeof createCertStore>;
export function createCertStore(ctx: PluginContext, table: Table) {
const client = () => ctx.db.client<Drizzle>();
const byUserAndHost = (userId: string, hostId: number) =>
and(eq(table.userId, userId), eq(table.hostId, hostId));
async function find(userId: string, hostId: number) {
const rows = await (
await client()
)
.select()
.from(table)
.where(byUserAndHost(userId, hostId))
.limit(1);
return rows[0] ?? null;
}
async function remove(userId: string, hostId: number): Promise<boolean> {
if (!(await find(userId, hostId))) return false;
await (await client()).delete(table).where(byUserAndHost(userId, hostId));
await ctx.db.persist();
return true;
}
return {
async save(
userId: string,
hostId: number,
certificate: IssuedCertificate,
expiresAt: Date,
email?: string,
): Promise<void> {
const values = {
sshCert: await ctx.secrets.seal(certificate.sshCert),
privateKey: await ctx.secrets.seal(certificate.privateKey),
email: email ?? null,
createdAt: new Date().toISOString(),
expiresAt: expiresAt.toISOString(),
};
if (await find(userId, hostId)) {
await (
await client()
)
.update(table)
.set(values)
.where(byUserAndHost(userId, hostId));
} else {
await (
await client()
)
.insert(table)
.values({ userId, hostId, ...values });
}
await ctx.db.persist();
},
/** The usable certificate, or null. Expired or unreadable rows are deleted. */
async get(
userId: string,
hostId: number,
): Promise<IssuedCertificate | null> {
const row = await find(userId, hostId);
if (!row) return null;
const expiresAt = new Date(row.expiresAt).getTime();
if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) {
await remove(userId, hostId);
return null;
}
const sshCert = await ctx.secrets.unseal(row.sshCert);
const privateKey = await ctx.secrets.unseal(row.privateKey);
if (!sshCert || !privateKey) {
await remove(userId, hostId);
return null;
}
return { sshCert, privateKey };
},
remove,
};
}
@@ -1,5 +1,5 @@
import crypto from "crypto"; import crypto from "node:crypto";
import { safeOutboundFetch } from "./safe-outbound-fetch.js"; import type { PluginFetch } from "@termix/plugin-sdk/backend";
/** /**
* A minimal client for smallstep's step-ca SSH user-certificate flow, done * A minimal client for smallstep's step-ca SSH user-certificate flow, done
@@ -12,6 +12,8 @@ import { safeOutboundFetch } from "./safe-outbound-fetch.js";
*/ */
export interface StepCaTarget { export interface StepCaTarget {
/** ctx.fetch: core's SSRF guard. */
fetch: PluginFetch;
caUrl: string; caUrl: string;
fingerprint: string; fingerprint: string;
/** Hosts the SSRF guard may reach even when they resolve to private ranges. */ /** Hosts the SSRF guard may reach even when they resolve to private ranges. */
@@ -87,11 +89,13 @@ export async function fetchRootCertificate(
target: StepCaTarget, target: StepCaTarget,
): Promise<string> { ): Promise<string> {
const fingerprint = normalizeFingerprint(target.fingerprint); const fingerprint = normalizeFingerprint(target.fingerprint);
const response = await safeOutboundFetch( const response = await target.fetch(
`${normalizeCaUrl(target.caUrl)}/root/${fingerprint}`, `${normalizeCaUrl(target.caUrl)}/root/${fingerprint}`,
{ method: "GET", signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }, {
target.allowedPrivateHosts, timeoutMs: FETCH_TIMEOUT_MS,
{ rejectUnauthorized: false }, allowPrivateHosts: target.allowedPrivateHosts,
tls: { rejectUnauthorized: false },
},
); );
const { ca } = await readJson<{ ca?: string }>( const { ca } = await readJson<{ ca?: string }>(
response, response,
@@ -111,11 +115,13 @@ export async function findOidcProvisioner(
const base = normalizeCaUrl(target.caUrl); const base = normalizeCaUrl(target.caUrl);
let cursor = ""; let cursor = "";
for (let page = 0; page < 20; page++) { for (let page = 0; page < 20; page++) {
const response = await safeOutboundFetch( const response = await target.fetch(
`${base}/provisioners?limit=100${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`, `${base}/provisioners?limit=100${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`,
{ method: "GET", signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }, {
target.allowedPrivateHosts, timeoutMs: FETCH_TIMEOUT_MS,
{ ca: rootPem }, allowPrivateHosts: target.allowedPrivateHosts,
tls: { ca: rootPem },
},
); );
const body = await readJson<{ const body = await readJson<{
provisioners?: Array<Record<string, unknown>>; provisioners?: Array<Record<string, unknown>>;
@@ -148,14 +154,14 @@ export async function findOidcProvisioner(
} }
export async function discoverOidcEndpoints( export async function discoverOidcEndpoints(
fetch: PluginFetch,
configurationEndpoint: string, configurationEndpoint: string,
allowedPrivateHosts: readonly string[], allowedPrivateHosts: readonly string[],
): Promise<OidcEndpoints> { ): Promise<OidcEndpoints> {
const response = await safeOutboundFetch( const response = await fetch(configurationEndpoint, {
configurationEndpoint, timeoutMs: FETCH_TIMEOUT_MS,
{ method: "GET", signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }, allowPrivateHosts: allowedPrivateHosts,
allowedPrivateHosts, });
);
const doc = await readJson<{ const doc = await readJson<{
authorization_endpoint?: string; authorization_endpoint?: string;
token_endpoint?: string; token_endpoint?: string;
@@ -199,6 +205,7 @@ export function buildAuthorizationUrl(input: {
} }
export async function exchangeCodeForIdToken(input: { export async function exchangeCodeForIdToken(input: {
fetch: PluginFetch;
tokenEndpoint: string; tokenEndpoint: string;
clientId: string; clientId: string;
clientSecret?: string; clientSecret?: string;
@@ -215,16 +222,13 @@ export async function exchangeCodeForIdToken(input: {
code_verifier: input.codeVerifier, code_verifier: input.codeVerifier,
}); });
if (input.clientSecret) form.set("client_secret", input.clientSecret); if (input.clientSecret) form.set("client_secret", input.clientSecret);
const response = await safeOutboundFetch( const response = await input.fetch(input.tokenEndpoint, {
input.tokenEndpoint,
{
method: "POST", method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" }, headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: form.toString(), body: form.toString(),
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), timeoutMs: FETCH_TIMEOUT_MS,
}, allowPrivateHosts: input.allowedPrivateHosts,
input.allowedPrivateHosts, });
);
const body = await readJson<{ id_token?: string }>( const body = await readJson<{ id_token?: string }>(
response, response,
"OIDC token exchange", "OIDC token exchange",
@@ -272,7 +276,7 @@ export async function signSshCertificate(
): Promise<string> { ): Promise<string> {
const blob = input.publicKeyLine.trim().split(/\s+/)[1]; const blob = input.publicKeyLine.trim().split(/\s+/)[1];
if (!blob) throw new Error("Invalid public key line"); if (!blob) throw new Error("Invalid public key line");
const response = await safeOutboundFetch( const response = await target.fetch(
`${normalizeCaUrl(target.caUrl)}/1.0/ssh/sign`, `${normalizeCaUrl(target.caUrl)}/1.0/ssh/sign`,
{ {
method: "POST", method: "POST",
@@ -284,10 +288,10 @@ export async function signSshCertificate(
principals: input.principals, principals: input.principals,
keyID: input.keyId, keyID: input.keyId,
}), }),
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), timeoutMs: FETCH_TIMEOUT_MS,
allowPrivateHosts: target.allowedPrivateHosts,
tls: { ca: rootPem },
}, },
target.allowedPrivateHosts,
{ ca: rootPem },
); );
if (!response.ok) { if (!response.ok) {
let detail = ""; let detail = "";
+28
View File
@@ -0,0 +1,28 @@
import type { Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { certs as certsTable } from "./tables.js";
import { createCertStore } from "./cert-store.js";
import { createRuntime } from "./runtime.js";
import { createAuthSessions } from "./auth-session.js";
import { createStepCaProvider } from "./provider.js";
import { PUBLIC_PATHS, registerRoutes } from "./routes.js";
export async function activate(ctx: PluginContext) {
const table = await ctx.db.define(certsTable);
const certs = createCertStore(ctx, table);
const runtime = createRuntime(ctx);
const sessions = createAuthSessions(ctx, { certs, runtime });
ctx.disposables.add(async () => {
sessions.closeAll();
await runtime.close();
});
ctx.auth.registerSshAuthProvider(createStepCaProvider(ctx, certs, sessions));
registerRoutes(ctx.http.router<Router>({ public: PUBLIC_PATHS }), sessions);
}
export async function deactivate() {
// Everything above was registered through ctx and is disposed by core.
}
+88
View File
@@ -0,0 +1,88 @@
import type {
PluginContext,
PluginSshAuthProvider,
} from "@termix/plugin-sdk/backend";
import { applyCertificateAuth } from "@termix/plugin-sdk/ssh-certs";
import type { AuthSessions, SignInSocket } from "./auth-session.js";
import type { CertStore } from "./cert-store.js";
export const AUTH_TYPE = "stepca";
const REQUIRED_MESSAGE =
"Step CA authentication required. Please open a Terminal connection to this host first to complete browser-based authentication.";
const AUTH_FAILED_PATTERN = /All configured authentication methods failed/i;
/**
* Hosts with auth type "stepca" connect with the certificate the CA issued.
* Without one, the terminal is asked for a browser sign-in
* ("stepca_auth_required"), which startInteraction runs.
*/
export function createStepCaProvider(
ctx: PluginContext,
certs: CertStore,
sessions: AuthSessions,
): PluginSshAuthProvider {
return {
type: AUTH_TYPE,
labelKey: "hosts.filterAuthStepca",
needsUserInteraction: true,
supportsBackground: false,
interaction: AUTH_TYPE,
prepare: async (config, host, env) => {
const certificate = await certs.get(env.userId, env.hostId);
if (!certificate) {
env.log("info", "No valid certificate found, requesting sign-in");
return {
status: "interaction-required",
interaction: AUTH_TYPE,
message: REQUIRED_MESSAGE,
};
}
try {
await applyCertificateAuth(
config as never,
env.client as never,
{
privateKey: certificate.privateKey,
certificate: certificate.sshCert,
},
host.username,
);
} catch (error) {
return {
status: "error",
code: "failed",
message:
"Step CA authentication failed: " +
(error instanceof Error ? error.message : String(error)),
};
}
env.log("info", "Using cached SSH certificate");
return { status: "ready" };
},
onAuthFailed: (_host, env, context) => {
if (!AUTH_FAILED_PATTERN.test(context.error.message)) return undefined;
ctx.log.warn("Step CA authentication failed, forgetting the certificate");
void certs.remove(env.userId, env.hostId).catch(() => undefined);
return {
status: "interaction-required",
interaction: AUTH_TYPE,
message:
"Step CA authentication failed or expired. Please authenticate again.",
};
},
startInteraction: async (request) => {
await sessions.start({
userId: request.userId,
hostId: request.hostId,
username: request.host.username,
socket: request.socket as SignInSocket,
requestOrigin: request.requestOrigin,
});
},
cancelInteraction: async (request) => {
if (request.requestId) sessions.cancel(request.requestId);
},
};
}
+65
View File
@@ -0,0 +1,65 @@
import type { Request, Response, Router } from "express";
import type { AuthSessions } from "./auth-session.js";
/**
* The identity provider redirects here without a Termix session: the sign-in
* may finish in another browser, so it is matched by the OAuth state.
*/
export const PUBLIC_PATHS = ["/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 ? "Signed in" : "Sign-in failed"}</h1><p>${escapeHtml(message)}</p></main></body></html>`;
}
export function registerRoutes(router: Router, sessions: AuthSessions): void {
/**
* @openapi
* /plugin-api/step-ca/callback:
* get:
* summary: OIDC callback for a Step CA sign-in
* description: Public. The redirect URI registered with the identity provider behind the CA's OIDC provisioner. Exchanges the code, has the CA sign the key and tells the terminal to reconnect. The 2.8 URI /host/step-ca-callback redirects here.
* tags: [Step CA]
* parameters:
* - name: state
* in: query
* schema:
* type: string
* - name: code
* in: query
* schema:
* type: string
* responses:
* 200:
* description: Signed in; an HTML page the browser can close.
* 400:
* description: The sign-in failed or is no longer active.
*/
router.get("/callback", async (req: Request, res: Response) => {
const query = (name: string): string | undefined => {
const value = req.query[name];
return typeof value === "string" ? value : undefined;
};
const result = await sessions.complete({
state: query("state"),
code: query("code"),
error: query("error"),
error_description: query("error_description"),
});
res
.status(result.ok ? 200 : 400)
.type("html")
.send(resultPage(result.ok, result.message));
});
}
+164
View File
@@ -0,0 +1,164 @@
import { createClient } from "redis";
import type { PluginContext } from "@termix/plugin-sdk/backend";
export interface CallbackQuery {
state?: string;
code?: string;
error?: string;
error_description?: string;
}
export interface CallbackResult {
ok: boolean;
message: string;
}
const SESSION_TTL_SECONDS = 5 * 60;
const RESULT_TTL_SECONDS = 60;
const CONNECT_RETRY_MS = 15_000;
export type Runtime = ReturnType<typeof createRuntime>;
/**
* Hands an identity provider callback to the Termix instance that started
* the sign-in, through Redis when REDIS_URL is set. With one instance, or no
* Redis, every call is a no-op and the callback is handled locally.
*/
export function createRuntime(ctx: PluginContext) {
const prefix =
process.env.TERMIX_STEP_CA_REDIS_PREFIX?.trim() || "termix:step-ca";
let client: ReturnType<typeof createClient> | null = null;
let connecting: Promise<boolean> | null = null;
let nextConnectAttempt = 0;
const routeKey = (state: string) => `${prefix}:route:${state}`;
const commandKey = (state: string) => `${prefix}:command:${state}`;
const resultKey = (state: string) => `${prefix}:result:${state}`;
function logFailure(operation: string, error: unknown): void {
ctx.log.warn(
`Step CA Redis runtime unavailable (${operation}): ${error instanceof Error ? error.message : String(error)}`,
);
}
async function seal(value: unknown): Promise<string> {
return ctx.secrets.seal(JSON.stringify(value));
}
async function unseal<T>(raw: string): Promise<T | null> {
const plain = await ctx.secrets.unseal(raw);
if (!plain) return null;
try {
return JSON.parse(plain) as T;
} catch {
return null;
}
}
async function connect(url: string): Promise<boolean> {
try {
client = createClient({
url,
socket: { connectTimeout: 1500, reconnectStrategy: false },
});
client.on("error", (error) => logFailure("client", error));
await client.connect();
nextConnectAttempt = 0;
return true;
} catch (error) {
nextConnectAttempt = Date.now() + CONNECT_RETRY_MS;
logFailure("connect", error);
await client?.disconnect().catch(() => {});
client = null;
return false;
}
}
async function ready(): Promise<boolean> {
const url = process.env.REDIS_URL?.trim();
if (!url) return false;
if (client?.isReady) return true;
if (Date.now() < nextConnectAttempt) return false;
if (connecting) return connecting;
connecting = connect(url).finally(() => {
connecting = null;
});
return connecting;
}
return {
async register(state: string): Promise<void> {
if (!(await ready()) || !client) return;
await client
.set(routeKey(state), "active", { EX: SESSION_TTL_SECONDS })
.catch((error) => logFailure("register", error));
},
/** Queues a callback for the instance that owns the sign-in. */
async submit(state: string, query: CallbackQuery): Promise<boolean> {
if (!(await ready()) || !client) return false;
try {
if (!(await client.exists(routeKey(state)))) return false;
const stored = await client.set(commandKey(state), await seal(query), {
EX: SESSION_TTL_SECONDS,
NX: true,
});
return stored === "OK";
} catch (error) {
logFailure("submit", error);
return false;
}
},
async takeCommand(state: string): Promise<CallbackQuery | null> {
if (!(await ready()) || !client) return null;
try {
const raw = await client.getDel(commandKey(state));
return raw ? unseal<CallbackQuery>(raw.toString()) : null;
} catch (error) {
logFailure("take_command", error);
return null;
}
},
async complete(state: string, result: CallbackResult): Promise<void> {
if (!(await ready()) || !client) return;
try {
await client
.multi()
.set(resultKey(state), await seal(result), {
EX: RESULT_TTL_SECONDS,
})
.del(routeKey(state))
.del(commandKey(state))
.exec();
} catch (error) {
logFailure("complete", error);
}
},
async takeResult(state: string): Promise<CallbackResult | null> {
if (!(await ready()) || !client) return null;
try {
const raw = await client.getDel(resultKey(state));
return raw ? unseal<CallbackResult>(raw.toString()) : null;
} catch (error) {
logFailure("take_result", error);
return null;
}
},
async remove(state: string): Promise<void> {
if (!(await ready()) || !client) return;
await client
.del([routeKey(state), commandKey(state), resultKey(state)])
.catch((error) => logFailure("remove", error));
},
async close(): Promise<void> {
const current = client;
client = null;
if (current?.isOpen) await current.quit().catch(() => {});
},
};
}
+34
View File
@@ -0,0 +1,34 @@
import {
defineTable,
id,
refHost,
refUser,
text,
varchar,
} from "@termix/plugin-sdk/db";
/**
* One issued certificate per user and host. sshCert and privateKey are
* sealed with ctx.secrets.seal. 2.8 kept Step CA certificates in
* opkssh_tokens; those are left to expire.
*/
export const certs = defineTable(
"certs",
{
id: id(),
userId: refUser(),
hostId: refHost(),
sshCert: text().notNull(),
privateKey: text().notNull(),
email: text(),
createdAt: text().notNull().defaultNow(),
expiresAt: varchar().notNull(),
},
{
uniques: [
{ name: "idx_step_ca_certs_user_host", columns: ["userId", "hostId"] },
],
},
);
export const tables = [certs];
@@ -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/step-ca/callback";
const LEGACY_CALLBACK_PATH = "host/step-ca-callback";
/** The callback URI to register with the provisioner's identity provider. */
export function redirectUri(
legacy: boolean,
baseUri = document.baseURI,
): string {
return new URL(
legacy ? LEGACY_CALLBACK_PATH : CALLBACK_PATH,
baseUri,
).toString();
}
export function RedirectUriSetting({ values }: SettingsComponentProps) {
const { t } = useTranslation();
const uri = redirectUri(values.legacyCallback === true);
return (
<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="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,29 @@
import { useTranslation } from "@termix/plugin-sdk/frontend";
const DOCS_URL =
"https://smallstep.com/docs/step-ca/provisioners/#oauthoidc-single-sign-on";
/** The host editor's "stepca" auth method: nothing to fill in, just a pointer. */
export function StepCaAuthEditor() {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-2 border-t border-border pt-3">
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("editor.label")}
</span>
<a
href={DOCS_URL}
target="_blank"
rel="noreferrer"
className="text-[10px] text-accent-brand hover:underline"
>
{t("editor.docs")}
</a>
</div>
<p className="text-[10px] text-muted-foreground">
{t("editor.description")}
</p>
</div>
);
}
@@ -0,0 +1,102 @@
import { Shield, ExternalLink, Loader2, AlertCircle } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { Button } from "@termix/plugin-sdk/ui";
export type SignInStage = "chooser" | "waiting" | "authenticating" | "error";
interface StepCaDialogProps {
stage: SignInStage;
error?: string;
onCancel: () => void;
onOpenUrl: () => void;
backgroundColor?: string;
}
const actionButton =
"border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 rounded-none text-[10px] font-bold uppercase tracking-widest w-full flex items-center gap-2";
const footerButton =
"rounded-none text-[10px] font-bold uppercase tracking-widest";
/** The browser sign-in the terminal shows while Step CA waits. */
export function StepCaDialog({
stage,
error,
onCancel,
onOpenUrl,
backgroundColor,
}: StepCaDialogProps) {
const { t } = useTranslation();
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 animate-in fade-in zoom-in-95 duration-200">
<div className="p-4 border-b border-border">
<div className="flex items-center gap-2">
<Shield className="size-4 text-accent-brand" />
<h3 className="text-xs font-bold uppercase tracking-widest">
{t("dialog.title")}
</h3>
</div>
{stage === "chooser" && (
<p className="text-[10px] font-bold uppercase tracking-tight text-muted-foreground mt-1">
{t("dialog.description")}
</p>
)}
</div>
<div className="p-4 flex flex-col gap-4">
{stage === "chooser" && (
<Button
type="button"
variant="outline"
onClick={onOpenUrl}
className={actionButton}
>
<ExternalLink className="size-3.5" />
{t("dialog.openBrowser")}
</Button>
)}
{(stage === "waiting" || stage === "authenticating") && (
<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">
{stage === "waiting"
? t("dialog.waiting")
: t("dialog.authenticating")}
</p>
</div>
)}
{stage === "error" && error && (
<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" />
<div className="flex-1 min-w-0">
<p className="text-[10px] font-bold uppercase tracking-widest text-destructive">
{t("common.error")}
</p>
<p className="text-xs text-destructive/90 mt-1 whitespace-pre-wrap break-words">
{error}
</p>
</div>
</div>
)}
<div className="flex justify-end">
<Button
type="button"
variant="ghost"
onClick={onCancel}
className={footerButton}
>
{stage === "error" ? t("common.close") : t("common.cancel")}
</Button>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,187 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { StepCaDialog, type SignInStage } from "./StepCaDialog";
/** 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 {
authUrl: string;
requestId: string;
stage: SignInStage;
error?: string;
}
/** How long the sign-in dialog waits before giving up on the connection. */
const DIALOG_TIMEOUT_MS = 300_000;
/**
* Runs the Step CA browser sign-in for a terminal: asks the server to start
* it, opens the identity provider, and reconnects once the CA has issued the
* certificate.
*/
export function StepCaOverlay({
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 heldRef = useRef(false);
// A second "auth required" after a sign-in means the certificate did not work.
const failedRef = useRef(false);
useEffect(() => {
const clearTimer = () => {
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = null;
};
const hold = (held: boolean) => {
if (heldRef.current === held) return;
heldRef.current = held;
holdConnectTimeout(held);
};
const showError = (requestId: unknown, error: string) => {
clearTimer();
setDialog((current) =>
current
? { ...current, stage: "error", error }
: {
authUrl: "",
requestId: typeof requestId === "string" ? requestId : "",
stage: "error",
error,
},
);
};
const unsubscribe = subscribe((message) => {
switch (message.type) {
case "stepca_auth_required":
hold(true);
if (failedRef.current) {
clearTimer();
setDialog(null);
hold(false);
fail(t("errors.authFailed"));
} else {
failedRef.current = true;
send("stepca_start_auth", { hostId: message.hostId });
}
break;
case "stepca_status":
if (message.stage === "chooser") {
setDialog({
authUrl: typeof message.url === "string" ? message.url : "",
requestId:
typeof message.requestId === "string" ? message.requestId : "",
stage: "chooser",
});
clearTimer();
timerRef.current = setTimeout(() => {
setDialog(null);
hold(false);
disconnect();
}, DIALOG_TIMEOUT_MS);
} else {
setDialog((current) =>
current
? { ...current, stage: message.stage as SignInStage }
: null,
);
}
break;
case "stepca_completed":
clearTimer();
setDialog(null);
send("stepca_auth_completed", connectPayload());
break;
case "stepca_error":
failedRef.current = true;
showError(message.requestId, String(message.error ?? ""));
break;
case "stepca_timeout":
failedRef.current = true;
showError(message.requestId, t("errors.timeout"));
break;
case "stepca_config_error":
showError(message.requestId, 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;
if (dialog.requestId) {
send("stepca_cancel", { requestId: dialog.requestId });
}
setDialog(null);
if (heldRef.current) {
heldRef.current = false;
holdConnectTimeout(false);
}
};
return (
<StepCaDialog
stage={dialog.stage}
error={dialog.error}
onCancel={close}
onOpenUrl={() => {
if (!dialog.authUrl) return;
window.open(dialog.authUrl, "_blank");
setDialog((current) =>
current ? { ...current, stage: "waiting" } : null,
);
}}
backgroundColor={backgroundColor}
/>
);
}
+29
View File
@@ -0,0 +1,29 @@
import type { ComponentType } from "react";
import type { TermixApp } from "@termix/plugin-sdk/frontend";
import { StepCaAuthEditor } from "./StepCaAuthEditor";
import { StepCaOverlay } from "./StepCaOverlay";
import { RedirectUriSetting } from "./RedirectUriSetting";
export function activate(app: TermixApp): void {
app.registerSshAuthEditor({
authType: "stepca",
titleKey: "hosts.filterAuthStepca",
component: StepCaAuthEditor,
});
// The browser sign-in while a terminal connects to a Step CA host.
app.registerSlotContribution("terminal.overlay", {
actionId: "step-ca.signIn",
titleKey: "dialog.title",
kind: "component",
component: StepCaOverlay as unknown as ComponentType<
Record<string, unknown>
>,
});
app.registerSettingsComponent("redirectUri", RedirectUriSetting);
}
export function deactivate(): void {
// Registrations through app are disposed by core.
}
@@ -1,19 +1,19 @@
import crypto from "node:crypto";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import crypto from "crypto";
import { import {
buildAuthorizationUrl, buildAuthorizationUrl,
certificateFingerprint, certificateFingerprint,
createPkce, createPkce,
decodeJwtClaims, decodeJwtClaims,
fetchRootCertificate,
findOidcProvisioner,
generateSshKeyPair, generateSshKeyPair,
normalizeCaUrl, normalizeCaUrl,
normalizeFingerprint, normalizeFingerprint,
parseSshCertificate, parseSshCertificate,
} from "../../utils/step-ca-client.js"; } from "../../src/backend/client.js";
import { import { parseHostList } from "../../src/backend/auth-session.js";
generateCa, import { CA_URL, createFakeCa, createSshCa, fakeRoot } from "./fake-ca.js";
signUserCertificate,
} from "../../database/routes/ssh-certificate.js";
describe("step-ca client helpers", () => { describe("step-ca client helpers", () => {
it("normalizes the CA url and fingerprint the way step does", () => { it("normalizes the CA url and fingerprint the way step does", () => {
@@ -21,17 +21,14 @@ describe("step-ca client helpers", () => {
"https://ca.internal:9000", "https://ca.internal:9000",
); );
expect(() => normalizeCaUrl("http://ca.internal")).toThrow(/https/); expect(() => normalizeCaUrl("http://ca.internal")).toThrow(/https/);
const fp = "AB:cd".repeat(16).replace(/:/g, "") + ""; const fp = "AB:cd".repeat(16).replace(/:/g, "");
expect(normalizeFingerprint("AB:cd".repeat(16))).toBe(fp.toLowerCase()); expect(normalizeFingerprint("AB:cd".repeat(16))).toBe(fp.toLowerCase());
expect(() => normalizeFingerprint("abcd")).toThrow(/SHA-256/); expect(() => normalizeFingerprint("abcd")).toThrow(/SHA-256/);
}); });
it("fingerprints a PEM certificate by the sha256 of its DER", () => { it("fingerprints a PEM certificate by the sha256 of its DER", () => {
const der = crypto.randomBytes(64); const root = fakeRoot();
const pem = `-----BEGIN CERTIFICATE-----\n${der.toString("base64")}\n-----END CERTIFICATE-----\n`; expect(certificateFingerprint(root.pem)).toBe(root.fingerprint);
expect(certificateFingerprint(pem)).toBe(
crypto.createHash("sha256").update(der).digest("hex"),
);
}); });
it("builds a PKCE authorization request", () => { it("builds a PKCE authorization request", () => {
@@ -55,23 +52,19 @@ describe("step-ca client helpers", () => {
expect(url.searchParams.get("scope")).toContain("openid"); expect(url.searchParams.get("scope")).toContain("openid");
}); });
it("generates an ed25519 key whose public line a CA can certify, and reads the cert back", () => { it("generates an ed25519 key a CA can certify, and reads the cert back", () => {
const { publicKeyLine, privateKeyPem } = generateSshKeyPair(); const { publicKeyLine, privateKeyPem } = generateSshKeyPair();
expect(publicKeyLine).toMatch(/^ssh-ed25519 [A-Za-z0-9+/=]+$/); expect(publicKeyLine).toMatch(/^ssh-ed25519 [A-Za-z0-9+/=]+$/);
expect(privateKeyPem).toContain("BEGIN PRIVATE KEY"); expect(privateKeyPem).toContain("BEGIN PRIVATE KEY");
const ca = generateCa(); const cert = createSshCa().sign({
const cert = signUserCertificate({ publicKeyBlob: publicKeyLine.split(" ")[1],
userPublicKeyLine: publicKeyLine,
caPrivateKeyPem: ca.privateKeyPem,
caPublicKeyLine: ca.publicKeyLine,
keyId: "alice@example", keyId: "alice@example",
principals: ["alice", "ops"], principals: ["alice", "ops"],
validAfter: 1_700_000_000, validAfter: 1_700_000_000,
validBefore: 1_700_057_600, validBefore: 1_700_057_600,
}); });
expect(cert).not.toBeNull(); const info = parseSshCertificate(cert);
const info = parseSshCertificate(cert!);
expect(info).toMatchObject({ expect(info).toMatchObject({
keyType: "ssh-ed25519-cert-v01@openssh.com", keyType: "ssh-ed25519-cert-v01@openssh.com",
publicKeyLine, publicKeyLine,
@@ -87,6 +80,46 @@ describe("step-ca client helpers", () => {
parseSshCertificate("ssh-ed25519-cert-v01@openssh.com AAAA"), parseSshCertificate("ssh-ed25519-cert-v01@openssh.com AAAA"),
).toThrow(/certificate/); ).toThrow(/certificate/);
}); });
it("reads the private endpoint list with commas or new lines", () => {
expect(parseHostList("ca.internal, SSO.internal\nidp.lan")).toEqual([
"ca.internal",
"sso.internal",
"idp.lan",
]);
expect(parseHostList(undefined)).toEqual([]);
});
});
describe("talking to the CA", () => {
it("refuses a root whose fingerprint does not match", async () => {
const ca = createFakeCa({ nonce: () => "" });
await expect(
fetchRootCertificate({
fetch: ca.fetch,
caUrl: CA_URL,
fingerprint: "b".repeat(64),
allowedPrivateHosts: [],
}),
).rejects.toThrow();
});
it("finds the OIDC provisioner by name and passes the private hosts", async () => {
const ca = createFakeCa({ nonce: () => "" });
const target = {
fetch: ca.fetch,
caUrl: CA_URL,
fingerprint: ca.root.fingerprint,
allowedPrivateHosts: ["ca.test"],
};
const root = await fetchRootCertificate(target);
const provisioner = await findOidcProvisioner(target, root, "oidc");
expect(provisioner.clientID).toBe("termix");
expect(ca.calls.every((call) => call.init?.allowPrivateHosts)).toBe(true);
await expect(findOidcProvisioner(target, root, "jwk")).rejects.toThrow(
/not found/,
);
});
}); });
describe("decodeJwtClaims", () => { describe("decodeJwtClaims", () => {
+160
View File
@@ -0,0 +1,160 @@
import crypto from "node:crypto";
import type { PluginFetchInit } from "@termix/plugin-sdk/backend";
const CERT_TYPE = "ssh-ed25519-cert-v01@openssh.com";
function sshString(value: Buffer | string): Buffer {
const data = Buffer.isBuffer(value) ? value : Buffer.from(value, "utf8");
const len = Buffer.alloc(4);
len.writeUInt32BE(data.length, 0);
return Buffer.concat([len, data]);
}
function uint64(value: bigint): Buffer {
const out = Buffer.alloc(8);
out.writeBigUInt64BE(value, 0);
return out;
}
function uint32(value: number): Buffer {
const out = Buffer.alloc(4);
out.writeUInt32BE(value, 0);
return out;
}
/** An ed25519 CA that signs OpenSSH user certificates, like step-ca does. */
export function createSshCa() {
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
const raw = Buffer.from(
(publicKey.export({ format: "jwk" }) as { x: string }).x,
"base64url",
);
const caBlob = Buffer.concat([sshString("ssh-ed25519"), sshString(raw)]);
return {
sign(input: {
publicKeyBlob: string;
keyId: string;
principals: string[];
validAfter: number;
validBefore: number;
}): string {
const userBlob = Buffer.from(input.publicKeyBlob, "base64");
// ssh-ed25519 blob: string type, string pk
const pk = userBlob.subarray(4 + 11 + 4);
const body = Buffer.concat([
sshString(CERT_TYPE),
sshString(crypto.randomBytes(32)),
sshString(pk),
uint64(1n),
uint32(1),
sshString(input.keyId),
sshString(Buffer.concat(input.principals.map((p) => sshString(p)))),
uint64(BigInt(input.validAfter)),
uint64(BigInt(input.validBefore)),
sshString(Buffer.alloc(0)),
sshString(Buffer.alloc(0)),
sshString(Buffer.alloc(0)),
sshString(caBlob),
]);
const signature = Buffer.concat([
sshString("ssh-ed25519"),
sshString(crypto.sign(null, body, privateKey)),
]);
return `${CERT_TYPE} ${Buffer.concat([body, sshString(signature)]).toString("base64")}`;
},
};
}
/** A root "certificate" the client fingerprints by the sha256 of its DER. */
export function fakeRoot() {
const der = crypto.randomBytes(128);
return {
pem: `-----BEGIN CERTIFICATE-----\n${der.toString("base64")}\n-----END CERTIFICATE-----\n`,
fingerprint: crypto.createHash("sha256").update(der).digest("hex"),
};
}
export function jwt(claims: Record<string, unknown>): string {
const part = (value: object) =>
Buffer.from(JSON.stringify(value)).toString("base64url");
return `${part({ alg: "none" })}.${part(claims)}.sig`;
}
export const CA_URL = "https://ca.test";
export const DISCOVERY_URL =
"https://idp.test/.well-known/openid-configuration";
export interface FakeCaOptions {
/** Principals the CA puts on the certificate; defaults to what was asked. */
principals?: string[];
/** Nonce the identity provider puts in the id token. */
nonce: () => string;
/** Status for /1.0/ssh/sign. */
signStatus?: number;
}
/**
* Answers ctx.fetch as a step-ca server plus its identity provider would.
* Records every call so a test can check the TLS options.
*/
export function createFakeCa(options: FakeCaOptions) {
const root = fakeRoot();
const ca = createSshCa();
const calls: Array<{ url: string; init?: PluginFetchInit }> = [];
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
const fetch = async (url: string, init?: PluginFetchInit) => {
calls.push({ url, init });
if (url === `${CA_URL}/root/${root.fingerprint}`) {
return json({ ca: root.pem });
}
if (url.startsWith(`${CA_URL}/provisioners`)) {
return json({
provisioners: [
{ name: "jwk", type: "JWK" },
{
name: "oidc",
type: "OIDC",
clientID: "termix",
configurationEndpoint: DISCOVERY_URL,
},
],
});
}
if (url === DISCOVERY_URL) {
return json({
authorization_endpoint: "https://idp.test/authorize",
token_endpoint: "https://idp.test/token",
});
}
if (url === "https://idp.test/token") {
return json({
id_token: jwt({ email: "alice@example.com", nonce: options.nonce() }),
});
}
if (url === `${CA_URL}/1.0/ssh/sign`) {
if (options.signStatus && options.signStatus !== 200) {
return json({ message: "not allowed" }, options.signStatus);
}
const body = JSON.parse(init?.body ?? "{}");
const now = Math.floor(Date.now() / 1000);
return json({
crt: ca.sign({
publicKeyBlob: body.publicKey,
keyId: body.keyID,
principals: options.principals ?? body.principals,
validAfter: now - 60,
validBefore: now + 16 * 3600,
}),
});
}
return new Response("not found", { status: 404 });
};
return { fetch, calls, root, ca };
}
+105
View File
@@ -0,0 +1,105 @@
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,
} 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;
/** A fake terminal socket that records what it was sent. */
export function fakeSocket() {
const sent: Array<Record<string, unknown>> = [];
const closers: Array<() => void> = [];
return {
sent,
socket: {
send: (data: string) => sent.push(JSON.parse(data)),
on: (_event: "close", listener: () => void) => closers.push(listener),
},
close: () => closers.forEach((listener) => listener()),
};
}
export interface TestServer {
db: TestDb;
mock: MockPluginContext;
provider: () => PluginSshAuthProvider;
get: (path: string) => Promise<{ status: number; body: string }>;
close: () => Promise<void>;
}
export async function startServer(
options: {
settings?: Record<string, unknown>;
fetch?: (url: string, init?: PluginFetchInit) => Promise<Response>;
} = {},
): Promise<TestServer> {
const db = await createTestDb(pluginDir);
db.sqlite
.prepare("INSERT OR IGNORE INTO users (id) VALUES (?)")
.run("user-1");
db.sqlite.prepare("INSERT OR IGNORE INTO ssh_data (id) VALUES (?)").run(7);
let router: Router | null = null;
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities,
db: db.database,
router: () => (router = express.Router()),
settings: options.settings,
fetch: options.fetch,
baseUrl: "https://termix.test",
});
const { activate } = await import("../../src/backend/index.js");
await activate(mock.ctx);
const app = express();
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 === "stepca",
)!,
async get(path) {
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
redirect: "manual",
});
return { status: response.status, body: await response.text() };
},
async close() {
await new Promise<void>((resolve) => server.close(() => resolve()));
db.close();
},
};
}
export const env = (hostId = 7) => ({
client: {},
userId: "user-1",
hostId,
purpose: "terminal",
interactive: true,
log: () => {},
});
export const HOST = { id: 7, ip: "10.0.0.7", port: 22, username: "root" };
@@ -0,0 +1,295 @@
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 { CA_URL, createFakeCa } from "./fake-ca.js";
import {
HOST,
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;
let nonce = "";
beforeEach(() => {
certs.applyCertificateAuth.mockReset();
nonce = "";
});
afterEach(async () => {
await server?.close();
server = null;
});
async function startWithCa(
options: {
principals?: string[];
signStatus?: number;
wrongNonce?: boolean;
settings?: Record<string, unknown>;
} = {},
) {
const ca = createFakeCa({
principals: options.principals,
signStatus: options.signStatus,
nonce: () => (options.wrongNonce ? "other" : nonce),
});
server = await startServer({
fetch: ca.fetch,
settings: {
caUrl: CA_URL,
fingerprint: ca.root.fingerprint,
provisioner: "oidc",
...options.settings,
},
});
return ca;
}
/** Starts a sign-in and returns the socket and the authorization URL. */
async function startSignIn() {
const socket = fakeSocket();
await server!.provider().startInteraction!({
userId: "user-1",
hostId: 7,
host: { name: "box", ip: "10.0.0.7", username: "root" },
socket: socket.socket,
requestOrigin: "https://termix.test",
payload: {},
});
const chooser = socket.sent.find((m) => m.stage === "chooser");
const url = chooser ? new URL(String(chooser.url)) : null;
nonce = url?.searchParams.get("nonce") ?? "";
return { socket, url, state: url?.searchParams.get("state") ?? "" };
}
describe("the certs table", () => {
it("is created on a fresh install", async () => {
const db = await createTestDb(pluginDir);
expect(
db.sqlite
.prepare(
"SELECT name FROM sqlite_master WHERE name = 'p_step_ca_certs'",
)
.get(),
).toBeTruthy();
db.close();
});
});
describe("activate", () => {
it("registers the stepca auth type and its callback route", async () => {
server = await startServer();
expect(server.provider()).toMatchObject({
type: "stepca",
interaction: "stepca",
supportsBackground: false,
});
expect(server.mock.httpRouters).toHaveLength(1);
});
it.each(["db:own", "auth:provide", "network:serve"])(
"fails closed without %s",
async (capability) => {
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities.filter((c) => c !== capability),
db: (await createTestDb(pluginDir)).database,
});
const { activate } = await import("../../src/backend/index.js");
await expect(activate(mock.ctx)).rejects.toThrow(PluginCapabilityError);
},
);
});
describe("issuing a certificate", () => {
it("runs the OIDC flow against the CA and stores the certificate sealed", async () => {
const ca = await startWithCa();
const { socket, url, state } = await startSignIn();
expect(url?.origin + url?.pathname).toBe("https://idp.test/authorize");
expect(url?.searchParams.get("client_id")).toBe("termix");
expect(url?.searchParams.get("redirect_uri")).toBe(
"https://termix.test/plugin-api/step-ca/callback",
);
expect(url?.searchParams.get("code_challenge_method")).toBe("S256");
// The root is fetched unverified and checked by fingerprint, then pinned.
expect(ca.calls[0].init?.tls).toEqual({ rejectUnauthorized: false });
expect(ca.calls[1].init?.tls).toEqual({ ca: ca.root.pem });
const response = await server!.get(`/callback?state=${state}&code=abc`);
expect(response.status).toBe(200);
expect(response.body).toContain("Signed in");
expect(socket.sent.at(-1)).toMatchObject({
type: "stepca_completed",
requestId: state,
});
const sign = ca.calls.find((call) => call.url.endsWith("/1.0/ssh/sign"));
expect(JSON.parse(sign!.init!.body!)).toMatchObject({
certType: "user",
principals: ["root"],
keyID: "alice@example.com",
});
expect(sign!.init!.tls).toEqual({ ca: ca.root.pem });
const row = server!.db.sqlite
.prepare("SELECT * FROM p_step_ca_certs")
.get() as Record<string, string>;
expect(row.user_id).toBe("user-1");
expect(row.email).toBe("alice@example.com");
expect(row.ssh_cert).toMatch(/^sealed:/);
expect(row.private_key).toMatch(/^sealed:/);
});
it("answers a second use of the same callback with an error", async () => {
await startWithCa();
const { state } = await startSignIn();
await server!.get(`/callback?state=${state}&code=abc`);
const again = await server!.get(`/callback?state=${state}&code=abc`);
expect(again.status).toBe(400);
expect(again.body).toContain("no longer active");
});
it("refuses an id token issued for another sign-in", async () => {
await startWithCa({ wrongNonce: true });
const { socket, state } = await startSignIn();
const response = await server!.get(`/callback?state=${state}&code=abc`);
expect(response.status).toBe(400);
expect(socket.sent.at(-1)).toMatchObject({
type: "stepca_error",
error: expect.stringContaining("does not match"),
});
expect(
server!.db.sqlite.prepare("SELECT * FROM p_step_ca_certs").all(),
).toHaveLength(0);
});
it("reports the CA refusing to sign", async () => {
await startWithCa({ signStatus: 403 });
const { socket, state } = await startSignIn();
await server!.get(`/callback?state=${state}&code=abc`);
expect(socket.sent.at(-1)).toMatchObject({
type: "stepca_error",
error: expect.stringContaining("HTTP 403"),
});
});
it("rejects a certificate without the host username", async () => {
await startWithCa({ principals: ["someone-else"] });
const { socket, state } = await startSignIn();
await server!.get(`/callback?state=${state}&code=abc`);
expect(socket.sent.at(-1)).toMatchObject({
type: "stepca_error",
error: expect.stringContaining("host username"),
});
});
it("passes an identity provider error through", async () => {
await startWithCa();
const { socket, state } = await startSignIn();
const response = await server!.get(
`/callback?state=${state}&error=access_denied&error_description=Denied`,
);
expect(response.status).toBe(400);
expect(socket.sent.at(-1)).toMatchObject({
type: "stepca_error",
error: "Step CA: Denied",
});
});
it("sends the 2.8 redirect URI while legacyCallback is on", async () => {
await startWithCa({ settings: { legacyCallback: true } });
const { url } = await startSignIn();
expect(url?.searchParams.get("redirect_uri")).toBe(
"https://termix.test/host/step-ca-callback",
);
});
it("tells the terminal when Step CA is not configured", async () => {
server = await startServer();
const { socket } = await startSignIn();
expect(socket.sent).toEqual([
expect.objectContaining({ type: "stepca_config_error" }),
]);
});
it("lets a pending sign-in be cancelled", async () => {
await startWithCa();
const { state } = await startSignIn();
await server!.provider().cancelInteraction!({
userId: "user-1",
requestId: state,
});
const response = await server!.get(`/callback?state=${state}&code=abc`);
expect(response.status).toBe(400);
});
});
describe("the stepca provider", () => {
it("asks for a sign-in without a certificate", async () => {
server = await startServer();
const outcome = await server.provider().prepare({}, HOST, env());
expect(outcome).toMatchObject({
status: "interaction-required",
interaction: "stepca",
});
expect(certs.applyCertificateAuth).not.toHaveBeenCalled();
});
it("connects with the issued certificate", async () => {
await startWithCa();
const { state } = await startSignIn();
await server!.get(`/callback?state=${state}&code=abc`);
const config: Record<string, unknown> = {};
const outcome = await server!.provider().prepare(config, HOST, env());
expect(outcome).toEqual({ status: "ready" });
const [passedConfig, , credentials, username] =
certs.applyCertificateAuth.mock.calls[0];
expect(passedConfig).toBe(config);
expect(username).toBe("root");
expect(credentials.certificate).toMatch(
/^ssh-ed25519-cert-v01@openssh\.com /,
);
expect(credentials.privateKey).toContain("BEGIN PRIVATE KEY");
});
it("forgets an expired certificate", async () => {
await startWithCa();
const { state } = await startSignIn();
await server!.get(`/callback?state=${state}&code=abc`);
server!.db.sqlite
.prepare("UPDATE p_step_ca_certs SET expires_at = ?")
.run("2000-01-01T00:00:00.000Z");
const outcome = await server!.provider().prepare({}, HOST, env());
expect(outcome.status).toBe("interaction-required");
expect(
server!.db.sqlite.prepare("SELECT * FROM p_step_ca_certs").all(),
).toHaveLength(0);
});
it("drops the certificate when the host rejects it", async () => {
await startWithCa();
const { state } = await startSignIn();
await server!.get(`/callback?state=${state}&code=abc`);
const decision = server!.provider().onAuthFailed!(HOST, env(), {
error: new Error("All configured authentication methods failed"),
} as never);
expect(decision).toMatchObject({ status: "interaction-required" });
await vi.waitFor(() =>
expect(
server!.db.sqlite.prepare("SELECT * FROM p_step_ca_certs").all(),
).toHaveLength(0),
);
});
});
@@ -0,0 +1,137 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen } from "@testing-library/react";
import {
renderWithApp,
type RenderedPluginApp,
} from "@termix/plugin-sdk/testing";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import * as plugin from "../../src/frontend/index";
import { StepCaOverlay } from "../../src/frontend/StepCaOverlay";
import { redirectUri } from "../../src/frontend/RedirectUriSetting";
import manifestJson from "../../manifest.json";
import locales from "../../locales/en.json";
vi.mock("@termix/plugin-sdk/frontend", async (importOriginal) => ({
...(await importOriginal<typeof import("@termix/plugin-sdk/frontend")>()),
useTranslation: () => ({ t: (key: string) => key }),
}));
const manifest = manifestJson as unknown as PluginManifest;
let rendered: RenderedPluginApp | null = null;
afterEach(async () => {
await rendered?.deactivate();
rendered = null;
});
describe("step-ca activate", () => {
it("registers the auth editor, the terminal overlay and the settings field", async () => {
rendered = await renderWithApp(plugin, { manifest, locales });
expect(rendered.registered.settingsComponents()).toEqual(["redirectUri"]);
expect(rendered.registered.slot("terminal.overlay")).toEqual([
"step-ca.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/step-ca/callback",
);
expect(redirectUri(true, "https://t.example/")).toBe(
"https://t.example/host/step-ca-callback",
);
});
});
describe("StepCaOverlay", () => {
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(<StepCaOverlay {...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: "stepca_auth_required", hostId: 7 });
expect(props.send).toHaveBeenCalledWith("stepca_start_auth", { hostId: 7 });
expect(props.holdConnectTimeout).toHaveBeenCalledWith(true);
emit({ type: "stepca_auth_required", hostId: 7 });
expect(props.fail).toHaveBeenCalledWith("errors.authFailed");
});
it("opens the identity provider, waits, and reconnects once signed in", () => {
const open = vi.spyOn(window, "open").mockReturnValue(null);
const { props, emit } = setup();
emit({ type: "stepca_auth_required", hostId: 7 });
emit({
type: "stepca_status",
stage: "chooser",
requestId: "req-1",
url: "https://idp.test/authorize?state=req-1",
});
fireEvent.click(screen.getByText("dialog.openBrowser"));
expect(open).toHaveBeenCalledWith(
"https://idp.test/authorize?state=req-1",
"_blank",
);
expect(screen.getByText("dialog.waiting")).toBeTruthy();
emit({ type: "stepca_completed", requestId: "req-1" });
expect(props.send).toHaveBeenCalledWith("stepca_auth_completed", {
hostId: 7,
cols: 80,
rows: 24,
});
expect(screen.queryByText("dialog.title")).toBeNull();
open.mockRestore();
});
it("cancels a pending sign-in", () => {
const { props, emit } = setup();
emit({
type: "stepca_status",
stage: "chooser",
requestId: "req-2",
url: "https://idp.test/authorize",
});
fireEvent.click(screen.getByText("common.cancel"));
expect(props.send).toHaveBeenCalledWith("stepca_cancel", {
requestId: "req-2",
});
});
it("shows a config error", () => {
const { emit } = setup();
emit({
type: "stepca_config_error",
requestId: "",
error: "Step CA is not configured.",
});
expect(screen.getByText("Step CA is not configured.")).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);
-70
View File
@@ -15,80 +15,11 @@ import type {
SshConnectHost, SshConnectHost,
} from "../hosts/connect/types.js"; } from "../hosts/connect/types.js";
const STEPCA_REQUIRED_MESSAGE =
"Step CA authentication required. Please open a Terminal connection to this host first to complete browser-based authentication.";
const VAULT_REQUIRED_MESSAGE = const VAULT_REQUIRED_MESSAGE =
"Vault SSH signer authentication required. Please open a Terminal connection first."; "Vault SSH signer authentication required. Please open a Terminal connection first.";
const AUTH_FAILED_PATTERN = /All configured authentication methods failed/i; const AUTH_FAILED_PATTERN = /All configured authentication methods failed/i;
const stepCaProvider: SshAuthProvider = {
type: "stepca",
pluginId: "core",
labelKey: "hosts.filterAuthStepca",
needsUserInteraction: true,
supportsBackground: false,
interaction: "stepca",
prepare: async (config, host, env) => {
const { getStepCaCert } = await import("../hosts/step-ca-auth.js");
const cert = getStepCaCert(env.userId, env.hostId);
if (!cert) {
env.log("info", "No valid certificate found, requesting sign-in");
return {
status: "interaction-required",
interaction: "stepca",
message: STEPCA_REQUIRED_MESSAGE,
};
}
try {
await applyCertificateAuth(
config,
env.client,
{ privateKey: cert.privateKey, certificate: cert.sshCert },
host.username,
);
} catch (error) {
return {
status: "error",
code: "failed",
message: "Step CA authentication failed: " + getErrorMessage(error),
};
}
env.log("info", "Using cached SSH certificate");
return { status: "ready" };
},
onAuthFailed: (_host, env, context) => {
if (!AUTH_FAILED_PATTERN.test(context.error.message)) return;
void import("../hosts/step-ca-auth.js")
.then(({ invalidateStepCaCert }) =>
invalidateStepCaCert(env.userId, env.hostId),
)
.catch(() => {});
return {
status: "interaction-required",
interaction: "stepca",
message:
"Step CA authentication failed or expired. Please authenticate again.",
};
},
startInteraction: async (request) => {
const { startStepCaAuth } = await import("../hosts/step-ca-auth.js");
await startStepCaAuth(
request.userId,
request.hostId,
request.host.username,
request.socket,
request.requestOrigin,
);
},
cancelInteraction: async (request) => {
if (!request.requestId) return;
const { cancelStepCaAuth } = await import("../hosts/step-ca-auth.js");
cancelStepCaAuth(request.requestId);
},
};
function vaultProfileId(host: SshConnectHost): number | undefined { function vaultProfileId(host: SshConnectHost): number | undefined {
return host.vaultProfile?.id ?? undefined; return host.vaultProfile?.id ?? undefined;
} }
@@ -192,7 +123,6 @@ export function registerLegacySshAuthProviders(): void {
if (sshRegistered) return; if (sshRegistered) return;
sshRegistered = true; sshRegistered = true;
registerSshAuthProvider(stepCaProvider);
registerSshAuthProvider(vaultProvider); registerSshAuthProvider(vaultProvider);
} }
@@ -1,8 +1,8 @@
/** /**
* The 2.8 URLs under /host that something outside Termix still depends on: * The 2.8 URLs under /host that something outside Termix still depends on:
* the OPKSSH redirect URI registered with identity providers. It forwards to * the OPKSSH and Step CA redirect URIs registered with identity providers.
* the plugin route that replaced it, so with the plugin off it signs nobody * Each forwards to the plugin route that replaced it, so with the plugin off
* in. * it signs nobody in.
*/ */
import type { Request, Router } from "express"; import type { Request, Router } from "express";
@@ -33,4 +33,20 @@ export function registerHostAuthCompatRoutes(router: Router): void {
const rest = req.path.slice("/opkssh-callback".length); const rest = req.path.slice("/opkssh-callback".length);
res.redirect(307, localUrl(req, `/plugin-api/opkssh/callback${rest}`)); res.redirect(307, localUrl(req, `/plugin-api/opkssh/callback${rest}`));
}); });
/**
* @openapi
* /host/step-ca-callback:
* get:
* summary: Step CA callback (2.8 URL)
* description: The redirect URI Step CA identity providers were set up with before 2.9. Redirects to /plugin-api/step-ca/callback with the same query, so existing provider configurations keep working.
* tags:
* - Auth
* responses:
* 307:
* description: Redirect to the step-ca plugin's callback.
*/
router.get("/step-ca-callback", (req, res) => {
res.redirect(307, localUrl(req, "/plugin-api/step-ca/callback"));
});
} }
@@ -1,42 +0,0 @@
import type { Request, Response, Router } from "express";
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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 ? "Signed in" : "Sign-in failed"}</h1><p>${escapeHtml(message)}</p></main></body></html>`;
}
/**
* The OIDC redirect target for Step CA sign-ins. Unauthenticated on purpose:
* the browser that finishes the sign-in may not be the one running Termix,
* so the request is matched to its session by the OAuth state.
*/
export function registerHostStepCaRoutes(router: Router): void {
router.get("/step-ca-callback", async (req: Request, res: Response) => {
const { completeStepCaAuth } = await import("../../hosts/step-ca-auth.js");
const stringQuery = (name: string): string | undefined => {
const value = req.query[name];
return typeof value === "string" ? value : undefined;
};
const result = await completeStepCaAuth({
state: stringQuery("state"),
code: stringQuery("code"),
error: stringQuery("error"),
error_description: stringQuery("error_description"),
});
res
.status(result.ok ? 200 : 400)
.type("html")
.send(resultPage(result.ok, result.message));
});
}
-2
View File
@@ -46,7 +46,6 @@ import {
} from "./host-plugin-settings.js"; } from "./host-plugin-settings.js";
import { validateParentHostId } from "./host-parent-validation.js"; import { validateParentHostId } from "./host-parent-validation.js";
import { registerHostAuthCompatRoutes } from "./host-compat-routes.js"; import { registerHostAuthCompatRoutes } from "./host-compat-routes.js";
import { registerHostStepCaRoutes } from "./host-step-ca-routes.js";
import { registerHostFolderRoutes } from "./host-folder-routes.js"; import { registerHostFolderRoutes } from "./host-folder-routes.js";
import { registerHostAutostartRoutes } from "./host-autostart-routes.js"; import { registerHostAutostartRoutes } from "./host-autostart-routes.js";
import { registerHostInternalRoutes } from "./host-internal-routes.js"; import { registerHostInternalRoutes } from "./host-internal-routes.js";
@@ -2661,7 +2660,6 @@ registerHostAutostartRoutes(router, {
}); });
registerHostAuthCompatRoutes(router); registerHostAuthCompatRoutes(router);
registerHostStepCaRoutes(router);
registerHostNetworkRoutes(router, { registerHostNetworkRoutes(router, {
authenticateJWT, authenticateJWT,
@@ -1,5 +1,4 @@
import type { AuthenticatedRequest } from "../../../types/index.js"; import type { AuthenticatedRequest } from "../../../types/index.js";
import { getErrorMessage } from "../../utils/error-message.js";
import type { RequestHandler, Router } from "express"; import type { RequestHandler, Router } from "express";
import { import {
authLogger, authLogger,
@@ -13,7 +12,6 @@ import {
AUDIT_FORWARD_URL_SETTING, AUDIT_FORWARD_URL_SETTING,
} from "../../utils/audit-forwarder.js"; } from "../../utils/audit-forwarder.js";
import { getTelemetryEnvOverride } from "../../utils/analytics.js"; import { getTelemetryEnvOverride } from "../../utils/analytics.js";
import { STEP_CA_PRIVATE_ALLOWLIST_KEY } from "../../utils/step-ca-egress.js";
import { import {
NOTIFICATION_PRIVATE_ALLOWLIST_KEY, NOTIFICATION_PRIVATE_ALLOWLIST_KEY,
parseNotificationAllowlist, parseNotificationAllowlist,
@@ -464,7 +462,7 @@ export function registerUserSettingsRoutes(
/** /**
* GET/PATCH a comma-list of private hosts an outbound feature may reach. * GET/PATCH a comma-list of private hosts an outbound feature may reach.
* Shared by notifications and Step CA; each keeps its own setting key. * One route pair per private endpoint allowlist, each with its own setting key.
*/ */
const registerPrivateEndpointAllowlist = ( const registerPrivateEndpointAllowlist = (
path: string, path: string,
@@ -562,131 +560,6 @@ export function registerUserSettingsRoutes(
"notification endpoint", "notification endpoint",
); );
/**
* @openapi
* /users/step-ca-private-endpoints:
* get:
* summary: Get the private hosts the Step CA certificate flow may contact (admin only)
* tags:
* - Users
* patch:
* summary: Replace that allowlist (admin only)
* tags:
* - Users
*/
registerPrivateEndpointAllowlist(
"/step-ca-private-endpoints",
STEP_CA_PRIVATE_ALLOWLIST_KEY,
"update_step_ca_private_endpoints",
"Step CA endpoint",
);
/**
* @openapi
* /users/step-ca-settings:
* get:
* summary: Step CA settings. Admins get the values; everyone else only whether it is configured.
* tags:
* - Users
* patch:
* summary: Set the Step CA URL, root fingerprint and OIDC provisioner (admin only). Empty values clear the configuration.
* tags:
* - Users
*/
router.get("/step-ca-settings", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const { readStepCaSettings } =
await import("../../hosts/step-ca-auth.js");
const settings = await readStepCaSettings();
if (!(await getAdminActor(userId))) {
return res.json({ configured: settings !== null });
}
res.json({
configured: settings !== null,
caUrl: settings?.caUrl ?? "",
fingerprint: settings?.fingerprint ?? "",
provisioner: settings?.provisioner ?? "",
});
} catch (err) {
authLogger.error("Failed to get Step CA settings", err);
res.status(500).json({ error: "Failed to get Step CA settings" });
}
});
router.patch("/step-ca-settings", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const actor = await getAdminActor(userId);
if (!actor) {
return res.status(403).json({ error: "Not authorized" });
}
const { caUrl, fingerprint, provisioner } = req.body ?? {};
if (
[caUrl, fingerprint, provisioner].some((v) => typeof v !== "string")
) {
return res.status(400).json({
error: "caUrl, fingerprint and provisioner must be strings",
});
}
const values = {
caUrl: caUrl.trim(),
fingerprint: fingerprint.trim(),
provisioner: provisioner.trim(),
};
const clearing =
!values.caUrl && !values.fingerprint && !values.provisioner;
if (!clearing) {
const { normalizeCaUrl, normalizeFingerprint } =
await import("../../utils/step-ca-client.js");
try {
values.caUrl = normalizeCaUrl(values.caUrl);
values.fingerprint = normalizeFingerprint(values.fingerprint);
} catch (err) {
return res.status(400).json({ error: getErrorMessage(err) });
}
if (!values.provisioner) {
return res.status(400).json({ error: "provisioner is required" });
}
}
const { STEP_CA_SETTING_KEYS } =
await import("../../hosts/step-ca-auth.js");
const settings = createCurrentSettingsRepository();
if (clearing) {
await settings.delete(STEP_CA_SETTING_KEYS.url);
await settings.delete(STEP_CA_SETTING_KEYS.fingerprint);
await settings.delete(STEP_CA_SETTING_KEYS.provisioner);
} else {
await settings.set(STEP_CA_SETTING_KEYS.url, values.caUrl);
await settings.set(
STEP_CA_SETTING_KEYS.fingerprint,
values.fingerprint,
);
await settings.set(
STEP_CA_SETTING_KEYS.provisioner,
values.provisioner,
);
}
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: actor.username ?? userId,
action: "update_step_ca_settings",
resourceType: "setting",
details: JSON.stringify({ configured: !clearing, caUrl: values.caUrl }),
ipAddress,
userAgent,
success: true,
});
res.json({ configured: !clearing, ...values });
} catch (err) {
authLogger.error("Failed to update Step CA settings", err);
res.status(500).json({ error: "Failed to update Step CA settings" });
}
});
/** /**
* @openapi * @openapi
* /users/host-defaults: * /users/host-defaults:
-376
View File
@@ -1,376 +0,0 @@
import { randomBytes } from "crypto";
import type { WebSocket } from "ws";
import { sshLogger } from "../utils/logger.js";
import { getErrorMessage } from "../utils/error-message.js";
import { createCurrentSettingsRepository } from "../database/repositories/factory.js";
import { readStepCaPrivateAllowlist } from "../utils/step-ca-egress.js";
import {
stepCaRuntime,
type StepCaCallbackQuery,
type StepCaCallbackResult,
} from "./step-ca-runtime.js";
import {
buildAuthorizationUrl,
createPkce,
decodeJwtClaims,
discoverOidcEndpoints,
exchangeCodeForIdToken,
fetchRootCertificate,
findOidcProvisioner,
generateSshKeyPair,
parseSshCertificate,
signSshCertificate,
type StepCaTarget,
} from "../utils/step-ca-client.js";
/**
* Step CA (smallstep) SSH user certificates through its OIDC provisioner.
*
* The CA's HTTP API plus one OIDC redirect back to Termix. Issued
* certificates are kept in memory until they expire.
*/
export const STEP_CA_CALLBACK_PATH = "/host/step-ca-callback";
export const STEP_CA_SETTING_KEYS = {
url: "step_ca_url",
fingerprint: "step_ca_fingerprint",
provisioner: "step_ca_provisioner",
} as const;
const AUTH_TIMEOUT_MS = 5 * 60 * 1000;
const REMOTE_CALLBACK_WAIT_MS = 30_000;
const REMOTE_POLL_MS = 250;
export interface StepCaSettings {
caUrl: string;
fingerprint: string;
provisioner: string;
}
export async function readStepCaSettings(): Promise<StepCaSettings | null> {
const settings = createCurrentSettingsRepository();
const [caUrl, fingerprint, provisioner] = await Promise.all([
settings.get(STEP_CA_SETTING_KEYS.url),
settings.get(STEP_CA_SETTING_KEYS.fingerprint),
settings.get(STEP_CA_SETTING_KEYS.provisioner),
]);
if (!caUrl || !fingerprint || !provisioner) return null;
return { caUrl, fingerprint, provisioner };
}
interface StepCaAuthSession {
state: string;
userId: string;
hostId: number;
username: string;
ws: WebSocket;
target: StepCaTarget;
rootPem: string;
clientId: string;
clientSecret?: string;
tokenEndpoint: string;
redirectUri: string;
codeVerifier: string;
nonce: string;
keyPair: { publicKeyLine: string; privateKeyPem: string };
timeout: NodeJS.Timeout;
commandPoll: NodeJS.Timeout | null;
processing: boolean;
completed: boolean;
}
const sessions = new Map<string, StepCaAuthSession>();
interface IssuedCertificate {
sshCert: string;
privateKey: string;
expiresAt: number;
}
const issued = new Map<string, IssuedCertificate>();
function certKey(userId: string, hostId: number): string {
return `${userId}:${hostId}`;
}
/** The cached certificate for this user and host, while it is valid. */
export function getStepCaCert(
userId: string,
hostId: number,
): { sshCert: string; privateKey: string } | null {
const key = certKey(userId, hostId);
const cert = issued.get(key);
if (!cert) return null;
if (cert.expiresAt <= Date.now()) {
issued.delete(key);
return null;
}
return { sshCert: cert.sshCert, privateKey: cert.privateKey };
}
export function invalidateStepCaCert(userId: string, hostId: number): void {
issued.delete(certKey(userId, hostId));
}
function send(ws: WebSocket, message: object): void {
try {
ws.send(JSON.stringify(message));
} catch {
/* socket already gone */
}
}
function endSession(session: StepCaAuthSession, removeRuntime = true): void {
clearTimeout(session.timeout);
if (session.commandPoll) clearInterval(session.commandPoll);
sessions.delete(session.state);
if (removeRuntime) void stepCaRuntime.remove(session.state);
}
export async function startStepCaAuth(
userId: string,
hostId: number,
username: string,
ws: WebSocket,
requestOrigin: string,
): Promise<void> {
const settings = await readStepCaSettings();
if (!settings) {
send(ws, {
type: "stepca_config_error",
requestId: "",
error:
"Step CA is not configured. An administrator must set the CA URL, root fingerprint and OIDC provisioner under Admin Settings.",
});
return;
}
const state = randomBytes(24).toString("base64url");
try {
const target: StepCaTarget = {
caUrl: settings.caUrl,
fingerprint: settings.fingerprint,
allowedPrivateHosts: await readStepCaPrivateAllowlist(),
};
const rootPem = await fetchRootCertificate(target);
const provisioner = await findOidcProvisioner(
target,
rootPem,
settings.provisioner,
);
const endpoints = await discoverOidcEndpoints(
provisioner.configurationEndpoint,
target.allowedPrivateHosts,
);
const pkce = createPkce();
const nonce = randomBytes(16).toString("base64url");
const redirectUri = `${requestOrigin}${STEP_CA_CALLBACK_PATH}`;
const session: StepCaAuthSession = {
state,
userId,
hostId,
username,
ws,
target,
rootPem,
clientId: provisioner.clientID,
clientSecret: provisioner.clientSecret,
tokenEndpoint: endpoints.tokenEndpoint,
redirectUri,
codeVerifier: pkce.verifier,
nonce,
keyPair: generateSshKeyPair(),
processing: false,
completed: false,
commandPoll: null,
timeout: setTimeout(() => {
const current = sessions.get(state);
if (!current || current.completed || current.processing) return;
send(ws, { type: "stepca_timeout", requestId: state });
endSession(current);
}, AUTH_TIMEOUT_MS),
};
sessions.set(state, session);
await stepCaRuntime.register(state);
session.commandPoll = setInterval(() => {
if (session.processing || session.completed) return;
void stepCaRuntime.takeCommand(state).then(async (query) => {
if (!query || session.processing || session.completed) return;
session.processing = true;
const result = await finishStepCaAuth(session, query, false);
await stepCaRuntime.complete(state, result);
});
}, REMOTE_POLL_MS);
session.commandPoll.unref();
ws.once("close", () => {
const current = sessions.get(state);
if (current && !current.completed) endSession(current);
});
send(ws, {
type: "stepca_status",
requestId: state,
stage: "chooser",
label: "Step CA",
url: buildAuthorizationUrl({
authorizationEndpoint: endpoints.authorizationEndpoint,
clientId: provisioner.clientID,
redirectUri,
state,
nonce,
codeChallenge: pkce.challenge,
}),
providers: [],
});
} catch (error) {
sshLogger.error("Failed to start Step CA authentication", error, {
operation: "step_ca_start_error",
userId,
hostId,
});
send(ws, {
type: "stepca_error",
requestId: state,
error: `Step CA: ${getErrorMessage(error)}`,
});
}
}
export function cancelStepCaAuth(requestId: string): boolean {
const session = sessions.get(requestId);
if (!session) return false;
endSession(session);
return true;
}
/**
* Finishes the flow once the identity provider redirects back: exchanges
* the code, has the CA sign the key, caches the certificate and tells the
* terminal to reconnect.
*/
export async function completeStepCaAuth(
query: StepCaCallbackQuery,
): Promise<StepCaCallbackResult> {
const session = query.state ? sessions.get(query.state) : undefined;
if (!session) {
if (!query.state || !(await stepCaRuntime.submit(query.state, query))) {
return {
ok: false,
message: "This sign-in request is no longer active.",
};
}
const deadline = Date.now() + REMOTE_CALLBACK_WAIT_MS;
while (Date.now() < deadline) {
const result = await stepCaRuntime.takeResult(query.state);
if (result) return result;
await new Promise((resolve) => setTimeout(resolve, REMOTE_POLL_MS));
}
return {
ok: false,
message: "The Termix instance handling this sign-in did not respond.",
};
}
if (session.processing || session.completed) {
return { ok: false, message: "This sign-in request was already used." };
}
session.processing = true;
return finishStepCaAuth(session, query);
}
async function finishStepCaAuth(
session: StepCaAuthSession,
query: StepCaCallbackQuery,
removeRuntime = true,
): Promise<StepCaCallbackResult> {
if (query.error || !query.code) {
const message = query.error_description || query.error || "Sign-in failed";
send(session.ws, {
type: "stepca_error",
requestId: session.state,
error: `Step CA: ${message}`,
});
endSession(session, removeRuntime);
return { ok: false, message };
}
try {
send(session.ws, {
type: "stepca_status",
requestId: session.state,
stage: "authenticating",
});
const idToken = await exchangeCodeForIdToken({
tokenEndpoint: session.tokenEndpoint,
clientId: session.clientId,
clientSecret: session.clientSecret,
code: query.code,
redirectUri: session.redirectUri,
codeVerifier: session.codeVerifier,
allowedPrivateHosts: session.target.allowedPrivateHosts,
});
const claims = decodeJwtClaims(idToken);
if (claims.nonce !== session.nonce) {
throw new Error("The identity token does not match this sign-in");
}
const email = typeof claims.email === "string" ? claims.email : undefined;
const certificate = await signSshCertificate(
session.target,
session.rootPem,
{
publicKeyLine: session.keyPair.publicKeyLine,
ott: idToken,
principals: [session.username],
keyId: email ?? session.username,
},
);
const certificateInfo = parseSshCertificate(certificate);
if (certificateInfo.publicKeyLine !== session.keyPair.publicKeyLine) {
throw new Error("The CA returned a certificate for a different key");
}
if (!certificateInfo.principals.includes(session.username)) {
throw new Error("The CA certificate does not include the host username");
}
const now = Date.now();
if (
certificateInfo.validBefore.getTime() <= now ||
certificateInfo.validAfter.getTime() > now + 60_000
) {
throw new Error(
"The CA returned a certificate outside its validity window",
);
}
const expiresAt = certificateInfo.validBefore;
issued.set(certKey(session.userId, session.hostId), {
sshCert: certificate,
privateKey: session.keyPair.privateKeyPem,
expiresAt: expiresAt.getTime(),
});
session.completed = true;
send(session.ws, {
type: "stepca_completed",
requestId: session.state,
expiresAt: expiresAt.toISOString(),
});
endSession(session, removeRuntime);
return { ok: true, message: "Signed in. You can close this window." };
} catch (error) {
sshLogger.error("Step CA certificate issuance failed", error, {
operation: "step_ca_complete_error",
userId: session.userId,
hostId: session.hostId,
});
const message = getErrorMessage(error);
send(session.ws, {
type: "stepca_error",
requestId: session.state,
error: `Step CA: ${message}`,
});
endSession(session, removeRuntime);
return { ok: false, message };
}
}
-172
View File
@@ -1,172 +0,0 @@
import { createClient } from "redis";
import { sshLogger } from "../utils/logger.js";
import {
decryptSystemSecret,
encryptSystemSecret,
} from "../utils/system-secret-crypto.js";
export interface StepCaCallbackQuery {
state?: string;
code?: string;
error?: string;
error_description?: string;
}
export interface StepCaCallbackResult {
ok: boolean;
message: string;
}
const PREFIX =
process.env.TERMIX_STEP_CA_REDIS_PREFIX?.trim() || "termix:step-ca";
const SESSION_TTL_SECONDS = 5 * 60;
const RESULT_TTL_SECONDS = 60;
const CONNECT_RETRY_MS = 15_000;
export class StepCaRuntime {
private client: ReturnType<typeof createClient> | null = null;
private connecting: Promise<boolean> | null = null;
private nextConnectAttempt = 0;
async register(state: string): Promise<void> {
if (!(await this.ensureConnected()) || !this.client) return;
await this.client
.set(this.routeKey(state), "active", { EX: SESSION_TTL_SECONDS })
.catch((error) => this.logFailure("register", error));
}
async submit(state: string, query: StepCaCallbackQuery): Promise<boolean> {
if (!(await this.ensureConnected()) || !this.client) return false;
try {
if (!(await this.client.exists(this.routeKey(state)))) return false;
const encrypted = await encryptSystemSecret(JSON.stringify(query));
const stored = await this.client.set(this.commandKey(state), encrypted, {
EX: SESSION_TTL_SECONDS,
NX: true,
});
return stored === "OK";
} catch (error) {
this.logFailure("submit", error);
return false;
}
}
async takeCommand(state: string): Promise<StepCaCallbackQuery | null> {
if (!(await this.ensureConnected()) || !this.client) return null;
try {
const encrypted = await this.client.getDel(this.commandKey(state));
if (!encrypted) return null;
return this.decode<StepCaCallbackQuery>(
await decryptSystemSecret(encrypted.toString()),
);
} catch (error) {
this.logFailure("take_command", error);
return null;
}
}
async complete(state: string, result: StepCaCallbackResult): Promise<void> {
if (!(await this.ensureConnected()) || !this.client) return;
try {
const encrypted = await encryptSystemSecret(JSON.stringify(result));
await this.client
.multi()
.set(this.resultKey(state), encrypted, { EX: RESULT_TTL_SECONDS })
.del(this.routeKey(state))
.del(this.commandKey(state))
.exec();
} catch (error) {
this.logFailure("complete", error);
}
}
async takeResult(state: string): Promise<StepCaCallbackResult | null> {
if (!(await this.ensureConnected()) || !this.client) return null;
try {
const encrypted = await this.client.getDel(this.resultKey(state));
if (!encrypted) return null;
return this.decode<StepCaCallbackResult>(
await decryptSystemSecret(encrypted.toString()),
);
} catch (error) {
this.logFailure("take_result", error);
return null;
}
}
async remove(state: string): Promise<void> {
if (!(await this.ensureConnected()) || !this.client) return;
await this.client
.del([
this.routeKey(state),
this.commandKey(state),
this.resultKey(state),
])
.catch((error) => this.logFailure("remove", error));
}
async close(): Promise<void> {
if (this.client?.isOpen) await this.client.quit();
this.client = null;
}
private async ensureConnected(): Promise<boolean> {
const url = process.env.REDIS_URL?.trim();
if (!url) return false;
if (this.client?.isReady) return true;
if (Date.now() < this.nextConnectAttempt) return false;
if (this.connecting) return this.connecting;
this.connecting = this.connect(url).finally(() => {
this.connecting = null;
});
return this.connecting;
}
private async connect(url: string): Promise<boolean> {
try {
this.client = createClient({
url,
socket: { connectTimeout: 1500, reconnectStrategy: false },
});
this.client.on("error", (error) => this.logFailure("client", error));
await this.client.connect();
this.nextConnectAttempt = 0;
return true;
} catch (error) {
this.nextConnectAttempt = Date.now() + CONNECT_RETRY_MS;
this.logFailure("connect", error);
await this.client?.disconnect().catch(() => {});
this.client = null;
return false;
}
}
private routeKey(state: string): string {
return `${PREFIX}:route:${state}`;
}
private commandKey(state: string): string {
return `${PREFIX}:command:${state}`;
}
private resultKey(state: string): string {
return `${PREFIX}:result:${state}`;
}
private decode<T>(raw: string): T | null {
try {
return JSON.parse(raw) as T;
} catch {
return null;
}
}
private logFailure(operation: string, error: unknown): void {
sshLogger.warn("Step CA Redis runtime unavailable", {
operation: `step_ca_redis_${operation}`,
error: error instanceof Error ? error.message : String(error),
});
}
}
export const stepCaRuntime = new StepCaRuntime();
+6
View File
@@ -147,6 +147,12 @@ export function createPluginFetch(deps: Deps): PluginFetch {
signal: controller.signal, signal: controller.signal,
}, },
(init.allowPrivateHosts ?? []).map((host) => host.toLowerCase()), (init.allowPrivateHosts ?? []).map((host) => host.toLowerCase()),
{
...(init.tls?.ca ? { ca: init.tls.ca } : {}),
...(init.tls?.rejectUnauthorized === false
? { rejectUnauthorized: false }
: {}),
},
); );
} finally { } finally {
clearTimeout(timer); clearTimeout(timer);
+4
View File
@@ -377,6 +377,10 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
await import("./utils/crypto-migration/warpgate-settings-migration.js"); await import("./utils/crypto-migration/warpgate-settings-migration.js");
await runWarpgateSettingsMigration(); await runWarpgateSettingsMigration();
const { runStepCaSettingsMigration } =
await import("./utils/crypto-migration/step-ca-settings-migration.js");
await runStepCaSettingsMigration();
const { runSecretSourcesTokenMigration } = const { runSecretSourcesTokenMigration } =
await import("./utils/crypto-migration/secret-sources-token-migration.js"); await import("./utils/crypto-migration/secret-sources-token-migration.js");
await runSecretSourcesTokenMigration(); await runSecretSourcesTokenMigration();
@@ -1,5 +1,5 @@
/** /**
* The OPKSSH redirect URI identity providers were registered with before * The OPKSSH and Step CA redirect URIs identity providers were registered with before
* 2.9 must keep reaching the plugin's callback, query intact. * 2.9 must keep reaching the plugin's callback, query intact.
*/ */
@@ -49,3 +49,16 @@ describe("the old OPKSSH callback", () => {
); );
}); });
}); });
describe("the old Step CA callback", () => {
it("forwards to the plugin's callback with the query", async () => {
const response = await fetch(
`${base}/host/step-ca-callback?code=abc&state=xyz`,
{ redirect: "manual" },
);
expect(response.status).toBe(307);
expect(response.headers.get("location")).toBe(
"/plugin-api/step-ca/callback?code=abc&state=xyz",
);
});
});
@@ -18,8 +18,6 @@ vi.mock("ssh2", async (importOriginal) => {
const mocks = vi.hoisted(() => ({ const mocks = vi.hoisted(() => ({
verifier: () => {}, verifier: () => {},
createHostVerifier: vi.fn(), createHostVerifier: vi.fn(),
getStepCaCert: vi.fn(),
invalidateStepCaCert: vi.fn(),
applyCertificateAuth: vi.fn(), applyCertificateAuth: vi.fn(),
getVaultCert: vi.fn(), getVaultCert: vi.fn(),
deleteVaultCert: vi.fn(), deleteVaultCert: vi.fn(),
@@ -40,10 +38,6 @@ vi.mock("../../../hosts/host-key-verifier.js", () => ({
createHostVerifier: mocks.createHostVerifier, createHostVerifier: mocks.createHostVerifier,
}, },
})); }));
vi.mock("../../../hosts/step-ca-auth.js", () => ({
getStepCaCert: mocks.getStepCaCert,
invalidateStepCaCert: mocks.invalidateStepCaCert,
}));
vi.mock("@termix/plugin-sdk/ssh-certs", () => ({ vi.mock("@termix/plugin-sdk/ssh-certs", () => ({
applyCertificateAuth: mocks.applyCertificateAuth, applyCertificateAuth: mocks.applyCertificateAuth,
})); }));
@@ -276,25 +270,20 @@ describe("buildConnectConfig per auth type", () => {
// OPKSSH is a plugin-registered SSH auth provider now: see // OPKSSH is a plugin-registered SSH auth provider now: see
// plugins/opkssh/tests/backend/provider.test.ts. // plugins/opkssh/tests/backend/provider.test.ts.
it("stepca without a cached certificate asks for a sign-in", async () => { // Step CA is the step-ca plugin's provider: see
mocks.getStepCaCert.mockReturnValueOnce(null); // plugins/step-ca/tests/backend/step-ca.test.ts.
const { outcome } = await build(host({ authType: "stepca" }));
expect(outcome).toMatchObject({
status: "interaction-required",
interaction: "stepca",
});
expect(mocks.getStepCaCert).toHaveBeenCalledWith("user-1", 7);
});
it("stepca uses the cached certificate", async () => { it("a stepca host without the step-ca plugin names it", async () => {
mocks.getStepCaCert.mockReturnValueOnce({ setSshAuthTypeOwnerSource(() => [
privateKey: "k", { type: "stepca", pluginId: "step-ca", pluginName: "Step CA" },
sshCert: "c", ]);
const { outcome } = await build(host({ authType: "stepca" }));
expect(outcome).toEqual({
status: "error",
code: "provider-missing",
message: "This host uses stepca, which needs the Step CA plugin",
}); });
const { config, outcome } = await build(host({ authType: "stepca" })); setSshAuthTypeOwnerSource(() => []);
expect(outcome.status).toBe("ready");
expect(config.authHandler).toBe("cert-handler");
expect(mocks.applyCertificateAuth).toHaveBeenCalledOnce();
}); });
it("vault needs a profile, then a cached certificate", async () => { it("vault needs a profile, then a cached certificate", async () => {
@@ -465,18 +454,20 @@ describe("connectHost", () => {
}); });
it("throws the auth outcome instead of connecting", async () => { it("throws the auth outcome instead of connecting", async () => {
mocks.getStepCaCert.mockReturnValue(null); mocks.getVaultCert.mockResolvedValue(null);
const attempt = connectHost(host({ authType: "stepca" }), { const vaultHost = () =>
host({ authType: "vault", vaultProfile: { id: 3 } });
const attempt = connectHost(vaultHost(), {
userId: "user-1", userId: "user-1",
purpose: "tmux", purpose: "tmux",
}); });
await expect(attempt).rejects.toBeInstanceOf(SshConnectError); await expect(attempt).rejects.toBeInstanceOf(SshConnectError);
await expect( await expect(
connectHost(host({ authType: "stepca" }), { connectHost(vaultHost(), {
userId: "user-1", userId: "user-1",
purpose: "tmux", purpose: "tmux",
}).catch((error) => error.code), }).catch((error) => error.code),
).resolves.toBe("stepca-required"); ).resolves.toBe("vault-required");
}); });
it("goes through the jump chain and forwards to the target", async () => { it("goes through the jump chain and forwards to the target", async () => {
@@ -628,18 +619,18 @@ describe("connectHost", () => {
it("lets the provider react to an auth failure", async () => { it("lets the provider react to an auth failure", async () => {
FakeSshClient.nextBehaviour = ["auth-fail"]; FakeSshClient.nextBehaviour = ["auth-fail"];
mocks.getStepCaCert.mockReturnValueOnce({ mocks.getVaultCert.mockResolvedValueOnce({
privateKey: "k", privateKey: "k",
sshCert: "c", sshCert: "c",
}); });
await expect( await expect(
connectHost(host({ authType: "stepca" }), { connectHost(host({ authType: "vault", vaultProfile: { id: 3 } }), {
userId: "user-1", userId: "user-1",
purpose: "fleet", purpose: "fleet",
}), }),
).rejects.toThrow(/All configured authentication methods failed/); ).rejects.toThrow(/All configured authentication methods failed/);
await vi.waitFor(() => await vi.waitFor(() =>
expect(mocks.invalidateStepCaCert).toHaveBeenCalledWith("user-1", 7), expect(mocks.deleteVaultCert).toHaveBeenCalledWith("user-1", 3),
); );
}); });
@@ -1,184 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { WebSocket } from "ws";
const state = vi.hoisted(() => ({
sent: [] as Array<Record<string, unknown>>,
parseInfo: {
publicKeyLine: "ssh-ed25519 TESTKEY",
principals: ["alice"],
validAfter: new Date(Date.now() - 60_000),
validBefore: new Date(Date.now() + 60 * 60_000),
},
idToken: "",
command: null as Record<string, string> | null,
remoteResult: null as { ok: boolean; message: string } | null,
runtimeComplete: vi.fn(async () => undefined),
}));
vi.mock("../../utils/logger.js", () => ({
sshLogger: { error: vi.fn(), warn: vi.fn(), info: vi.fn() },
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentSettingsRepository: () => ({
get: async (key: string) =>
({
step_ca_url: "https://ca.example",
step_ca_fingerprint: "a".repeat(64),
step_ca_provisioner: "oidc",
})[key],
}),
}));
vi.mock("../../utils/step-ca-egress.js", () => ({
readStepCaPrivateAllowlist: async () => [],
}));
vi.mock("../../utils/step-ca-client.js", () => ({
fetchRootCertificate: async () => "ROOT",
findOidcProvisioner: async () => ({
clientID: "client",
configurationEndpoint: "https://idp.example/.well-known/openid",
}),
discoverOidcEndpoints: async () => ({
authorizationEndpoint: "https://idp.example/auth",
tokenEndpoint: "https://idp.example/token",
}),
createPkce: () => ({ verifier: "verifier", challenge: "challenge" }),
generateSshKeyPair: () => ({
publicKeyLine: "ssh-ed25519 TESTKEY",
privateKeyPem: "PRIVATE",
}),
buildAuthorizationUrl: (input: Record<string, string>) => {
const url = new URL("https://idp.example/auth");
url.searchParams.set("state", input.state);
url.searchParams.set("nonce", input.nonce);
return url.toString();
},
exchangeCodeForIdToken: async () => state.idToken,
decodeJwtClaims: (token: string) => {
const payload = token.split(".")[1];
return payload
? JSON.parse(Buffer.from(payload, "base64url").toString("utf8"))
: {};
},
signSshCertificate: async () => "CERT",
parseSshCertificate: () => state.parseInfo,
}));
vi.mock("../../hosts/step-ca-runtime.js", () => ({
stepCaRuntime: {
register: vi.fn(async () => undefined),
remove: vi.fn(async () => undefined),
submit: vi.fn(async () => true),
takeCommand: vi.fn(async () => {
const command = state.command;
state.command = null;
return command;
}),
complete: state.runtimeComplete,
takeResult: vi.fn(async () => {
const result = state.remoteResult;
state.remoteResult = null;
return result;
}),
},
}));
const {
cancelStepCaAuth,
completeStepCaAuth,
getStepCaCert,
invalidateStepCaCert,
startStepCaAuth,
} = await import("../../hosts/step-ca-auth.js");
function jwt(payload: object): string {
return `x.${Buffer.from(JSON.stringify(payload)).toString("base64url")}.y`;
}
function fakeWs(): WebSocket {
return {
send: (raw: string) => state.sent.push(JSON.parse(raw)),
once: vi.fn(),
} as unknown as WebSocket;
}
async function start(): Promise<{ requestId: string; nonce: string }> {
await startStepCaAuth(
"user-1",
7,
"alice",
fakeWs(),
"https://termix.example",
);
const chooser = state.sent.at(-1)!;
const url = new URL(String(chooser.url));
return {
requestId: String(chooser.requestId),
nonce: url.searchParams.get("nonce")!,
};
}
beforeEach(() => {
state.sent.length = 0;
invalidateStepCaCert("user-1", 7);
state.runtimeComplete.mockClear();
state.command = null;
state.remoteResult = null;
state.parseInfo = {
publicKeyLine: "ssh-ed25519 TESTKEY",
principals: ["alice"],
validAfter: new Date(Date.now() - 60_000),
validBefore: new Date(Date.now() + 60 * 60_000),
};
});
describe("Step CA authentication", () => {
it("stores only a certificate bound to the nonce, key, principal and validity window", async () => {
const { requestId, nonce } = await start();
state.idToken = jwt({ nonce, email: "alice@example.com" });
await expect(
completeStepCaAuth({ state: requestId, code: "code" }),
).resolves.toEqual({
ok: true,
message: "Signed in. You can close this window.",
});
expect(getStepCaCert("user-1", 7)).toEqual({
sshCert: "CERT",
privateKey: "PRIVATE",
});
expect(state.sent.at(-1)).toMatchObject({ type: "stepca_completed" });
});
it("rejects a token without the requested nonce", async () => {
const { requestId } = await start();
state.idToken = jwt({ email: "alice@example.com" });
const result = await completeStepCaAuth({ state: requestId, code: "code" });
expect(result.ok).toBe(false);
expect(result.message).toMatch(/does not match/);
expect(getStepCaCert("user-1", 7)).toBeNull();
});
it("routes a callback through Redis to the instance holding the WebSocket", async () => {
const { requestId, nonce } = await start();
state.idToken = jwt({ nonce });
state.command = { state: requestId, code: "remote-code" };
await vi.waitFor(() => expect(state.runtimeComplete).toHaveBeenCalled(), {
timeout: 2000,
});
expect(getStepCaCert("user-1", 7)).not.toBeNull();
});
it("returns a result produced by another instance", async () => {
state.remoteResult = { ok: true, message: "remote success" };
await expect(
completeStepCaAuth({ state: "remote-state", code: "code" }),
).resolves.toEqual({ ok: true, message: "remote success" });
});
it("cancels local sessions", async () => {
const { requestId } = await start();
expect(cancelStepCaAuth(requestId)).toBe(true);
expect(cancelStepCaAuth(requestId)).toBe(false);
});
});
@@ -23,6 +23,7 @@ const state = vi.hoisted(() => ({
delivered: [] as Array<{ channelId: number; title: string }>, delivered: [] as Array<{ channelId: number; title: string }>,
failChannel: null as number | null, failChannel: null as number | null,
fetchCalls: [] as Array<{ url: string; allowlist: readonly string[] }>, fetchCalls: [] as Array<{ url: string; allowlist: readonly string[] }>,
lastTls: null as Record<string, unknown> | null,
lastSignal: null as AbortSignal | null, lastSignal: null as AbortSignal | null,
})); }));
@@ -61,7 +62,9 @@ vi.mock("../../utils/safe-outbound-fetch.js", () => ({
url: string, url: string,
init: RequestInit, init: RequestInit,
allowlist: readonly string[], allowlist: readonly string[],
tls: Record<string, unknown> = {},
) => { ) => {
state.lastTls = tls;
state.lastSignal = init.signal ?? null; state.lastSignal = init.signal ?? null;
state.fetchCalls.push({ url, allowlist }); state.fetchCalls.push({ url, allowlist });
return new Response("ok", { status: 200 }); return new Response("ok", { status: 200 });
@@ -184,6 +187,18 @@ describe("ctx.fetch", () => {
]); ]);
}); });
it("passes a pinned CA and the fingerprint bootstrap flag to the guard", async () => {
const ctx = contextFor(["network:outbound"]);
await ctx.fetch("https://ca.lan/root/abc", {
tls: { rejectUnauthorized: false },
});
expect(state.lastTls).toEqual({ rejectUnauthorized: false });
await ctx.fetch("https://ca.lan/provisioners", { tls: { ca: "PEM" } });
expect(state.lastTls).toEqual({ ca: "PEM" });
await ctx.fetch("https://example.com");
expect(state.lastTls).toEqual({});
});
it("aborts the request, and a streamed body, when the caller's signal fires", async () => { it("aborts the request, and a streamed body, when the caller's signal fires", async () => {
const ctx = contextFor(["network:outbound"]); const ctx = contextFor(["network:outbound"]);
const controller = new AbortController(); const controller = new AbortController();
@@ -0,0 +1,116 @@
/**
* The Step CA settings migration: a 2.8 CA configuration and its private
* endpoint allowlist come through into the step-ca plugin's admin settings,
* the old redirect URI stays in use, and a second run changes nothing.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
legacy: {} as Record<string, string>,
pluginRows: [] as Array<{ key: string; value: string | null }>,
pluginInstalled: true,
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) =>
state.pluginInstalled && id === "step-ca" ? { id } : null,
}),
createCurrentSettingsRepository: () => ({
get: async (key: string) => state.legacy[key] ?? null,
}),
createCurrentPluginSettingsRepository: () => ({
get: async (
_pluginId: string,
_scope: string,
_scopeId: string | null,
key: string,
) => state.pluginRows.find((row) => row.key === key) ?? null,
set: async (
_pluginId: string,
_scope: string,
_scopeId: string | null,
key: string,
value: string | null,
) => {
state.pluginRows = state.pluginRows.filter((row) => row.key !== key);
state.pluginRows.push({ key, value });
},
}),
}));
vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn() },
}));
import { runStepCaSettingsMigration } from "../../utils/crypto-migration/step-ca-settings-migration.js";
const value = (key: string) => {
const row = state.pluginRows.find((candidate) => candidate.key === key);
return row?.value == null ? undefined : JSON.parse(row.value);
};
beforeEach(() => {
state.legacy = {
step_ca_url: "https://ca.internal:9000",
step_ca_fingerprint: "a".repeat(64),
step_ca_provisioner: "oidc",
step_ca_private_endpoint_allowlist: JSON.stringify([
"ca.internal",
"SSO.internal",
]),
};
state.pluginRows = [];
state.pluginInstalled = true;
});
describe("runStepCaSettingsMigration", () => {
it("moves the 2.8 settings and keeps the old redirect URI", async () => {
const result = await runStepCaSettingsMigration();
expect(result.moved).toEqual([
"step_ca_url",
"step_ca_fingerprint",
"step_ca_provisioner",
"step_ca_private_endpoint_allowlist",
]);
expect(result.legacyCallback).toBe(true);
expect(value("caUrl")).toBe("https://ca.internal:9000");
expect(value("fingerprint")).toBe("a".repeat(64));
expect(value("provisioner")).toBe("oidc");
expect(value("privateEndpoints")).toBe("ca.internal, sso.internal");
expect(value("legacyCallback")).toBe(true);
});
it("changes nothing the second time", async () => {
await runStepCaSettingsMigration();
const before = JSON.stringify(state.pluginRows);
const again = await runStepCaSettingsMigration();
expect(again).toEqual({ moved: [], legacyCallback: false });
expect(JSON.stringify(state.pluginRows)).toBe(before);
});
it("never overwrites a value set in the plugin", async () => {
state.pluginRows = [
{ key: "caUrl", value: JSON.stringify("https://new.ca") },
{ key: "legacyCallback", value: JSON.stringify(false) },
];
await runStepCaSettingsMigration();
expect(value("caUrl")).toBe("https://new.ca");
expect(value("legacyCallback")).toBe(false);
expect(value("provisioner")).toBe("oidc");
});
it("leaves an install that never used Step CA alone", async () => {
state.legacy = {};
const result = await runStepCaSettingsMigration();
expect(result).toEqual({ moved: [], legacyCallback: false });
expect(state.pluginRows).toEqual([]);
});
it("does nothing until the plugin row exists", async () => {
state.pluginInstalled = false;
await runStepCaSettingsMigration();
expect(state.pluginRows).toEqual([]);
});
});
@@ -0,0 +1,110 @@
/**
* Moves the 2.8 Step CA configuration into the step-ca plugin.
*
* The CA URL, root fingerprint, OIDC provisioner and private endpoint
* allowlist lived in core `settings` rows. They become the plugin's admin
* settings. An install that had a CA configured keeps sending the pre-2.9
* redirect URI (/host/step-ca-callback), because that is the one registered
* with its identity provider: the plugin's legacyCallback setting is turned
* on.
*
* Idempotent: a value already present in plugin_settings is never
* overwritten. The old rows stay for one release so a downgrade still works.
*/
import { databaseLogger } from "../logger.js";
import {
createCurrentPluginRepository,
createCurrentPluginSettingsRepository,
createCurrentSettingsRepository,
} from "../../database/repositories/factory.js";
import { parseNotificationAllowlist } from "../notification-egress.js";
const PLUGIN_ID = "step-ca";
const MOVES: { legacyKey: string; field: string }[] = [
{ legacyKey: "step_ca_url", field: "caUrl" },
{ legacyKey: "step_ca_fingerprint", field: "fingerprint" },
{ legacyKey: "step_ca_provisioner", field: "provisioner" },
];
const LEGACY_ALLOWLIST_KEY = "step_ca_private_endpoint_allowlist";
export interface StepCaSettingsMigrationResult {
moved: string[];
legacyCallback: boolean;
}
export async function runStepCaSettingsMigration(): Promise<StepCaSettingsMigrationResult> {
const result: StepCaSettingsMigrationResult = {
moved: [],
legacyCallback: false,
};
try {
// plugin_settings has a foreign key to plugins: nothing to move into yet.
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
if (!plugin) return result;
const settings = createCurrentSettingsRepository();
const pluginSettings = createCurrentPluginSettingsRepository();
const writeIfUnset = async (field: string, value: unknown) => {
const existing = await pluginSettings.get(
PLUGIN_ID,
"admin",
null,
field,
);
if (existing && existing.value !== null) return false;
await pluginSettings.set(
PLUGIN_ID,
"admin",
null,
field,
JSON.stringify(value),
);
return true;
};
for (const move of MOVES) {
const value = (await settings.get(move.legacyKey))?.trim();
if (!value) continue;
if (await writeIfUnset(move.field, value)) {
result.moved.push(move.legacyKey);
}
}
const hosts = parseNotificationAllowlist(
await settings.get(LEGACY_ALLOWLIST_KEY),
);
if (
hosts.length > 0 &&
(await writeIfUnset("privateEndpoints", hosts.join(", ")))
) {
result.moved.push(LEGACY_ALLOWLIST_KEY);
}
if ((await settings.get("step_ca_url"))?.trim()) {
result.legacyCallback = await writeIfUnset("legacyCallback", true);
}
if (result.moved.length > 0) {
databaseLogger.info(
`Moved ${result.moved.length} Step CA setting(s) into plugin settings`,
{
operation: "step_ca_settings_migration",
moved: result.moved.join(", "),
},
);
}
} catch (error) {
// Must not stop the backend: the plugin then reports "not configured".
databaseLogger.warn("Step CA settings migration failed", {
operation: "step_ca_settings_migration",
error: error instanceof Error ? error.message : String(error),
});
}
return result;
}
-13
View File
@@ -1,13 +0,0 @@
import { createCurrentSettingsRepository } from "../database/repositories/factory.js";
import { parseNotificationAllowlist } from "./notification-egress.js";
/** Private hosts (a CA on the LAN, an internal IdP) the Step CA flow may reach. */
export const STEP_CA_PRIVATE_ALLOWLIST_KEY =
"step_ca_private_endpoint_allowlist";
export async function readStepCaPrivateAllowlist(): Promise<string[]> {
const raw = await createCurrentSettingsRepository().get(
STEP_CA_PRIVATE_ALLOWLIST_KEY,
);
return parseNotificationAllowlist(raw);
}
-19
View File
@@ -14,25 +14,6 @@ export async function getNotificationPrivateEndpoints(): Promise<string[]> {
} }
} }
export async function getStepCaPrivateEndpoints(): Promise<string[]> {
try {
return (await authApi.get("/users/step-ca-private-endpoints")).data.hosts;
} catch (error) {
throw handleApiError(error, "get Step CA endpoint allowlist");
}
}
export async function setStepCaPrivateEndpoints(
hosts: string[],
): Promise<string[]> {
try {
return (await authApi.patch("/users/step-ca-private-endpoints", { hosts }))
.data.hosts;
} catch (error) {
throw handleApiError(error, "update Step CA endpoint allowlist");
}
}
export async function setNotificationPrivateEndpoints( export async function setNotificationPrivateEndpoints(
hosts: string[], hosts: string[],
): Promise<string[]> { ): Promise<string[]> {
-33
View File
@@ -33,39 +33,6 @@ export async function getSessionTimeout(): Promise<{ timeoutHours: number }> {
} }
} }
export interface StepCaSettings {
configured: boolean;
caUrl: string;
fingerprint: string;
provisioner: string;
}
export async function getStepCaSettings(): Promise<StepCaSettings> {
try {
const response = await authApi.get("/users/step-ca-settings");
return {
configured: !!response.data.configured,
caUrl: response.data.caUrl ?? "",
fingerprint: response.data.fingerprint ?? "",
provisioner: response.data.provisioner ?? "",
};
} catch (error) {
handleApiError(error, "fetch Step CA settings");
}
}
export async function updateStepCaSettings(input: {
caUrl: string;
fingerprint: string;
provisioner: string;
}): Promise<void> {
try {
await authApi.patch("/users/step-ca-settings", input);
} catch (error) {
handleApiError(error, "update Step CA settings");
}
}
export async function updateSessionTimeout( export async function updateSessionTimeout(
timeoutHours: number, timeoutHours: number,
): Promise<void> { ): Promise<void> {
-3
View File
@@ -46,7 +46,6 @@ import {
type SecondFactorRef, type SecondFactorRef,
} from "@/api/auth-methods-api"; } from "@/api/auth-methods-api";
import { useLoginMethods, useSecondFactors } from "@/plugin-host/auth-registry"; import { useLoginMethods, useSecondFactors } from "@/plugin-host/auth-registry";
import { ensureLegacyAuthUI } from "./legacy-auth-ui";
import { startPreLoginPlugins } from "@/plugin-host/loader"; import { startPreLoginPlugins } from "@/plugin-host/loader";
import { Checkbox } from "@/components/checkbox"; import { Checkbox } from "@/components/checkbox";
import { useBranding } from "@/contexts/BrandingContext"; import { useBranding } from "@/contexts/BrandingContext";
@@ -99,8 +98,6 @@ const LANGUAGES = [
{ code: "vi", label: "Tiếng Việt" }, { code: "vi", label: "Tiếng Việt" },
]; ];
ensureLegacyAuthUI();
const STORAGE_KEY = "termix_auth"; const STORAGE_KEY = "termix_auth";
/** An empty list means "whichever factors this screen has UI for". */ /** An empty list means "whichever factors this screen has UI for". */
-66
View File
@@ -1,66 +0,0 @@
/**
* The certificate SSH auth editors that still live in core for 2.9.0,
* registered through the same registry a plugin uses.
* Phase C moves each into its plugin by moving its block out of this file;
* D1 deletes the file once it is empty.
*/
import { useTranslation } from "react-i18next";
import { registerSshAuthEditor } from "@/plugin-host/auth-registry";
function CertificateAuthInfo({
labelKey,
descriptionKey,
docsUrl,
}: {
labelKey: string;
descriptionKey: string;
docsUrl: string;
}) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-2 border-t border-border pt-3">
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t(labelKey)}
</span>
<a
href={docsUrl}
target="_blank"
rel="noreferrer"
className="text-[10px] text-accent-brand hover:underline"
>
{t("hosts.docsLink")}
</a>
</div>
<p className="text-[10px] text-muted-foreground">{t(descriptionKey)}</p>
</div>
);
}
function StepCaAuthEditor() {
return (
<CertificateAuthInfo
labelKey="hosts.stepcaLabel"
descriptionKey="hosts.stepcaDesc"
docsUrl="https://smallstep.com/docs/step-ca/provisioners/#oauthoidc-single-sign-on"
/>
);
}
let registered = false;
export function ensureLegacyAuthUI(): void {
if (registered) return;
registered = true;
registerSshAuthEditor({
id: "stepca",
pluginId: "core",
titleKey: "hosts.filterAuthStepca",
component: StepCaAuthEditor,
});
}
/** Test helper. */
export function resetLegacyAuthUIForTests(): void {
registered = false;
}
-11
View File
@@ -643,8 +643,6 @@
"keyPassphraseSaved": "Passphrase saved, type to change", "keyPassphraseSaved": "Passphrase saved, type to change",
"replaceKey": "Replace key", "replaceKey": "Replace key",
"docsLink": "View docs", "docsLink": "View docs",
"stepcaLabel": "Step CA",
"stepcaDesc": "Sign in through your identity provider and let your Step CA issue a short-lived SSH certificate for this host. The CA URL, root fingerprint and OIDC provisioner are set by an administrator under Admin Settings.",
"agentLabel": "SSH Agent", "agentLabel": "SSH Agent",
"agentDesc": "Authenticate using an SSH agent running on the Termix server (Bitwarden, 1Password, gpg-agent, KeeAgent, ssh-agent). The agent must be running on the machine where Termix is hosted.", "agentDesc": "Authenticate using an SSH agent running on the Termix server (Bitwarden, 1Password, gpg-agent, KeeAgent, ssh-agent). The agent must be running on the machine where Termix is hosted.",
"agentAuthenticationDesc": "Use keys from an SSH agent to authenticate this connection. Leave the socket path empty to use SSH_AUTH_SOCK.", "agentAuthenticationDesc": "Use keys from an SSH agent to authenticate this connection. Leave the socket path empty to use SSH_AUTH_SOCK.",
@@ -1995,15 +1993,6 @@
"admin": "Administration" "admin": "Administration"
} }
}, },
"stepCa": "Step CA",
"stepCaDesc": "Issue short-lived SSH certificates from a smallstep CA. Enter the CA URL, its root fingerprint (as shown by step ca bootstrap) and the name of the OIDC provisioner; then choose \"Step CA\" as a host's authentication type. Leave all three empty to disable.",
"stepCaFingerprint": "Root fingerprint (SHA-256)",
"stepCaProvisioner": "OIDC provisioner name",
"stepCaSaved": "Step CA settings saved",
"stepCaSaveFailed": "Failed to save Step CA settings",
"stepCaPrivateEndpoints": "Allowed private Step CA hosts",
"stepCaPrivateEndpointsDesc": "Private hosts the Step CA certificate flow may contact: the CA itself and, if internal, your identity provider. Separate them with commas.",
"updateStepCaEndpointsFailed": "Failed to update the Step CA endpoint allowlist",
"notificationPrivateEndpoints": "Allowed private notification hosts", "notificationPrivateEndpoints": "Allowed private notification hosts",
"notificationPrivateEndpointsDesc": "Exact private hosts that notification channels may contact. Separate them with commas.", "notificationPrivateEndpointsDesc": "Exact private hosts that notification channels may contact. Separate them with commas.",
"updateNotificationEndpointsFailed": "Failed to update notification endpoint allowlist", "updateNotificationEndpointsFailed": "Failed to update notification endpoint allowlist",
-11
View File
@@ -771,8 +771,6 @@
"keyPassphraseSaved": "Wagwoordfrase gestoor, tik om te verander", "keyPassphraseSaved": "Wagwoordfrase gestoor, tik om te verander",
"replaceKey": "Vervang sleutel", "replaceKey": "Vervang sleutel",
"docsLink": "Bekyk dokumente", "docsLink": "Bekyk dokumente",
"stepcaLabel": "Stap CA",
"stepcaDesc": "Meld aan deur jou identiteitsverskaffer en laat jou Step CA 'n kortstondige SSH-sertifikaat vir hierdie gasheer uitreik. Die CA-URL, wortelvingerafdruk en OIDC-voorsiening word deur 'n administrateur onder Admin-instellings ingestel.",
"agentLabel": "SSH-agent", "agentLabel": "SSH-agent",
"agentDesc": "Verifieer deur 'n SSH-agent te gebruik wat op die Termix-bediener loop (Bitwarden, 1Password, gpg-agent, KeeAgent, ssh-agent). Die agent moet loop op die masjien waar Termix gehuisves word.", "agentDesc": "Verifieer deur 'n SSH-agent te gebruik wat op die Termix-bediener loop (Bitwarden, 1Password, gpg-agent, KeeAgent, ssh-agent). Die agent moet loop op die masjien waar Termix gehuisves word.",
"agentAuthenticationDesc": "Gebruik sleutels van 'n SSH-agent om hierdie verbinding te verifieer. Los die sokpad leeg om SSH_AUTH_SOCK te gebruik.", "agentAuthenticationDesc": "Gebruik sleutels van 'n SSH-agent om hierdie verbinding te verifieer. Los die sokpad leeg om SSH_AUTH_SOCK te gebruik.",
@@ -2121,18 +2119,9 @@
"save": "Stoor", "save": "Stoor",
"saving": "Stoor..." "saving": "Stoor..."
}, },
"stepCa": "Stap CA",
"stepCaDesc": "Reik kortstondige SSH-sertifikate uit vanaf 'n smallstep CA. Voer die CA-URL, die wortelvingerafdruk daarvan (soos getoon deur stap ca se selflaaistelsel) en die naam van die OIDC-voorsiener in; kies dan \"Stap CA\" as 'n gasheer se verifikasietipe. Los al drie leeg om te deaktiveer.",
"stepCaFingerprint": "Wortelvingerafdruk (SHA-256)",
"stepCaProvisioner": "OIDC-voorsieningsnaam",
"stepCaSaved": "Stap CA-instellings gestoor",
"stepCaSaveFailed": "Kon nie Stap CA-instellings stoor nie",
"secretSourcePrivateEndpoints": "Toegelate privaat geheime brongashere", "secretSourcePrivateEndpoints": "Toegelate privaat geheime brongashere",
"secretSourcePrivateEndpointsDesc": "Privaat gashere wat geheime bronne (1Password Connect) mag kontak. Skei hulle met kommas.", "secretSourcePrivateEndpointsDesc": "Privaat gashere wat geheime bronne (1Password Connect) mag kontak. Skei hulle met kommas.",
"updateSecretSourceEndpointsFailed": "Kon nie die geheime bron-eindpunt-toelaatlys opdateer nie", "updateSecretSourceEndpointsFailed": "Kon nie die geheime bron-eindpunt-toelaatlys opdateer nie",
"stepCaPrivateEndpoints": "Toegelate private Step CA-gashere",
"stepCaPrivateEndpointsDesc": "Privaat gashere, die Step CA-sertifikaatvloei, mag kontak maak met: die CA self en, indien intern, jou identiteitsverskaffer. Skei hulle met kommas.",
"updateStepCaEndpointsFailed": "Kon nie die Step CA-eindpunt-toelaatlys opdateer nie",
"notificationPrivateEndpoints": "Toegelate privaat kennisgewinggashere", "notificationPrivateEndpoints": "Toegelate privaat kennisgewinggashere",
"notificationPrivateEndpointsDesc": "Presiese private gashere wat kennisgewingkanale mag kontak. Skei hulle met kommas.", "notificationPrivateEndpointsDesc": "Presiese private gashere wat kennisgewingkanale mag kontak. Skei hulle met kommas.",
"updateNotificationEndpointsFailed": "Kon nie kennisgewing-eindpunt-toelaatlys opdateer nie" "updateNotificationEndpointsFailed": "Kon nie kennisgewing-eindpunt-toelaatlys opdateer nie"
-11
View File
@@ -771,8 +771,6 @@
"keyPassphraseSaved": "تم حفظ عبارة المرور، اكتب للتغيير", "keyPassphraseSaved": "تم حفظ عبارة المرور، اكتب للتغيير",
"replaceKey": "استبدال المفتاح", "replaceKey": "استبدال المفتاح",
"docsLink": "عرض المستندات", "docsLink": "عرض المستندات",
"stepcaLabel": "الخطوة ج",
"stepcaDesc": "سجّل الدخول عبر موفر الهوية الخاص بك، ودع جهة إصدار الشهادات المعتمدة (SA) التابعة لـ Step CA تُصدر شهادة SSH مؤقتة لهذا المضيف. يتم تحديد عنوان URL الخاص بجهة إصدار الشهادات المعتمدة، وبصمة الجذر، وموفر OIDC بواسطة مسؤول النظام ضمن إعدادات المسؤول.",
"agentLabel": "وكيل SSH", "agentLabel": "وكيل SSH",
"agentDesc": "المصادقة باستخدام وكيل SSH يعمل على خادم Termix (مثل Bitwarden, 1Password, gpg-agent, KeeAgent, ssh-agent). يجب أن يكون الوكيل قيد التشغيل على الجهاز الذي يستضيف Termix.", "agentDesc": "المصادقة باستخدام وكيل SSH يعمل على خادم Termix (مثل Bitwarden, 1Password, gpg-agent, KeeAgent, ssh-agent). يجب أن يكون الوكيل قيد التشغيل على الجهاز الذي يستضيف Termix.",
"agentAuthenticationDesc": "استخدم مفاتيح من وكيل SSH لمصادقة هذا الاتصال. اترك مسار المقبس فارغًا لاستخدام SSH_AUTH_SOCK.", "agentAuthenticationDesc": "استخدم مفاتيح من وكيل SSH لمصادقة هذا الاتصال. اترك مسار المقبس فارغًا لاستخدام SSH_AUTH_SOCK.",
@@ -2121,18 +2119,9 @@
"save": "حفظ", "save": "حفظ",
"saving": "جارٍ الحفظ..." "saving": "جارٍ الحفظ..."
}, },
"stepCa": "الخطوة ج",
"stepCaDesc": "إصدار شهادات SSH قصيرة الأجل من جهة مصدقة صغيرة (Smallstep CA). أدخل عنوان URL الخاص بالجهة المصدقة، وبصمة الجذر (كما هو موضح في أمر step ca bootstrap)، واسم موفر OIDC؛ ثم اختر \"Step CA\" كنوع مصادقة المضيف. اترك الحقول الثلاثة فارغة لتعطيل هذه الميزة.",
"stepCaFingerprint": "بصمة الجذر (SHA-256)",
"stepCaProvisioner": "اسم موفر OIDC",
"stepCaSaved": "تم حفظ إعدادات CA للخطوة",
"stepCaSaveFailed": "فشل حفظ إعدادات الخطوة CA",
"secretSourcePrivateEndpoints": "مضيفو مصادر الأسرار الخاصة المسموح بهم", "secretSourcePrivateEndpoints": "مضيفو مصادر الأسرار الخاصة المسموح بهم",
"secretSourcePrivateEndpointsDesc": "المضيفون الخاصون الذين قد تتصل بهم مصادر سرية (مثل 1Password Connect). افصل بينهم بفواصل.", "secretSourcePrivateEndpointsDesc": "المضيفون الخاصون الذين قد تتصل بهم مصادر سرية (مثل 1Password Connect). افصل بينهم بفواصل.",
"updateSecretSourceEndpointsFailed": "فشل تحديث قائمة السماح لنقاط نهاية المصدر السرية", "updateSecretSourceEndpointsFailed": "فشل تحديث قائمة السماح لنقاط نهاية المصدر السرية",
"stepCaPrivateEndpoints": "مضيفو شهادات الخطوة الخاصة المسموح بها",
"stepCaPrivateEndpointsDesc": "قد تتصل جهات الاستضافة الخاصة بعملية شهادة Step CA بما يلي: جهة إصدار الشهادات نفسها، وإذا كانت داخلية، مزود الهوية الخاص بك. افصل بينهما بفواصل.",
"updateStepCaEndpointsFailed": "فشل تحديث قائمة السماح لنقاط نهاية شهادة التحقق من صحة الخطوة",
"notificationPrivateEndpoints": "المضيفون المسموح لهم بالإشعارات الخاصة", "notificationPrivateEndpoints": "المضيفون المسموح لهم بالإشعارات الخاصة",
"notificationPrivateEndpointsDesc": "حدد المضيفين الخاصين الذين يمكن لقنوات الإشعارات الاتصال بهم. افصل بينهم بفواصل.", "notificationPrivateEndpointsDesc": "حدد المضيفين الخاصين الذين يمكن لقنوات الإشعارات الاتصال بهم. افصل بينهم بفواصل.",
"updateNotificationEndpointsFailed": "فشل تحديث قائمة السماح لنقاط نهاية الإشعارات" "updateNotificationEndpointsFailed": "فشل تحديث قائمة السماح لنقاط نهاية الإشعارات"
-11
View File
@@ -771,8 +771,6 @@
"keyPassphraseSaved": "Паролата е запазена, въведете, за да я промените", "keyPassphraseSaved": "Паролата е запазена, въведете, за да я промените",
"replaceKey": "Сменете ключа", "replaceKey": "Сменете ключа",
"docsLink": "Преглед на документи", "docsLink": "Преглед на документи",
"stepcaLabel": "Стъпка CA",
"stepcaDesc": "Влезте чрез вашия доставчик на самоличност и позволете на вашия Step CA да издаде краткотраен SSH сертификат за този хост. URL адресът на CA, root пръстовият отпечатък и OIDC provisioner се задават от администратор в „Настройки на администратора“.",
"agentLabel": "SSH агент", "agentLabel": "SSH агент",
"agentDesc": "Удостоверете се, използвайки SSH агент, работещ на Termix сървъра (Bitwarden, 1Password, gpg-agent, KeeAgent, ssh-agent). Агентът трябва да работи на машината, където се хоства Termix.", "agentDesc": "Удостоверете се, използвайки SSH агент, работещ на Termix сървъра (Bitwarden, 1Password, gpg-agent, KeeAgent, ssh-agent). Агентът трябва да работи на машината, където се хоства Termix.",
"agentAuthenticationDesc": "Използвайте ключове от SSH агент за удостоверяване на тази връзка. Оставете пътя на сокета празен, за да използвате SSH_AUTH_SOCK.", "agentAuthenticationDesc": "Използвайте ключове от SSH агент за удостоверяване на тази връзка. Оставете пътя на сокета празен, за да използвате SSH_AUTH_SOCK.",
@@ -2121,18 +2119,9 @@
"save": "Запазване", "save": "Запазване",
"saving": "Запазване..." "saving": "Запазване..."
}, },
"stepCa": "Стъпка CA",
"stepCaDesc": "Издавайте краткотрайни SSH сертификати от smallstep CA. Въведете URL адреса на CA, неговия root fingerprint (както е показано от step ca bootstrap) и името на OIDC provisioner; след това изберете „Step CA“ като тип удостоверяване на хоста. Оставете и трите празни, за да деактивирате.",
"stepCaFingerprint": "Коренен пръстов отпечатък (SHA-256)",
"stepCaProvisioner": "Име на доставчика на OIDC",
"stepCaSaved": "Настройките на стъпка CA са запазени",
"stepCaSaveFailed": "Запазването на настройките на стъпка CA не бе успешно",
"secretSourcePrivateEndpoints": "Разрешени частни секретни хостове от източници", "secretSourcePrivateEndpoints": "Разрешени частни секретни хостове от източници",
"secretSourcePrivateEndpointsDesc": "Частни хостове, с които тайни източници (1Password Connect) могат да се свържат. Разделете ги със запетаи.", "secretSourcePrivateEndpointsDesc": "Частни хостове, с които тайни източници (1Password Connect) могат да се свържат. Разделете ги със запетаи.",
"updateSecretSourceEndpointsFailed": "Актуализирането на списъка с разрешени крайни точки на секретния източник не бе успешно", "updateSecretSourceEndpointsFailed": "Актуализирането на списъка с разрешени крайни точки на секретния източник не бе успешно",
"stepCaPrivateEndpoints": "Разрешени частни хостове на Step CA",
"stepCaPrivateEndpointsDesc": "Частни хостове, с които потокът от сертификати на Step CA може да се свърже: самия CA и, ако е вътрешен, вашият доставчик на идентичност. Разделете ги със запетаи.",
"updateStepCaEndpointsFailed": "Актуализирането на списъка с разрешени крайни точки на Step CA не бе успешно",
"notificationPrivateEndpoints": "Разрешени хостове за частни известия", "notificationPrivateEndpoints": "Разрешени хостове за частни известия",
"notificationPrivateEndpointsDesc": "Точните частни хостове, с които каналите за известия могат да се свържат. Разделете ги със запетаи.", "notificationPrivateEndpointsDesc": "Точните частни хостове, с които каналите за известия могат да се свържат. Разделете ги със запетаи.",
"updateNotificationEndpointsFailed": "Актуализирането на списъка с разрешени крайни точки за известия не бе успешно" "updateNotificationEndpointsFailed": "Актуализирането на списъка с разрешени крайни точки за известия не бе успешно"
-11
View File
@@ -771,8 +771,6 @@
"keyPassphraseSaved": "পাসফ্রেজ সংরক্ষিত হয়েছে, পরিবর্তন করতে টাইপ করুন", "keyPassphraseSaved": "পাসফ্রেজ সংরক্ষিত হয়েছে, পরিবর্তন করতে টাইপ করুন",
"replaceKey": "চাবি প্রতিস্থাপন করুন", "replaceKey": "চাবি প্রতিস্থাপন করুন",
"docsLink": "নথি দেখুন", "docsLink": "নথি দেখুন",
"stepcaLabel": "স্টেপ সিএ",
"stepcaDesc": "আপনার আইডেন্টিটি প্রোভাইডারের মাধ্যমে সাইন ইন করুন এবং আপনার স্টেপ সিএ-কে এই হোস্টের জন্য একটি স্বল্পস্থায়ী এসএসএইচ সার্টিফিকেট ইস্যু করতে দিন। সিএ ইউআরএল, রুট ফিঙ্গারপ্রিন্ট এবং ওআইডিসি প্রোভিশনার একজন অ্যাডমিনিস্ট্রেটর দ্বারা অ্যাডমিন সেটিংস-এর অধীনে সেট করা হয়।",
"agentLabel": "এসএসএইচ এজেন্ট", "agentLabel": "এসএসএইচ এজেন্ট",
"agentDesc": "Termix সার্ভারে চলমান একটি SSH এজেন্ট (Bitwarden, 1Password, gpg-agent, KeeAgent, ssh-agent) ব্যবহার করে প্রমাণীকরণ করুন। এজেন্টটি অবশ্যই সেই মেশিনে চালু থাকতে হবে যেখানে Termix হোস্ট করা আছে।", "agentDesc": "Termix সার্ভারে চলমান একটি SSH এজেন্ট (Bitwarden, 1Password, gpg-agent, KeeAgent, ssh-agent) ব্যবহার করে প্রমাণীকরণ করুন। এজেন্টটি অবশ্যই সেই মেশিনে চালু থাকতে হবে যেখানে Termix হোস্ট করা আছে।",
"agentAuthenticationDesc": "এই সংযোগটি প্রমাণীকরণের জন্য একটি SSH এজেন্ট থেকে কী ব্যবহার করুন। SSH_AUTH_SOCK ব্যবহার করতে সকেট পাথটি খালি রাখুন।", "agentAuthenticationDesc": "এই সংযোগটি প্রমাণীকরণের জন্য একটি SSH এজেন্ট থেকে কী ব্যবহার করুন। SSH_AUTH_SOCK ব্যবহার করতে সকেট পাথটি খালি রাখুন।",
@@ -2121,18 +2119,9 @@
"save": "সংরক্ষণ করুন", "save": "সংরক্ষণ করুন",
"saving": "সঞ্চয়..." "saving": "সঞ্চয়..."
}, },
"stepCa": "স্টেপ সিএ",
"stepCaDesc": "একটি smallstep CA থেকে স্বল্পস্থায়ী SSH সার্টিফিকেট ইস্যু করুন। CA URL, এর রুট ফিঙ্গারপ্রিন্ট (যেমনটি step ca bootstrap দ্বারা দেখানো হয়েছে) এবং OIDC প্রোভিশনারের নাম লিখুন; তারপর হোস্টের অথেনটিকেশন টাইপ হিসেবে \"Step CA\" বেছে নিন। নিষ্ক্রিয় করতে তিনটিই খালি রাখুন।",
"stepCaFingerprint": "রুট ফিঙ্গারপ্রিন্ট (SHA-256)",
"stepCaProvisioner": "OIDC সরবরাহকারীর নাম",
"stepCaSaved": "ধাপ CA সেটিংস সংরক্ষিত হয়েছে",
"stepCaSaveFailed": "স্টেপ সিএ সেটিংস সংরক্ষণ করতে ব্যর্থ হয়েছে",
"secretSourcePrivateEndpoints": "অনুমোদিত ব্যক্তিগত গোপন উৎস হোস্ট", "secretSourcePrivateEndpoints": "অনুমোদিত ব্যক্তিগত গোপন উৎস হোস্ট",
"secretSourcePrivateEndpointsDesc": "ব্যক্তিগত হোস্ট, যাদের সাথে গোপন সূত্র (1Password Connect) যোগাযোগ করতে পারে। এগুলিকে কমা দিয়ে আলাদা করুন।", "secretSourcePrivateEndpointsDesc": "ব্যক্তিগত হোস্ট, যাদের সাথে গোপন সূত্র (1Password Connect) যোগাযোগ করতে পারে। এগুলিকে কমা দিয়ে আলাদা করুন।",
"updateSecretSourceEndpointsFailed": "গোপন উৎস এন্ডপয়েন্ট অনুমতি তালিকা আপডেট করতে ব্যর্থ হয়েছে", "updateSecretSourceEndpointsFailed": "গোপন উৎস এন্ডপয়েন্ট অনুমতি তালিকা আপডেট করতে ব্যর্থ হয়েছে",
"stepCaPrivateEndpoints": "ব্যক্তিগত স্টেপ সিএ হোস্টদের অনুমতি দেওয়া হয়েছে",
"stepCaPrivateEndpointsDesc": "স্টেপ সিএ সার্টিফিকেট ফ্লো যেসব প্রাইভেট হোস্টের সাথে যোগাযোগ করতে পারে, সেগুলো হলো: স্বয়ং সিএ এবং, যদি অভ্যন্তরীণ হয়, আপনার আইডেন্টিটি প্রোভাইডার। এদেরকে কমা দিয়ে আলাদা করুন।",
"updateStepCaEndpointsFailed": "Step CA এন্ডপয়েন্ট অ্যালাওলিস্ট আপডেট করতে ব্যর্থ হয়েছে",
"notificationPrivateEndpoints": "ব্যক্তিগত বিজ্ঞপ্তি হোস্ট অনুমোদিত", "notificationPrivateEndpoints": "ব্যক্তিগত বিজ্ঞপ্তি হোস্ট অনুমোদিত",
"notificationPrivateEndpointsDesc": "সুনির্দিষ্ট ব্যক্তিগত হোস্টগুলো, যাদের সাথে নোটিফিকেশন চ্যানেলগুলো যোগাযোগ করতে পারে। এগুলোকে কমা দিয়ে আলাদা করুন।", "notificationPrivateEndpointsDesc": "সুনির্দিষ্ট ব্যক্তিগত হোস্টগুলো, যাদের সাথে নোটিফিকেশন চ্যানেলগুলো যোগাযোগ করতে পারে। এগুলোকে কমা দিয়ে আলাদা করুন।",
"updateNotificationEndpointsFailed": "নোটিফিকেশন এন্ডপয়েন্ট অ্যালাওলিস্ট আপডেট করতে ব্যর্থ হয়েছে" "updateNotificationEndpointsFailed": "নোটিফিকেশন এন্ডপয়েন্ট অ্যালাওলিস্ট আপডেট করতে ব্যর্থ হয়েছে"
-11
View File
@@ -771,8 +771,6 @@
"keyPassphraseSaved": "Contrasenya desada, escriu per canviar-la", "keyPassphraseSaved": "Contrasenya desada, escriu per canviar-la",
"replaceKey": "Substitueix la clau", "replaceKey": "Substitueix la clau",
"docsLink": "Veure documents", "docsLink": "Veure documents",
"stepcaLabel": "Pas CA",
"stepcaDesc": "Inicieu la sessió a través del vostre proveïdor d'identitat i deixeu que la vostra Step CA emeti un certificat SSH de curta durada per a aquest amfitrió. L'URL de la CA, l'empremta digital arrel i el proveïdor OIDC els estableix un administrador a Configuració de l'administrador.",
"agentLabel": "Agent SSH", "agentLabel": "Agent SSH",
"agentDesc": "Autentiqueu-vos mitjançant un agent SSH que s'executi al servidor Termix (Bitwarden, 1Password, gpg-agent, KeeAgent, ssh-agent). L'agent ha d'estar executant-se a la màquina on s'allotja Termix.", "agentDesc": "Autentiqueu-vos mitjançant un agent SSH que s'executi al servidor Termix (Bitwarden, 1Password, gpg-agent, KeeAgent, ssh-agent). L'agent ha d'estar executant-se a la màquina on s'allotja Termix.",
"agentAuthenticationDesc": "Feu servir claus d'un agent SSH per autenticar aquesta connexió. Deixeu la ruta del sòcol buida per utilitzar SSH_AUTH_SOCK.", "agentAuthenticationDesc": "Feu servir claus d'un agent SSH per autenticar aquesta connexió. Deixeu la ruta del sòcol buida per utilitzar SSH_AUTH_SOCK.",
@@ -2121,18 +2119,9 @@
"save": "Desa", "save": "Desa",
"saving": "Desant..." "saving": "Desant..."
}, },
"stepCa": "Pas CA",
"stepCaDesc": "Emet certificats SSH de curta durada des d'una CA Smallstep. Introdueix l'URL de la CA, la seva empremta digital arrel (tal com es mostra a l'arrencada de la CA Step) i el nom del proveïdor OIDC; a continuació, tria \"Step CA\" com a tipus d'autenticació de l'amfitrió. Deixa els tres buits per desactivar-los.",
"stepCaFingerprint": "Empremta digital arrel (SHA-256)",
"stepCaProvisioner": "Nom del proveïdor OIDC",
"stepCaSaved": "S'ha desat la configuració de l'Administració de Control del Pas",
"stepCaSaveFailed": "No s'ha pogut desar la configuració de Step CA.",
"secretSourcePrivateEndpoints": "Amfitrions d'origen secret privat permesos", "secretSourcePrivateEndpoints": "Amfitrions d'origen secret privat permesos",
"secretSourcePrivateEndpointsDesc": "Amfitrions privats amb els quals es poden posar en contacte fonts secretes (1Password Connect). Separeu-los amb comes.", "secretSourcePrivateEndpointsDesc": "Amfitrions privats amb els quals es poden posar en contacte fonts secretes (1Password Connect). Separeu-los amb comes.",
"updateSecretSourceEndpointsFailed": "No s'ha pogut actualitzar la llista de permesos del punt final de la font secreta", "updateSecretSourceEndpointsFailed": "No s'ha pogut actualitzar la llista de permesos del punt final de la font secreta",
"stepCaPrivateEndpoints": "Amfitrions privats permesos de Step CA",
"stepCaPrivateEndpointsDesc": "Amfitrions privats amb els quals el flux de certificats de la CA de Step pot contactar: la mateixa CA i, si és intern, el vostre proveïdor d'identitat. Separeu-los amb comes.",
"updateStepCaEndpointsFailed": "No s'ha pogut actualitzar la llista permesa del punt final de Step CA.",
"notificationPrivateEndpoints": "Amfitrions de notificacions privades permesos", "notificationPrivateEndpoints": "Amfitrions de notificacions privades permesos",
"notificationPrivateEndpointsDesc": "Amfitrions privats exactes amb els quals els canals de notificació poden contactar. Separeu-los amb comes.", "notificationPrivateEndpointsDesc": "Amfitrions privats exactes amb els quals els canals de notificació poden contactar. Separeu-los amb comes.",
"updateNotificationEndpointsFailed": "No s'ha pogut actualitzar la llista de permesos del punt final de notificació" "updateNotificationEndpointsFailed": "No s'ha pogut actualitzar la llista de permesos del punt final de notificació"
-11
View File
@@ -771,8 +771,6 @@
"keyPassphraseSaved": "Heslo uloženo, zadejte heslo pro změnu.", "keyPassphraseSaved": "Heslo uloženo, zadejte heslo pro změnu.",
"replaceKey": "Vyměňte klíč", "replaceKey": "Vyměňte klíč",
"docsLink": "Zobrazit dokumenty", "docsLink": "Zobrazit dokumenty",
"stepcaLabel": "Krok CA",
"stepcaDesc": "Přihlaste se prostřednictvím svého poskytovatele identity a nechte svou certifikační autoritu Step CA vydat krátkodobý SSH certifikát pro tohoto hostitele. URL certifikační autority, otisk kořenového serveru a zřizovatel OIDC nastavuje administrátor v Nastavení správce.",
"agentLabel": "SSH agent", "agentLabel": "SSH agent",
"agentDesc": "Ověřte se pomocí SSH agenta spuštěného na serveru Termix (Bitwarden, 1Password, gpg-agent, KeeAgent, ssh-agent). Agent musí běžet na počítači, kde je hostován Termix.", "agentDesc": "Ověřte se pomocí SSH agenta spuštěného na serveru Termix (Bitwarden, 1Password, gpg-agent, KeeAgent, ssh-agent). Agent musí běžet na počítači, kde je hostován Termix.",
"agentAuthenticationDesc": "K ověření tohoto připojení použijte klíče od SSH agenta. Pro použití SSH_AUTH_SOCK nechte cestu k socketu prázdnou.", "agentAuthenticationDesc": "K ověření tohoto připojení použijte klíče od SSH agenta. Pro použití SSH_AUTH_SOCK nechte cestu k socketu prázdnou.",
@@ -2121,18 +2119,9 @@
"save": "Uložit", "save": "Uložit",
"saving": "Ukládání..." "saving": "Ukládání..."
}, },
"stepCa": "Krok CA",
"stepCaDesc": "Vydávejte krátkodobé SSH certifikáty z certifikační autority smallstep. Zadejte URL adresu certifikační autority, její kořenový otisk prstu (jak je znázorněno v kroku ca bootstrap) a název poskytovatele OIDC; poté jako typ ověřování hostitele vyberte „Step CA“. Pro deaktivaci ponechte všechny tři pole prázdné.",
"stepCaFingerprint": "Kořenový otisk (SHA-256)",
"stepCaProvisioner": "Název zřizovatele OIDC",
"stepCaSaved": "Nastavení kroku CA uloženo",
"stepCaSaveFailed": "Nepodařilo se uložit nastavení kroku CA",
"secretSourcePrivateEndpoints": "Povolené hostitele soukromých tajných zdrojů", "secretSourcePrivateEndpoints": "Povolené hostitele soukromých tajných zdrojů",
"secretSourcePrivateEndpointsDesc": "Soukromé hostitele, které mohou kontaktovat tajné zdroje (1Password Connect). Oddělte je čárkami.", "secretSourcePrivateEndpointsDesc": "Soukromé hostitele, které mohou kontaktovat tajné zdroje (1Password Connect). Oddělte je čárkami.",
"updateSecretSourceEndpointsFailed": "Nepodařilo se aktualizovat seznam povolených koncových bodů tajného zdroje.", "updateSecretSourceEndpointsFailed": "Nepodařilo se aktualizovat seznam povolených koncových bodů tajného zdroje.",
"stepCaPrivateEndpoints": "Povolení soukromí hostitelé Step CA",
"stepCaPrivateEndpointsDesc": "Soukromí hostitelé, které může tok certifikátů Step CA kontaktovat: samotná CA a v případě interní certifikace také váš poskytovatel identity. Oddělte je čárkami.",
"updateStepCaEndpointsFailed": "Nepodařilo se aktualizovat seznam povolených koncových bodů Step CA.",
"notificationPrivateEndpoints": "Povolení hostitelé soukromých oznámení", "notificationPrivateEndpoints": "Povolení hostitelé soukromých oznámení",
"notificationPrivateEndpointsDesc": "Přesné soukromé hostitele, které mohou notifikační kanály kontaktovat. Oddělte je čárkami.", "notificationPrivateEndpointsDesc": "Přesné soukromé hostitele, které mohou notifikační kanály kontaktovat. Oddělte je čárkami.",
"updateNotificationEndpointsFailed": "Nepodařilo se aktualizovat seznam povolených koncových bodů oznámení" "updateNotificationEndpointsFailed": "Nepodařilo se aktualizovat seznam povolených koncových bodů oznámení"
-11
View File
@@ -771,8 +771,6 @@
"keyPassphraseSaved": "Adgangskode gemt, skriv for at ændre", "keyPassphraseSaved": "Adgangskode gemt, skriv for at ændre",
"replaceKey": "Udskift nøgle", "replaceKey": "Udskift nøgle",
"docsLink": "Se dokumenter", "docsLink": "Se dokumenter",
"stepcaLabel": "Trin CA",
"stepcaDesc": "Log ind via din identitetsudbyder, og lad din Step CA udstede et kortvarigt SSH-certifikat til denne vært. CA-URL'en, root-fingeraftrykket og OIDC-provisioneren indstilles af en administrator under administratorindstillinger.",
"agentLabel": "SSH-agent", "agentLabel": "SSH-agent",
"agentDesc": "Godkend ved hjælp af en SSH-agent, der kører på Termix-serveren (Bitwarden, 1Password, gpg-agent, KeeAgent, ssh-agent). Agenten skal køre på den maskine, hvor Termix hostes.", "agentDesc": "Godkend ved hjælp af en SSH-agent, der kører på Termix-serveren (Bitwarden, 1Password, gpg-agent, KeeAgent, ssh-agent). Agenten skal køre på den maskine, hvor Termix hostes.",
"agentAuthenticationDesc": "Brug nøgler fra en SSH-agent til at godkende denne forbindelse. Lad socket-stien være tom for at bruge SSH_AUTH_SOCK.", "agentAuthenticationDesc": "Brug nøgler fra en SSH-agent til at godkende denne forbindelse. Lad socket-stien være tom for at bruge SSH_AUTH_SOCK.",
@@ -2121,18 +2119,9 @@
"save": "Spare", "save": "Spare",
"saving": "Gemmer..." "saving": "Gemmer..."
}, },
"stepCa": "Trin CA",
"stepCaDesc": "Udsted kortlivede SSH-certifikater fra en smallstep CA. Indtast CA-URL'en, dens rodfingeraftryk (som vist i step ca bootstrap) og navnet på OIDC-provisioneren; vælg derefter \"Step CA\" som en værts godkendelsestype. Lad alle tre være tomme for at deaktivere.",
"stepCaFingerprint": "Rodfingeraftryk (SHA-256)",
"stepCaProvisioner": "OIDC-provisioneringsnavn",
"stepCaSaved": "Trin CA-indstillinger gemt",
"stepCaSaveFailed": "Kunne ikke gemme trin CA-indstillinger",
"secretSourcePrivateEndpoints": "Tilladte private hemmelige kildeværter", "secretSourcePrivateEndpoints": "Tilladte private hemmelige kildeværter",
"secretSourcePrivateEndpointsDesc": "Private værter som hemmelige kilder (1Password Connect) kan kontakte. Adskil dem med kommaer.", "secretSourcePrivateEndpointsDesc": "Private værter som hemmelige kilder (1Password Connect) kan kontakte. Adskil dem med kommaer.",
"updateSecretSourceEndpointsFailed": "Kunne ikke opdatere tilladelseslisten for det hemmelige kildeslutpunkt", "updateSecretSourceEndpointsFailed": "Kunne ikke opdatere tilladelseslisten for det hemmelige kildeslutpunkt",
"stepCaPrivateEndpoints": "Tilladte private Step CA-værter",
"stepCaPrivateEndpointsDesc": "Private værter, som Step CA-certifikatflowet kan kontakte: selve CA'en og, hvis den er intern, din identitetsudbyder. Adskil dem med kommaer.",
"updateStepCaEndpointsFailed": "Kunne ikke opdatere Step CA-slutpunktstilladelseslisten",
"notificationPrivateEndpoints": "Tilladte private notifikationsværter", "notificationPrivateEndpoints": "Tilladte private notifikationsværter",
"notificationPrivateEndpointsDesc": "Præcise private værter, som notifikationskanaler kan kontakte. Adskil dem med kommaer.", "notificationPrivateEndpointsDesc": "Præcise private værter, som notifikationskanaler kan kontakte. Adskil dem med kommaer.",
"updateNotificationEndpointsFailed": "Kunne ikke opdatere tilladelseslisten for notifikationsslutpunktet" "updateNotificationEndpointsFailed": "Kunne ikke opdatere tilladelseslisten for notifikationsslutpunktet"
-11
View File
@@ -771,8 +771,6 @@
"keyPassphraseSaved": "Passphrase gespeichert, zum Ändern eingeben", "keyPassphraseSaved": "Passphrase gespeichert, zum Ändern eingeben",
"replaceKey": "Schlüssel ersetzen", "replaceKey": "Schlüssel ersetzen",
"docsLink": "Dokumentation anzeigen", "docsLink": "Dokumentation anzeigen",
"stepcaLabel": "Schritt CA",
"stepcaDesc": "Melden Sie sich über Ihren Identitätsanbieter an und lassen Sie Ihre Step-Zertifizierungsstelle ein kurzlebiges SSH-Zertifikat für diesen Host ausstellen. Die URL der Zertifizierungsstelle, der Root-Fingerabdruck und der OIDC-Provisioner werden von einem Administrator unter „Admin-Einstellungen“ festgelegt.",
"agentLabel": "SSH-Agent", "agentLabel": "SSH-Agent",
"agentDesc": "Authentifizieren Sie sich mit einem SSH-Agenten, der auf dem Termix-Server läuft (Bitwarden, 1Password, gpg-agent, KeeAgent, ssh-agent). Der Agent muss auf dem Rechner laufen, auf dem Termix gehostet wird.", "agentDesc": "Authentifizieren Sie sich mit einem SSH-Agenten, der auf dem Termix-Server läuft (Bitwarden, 1Password, gpg-agent, KeeAgent, ssh-agent). Der Agent muss auf dem Rechner laufen, auf dem Termix gehostet wird.",
"agentAuthenticationDesc": "Verwenden Sie Schlüssel eines SSH-Agenten, um diese Verbindung zu authentifizieren. Lassen Sie den Socket-Pfad leer, um SSH_AUTH_SOCK zu verwenden.", "agentAuthenticationDesc": "Verwenden Sie Schlüssel eines SSH-Agenten, um diese Verbindung zu authentifizieren. Lassen Sie den Socket-Pfad leer, um SSH_AUTH_SOCK zu verwenden.",
@@ -2121,18 +2119,9 @@
"save": "Speichern", "save": "Speichern",
"saving": "Wird gespeichert …" "saving": "Wird gespeichert …"
}, },
"stepCa": "Schritt CA",
"stepCaDesc": "Stellen Sie kurzlebige SSH-Zertifikate von einer Smallstep-CA aus. Geben Sie die CA-URL, ihren Root-Fingerabdruck (wie von step ca bootstrap angezeigt) und den Namen des OIDC-Provisioners ein. Wählen Sie anschließend „Step CA“ als Authentifizierungstyp für den Host. Lassen Sie alle drei Felder leer, um die Authentifizierung zu deaktivieren.",
"stepCaFingerprint": "Root-Fingerabdruck (SHA-256)",
"stepCaProvisioner": "OIDC-Provisionername",
"stepCaSaved": "Schritt CA-Einstellungen gespeichert",
"stepCaSaveFailed": "Speichern der Step CA-Einstellungen fehlgeschlagen",
"secretSourcePrivateEndpoints": "Zulässige private geheime Quellhosts", "secretSourcePrivateEndpoints": "Zulässige private geheime Quellhosts",
"secretSourcePrivateEndpointsDesc": "Private Hosts, die von geheimen Quellen (1Password Connect) kontaktiert werden können. Trennen Sie diese durch Kommas.", "secretSourcePrivateEndpointsDesc": "Private Hosts, die von geheimen Quellen (1Password Connect) kontaktiert werden können. Trennen Sie diese durch Kommas.",
"updateSecretSourceEndpointsFailed": "Die Aktualisierung der Zulassungsliste für geheime Quellendpunkte ist fehlgeschlagen.", "updateSecretSourceEndpointsFailed": "Die Aktualisierung der Zulassungsliste für geheime Quellendpunkte ist fehlgeschlagen.",
"stepCaPrivateEndpoints": "Zulässige private Step CA-Hosts",
"stepCaPrivateEndpointsDesc": "Private Hosts, mit denen der Step CA-Zertifikatsfluss in Kontakt treten kann, kontaktieren möglicherweise die Zertifizierungsstelle selbst und, falls intern, Ihren Identitätsanbieter. Trennen Sie diese durch Kommas.",
"updateStepCaEndpointsFailed": "Die Aktualisierung der Step CA-Endpunkt-Zulassungsliste ist fehlgeschlagen.",
"notificationPrivateEndpoints": "Zulässige Hosts für private Benachrichtigungen", "notificationPrivateEndpoints": "Zulässige Hosts für private Benachrichtigungen",
"notificationPrivateEndpointsDesc": "Genaue private Hosts, die Benachrichtigungskanäle kontaktieren dürfen. Durch Kommas trennen.", "notificationPrivateEndpointsDesc": "Genaue private Hosts, die Benachrichtigungskanäle kontaktieren dürfen. Durch Kommas trennen.",
"updateNotificationEndpointsFailed": "Aktualisierung der Zulassungsliste für Benachrichtigungsendpunkte fehlgeschlagen" "updateNotificationEndpointsFailed": "Aktualisierung der Zulassungsliste für Benachrichtigungsendpunkte fehlgeschlagen"
-11
View File
@@ -771,8 +771,6 @@
"keyPassphraseSaved": "Η φράση πρόσβασης αποθηκεύτηκε, πληκτρολογήστε για αλλαγή", "keyPassphraseSaved": "Η φράση πρόσβασης αποθηκεύτηκε, πληκτρολογήστε για αλλαγή",
"replaceKey": "Αντικατάσταση κλειδιού", "replaceKey": "Αντικατάσταση κλειδιού",
"docsLink": "Προβολή εγγράφων", "docsLink": "Προβολή εγγράφων",
"stepcaLabel": "Βήμα CA",
"stepcaDesc": "Συνδεθείτε μέσω του παρόχου ταυτότητας και αφήστε την Step CA σας να εκδώσει ένα βραχύβιο πιστοποιητικό SSH για αυτόν τον κεντρικό υπολογιστή. Η διεύθυνση URL CA, το δακτυλικό αποτύπωμα root και ο πάροχος OIDC ορίζονται από έναν διαχειριστή στις Ρυθμίσεις διαχειριστή.",
"agentLabel": "Πράκτορας SSH", "agentLabel": "Πράκτορας SSH",
"agentDesc": "Πραγματοποιήστε έλεγχο ταυτότητας χρησιμοποιώντας έναν παράγοντα SSH που εκτελείται στον διακομιστή Termix (Bitwarden, 1Password, gpg-agent, KeeAgent, ssh-agent). Ο παράγοντας πρέπει να εκτελείται στον υπολογιστή όπου φιλοξενείται το Termix.", "agentDesc": "Πραγματοποιήστε έλεγχο ταυτότητας χρησιμοποιώντας έναν παράγοντα SSH που εκτελείται στον διακομιστή Termix (Bitwarden, 1Password, gpg-agent, KeeAgent, ssh-agent). Ο παράγοντας πρέπει να εκτελείται στον υπολογιστή όπου φιλοξενείται το Termix.",
"agentAuthenticationDesc": "Χρησιμοποιήστε κλειδιά από έναν παράγοντα SSH για να επαληθεύσετε αυτήν τη σύνδεση. Αφήστε τη διαδρομή υποδοχής κενή για να χρησιμοποιήσετε το SSH_AUTH_SOCK.", "agentAuthenticationDesc": "Χρησιμοποιήστε κλειδιά από έναν παράγοντα SSH για να επαληθεύσετε αυτήν τη σύνδεση. Αφήστε τη διαδρομή υποδοχής κενή για να χρησιμοποιήσετε το SSH_AUTH_SOCK.",
@@ -2121,18 +2119,9 @@
"save": "Εκτός", "save": "Εκτός",
"saving": "Οικονομία..." "saving": "Οικονομία..."
}, },
"stepCa": "Βήμα CA",
"stepCaDesc": "Έκδοση βραχύβιων πιστοποιητικών SSH από μια αρχή έκδοσης πιστοποιητικών smallstep. Εισαγάγετε τη διεύθυνση URL της αρχής έκδοσης πιστοποιητικών, το ριζικό της αποτύπωμα (όπως φαίνεται από το bootstrap του step ca) και το όνομα του παροχέα OIDC. Στη συνέχεια, επιλέξτε \"Step CA\" ως τύπο ελέγχου ταυτότητας κεντρικού υπολογιστή. Αφήστε και τα τρία κενά για απενεργοποίηση.",
"stepCaFingerprint": "Δακτυλικό αποτύπωμα ρίζας (SHA-256)",
"stepCaProvisioner": "Όνομα παρόχου OIDC",
"stepCaSaved": "Οι ρυθμίσεις CA του βήματος αποθηκεύτηκαν",
"stepCaSaveFailed": "Αποτυχία αποθήκευσης ρυθμίσεων Step CA",
"secretSourcePrivateEndpoints": "Επιτρεπόμενοι ιδιωτικοί μυστικοί κεντρικοί υπολογιστές πηγών", "secretSourcePrivateEndpoints": "Επιτρεπόμενοι ιδιωτικοί μυστικοί κεντρικοί υπολογιστές πηγών",
"secretSourcePrivateEndpointsDesc": "Ιδιωτικοί κεντρικοί υπολογιστές με τους οποίους ενδέχεται να επικοινωνήσουν μυστικές πηγές (1Password Connect). Διαχωρίστε τους με κόμματα.", "secretSourcePrivateEndpointsDesc": "Ιδιωτικοί κεντρικοί υπολογιστές με τους οποίους ενδέχεται να επικοινωνήσουν μυστικές πηγές (1Password Connect). Διαχωρίστε τους με κόμματα.",
"updateSecretSourceEndpointsFailed": "Αποτυχία ενημέρωσης της λίστας επιτρεπόμενων τελικών σημείων μυστικής πηγής", "updateSecretSourceEndpointsFailed": "Αποτυχία ενημέρωσης της λίστας επιτρεπόμενων τελικών σημείων μυστικής πηγής",
"stepCaPrivateEndpoints": "Επιτρεπόμενοι ιδιωτικοί κεντρικοί υπολογιστές Step CA",
"stepCaPrivateEndpointsDesc": "Οι ιδιωτικοί κεντρικοί υπολογιστές της ροής πιστοποιητικών Step CA ενδέχεται να επικοινωνήσουν με: την ίδια την CA και, εάν είναι εσωτερική, τον πάροχο ταυτότητάς σας. Διαχωρίστε τους με κόμματα.",
"updateStepCaEndpointsFailed": "Αποτυχία ενημέρωσης της λίστας επιτρεπόμενων τελικών σημείων Step CA",
"notificationPrivateEndpoints": "Επιτρεπόμενοι ιδιωτικοί κεντρικοί υπολογιστές ειδοποιήσεων", "notificationPrivateEndpoints": "Επιτρεπόμενοι ιδιωτικοί κεντρικοί υπολογιστές ειδοποιήσεων",
"notificationPrivateEndpointsDesc": "Ακριβώς ορίστε τους ιδιωτικούς κεντρικούς υπολογιστές με τους οποίους ενδέχεται να επικοινωνήσουν τα κανάλια ειδοποιήσεων. Διαχωρίστε τους με κόμματα.", "notificationPrivateEndpointsDesc": "Ακριβώς ορίστε τους ιδιωτικούς κεντρικούς υπολογιστές με τους οποίους ενδέχεται να επικοινωνήσουν τα κανάλια ειδοποιήσεων. Διαχωρίστε τους με κόμματα.",
"updateNotificationEndpointsFailed": "Αποτυχία ενημέρωσης της λίστας επιτρεπόμενων τελικών σημείων ειδοποιήσεων" "updateNotificationEndpointsFailed": "Αποτυχία ενημέρωσης της λίστας επιτρεπόμενων τελικών σημείων ειδοποιήσεων"
-11
View File
@@ -771,8 +771,6 @@
"keyPassphraseSaved": "Frase de contraseña guardada, escriba para cambiarla", "keyPassphraseSaved": "Frase de contraseña guardada, escriba para cambiarla",
"replaceKey": "Reemplazar clave", "replaceKey": "Reemplazar clave",
"docsLink": "Ver documentación", "docsLink": "Ver documentación",
"stepcaLabel": "Paso CA",
"stepcaDesc": "Inicie sesión a través de su proveedor de identidad y permita que su CA de Step emita un certificado SSH de corta duración para este host. La URL de la CA, la huella digital raíz y el proveedor OIDC se configuran mediante un administrador en la sección de Configuración de administración.",
"agentLabel": "Agente SSH", "agentLabel": "Agente SSH",
"agentDesc": "Autentíquese usando un agente SSH que se ejecuta en el servidor de Termix (Bitwarden, 1Password, gpg-agent, KeeAgent, ssh-agent). El agente debe estar ejecutándose en la máquina donde está alojado Termix.", "agentDesc": "Autentíquese usando un agente SSH que se ejecuta en el servidor de Termix (Bitwarden, 1Password, gpg-agent, KeeAgent, ssh-agent). El agente debe estar ejecutándose en la máquina donde está alojado Termix.",
"agentAuthenticationDesc": "Utilice las claves de un agente SSH para autenticar esta conexión. Deje la ruta del socket vacía para usar SSH_AUTH_SOCK.", "agentAuthenticationDesc": "Utilice las claves de un agente SSH para autenticar esta conexión. Deje la ruta del socket vacía para usar SSH_AUTH_SOCK.",
@@ -2121,18 +2119,9 @@
"save": "Guardar", "save": "Guardar",
"saving": "Guardando..." "saving": "Guardando..."
}, },
"stepCa": "Paso CA",
"stepCaDesc": "Emita certificados SSH de corta duración desde una CA de paso pequeño. Introduzca la URL de la CA, su huella digital raíz (tal como la muestra step ca bootstrap) y el nombre del proveedor OIDC; a continuación, seleccione \"Step CA\" como tipo de autenticación del host. Deje los tres campos vacíos para desactivarlo.",
"stepCaFingerprint": "Huella digital de la raíz (SHA-256)",
"stepCaProvisioner": "nombre del proveedor OIDC",
"stepCaSaved": "Configuración de CA guardada",
"stepCaSaveFailed": "No se pudieron guardar los ajustes de Step CA.",
"secretSourcePrivateEndpoints": "Hosts de fuentes secretas privadas permitidas", "secretSourcePrivateEndpoints": "Hosts de fuentes secretas privadas permitidas",
"secretSourcePrivateEndpointsDesc": "Hosts privados con los que fuentes secretas (1Password Connect) pueden contactar. Sepárelos con comas.", "secretSourcePrivateEndpointsDesc": "Hosts privados con los que fuentes secretas (1Password Connect) pueden contactar. Sepárelos con comas.",
"updateSecretSourceEndpointsFailed": "No se pudo actualizar la lista de permitidos del punto final de origen secreto.", "updateSecretSourceEndpointsFailed": "No se pudo actualizar la lista de permitidos del punto final de origen secreto.",
"stepCaPrivateEndpoints": "Hosts de CA de paso privado permitidos",
"stepCaPrivateEndpointsDesc": "Los hosts privados con los que puede contactar el flujo de certificados de Step CA son: la propia CA y, si es interna, su proveedor de identidad. Sepárelos con comas.",
"updateStepCaEndpointsFailed": "No se pudo actualizar la lista de puntos finales permitidos de Step CA.",
"notificationPrivateEndpoints": "Hosts de notificación privada permitidos", "notificationPrivateEndpoints": "Hosts de notificación privada permitidos",
"notificationPrivateEndpointsDesc": "Hosts privados exactos con los que los canales de notificación pueden contactar. Sepárelos con comas.", "notificationPrivateEndpointsDesc": "Hosts privados exactos con los que los canales de notificación pueden contactar. Sepárelos con comas.",
"updateNotificationEndpointsFailed": "No se pudo actualizar la lista de permitidos del punto final de notificación." "updateNotificationEndpointsFailed": "No se pudo actualizar la lista de permitidos del punto final de notificación."
Loaded 100 of 128 files, more files were not shown because too many files have changed in this diff. Show more